diff --git a/README.md b/README.md index 823ff347d..8315b0d49 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ It currently consists of # Release Notes BOAT is still under development and subject to change. +## 0.18.5 + * Java RestTemplate generator: added `useSingleRequestParameter` support to generate named `*Param` overloads for multi-parameter operations, see [boat-maven-plugin README](boat-maven-plugin/README.md#single-request-parameter). + ## 0.18.3 * **Breaking change**: `boat:bundle` and `boat:generate` (when `bundleSpecs` is enabled) now de-duplicate `components/schemas` entries that are structurally identical but were registered under different names diff --git a/boat-maven-plugin/README.md b/boat-maven-plugin/README.md index 67b2202a5..c2d98c841 100644 --- a/boat-maven-plugin/README.md +++ b/boat-maven-plugin/README.md @@ -123,6 +123,19 @@ Same with `generate` but with opinionated defaults for Rest Template Client +### Single request parameter + +When enabled, BOAT generates a `*Param` inner class for each multi-parameter API method alongside +the existing positional-argument overloads. Callers can use named parameters instead of a positional +argument list, reducing the risk of argument-order mistakes when APIs evolve. + + + ... + + true + + + ### Property & Enum Name Mappings (new) Two new optional parameters are supported by the BOAT plugin (mirroring OpenAPI Generator capabilities) to rename generated members without post-processing: diff --git a/boat-scaffold/src/main/templates/boat-java/libraries/resttemplate/api.mustache b/boat-scaffold/src/main/templates/boat-java/libraries/resttemplate/api.mustache index eb0f4b50c..1b8ce3a6a 100644 --- a/boat-scaffold/src/main/templates/boat-java/libraries/resttemplate/api.mustache +++ b/boat-scaffold/src/main/templates/boat-java/libraries/resttemplate/api.mustache @@ -157,6 +157,14 @@ public class {{classname}} extends BaseApi { {{#returnType}}ParameterizedTypeReference<{{#returnType}}{{#isResponseFile}}{{#useAbstractionForFiles}}org.springframework.core.io.Resource{{/useAbstractionForFiles}}{{^useAbstractionForFiles}}{{{.}}}{{/useAbstractionForFiles}}{{/isResponseFile}}{{^isResponseFile}}{{{.}}}{{/isResponseFile}}{{/returnType}}> localReturnType = new ParameterizedTypeReference<{{#returnType}}{{#isResponseFile}}{{#useAbstractionForFiles}}org.springframework.core.io.Resource{{/useAbstractionForFiles}}{{^useAbstractionForFiles}}{{{.}}}{{/useAbstractionForFiles}}{{/isResponseFile}}{{^isResponseFile}}{{{.}}}{{/isResponseFile}}{{/returnType}}>() {};{{/returnType}}{{^returnType}}ParameterizedTypeReference localReturnType = new ParameterizedTypeReference() {};{{/returnType}} return apiClient.invokeAPI("{{{path}}}", HttpMethod.{{httpMethod}}, {{#hasPathParams}}uriVariables{{/hasPathParams}}{{^hasPathParams}}Collections.emptyMap(){{/hasPathParams}}, localVarQueryParams, localVarPostBody, localVarHeaderParams, localVarCookieParams, localVarFormParams, localVarAccept, localVarContentType, localVarAuthNames, localReturnType); } + {{#singleRequestParameter}} + {{#hasParams}} + {{^hasSingleParam}} + {{>libraries/resttemplate/singleRequestParameter}} + {{/hasSingleParam}} + {{/hasParams}} + {{/singleRequestParameter}} + {{#-last}} @Override diff --git a/boat-scaffold/src/main/templates/boat-java/libraries/resttemplate/singleRequestParameter.mustache b/boat-scaffold/src/main/templates/boat-java/libraries/resttemplate/singleRequestParameter.mustache new file mode 100644 index 000000000..9c92f36c0 --- /dev/null +++ b/boat-scaffold/src/main/templates/boat-java/libraries/resttemplate/singleRequestParameter.mustache @@ -0,0 +1,146 @@ +/** +* Parameters for the {@link #{{operationId}}({{#lambda.titlecase}}{{operationId}}{{/lambda.titlecase}}Param)} +* operation. +*/ +public static class {{#lambda.titlecase}}{{operationId}}{{/lambda.titlecase}}Param { +{{#allParams}} + private {{#isFile}}{{#useAbstractionForFiles}}{{#collectionFormat}}java.util.Collection{{/collectionFormat}}{{^collectionFormat}}org.springframework.core.io.Resource{{/collectionFormat}}{{/useAbstractionForFiles}}{{^useAbstractionForFiles}}{{{dataType}}}{{/useAbstractionForFiles}}{{/isFile}}{{^isFile}}{{{dataType}}}{{/isFile}} {{paramName}}; +{{/allParams}} + + public {{#lambda.titlecase}}{{operationId}}{{/lambda.titlecase}}Param() { + } + +{{#allParams}} + public {{#isFile}}{{#useAbstractionForFiles}}{{#collectionFormat}}java.util.Collection{{/collectionFormat}}{{^collectionFormat}}org.springframework.core.io.Resource{{/collectionFormat}}{{/useAbstractionForFiles}}{{^useAbstractionForFiles}}{{{dataType}}}{{/useAbstractionForFiles}}{{/isFile}}{{^isFile}}{{{dataType}}}{{/isFile}} get{{#lambda.titlecase}}{{paramName}}{{/lambda.titlecase}}() { + return this.{{paramName}}; + } + + public {{#lambda.titlecase}}{{operationId}}{{/lambda.titlecase}}Param {{paramName}}( + {{#isFile}}{{#useAbstractionForFiles}}{{#collectionFormat}}java.util.Collection{{/collectionFormat}}{{^collectionFormat}}org.springframework.core.io.Resource{{/collectionFormat}}{{/useAbstractionForFiles}}{{^useAbstractionForFiles}}{{{dataType}}}{{/useAbstractionForFiles}}{{/isFile}}{{^isFile}}{{{dataType}}}{{/isFile}} {{paramName}} + ) { + this.{{paramName}} = {{paramName}}; + return this; + } + +{{/allParams}} + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + +{{#lambda.titlecase}}{{operationId}}{{/lambda.titlecase}}Param param = + ({{#lambda.titlecase}}{{operationId}}{{/lambda.titlecase}}Param) o; + + return {{#allParams}}{{#isByteArray}}java.util.Arrays.equals(this.{{paramName}}, param.{{paramName}}){{/isByteArray}}{{^isByteArray}}java.util.Objects.equals(this.{{paramName}}, param.{{paramName}}){{/isByteArray}}{{^-last}} + && {{/-last}}{{/allParams}}; + } + + @Override + public int hashCode() { + return java.util.Objects.hash( +{{#allParams}} + {{#isByteArray}}java.util.Arrays.hashCode({{paramName}}){{/isByteArray}}{{^isByteArray}}{{paramName}}{{/isByteArray}}{{^-last}},{{/-last}} +{{/allParams}} + ); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + + sb.append( + "class {{#lambda.titlecase}}{{operationId}}{{/lambda.titlecase}}Param {\n" + ); +{{#allParams}} + sb.append(" {{paramName}}: ") + .append(toIndentedString({{paramName}})) + .append("\n"); +{{/allParams}} + sb.append("}"); + + return sb.toString(); + } + + private String toIndentedString(Object value) { + if (value == null) { + return "null"; + } + + return value.toString().replace("\n", "\n "); + } + } + + /** + * {{summary}} + * {{notes}} +{{#responses}} + *

