From 3c658961d9db16a408f2d93d174a16660d578ed2 Mon Sep 17 00:00:00 2001 From: Przemek Trzaskala Date: Mon, 24 Aug 2026 16:37:43 +0200 Subject: [PATCH] feat: add single request parameter support for RestTemplate feat: add single request parameter support for RestTemplate p3 --- README.md | 5 + boat-maven-plugin/README.md | 13 ++ .../libraries/resttemplate/api.mustache | 123 +++----------- .../singleRequestParameter.mustache | 124 ++++++++++++++ .../codegen/java/BoatJavaCodeGenTests.java | 150 +++++++++++++++++ .../petstore-single-request-parameter.yaml | 153 ++++++++++++++++++ 6 files changed, 464 insertions(+), 104 deletions(-) create mode 100644 boat-scaffold/src/main/templates/boat-java/libraries/resttemplate/singleRequestParameter.mustache create mode 100644 boat-scaffold/src/test/resources/boat-java/petstore-single-request-parameter.yaml diff --git a/README.md b/README.md index 8a37eb7d6..dbe903913 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,11 @@ BOAT is still under development and subject to change. See also: https://github.com/Backbase/backbase-openapi-tools/releases + +## 0.18.6 + * * Java RestTemplate generator: added `useSingleRequestParameter` support to replace positional parameter lists with named `*Param` objects for multi-parameter operations, + see [boat-maven-plugin README](boat-maven-plugin/README.md#single-request-parameter). + ## 0.18.4 * **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..5fa4ca828 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 multi-parameter API operations and uses it as +the method's single argument instead of a positional parameter list. Operations with zero or one parameter +remain unchanged. + + + ... + + 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..eab65ff29 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 @@ -52,111 +52,26 @@ public class {{classname}} extends BaseApi { } {{#operation}} - /** - * {{summary}} - * {{notes}} - {{#responses}} - *

