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..526458f9f53 --- /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` via the `experimental-parameter-examples` option. diff --git a/packages/openapi3/README.md b/packages/openapi3/README.md index 3b34592204b..792084912ad 100644 --- a/packages/openapi3/README.md +++ b/packages/openapi3/README.md @@ -125,6 +125,15 @@ 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 for parameter example serialization rules +See https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion on handling parameter examples. + ## Decorators ### TypeSpec.OpenAPI diff --git a/packages/openapi3/src/examples.ts b/packages/openapi3/src/examples.ts index ffdc70e9346..d39223e2722 100644 --- a/packages/openapi3/src/examples.ts +++ b/packages/openapi3/src/examples.ts @@ -1,35 +1,56 @@ import { + BooleanValue, + EncodeData, Example, + getEncode, getOpExamples, ignoreDiagnostics, + ModelProperty, + NumericValue, OpExample, Program, serializeValueAsJson, + StringValue, Type, Value, } from "@typespec/compiler"; -import type { - HttpOperation, - HttpOperationResponse, - HttpOperationResponseContent, - HttpProperty, - HttpStatusCodeRange, +import { $ } from "@typespec/compiler/typekit"; +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"; 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>; } +type ResolveOperationExamplesOptions = { + parameterExamplesStrategy?: ExperimentalParameterExamplesStrategy; +}; + export function resolveOperationExamples( program: Program, operation: HttpOperation | SharedHttpOperation, + { parameterExamplesStrategy }: ResolveOperationExamplesOptions, ): 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 +71,33 @@ 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, + parameterExamplesStrategy, + ); + 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) { @@ -168,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][], @@ -182,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 }; @@ -231,6 +316,361 @@ export function getBodyValue(value: Value, properties: HttpProperty[]): Value | return value; } +function getParameterValue( + program: Program, + parameterExamples: Value, + property: HttpParameterProperties, + parameterExamplesStrategy?: ExperimentalParameterExamplesStrategy, +): Value | undefined { + if (!parameterExamplesStrategy) { + return; + } + + const value = getValueByPath(parameterExamples, property.path); + if (!value) return; + + if (parameterExamplesStrategy === "data") { + 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 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(`${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(`${prefix}${pairs.join(joiner)}`); + } + + if (isSerializableScalarValue(originalValue)) { + return tk.value.createString(`${name}=${originalValue.value}`); + } + + // null is treated as the 'undefined' value + if (tk.value.isNull(originalValue)) { + return tk.value.createString(`${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..ae5cee57c8b 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. @@ -81,6 +82,15 @@ export interface OpenAPI3EmitterOptions { * @default false */ "seal-object-schemas"?: boolean; + + /** + * 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 for parameter example serialization rules. + * @see https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion on handling parameter examples. + */ + "experimental-parameter-examples"?: ExperimentalParameterExamplesStrategy; } const EmitterOptionsSchema: JSONSchemaType = { @@ -180,6 +190,17 @@ const EmitterOptionsSchema: JSONSchemaType = { "Default: `false`", ].join("\n"), }, + "experimental-parameter-examples": { + type: "string", + enum: ["data", "serialized"], + nullable: true, + 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 for parameter example serialization rules", + "See https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion on handling parameter examples.", + ].join("\n"), + }, }, required: [], }; diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 86739504387..8ddb0305388 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. @@ -215,6 +211,7 @@ export function resolveOptions( outputFile: resolvePath(context.emitterOutputDir, specDir, outputFile), openapiVersions, sealObjectSchemas: resolvedOptions["seal-object-schemas"], + parameterExamplesStrategy: resolvedOptions["experimental-parameter-examples"], }; } @@ -227,6 +224,7 @@ export interface ResolvedOpenAPI3EmitterOptions { includeXTypeSpecName: "inline-only" | "never"; safeintStrategy: "double-int" | "int64"; sealObjectSchemas: boolean; + parameterExamplesStrategy?: "data" | "serialized"; } function createOAPIEmitter( @@ -736,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, { + parameterExamplesStrategy: options.parameterExamplesStrategy, + }); const oai3Operation: OpenAPI3Operation = { operationId: computeSharedOperationId(shared), parameters: [], @@ -787,6 +787,7 @@ function createOAPIEmitter( oai3Operation.parameters = getEndpointParameters( resolveSharedRouteParameters(operations), visibility, + examples, ); const bodies = [ @@ -813,12 +814,14 @@ function createOAPIEmitter( return undefined; } const visibility = resolveRequestVisibility(program, operation.operation, verb); - const examples = resolveOperationExamples(program, operation); + const examples = resolveOperationExamples(program, operation, { + parameterExamplesStrategy: options.parameterExamplesStrategy, + }); const oai3Operation: OpenAPI3Operation = { 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 +1284,7 @@ function createOAPIEmitter( function getParameter( httpProperty: HttpParameterProperties, visibility: Visibility, + examples: [Example, Type][], ): OpenAPI3Parameter { const param: OpenAPI3Parameter = { name: httpProperty.options.name, @@ -1301,12 +1305,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 +1325,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 +1398,7 @@ function createOAPIEmitter( function getParameterOrRef( httpProperty: HttpParameterProperties, visibility: Visibility, + examples: [Example, Type][], ): Refable | undefined { if (isNeverType(httpProperty.property.type)) { return undefined; @@ -1415,7 +1428,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 +1559,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 +1567,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..b9c01ed79d2 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,637 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { age: 2, }); }); + + 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) => { + 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; + `, + undefined, + { "experimental-parameter-examples": "data" }, + ); + expect((res.paths[path].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( + expectedExample, + ); + }); + }, + ); + + 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, + { "experimental-parameter-examples": "serialized" }, + ); + 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, + { "experimental-parameter-examples": "serialized" }, + ); + 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, + { "experimental-parameter-examples": "serialized" }, + ); + 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, + { "experimental-parameter-examples": "serialized" }, + ); + 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, + { "experimental-parameter-examples": "serialized" }, + ); + 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; + `, + undefined, + { "experimental-parameter-examples": "data" }, + ); + expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).examples).toEqual({ + MyExample: { + summary: "MyExample", + value: "green", + }, + MyExample2: { + summary: "MyExample2", + value: "red", + }, + }); + }); + + it("supports encoding in examples", 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"), + } + }, #{ 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; + + @header + defaultHeader: utcDateTime; + + @header + @encode(DateTimeKnownEncoding.rfc3339) + encodedHeader: utcDateTime; + } + `, + undefined, + { "experimental-parameter-examples": "data" }, + ); + 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 }, + }); + 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", + ); + }); }); 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..726054886b8 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,12 @@ 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 for parameter example serialization rules +See https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion on handling parameter examples.