Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/gradle-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/maven-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<String> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
OpenApiUtil.solvePathRefs(openAPI, baseDir.resolve(specFile.getFilePath()).getParent().toUri());
final String clientPackage = specFile.getClientPackage();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ public class OpenApiUtil {

public static final String PATHS = "paths";

public static final String WEBHOOKS = "webhooks";

static final Set<String> REST_VERB_SET = Set.of("get", "post", "delete", "patch", "put");

private OpenApiUtil() {
Expand Down Expand Up @@ -92,6 +94,52 @@ 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 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);
}
}
});
}
}

/**
* Dereferences Path Item Objects that are declared as a {@code $ref} to another file (modular
* contracts). These references are otherwise never resolved, so the affected paths silently
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,33 @@ public final class OpenApiGeneratorFixtures {
.clientPackage("com.sngular.multifileplugin.externalref.client").modelNamePrefix("Api")
.modelNameSuffix("DTO").build());

static final List<SpecFile> 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<SpecFile> 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<SpecFile> 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<SpecFile> 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());
.modelNameSuffix("DTO").build());

static final List<SpecFile> TEST_EXTERNAL_PATH_ITEM_REF_GENERATION = List
.of(SpecFile.builder().filePath("openapigenerator/testExternalPathItemRefsGeneration/api-test.yml")
Expand Down Expand Up @@ -777,6 +798,60 @@ static Function<Path, Boolean> validateExternalRefGeneration() {
DEFAULT_MODEL_API, expectedExceptionFiles, DEFAULT_EXCEPTION_API);
}

static Function<Path, Boolean> 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<String> expectedTestApiFile = List.of(ASSETS_PATH + "ItemApi.java");

final List<String> expectedTestApiModelFiles = List.of(ASSETS_PATH + "ItemDTO.java");

return path -> commonTest(path, expectedTestApiFile, expectedTestApiModelFiles, DEFAULT_TARGET_API,
DEFAULT_MODEL_API, Collections.emptyList(), null);
}

static Function<Path, Boolean> 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<String> expectedTestApiFile = List.of(ASSETS_PATH + "NewPetApi.java");

final List<String> expectedTestApiModelFiles = List.of(ASSETS_PATH + "PetDTO.java");

return path -> commonTest(path, expectedTestApiFile, expectedTestApiModelFiles, DEFAULT_TARGET_API,
DEFAULT_MODEL_API, Collections.emptyList(), null);
}

static Function<Path, Boolean> 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<String> expectedTestApiFile = List.of(ASSETS_PATH + "NewPetApi.java");

final List<String> expectedTestApiModelFiles = List.of(ASSETS_PATH + "PetDTO.java");

return path -> commonTest(path, expectedTestApiFile, expectedTestApiModelFiles, DEFAULT_TARGET_API,
DEFAULT_MODEL_API, Collections.emptyList(), null);
}

static Function<Path, Boolean> validateOpenApi31Types() {

final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/openapi31types";
Expand All @@ -793,25 +868,25 @@ static Function<Path, Boolean> validateOpenApi31Types() {

return path -> commonTest(path, expectedTestApiFile, expectedTestApiModelFiles, DEFAULT_TARGET_API,
DEFAULT_MODEL_API, Collections.emptyList(), null);
}

static Function<Path, Boolean> validateExternalPathItemRefGeneration() {
}

final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/externalpathitemref";
static Function<Path, Boolean> validateExternalPathItemRefGeneration() {

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

final String COMMON_PATH = "openapigenerator/testExternalPathItemRefsGeneration/";
final String DEFAULT_MODEL_API = "generated/com/sngular/multifileplugin/externalpathitemref/model";

final String ASSETS_PATH = COMMON_PATH + "assets/";
final String COMMON_PATH = "openapigenerator/testExternalPathItemRefsGeneration/";

final List<String> expectedTestApiFile = List.of(ASSETS_PATH + "DashboardApi.java");
final String ASSETS_PATH = COMMON_PATH + "assets/";

final List<String> expectedTestApiModelFiles = List.of(ASSETS_PATH + "DashboardDTO.java");
final List<String> expectedTestApiFile = List.of(ASSETS_PATH + "DashboardApi.java");

return path -> commonTest(path, expectedTestApiFile, expectedTestApiModelFiles, DEFAULT_TARGET_API,
DEFAULT_MODEL_API, Collections.emptyList(), null);
}
final List<String> expectedTestApiModelFiles = List.of(ASSETS_PATH + "DashboardDTO.java");

return path -> commonTest(path, expectedTestApiFile, expectedTestApiModelFiles, DEFAULT_TARGET_API,
DEFAULT_MODEL_API, Collections.emptyList(), null);
}

static Function<Path, Boolean> validateAnyOfInResponse() {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ static Stream<Arguments> fileSpecToProcess() {
OpenApiGeneratorFixtures.validateExternalRefGeneration()),
Arguments.of("testOpenApi31Types", OpenApiGeneratorFixtures.TEST_OPEN_API_31_TYPES,
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("testExternalPathItemRefsGeneration", OpenApiGeneratorFixtures.TEST_EXTERNAL_PATH_ITEM_REF_GENERATION,
OpenApiGeneratorFixtures.validateExternalPathItemRefGeneration()),
Arguments.of("testAnyOfInResponse", OpenApiGeneratorFixtures.TEST_ANY_OF_IN_RESPONSE,
Expand Down
Original file line number Diff line number Diff line change
@@ -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"]
Original file line number Diff line number Diff line change
@@ -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<ItemDTO> getItem() {
return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
}

}
Loading
Loading