Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions packages/http-canonicalization/src/operation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
): 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")} {
Expand Down
2 changes: 2 additions & 0 deletions packages/http-canonicalization/src/operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export interface CanonicalCookieParameterOptions {
export interface CanonicalQueryParameterOptions {
readonly name: string;
readonly explode: boolean;
readonly style: "form" | "deepObject";
}

export interface CanonicalPathParameterOptions {
Expand Down Expand Up @@ -600,6 +601,7 @@ export class OperationHttpCanonicalization
options: {
name: property.options.name,
explode: property.options.explode,
style: property.options.style,
},
};
case "path":
Expand Down
1 change: 1 addition & 0 deletions packages/http/generated-defs/TypeSpec.Http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface CookieOptions {
export interface QueryOptions {
readonly name?: string;
readonly explode?: boolean;
readonly style?: "form" | "deepObject";
}

export interface PathOptions {
Expand Down
11 changes: 11 additions & 0 deletions packages/http/lib/decorators.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

/**
Expand Down
4 changes: 3 additions & 1 deletion packages/http/src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export const $query: QueryDecorator = (

setQueryOptions(context.program, entity, {
explode: userOptions.explode,
style: userOptions.style,
name: paramName,
});
};
Expand All @@ -168,7 +169,8 @@ export function resolveQueryOptionsWithDefaults(
options: QueryOptions & { name: string },
): Required<QueryOptions> {
return {
explode: options.explode ?? false,
explode: options.explode ?? options.style === "deepObject",
style: options.style ?? "form",
name: options.name,
};
}
Expand Down
2 changes: 1 addition & 1 deletion packages/http/src/http-property.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/http/src/parameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ function getOperationParametersForVerb(
type: "query",
name: uriParam.name,
explode,
style: "form",
};
} else if (uriParam.operator === "+") {
return {
Expand Down
19 changes: 19 additions & 0 deletions packages/http/test/http-decorators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, [
Expand All @@ -289,6 +290,9 @@ describe("@query", () => {
{
code: "invalid-argument",
},
{
code: "invalid-argument",
},
]);
});

Expand Down Expand Up @@ -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>
): string;
`);
expect(getQueryParamOptions(program, filter)).toEqual({
type: "query",
name: "filter",
explode: true,
style: "deepObject",
});
});
});
Expand Down
21 changes: 13 additions & 8 deletions packages/http/test/routes.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -572,28 +572,28 @@ 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 () => {
const param = await getParameter(
`@route("/bar?fixed=yes{&foo}") op foo(foo: string): void;`,
"foo",
);
expectQueryParameter(param, { explode: false });
expectQueryParameter(param, { explode: false, style: "form" });
});
});

Expand Down Expand Up @@ -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;`,
);
Expand Down
1 change: 1 addition & 0 deletions packages/http/test/typekit/http-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ describe("HttpRequest Get Parameters", () => {
expect(tk.modelProperty.getHttpQueryOptions(dataProperty!)).toStrictEqual({
explode: true,
name: "data",
style: "form",
type: "query",
});
});
Expand Down
17 changes: 11 additions & 6 deletions packages/openapi3/src/cli/actions/convert/utils/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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;
Expand Down
25 changes: 24 additions & 1 deletion packages/openapi3/src/examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,10 @@ function getQueryParameterValue(
originalValue: Value,
property: Extract<HttpParameterProperties, { kind: "query" }>,
): 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":
Expand All @@ -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<HttpParameterProperties, { kind: "query" }>,
): 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,
Expand Down
12 changes: 10 additions & 2 deletions packages/openapi3/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
12 changes: 12 additions & 0 deletions packages/openapi3/test/examples.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<int32>`,
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<int32>`,
paramExample: `#{R: 100, G: 200, B: 150}`,
expectedExample: undefined,
},
])("$desc", async ({ param, paramExample, expectedExample }) => {
const res = await openApiFor(
`
Expand Down
20 changes: 20 additions & 0 deletions packages/openapi3/test/parameters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>): 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<string>): 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;`,
Expand Down
Loading