英文:
Spring OpenApi DTOs in GET requests
问题
We use OpenAPI to generate the interface description for the services exposed in one of our server applications. We use that openapi.yaml
then to generate client-sided libraries that will interact with these services for various languages. As the server backend uses the typical Maven + Java Spring Boot techstack we use springdoc-openapi-maven-plugin
to generate the interface description and openapi-generator-maven-plugin
to generate the client library project for Java based clients.
This setup works in general but for one of our services which allows the management of various tickets we have a couple of business methods that operate on a provided search filter. This is implemented through a DTO that has roughly 30+ properties that can be specified.
Within a Spring typical RestController
named TicketController
one of the methods is now defining a GET endpoint for querying a list of tickets that match the given search filter. In Java Spring syntax this is implemented as such:
@GetMapping
public Page<Ticket> getTickets(
@ParameterObject TicketSearchFilterDTO filterDto,
@ParameterObject @PageableDefault(size = 25) Pageable pageable
) {
log.info("Getting tickets, filter {}, pageable {}", filterDto, pageable);
TicketSearchFilter filter = ticketSearchFilterDTOConverter.convert(filterDto);
return ticketSearchRepository.getTickets(filter, pageable);
}
which results in a OpenAPI interface definition like...
When generating the Java client code for this definition I will have a TicketControllerApi
class available that provides the mentioned getTickets
method. But instead of providing a TicketSearchFilterDTO
as input parameter it exposes all of the 30+ possible search parameters as arguments to that method.
openApiClient.getTicketControllerApi().getTickets("abc123", ..., 0, 25, List.of());
where the last 3 arguments represent the Pageable
parameters for page, size, and sort order. As we have so many search parameters, nulling out all of the not used search filters is a bit tedious if we i.e. only want to allow a search via the internal ID or the title of a ticket. When invoking this method, we see that a request to /tickets?startsWithInternalIdOrTitle=abc123&page=0&size=25
is made, which is picked up by our Spring ticket controller as intended. The client will only add properties to the URI that were specified and are therefore non-null values.
In an attempt to make the client code a bit less painful, I stumbled upon this Github issue where a user is using...
When generating the Java client stuff, the actual usage now changes to...
which actually matches what we aim for, however, when sending a request with this implementation, the OpenAPI client will serialize the DTO as is into the URI instead of the DTO's parameters when not null:
/tickets?filterDto=class%20TicketSearchFilterDTO%20%7B%0A%20%20%20%20startsWithInternalIdOrTitle%3A%20abc123%0A%20%20%20%20...&page=0&size=15
which our Spring backend controller can't handle and therefore will return a DTO that is actually null and therefore will result in the first 25 entries being returned.
The core issue should be contained in the way the filterDto
parameter is added: queryParams.putAll(apiClient.parameterToMultiValueMap(null, "filterDto", filterDto));
.
While manually customizing that code can surely solve this issue, this DTO is just one of many and we have plenty more controllers that operate on the same premise. Also, manually checking which properties a DTO defines involves reflection which might not be needed as the definition of the TicketSearchFilterDTO
should be there already in the openapi.yaml
definition.
Not that for PUT or POST requests where the DTO is added with @RequestBody @Valid
annotations, OpenAPI is perfectly fine in generating methods that expose DTOs to clients directly when Java code is generated therefore. Also, the response is picked up by the Spring controller this way without much issues here. So the problem here only exists for GET requests where the properties of the DTO should be encoded into the URI when performing the actual request.
As this shouldn't be a too uncommon case in practice I wonder if there is a way to generate client code that allows DTOs in GET requests being added to the URI called when a parameter is non-null? Or is there a better alternative to allow the usage of DTOs within GET requests via generated OpenAPI client code out of the box?
英文:
We use OpenAPI to generate the interface description for the services exposed in one of our server applications. We use that openapi.yaml
then to generate client-sided libraries that will interact with these services for various languages. As the server backend uses the typical Maven + Java Spring Boot techstack we use springdoc-openapi-maven-plugin
<plugin>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-maven-plugin</artifactId>
<version>1.4</version>
<executions>
<execution>
<id>integration-test</id>
<goals>
<goal>generate</goal>
</goals>
</execution>
</executions>
<configuration>
<apiDocsUrl>http://localhost:8822/v3/api-docs</apiDocsUrl>
<outputFileName>openapi.json</outputFileName>
<outputDir>${openapi.outputDir}</outputDir>
</configuration>
</plugin>
to generate the interface description and openapi-generator-maven-plugin
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<version>6.3.0</version>
<executions>
<execution>
<id>spring-webclient</id>
<phase>post-integration-test</phase>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>.../${openapi.outputDir}/openapi.json</inputSpec>
<generatorName>java</generatorName>
<library>webclient</library>
<output>${openapi.outputDir}/spring-webclient</output>
<configOptions>
<groupId>com.whatever</groupId>
<artifactId>web-api-client</artifactId>
<artifactVersion>${env.CLIENT_VERSION}</artifactVersion>
<apiPackage>com.whatever.api</apiPackage>
<modelPackage>com.whatever.dto</modelPackage>
<generateModelTests>false</generateModelTests>
<generateApiTests>false</generateApiTests>
</configOptions>
</configuration>
</execution>
</executions>
</plugin>
to generate the client library project for Java based clients.
This setup works in general but for one of our services which allows the management of various tickets we have a couple of business methods that operate on a provided search filter. This is implemented through a DTO that has roughly 30+ properties that can be specified.
@Data
@AllArgsConstructor
@NoArgsConstructor
@Schema
public class TicketSearchFilterDTO implements Serializable {
@Schema(
description = "..."
)
@JsonInclude(JsonInclude.Include.NON_NULL)
private String startsWithInternalIdOrTitle;
...
}
Within a Spring typical RestController
named TicketController
one of the methods is now defining a GET endpoint for querying a list of tickets that match the given search filter. In Java Spring syntax this is implemented as such:
@GetMapping
public Page<Ticket> getTickets(
@ParameterObject TicketSearchFilterDTO filterDto,
@ParameterObject @PageableDefault(size = 25) Pageable pageable
) {
log.info("Getting tickets, filter {}, pageable {}", filterDto, pageable);
TicketSearchFilter filter = ticketSearchFilterDTOConverter.convert(filterDto);
return ticketSearchRepository.getTickets(filter, pageable);
}
which results in a OpenAPI interface definition like
openapi: 3.0.1
info:
title: OpenAPI definition
version: '@env.CI_COMMIT_REF_NAME@'
servers:
...
paths:
...
/tickets:
get:
operationId: getTickets
parameters:
- explode: true
in: query
name: startsWithInternalIdOrTitle
required: false
schema:
description: ...
type: string
style: form
...
- description: Zero-based page index (0..N)
explode: true
in: query
name: page
required: false
schema:
default: 0
minimum: 0
type: integer
style: form
- description: The size of the page to be returned
explode: true
in: query
name: size
required: false
schema:
default: 25
minimum: 1
type: integer
style: form
- description: "Sorting criteria in the format: property,(asc|desc). Default\
\ sort order is ascending. Multiple sort criteria are supported."
explode: true
in: query
name: sort
required: false
schema:
items:
type: string
type: array
style: form
responses:
"404":
content:
'*/*':
schema:
$ref: '#/components/schemas/NotFoundError'
description: Not Found
"400":
content:
'*/*':
schema:
type: object
description: Bad Request
"409":
content:
'*/*':
schema:
type: object
description: Conflict
"200":
content:
'*/*':
schema:
$ref: '#/components/schemas/PageTicket'
description: OK
tags:
- ticket-controller
x-accepts: '*/*'
When generating the Java client code for this definition I will have a TicketControllerApi
class available that provides the mentioned getTickets
method. But instead of providing a TicketSearchFilterDTO
as input parameter it exposes all of the 30+ possible search parameters as argument to that method.
openApiClient.getTicketControllerApi().getTickets("abc123", ..., 0, 25, List.of());
where the last 3 arguments represents the Pageable
parameters for page, size and sort order. As we have so many search parameters nulling out all of the not used search filters is a bit tedious if we i.e. only want to allow a search via the internal ID or the title of a ticket. When invoking this method we see that a request to /tickets?startsWithInternalIdOrTitle=abc123&page=0&size=25
is made, which is picked up by our Spring ticket controller as intended. The client will only add properties to the URI that were specified and are therefore non-null values.
In an attempt to make the client code a bit less painful I stumbled on this Github issue where a user is using
@Parameter(
explode = Explode.TRUE,
in = ParameterIn.QUERY,
content = @Content(
schema = @Schema(implementation = TicketSearchFilterDTO.class,
ref = "#/components/schemas/TicketSearchFilterDTO")
)
) final TicketSearchFilterDTO filterDto
instead of
@ParameterObject final TicketSearchFilterDTO filterDto
as argument definition for the getTickets(...)
method. This will change the OpenAPI definition to
openapi: 3.0.1
info:
title: OpenAPI definition
version: '@env.CI_COMMIT_REF_NAME@'
servers:
...
paths:
...
/tickets:
get:
operationId: getTickets
parameters:
- content:
'*/*':
schema:
$ref: '#/components/schemas/TicketSearchFilterDTO'
in: query
name: filterDto
required: true
- description: Zero-based page index (0..N)
explode: true
in: query
name: page
required: false
schema:
default: 0
minimum: 0
type: integer
style: form
- description: The size of the page to be returned
explode: true
in: query
name: size
required: false
schema:
default: 25
minimum: 1
type: integer
style: form
- description: "Sorting criteria in the format: property,(asc|desc). Default\
\ sort order is ascending. Multiple sort criteria are supported."
explode: true
in: query
name: sort
required: false
schema:
items:
type: string
type: array
style: form
responses:
"404":
content:
'*/*':
schema:
$ref: '#/components/schemas/NotFoundError'
description: Not Found
"400":
content:
'*/*':
schema:
type: object
description: Bad Request
"409":
content:
'*/*':
schema:
type: object
description: Conflict
"200":
content:
'*/*':
schema:
$ref: '#/components/schemas/PageTicket'
description: OK
tags:
- ticket-controller
x-accepts: '*/*'
...
components:
...
schemas:
...
TicketSearchFilterDTO:
properties:
startsWithInternalIdOrTitle:
description: ...
type: string
...
type: object
...
When generating the Java client stuff the actual usage now changes to
TicketSearchFilterDTO searchFilter = new TicketSearchFilterDTO();
searchFilter.setStartsWithInternalIdOrTitle("abc123");
openApiClient.getTicketControllerApi().getTickets(searchFilter, 0, 25, List.of());
which actually matches what we aim for, however, when sending a request with this implementation the OpenAPI client will serialize the DTO as is into the URI instead of the DTO's parameters when not null:
/tickets?filterDto=class%20TicketSearchFilterDTO%20%7B%0A%20%20%20%20startsWithInternalIdOrTitle%3A%20abc123%0A%20%20%20%20...&page=0&size=15
which our Spring backend controller can't handle and therefore will return a DTO that is actually null and therefore will result in the first 25 entries being returned. The generated client Java code does look like this:
public Mono<PageTicket> getTickets(TicketSearchFilterDTO filterDto, Integer page, Integer size, List<String> sort) throws WebClientResponseException {
ParameterizedTypeReference<PageTicket> localVarReturnType = new ParameterizedTypeReference<PageTicket>() {};
return getTicketsRequestCreation(filterDto, page, size, sort).bodyToMono(localVarReturnType);
}
private ResponseSpec getTicketsRequestCreation(TicketSearchFilterDTO filterDto, Integer page, Integer size, List<String> sort) throws WebClientResponseException {
Object postBody = null;
// verify the required parameter 'filterDto' is set
if (filterDto == null) {
throw new WebClientResponseException("Missing the required parameter 'filterDto' when calling getTickets", HttpStatus.BAD_REQUEST.value(), HttpStatus.BAD_REQUEST.getReasonPhrase(), null, null, null);
}
// create path and map variables
final Map<String, Object> pathParams = new HashMap<String, Object>();
final MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<String, String>();
final HttpHeaders headerParams = new HttpHeaders();
final MultiValueMap<String, String> cookieParams = new LinkedMultiValueMap<String, String>();
final MultiValueMap<String, Object> formParams = new LinkedMultiValueMap<String, Object>();
queryParams.putAll(apiClient.parameterToMultiValueMap(null, "filterDto", filterDto));
queryParams.putAll(apiClient.parameterToMultiValueMap(null, "page", page));
queryParams.putAll(apiClient.parameterToMultiValueMap(null, "size", size));
queryParams.putAll(apiClient.parameterToMultiValueMap(ApiClient.CollectionFormat.valueOf("multi".toUpperCase(Locale.ROOT)), "sort", sort));
final String[] localVarAccepts = {
"*/*"
};
final List<MediaType> localVarAccept = apiClient.selectHeaderAccept(localVarAccepts);
final String[] localVarContentTypes = { };
final MediaType localVarContentType = apiClient.selectHeaderContentType(localVarContentTypes);
String[] localVarAuthNames = new String[] { };
ParameterizedTypeReference<PageTicket> localVarReturnType = new ParameterizedTypeReference<PageTicket>() {};
return apiClient.invokeAPI("/tickets", HttpMethod.GET, pathParams, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType);
}
and so the core issue should be contained in the way the filterDto
parameter is added: queryParams.putAll(apiClient.parameterToMultiValueMap(null, "filterDto", filterDto));
.
While manually customizing that code can surely solve this issue this DTO is just one of many and we have plenty more controllers that operate on the same premise. Also, manually checking which properties a DTO defines involves reflection which might not be needed as the definition of the TicketSearchFilterDTO
should be there already in the openapi.yaml
definition.
Not that for PUT or POST requests where the DTO is added with @RequestBody @Valid
annotations, OpenAPI is perfectly fine in generating method that expose DTOs to clients directly when Java code is generated therefore. Also the response is picked up by the Spring controller this way without much issues here. So the problem here only exists for GET requests where the properties of the DTO should be encoded into the URI when performing the actual request.
As this shouldn't be a to uncommon case in practice I wonder if there is a way to generate client code that allows DTOs in GET requests being added to the URI called when a parameter is non-null? Or is there a better alternative to allow the usage of DTOs within GET requests via generated OpenAPI client code out of the box?
答案1
得分: 1
以下是您提供的代码的翻译:
在进一步调查问题并根据Swagger序列化文档检查后,生成给定控制器方法的openapi.yaml
,如下所示:
@GetMapping
public Page<Ticket> getTickets(
@Parameter(explode = Explode.TRUE,
in = ParameterIn.QUERY,
style = ParameterStyle.FORM
) final TicketSearchFilterDTO filterDto,
@ParameterObject @PageableDefault(size = 25) Pageable pageable
) {
log.info("Getting tickets, filter {}, pageable {}", filterDto, pageable);
TicketSearchFilter filter = ticketSearchFilterDTOConverter.convert(filterDto);
return ticketSearchRepository.getTickets(filter, pageable);
}
这将生成一个类似以下的openapi.yaml
输出:
openapi: 3.0.1
info:
title: OpenAPI definition
version: '@env.CI_COMMIT_REF_NAME@'
servers:
...
paths:
...
/tickets:
get:
operationId: getTickets
parameters:
- explode: true
in: query
name: filterDto
required: true
schema:
$ref: '#/components/schemas/TicketSearchFilterDTO'
style: form
- description: 从零开始的页面索引 (0..N)
explode: true
in: query
name: page
required: false
schema:
default: 0
minimum: 0
type: integer
style: form
- description: 要返回的页面大小
explode: true
in: query
name: size
required: false
schema:
default: 25
minimum: 1
type: integer
style: form
- description: "以属性,(asc|desc)格式的排序标准。默认排序顺序是升序。支持多个排序标准。"
explode: true
in: query
name: sort
required: false
schema:
items:
type: string
type: array
style: form
responses:
"404":
content:
'*/*':
schema:
$ref: '#/components/schemas/NotFoundError'
description: 未找到
"400":
content:
'*/*':
schema:
type: object
description: 错误请求
"409":
content:
'*/*':
schema:
type: object
description: 冲突
"200":
content:
'*/*':
schema:
$ref: '#/components/schemas/PageTicket'
description: 正常
tags:
- ticket-controller
x-accepts: '*/*'
...
components:
schemas:
...
TicketSearchFilterDTO:
properties:
startsWithInternalIdOrTitle:
description: ...
type: string
...
type: object
...
看起来一切都没问题。这将导致所述客户端API,该API将过滤选项封装在所需的DTO对象中。但是,openapi-generator-maven-plugin
似乎在某种程度上缺乏对这些情况的覆盖。
生成器使用Mustache进行客户端生成,输入参数的相应api.mustache将生成如下代码作为RequestCreation子方法的一部分:
queryParams.putAll(apiClient.parameterToMultiValueMap(null, "filterDto", filterDto));
这行代码特别负责将给定的DTO添加到最终由HTTP客户端调用的查询参数中。不幸的是,这将导致整个DTO以序列化形式添加为filterDto
查询参数,而不是将其属性添加到查询参数中。然而,Spring后端不知道filterDto
查询参数,因此无论输入如何,它都会返回前25个票。
Maven插件允许在<configOptions>
部分之前添加以下行以指定自定义的Mustache模板:
<templateDirectory>
./open-api/templates
</templateDirectory>
现在只需在上面提到的链接中的api.mustache
中自定义代码,如下所示:
if ({{paramName}}.getClass().getSimpleName().endsWith("DTO")) {
// 遍历DTO的属性
Class<?> clazz = {{paramName}}.getClass();
for (Field field : clazz.getDeclaredFields()) {
if (Modifier.isPrivate(field.getModifiers()) && !Modifier.isStatic(field.getModifiers())) {
field.setAccessible(true);
String fieldName = field.getName();
try {
Object value = field.get({{paramName}});
queryParams.putAll(apiClient.parameterToMultiValueMap({{#collectionFormat}}ApiClient.CollectionFormat.valueOf("{{{.}}}".toUpperCase(Locale.ROOT)){{/collectionFormat}}{{^collectionFormat}}null{{/collectionFormat}}, fieldName, value));
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
} else {
queryParams.putAll(apiClient.parameterToMultiValueMap({{#collectionFormat}}ApiClient.CollectionFormat.valueOf("{{{.}}}".toUpperCase(Locale.ROOT)){{/collectionFormat}}{{^collectionFormat}}null{{/collectionFormat}}, "{{baseName}}", {{paramName}}));
}
并在Mustache文件的顶部添加以下导入:
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
这将修改生成的代码,以便对于类名以DTO
结尾的任何参数,将其直接添加到查询参数的操作替换为将其所有定义的属性添加到查询参数列表中。
虽然我更喜欢不需要首先使用反射的解决方案,而是直接使用组件方案的属性定义,但这至少能够解决目前的问题。
英文:
After investigating the issue at hand further and checking against the Swagger serialization documentation the generation of the openapi.yaml
for the given controller method
@GetMapping
public Page<Ticket> getTickets(
@Parameter(explode = Explode.TRUE,
in = ParameterIn.QUERY,
style = ParameterStyle.FORM
) final TicketSearchFilterDTO filterDto,
@ParameterObject @PageableDefault(size = 25) Pageable pageable
) {
log.info("Getting tickets, filter {}, pageable {}", filterDto, pageable);
TicketSearchFilter filter = ticketSearchFilterDTOConverter.convert(filterDto);
return ticketSearchRepository.getTickets(filter, pageable);
}
which results in an openapi.yaml
output like
openapi: 3.0.1
info:
title: OpenAPI definition
version: '@env.CI_COMMIT_REF_NAME@'
servers:
...
paths:
...
/tickets:
get:
operationId: getTickets
parameters:
- explode: true
in: query
name: filterDto
required: true
schema:
$ref: '#/components/schemas/TicketSearchFilterDTO'
style: form
- description: Zero-based page index (0..N)
explode: true
in: query
name: page
required: false
schema:
default: 0
minimum: 0
type: integer
style: form
- description: The size of the page to be returned
explode: true
in: query
name: size
required: false
schema:
default: 25
minimum: 1
type: integer
style: form
- description: "Sorting criteria in the format: property,(asc|desc). Default\
\ sort order is ascending. Multiple sort criteria are supported."
explode: true
in: query
name: sort
required: false
schema:
items:
type: string
type: array
style: form
responses:
"404":
content:
'*/*':
schema:
$ref: '#/components/schemas/NotFoundError'
description: Not Found
"400":
content:
'*/*':
schema:
type: object
description: Bad Request
"409":
content:
'*/*':
schema:
type: object
description: Conflict
"200":
content:
'*/*':
schema:
$ref: '#/components/schemas/PageTicket'
description: OK
tags:
- ticket-controller
x-accepts: '*/*'
...
components:
schemas:
...
TicketSearchFilterDTO:
properties:
startsWithInternalIdOrTitle:
description: ...
type: string
...
type: object
...
seems to be fine. This will result in mentioned client API that results in encapsulating the filter options within the desired DTO object. However, openapi-generator-maven-plugin
seems to lack coverage of these cases somehow.
The generator uses Mustache for the client generation and the respective api.mustache for the input parameter will generate i.e. the following line as part of the RequestCreation sub method:
queryParams.putAll(apiClient.parameterToMultiValueMap(null, "filterDto", filterDto));
This line is in particular responsible for adding the given DTO to the query parameters that will be called by the HTTP client at the end. And this will unfortunately result in the whole DTO being added as fliterDto
query parameter in a serialized from rather than its properties being added to the query parameters. That filterDto
query parameter is though unknown by the Spring backend and thus will return the first 25 tickets if find regardless of the input.
The Maven plugin though allows to specify custom Mustache templates i.e. via the following lines added before the <configOptions>
section:
<templateDirectory>
./open-api/templates
</templateDirectory>
Now it is just a matter of customizing api.mustache
at the referenced line in the above-mentioned link with the following code:
if ({{paramName}}.getClass().getSimpleName().endsWith("DTO")) {
// iterate through DTOs properties
Class<?> clazz = {{paramName}}.getClass();
for (Field field : clazz.getDeclaredFields()) {
if (Modifier.isPrivate(field.getModifiers()) && !Modifier.isStatic(field.getModifiers())) {
field.setAccessible(true);
String fieldName = field.getName();
try {
Object value = field.get({{paramName}});
queryParams.putAll(apiClient.parameterToMultiValueMap({{#collectionFormat}}ApiClient.CollectionFormat.valueOf("{{{.}}}".toUpperCase(Locale.ROOT)){{/collectionFormat}}{{^collectionFormat}}null{{/collectionFormat}}, fieldName, value));
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
} else {
queryParams.putAll(apiClient.parameterToMultiValueMap({{#collectionFormat}}ApiClient.CollectionFormat.valueOf("{{{.}}}".toUpperCase(Locale.ROOT)){{/collectionFormat}}{{^collectionFormat}}null{{/collectionFormat}}, "{{baseName}}", {{paramName}}));
}
and adding imports for
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
at the top of the Mustache file to modify the generated code so that any parameters which class name ends with DTO
to replace the previous adding of the DTO object directly to the query parameters with adding all of its defined properties to the query parameter list.
While I'd prefer a solution that doesn't need reflection in first place and instead use the property definition from the component scheme directly, this at least gets the job done for now.
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论