Skip to content

fix: emit $ref for array items when cycle guard suppresses implementation processing#5205

Open
seonwooj0810 wants to merge 1 commit into
swagger-api:masterfrom
seonwooj0810:fix/issue-5187-cycle-guard-items-ref
Open

fix: emit $ref for array items when cycle guard suppresses implementation processing#5205
seonwooj0810 wants to merge 1 commit into
swagger-api:masterfrom
seonwooj0810:fix/issue-5187-cycle-guard-items-ref

Conversation

@seonwooj0810

Copy link
Copy Markdown

Description

Fixes #5187.

AnnotationsUtils.getArraySchema(...) is invoked with processSchemaImplementation = false whenever the cycle guard added in #5004 detects that the array's annotated type is already being resolved further up the stack. In that branch both setItems(...) paths are skipped:

if (arraySchema.schema() != null) {
    if (arraySchema.schema().implementation().equals(Void.class)) {
        // setItems called
    } else if (processSchemaImplementation) {
        // setItems called
    }
    // else: items is never set — array degrades to {type: array, no items}
}

So when a recursive model uses @ArraySchema(schema = @Schema(implementation = X.class)) and the guard fires, the resulting array schema loses its items field. In OAS 3.1 the existing schema passed in usually already had items from context.resolve(...), but the cycle-guard branch itself contributes nothing — and when downstream code starts from an empty array schema (no pre-existing items), the property degrades to bare { type: "array" } or gets pruned entirely. This is the regression #5187 reports against 2.2.41 — 2.2.49.

Root cause

The cycle guard was introduced in #5004 to prevent StackOverflowError on recursive models. To avoid recursing it forces processSchemaImplementation = false, but the recovery path then drops the items field rather than reaching for the schema that the converter context is already (or about to be) holding for that implementation type.

Fix

When the cycle guard fires AND arraySchemaObject.getItems() == null, look up the implementation class in ModelConverterContext.getDefinedModels() and emit items: { $ref: "#/components/schemas/<Name>" }. The lookup honours an explicit @Schema(name = "...") declaration on the implementation type and falls back to the simple class name (matching the default Jackson convention used elsewhere). If neither is registered we leave items unset — so the fallback is purely additive: schemas that populated items through other paths are untouched.

The implementation type is exactly the one the user named in @Schema(implementation = X.class), so emitting a $ref to its already-registered component preserves the StackOverflow fix while keeping the array schema valid.

Tests

Added Issue5187Test with five focused regressions, exercising:

  • OAS 3.1 fallback emits $ref to the registered implementation.
  • OAS 3.0 fallback emits $ref to the registered implementation.
  • Explicit @Schema(name = "Renamed") is honoured over the simple class name.
  • Pre-existing items on the array schema are preserved (the fallback only runs when items is null).
  • If the implementation type isn't registered in the context, items is left unset (no invented refs).

Without the fix three of these tests fail (confirmed by reverting AnnotationsUtils.java locally); with it the full swagger-core suite (692 tests) passes.

[INFO] Tests run: 692, Failures: 0, Errors: 0, Skipped: 0

Type of Change

  • 🐛 Bug fix
  • ✨ New feature
  • ♻️ Refactor (non-breaking change)
  • 🧪 Tests
  • 📝 Documentation
  • 🧹 Chore (build or tooling)

Checklist

…entation processing (swagger-api#5187)

`AnnotationsUtils.getArraySchema(...)` is called with
`processSchemaImplementation = false` whenever the cycle guard added in
swagger-api#5004 detects that the array's annotated type is already being resolved
further up the stack. In that branch the existing `setItems(...)` paths
are skipped, so the resulting array schema ends up with no `items` field
at all — degrading to `type: array` (and in deeper cycles getting
dropped entirely by downstream pruning).

The implementation class itself has already been (or will be) registered
in the `ModelConverterContext`, so we can recover a valid schema by
emitting a `$ref` to that registered component. This preserves the
StackOverflow fix while restoring `items: { $ref: ... }` for the
recursive case.

The lookup honours an explicit `@Schema(name = "...")` declaration on
the implementation type and otherwise falls back to the simple class
name (matching the default Jackson convention used elsewhere in
swagger-core). If neither is registered we leave `items` unset, so the
fallback is purely additive — schemas that already populated `items`
through other paths are not modified.
@ewaostrowska ewaostrowska added the backlog label Jun 18, 2026 — with Claude
@caoimhebyrne

Copy link
Copy Markdown

@ewaostrowska I don't think this should have the 'backlog' label... any array type within a schema that references a non-primitive (e.g. string etc) type is generated incorrectly at the moment.

@seonwooj0810

Copy link
Copy Markdown
Author

Thanks for the bump, @caoimhebyrne — agreed this hurts real payloads and shouldn't sit in the backlog.

One scoping note so maintainers can size it accurately: the regression this PR fixes is specifically the cycle-guard branch added in #5004. When ModelResolver detects that an array's annotated implementation type is already being resolved higher up the stack, it calls getArraySchema(..., processSchemaImplementation = false), and that recovery path never sets items — so a List<T> on a recursive/self-referential model degrades to { type: "array" } with no items (exactly #5187, regressed 2.2.41–2.2.49).

The fix is purely additive: it only fills items with a $ref to the already-registered component when items is otherwise null, so arrays that already resolve correctly today are left untouched.

If you're seeing arrays of non-primitive types dropped outside the recursive/cycle-guard case, that would be a different code path from what this PR touches — a minimal repro would be really helpful, and I'm happy to extend this fix or open a follow-up to cover it.

For reference, the PR adds five focused regression tests and the full 692-test suite passes locally, so it's ready for review whenever a maintainer has a moment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: ModelResolver cycle guard drops items from recursive List<T> schemas (regression since 2.2.41)

3 participants