How to fix Springdoc-openapi ignoring @Deprecated on non-primitive fields

This post aims to help you solve an issue with springdoc-openapi not handling the @Deprecated annotation properly.

You might come across a need to document a deprecation in your OpenAPI DTO like this:

record TestClass (
  @Deprecated InnerClass someField
){
}

Unfortunately, springdoc-openapi (at least in version 2.0.3) does not produce the correct OpenAPI 3.0 description for this. The description that it tries to produce is:

schema:
  deprecated: true
  $ref: '#/components/schemas/InnerClass'

As it is documented in the Swagger documentation, the deprecated: true value will be completely ignored. This is further confirmed by the OpenAPI 3.0.0 specification:

This object cannot be extended with additional properties and any properties added SHALL be ignored.

OpenAPI 3.0.0 specification on using $ref

Unfortunately, the maintainer of springdoc-openapi does not believe that this is a bug in his code (and will permanently delete any issue suggesting it, close any PRs fixing it without a comment, and ban you for trying to convince him otherwise), which means you must fix it yourself if you want it to work properly.

Fortunately, the fix is incredibly easy. All you need to do is implement a custom SchemaPropertyDeprecatingConverter:

import io.swagger.v3.core.converter.AnnotatedType;
import io.swagger.v3.core.converter.ModelConverter;
import io.swagger.v3.core.converter.ModelConverterContext;
import io.swagger.v3.oas.models.media.Schema;
import org.springdoc.core.converters.SchemaPropertyDeprecatingConverter;
import org.springframework.stereotype.Component;

import java.util.Iterator;

import static java.util.Collections.singletonList;

@Component
public class CustomSchemaPropertyDeprecatingConverter extends SchemaPropertyDeprecatingConverter {

    @Override
    public Schema<?> resolve(AnnotatedType type, ModelConverterContext context, Iterator<ModelConverter> chain) {
        if (chain.hasNext()) {
            Schema<?> resolvedSchema = chain.next().resolve(type, context, chain);
            if (type.isSchemaProperty() && containsDeprecatedAnnotation(type.getCtxAnnotations())) {
                if (resolvedSchema.get$ref() != null) {
                    // Sibling values alongside $ref are ignored in OpenAPI versions lower than 3.1. See:
                    // https://swagger.io/docs/specification/using-ref/#sibling
                    // To add properties to a $ref, it must be wrapped in allOf.
                    resolvedSchema = wrapInAllOf(resolvedSchema);
                }
                resolvedSchema.setDeprecated(true);
            }
            return resolvedSchema;
        }
        return null;
    }

    private Schema<?> wrapInAllOf(Schema<?> resolvedSchema) {
        Schema<?> wrapperSchema = new Schema<>();
        wrapperSchema.allOf(singletonList(resolvedSchema));
        return wrapperSchema;
    }
}

Tests prove that it works correctly:

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.jayway.jsonpath.JsonPath;
import io.swagger.v3.core.converter.*;
import io.swagger.v3.core.util.Json;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.Execution;

import java.util.Iterator;
import java.util.List;
import java.util.Map;

import static java.util.Collections.emptyList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.parallel.ExecutionMode.SAME_THREAD;
import static org.mockito.Mockito.mock;

@Execution(SAME_THREAD)
class CustomSchemaPropertyDeprecatingConverterTest {

    public static CustomSchemaPropertyDeprecatingConverter CONVERTER;
    private static boolean ORIGINAL_ENUMS_AS_REF;
    private static ModelConverters CONVERTERS;
    private final ObjectWriter objectWriter = Json.mapper().writer().withDefaultPrettyPrinter();

    @BeforeAll
    static void beforeAll() {
        ORIGINAL_ENUMS_AS_REF = io.swagger.v3.core.jackson.ModelResolver.enumsAsRef;
        io.swagger.v3.core.jackson.ModelResolver.enumsAsRef = true;

        CONVERTER = new CustomSchemaPropertyDeprecatingConverter();
        CONVERTERS = new ModelConverters();
        CONVERTERS.addConverter(CONVERTER);
    }

