Skip to content
Open
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions boat-maven-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,19 @@ Same with `generate` but with opinionated defaults for Rest Template Client
</additionalProperties>
</configuration>

### 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.

<configuration>
...
<configOptions>
<useSingleRequestParameter>true</useSingleRequestParameter>
</configOptions>
</configuration>

### 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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void> localReturnType = new ParameterizedTypeReference<Void>() {};{{/returnType}}
return apiClient.invokeAPI("{{{path}}}", HttpMethod.{{httpMethod}}, {{#hasPathParams}}uriVariables{{/hasPathParams}}{{^hasPathParams}}Collections.<String, Object>emptyMap(){{/hasPathParams}}, localVarQueryParams, localVarPostBody, localVarHeaderParams, localVarCookieParams, localVarFormParams, localVarAccept, localVarContentType, localVarAuthNames, localReturnType);
}
{{#singleRequestParameter}}
{{#hasParams}}
{{^hasSingleParam}}
{{>libraries/resttemplate/singleRequestParameter}}
{{/hasSingleParam}}
{{/hasParams}}
{{/singleRequestParameter}}

{{#-last}}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -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<org.springframework.core.io.Resource>{{/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<org.springframework.core.io.Resource>{{/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<org.springframework.core.io.Resource>{{/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}}
* <p><b>{{code}}</b>{{#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 <a href="{{url}}">{{summary}} Documentation</a>
{{/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}}
* <p><b>{{code}}</b>{{#message}} - {{.}}{{/message}}
{{/responses}}
*
* @param params parameters for the {{operationId}} operation
* @return ResponseEntity&lt;{{returnType}}{{^returnType}}Void{{/returnType}}&gt;
* @throws RestClientException if an error occurs while attempting to invoke the API
{{#externalDocs}}
* {{description}}
* @see <a href="{{url}}">{{summary}} Documentation</a>
{{/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}}
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<MethodDeclaration> 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<ClassOrInterfaceDeclaration> 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());
}
}
Loading