Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,62 @@ Import-scoped BOMs so child projects can declare these dependencies **without a

- **Spring Boot** — `spring-boot-dependencies` (`3.5.14`)
- **Testcontainers** — `testcontainers-bom` (`2.0.5`)
- **OpenAPI** — `springdoc-openapi-bom` (`2.8.17`) and `swagger-bom` (`2.2.47`),
plus `org.webjars:swagger-ui` (`5.32.2`) — see
[The OpenAPI stack](#the-openapi-stack)

### The OpenAPI stack

springdoc, Swagger and the Swagger UI webjar are **one coupled set**, and the parent
manages all three so a child gets a version-uniform stack without configuring
anything.

This is not tidiness. springdoc declares its Swagger dependency without a version and
inherits it from its own aggregator POM, so Swagger floats in every consuming project
and is decided by nearest-wins mediation. A library that uses only the Swagger
annotations declares only that artifact — the normal thing to do — and its
declaration sits closer to the application than the `swagger-core-jakarta` springdoc
contributes two levels down. The stack then splits along exactly that boundary, and
Swagger's own modules call each other across incompatible releases:

```
io.swagger.v3.core.jackson.ModelResolver (swagger-core-jakarta 2.2.47)
→ io.swagger.v3.oas.annotations.media.Schema.$dynamicRef()
absent in annotations 2.2.29
```

The result is a `NoSuchMethodError` at schema resolution, not a missing feature.
Managing the versions in the parent removes the mediation entirely, because
`dependencyManagement` is consulted before nearest-wins and applies at any depth.

**What this does not cover.** Management overrides *transitive* resolution only. A
project that declares a Swagger artifact directly with an explicit `<version>` still
wins over the managed version and keeps whatever it had. Dropping that `<version>`
activates the managed one.

**Retargeting the stack in a child.** Setting `<swagger.version>` in a child moves all
14 Swagger coordinates at once. The floor is `2.2.47`: `swagger-bom` was first
published at that version, so a lower value fails the build with a non-resolvable
import. Use a direct declaration with a `<version>` to pull an older Swagger.

#### Maintenance: bump the three together

`springdoc.version`, `swagger.version` and `swagger-ui.version` **must** move
together. Bumping springdoc alone would publish a split stack to every child project
at once, and this parent's own build would not notice — it has no Java sources and
never exercises the stack.

The matching values are not exposed by `springdoc-openapi-bom`, which manages
springdoc artifacts only. Read them from springdoc's aggregator POM:

```
org/springdoc/springdoc-openapi/<version>/springdoc-openapi-<version>.pom
<swagger-api.version> → swagger.version
<swagger-ui.version> → swagger-ui.version
```

For reference: springdoc `2.8.6` pairs with Swagger `2.2.29` and Swagger UI `5.20.1`;
springdoc `2.8.17` pairs with `2.2.47` and `5.32.2`.

### Pinned plugin versions

Expand Down
7 changes: 6 additions & 1 deletion docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,14 @@ JAX-RS artifacts are pinned for every child as a side effect — at a version se
for springdoc compatibility, not for that child. It is silent: the build succeeds
either way.

Measured during the implementation of spec 003, so this is confirmed rather than
suspected: a probe child declaring `swagger-annotations` (the javax artifact) and
`swagger-jaxrs2-jakarta` without versions resolves both to 2.2.47.

Worth deciding whether that is wanted. The alternatives are pinning only the three
jakarta artifacts by hand (an artifact list that goes stale when springdoc adds a
Swagger module) or leaving it as is and documenting the reach.
Swagger module, and which would also sidestep the 2.2.47 floor that `swagger-bom`
imposes) or leaving it as is and documenting the reach.

**Context:** Identified while choosing between `swagger-bom` and hand-written pins
during spec 003; the wider reach was accepted to avoid maintaining an artifact list.
Expand Down
24 changes: 18 additions & 6 deletions docs/specs/003-openapi-stack-versions/behaviors.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,22 @@ is managed. "Consumer" means a project whose parent is `java-parent`.

### A consumer may still override deliberately

- **Given** a consumer that declares its own `swagger.version` property or its own
`dependencyManagement` entry for a Swagger artifact
- **Given** a consumer that declares its own `swagger.version` property
- **When** the project resolves its dependencies
- **Then** the consumer's value wins, because a child's `dependencyManagement` takes
precedence over the inherited one
- **Then** the whole Swagger stack moves to that version, because property
interpolation happens in the child's effective POM and therefore reaches the
parent's BOM import
- **And** one property in the child retargets all 14 coordinates at once

