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
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Maven and Gradle
- [Calling an API from your service (callMode)](#calling-an-api-from-your-service-callmode)
- [Object-typed query parameters and multipart bodies](#object-typed-query-parameters-and-multipart-bodies)
- [Streaming endpoints (NDJSON and JSON Lines)](#streaming-endpoints-ndjson-and-json-lines)
- [OpenAPI 3.2 operations](#openapi-32-operations)
- [Camel case Java names (useCamelCaseNames)](#camel-case-java-names-usecamelcasenames)
- [Unknown enum values (useUnknownEnumValue)](#unknown-enum-values-useunknownenumvalue)
- [Property Validation](#property-validation)
Expand Down Expand Up @@ -1183,6 +1184,59 @@ for it.
the generation fails for a streaming operation: generate that client with
`reactive` on.

### OpenAPI 3.2 operations

Every operation a Path Item declares is generated: `get`, `put`, `post`,
`delete`, `patch`, `head`, `options` and `trace`, OpenAPI 3.2's `query`, and the
methods of its `additionalOperations` map, such as `PURGE`.

Spring's `@RequestMapping` only takes the methods of `RequestMethod`, so the
server interfaces map `QUERY` and the `additionalOperations` methods with a
generated annotation instead:

```java
@HttpMethodMapping(method = "QUERY", value = "/items",
produces = {"application/json"}, consumes = {"application/json"})
default ResponseEntity<List<ItemDTO>> queryItems(
@Valid @RequestBody FilterDTO filterDTO) {
```

The interfaces are implemented as usual. The generated
`HttpMethodMappingConfiguration`, next to them, adds a handler mapping of those
operations, and of nothing else, next to Spring MVC's or WebFlux's own, so it
does not interfere with the rest of the application's configuration. It is
picked up by component scanning when the API package is under the
application's; otherwise add `@Import(HttpMethodMappingConfiguration.class)`.
Other methods of the same paths are still answered by Spring, 405 included.

- The clients send any method on Spring Boot 3 or later. Spring Framework 5's
`HttpMethod` is an enum of the methods it knows, so the generation of a Spring
Boot 2 client fails for `QUERY` or an `additionalOperations` method.
- The generated RestClient class sends them through an `ApiRestClient` built on
a `RestClient`, or on a `RestTemplate` whose request factory supports them
(JDK `HttpClient`, Apache HttpComponents, Jetty, Reactor Netty). The default
`RestTemplate` factory, the JDK's `HttpURLConnection`, rejects them.
- springdoc documents the operations Spring maps, so the `@HttpMethodMapping`
ones are missing from the OpenAPI document it serves.
- Spring MVC does not dispatch `TRACE` requests unless
`spring.mvc.dispatch-trace-request` is on, and Tomcat refuses them unless its
connector allows them.

The other OpenAPI 3.2 additions that change the generated code:

- A parameter `in: querystring`, whose `application/x-www-form-urlencoded`
schema describes the whole query string, is generated as that object sent
`form`-exploded: one argument per property in the server and `@HttpExchange`
interfaces, and the object, sent as its properties, in the client classes. Any
other media type fails the generation.
- A media type declared as a `$ref` to `components/mediaTypes` is resolved.
- A response without `description` is documented with its `summary`.
- `application/json-seq` and `text/event-stream` fail the generation, since
their items would be read as a single JSON document.

`$self`, the new Tag, Server and Example fields, and the new security scheme
fields do not change the generated code.

### Camel case Java names (useCamelCaseNames)

By default the generated Java code uses the names the contract gives, whenever
Expand Down
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>8.3.1</version>
<version>8.4.0</version>
<packaging>jar</packaging>

<properties>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import com.sngular.api.generator.plugin.openapi.exception.DuplicateModelClassException;
import com.sngular.api.generator.plugin.openapi.model.AuthObject;
import com.sngular.api.generator.plugin.openapi.model.GlobalObject;
import com.sngular.api.generator.plugin.openapi.model.OperationObject;
import com.sngular.api.generator.plugin.openapi.model.PathObject;
import com.sngular.api.generator.plugin.openapi.parameter.SpecFile;
import com.sngular.api.generator.plugin.openapi.template.TemplateFactory;
Expand Down Expand Up @@ -200,6 +201,9 @@ private void processFile(final SpecFile specFile) {
// Determine the actual base URI for resolving external references
final URI specBaseUri = resolveSpecBaseUri(specFile);
OpenApiUtil.solvePathRefs(openAPI, specBaseUri);
OpenApiUtil.flattenAdditionalOperations(openAPI);
OpenApiUtil.resolveMediaTypeRefs(openAPI);
OpenApiUtil.normalizeQuerystringParameters(openAPI);
OpenApiUtil.promoteItemSchemas(openAPI);
final String clientPackage = specFile.getClientPackage();

Expand Down Expand Up @@ -268,6 +272,7 @@ private GlobalObject createApiTemplate(final SpecFile specFile, final JsonNode o
final String javaFileName = OpenApiUtil.processJavaFileName(apisKey);
final List<PathObject> pathObjects = MapperPathUtil.mapPathObjects(specFile, apis.get(apisKey), globalObject, baseDir);
final AuthObject authObject = MapperAuthUtil.getApiAuthObject(globalObject.getAuthSchemas(), pathObjects);
validateCustomMethods(specFile, pathObjects);

try {
templateFactory.fillTemplate(specFile, javaFileName, pathObjects, authObject);
Expand All @@ -283,6 +288,19 @@ private GlobalObject createApiTemplate(final SpecFile specFile, final JsonNode o
return globalObject;
}

/**
* Spring Framework 5's {@code HttpMethod} is an enum of the methods it knows, so clients generated for Spring Boot 2 cannot
* send any other one, such as OpenAPI 3.2's {@code QUERY}.
*/
private void validateCustomMethods(final SpecFile specFile, final List<PathObject> pathObjects) {
if (specFile.isCallMode() && !bootVersion.isAtLeast(3, 0)) {
pathObjects.stream().flatMap(path -> path.getOperationObjects().stream()).filter(OperationObject::isCustomMethod).findFirst().ifPresent(operation -> {
throw new CodeGenerationException("Operation '" + operation.getOperationId() + "' uses the HTTP method " + operation.getOperationType()
+ ", which Spring Framework 5 clients cannot send: generate its client with springBootVersion 3 or later");
});
}
}

private void createModelTemplate(final SpecFile specFile, final JsonNode openAPI, final GlobalObject globalObject) {
final var modelPackage = processModelPackage(specFile.getApiPackage(), specFile.getModelPackage());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ public final class OperationObject {

private String operationType;

/**
* Whether the HTTP method is one Spring's {@code RequestMethod} has no constant for, such as OpenAPI 3.2's {@code QUERY}, so
* servers map it through the generated {@code @HttpMethodMapping}.
*/
private boolean customMethod;

private String summary;

private String operationId;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ public class ClasspathTemplateLoader extends CommonTemplateLoader {
private static final List<String> TEMPLATE_FILES = List.of(TemplateIndexConstants.TEMPLATE_INTERFACE_API, TemplateIndexConstants.TEMPLATE_CALL_WEB_API,
TemplateIndexConstants.TEMPLATE_WEB_CLIENT, TemplateIndexConstants.TEMPLATE_CALL_REST_API,
TemplateIndexConstants.TEMPLATE_REST_CLIENT, TemplateIndexConstants.TEMPLATE_REACTIVE_API,
TemplateIndexConstants.TEMPLATE_CALL_HTTP_EXCHANGE_API, TemplateIndexConstants.TEMPLATE_NDJSON_SUPPORT);
TemplateIndexConstants.TEMPLATE_CALL_HTTP_EXCHANGE_API, TemplateIndexConstants.TEMPLATE_NDJSON_SUPPORT,
TemplateIndexConstants.TEMPLATE_HTTP_METHOD_MAPPING,
TemplateIndexConstants.TEMPLATE_HTTP_METHOD_MAPPING_CONFIGURATION);

private static final List<String> TEMPLATE_AUTH_FILES = List.of(TemplateIndexConstants.TEMPLATE_API_KEY, TemplateIndexConstants.TEMPLATE_AUTHENTICATION,
TemplateIndexConstants.TEMPLATE_HTTP_BASIC, TemplateIndexConstants.TEMPLATE_HTTP_BEARER,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import com.sngular.api.generator.plugin.common.template.CommonTemplateFactory;
import com.sngular.api.generator.plugin.openapi.model.AuthObject;
import com.sngular.api.generator.plugin.openapi.model.OperationObject;
import com.sngular.api.generator.plugin.openapi.model.PathObject;
import com.sngular.api.generator.plugin.openapi.model.RequestObject;
import com.sngular.api.generator.plugin.openapi.parameter.SpecFile;
Expand Down Expand Up @@ -81,21 +82,30 @@ public final void fillTemplate(
addToRoot("exceptionPackage", specFile.getModelPackage());
}

addToRoot("isReactive", specFile.isReactive());
if (specFile.isCallMode()) {
addToRoot("authObject", authObject);
addToRoot("clientPackage", specFile.getClientPackage());
addToRoot("clientComponent", specFile.shouldRegisterClientComponent());
addToRoot("isReactive", specFile.isReactive());
}

writeTemplateToFile(specFile.isCallMode() ? getTemplateClientApi(specFile) : getTemplateApi(specFile),
StringUtils.defaultIfEmpty(specFile.getApiPackage(), DEFAULT_API_PACKAGE), className + "Api");

final String apiPackage = StringUtils.defaultIfEmpty(specFile.getApiPackage(), DEFAULT_API_PACKAGE);
// Spring MVC interfaces send and read application/x-ndjson through a helper written next to them.
if (!specFile.isCallMode() && !specFile.isReactive() && streams(pathObjects)) {
writeTemplateToFile(TemplateIndexConstants.TEMPLATE_NDJSON_SUPPORT, StringUtils.defaultIfEmpty(specFile.getApiPackage(), DEFAULT_API_PACKAGE),
"NdjsonSupport");
writeTemplateToFile(TemplateIndexConstants.TEMPLATE_NDJSON_SUPPORT, apiPackage, "NdjsonSupport");
}
// Servers map the HTTP methods Spring's RequestMethod has no constant for through an annotation and a handler mapping of their own.
if (!specFile.isCallMode() && usesCustomMethods(pathObjects)) {
writeTemplateToFile(TemplateIndexConstants.TEMPLATE_HTTP_METHOD_MAPPING, apiPackage, "HttpMethodMapping");
writeTemplateToFile(TemplateIndexConstants.TEMPLATE_HTTP_METHOD_MAPPING_CONFIGURATION, apiPackage, "HttpMethodMappingConfiguration");
}
}

public static boolean usesCustomMethods(final List<PathObject> pathObjects) {
return pathObjects.stream().flatMap(path -> path.getOperationObjects().stream()).anyMatch(OperationObject::isCustomMethod);
}

private static boolean streams(final List<PathObject> pathObjects) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ public class TemplateIndexConstants extends CommonTemplateIndexConstants {

public static final String TEMPLATE_NDJSON_SUPPORT = "templateNdjsonSupport.ftlh";

public static final String TEMPLATE_HTTP_METHOD_MAPPING = "templateHttpMethodMapping.ftlh";

public static final String TEMPLATE_HTTP_METHOD_MAPPING_CONFIGURATION = "templateHttpMethodMappingConfiguration.ftlh";

public static final String TEMPLATE_REST_CLIENT = "templateRestClient.ftlh";

public static final String TEMPLATE_WEB_CLIENT = "templateWebClient.ftlh";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ public class MapperPathUtil {

private static final String JSON_MEDIA_TYPE = "application/json";

/**
* Sequential media types OpenAPI 3.2 names that are not generated yet, whose items would otherwise be read as a single JSON
* document.
*/
private static final Set<String> UNSUPPORTED_SEQUENTIAL_MEDIA_TYPES = Set.of("application/json-seq", "text/event-stream");

/**
* The HTTP methods Spring's {@code RequestMethod} and Spring Framework 5's {@code HttpMethod} name. Any other one, such as
* OpenAPI 3.2's {@code QUERY} or an {@code additionalOperations} method, is mapped through the generated
* {@code @HttpMethodMapping}.
*/
private static final Set<String> SPRING_REQUEST_METHODS = Set.of("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE");

private MapperPathUtil() {
}

Expand Down Expand Up @@ -137,19 +150,10 @@ private static List<OperationObject> mapOperationObject(final SpecFile specFile,
final var pathParameters = new ArrayList<ParameterObject>();
for (final Iterator<Entry<String, JsonNode>> it = pathNode.fields(); it.hasNext(); ) {
final var field = it.next();
switch (field.getKey()) {
case "get":
case "post":
case "delete":
case "put":
case "patch":
operationObjects.add(createOperation(field.getValue(), field.getKey().toUpperCase(), specFile, globalObject, operationIdList, baseDir));
break;
case "parameters":
pathParameters.addAll(mapParameterObjects(IteratorUtils.toList(field.getValue().elements()), specFile, null, globalObject, baseDir));
break;
default:
break;
if (OpenApiUtil.isOperation(field.getKey())) {
operationObjects.add(createOperation(field.getValue(), OpenApiUtil.httpMethod(field.getKey()), specFile, globalObject, operationIdList, baseDir));
} else if ("parameters".equals(field.getKey())) {
pathParameters.addAll(mapParameterObjects(IteratorUtils.toList(field.getValue().elements()), specFile, null, globalObject, baseDir));
}
}
if (!pathParameters.isEmpty()) {
Expand All @@ -169,6 +173,7 @@ private static OperationObject createOperation(
final OperationObject operationObject = OperationObject.builder()
.operationId(mapOperationId(getOperationId(operation), operationIdList))
.operationType(operationType)
.customMethod(!SPRING_REQUEST_METHODS.contains(operationType))
.summary(ApiTool.getNodeAsString(operation, "summary"))
.tags(elementsToStrList(Objects.nonNull(tagsNode) ? tagsNode.elements() : null))
.requestObjects(mapRequestObject(specFile, operation, globalObject, baseDir))
Expand All @@ -193,6 +198,12 @@ public static boolean isStreamingMediaType(final String mediaType) {
* content.
*/
private static void applyStreaming(final OperationObject operation, final SpecFile specFile) {
for (final String mediaType : UNSUPPORTED_SEQUENTIAL_MEDIA_TYPES) {
if (operation.getProduces().contains(mediaType) || operation.getConsumes().contains(mediaType)) {
throw new CodeGenerationException("Operation '" + operation.getOperationId() + "' declares " + mediaType + ", a sequential media type that is "
+ "not generated yet: its items would be read as a single JSON document");
}
}
if (operation.getProduces().contains(STREAM_JSON_MEDIA_TYPE) || operation.getConsumes().contains(STREAM_JSON_MEDIA_TYPE)) {
throw new CodeGenerationException("Operation '" + operation.getOperationId() + "' declares " + STREAM_JSON_MEDIA_TYPE + ", which is deprecated and "
+ "which Spring Framework 7 no longer streams: declare the stream as application/x-ndjson or application/jsonl");
Expand Down Expand Up @@ -560,7 +571,10 @@ private static void buildResponse(
}
final String operationIdWithCap = operationId.substring(0, 1).toUpperCase() + operationId.substring(1);
final JsonNode content = Objects.nonNull(realResponse) ? ApiTool.getNode(realResponse, CONTENT) : null;
final String description = Objects.nonNull(realResponse) ? StringUtils.defaultIfEmpty(ApiTool.getNodeAsString(realResponse, DESCRIPTION), "") : "";
// OpenAPI 3.2 makes the description optional, next to a short summary.
final String description = Objects.nonNull(realResponse)
? StringUtils.defaultIfEmpty(ApiTool.getNodeAsString(realResponse, DESCRIPTION), StringUtils.defaultString(ApiTool.getNodeAsString(realResponse, "summary")))
: "";
responseObjects.add(ResponseObject
.builder()
.responseName(responseCode)
Expand Down
Loading
Loading