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 multiapi-engine/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<groupId>com.sngular</groupId>
<artifactId>multiapi-engine</artifactId>
<version>6.4.2</version>
<version>6.5.0</version>
<packaging>jar</packaging>

<properties>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,8 @@ public class SchemaFieldObject {
private Map<String, String> enumValues;

private Object constValue;

private String description;

private String example;
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ public final class ApiTool {

public static final String SCHEMAS = "schemas";

public static final String DEFS = "$defs";

public static final String REQUIRED = "required";

public static final String PARAMETERS = "parameters";
Expand Down Expand Up @@ -153,7 +155,21 @@ public static JsonNode getItems(final JsonNode schema) {
}

public static Map<String, JsonNode> getComponentSchemas(final JsonNode openApi) {
return getComponentSchemasByType(openApi, SCHEMAS);
final var schemasMap = getComponentSchemasByType(openApi, SCHEMAS);
// JSON Schema 2020-12 / OpenAPI 3.1: reusable schemas may live under a
// top-level `$defs` object instead of `components/schemas`.
schemasMap.putAll(getDefsSchemas(openApi));
return schemasMap;
}

private static Map<String, JsonNode> getDefsSchemas(final JsonNode openApi) {
final var schemasMap = new HashMap<String, JsonNode>();
if (hasNode(openApi, DEFS)) {
final var defs = getNode(openApi, DEFS);
defs.fieldNames().forEachRemaining(name -> schemasMap.put(
DEFS.toUpperCase() + "/" + StringCaseUtils.titleToSnakeCase(name), getNode(defs, name)));
}
return schemasMap;
}

private static Map<String, JsonNode> getComponentSchemasByType(final JsonNode openApi, final String schemaType) {
Expand Down Expand Up @@ -226,7 +242,15 @@ public static String getType(final JsonNode schema) {
return "";
}
final JsonNode typeNode = getNode(schema, "type");
return typeNode.isArray() ? getArrayType(typeNode) : StringUtils.defaultIfEmpty(typeNode.textValue(), "");
if (typeNode.isArray()) {
return getArrayType(typeNode);
}
// OpenAPI 3.1 / JSON Schema 2020-12: a scalar `type: "null"` carries no concrete
// Java type; degrade to object so we never emit an invalid `null` type.
if (TypeConstants.NULL.equalsIgnoreCase(typeNode.textValue())) {
return TypeConstants.OBJECT;
}
return StringUtils.defaultIfEmpty(typeNode.textValue(), "");
}

private static String getArrayType(final JsonNode typeNode) {
Expand Down Expand Up @@ -347,17 +371,59 @@ public static boolean isDateTime(final JsonNode schema) {
public static boolean isBinary(final JsonNode schema) {
final boolean isMultipartFile;
if (hasType(schema) && TypeConstants.STRING.equalsIgnoreCase(getType(schema))) {
if (hasNode(schema, FORMAT)) {
isMultipartFile = "binary".equalsIgnoreCase(getNode(schema, FORMAT).textValue());
if (hasNode(schema, FORMAT) && "binary".equalsIgnoreCase(getNode(schema, FORMAT).textValue())) {
isMultipartFile = true;
} else {
isMultipartFile = false;
// OpenAPI 3.1 / JSON Schema 2020-12 replace `format: binary` with the
// `contentEncoding` / `contentMediaType` keywords for binary payloads.
isMultipartFile = hasNode(schema, "contentEncoding") || hasNode(schema, "contentMediaType");
}
} else {
isMultipartFile = false;
}
return isMultipartFile;
}

public static boolean isNullable(final JsonNode schema) {
if (Objects.isNull(schema)) {
return false;
}
// OpenAPI 3.0 `nullable: true`.
if (getNodeAsBoolean(schema, "nullable")) {
return true;
}
// OpenAPI 3.1 idiom: `type: ["<type>", "null"]`.
if (hasType(schema)) {
final JsonNode typeNode = getNode(schema, "type");
if (typeNode.isArray()) {
for (final JsonNode element : typeNode) {
if (TypeConstants.NULL.equalsIgnoreCase(element.asText())) {
return true;
}
}
} else {
return TypeConstants.NULL.equalsIgnoreCase(typeNode.textValue());
}
}
return false;
}

public static boolean hasPatternProperties(final JsonNode schema) {
return hasNode(schema, "patternProperties");
}

public static JsonNode getPatternProperties(final JsonNode schema) {
return getNode(schema, "patternProperties");
}

public static boolean hasPrefixItems(final JsonNode schema) {
return hasNode(schema, "prefixItems");
}

public static JsonNode getPrefixItems(final JsonNode schema) {
return getNode(schema, "prefixItems");
}

public static List<JsonNode> findContentSchemas(final JsonNode schema) {
return hasNode(schema, "content") ? schema.findValues("schema") : Collections.emptyList();
}
Expand Down Expand Up @@ -414,6 +480,32 @@ public static JsonNode nodeFromFile(final FileLocation ymlParent, final String f
return om.readTree(file);
}

public static String getDescription(final JsonNode schema) {
return getNodeAsString(schema, "description");
}

public static String getExample(final JsonNode schema) {
// OpenAPI 3.0 uses a single `example`; OpenAPI 3.1 / JSON Schema 2020-12 use an
// `examples` array. Prefer `example`, otherwise take the first `examples` entry.
if (hasNode(schema, "example")) {
return asExampleText(getNode(schema, "example"));
}
if (hasNode(schema, "examples")) {
final JsonNode examples = getNode(schema, "examples");
if (examples.isArray() && examples.elements().hasNext()) {
return asExampleText(examples.elements().next());
}
}
return null;
}

private static String asExampleText(final JsonNode example) {
if (Objects.isNull(example) || example.isNull()) {
return null;
}
return example.isValueNode() ? example.asText() : example.toString();
}

public static boolean hasConst(final JsonNode fieldBody) {
return hasNode(fieldBody, "const");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,12 +169,12 @@ private static Set<SchemaFieldObject> getFields(
baseDir));
}
} else if (TypeConstants.ARRAY.equalsIgnoreCase(ApiTool.getType(schema))) {
final String itemType = ApiTool.hasRef(ApiTool.getItems(schema)) ? MapperUtil.getPojoNameFromRef(ApiTool.getItems(schema), specFile, null)
: ApiTool.getType(ApiTool.getItems(schema));
fieldObjectArrayList.add(SchemaFieldObject.builder()
.baseName("items")
.dataType(SchemaFieldObjectType.fromTypeList(TypeConstants.ARRAY, itemType))
.dataType(SchemaFieldObjectType.fromTypeList(TypeConstants.ARRAY, resolveArrayItemType(schema, specFile)))
.build());
} else if (ApiTool.hasPatternProperties(schema)) {
fieldObjectArrayList.add(buildPatternPropertiesField(ApiTool.getName(schema), schema, specFile));
} else if (ApiTool.isAllOf(schema)) {
fieldObjectArrayList.addAll(processAllOf(totalSchemas, ApiTool.getAllOf(schema), specFile, compositedSchemas, antiLoopList, baseDir));
} else if (ApiTool.isAnyOf(schema)) {
Expand Down Expand Up @@ -209,6 +209,8 @@ private static List<SchemaFieldObject> processFieldObjectList(
fieldObjectArrayList.addAll(processArray(fieldName, className, schema, specFile, totalSchemas, compositedSchemas, antiLoopList, baseDir));
} else if (ApiTool.hasAdditionalProperties(schema)) {
fieldObjectArrayList.addAll(processMap(fieldName, schema, specFile, totalSchemas, compositedSchemas, antiLoopList, baseDir));
} else if (ApiTool.hasPatternProperties(schema)) {
fieldObjectArrayList.add(buildPatternPropertiesField(fieldName, schema, specFile));
} else if (ApiTool.hasRef(schema)) {
fieldObjectArrayList.add(
processRef(fieldName, schema, new SchemaFieldObjectType(MapperUtil.getSimpleType(schema, specFile)), totalSchemas, compositedSchemas, antiLoopList, specFile, baseDir));
Expand Down Expand Up @@ -337,9 +339,28 @@ private static List<SchemaFieldObject> processObjectProperty(
} else {
fieldObjectArrayList.addAll(processFieldObjectList(buildingSchema, fieldName, fieldName, fieldBody, specFile, totalSchemas, compositedSchemas, antiLoopList, baseDir));
}
applyMetadata(fieldObjectArrayList, fieldName, fieldBody);
return fieldObjectArrayList;
}

private static void applyMetadata(final List<SchemaFieldObject> fields, final String fieldName, final JsonNode fieldBody) {
final String description = ApiTool.getDescription(fieldBody);
final String example = ApiTool.getExample(fieldBody);
if (Objects.isNull(description) && Objects.isNull(example)) {
return;
}
for (final var field : fields) {
if (Objects.equals(field.getBaseName(), fieldName)) {
if (Objects.nonNull(description)) {
field.setDescription(description);
}
if (Objects.nonNull(example)) {
field.setExample(example);
}
}
}
}

private static Object getConst(final JsonNode fieldBody) {
return ApiTool.hasConst(fieldBody) ? ApiTool.getConst(fieldBody) : null;
}
Expand Down Expand Up @@ -426,11 +447,17 @@ private static List<SchemaFieldObject> processArray(
final Map<String, SchemaObject> compositedSchemas, final Set<String> antiLoopList, final Path baseDir) {
final List<SchemaFieldObject> fieldObjectArrayList = new LinkedList<>();

if (!ApiTool.hasItems(schema)) {
if (!ApiTool.hasItems(schema) || ApiTool.getItems(schema).isBoolean()) {
// No `items` schema, or `items: false` (JSON Schema 2020-12). If the array
// declares positional `prefixItems` (tuple), or nothing typable at all, the
// element type degrades to Object -> List<Object>.
final boolean isTuple = ApiTool.hasPrefixItems(schema);
fieldObjectArrayList.add(SchemaFieldObject
.builder()
.baseName(fieldName)
.dataType(new SchemaFieldObjectType(TypeConstants.OBJECT))
.dataType(isTuple
? SchemaFieldObjectType.fromTypeList(TypeConstants.ARRAY, TypeConstants.OBJECT)
: new SchemaFieldObjectType(TypeConstants.OBJECT))
.build());
} else {
final var items = ApiTool.getItems(schema);
Expand Down Expand Up @@ -626,7 +653,9 @@ private static SchemaObject createComposedSchema(
private static void setFieldType(
final SchemaFieldObject field, final JsonNode schemaProperty, final JsonNode schema,
final CommonSpecFile specFile, final String key) {
field.setRequired(ApiTool.hasRequired(schema) && ApiTool.checkIfRequired(schema, key));
// A nullable field (3.0 `nullable: true` or the 3.1 ["T","null"] idiom) must not be
// marked required, otherwise the generated @NotNull would reject a legitimate null value.
field.setRequired(ApiTool.hasRequired(schema) && ApiTool.checkIfRequired(schema, key) && !ApiTool.isNullable(schemaProperty));
if (ApiTool.isArray(schemaProperty)) {
final String typeArray;
if (ApiTool.hasItems(schemaProperty)) {
Expand Down Expand Up @@ -888,5 +917,38 @@ private static String getImportClass(final String type) {
return StringUtils.isNotBlank(type) && !TypeConstants.NO_IMPORT_TYPE.contains(type) ? StringUtils.capitalize(type) : "";
}

private static String resolveArrayItemType(final JsonNode schema, final CommonSpecFile specFile) {
final var items = ApiTool.getItems(schema);
if (Objects.nonNull(items) && !items.isBoolean()) {
return ApiTool.hasRef(items) ? MapperUtil.getPojoNameFromRef(items, specFile, null) : ApiTool.getType(items);
}
// `prefixItems` (tuple) or `items: false` -> degrade the element type to Object.
return TypeConstants.OBJECT;
}

private static SchemaFieldObject buildPatternPropertiesField(
final String fieldName, final JsonNode schema, final CommonSpecFile specFile) {
// JSON Schema 2020-12 `patternProperties` maps regex keys to a value schema.
// We model it as Map<String, ValueType> using the first declared pattern's value schema.
final var patternProps = ApiTool.getPatternProperties(schema);
final var valueSchemas = patternProps.elements();
final JsonNode valueSchema = valueSchemas.hasNext() ? valueSchemas.next() : null;
final String valueType;
if (Objects.isNull(valueSchema)) {
valueType = TypeConstants.OBJECT;
} else if (ApiTool.hasRef(valueSchema)) {
valueType = MapperUtil.getPojoNameFromRef(valueSchema, specFile, null);
} else if (isBasicType(valueSchema) && ApiTool.hasType(valueSchema)) {
valueType = MapperUtil.getSimpleType(valueSchema, specFile);
} else {
valueType = TypeConstants.OBJECT;
}
return SchemaFieldObject
.builder()
.baseName(StringUtils.defaultIfBlank(fieldName, ADDITIONAL_PROPERTIES))
.dataType(SchemaFieldObjectType.fromTypeList(TypeConstants.MAP, valueType))
.build();
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ public class ${schema.className} {
}

<#list schema.fieldObjectList as field>
@Schema(name = "${field.baseName?uncap_first}", required = <#if field.required?has_content && field.required == true>true<#else>false</#if>)
@Schema(name = "${field.baseName?uncap_first}", required = <#if field.required?has_content && field.required == true>true<#else>false</#if><#if field.description?has_content>, description = "${field.description?j_string}"</#if><#if field.example?has_content>, example = "${field.example?j_string}"</#if>)
<#if field.dataType.baseType == "array">
public ${field.dataType} get${field.baseName?cap_first}() {
return ${calculateSafeName (field.baseName, ";")}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,13 @@ public final class OpenApiGeneratorFixtures {
.clientPackage("com.sngular.multifileplugin.refwithdescription.client")
.modelNameSuffix("DTO").build());

static final List<SpecFile> TEST_OPEN_API_31_COMPLETENESS = List
.of(SpecFile.builder().filePath("openapigenerator/testOpenApi31Completeness/api-test.yml")
.apiPackage("com.sngular.multifileplugin.openapi31completeness")
.modelPackage("com.sngular.multifileplugin.openapi31completeness.model")
.clientPackage("com.sngular.multifileplugin.openapi31completeness.client")
.modelNameSuffix("DTO").build());

static final List<SpecFile> TEST_EXTERNAL_PATH_ITEM_REF_GENERATION = List
.of(SpecFile.builder().filePath("openapigenerator/testExternalPathItemRefsGeneration/api-test.yml")
.apiPackage("com.sngular.multifileplugin.externalpathitemref")
Expand Down Expand Up @@ -891,6 +898,25 @@ static Function<Path, Boolean> validateOpenApi31Types() {
DEFAULT_MODEL_API, Collections.emptyList(), null);
}

static Function<Path, Boolean> validateOpenApi31Completeness() {

final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/openapi31completeness";

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

final String COMMON_PATH = "openapigenerator/testOpenApi31Completeness/";

final String ASSETS_PATH = COMMON_PATH + "assets/";

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

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

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

static Function<Path, Boolean> validateRefWithDescription() {

final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/refwithdescription";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ static Stream<Arguments> fileSpecToProcess() {
OpenApiGeneratorFixtures.validateOpenApi31Types()),
Arguments.of("testRefWithDescription", OpenApiGeneratorFixtures.TEST_REF_WITH_DESCRIPTION,
OpenApiGeneratorFixtures.validateRefWithDescription()),
Arguments.of("testOpenApi31Completeness", OpenApiGeneratorFixtures.TEST_OPEN_API_31_COMPLETENESS,
OpenApiGeneratorFixtures.validateOpenApi31Completeness()),
Arguments.of("testWebhooks", OpenApiGeneratorFixtures.TEST_WEBHOOKS,
OpenApiGeneratorFixtures.validateWebhooks()),
Arguments.of("testOpenApi31Union", OpenApiGeneratorFixtures.TEST_OPEN_API_31_UNION,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,22 +98,22 @@ public DataDTO build() {
}
}

@Schema(name = "clientName", required = true)
@Schema(name = "clientName", required = true, description = "Nombre del cliente.")
public String getClientName() {
return clientName;
}

@Schema(name = "flightNumber", required = true)
@Schema(name = "flightNumber", required = true, description = "Número de vuelo.")
public String getFlightNumber() {
return flightNumber;
}

@Schema(name = "clientId", required = true)
@Schema(name = "clientId", required = true, description = "Id del cliente.")
public Integer getClientId() {
return clientId;
}

@Schema(name = "test", required = false)
@Schema(name = "test", required = false, description = "Array para probar anotaciones")
public List<Integer> getTest() {
return test;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ public void setStatus(Status status) {
this.status = status;
}

@Schema(name = "clientId", required = false)
@Schema(name = "clientId", required = false, description = "Id del cliente.")
public Integer getClientId() {
return clientId;
}
Expand Down
Loading
Loading