OpenAPITools / OpenAPITools/openapi-generator

## [typescript-angular] Nested query object serialization changed between 7.11.0 and 7.23.0

Open
#24,059 1 comment 2 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Java
Stars
26.8k
Forks
7.7k
PR merge metrics
PR metrics pending

Description

[typescript-angular] Nested query object serialization changed between 7.11.0 and 7.23.0

Hi OpenAPI Generator team,

I am upgrading an Angular client generated with typescript-angular from OpenAPI Generator 7.11.0 to 7.23.0.

I noticed a behavior change in query parameter serialization for nested object query parameters. This is affecting our Spring backend criteria/filter binding.

The issue is not only that nested objects become [object Object]. Depending on the OpenAPI parameter style, the generated 7.23.0 client produces different incorrect outputs for the same criteria object:

  • with style: deepObject and explode: true, nested fields become [object Object]
  • with style: form and explode: false, the nested object can be flattened/malformed in a way that loses the parent field name, producing keys like [contains]=... instead of username.contains=...
  • in 7.11.0, the generated client recursively serialized the same criteria object into Spring-compatible dot notation, and this worked regardless of the style configuration we had in the OpenAPI file

I would like to understand if this change was intentional, and whether recursive nested object serialization will be supported again in a future version or via a generator option.


Simplified OpenAPI definition

We have endpoints that accept a criteria object as query parameter.

Example:

/v1/tenant_users:
  get:
    operationId: getTenantUsersV1
    parameters:
      - name: criteria
        in: query
        required: true
        style: deepObject
        explode: true
        schema:
          $ref: '#/components/schemas/TenantUsersCriteriaV1'
      - name: page_number
        in: query
        required: true
        style: form
        explode: false
        schema:
          type: integer
      - name: page_size
        in: query
        required: true
        style: form
        explode: false
        schema:
          type: integer

The criteria schema is nested:

TenantUsersCriteriaV1:
  type: object
  properties:
    username:
      $ref: '#/components/schemas/StringFilter'
    tenant_id:
      $ref: '#/components/schemas/IntegerFilter'
    sort:
      $ref: '#/components/schemas/SortCriteria'

StringFilter:
  type: object
  properties:
    equals:
      type: string
    contains:
      type: string

IntegerFilter:
  type: object
  properties:
    equals:
      type: integer

SortCriteria:
  type: object
  properties:
    values:
      type: array
      items:
        $ref: '#/components/schemas/SortValue'

SortValue:
  type: object
  properties:
    property:
      type: string
    direction:
      type: string
      enum:
        - ASC
        - DESC

The frontend passes an object like this:

const criteria = {
  tenant_id: {
    equals: 22
  },
  username: {
    contains: 'name'
  },
  sort: {
    values: [
      {
        property: 'username',
        direction: 'ASC'
      }
    ]
  }
};

Behavior with generated client from 7.11.0

With our generated Angular client from 7.11.0, query params were serialized recursively.
The generated service had logic similar to this:

private addToHttpParams(httpParams: HttpParams, value: any, key?: string): HttpParams {
    if (typeof value === "object" && value instanceof Date === false) {
        httpParams = this.addToHttpParamsRecursive(httpParams, value);
    } else {
        httpParams = this.addToHttpParamsRecursive(httpParams, value, key);
    }

    return httpParams;
}

private addToHttpParamsRecursive(httpParams: HttpParams, value?: any, key?: string): HttpParams {
    if (value == null) {
        return httpParams;
    }

    if (typeof value === "object") {
        if (Array.isArray(value)) {
            (value as any[]).forEach(elem =>
                httpParams = this.addToHttpParamsRecursive(httpParams, elem, key)
            );
        } else if (value instanceof Date) {
            if (key != null) {
                httpParams = httpParams.append(key, (value as Date).toISOString().substring(0, 10));
            } else {
                throw Error("key may not be null if value is Date");
            }
        } else {
            Object.keys(value).forEach(k =>
                httpParams = this.addToHttpParamsRecursive(
                    httpParams,
                    value[k],
                    key != null ? `${key}.${k}` : k
                )
            );
        }
    } else if (key != null) {
        httpParams = httpParams.append(key, value);
    } else {
        throw Error("key may not be null if value is not object or array");
    }

    return httpParams;
}

So the criteria object above was serialized recursively as:

tenant_id.equals=22
username.contains=name
sort.values[0].property=username
sort.values[0].direction=ASC
page_number=0
page_size=25

This worked correctly with our Spring backend binding.
Our backend receives a criteria object and fields like these are populated correctly:

criteria.getTenantId().getEquals()
criteria.getUsername().getContains()
criteria.getSort().getValues()

The important part is that the generated client recursively walked the object and preserved the full field path.

Behavior with generated client from 7.23.0
After upgrading to OpenAPI Generator 7.23.0, the generated Angular client now uses OpenApiHttpParams and QueryParamStyle.
The generated API method looks like this:

localVarQueryParameters = this.addToHttpParams(
    localVarQueryParameters,
    'criteria',
    <any>criteria,
    QueryParamStyle.DeepObject,
    true,
);

The generated BaseService handles DeepObject like this:

