Skip to content

[BUG] 2.8.17 regression: @Nullable field on a referenced type mutates the referent's component schema (type: "null" in OAS 3.1, nullable: true on referent in OAS 3.0) #3275

@franzmathauser

Description

@franzmathauser

Describe the bug

Since springdoc-openapi 2.8.17, a field annotated with org.springframework.lang.Nullable whose type becomes a separate component schema causes the referenced component itself to be mutated — not the reference. In OAS 3.1 mode (springdoc's default from 2.8.x) the referent's type is set to "null" while its properties remain populated, producing a contradictory schema such as {"type": "null", "properties": { … }}. Separately, the referring field's $ref carries no nullability marker at all, so the change both (a) writes to the wrong schema and (b) fails to express the nullability it was meant to add.

Downstream tooling is broken: openapi-generator-maven-plugin 7.14.0 treats "type": "null" as the JSON-Schema null type, renames the component to ModelNull, and emits an uncompilable Java class. 2.8.16 produces the expected "type": "object" for the same input, so this is a regression introduced in 2.8.17.

The suspected trigger is the fix for #3255 "Handle $ref nullable wrapping and OAS 3.1 support", listed under "Fixed" in the 2.8.17 release notes.

To Reproduce

Standalone reproducer project: https://github.com/franzmathauser/springdoc-2817-reproducer-repro

git clone https://github.com/franzmathauser/springdoc-2817-reproducer-repro.git
cd springdoc-2817-reproducer-repro
./mvnw spring-boot:run
# in another terminal:
curl -s http://localhost:8080/v3/api-docs | python3 -m json.tool

Steps to reproduce the behavior:

  • Spring Boot version: 3.5.13
  • springdoc-openapi modules and versions: springdoc-openapi-starter-webmvc-ui:2.8.17
  • Java version: 21
  • Sample code that reproduces the problem:
package com.example.repro;

import io.swagger.v3.oas.annotations.Operation;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.lang.Nullable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
public class App {
  public static void main(String[] args) { SpringApplication.run(App.class, args); }

  public record Inner(int lines, long bytes, String bucket, String key) {}
  public record Outer(String id, @Nullable Inner result) {}

  @RestController
  static class DemoController {
    @Operation(operationId = "getOuter")
    @GetMapping("/outer")
    public Outer getOuter() { return new Outer("x", null); }
  }
}

pom.xml:

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>3.5.13</version>
</parent>
<properties>
  <java.version>21</java.version>
</properties>
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.8.17</version>
  </dependency>
</dependencies>

Run ./mvnw spring-boot:run, then curl http://localhost:8080/v3/api-docs.

Actual result (verbatim from the reproducer, OAS 3.1 default):

{
  "openapi": "3.1.0",
  "paths": {
    "/outer": {
      "get": {
        "operationId": "getOuter",
        "responses": {
          "200": {
            "description": "OK",
            "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Outer" } } }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Inner": {
        "type": "null",
        "properties": {
          "lines":  { "type": "integer", "format": "int32" },
          "bytes":  { "type": "integer", "format": "int64" },
          "bucket": { "type": "string" },
          "key":    { "type": "string" }
        }
      },
      "Outer": {
        "type": "object",
        "properties": {
          "id":     { "type": "string" },
          "result": { "$ref": "#/components/schemas/Inner" }
        }
      }
    }
  }
}

Note that Inner.type is "null" even though Inner.properties is populated, and Outer.result carries no nullability marker.

Expected behavior

Inner should keep its valid object schema; nullability of Outer.result should either be dropped (as in 2.8.16) or be expressed on the reference, not on the referent.

Expected JSON for Inner (as produced by springdoc 2.8.16 for the same reproducer):

{
  "Inner": {
    "type": "object",
    "properties": {
      "lines":  { "type": "integer", "format": "int32" },
      "bytes":  { "type": "integer", "format": "int64" },
      "bucket": { "type": "string" },
      "key":    { "type": "string" }
    }
  }
}

The correct OAS 3.1 encoding of a nullable reference is a wrapper on the reference, never a mutation of the referent:

{
  "result": {
    "oneOf": [
      { "$ref": "#/components/schemas/Inner" },
      { "type": "null" }
    ]
  }
}

OAS 3.0 has no valid encoding for a nullable $ref (OAI/OpenAPI-Specification#1368) — in that mode the nullability should be dropped, not applied incorrectly.

Screenshots

Not applicable — the relevant artifact is the /v3/api-docs JSON (shown verbatim above). The standalone reproducer project at https://github.com/franzmathauser/springdoc-2817-reproducer-repro includes the full bisection matrix in its README so the behaviour differences across versions / OAS modes can be verified with a single-line version flip in pom.xml.

Additional context

Bisection

springdoc swagger-core OAS mode Inner component
2.8.16 2.2.46 (managed) 3.1 (default) "type": "object"
2.8.17 2.2.47 (managed) 3.1 (default) "type": "null" + properties
2.8.17 2.2.46 (pinned) 3.1 (default) "type": "null" + properties
2.8.17 2.2.47 (managed) 3.0 (forced) "type": "object" + "nullable": true on referent ✗
  • Row 3 pins swagger-core back to 2.8.16's transitive version; the bug persists. The regression is in springdoc-openapi 2.8.17 itself, not the concurrent swagger-core 2.2.46→2.2.47 bump.
  • Row 4 sets springdoc.api-docs.version=openapi_3_0. type is no longer mutated to "null", but springdoc instead writes "nullable": true onto the referenced component. Same root cause ("adapter mutates the referent"), different manifestation — every reference to Inner now implicitly accepts null, not just the one field that was annotated. openapi-generator tolerates this (no ModelNull), so forcing OAS 3.0 is a partial workaround but still semantically wrong.

Root-cause hypothesis

Springdoc's converters delegate Schema construction to swagger-core's ModelConverters, which cache one Schema instance per component. The #3255 change appears to apply nullability to that shared instance rather than to a per-call wrapper. The same "adapter mutates the referent" pattern has prior art in springdoc: #1870 (field-level @Schema overrides the referenced schema) and #2902 (HateoasLinksConverter replaces a referenced schema with String because it was resolved with resolveAsRef(false)). The fix pattern from #2902 (use resolveAsRef(true) before touching the schema) likely applies here as well.

Workaround

Post-process the OpenAPI model with a customizer that restores type: "object" on any component with properties but type/types only equal to "null":

@Bean
OpenApiCustomizer fixNullTypeObjectSchemas() {
  return openApi -> {
    if (openApi.getComponents() == null || openApi.getComponents().getSchemas() == null) return;
    openApi.getComponents().getSchemas().values().stream()
        .filter(s -> s.getProperties() != null && !s.getProperties().isEmpty())
        .filter(s -> "null".equals(s.getType())
            || (s.getTypes() != null && s.getTypes().size() == 1 && s.getTypes().contains("null")))
        .forEach(s -> {
          s.setType("object");
          if (s.getTypes() != null) { s.getTypes().remove("null"); s.getTypes().add("object"); }
          s.setDefault(null);
        });
  };
}

Downgrading to 2.8.16 also avoids the symptom.

Related issues

  • #3255 — the 2.8.17 fix that introduced this regression.
  • #2920, #2840 — same subsystem, opposite manifestation (nullable dropped).
  • #3110, swagger-api/swagger-core#5001 — Jakarta @Nullable feature request, shares code paths.
  • #1870, #2902 — earlier variants of "adapter mutates shared Schema."

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions