Skip to content

Commit dfae05b

Browse files
committed
Initial Code
1 parent a26d0c2 commit dfae05b

32 files changed

Lines changed: 3252 additions & 22 deletions

‎.github/workflows/o2j-matrix.yml‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,36 @@ jobs:
7676
JAVA_TARGET: ${{ matrix.java }}
7777
EXPECT: ${{ matrix.expect_exit || 0 }}
7878
PROFILE: ${{ matrix.tier == 2 && 'sandbox' || 'production' }}
79+
80+
# The job above stops at `--no-run-generator`, so it never compiles Java. This one builds
81+
# the generated service with Maven (scaffold + generator + -Werror compile + the scaffolded
82+
# and managed tests) on a real JDK of each LTS floor, for both Spring Boot lines: 3.5, 4.0
83+
# and the pinned 4.x. See tests/integration/test_generated_service_builds.py.
84+
build:
85+
runs-on: ubuntu-latest
86+
strategy:
87+
fail-fast: false
88+
matrix:
89+
java: ["17", "21"]
90+
name: Build generated service / Java ${{ matrix.java }}
91+
steps:
92+
- uses: actions/checkout@v4
93+
94+
- uses: actions/setup-java@v4
95+
with:
96+
distribution: temurin
97+
java-version: ${{ matrix.java }}
98+
cache: maven
99+
100+
- uses: actions/setup-python@v5
101+
with:
102+
python-version: "3.12"
103+
104+
- run: pip install -e ".[dev]"
105+
106+
- name: Build and test generated services (Boot 3.5, 4.0, pinned 4.x)
107+
run: |
108+
export "O2J_JDK_${{ matrix.java }}=$JAVA_HOME"
109+
python -I -m pytest tests/integration -v -k "java${{ matrix.java }}"
110+
env:
111+
O2J_JAVA_IT: "1"

‎README.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,18 @@ subscription. The Boot version is a profile, not a constant: switch
175175
`target.spring_profile` to `spring-boot-4` (Java 17 floor is unchanged) when you are ready.
176176
`o2j doctor` says this on every run.
177177

