Search code examples
spring-bootkotlingsonswagger-uiopenapi

Endpoint "/api-docs" doesn't work with custom GsonHttpMessageConverter


I migrated from Springfox Swagger to Springdoc OpenApi. I have added few lines in my configuration about springdoc:

springdoc:
  pathsToMatch: /api/**
  api-docs:
    path: /api-docs
  swagger-ui:
    path: /swagger-ui.html

In configuration class MainConfig.kt I have following code:

val customGson: Gson = GsonBuilder()
        .registerTypeAdapter(LocalDateTime::class.java, DateSerializer())
        .registerTypeAdapter(ZonedDateTime::class.java, ZonedDateSerializer())
        .addSerializationExclusionStrategy(AnnotationExclusionStrategy())
        .enableComplexMapKeySerialization()
        .setPrettyPrinting()
        .create()

    override fun configureMessageConverters(converters: MutableList<HttpMessageConverter<*>>) {
        converters.add(GsonHttpMessageConverter(customGson))
    }

When I go to http://localhost:8013/swagger-ui.html (in configuration I have server.port: 8013) the page is not redirect to swagger-ui/index.html?url=/api-docs&validatorUrl=. But this is not my main problem :). When I go to swagger-ui/index.html?url=/api-docs&validatorUrl= I got page with this information:

Unable to render this definition
The provided definition does not specify a valid version field.

Please indicate a valid Swagger or OpenAPI version field. Supported version fields are swagger: "2.0" and those that match openapi: 3.0.n (for example, openapi: 3.0.0).

But when I go to http://localhost:8013/api-docs I have this result:

"{\"openapi\":\"3.0.1\",\"info\":{(...)}}"

I tried using default config and I commented configureMessageConverters() method and result of \api-docs now looks like normal JSON:

// 20191218134933
// http://localhost:8013/api-docs

{
  "openapi": "3.0.1",
  "info": {(...)}
}

I remember when I was using Springfox there was something wrong with serialization and my customGson had additional line: .registerTypeAdapter(Json::class.java, JsonSerializer<Json> { src, _, _ -> JsonParser.parseString(src.value()) })

I was wondering that I should have special JsonSerializer. After debugging my first thought was leading to OpenApi class in io.swagger.v3.oas.models package. I added this code: .registerTypeAdapter(OpenAPI::class.java, JsonSerializer<OpenAPI> { _, _, _ -> JsonParser.parseString("") }) to customGson and nothing changed... So, I was digging deeper...

After when I ran my Swagger tests:

@EnableAutoConfiguration
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@ExtendWith(SpringExtension::class)
@ActiveProfiles("test")
class SwaggerIntegrationTest(@Autowired private val mockMvc: MockMvc) {
    @Test
    fun `should display Swagger UI page`() {
        val result = mockMvc.perform(MockMvcRequestBuilders.get("/swagger-ui/index.html"))
                .andExpect(status().isOk)
                .andReturn()

        assertTrue(result.response.contentAsString.contains("Swagger UI"))
    }

    @Disabled("Redirect doesn't work. Check it later")
    @Test
    fun `should display Swagger UI page with redirect`() {
        mockMvc.perform(MockMvcRequestBuilders.get("/swagger-ui.html"))
                .andExpect(status().isOk)
                .andExpect(MockMvcResultMatchers.content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
    }

    @Test
    fun `should get api docs`() {
        mockMvc.perform(MockMvcRequestBuilders.get("/api-docs"))
                .andExpect(status().isOk)
                .andExpect(MockMvcResultMatchers.content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
                .andExpect(MockMvcResultMatchers.jsonPath("\$.openapi").exists())
    }
}

I saw in console this:

MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /api-docs
       Parameters = {}
          Headers = []
             Body = null
    Session Attrs = {}

Handler:
             Type = org.springdoc.api.OpenApiResource
           Method = org.springdoc.api.OpenApiResource#openapiJson(HttpServletRequest, String)

Next I check openapiJson in OpenApiResource and...

    @Operation(hidden = true)
    @GetMapping(value = API_DOCS_URL, produces = MediaType.APPLICATION_JSON_VALUE)
    public String openapiJson(HttpServletRequest request, @Value(API_DOCS_URL) String apiDocsUrl)
            throws JsonProcessingException {
        calculateServerUrl(request, apiDocsUrl);
        OpenAPI openAPI = this.getOpenApi();
        return Json.mapper().writeValueAsString(openAPI);
    }

OK, Jackson... I have disabled Jackson by @EnableAutoConfiguration(exclude = [(JacksonAutoConfiguration::class)]) because I (and my colleagues) prefer GSON, but it doesn't explain why serialization go wrong after adding custom GsonHttpMessageConverter. I have no idea what I made bad. This openapiJson() is endpoint and maybe it mess something... I don't know. I haven't any idea. Did you have a similar problem? Can you give some advice or hint?

PS. Sorry for my bad English :).


Solution

  • I had the same issue with a project written in Java, and I've just solved that by defining a filter to format my springdoc-openapi json documentation using Gson. I guess you can easily port this workaround to Kotlin.

    @Override
    public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain)
            throws IOException, ServletException {
        ByteResponseWrapper byteResponseWrapper = new ByteResponseWrapper((HttpServletResponse) response);
        ByteRequestWrapper byteRequestWrapper = new ByteRequestWrapper((HttpServletRequest) request);
    
        chain.doFilter(byteRequestWrapper, byteResponseWrapper);
    
        String jsonResponse = new String(byteResponseWrapper.getBytes(), response.getCharacterEncoding());
    
        response.getOutputStream().write((new com.google.gson.JsonParser().parse(jsonResponse).getAsString())
                .getBytes(response.getCharacterEncoding()));
    }
    
    @Override
    public void destroy() {
    
    }
    
    static class ByteResponseWrapper extends HttpServletResponseWrapper {
    
        private PrintWriter writer;
        private ByteOutputStream output;
    
        public byte[] getBytes() {
            writer.flush();
            return output.getBytes();
        }
    
        public ByteResponseWrapper(HttpServletResponse response) {
            super(response);
            output = new ByteOutputStream();
            writer = new PrintWriter(output);
        }
    
        @Override
        public PrintWriter getWriter() {
            return writer;
        }
    
        @Override
        public ServletOutputStream getOutputStream() {
            return output;
        }
    }
    
    static class ByteRequestWrapper extends HttpServletRequestWrapper {
    
        byte[] requestBytes = null;
        private ByteInputStream byteInputStream;
    
    
        public ByteRequestWrapper(HttpServletRequest request) throws IOException {
            super(request);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
    
            InputStream inputStream = request.getInputStream();
    
            byte[] buffer = new byte[4096];
            int read = 0;
            while ((read = inputStream.read(buffer)) != -1) {
                baos.write(buffer, 0, read);
            }
    
            replaceRequestPayload(baos.toByteArray());
        }
    
        @Override
        public BufferedReader getReader() {
            return new BufferedReader(new InputStreamReader(getInputStream()));
        }
    
        @Override
        public ServletInputStream getInputStream() {
            return byteInputStream;
        }
    
        public void replaceRequestPayload(byte[] newPayload) {
            requestBytes = newPayload;
            byteInputStream = new ByteInputStream(new ByteArrayInputStream(requestBytes));
        }
    }
    
    static class ByteOutputStream extends ServletOutputStream {
    
        private ByteArrayOutputStream bos = new ByteArrayOutputStream();
    
        @Override
        public void write(int b) {
            bos.write(b);
        }
    
        public byte[] getBytes() {
            return bos.toByteArray();
        }
    
        @Override
        public boolean isReady() {
            return false;
        }
    
        @Override
        public void setWriteListener(WriteListener writeListener) {
    
        }
    }
    
    static class ByteInputStream extends ServletInputStream {
    
        private InputStream inputStream;
    
        public ByteInputStream(final InputStream inputStream) {
            this.inputStream = inputStream;
        }
    
        @Override
        public int read() throws IOException {
            return inputStream.read();
        }
    
        @Override
        public boolean isFinished() {
            return false;
        }
    
        @Override
        public boolean isReady() {
            return false;
        }
    
        @Override
        public void setReadListener(ReadListener readListener) {
    
        }
    }
    

    You will also have to register your filter only for your documentation url pattern.

    @Bean
    public FilterRegistrationBean<DocsFormatterFilter> loggingFilter() {
        FilterRegistrationBean<DocsFormatterFilter> registrationBean = new FilterRegistrationBean<>();
    
        registrationBean.setFilter(new DocsFormatterFilter());
        registrationBean.addUrlPatterns("/v3/api-docs");
    
        return registrationBean;
    }