{{code}}{{#message}} - {{.}}{{/message}} - {{/responses}} - {{#allParams}} - * @param {{paramName}} {{description}}{{#required}} (required){{/required}}{{^required}} (optional{{#defaultValue}}, default to {{.}}{{/defaultValue}}){{/required}} - {{/allParams}} - {{#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}}({{#allParams}}{{#isFile}}{{#useAbstractionForFiles}}{{#collectionFormat}}java.util.Collection{{/collectionFormat}}{{^collectionFormat}}org.springframework.core.io.Resource{{/collectionFormat}}{{/useAbstractionForFiles}}{{^useAbstractionForFiles}}{{{dataType}}}{{/useAbstractionForFiles}}{{/isFile}}{{^isFile}}{{{dataType}}}{{/isFile}} {{paramName}}{{^-last}}, {{/-last}}{{/allParams}}) throws RestClientException { - {{#returnType}} - return {{operationId}}WithHttpInfo({{#allParams}}{{paramName}}{{^-last}}, {{/-last}}{{/allParams}}).getBody(); - {{/returnType}} - {{^returnType}} - {{operationId}}WithHttpInfo({{#allParams}}{{paramName}}{{^-last}}, {{/-last}}{{/allParams}}); - {{/returnType}} - } - - /** - * {{summary}} - * {{notes}} - {{#responses}} - *

{{code}}{{#message}} - {{.}}{{/message}} - {{/responses}} - {{#allParams}} - * @param {{paramName}} {{description}}{{#required}} (required){{/required}}{{^required}} (optional{{#defaultValue}}, default to {{.}}{{/defaultValue}}){{/required}} - {{/allParams}} - * @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({{#allParams}}{{#isFile}}{{#useAbstractionForFiles}}{{#collectionFormat}}java.util.Collection{{/collectionFormat}}{{^collectionFormat}}org.springframework.core.io.Resource{{/collectionFormat}}{{/useAbstractionForFiles}}{{^useAbstractionForFiles}}{{{dataType}}}{{/useAbstractionForFiles}}{{/isFile}}{{^isFile}}{{{dataType}}}{{/isFile}} {{paramName}}{{^-last}}, {{/-last}}{{/allParams}}) throws RestClientException { - Object localVarPostBody = {{#bodyParam}}{{paramName}}{{/bodyParam}}{{^bodyParam}}null{{/bodyParam}}; - {{#allParams}}{{#required}} - // verify the required parameter '{{paramName}}' is set - if ({{paramName}} == null) { - throw new HttpClientErrorException(HttpStatus.BAD_REQUEST, "Missing the required parameter '{{paramName}}' when calling {{operationId}}"); - } - {{/required}}{{/allParams}}{{#hasPathParams}} - // create path and map variables - final Map uriVariables = new HashMap();{{#pathParams}} - uriVariables.put("{{baseName}}", {{#collectionFormat}}apiClient.collectionPathParameterToString(ApiClient.CollectionFormat.valueOf("{{{collectionFormat}}}".toUpperCase()), {{{paramName}}}){{/collectionFormat}}{{^collectionFormat}}{{{paramName}}}{{/collectionFormat}});{{/pathParams}}{{/hasPathParams}} - - final MultiValueMap localVarQueryParams = new LinkedMultiValueMap(); - final HttpHeaders localVarHeaderParams = new HttpHeaders(); - final MultiValueMap localVarCookieParams = new LinkedMultiValueMap(); - final MultiValueMap localVarFormParams = new LinkedMultiValueMap();{{#hasQueryParams}} - - {{#queryParams}}{{#isExplode}}{{#hasVars}} - if ({{paramName}} != null) { - {{#vars}} localVarQueryParams.putAll(apiClient.parameterToMultiValueMap({{#collectionFormat}}ApiClient.CollectionFormat.valueOf("{{{.}}}".toUpperCase(Locale.ROOT)){{/collectionFormat}}{{^collectionFormat}}null{{/collectionFormat}}, "{{baseName}}", {{paramName}}.{{getter}}())); - {{/vars}}}{{/hasVars}}{{^hasVars}}localVarQueryParams.putAll(apiClient.parameterToMultiValueMap({{#collectionFormat}}ApiClient.CollectionFormat.valueOf("{{{.}}}".toUpperCase(Locale.ROOT)){{/collectionFormat}}{{^collectionFormat}}null{{/collectionFormat}}, "{{baseName}}", {{paramName}})); - {{/hasVars}}{{/isExplode}}{{^isExplode}}localVarQueryParams.putAll(apiClient.parameterToMultiValueMap({{#collectionFormat}}ApiClient.CollectionFormat.valueOf("{{{.}}}".toUpperCase(Locale.ROOT)){{/collectionFormat}}{{^collectionFormat}}null{{/collectionFormat}}, "{{baseName}}", {{paramName}})); - {{/isExplode}}{{/queryParams}}{{/hasQueryParams}}{{#hasHeaderParams}} - - {{#headerParams}}if ({{paramName}} != null) - localVarHeaderParams.add("{{baseName}}", apiClient.parameterToString({{paramName}}));{{^-last}} - {{/-last}}{{/headerParams}}{{/hasHeaderParams}}{{#hasCookieParams}} - - {{#cookieParams}}if ({{paramName}} != null) - localVarCookieParams.add("{{baseName}}", apiClient.parameterToString({{paramName}}));{{^-last}} - {{/-last}}{{/cookieParams}}{{/hasCookieParams}}{{#hasFormParams}} - - {{#formParams}}if ({{paramName}} != null) - localVarFormParams.{{^collectionFormat}}add{{/collectionFormat}}{{#collectionFormat}}addAll{{/collectionFormat}}("{{baseName}}", {{#isFile}}{{^collectionFormat}}{{#useAbstractionForFiles}}{{paramName}}{{/useAbstractionForFiles}}{{^useAbstractionForFiles}}new FileSystemResource({{paramName}}){{/useAbstractionForFiles}}{{/collectionFormat}}{{/isFile}}{{#isFile}}{{#collectionFormat}}{{paramName}}.stream(){{^useAbstractionForFiles}}.map(FileSystemResource::new){{/useAbstractionForFiles}}.collect(Collectors.toList()){{/collectionFormat}}{{/isFile}}{{^isFile}}{{paramName}}{{/isFile}});{{^-last}} - {{/-last}}{{/formParams}}{{/hasFormParams}} - - final String[] localVarAccepts = { {{#hasProduces}} - {{#produces}}"{{{mediaType}}}"{{^-last}}, {{/-last}}{{/produces}} - {{/hasProduces}} }; - final List localVarAccept = apiClient.selectHeaderAccept(localVarAccepts); - final String[] localVarContentTypes = { {{#hasConsumes}} - {{#consumes}}"{{{mediaType}}}"{{^-last}}, {{/-last}}{{/consumes}} - {{/hasConsumes}} }; - final MediaType localVarContentType = apiClient.selectHeaderContentType(localVarContentTypes); + {{! Keep positional methods for zero- and one-parameter operations. + For multi-parameter operations, use *Param when singleRequestParameter is enabled. }} + {{^singleRequestParameter}} + {{>libraries/resttemplate/positionalMethods}} + {{/singleRequestParameter}} + {{#singleRequestParameter}} + {{^hasParams}} + {{>libraries/resttemplate/positionalMethods}} + {{/hasParams}} + {{#hasSingleParam}} + {{>libraries/resttemplate/positionalMethods}} + {{/hasSingleParam}} + {{! Param-object methods: 2+ params when singleRequestParameter=true }} + {{#hasParams}} + {{^hasSingleParam}} + {{>libraries/resttemplate/singleRequestParameter}} + {{/hasSingleParam}} + {{/hasParams}} + {{/singleRequestParameter}} - String[] localVarAuthNames = new String[] { {{#authMethods}}"{{name}}"{{^-last}}, {{/-last}}{{/authMethods}} }; - - {{#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); - } {{#-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..f43c3b4fe --- /dev/null +++ b/boat-scaffold/src/main/templates/boat-java/libraries/resttemplate/singleRequestParameter.mustache @@ -0,0 +1,124 @@ +/** +* 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() { + return "{{#lambda.titlecase}}{{operationId}}{{/lambda.titlecase}}Param{values redacted}"; + } + } + + /** + * {{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" + ); +{{#allParams}} + final {{#isFile}}{{#useAbstractionForFiles}}{{#collectionFormat}}java.util.Collection{{/collectionFormat}}{{^collectionFormat}}org.springframework.core.io.Resource{{/collectionFormat}}{{/useAbstractionForFiles}}{{^useAbstractionForFiles}}{{{dataType}}}{{/useAbstractionForFiles}}{{/isFile}}{{^isFile}}{{{dataType}}}{{/isFile}} {{paramName}} = params.get{{#lambda.titlecase}}{{paramName}}{{/lambda.titlecase}}(); +{{/allParams}} +{{>libraries/resttemplate/withHttpInfoBody}} + } \ 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..ba2f7691c 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,143 @@ 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 shouldGenerateSingleRequestParameterWhenEnabled(@TempDir Path temporaryDirectory) throws FileNotFoundException { + ClassOrInterfaceDeclaration api = generateRestTemplateClient( + temporaryDirectory.resolve("generated-enabled"), + true + ); + + // Multi-param operation: *Param class is generated and is static + ClassOrInterfaceDeclaration parameters = findNestedClass(api, "ListPetsParam").orElseThrow(); + assertTrue(parameters.isStatic()); + + // Single-param operation: no *Param class generated + assertTrue(findNestedClass(api, "ShowPetByIdParam").isEmpty()); + + // Multi-param operation: ONLY the *Param overloads exist (no positional) + assertEquals(1, api.getMethodsByName("listPets").size(), + "should have exactly one listPets method (the *Param overload)"); + assertEquals(1, api.getMethodsByName("listPetsWithHttpInfo").size(), + "should have exactly one listPetsWithHttpInfo method (the *Param overload)"); + + assertTrue(api.getMethodsBySignature("listPets", "Integer", "String").isEmpty(), + "positional listPets(Integer, String) must not exist when flag is enabled"); + assertTrue(api.getMethodsBySignature("listPetsWithHttpInfo", "Integer", "String").isEmpty(), + "positional listPetsWithHttpInfo(Integer, String) must not exist when flag is enabled"); + + // *Param method delegates to *Param WithHttpInfo + MethodDeclaration listPets = findMethod(api, "listPets", "ListPetsParam"); + assertEquals("listPetsWithHttpInfo(params).getBody()", returnExpression(listPets)); + + // *Param WithHttpInfo contains the full request-building implementation (not a delegation) + MethodDeclaration listPetsWithHttpInfo = findMethod(api, "listPetsWithHttpInfo", "ListPetsParam"); + String withHttpInfoBody = listPetsWithHttpInfo.getBody().orElseThrow().toString(); + assertTrue(withHttpInfoBody.contains("apiClient.invokeAPI"), + "listPetsWithHttpInfo(ListPetsParam) must contain the full request-building implementation"); + + // Single-param operation is unchanged: positional methods still exist, no Param class + findMethod(api, "showPetById", "String"); + findMethod(api, "showPetByIdWithHttpInfo", "String"); + assertTrue(findNestedClass(api, "ShowPetByIdParam").isEmpty()); + + // Zero-param operation is unchanged: original method still exists, no Param class + findMethod(api, "createPets"); + findMethod(api, "createPetsWithHttpInfo"); + assertTrue(findNestedClass(api, "CreatePetsParam").isEmpty()); + } + + @Test + void shouldNotGenerateSingleRequestParameterOverloadsByDefault( + @TempDir Path temporaryDirectory + ) throws FileNotFoundException { + ClassOrInterfaceDeclaration api = generateRestTemplateClient( + temporaryDirectory.resolve("generated-disabled"), + false + ); + + // No *Param classes generated + assertFalse(findNestedClass(api, "ListPetsParam").isPresent()); + + // Only positional methods exist + 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