### Overriding the Swagger version below 2.2.47 fails the build

- **Given** a consumer that sets `swagger.version` to a release older than 2.2.47
- **When** the project is built
- **Then** the build fails at model-building time with a non-resolvable import POM,
because `io.swagger.core.v3:swagger-bom` was first published at 2.2.47
- **And** the failure is explicit rather than silent
- **And** declaring the artifact directly with a `<version>` is unaffected and remains
the way to use an older Swagger

## Side effects

Expand All @@ -115,8 +126,9 @@ is managed. "Consumer" means a project whose parent is `java-parent`.

- **Given** the parent's existing `spring-boot-dependencies` 3.5.14 import
- **When** the effective POM is computed
- **Then** no coordinate is managed by both, because Spring Boot manages no
`io.swagger`, `org.springdoc` or `org.webjars:swagger-ui` entry
- **Then** no coordinate is managed by both: `spring-boot-dependencies` 3.5.14
contains no `io.swagger` and no `org.springdoc` entry at all, and its only
`org.webjars` entries are `webjars-locator-core` and `webjars-locator-lite`
- **And** the relative order of the imports has no effect on the resolved versions

## Maintenance
Expand Down
15 changes: 15 additions & 0 deletions docs/specs/003-openapi-stack-versions/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,15 @@ silently fails to cover it and the split-stack failure returns in a new place. T
BOM is published by the Swagger project, covers all 14 artifacts, and has no parent
POM of its own, so importing it drags nothing else in.

**Constraint discovered during implementation:** `swagger-bom` was first published at
**2.2.47** — every earlier version returns 404 on Maven Central, and the published
range is 2.2.47 through 2.2.55. The version this change needs is therefore the very
first one that has a BOM at all. Two consequences follow. A future springdoc release
pinned to a Swagger older than 2.2.47 could not use the import and would need the
three jakarta artifacts pinned individually. And a child overriding `swagger.version`
below 2.2.47 gets a hard model-building failure rather than an override — declaring
the artifact directly with a version remains available and unaffected.

**Rationale — importing `springdoc-openapi-bom` even though it does not fix the bug.**
It manages only springdoc's own artifacts and says nothing about Swagger, so it
contributes nothing to the failure at hand. It is imported because the parent
Expand Down Expand Up @@ -257,6 +266,12 @@ The rule is written where someone would otherwise break it, in two places:
version chosen for springdoc compatibility. Tracked in `docs/TODO.md`.
- **Children that hard-pin Swagger themselves are unaffected**, silently. Their direct
declaration still wins, so they keep whatever they had and see no error and no fix.
Measured on a probe child: a direct `swagger-annotations-jakarta` 2.2.29 declaration
keeps the split stack even with the fix in place.
- **`swagger.version` has a floor of 2.2.47.** The BOM does not exist below that, so
overriding the property downwards fails the build at model-building time with a
non-resolvable import. The error is explicit rather than silent, but it is a new
way for a child to break that did not exist before.

## Acceptance

Expand Down
128 changes: 128 additions & 0 deletions docs/specs/003-openapi-stack-versions/steps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Implementation Steps: OpenAPI stack versions

Spec: [`design.md`](design.md) · [`behaviors.md`](behaviors.md) · Issue #6

