Search code examples
quarkusquarkus-rest-client

Quarkus RestClient @BeanParam equivalent


I have a case where I have a search endpoint with many possible parameters. On the server side, I have a @BeanParam keeping the method signature sane.

I would like similar functionality, to use a pojo to specify the possible params and not have to include them all in the method signature. Is there a way to do this?

    @GET
    Set<Extension> getByFilter(@RestQuery Map<String, String> filter);

seems close, but I would rather specify an actual pojo rather than deal with making a map.


Solution

  • @BeanParam is definitely works on client side.

    Configuring OpenapiTools to generate parameters as @BeanParam

    A sample endpoint which allows a custom headper parameter X-Custom-Header and three query params like: filter_name, filter_age, filter_active is documented like:

    ---
    openapi: 3.0.3
    info:
      title: so-77975157-quarkus-client-beanparam API
      version: 1.0-SNAPSHOT
    paths:
      /api:
        get:
          tags:
          - Example Resource
          parameters:
          - name: filter_active
            in: query
            schema:
              type: boolean
          - name: filter_age
            in: query
            schema:
              format: int32
              type: integer
          - name: filter_name
            in: query
            schema:
              type: string
          - name: X-Custom-Header
            in: header
            schema:
              type: string
          responses:
            "200":
              description: OK
              content:
                application/json:
                  schema:
                    uniqueItems: true
                    type: array
                    items:
                      type: string
    

    By default Openapi Generator will create a method and that's signature will contain every parameter one by one.

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public Set<String> filter(@HeaderParam("X-Custom-Header") String headerParam,
                              @QueryParam("filter_name") String nameFilter,
                              @QueryParam("filter_age") Integer ageFilter,
                              @QueryParam("filter_active") Boolean activeFilter);
    

    Using the following plugin configuration will generate interfaces using one parameter object annotated by @BeanParam

    <plugin>
        <groupId>org.openapitools</groupId>
        <artifactId>openapi-generator-maven-plugin</artifactId>
        <version>7.3.0</version>
        <executions>
            <execution>
                <goals>
                    <goal>generate</goal>
                </goals>
                <configuration>
                    <cleanupOutput>true</cleanupOutput>
                    <verbose>true</verbose>
                    <inputSpec>${project.basedir}/src/main/resources/api.yaml</inputSpec>
                    <additionalProperties>disableMultipart=true</additionalProperties>
                    <generateApiTests>false</generateApiTests>
                    <generateModelTests>false</generateModelTests>
    
                    <generatorName>java</generatorName>
                    <library>microprofile</library>
                    <configOptions>
                        <interfaceOnly>true</interfaceOnly>
                        <useJakartaEe>true</useJakartaEe>
                        <configKey>myclient</configKey>
                        <serializationLibrary>jackson</serializationLibrary>
                        <useSingleRequestParameter>true</useSingleRequestParameter>
                        <microprofileRestClientVersion>3.0</microprofileRestClientVersion>
                        <useRuntimeException>true</useRuntimeException>
                    </configOptions>
                </configuration>
            </execution>
        </executions>
    </plugin>
    

    Note #1 the magic is useSingleRequestParameter configuration option.

    Note #2 there are some hidden trap in the built-in template. E.g. an unvanted Apache CXF import definition which may cause compile errors. So, <additionalProperties>disableMultipart=true</additionalProperties> property is important to set.

    Now, the generated method signature will be:

    @GET
    @Produces({ "application/json" })
    Set<String> apiGet(@BeanParam ApiGetRequest request) throws ApiException, ProcessingException;
    

    and the ApiGetRequest (sorry for that name, I didn't use operationId and/or tags in the sample)

    public class ApiGetRequest {
    
        private @QueryParam("filter_active") Boolean filterActive;
        private @QueryParam("filter_age") Integer filterAge;
        private @QueryParam("filter_name") String filterName;
        private @HeaderParam("X-Custom-Header")  String xCustomHeader;
    
        private ApiGetRequest() {
        }
    
        public static ApiGetRequest newInstance() {
            return new ApiGetRequest();
        }
    
        /**
         * Set filterActive
         * @param filterActive  (optional)
         * @return ApiGetRequest
         */
        public ApiGetRequest filterActive(Boolean filterActive) {
            this.filterActive = filterActive;
            return this;
        }
        /**
         * Set filterAge
         * @param filterAge  (optional)
         * @return ApiGetRequest
         */
        public ApiGetRequest filterAge(Integer filterAge) {
            this.filterAge = filterAge;
            return this;
        }
        /**
         * Set filterName
         * @param filterName  (optional)
         * @return ApiGetRequest
         */
        public ApiGetRequest filterName(String filterName) {
            this.filterName = filterName;
            return this;
        }
        /**
         * Set xCustomHeader
         * @param xCustomHeader  (optional)
         * @return ApiGetRequest
         */
        public ApiGetRequest xCustomHeader(String xCustomHeader) {
            this.xCustomHeader = xCustomHeader;
            return this;
        }
    }
    

    On the server side the registered client will be available.

    @Path("/foobar")
    public class ServerSideResource {
    
        @RestClient
        ExampleResourceApi clientApi;
    
        @GET
        public Set<String> callSampleClient() {
            return clientApi.apiGet(ApiGetRequest.newInstance()
                    .filterActive(true)
                    .filterName("zforgo")
            );
        }
    
    }
    

    However, Openapi Generator can generate functions with a single argument it has lack of utilization capabilities (like inheritance, reuse common parts, etc.)

    Fortunately JakartaEE standard supports that, but it has to be created manually.

    Creating more complex API manually

    @RegisterRestClient(configKey = "person-api")
    public interface PersonClient {
    
        @GET
        @Produces(MediaType.APPLICATION_JSON)
        FilterResult<PersonDTO> search(@HeaderParam("X-Custom-Header") String headerParam,
                                       @BeanParam PersonFilter personFilter,
                                       @BeanParam PagingAndSorting pagingAndSorting);
    
    
    }
    

    As you can see, the result type is generic and paging and sorting capabilities are utilized to a separated argument.

    PagingAndSorting:

    public class PagingAndSorting {
        public static final String param_PageIndex = "page_index";
        public static final String param_PageSize = "page_size";
        public static final String param_SortCriteria = "sort_criteria";
        public static final String param_SortDirection = "sort_direction";
    
        private static final String directionAscending = "Ascending";
        public static final String pageUnlimited = "-1";
    
        @QueryParam(param_SortCriteria)
        public Optional<String> sortingCriteria;
    
        @QueryParam(param_SortDirection)
        @DefaultValue(directionAscending)
        public Sort.Direction sortDirection;
    
        @QueryParam(param_PageIndex)
        public int pageIndex;
    
        @QueryParam(param_PageSize)
        @DefaultValue(pageUnlimited)
        public int pageSize;
    
        // getters, setters, builder ...
    }
    

    PersonFilter:

    public class PersonFilter {
    
        @QueryParam("filter_name")
        private String nameFilter;
    
        @QueryParam("filter_age")
        private Integer ageFilter;
    
        @QueryParam("filter_active")
        private Boolean activeFilter;
    
        // getters, setters, builder ...
    }
    

    FilterResult:

    public class FilterResult<T> {
        private final Pagination pagination;
        private final List<T> items;
    
        public FilterResult(List<T> items, Pagination pagination) {
            this.items = items;
            this.pagination = pagination;
        }
    
        @JsonCreator
        public FilterResult(List<T> items, long totalCount, Integer pageCount, Integer pageIndex) {
            this(items, new Pagination(totalCount, pageCount, pageIndex));
        }
    
        public List<T> getItems() {
            return items;
        }
    
        public Pagination getPagination() {
            return pagination;
        }
    
        public FilterResult<T> withPageSize(Integer pageSize) {
            pagination.pageSize = pageSize;
            return this;
        }
    }
    

    Pagination:

    public class Pagination {
    
        protected long totalCount;
        protected Integer pageCount;
        protected Integer pageIndex;
    
        protected Integer pageSize;
    
        public Pagination(long totalCount, Integer pageCount, Integer pageIndex) {
            this.totalCount = totalCount;
            this.pageCount = pageCount;
            this.pageIndex = pageIndex;
        }
    
        public long getTotalCount() {
            return totalCount;
        }
    
        public Integer getPageCount() {
            return pageCount;
        }
    
        public Integer getPageIndex() {
            return pageIndex;
        }
    }
    

    Finally the server side code sample:

    @Path("/check")
    public class ServerSideResource {
    
        @RestClient
        PersonClient personApi;
    
        @GET
        @Path("/person-search")
        public FilterResult<PersonDTO> searchPerson() {
            return personApi.search("FooBar",
                    // create and fill PersonFilter
                    personFilter()
                            .filterActive(true)
                            .filterName("zforgo"),
                    // create and fill PagingAndSorting
                    pagingAndSorting()
                            .pageSize(20)
                            .sortCriteria("name")
                            .sortDirection(Sort.Direction.Ascending)
            );
        }
    }