{{code}}{{#message}} - {{.}}{{/message}} +{{/responses}} + * + * @param params parameters for the {{operationId}} operation +{{#returnType}} + * @return {{.}} +{{/returnType}} + * @throws RestClientException if an error occurs while attempting to invoke the API +{{#externalDocs}} + * {{description}} + * @see {{summary}} Documentation +{{/externalDocs}} +{{#isDeprecated}} + * @deprecated +{{/isDeprecated}} + */ +{{#isDeprecated}} + @Deprecated +{{/isDeprecated}} + public {{#returnType}}{{#isResponseFile}}{{#useAbstractionForFiles}}org.springframework.core.io.Resource{{/useAbstractionForFiles}}{{^useAbstractionForFiles}}{{{.}}}{{/useAbstractionForFiles}}{{/isResponseFile}}{{^isResponseFile}}{{{.}}}{{/isResponseFile}} {{/returnType}}{{^returnType}}void {{/returnType}}{{operationId}}( +{{#lambda.titlecase}}{{operationId}}{{/lambda.titlecase}}Param params + ) throws RestClientException { +{{#returnType}} + return {{operationId}}WithHttpInfo(params).getBody(); +{{/returnType}} +{{^returnType}} + {{operationId}}WithHttpInfo(params); +{{/returnType}} + } + + /** + * {{summary}} + * {{notes}} +{{#responses}} + *

{{code}}{{#message}} - {{.}}{{/message}} +{{/responses}} + * + * @param params parameters for the {{operationId}} operation + * @return ResponseEntity<{{returnType}}{{^returnType}}Void{{/returnType}}> + * @throws RestClientException if an error occurs while attempting to invoke the API +{{#externalDocs}} + * {{description}} + * @see {{summary}} Documentation +{{/externalDocs}} +{{#isDeprecated}} + * @deprecated +{{/isDeprecated}} + */ +{{#isDeprecated}} + @Deprecated +{{/isDeprecated}} + public ResponseEntity<{{#returnType}}{{#isResponseFile}}{{#useAbstractionForFiles}}org.springframework.core.io.Resource{{/useAbstractionForFiles}}{{^useAbstractionForFiles}}{{{.}}}{{/useAbstractionForFiles}}{{/isResponseFile}}{{^isResponseFile}}{{{.}}}{{/isResponseFile}}{{/returnType}}{{^returnType}}Void{{/returnType}}> {{operationId}}WithHttpInfo( + {{#lambda.titlecase}}{{operationId}}{{/lambda.titlecase}}Param params + ) throws RestClientException { + java.util.Objects.requireNonNull( + params, + "params must not be null" + ); + + return {{operationId}}WithHttpInfo( + {{#allParams}} + params.get{{#lambda.titlecase}}{{paramName}}{{/lambda.titlecase}}(){{^-last}},{{/-last}} + {{/allParams}} + ); + } \ No newline at end of file diff --git a/boat-scaffold/src/test/java/com/backbase/oss/codegen/java/BoatJavaCodeGenTests.java b/boat-scaffold/src/test/java/com/backbase/oss/codegen/java/BoatJavaCodeGenTests.java index 5d9e49e20..a0ef488ee 100644 --- a/boat-scaffold/src/test/java/com/backbase/oss/codegen/java/BoatJavaCodeGenTests.java +++ b/boat-scaffold/src/test/java/com/backbase/oss/codegen/java/BoatJavaCodeGenTests.java @@ -7,30 +7,41 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.openapitools.codegen.languages.JavaClientCodegen.GENERATE_CLIENT_AS_BEAN; import com.github.javaparser.StaticJavaParser; import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.BodyDeclaration; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; import com.github.javaparser.ast.body.MethodDeclaration; import com.github.javaparser.ast.body.TypeDeclaration; +import com.github.javaparser.ast.stmt.ReturnStmt; +import com.github.javaparser.ast.stmt.Statement; import io.swagger.parser.OpenAPIParser; import io.swagger.v3.parser.core.models.ParseOptions; import java.io.File; import java.io.FileNotFoundException; +import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.Function; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.openapitools.codegen.CliOption; import org.openapitools.codegen.ClientOptInput; import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.config.CodegenConfigurator; class BoatJavaCodeGenTests { static final String PROP_BASE = BoatJavaCodeGenTests.class.getSimpleName() + "."; static final String TEST_OUTPUT = System.getProperty(PROP_BASE + "output", "target/boat-java-codegen-tests"); + @Test void clientOptsUnicity() { final BoatJavaCodeGen gen = new BoatJavaCodeGen(); @@ -168,4 +179,118 @@ void shouldHonourBeanValidationOption(boolean useBeanValidation) throws FileNotF assertThat("Expect jakarta Valid import", compilationUnit.getImports().stream().anyMatch( id -> id.getNameAsString().equals("jakarta.validation.Valid")), is(useBeanValidation)); } + + @Test + void shouldGenerateBackwardCompatibleSingleRequestParameterOverloads(@TempDir Path temporaryDirectory) throws FileNotFoundException { + ClassOrInterfaceDeclaration api = generateRestTemplateClient( + temporaryDirectory.resolve("generated-enabled"), + true + ); + + ClassOrInterfaceDeclaration parameters = findNestedClass(api, "ListPetsParam").orElseThrow(); + assertTrue(parameters.isStatic()); + assertTrue(findNestedClass(api, "ShowPetByIdParam").isEmpty()); + + findMethod(api, "listPets", "Integer", "String"); + findMethod(api, "listPetsWithHttpInfo", "Integer", "String"); + + MethodDeclaration listPets = findMethod(api, "listPets", "ListPetsParam"); + assertEquals("listPetsWithHttpInfo(params).getBody()", returnExpression(listPets)); + + MethodDeclaration listPetsWithHttpInfo = findMethod(api, "listPetsWithHttpInfo", "ListPetsParam"); + assertEquals( + "listPetsWithHttpInfo(params.getLimit(), params.getStatus())", + returnExpression(listPetsWithHttpInfo) + ); + } + + @Test + void shouldNotGenerateSingleRequestParameterOverloadsByDefault( + @TempDir Path temporaryDirectory + ) throws FileNotFoundException { + ClassOrInterfaceDeclaration api = generateRestTemplateClient( + temporaryDirectory.resolve("generated-disabled"), + false + ); + + assertFalse(findNestedClass(api, "ListPetsParam").isPresent()); + assertEquals(1, api.getMethodsByName("listPets").size()); + assertEquals(1, api.getMethodsByName("listPetsWithHttpInfo").size()); + findMethod(api, "listPets", "Integer", "String"); + findMethod(api, "listPetsWithHttpInfo", "Integer", "String"); + } + + private ClassOrInterfaceDeclaration generateRestTemplateClient(Path outputDirectory, boolean useSingleRequestParameter) + throws FileNotFoundException { + CodegenConfigurator configurator = getCodegenConfigurator(outputDirectory); + + if (useSingleRequestParameter) { + configurator.addAdditionalProperty("useSingleRequestParameter", true); + } + + File generatedApi = new DefaultGenerator() + .opts(configurator.toClientOptInput()) + .generate() + .stream() + .filter(file -> file.getName().equals("PetsApi.java")) + .findFirst() + .orElseThrow(); + + return StaticJavaParser.parse(generatedApi) + .getClassByName("PetsApi") + .orElseThrow(); + } + + private CodegenConfigurator getCodegenConfigurator(Path outputDirectory) { + CodegenConfigurator configurator = new CodegenConfigurator(); + configurator.setGeneratorName("boat-java"); + configurator.setLibrary("resttemplate"); + configurator.setInputSpec( + getFile("/boat-java/petstore-single-request-parameter.yaml") + .getAbsolutePath() + ); + configurator.setOutputDir(outputDirectory.toAbsolutePath().toString()); + configurator.setApiPackage("com.example.api"); + configurator.setModelPackage("com.example.model"); + return configurator; + } + + private static MethodDeclaration findMethod(ClassOrInterfaceDeclaration api, String name, String... parameterTypes) { + List methods = api.getMethodsBySignature(name, parameterTypes); + + assertEquals(1, methods.size(), + () -> "Expected exactly one method " + name + List.of(parameterTypes) + ", but found " + methods.size() + ); + + return methods.get(0); + } + + private static Optional findNestedClass(ClassOrInterfaceDeclaration api, String name) { + return api.getMembers() + .stream() + .filter(BodyDeclaration::isClassOrInterfaceDeclaration) + .map(BodyDeclaration::asClassOrInterfaceDeclaration) + .filter(type -> type.getNameAsString().equals(name)) + .findFirst(); + } + + private static String returnExpression(MethodDeclaration method) { + return method.getBody() + .orElseThrow() + .getStatements() + .stream() + .filter(Statement::isReturnStmt) + .map(Statement::asReturnStmt) + .map(ReturnStmt::getExpression) + .flatMap(Optional::stream) + .map(Object::toString) + .findFirst() + .orElseThrow(() -> new AssertionError( + "No direct return statement found in " + method.getSignature() + )); + } + + private File getFile(String fileName) { + return new File(getClass().getResource(fileName).getFile()); + } } diff --git a/boat-scaffold/src/test/resources/boat-java/petstore-single-request-parameter.yaml b/boat-scaffold/src/test/resources/boat-java/petstore-single-request-parameter.yaml new file mode 100644 index 000000000..7d213bbde --- /dev/null +++ b/boat-scaffold/src/test/resources/boat-java/petstore-single-request-parameter.yaml @@ -0,0 +1,153 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT +servers: + - url: http://petstore.swagger.io/v1 +paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + - name: status + in: query + description: Status of pets to return + required: false + schema: + type: string + responses: + '200': + description: A paged array of pets + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/Pets" + text/csv: + schema: + type: string + 500: + content: + application/json: + schema: + $ref: '#/components/schemas/InternalServerError' + description: InternalServerError + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + summary: Create a pet + operationId: createPets + tags: + - pets + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /no-schema: + get: + summary: No Schema + operationId: getNoSchema + tags: + - pets + responses: + 200: + description: A response that does not specify a schema + content: + application/json: {} +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + size: + $ref: '#/components/schemas/Size' + Pets: + type: array + items: + $ref: "#/components/schemas/Pet" + Size: + type: string + description: Size of the pet + enum: + - SMALL + - MEDIUM + - LARGE + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string + InternalServerError: + type: object + required: + - message + properties: + message: + type: string