diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/HateoasLinksConverter.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/HateoasLinksConverter.java index 7fbfa420b..bcde9140c 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/HateoasLinksConverter.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/HateoasLinksConverter.java @@ -28,6 +28,7 @@ import java.util.Iterator; +import java.util.Optional; import com.fasterxml.jackson.databind.JavaType; import io.swagger.v3.core.converter.ModelConverter; @@ -43,7 +44,7 @@ /** * The type Hateoas links converter. - * + * * @author bnasslahsen */ public class HateoasLinksConverter implements ModelConverter { @@ -70,19 +71,30 @@ public Schema resolve( ) { JavaType javaType = springDocObjectMapper.jsonMapper().constructType(type.getType()); if (javaType != null && RepresentationModel.class.isAssignableFrom(javaType.getRawClass())) { - Schema schema = chain.next().resolve(type, context, chain); - String schemaName = schema.get$ref().substring(Components.COMPONENTS_SCHEMAS_REF.length()); - Schema original = context.getDefinedModels().get(schemaName); - Object links = original.getProperties().get("_links"); - if(links instanceof JsonSchema jsonSchema) { - jsonSchema.set$ref(AnnotationsUtils.COMPONENTS_REF + "Links"); - jsonSchema.setType(null); - jsonSchema.setItems(null); - jsonSchema.setTypes(null); - } else if (links instanceof ArraySchema arraySchema){ - arraySchema.set$ref(AnnotationsUtils.COMPONENTS_REF + "Links"); + Schema schema = chain.next().resolve(type, context, chain); + if (schema != null) { + String schemaName = Optional.ofNullable(schema.get$ref()) + .filter(ref -> ref.startsWith(Components.COMPONENTS_SCHEMAS_REF)) + .map(ref -> ref.substring(Components.COMPONENTS_SCHEMAS_REF.length())) + .orElse(schema.getName()); + if(schemaName != null) { + Schema original = context.getDefinedModels().get(schemaName); + if (original == null || original.getProperties() == null) { + return schema; + } + Object links = original.getProperties().get("_links"); + if (links instanceof JsonSchema jsonSchema) { + jsonSchema.set$ref(AnnotationsUtils.COMPONENTS_REF + "Links"); + jsonSchema.setType(null); + jsonSchema.setItems(null); + jsonSchema.setTypes(null); + } + else if (links instanceof ArraySchema arraySchema) { + arraySchema.set$ref(AnnotationsUtils.COMPONENTS_REF + "Links"); + } } - return schema; + } + return schema; } return chain.hasNext() ? chain.next().resolve(type, context, chain) : null; } diff --git a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app11/SpringDocApp11Test.java b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app11/SpringDocApp11Test.java new file mode 100644 index 000000000..3e619a272 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app11/SpringDocApp11Test.java @@ -0,0 +1,39 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * Copyright 2019-2025 the original author or authors. + * * * * * * + * * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * * you may not use this file except in compliance with the License. + * * * * * * You may obtain a copy of the License at + * * * * * * + * * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * * + * * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app11; + +import test.org.springdoc.api.v31.AbstractSpringDocTest; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +public class SpringDocApp11Test extends AbstractSpringDocTest { + + @SpringBootApplication + static class SpringDocTestApp { + } + +} \ No newline at end of file diff --git a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app11/configuration/WebMvcConfiguration.java b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app11/configuration/WebMvcConfiguration.java new file mode 100644 index 000000000..032eeeac7 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app11/configuration/WebMvcConfiguration.java @@ -0,0 +1,25 @@ +package test.org.springdoc.api.v31.app11.configuration; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; + +@Configuration +public class WebMvcConfiguration { + + @Bean + MappingJackson2HttpMessageConverter getMappingJacksonHttpMessageConverter() { + MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); + converter.setSupportedMediaTypes(List.of(MediaType.APPLICATION_JSON)); + converter.setObjectMapper(new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL) + ); + + return converter; + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app11/controllers/BasicController.java b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app11/controllers/BasicController.java new file mode 100644 index 000000000..7f6586f05 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app11/controllers/BasicController.java @@ -0,0 +1,41 @@ +package test.org.springdoc.api.v31.app11.controllers; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import test.org.springdoc.api.v31.app11.model.Cat; + +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.RepresentationModel; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(path = "/") +public class BasicController { + + @GetMapping("/cat") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "get", description = "Provides an animal.") + public String get(Cat cat) { + return cat != null ? cat.getName() : ""; + } + + @GetMapping("/test") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "get", description = "Provides a response.") + @ApiResponse(content = @Content(mediaType = MediaTypes.HAL_JSON_VALUE, + schema = @io.swagger.v3.oas.annotations.media.Schema(implementation = Response.class)), + responseCode = "200") + public Response get() { + return new Response("value"); + } + + // dummy + public static class Response extends RepresentationModel { + public Response(String v) {} + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app11/controllers/CustomOpenApiWebMvcResource.java b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app11/controllers/CustomOpenApiWebMvcResource.java new file mode 100644 index 000000000..f88930430 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app11/controllers/CustomOpenApiWebMvcResource.java @@ -0,0 +1,27 @@ +package test.org.springdoc.api.v31.app11.controllers; + +import org.springdoc.core.customizers.SpringDocCustomizers; +import org.springdoc.core.properties.SpringDocConfigProperties; +import org.springdoc.core.providers.SpringDocProviders; +import org.springdoc.core.service.AbstractRequestService; +import org.springdoc.core.service.GenericResponseService; +import org.springdoc.core.service.OpenAPIService; +import org.springdoc.core.service.OperationService; +import org.springdoc.webmvc.api.OpenApiWebMvcResource; + +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class CustomOpenApiWebMvcResource extends OpenApiWebMvcResource { + + public CustomOpenApiWebMvcResource(ObjectFactory openAPIBuilderObjectFactory, + AbstractRequestService requestBuilder, + GenericResponseService responseBuilder, + OperationService operationParser, + SpringDocConfigProperties springDocConfigProperties, + SpringDocProviders springDocProviders, + SpringDocCustomizers springDocCustomizers) { + super(openAPIBuilderObjectFactory, requestBuilder, responseBuilder, operationParser, springDocConfigProperties, springDocProviders, springDocCustomizers); + } +} \ No newline at end of file diff --git a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app11/model/Cat.java b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app11/model/Cat.java new file mode 100644 index 000000000..95595b01a --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app11/model/Cat.java @@ -0,0 +1,24 @@ +package test.org.springdoc.api.v31.app11.model; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Represents a Cat class.") +public class Cat { + + @JsonUnwrapped + @Schema(description = "The name.", nullable = true) + private String name; + + public Cat(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/resources/results/3.1.0/app11.json b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/resources/results/3.1.0/app11.json new file mode 100644 index 000000000..a40b8cb0b --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/resources/results/3.1.0/app11.json @@ -0,0 +1,126 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/test": { + "get": { + "tags": [ + "basic-controller" + ], + "summary": "get", + "description": "Provides a response.", + "operationId": "get", + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + } + } + } + } + }, + "/cat": { + "get": { + "tags": [ + "basic-controller" + ], + "summary": "get", + "description": "Provides an animal.", + "operationId": "get_1", + "parameters": [ + { + "name": "cat", + "in": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/Cat" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Response": { + "type": "object", + "properties": { + "_links": { + "$ref": "#/components/schemas/Links" + } + } + }, + "Cat": { + "type": "object", + "description": "Represents a Cat class.", + "properties": { + "name": { + "type": "string", + "description": "The name." + } + } + }, + "Link": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "hreflang": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "deprecation": { + "type": "string" + }, + "profile": { + "type": "string" + }, + "name": { + "type": "string" + }, + "templated": { + "type": "boolean" + } + } + }, + "Links": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Link" + } + } + } + } +}