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