javaspring-bootjacksonopenapispringdoc

How to include Springdoc schemas for a request body field that uses Jackson polymorphism of an interface


To start with, here are the relevant dependencies in my build.gradle:

    implementation("org.springframework.boot:spring-boot-starter-web:2.7.18")
    implementation("org.springdoc:springdoc-openapi-ui:1.8.0") {
        exclude(group = "org.slf4j", module = "slf4j-api")
    }
    implementation("org.springdoc:springdoc-openapi-webmvc-core:1.8.0") {
        exclude(group = "org.slf4j", module = "slf4j-api")
    }

(slf4j-api is excluded from Springdoc because it conflicts with the slf4j version used by spring-boot-starter-web, leading to no logging output)

What I'm trying to achieve:

a POST endpoint to create a Foo resource. CreateFooRequest is an abstract superclass, with two subclasses, CreateFooARequest and CreateFooBRequest. CreateFooRequest is annotated with Jackson's @JsonSubTypes, referencing the two subclasses.

This works just fine: in the OpenAPI specification generated from the Spring REST controller, I can see all these types as components, and my CreateFooController correctly indicates that the OpenAPI operation accepts oneOf: [FooA, FooB].

However, on any CreateFooRequest there are two fields which are themselves polymorphic.

The first field, config, is of type Config, with subtypes None, One and Many.

The second field is a List<Animal>, the subtypes of Animal currently include Cat and Dog.

The request class therefore looks something like this:

    @Schema
    private Config config = new None();

    @Schema
    private List<Animal> animals = new ArrayList<>();

and both of Config and Animal look something like:

@JsonTypeInfo(
        use = JsonTypeInfo.Id.NAME,
        include = JsonTypeInfo.As.PROPERTY,
        property = "type")
@JsonSubTypes({
        @JsonSubTypes.Type(name = "none", value = None.class),
        @JsonSubTypes.Type(name = "many", value = Many.class),
        @JsonSubTypes.Type(name = "one", value = One.class),
})
@Schema(
        subTypes = {
                None.class,
                Many.class,
                One.class,
        },
        discriminatorProperty = "type",
        discriminatorMapping = {
                @DiscriminatorMapping(value = "none", schema = None.class),
                @DiscriminatorMapping(value = "many", schema = Many.class),
                @DiscriminatorMapping(value = "one", schema = One.class),
        })
public interface Config {
}

Springdoc is able to detect the JSON schema types of these fields. For instance, createFooRequest.animals is correctly described as

        animals:
          type: array
          description: Some animals in the foo
          items:
            oneOf:
            - $ref: '#/components/schemas/Cat'
            - $ref: '#/components/schemas/Dog'

However, those schemas (#/components/schemas/Cat and #/components/schemas/Dog, Animal, Config and all its subtypes) are simply missing from the OpenAPI spec, making it an invalid spec. Annotating those types with @Schema doesn't help.

Fields whose types are specific to one of those types are also excluded: for instance, if Dog is the only place where an enum Wibble {... is used, then Wibble will also be missing from the spec.

What do I need to do to get these schemas included? I would prefer not to have to explicitly include them myself using a GlobalOpenApiCustomizer. This is because there could foreseeably be future subtypes of Animal and I want them to be automatically included, rather than requiring that all future Animal subtype developers must remember to add the new types if they want the spec to stay valid.


Solution

  • There seems to be a few issues working together against me, here. This is how I declare the request mapping in the @RestController:

    @PostMapping
    public <T extends CreateFooRequest> ResponseEntity<Void> createFoo(
            @Valid @NotNull @RequestBody T request) {
    

    There's a step in OpenAPI generation, io.swagger.v3.core.filter.SpecFilter#removeBrokenReferenceDefinitions. This is the culprit, although I can't work out why it's marking the Animal and Config types as broken references.

    The controller declaration seems to mark only FooA and FooB as 'directly in use', but the logic in that method decides that the base Foo request is also transitively used. In the generated spec, I can see CreateFooRequest, CreateFooARequest and CreateFooBRequest, but I don't see any of the other types.

    Animal, Config and their associated subtypes are all declared as fields on the base CreateFooRequest. So it looks like it isn't recursively marking the schemas used by 'indirectly-used schemas', of which CreateFooRequest is one, as themselves being in use, and therefore not a broken reference.

    If I move the Config field declaration to one of FooA or FooB, then I can see it and its implementations in the spec. They will also appear if I add @Schema(implementation = CreateFooRequest.class) to the request body parameter. So the fact that it's declared on the parent is what's breaking it.

    The List<Animal> case still doesn't show when moved to FooA, suggesting that it's not 'hopping far enough' to find the usage of the Animal schema.

    I can 'fix' this by adding the following property to my Spring application.yml:

    springdoc:
      remove-broken-reference-definitions: false
    

    which skips this behaviour. I would love a more 'robust' fix, that correctly displays the dependent schemas without requiring this flag, if anyone can provide one.

    ---------- EDIT ----------

    By changing my dependencies to:

        implementation("org.springdoc:springdoc-openapi-ui:1.8.0") {
            exclude(group = "org.slf4j", module = "slf4j-api")
        }
        implementation("io.swagger.core.v3:swagger-core:2.2.30") {
            exclude(group = "org.slf4j", module = "slf4j-api")
        }
    

    i.e. explicitly upgrading swagger-core to the latest version (which ought to be compatible given the semver version), I'm now able to product the expected output in the spec file.