protected addToHttpParams(
    httpParams: OpenApiHttpParams,
    key: string,
    value: any | null | undefined,
    paramStyle: QueryParamStyle,
    explode: boolean
): OpenApiHttpParams {
    if (value === null || value === undefined) {
        return httpParams;
    }

    if (paramStyle === QueryParamStyle.DeepObject) {
        if (typeof value !== 'object') {
            throw Error(`An object must be provided for key ${key} as it is a deep object`);
        }

        return Object.keys(value as Record<string, any>).reduce(
            (hp, k) => hp.append(`${key}[${k}]`, value[k]),
            httpParams,
        );
    }

    // other styles omitted
}

This only serializes one level deep.
So for the same object:

const criteria = {
  tenant_id: {
    equals: 22
  },
  username: {
    contains: 'name'
  },
  sort: {
    values: [
      {
        property: 'username',
        direction: 'ASC'
      }
    ]
  }
};

the generated request becomes:

criteria[tenant_id]=[object Object]
criteria[username]=[object Object]
criteria[sort]=[object Object]
page_number=0
page_size=25

In the browser Network tab this appears as:

criteria[tenant_id]    [object Object]
criteria[username]     [object Object]
criteria[sort]         [object Object]
page_number            0
page_size              25

So the nested filter values are not sent to the backend anymore.

Behavior with form/explode:false in 7.23.0

We also tried using:

style: form
explode: false

for the same criteria parameter.
In 7.11.0, the recursive serializer still produced a usable request because it walked the nested object and generated dot-notation field paths.
In 7.23.0, because serialization is now style-driven, the same nested object no longer serializes into the old working format.
Instead of preserving the full field path like:

username.contains=name
tenant_id.equals=22

the generated output can become malformed/flattened in a way that loses the parent field context, for example producing query keys similar to:

[contains]=name
[equals]=22

or otherwise failing to preserve the original parent field name such as username or tenant_id.
So the issue is not only deepObject producing [object Object].
The broader issue is that after the upgrade, there does not seem to be a generated serialization mode that recursively serializes nested query objects into the previous working Spring-compatible format.

Expected behavior

For our API, the expected serialized query string is:

tenant_id.equals=22
username.contains=name
sort.values[0].property=username
sort.values[0].direction=ASC
page_number=0
page_size=25

At minimum, we need a generated serialization mode that recursively traverses nested query objects and does not produce:

criteria[username]=[object Object]

or malformed keys that lose the parent field name.

Workaround
If I manually patch the generated BaseService and make object query serialization recursive, the issue is fixed.
For example:

if (paramStyle === QueryParamStyle.DeepObject) {
    if (typeof value !== 'object') {
        throw Error(`An object must be provided for key ${key} as it is a deep object`);
    }

    return this.addDotNotationObjectToHttpParams(httpParams, '', value);
}

with:

private addDotNotationObjectToHttpParams(
    httpParams: OpenApiHttpParams,
    keyPrefix: string,
    value: any
): OpenApiHttpParams {
    if (value === null || value === undefined) {
        return httpParams;
    }

    if (value instanceof Date) {
        return httpParams.append(keyPrefix, value.toISOString());
    }

    if (Array.isArray(value)) {
        value.forEach((item, index) => {
            httpParams = this.addDotNotationObjectToHttpParams(
                httpParams,
                `${keyPrefix}[${index}]`,
                item
            );
        });

        return httpParams;
    }

    if (typeof value === 'object') {
        Object.keys(value).forEach(childKey => {
            const nextKey = keyPrefix ? `${keyPrefix}.${childKey}` : childKey;

            httpParams = this.addDotNotationObjectToHttpParams(
                httpParams,
                nextKey,
                value[childKey]
            );
        });

        return httpParams;
    }

    return httpParams.append(keyPrefix, value.toString());
}

This produces the expected request:

tenant_id.equals=22
username.contains=name
sort.values[0].property=username
sort.values[0].direction=ASC

However, BaseService is generated code, so manually patching it is not a maintainable solution.

Questions

1/ Was the removal/change of recursive nested query object serialization between 7.11.0 and 7.23.0 intentional for the typescript-angular generator?

2/ Is the current 7.23.0 behavior expected, where deepObject only serializes one level and nested values become [object Object]?

3/ Is there currently a generator option that restores the previous recursive object serialization behavior?

4/ If not, would the maintainers consider adding an option for recursive nested object query serialization?

For example:

recursiveQueryObjectSerialization=true

or:

queryObjectSerialization=dot
  1. If the current behavior is considered correct according to OpenAPI, what is the recommended migration path for APIs using Spring-style criteria/query filter objects?

Specifically, how should an OpenAPI definition be modeled so that the generated Angular client sends:

username.contains=name
tenant_id.equals=22

instead of either:

criteria[username]=[object Object]
criteria[tenant_id]=[object Object]

or malformed flattened keys that lose the parent field name?

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the generated typescript-angular BaseService and its addToHttpParams handling for OpenApiHttpParams, QueryParamStyle.DeepObject, and form/explode:false; compare it with the 7.11.0 behavior described. Reproduce nested criteria serialization and verify that completion preserves full parent paths recursively without [object Object] or lost field names, including the expected array notation.

Written by the indexing model from the issue text.

Assessment

Tech stack
angular, typescript
Domain
tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.