From c6e01962a69f2f5a5f160d8a1b9eff063234b41f Mon Sep 17 00:00:00 2001 From: souloss <23100994+souloss@users.noreply.github.com> Date: Mon, 7 Sep 2026 06:21:26 +0800 Subject: [PATCH] feat(http): support deepObject query style --- ...port-deep-object-query-style-2026-09-07.md | 9 ++++++ .../src/operation.test.ts | 19 ++++++++++++ .../http-canonicalization/src/operation.ts | 2 ++ packages/http/generated-defs/TypeSpec.Http.ts | 1 + packages/http/lib/decorators.tsp | 11 +++++++ packages/http/src/decorators.ts | 4 ++- packages/http/src/http-property.ts | 2 +- packages/http/src/parameters.ts | 1 + packages/http/test/http-decorators.test.ts | 19 ++++++++++++ packages/http/test/routes.test.ts | 21 ++++++++----- .../http/test/typekit/http-request.test.ts | 1 + .../cli/actions/convert/utils/decorators.ts | 17 ++++++---- packages/openapi3/src/examples.ts | 25 ++++++++++++++- packages/openapi3/src/openapi.ts | 12 +++++-- packages/openapi3/test/examples.test.ts | 12 +++++++ packages/openapi3/test/parameters.test.ts | 20 ++++++++++++ .../test/tsp-openapi3/parameters.test.ts | 31 +++++++++++++++++++ .../libraries/http/reference/data-types.md | 9 +++--- 18 files changed, 193 insertions(+), 23 deletions(-) create mode 100644 .chronus/changes/support-deep-object-query-style-2026-09-07.md diff --git a/.chronus/changes/support-deep-object-query-style-2026-09-07.md b/.chronus/changes/support-deep-object-query-style-2026-09-07.md new file mode 100644 index 00000000000..2b8cc1699fd --- /dev/null +++ b/.chronus/changes/support-deep-object-query-style-2026-09-07.md @@ -0,0 +1,9 @@ +--- +changeKind: feature +packages: + - "@typespec/http" + - "@typespec/openapi3" + - "@typespec/http-canonicalization" +--- + +Add `style: "deepObject"` support to `@query` options and preserve the style when emitting or converting OpenAPI 3. diff --git a/packages/http-canonicalization/src/operation.test.ts b/packages/http-canonicalization/src/operation.test.ts index 0aa1e687fbe..91da24df396 100644 --- a/packages/http-canonicalization/src/operation.test.ts +++ b/packages/http-canonicalization/src/operation.test.ts @@ -94,6 +94,25 @@ describe("Operation parameters", async () => { expect(createFooCanonical.requestParameters.properties.length).toBe(2); }); + it("preserves query parameter style", async () => { + const { list, program } = await runner.compile(t.code` + @route("/items") + @get + op ${t.op("list")}( + @query(#{ style: "deepObject" }) filter: Record + ): void; + `); + + const canonicalizer = new HttpCanonicalizer($(program)); + const listCanonical = canonicalizer.canonicalize(list); + const filter = listCanonical.queryParameters[0]; + expect(filter.options).toEqual({ + name: "filter", + explode: true, + style: "deepObject", + }); + }); + it("works with merge patch", async () => { const { updateFoo, program } = await runner.compile(t.code` model ${t.model("Foo")} { diff --git a/packages/http-canonicalization/src/operation.ts b/packages/http-canonicalization/src/operation.ts index 5c418236597..3c457fd2d5d 100644 --- a/packages/http-canonicalization/src/operation.ts +++ b/packages/http-canonicalization/src/operation.ts @@ -51,6 +51,7 @@ export interface CanonicalCookieParameterOptions { export interface CanonicalQueryParameterOptions { readonly name: string; readonly explode: boolean; + readonly style: "form" | "deepObject"; } export interface CanonicalPathParameterOptions { @@ -600,6 +601,7 @@ export class OperationHttpCanonicalization options: { name: property.options.name, explode: property.options.explode, + style: property.options.style, }, }; case "path": diff --git a/packages/http/generated-defs/TypeSpec.Http.ts b/packages/http/generated-defs/TypeSpec.Http.ts index 10543ac2732..a837056f26c 100644 --- a/packages/http/generated-defs/TypeSpec.Http.ts +++ b/packages/http/generated-defs/TypeSpec.Http.ts @@ -20,6 +20,7 @@ export interface CookieOptions { export interface QueryOptions { readonly name?: string; readonly explode?: boolean; + readonly style?: "form" | "deepObject"; } export interface PathOptions { diff --git a/packages/http/lib/decorators.tsp b/packages/http/lib/decorators.tsp index 0e2203678e5..5f1f6833ba6 100644 --- a/packages/http/lib/decorators.tsp +++ b/packages/http/lib/decorators.tsp @@ -97,6 +97,17 @@ model QueryOptions { * */ explode?: boolean; + + /** + * The style used to serialize the query parameter. + * + * - `form`: Serialize primitive values, arrays, and objects using form-style expansion. + * - `deepObject`: Serialize object properties using bracket notation, for example `filter[name]=alice`. + * + * Use `@encode(ArrayEncoding.spaceDelimited)` or `@encode(ArrayEncoding.pipeDelimited)` for + * delimited array query parameters. + */ + style?: "form" | "deepObject"; } /** diff --git a/packages/http/src/decorators.ts b/packages/http/src/decorators.ts index 1a00051de47..6cd1d7d7ed1 100644 --- a/packages/http/src/decorators.ts +++ b/packages/http/src/decorators.ts @@ -157,6 +157,7 @@ export const $query: QueryDecorator = ( setQueryOptions(context.program, entity, { explode: userOptions.explode, + style: userOptions.style, name: paramName, }); }; @@ -168,7 +169,8 @@ export function resolveQueryOptionsWithDefaults( options: QueryOptions & { name: string }, ): Required { return { - explode: options.explode ?? false, + explode: options.explode ?? options.style === "deepObject", + style: options.style ?? "form", name: options.name, }; } diff --git a/packages/http/src/http-property.ts b/packages/http/src/http-property.ts index 778e9fb3c6e..59214228e0f 100644 --- a/packages/http/src/http-property.ts +++ b/packages/http/src/http-property.ts @@ -151,7 +151,7 @@ function getHttpProperty( ); } } else if (implicit.type === "query" && annotations.query) { - if (annotations.query.explode !== undefined) { + if (annotations.query.explode !== undefined || annotations.query.style !== undefined) { diagnostics.push( createDiagnostic({ code: "use-uri-template", diff --git a/packages/http/src/parameters.ts b/packages/http/src/parameters.ts index 1cf02d6998e..9f4e3d54369 100644 --- a/packages/http/src/parameters.ts +++ b/packages/http/src/parameters.ts @@ -90,6 +90,7 @@ function getOperationParametersForVerb( type: "query", name: uriParam.name, explode, + style: "form", }; } else if (uriParam.operator === "+") { return { diff --git a/packages/http/test/http-decorators.test.ts b/packages/http/test/http-decorators.test.ts index c92f05cd300..21cbab95963 100644 --- a/packages/http/test/http-decorators.test.ts +++ b/packages/http/test/http-decorators.test.ts @@ -277,6 +277,7 @@ describe("@query", () => { op test(@query(123) MyQuery: string): string; op test2(@query(#{name: 123}) MyQuery: string): string; op test3(@query(#{format: "invalid"}) MyQuery: string): string; + op test4(@query(#{style: "invalid"}) MyQuery: string): string; `); expectDiagnostics(diagnostics, [ @@ -289,6 +290,9 @@ describe("@query", () => { { code: "invalid-argument", }, + { + code: "invalid-argument", + }, ]); }); @@ -317,6 +321,21 @@ describe("@query", () => { type: "query", name: "selects", explode: true, + style: "form", + }); + }); + + it("specify deepObject style", async () => { + const { filter, program } = await Tester.compile(t.code` + op test( + @query(#{ style: "deepObject" }) ${t.modelProperty("filter")}: Record + ): string; + `); + expect(getQueryParamOptions(program, filter)).toEqual({ + type: "query", + name: "filter", + explode: true, + style: "deepObject", }); }); }); diff --git a/packages/http/test/routes.test.ts b/packages/http/test/routes.test.ts index 280d1b6253c..a5ca272d4da 100644 --- a/packages/http/test/routes.test.ts +++ b/packages/http/test/routes.test.ts @@ -1,7 +1,7 @@ import { expectDiagnosticEmpty, expectDiagnostics, t } from "@typespec/compiler/testing"; import { deepStrictEqual, ok, strictEqual } from "assert"; import { describe, expect, it } from "vitest"; -import type { PathOptions } from "../generated-defs/TypeSpec.Http.js"; +import type { PathOptions, QueryOptions } from "../generated-defs/TypeSpec.Http.js"; import type { HttpOperation, HttpOperationParameter } from "../src/index.js"; import { getRoutePath, joinPathSegments } from "../src/index.js"; import { @@ -572,20 +572,20 @@ describe("uri template", () => { expectPathParameter(param, { style, allowReserved: false, explode: false }); }); - function expectQueryParameter(param: HttpOperationParameter, expected: PathOptions) { + function expectQueryParameter(param: HttpOperationParameter, expected: QueryOptions) { strictEqual(param.type, "query"); - const { explode } = param; - expect({ explode }).toEqual(expected); + const { explode, style } = param; + expect({ explode, style }).toEqual(expected); } it("extract simple query parameter", async () => { const param = await getParameter(`@route("/bar{?foo}") op foo(foo: string): void;`, "foo"); - expectQueryParameter(param, { explode: false }); + expectQueryParameter(param, { explode: false, style: "form" }); }); it("extract explode query parameter", async () => { const param = await getParameter(`@route("/bar{?foo*}") op foo(foo: string): void;`, "foo"); - expectQueryParameter(param, { explode: true }); + expectQueryParameter(param, { explode: true, style: "form" }); }); it("extract simple query continuation parameter", async () => { @@ -593,7 +593,7 @@ describe("uri template", () => { `@route("/bar?fixed=yes{&foo}") op foo(foo: string): void;`, "foo", ); - expectQueryParameter(param, { explode: false }); + expectQueryParameter(param, { explode: false, style: "form" }); }); }); @@ -706,7 +706,12 @@ describe("uri template", () => { }); describe("emit diagnostic if using any of the query options when parameter is already defined in the uri template", () => { - it.each(["#{ explode: false }", "#{ explode: true }"])("%s", async (options) => { + it.each([ + "#{ explode: false }", + "#{ explode: true }", + `#{ style: "form" }`, + `#{ style: "deepObject" }`, + ])("%s", async (options) => { const diagnostics = await diagnoseOperations( `@route("/bar{?foo}") op foo(@query(${options}) foo: string): void;`, ); diff --git a/packages/http/test/typekit/http-request.test.ts b/packages/http/test/typekit/http-request.test.ts index bd24476ff37..5bd67471c74 100644 --- a/packages/http/test/typekit/http-request.test.ts +++ b/packages/http/test/typekit/http-request.test.ts @@ -203,6 +203,7 @@ describe("HttpRequest Get Parameters", () => { expect(tk.modelProperty.getHttpQueryOptions(dataProperty!)).toStrictEqual({ explode: true, name: "data", + style: "form", type: "query", }); }); diff --git a/packages/openapi3/src/cli/actions/convert/utils/decorators.ts b/packages/openapi3/src/cli/actions/convert/utils/decorators.ts index 3994c2a9fdc..b2e916a2ba1 100644 --- a/packages/openapi3/src/cli/actions/convert/utils/decorators.ts +++ b/packages/openapi3/src/cli/actions/convert/utils/decorators.ts @@ -148,7 +148,7 @@ function getLocationDecorator( decoratorArgs = getHeaderArgs(parameter.explode ?? false); break; case "query": - decoratorArgs = getQueryArgs({ explode: parameter.explode ?? true, style: parameter.style }); + decoratorArgs = getQueryArgs({ explode: parameter.explode, style: parameter.style }); break; } @@ -197,12 +197,12 @@ export function normalizeObjectValueToTSValueExpression(value: any): string { } else return `${JSON.stringify(value)}`; } -function getQueryArgs(parameter: { explode: boolean; style?: string }): TSValue | undefined { +function getQueryArgs(parameter: { explode?: boolean; style?: string }): TSValue | undefined { const queryOptions = getNormalizedQueryOptions(parameter); return createTSValueFromObjectValue(queryOptions); } -type QueryOptions = { explode?: boolean }; +type QueryOptions = { explode?: boolean; style?: "deepObject" }; function getNormalizedQueryOptions({ explode, @@ -212,6 +212,10 @@ function getNormalizedQueryOptions({ style?: string; }): QueryOptions { const queryOptions: QueryOptions = {}; + if (style === "deepObject") { + queryOptions.style = "deepObject"; + } + // In OpenAPI 3, default style is 'form', and explode is true when 'form' is the style if (typeof explode !== "boolean") { if (style === "form" || !style) { @@ -221,9 +225,10 @@ function getNormalizedQueryOptions({ } } - // In TypeSpec, default explode is "false" - if (explode) { - queryOptions.explode = true; + // TypeSpec defaults deepObject to explode=true and all other query styles to explode=false. + const typespecDefaultExplode = style === "deepObject"; + if (explode !== typespecDefaultExplode) { + queryOptions.explode = explode; } return queryOptions; diff --git a/packages/openapi3/src/examples.ts b/packages/openapi3/src/examples.ts index b6fe3179db9..535f9f798b0 100644 --- a/packages/openapi3/src/examples.ts +++ b/packages/openapi3/src/examples.ts @@ -386,7 +386,10 @@ function getQueryParameterValue( originalValue: Value, property: Extract, ): Value | undefined { - const style = getParameterStyle(program, property.property) ?? "form"; + const style = + property.options.style === "deepObject" + ? property.options.style + : (getParameterStyle(program, property.property) ?? property.options.style); switch (style) { case "form": @@ -399,9 +402,29 @@ function getQueryParameterValue( return getParameterDelimitedValue(program, originalValue, property, ","); case "newlineDelimited": return getParameterDelimitedValue(program, originalValue, property, "\n"); + case "deepObject": + return getParameterDeepObjectValue(program, originalValue, property); } } +function getParameterDeepObjectValue( + program: Program, + originalValue: Value, + property: Extract, +): Value | undefined { + if (!property.options.explode) return undefined; + + const tk = $(program); + if (!tk.value.isObject(originalValue)) return undefined; + + const pairs: string[] = []; + for (const [key, { value }] of originalValue.properties) { + if (!isSerializableScalarValue(value)) continue; + pairs.push(`${property.options.name}[${key}]=${value.value}`); + } + return tk.value.createString(pairs.join("&")); +} + function getHeaderParameterValue( program: Program, originalValue: Value, diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 9784ffbb8ab..4e59c34bd8c 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -1750,12 +1750,20 @@ function createOAPIEmitter( function getQueryParameterAttributes(httpProperty: HttpProperty & { kind: "query" }) { const attributes: { style?: string; explode?: boolean } = {}; + const encodedStyle = getParameterStyle(program, httpProperty.property); + const style = + httpProperty.options.style === "deepObject" + ? httpProperty.options.style + : (encodedStyle ?? httpProperty.options.style); + if (httpProperty.options.explode !== true) { // For query parameters(style: form) the default is explode: true https://spec.openapis.org/oas/v3.0.2#fixed-fields-9 attributes.explode = false; + } else if (style !== "form") { + // All non-form query styles default explode to false. + attributes.explode = true; } - const style = getParameterStyle(program, httpProperty.property); - if (style) { + if (style !== "form") { attributes.style = style; } diff --git a/packages/openapi3/test/examples.test.ts b/packages/openapi3/test/examples.test.ts index 61194876498..8eecd08e921 100644 --- a/packages/openapi3/test/examples.test.ts +++ b/packages/openapi3/test/examples.test.ts @@ -579,6 +579,18 @@ worksFor(supportedVersions, ({ openApiFor }) => { paramExample: `#{R: 100, G: 200, B: 150}`, expectedExample: undefined, }, + { + desc: "deepObject (object) explode: true", + param: `@query(#{ style: "deepObject" }) color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "color[R]=100&color[G]=200&color[B]=150", + }, + { + desc: "deepObject (object) explode: false", + param: `@query(#{ style: "deepObject", explode: false }) color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: undefined, + }, ])("$desc", async ({ param, paramExample, expectedExample }) => { const res = await openApiFor( ` diff --git a/packages/openapi3/test/parameters.test.ts b/packages/openapi3/test/parameters.test.ts index 77051702847..849f330b31f 100644 --- a/packages/openapi3/test/parameters.test.ts +++ b/packages/openapi3/test/parameters.test.ts @@ -51,6 +51,26 @@ worksFor(supportedVersions, ({ diagnoseOpenApiFor, openApiFor, version }) => { }); }); + it("can set style to deepObject with @query", async () => { + const param = await getQueryParam( + `op test(@query(#{style: "deepObject"}) filter: Record): void;`, + ); + expect(param).toMatchObject({ + style: "deepObject", + explode: true, + }); + }); + + it("preserves an explicit explode value with deepObject style", async () => { + const param = await getQueryParam( + `op test(@query(#{style: "deepObject", explode: false}) filter: Record): void;`, + ); + expect(param).toMatchObject({ + style: "deepObject", + explode: false, + }); + }); + it("propagates @JsonSchema.uniqueItems to a query parameter schema", async () => { const param = await getQueryParam( `op test(@query @JsonSchema.uniqueItems myParam: string[]): void;`, diff --git a/packages/openapi3/test/tsp-openapi3/parameters.test.ts b/packages/openapi3/test/tsp-openapi3/parameters.test.ts index 5334d502892..442aace49be 100644 --- a/packages/openapi3/test/tsp-openapi3/parameters.test.ts +++ b/packages/openapi3/test/tsp-openapi3/parameters.test.ts @@ -656,6 +656,37 @@ describe("query", () => { ); }); + describe("deepObject style", () => { + it.each([ + { explode: undefined, expected: { style: "deepObject", explode: false } }, + { explode: false, expected: { style: "deepObject", explode: false } }, + { explode: true, expected: { style: "deepObject" } }, + ])("preserves explode: $explode", async ({ explode, expected }) => { + const serviceNamespace = await tspForOpenAPI3({ + parameters: { + Filter: { + name: "filter", + in: "query", + schema: { + type: "object", + additionalProperties: { type: "string" }, + }, + style: "deepObject", + ...(explode === undefined ? {} : { explode }), + }, + }, + }); + + const parametersNamespace = serviceNamespace.namespaces.get("Parameters"); + assert(parametersNamespace, "Parameters namespace not found"); + const Filter = parametersNamespace.models.get("Filter"); + assert(Filter, "Filter model not found"); + const filterProperty = Filter.properties.get("filter"); + assert(filterProperty, "filter property not found"); + expectDecorators(filterProperty.decorators, [{ name: "query", args: [expected] }]); + }); + }); + describe("x-ms-list-page-index extension", () => { it("adds @pageIndex decorator when x-ms-list-page-index is true", async () => { const { namespace: serviceNamespace } = await compileForOpenAPI3({ diff --git a/website/src/content/docs/docs/libraries/http/reference/data-types.md b/website/src/content/docs/docs/libraries/http/reference/data-types.md index a72a2ce542d..04f3cbafa4e 100644 --- a/website/src/content/docs/docs/libraries/http/reference/data-types.md +++ b/website/src/content/docs/docs/libraries/http/reference/data-types.md @@ -680,10 +680,11 @@ model TypeSpec.Http.QueryOptions #### Properties -| Name | Type | Description | -| -------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| name? | `string` | Name of the query when included in the url. | -| explode? | `boolean` | If true send each value in the array/object as a separate query parameter.
Equivalent of adding `*` in the path parameter as per [RFC-6570](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.3)

\| Style \| Explode \| Uri Template \| Primitive value id = 5 \| Array id = [3, 4, 5] \| Object id = {"role": "admin", "firstName": "Alex"} \|
\| ------ \| ------- \| -------------- \| ---------------------- \| ----------------------- \| -------------------------------------------------- \|
\| simple \| false \| `/users{?id}` \| `/users?id=5` \| `/users?id=3,4,5` \| `/users?id=role,admin,firstName,Alex` \|
\| simple \| true \| `/users{?id*}` \| `/users?id=5` \| `/users?id=3&id=4&id=5` \| `/users?role=admin&firstName=Alex` \| | +| Name | Type | Description | +| -------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| name? | `string` | Name of the query when included in the url. | +| explode? | `boolean` | If true send each value in the array/object as a separate query parameter.
Equivalent of adding `*` in the path parameter as per [RFC-6570](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.3)

\| Style \| Explode \| Uri Template \| Primitive value id = 5 \| Array id = [3, 4, 5] \| Object id = {"role": "admin", "firstName": "Alex"} \|
\| ------ \| ------- \| -------------- \| ---------------------- \| ----------------------- \| -------------------------------------------------- \|
\| simple \| false \| `/users{?id}` \| `/users?id=5` \| `/users?id=3,4,5` \| `/users?id=role,admin,firstName,Alex` \|
\| simple \| true \| `/users{?id*}` \| `/users?id=5` \| `/users?id=3&id=4&id=5` \| `/users?role=admin&firstName=Alex` \| | +| style? | `"form" \| "deepObject"` | The style used to serialize the query parameter.

- `form`: Serialize primitive values, arrays, and objects using form-style expansion.
- `deepObject`: Serialize object properties using bracket notation, for example `filter[name]=alice`.

Use `@encode(ArrayEncoding.spaceDelimited)` or `@encode(ArrayEncoding.pipeDelimited)` for
delimited array query parameters. | ### `Response` {#TypeSpec.Http.Response}