Skip to content

[openapi3] add support for parameter examples via @opExample #7403

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .chronus/changes/oa3-examples-on-params-2025-4-20-12-45-54.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/openapi3"
---

Adds support for parameter examples via `@opExample`
41 changes: 39 additions & 2 deletions packages/openapi3/src/examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@ import type {
} from "@typespec/http";
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<string, [Example, Type][]>;
parameters: Record<string, [Example, Type][]>;
responses: Record<string, Record<string, [Example, Type][]>>;
}

Expand All @@ -29,7 +35,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;
}
Expand All @@ -50,6 +56,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) {
Expand Down Expand Up @@ -231,6 +259,15 @@ 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);
return value;
}

function getValueByPath(value: Value, path: (string | number)[]): Value | undefined {
let current: Value | undefined = value;
for (const key of path) {
Expand Down
39 changes: 17 additions & 22 deletions packages/openapi3/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
getAllTags,
getAnyExtensionFromPath,
getDoc,
getEncode,
getFormat,
getMaxItems,
getMaxLength,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -115,6 +115,7 @@ import {
deepEquals,
ensureValidComponentFixedFieldKey,
getDefaultValue,
HttpParameterProperties,
isBytesKeptRaw,
isSharedHttpOperation,
SharedHttpOperation,
Expand Down Expand Up @@ -142,11 +143,6 @@ export async function $onEmit(context: EmitContext<OpenAPI3EmitterOptions>) {

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.
Expand Down Expand Up @@ -787,6 +783,7 @@ function createOAPIEmitter(
oai3Operation.parameters = getEndpointParameters(
resolveSharedRouteParameters(operations),
visibility,
examples,
);

const bodies = [
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1281,6 +1278,7 @@ function createOAPIEmitter(
function getParameter(
httpProperty: HttpParameterProperties,
visibility: Visibility,
examples: [Example, Type][],
): OpenAPI3Parameter {
const param: OpenAPI3Parameter = {
name: httpProperty.options.name,
Expand All @@ -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<OpenAPI3Parameter>[] {
const result: Refable<OpenAPI3Parameter>[] = [];
for (const httpProp of properties) {
Expand All @@ -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,
Expand Down Expand Up @@ -1386,6 +1392,7 @@ function createOAPIEmitter(
function getParameterOrRef(
httpProperty: HttpParameterProperties,
visibility: Visibility,
examples: [Example, Type][],
): Refable<OpenAPI3Parameter> | undefined {
if (isNeverType(httpProperty.property.type)) {
return undefined;
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -1546,26 +1553,14 @@ 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;
}

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) {
Expand Down
16 changes: 16 additions & 0 deletions packages/openapi3/src/parameters.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions packages/openapi3/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Refable<OpenAPI3Example>>;
};

export type OpenAPI3QueryParameter = OpenAPI3ParameterBase & {
Expand Down
13 changes: 12 additions & 1 deletion packages/openapi3/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
118 changes: 117 additions & 1 deletion packages/openapi3/test/examples.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -234,4 +234,120 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => {
age: 2,
});
});

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<int32>`,
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,
);
});
},
);

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",
},
});
});

it("supports encoding", async () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should have a test also that test that by default headers are encoded as rfc7231

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 },
});
});
});
Loading