Skip to content

Commit 05ac448

Browse files
joseegarciaclaude
andcommitted
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#1 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1e36d51 commit 05ac448

11 files changed

Lines changed: 439 additions & 7 deletions

File tree

multiapi-engine/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
<groupId>com.sngular</groupId>
66
<artifactId>multiapi-engine</artifactId>
7-
<version>6.4.1</version>
7+
<version>6.4.2</version>
88
<packaging>jar</packaging>
99

1010
<properties>

multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/ApiTool.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,10 @@ public static boolean hasName(JsonNode message) {
262262
}
263263

264264
public static boolean hasRef(final JsonNode schema) {
265-
return (hasNode(schema, "$ref") || schema.fieldNames().hasNext()) && schema.fieldNames().next().equals("$ref");
265+
// OpenAPI 3.1 / JSON Schema 2020-12 allow sibling keywords next to `$ref`
266+
// (e.g. `description`), and their ordering is not significant. A node is a
267+
// reference whenever it declares a top-level `$ref`, regardless of position.
268+
return hasNode(schema, "$ref");
266269
}
267270

268271
public static boolean hasProperties(final JsonNode schema) {

multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorFixtures.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,13 @@ public final class OpenApiGeneratorFixtures {
175175
.clientPackage("com.sngular.multifileplugin.openapi31types.client")
176176
.modelNameSuffix("DTO").build());
177177

178+
static final List<SpecFile> TEST_REF_WITH_DESCRIPTION = List
179+
.of(SpecFile.builder().filePath("openapigenerator/testRefWithDescription/api-test.yml")
180+
.apiPackage("com.sngular.multifileplugin.refwithdescription")
181+
.modelPackage("com.sngular.multifileplugin.refwithdescription.model")
182+
.clientPackage("com.sngular.multifileplugin.refwithdescription.client")
183+
.modelNameSuffix("DTO").build());
184+
178185
static final List<SpecFile> TEST_EXTERNAL_PATH_ITEM_REF_GENERATION = List
179186
.of(SpecFile.builder().filePath("openapigenerator/testExternalPathItemRefsGeneration/api-test.yml")
180187
.apiPackage("com.sngular.multifileplugin.externalpathitemref")
@@ -884,6 +891,25 @@ static Function<Path, Boolean> validateOpenApi31Types() {
884891
DEFAULT_MODEL_API, Collections.emptyList(), null);
885892
}
886893

894+
static Function<Path, Boolean> validateRefWithDescription() {
895+
896+
final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/refwithdescription";
897+
898+
final String DEFAULT_MODEL_API = "generated/com/sngular/multifileplugin/refwithdescription/model";
899+
900+
final String COMMON_PATH = "openapigenerator/testRefWithDescription/";
901+
902+
final String ASSETS_PATH = COMMON_PATH + "assets/";
903+
904+
final List<String> expectedTestApiFile = List.of(ASSETS_PATH + "OrderApi.java");
905+
906+
final List<String> expectedTestApiModelFiles = List.of(ASSETS_PATH + "AddressDTO.java",
907+
ASSETS_PATH + "CustomerDTO.java", ASSETS_PATH + "OrderDTO.java");
908+
909+
return path -> commonTest(path, expectedTestApiFile, expectedTestApiModelFiles, DEFAULT_TARGET_API,
910+
DEFAULT_MODEL_API, Collections.emptyList(), null);
911+
}
912+
887913
static Function<Path, Boolean> validateExternalPathItemRefGeneration() {
888914

889915
final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/externalpathitemref";

multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorTest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ static Stream<Arguments> fileSpecToProcess() {
8585
OpenApiGeneratorFixtures.validateExternalRefGeneration()),
8686
Arguments.of("testOpenApi31Types", OpenApiGeneratorFixtures.TEST_OPEN_API_31_TYPES,
8787
OpenApiGeneratorFixtures.validateOpenApi31Types()),
88+
Arguments.of("testRefWithDescription", OpenApiGeneratorFixtures.TEST_REF_WITH_DESCRIPTION,
89+
OpenApiGeneratorFixtures.validateRefWithDescription()),
8890
Arguments.of("testWebhooks", OpenApiGeneratorFixtures.TEST_WEBHOOKS,
8991
OpenApiGeneratorFixtures.validateWebhooks()),
9092
Arguments.of("testOpenApi31Union", OpenApiGeneratorFixtures.TEST_OPEN_API_31_UNION,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
# Regression for OpenAPI 3.1 support: a `$ref` may carry sibling keywords such as
3+
# `description`. The generator must still resolve the reference regardless of the
4+
# relative ordering of `$ref` and its siblings (see issue: $ref + description).
5+
openapi: "3.1.0"
6+
info:
7+
version: 1.0.0
8+
title: Order API (OpenAPI 3.1)
9+
license:
10+
name: MIT
11+
servers:
12+
- url: http://localhost:8080/v1
13+
tags:
14+
- name: order
15+
paths:
16+
/order:
17+
get:
18+
summary: Get an order
19+
operationId: getOrder
20+
tags:
21+
- order
22+
responses:
23+
'200':
24+
description: The requested order
25+
content:
26+
application/json:
27+
schema:
28+
$ref: "#/components/schemas/Order"
29+
components:
30+
schemas:
31+
Order:
32+
type: object
33+
properties:
34+
id:
35+
type: string
36+
customer:
37+
description: The customer that placed the order
38+
$ref: "#/components/schemas/Customer"
39+
shippingAddress:
40+
$ref: "#/components/schemas/Address"
41+
description: Where the order is shipped
42+
Customer:
43+
type: object
44+
properties:
45+
name:
46+
type: string
47+
email:
48+
type: string
49+
Address:
50+
type: object
51+
properties:
52+
street:
53+
type: string
54+
city:
55+
type: string
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.sngular.multifileplugin.refwithdescription.model;
2+
3+
import java.util.Objects;
4+
5+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
6+
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
7+
import com.fasterxml.jackson.annotation.JsonProperty;
8+
import io.swagger.v3.oas.annotations.media.Schema;
9+
10+
@JsonDeserialize(builder = AddressDTO.AddressDTOBuilder.class)
11+
public class AddressDTO {
12+
13+
@JsonProperty(value ="city")
14+
private String city;
15+
@JsonProperty(value ="street")
16+
private String street;
17+
18+
private AddressDTO(AddressDTOBuilder builder) {
19+
this.city = builder.city;
20+
this.street = builder.street;
21+
22+
}
23+
24+
public static AddressDTO.AddressDTOBuilder builder() {
25+
return new AddressDTO.AddressDTOBuilder();
26+
}
27+
28+
@JsonPOJOBuilder(buildMethodName = "build", withPrefix = "")
29+
public static class AddressDTOBuilder {
30+
31+
private String city;
32+
private String street;
33+
34+
public AddressDTO.AddressDTOBuilder city(String city) {
35+
this.city = city;
36+
return this;
37+
}
38+
39+
public AddressDTO.AddressDTOBuilder street(String street) {
40+
this.street = street;
41+
return this;
42+
}
43+
44+
public AddressDTO build() {
45+
AddressDTO addressDTO = new AddressDTO(this);
46+
return addressDTO;
47+
}
48+
}
49+
50+
@Schema(name = "city", required = false)
51+
public String getCity() {
52+
return city;
53+
}
54+
public void setCity(String city) {
55+
this.city = city;
56+
}
57+
58+
@Schema(name = "street", required = false)
59+
public String getStreet() {
60+
return street;
61+
}
62+
public void setStreet(String street) {
63+
this.street = street;
64+
}
65+
66+
@Override
67+
public boolean equals(Object o) {
68+
if (this == o) {
69+
return true;
70+
}
71+
if (o == null || getClass() != o.getClass()) {
72+
return false;
73+
}
74+
AddressDTO addressDTO = (AddressDTO) o;
75+
return Objects.equals(this.city, addressDTO.city) && Objects.equals(this.street, addressDTO.street);
76+
}
77+
78+
@Override
79+
public int hashCode() {
80+
return Objects.hash(city, street);
81+
}
82+
83+
@Override
84+
public String toString() {
85+
StringBuilder sb = new StringBuilder();
86+
sb.append("AddressDTO{");
87+
sb.append(" city:").append(city).append(",");
88+
sb.append(" street:").append(street);
89+
sb.append("}");
90+
return sb.toString();
91+
}
92+
93+
94+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.sngular.multifileplugin.refwithdescription.model;
2+
3+
import java.util.Objects;
4+
5+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
6+
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
7+
import com.fasterxml.jackson.annotation.JsonProperty;
8+
import io.swagger.v3.oas.annotations.media.Schema;
9+
10+
@JsonDeserialize(builder = CustomerDTO.CustomerDTOBuilder.class)
11+
public class CustomerDTO {
12+
13+
@JsonProperty(value ="name")
14+
private String name;
15+
@JsonProperty(value ="email")
16+
private String email;
17+
18+
private CustomerDTO(CustomerDTOBuilder builder) {
19+
this.name = builder.name;
20+
this.email = builder.email;
21+
22+
}
23+
24+
public static CustomerDTO.CustomerDTOBuilder builder() {
25+
return new CustomerDTO.CustomerDTOBuilder();
26+
}
27+
28+
@JsonPOJOBuilder(buildMethodName = "build", withPrefix = "")
29+
public static class CustomerDTOBuilder {
30+
31+
private String name;
32+
private String email;
33+
34+
public CustomerDTO.CustomerDTOBuilder name(String name) {
35+
this.name = name;
36+
return this;
37+
}
38+
39+
public CustomerDTO.CustomerDTOBuilder email(String email) {
40+
this.email = email;
41+
return this;
42+
}
43+
44+
public CustomerDTO build() {
45+
CustomerDTO customerDTO = new CustomerDTO(this);
46+
return customerDTO;
47+
}
48+
}
49+
50+
@Schema(name = "name", required = false)
51+
public String getName() {
52+
return name;
53+
}
54+
public void setName(String name) {
55+
this.name = name;
56+
}
57+
58+
@Schema(name = "email", required = false)
59+
public String getEmail() {
60+
return email;
61+
}
62+
public void setEmail(String email) {
63+
this.email = email;
64+
}
65+
66+
@Override
67+
public boolean equals(Object o) {
68+
if (this == o) {
69+
return true;
70+
}
71+
if (o == null || getClass() != o.getClass()) {
72+
return false;
73+
}
74+
CustomerDTO customerDTO = (CustomerDTO) o;
75+
return Objects.equals(this.name, customerDTO.name) && Objects.equals(this.email, customerDTO.email);
76+
}
77+
78+
@Override
79+
public int hashCode() {
80+
return Objects.hash(name, email);
81+
}
82+
83+
@Override
84+
public String toString() {
85+
StringBuilder sb = new StringBuilder();
86+
sb.append("CustomerDTO{");
87+
sb.append(" name:").append(name).append(",");
88+
sb.append(" email:").append(email);
89+
sb.append("}");
90+
return sb.toString();
91+
}
92+
93+
94+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.sngular.multifileplugin.refwithdescription;
2+
3+
import java.util.Optional;
4+
import java.util.List;
5+
import java.util.Map;
6+
import javax.validation.Valid;
7+
8+
import io.swagger.v3.oas.annotations.Operation;
9+
import io.swagger.v3.oas.annotations.Parameter;
10+
import io.swagger.v3.oas.annotations.media.Content;
11+
import io.swagger.v3.oas.annotations.media.Schema;
12+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
13+
import org.springframework.http.MediaType;
14+
import org.springframework.http.HttpStatus;
15+
import org.springframework.http.ResponseEntity;
16+
import org.springframework.web.bind.annotation.*;
17+
import org.springframework.web.context.request.NativeWebRequest;
18+
19+
import com.sngular.multifileplugin.refwithdescription.model.OrderDTO;
20+
21+
public interface OrderApi {
22+
23+
/**
24+
* GET /order: Get an order
25+
* @return The requested order; (status code 200)
26+
*/
27+
28+
@Operation(
29+
operationId = "getOrder",
30+
summary = "Get an order",
31+
tags = {"order"},
32+
responses = {
33+
@ApiResponse(responseCode = "200", description = "The requested order", content = @Content(mediaType = "application/json", schema = @Schema(implementation = OrderDTO.class)))
34+
}
35+
)
36+
@RequestMapping(
37+
method = RequestMethod.GET,
38+
value = "/order",
39+
produces = {"application/json"}
40+
)
41+
42+
default ResponseEntity<OrderDTO> getOrder() {
43+
return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
44+
}
45+
46+
}

0 commit comments

Comments
 (0)