`java-parent` has `packaging=pom` and no Java sources, so the fix cannot be exercised
by building the parent alone. The executable form of these scenarios is Maven
dependency resolution against a throwaway probe child, per
[Acceptance](design.md#acceptance).

---

## Step 1: Declare the coupled version properties

- [x] Add `swagger.version` and `swagger-ui.version` next to the existing
`springdoc.version`
- [x] Write the lockstep comment above them: the three are coupled, mixing Swagger
versions produces `NoSuchMethodError` inside Swagger itself, and the matching
values are read from `<swagger-api.version>` / `<swagger-ui.version>` in
`org/springdoc/springdoc-openapi/<version>/springdoc-openapi-<version>.pom`

**Acceptance criteria:**
- [x] `./mvnw help:evaluate -Dexpression=swagger.version -q -DforceStdout` prints `2.2.47`
- [x] `./mvnw help:evaluate -Dexpression=swagger-ui.version -q -DforceStdout` prints `5.32.2`
- [x] The values match springdoc 2.8.17's aggregator POM

**Related behaviors:** The lockstep values are discoverable

---

## Step 2: Import the BOMs and manage the Swagger UI webjar

- [x] Import `org.springdoc:springdoc-openapi-bom` at `${springdoc.version}`
- [x] Import `io.swagger.core.v3:swagger-bom` at `${swagger.version}`
- [x] Manage `org.webjars:swagger-ui` at `${swagger-ui.version}`, with a comment
noting no BOM covers it
- [x] Remove the explicit `springdoc-openapi-starter-webmvc-ui` entry the BOM replaces

**Acceptance criteria:**
- [x] `./mvnw -Pfull-build clean verify` passes, including pomchecker
- [x] The effective POM manages every springdoc starter and all 14 Swagger coordinates
- [x] No coordinate is managed twice

**Related behaviors:** springdoc resolves without a version · Other springdoc starters
are now usable without a version · No overlap with the Spring Boot BOM · An
unresolvable BOM fails fast

---

## Step 3: Prove the fix against a probe child

- [x] Install the modified parent locally with `./mvnw -N install`
- [x] Create a throwaway child inheriting it that declares
`springdoc-openapi-starter-webmvc-ui` without a version, plus a dependency
that contributes `swagger-annotations-jakarta` 2.2.29
- [x] Resolve the tree and record the Swagger and Swagger UI versions
- [x] Repeat against the **unmodified** parent to confirm the probe reproduces the
split — otherwise the check passes vacuously and proves nothing
- [x] Record both trees for the pull request description

**Acceptance criteria:**
- [x] Against the modified parent: `swagger-annotations-jakarta`,
`swagger-models-jakarta` and `swagger-core-jakarta` all resolve to 2.2.47, and
`swagger-ui` to 5.32.2
- [x] Against the unmodified parent: `swagger-annotations-jakarta` resolves to 2.2.29
while `swagger-core-jakarta` resolves to 2.2.47

**Related behaviors:** The Swagger stack is uniform · The Swagger UI webjar is pinned ·
A library contributing an older annotations artifact no longer splits the stack · The
same graph fails on the previous parent version · A direct pin in the consumer still
wins · Dropping the direct pin activates the fix

---

## Step 4: Document the managed stack and the maintenance rule

- [x] Add the OpenAPI stack to the README's managed-versions section
- [x] Add a maintenance subsection describing the coupling, the failure it prevents,
and the concrete lookup path for a future springdoc bump

**Acceptance criteria:**
- [x] The lookup path names the exact artifact and the exact properties
- [x] The consequence of getting it wrong is stated: the parent would publish a split
stack to every child at once

**Related behaviors:** The lockstep values are discoverable · Bumping springdoc alone
reintroduces the failure

---

## Step 5: Final verification

- [x] `./mvnw -Pfull-build clean verify`
- [x] `docs/specs/INDEX.md` status updated

**Acceptance criteria:**
- [x] Build passes
- [x] The probe project is removed and leaves no trace in the repository

**Related behaviors:** The parent still builds and validates

---

## Behavior Coverage

18 scenarios. Layer is dependency resolution throughout. All rows marked *Measured*
were confirmed against throwaway probe projects during implementation; the probes
were removed afterwards and left no artifacts behind.

| Scenario | Verification | Step |
|---|---|---|
| springdoc resolves without a version | Measured — probe child resolves 2.8.17 | 2, 3 |
| Other springdoc starters are now usable without a version | Measured — `webflux-ui` resolves to 2.8.17 without a version | 3 |
| The Swagger stack is uniform | Measured — annotations, models and core all 2.2.47 | 3 |
| The Swagger UI webjar is pinned | Measured — 5.32.2; note the probe did not create a competing webjar version, so the pin is preventive | 3 |
| A library contributing an older annotations artifact no longer splits the stack | Measured — 2.2.29 contributor is overridden to 2.2.47 | 3 |
| The same graph fails on the previous parent version | Measured — pre-change parent yields annotations 2.2.29 next to core 2.2.47 | 3 |
| Depth of the conflicting declaration does not matter | Follows from management semantics; the probe covers depth 2 | 3 |
| A direct pin in the consumer still wins | Measured — direct 2.2.29 declaration survives the fix | 3 |
| Dropping the direct pin activates the fix | Measured — removing `<version>` yields 2.2.47 | 3 |
| The javax Swagger line becomes managed | Measured — `swagger-annotations` and `swagger-jaxrs2-jakarta` resolve to 2.2.47 | 3 |
| A consumer using the JAX-RS artifacts is affected the same way | Measured together with the row above | 3 |
| No overlap with the Spring Boot BOM | Verified against `spring-boot-dependencies` 3.5.14 | 2 |
| Bumping springdoc alone reintroduces the failure | Not executable — documented risk | 4 |
| The lockstep values are discoverable | Lookup path followed once during implementation | 1, 4 |
| The parent still builds and validates | `./mvnw -Pfull-build clean verify` | 2, 5 |
| An unresolvable BOM fails fast | Measured — a child setting `swagger.version=2.2.38` fails at model building | 3 |
| A consumer may still override deliberately | Measured — `swagger.version=2.2.50` moves all Swagger coordinates | 3 |
| Overriding the Swagger version below 2.2.47 fails the build | Measured — `swagger-bom` does not exist below 2.2.47 | 3 |
2 changes: 1 addition & 1 deletion docs/specs/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
|-----|-------------|------|-------|-------------|--------------|--------|
| 001 | 001-reproducible-build-timestamp | Reproducible build timestamp | build, infrastructure, documentation | Fixed `project.build.outputTimestamp` literal in the parent POM, inherited by all child projects, maintained by `release.sh` — so a third party can rebuild any Open Elements Java artifact byte-identically | #3 | done |
| 002 | 002-pinned-line-endings | Pinned line endings | build, infrastructure, documentation | `.gitattributes` forcing LF (CRLF for `*.bat`/`*.cmd`), a matching `.editorconfig` aligned with Google Java Format, and an inherited Spotless `lineEndings=UNIX` — so a checkout produces the same text bytes on every platform | #4 | done |
| 003 | 003-openapi-stack-versions | OpenAPI stack versions | build, api, documentation | Import `springdoc-openapi-bom` and `swagger-bom` and manage `org.webjars:swagger-ui`, so the coupled OpenAPI stack resolves uniformly in every consumer instead of splitting into a `NoSuchMethodError` | | open |
| 003 | 003-openapi-stack-versions | OpenAPI stack versions | build, api, documentation | Import `springdoc-openapi-bom` and `swagger-bom` and manage `org.webjars:swagger-ui`, so the coupled OpenAPI stack resolves uniformly in every consumer instead of splitting into a `NoSuchMethodError` | #6 | done |
43 changes: 41 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,25 @@
<!-- Dependency versions -->
<spring-boot.version>3.5.14</spring-boot.version>
<testcontainers.version>2.0.5</testcontainers.version>
<springdoc.version>2.8.17</springdoc.version>
<jspecify.version>1.0.0</jspecify.version>

<!-- OpenAPI stack. These three versions are coupled and MUST be bumped together.
springdoc is built against one specific Swagger version, and mixing Swagger
versions does not merely downgrade a feature: swagger-core calls annotation
members that only exist in its own release, so a split stack throws
NoSuchMethodError at runtime.
To find the matching values for a new springdoc release, open
org/springdoc/springdoc-openapi/<version>/springdoc-openapi-<version>.pom
and read <swagger-api.version> and <swagger-ui.version>. They are not
exposed by springdoc-openapi-bom, which manages springdoc artifacts only.

Floor: io.swagger.core.v3:swagger-bom was first published at 2.2.47, so
swagger.version cannot go below that. A springdoc release pinned to an
older Swagger would need the three jakarta artifacts pinned individually
instead of the BOM import. -->
<springdoc.version>2.8.17</springdoc.version>
<swagger.version>2.2.47</swagger.version>
<swagger-ui.version>5.32.2</swagger-ui.version>
</properties>

<distributionManagement>
Expand All @@ -116,10 +133,32 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- OpenAPI stack, kept version-uniform for every child project.
springdoc-openapi-bom manages the springdoc starters and nothing else;
the Swagger versions it is built against are deliberately not part of
that contract, so swagger-bom is imported alongside it. Without the
second import Swagger floats and nearest-wins mediation can split the
stack across incompatible releases. -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<artifactId>springdoc-openapi-bom</artifactId>
<version>${springdoc.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-bom</artifactId>
<version>${swagger.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Covered by neither BOM: springdoc pulls the Swagger UI webjar
transitively and its version belongs to the same coupled set. -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>swagger-ui</artifactId>
<version>${swagger-ui.version}</version>
</dependency>
<dependency>
<groupId>org.jspecify</groupId>
Expand Down