Skip to content

Commit 3d946cf

Browse files
Merge branch 'main' into all-contributors/add-Orbifoldt
2 parents 6507798 + 41366c0 commit 3d946cf

File tree

16 files changed

+471
-36
lines changed

16 files changed

+471
-36
lines changed

.all-contributorsrc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"avatar_url": "https://avatars.githubusercontent.com/u/11776454?v=4",
2222
"profile": "http://thegreatapi.com",
2323
"contributions": [
24-
"doc"
24+
"doc",
25+
"code"
2526
]
2627
},
2728
{

README.md

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,69 @@ org.acme.openapi.simple.api.DefaultApi/byeGet/CircuitBreaker/failureRatio=3.14
276276
org.acme.openapi.simple.api.DefaultApi/byeGet/CircuitBreaker/successThreshold=22
277277
````
278278

279+
## Sending multipart/form-data
280+
The rest client also supports request with mime-type multipart/form-data and, if the schema of the request body is known in advance, we can also automatically generate the models of the request bodies.
281+
282+
You need to add the following additional dependency to your `pom.xml`:
283+
```xml
284+
<dependency>
285+
<groupId>io.quarkus</groupId>
286+
<artifactId>quarkus-resteasy-multipart</artifactId>
287+
</dependency>
288+
```
289+
For any multipart/form-data operation a model for the request body will be generated. Each part of the multipart is a field in this model that is annotated with the following annotations:
290+
- `javax.ws.rs.FormParam`, where the value parameter denotes the part name,
291+
- `org.jboss.resteasy.annotations.providers.multipart.PartType`, where the parameter is the jax-rs MediaType of the part (see below for details),
292+
- and, if the part contains a file, `org.jboss.resteasy.annotations.providers.multipart.PartFilename`, with a generated default parameter that will be passed as the fileName sub-header in the Content-Disposition header of the part.
293+
294+
For example, the model for a request that requires a file, a string and some complex object will look like this:
295+
```java
296+
public class MultipartBody {
297+
298+
@FormParam("file")
299+
@PartType(MediaType.APPLICATION_OCTET_STREAM)
300+
@PartFilename("defaultFileName")
301+
public File file;
302+
303+
@FormParam("fileName")
304+
@PartType(MediaType.TEXT_PLAIN)
305+
public String fileName;
306+
307+
@FormParam("someObject")
308+
@PartType(MediaType.APPLICATION_JSON)
309+
public MyComplexObject someObject;
310+
}
311+
```
312+
313+
Then in the client the `org.jboss.resteasy.annotations.providers.multipart.MultipartForm` annotation is added in front of the multipart parameter:
314+
```java
315+
@Path("/echo")
316+
@RegisterRestClient
317+
public interface MultipartService {
318+
319+
@POST
320+
@Consumes(MediaType.MULTIPART_FORM_DATA)
321+
@Produces(MediaType.TEXT_PLAIN)
322+
String sendMultipartData(@MultipartForm MultipartBody data);
323+
324+
}
325+
```
326+
See [Quarkus - Using the REST Client with Multipart](https://quarkus.io/guides/rest-client-multipart) and the [RESTEasy JAX-RS specifications](https://docs.jboss.org/resteasy/docs/4.7.5.Final/userguide/html_single/index.html) for more details.
327+
328+
Importantly, if some multipart request bodies contain complex objects (i.e. non-primitives) you need to explicitly tell the Open API generator to create models for these objects by setting the `skip-form-model` property corresponding to your spec in the `application.properties` to `false`, e.g.:
329+
```properties
330+
quarkus.openapi-generator.codegen.spec."my-multipart-requests.yml".skip-form-model=false
331+
```
332+
333+
### Default content-types according to OpenAPI Specification and limitations
334+
The [OAS 3.0](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#special-considerations-for-multipart-content) specifies the following default content-types for a multipart:
335+
- If the property is a primitive, or an array of primitive values, the default Content-Type is `text/plain`
336+
- If the property is complex, or an array of complex values, the default Content-Type is `application/json`
337+
- If the property is a `type: string` with `format: binary` or `format: base64` (aka a file object), the default Content-Type is `application/octet-stream`
338+
339+
A different content-type may be defined in your api spec, but this is not yet supported in the code generation. Also, this "annotation-oriented" approach of RestEasy (i.e. using `@MultipartForm` to denote the multipart body parameter) does not seem to properly support the unmarshalling of arrays of the same type (e.g. array of files), in these cases it uses Content-Type equal to `application/json`.
340+
341+
279342
## Generating files via InputStream
280343

281344
Having the files in the `src/main/openapi` directory will generate the REST stubs by default. Alternatively, you can implement the `io.quarkiverse.openapi.generator.codegen.OpenApiSpecInputProvider`
@@ -304,7 +367,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
304367
<table>
305368
<tr>
306369
<td align="center"><a href="https://ricardozanini.medium.com/"><img src="https://avatars.githubusercontent.com/u/1538000?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ricardo Zanini</b></sub></a><br /><a href="https://github.com/quarkiverse/quarkus-openapi-generator/commits?author=ricardozanini" title="Code">💻</a> <a href="#maintenance-ricardozanini" title="Maintenance">🚧</a></td>
307-
<td align="center"><a href="http://thegreatapi.com"><img src="https://avatars.githubusercontent.com/u/11776454?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Helber Belmiro</b></sub></a><br /><a href="https://github.com/quarkiverse/quarkus-openapi-generator/commits?author=hbelmiro" title="Documentation">📖</a></td>
370+
<td align="center"><a href="http://thegreatapi.com"><img src="https://avatars.githubusercontent.com/u/11776454?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Helber Belmiro</b></sub></a><br /><a href="https://github.com/quarkiverse/quarkus-openapi-generator/commits?author=hbelmiro" title="Documentation">📖</a> <a href="https://github.com/quarkiverse/quarkus-openapi-generator/commits?author=hbelmiro" title="Code">💻</a></td>
308371
<td align="center"><a href="http://gastaldi.wordpress.com"><img src="https://avatars.githubusercontent.com/u/54133?v=4?s=100" width="100px;" alt=""/><br /><sub><b>George Gastaldi</b></sub></a><br /><a href="https://github.com/quarkiverse/quarkus-openapi-generator/commits?author=gastaldi" title="Code">💻</a> <a href="#infra-gastaldi" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
309372
<td align="center"><a href="https://github.com/RishiKumarRay"><img src="https://avatars.githubusercontent.com/u/87641376?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Rishi Kumar Ray</b></sub></a><br /><a href="#infra-RishiKumarRay" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
310373
<td align="center"><a href="https://github.com/fjtirado"><img src="https://avatars.githubusercontent.com/u/65240126?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Francisco Javier Tirado Sarti</b></sub></a><br /><a href="https://github.com/quarkiverse/quarkus-openapi-generator/commits?author=fjtirado" title="Code">💻</a></td>

deployment/src/main/java/io/quarkiverse/openapi/generator/deployment/SpecConfig.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public class SpecConfig {
1313
public static final String MODEL_PKG_SUFFIX = ".model";
1414
public static final String BUILD_TIME_SPEC_PREFIX_FORMAT = BUILD_TIME_CONFIG_PREFIX + ".spec.\"%s\"";
1515
private static final String BASE_PACKAGE_PROP_FORMAT = "%s.base-package";
16+
private static final String SKIP_FORM_MODEL_PROP_FORMAT = "%s.skip-form-model";
1617

1718
/**
1819
* Defines the base package name for the generated classes.
@@ -32,6 +33,10 @@ public static String getResolvedBasePackageProperty(final Path openApiFilePath)
3233
return String.format(BASE_PACKAGE_PROP_FORMAT, getBuildTimeSpecPropertyPrefix(openApiFilePath));
3334
}
3435

36+
public static String getSkipFormModelPropertyName(final Path openApiFilePath) {
37+
return String.format(SKIP_FORM_MODEL_PROP_FORMAT, getBuildTimeSpecPropertyPrefix(openApiFilePath));
38+
}
39+
3540
/**
3641
* Gets the config prefix for a given OpenAPI file in the path.
3742
* For example, given a path like /home/luke/projects/petstore.json, the returned value is

deployment/src/main/java/io/quarkiverse/openapi/generator/deployment/codegen/OpenApiGeneratorCodeGenBase.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.quarkiverse.openapi.generator.deployment.codegen;
22

33
import static io.quarkiverse.openapi.generator.deployment.SpecConfig.getResolvedBasePackageProperty;
4+
import static io.quarkiverse.openapi.generator.deployment.SpecConfig.getSkipFormModelPropertyName;
45

56
import java.io.IOException;
67
import java.nio.file.Files;
@@ -62,6 +63,10 @@ protected void generate(CodeGenContext context, final Path openApiFilePath, fina
6263
context.config()
6364
.getOptionalValue(getResolvedBasePackageProperty(openApiFilePath), String.class)
6465
.ifPresent(generator::withBasePackage);
66+
context.config()
67+
.getOptionalValue(getSkipFormModelPropertyName(openApiFilePath), String.class)
68+
.ifPresent(generator::withSkipFormModelConfig);
69+
6570
generator.generate();
6671
}
6772
}

deployment/src/main/java/io/quarkiverse/openapi/generator/deployment/template/QuteTemplatingEngineAdapter.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@ public class QuteTemplatingEngineAdapter extends AbstractTemplatingEngineAdapter
2222
"bodyParams.qute",
2323
"enumClass.qute",
2424
"enumOuterClass.qute",
25-
"formParams.qute",
2625
"headerParams.qute",
2726
"pathParams.qute",
2827
"pojo.qute",
2928
"queryParams.qute",
3029
"auth/compositeAuthenticationProvider.qute",
31-
"auth/authConfig.qute"
30+
"auth/authConfig.qute",
31+
"multipartFormdataPojo.qute"
3232
};
3333
public final Engine engine;
3434

deployment/src/main/java/io/quarkiverse/openapi/generator/deployment/wrapper/OpenApiClientGeneratorWrapper.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,17 @@ public OpenApiClientGeneratorWrapper withCircuitBreakerConfiguration(final Map<S
8080
return this;
8181
}
8282

83+
/**
84+
* Sets the global 'skipFormModel' setting. If not set this setting will default to true.
85+
*
86+
* @param skipFormModel whether to skip the generation of models for form parameters
87+
* @return this wrapper
88+
*/
89+
public OpenApiClientGeneratorWrapper withSkipFormModelConfig(final String skipFormModel) {
90+
GlobalSettings.setProperty(CodegenConstants.SKIP_FORM_MODEL, skipFormModel);
91+
return this;
92+
}
93+
8394
public List<File> generate() {
8495
this.consolidatePackageNames();
8596
return generator.opts(configurator.toClientOptInput()).generate();

deployment/src/main/resources/templates/api.qute

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,24 @@ public interface {classname} {
5858
@org.eclipse.microprofile.faulttolerance.CircuitBreaker
5959
{/if}{/for}
6060
{/if}{/for}
61-
public {#if op.returnType}{op.returnType}{#else}void{/if} {op.nickname}({#for p in op.allParams}{#include pathParams.qute param=p/}{#include queryParams.qute param=p/}{#include bodyParams.qute param=p/}{#include formParams.qute param=p/}{#include headerParams.qute param=p/}{#if p_hasNext}, {/if}{/for});
61+
public {#if op.returnType}{op.returnType}{#else}void{/if} {op.nickname}(
62+
{#if op.hasFormParams}
63+
@org.jboss.resteasy.annotations.providers.multipart.MultipartForm {op.operationIdCamelCase}MultipartForm multipartForm{#if op.hasPathParams},{/if}{!
64+
!}{#for p in op.pathParams}{#include pathParams.qute param=p/}{#if p_hasNext}, {/if}{/for}{#if op.hasQueryParams},{/if}{!
65+
!}{#for p in op.queryParams}{#include queryParams.qute param=p/}{#if p_hasNext}, {/if}{/for}{#if op.hasBodyParams},{/if}{!
66+
!}{#for p in op.bodyParams}{#include bodyParams.qute param=p/}{#if p_hasNext}, {/if}{/for}{#if op.hasHeaderParams},{/if}{!
67+
!}{#for p in op.headerParams}{#include headerParams.qute param=p/}{#if p_hasNext}, {/if}{/for}
68+
{#else}
69+
{#for p in op.allParams}
70+
{#include pathParams.qute param=p/}{#include queryParams.qute param=p/}{#include bodyParams.qute param=p/}{#include headerParams.qute param=p/}{#if p_hasNext}, {/if}
71+
{/for}
72+
{/if}
73+
);
74+
{#if op.hasFormParams}
75+
76+
{#include multipartFormdataPojo.qute param=op/}
77+
{/if}
78+
6279

6380
{/for}
6481
}

deployment/src/main/resources/templates/formParams.qute

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
public static class {op.operationIdCamelCase}MultipartForm {
2+
{#for p in op.formParams}
3+
@FormParam("{p.baseName}")
4+
{#if p.isFile || (p.isString && p.dataFormat == 'base64')}
5+
@org.jboss.resteasy.annotations.providers.multipart.PartFilename("{p.baseName}File")
6+
@org.jboss.resteasy.annotations.providers.multipart.PartType(MediaType.APPLICATION_OCTET_STREAM)
7+
{#else if p.isPrimitiveType or (p.isArray && p.items.isPrimitiveType)}
8+
@org.jboss.resteasy.annotations.providers.multipart.PartType(MediaType.TEXT_PLAIN)
9+
{#else}
10+
@org.jboss.resteasy.annotations.providers.multipart.PartType(MediaType.APPLICATION_JSON)
11+
{/if}
12+
public {p.dataType} {p.paramName};
13+
{/for}
14+
}

deployment/src/test/java/io/quarkiverse/openapi/generator/deployment/wrapper/OpenApiClientGeneratorWrapperTest.java

Lines changed: 71 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,58 +13,43 @@
1313
import java.nio.file.Paths;
1414
import java.util.List;
1515
import java.util.Map;
16-
import java.util.Objects;
1716
import java.util.Optional;
1817

1918
import org.junit.jupiter.api.Test;
2019

2120
import com.github.javaparser.StaticJavaParser;
2221
import com.github.javaparser.ast.CompilationUnit;
22+
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
2323
import com.github.javaparser.ast.body.EnumConstantDeclaration;
2424
import com.github.javaparser.ast.body.MethodDeclaration;
25+
import com.github.javaparser.ast.body.Parameter;
2526

2627
public class OpenApiClientGeneratorWrapperTest {
2728

2829
@Test
2930
void verifyCommonGenerated() throws URISyntaxException {
30-
final Path petstoreOpenApi = Path
31-
.of(requireNonNull(this.getClass().getResource("/openapi/petstore-openapi.json")).toURI());
32-
final Path targetPath = Paths.get(getTargetDir(), "openapi-gen");
33-
final OpenApiClientGeneratorWrapper generatorWrapper = new OpenApiClientGeneratorWrapper(petstoreOpenApi, targetPath);
34-
final List<File> generatedFiles = generatorWrapper.generate();
31+
final List<File> generatedFiles = createGeneratorWrapper("petstore-openapi.json").generate();
3532
assertNotNull(generatedFiles);
3633
assertFalse(generatedFiles.isEmpty());
3734
}
3835

3936
@Test
4037
void verifyAuthBasicGenerated() throws URISyntaxException {
41-
final Path petstoreOpenApi = Path
42-
.of(requireNonNull(this.getClass().getResource("/openapi/petstore-openapi-httpbasic.json")).toURI());
43-
final Path targetPath = Paths.get(getTargetDir(), "openapi-gen");
44-
final OpenApiClientGeneratorWrapper generatorWrapper = new OpenApiClientGeneratorWrapper(petstoreOpenApi, targetPath);
45-
final List<File> generatedFiles = generatorWrapper.generate();
38+
final List<File> generatedFiles = createGeneratorWrapper("petstore-openapi-httpbasic.json").generate();
4639
assertTrue(generatedFiles.stream().anyMatch(f -> f.getName().equals("CompositeAuthenticationProvider.java")));
4740
}
4841

4942
@Test
5043
void verifyAuthBearerGenerated() throws URISyntaxException {
51-
final Path petstoreOpenApi = Path
52-
.of(requireNonNull(this.getClass().getResource("/openapi/petstore-openapi-bearer.json")).toURI());
53-
final Path targetPath = Paths.get(getTargetDir(), "openapi-gen");
54-
final OpenApiClientGeneratorWrapper generatorWrapper = new OpenApiClientGeneratorWrapper(petstoreOpenApi, targetPath);
55-
final List<File> generatedFiles = generatorWrapper.generate();
44+
final List<File> generatedFiles = createGeneratorWrapper("petstore-openapi-bearer.json").generate();
5645
assertTrue(generatedFiles.stream().anyMatch(f -> f.getName().equals("CompositeAuthenticationProvider.java")));
5746
}
5847

5948
@Test
6049
void verifyEnumGeneration() throws URISyntaxException, FileNotFoundException {
61-
final Path issue28Path = Path
62-
.of(requireNonNull(this.getClass().getResource("/openapi/issue-28.yaml")).toURI());
63-
final Path targetPath = Paths.get(getTargetDir(), "openapi-gen");
64-
final OpenApiClientGeneratorWrapper generatorWrapper = new OpenApiClientGeneratorWrapper(issue28Path, targetPath)
65-
.withBasePackage("org.issue28");
66-
67-
final List<File> generatedFiles = generatorWrapper.generate();
50+
final List<File> generatedFiles = createGeneratorWrapper("issue-28.yaml")
51+
.withBasePackage("org.issue28")
52+
.generate();
6853
final Optional<File> enumFile = generatedFiles.stream()
6954
.filter(f -> f.getName().endsWith("ConnectorNamespaceState.java")).findFirst();
7055
assertThat(enumFile).isPresent();
@@ -114,15 +99,72 @@ void circuitBreaker() throws URISyntaxException, FileNotFoundException {
11499
}
115100

116101
private List<File> generateRestClientFiles() throws URISyntaxException {
117-
Path targetPath = Paths.get(getTargetDir(), "openapi-gen");
118-
119-
Path simpleOpenApiFile = Path.of(Objects.requireNonNull(getClass().getResource("/openapi/simple-openapi.json"))
120-
.toURI());
121-
122-
OpenApiClientGeneratorWrapper generatorWrapper = new OpenApiClientGeneratorWrapper(simpleOpenApiFile, targetPath)
102+
OpenApiClientGeneratorWrapper generatorWrapper = createGeneratorWrapper("simple-openapi.json")
123103
.withCircuitBreakerConfiguration(Map.of(
124104
"org.openapitools.client.api.DefaultApi", List.of("opThatDoesNotExist", "byeGet")));
125105

126106
return generatorWrapper.generate();
127107
}
108+
109+
private OpenApiClientGeneratorWrapper createGeneratorWrapper(String specFileName) throws URISyntaxException {
110+
final Path openApiSpec = Path
111+
.of(requireNonNull(this.getClass().getResource(String.format("/openapi/%s", specFileName))).toURI());
112+
final Path targetPath = Paths.get(getTargetDir(), "openapi-gen");
113+
114+
return new OpenApiClientGeneratorWrapper(openApiSpec, targetPath);
115+
}
116+
117+
@Test
118+
void verifyMultipartFormAnnotationIsGeneratedForParameter() throws URISyntaxException, FileNotFoundException {
119+
List<File> generatedFiles = createGeneratorWrapper("multipart-openapi.yml")
120+
.withSkipFormModelConfig("false")
121+
.generate();
122+
assertThat(generatedFiles).isNotEmpty();
123+
124+
Optional<File> file = generatedFiles.stream()
125+
.filter(f -> f.getName().endsWith("UserProfileDataApi.java"))
126+
.findAny();
127+
assertThat(file).isPresent();
128+
129+
CompilationUnit compilationUnit = StaticJavaParser.parse(file.orElseThrow());
130+
List<MethodDeclaration> methodDeclarations = compilationUnit.findAll(MethodDeclaration.class);
131+
assertThat(methodDeclarations).isNotEmpty();
132+
133+
Optional<MethodDeclaration> multipartPostMethod = methodDeclarations.stream()
134+
.filter(m -> m.getNameAsString().equals("postUserProfileData"))
135+
.findAny();
136+
assertThat(multipartPostMethod).isPresent();
137+
138+
List<Parameter> parameters = multipartPostMethod.orElseThrow().getParameters();
139+
assertThat(parameters).hasSize(1);
140+
141+
Parameter param = parameters.get(0);
142+
assertThat(param.getAnnotationByName("MultipartForm")).isPresent();
143+
}
144+
145+
@Test
146+
void verifyMultipartPojoGeneratedAndFieldsHaveAnnotations() throws URISyntaxException, FileNotFoundException {
147+
List<File> generatedFiles = createGeneratorWrapper("multipart-openapi.yml")
148+
.withSkipFormModelConfig("false")
149+
.generate();
150+
assertFalse(generatedFiles.isEmpty());
151+
152+
Optional<File> file = generatedFiles.stream()
153+
.filter(f -> f.getName().endsWith("UserProfileDataApi.java"))
154+
.findAny();
155+
assertThat(file).isNotEmpty();
156+
157+
CompilationUnit compilationUnit = StaticJavaParser.parse(file.orElseThrow());
158+
Optional<ClassOrInterfaceDeclaration> multipartPojo = compilationUnit.findAll(ClassOrInterfaceDeclaration.class)
159+
.stream()
160+
.filter(c -> c.getNameAsString().equals("PostUserProfileDataMultipartForm"))
161+
.findAny();
162+
assertThat(multipartPojo).isNotEmpty();
163+
164+
assertThat(multipartPojo.orElseThrow().getFields()).hasSize(3);
165+
multipartPojo.orElseThrow().getFields().forEach(field -> {
166+
assertThat(field.getAnnotationByName("FormParam")).isPresent();
167+
assertThat(field.getAnnotationByName("PartType")).isPresent();
168+
});
169+
}
128170
}

0 commit comments

Comments
 (0)