From a06b55eed29d47d217bd1a5f4d9381ef791ce752 Mon Sep 17 00:00:00 2001 From: joseegarcia Date: Fri, 24 Jul 2026 00:47:51 +0200 Subject: [PATCH] Fix schema generation for $ref with sibling description (OpenAPI 3.1) OpenAPI 3.1 / JSON Schema 2020-12 allow sibling keywords (e.g. `description`) next to `$ref`, and their ordering is not significant. `ApiTool.hasRef` only treated a node as a reference when `$ref` was the *first* field, so a property declaring `description` before `$ref` was not recognised as a reference: it fell through to the basic-type branch and generated a field with an empty type, breaking generation. `hasRef` now detects a top-level `$ref` regardless of field ordering. Adds a regression test (`testRefWithDescription`) covering both orderings. Bumps version 6.4.1 -> 6.4.2. Closes joseegman-idoneea/scs-multiapi-plugin#1 Co-Authored-By: Claude Opus 4.8 (1M context) --- multiapi-engine/pom.xml | 2 +- .../plugin/common/tools/ApiTool.java | 5 +- .../openapi/OpenApiGeneratorFixtures.java | 26 ++++ .../plugin/openapi/OpenApiGeneratorTest.java | 2 + .../testRefWithDescription/api-test.yml | 55 +++++++++ .../assets/AddressDTO.java | 94 +++++++++++++++ .../assets/CustomerDTO.java | 94 +++++++++++++++ .../assets/OrderApi.java | 46 +++++++ .../assets/OrderDTO.java | 112 ++++++++++++++++++ scs-multiapi-gradle-plugin/build.gradle | 6 +- scs-multiapi-maven-plugin/pom.xml | 4 +- 11 files changed, 439 insertions(+), 7 deletions(-) create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/api-test.yml create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/assets/AddressDTO.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/assets/CustomerDTO.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/assets/OrderApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/assets/OrderDTO.java diff --git a/multiapi-engine/pom.xml b/multiapi-engine/pom.xml index 7b691435..ec41b1ee 100644 --- a/multiapi-engine/pom.xml +++ b/multiapi-engine/pom.xml @@ -4,7 +4,7 @@ com.sngular multiapi-engine - 6.4.1 + 6.4.2 jar diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/ApiTool.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/ApiTool.java index f9aa1508..2f260e18 100644 --- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/ApiTool.java +++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/ApiTool.java @@ -262,7 +262,10 @@ public static boolean hasName(JsonNode message) { } public static boolean hasRef(final JsonNode schema) { - return (hasNode(schema, "$ref") || schema.fieldNames().hasNext()) && schema.fieldNames().next().equals("$ref"); + // OpenAPI 3.1 / JSON Schema 2020-12 allow sibling keywords next to `$ref` + // (e.g. `description`), and their ordering is not significant. A node is a + // reference whenever it declares a top-level `$ref`, regardless of position. + return hasNode(schema, "$ref"); } public static boolean hasProperties(final JsonNode schema) { diff --git a/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorFixtures.java b/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorFixtures.java index 7748c294..1dc6859b 100644 --- a/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorFixtures.java +++ b/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorFixtures.java @@ -175,6 +175,13 @@ public final class OpenApiGeneratorFixtures { .clientPackage("com.sngular.multifileplugin.openapi31types.client") .modelNameSuffix("DTO").build()); + static final List TEST_REF_WITH_DESCRIPTION = List + .of(SpecFile.builder().filePath("openapigenerator/testRefWithDescription/api-test.yml") + .apiPackage("com.sngular.multifileplugin.refwithdescription") + .modelPackage("com.sngular.multifileplugin.refwithdescription.model") + .clientPackage("com.sngular.multifileplugin.refwithdescription.client") + .modelNameSuffix("DTO").build()); + static final List TEST_EXTERNAL_PATH_ITEM_REF_GENERATION = List .of(SpecFile.builder().filePath("openapigenerator/testExternalPathItemRefsGeneration/api-test.yml") .apiPackage("com.sngular.multifileplugin.externalpathitemref") @@ -884,6 +891,25 @@ static Function validateOpenApi31Types() { DEFAULT_MODEL_API, Collections.emptyList(), null); } + static Function validateRefWithDescription() { + + final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/refwithdescription"; + + final String DEFAULT_MODEL_API = "generated/com/sngular/multifileplugin/refwithdescription/model"; + + final String COMMON_PATH = "openapigenerator/testRefWithDescription/"; + + final String ASSETS_PATH = COMMON_PATH + "assets/"; + + final List expectedTestApiFile = List.of(ASSETS_PATH + "OrderApi.java"); + + final List expectedTestApiModelFiles = List.of(ASSETS_PATH + "AddressDTO.java", + ASSETS_PATH + "CustomerDTO.java", ASSETS_PATH + "OrderDTO.java"); + + return path -> commonTest(path, expectedTestApiFile, expectedTestApiModelFiles, DEFAULT_TARGET_API, + DEFAULT_MODEL_API, Collections.emptyList(), null); + } + static Function validateExternalPathItemRefGeneration() { final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/externalpathitemref"; diff --git a/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorTest.java b/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorTest.java index 723356a0..90d3b56e 100644 --- a/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorTest.java +++ b/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorTest.java @@ -85,6 +85,8 @@ static Stream fileSpecToProcess() { OpenApiGeneratorFixtures.validateExternalRefGeneration()), Arguments.of("testOpenApi31Types", OpenApiGeneratorFixtures.TEST_OPEN_API_31_TYPES, OpenApiGeneratorFixtures.validateOpenApi31Types()), + Arguments.of("testRefWithDescription", OpenApiGeneratorFixtures.TEST_REF_WITH_DESCRIPTION, + OpenApiGeneratorFixtures.validateRefWithDescription()), Arguments.of("testWebhooks", OpenApiGeneratorFixtures.TEST_WEBHOOKS, OpenApiGeneratorFixtures.validateWebhooks()), Arguments.of("testOpenApi31Union", OpenApiGeneratorFixtures.TEST_OPEN_API_31_UNION, diff --git a/multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/api-test.yml b/multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/api-test.yml new file mode 100644 index 00000000..a3af9431 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/api-test.yml @@ -0,0 +1,55 @@ +--- +# Regression for OpenAPI 3.1 support: a `$ref` may carry sibling keywords such as +# `description`. The generator must still resolve the reference regardless of the +# relative ordering of `$ref` and its siblings (see issue: $ref + description). +openapi: "3.1.0" +info: + version: 1.0.0 + title: Order API (OpenAPI 3.1) + license: + name: MIT +servers: + - url: http://localhost:8080/v1 +tags: + - name: order +paths: + /order: + get: + summary: Get an order + operationId: getOrder + tags: + - order + responses: + '200': + description: The requested order + content: + application/json: + schema: + $ref: "#/components/schemas/Order" +components: + schemas: + Order: + type: object + properties: + id: + type: string + customer: + description: The customer that placed the order + $ref: "#/components/schemas/Customer" + shippingAddress: + $ref: "#/components/schemas/Address" + description: Where the order is shipped + Customer: + type: object + properties: + name: + type: string + email: + type: string + Address: + type: object + properties: + street: + type: string + city: + type: string diff --git a/multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/assets/AddressDTO.java b/multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/assets/AddressDTO.java new file mode 100644 index 00000000..3c776daf --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/assets/AddressDTO.java @@ -0,0 +1,94 @@ +package com.sngular.multifileplugin.refwithdescription.model; + +import java.util.Objects; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonDeserialize(builder = AddressDTO.AddressDTOBuilder.class) +public class AddressDTO { + + @JsonProperty(value ="city") + private String city; + @JsonProperty(value ="street") + private String street; + + private AddressDTO(AddressDTOBuilder builder) { + this.city = builder.city; + this.street = builder.street; + + } + + public static AddressDTO.AddressDTOBuilder builder() { + return new AddressDTO.AddressDTOBuilder(); + } + + @JsonPOJOBuilder(buildMethodName = "build", withPrefix = "") + public static class AddressDTOBuilder { + + private String city; + private String street; + + public AddressDTO.AddressDTOBuilder city(String city) { + this.city = city; + return this; + } + + public AddressDTO.AddressDTOBuilder street(String street) { + this.street = street; + return this; + } + + public AddressDTO build() { + AddressDTO addressDTO = new AddressDTO(this); + return addressDTO; + } + } + + @Schema(name = "city", required = false) + public String getCity() { + return city; + } + public void setCity(String city) { + this.city = city; + } + + @Schema(name = "street", required = false) + public String getStreet() { + return street; + } + public void setStreet(String street) { + this.street = street; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AddressDTO addressDTO = (AddressDTO) o; + return Objects.equals(this.city, addressDTO.city) && Objects.equals(this.street, addressDTO.street); + } + + @Override + public int hashCode() { + return Objects.hash(city, street); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("AddressDTO{"); + sb.append(" city:").append(city).append(","); + sb.append(" street:").append(street); + sb.append("}"); + return sb.toString(); + } + + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/assets/CustomerDTO.java b/multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/assets/CustomerDTO.java new file mode 100644 index 00000000..b63a0424 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/assets/CustomerDTO.java @@ -0,0 +1,94 @@ +package com.sngular.multifileplugin.refwithdescription.model; + +import java.util.Objects; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonDeserialize(builder = CustomerDTO.CustomerDTOBuilder.class) +public class CustomerDTO { + + @JsonProperty(value ="name") + private String name; + @JsonProperty(value ="email") + private String email; + + private CustomerDTO(CustomerDTOBuilder builder) { + this.name = builder.name; + this.email = builder.email; + + } + + public static CustomerDTO.CustomerDTOBuilder builder() { + return new CustomerDTO.CustomerDTOBuilder(); + } + + @JsonPOJOBuilder(buildMethodName = "build", withPrefix = "") + public static class CustomerDTOBuilder { + + private String name; + private String email; + + public CustomerDTO.CustomerDTOBuilder name(String name) { + this.name = name; + return this; + } + + public CustomerDTO.CustomerDTOBuilder email(String email) { + this.email = email; + return this; + } + + public CustomerDTO build() { + CustomerDTO customerDTO = new CustomerDTO(this); + return customerDTO; + } + } + + @Schema(name = "name", required = false) + public String getName() { + return name; + } + public void setName(String name) { + this.name = name; + } + + @Schema(name = "email", required = false) + public String getEmail() { + return email; + } + public void setEmail(String email) { + this.email = email; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CustomerDTO customerDTO = (CustomerDTO) o; + return Objects.equals(this.name, customerDTO.name) && Objects.equals(this.email, customerDTO.email); + } + + @Override + public int hashCode() { + return Objects.hash(name, email); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("CustomerDTO{"); + sb.append(" name:").append(name).append(","); + sb.append(" email:").append(email); + sb.append("}"); + return sb.toString(); + } + + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/assets/OrderApi.java b/multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/assets/OrderApi.java new file mode 100644 index 00000000..f7d9d872 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/assets/OrderApi.java @@ -0,0 +1,46 @@ +package com.sngular.multifileplugin.refwithdescription; + +import java.util.Optional; +import java.util.List; +import java.util.Map; +import javax.validation.Valid; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.context.request.NativeWebRequest; + +import com.sngular.multifileplugin.refwithdescription.model.OrderDTO; + +public interface OrderApi { + + /** + * GET /order: Get an order + * @return The requested order; (status code 200) + */ + + @Operation( + operationId = "getOrder", + summary = "Get an order", + tags = {"order"}, + responses = { + @ApiResponse(responseCode = "200", description = "The requested order", content = @Content(mediaType = "application/json", schema = @Schema(implementation = OrderDTO.class))) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/order", + produces = {"application/json"} + ) + + default ResponseEntity getOrder() { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/assets/OrderDTO.java b/multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/assets/OrderDTO.java new file mode 100644 index 00000000..9e932363 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testRefWithDescription/assets/OrderDTO.java @@ -0,0 +1,112 @@ +package com.sngular.multifileplugin.refwithdescription.model; + +import java.util.Objects; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonDeserialize(builder = OrderDTO.OrderDTOBuilder.class) +public class OrderDTO { + + @JsonProperty(value ="id") + private String id; + @JsonProperty(value ="customer") + private CustomerDTO customer; + @JsonProperty(value ="shippingAddress") + private AddressDTO shippingAddress; + + private OrderDTO(OrderDTOBuilder builder) { + this.id = builder.id; + this.customer = builder.customer; + this.shippingAddress = builder.shippingAddress; + + } + + public static OrderDTO.OrderDTOBuilder builder() { + return new OrderDTO.OrderDTOBuilder(); + } + + @JsonPOJOBuilder(buildMethodName = "build", withPrefix = "") + public static class OrderDTOBuilder { + + private String id; + private CustomerDTO customer; + private AddressDTO shippingAddress; + + public OrderDTO.OrderDTOBuilder id(String id) { + this.id = id; + return this; + } + + public OrderDTO.OrderDTOBuilder customer(CustomerDTO customer) { + this.customer = customer; + return this; + } + + public OrderDTO.OrderDTOBuilder shippingAddress(AddressDTO shippingAddress) { + this.shippingAddress = shippingAddress; + return this; + } + + public OrderDTO build() { + OrderDTO orderDTO = new OrderDTO(this); + return orderDTO; + } + } + + @Schema(name = "id", required = false) + public String getId() { + return id; + } + public void setId(String id) { + this.id = id; + } + + @Schema(name = "customer", required = false) + public CustomerDTO getCustomer() { + return customer; + } + public void setCustomer(CustomerDTO customer) { + this.customer = customer; + } + + @Schema(name = "shippingAddress", required = false) + public AddressDTO getShippingAddress() { + return shippingAddress; + } + public void setShippingAddress(AddressDTO shippingAddress) { + this.shippingAddress = shippingAddress; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + OrderDTO orderDTO = (OrderDTO) o; + return Objects.equals(this.id, orderDTO.id) && Objects.equals(this.customer, orderDTO.customer) && Objects.equals(this.shippingAddress, orderDTO.shippingAddress); + } + + @Override + public int hashCode() { + return Objects.hash(id, customer, shippingAddress); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("OrderDTO{"); + sb.append(" id:").append(id).append(","); + sb.append(" customer:").append(customer).append(","); + sb.append(" shippingAddress:").append(shippingAddress); + sb.append("}"); + return sb.toString(); + } + + +} diff --git a/scs-multiapi-gradle-plugin/build.gradle b/scs-multiapi-gradle-plugin/build.gradle index 129e16cd..993a8fe3 100644 --- a/scs-multiapi-gradle-plugin/build.gradle +++ b/scs-multiapi-gradle-plugin/build.gradle @@ -21,7 +21,7 @@ repositories { } group = 'com.sngular' -version = '6.4.1' +version = '6.4.2' def SCSMultiApiPluginGroupId = group def SCSMultiApiPluginVersion = version @@ -31,7 +31,7 @@ dependencies { shadow localGroovy() shadow gradleApi() - implementation 'com.sngular:multiapi-engine:6.4.1' + implementation 'com.sngular:multiapi-engine:6.4.2' testImplementation 'org.assertj:assertj-core:3.24.2' testImplementation 'com.puppycrawl.tools:checkstyle:10.12.3' testImplementation 'org.junit.platform:junit-platform-launcher:1.9.2' @@ -100,7 +100,7 @@ testing { integrationTest(JvmTestSuite) { dependencies { - implementation 'com.sngular:scs-multiapi-gradle-plugin:6.4.1' + implementation 'com.sngular:scs-multiapi-gradle-plugin:6.4.2' implementation 'org.assertj:assertj-core:3.24.2' } diff --git a/scs-multiapi-maven-plugin/pom.xml b/scs-multiapi-maven-plugin/pom.xml index 6f3c5e7f..81ee251a 100644 --- a/scs-multiapi-maven-plugin/pom.xml +++ b/scs-multiapi-maven-plugin/pom.xml @@ -4,7 +4,7 @@ com.sngular scs-multiapi-maven-plugin - 6.4.1 + 6.4.2 maven-plugin AsyncApi - OpenApi Code Generator Maven Plugin @@ -271,7 +271,7 @@ com.sngular multiapi-engine - 6.4.1 + 6.4.2 org.apache.maven