178+
**Spring Boot 4.0 and 4.x are supported and built.** The `spring-boot-4` profile uses
179+
`versions.spring_boot_4` (pinned 4.1.0); set it to a 4.0.x release such as `4.0.9` to target
180+
Boot 4.0. The profile switches what Boot 4 changed: the renamed `webmvc` and
181+
`security-oauth2-resource-server` starters, the split test modules (`@WebMvcTest` moved to
182+
`org.springframework.boot.webmvc.test.autoconfigure`, and slices only load security with
183+
`spring-boot-starter-security-oauth2-resource-server-test`), Jackson 3, JSpecify nullness
184+
annotations in the generated DTOs (Spring 7 deprecates `org.springframework.lang.Nullable`,
185+
which fails `-Werror`), springdoc 3.x (`versions.springdoc_boot_4`) and Testcontainers 2.x.
186+
Every combination of Java 17/21 with Boot 3.5, 4.0 and the pinned 4.x is compiled and tested
187+
by `tests/integration/test_generated_service_builds.py`, on real JDKs in the `build` job of
188+
`o2j-matrix.yml`.
189+
178190
**OpenAPI 3.2.0 cannot reach the generator.** `openapi-generator` parses through
179191
`swagger-parser`, which does not support 3.2
180192
([swagger-api/swagger-parser#2248](https://github.com/swagger-api/swagger-parser/issues/2248)).
@@ -194,6 +206,16 @@ downgrade is still needed.
194206
The version is never hardcoded: it is a Maven toolchain value from `o2j.yaml`, and the CI
195207
matrix is generated from it. Adding Java 29 is a one-line config change.
196208

209+
The generated service itself is built on Java 17 and 21 (Boot 3.5, 4.0 and 4.x) by the
210+
integration tests:
211+
212+
```bash
213+
O2J_JAVA_IT=1 O2J_JDK_17=/path/to/jdk-17 python -I -m pytest tests/integration -v
214+
```
215+
216+
Without `O2J_JDK_17`, the Java 17 cases compile with `--release 17` on the running JDK, which
217+
checks the language level and API but not a Java 17 runtime.
218+
197219
A tier-2 JDK with `service.profile: production` raises `JAVA_TIER2_IN_PRODUCTION` and fails
198220
validation — shipping production on a JDK with no security updates should be an explicit
199221
decision, not a config default.

‎o2j.yaml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ versions:
3232
spotbugs: 4.8.6.6
3333
archunit: 1.3.0
3434
testcontainers: 1.20.6
35-
springdoc: 2.7.0
35+
springdoc: 2.7.0 # Spring Boot 3 line
36+
springdoc_boot_4: 3.0.3 # Spring Boot 4 line (used by the spring-boot-4 profile)
3637
owasp_dependency_check: 11.1.0
3738
cyclonedx: 2.9.1
3839

‎o2j/cli.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,10 @@ def generate(
304304

305305
work.write_json("generate", "generation-result.json", result)
306306
_err(f"generated {len(result.files)} contract file(s)")
307+
_err(
308+
f"required test cases: {result.generated_cases} generated by o2j, "
309+
f"{result.pending_cases} left as disabled skeletons"
310+
)
307311
for path in result.managed:
308312
_err(f" managed {path}")
309313
for path in result.scaffolded:
@@ -376,7 +380,9 @@ def fmt(value: float | None) -> str:
376380

377381
@app.command()
378382
def verify(
379-
mode: Annotated[str, typer.Option(help="determinism | drift | contract")] = "determinism",
383+
mode: Annotated[
384+
str, typer.Option(help="determinism | drift | contract | conformance")
385+
] = "determinism",
380386
config: ConfigOpt = None,
381387
work_dir: WorkOpt = None,
382388
static: Annotated[
@@ -518,6 +524,10 @@ def run(
518524
f"{len(generated.scaffolded)} scaffolded, "
519525
f"{len(generated.conflicts)} conflict(s)"
520526
)
527+
_err(
528+
f"[S4] tests {generated.generated_cases} required case(s) generated, "
529+
f"{generated.pending_cases} left as disabled skeletons"
530+
)
521531

522532
determinism = verify_stage.verify(cfg, "determinism", work)
523533
_err(f"[S4] determinism {'passed' if determinism.passed else 'FAILED'}")

‎o2j/config.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ class VersionsConfig(StrictModel):
9797
archunit: str
9898
testcontainers: str
9999
springdoc: str
100+
# springdoc 2.x is built for Spring Boot 3, 3.x for Spring Boot 4. Optional so existing
101+
# o2j.yaml files stay valid; used only by the spring-boot-4 profile.
102+
springdoc_boot_4: str = "3.0.3"
100103
owasp_dependency_check: str
101104
cyclonedx: str
102105

@@ -185,6 +188,31 @@ class QualityConfig(StrictModel):
185188
sonar: SonarConfig = Field(default_factory=SonarConfig)
186189

187190

191+
class EmsRouteConfig(StrictModel):
192+
"""One TIBCO EMS request queue served by one spec operation (see o2j/ems.py)."""
193+
194+
destination: str
195+
operation: str
196+
# How the reply is correlated: the request's JMSMessageID (the TIBCO request/reply default)
197+
# or its JMSCorrelationID (when the requester sets one and waits on a shared reply queue).
198+
correlation: Literal["message-id", "correlation-id"] = "message-id"
199+
reply_timeout_ms: int | None = None
200+
# HTTP parameter name -> source. Sources: property:<name>, jms:JMSMessageID,
201+
# jms:JMSCorrelationID, json:<pointer into the message body>, literal:<value>.
202+
# Unlisted parameters default to property:<name with non-identifier chars as _>.
203+
params: dict[str, str] = Field(default_factory=dict)
204+
# HTTP status -> the legacy fault code EMS consumers already handle.
205+
faults: dict[int, str] = Field(default_factory=dict)
206+
207+
208+
class EmsConfig(StrictModel):
209+
"""TIBCO EMS request/reply queues the REST service replaces (optional)."""
210+
211+
enabled: bool = False
212+
reply_timeout_ms: int = 5000
213+
routes: list[EmsRouteConfig] = Field(default_factory=list)
214+
215+
188216
class O2JConfig(StrictModel):
189217
"""The whole of ``o2j.yaml``."""
190218

@@ -195,6 +223,7 @@ class O2JConfig(StrictModel):
195223
openapi: OpenApiConfig = Field(default_factory=OpenApiConfig)
196224
validation: ValidationConfig = Field(default_factory=ValidationConfig)
197225
generation: GenerationConfig = Field(default_factory=GenerationConfig)
226+
ems: EmsConfig = Field(default_factory=EmsConfig)
198227
quality: QualityConfig = Field(default_factory=QualityConfig)
199228

200229
# Set by the loader; not part of the file.

‎o2j/conformance.py‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"""Check the HTTP exchanges recorded during the test run against the spec.
2+
3+
``O2jConformanceRecorder`` (a test-scope servlet filter in every generated service) writes one
4+
JSON line per exchange to ``target/o2j-conformance/exchanges.jsonl``. For each exchange that
5+
belongs to a spec operation this module checks:
6+
7+
* the status is declared for the operation (exact, ``4XX``-style range, or ``default``);
8+
* a non-empty body has a content type the response declares;
9+
* the body is valid against that media type's schema (JSON Schema 2020-12, which is the
10+
dialect of the canonical 3.1 document, with formats checked).
11+
12+
This is what turns "the tests pass" into "what the service actually returned matches the
13+
contract". Line coverage cannot see a renamed JSON property or an undeclared 500; this can.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import json
19+
import re
20+
from dataclasses import dataclass, field
21+
from pathlib import Path
22+
from typing import Any
23+
24+
from o2j.samples import Sampler
25+
26+
EXCHANGES = Path("target") / "o2j-conformance" / "exchanges.jsonl"
27+
28+
29+
@dataclass
30+
class OperationConformance:
31+
exchanges: int = 0
32+
conforming_success: int = 0 # 2xx exchanges that passed every check
33+
violations: list[str] = field(default_factory=list)
34+
35+
36+
@dataclass
37+
class ConformanceResult:
38+
found: bool
39+
exchanges: int = 0
40+
checked: int = 0
41+
ignored: int = 0 # exchanges outside the spec (actuator, refused unknown routes)
42+
by_operation: dict[str, OperationConformance] = field(default_factory=dict)
43+
44+
@property
45+
def violations(self) -> list[str]:
46+
return [v for op in self.by_operation.values() for v in op.violations]
47+
48+
49+
@dataclass(frozen=True)
50+
class _Route:
51+
operation_id: str
52+
method: str
53+
path: str
54+
regex: re.Pattern[str]
55+
56+
57+
def check(project: Path, spec: dict[str, Any], base_path: str) -> ConformanceResult:
58+
path = project / EXCHANGES
59+
if not path.is_file():
60+
return ConformanceResult(found=False)
61+
62+
sampler = Sampler(spec)
63+
routes = _routes(spec, base_path)
64+
result = ConformanceResult(found=True)
65+
66+
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
67+
if not line.strip():
68+
continue
69+
result.exchanges += 1
70+
try:
71+
exchange = json.loads(line)
72+
except json.JSONDecodeError:
73+
result.ignored += 1
74+
continue
75+
route = _match(routes, str(exchange.get("method", "")), str(exchange.get("path", "")))
76+
if route is None:
77+
result.ignored += 1
78+
continue
79+
result.checked += 1
80+
stats = result.by_operation.setdefault(route.operation_id, OperationConformance())
81+
stats.exchanges += 1
82+
problems = _check_exchange(sampler, spec, route, exchange)
83+
where = f"{route.operation_id} {exchange.get('method')} {exchange.get('path')}"
84+
status = exchange.get("status")
85+
stats.violations += [f"{where} -> {status}: {p} (exchange {number})" for p in problems]
86+
if not problems and isinstance(status, int) and 200 <= status < 300:
87+
stats.conforming_success += 1
88+
return result
89+
90+
91+
def _routes(spec: dict[str, Any], base_path: str) -> list[_Route]:
92+
out: list[_Route] = []
93+
for path, item in (spec.get("paths") or {}).items():
94+
if not isinstance(item, dict):
95+
continue
96+
full = (base_path.rstrip("/") + "/" + path.lstrip("/")) if base_path else path
97+
pattern = "^" + re.sub(r"\\\{[^/]+?\\\}", "[^/]+", re.escape(full)) + "/?$"
98+
for method, op in item.items():
99+
if isinstance(op, dict) and "responses" in op:
100+
out.append(
101+
_Route(
102+
operation_id=str(op.get("operationId") or f"{method} {path}"),
103+
method=method.upper(),
104+
path=path,
105+
regex=re.compile(pattern),
106+
)
107+
)
108+
# Literal segments before templated ones, as Spring MVC matches.
109+
return sorted(out, key=lambda r: (r.path.count("{"), r.path))
110+
111+
112+
def _match(routes: list[_Route], method: str, path: str) -> _Route | None:
113+
for route in routes:
114+
if route.method == method.upper() and route.regex.match(path):
115+
return route
116+
return None
117+
118+
119+
def _check_exchange(
120+
sampler: Sampler, spec: dict[str, Any], route: _Route, exchange: dict[str, Any]
121+
) -> list[str]:
122+
status = exchange.get("status")
123+
if not isinstance(status, int):
124+
return ["recorded exchange has no status"]
125+
if status == 501:
126+
return ["501 Not Implemented: the operation is still a stub"]
127+
128+
operation = spec["paths"][route.path][route.method.lower()]
129+
responses = operation.get("responses", {})
130+
response = (
131+
responses.get(str(status))
132+
or responses.get(f"{str(status)[0]}XX")
133+
or responses.get("default")
134+
)
135+
if response is None:
136+
declared = ", ".join(sorted(str(k) for k in responses))
137+
return [f"status {status} is not declared (declared: {declared})"]
138+
response = sampler.resolve(response)
139+
140+
body = exchange.get("body") or ""
141+
if not body.strip():
142+
return []
143+
144+
content = response.get("content") or {}
145+
if not content:
146+
return ["a body was returned but the response declares no content"]
147+
media = _media_type(exchange.get("contentType"))
148+
if media not in content:
149+
return [f"content type {media or 'none'} is not declared (declared: {', '.join(content)})"]
150+
schema = content[media].get("schema")
151+
if not isinstance(schema, dict) or not (media.endswith("json")):
152+
return []
153+
try:
154+
value = json.loads(body)
155+
except json.JSONDecodeError:
156+
return [f"body is not valid JSON although {media} was declared"]
157+
158+
errors = sorted(sampler.validator(schema).iter_errors(value), key=lambda e: list(e.path))
159+
return [
160+
f"body does not match the schema at /{'/'.join(str(p) for p in e.path)}: {e.message}"
161+
for e in errors[:5]
162+
]
163+
164+
165+
def _media_type(content_type: Any) -> str | None:
166+
if not isinstance(content_type, str) or not content_type:
167+
return None
168+
return content_type.split(";", 1)[0].strip().lower()

0 commit comments

Comments
 (0)