    @AfterAll
    static void afterAll() {
        io.swagger.v3.core.jackson.ModelResolver.enumsAsRef = ORIGINAL_ENUMS_AS_REF;
    }

    /**
     * This is the behavior of the original SchemaPropertyDeprecatingConverter.
     */
    @SuppressWarnings("RedundantOperationOnEmptyContainer")
    @Test
    void shouldReturnNullIfLastInChain() {
        var type = new AnnotatedType();
        ModelConverterContext context = mock(ModelConverterContext.class);
        List<ModelConverter> modelConverters = emptyList();
        Iterator<ModelConverter> chain = modelConverters.iterator();

        var result = new CustomSchemaPropertyDeprecatingConverter().resolve(type, context, chain);

        assertNull(result);
    }

    @Test
    void shouldFixDeprecationProblem() throws Exception {
        ResolvedSchema resolvedSchema = CONVERTERS.resolveAsResolvedSchema(
                new AnnotatedType(TestClassWithDeprecations.class)
                        .resolveAsRef(true)
        );

        String expectedJson = """
                {
                   "TestClassWithDeprecations" : {
                     "type" : "object",
                     "properties" : {
                       "nonDeprecatedEnum" : {
                         "$ref" : "#/components/schemas/TestEnum"
                       },
                       "deprecatedEnum" : {
                         "deprecated" : true,
                         "allOf" : [ {
                           "$ref" : "#/components/schemas/TestEnum"
                         } ]
                       },
                       "nonDeprecatedClass" : {
                         "$ref" : "#/components/schemas/TestInnerClass"
                       },
                       "deprecatedClass" : {
                         "deprecated" : true,
                         "allOf" : [ {
                           "$ref" : "#/components/schemas/TestInnerClass"
                         } ]
                       },
                       "normalDeprecatedField" : {
                         "type" : "string",
                         "deprecated" : true
                       }
                     }
                   },
                   "TestEnum" : {
                     "type" : "string",
                     "enum" : [ "VAL1", "VAL2" ]
                   },
                   "TestInnerClass" : {
                     "type" : "object"
                   }
                 }""";
        assertComponentSchemaEquals(expectedJson, resolvedSchema.referencedSchemas);
    }

    @SuppressWarnings("rawtypes")
    private void assertComponentSchemaEquals(String expectedJson,
                                             Map<String, io.swagger.v3.oas.models.media.Schema> schema)
            throws JsonProcessingException {
        String componentSchemas = objectWriter.writeValueAsString(schema);
        String expectedComponentSchemas = objectWriter.writeValueAsString(JsonPath.parse(expectedJson).read("$"));
        assertThat(componentSchemas).isEqualTo(expectedComponentSchemas);
    }

    private record TestClassWithDeprecations(
            TestEnum nonDeprecatedEnum,
            @Deprecated TestEnum deprecatedEnum,
            TestInnerClass nonDeprecatedClass,
            @Deprecated TestInnerClass deprecatedClass,
            @Deprecated String normalDeprecatedField
    ) {

        private enum TestEnum {
            VAL1, VAL2
        }
    }

    private record TestInnerClass() {
    }
}
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.jayway.jsonpath.JsonPath;
import io.swagger.v3.oas.annotations.media.Schema;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.Execution;
import org.springdoc.core.configuration.SpringDocConfiguration;
import org.springdoc.core.converters.SchemaPropertyDeprecatingConverter;
import org.springdoc.core.properties.SpringDocConfigProperties;
import org.springdoc.webmvc.core.configuration.SpringDocWebMvcConfiguration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.UnsupportedEncodingException;

import static org.junit.jupiter.api.parallel.ExecutionMode.SAME_THREAD;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;

@Execution(SAME_THREAD)
@SpringBootTest
@ContextConfiguration(classes = {
        CustomSchemaPropertyDeprecatingConverterTest.TestConfiguration.class,
        CustomSchemaPropertyDeprecatingConverterTest.ControllerWithDeprecation.class,
        SpringDocConfiguration.class, SpringDocWebMvcConfiguration.class,
        WebMvcAutoConfiguration.class
})
@AutoConfigureMockMvc
class CustomSchemaPropertyDeprecatingConverterIT {

