diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSpecificationStringPropertiesConfiguration.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSpecificationStringPropertiesConfiguration.java new file mode 100644 index 000000000..ff2581f78 --- /dev/null +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSpecificationStringPropertiesConfiguration.java @@ -0,0 +1,103 @@ +/* + * + * * + * * * + * * * * + * * * * * Copyright 2019-2022 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 org.springdoc.core.configuration; + +import org.springdoc.core.customizers.SpecificationStringPropertiesCustomizer; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.env.PropertyResolver; + +import java.util.List; + +/** + * The type Spring doc specification string properties configuration. + * + * @author Anton Tkachenko tkachenkoas@gmail.com + */ +@Lazy(false) +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty(name = "springdoc.api-docs.specification-string-properties") +@ConditionalOnBean(SpringDocConfiguration.class) +public class SpringDocSpecificationStringPropertiesConfiguration { + + /** + * Springdoc customizer that takes care of the specification string properties customization. + * Will be applied to general openapi schema. + * + * @return the springdoc customizer + */ + @Bean + @ConditionalOnMissingBean + @Lazy(false) + SpecificationStringPropertiesCustomizer specificationStringPropertiesCustomizer( + PropertyResolver propertyResolverUtils + ) { + return new SpecificationStringPropertiesCustomizer(propertyResolverUtils); + } + + /** + * Bean post processor that applies the specification string properties customization to + * grouped openapi schemas by using group name as a prefix for properties. + * + * @return the bean post processor + */ + @Bean + @ConditionalOnMissingBean + @Lazy(false) + SpecificationStringPropertiesCustomizerBeanPostProcessor specificationStringPropertiesCustomizerBeanPostProcessor( + PropertyResolver propertyResolverUtils + ) { + return new SpecificationStringPropertiesCustomizerBeanPostProcessor(propertyResolverUtils); + } + + + private static class SpecificationStringPropertiesCustomizerBeanPostProcessor implements BeanPostProcessor { + + private final PropertyResolver propertyResolverUtils; + + public SpecificationStringPropertiesCustomizerBeanPostProcessor( + PropertyResolver propertyResolverUtils + ) { + this.propertyResolverUtils = propertyResolverUtils; + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) { + if (bean instanceof GroupedOpenApi groupedOpenApi) { + groupedOpenApi.addAllOpenApiCustomizer(List.of(new SpecificationStringPropertiesCustomizer( + propertyResolverUtils, groupedOpenApi.getGroup() + ))); + } + return bean; + } + } + +} \ No newline at end of file diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/SpecificationStringPropertiesCustomizer.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/SpecificationStringPropertiesCustomizer.java new file mode 100644 index 000000000..a7a2c743a --- /dev/null +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/SpecificationStringPropertiesCustomizer.java @@ -0,0 +1,165 @@ +/* + * + * * + * * * + * * * * + * * * * * Copyright 2019-2022 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 org.springdoc.core.customizers; + +import io.swagger.v3.oas.models.*; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.media.Schema; +import org.apache.commons.lang3.StringUtils; +import org.springframework.core.env.PropertyResolver; +import org.springframework.util.CollectionUtils; + +import java.text.MessageFormat; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * Allows externalizing strings in generated openapi schema via properties that follow + * conventional naming similar or identical to openapi schema + *

+ * To set value of a string in schema, define an application property that matches the target node + * with springdoc.specification-strings prefix. + *

+ * Sample supported properties for api-info customization: + *

+ *

+ * Sample supported properties for components customization: + *

+ *

+ * Sample supported properties for paths/operationIds customization: + *

+ *

+ * Support for groped openapi customization is similar to the above, but with a group name prefix. + * E.g. + *

+ * + * @author Anton Tkachenko tkachenkoas@gmail.com + */ +public class SpecificationStringPropertiesCustomizer implements GlobalOpenApiCustomizer { + + private static final String SPECIFICATION_STRINGS_PREFIX = "springdoc.specification-strings."; + + private final PropertyResolver propertyResolver; + private final String propertyPrefix; + + public SpecificationStringPropertiesCustomizer(PropertyResolver resolverUtils) { + this.propertyResolver = resolverUtils; + this.propertyPrefix = SPECIFICATION_STRINGS_PREFIX; + } + + public SpecificationStringPropertiesCustomizer(PropertyResolver propertyResolver, String groupName) { + this.propertyResolver = propertyResolver; + this.propertyPrefix = SPECIFICATION_STRINGS_PREFIX + groupName + "."; + } + + @Override + public void customise(OpenAPI openApi) { + setOperationInfoProperties(openApi); + setComponentsProperties(openApi); + setPathsProperties(openApi); + } + + private void setOperationInfoProperties(OpenAPI openApi) { + if (openApi.getInfo() == null) { + openApi.setInfo(new Info()); + } + Info info = openApi.getInfo(); + resolveString(info::setTitle, "info.title"); + resolveString(info::setDescription, "info.description"); + resolveString(info::setVersion, "info.version"); + resolveString(info::setTermsOfService, "info.termsOfService"); + } + + private void setPathsProperties(OpenAPI openApi) { + Paths paths = openApi.getPaths(); + if (CollectionUtils.isEmpty(paths.values())) { + return; + } + for (PathItem pathItem : paths.values()) { + List operations = pathItem.readOperations(); + for (Operation operation : operations) { + String operationId = operation.getOperationId(); + String operationNode = MessageFormat.format("paths.{0}", operationId); + resolveString(operation::setDescription, operationNode + ".description"); + + resolveString(operation::setSummary, operationNode + ".summary"); + } + } + } + + private void setComponentsProperties(OpenAPI openApi) { + Components components = openApi.getComponents(); + if (components == null || CollectionUtils.isEmpty(components.getSchemas())) { + return; + } + + for (Schema componentSchema : components.getSchemas().values()) { + // set component description + String schemaPropertyPrefix = MessageFormat.format("components.schemas.{0}", componentSchema.getName()); + resolveString(componentSchema::setDescription, schemaPropertyPrefix + ".description"); + Map properties = componentSchema.getProperties(); + + if (CollectionUtils.isEmpty(properties)) { + continue; + } + + for (Schema propSchema : properties.values()) { + String propertyNode = MessageFormat.format("components.schemas.{0}.properties.{1}", + componentSchema.getName(), propSchema.getName()); + + resolveString(propSchema::setDescription, propertyNode + ".description"); + resolveString(propSchema::setExample, propertyNode + ".example"); + } + } + } + + private void resolveString( + Consumer setter, String node + ) { + String nodeWithPrefix = propertyPrefix + node; + String value = propertyResolver.getProperty(nodeWithPrefix); + if (StringUtils.isNotBlank(value)) { + setter.accept(value); + } + } + +} diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/Constants.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/Constants.java index 342224c90..7a8c6ab48 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/Constants.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/Constants.java @@ -104,6 +104,11 @@ public final class Constants { */ public static final String SPRINGDOC_SCHEMA_RESOLVE_PROPERTIES = "springdoc.api-docs.resolve-schema-properties"; + /** + * The constant SPRINGDOC_SPECIFICATION_STRING_PROPERTIES. + */ + public static final String SPRINGDOC_SPECIFICATION_STRING_PROPERTIES = "springdoc.api-docs.specification-string-properties"; + /** * The constant SPRINGDOC_SHOW_LOGIN_ENDPOINT. */ diff --git a/springdoc-openapi-starter-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/springdoc-openapi-starter-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index b24184faa..8eac4c907 100644 --- a/springdoc-openapi-starter-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/springdoc-openapi-starter-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -7,6 +7,7 @@ org.springdoc.core.configuration.SpringDocFunctionCatalogConfiguration org.springdoc.core.configuration.SpringDocHateoasConfiguration org.springdoc.core.configuration.SpringDocPageableConfiguration org.springdoc.core.configuration.SpringDocSortConfiguration +org.springdoc.core.configuration.SpringDocSpecificationStringPropertiesConfiguration org.springdoc.core.configuration.SpringDocDataRestConfiguration org.springdoc.core.configuration.SpringDocKotlinConfiguration org.springdoc.core.configuration.SpringDocKotlinxConfiguration diff --git a/springdoc-openapi-tests/springdoc-openapi-groovy-tests/src/test/groovy/test/org/springdoc/api/app212/HelloController.java b/springdoc-openapi-tests/springdoc-openapi-groovy-tests/src/test/groovy/test/org/springdoc/api/app212/HelloController.java new file mode 100644 index 000000000..92bdbb239 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-groovy-tests/src/test/groovy/test/org/springdoc/api/app212/HelloController.java @@ -0,0 +1,32 @@ +/* + * + * * Copyright 2019-2023 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.app212; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HelloController { + + @GetMapping(value = "/persons") + public PersonDTO persons() { + return new PersonDTO("John"); + } + +} diff --git a/springdoc-openapi-tests/springdoc-openapi-groovy-tests/src/test/groovy/test/org/springdoc/api/app212/PersonDTO.java b/springdoc-openapi-tests/springdoc-openapi-groovy-tests/src/test/groovy/test/org/springdoc/api/app212/PersonDTO.java new file mode 100644 index 000000000..8087efc7d --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-groovy-tests/src/test/groovy/test/org/springdoc/api/app212/PersonDTO.java @@ -0,0 +1,22 @@ +/* + * + * * Copyright 2019-2023 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.app212; + +public record PersonDTO(String name) { +} diff --git a/springdoc-openapi-tests/springdoc-openapi-groovy-tests/src/test/groovy/test/org/springdoc/api/app212/SpringDocApp212Test.java b/springdoc-openapi-tests/springdoc-openapi-groovy-tests/src/test/groovy/test/org/springdoc/api/app212/SpringDocApp212Test.java new file mode 100644 index 000000000..5c6126ed3 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-groovy-tests/src/test/groovy/test/org/springdoc/api/app212/SpringDocApp212Test.java @@ -0,0 +1,64 @@ +/* + * + * * Copyright 2019-2023 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.app212; + +import org.junit.jupiter.api.Test; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.ActiveProfiles; +import test.org.springdoc.api.AbstractSpringDocTest; + +import static org.skyscreamer.jsonassert.JSONAssert.assertEquals; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The type Spring doc app 192 test. + *

+ * A test for {@link org.springdoc.core.customizers.SpecificationStringPropertiesCustomizer} + */ +@ActiveProfiles("212") +public class SpringDocApp212Test extends AbstractSpringDocTest { + + /** + * The type Spring doc test app. + */ + @SpringBootApplication + static class SpringDocTestApp { + + @Bean + GroupedOpenApi apiGroupBeanName() { + return GroupedOpenApi.builder() + .group("apiGroupName") + .packagesToScan("test.org.springdoc.api.app212") + .build(); + } + } + + @Test + void getGroupedOpenapi_shouldCustomizeFromPropertiesWithGroupNamePrefix() throws Exception { + String result = mockMvc.perform(get("/v3/api-docs/apiGroupName")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + String expected = getContent("results/app212-grouped.json"); + assertEquals(expected, result, true); + } + +} diff --git a/springdoc-openapi-tests/springdoc-openapi-groovy-tests/src/test/resources/application-212.yml b/springdoc-openapi-tests/springdoc-openapi-groovy-tests/src/test/resources/application-212.yml new file mode 100644 index 000000000..7936144b8 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-groovy-tests/src/test/resources/application-212.yml @@ -0,0 +1,37 @@ +springdoc: + api-docs: + specification-string-properties: true + specification-strings: + info: + title: Api info title + description: Api info description + version: Api info version + components: + schemas: + PersonDTO: + description: Description for PersonDTO component + properties: + name: + description: Description for 'name' property + example: Example value for 'name' property + paths: + persons: + description: Description of operationId 'persons' + summary: Summary of operationId 'persons' + apiGroupName: + info: + title: ApiGroupName info title + description: ApiGroupName info description + version: ApiGroupName info version + components: + schemas: + PersonDTO: + description: Description for PersonDTO component in ApiGroupName + properties: + name: + description: Description for 'name' property in ApiGroupName + example: Example value for 'name' property in ApiGroupName + paths: + persons: + description: Description of operationId 'persons' in ApiGroupName + summary: Summary of operationId 'persons' in ApiGroupName \ No newline at end of file diff --git a/springdoc-openapi-tests/springdoc-openapi-groovy-tests/src/test/resources/results/app212-grouped.json b/springdoc-openapi-tests/springdoc-openapi-groovy-tests/src/test/resources/results/app212-grouped.json new file mode 100644 index 000000000..72ac0c1f1 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-groovy-tests/src/test/resources/results/app212-grouped.json @@ -0,0 +1,53 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "ApiGroupName info title", + "description": "ApiGroupName info description", + "version": "ApiGroupName info version" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/persons": { + "get": { + "tags": [ + "hello-controller" + ], + "summary": "Summary of operationId 'persons' in ApiGroupName", + "description": "Description of operationId 'persons' in ApiGroupName", + "operationId": "persons", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PersonDTO" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "PersonDTO": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Description for 'name' property in ApiGroupName", + "example": "Example value for 'name' property in ApiGroupName" + } + }, + "description": "Description for PersonDTO component in ApiGroupName" + } + } + } +} \ No newline at end of file diff --git a/springdoc-openapi-tests/springdoc-openapi-groovy-tests/src/test/resources/results/app212.json b/springdoc-openapi-tests/springdoc-openapi-groovy-tests/src/test/resources/results/app212.json new file mode 100644 index 000000000..4576d3406 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-groovy-tests/src/test/resources/results/app212.json @@ -0,0 +1,54 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Api info title", + "description": "Api info description", + "version": "Api info version" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/persons": { + "get": { + "tags": [ + "hello-controller" + ], + "summary": "Summary of operationId 'persons'", + "description": "Description of operationId 'persons'", + "operationId": "persons", + "responses": { + "200": { + "description":"OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PersonDTO" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "PersonDTO": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Description for 'name' property", + "example": "Example value for 'name' property" + } + }, + "description": "Description for PersonDTO component" + } + } + } +} +