From 4cf6aa8b11135ccb57989fb5c0483758aa037ba3 Mon Sep 17 00:00:00 2001 From: joseegarcia Date: Wed, 22 Jul 2026 15:44:04 +0200 Subject: [PATCH 1/6] Fix #375: support OpenAPI 3.1 array-valued type (unions & nullable) OpenAPI 3.1 / JSON Schema 2020-12 allows a schema's `type` to be an array, e.g. type: ["string", "null"] (the nullable idiom that replaces 3.0's `nullable: true`, and general unions). ApiTool.getType() read the type via JsonNode.textValue(), which returns null for an array node and collapsed to "", so every isObject/isArray/isString/isNumber/isDateTime predicate and MapperUtil.getSimpleType failed -> the generator silently produced wrong or empty types for those fields. - ApiTool.getType: when `type` is an array, resolve to the first non-"null" entry ("null" only marks the type as nullable). All the isX predicates and getSimpleType then work unchanged for union/nullable types. - MapperUtil.processNumber: read the type via ApiTool.getType instead of schema.get("type").asText(), so array-valued numeric types resolve. - Add TypeConstants.NULL. - Add a 3.1.0 regression fixture (testOpenApi31Types) covering ["string", "null"], ["integer","null"], ["number","null"]+double, int64 and a plain array, with golden assets. Bumps version 6.3.2 -> 6.4.0 across the engine, maven and gradle modules. Non-array `type` handling is unchanged, so existing 3.0 specs are unaffected. Follow-up 3.1 items (examples array, $ref siblings, webhooks) tracked in #375. Co-Authored-By: Claude Opus 4.8 (1M context) --- multiapi-engine/pom.xml | 2 +- .../plugin/common/model/TypeConstants.java | 2 + .../plugin/common/tools/ApiTool.java | 18 +- .../plugin/common/tools/MapperUtil.java | 2 +- .../openapi/OpenApiGeneratorFixtures.java | 25 +++ .../plugin/openapi/OpenApiGeneratorTest.java | 2 + .../testOpenApi31Types/api-test.yml | 49 +++++ .../testOpenApi31Types/assets/ProfileApi.java | 46 +++++ .../testOpenApi31Types/assets/ProfileDTO.java | 177 ++++++++++++++++++ scs-multiapi-gradle-plugin/build.gradle | 4 +- scs-multiapi-maven-plugin/pom.xml | 4 +- 11 files changed, 324 insertions(+), 7 deletions(-) create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Types/api-test.yml create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Types/assets/ProfileApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Types/assets/ProfileDTO.java diff --git a/multiapi-engine/pom.xml b/multiapi-engine/pom.xml index a65104c6..ef7ec3f6 100644 --- a/multiapi-engine/pom.xml +++ b/multiapi-engine/pom.xml @@ -4,7 +4,7 @@ com.sngular multiapi-engine - 6.3.2 + 6.4.0 jar diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/model/TypeConstants.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/model/TypeConstants.java index a4fd82a7..7b98ed51 100644 --- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/model/TypeConstants.java +++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/model/TypeConstants.java @@ -18,6 +18,8 @@ public final class TypeConstants { public static final String ARRAY = "array"; + public static final String NULL = "null"; + public static final String MAP = "map"; public static final String BIG_DECIMAL = "bigDecimal"; 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 11333ecf..c271dc9e 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 @@ -220,7 +220,23 @@ public static boolean hasType(final JsonNode schema) { } public static String getType(final JsonNode schema) { - return hasType(schema) ? StringUtils.defaultIfEmpty(getNodeAsString(schema, "type"), "") : ""; + if (!hasType(schema)) { + return ""; + } + final JsonNode typeNode = getNode(schema, "type"); + if (typeNode.isArray()) { + // OpenAPI 3.1 / JSON Schema 2020-12: "type" may be an array (e.g. ["string", "null"]). + // Resolve to the first non-"null" entry; "null" only marks the type as nullable. + String resolvedType = ""; + for (final JsonNode element : typeNode) { + if (!TypeConstants.NULL.equalsIgnoreCase(element.asText())) { + resolvedType = element.asText(); + break; + } + } + return resolvedType; + } + return StringUtils.defaultIfEmpty(typeNode.textValue(), ""); } public static boolean hasItems(final JsonNode schema) { diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/MapperUtil.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/MapperUtil.java index b7734089..0850c200 100644 --- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/MapperUtil.java +++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/MapperUtil.java @@ -50,7 +50,7 @@ private static boolean checkIfNumber(final String nodeType) { private static String processNumber(final JsonNode schema) { - final var nodeType = schema.get("type").asText(); + final var nodeType = ApiTool.getType(schema); final var formatType = schema.has("format") ? schema.get("format").asText() : null; String type = TypeConstants.INTEGER; if (TypeConstants.NUMBER.equalsIgnoreCase(nodeType)) { 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 189b8356..5e7f0b68 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 @@ -147,6 +147,13 @@ public final class OpenApiGeneratorFixtures { .clientPackage("com.sngular.multifileplugin.externalref.client").modelNamePrefix("Api") .modelNameSuffix("DTO").build()); + static final List TEST_OPEN_API_31_TYPES = List + .of(SpecFile.builder().filePath("openapigenerator/testOpenApi31Types/api-test.yml") + .apiPackage("com.sngular.multifileplugin.openapi31types") + .modelPackage("com.sngular.multifileplugin.openapi31types.model") + .clientPackage("com.sngular.multifileplugin.openapi31types.client") + .modelNameSuffix("DTO").build()); + static final List TEST_ANY_OF_IN_RESPONSE = List .of(SpecFile.builder().filePath("openapigenerator/testAnyOfInResponse/api-test.yml") .apiPackage("com.sngular.multifileplugin.testanyofinresponse") @@ -763,6 +770,24 @@ static Function validateExternalRefGeneration() { DEFAULT_MODEL_API, expectedExceptionFiles, DEFAULT_EXCEPTION_API); } + static Function validateOpenApi31Types() { + + final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/openapi31types"; + + final String DEFAULT_MODEL_API = "generated/com/sngular/multifileplugin/openapi31types/model"; + + final String COMMON_PATH = "openapigenerator/testOpenApi31Types/"; + + final String ASSETS_PATH = COMMON_PATH + "assets/"; + + final List expectedTestApiFile = List.of(ASSETS_PATH + "ProfileApi.java"); + + final List expectedTestApiModelFiles = List.of(ASSETS_PATH + "ProfileDTO.java"); + + return path -> commonTest(path, expectedTestApiFile, expectedTestApiModelFiles, DEFAULT_TARGET_API, + DEFAULT_MODEL_API, Collections.emptyList(), null); + } + static Function validateAnyOfInResponse() { final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/testanyofinresponse"; 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 2a750813..4bb3236c 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 @@ -83,6 +83,8 @@ static Stream fileSpecToProcess() { OpenApiGeneratorFixtures.validateEnumsLombokGeneration()), Arguments.of("testExternalRefsGeneration", OpenApiGeneratorFixtures.TEST_EXTERNAL_REF_GENERATION, OpenApiGeneratorFixtures.validateExternalRefGeneration()), + Arguments.of("testOpenApi31Types", OpenApiGeneratorFixtures.TEST_OPEN_API_31_TYPES, + OpenApiGeneratorFixtures.validateOpenApi31Types()), Arguments.of("testAnyOfInResponse", OpenApiGeneratorFixtures.TEST_ANY_OF_IN_RESPONSE, OpenApiGeneratorFixtures.validateAnyOfInResponse()), Arguments.of("testOneOfInResponse", OpenApiGeneratorFixtures.TEST_ONE_OF_IN_RESPONSE, diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Types/api-test.yml b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Types/api-test.yml new file mode 100644 index 00000000..352d08d2 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Types/api-test.yml @@ -0,0 +1,49 @@ +--- +# Reproduction/regression for OpenAPI 3.1.x support (issue #375). +# Exercises JSON Schema 2020-12 array-valued `type`, including the ["", "null"] +# nullable idiom that replaces 3.0's `nullable: true`, plus a int64 and a plain array. +openapi: "3.1.0" +info: + version: 1.0.0 + title: Profile API (OpenAPI 3.1) + license: + name: MIT +servers: + - url: http://localhost:8080/v1 +tags: + - name: profile +paths: + /profile: + get: + summary: Get the profile + operationId: getProfile + tags: + - profile + responses: + '200': + description: The requested profile + content: + application/json: + schema: + $ref: "#/components/schemas/Profile" +components: + schemas: + Profile: + type: object + properties: + id: + type: string + nickname: + type: ["string", "null"] + age: + type: ["integer", "null"] + score: + type: ["number", "null"] + format: double + loginCount: + type: integer + format: int64 + tags: + type: array + items: + type: string diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Types/assets/ProfileApi.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Types/assets/ProfileApi.java new file mode 100644 index 00000000..549f239b --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Types/assets/ProfileApi.java @@ -0,0 +1,46 @@ +package com.sngular.multifileplugin.openapi31types; + +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.openapi31types.model.ProfileDTO; + +public interface ProfileApi { + + /** + * GET /profile: Get the profile + * @return The requested profile; (status code 200) + */ + + @Operation( + operationId = "getProfile", + summary = "Get the profile", + tags = {"profile"}, + responses = { + @ApiResponse(responseCode = "200", description = "The requested profile", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProfileDTO.class))) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/profile", + produces = {"application/json"} + ) + + default ResponseEntity getProfile() { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Types/assets/ProfileDTO.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Types/assets/ProfileDTO.java new file mode 100644 index 00000000..7a0671d0 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Types/assets/ProfileDTO.java @@ -0,0 +1,177 @@ +package com.sngular.multifileplugin.openapi31types.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; +import java.util.List; +import java.util.ArrayList; + +@JsonDeserialize(builder = ProfileDTO.ProfileDTOBuilder.class) +public class ProfileDTO { + + @JsonProperty(value ="tags") + private List tags; + @JsonProperty(value ="id") + private String id; + @JsonProperty(value ="loginCount") + private Long loginCount; + @JsonProperty(value ="age") + private Integer age; + @JsonProperty(value ="score") + private Double score; + @JsonProperty(value ="nickname") + private String nickname; + + private ProfileDTO(ProfileDTOBuilder builder) { + this.tags = builder.tags; + this.id = builder.id; + this.loginCount = builder.loginCount; + this.age = builder.age; + this.score = builder.score; + this.nickname = builder.nickname; + + } + + public static ProfileDTO.ProfileDTOBuilder builder() { + return new ProfileDTO.ProfileDTOBuilder(); + } + + @JsonPOJOBuilder(buildMethodName = "build", withPrefix = "") + public static class ProfileDTOBuilder { + + private List tags = new ArrayList(); + private String id; + private Long loginCount; + private Integer age; + private Double score; + private String nickname; + + public ProfileDTO.ProfileDTOBuilder tags(List tags) { + if (!tags.isEmpty()) { + this.tags.addAll(tags); + } + return this; + } + + public ProfileDTO.ProfileDTOBuilder tag(String tag) { + if (Objects.nonNull(tag)) { + this.tags.add(tag); + } + return this; + } + + public ProfileDTO.ProfileDTOBuilder id(String id) { + this.id = id; + return this; + } + + public ProfileDTO.ProfileDTOBuilder loginCount(Long loginCount) { + this.loginCount = loginCount; + return this; + } + + public ProfileDTO.ProfileDTOBuilder age(Integer age) { + this.age = age; + return this; + } + + public ProfileDTO.ProfileDTOBuilder score(Double score) { + this.score = score; + return this; + } + + public ProfileDTO.ProfileDTOBuilder nickname(String nickname) { + this.nickname = nickname; + return this; + } + + public ProfileDTO build() { + ProfileDTO profileDTO = new ProfileDTO(this); + return profileDTO; + } + } + + @Schema(name = "tags", required = false) + public List getTags() { + return tags; + } + public void setTags(List tags) { + this.tags = tags; + } + + @Schema(name = "id", required = false) + public String getId() { + return id; + } + public void setId(String id) { + this.id = id; + } + + @Schema(name = "loginCount", required = false) + public Long getLoginCount() { + return loginCount; + } + public void setLoginCount(Long loginCount) { + this.loginCount = loginCount; + } + + @Schema(name = "age", required = false) + public Integer getAge() { + return age; + } + public void setAge(Integer age) { + this.age = age; + } + + @Schema(name = "score", required = false) + public Double getScore() { + return score; + } + public void setScore(Double score) { + this.score = score; + } + + @Schema(name = "nickname", required = false) + public String getNickname() { + return nickname; + } + public void setNickname(String nickname) { + this.nickname = nickname; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ProfileDTO profileDTO = (ProfileDTO) o; + return Objects.equals(this.tags, profileDTO.tags) && Objects.equals(this.id, profileDTO.id) && Objects.equals(this.loginCount, profileDTO.loginCount) && Objects.equals(this.age, profileDTO.age) && Objects.equals(this.score, profileDTO.score) && Objects.equals(this.nickname, profileDTO.nickname); + } + + @Override + public int hashCode() { + return Objects.hash(tags, id, loginCount, age, score, nickname); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("ProfileDTO{"); + sb.append(" tags:").append(tags).append(","); + sb.append(" id:").append(id).append(","); + sb.append(" loginCount:").append(loginCount).append(","); + sb.append(" age:").append(age).append(","); + sb.append(" score:").append(score).append(","); + sb.append(" nickname:").append(nickname); + sb.append("}"); + return sb.toString(); + } + + +} diff --git a/scs-multiapi-gradle-plugin/build.gradle b/scs-multiapi-gradle-plugin/build.gradle index ab429600..9205af3b 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.3.2' +version = '6.4.0' def SCSMultiApiPluginGroupId = group def SCSMultiApiPluginVersion = version @@ -31,7 +31,7 @@ dependencies { shadow localGroovy() shadow gradleApi() - implementation 'com.sngular:multiapi-engine:6.3.2' + implementation 'com.sngular:multiapi-engine:6.4.0' 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' diff --git a/scs-multiapi-maven-plugin/pom.xml b/scs-multiapi-maven-plugin/pom.xml index eb28326d..b9e8fd8b 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.3.2 + 6.4.0 maven-plugin AsyncApi - OpenApi Code Generator Maven Plugin @@ -271,7 +271,7 @@ com.sngular multiapi-engine - 6.3.2 + 6.4.0 org.apache.maven From c6a4ec1a0ad6797b59709bd7a1477d684ee741fb Mon Sep 17 00:00:00 2001 From: joseegarcia Date: Wed, 22 Jul 2026 15:49:31 +0200 Subject: [PATCH 2/6] Support OpenAPI 3.1 top-level webhooks (#375) OpenAPI 3.1 adds a top-level `webhooks` object: a map of named Path Item Objects describing out-of-band requests. The generator only iterated `paths`, so webhooks were ignored - no handler interface, no payload models. Add OpenApiUtil.mergeWebhooksIntoPaths, invoked right after parsing in OpenApiGenerator.processFile. Each webhook (keyed by name) is merged into `paths` under a "/"-prefixed key so the existing pipeline generates a handler interface for its operations and the request/response payload models. `paths` is created if the contract has none, and existing `paths` entries are never overwritten. Adds a 3.1.0 webhooks-only regression fixture (testWebhooks) with golden assets (NewPetApi + PetDTO). Stacked on the 6.4.0 array-type work (#376); both are part of the 3.1 support milestone, so the version stays 6.4.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plugin/openapi/OpenApiGenerator.java | 1 + .../plugin/openapi/utils/OpenApiUtil.java | 27 ++++++ .../openapi/OpenApiGeneratorFixtures.java | 25 +++++ .../plugin/openapi/OpenApiGeneratorTest.java | 2 + .../testWebhooks/api-test.yml | 40 ++++++++ .../testWebhooks/assets/NewPetApi.java | 47 ++++++++++ .../testWebhooks/assets/PetDTO.java | 94 +++++++++++++++++++ 7 files changed, 236 insertions(+) create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testWebhooks/api-test.yml create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testWebhooks/assets/NewPetApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testWebhooks/assets/PetDTO.java diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/OpenApiGenerator.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/OpenApiGenerator.java index 9177c273..67048656 100644 --- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/OpenApiGenerator.java +++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/OpenApiGenerator.java @@ -98,6 +98,7 @@ private void processPackage(final String apiPackage) { private void processFile(final SpecFile specFile) { final JsonNode openAPI = OpenApiUtil.getPojoFromSpecFile(baseDir, specFile); + OpenApiUtil.mergeWebhooksIntoPaths(openAPI); final String clientPackage = specFile.getClientPackage(); if (specFile.isCallMode()) { diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/utils/OpenApiUtil.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/utils/OpenApiUtil.java index c5690e71..75fc89cc 100644 --- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/utils/OpenApiUtil.java +++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/utils/OpenApiUtil.java @@ -34,6 +34,8 @@ public class OpenApiUtil { public static final String PATHS = "paths"; + public static final String WEBHOOKS = "webhooks"; + static final Set REST_VERB_SET = Set.of("get", "post", "delete", "patch", "put"); private OpenApiUtil() { @@ -91,6 +93,31 @@ public static JsonNode getPojoFromSpecFile(final Path baseDir, final SpecFile sp return SchemaUtil.getPojoFromRef(baseDir.toUri(), specFile.getFilePath()); } + /** + * Merges the OpenAPI 3.1 top-level {@code webhooks} object into {@code paths} so the existing + * path pipeline generates a handler interface and the request/response payload models for each + * webhook. Each webhook is a Path Item Object keyed by name; it is added under a {@code "/"}- + * prefixed key (webhooks have no URL) so the by-url grouping treats the webhook name as the + * endpoint. Existing {@code paths} entries take precedence and are never overwritten. + * + * @param openApi the parsed root contract; its {@code paths} node is created/extended in place. + */ + public static void mergeWebhooksIntoPaths(final JsonNode openApi) { + final JsonNode webhooks = openApi.get(WEBHOOKS); + if (webhooks instanceof ObjectNode && openApi instanceof ObjectNode) { + final ObjectNode root = (ObjectNode) openApi; + final ObjectNode paths = root.has(PATHS) && root.get(PATHS).isObject() + ? (ObjectNode) root.get(PATHS) + : root.putObject(PATHS); + webhooks.fields().forEachRemaining(webhook -> { + final String pathKey = webhook.getKey().startsWith("/") ? webhook.getKey() : "/" + webhook.getKey(); + if (!paths.has(pathKey)) { + paths.set(pathKey, webhook.getValue()); + } + }); + } + } + public static Map processPaths(final JsonNode openApi, final Map schemaMap, SpecFile specFile) { final var basicJsonNodeMap = new HashMap<>(schemaMap); 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 5e7f0b68..abc2afb2 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 @@ -147,6 +147,13 @@ public final class OpenApiGeneratorFixtures { .clientPackage("com.sngular.multifileplugin.externalref.client").modelNamePrefix("Api") .modelNameSuffix("DTO").build()); + static final List TEST_WEBHOOKS = List + .of(SpecFile.builder().filePath("openapigenerator/testWebhooks/api-test.yml") + .apiPackage("com.sngular.multifileplugin.webhooks") + .modelPackage("com.sngular.multifileplugin.webhooks.model") + .clientPackage("com.sngular.multifileplugin.webhooks.client") + .modelNameSuffix("DTO").build()); + static final List TEST_OPEN_API_31_TYPES = List .of(SpecFile.builder().filePath("openapigenerator/testOpenApi31Types/api-test.yml") .apiPackage("com.sngular.multifileplugin.openapi31types") @@ -770,6 +777,24 @@ static Function validateExternalRefGeneration() { DEFAULT_MODEL_API, expectedExceptionFiles, DEFAULT_EXCEPTION_API); } + static Function validateWebhooks() { + + final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/webhooks"; + + final String DEFAULT_MODEL_API = "generated/com/sngular/multifileplugin/webhooks/model"; + + final String COMMON_PATH = "openapigenerator/testWebhooks/"; + + final String ASSETS_PATH = COMMON_PATH + "assets/"; + + final List expectedTestApiFile = List.of(ASSETS_PATH + "NewPetApi.java"); + + final List expectedTestApiModelFiles = List.of(ASSETS_PATH + "PetDTO.java"); + + return path -> commonTest(path, expectedTestApiFile, expectedTestApiModelFiles, DEFAULT_TARGET_API, + DEFAULT_MODEL_API, Collections.emptyList(), null); + } + static Function validateOpenApi31Types() { final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/openapi31types"; 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 4bb3236c..a6380ccd 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("testWebhooks", OpenApiGeneratorFixtures.TEST_WEBHOOKS, + OpenApiGeneratorFixtures.validateWebhooks()), Arguments.of("testAnyOfInResponse", OpenApiGeneratorFixtures.TEST_ANY_OF_IN_RESPONSE, OpenApiGeneratorFixtures.validateAnyOfInResponse()), Arguments.of("testOneOfInResponse", OpenApiGeneratorFixtures.TEST_ONE_OF_IN_RESPONSE, diff --git a/multiapi-engine/src/test/resources/openapigenerator/testWebhooks/api-test.yml b/multiapi-engine/src/test/resources/openapigenerator/testWebhooks/api-test.yml new file mode 100644 index 00000000..25a107f5 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testWebhooks/api-test.yml @@ -0,0 +1,40 @@ +--- +# Regression for OpenAPI 3.1 top-level `webhooks` support (issue #375). +# A webhooks-only contract (no `paths`): each webhook is a Path Item Object keyed by +# name. The generator merges these into the path pipeline so a handler interface and the +# request payload model are generated. +openapi: "3.1.0" +info: + version: 1.0.0 + title: Webhooks API (OpenAPI 3.1) + license: + name: MIT +servers: + - url: http://localhost:8080/v1 +tags: + - name: webhook +webhooks: + newPet: + post: + summary: New pet notification + operationId: newPetWebhook + tags: + - webhook + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + responses: + '200': + description: Notification acknowledged +components: + schemas: + Pet: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string diff --git a/multiapi-engine/src/test/resources/openapigenerator/testWebhooks/assets/NewPetApi.java b/multiapi-engine/src/test/resources/openapigenerator/testWebhooks/assets/NewPetApi.java new file mode 100644 index 00000000..f41020fe --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testWebhooks/assets/NewPetApi.java @@ -0,0 +1,47 @@ +package com.sngular.multifileplugin.webhooks; + +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.webhooks.model.PetDTO; + +public interface NewPetApi { + + /** + * POST /newPet: New pet notification + * @param petDTO + * @return Notification acknowledged; (status code 200) + */ + + @Operation( + operationId = "newPetWebhook", + summary = "New pet notification", + tags = {"webhook"}, + responses = { + @ApiResponse(responseCode = "200", description = "Notification acknowledged") + } + ) + @RequestMapping( + method = RequestMethod.POST, + value = "/newPet", + produces = {"application/json"} + ) + + default ResponseEntity newPetWebhook(@Parameter(name = "petDTO", description = "", required = false, schema = @Schema(description = "")) @Valid @RequestBody PetDTO petDTO) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testWebhooks/assets/PetDTO.java b/multiapi-engine/src/test/resources/openapigenerator/testWebhooks/assets/PetDTO.java new file mode 100644 index 00000000..39e40029 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testWebhooks/assets/PetDTO.java @@ -0,0 +1,94 @@ +package com.sngular.multifileplugin.webhooks.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 = PetDTO.PetDTOBuilder.class) +public class PetDTO { + + @JsonProperty(value ="name") + private String name; + @JsonProperty(value ="id") + private Long id; + + private PetDTO(PetDTOBuilder builder) { + this.name = builder.name; + this.id = builder.id; + + } + + public static PetDTO.PetDTOBuilder builder() { + return new PetDTO.PetDTOBuilder(); + } + + @JsonPOJOBuilder(buildMethodName = "build", withPrefix = "") + public static class PetDTOBuilder { + + private String name; + private Long id; + + public PetDTO.PetDTOBuilder name(String name) { + this.name = name; + return this; + } + + public PetDTO.PetDTOBuilder id(Long id) { + this.id = id; + return this; + } + + public PetDTO build() { + PetDTO petDTO = new PetDTO(this); + return petDTO; + } + } + + @Schema(name = "name", required = false) + public String getName() { + return name; + } + public void setName(String name) { + this.name = name; + } + + @Schema(name = "id", required = false) + public Long getId() { + return id; + } + public void setId(Long id) { + this.id = id; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PetDTO petDTO = (PetDTO) o; + return Objects.equals(this.name, petDTO.name) && Objects.equals(this.id, petDTO.id); + } + + @Override + public int hashCode() { + return Objects.hash(name, id); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("PetDTO{"); + sb.append(" name:").append(name).append(","); + sb.append(" id:").append(id); + sb.append("}"); + return sb.toString(); + } + + +} From beef8a1cb523f8ec12911538f05981924814d1d1 Mon Sep 17 00:00:00 2001 From: joseegarcia Date: Wed, 22 Jul 2026 16:12:16 +0200 Subject: [PATCH 3/6] Default tags on tag-less webhook operations to avoid NPE Webhook operations normally omit `tags`, but createOperation requires one (Objects.requireNonNull), so a realistic tag-less webhook crashed with a NullPointerException in the default by-url grouping mode - and was silently skipped in tag-grouping mode. The prior test masked this by giving the webhook explicit tags. mergeWebhooksIntoPaths now defaults each webhook operation's tags to the webhook name when absent/empty, and skips blank keys (guards the pathUrl.split()[1] grouping). The testWebhooks fixture is updated to the realistic tag-less shape to cover this. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plugin/openapi/utils/OpenApiUtil.java | 25 +++++++++++++++++-- .../testWebhooks/api-test.yml | 4 +-- .../testWebhooks/assets/NewPetApi.java | 2 +- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/utils/OpenApiUtil.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/utils/OpenApiUtil.java index 75fc89cc..c496a112 100644 --- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/utils/OpenApiUtil.java +++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/utils/OpenApiUtil.java @@ -110,14 +110,35 @@ public static void mergeWebhooksIntoPaths(final JsonNode openApi) { ? (ObjectNode) root.get(PATHS) : root.putObject(PATHS); webhooks.fields().forEachRemaining(webhook -> { - final String pathKey = webhook.getKey().startsWith("/") ? webhook.getKey() : "/" + webhook.getKey(); - if (!paths.has(pathKey)) { + final String webhookName = webhook.getKey(); + final String pathKey = webhookName.startsWith("/") ? webhookName : "/" + webhookName; + // A leading-slash-only key would break the by-url grouping (pathUrl.split("/")[1]). + if (StringUtils.isNotBlank(StringUtils.strip(webhookName, "/")) && !paths.has(pathKey)) { + defaultOperationTags(webhook.getValue(), StringUtils.strip(webhookName, "/")); paths.set(pathKey, webhook.getValue()); } }); } } + /** + * Ensures every operation of a webhook-derived Path Item carries a {@code tags} entry. Webhook + * operations normally omit {@code tags}, but the path pipeline requires one; a missing/empty + * {@code tags} is defaulted to the webhook name so generation works in both grouping modes. + */ + private static void defaultOperationTags(final JsonNode pathItem, final String defaultTag) { + if (pathItem instanceof ObjectNode) { + pathItem.fields().forEachRemaining(field -> { + if (REST_VERB_SET.contains(field.getKey()) && field.getValue() instanceof ObjectNode) { + final ObjectNode operation = (ObjectNode) field.getValue(); + if (!ApiTool.hasNode(operation, "tags") || !operation.get("tags").isArray() || operation.get("tags").isEmpty()) { + operation.putArray("tags").add(defaultTag); + } + } + }); + } + } + public static Map processPaths(final JsonNode openApi, final Map schemaMap, SpecFile specFile) { final var basicJsonNodeMap = new HashMap<>(schemaMap); diff --git a/multiapi-engine/src/test/resources/openapigenerator/testWebhooks/api-test.yml b/multiapi-engine/src/test/resources/openapigenerator/testWebhooks/api-test.yml index 25a107f5..dfdf1ea5 100644 --- a/multiapi-engine/src/test/resources/openapigenerator/testWebhooks/api-test.yml +++ b/multiapi-engine/src/test/resources/openapigenerator/testWebhooks/api-test.yml @@ -18,8 +18,8 @@ webhooks: post: summary: New pet notification operationId: newPetWebhook - tags: - - webhook + # No `tags`: webhook operations normally omit them. The merge defaults the tag + # to the webhook name so generation works in the default (by-url) grouping mode. requestBody: content: application/json: diff --git a/multiapi-engine/src/test/resources/openapigenerator/testWebhooks/assets/NewPetApi.java b/multiapi-engine/src/test/resources/openapigenerator/testWebhooks/assets/NewPetApi.java index f41020fe..99ee3f07 100644 --- a/multiapi-engine/src/test/resources/openapigenerator/testWebhooks/assets/NewPetApi.java +++ b/multiapi-engine/src/test/resources/openapigenerator/testWebhooks/assets/NewPetApi.java @@ -29,7 +29,7 @@ public interface NewPetApi { @Operation( operationId = "newPetWebhook", summary = "New pet notification", - tags = {"webhook"}, + tags = {"newPet"}, responses = { @ApiResponse(responseCode = "200", description = "Notification acknowledged") } From d1d2f9b18b309e98e4490f08edf1cb302be3f9ce Mon Sep 17 00:00:00 2001 From: joseegarcia Date: Wed, 22 Jul 2026 16:20:22 +0200 Subject: [PATCH 4/6] Harden 3.1 type/webhook handling and add edge-case tests getType: extract array-type resolution into a helper. A genuine multi-type union (e.g. ["string","integer"]) now logs a warning and uses the first concrete type; a "null"-only/empty type array falls back to `object` instead of an empty type string. Adds edge-case regression fixtures: - testOpenApi31Union: union -> String (+warning), ["null"] -> Object - testWebhookPathCollision: webhook name colliding with an existing path is dropped (existing paths take precedence). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plugin/common/tools/ApiTool.java | 32 +++-- .../openapi/OpenApiGeneratorFixtures.java | 50 ++++++++ .../plugin/openapi/OpenApiGeneratorTest.java | 4 + .../testOpenApi31Union/api-test.yml | 40 +++++++ .../testOpenApi31Union/assets/ItemApi.java | 46 +++++++ .../testOpenApi31Union/assets/ItemDTO.java | 112 ++++++++++++++++++ .../testWebhookPathCollision/api-test.yml | 51 ++++++++ .../assets/NewPetApi.java | 46 +++++++ .../assets/PetDTO.java | 94 +++++++++++++++ 9 files changed, 464 insertions(+), 11 deletions(-) create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Union/api-test.yml create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Union/assets/ItemApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Union/assets/ItemDTO.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testWebhookPathCollision/api-test.yml create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testWebhookPathCollision/assets/NewPetApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testWebhookPathCollision/assets/PetDTO.java 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 c271dc9e..f9aa1508 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 @@ -21,11 +21,13 @@ import com.sngular.api.generator.plugin.asyncapi.util.FactoryTypeEnum; import com.sngular.api.generator.plugin.common.files.FileLocation; import com.sngular.api.generator.plugin.common.model.TypeConstants; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.collections4.IteratorUtils; import org.apache.commons.collections4.Transformer; import org.apache.commons.lang3.StringUtils; +@Slf4j public final class ApiTool { public static final String FORMAT = "format"; @@ -224,19 +226,27 @@ public static String getType(final JsonNode schema) { return ""; } final JsonNode typeNode = getNode(schema, "type"); - if (typeNode.isArray()) { - // OpenAPI 3.1 / JSON Schema 2020-12: "type" may be an array (e.g. ["string", "null"]). - // Resolve to the first non-"null" entry; "null" only marks the type as nullable. - String resolvedType = ""; - for (final JsonNode element : typeNode) { - if (!TypeConstants.NULL.equalsIgnoreCase(element.asText())) { - resolvedType = element.asText(); - break; - } + return typeNode.isArray() ? getArrayType(typeNode) : StringUtils.defaultIfEmpty(typeNode.textValue(), ""); + } + + private static String getArrayType(final JsonNode typeNode) { + // OpenAPI 3.1 / JSON Schema 2020-12: "type" may be an array (e.g. ["string", "null"]). + // "null" only marks the type as nullable; keep the concrete (non-"null") types. + final List concreteTypes = new ArrayList<>(); + for (final JsonNode element : typeNode) { + if (!TypeConstants.NULL.equalsIgnoreCase(element.asText())) { + concreteTypes.add(element.asText()); } - return resolvedType; } - return StringUtils.defaultIfEmpty(typeNode.textValue(), ""); + if (concreteTypes.isEmpty()) { + // Only "null" (or an empty array): no concrete type to generate, fall back to object. + return TypeConstants.OBJECT; + } + if (concreteTypes.size() > 1) { + log.warn("Schema declares a multi-type union {}; only '{}' is generated, the remaining types are ignored.", + concreteTypes, concreteTypes.get(0)); + } + return concreteTypes.get(0); } public static boolean hasItems(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 abc2afb2..781eee21 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 @@ -147,6 +147,20 @@ public final class OpenApiGeneratorFixtures { .clientPackage("com.sngular.multifileplugin.externalref.client").modelNamePrefix("Api") .modelNameSuffix("DTO").build()); + static final List TEST_OPEN_API_31_UNION = List + .of(SpecFile.builder().filePath("openapigenerator/testOpenApi31Union/api-test.yml") + .apiPackage("com.sngular.multifileplugin.openapi31union") + .modelPackage("com.sngular.multifileplugin.openapi31union.model") + .clientPackage("com.sngular.multifileplugin.openapi31union.client") + .modelNameSuffix("DTO").build()); + + static final List TEST_WEBHOOK_PATH_COLLISION = List + .of(SpecFile.builder().filePath("openapigenerator/testWebhookPathCollision/api-test.yml") + .apiPackage("com.sngular.multifileplugin.webhookpathcollision") + .modelPackage("com.sngular.multifileplugin.webhookpathcollision.model") + .clientPackage("com.sngular.multifileplugin.webhookpathcollision.client") + .modelNameSuffix("DTO").build()); + static final List TEST_WEBHOOKS = List .of(SpecFile.builder().filePath("openapigenerator/testWebhooks/api-test.yml") .apiPackage("com.sngular.multifileplugin.webhooks") @@ -777,6 +791,42 @@ static Function validateExternalRefGeneration() { DEFAULT_MODEL_API, expectedExceptionFiles, DEFAULT_EXCEPTION_API); } + static Function validateOpenApi31Union() { + + final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/openapi31union"; + + final String DEFAULT_MODEL_API = "generated/com/sngular/multifileplugin/openapi31union/model"; + + final String COMMON_PATH = "openapigenerator/testOpenApi31Union/"; + + final String ASSETS_PATH = COMMON_PATH + "assets/"; + + final List expectedTestApiFile = List.of(ASSETS_PATH + "ItemApi.java"); + + final List expectedTestApiModelFiles = List.of(ASSETS_PATH + "ItemDTO.java"); + + return path -> commonTest(path, expectedTestApiFile, expectedTestApiModelFiles, DEFAULT_TARGET_API, + DEFAULT_MODEL_API, Collections.emptyList(), null); + } + + static Function validateWebhookPathCollision() { + + final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/webhookpathcollision"; + + final String DEFAULT_MODEL_API = "generated/com/sngular/multifileplugin/webhookpathcollision/model"; + + final String COMMON_PATH = "openapigenerator/testWebhookPathCollision/"; + + final String ASSETS_PATH = COMMON_PATH + "assets/"; + + final List expectedTestApiFile = List.of(ASSETS_PATH + "NewPetApi.java"); + + final List expectedTestApiModelFiles = List.of(ASSETS_PATH + "PetDTO.java"); + + return path -> commonTest(path, expectedTestApiFile, expectedTestApiModelFiles, DEFAULT_TARGET_API, + DEFAULT_MODEL_API, Collections.emptyList(), null); + } + static Function validateWebhooks() { final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/webhooks"; 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 a6380ccd..44ddca88 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 @@ -87,6 +87,10 @@ static Stream fileSpecToProcess() { OpenApiGeneratorFixtures.validateOpenApi31Types()), Arguments.of("testWebhooks", OpenApiGeneratorFixtures.TEST_WEBHOOKS, OpenApiGeneratorFixtures.validateWebhooks()), + Arguments.of("testOpenApi31Union", OpenApiGeneratorFixtures.TEST_OPEN_API_31_UNION, + OpenApiGeneratorFixtures.validateOpenApi31Union()), + Arguments.of("testWebhookPathCollision", OpenApiGeneratorFixtures.TEST_WEBHOOK_PATH_COLLISION, + OpenApiGeneratorFixtures.validateWebhookPathCollision()), Arguments.of("testAnyOfInResponse", OpenApiGeneratorFixtures.TEST_ANY_OF_IN_RESPONSE, OpenApiGeneratorFixtures.validateAnyOfInResponse()), Arguments.of("testOneOfInResponse", OpenApiGeneratorFixtures.TEST_ONE_OF_IN_RESPONSE, diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Union/api-test.yml b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Union/api-test.yml new file mode 100644 index 00000000..fac249e2 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Union/api-test.yml @@ -0,0 +1,40 @@ +--- +# Edge-case regression for OpenAPI 3.1 array-valued `type` (issue #375): +# a genuine multi-type union (no "null") and a "null"-only type. +# The generator picks the first concrete type for a union (logging a warning) and +# falls back to `object` when the array has no concrete type. +openapi: "3.1.0" +info: + version: 1.0.0 + title: Union types API (OpenAPI 3.1) + license: + name: MIT +servers: + - url: http://localhost:8080/v1 +tags: + - name: item +paths: + /item: + get: + summary: Get the item + operationId: getItem + tags: + - item + responses: + '200': + description: The requested item + content: + application/json: + schema: + $ref: "#/components/schemas/Item" +components: + schemas: + Item: + type: object + properties: + id: + type: string + code: + type: ["string", "integer"] + note: + type: ["null"] diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Union/assets/ItemApi.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Union/assets/ItemApi.java new file mode 100644 index 00000000..797d58ca --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Union/assets/ItemApi.java @@ -0,0 +1,46 @@ +package com.sngular.multifileplugin.openapi31union; + +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.openapi31union.model.ItemDTO; + +public interface ItemApi { + + /** + * GET /item: Get the item + * @return The requested item; (status code 200) + */ + + @Operation( + operationId = "getItem", + summary = "Get the item", + tags = {"item"}, + responses = { + @ApiResponse(responseCode = "200", description = "The requested item", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ItemDTO.class))) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/item", + produces = {"application/json"} + ) + + default ResponseEntity getItem() { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Union/assets/ItemDTO.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Union/assets/ItemDTO.java new file mode 100644 index 00000000..50d37fac --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi31Union/assets/ItemDTO.java @@ -0,0 +1,112 @@ +package com.sngular.multifileplugin.openapi31union.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 = ItemDTO.ItemDTOBuilder.class) +public class ItemDTO { + + @JsonProperty(value ="id") + private String id; + @JsonProperty(value ="code") + private String code; + @JsonProperty(value ="note") + private Object note; + + private ItemDTO(ItemDTOBuilder builder) { + this.id = builder.id; + this.code = builder.code; + this.note = builder.note; + + } + + public static ItemDTO.ItemDTOBuilder builder() { + return new ItemDTO.ItemDTOBuilder(); + } + + @JsonPOJOBuilder(buildMethodName = "build", withPrefix = "") + public static class ItemDTOBuilder { + + private String id; + private String code; + private Object note; + + public ItemDTO.ItemDTOBuilder id(String id) { + this.id = id; + return this; + } + + public ItemDTO.ItemDTOBuilder code(String code) { + this.code = code; + return this; + } + + public ItemDTO.ItemDTOBuilder note(Object note) { + this.note = note; + return this; + } + + public ItemDTO build() { + ItemDTO itemDTO = new ItemDTO(this); + return itemDTO; + } + } + + @Schema(name = "id", required = false) + public String getId() { + return id; + } + public void setId(String id) { + this.id = id; + } + + @Schema(name = "code", required = false) + public String getCode() { + return code; + } + public void setCode(String code) { + this.code = code; + } + + @Schema(name = "note", required = false) + public Object getNote() { + return note; + } + public void setNote(Object note) { + this.note = note; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ItemDTO itemDTO = (ItemDTO) o; + return Objects.equals(this.id, itemDTO.id) && Objects.equals(this.code, itemDTO.code) && Objects.equals(this.note, itemDTO.note); + } + + @Override + public int hashCode() { + return Objects.hash(id, code, note); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("ItemDTO{"); + sb.append(" id:").append(id).append(","); + sb.append(" code:").append(code).append(","); + sb.append(" note:").append(note); + sb.append("}"); + return sb.toString(); + } + + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testWebhookPathCollision/api-test.yml b/multiapi-engine/src/test/resources/openapigenerator/testWebhookPathCollision/api-test.yml new file mode 100644 index 00000000..c6301065 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testWebhookPathCollision/api-test.yml @@ -0,0 +1,51 @@ +--- +# Edge-case regression for OpenAPI 3.1 webhooks (issue #375): a webhook whose name +# collides with an existing path. Existing `paths` entries must take precedence, so +# the `/newPet` GET is generated and the colliding webhook is dropped. +openapi: "3.1.0" +info: + version: 1.0.0 + title: Webhook/path collision API (OpenAPI 3.1) + license: + name: MIT +servers: + - url: http://localhost:8080/v1 +tags: + - name: pet +paths: + /newPet: + get: + summary: Get the latest new pet + operationId: getNewPet + tags: + - pet + responses: + '200': + description: The latest new pet + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" +webhooks: + newPet: + post: + summary: New pet notification (must be ignored, collides with /newPet) + operationId: newPetWebhook + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + responses: + '200': + description: Notification acknowledged +components: + schemas: + Pet: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string diff --git a/multiapi-engine/src/test/resources/openapigenerator/testWebhookPathCollision/assets/NewPetApi.java b/multiapi-engine/src/test/resources/openapigenerator/testWebhookPathCollision/assets/NewPetApi.java new file mode 100644 index 00000000..452f37e3 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testWebhookPathCollision/assets/NewPetApi.java @@ -0,0 +1,46 @@ +package com.sngular.multifileplugin.webhookpathcollision; + +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.webhookpathcollision.model.PetDTO; + +public interface NewPetApi { + + /** + * GET /newPet: Get the latest new pet + * @return The latest new pet; (status code 200) + */ + + @Operation( + operationId = "getNewPet", + summary = "Get the latest new pet", + tags = {"pet"}, + responses = { + @ApiResponse(responseCode = "200", description = "The latest new pet", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PetDTO.class))) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/newPet", + produces = {"application/json"} + ) + + default ResponseEntity getNewPet() { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testWebhookPathCollision/assets/PetDTO.java b/multiapi-engine/src/test/resources/openapigenerator/testWebhookPathCollision/assets/PetDTO.java new file mode 100644 index 00000000..2eabc332 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testWebhookPathCollision/assets/PetDTO.java @@ -0,0 +1,94 @@ +package com.sngular.multifileplugin.webhookpathcollision.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 = PetDTO.PetDTOBuilder.class) +public class PetDTO { + + @JsonProperty(value ="name") + private String name; + @JsonProperty(value ="id") + private Long id; + + private PetDTO(PetDTOBuilder builder) { + this.name = builder.name; + this.id = builder.id; + + } + + public static PetDTO.PetDTOBuilder builder() { + return new PetDTO.PetDTOBuilder(); + } + + @JsonPOJOBuilder(buildMethodName = "build", withPrefix = "") + public static class PetDTOBuilder { + + private String name; + private Long id; + + public PetDTO.PetDTOBuilder name(String name) { + this.name = name; + return this; + } + + public PetDTO.PetDTOBuilder id(Long id) { + this.id = id; + return this; + } + + public PetDTO build() { + PetDTO petDTO = new PetDTO(this); + return petDTO; + } + } + + @Schema(name = "name", required = false) + public String getName() { + return name; + } + public void setName(String name) { + this.name = name; + } + + @Schema(name = "id", required = false) + public Long getId() { + return id; + } + public void setId(Long id) { + this.id = id; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PetDTO petDTO = (PetDTO) o; + return Objects.equals(this.name, petDTO.name) && Objects.equals(this.id, petDTO.id); + } + + @Override + public int hashCode() { + return Objects.hash(name, id); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("PetDTO{"); + sb.append(" name:").append(name).append(","); + sb.append(" id:").append(id); + sb.append("}"); + return sb.toString(); + } + + +} From a5a5fc4e44bc892e7c8094560a4eaa63e30b22e6 Mon Sep 17 00:00:00 2001 From: joseegarcia Date: Thu, 23 Jul 2026 09:12:40 +0200 Subject: [PATCH 5/6] Annotate Gradle tasks with @DisableCachingByDefault Gradle 9.x plugin validation fails the build when a task type is neither @CacheableTask nor @DisableCachingByDefault. OpenApiTask and AsyncApiTask are code generators whose spec-file inputs are not declared as cacheable inputs, so caching is disabled explicitly with a documented reason. Co-Authored-By: Claude Opus 4.8 --- .../groovy/com/sngular/api/generator/plugin/AsyncApiTask.groovy | 2 ++ .../groovy/com/sngular/api/generator/plugin/OpenApiTask.groovy | 2 ++ 2 files changed, 4 insertions(+) diff --git a/scs-multiapi-gradle-plugin/src/main/groovy/com/sngular/api/generator/plugin/AsyncApiTask.groovy b/scs-multiapi-gradle-plugin/src/main/groovy/com/sngular/api/generator/plugin/AsyncApiTask.groovy index 74c39b13..2eab7796 100644 --- a/scs-multiapi-gradle-plugin/src/main/groovy/com/sngular/api/generator/plugin/AsyncApiTask.groovy +++ b/scs-multiapi-gradle-plugin/src/main/groovy/com/sngular/api/generator/plugin/AsyncApiTask.groovy @@ -17,7 +17,9 @@ import org.gradle.api.file.DirectoryProperty import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault +@DisableCachingByDefault(because = "Generation depends on external spec files that are not declared as cacheable inputs") abstract class AsyncApiTask extends DefaultTask { @Optional diff --git a/scs-multiapi-gradle-plugin/src/main/groovy/com/sngular/api/generator/plugin/OpenApiTask.groovy b/scs-multiapi-gradle-plugin/src/main/groovy/com/sngular/api/generator/plugin/OpenApiTask.groovy index b7b21fe0..f5b13937 100644 --- a/scs-multiapi-gradle-plugin/src/main/groovy/com/sngular/api/generator/plugin/OpenApiTask.groovy +++ b/scs-multiapi-gradle-plugin/src/main/groovy/com/sngular/api/generator/plugin/OpenApiTask.groovy @@ -16,7 +16,9 @@ import org.gradle.api.file.DirectoryProperty import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault +@DisableCachingByDefault(because = "Generation depends on external spec files that are not declared as cacheable inputs") abstract class OpenApiTask extends DefaultTask { @Optional From 19ae9ca33254cbd26ae3ee5a66cab000bc88a1f0 Mon Sep 17 00:00:00 2001 From: joseegarcia Date: Thu, 23 Jul 2026 11:40:34 +0200 Subject: [PATCH 6/6] fix: update thollander/actions-comment-pull-request to v2 (Node 20 deprecated) --- .github/workflows/gradle-pr.yml | 2 +- .github/workflows/maven-pr.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gradle-pr.yml b/.github/workflows/gradle-pr.yml index 84913fa3..99e6e816 100644 --- a/.github/workflows/gradle-pr.yml +++ b/.github/workflows/gradle-pr.yml @@ -29,7 +29,7 @@ jobs: continue-on-error: true - name: Warn about version specification if: steps.plugin-version-check.outcome != 'success' - uses: thollander/actions-comment-pull-request@v1 + uses: thollander/actions-comment-pull-request@v2 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} message: Project version has not been updated in build.gradle. Please, update your version using https://semver.org specifications diff --git a/.github/workflows/maven-pr.yml b/.github/workflows/maven-pr.yml index 8fa70ffb..ebeff5da 100644 --- a/.github/workflows/maven-pr.yml +++ b/.github/workflows/maven-pr.yml @@ -28,7 +28,7 @@ jobs: continue-on-error: true - name: Warn about version specification if: ${{ steps.engine_version_check.outcome != 'success' || steps.maven_plugin_version_check.outcome != 'success' }} - uses: thollander/actions-comment-pull-request@v1 + uses: thollander/actions-comment-pull-request@v2 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} message: Project version has not been updated in pom.xml. Please, update your version using https://semver.org specifications