From 9c82564748a2c82d14475f158a6580576a1e2283 Mon Sep 17 00:00:00 2001 From: Christopher Radek Date: Tue, 20 May 2025 12:45:27 -0700 Subject: [PATCH 01/14] [openapi3] add support for parameter examples via `@opExample` --- packages/openapi3/src/examples.ts | 383 +++++++++++++++++++++++- packages/openapi3/src/openapi.ts | 39 ++- packages/openapi3/src/parameters.ts | 16 + packages/openapi3/src/types.ts | 5 + packages/openapi3/src/util.ts | 13 +- packages/openapi3/test/examples.test.ts | 382 ++++++++++++++++++++++- 6 files changed, 812 insertions(+), 26 deletions(-) create mode 100644 packages/openapi3/src/parameters.ts diff --git a/packages/openapi3/src/examples.ts b/packages/openapi3/src/examples.ts index ffdc70e9346..1499c1fe023 100644 --- a/packages/openapi3/src/examples.ts +++ b/packages/openapi3/src/examples.ts @@ -1,13 +1,17 @@ import { + BooleanValue, Example, getOpExamples, ignoreDiagnostics, + NumericValue, OpExample, Program, serializeValueAsJson, + StringValue, Type, Value, } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; import type { HttpOperation, HttpOperationResponse, @@ -15,12 +19,19 @@ import type { HttpProperty, HttpStatusCodeRange, } from "@typespec/http"; +import { getParameterStyle } from "./parameters.js"; import { getOpenAPI3StatusCodes } from "./status-codes.js"; import { OpenAPI3Example, OpenAPI3MediaType } from "./types.js"; -import { isSharedHttpOperation, SharedHttpOperation } from "./util.js"; +import { + HttpParameterProperties, + isHttpParameterProperty, + isSharedHttpOperation, + SharedHttpOperation, +} from "./util.js"; export interface OperationExamples { requestBody: Record; + parameters: Record; responses: Record>; } @@ -29,7 +40,7 @@ export function resolveOperationExamples( operation: HttpOperation | SharedHttpOperation, ): OperationExamples { const examples = findOperationExamples(program, operation); - const result: OperationExamples = { requestBody: {}, responses: {} }; + const result: OperationExamples = { requestBody: {}, parameters: {}, responses: {} }; if (examples.length === 0) { return result; } @@ -50,6 +61,28 @@ export function resolveOperationExamples( ]); } } + + if (example.parameters) { + // iterate over properties + for (const property of op.parameters.properties) { + if (!isHttpParameterProperty(property)) continue; + + const value = getParameterValue(program, example.parameters, property); + if (value) { + const parameterName = property.options.name; + result.parameters[parameterName] ??= []; + result.parameters[parameterName].push([ + { + value, + title: example.title, + description: example.description, + }, + property.property as Type, + ]); + } + } + } + if (example.returnType && op.responses) { const match = findResponseForExample(program, example.returnType, op.responses); if (match) { @@ -231,6 +264,352 @@ export function getBodyValue(value: Value, properties: HttpProperty[]): Value | return value; } +function getParameterValue( + program: Program, + parameterExamples: Value, + property: HttpParameterProperties, +): Value | undefined { + const value = getValueByPath(parameterExamples, property.path); + if (!value) return value; + + // Depending on the parameter type, we may need to serialize the value differently. + // https://spec.openapis.org/oas/v3.0.4.html#style-examples + /* + Supported styles per location: https://spec.openapis.org/oas/v3.0.4.html#style-values + | Location | Default Style | Supported Styles | + | -------- | ------------- | ----------------------------------------------- | + | query | form | form, spaceDelimited, pipeDelimited, deepObject | + | header | simple | simple | + | path | simple | simple, label, matrix | + | cookie | form | form | + */ + // explode is only relevant for array/object types + + if (property.kind === "query") { + return getQueryParameterValue(program, value, property); + } else if (property.kind === "header") { + return getHeaderParameterValue(program, value, property); + } else if (property.kind === "path") { + return getPathParameterValue(program, value, property); + } else if (property.kind === "cookie") { + return getCookieParameterValue(program, value, property); + } + + return value; +} + +function getQueryParameterValue( + program: Program, + originalValue: Value, + property: Extract, +): Value | undefined { + const style = getParameterStyle(program, property.property) ?? "form"; + + switch (style) { + case "form": + return getParameterFormValue(program, originalValue, property); + case "spaceDelimited": + return getParameterDelimitedValue(program, originalValue, property, " "); + case "pipeDelimited": + return getParameterDelimitedValue(program, originalValue, property, "|"); + } +} + +function getHeaderParameterValue( + program: Program, + originalValue: Value, + property: Extract, +): Value | undefined { + return getParameterSimpleValue(program, originalValue, property); +} + +function getPathParameterValue( + program: Program, + originalValue: Value, + property: Extract, +): Value | undefined { + const { style } = property.options; + + if (style === "label") { + return getParameterLabelValue(program, originalValue, property); + } else if (style === "matrix") { + return getParameterMatrixValue(program, originalValue, property); + } else if (style === "simple") { + return getParameterSimpleValue(program, originalValue, property); + } + + return undefined; +} + +function getCookieParameterValue( + program: Program, + originalValue: Value, + property: Extract, +): Value | undefined { + return getParameterFormValue(program, originalValue, property); +} + +function getParameterLabelValue( + program: Program, + originalValue: Value, + property: Extract, +): Value | undefined { + const { explode } = property.options; + const tk = $(program); + + /* + https://spec.openapis.org/oas/v3.0.4.html#style-examples + string -> "blue" + array -> ["blue", "black", "brown"] + object -> { "R": 100, "G": 200, "B": 150 } + + | explode | string | array | object | + | ------- | ------- | ----------------- | ------------------ | + | false | .blue | .blue,black,brown | .R,100,G,200,B,150 | + | true | .blue | .blue.black.brown | .R=100.G=200.B=150 | + */ + + const joiner = explode ? "." : ","; + if (tk.value.isArray(originalValue)) { + const pairs: string[] = []; + for (const value of originalValue.values) { + if (!isSerializableScalarValue(value)) continue; + pairs.push(`${value.value}`); + } + return tk.value.createString(`.${pairs.join(joiner)}`); + } + + if (tk.value.isObject(originalValue)) { + const pairs: string[] = []; + for (const [key, { value }] of originalValue.properties) { + if (!isSerializableScalarValue(value)) continue; + const sep = explode ? "=" : ","; + pairs.push(`${key}${sep}${value.value}`); + } + return tk.value.createString(`.${pairs.join(joiner)}`); + } + + // null (undefined) is treated as a a dot + if (tk.value.isNull(originalValue)) { + return tk.value.createString("."); + } + + if (isSerializableScalarValue(originalValue)) { + return tk.value.createString(`.${originalValue.value}`); + } + + return; +} + +function getParameterMatrixValue( + program: Program, + originalValue: Value, + property: Extract, +): Value | undefined { + const { explode, name } = property.options; + const tk = $(program); + + /* + https://spec.openapis.org/oas/v3.0.4.html#style-examples + string -> "blue" + array -> ["blue", "black", "brown"] + object -> { "R": 100, "G": 200, "B": 150 } + + | explode | string | array | object | + | ------- | ------------- | ----------------------------------- | ------------------------ | + | false | ;color=blue | ;color=blue,black,brown | ;color=R,100,G,200,B,150 | + | true | ;color=blue | ;color=blue;color=black;color=brown | ;R=100;G=200;B=150 | + */ + + const joiner = explode ? ";" : ","; + const prefix = explode ? "" : `${name}=`; + if (tk.value.isArray(originalValue)) { + const pairs: string[] = []; + for (const value of originalValue.values) { + if (!isSerializableScalarValue(value)) continue; + pairs.push(explode ? `${name}=${value.value}` : `${value.value}`); + } + return tk.value.createString(`;${prefix}${pairs.join(joiner)}`); + } + + if (tk.value.isObject(originalValue)) { + const sep = explode ? "=" : ","; + const pairs: string[] = []; + for (const [key, { value }] of originalValue.properties) { + if (!isSerializableScalarValue(value)) continue; + pairs.push(`${key}${sep}${value.value}`); + } + return tk.value.createString(`;${prefix}${pairs.join(joiner)}`); + } + + if (tk.value.isNull(originalValue)) { + return tk.value.createString(`;${name}`); + } + + if (isSerializableScalarValue(originalValue)) { + return tk.value.createString(`;${name}=${originalValue.value}`); + } + + return; +} + +function getParameterDelimitedValue( + program: Program, + originalValue: Value, + property: Extract, + delimiter: " " | "|", +): Value | undefined { + const { explode, name } = property.options; + // Serialization is undefined for explode=true + if (explode) return undefined; + + const tk = $(program); + /* + https://spec.openapis.org/oas/v3.0.4.html#style-examples + array -> ["blue", "black", "brown"] + object -> { "R": 100, "G": 200, "B": 150 } + + | style | explode | string | array | object | + | ----- | ------- | ------ | --------------------------- | ---------------------------------- | + | pipe | false | n/a | ?color=blue%7Cblack%7Cbrown | ?color=R%7C100%7CG%7C200%7CB%7C150 | + | pipe | true | n/a | n/a | n/a | + | space | false | n/a | ?color=blue%20black%20brown | ?color=R%20100%20G%20200%20B%20150 | + | space | true | n/a | n/a | n/a | + */ + + if (tk.value.isArray(originalValue)) { + const pairs: string[] = []; + for (const value of originalValue.values) { + if (!isSerializableScalarValue(value)) continue; + pairs.push(`${value.value}`); + } + return tk.value.createString(`?${name}=${encodeURIComponent(pairs.join(delimiter))}`); + } + + if (tk.value.isObject(originalValue)) { + const pairs: string[] = []; + for (const [key, { value }] of originalValue.properties) { + if (!isSerializableScalarValue(value)) continue; + pairs.push(`${key}${delimiter}${value.value}`); + } + return tk.value.createString(`?${name}=${encodeURIComponent(pairs.join(delimiter))}`); + } + + return undefined; +} + +function getParameterFormValue( + program: Program, + originalValue: Value, + property: Extract, +): Value | undefined { + const { name } = property.options; + const isCookie = property.kind === "cookie"; + const explode = isCookie ? false : property.options.explode; + const tk = $(program); + /* + https://spec.openapis.org/oas/v3.0.4.html#style-examples + string -> "blue" + array -> ["blue", "black", "brown"] + object -> { "R": 100, "G": 200, "B": 150 } + + | explode | string | array | object | + | ------- | ------------- | ----------------------------------- | ------------------------ | + | false | ?color=blue | ?color=blue,black,brown | ?color=R,100,G,200,B,150 | + | true | ?color=blue | ?color=blue&color=black&color=brown | ?R=100&G=200&B=150 | + */ + + const qPrefix = isCookie ? "" : "?"; + const prefix = explode ? "" : `${name}=`; + if (tk.value.isArray(originalValue)) { + const sep = explode ? "&" : ","; + const pairs: string[] = []; + for (const value of originalValue.values) { + if (!isSerializableScalarValue(value)) continue; + pairs.push(explode ? `${name}=${value.value}` : `${value.value}`); + } + return tk.value.createString(`${qPrefix}${prefix}${pairs.join(sep)}`); + } + + if (tk.value.isObject(originalValue)) { + const sep = explode ? "=" : ","; + const joiner = explode ? "&" : ","; + const pairs: string[] = []; + for (const [key, { value }] of originalValue.properties) { + if (!isSerializableScalarValue(value)) continue; + pairs.push(`${key}${sep}${value.value}`); + } + return tk.value.createString(`${qPrefix}${prefix}${pairs.join(joiner)}`); + } + + if (isSerializableScalarValue(originalValue)) { + return tk.value.createString(`${qPrefix}${name}=${originalValue.value}`); + } + + // null is treated as the 'undefined' value + if (tk.value.isNull(originalValue)) { + return tk.value.createString(`${qPrefix}${name}=`); + } + + return; +} + +function getParameterSimpleValue( + program: Program, + originalValue: Value, + property: Extract, +): Value | undefined { + const { explode } = property.options; + const tk = $(program); + + /* + https://spec.openapis.org/oas/v3.0.4.html#style-examples + string -> "blue" + array -> ["blue", "black", "brown"] + object -> { "R": 100, "G": 200, "B": 150 } + + | explode | string | array | object | + | ------- | ------ | ---------------- | ----------------- | + | false | blue | blue,black,brown | R,100,G,200,B,150 | + | true | blue | blue,black,brown | R=100,G=200,B=150 | + */ + + if (tk.value.isArray(originalValue)) { + const serializedValue = originalValue.values + .filter(isSerializableScalarValue) + .map((v) => v.value) + .join(","); + return tk.value.createString(serializedValue); + } + + if (tk.value.isObject(originalValue)) { + const pairs: string[] = []; + for (const [key, { value }] of originalValue.properties) { + if (!isSerializableScalarValue(value)) continue; + const sep = explode ? "=" : ","; + pairs.push(`${key}${sep}${value.value}`); + } + return tk.value.createString(pairs.join(",")); + } + + // null (undefined) is treated as an empty string - unrelated to allowEmptyValue + if (tk.value.isNull(originalValue)) { + return tk.value.createString(""); + } + + if (isSerializableScalarValue(originalValue)) { + return originalValue; + } + + return; +} + +function isSerializableScalarValue( + value: Value, +): value is BooleanValue | NumericValue | StringValue { + return ["BooleanValue", "NumericValue", "StringValue"].includes(value.valueKind); +} + function getValueByPath(value: Value, path: (string | number)[]): Value | undefined { let current: Value | undefined = value; for (const key of path) { diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 86739504387..74d22b16a23 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -10,7 +10,6 @@ import { getAllTags, getAnyExtensionFromPath, getDoc, - getEncode, getFormat, getMaxItems, getMaxLength, @@ -88,6 +87,7 @@ import { getExampleOrExamples, OperationExamples, resolveOperationExamples } fro import { JsonSchemaModule, resolveJsonSchemaModule } from "./json-schema.js"; import { createDiagnostic, FileType, OpenAPI3EmitterOptions, OpenAPIVersion } from "./lib.js"; import { getOpenApiSpecProps } from "./openapi-spec-mappings.js"; +import { getParameterStyle } from "./parameters.js"; import { getOpenAPI3StatusCodes } from "./status-codes.js"; import { OpenAPI3Encoding, @@ -115,6 +115,7 @@ import { deepEquals, ensureValidComponentFixedFieldKey, getDefaultValue, + HttpParameterProperties, isBytesKeptRaw, isSharedHttpOperation, SharedHttpOperation, @@ -142,11 +143,6 @@ export async function $onEmit(context: EmitContext) { type IrrelevantOpenAPI3EmitterOptionsForObject = "file-type" | "output-file" | "new-line"; -type HttpParameterProperties = Extract< - HttpProperty, - { kind: "header" | "query" | "path" | "cookie" } ->; - /** * Get the OpenAPI 3 document records from the given program. The documents are * returned as a JS object. @@ -787,6 +783,7 @@ function createOAPIEmitter( oai3Operation.parameters = getEndpointParameters( resolveSharedRouteParameters(operations), visibility, + examples, ); const bodies = [ @@ -818,7 +815,7 @@ function createOAPIEmitter( operationId: resolveOperationId(program, operation.operation), summary: getSummary(program, operation.operation), description: getDoc(program, operation.operation), - parameters: getEndpointParameters(parameters.properties, visibility), + parameters: getEndpointParameters(parameters.properties, visibility, examples), responses: getResponses(operation, operation.responses, examples), }; const currentTags = getAllTags(program, op); @@ -1281,6 +1278,7 @@ function createOAPIEmitter( function getParameter( httpProperty: HttpParameterProperties, visibility: Visibility, + examples: [Example, Type][], ): OpenAPI3Parameter { const param: OpenAPI3Parameter = { name: httpProperty.options.name, @@ -1301,12 +1299,16 @@ function createOAPIEmitter( param.deprecated = true; } + const paramExamples = getExampleOrExamples(program, examples); + Object.assign(param, paramExamples); + return param; } function getEndpointParameters( properties: HttpProperty[], visibility: Visibility, + examples: OperationExamples, ): Refable[] { const result: Refable[] = []; for (const httpProp of properties) { @@ -1317,7 +1319,11 @@ function createOAPIEmitter( if (!isHttpParameterProperty(httpProp)) { continue; } - const param = getParameterOrRef(httpProp, visibility); + const param = getParameterOrRef( + httpProp, + visibility, + examples.parameters[httpProp.options.name] ?? [], + ); if (param) { const existing = result.find( (x) => !("$ref" in param) && !("$ref" in x) && x.name === param.name && x.in === param.in, @@ -1386,6 +1392,7 @@ function createOAPIEmitter( function getParameterOrRef( httpProperty: HttpParameterProperties, visibility: Visibility, + examples: [Example, Type][], ): Refable | undefined { if (isNeverType(httpProperty.property.type)) { return undefined; @@ -1415,7 +1422,7 @@ function createOAPIEmitter( return params.get(property); } - const param = getParameter(httpProperty, visibility); + const param = getParameter(httpProperty, visibility, examples); // only parameters inherited by spreading from non-inlined type are shared in #/components/parameters if (spreadParam && property.model && !shouldInline(program, property.model)) { @@ -1546,7 +1553,7 @@ function createOAPIEmitter( // For query parameters(style: form) the default is explode: true https://spec.openapis.org/oas/v3.0.2#fixed-fields-9 attributes.explode = false; } - const style = getParameterStyle(httpProperty.property); + const style = getParameterStyle(program, httpProperty.property); if (style) { attributes.style = style; } @@ -1554,18 +1561,6 @@ function createOAPIEmitter( return attributes; } - function getParameterStyle(type: ModelProperty): string | undefined { - const encode = getEncode(program, type); - if (!encode) return; - - if (encode.encoding === "ArrayEncoding.pipeDelimited") { - return "pipeDelimited"; - } else if (encode.encoding === "ArrayEncoding.spaceDelimited") { - return "spaceDelimited"; - } - return; - } - function getHeaderParameterAttributes(httpProperty: HttpProperty & { kind: "header" }) { const attributes: { style?: "simple"; explode?: boolean } = {}; if (httpProperty.options.explode) { diff --git a/packages/openapi3/src/parameters.ts b/packages/openapi3/src/parameters.ts new file mode 100644 index 00000000000..d2c22a753e2 --- /dev/null +++ b/packages/openapi3/src/parameters.ts @@ -0,0 +1,16 @@ +import { getEncode, ModelProperty, Program } from "@typespec/compiler"; + +export function getParameterStyle( + program: Program, + type: ModelProperty, +): "pipeDelimited" | "spaceDelimited" | undefined { + const encode = getEncode(program, type); + if (!encode) return; + + if (encode.encoding === "ArrayEncoding.pipeDelimited") { + return "pipeDelimited"; + } else if (encode.encoding === "ArrayEncoding.spaceDelimited") { + return "spaceDelimited"; + } + return; +} diff --git a/packages/openapi3/src/types.ts b/packages/openapi3/src/types.ts index 4b87710afa1..7d18c85644f 100644 --- a/packages/openapi3/src/types.ts +++ b/packages/openapi3/src/types.ts @@ -618,6 +618,11 @@ export type OpenAPI3ParameterBase = Extensions & { explode?: boolean; schema: OpenAPI3Schema; + + /** A free-form property to include an example of an instance for this schema. To represent examples that cannot be naturally represented in JSON or YAML, a string value can be used to contain the example with escaping where necessary. */ + example?: any; + + examples?: Record>; }; export type OpenAPI3QueryParameter = OpenAPI3ParameterBase & { diff --git a/packages/openapi3/src/util.ts b/packages/openapi3/src/util.ts index 90bb5ddbde6..36c4b52ee59 100644 --- a/packages/openapi3/src/util.ts +++ b/packages/openapi3/src/util.ts @@ -13,7 +13,7 @@ import { Type, Value, } from "@typespec/compiler"; -import { HttpOperation } from "@typespec/http"; +import { HttpOperation, HttpProperty } from "@typespec/http"; import { createDiagnostic } from "./lib.js"; /** * Checks if two objects are deeply equal. @@ -189,3 +189,14 @@ function reportInvalidKey(program: Program, type: Type, key: string) { function createValidKey(invalidKey: string): string { return invalidKey.replace(/[^a-zA-Z0-9.\-_]/g, "_"); } + +export type HttpParameterProperties = Extract< + HttpProperty, + { kind: "header" | "query" | "path" | "cookie" } +>; + +export function isHttpParameterProperty( + httpProperty: HttpProperty, +): httpProperty is HttpParameterProperties { + return ["header", "query", "path", "cookie"].includes(httpProperty.kind); +} diff --git a/packages/openapi3/test/examples.test.ts b/packages/openapi3/test/examples.test.ts index 34649f87b0c..76de4dc7ff7 100644 --- a/packages/openapi3/test/examples.test.ts +++ b/packages/openapi3/test/examples.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { OpenAPI3Document, OpenAPI3RequestBody } from "../src/types.js"; +import { OpenAPI3Document, OpenAPI3Parameter, OpenAPI3RequestBody } from "../src/types.js"; import { openApiFor } from "./test-host.js"; import { worksFor } from "./works-for.js"; @@ -234,4 +234,384 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { age: 2, }); }); + + describe("parameters", () => { + it.each([ + { + desc: "form (undefined)", + param: `@query color: string | null`, + paramExample: `null`, + expectedExample: "?color=", + }, + { + desc: "form (string)", + param: `@query color: string`, + paramExample: `"blue"`, + expectedExample: "?color=blue", + }, + { + desc: "form (array) explode: false", + param: `@query color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: "?color=blue,black,brown", + }, + { + desc: "form (array) explode: true", + param: `@query(#{ explode: true }) color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: "?color=blue&color=black&color=brown", + }, + { + desc: "form (object) explode: false", + param: `@query color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "?color=R,100,G,200,B,150", + }, + { + desc: "form (object) explode: true", + param: `@query(#{ explode: true }) color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "?R=100&G=200&B=150", + }, + { + desc: "spaceDelimited (undefined)", + param: `@query @encode(ArrayEncoding.spaceDelimited) color: string | null`, + paramExample: `null`, + expectedExample: undefined, + }, + { + desc: "spaceDelimited (string)", + param: `@query @encode(ArrayEncoding.spaceDelimited) color: string`, + paramExample: `"blue"`, + expectedExample: undefined, + }, + { + desc: "spaceDelimited (array) explode: false", + param: `@query @encode(ArrayEncoding.spaceDelimited) color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: "?color=blue%20black%20brown", + }, + { + desc: "spaceDelimited (array) explode: true", + param: `@query(#{ explode: true }) @encode(ArrayEncoding.spaceDelimited) color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: undefined, + }, + { + desc: "spaceDelimited (object) explode: false", + param: `@query @encode(ArrayEncoding.spaceDelimited) color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "?color=R%20100%20G%20200%20B%20150", + }, + { + desc: "spaceDelimited (object) explode: true", + param: `@query(#{ explode: true }) @encode(ArrayEncoding.spaceDelimited) color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: undefined, + }, + { + desc: "pipeDelimited (undefined)", + param: `@query @encode(ArrayEncoding.pipeDelimited) color: string | null`, + paramExample: `null`, + expectedExample: undefined, + }, + { + desc: "pipeDelimited (string)", + param: `@query @encode(ArrayEncoding.pipeDelimited) color: string`, + paramExample: `"blue"`, + expectedExample: undefined, + }, + { + desc: "pipeDelimited (array) explode: false", + param: `@query @encode(ArrayEncoding.pipeDelimited) color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: "?color=blue%7Cblack%7Cbrown", + }, + { + desc: "pipeDelimited (array) explode: true", + param: `@query(#{ explode: true }) @encode(ArrayEncoding.pipeDelimited) color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: undefined, + }, + { + desc: "pipeDelimited (object) explode: false", + param: `@query @encode(ArrayEncoding.pipeDelimited) color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "?color=R%7C100%7CG%7C200%7CB%7C150", + }, + { + desc: "pipeDelimited (object) explode: true", + param: `@query(#{ explode: true }) @encode(ArrayEncoding.pipeDelimited) color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: undefined, + }, + ])( + "set example on the query parameter with style $desc", + async ({ param, paramExample, expectedExample }) => { + const res = await openApiFor( + ` + @opExample(#{ + parameters: #{ + color: ${paramExample}, + }, + }) + @route("/") + op getColors(${param}): void; + `, + ); + expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( + expectedExample, + ); + }, + ); + + it.each([ + { + desc: "simple (undefined)", + route: "/{color}", + param: `@path color: string | null`, + paramExample: `null`, + expectedExample: "", + }, + { + desc: "simple (string)", + route: "/{color}", + param: `@path color: string`, + paramExample: `"blue"`, + expectedExample: "blue", + }, + { + desc: "simple (array) explode: false", + route: "/{color}", + param: `@path color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: "blue,black,brown", + }, + { + desc: "simple (array) explode: true", + route: "/{color*}", + param: `@path color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: "blue,black,brown", + }, + { + desc: "simple (object) explode: false", + route: "/{color}", + param: `@path color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "R,100,G,200,B,150", + }, + { + desc: "simple (object) explode: true", + route: "/{color*}", + param: `@path color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "R=100,G=200,B=150", + }, + { + desc: "matrix (undefined)", + route: "/{;color}", + param: `@path color: string | null`, + paramExample: `null`, + expectedExample: ";color", + }, + { + desc: "matrix (string)", + route: "/{;color}", + param: `@path color: string`, + paramExample: `"blue"`, + expectedExample: ";color=blue", + }, + { + desc: "matrix (array) explode: false", + route: "/{;color}", + param: `@path color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: ";color=blue,black,brown", + }, + { + desc: "matrix (array) explode: true", + route: "/{;color*}", + param: `@path color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: ";color=blue;color=black;color=brown", + }, + { + desc: "matrix (object) explode: false", + route: "/{;color}", + param: `@path color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: ";color=R,100,G,200,B,150", + }, + { + desc: "matrix (object) explode: true", + route: "/{;color*}", + param: `@path color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: ";R=100;G=200;B=150", + }, + { + desc: "label (undefined)", + route: "/{.color}", + param: `@path color: string | null`, + paramExample: `null`, + expectedExample: ".", + }, + { + desc: "label (string)", + route: "/{.color}", + param: `@path color: string`, + paramExample: `"blue"`, + expectedExample: ".blue", + }, + { + desc: "label (array) explode: false", + route: "/{.color}", + param: `@path color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: ".blue,black,brown", + }, + { + desc: "label (array) explode: true", + route: "/{.color*}", + param: `@path color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: ".blue.black.brown", + }, + { + desc: "label (object) explode: false", + route: "/{.color}", + param: `@path color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: ".R,100,G,200,B,150", + }, + { + desc: "label (object) explode: true", + route: "/{.color*}", + param: `@path color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: ".R=100.G=200.B=150", + }, + ])( + "set example on the path parameter with style $desc", + async ({ param, route, paramExample, expectedExample }) => { + const res: OpenAPI3Document = await openApiFor( + ` + @opExample(#{ + parameters: #{ + color: ${paramExample}, + }, + }) + @route("${route}") + op getColors(${param}): void; + `, + ); + expect((res.paths[`/{color}`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( + expectedExample, + ); + }, + ); + + it.each([ + { + desc: "simple (undefined)", + param: `@header color: string | null`, + paramExample: `null`, + expectedExample: "", + }, + { + desc: "simple (string)", + param: `@header color: string`, + paramExample: `"blue"`, + expectedExample: "blue", + }, + { + desc: "simple (array) explode: false", + param: `@header color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: "blue,black,brown", + }, + { + desc: "simple (array) explode: true", + param: `@header(#{ explode: true }) color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: "blue,black,brown", + }, + { + desc: "simple (object) explode: false", + param: `@header color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "R,100,G,200,B,150", + }, + { + desc: "simple (object) explode: true", + param: `@header(#{ explode: true }) color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "R=100,G=200,B=150", + }, + ])( + "set example on the header parameter with style $desc", + async ({ param, paramExample, expectedExample }) => { + const res: OpenAPI3Document = await openApiFor( + ` + @opExample(#{ + parameters: #{ + color: ${paramExample}, + }, + }) + @route("/") + op getColors(${param}): void; + `, + ); + expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( + expectedExample, + ); + }, + ); + + it.each([ + { + desc: "form (undefined)", + param: `@cookie color: string | null`, + paramExample: `null`, + expectedExample: "color=", + }, + { + desc: "form (string)", + param: `@cookie color: string`, + paramExample: `"blue"`, + expectedExample: "color=blue", + }, + { + desc: "form (array) explode: false", + param: `@cookie color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: "color=blue,black,brown", + }, + { + desc: "form (object) explode: false", + param: `@cookie color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "color=R,100,G,200,B,150", + }, + ])( + "set example on the cookie parameter with style $desc", + async ({ param, paramExample, expectedExample }) => { + const res: OpenAPI3Document = await openApiFor( + ` + @opExample(#{ + parameters: #{ + color: ${paramExample}, + }, + }) + @route("/") + op getColors(${param}): void; + `, + ); + expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( + expectedExample, + ); + }, + ); + }); }); From 5a7f895152f89fe3cfca37be9f39f3dfbc88c9f8 Mon Sep 17 00:00:00 2001 From: Christopher Radek Date: Tue, 20 May 2025 12:46:02 -0700 Subject: [PATCH 02/14] add changelog --- .../changes/oa3-examples-on-params-2025-4-20-12-45-54.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .chronus/changes/oa3-examples-on-params-2025-4-20-12-45-54.md diff --git a/.chronus/changes/oa3-examples-on-params-2025-4-20-12-45-54.md b/.chronus/changes/oa3-examples-on-params-2025-4-20-12-45-54.md new file mode 100644 index 00000000000..7a9ed849dfd --- /dev/null +++ b/.chronus/changes/oa3-examples-on-params-2025-4-20-12-45-54.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi3" +--- + +Adds support for parameter examples via `@opExample` \ No newline at end of file From ed5265528d67ed891f1bcfbdf3185a39f4f52317 Mon Sep 17 00:00:00 2001 From: Christopher Radek Date: Tue, 20 May 2025 12:56:43 -0700 Subject: [PATCH 03/14] make cspell happy --- packages/openapi3/src/examples.ts | 1 + packages/openapi3/test/examples.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/openapi3/src/examples.ts b/packages/openapi3/src/examples.ts index 1499c1fe023..4678eaa33f4 100644 --- a/packages/openapi3/src/examples.ts +++ b/packages/openapi3/src/examples.ts @@ -464,6 +464,7 @@ function getParameterDelimitedValue( if (explode) return undefined; const tk = $(program); + // cspell: ignore Cblack Cbrown /* https://spec.openapis.org/oas/v3.0.4.html#style-examples array -> ["blue", "black", "brown"] diff --git a/packages/openapi3/test/examples.test.ts b/packages/openapi3/test/examples.test.ts index 76de4dc7ff7..6c08808583f 100644 --- a/packages/openapi3/test/examples.test.ts +++ b/packages/openapi3/test/examples.test.ts @@ -325,6 +325,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { desc: "pipeDelimited (array) explode: false", param: `@query @encode(ArrayEncoding.pipeDelimited) color: string[]`, paramExample: `#["blue", "black", "brown"]`, + // cspell:disable-next-line expectedExample: "?color=blue%7Cblack%7Cbrown", }, { From ccce21695882d6bab0f643b4791ae8c8c7ec024d Mon Sep 17 00:00:00 2001 From: Christopher Radek Date: Tue, 20 May 2025 15:24:49 -0700 Subject: [PATCH 04/14] default to no parameter example serialization --- packages/openapi3/README.md | 8 ++ packages/openapi3/src/examples.ts | 17 ++- packages/openapi3/src/lib.ts | 17 +++ packages/openapi3/src/openapi.ts | 10 +- packages/openapi3/test/examples.test.ts | 130 ++++++++++++------ .../emitters/openapi3/reference/emitter.md | 8 ++ 6 files changed, 145 insertions(+), 45 deletions(-) diff --git a/packages/openapi3/README.md b/packages/openapi3/README.md index f650ab8a3f2..779e23868ea 100644 --- a/packages/openapi3/README.md +++ b/packages/openapi3/README.md @@ -125,6 +125,14 @@ If true, then for models emitted as object schemas we default `additionalPropert OpenAPI 3.0, and `unevaluatedProperties` to false for OpenAPI 3.1, if not explicitly specified elsewhere. Default: `false` +### `serialize-parameter-examples` + +**Type:** `boolean` + +If true, then examples on parameters will be serialized based on the parameter's prescribed serialization strategy. +Default: `false` +See https://spec.openapis.org/oas/v3.0.4.html#style-examples + ## Decorators ### TypeSpec.OpenAPI diff --git a/packages/openapi3/src/examples.ts b/packages/openapi3/src/examples.ts index 4678eaa33f4..14244765d13 100644 --- a/packages/openapi3/src/examples.ts +++ b/packages/openapi3/src/examples.ts @@ -35,9 +35,14 @@ export interface OperationExamples { responses: Record>; } +type ResolveOperationExamplesOptions = { + enableParameterSerialization: boolean; +}; + export function resolveOperationExamples( program: Program, operation: HttpOperation | SharedHttpOperation, + { enableParameterSerialization }: ResolveOperationExamplesOptions, ): OperationExamples { const examples = findOperationExamples(program, operation); const result: OperationExamples = { requestBody: {}, parameters: {}, responses: {} }; @@ -67,7 +72,12 @@ export function resolveOperationExamples( for (const property of op.parameters.properties) { if (!isHttpParameterProperty(property)) continue; - const value = getParameterValue(program, example.parameters, property); + const value = getParameterValue( + program, + example.parameters, + property, + enableParameterSerialization, + ); if (value) { const parameterName = property.options.name; result.parameters[parameterName] ??= []; @@ -268,8 +278,13 @@ function getParameterValue( program: Program, parameterExamples: Value, property: HttpParameterProperties, + enableSerialization: boolean = false, ): Value | undefined { const value = getValueByPath(parameterExamples, property.path); + if (!enableSerialization) { + return value; + } + if (!value) return value; // Depending on the parameter type, we may need to serialize the value differently. diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index cbf01f1ae29..ebedd31262b 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -81,6 +81,13 @@ export interface OpenAPI3EmitterOptions { * @default false */ "seal-object-schemas"?: boolean; + + /** + * If true, then examples on parameters will be serialized based on the parameter's prescribed serialization strategy. + * @default false + * @see https://spec.openapis.org/oas/v3.0.4.html#style-examples + */ + "serialize-parameter-examples"?: boolean; } const EmitterOptionsSchema: JSONSchemaType = { @@ -180,6 +187,16 @@ const EmitterOptionsSchema: JSONSchemaType = { "Default: `false`", ].join("\n"), }, + "serialize-parameter-examples": { + type: "boolean", + nullable: true, + default: false, + description: [ + "If true, then examples on parameters will be serialized based on the parameter's prescribed serialization strategy.", + "Default: `false`", + "See https://spec.openapis.org/oas/v3.0.4.html#style-examples", + ].join("\n"), + }, }, required: [], }; diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 74d22b16a23..dfee294b504 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -211,6 +211,7 @@ export function resolveOptions( outputFile: resolvePath(context.emitterOutputDir, specDir, outputFile), openapiVersions, sealObjectSchemas: resolvedOptions["seal-object-schemas"], + serializeParameterExamples: resolvedOptions["serialize-parameter-examples"] ?? false, }; } @@ -223,6 +224,7 @@ export interface ResolvedOpenAPI3EmitterOptions { includeXTypeSpecName: "inline-only" | "never"; safeintStrategy: "double-int" | "int64"; sealObjectSchemas: boolean; + serializeParameterExamples: boolean; } function createOAPIEmitter( @@ -732,7 +734,9 @@ function createOAPIEmitter( const operations = shared.operations; const verb = operations[0].verb; const path = operations[0].path; - const examples = resolveOperationExamples(program, shared); + const examples = resolveOperationExamples(program, shared, { + enableParameterSerialization: options.serializeParameterExamples, + }); const oai3Operation: OpenAPI3Operation = { operationId: computeSharedOperationId(shared), parameters: [], @@ -810,7 +814,9 @@ function createOAPIEmitter( return undefined; } const visibility = resolveRequestVisibility(program, operation.operation, verb); - const examples = resolveOperationExamples(program, operation); + const examples = resolveOperationExamples(program, operation, { + enableParameterSerialization: options.serializeParameterExamples, + }); const oai3Operation: OpenAPI3Operation = { operationId: resolveOperationId(program, operation.operation), summary: getSummary(program, operation.operation), diff --git a/packages/openapi3/test/examples.test.ts b/packages/openapi3/test/examples.test.ts index 6c08808583f..fe608c999ff 100644 --- a/packages/openapi3/test/examples.test.ts +++ b/packages/openapi3/test/examples.test.ts @@ -235,7 +235,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { }); }); - describe("parameters", () => { + describe("set example on the query parameter with serialization enabled", () => { it.each([ { desc: "form (undefined)", @@ -346,11 +346,9 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { paramExample: `#{R: 100, G: 200, B: 150}`, expectedExample: undefined, }, - ])( - "set example on the query parameter with style $desc", - async ({ param, paramExample, expectedExample }) => { - const res = await openApiFor( - ` + ])("$desc", async ({ param, paramExample, expectedExample }) => { + const res = await openApiFor( + ` @opExample(#{ parameters: #{ color: ${paramExample}, @@ -359,13 +357,60 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { @route("/") op getColors(${param}): void; `, + undefined, + { "serialize-parameter-examples": true }, + ); + expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( + expectedExample, + ); + }); + }); + + describe.each(["path", "query", "header", "cookie"])( + "set example on the %s parameter without serialization", + (paramType) => { + it.each([ + { + param: `@${paramType} color: string | null`, + paramExample: `null`, + expectedExample: null, + }, + { + param: `@${paramType} color: string | null`, + paramExample: `"blue"`, + expectedExample: "blue", + }, + { + param: `@${paramType} color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: ["blue", "black", "brown"], + }, + { + param: `@${paramType} color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: { R: 100, G: 200, B: 150 }, + }, + ])("$paramExample", async ({ param, paramExample, expectedExample }) => { + const path = paramType === "path" ? "/{color}" : "/"; + const res = await openApiFor( + ` + @opExample(#{ + parameters: #{ + color: ${paramExample}, + }, + }) + @route("/") + op getColors(${param}): void; + `, ); - expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( + expect((res.paths[path].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( expectedExample, ); - }, - ); + }); + }, + ); + describe("set example on the path parameter", () => { it.each([ { desc: "simple (undefined)", @@ -493,11 +538,9 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { paramExample: `#{R: 100, G: 200, B: 150}`, expectedExample: ".R=100.G=200.B=150", }, - ])( - "set example on the path parameter with style $desc", - async ({ param, route, paramExample, expectedExample }) => { - const res: OpenAPI3Document = await openApiFor( - ` + ])("$desc", async ({ param, route, paramExample, expectedExample }) => { + const res: OpenAPI3Document = await openApiFor( + ` @opExample(#{ parameters: #{ color: ${paramExample}, @@ -506,13 +549,16 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { @route("${route}") op getColors(${param}): void; `, - ); - expect((res.paths[`/{color}`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( - expectedExample, - ); - }, - ); + undefined, + { "serialize-parameter-examples": true }, + ); + expect((res.paths[`/{color}`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( + expectedExample, + ); + }); + }); + describe("set example on the header parameter", () => { it.each([ { desc: "simple (undefined)", @@ -550,11 +596,9 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { paramExample: `#{R: 100, G: 200, B: 150}`, expectedExample: "R=100,G=200,B=150", }, - ])( - "set example on the header parameter with style $desc", - async ({ param, paramExample, expectedExample }) => { - const res: OpenAPI3Document = await openApiFor( - ` + ])("$desc", async ({ param, paramExample, expectedExample }) => { + const res: OpenAPI3Document = await openApiFor( + ` @opExample(#{ parameters: #{ color: ${paramExample}, @@ -563,13 +607,16 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { @route("/") op getColors(${param}): void; `, - ); - expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( - expectedExample, - ); - }, - ); + undefined, + { "serialize-parameter-examples": true }, + ); + expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( + expectedExample, + ); + }); + }); + describe("set example on the cookie parameter", () => { it.each([ { desc: "form (undefined)", @@ -595,11 +642,9 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { paramExample: `#{R: 100, G: 200, B: 150}`, expectedExample: "color=R,100,G,200,B,150", }, - ])( - "set example on the cookie parameter with style $desc", - async ({ param, paramExample, expectedExample }) => { - const res: OpenAPI3Document = await openApiFor( - ` + ])("$desc", async ({ param, paramExample, expectedExample }) => { + const res: OpenAPI3Document = await openApiFor( + ` @opExample(#{ parameters: #{ color: ${paramExample}, @@ -608,11 +653,12 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { @route("/") op getColors(${param}): void; `, - ); - expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( - expectedExample, - ); - }, - ); + undefined, + { "serialize-parameter-examples": true }, + ); + expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( + expectedExample, + ); + }); }); }); diff --git a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md index 66b71c12c7d..bc437b1743e 100644 --- a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md @@ -118,3 +118,11 @@ Default: `int64` If true, then for models emitted as object schemas we default `additionalProperties` to false for OpenAPI 3.0, and `unevaluatedProperties` to false for OpenAPI 3.1, if not explicitly specified elsewhere. Default: `false` + +### `serialize-parameter-examples` + +**Type:** `boolean` + +If true, then examples on parameters will be serialized based on the parameter's prescribed serialization strategy. +Default: `false` +See https://spec.openapis.org/oas/v3.0.4.html#style-examples From 8653ed7e28b6f03a75f59b17fc2633a90f51e779 Mon Sep 17 00:00:00 2001 From: Christopher Radek Date: Tue, 20 May 2025 15:36:23 -0700 Subject: [PATCH 05/14] test with multiple examples per param --- packages/openapi3/test/examples.test.ts | 154 ++++++++++++++++-------- 1 file changed, 107 insertions(+), 47 deletions(-) diff --git a/packages/openapi3/test/examples.test.ts b/packages/openapi3/test/examples.test.ts index fe608c999ff..572b25876f7 100644 --- a/packages/openapi3/test/examples.test.ts +++ b/packages/openapi3/test/examples.test.ts @@ -235,6 +235,50 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { }); }); + describe.each(["path", "query", "header", "cookie"])( + "set example on the %s parameter without serialization", + (paramType) => { + it.each([ + { + param: `@${paramType} color: string | null`, + paramExample: `null`, + expectedExample: null, + }, + { + param: `@${paramType} color: string | null`, + paramExample: `"blue"`, + expectedExample: "blue", + }, + { + param: `@${paramType} color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: ["blue", "black", "brown"], + }, + { + param: `@${paramType} color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: { R: 100, G: 200, B: 150 }, + }, + ])("$paramExample", async ({ param, paramExample, expectedExample }) => { + const path = paramType === "path" ? "/{color}" : "/"; + const res = await openApiFor( + ` + @opExample(#{ + parameters: #{ + color: ${paramExample}, + }, + }) + @route("/") + op getColors(${param}): void; + `, + ); + expect((res.paths[path].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( + expectedExample, + ); + }); + }, + ); + describe("set example on the query parameter with serialization enabled", () => { it.each([ { @@ -366,51 +410,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { }); }); - describe.each(["path", "query", "header", "cookie"])( - "set example on the %s parameter without serialization", - (paramType) => { - it.each([ - { - param: `@${paramType} color: string | null`, - paramExample: `null`, - expectedExample: null, - }, - { - param: `@${paramType} color: string | null`, - paramExample: `"blue"`, - expectedExample: "blue", - }, - { - param: `@${paramType} color: string[]`, - paramExample: `#["blue", "black", "brown"]`, - expectedExample: ["blue", "black", "brown"], - }, - { - param: `@${paramType} color: Record`, - paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: { R: 100, G: 200, B: 150 }, - }, - ])("$paramExample", async ({ param, paramExample, expectedExample }) => { - const path = paramType === "path" ? "/{color}" : "/"; - const res = await openApiFor( - ` - @opExample(#{ - parameters: #{ - color: ${paramExample}, - }, - }) - @route("/") - op getColors(${param}): void; - `, - ); - expect((res.paths[path].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( - expectedExample, - ); - }); - }, - ); - - describe("set example on the path parameter", () => { + describe("set example on the path parameter with serialization enabled", () => { it.each([ { desc: "simple (undefined)", @@ -558,7 +558,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { }); }); - describe("set example on the header parameter", () => { + describe("set example on the header parameter with serialization enabled", () => { it.each([ { desc: "simple (undefined)", @@ -616,7 +616,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { }); }); - describe("set example on the cookie parameter", () => { + describe("set example on the cookie parameter with serialization enabled", () => { it.each([ { desc: "form (undefined)", @@ -661,4 +661,64 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { ); }); }); + + it("supports multiple examples on parameter with serialization enabled", async () => { + const res = await openApiFor( + ` + @opExample(#{ + parameters: #{ + color: "green", + }, + }, #{ title: "MyExample" }) + @opExample(#{ + parameters: #{ + color: "red", + }, + }, #{ title: "MyExample2" }) + @route("/") + op getColors(@query color: string): void; + `, + undefined, + { "serialize-parameter-examples": true }, + ); + expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).examples).toEqual({ + MyExample: { + summary: "MyExample", + value: "?color=green", + }, + MyExample2: { + summary: "MyExample2", + value: "?color=red", + }, + }); + }); + + it("supports multiple examples on parameter without serialization enabled", async () => { + const res = await openApiFor( + ` + @opExample(#{ + parameters: #{ + color: "green", + }, + }, #{ title: "MyExample" }) + @opExample(#{ + parameters: #{ + color: "red", + }, + }, #{ title: "MyExample2" }) + @route("/") + op getColors(@query color: string): void; + `, + ); + expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).examples).toEqual({ + MyExample: { + summary: "MyExample", + value: "green", + }, + MyExample2: { + summary: "MyExample2", + value: "red", + }, + }); + }); }); From 30a082decf8d0664cf68fda64b6edc21276613f6 Mon Sep 17 00:00:00 2001 From: Christopher Radek Date: Thu, 22 May 2025 11:47:25 -0700 Subject: [PATCH 06/14] remove parameter example serialization --- packages/openapi3/src/examples.ts | 360 +-------------------- packages/openapi3/src/lib.ts | 17 - packages/openapi3/src/openapi.ts | 10 +- packages/openapi3/test/examples.test.ts | 414 ------------------------ 4 files changed, 3 insertions(+), 798 deletions(-) diff --git a/packages/openapi3/src/examples.ts b/packages/openapi3/src/examples.ts index 14244765d13..86fe4510a44 100644 --- a/packages/openapi3/src/examples.ts +++ b/packages/openapi3/src/examples.ts @@ -1,17 +1,13 @@ import { - BooleanValue, Example, getOpExamples, ignoreDiagnostics, - NumericValue, OpExample, Program, serializeValueAsJson, - StringValue, Type, Value, } from "@typespec/compiler"; -import { $ } from "@typespec/compiler/typekit"; import type { HttpOperation, HttpOperationResponse, @@ -19,7 +15,6 @@ import type { HttpProperty, HttpStatusCodeRange, } from "@typespec/http"; -import { getParameterStyle } from "./parameters.js"; import { getOpenAPI3StatusCodes } from "./status-codes.js"; import { OpenAPI3Example, OpenAPI3MediaType } from "./types.js"; import { @@ -35,14 +30,9 @@ export interface OperationExamples { responses: Record>; } -type ResolveOperationExamplesOptions = { - enableParameterSerialization: boolean; -}; - export function resolveOperationExamples( program: Program, operation: HttpOperation | SharedHttpOperation, - { enableParameterSerialization }: ResolveOperationExamplesOptions, ): OperationExamples { const examples = findOperationExamples(program, operation); const result: OperationExamples = { requestBody: {}, parameters: {}, responses: {} }; @@ -72,12 +62,7 @@ export function resolveOperationExamples( for (const property of op.parameters.properties) { if (!isHttpParameterProperty(property)) continue; - const value = getParameterValue( - program, - example.parameters, - property, - enableParameterSerialization, - ); + const value = getParameterValue(program, example.parameters, property); if (value) { const parameterName = property.options.name; result.parameters[parameterName] ??= []; @@ -278,354 +263,11 @@ function getParameterValue( program: Program, parameterExamples: Value, property: HttpParameterProperties, - enableSerialization: boolean = false, ): Value | undefined { const value = getValueByPath(parameterExamples, property.path); - if (!enableSerialization) { - return value; - } - - if (!value) return value; - - // Depending on the parameter type, we may need to serialize the value differently. - // https://spec.openapis.org/oas/v3.0.4.html#style-examples - /* - Supported styles per location: https://spec.openapis.org/oas/v3.0.4.html#style-values - | Location | Default Style | Supported Styles | - | -------- | ------------- | ----------------------------------------------- | - | query | form | form, spaceDelimited, pipeDelimited, deepObject | - | header | simple | simple | - | path | simple | simple, label, matrix | - | cookie | form | form | - */ - // explode is only relevant for array/object types - - if (property.kind === "query") { - return getQueryParameterValue(program, value, property); - } else if (property.kind === "header") { - return getHeaderParameterValue(program, value, property); - } else if (property.kind === "path") { - return getPathParameterValue(program, value, property); - } else if (property.kind === "cookie") { - return getCookieParameterValue(program, value, property); - } - return value; } -function getQueryParameterValue( - program: Program, - originalValue: Value, - property: Extract, -): Value | undefined { - const style = getParameterStyle(program, property.property) ?? "form"; - - switch (style) { - case "form": - return getParameterFormValue(program, originalValue, property); - case "spaceDelimited": - return getParameterDelimitedValue(program, originalValue, property, " "); - case "pipeDelimited": - return getParameterDelimitedValue(program, originalValue, property, "|"); - } -} - -function getHeaderParameterValue( - program: Program, - originalValue: Value, - property: Extract, -): Value | undefined { - return getParameterSimpleValue(program, originalValue, property); -} - -function getPathParameterValue( - program: Program, - originalValue: Value, - property: Extract, -): Value | undefined { - const { style } = property.options; - - if (style === "label") { - return getParameterLabelValue(program, originalValue, property); - } else if (style === "matrix") { - return getParameterMatrixValue(program, originalValue, property); - } else if (style === "simple") { - return getParameterSimpleValue(program, originalValue, property); - } - - return undefined; -} - -function getCookieParameterValue( - program: Program, - originalValue: Value, - property: Extract, -): Value | undefined { - return getParameterFormValue(program, originalValue, property); -} - -function getParameterLabelValue( - program: Program, - originalValue: Value, - property: Extract, -): Value | undefined { - const { explode } = property.options; - const tk = $(program); - - /* - https://spec.openapis.org/oas/v3.0.4.html#style-examples - string -> "blue" - array -> ["blue", "black", "brown"] - object -> { "R": 100, "G": 200, "B": 150 } - - | explode | string | array | object | - | ------- | ------- | ----------------- | ------------------ | - | false | .blue | .blue,black,brown | .R,100,G,200,B,150 | - | true | .blue | .blue.black.brown | .R=100.G=200.B=150 | - */ - - const joiner = explode ? "." : ","; - if (tk.value.isArray(originalValue)) { - const pairs: string[] = []; - for (const value of originalValue.values) { - if (!isSerializableScalarValue(value)) continue; - pairs.push(`${value.value}`); - } - return tk.value.createString(`.${pairs.join(joiner)}`); - } - - if (tk.value.isObject(originalValue)) { - const pairs: string[] = []; - for (const [key, { value }] of originalValue.properties) { - if (!isSerializableScalarValue(value)) continue; - const sep = explode ? "=" : ","; - pairs.push(`${key}${sep}${value.value}`); - } - return tk.value.createString(`.${pairs.join(joiner)}`); - } - - // null (undefined) is treated as a a dot - if (tk.value.isNull(originalValue)) { - return tk.value.createString("."); - } - - if (isSerializableScalarValue(originalValue)) { - return tk.value.createString(`.${originalValue.value}`); - } - - return; -} - -function getParameterMatrixValue( - program: Program, - originalValue: Value, - property: Extract, -): Value | undefined { - const { explode, name } = property.options; - const tk = $(program); - - /* - https://spec.openapis.org/oas/v3.0.4.html#style-examples - string -> "blue" - array -> ["blue", "black", "brown"] - object -> { "R": 100, "G": 200, "B": 150 } - - | explode | string | array | object | - | ------- | ------------- | ----------------------------------- | ------------------------ | - | false | ;color=blue | ;color=blue,black,brown | ;color=R,100,G,200,B,150 | - | true | ;color=blue | ;color=blue;color=black;color=brown | ;R=100;G=200;B=150 | - */ - - const joiner = explode ? ";" : ","; - const prefix = explode ? "" : `${name}=`; - if (tk.value.isArray(originalValue)) { - const pairs: string[] = []; - for (const value of originalValue.values) { - if (!isSerializableScalarValue(value)) continue; - pairs.push(explode ? `${name}=${value.value}` : `${value.value}`); - } - return tk.value.createString(`;${prefix}${pairs.join(joiner)}`); - } - - if (tk.value.isObject(originalValue)) { - const sep = explode ? "=" : ","; - const pairs: string[] = []; - for (const [key, { value }] of originalValue.properties) { - if (!isSerializableScalarValue(value)) continue; - pairs.push(`${key}${sep}${value.value}`); - } - return tk.value.createString(`;${prefix}${pairs.join(joiner)}`); - } - - if (tk.value.isNull(originalValue)) { - return tk.value.createString(`;${name}`); - } - - if (isSerializableScalarValue(originalValue)) { - return tk.value.createString(`;${name}=${originalValue.value}`); - } - - return; -} - -function getParameterDelimitedValue( - program: Program, - originalValue: Value, - property: Extract, - delimiter: " " | "|", -): Value | undefined { - const { explode, name } = property.options; - // Serialization is undefined for explode=true - if (explode) return undefined; - - const tk = $(program); - // cspell: ignore Cblack Cbrown - /* - https://spec.openapis.org/oas/v3.0.4.html#style-examples - array -> ["blue", "black", "brown"] - object -> { "R": 100, "G": 200, "B": 150 } - - | style | explode | string | array | object | - | ----- | ------- | ------ | --------------------------- | ---------------------------------- | - | pipe | false | n/a | ?color=blue%7Cblack%7Cbrown | ?color=R%7C100%7CG%7C200%7CB%7C150 | - | pipe | true | n/a | n/a | n/a | - | space | false | n/a | ?color=blue%20black%20brown | ?color=R%20100%20G%20200%20B%20150 | - | space | true | n/a | n/a | n/a | - */ - - if (tk.value.isArray(originalValue)) { - const pairs: string[] = []; - for (const value of originalValue.values) { - if (!isSerializableScalarValue(value)) continue; - pairs.push(`${value.value}`); - } - return tk.value.createString(`?${name}=${encodeURIComponent(pairs.join(delimiter))}`); - } - - if (tk.value.isObject(originalValue)) { - const pairs: string[] = []; - for (const [key, { value }] of originalValue.properties) { - if (!isSerializableScalarValue(value)) continue; - pairs.push(`${key}${delimiter}${value.value}`); - } - return tk.value.createString(`?${name}=${encodeURIComponent(pairs.join(delimiter))}`); - } - - return undefined; -} - -function getParameterFormValue( - program: Program, - originalValue: Value, - property: Extract, -): Value | undefined { - const { name } = property.options; - const isCookie = property.kind === "cookie"; - const explode = isCookie ? false : property.options.explode; - const tk = $(program); - /* - https://spec.openapis.org/oas/v3.0.4.html#style-examples - string -> "blue" - array -> ["blue", "black", "brown"] - object -> { "R": 100, "G": 200, "B": 150 } - - | explode | string | array | object | - | ------- | ------------- | ----------------------------------- | ------------------------ | - | false | ?color=blue | ?color=blue,black,brown | ?color=R,100,G,200,B,150 | - | true | ?color=blue | ?color=blue&color=black&color=brown | ?R=100&G=200&B=150 | - */ - - const qPrefix = isCookie ? "" : "?"; - const prefix = explode ? "" : `${name}=`; - if (tk.value.isArray(originalValue)) { - const sep = explode ? "&" : ","; - const pairs: string[] = []; - for (const value of originalValue.values) { - if (!isSerializableScalarValue(value)) continue; - pairs.push(explode ? `${name}=${value.value}` : `${value.value}`); - } - return tk.value.createString(`${qPrefix}${prefix}${pairs.join(sep)}`); - } - - if (tk.value.isObject(originalValue)) { - const sep = explode ? "=" : ","; - const joiner = explode ? "&" : ","; - const pairs: string[] = []; - for (const [key, { value }] of originalValue.properties) { - if (!isSerializableScalarValue(value)) continue; - pairs.push(`${key}${sep}${value.value}`); - } - return tk.value.createString(`${qPrefix}${prefix}${pairs.join(joiner)}`); - } - - if (isSerializableScalarValue(originalValue)) { - return tk.value.createString(`${qPrefix}${name}=${originalValue.value}`); - } - - // null is treated as the 'undefined' value - if (tk.value.isNull(originalValue)) { - return tk.value.createString(`${qPrefix}${name}=`); - } - - return; -} - -function getParameterSimpleValue( - program: Program, - originalValue: Value, - property: Extract, -): Value | undefined { - const { explode } = property.options; - const tk = $(program); - - /* - https://spec.openapis.org/oas/v3.0.4.html#style-examples - string -> "blue" - array -> ["blue", "black", "brown"] - object -> { "R": 100, "G": 200, "B": 150 } - - | explode | string | array | object | - | ------- | ------ | ---------------- | ----------------- | - | false | blue | blue,black,brown | R,100,G,200,B,150 | - | true | blue | blue,black,brown | R=100,G=200,B=150 | - */ - - if (tk.value.isArray(originalValue)) { - const serializedValue = originalValue.values - .filter(isSerializableScalarValue) - .map((v) => v.value) - .join(","); - return tk.value.createString(serializedValue); - } - - if (tk.value.isObject(originalValue)) { - const pairs: string[] = []; - for (const [key, { value }] of originalValue.properties) { - if (!isSerializableScalarValue(value)) continue; - const sep = explode ? "=" : ","; - pairs.push(`${key}${sep}${value.value}`); - } - return tk.value.createString(pairs.join(",")); - } - - // null (undefined) is treated as an empty string - unrelated to allowEmptyValue - if (tk.value.isNull(originalValue)) { - return tk.value.createString(""); - } - - if (isSerializableScalarValue(originalValue)) { - return originalValue; - } - - return; -} - -function isSerializableScalarValue( - value: Value, -): value is BooleanValue | NumericValue | StringValue { - return ["BooleanValue", "NumericValue", "StringValue"].includes(value.valueKind); -} - function getValueByPath(value: Value, path: (string | number)[]): Value | undefined { let current: Value | undefined = value; for (const key of path) { diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index ebedd31262b..cbf01f1ae29 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -81,13 +81,6 @@ export interface OpenAPI3EmitterOptions { * @default false */ "seal-object-schemas"?: boolean; - - /** - * If true, then examples on parameters will be serialized based on the parameter's prescribed serialization strategy. - * @default false - * @see https://spec.openapis.org/oas/v3.0.4.html#style-examples - */ - "serialize-parameter-examples"?: boolean; } const EmitterOptionsSchema: JSONSchemaType = { @@ -187,16 +180,6 @@ const EmitterOptionsSchema: JSONSchemaType = { "Default: `false`", ].join("\n"), }, - "serialize-parameter-examples": { - type: "boolean", - nullable: true, - default: false, - description: [ - "If true, then examples on parameters will be serialized based on the parameter's prescribed serialization strategy.", - "Default: `false`", - "See https://spec.openapis.org/oas/v3.0.4.html#style-examples", - ].join("\n"), - }, }, required: [], }; diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index dfee294b504..74d22b16a23 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -211,7 +211,6 @@ export function resolveOptions( outputFile: resolvePath(context.emitterOutputDir, specDir, outputFile), openapiVersions, sealObjectSchemas: resolvedOptions["seal-object-schemas"], - serializeParameterExamples: resolvedOptions["serialize-parameter-examples"] ?? false, }; } @@ -224,7 +223,6 @@ export interface ResolvedOpenAPI3EmitterOptions { includeXTypeSpecName: "inline-only" | "never"; safeintStrategy: "double-int" | "int64"; sealObjectSchemas: boolean; - serializeParameterExamples: boolean; } function createOAPIEmitter( @@ -734,9 +732,7 @@ function createOAPIEmitter( const operations = shared.operations; const verb = operations[0].verb; const path = operations[0].path; - const examples = resolveOperationExamples(program, shared, { - enableParameterSerialization: options.serializeParameterExamples, - }); + const examples = resolveOperationExamples(program, shared); const oai3Operation: OpenAPI3Operation = { operationId: computeSharedOperationId(shared), parameters: [], @@ -814,9 +810,7 @@ function createOAPIEmitter( return undefined; } const visibility = resolveRequestVisibility(program, operation.operation, verb); - const examples = resolveOperationExamples(program, operation, { - enableParameterSerialization: options.serializeParameterExamples, - }); + const examples = resolveOperationExamples(program, operation); const oai3Operation: OpenAPI3Operation = { operationId: resolveOperationId(program, operation.operation), summary: getSummary(program, operation.operation), diff --git a/packages/openapi3/test/examples.test.ts b/packages/openapi3/test/examples.test.ts index 572b25876f7..74a57f16b3e 100644 --- a/packages/openapi3/test/examples.test.ts +++ b/packages/openapi3/test/examples.test.ts @@ -279,420 +279,6 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { }, ); - describe("set example on the query parameter with serialization enabled", () => { - it.each([ - { - desc: "form (undefined)", - param: `@query color: string | null`, - paramExample: `null`, - expectedExample: "?color=", - }, - { - desc: "form (string)", - param: `@query color: string`, - paramExample: `"blue"`, - expectedExample: "?color=blue", - }, - { - desc: "form (array) explode: false", - param: `@query color: string[]`, - paramExample: `#["blue", "black", "brown"]`, - expectedExample: "?color=blue,black,brown", - }, - { - desc: "form (array) explode: true", - param: `@query(#{ explode: true }) color: string[]`, - paramExample: `#["blue", "black", "brown"]`, - expectedExample: "?color=blue&color=black&color=brown", - }, - { - desc: "form (object) explode: false", - param: `@query color: Record`, - paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: "?color=R,100,G,200,B,150", - }, - { - desc: "form (object) explode: true", - param: `@query(#{ explode: true }) color: Record`, - paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: "?R=100&G=200&B=150", - }, - { - desc: "spaceDelimited (undefined)", - param: `@query @encode(ArrayEncoding.spaceDelimited) color: string | null`, - paramExample: `null`, - expectedExample: undefined, - }, - { - desc: "spaceDelimited (string)", - param: `@query @encode(ArrayEncoding.spaceDelimited) color: string`, - paramExample: `"blue"`, - expectedExample: undefined, - }, - { - desc: "spaceDelimited (array) explode: false", - param: `@query @encode(ArrayEncoding.spaceDelimited) color: string[]`, - paramExample: `#["blue", "black", "brown"]`, - expectedExample: "?color=blue%20black%20brown", - }, - { - desc: "spaceDelimited (array) explode: true", - param: `@query(#{ explode: true }) @encode(ArrayEncoding.spaceDelimited) color: string[]`, - paramExample: `#["blue", "black", "brown"]`, - expectedExample: undefined, - }, - { - desc: "spaceDelimited (object) explode: false", - param: `@query @encode(ArrayEncoding.spaceDelimited) color: Record`, - paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: "?color=R%20100%20G%20200%20B%20150", - }, - { - desc: "spaceDelimited (object) explode: true", - param: `@query(#{ explode: true }) @encode(ArrayEncoding.spaceDelimited) color: Record`, - paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: undefined, - }, - { - desc: "pipeDelimited (undefined)", - param: `@query @encode(ArrayEncoding.pipeDelimited) color: string | null`, - paramExample: `null`, - expectedExample: undefined, - }, - { - desc: "pipeDelimited (string)", - param: `@query @encode(ArrayEncoding.pipeDelimited) color: string`, - paramExample: `"blue"`, - expectedExample: undefined, - }, - { - desc: "pipeDelimited (array) explode: false", - param: `@query @encode(ArrayEncoding.pipeDelimited) color: string[]`, - paramExample: `#["blue", "black", "brown"]`, - // cspell:disable-next-line - expectedExample: "?color=blue%7Cblack%7Cbrown", - }, - { - desc: "pipeDelimited (array) explode: true", - param: `@query(#{ explode: true }) @encode(ArrayEncoding.pipeDelimited) color: string[]`, - paramExample: `#["blue", "black", "brown"]`, - expectedExample: undefined, - }, - { - desc: "pipeDelimited (object) explode: false", - param: `@query @encode(ArrayEncoding.pipeDelimited) color: Record`, - paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: "?color=R%7C100%7CG%7C200%7CB%7C150", - }, - { - desc: "pipeDelimited (object) explode: true", - param: `@query(#{ explode: true }) @encode(ArrayEncoding.pipeDelimited) color: Record`, - paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: undefined, - }, - ])("$desc", async ({ param, paramExample, expectedExample }) => { - const res = await openApiFor( - ` - @opExample(#{ - parameters: #{ - color: ${paramExample}, - }, - }) - @route("/") - op getColors(${param}): void; - `, - undefined, - { "serialize-parameter-examples": true }, - ); - expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( - expectedExample, - ); - }); - }); - - describe("set example on the path parameter with serialization enabled", () => { - it.each([ - { - desc: "simple (undefined)", - route: "/{color}", - param: `@path color: string | null`, - paramExample: `null`, - expectedExample: "", - }, - { - desc: "simple (string)", - route: "/{color}", - param: `@path color: string`, - paramExample: `"blue"`, - expectedExample: "blue", - }, - { - desc: "simple (array) explode: false", - route: "/{color}", - param: `@path color: string[]`, - paramExample: `#["blue", "black", "brown"]`, - expectedExample: "blue,black,brown", - }, - { - desc: "simple (array) explode: true", - route: "/{color*}", - param: `@path color: string[]`, - paramExample: `#["blue", "black", "brown"]`, - expectedExample: "blue,black,brown", - }, - { - desc: "simple (object) explode: false", - route: "/{color}", - param: `@path color: Record`, - paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: "R,100,G,200,B,150", - }, - { - desc: "simple (object) explode: true", - route: "/{color*}", - param: `@path color: Record`, - paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: "R=100,G=200,B=150", - }, - { - desc: "matrix (undefined)", - route: "/{;color}", - param: `@path color: string | null`, - paramExample: `null`, - expectedExample: ";color", - }, - { - desc: "matrix (string)", - route: "/{;color}", - param: `@path color: string`, - paramExample: `"blue"`, - expectedExample: ";color=blue", - }, - { - desc: "matrix (array) explode: false", - route: "/{;color}", - param: `@path color: string[]`, - paramExample: `#["blue", "black", "brown"]`, - expectedExample: ";color=blue,black,brown", - }, - { - desc: "matrix (array) explode: true", - route: "/{;color*}", - param: `@path color: string[]`, - paramExample: `#["blue", "black", "brown"]`, - expectedExample: ";color=blue;color=black;color=brown", - }, - { - desc: "matrix (object) explode: false", - route: "/{;color}", - param: `@path color: Record`, - paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: ";color=R,100,G,200,B,150", - }, - { - desc: "matrix (object) explode: true", - route: "/{;color*}", - param: `@path color: Record`, - paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: ";R=100;G=200;B=150", - }, - { - desc: "label (undefined)", - route: "/{.color}", - param: `@path color: string | null`, - paramExample: `null`, - expectedExample: ".", - }, - { - desc: "label (string)", - route: "/{.color}", - param: `@path color: string`, - paramExample: `"blue"`, - expectedExample: ".blue", - }, - { - desc: "label (array) explode: false", - route: "/{.color}", - param: `@path color: string[]`, - paramExample: `#["blue", "black", "brown"]`, - expectedExample: ".blue,black,brown", - }, - { - desc: "label (array) explode: true", - route: "/{.color*}", - param: `@path color: string[]`, - paramExample: `#["blue", "black", "brown"]`, - expectedExample: ".blue.black.brown", - }, - { - desc: "label (object) explode: false", - route: "/{.color}", - param: `@path color: Record`, - paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: ".R,100,G,200,B,150", - }, - { - desc: "label (object) explode: true", - route: "/{.color*}", - param: `@path color: Record`, - paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: ".R=100.G=200.B=150", - }, - ])("$desc", async ({ param, route, paramExample, expectedExample }) => { - const res: OpenAPI3Document = await openApiFor( - ` - @opExample(#{ - parameters: #{ - color: ${paramExample}, - }, - }) - @route("${route}") - op getColors(${param}): void; - `, - undefined, - { "serialize-parameter-examples": true }, - ); - expect((res.paths[`/{color}`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( - expectedExample, - ); - }); - }); - - describe("set example on the header parameter with serialization enabled", () => { - it.each([ - { - desc: "simple (undefined)", - param: `@header color: string | null`, - paramExample: `null`, - expectedExample: "", - }, - { - desc: "simple (string)", - param: `@header color: string`, - paramExample: `"blue"`, - expectedExample: "blue", - }, - { - desc: "simple (array) explode: false", - param: `@header color: string[]`, - paramExample: `#["blue", "black", "brown"]`, - expectedExample: "blue,black,brown", - }, - { - desc: "simple (array) explode: true", - param: `@header(#{ explode: true }) color: string[]`, - paramExample: `#["blue", "black", "brown"]`, - expectedExample: "blue,black,brown", - }, - { - desc: "simple (object) explode: false", - param: `@header color: Record`, - paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: "R,100,G,200,B,150", - }, - { - desc: "simple (object) explode: true", - param: `@header(#{ explode: true }) color: Record`, - paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: "R=100,G=200,B=150", - }, - ])("$desc", async ({ param, paramExample, expectedExample }) => { - const res: OpenAPI3Document = await openApiFor( - ` - @opExample(#{ - parameters: #{ - color: ${paramExample}, - }, - }) - @route("/") - op getColors(${param}): void; - `, - undefined, - { "serialize-parameter-examples": true }, - ); - expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( - expectedExample, - ); - }); - }); - - describe("set example on the cookie parameter with serialization enabled", () => { - it.each([ - { - desc: "form (undefined)", - param: `@cookie color: string | null`, - paramExample: `null`, - expectedExample: "color=", - }, - { - desc: "form (string)", - param: `@cookie color: string`, - paramExample: `"blue"`, - expectedExample: "color=blue", - }, - { - desc: "form (array) explode: false", - param: `@cookie color: string[]`, - paramExample: `#["blue", "black", "brown"]`, - expectedExample: "color=blue,black,brown", - }, - { - desc: "form (object) explode: false", - param: `@cookie color: Record`, - paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: "color=R,100,G,200,B,150", - }, - ])("$desc", async ({ param, paramExample, expectedExample }) => { - const res: OpenAPI3Document = await openApiFor( - ` - @opExample(#{ - parameters: #{ - color: ${paramExample}, - }, - }) - @route("/") - op getColors(${param}): void; - `, - undefined, - { "serialize-parameter-examples": true }, - ); - expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( - expectedExample, - ); - }); - }); - - it("supports multiple examples on parameter with serialization enabled", async () => { - const res = await openApiFor( - ` - @opExample(#{ - parameters: #{ - color: "green", - }, - }, #{ title: "MyExample" }) - @opExample(#{ - parameters: #{ - color: "red", - }, - }, #{ title: "MyExample2" }) - @route("/") - op getColors(@query color: string): void; - `, - undefined, - { "serialize-parameter-examples": true }, - ); - expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).examples).toEqual({ - MyExample: { - summary: "MyExample", - value: "?color=green", - }, - MyExample2: { - summary: "MyExample2", - value: "?color=red", - }, - }); - }); - it("supports multiple examples on parameter without serialization enabled", async () => { const res = await openApiFor( ` From ddcc61d3c71aab86d13fb51965c201ae680acfa8 Mon Sep 17 00:00:00 2001 From: Christopher Radek Date: Thu, 22 May 2025 13:27:11 -0700 Subject: [PATCH 07/14] add tests for encoded examples --- packages/openapi3/test/examples.test.ts | 43 +++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/openapi3/test/examples.test.ts b/packages/openapi3/test/examples.test.ts index 74a57f16b3e..abeca8807d7 100644 --- a/packages/openapi3/test/examples.test.ts +++ b/packages/openapi3/test/examples.test.ts @@ -307,4 +307,47 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { }, }); }); + + it("supports encoding", async () => { + const res = await openApiFor(` + @opExample(#{ + parameters: #{ + dob: plainDate.fromISO("2021-01-01"), + utc: utcDateTime.fromISO("2021-01-01T00:00:00Z"), + utcAsUnix: utcDateTime.fromISO("2021-01-01T00:00:00Z"), + dur: duration.fromISO("PT1H"), + } + }, #{ title: "Test Example"}) + @route("/") + op getDates(...Test): void; + + model Test { + @query + dob: plainDate; + + @query + utc: utcDateTime; + + @query + @encode(DateTimeKnownEncoding.unixTimestamp, int32) + utcAsUnix: utcDateTime; + + @query + @encode(DurationKnownEncoding.seconds, int32) + dur: duration; + } + `); + expect((res.components.parameters["Test.dob"] as OpenAPI3Parameter).examples).toEqual({ + "Test Example": { summary: "Test Example", value: "2021-01-01" }, + }); + expect((res.components.parameters["Test.utc"] as OpenAPI3Parameter).examples).toEqual({ + "Test Example": { summary: "Test Example", value: "2021-01-01T00:00:00Z" }, + }); + expect((res.components.parameters["Test.utcAsUnix"] as OpenAPI3Parameter).examples).toEqual({ + "Test Example": { summary: "Test Example", value: 1609459200 }, + }); + expect((res.components.parameters["Test.dur"] as OpenAPI3Parameter).examples).toEqual({ + "Test Example": { summary: "Test Example", value: 3600 }, + }); + }); }); From 41dd053ae1c6a72471c2fa58652d3d5b930576bc Mon Sep 17 00:00:00 2001 From: Christopher Radek Date: Fri, 23 May 2025 09:57:24 -0700 Subject: [PATCH 08/14] revert docs --- packages/openapi3/README.md | 8 -------- .../docs/docs/emitters/openapi3/reference/emitter.md | 8 -------- 2 files changed, 16 deletions(-) diff --git a/packages/openapi3/README.md b/packages/openapi3/README.md index 779e23868ea..f650ab8a3f2 100644 --- a/packages/openapi3/README.md +++ b/packages/openapi3/README.md @@ -125,14 +125,6 @@ If true, then for models emitted as object schemas we default `additionalPropert OpenAPI 3.0, and `unevaluatedProperties` to false for OpenAPI 3.1, if not explicitly specified elsewhere. Default: `false` -### `serialize-parameter-examples` - -**Type:** `boolean` - -If true, then examples on parameters will be serialized based on the parameter's prescribed serialization strategy. -Default: `false` -See https://spec.openapis.org/oas/v3.0.4.html#style-examples - ## Decorators ### TypeSpec.OpenAPI diff --git a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md index bc437b1743e..66b71c12c7d 100644 --- a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md @@ -118,11 +118,3 @@ Default: `int64` If true, then for models emitted as object schemas we default `additionalProperties` to false for OpenAPI 3.0, and `unevaluatedProperties` to false for OpenAPI 3.1, if not explicitly specified elsewhere. Default: `false` - -### `serialize-parameter-examples` - -**Type:** `boolean` - -If true, then examples on parameters will be serialized based on the parameter's prescribed serialization strategy. -Default: `false` -See https://spec.openapis.org/oas/v3.0.4.html#style-examples From 995e840ba5ce20f549a0a54b9b7f4111216c90ef Mon Sep 17 00:00:00 2001 From: Christopher Radek Date: Mon, 2 Jun 2025 13:41:36 -0700 Subject: [PATCH 09/14] Revert "remove parameter example serialization" This reverts commit 30a082decf8d0664cf68fda64b6edc21276613f6. --- packages/openapi3/src/examples.ts | 360 ++++++++++++++++++++- packages/openapi3/src/lib.ts | 17 + packages/openapi3/src/openapi.ts | 10 +- packages/openapi3/test/examples.test.ts | 414 ++++++++++++++++++++++++ 4 files changed, 798 insertions(+), 3 deletions(-) diff --git a/packages/openapi3/src/examples.ts b/packages/openapi3/src/examples.ts index 86fe4510a44..14244765d13 100644 --- a/packages/openapi3/src/examples.ts +++ b/packages/openapi3/src/examples.ts @@ -1,13 +1,17 @@ import { + BooleanValue, Example, getOpExamples, ignoreDiagnostics, + NumericValue, OpExample, Program, serializeValueAsJson, + StringValue, Type, Value, } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; import type { HttpOperation, HttpOperationResponse, @@ -15,6 +19,7 @@ import type { HttpProperty, HttpStatusCodeRange, } from "@typespec/http"; +import { getParameterStyle } from "./parameters.js"; import { getOpenAPI3StatusCodes } from "./status-codes.js"; import { OpenAPI3Example, OpenAPI3MediaType } from "./types.js"; import { @@ -30,9 +35,14 @@ export interface OperationExamples { responses: Record>; } +type ResolveOperationExamplesOptions = { + enableParameterSerialization: boolean; +}; + export function resolveOperationExamples( program: Program, operation: HttpOperation | SharedHttpOperation, + { enableParameterSerialization }: ResolveOperationExamplesOptions, ): OperationExamples { const examples = findOperationExamples(program, operation); const result: OperationExamples = { requestBody: {}, parameters: {}, responses: {} }; @@ -62,7 +72,12 @@ export function resolveOperationExamples( for (const property of op.parameters.properties) { if (!isHttpParameterProperty(property)) continue; - const value = getParameterValue(program, example.parameters, property); + const value = getParameterValue( + program, + example.parameters, + property, + enableParameterSerialization, + ); if (value) { const parameterName = property.options.name; result.parameters[parameterName] ??= []; @@ -263,11 +278,354 @@ function getParameterValue( program: Program, parameterExamples: Value, property: HttpParameterProperties, + enableSerialization: boolean = false, ): Value | undefined { const value = getValueByPath(parameterExamples, property.path); + if (!enableSerialization) { + return value; + } + + if (!value) return value; + + // Depending on the parameter type, we may need to serialize the value differently. + // https://spec.openapis.org/oas/v3.0.4.html#style-examples + /* + Supported styles per location: https://spec.openapis.org/oas/v3.0.4.html#style-values + | Location | Default Style | Supported Styles | + | -------- | ------------- | ----------------------------------------------- | + | query | form | form, spaceDelimited, pipeDelimited, deepObject | + | header | simple | simple | + | path | simple | simple, label, matrix | + | cookie | form | form | + */ + // explode is only relevant for array/object types + + if (property.kind === "query") { + return getQueryParameterValue(program, value, property); + } else if (property.kind === "header") { + return getHeaderParameterValue(program, value, property); + } else if (property.kind === "path") { + return getPathParameterValue(program, value, property); + } else if (property.kind === "cookie") { + return getCookieParameterValue(program, value, property); + } + return value; } +function getQueryParameterValue( + program: Program, + originalValue: Value, + property: Extract, +): Value | undefined { + const style = getParameterStyle(program, property.property) ?? "form"; + + switch (style) { + case "form": + return getParameterFormValue(program, originalValue, property); + case "spaceDelimited": + return getParameterDelimitedValue(program, originalValue, property, " "); + case "pipeDelimited": + return getParameterDelimitedValue(program, originalValue, property, "|"); + } +} + +function getHeaderParameterValue( + program: Program, + originalValue: Value, + property: Extract, +): Value | undefined { + return getParameterSimpleValue(program, originalValue, property); +} + +function getPathParameterValue( + program: Program, + originalValue: Value, + property: Extract, +): Value | undefined { + const { style } = property.options; + + if (style === "label") { + return getParameterLabelValue(program, originalValue, property); + } else if (style === "matrix") { + return getParameterMatrixValue(program, originalValue, property); + } else if (style === "simple") { + return getParameterSimpleValue(program, originalValue, property); + } + + return undefined; +} + +function getCookieParameterValue( + program: Program, + originalValue: Value, + property: Extract, +): Value | undefined { + return getParameterFormValue(program, originalValue, property); +} + +function getParameterLabelValue( + program: Program, + originalValue: Value, + property: Extract, +): Value | undefined { + const { explode } = property.options; + const tk = $(program); + + /* + https://spec.openapis.org/oas/v3.0.4.html#style-examples + string -> "blue" + array -> ["blue", "black", "brown"] + object -> { "R": 100, "G": 200, "B": 150 } + + | explode | string | array | object | + | ------- | ------- | ----------------- | ------------------ | + | false | .blue | .blue,black,brown | .R,100,G,200,B,150 | + | true | .blue | .blue.black.brown | .R=100.G=200.B=150 | + */ + + const joiner = explode ? "." : ","; + if (tk.value.isArray(originalValue)) { + const pairs: string[] = []; + for (const value of originalValue.values) { + if (!isSerializableScalarValue(value)) continue; + pairs.push(`${value.value}`); + } + return tk.value.createString(`.${pairs.join(joiner)}`); + } + + if (tk.value.isObject(originalValue)) { + const pairs: string[] = []; + for (const [key, { value }] of originalValue.properties) { + if (!isSerializableScalarValue(value)) continue; + const sep = explode ? "=" : ","; + pairs.push(`${key}${sep}${value.value}`); + } + return tk.value.createString(`.${pairs.join(joiner)}`); + } + + // null (undefined) is treated as a a dot + if (tk.value.isNull(originalValue)) { + return tk.value.createString("."); + } + + if (isSerializableScalarValue(originalValue)) { + return tk.value.createString(`.${originalValue.value}`); + } + + return; +} + +function getParameterMatrixValue( + program: Program, + originalValue: Value, + property: Extract, +): Value | undefined { + const { explode, name } = property.options; + const tk = $(program); + + /* + https://spec.openapis.org/oas/v3.0.4.html#style-examples + string -> "blue" + array -> ["blue", "black", "brown"] + object -> { "R": 100, "G": 200, "B": 150 } + + | explode | string | array | object | + | ------- | ------------- | ----------------------------------- | ------------------------ | + | false | ;color=blue | ;color=blue,black,brown | ;color=R,100,G,200,B,150 | + | true | ;color=blue | ;color=blue;color=black;color=brown | ;R=100;G=200;B=150 | + */ + + const joiner = explode ? ";" : ","; + const prefix = explode ? "" : `${name}=`; + if (tk.value.isArray(originalValue)) { + const pairs: string[] = []; + for (const value of originalValue.values) { + if (!isSerializableScalarValue(value)) continue; + pairs.push(explode ? `${name}=${value.value}` : `${value.value}`); + } + return tk.value.createString(`;${prefix}${pairs.join(joiner)}`); + } + + if (tk.value.isObject(originalValue)) { + const sep = explode ? "=" : ","; + const pairs: string[] = []; + for (const [key, { value }] of originalValue.properties) { + if (!isSerializableScalarValue(value)) continue; + pairs.push(`${key}${sep}${value.value}`); + } + return tk.value.createString(`;${prefix}${pairs.join(joiner)}`); + } + + if (tk.value.isNull(originalValue)) { + return tk.value.createString(`;${name}`); + } + + if (isSerializableScalarValue(originalValue)) { + return tk.value.createString(`;${name}=${originalValue.value}`); + } + + return; +} + +function getParameterDelimitedValue( + program: Program, + originalValue: Value, + property: Extract, + delimiter: " " | "|", +): Value | undefined { + const { explode, name } = property.options; + // Serialization is undefined for explode=true + if (explode) return undefined; + + const tk = $(program); + // cspell: ignore Cblack Cbrown + /* + https://spec.openapis.org/oas/v3.0.4.html#style-examples + array -> ["blue", "black", "brown"] + object -> { "R": 100, "G": 200, "B": 150 } + + | style | explode | string | array | object | + | ----- | ------- | ------ | --------------------------- | ---------------------------------- | + | pipe | false | n/a | ?color=blue%7Cblack%7Cbrown | ?color=R%7C100%7CG%7C200%7CB%7C150 | + | pipe | true | n/a | n/a | n/a | + | space | false | n/a | ?color=blue%20black%20brown | ?color=R%20100%20G%20200%20B%20150 | + | space | true | n/a | n/a | n/a | + */ + + if (tk.value.isArray(originalValue)) { + const pairs: string[] = []; + for (const value of originalValue.values) { + if (!isSerializableScalarValue(value)) continue; + pairs.push(`${value.value}`); + } + return tk.value.createString(`?${name}=${encodeURIComponent(pairs.join(delimiter))}`); + } + + if (tk.value.isObject(originalValue)) { + const pairs: string[] = []; + for (const [key, { value }] of originalValue.properties) { + if (!isSerializableScalarValue(value)) continue; + pairs.push(`${key}${delimiter}${value.value}`); + } + return tk.value.createString(`?${name}=${encodeURIComponent(pairs.join(delimiter))}`); + } + + return undefined; +} + +function getParameterFormValue( + program: Program, + originalValue: Value, + property: Extract, +): Value | undefined { + const { name } = property.options; + const isCookie = property.kind === "cookie"; + const explode = isCookie ? false : property.options.explode; + const tk = $(program); + /* + https://spec.openapis.org/oas/v3.0.4.html#style-examples + string -> "blue" + array -> ["blue", "black", "brown"] + object -> { "R": 100, "G": 200, "B": 150 } + + | explode | string | array | object | + | ------- | ------------- | ----------------------------------- | ------------------------ | + | false | ?color=blue | ?color=blue,black,brown | ?color=R,100,G,200,B,150 | + | true | ?color=blue | ?color=blue&color=black&color=brown | ?R=100&G=200&B=150 | + */ + + const qPrefix = isCookie ? "" : "?"; + const prefix = explode ? "" : `${name}=`; + if (tk.value.isArray(originalValue)) { + const sep = explode ? "&" : ","; + const pairs: string[] = []; + for (const value of originalValue.values) { + if (!isSerializableScalarValue(value)) continue; + pairs.push(explode ? `${name}=${value.value}` : `${value.value}`); + } + return tk.value.createString(`${qPrefix}${prefix}${pairs.join(sep)}`); + } + + if (tk.value.isObject(originalValue)) { + const sep = explode ? "=" : ","; + const joiner = explode ? "&" : ","; + const pairs: string[] = []; + for (const [key, { value }] of originalValue.properties) { + if (!isSerializableScalarValue(value)) continue; + pairs.push(`${key}${sep}${value.value}`); + } + return tk.value.createString(`${qPrefix}${prefix}${pairs.join(joiner)}`); + } + + if (isSerializableScalarValue(originalValue)) { + return tk.value.createString(`${qPrefix}${name}=${originalValue.value}`); + } + + // null is treated as the 'undefined' value + if (tk.value.isNull(originalValue)) { + return tk.value.createString(`${qPrefix}${name}=`); + } + + return; +} + +function getParameterSimpleValue( + program: Program, + originalValue: Value, + property: Extract, +): Value | undefined { + const { explode } = property.options; + const tk = $(program); + + /* + https://spec.openapis.org/oas/v3.0.4.html#style-examples + string -> "blue" + array -> ["blue", "black", "brown"] + object -> { "R": 100, "G": 200, "B": 150 } + + | explode | string | array | object | + | ------- | ------ | ---------------- | ----------------- | + | false | blue | blue,black,brown | R,100,G,200,B,150 | + | true | blue | blue,black,brown | R=100,G=200,B=150 | + */ + + if (tk.value.isArray(originalValue)) { + const serializedValue = originalValue.values + .filter(isSerializableScalarValue) + .map((v) => v.value) + .join(","); + return tk.value.createString(serializedValue); + } + + if (tk.value.isObject(originalValue)) { + const pairs: string[] = []; + for (const [key, { value }] of originalValue.properties) { + if (!isSerializableScalarValue(value)) continue; + const sep = explode ? "=" : ","; + pairs.push(`${key}${sep}${value.value}`); + } + return tk.value.createString(pairs.join(",")); + } + + // null (undefined) is treated as an empty string - unrelated to allowEmptyValue + if (tk.value.isNull(originalValue)) { + return tk.value.createString(""); + } + + if (isSerializableScalarValue(originalValue)) { + return originalValue; + } + + return; +} + +function isSerializableScalarValue( + value: Value, +): value is BooleanValue | NumericValue | StringValue { + return ["BooleanValue", "NumericValue", "StringValue"].includes(value.valueKind); +} + function getValueByPath(value: Value, path: (string | number)[]): Value | undefined { let current: Value | undefined = value; for (const key of path) { diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index cbf01f1ae29..ebedd31262b 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -81,6 +81,13 @@ export interface OpenAPI3EmitterOptions { * @default false */ "seal-object-schemas"?: boolean; + + /** + * If true, then examples on parameters will be serialized based on the parameter's prescribed serialization strategy. + * @default false + * @see https://spec.openapis.org/oas/v3.0.4.html#style-examples + */ + "serialize-parameter-examples"?: boolean; } const EmitterOptionsSchema: JSONSchemaType = { @@ -180,6 +187,16 @@ const EmitterOptionsSchema: JSONSchemaType = { "Default: `false`", ].join("\n"), }, + "serialize-parameter-examples": { + type: "boolean", + nullable: true, + default: false, + description: [ + "If true, then examples on parameters will be serialized based on the parameter's prescribed serialization strategy.", + "Default: `false`", + "See https://spec.openapis.org/oas/v3.0.4.html#style-examples", + ].join("\n"), + }, }, required: [], }; diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 74d22b16a23..dfee294b504 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -211,6 +211,7 @@ export function resolveOptions( outputFile: resolvePath(context.emitterOutputDir, specDir, outputFile), openapiVersions, sealObjectSchemas: resolvedOptions["seal-object-schemas"], + serializeParameterExamples: resolvedOptions["serialize-parameter-examples"] ?? false, }; } @@ -223,6 +224,7 @@ export interface ResolvedOpenAPI3EmitterOptions { includeXTypeSpecName: "inline-only" | "never"; safeintStrategy: "double-int" | "int64"; sealObjectSchemas: boolean; + serializeParameterExamples: boolean; } function createOAPIEmitter( @@ -732,7 +734,9 @@ function createOAPIEmitter( const operations = shared.operations; const verb = operations[0].verb; const path = operations[0].path; - const examples = resolveOperationExamples(program, shared); + const examples = resolveOperationExamples(program, shared, { + enableParameterSerialization: options.serializeParameterExamples, + }); const oai3Operation: OpenAPI3Operation = { operationId: computeSharedOperationId(shared), parameters: [], @@ -810,7 +814,9 @@ function createOAPIEmitter( return undefined; } const visibility = resolveRequestVisibility(program, operation.operation, verb); - const examples = resolveOperationExamples(program, operation); + const examples = resolveOperationExamples(program, operation, { + enableParameterSerialization: options.serializeParameterExamples, + }); const oai3Operation: OpenAPI3Operation = { operationId: resolveOperationId(program, operation.operation), summary: getSummary(program, operation.operation), diff --git a/packages/openapi3/test/examples.test.ts b/packages/openapi3/test/examples.test.ts index abeca8807d7..06683e153af 100644 --- a/packages/openapi3/test/examples.test.ts +++ b/packages/openapi3/test/examples.test.ts @@ -279,6 +279,420 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { }, ); + describe("set example on the query parameter with serialization enabled", () => { + it.each([ + { + desc: "form (undefined)", + param: `@query color: string | null`, + paramExample: `null`, + expectedExample: "?color=", + }, + { + desc: "form (string)", + param: `@query color: string`, + paramExample: `"blue"`, + expectedExample: "?color=blue", + }, + { + desc: "form (array) explode: false", + param: `@query color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: "?color=blue,black,brown", + }, + { + desc: "form (array) explode: true", + param: `@query(#{ explode: true }) color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: "?color=blue&color=black&color=brown", + }, + { + desc: "form (object) explode: false", + param: `@query color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "?color=R,100,G,200,B,150", + }, + { + desc: "form (object) explode: true", + param: `@query(#{ explode: true }) color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "?R=100&G=200&B=150", + }, + { + desc: "spaceDelimited (undefined)", + param: `@query @encode(ArrayEncoding.spaceDelimited) color: string | null`, + paramExample: `null`, + expectedExample: undefined, + }, + { + desc: "spaceDelimited (string)", + param: `@query @encode(ArrayEncoding.spaceDelimited) color: string`, + paramExample: `"blue"`, + expectedExample: undefined, + }, + { + desc: "spaceDelimited (array) explode: false", + param: `@query @encode(ArrayEncoding.spaceDelimited) color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: "?color=blue%20black%20brown", + }, + { + desc: "spaceDelimited (array) explode: true", + param: `@query(#{ explode: true }) @encode(ArrayEncoding.spaceDelimited) color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: undefined, + }, + { + desc: "spaceDelimited (object) explode: false", + param: `@query @encode(ArrayEncoding.spaceDelimited) color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "?color=R%20100%20G%20200%20B%20150", + }, + { + desc: "spaceDelimited (object) explode: true", + param: `@query(#{ explode: true }) @encode(ArrayEncoding.spaceDelimited) color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: undefined, + }, + { + desc: "pipeDelimited (undefined)", + param: `@query @encode(ArrayEncoding.pipeDelimited) color: string | null`, + paramExample: `null`, + expectedExample: undefined, + }, + { + desc: "pipeDelimited (string)", + param: `@query @encode(ArrayEncoding.pipeDelimited) color: string`, + paramExample: `"blue"`, + expectedExample: undefined, + }, + { + desc: "pipeDelimited (array) explode: false", + param: `@query @encode(ArrayEncoding.pipeDelimited) color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + // cspell:disable-next-line + expectedExample: "?color=blue%7Cblack%7Cbrown", + }, + { + desc: "pipeDelimited (array) explode: true", + param: `@query(#{ explode: true }) @encode(ArrayEncoding.pipeDelimited) color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: undefined, + }, + { + desc: "pipeDelimited (object) explode: false", + param: `@query @encode(ArrayEncoding.pipeDelimited) color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "?color=R%7C100%7CG%7C200%7CB%7C150", + }, + { + desc: "pipeDelimited (object) explode: true", + param: `@query(#{ explode: true }) @encode(ArrayEncoding.pipeDelimited) color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: undefined, + }, + ])("$desc", async ({ param, paramExample, expectedExample }) => { + const res = await openApiFor( + ` + @opExample(#{ + parameters: #{ + color: ${paramExample}, + }, + }) + @route("/") + op getColors(${param}): void; + `, + undefined, + { "serialize-parameter-examples": true }, + ); + expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( + expectedExample, + ); + }); + }); + + describe("set example on the path parameter with serialization enabled", () => { + it.each([ + { + desc: "simple (undefined)", + route: "/{color}", + param: `@path color: string | null`, + paramExample: `null`, + expectedExample: "", + }, + { + desc: "simple (string)", + route: "/{color}", + param: `@path color: string`, + paramExample: `"blue"`, + expectedExample: "blue", + }, + { + desc: "simple (array) explode: false", + route: "/{color}", + param: `@path color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: "blue,black,brown", + }, + { + desc: "simple (array) explode: true", + route: "/{color*}", + param: `@path color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: "blue,black,brown", + }, + { + desc: "simple (object) explode: false", + route: "/{color}", + param: `@path color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "R,100,G,200,B,150", + }, + { + desc: "simple (object) explode: true", + route: "/{color*}", + param: `@path color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "R=100,G=200,B=150", + }, + { + desc: "matrix (undefined)", + route: "/{;color}", + param: `@path color: string | null`, + paramExample: `null`, + expectedExample: ";color", + }, + { + desc: "matrix (string)", + route: "/{;color}", + param: `@path color: string`, + paramExample: `"blue"`, + expectedExample: ";color=blue", + }, + { + desc: "matrix (array) explode: false", + route: "/{;color}", + param: `@path color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: ";color=blue,black,brown", + }, + { + desc: "matrix (array) explode: true", + route: "/{;color*}", + param: `@path color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: ";color=blue;color=black;color=brown", + }, + { + desc: "matrix (object) explode: false", + route: "/{;color}", + param: `@path color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: ";color=R,100,G,200,B,150", + }, + { + desc: "matrix (object) explode: true", + route: "/{;color*}", + param: `@path color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: ";R=100;G=200;B=150", + }, + { + desc: "label (undefined)", + route: "/{.color}", + param: `@path color: string | null`, + paramExample: `null`, + expectedExample: ".", + }, + { + desc: "label (string)", + route: "/{.color}", + param: `@path color: string`, + paramExample: `"blue"`, + expectedExample: ".blue", + }, + { + desc: "label (array) explode: false", + route: "/{.color}", + param: `@path color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: ".blue,black,brown", + }, + { + desc: "label (array) explode: true", + route: "/{.color*}", + param: `@path color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: ".blue.black.brown", + }, + { + desc: "label (object) explode: false", + route: "/{.color}", + param: `@path color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: ".R,100,G,200,B,150", + }, + { + desc: "label (object) explode: true", + route: "/{.color*}", + param: `@path color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: ".R=100.G=200.B=150", + }, + ])("$desc", async ({ param, route, paramExample, expectedExample }) => { + const res: OpenAPI3Document = await openApiFor( + ` + @opExample(#{ + parameters: #{ + color: ${paramExample}, + }, + }) + @route("${route}") + op getColors(${param}): void; + `, + undefined, + { "serialize-parameter-examples": true }, + ); + expect((res.paths[`/{color}`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( + expectedExample, + ); + }); + }); + + describe("set example on the header parameter with serialization enabled", () => { + it.each([ + { + desc: "simple (undefined)", + param: `@header color: string | null`, + paramExample: `null`, + expectedExample: "", + }, + { + desc: "simple (string)", + param: `@header color: string`, + paramExample: `"blue"`, + expectedExample: "blue", + }, + { + desc: "simple (array) explode: false", + param: `@header color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: "blue,black,brown", + }, + { + desc: "simple (array) explode: true", + param: `@header(#{ explode: true }) color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: "blue,black,brown", + }, + { + desc: "simple (object) explode: false", + param: `@header color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "R,100,G,200,B,150", + }, + { + desc: "simple (object) explode: true", + param: `@header(#{ explode: true }) color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "R=100,G=200,B=150", + }, + ])("$desc", async ({ param, paramExample, expectedExample }) => { + const res: OpenAPI3Document = await openApiFor( + ` + @opExample(#{ + parameters: #{ + color: ${paramExample}, + }, + }) + @route("/") + op getColors(${param}): void; + `, + undefined, + { "serialize-parameter-examples": true }, + ); + expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( + expectedExample, + ); + }); + }); + + describe("set example on the cookie parameter with serialization enabled", () => { + it.each([ + { + desc: "form (undefined)", + param: `@cookie color: string | null`, + paramExample: `null`, + expectedExample: "color=", + }, + { + desc: "form (string)", + param: `@cookie color: string`, + paramExample: `"blue"`, + expectedExample: "color=blue", + }, + { + desc: "form (array) explode: false", + param: `@cookie color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: "color=blue,black,brown", + }, + { + desc: "form (object) explode: false", + param: `@cookie color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "color=R,100,G,200,B,150", + }, + ])("$desc", async ({ param, paramExample, expectedExample }) => { + const res: OpenAPI3Document = await openApiFor( + ` + @opExample(#{ + parameters: #{ + color: ${paramExample}, + }, + }) + @route("/") + op getColors(${param}): void; + `, + undefined, + { "serialize-parameter-examples": true }, + ); + expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( + expectedExample, + ); + }); + }); + + it("supports multiple examples on parameter with serialization enabled", async () => { + const res = await openApiFor( + ` + @opExample(#{ + parameters: #{ + color: "green", + }, + }, #{ title: "MyExample" }) + @opExample(#{ + parameters: #{ + color: "red", + }, + }, #{ title: "MyExample2" }) + @route("/") + op getColors(@query color: string): void; + `, + undefined, + { "serialize-parameter-examples": true }, + ); + expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).examples).toEqual({ + MyExample: { + summary: "MyExample", + value: "?color=green", + }, + MyExample2: { + summary: "MyExample2", + value: "?color=red", + }, + }); + }); + it("supports multiple examples on parameter without serialization enabled", async () => { const res = await openApiFor( ` From ff7b31e68ccfff53ecb9fa63bace3ca3d9ece754 Mon Sep 17 00:00:00 2001 From: Christopher Radek Date: Mon, 2 Jun 2025 14:15:11 -0700 Subject: [PATCH 10/14] add back in parameter example serialization --- packages/openapi3/src/examples.ts | 13 ++++++------- packages/openapi3/test/examples.test.ts | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/openapi3/src/examples.ts b/packages/openapi3/src/examples.ts index 14244765d13..74076cc47f4 100644 --- a/packages/openapi3/src/examples.ts +++ b/packages/openapi3/src/examples.ts @@ -499,7 +499,7 @@ function getParameterDelimitedValue( if (!isSerializableScalarValue(value)) continue; pairs.push(`${value.value}`); } - return tk.value.createString(`?${name}=${encodeURIComponent(pairs.join(delimiter))}`); + return tk.value.createString(`${name}=${encodeURIComponent(pairs.join(delimiter))}`); } if (tk.value.isObject(originalValue)) { @@ -508,7 +508,7 @@ function getParameterDelimitedValue( if (!isSerializableScalarValue(value)) continue; pairs.push(`${key}${delimiter}${value.value}`); } - return tk.value.createString(`?${name}=${encodeURIComponent(pairs.join(delimiter))}`); + return tk.value.createString(`${name}=${encodeURIComponent(pairs.join(delimiter))}`); } return undefined; @@ -535,7 +535,6 @@ function getParameterFormValue( | true | ?color=blue | ?color=blue&color=black&color=brown | ?R=100&G=200&B=150 | */ - const qPrefix = isCookie ? "" : "?"; const prefix = explode ? "" : `${name}=`; if (tk.value.isArray(originalValue)) { const sep = explode ? "&" : ","; @@ -544,7 +543,7 @@ function getParameterFormValue( if (!isSerializableScalarValue(value)) continue; pairs.push(explode ? `${name}=${value.value}` : `${value.value}`); } - return tk.value.createString(`${qPrefix}${prefix}${pairs.join(sep)}`); + return tk.value.createString(`${prefix}${pairs.join(sep)}`); } if (tk.value.isObject(originalValue)) { @@ -555,16 +554,16 @@ function getParameterFormValue( if (!isSerializableScalarValue(value)) continue; pairs.push(`${key}${sep}${value.value}`); } - return tk.value.createString(`${qPrefix}${prefix}${pairs.join(joiner)}`); + return tk.value.createString(`${prefix}${pairs.join(joiner)}`); } if (isSerializableScalarValue(originalValue)) { - return tk.value.createString(`${qPrefix}${name}=${originalValue.value}`); + return tk.value.createString(`${name}=${originalValue.value}`); } // null is treated as the 'undefined' value if (tk.value.isNull(originalValue)) { - return tk.value.createString(`${qPrefix}${name}=`); + return tk.value.createString(`${name}=`); } return; diff --git a/packages/openapi3/test/examples.test.ts b/packages/openapi3/test/examples.test.ts index 06683e153af..f1fbcb0ae8e 100644 --- a/packages/openapi3/test/examples.test.ts +++ b/packages/openapi3/test/examples.test.ts @@ -285,37 +285,37 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { desc: "form (undefined)", param: `@query color: string | null`, paramExample: `null`, - expectedExample: "?color=", + expectedExample: "color=", }, { desc: "form (string)", param: `@query color: string`, paramExample: `"blue"`, - expectedExample: "?color=blue", + expectedExample: "color=blue", }, { desc: "form (array) explode: false", param: `@query color: string[]`, paramExample: `#["blue", "black", "brown"]`, - expectedExample: "?color=blue,black,brown", + expectedExample: "color=blue,black,brown", }, { desc: "form (array) explode: true", param: `@query(#{ explode: true }) color: string[]`, paramExample: `#["blue", "black", "brown"]`, - expectedExample: "?color=blue&color=black&color=brown", + expectedExample: "color=blue&color=black&color=brown", }, { desc: "form (object) explode: false", param: `@query color: Record`, paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: "?color=R,100,G,200,B,150", + expectedExample: "color=R,100,G,200,B,150", }, { desc: "form (object) explode: true", param: `@query(#{ explode: true }) color: Record`, paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: "?R=100&G=200&B=150", + expectedExample: "R=100&G=200&B=150", }, { desc: "spaceDelimited (undefined)", @@ -333,7 +333,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { desc: "spaceDelimited (array) explode: false", param: `@query @encode(ArrayEncoding.spaceDelimited) color: string[]`, paramExample: `#["blue", "black", "brown"]`, - expectedExample: "?color=blue%20black%20brown", + expectedExample: "color=blue%20black%20brown", }, { desc: "spaceDelimited (array) explode: true", @@ -345,7 +345,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { desc: "spaceDelimited (object) explode: false", param: `@query @encode(ArrayEncoding.spaceDelimited) color: Record`, paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: "?color=R%20100%20G%20200%20B%20150", + expectedExample: "color=R%20100%20G%20200%20B%20150", }, { desc: "spaceDelimited (object) explode: true", @@ -370,7 +370,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { param: `@query @encode(ArrayEncoding.pipeDelimited) color: string[]`, paramExample: `#["blue", "black", "brown"]`, // cspell:disable-next-line - expectedExample: "?color=blue%7Cblack%7Cbrown", + expectedExample: "color=blue%7Cblack%7Cbrown", }, { desc: "pipeDelimited (array) explode: true", @@ -382,7 +382,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { desc: "pipeDelimited (object) explode: false", param: `@query @encode(ArrayEncoding.pipeDelimited) color: Record`, paramExample: `#{R: 100, G: 200, B: 150}`, - expectedExample: "?color=R%7C100%7CG%7C200%7CB%7C150", + expectedExample: "color=R%7C100%7CG%7C200%7CB%7C150", }, { desc: "pipeDelimited (object) explode: true", @@ -684,11 +684,11 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).examples).toEqual({ MyExample: { summary: "MyExample", - value: "?color=green", + value: "color=green", }, MyExample2: { summary: "MyExample2", - value: "?color=red", + value: "color=red", }, }); }); From 61d52d798ce6289a382ee6d90f0a6f34d7523abd Mon Sep 17 00:00:00 2001 From: Christopher Radek Date: Mon, 2 Jun 2025 14:18:23 -0700 Subject: [PATCH 11/14] add experimental parameter examples option to opt into example emission strategy --- packages/openapi3/src/examples.ts | 39 ++++++++++++++----------- packages/openapi3/src/lib.ts | 18 +++++++----- packages/openapi3/src/openapi.ts | 8 ++--- packages/openapi3/test/examples.test.ts | 38 +++++++++++++++++++----- 4 files changed, 67 insertions(+), 36 deletions(-) diff --git a/packages/openapi3/src/examples.ts b/packages/openapi3/src/examples.ts index 74076cc47f4..9cf15d2be23 100644 --- a/packages/openapi3/src/examples.ts +++ b/packages/openapi3/src/examples.ts @@ -19,6 +19,7 @@ import type { HttpProperty, HttpStatusCodeRange, } from "@typespec/http"; +import { ExperimentalParameterExamplesStrategy } from "./lib.js"; import { getParameterStyle } from "./parameters.js"; import { getOpenAPI3StatusCodes } from "./status-codes.js"; import { OpenAPI3Example, OpenAPI3MediaType } from "./types.js"; @@ -36,13 +37,13 @@ export interface OperationExamples { } type ResolveOperationExamplesOptions = { - enableParameterSerialization: boolean; + parameterExamplesStrategy?: ExperimentalParameterExamplesStrategy; }; export function resolveOperationExamples( program: Program, operation: HttpOperation | SharedHttpOperation, - { enableParameterSerialization }: ResolveOperationExamplesOptions, + { parameterExamplesStrategy }: ResolveOperationExamplesOptions, ): OperationExamples { const examples = findOperationExamples(program, operation); const result: OperationExamples = { requestBody: {}, parameters: {}, responses: {} }; @@ -76,7 +77,7 @@ export function resolveOperationExamples( program, example.parameters, property, - enableParameterSerialization, + parameterExamplesStrategy, ); if (value) { const parameterName = property.options.name; @@ -278,15 +279,19 @@ function getParameterValue( program: Program, parameterExamples: Value, property: HttpParameterProperties, - enableSerialization: boolean = false, + parameterExamplesStrategy?: ExperimentalParameterExamplesStrategy, ): Value | undefined { + if (!parameterExamplesStrategy) { + return; + } + const value = getValueByPath(parameterExamples, property.path); - if (!enableSerialization) { + if (!value) return; + + if (parameterExamplesStrategy === "data") { return value; } - if (!value) return value; - // Depending on the parameter type, we may need to serialize the value differently. // https://spec.openapis.org/oas/v3.0.4.html#style-examples /* @@ -485,12 +490,12 @@ function getParameterDelimitedValue( array -> ["blue", "black", "brown"] object -> { "R": 100, "G": 200, "B": 150 } - | style | explode | string | array | object | - | ----- | ------- | ------ | --------------------------- | ---------------------------------- | - | pipe | false | n/a | ?color=blue%7Cblack%7Cbrown | ?color=R%7C100%7CG%7C200%7CB%7C150 | - | pipe | true | n/a | n/a | n/a | - | space | false | n/a | ?color=blue%20black%20brown | ?color=R%20100%20G%20200%20B%20150 | - | space | true | n/a | n/a | n/a | + | style | explode | string | array | object | + | ----- | ------- | ------ | -------------------------- | ----------------------------------| + | pipe | false | n/a | color=blue%7Cblack%7Cbrown | color=R%7C100%7CG%7C200%7CB%7C150 | + | pipe | true | n/a | n/a | n/a | + | space | false | n/a | color=blue%20black%20brown | color=R%20100%20G%20200%20B%20150 | + | space | true | n/a | n/a | n/a | */ if (tk.value.isArray(originalValue)) { @@ -529,10 +534,10 @@ function getParameterFormValue( array -> ["blue", "black", "brown"] object -> { "R": 100, "G": 200, "B": 150 } - | explode | string | array | object | - | ------- | ------------- | ----------------------------------- | ------------------------ | - | false | ?color=blue | ?color=blue,black,brown | ?color=R,100,G,200,B,150 | - | true | ?color=blue | ?color=blue&color=black&color=brown | ?R=100&G=200&B=150 | + | explode | string | array | object | + | ------- | ------------ | ---------------------------------- | ----------------------- | + | false | color=blue | color=blue,black,brown | color=R,100,G,200,B,150 | + | true | color=blue | color=blue&color=black&color=brown | R=100&G=200&B=150 | */ const prefix = explode ? "" : `${name}=`; diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index ebedd31262b..b6a69afc78f 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -2,6 +2,7 @@ import { createTypeSpecLibrary, JSONSchemaType, paramMessage } from "@typespec/c export type FileType = "yaml" | "json"; export type OpenAPIVersion = "3.0.0" | "3.1.0"; +export type ExperimentalParameterExamplesStrategy = "data" | "serialized"; export interface OpenAPI3EmitterOptions { /** * If the content should be serialized as YAML or JSON. @@ -83,11 +84,12 @@ export interface OpenAPI3EmitterOptions { "seal-object-schemas"?: boolean; /** - * If true, then examples on parameters will be serialized based on the parameter's prescribed serialization strategy. - * @default false + * Determines how to emit examples on parameters. + * + * Note: This is an experimental feature and may change in future versions. * @see https://spec.openapis.org/oas/v3.0.4.html#style-examples */ - "serialize-parameter-examples"?: boolean; + "experimental-parameter-examples"?: ExperimentalParameterExamplesStrategy; } const EmitterOptionsSchema: JSONSchemaType = { @@ -187,13 +189,13 @@ const EmitterOptionsSchema: JSONSchemaType = { "Default: `false`", ].join("\n"), }, - "serialize-parameter-examples": { - type: "boolean", + "experimental-parameter-examples": { + type: "string", + enum: ["data", "serialized"], nullable: true, - default: false, description: [ - "If true, then examples on parameters will be serialized based on the parameter's prescribed serialization strategy.", - "Default: `false`", + "Determines how to emit examples on parameters.", + "Note: This is an experimental feature and may change in future versions.", "See https://spec.openapis.org/oas/v3.0.4.html#style-examples", ].join("\n"), }, diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index dfee294b504..8ddb0305388 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -211,7 +211,7 @@ export function resolveOptions( outputFile: resolvePath(context.emitterOutputDir, specDir, outputFile), openapiVersions, sealObjectSchemas: resolvedOptions["seal-object-schemas"], - serializeParameterExamples: resolvedOptions["serialize-parameter-examples"] ?? false, + parameterExamplesStrategy: resolvedOptions["experimental-parameter-examples"], }; } @@ -224,7 +224,7 @@ export interface ResolvedOpenAPI3EmitterOptions { includeXTypeSpecName: "inline-only" | "never"; safeintStrategy: "double-int" | "int64"; sealObjectSchemas: boolean; - serializeParameterExamples: boolean; + parameterExamplesStrategy?: "data" | "serialized"; } function createOAPIEmitter( @@ -735,7 +735,7 @@ function createOAPIEmitter( const verb = operations[0].verb; const path = operations[0].path; const examples = resolveOperationExamples(program, shared, { - enableParameterSerialization: options.serializeParameterExamples, + parameterExamplesStrategy: options.parameterExamplesStrategy, }); const oai3Operation: OpenAPI3Operation = { operationId: computeSharedOperationId(shared), @@ -815,7 +815,7 @@ function createOAPIEmitter( } const visibility = resolveRequestVisibility(program, operation.operation, verb); const examples = resolveOperationExamples(program, operation, { - enableParameterSerialization: options.serializeParameterExamples, + parameterExamplesStrategy: options.parameterExamplesStrategy, }); const oai3Operation: OpenAPI3Operation = { operationId: resolveOperationId(program, operation.operation), diff --git a/packages/openapi3/test/examples.test.ts b/packages/openapi3/test/examples.test.ts index f1fbcb0ae8e..8c8a304b0de 100644 --- a/packages/openapi3/test/examples.test.ts +++ b/packages/openapi3/test/examples.test.ts @@ -235,6 +235,22 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { }); }); + it("does not set examples on parameters by default", async () => { + const res = await openApiFor( + ` + @opExample(#{ + parameters: #{ + color: "blue", + }, + }) + @route("/") + op getColors(@query color: string): void; + `, + ); + expect((res.paths["/"].get?.parameters[0] as OpenAPI3Parameter).example).toBeUndefined(); + expect((res.paths["/"].get?.parameters[0] as OpenAPI3Parameter).examples).toBeUndefined(); + }); + describe.each(["path", "query", "header", "cookie"])( "set example on the %s parameter without serialization", (paramType) => { @@ -271,6 +287,8 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { @route("/") op getColors(${param}): void; `, + undefined, + { "experimental-parameter-examples": "data" }, ); expect((res.paths[path].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( expectedExample, @@ -402,7 +420,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { op getColors(${param}): void; `, undefined, - { "serialize-parameter-examples": true }, + { "experimental-parameter-examples": "serialized" }, ); expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( expectedExample, @@ -550,7 +568,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { op getColors(${param}): void; `, undefined, - { "serialize-parameter-examples": true }, + { "experimental-parameter-examples": "serialized" }, ); expect((res.paths[`/{color}`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( expectedExample, @@ -608,7 +626,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { op getColors(${param}): void; `, undefined, - { "serialize-parameter-examples": true }, + { "experimental-parameter-examples": "serialized" }, ); expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( expectedExample, @@ -654,7 +672,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { op getColors(${param}): void; `, undefined, - { "serialize-parameter-examples": true }, + { "experimental-parameter-examples": "serialized" }, ); expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( expectedExample, @@ -679,7 +697,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { op getColors(@query color: string): void; `, undefined, - { "serialize-parameter-examples": true }, + { "experimental-parameter-examples": "serialized" }, ); expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).examples).toEqual({ MyExample: { @@ -709,6 +727,8 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { @route("/") op getColors(@query color: string): void; `, + undefined, + { "experimental-parameter-examples": "data" }, ); expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).examples).toEqual({ MyExample: { @@ -723,7 +743,8 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { }); it("supports encoding", async () => { - const res = await openApiFor(` + const res = await openApiFor( + ` @opExample(#{ parameters: #{ dob: plainDate.fromISO("2021-01-01"), @@ -750,7 +771,10 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { @encode(DurationKnownEncoding.seconds, int32) dur: duration; } - `); + `, + undefined, + { "experimental-parameter-examples": "data" }, + ); expect((res.components.parameters["Test.dob"] as OpenAPI3Parameter).examples).toEqual({ "Test Example": { summary: "Test Example", value: "2021-01-01" }, }); From 88cc8ac6290be2089448c2c2e731f63a01547aad Mon Sep 17 00:00:00 2001 From: Christopher Radek Date: Mon, 2 Jun 2025 15:51:26 -0700 Subject: [PATCH 12/14] correct encoding of headers in parameter examples --- packages/openapi3/src/examples.ts | 57 ++++++++++++++--- packages/openapi3/test/examples.test.ts | 81 ++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 9 deletions(-) diff --git a/packages/openapi3/src/examples.ts b/packages/openapi3/src/examples.ts index 9cf15d2be23..d39223e2722 100644 --- a/packages/openapi3/src/examples.ts +++ b/packages/openapi3/src/examples.ts @@ -1,8 +1,11 @@ import { BooleanValue, + EncodeData, Example, + getEncode, getOpExamples, ignoreDiagnostics, + ModelProperty, NumericValue, OpExample, Program, @@ -12,12 +15,13 @@ import { Value, } from "@typespec/compiler"; import { $ } from "@typespec/compiler/typekit"; -import type { - HttpOperation, - HttpOperationResponse, - HttpOperationResponseContent, - HttpProperty, - HttpStatusCodeRange, +import { + isHeader, + type HttpOperation, + type HttpOperationResponse, + type HttpOperationResponseContent, + type HttpProperty, + type HttpStatusCodeRange, } from "@typespec/http"; import { ExperimentalParameterExamplesStrategy } from "./lib.js"; import { getParameterStyle } from "./parameters.js"; @@ -212,6 +216,41 @@ function findResponseForExample( return undefined; } +/** + * Only returns an encoding if one is not explicitly defined + */ +function getDefaultHeaderEncodeAs(program: Program, header: ModelProperty): EncodeData | undefined { + // Get existing encoded data if it has been explicitly defined + const encodeData = getEncode(program, header); + // If there's an explicit encoding, return undefined + if (encodeData) return; + + const tk = $(program); + + if (!tk.scalar.isUtcDateTime(header.type) && tk.scalar.isOffsetDateTime(header.type)) { + return; + } + + if (!tk.scalar.is(header.type)) return; + + // Use the default encoding for date-time headers + return { + encoding: "rfc7231", + type: header.type, + }; +} + +/** + * This function should only be used for special default encodings. + */ +function getEncodeAs(program: Program, type: Type): EncodeData | undefined { + if (isHeader(program, type)) { + return getDefaultHeaderEncodeAs(program, type as ModelProperty); + } + + return undefined; +} + export function getExampleOrExamples( program: Program, examples: [Example, Type][], @@ -226,14 +265,16 @@ export function getExampleOrExamples( examples[0][0].description === undefined ) { const [example, type] = examples[0]; - return { example: serializeValueAsJson(program, example.value, type) }; + const encodeAs = getEncodeAs(program, type); + return { example: serializeValueAsJson(program, example.value, type, encodeAs) }; } else { const exampleObj: Record = {}; for (const [index, [example, type]] of examples.entries()) { + const encodeAs = getEncodeAs(program, type); exampleObj[example.title ?? `example${index}`] = { summary: example.title, description: example.description, - value: serializeValueAsJson(program, example.value, type), + value: serializeValueAsJson(program, example.value, type, encodeAs), }; } return { examples: exampleObj }; diff --git a/packages/openapi3/test/examples.test.ts b/packages/openapi3/test/examples.test.ts index 8c8a304b0de..b9c01ed79d2 100644 --- a/packages/openapi3/test/examples.test.ts +++ b/packages/openapi3/test/examples.test.ts @@ -742,7 +742,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { }); }); - it("supports encoding", async () => { + it("supports encoding in examples", async () => { const res = await openApiFor( ` @opExample(#{ @@ -751,6 +751,8 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { utc: utcDateTime.fromISO("2021-01-01T00:00:00Z"), utcAsUnix: utcDateTime.fromISO("2021-01-01T00:00:00Z"), dur: duration.fromISO("PT1H"), + defaultHeader: utcDateTime.fromISO("2021-01-01T00:00:00Z"), + encodedHeader: utcDateTime.fromISO("2021-01-01T00:00:00Z"), } }, #{ title: "Test Example"}) @route("/") @@ -770,6 +772,13 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { @query @encode(DurationKnownEncoding.seconds, int32) dur: duration; + + @header + defaultHeader: utcDateTime; + + @header + @encode(DateTimeKnownEncoding.rfc3339) + encodedHeader: utcDateTime; } `, undefined, @@ -787,5 +796,75 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { expect((res.components.parameters["Test.dur"] as OpenAPI3Parameter).examples).toEqual({ "Test Example": { summary: "Test Example", value: 3600 }, }); + expect((res.components.parameters["Test.defaultHeader"] as OpenAPI3Parameter).examples).toEqual( + { + "Test Example": { summary: "Test Example", value: "Fri, 01 Jan 2021 00:00:00 GMT" }, + }, + ); + expect((res.components.parameters["Test.encodedHeader"] as OpenAPI3Parameter).examples).toEqual( + { + "Test Example": { summary: "Test Example", value: "2021-01-01T00:00:00Z" }, + }, + ); + }); + + it("supports encoding in example", async () => { + const res = await openApiFor( + ` + @opExample(#{ + parameters: #{ + dob: plainDate.fromISO("2021-01-01"), + utc: utcDateTime.fromISO("2021-01-01T00:00:00Z"), + utcAsUnix: utcDateTime.fromISO("2021-01-01T00:00:00Z"), + dur: duration.fromISO("PT1H"), + defaultHeader: utcDateTime.fromISO("2021-01-01T00:00:00Z"), + encodedHeader: utcDateTime.fromISO("2021-01-01T00:00:00Z"), + } + }) + @route("/") + op getDates(...Test): void; + + model Test { + @query + dob: plainDate; + + @query + utc: utcDateTime; + + @query + @encode(DateTimeKnownEncoding.unixTimestamp, int32) + utcAsUnix: utcDateTime; + + @query + @encode(DurationKnownEncoding.seconds, int32) + dur: duration; + + @header + defaultHeader: utcDateTime; + + @header + @encode(DateTimeKnownEncoding.rfc3339) + encodedHeader: utcDateTime; + } + `, + undefined, + { "experimental-parameter-examples": "data" }, + ); + expect((res.components.parameters["Test.dob"] as OpenAPI3Parameter).example).toEqual( + "2021-01-01", + ); + expect((res.components.parameters["Test.utc"] as OpenAPI3Parameter).example).toEqual( + "2021-01-01T00:00:00Z", + ); + expect((res.components.parameters["Test.utcAsUnix"] as OpenAPI3Parameter).example).toEqual( + 1609459200, + ); + expect((res.components.parameters["Test.dur"] as OpenAPI3Parameter).example).toEqual(3600); + expect((res.components.parameters["Test.defaultHeader"] as OpenAPI3Parameter).example).toEqual( + "Fri, 01 Jan 2021 00:00:00 GMT", + ); + expect((res.components.parameters["Test.encodedHeader"] as OpenAPI3Parameter).example).toEqual( + "2021-01-01T00:00:00Z", + ); }); }); From e51e4cb9c45cdd9213df6caef38eb3abd124b01a Mon Sep 17 00:00:00 2001 From: Christopher Radek Date: Tue, 3 Jun 2025 08:55:23 -0700 Subject: [PATCH 13/14] update docs --- .../changes/oa3-examples-on-params-2025-4-20-12-45-54.md | 2 +- packages/openapi3/README.md | 8 ++++++++ .../docs/docs/emitters/openapi3/reference/emitter.md | 8 ++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.chronus/changes/oa3-examples-on-params-2025-4-20-12-45-54.md b/.chronus/changes/oa3-examples-on-params-2025-4-20-12-45-54.md index 7a9ed849dfd..526458f9f53 100644 --- a/.chronus/changes/oa3-examples-on-params-2025-4-20-12-45-54.md +++ b/.chronus/changes/oa3-examples-on-params-2025-4-20-12-45-54.md @@ -4,4 +4,4 @@ packages: - "@typespec/openapi3" --- -Adds support for parameter examples via `@opExample` \ No newline at end of file +Adds support for parameter examples via `@opExample` via the `experimental-parameter-examples` option. diff --git a/packages/openapi3/README.md b/packages/openapi3/README.md index 3b34592204b..d7506d92f72 100644 --- a/packages/openapi3/README.md +++ b/packages/openapi3/README.md @@ -125,6 +125,14 @@ If true, then for models emitted as object schemas we default `additionalPropert OpenAPI 3.0, and `unevaluatedProperties` to false for OpenAPI 3.1, if not explicitly specified elsewhere. Default: `false` +### `experimental-parameter-examples` + +**Type:** `"data" | "serialized"` + +Determines how to emit examples on parameters. +Note: This is an experimental feature and may change in future versions. +See https://spec.openapis.org/oas/v3.0.4.html#style-examples + ## Decorators ### TypeSpec.OpenAPI diff --git a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md index e7246583da0..bf32451df62 100644 --- a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md @@ -118,3 +118,11 @@ Default: `int64` If true, then for models emitted as object schemas we default `additionalProperties` to false for OpenAPI 3.0, and `unevaluatedProperties` to false for OpenAPI 3.1, if not explicitly specified elsewhere. Default: `false` + +### `experimental-parameter-examples` + +**Type:** `"data" | "serialized"` + +Determines how to emit examples on parameters. +Note: This is an experimental feature and may change in future versions. +See https://spec.openapis.org/oas/v3.0.4.html#style-examples From 2c3a41431c237b9dc9b29a36a25a8b800311229f Mon Sep 17 00:00:00 2001 From: Christopher Radek Date: Tue, 3 Jun 2025 09:22:25 -0700 Subject: [PATCH 14/14] add link to discussion --- packages/openapi3/README.md | 3 ++- packages/openapi3/src/lib.ts | 6 ++++-- .../docs/docs/emitters/openapi3/reference/emitter.md | 3 ++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/openapi3/README.md b/packages/openapi3/README.md index d7506d92f72..792084912ad 100644 --- a/packages/openapi3/README.md +++ b/packages/openapi3/README.md @@ -131,7 +131,8 @@ Default: `false` Determines how to emit examples on parameters. Note: This is an experimental feature and may change in future versions. -See https://spec.openapis.org/oas/v3.0.4.html#style-examples +See https://spec.openapis.org/oas/v3.0.4.html#style-examples for parameter example serialization rules +See https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion on handling parameter examples. ## Decorators diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index b6a69afc78f..ae5cee57c8b 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -87,7 +87,8 @@ export interface OpenAPI3EmitterOptions { * Determines how to emit examples on parameters. * * Note: This is an experimental feature and may change in future versions. - * @see https://spec.openapis.org/oas/v3.0.4.html#style-examples + * @see https://spec.openapis.org/oas/v3.0.4.html#style-examples for parameter example serialization rules. + * @see https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion on handling parameter examples. */ "experimental-parameter-examples"?: ExperimentalParameterExamplesStrategy; } @@ -196,7 +197,8 @@ const EmitterOptionsSchema: JSONSchemaType = { description: [ "Determines how to emit examples on parameters.", "Note: This is an experimental feature and may change in future versions.", - "See https://spec.openapis.org/oas/v3.0.4.html#style-examples", + "See https://spec.openapis.org/oas/v3.0.4.html#style-examples for parameter example serialization rules", + "See https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion on handling parameter examples.", ].join("\n"), }, }, diff --git a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md index bf32451df62..726054886b8 100644 --- a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md @@ -125,4 +125,5 @@ Default: `false` Determines how to emit examples on parameters. Note: This is an experimental feature and may change in future versions. -See https://spec.openapis.org/oas/v3.0.4.html#style-examples +See https://spec.openapis.org/oas/v3.0.4.html#style-examples for parameter example serialization rules +See https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion on handling parameter examples.