Where is Your Deep Object in the Query?

Written by anatolii | Published 2022/11/09
Tech Story Tags: api | openapi | ts.ed | query | url | programming | functional-programming | guide

TLDRFrom version v3, the OpenApi standard brings another way to introduce query parameters via DeepObject. This approach helps to annotate and combine multiple related parameters in one general object. In a base query, you will use domain name, a path to a specific resource, and query parameters at the end. In the new way of query annotation, each of the query parameters will have a prefix - the name of the object or unique id in other words. And the properties will be surrounded by square brackets `[QUERY_PARAMTER]`via the TL;DR App

It’s a widespread situation when in the application many requests contain query parameters. From version v3, the OpenApi standard brings another way to introduce query parameters via DeepObject. You can find the specification of the standard here.

This approach helps to annotate and combine multiple related parameters in one general object. For example, in a base query, you will use the domain name, a path to a specific resource, and query parameters at the end.

{YOUR_DOMAIN}/[PATH]?[QUERY_PARAMETERS]

e.g.

https://test.com/users?userName=test_user&isBlocked=true

In the new way of query annotation, each of the query parameters will have a prefix - the name of the object or unique id in other words. And the properties will be surrounded by square brackets [QUERY_PARAMTER]. I can rewrite the previous URL to the new one with this approach:

https://test.com/users?uq[userName]=test_user&uq[isBlocked]=true

In the example above uq representing the object with fields userName and isBlocked. It can be written as a typical JSON like:

{
  uq: {
    userName: 'test_user',
    isBlocked: true
  }
}

So from the first look, this approach only adds additional data to the query string. But let’s look at how it will be represented on the Frontend and Backend sides and when there are more complex queries.

Where are more complex queries typically live? One place where they started their life is pagination. When the received array data is huge enough it is quite problematic to retrieve all data from DB and send it back to the user in a short time.

The frontend part

From the UI perspective, it will be more convenient to add an additional library if you did not use it already of course. The package QS - QueryString. It helps to map the query object to the string that will be added to the URL.

import qs from 'qs';
 
qs.stringify(params, {
    arrayFormat: 'brackets',
    encodeValuesOnly: true,
    skipNulls: true,
});

You could use this stringify function to convert an object query to an appropriate string value.

const paginationQuery = { take: 100, skip: 4000 };
const params = { pq: paginationQuery };

const queryString = qs.stringify(params, {
    arrayFormat: 'brackets',
    encodeValuesOnly: true,
    skipNulls: true,
});

// It eventually will be translated to something like:
// https://test.com/users?pq[take]=100&pq[skip]=4000

So it is pretty much done on the FE side with the deepObject. Depending on what program interface you want to use, the standard Fetch API or some library you will need to add the result query string to the URL you want to reach. It seems more complex but it adds advanced maintainability to the codebase.

The backend part

On the backend side if I use plain query params I will require to describe them individually on the controller method.

@QueryParams('take', Number)
@QueryParams('skip', Number)
@QueryParams('searchText', String)
// e.t.c

Instead of describing the query parameters one by one, I can create a class that will contain all required properties and I define the metadata on each property that will help to validate the query model and describe it in one place.

@Description('Get run results')
@Get(endpoints.REPORT_DETAILS)
async getRunResults(
  @PathParams(WS_ID, Number) workspaceId: number,
  @PathParams(MODEL_ID, Number) modelId: number,
  @PathParams(RUN_ID, String) runId: string,
  @QueryParams(PAGINATION_QUERY, PaginationQuery)
  @GenericOf(RunTestView) paginationQuery: PaginationQuery<RunTestView>
): Promise<ApiResponse<PaginationResult<RunTestView>>> {
    const paginationResult = await this.reportService.findRunResults(
      workspaceId,
      modelId,
      runId,
      paginationQuery
    );
    return ApiResponse.ok(paginationResult);
}

So then the declaration of this class can be reused on the different endpoints where a pagination query is required.

@Generics('T')
export class PaginationQuery<T> {
    @Minimum(1)
    @Default(50)
    public take?: number;

    @Minimum(0)
    @Default(0)
    public skip?: number;

    @Property()
    @GenericOf('T')
    public order?: Order<T>;

    @Property()
    public searchText?: string;

    @Property()
    @GenericOf('T')
    public filters?: Filters<T>;
}

Also, it is useful when you have to use query parameters that actually will be used for different logical operations like pagination query and query params to include additional fields in the response like: { pq: paginationQuery, fq: fieldsQuery }.

The good hack

I believe it’s a good addition to what we all use in query parameters. It gives more maintainability to the codebase and decreases redundant code duplication in particular scenarios. The deepObject also allows for maintaining model validation in the class and not spreading duplication of common query parameters constraints. It provides the ability to logically combine parameters.


Written by anatolii | Developing and enjoying life. Life is one, implement yours. All the best in your endeavors.
Published by HackerNoon on 2022/11/09