    private static boolean originalEnumsAsRef;
    private final ObjectWriter objectWriter = new ObjectMapper().writerWithDefaultPrettyPrinter();

    @Autowired
    private MockMvc mockMvc;

    @BeforeAll
    static void beforeAll() {
        originalEnumsAsRef = io.swagger.v3.core.jackson.ModelResolver.enumsAsRef;
        io.swagger.v3.core.jackson.ModelResolver.enumsAsRef = true;
    }

    @AfterAll
    static void afterAll() {
        io.swagger.v3.core.jackson.ModelResolver.enumsAsRef = originalEnumsAsRef;
    }

    @Test
    void shouldFixDeprecationProblem() throws Exception {
        String expectedJson = """
                {
                   "TestClassWithDeprecations" : {
                     "type" : "object",
                     "properties" : {
                       "nonDeprecatedEnum" : {
                         "$ref" : "#/components/schemas/TestEnum"
                       },
                       "deprecatedEnum" : {
                         "deprecated" : true,
                         "allOf" : [ {
                           "$ref" : "#/components/schemas/TestEnum"
                         } ]
                       },
                       "nonDeprecatedClass" : {
                         "$ref" : "#/components/schemas/TestInnerClass"
                       },
                       "deprecatedClass" : {
                         "deprecated" : true,
                         "allOf" : [ {
                           "$ref" : "#/components/schemas/TestInnerClass"
                         } ]
                       }
                     }
                   },
                   "TestEnum" : {
                     "type" : "string",
                     "enum" : [ "VAL1", "VAL2" ]
                   },
                   "TestInnerClass" : {
                     "type" : "object"
                   }
                 }""";
        mockMvc.perform(get("/v3/api-docs").accept(MediaType.APPLICATION_JSON))
                .andDo(mvcResult -> assertComponentSchemaEquals(expectedJson, mvcResult));
    }

    private void assertComponentSchemaEquals(String
expectedJson, MvcResult mvcResult)
            throws UnsupportedEncodingException, JsonProcessingException {
        String json = mvcResult.getResponse().getContentAsString();
        String componentSchemas = objectWriter.writeValueAsString(
                JsonPath.parse(json).read("$.components.schemas"));
        String expectedComponentSchemas = objectWriter.writeValueAsString(JsonPath.parse(expectedJson).read("$"));

        assertThat(componentSchemas).isEqualTo(expectedComponentSchemas);
    }

    private enum TestEnum {
        VAL1, VAL2
    }

    @Configuration
    public static class TestConfiguration {

        @Bean
        public SpringDocConfigProperties springDocConfigProperties() {
            return new SpringDocConfigProperties();
        }

        @Bean
        public SchemaPropertyDeprecatingConverter schemaPropertyDeprecatingConverter() {
            return new CustomSchemaPropertyDeprecatingConverter();
        }
    }

    @RestController
    public static class ControllerWithDeprecation {

        @GetMapping
        public TestClassWithDeprecations testEndpoint() {

            return null;
        }
    }

    private record TestClassWithDeprecations(
            TestEnum nonDeprecatedEnum,
            @Deprecated TestEnum deprecatedEnum,
            TestInnerClass nonDeprecatedClass,
            @Deprecated TestInnerClass deprecatedClass
    ) {
    }

    private record TestInnerClass() {
    }
}

The custom component wraps any deprecated $ref in an allOf, creating a valid OpenAPI description, e.g.,:

schema:
  deprecated: true
  allOf:
    - $ref: '#/components/schemas/InnerClass'

This should work without a problem with every client code generator, enabling you to easily mark non-primitive fields as @Deprecated. It will also work properly with enums, when io.swagger.v3.core.jackson.ModelResolver.enumsAsRef is true.

Be First to Comment

Leave a Reply

Your email address will not be published. Required fields are marked *