From c91ba7b74b0727128a532028b243240602afdece Mon Sep 17 00:00:00 2001 From: hej090224 Date: Tue, 25 Aug 2026 17:18:25 +0900 Subject: [PATCH 1/2] Fix embedded ID relation response schemas Fixes #3136 --- .../core/utils/SpringDocDataRestUtils.java | 57 +++++++++++++++ .../org/springdoc/api/v31/issue3136/Goal.java | 59 +++++++++++++++ .../v31/issue3136/GoalInitiativeImpactId.java | 52 +++++++++++++ .../api/v31/issue3136/GoalRepository.java | 31 ++++++++ .../api/v31/issue3136/Initiative.java | 47 ++++++++++++ .../v31/issue3136/InitiativeImpactOnGoal.java | 73 +++++++++++++++++++ .../v31/issue3136/InitiativeRepository.java | 31 ++++++++ .../v31/issue3136/SpringDocIssue3136Test.java | 66 +++++++++++++++++ 8 files changed, 416 insertions(+) create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/Goal.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/GoalInitiativeImpactId.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/GoalRepository.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/Initiative.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/InitiativeImpactOnGoal.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/InitiativeRepository.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/SpringDocIssue3136Test.java diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocDataRestUtils.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocDataRestUtils.java index c37857f98..bab63e067 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocDataRestUtils.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocDataRestUtils.java @@ -99,6 +99,11 @@ public class SpringDocDataRestUtils { */ private final HashMap entityInoMap = new HashMap(); + /** + * The associations fields by entity. + */ + private final HashMap> allAssociationsFieldsMap = new HashMap<>(); + /** * The Repository rest configuration. */ @@ -136,6 +141,7 @@ public void customise(OpenAPI openAPI, ResourceMappings mappings, PersistentEnti entityInfo.setIgnoredFields(ignoredFields); List associationsFields = getAssociationsFields(resourceMetadata, entity); entityInfo.setAssociationsFields(associationsFields); + allAssociationsFieldsMap.put(domainType.getSimpleName(), getAllAssociationsFields(resourceMetadata, entity)); entityInoMap.put(domainType.getSimpleName(), entityInfo); } @@ -277,11 +283,43 @@ private Schema updateResponseSchema(String className, Schema existingSchema, Com else if (EMBEDDED.equals(propId)) { updateResponseSchemaEmbedded(components, entityInfo, entry, openapi31); } + else if (allAssociationsFieldsMap.get(className).contains(propId)) { + updateResponseSchemaProperty(entry.getValue(), components, openapi31); + } } } return existingSchema; } + /** + * Update a response schema property that points to an entity which is not an + * exported repository. Spring Data REST serializes these associations in + * the containing representation, so they need the same association filtering + * as an exported entity response. + * + * @param property the property + * @param components the components + * @param openapi31 the openapi 31 + */ + private void updateResponseSchemaProperty(Schema property, Components components, boolean openapi31) { + if (property == null) + return; + if (property.get$ref() != null && !property.get$ref().endsWith(RESPONSE)) { + String key = property.get$ref().substring(Components.COMPONENTS_SCHEMAS_REF.length()); + if (entityInoMap.containsKey(key)) { + String newKey = property.get$ref() + RESPONSE; + if (!components.getSchemas().containsKey(key + RESPONSE)) { + createNewResponseSchema(key, components, openapi31); + updateResponseSchema(key, components.getSchemas().get(key + RESPONSE), components, openapi31); + } + property.set$ref(newKey); + } + } + else if (property.getItems() != null) { + updateResponseSchemaProperty(property.getItems(), components, openapi31); + } + } + /** * Update response schema embedded. * @@ -447,6 +485,25 @@ private List getAssociationsFields(ResourceMetadata return associationsFields; } + /** + * Gets all associations fields. + * + * @param resourceMetadata the resource metadata + * @param entity the entity + * @return all associations fields + */ + private List getAllAssociationsFields(ResourceMetadata + resourceMetadata, PersistentEntity entity) { + List associationsFields = new ArrayList<>(); + entity.doWithAssociations((SimpleAssociationHandler) association -> { + PersistentProperty property = association.getInverse(); + ResourceMapping mapping = resourceMetadata.getMappingFor(property); + String fieldName = mapping.getRel().value(); + associationsFields.add(fieldName); + }); + return associationsFields; + } + /** * Gets ignored fields. * diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/Goal.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/Goal.java new file mode 100644 index 000000000..6ac29d44c --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/Goal.java @@ -0,0 +1,59 @@ +/* + * + * * Copyright 2019-2026 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.issue3136; + +import java.util.HashSet; +import java.util.Set; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; + +/** + * Aggregate root with a collection of composite-key join entities. + * + * @author hej090224 + */ +@Entity +public class Goal { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + private String name; + + @OneToMany(mappedBy = "goal", cascade = CascadeType.ALL) + private Set impactsByInitiatives = new HashSet<>(); + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Set getImpactsByInitiatives() { + return impactsByInitiatives; + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/GoalInitiativeImpactId.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/GoalInitiativeImpactId.java new file mode 100644 index 000000000..428543da9 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/GoalInitiativeImpactId.java @@ -0,0 +1,52 @@ +/* + * + * * Copyright 2019-2026 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.issue3136; + +import java.io.Serializable; + +import jakarta.persistence.Embeddable; + +/** + * Composite identifier for the join entity used by the issue reproducer. + * + * @author hej090224 + */ +@Embeddable +public class GoalInitiativeImpactId implements Serializable { + + private Long goalId; + + private Long initiativeId; + + public Long getGoalId() { + return goalId; + } + + public void setGoalId(Long goalId) { + this.goalId = goalId; + } + + public Long getInitiativeId() { + return initiativeId; + } + + public void setInitiativeId(Long initiativeId) { + this.initiativeId = initiativeId; + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/GoalRepository.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/GoalRepository.java new file mode 100644 index 000000000..927271ee3 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/GoalRepository.java @@ -0,0 +1,31 @@ +/* + * + * * Copyright 2019-2026 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.issue3136; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.rest.core.annotation.RepositoryRestResource; + +/** + * Spring Data REST repository for the issue reproducer aggregate. + * + * @author hej090224 + */ +@RepositoryRestResource +public interface GoalRepository extends CrudRepository { +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/Initiative.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/Initiative.java new file mode 100644 index 000000000..1408a756e --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/Initiative.java @@ -0,0 +1,47 @@ +/* + * + * * Copyright 2019-2026 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.issue3136; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +/** + * Entity referenced by the composite-key join entity. + * + * @author hej090224 + */ +@Entity +public class Initiative { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + private String name; + + public Long getId() { + return id; + } + + public String getName() { + return name; + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/InitiativeImpactOnGoal.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/InitiativeImpactOnGoal.java new file mode 100644 index 000000000..694a18fda --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/InitiativeImpactOnGoal.java @@ -0,0 +1,73 @@ +/* + * + * * Copyright 2019-2026 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.issue3136; + +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; + +/** + * Join entity combining an embedded identifier with two mapped associations. + * + * @author hej090224 + */ +@Entity +public class InitiativeImpactOnGoal { + + @EmbeddedId + private GoalInitiativeImpactId id; + + @ManyToOne + @MapsId("goalId") + @JoinColumn(name = "goal_id") + private Goal goal; + + @ManyToOne + @MapsId("initiativeId") + @JoinColumn(name = "initiative_id") + private Initiative initiative; + + @Enumerated(EnumType.STRING) + private ImpactLevel impactLevel; + + public GoalInitiativeImpactId getId() { + return id; + } + + public Goal getGoal() { + return goal; + } + + public Initiative getInitiative() { + return initiative; + } + + public ImpactLevel getImpactLevel() { + return impactLevel; + } + + enum ImpactLevel { + TRIVIAL, + SIGNIFICANT + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/InitiativeRepository.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/InitiativeRepository.java new file mode 100644 index 000000000..f5bd4cf2d --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/InitiativeRepository.java @@ -0,0 +1,31 @@ +/* + * + * * Copyright 2019-2026 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.issue3136; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.rest.core.annotation.RepositoryRestResource; + +/** + * Spring Data REST repository for initiatives. + * + * @author hej090224 + */ +@RepositoryRestResource +public interface InitiativeRepository extends CrudRepository { +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/SpringDocIssue3136Test.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/SpringDocIssue3136Test.java new file mode 100644 index 000000000..10c7f0d7a --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/SpringDocIssue3136Test.java @@ -0,0 +1,66 @@ +/* + * + * * Copyright 2019-2026 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.issue3136; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.equalTo; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Verifies that a Spring Data REST response schema keeps composite-key values + * while representing associations as HAL links instead of recursively + * expanding related entities. + * + * @author hej090224 + */ +@ActiveProfiles("test") +@SpringBootTest +@AutoConfigureMockMvc +class SpringDocIssue3136Test { + + @Autowired + private MockMvc mockMvc; + + @Test + void embeddedIdRelationsUseCompactResponseSchema() throws Exception { + mockMvc.perform(get("/v3/api-docs")).andExpect(status().isOk()) + .andExpect(jsonPath("$.components.schemas.EntityModelGoal.properties.impactsByInitiatives.items.$ref", + equalTo("#/components/schemas/InitiativeImpactOnGoalResponse"))) + .andExpect(jsonPath("$.components.schemas.GoalInitiativeImpactId.properties.goalId.type", equalTo("integer"))) + .andExpect(jsonPath("$.components.schemas.GoalInitiativeImpactId.properties.initiativeId.type", equalTo("integer"))) + .andExpect(jsonPath("$.components.schemas.InitiativeImpactOnGoalResponse.properties.impactLevel").exists()) + .andExpect(jsonPath("$.components.schemas.InitiativeImpactOnGoalResponse.properties.id").doesNotExist()) + .andExpect(jsonPath("$.components.schemas.InitiativeImpactOnGoalResponse.properties.goal").doesNotExist()) + .andExpect(jsonPath("$.components.schemas.InitiativeImpactOnGoalResponse.properties.initiative").doesNotExist()) + .andExpect(jsonPath("$.components.schemas.CollectionModelEntityModelGoal.properties._embedded.properties.goals.items").exists()); + } + + @SpringBootApplication + static class SpringDocTestApp { + } +} From 5b760788ce7f34e182e10eba6d2d8977ad9fe994 Mon Sep 17 00:00:00 2001 From: hej090224 Date: Wed, 26 Aug 2026 01:36:01 +0900 Subject: [PATCH 2/2] Refactor issue 3136 regression test --- .../api/v31/{issue3136 => app40}/Goal.java | 2 +- .../GoalInitiativeImpactId.java | 2 +- .../{issue3136 => app40}/GoalRepository.java | 2 +- .../v31/{issue3136 => app40}/Initiative.java | 2 +- .../InitiativeImpactOnGoal.java | 2 +- .../InitiativeRepository.java | 2 +- .../api/v31/app40/SpringDocApp40Test.java | 35 + .../v31/issue3136/SpringDocIssue3136Test.java | 66 -- .../test/resources/results/3.1.0/app40.json | 809 ++++++++++++++++++ 9 files changed, 850 insertions(+), 72 deletions(-) rename springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/{issue3136 => app40}/Goal.java (97%) rename springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/{issue3136 => app40}/GoalInitiativeImpactId.java (96%) rename springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/{issue3136 => app40}/GoalRepository.java (95%) rename springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/{issue3136 => app40}/Initiative.java (96%) rename springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/{issue3136 => app40}/InitiativeImpactOnGoal.java (97%) rename springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/{issue3136 => app40}/InitiativeRepository.java (95%) create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/SpringDocApp40Test.java delete mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/SpringDocIssue3136Test.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.1.0/app40.json diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/Goal.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/Goal.java similarity index 97% rename from springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/Goal.java rename to springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/Goal.java index 6ac29d44c..5d657bbb8 100644 --- a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/Goal.java +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/Goal.java @@ -16,7 +16,7 @@ * */ -package test.org.springdoc.api.v31.issue3136; +package test.org.springdoc.api.v31.app40; import java.util.HashSet; import java.util.Set; diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/GoalInitiativeImpactId.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/GoalInitiativeImpactId.java similarity index 96% rename from springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/GoalInitiativeImpactId.java rename to springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/GoalInitiativeImpactId.java index 428543da9..bb50b5b1d 100644 --- a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/GoalInitiativeImpactId.java +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/GoalInitiativeImpactId.java @@ -16,7 +16,7 @@ * */ -package test.org.springdoc.api.v31.issue3136; +package test.org.springdoc.api.v31.app40; import java.io.Serializable; diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/GoalRepository.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/GoalRepository.java similarity index 95% rename from springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/GoalRepository.java rename to springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/GoalRepository.java index 927271ee3..99b3d5375 100644 --- a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/GoalRepository.java +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/GoalRepository.java @@ -16,7 +16,7 @@ * */ -package test.org.springdoc.api.v31.issue3136; +package test.org.springdoc.api.v31.app40; import org.springframework.data.repository.CrudRepository; import org.springframework.data.rest.core.annotation.RepositoryRestResource; diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/Initiative.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/Initiative.java similarity index 96% rename from springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/Initiative.java rename to springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/Initiative.java index 1408a756e..685c84663 100644 --- a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/Initiative.java +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/Initiative.java @@ -16,7 +16,7 @@ * */ -package test.org.springdoc.api.v31.issue3136; +package test.org.springdoc.api.v31.app40; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/InitiativeImpactOnGoal.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/InitiativeImpactOnGoal.java similarity index 97% rename from springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/InitiativeImpactOnGoal.java rename to springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/InitiativeImpactOnGoal.java index 694a18fda..0d1c2ee11 100644 --- a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/InitiativeImpactOnGoal.java +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/InitiativeImpactOnGoal.java @@ -16,7 +16,7 @@ * */ -package test.org.springdoc.api.v31.issue3136; +package test.org.springdoc.api.v31.app40; import jakarta.persistence.EmbeddedId; import jakarta.persistence.Entity; diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/InitiativeRepository.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/InitiativeRepository.java similarity index 95% rename from springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/InitiativeRepository.java rename to springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/InitiativeRepository.java index f5bd4cf2d..9768eb39a 100644 --- a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/InitiativeRepository.java +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/InitiativeRepository.java @@ -16,7 +16,7 @@ * */ -package test.org.springdoc.api.v31.issue3136; +package test.org.springdoc.api.v31.app40; import org.springframework.data.repository.CrudRepository; import org.springframework.data.rest.core.annotation.RepositoryRestResource; diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/SpringDocApp40Test.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/SpringDocApp40Test.java new file mode 100644 index 000000000..b8d89f0fd --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/SpringDocApp40Test.java @@ -0,0 +1,35 @@ +/* + * + * * Copyright 2019-2026 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.app40; + +import test.org.springdoc.api.v31.AbstractSpringDocTest; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Verifies the response schemas for an embedded ID and mapped associations. + * + * @author hej090224 + */ +public class SpringDocApp40Test extends AbstractSpringDocTest { + + @SpringBootApplication + static class SpringDocTestApp { + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/SpringDocIssue3136Test.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/SpringDocIssue3136Test.java deleted file mode 100644 index 10c7f0d7a..000000000 --- a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/issue3136/SpringDocIssue3136Test.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * - * * Copyright 2019-2026 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.issue3136; - -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; - -import static org.hamcrest.Matchers.equalTo; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -/** - * Verifies that a Spring Data REST response schema keeps composite-key values - * while representing associations as HAL links instead of recursively - * expanding related entities. - * - * @author hej090224 - */ -@ActiveProfiles("test") -@SpringBootTest -@AutoConfigureMockMvc -class SpringDocIssue3136Test { - - @Autowired - private MockMvc mockMvc; - - @Test - void embeddedIdRelationsUseCompactResponseSchema() throws Exception { - mockMvc.perform(get("/v3/api-docs")).andExpect(status().isOk()) - .andExpect(jsonPath("$.components.schemas.EntityModelGoal.properties.impactsByInitiatives.items.$ref", - equalTo("#/components/schemas/InitiativeImpactOnGoalResponse"))) - .andExpect(jsonPath("$.components.schemas.GoalInitiativeImpactId.properties.goalId.type", equalTo("integer"))) - .andExpect(jsonPath("$.components.schemas.GoalInitiativeImpactId.properties.initiativeId.type", equalTo("integer"))) - .andExpect(jsonPath("$.components.schemas.InitiativeImpactOnGoalResponse.properties.impactLevel").exists()) - .andExpect(jsonPath("$.components.schemas.InitiativeImpactOnGoalResponse.properties.id").doesNotExist()) - .andExpect(jsonPath("$.components.schemas.InitiativeImpactOnGoalResponse.properties.goal").doesNotExist()) - .andExpect(jsonPath("$.components.schemas.InitiativeImpactOnGoalResponse.properties.initiative").doesNotExist()) - .andExpect(jsonPath("$.components.schemas.CollectionModelEntityModelGoal.properties._embedded.properties.goals.items").exists()); - } - - @SpringBootApplication - static class SpringDocTestApp { - } -} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.1.0/app40.json b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.1.0/app40.json new file mode 100644 index 000000000..bd04fd3dd --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.1.0/app40.json @@ -0,0 +1,809 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/goals": { + "get": { + "tags": [ + "goal-entity-controller" + ], + "description": "get-goal", + "operationId": "getCollectionResource-goal-get", + "responses": { + "200": { + "description": "OK", + "content": { + "application/vnd.hal+json": { + "schema": { + "$ref": "#/components/schemas/CollectionModelEntityModelGoal" + } + }, + "application/x-spring-data-compact+json": { + "schema": { + "$ref": "#/components/schemas/CollectionModelEntityModelGoal" + } + }, + "text/uri-list": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "tags": [ + "goal-entity-controller" + ], + "description": "create-goal", + "operationId": "postCollectionResource-goal-post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoalRequestBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/vnd.hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelGoal" + } + } + } + } + } + } + }, + "/goals/{id}": { + "get": { + "tags": [ + "goal-entity-controller" + ], + "description": "get-goal", + "operationId": "getItemResource-goal-get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/vnd.hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelGoal" + } + } + } + }, + "404": { + "description": "Not Found" + } + } + }, + "put": { + "tags": [ + "goal-entity-controller" + ], + "description": "update-goal", + "operationId": "putItemResource-goal-put", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoalRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/vnd.hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelGoal" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/vnd.hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelGoal" + } + } + } + }, + "204": { + "description": "No Content" + } + } + }, + "delete": { + "tags": [ + "goal-entity-controller" + ], + "description": "delete-goal", + "operationId": "deleteItemResource-goal-delete", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found" + } + } + }, + "patch": { + "tags": [ + "goal-entity-controller" + ], + "description": "patch-goal", + "operationId": "patchItemResource-goal-patch", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoalRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/vnd.hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelGoal" + } + } + } + }, + "204": { + "description": "No Content" + } + } + } + }, + "/initiatives": { + "get": { + "tags": [ + "initiative-entity-controller" + ], + "description": "get-initiative", + "operationId": "getCollectionResource-initiative-get", + "responses": { + "200": { + "description": "OK", + "content": { + "application/vnd.hal+json": { + "schema": { + "$ref": "#/components/schemas/CollectionModelEntityModelInitiative" + } + }, + "application/x-spring-data-compact+json": { + "schema": { + "$ref": "#/components/schemas/CollectionModelEntityModelInitiative" + } + }, + "text/uri-list": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "tags": [ + "initiative-entity-controller" + ], + "description": "create-initiative", + "operationId": "postCollectionResource-initiative-post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitiativeRequestBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/vnd.hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelInitiative" + } + } + } + } + } + } + }, + "/initiatives/{id}": { + "get": { + "tags": [ + "initiative-entity-controller" + ], + "description": "get-initiative", + "operationId": "getItemResource-initiative-get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/vnd.hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelInitiative" + } + } + } + }, + "404": { + "description": "Not Found" + } + } + }, + "put": { + "tags": [ + "initiative-entity-controller" + ], + "description": "update-initiative", + "operationId": "putItemResource-initiative-put", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitiativeRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/vnd.hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelInitiative" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/vnd.hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelInitiative" + } + } + } + }, + "204": { + "description": "No Content" + } + } + }, + "delete": { + "tags": [ + "initiative-entity-controller" + ], + "description": "delete-initiative", + "operationId": "deleteItemResource-initiative-delete", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found" + } + } + }, + "patch": { + "tags": [ + "initiative-entity-controller" + ], + "description": "patch-initiative", + "operationId": "patchItemResource-initiative-patch", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitiativeRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/vnd.hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelInitiative" + } + } + } + }, + "204": { + "description": "No Content" + } + } + } + }, + "/profile": { + "get": { + "tags": [ + "profile-controller" + ], + "operationId": "listAllFormsOfMetadata", + "responses": { + "200": { + "description": "OK", + "content": { + "application/vnd.hal+json": { + "schema": { + "$ref": "#/components/schemas/RepresentationModelObject" + } + } + } + } + } + } + }, + "/profile/goals": { + "get": { + "tags": [ + "profile-controller" + ], + "operationId": "descriptor", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + }, + "application/alps+json": { + "schema": { + "type": "string" + } + }, + "application/schema+json": { + "schema": { + "$ref": "#/components/schemas/JsonSchema" + } + } + } + } + } + } + }, + "/profile/initiatives": { + "get": { + "tags": [ + "profile-controller" + ], + "operationId": "descriptor_1", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + }, + "application/alps+json": { + "schema": { + "type": "string" + } + }, + "application/schema+json": { + "schema": { + "$ref": "#/components/schemas/JsonSchema" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AbstractJsonSchemaPropertyObject": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + } + } + }, + "Item": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/AbstractJsonSchemaPropertyObject" + } + }, + "requiredProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "JsonSchema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/AbstractJsonSchemaPropertyObject" + } + }, + "requiredProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "definitions": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Item" + } + }, + "type": { + "type": "string" + }, + "$schema": { + "type": "string" + } + } + }, + "RepresentationModelObject": { + "type": "object", + "properties": { + "_links": { + "$ref": "#/components/schemas/Links" + } + } + }, + "EntityModelInitiative": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "_links": { + "$ref": "#/components/schemas/Links" + } + } + }, + "Initiative": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + }, + "CollectionModelEntityModelInitiative": { + "type": "object", + "properties": { + "_embedded": { + "type": "object", + "properties": { + "initiatives": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityModelInitiative" + } + } + } + }, + "_links": { + "$ref": "#/components/schemas/Links" + } + } + }, + "EntityModelGoal": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "impactsByInitiatives": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InitiativeImpactOnGoalResponse" + }, + "uniqueItems": true + }, + "_links": { + "$ref": "#/components/schemas/Links" + } + } + }, + "Goal": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "impactsByInitiatives": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InitiativeImpactOnGoalResponse" + }, + "uniqueItems": true + } + } + }, + "GoalInitiativeImpactId": { + "type": "object", + "properties": { + "goalId": { + "type": "integer", + "format": "int64" + }, + "initiativeId": { + "type": "integer", + "format": "int64" + } + } + }, + "InitiativeImpactOnGoal": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/GoalInitiativeImpactId" + }, + "goal": { + "$ref": "#/components/schemas/Goal" + }, + "initiative": { + "$ref": "#/components/schemas/Initiative" + }, + "impactLevel": { + "type": "string", + "enum": [ + "TRIVIAL", + "SIGNIFICANT" + ] + } + } + }, + "CollectionModelEntityModelGoal": { + "type": "object", + "properties": { + "_embedded": { + "type": "object", + "properties": { + "goals": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityModelGoal" + } + } + } + }, + "_links": { + "$ref": "#/components/schemas/Links" + } + } + }, + "GoalRequestBody": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "impactsByInitiatives": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InitiativeImpactOnGoal" + }, + "uniqueItems": true + } + } + }, + "InitiativeRequestBody": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + }, + "InitiativeImpactOnGoalResponse": { + "type": "object", + "properties": { + "impactLevel": { + "type": "string", + "enum": [ + "TRIVIAL", + "SIGNIFICANT" + ] + } + } + }, + "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" + } + } + } + } +}