diff --git a/.chronus/changes/examples-2024-5-12-22-55-41.md b/.chronus/changes/examples-2024-5-12-22-55-41.md new file mode 100644 index 00000000000..7204996e21b --- /dev/null +++ b/.chronus/changes/examples-2024-5-12-22-55-41.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: feature +packages: + - "@typespec/openapi3" +--- + +Add support for new `@example` and `@opExample` decorator diff --git a/.chronus/changes/examples-2024-5-14-18-46-26.md b/.chronus/changes/examples-2024-5-14-18-46-26.md new file mode 100644 index 00000000000..cef4d5be648 --- /dev/null +++ b/.chronus/changes/examples-2024-5-14-18-46-26.md @@ -0,0 +1,43 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Add new `@example` and `@opExample` decorator to provide examples on types and operations. + + ```tsp + @example(#{ + id: "some", + date: utcDateTime.fromISO("2020-01-01T00:00:00Z"), + timeout: duration.fromISO("PT1M"), + }) + model Foo { + id: string; + date: utcDateTime; + + @encode("seconds", int32) timeout: duration; + } + ``` + + ```tsp + @opExample( + #{ + parameters: #{ + pet: #{ + id: "some", + name: "Fluffy", + dob: plainDate.fromISO("2020-01-01"), + }, + }, + returnType: #{ + id: "some", + name: "Fluffy", + dob: plainDate.fromISO("2020-01-01"), + }, + }, + #{ title: "First", description: "Show creating a pet" } + ) + op createPet(pet: Pet): Pet; + ``` diff --git a/docs/standard-library/built-in-data-types.md b/docs/standard-library/built-in-data-types.md index d058b9dbe9d..66ad5be3c98 100644 --- a/docs/standard-library/built-in-data-types.md +++ b/docs/standard-library/built-in-data-types.md @@ -39,6 +39,20 @@ model DefaultKeyVisibility #### Properties None +### `ExampleOptions` {#ExampleOptions} + +Options for example decorators +```typespec +model ExampleOptions +``` + + +#### Properties +| Name | Type | Description | +|------|------|-------------| +| title? | [`string`](#string) | The title of the example | +| description? | [`string`](#string) | Description of the example | + ### `object` {#object} Represent a model @@ -83,6 +97,20 @@ model OmitProperties #### Properties None +### `OperationExample` {#OperationExample} + +Operation example configuration. +```typespec +model OperationExample +``` + + +#### Properties +| Name | Type | Description | +|------|------|-------------| +| parameters? | `unknown` | Example request body. | +| returnType? | `unknown` | Example response body. | + ### `OptionalProperties` {#OptionalProperties} Represents a collection of optional properties. diff --git a/docs/standard-library/built-in-decorators.md b/docs/standard-library/built-in-decorators.md index 1c2dc8b68a2..d7413c60394 100644 --- a/docs/standard-library/built-in-decorators.md +++ b/docs/standard-library/built-in-decorators.md @@ -214,6 +214,34 @@ op get(): Pet | NotFound; ``` +### `@example` {#@example} + +Provide an example value for a data type. +```typespec +@example(example: valueof unknown, options?: valueof ExampleOptions) +``` + +#### Target + +`Model | Enum | Scalar | Union | ModelProperty | UnionVariant` + +#### Parameters +| Name | Type | Description | +|------|------|-------------| +| example | `valueof unknown` | Example value. | +| options | [valueof `ExampleOptions`](./built-in-data-types.md#ExampleOptions) | Optional metadata for the example. | + +#### Examples + +```tsp +@example(#{name: "Fluffy", age: 2}) +model Pet { + name: string; + age: int32; +} +``` + + ### `@format` {#@format} Specify a known data format hint for this string type. For example `uuid`, `uri`, etc. @@ -570,6 +598,31 @@ scalar distance is float64; ``` +### `@opExample` {#@opExample} + +Provide example values for an operation's parameters and corresponding return type. +```typespec +@opExample(example: valueof OperationExample, options?: valueof ExampleOptions) +``` + +#### Target + +`Operation` + +#### Parameters +| Name | Type | Description | +|------|------|-------------| +| example | [valueof `OperationExample`](./built-in-data-types.md#OperationExample) | Example value. | +| options | [valueof `ExampleOptions`](./built-in-data-types.md#ExampleOptions) | Optional metadata for the example. | + +#### Examples + +```tsp +@example(#{parameters: #{name: "Fluffy", age: 2}, returnType: #{name: "Fluffy", age: 2, id: "abc"}) +op createPet(pet: Pet): Pet; +``` + + ### `@overload` {#@overload} Specify this operation is an overload of the given operation. diff --git a/packages/compiler/generated-defs/TypeSpec.ts b/packages/compiler/generated-defs/TypeSpec.ts index f76840f38be..ad22ea338df 100644 --- a/packages/compiler/generated-defs/TypeSpec.ts +++ b/packages/compiler/generated-defs/TypeSpec.ts @@ -10,6 +10,7 @@ import type { Scalar, Type, Union, + UnionVariant, } from "../src/index.js"; /** @@ -571,6 +572,45 @@ export type DiscriminatorDecorator = ( propertyName: string ) => void; +/** + * Provide an example value for a data type. + * + * @param example Example value. + * @param options Optional metadata for the example. + * @example + * ```tsp + * @example(#{name: "Fluffy", age: 2}) + * model Pet { + * name: string; + * age: int32; + * } + * ``` + */ +export type ExampleDecorator = ( + context: DecoratorContext, + target: Model | Enum | Scalar | Union | ModelProperty | UnionVariant, + example: unknown, + options?: unknown +) => void; + +/** + * Provide example values for an operation's parameters and corresponding return type. + * + * @param example Example value. + * @param options Optional metadata for the example. + * @example + * ```tsp + * @example(#{parameters: #{name: "Fluffy", age: 2}, returnType: #{name: "Fluffy", age: 2, id: "abc"}) + * op createPet(pet: Pet): Pet; + * ``` + */ +export type OpExampleDecorator = ( + context: DecoratorContext, + target: Operation, + example: unknown, + options?: unknown +) => void; + /** * Indicates that a property is only considered to be present or applicable ("visible") with * the in the given named contexts ("visibilities"). When a property has no visibilities applied diff --git a/packages/compiler/generated-defs/TypeSpec.ts-test.ts b/packages/compiler/generated-defs/TypeSpec.ts-test.ts index b923f63be00..3297363694d 100644 --- a/packages/compiler/generated-defs/TypeSpec.ts-test.ts +++ b/packages/compiler/generated-defs/TypeSpec.ts-test.ts @@ -7,6 +7,7 @@ import { $encodedName, $error, $errorsDoc, + $example, $format, $friendlyName, $inspectType, @@ -22,6 +23,7 @@ import { $minLength, $minValue, $minValueExclusive, + $opExample, $overload, $parameterVisibility, $pattern, @@ -49,6 +51,7 @@ import type { EncodedNameDecorator, ErrorDecorator, ErrorsDocDecorator, + ExampleDecorator, FormatDecorator, FriendlyNameDecorator, InspectTypeDecorator, @@ -64,6 +67,7 @@ import type { MinLengthDecorator, MinValueDecorator, MinValueExclusiveDecorator, + OpExampleDecorator, OverloadDecorator, ParameterVisibilityDecorator, PatternDecorator, @@ -119,6 +123,8 @@ type Decorators = { $projectedName: ProjectedNameDecorator; $encodedName: EncodedNameDecorator; $discriminator: DiscriminatorDecorator; + $example: ExampleDecorator; + $opExample: OpExampleDecorator; $visibility: VisibilityDecorator; $withVisibility: WithVisibilityDecorator; $inspectType: InspectTypeDecorator; @@ -163,6 +169,8 @@ const _: Decorators = { $projectedName, $encodedName, $discriminator, + $example, + $opExample, $visibility, $withVisibility, $inspectType, diff --git a/packages/compiler/lib/std/decorators.tsp b/packages/compiler/lib/std/decorators.tsp index e8ac6f3443a..48e685b7793 100644 --- a/packages/compiler/lib/std/decorators.tsp +++ b/packages/compiler/lib/std/decorators.tsp @@ -500,6 +500,66 @@ extern dec encode( encodedAs?: Scalar ); +/** Options for example decorators */ +model ExampleOptions { + /** The title of the example */ + title?: string; + + /** Description of the example */ + description?: string; +} + +/** + * Provide an example value for a data type. + * + * @param example Example value. + * @param options Optional metadata for the example. + * + * @example + * + * ```tsp + * @example(#{name: "Fluffy", age: 2}) + * model Pet { + * name: string; + * age: int32; + * } + * ``` + */ +extern dec example( + target: Model | Enum | Scalar | Union | ModelProperty | UnionVariant, + example: valueof unknown, + options?: valueof ExampleOptions +); + +/** + * Operation example configuration. + */ +model OperationExample { + /** Example request body. */ + parameters?: unknown; + + /** Example response body. */ + returnType?: unknown; +} + +/** + * Provide example values for an operation's parameters and corresponding return type. + * + * @param example Example value. + * @param options Optional metadata for the example. + * + * @example + * + * ```tsp + * @example(#{parameters: #{name: "Fluffy", age: 2}, returnType: #{name: "Fluffy", age: 2, id: "abc"}) + * op createPet(pet: Pet): Pet; + * ``` + */ +extern dec opExample( + target: Operation, + example: valueof OperationExample, + options?: valueof ExampleOptions +); /** * Indicates that a property is only considered to be present or applicable ("visible") with * the in the given named contexts ("visibilities"). When a property has no visibilities applied diff --git a/packages/compiler/package.json b/packages/compiler/package.json index da292ee7332..e614b3b24c1 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -91,6 +91,7 @@ "prettier": "~3.3.2", "prompts": "~2.4.2", "semver": "^7.6.2", + "temporal-polyfill": "^0.2.5", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "yaml": "~2.4.5", diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 4bd74c586de..8a05ab0468b 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -292,6 +292,16 @@ export interface Checker { */ getStdType(name: T): StdTypes[T]; + /** + * Return the exact type of a value. + * + * ```tsp + * const a: string = "hello"; + * ``` + * calling `getValueExactType` on the value of a would give the string literal "hello". + * @param value + */ + getValueExactType(value: Value): Type | undefined; /** * Check and resolve a type for the given type reference node. * @param node Node. @@ -356,6 +366,7 @@ export function createChecker(program: Program): Checker { TypeReferenceNode | MemberExpressionNode | IdentifierNode, Sym | undefined >(); + const valueExactTypes = new WeakMap(); let onCheckerDiagnostic: (diagnostic: Diagnostic) => void = (x: Diagnostic) => { program.reportDiagnostic(x); }; @@ -462,6 +473,7 @@ export function createChecker(program: Program): Checker { resolveTypeReference, getValueForNode, getTypeOrValueForNode, + getValueExactType, }; const projectionMembers = createProjectionMembers(checker); @@ -4089,13 +4101,16 @@ export function createChecker(program: Program): Checker { if (constraint && !checkTypeOfValueMatchConstraint(preciseType, constraint, node)) { return null; } - return { - entityKind: "Value", - valueKind: "ObjectValue", - node: node, - properties, - type: constraint ? constraint.type : preciseType, - }; + return createValue( + { + entityKind: "Value", + valueKind: "ObjectValue", + node: node, + properties, + type: constraint ? constraint.type : preciseType, + }, + preciseType + ); } function createTypeForObjectValue( @@ -4197,13 +4212,29 @@ export function createChecker(program: Program): Checker { return null; } - return { - entityKind: "Value", - valueKind: "ArrayValue", - node: node, - values: values as any, - type: constraint ? constraint.type : preciseType, - }; + return createValue( + { + entityKind: "Value", + valueKind: "ArrayValue", + node: node, + values: values as any, + type: constraint ? constraint.type : preciseType, + }, + preciseType + ); + } + + function createValue(value: T, preciseType: Type): T { + valueExactTypes.set(value, preciseType); + return value; + } + function copyValue(value: T, overrides?: Partial): T { + const newValue = { ...value, ...overrides }; + const preciseType = valueExactTypes.get(value); + if (preciseType) { + valueExactTypes.set(newValue, preciseType); + } + return newValue; } function createTypeForArrayValue(node: ArrayLiteralNode, values: Value[]): Tuple { @@ -4276,13 +4307,16 @@ export function createChecker(program: Program): Checker { value = literalType.value; } const scalar = inferScalarForPrimitiveValue(constraint?.type, literalType); - return { - entityKind: "Value", - valueKind: "StringValue", - value, - type: constraint ? constraint.type : literalType, - scalar, - }; + return createValue( + { + entityKind: "Value", + valueKind: "StringValue", + value, + type: constraint ? constraint.type : literalType, + scalar, + }, + literalType + ); } function checkNumericValue( @@ -4294,13 +4328,16 @@ export function createChecker(program: Program): Checker { return null; } const scalar = inferScalarForPrimitiveValue(constraint?.type, literalType); - return { - entityKind: "Value", - valueKind: "NumericValue", - value: Numeric(literalType.valueAsString), - type: constraint ? constraint.type : literalType, - scalar, - }; + return createValue( + { + entityKind: "Value", + valueKind: "NumericValue", + value: Numeric(literalType.valueAsString), + type: constraint ? constraint.type : literalType, + scalar, + }, + literalType + ); } function checkBooleanValue( @@ -4312,13 +4349,16 @@ export function createChecker(program: Program): Checker { return null; } const scalar = inferScalarForPrimitiveValue(constraint?.type, literalType); - return { - entityKind: "Value", - valueKind: "BooleanValue", - value: literalType.value, - type: constraint ? constraint.type : literalType, - scalar, - }; + return createValue( + { + entityKind: "Value", + valueKind: "BooleanValue", + value: literalType.value, + type: constraint ? constraint.type : literalType, + scalar, + }, + literalType + ); } function checkNullValue( @@ -4330,13 +4370,16 @@ export function createChecker(program: Program): Checker { return null; } - return { - entityKind: "Value", + return createValue( + { + entityKind: "Value", - valueKind: "NullValue", - type: constraint ? constraint.type : literalType, - value: null, - }; + valueKind: "NullValue", + type: constraint ? constraint.type : literalType, + value: null, + }, + literalType + ); } function checkEnumValue( @@ -4347,13 +4390,16 @@ export function createChecker(program: Program): Checker { if (constraint && !checkTypeOfValueMatchConstraint(literalType, constraint, node)) { return null; } - return { - entityKind: "Value", + return createValue( + { + entityKind: "Value", - valueKind: "EnumValue", - type: constraint ? constraint.type : literalType, - value: literalType, - }; + valueKind: "EnumValue", + type: constraint ? constraint.type : literalType, + value: literalType, + }, + literalType + ); } function checkCallExpressionTarget( @@ -4409,7 +4455,7 @@ export function createChecker(program: Program): Checker { if (!checkValueOfType(value, scalar, argNode)) { return null; } - return { ...value, scalar, type: scalar } as any; + return copyValue(value, { scalar, type: scalar }) as any; } function createScalarValue( @@ -5852,7 +5898,7 @@ export function createChecker(program: Program): Checker { links.value = null; return links.value; } - links.value = type ? { ...value, type } : { ...value }; + links.value = type ? copyValue(value, { type }) : copyValue(value); return links.value; } @@ -5863,7 +5909,7 @@ export function createChecker(program: Program): Checker { case "NumericValue": if (value.scalar === undefined) { const scalar = inferScalarForPrimitiveValue(type, value.type); - return { ...value, scalar }; + return copyValue(value as any, { scalar }); } return value; case "ArrayValue": @@ -8181,6 +8227,10 @@ export function createChecker(program: Program): Checker { if (type.kind === "Model") return stdType === undefined || stdType === type.name; return false; } + + function getValueExactType(value: Value): Type | undefined { + return valueExactTypes.get(value); + } } function isAnonymous(type: Type) { diff --git a/packages/compiler/src/lib/decorators.ts b/packages/compiler/src/lib/decorators.ts index 6ba866f56a8..b47ae0d182c 100644 --- a/packages/compiler/src/lib/decorators.ts +++ b/packages/compiler/src/lib/decorators.ts @@ -5,6 +5,7 @@ import type { EncodeDecorator, ErrorDecorator, ErrorsDocDecorator, + ExampleDecorator, FormatDecorator, FriendlyNameDecorator, InspectTypeDecorator, @@ -19,6 +20,7 @@ import type { MinLengthDecorator, MinValueDecorator, MinValueExclusiveDecorator, + OpExampleDecorator, OverloadDecorator, ParameterVisibilityDecorator, PatternDecorator, @@ -47,6 +49,7 @@ import { getDeprecationDetails, markDeprecated } from "../core/deprecation.js"; import { Numeric, StdTypeName, + compilerAssert, getDiscriminatedUnion, getTypeName, ignoreDiagnostics, @@ -83,6 +86,7 @@ import { AugmentDecoratorStatementNode, DecoratorContext, DecoratorExpressionNode, + DiagnosticTarget, Enum, EnumMember, Interface, @@ -90,14 +94,18 @@ import { ModelProperty, Namespace, Node, + ObjectValue, Operation, Scalar, SyntaxKind, Type, Union, + UnionVariant, + Value, } from "../core/types.js"; export { $encodedName, resolveEncodedName } from "./encoded-names.js"; +export { serializeValueAsJson } from "./examples.js"; export * from "./service.js"; export const namespace = "TypeSpec"; @@ -1441,3 +1449,128 @@ export const $returnTypeVisibility: ReturnTypeVisibilityDecorator = ( export function getReturnTypeVisibility(program: Program, entity: Operation): string[] | undefined { return program.stateMap(returnTypeVisibilityKey).get(entity); } + +export interface ExampleOptions { + readonly title?: string; + readonly description?: string; +} + +export interface Example extends ExampleOptions { + readonly value: Value; +} +export interface OpExample extends ExampleOptions { + readonly parameters?: Value; + readonly returnType?: Value; +} + +const exampleKey = createStateSymbol("examples"); +export const $example: ExampleDecorator = ( + context: DecoratorContext, + target: Model | Scalar | Enum | Union | ModelProperty | UnionVariant, + _example: unknown, + options?: unknown // TODO: change `options?: ExampleOptions` when tspd supports it +) => { + const decorator = target.decorators.find( + (d) => d.decorator === $example && d.node === context.decoratorTarget + ); + compilerAssert(decorator, `Couldn't find @example decorator`, context.decoratorTarget); + const rawExample = decorator.args[0].value as Value; + // skip validation in projections + if (target.projectionBase === undefined) { + if ( + !checkExampleValid( + context.program, + rawExample, + target.kind === "ModelProperty" ? target.type : target, + context.getArgumentTarget(0)! + ) + ) { + return; + } + } + + let list = context.program.stateMap(exampleKey).get(target); + if (list === undefined) { + list = []; + context.program.stateMap(exampleKey).set(target, list); + } + list.push({ value: rawExample, ...(options as any) }); +}; + +export function getExamples( + program: Program, + target: Model | Scalar | Enum | Union | ModelProperty +): readonly Example[] { + return program.stateMap(exampleKey).get(target) ?? []; +} + +const opExampleKey = createStateSymbol("opExamples"); +export const $opExample: OpExampleDecorator = ( + context: DecoratorContext, + target: Operation, + _example: unknown, + options?: unknown // TODO: change `options?: ExampleOptions` when tspd supports it +) => { + const decorator = target.decorators.find( + (d) => d.decorator === $opExample && d.node === context.decoratorTarget + ); + compilerAssert(decorator, `Couldn't find @opExample decorator`, context.decoratorTarget); + const rawExampleConfig = decorator.args[0].value as ObjectValue; + const parameters = rawExampleConfig.properties.get("parameters")?.value; + const returnType = rawExampleConfig.properties.get("returnType")?.value; + + // skip validation in projections + if (target.projectionBase === undefined) { + if ( + parameters && + !checkExampleValid( + context.program, + parameters, + target.parameters, + context.getArgumentTarget(0)! + ) + ) { + return; + } + if ( + returnType && + !checkExampleValid( + context.program, + returnType, + target.returnType, + context.getArgumentTarget(0)! + ) + ) { + return; + } + } + + let list = context.program.stateMap(opExampleKey).get(target); + if (list === undefined) { + list = []; + context.program.stateMap(opExampleKey).set(target, list); + } + list.push({ parameters, returnType, ...(options as any) }); +}; + +function checkExampleValid( + program: Program, + value: Value, + target: Type, + diagnosticTarget: DiagnosticTarget +): boolean { + const exactType = program.checker.getValueExactType(value); + const [assignable, diagnostics] = program.checker.isTypeAssignableTo( + exactType ?? value.type, + target, + diagnosticTarget + ); + if (!assignable) { + program.reportDiagnostics(diagnostics); + } + return assignable; +} + +export function getOpExamples(program: Program, target: Operation): OpExample[] { + return program.stateMap(opExampleKey).get(target) ?? []; +} diff --git a/packages/compiler/src/lib/examples.ts b/packages/compiler/src/lib/examples.ts new file mode 100644 index 00000000000..0f8f8d097bc --- /dev/null +++ b/packages/compiler/src/lib/examples.ts @@ -0,0 +1,173 @@ +import { Temporal } from "temporal-polyfill"; +import type { Program } from "../core/program.js"; +import type { Model, ObjectValue, Scalar, ScalarValue, Type, Value } from "../core/types.js"; +import { getEncode, type EncodeData } from "./decorators.js"; + +/** + * Serialize the given TypeSpec value as a JSON object using the given type and its encoding annotations. + * The Value MUST be assignable to the given type. + */ +export function serializeValueAsJson( + program: Program, + value: Value, + type: Type, + encodeAs?: EncodeData +): unknown { + if (type.kind === "ModelProperty") { + return serializeValueAsJson(program, value, type.type, encodeAs ?? getEncode(program, type)); + } + switch (value.valueKind) { + case "NullValue": + return null; + case "BooleanValue": + case "StringValue": + return value.value; + case "NumericValue": + return value.value.asNumber(); + case "EnumValue": + return value.value.value ?? value.value.name; + case "ArrayValue": + return value.values.map((v) => serializeValueAsJson(program, v, type)); + case "ObjectValue": + return serializeObjectValueAsJson(program, value, type as Model); + case "ScalarValue": + return serializeScalarValueAsJson(program, value, type, encodeAs); + } +} + +function serializeObjectValueAsJson( + program: Program, + value: ObjectValue, + type: Model +): Record { + const obj: Record = {}; + for (const propValue of value.properties.values()) { + const definition = type.properties.get(propValue.name); + if (definition) { + obj[propValue.name] = serializeValueAsJson(program, propValue.value, definition); + } + } + return obj; +} + +function resolveKnownScalar( + program: Program, + scalar: Scalar +): + | { + scalar: Scalar & { + name: "utcDateTime" | "offsetDateTime" | "plainDate" | "plainTime" | "duration"; + }; + encodeAs: EncodeData | undefined; + } + | undefined { + const encode = getEncode(program, scalar); + if (program.checker.isStdType(scalar)) { + switch (scalar.name as any) { + case "utcDateTime": + case "offsetDateTime": + case "plainDate": + case "plainTime": + case "duration": + return { scalar: scalar as any, encodeAs: encode }; + case "unixTimestamp32": + break; + default: + return undefined; + } + } + if (scalar.baseScalar) { + const result = resolveKnownScalar(program, scalar.baseScalar); + return result && { scalar: result.scalar, encodeAs: encode }; + } + return undefined; +} +function serializeScalarValueAsJson( + program: Program, + value: ScalarValue, + type: Type, + encodeAs: EncodeData | undefined +): unknown { + const result = resolveKnownScalar(program, value.scalar); + if (result === undefined) { + return serializeValueAsJson(program, value.value.args[0], value.value.args[0].type); + } + + encodeAs = encodeAs ?? result.encodeAs; + + switch (result.scalar.name) { + case "utcDateTime": + return ScalarSerializers.utcDateTime((value.value.args[0] as any as any).value, encodeAs); + case "offsetDateTime": + return ScalarSerializers.offsetDateTime((value.value.args[0] as any).value, encodeAs); + case "plainDate": + return ScalarSerializers.plainDate((value.value.args[0] as any).value); + case "plainTime": + return ScalarSerializers.plainTime((value.value.args[0] as any).value); + case "duration": + return ScalarSerializers.duration((value.value.args[0] as any).value, encodeAs); + } +} + +const ScalarSerializers = { + utcDateTime: (value: string, encodeAs: EncodeData | undefined): unknown => { + if (encodeAs === undefined || encodeAs.encoding === "rfc3339") { + return value; + } + + const date = new Date(value); + + switch (encodeAs.encoding) { + case "unixTimestamp": + return Math.floor(date.getTime() / 1000); + case "rfc7231": + return date.toUTCString(); + default: + return date.toISOString(); + } + }, + offsetDateTime: (value: string, encodeAs: EncodeData | undefined): unknown => { + if (encodeAs === undefined || encodeAs.encoding === "rfc3339") { + return value; + } + + const date = new Date(value); + + switch (encodeAs.encoding) { + case "rfc7231": + return date.toUTCString(); + default: + return date.toISOString(); + } + }, + plainDate: (value: string): unknown => { + return value; + }, + plainTime: (value: string): unknown => { + return value; + }, + duration: (value: string, encodeAs: EncodeData | undefined): unknown => { + const duration = Temporal.Duration.from(value); + + switch (encodeAs?.encoding) { + case "seconds": + if (isInteger(encodeAs.type)) { + return Math.floor(duration.total({ unit: "seconds" })); + } else { + return duration.total({ unit: "seconds" }); + } + default: + return duration.toString(); + } + }, +}; + +function isInteger(scalar: Scalar) { + while (scalar.baseScalar) { + scalar = scalar.baseScalar; + if (scalar.name === "integer") { + return true; + } + } + return false; +} diff --git a/packages/compiler/test/decorators/examples.test.ts b/packages/compiler/test/decorators/examples.test.ts new file mode 100644 index 00000000000..1efc94e1e0d --- /dev/null +++ b/packages/compiler/test/decorators/examples.test.ts @@ -0,0 +1,361 @@ +import { ok } from "assert"; +import { describe, expect, it } from "vitest"; +import { Operation, getExamples, getOpExamples, serializeValueAsJson } from "../../src/index.js"; +import { expectDiagnostics } from "../../src/testing/expect.js"; +import { createTestRunner } from "../../src/testing/test-host.js"; + +async function getExamplesFor(code: string) { + const runner = await createTestRunner(); + const { test } = await runner.compile(code); + + ok(test, "Expect to have @test type named test."); + return { + program: runner.program, + target: test, + examples: getExamples(runner.program, test as any), + }; +} + +async function getOpExamplesFor(code: string) { + const runner = await createTestRunner(); + const { test } = (await runner.compile(code)) as { test: Operation }; + + ok(test, "Expect to have @test type named test."); + return { + program: runner.program, + target: test, + examples: getOpExamples(runner.program, test as any), + }; +} + +async function diagnoseCode(code: string) { + const runner = await createTestRunner(); + return await runner.diagnose(code); +} + +describe("@example", () => { + describe("model", () => { + it("valid", async () => { + const { program, examples, target } = await getExamplesFor(` + @example(#{ a: 1, b: 2 }) + @test model test { + a: int32; + b: int32; + } + `); + expect(examples).toHaveLength(1); + expect(serializeValueAsJson(program, examples[0].value, target)).toEqual({ a: 1, b: 2 }); + }); + + it("emit diagnostic for missing property", async () => { + const diagnostics = await diagnoseCode(` + @example(#{ a: 1 }) + @test model test { + a: int32; + b: int32; + } + `); + expectDiagnostics(diagnostics, { + code: "missing-property", + }); + }); + }); + + describe("model property", () => { + it("valid", async () => { + const { program, examples, target } = await getExamplesFor(` + model TestModel { + @example(1) + @test test: int32; + b: int32; + } + `); + expect(examples).toHaveLength(1); + expect(serializeValueAsJson(program, examples[0].value, target)).toEqual(1); + }); + + it("emit diagnostic for unassignable value", async () => { + const diagnostics = await diagnoseCode(` + model TestModel { + @example("abc") + @test test: int32; + b: int32; + } + `); + expectDiagnostics(diagnostics, { + code: "unassignable", + }); + }); + }); + + describe("scalar", () => { + it("valid", async () => { + const { program, examples, target } = await getExamplesFor(` + @example(test.fromISO("11:32")) + @test scalar test extends utcDateTime; + `); + expect(examples).toHaveLength(1); + expect(serializeValueAsJson(program, examples[0].value, target)).toEqual("11:32"); + }); + + it("emit diagnostic for unassignable value", async () => { + const diagnostics = await diagnoseCode(` + @example("11:32") + @test scalar test extends utcDateTime; + `); + expectDiagnostics(diagnostics, { + code: "unassignable", + }); + }); + }); + + describe("enum", () => { + it("valid", async () => { + const { program, examples, target } = await getExamplesFor(` + @example(test.a) + @test enum test { + a, + b, + } + `); + expect(examples).toHaveLength(1); + expect(serializeValueAsJson(program, examples[0].value, target)).toEqual("a"); + }); + + it("emit diagnostic for unassignable value", async () => { + const diagnostics = await diagnoseCode(` + @example(1) + @test enum test { + a, + b, + } + `); + expectDiagnostics(diagnostics, { + code: "unassignable", + }); + }); + }); + + describe("union", () => { + it("valid", async () => { + const { program, examples, target } = await getExamplesFor(` + @example(test.a) + @test union test {a: "a", b: "b"} + `); + expect(examples).toHaveLength(1); + expect(serializeValueAsJson(program, examples[0].value, target)).toEqual("a"); + }); + + it("emit diagnostic for unassignable value", async () => { + const diagnostics = await diagnoseCode(` + @example(1) + @test union test {a: "a", b: "b"} + `); + expectDiagnostics(diagnostics, { + code: "unassignable", + }); + }); + }); + + it("emit diagnostic if used on Operation", async () => { + const diagnostics = await diagnoseCode(` + @example(1) + op test(): void; + `); + expectDiagnostics(diagnostics, { + code: "decorator-wrong-target", + }); + }); +}); + +describe("@opExample", () => { + it("provide parameters and return type", async () => { + const { program, examples, target } = await getOpExamplesFor(` + model Pet { id: string; name: string; } + + @opExample( + #{ + parameters: #{ id: "some", name: "Fluffy" }, + returnType: #{ id: "some", name: "Fluffy" }, + } + ) + @test op test(...Pet): Pet; + `); + expect(examples).toHaveLength(1); + ok(examples[0].parameters); + ok(examples[0].returnType); + expect(serializeValueAsJson(program, examples[0].parameters, target.parameters)).toEqual({ + id: "some", + name: "Fluffy", + }); + expect(serializeValueAsJson(program, examples[0].returnType, target.returnType)).toEqual({ + id: "some", + name: "Fluffy", + }); + }); + + it("provide only parameters", async () => { + const { program, examples, target } = await getOpExamplesFor(` + model Pet { id: string; name: string; } + + @opExample( + #{ + parameters: #{ id: "some", name: "Fluffy" }, + } + ) + @test op test(...Pet): void; + `); + expect(examples).toHaveLength(1); + ok(examples[0].parameters); + ok(examples[0].returnType === undefined); + expect(serializeValueAsJson(program, examples[0].parameters, target.parameters)).toEqual({ + id: "some", + name: "Fluffy", + }); + }); + it("provide only return type", async () => { + const { program, examples, target } = await getOpExamplesFor(` + model Pet { id: string; name: string; } + + @opExample( + #{ + returnType: #{ id: "some", name: "Fluffy" }, + } + ) + @test op test(): Pet; + `); + expect(examples).toHaveLength(1); + ok(examples[0].parameters === undefined); + ok(examples[0].returnType); + expect(serializeValueAsJson(program, examples[0].returnType, target.returnType)).toEqual({ + id: "some", + name: "Fluffy", + }); + }); + + it("emit diagnostic for unassignable value", async () => { + const diagnostics = await diagnoseCode(` + model Pet { id: string; name: string; } + @opExample( + #{ + returnType: #{ id: 123, name: "Fluffy" }, + } + ) + @test op read(): Pet; + `); + expectDiagnostics(diagnostics, { + code: "unassignable", + }); + }); +}); + +describe("json serialization of examples", () => { + async function getJsonValueOfExample(code: string) { + const { examples, program, target } = await getExamplesFor(code); + return serializeValueAsJson(program, examples[0].value, target); + } + + const allCases: [ + string, + { + value: string; + expect: unknown; + encode?: string; + }[], + ][] = [ + ["int32", [{ value: `123`, expect: 123 }]], + ["string", [{ value: `"abc"`, expect: "abc" }]], + ["boolean", [{ value: `true`, expect: true }]], + [ + "utcDateTime", + [ + { value: `utcDateTime.fromISO("2024-01-01T11:32:00Z")`, expect: "2024-01-01T11:32:00Z" }, + { + value: `utcDateTime.fromISO("2024-01-01T11:32:00Z")`, + expect: "Mon, 01 Jan 2024 11:32:00 GMT", + encode: `@encode("rfc7231")`, + }, + { + value: `utcDateTime.fromISO("2024-01-01T11:32:00Z")`, + expect: 1704108720, + encode: `@encode("unixTimestamp", int32)`, + }, + ], + ], + [ + "offsetDateTime", + [ + { + value: `offsetDateTime.fromISO("2024-01-01T11:32:00+01:00")`, + expect: "2024-01-01T11:32:00+01:00", + }, + { + value: `offsetDateTime.fromISO("2024-01-01T11:32:00+01:00")`, + expect: "Mon, 01 Jan 2024 10:32:00 GMT", + encode: `@encode("rfc7231")`, + }, + ], + ], + [ + "plainDate", + [ + { + value: `plainDate.fromISO("2024-01-01")`, + expect: "2024-01-01", + }, + ], + ], + [ + "plainTime", + [ + { + value: `plainTime.fromISO("11:31")`, + expect: "11:31", + }, + ], + ], + [ + "duration", + [ + { + value: `duration.fromISO("PT5M")`, + expect: "PT5M", + }, + { + value: `duration.fromISO("PT5M")`, + expect: 300, + encode: `@encode("seconds", int32)`, + }, + { + value: `duration.fromISO("PT0.5S")`, + expect: 0.5, + encode: `@encode("seconds", float32)`, + }, + ], + ], + ]; + + describe.each(allCases)("%s", (type, cases) => { + const casesWithLabel = cases.map((x) => ({ + ...x, + encodeLabel: x.encode ?? "default encoding", + })); + it.each(casesWithLabel)( + `serialize with $encodeLabel`, + async ({ value, expect: expected, encode }) => { + const result = await getJsonValueOfExample(` + model TestModel { + @example(${value}) + ${encode ?? ""} + @test test: ${type}; + } + `); + if (expected instanceof RegExp) { + expect(result).toMatch(expected); + } else { + expect(result).toEqual(expected); + } + } + ); + }); +}); diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 179ef0d89f4..d02a87a0012 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -6,6 +6,7 @@ import { DiagnosticTarget, EmitContext, emitFile, + Example, getAllTags, getAnyExtensionFromPath, getDoc, @@ -21,6 +22,7 @@ import { getMinValue, getMinValueExclusive, getNamespaceFullName, + getOpExamples, getPattern, getService, getSummary, @@ -37,11 +39,13 @@ import { navigateTypesInNamespace, NewLine, Operation, + OpExample, Program, ProjectionApplication, projectProgram, resolvePath, Scalar, + serializeValueAsJson, Service, Type, TypeNameOptions, @@ -100,12 +104,14 @@ import { getDefaultValue, isBytesKeptRaw, OpenAPI3SchemaEmitter } from "./schema import { OpenAPI3Document, OpenAPI3Encoding, + OpenAPI3Example, OpenAPI3Header, OpenAPI3MediaType, OpenAPI3OAuthFlows, OpenAPI3Operation, OpenAPI3Parameter, OpenAPI3ParameterBase, + OpenAPI3Response, OpenAPI3Schema, OpenAPI3SecurityScheme, OpenAPI3Server, @@ -115,7 +121,7 @@ import { OpenAPI3VersionedServiceRecord, Refable, } from "./types.js"; -import { deepEquals } from "./util.js"; +import { deepEquals, isDefined } from "./util.js"; import { resolveVisibilityUsage, VisibilityUsageTracker } from "./visibility-usage.js"; const defaultFileType: FileType = "yaml"; @@ -860,12 +866,12 @@ function createOAPIEmitter( emitEndpointParameters(shared.parameters.parameters, visibility); if (shared.bodies) { if (shared.bodies.length === 1) { - emitRequestBody(shared.bodies[0], visibility); + emitRequestBody(shared, shared.bodies[0], visibility); } else if (shared.bodies.length > 1) { - emitMergedRequestBody(shared.bodies, visibility); + emitMergedRequestBody(shared, shared.bodies, visibility); } } - emitSharedResponses(shared.responses); + emitSharedResponses(shared, shared.responses); for (const op of ops) { if (isDeprecated(program, op)) { currentEndpoint.deprecated = true; @@ -910,8 +916,8 @@ function createOAPIEmitter( const visibility = resolveRequestVisibility(program, operation.operation, verb); emitEndpointParameters(parameters.parameters, visibility); - emitRequestBody(parameters.body, visibility); - emitResponses(operation.responses); + emitRequestBody(operation, parameters.body, visibility); + emitResponses(operation, operation.responses); if (authReference) { emitEndpointSecurity(authReference); } @@ -921,20 +927,23 @@ function createOAPIEmitter( attachExtensions(program, op, currentEndpoint); } - function emitSharedResponses(responses: Map) { + function emitSharedResponses( + operation: SharedHttpOperation, + responses: Map + ) { for (const [statusCode, statusCodeResponses] of responses) { if (statusCodeResponses.length === 1) { - emitResponseObject(statusCode, statusCodeResponses[0]); + emitResponseObject(operation, statusCode, statusCodeResponses[0]); } else { - emitMergedResponseObject(statusCode, statusCodeResponses); + emitMergedResponseObject(operation, statusCode, statusCodeResponses); } } } - function emitResponses(responses: HttpOperationResponse[]) { + function emitResponses(operation: HttpOperation, responses: HttpOperationResponse[]) { for (const response of responses) { for (const statusCode of getOpenAPI3StatusCodes(response.statusCodes, response.type)) { - emitResponseObject(statusCode, response); + emitResponseObject(operation, statusCode, response); } } } @@ -949,6 +958,7 @@ function createOAPIEmitter( } function emitMergedResponseObject( + operation: SharedHttpOperation, statusCode: OpenAPI3StatusCode, responses: HttpOperationResponse[] ) { @@ -964,7 +974,7 @@ function createOAPIEmitter( : response.description; } emitResponseHeaders(openApiResponse, response.responses, response.type); - emitResponseContent(openApiResponse, response.responses, schemaMap); + emitResponseContent(operation, openApiResponse, response.responses, schemaMap); if (!openApiResponse.description) { openApiResponse.description = getResponseDescriptionForStatusCode(statusCode); } @@ -973,6 +983,7 @@ function createOAPIEmitter( } function emitResponseObject( + operation: HttpOperation | SharedHttpOperation, statusCode: OpenAPI3StatusCode, response: Readonly ) { @@ -980,7 +991,7 @@ function createOAPIEmitter( description: response.description ?? getResponseDescriptionForStatusCode(statusCode), }; emitResponseHeaders(openApiResponse, response.responses, response.type); - emitResponseContent(openApiResponse, response.responses); + emitResponseContent(operation, openApiResponse, response.responses); currentEndpoint.responses[statusCode] = openApiResponse; } @@ -1012,30 +1023,37 @@ function createOAPIEmitter( } function emitResponseContent( - obj: any, + operation: HttpOperation | SharedHttpOperation, + obj: OpenAPI3Response, responses: HttpOperationResponseContent[], - schemaMap: Map | undefined = undefined + schemaMap: Map | undefined = undefined ) { - schemaMap ??= new Map(); + schemaMap ??= new Map(); for (const data of responses) { if (data.body === undefined) { continue; } obj.content ??= {}; for (const contentType of data.body.contentTypes) { - const { schema } = getBodyContentEntry(data.body, Visibility.Read, contentType); + const contents = getBodyContentEntry( + operation, + "response", + data.body, + Visibility.Read, + contentType + ); if (schemaMap.has(contentType)) { - schemaMap.get(contentType)!.push(schema); + schemaMap.get(contentType)!.push(contents); } else { - schemaMap.set(contentType, [schema]); + schemaMap.set(contentType, [contents]); } } - for (const [contentType, schema] of schemaMap) { - if (schema.length === 1) { - obj.content[contentType] = { schema: schema[0] }; + for (const [contentType, contents] of schemaMap) { + if (contents.length === 1) { + obj.content[contentType] = contents[0]; } else { obj.content[contentType] = { - schema: { anyOf: schema }, + schema: { anyOf: contents.map((x) => x.schema) as any }, }; } } @@ -1126,7 +1144,75 @@ function createOAPIEmitter( }) as any; } + function isSharedHttpOperation( + operation: HttpOperation | SharedHttpOperation + ): operation is SharedHttpOperation { + return (operation as SharedHttpOperation).kind === "shared"; + } + + function findOperationExamples( + operation: HttpOperation | SharedHttpOperation + ): [Operation, OpExample][] { + if (isSharedHttpOperation(operation)) { + return operation.operations.flatMap((op) => + getOpExamples(program, op).map((x): [Operation, OpExample] => [op, x]) + ); + } else { + return getOpExamples(program, operation.operation).map((x) => [operation.operation, x]); + } + } + function getExamplesForBodyContentEntry( + operation: HttpOperation | SharedHttpOperation, + target: "request" | "response" + ): Pick { + const examples = findOperationExamples(operation); + if (examples.length === 0) { + return {}; + } + + const flattenedExamples: [Example, Type][] = examples + .map(([op, example]): [Example, Type] | undefined => { + const value = target === "request" ? example.parameters : example.returnType; + const type = target === "request" ? op.parameters : op.returnType; + return value + ? [{ value, title: example.title, description: example.description }, type] + : undefined; + }) + .filter(isDefined); + + return getExampleOrExamples(flattenedExamples); + } + + function getExampleOrExamples( + examples: [Example, Type][] + ): Pick { + if (examples.length === 0) { + return {}; + } + + if ( + examples.length === 1 && + examples[0][0].title === undefined && + examples[0][0].description === undefined + ) { + const [example, type] = examples[0]; + return { example: serializeValueAsJson(program, example.value, type) }; + } else { + const exampleObj: Record = {}; + for (const [index, [example, type]] of examples.entries()) { + exampleObj[example.title ?? `example${index}`] = { + summary: example.title, + description: example.description, + value: serializeValueAsJson(program, example.value, type), + }; + } + return { examples: exampleObj }; + } + } + function getBodyContentEntry( + operation: HttpOperation | SharedHttpOperation, + target: "request" | "response", body: HttpOperationBody | HttpOperationMultipartBody, visibility: Visibility, contentType: string @@ -1145,9 +1231,13 @@ function createOAPIEmitter( body.isExplicit && body.containsMetadataAnnotations, contentType.startsWith("multipart/") ? contentType : undefined ), + ...getExamplesForBodyContentEntry(operation, target), }; case "multipart": - return getBodyContentForMultipartBody(body, visibility, contentType); + return { + ...getBodyContentForMultipartBody(body, visibility, contentType), + ...getExamplesForBodyContentEntry(operation, target), + }; } } @@ -1327,7 +1417,11 @@ function createOAPIEmitter( } } - function emitMergedRequestBody(bodies: HttpOperationBody[] | undefined, visibility: Visibility) { + function emitMergedRequestBody( + operation: HttpOperation | SharedHttpOperation, + bodies: HttpOperationBody[] | undefined, + visibility: Visibility + ) { if (bodies === undefined) { return; } @@ -1345,7 +1439,13 @@ function createOAPIEmitter( } const contentTypes = body.contentTypes.length > 0 ? body.contentTypes : ["application/json"]; for (const contentType of contentTypes) { - const { schema: bodySchema } = getBodyContentEntry(body, visibility, contentType); + const { schema: bodySchema } = getBodyContentEntry( + operation, + "request", + body, + visibility, + contentType + ); if (schemaMap.has(contentType)) { schemaMap.get(contentType)!.push(bodySchema); } else { @@ -1368,6 +1468,7 @@ function createOAPIEmitter( } function emitRequestBody( + operation: HttpOperation | SharedHttpOperation, body: HttpOperationBody | HttpOperationMultipartBody | undefined, visibility: Visibility ) { @@ -1383,7 +1484,13 @@ function createOAPIEmitter( const contentTypes = body.contentTypes.length > 0 ? body.contentTypes : ["application/json"]; for (const contentType of contentTypes) { - requestBody.content[contentType] = getBodyContentEntry(body, visibility, contentType); + requestBody.content[contentType] = getBodyContentEntry( + operation, + "request", + body, + visibility, + contentType + ); } currentEndpoint.requestBody = requestBody; diff --git a/packages/openapi3/src/schema-emitter.ts b/packages/openapi3/src/schema-emitter.ts index 3e05cd9978f..212f9a4ea70 100644 --- a/packages/openapi3/src/schema-emitter.ts +++ b/packages/openapi3/src/schema-emitter.ts @@ -25,6 +25,7 @@ import { getDiscriminator, getDoc, getEncode, + getExamples, getFormat, getKnownValues, getMaxItems, @@ -46,6 +47,7 @@ import { isSecret, isTemplateDeclaration, resolveEncodedName, + serializeValueAsJson, } from "@typespec/compiler"; import { ArrayBuilder, @@ -767,6 +769,17 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< } } + #applySchemaExamples( + type: Model | Scalar | Union | Enum | ModelProperty, + target: ObjectBuilder + ) { + const program = this.emitter.getProgram(); + const examples = getExamples(program, type); + if (examples.length > 0) { + target.set("example", serializeValueAsJson(program, examples[0].value, type)); + } + } + #applyConstraints( type: Scalar | Model | ModelProperty | Union | Enum, original: OpenAPI3Schema @@ -780,6 +793,7 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< } }; + this.#applySchemaExamples(type, schema); applyConstraint(getMinLength, "minLength"); applyConstraint(getMaxLength, "maxLength"); applyConstraint(getMinValue, "minimum"); diff --git a/packages/openapi3/src/types.ts b/packages/openapi3/src/types.ts index 19e3e12228f..283e58f8583 100644 --- a/packages/openapi3/src/types.ts +++ b/packages/openapi3/src/types.ts @@ -177,6 +177,12 @@ export type OpenAPI3MediaType = Extensions & { /** A map between a property name and its encoding information. The key, being the property name, MUST exist in the schema as a property. The encoding object SHALL only apply to requestBody objects when the media type is multipart or application/x-www-form-urlencoded. */ encoding?: Record; + + /** Example */ + example?: unknown; + + /** Examples with title */ + examples?: Record; }; /** @@ -352,8 +358,10 @@ export type OpenAPI3Link = * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#exampleObject */ export interface OpenAPI3Example { - /** The name of the property MUST be one of the Operation produces values (either implicit or inherited). The value SHOULD be an example of what such a response would look like. */ - [mineType: string]: unknown; + summary?: string; + description?: string; + value?: unknown; + externalValue?: string; } export interface OpenAPI3Discriminator extends Extensions { diff --git a/packages/openapi3/src/util.ts b/packages/openapi3/src/util.ts index eb4f5ace5d4..cc1cd59e90f 100644 --- a/packages/openapi3/src/util.ts +++ b/packages/openapi3/src/util.ts @@ -69,3 +69,9 @@ export function mapEquals( } return true; } +/** + * Check if argument is not undefined. + */ +export function isDefined(arg: T | undefined): arg is T { + return arg !== undefined; +} diff --git a/packages/openapi3/test/examples.test.ts b/packages/openapi3/test/examples.test.ts new file mode 100644 index 00000000000..de5cfd31225 --- /dev/null +++ b/packages/openapi3/test/examples.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { OpenAPI3Document } from "../src/types.js"; +import { openApiFor } from "./test-host.js"; + +describe("schema examples", () => { + it("apply example on model", async () => { + const res = await openApiFor( + ` + @example(#{name: "John"}) + model Test { name: string } + ` + ); + expect(res.components.schemas.Test.example).toEqual({ name: "John" }); + }); + + it("apply example on property", async () => { + const res = await openApiFor( + ` + model Test { @example("John") name: string } + ` + ); + expect(res.components.schemas.Test.properties.name.example).toEqual("John"); + }); + + it("serialize the examples with their json encoding", async () => { + const res = await openApiFor( + ` + @example(#{dob: plainDate.fromISO("2021-01-01")}) + model Test { dob: plainDate } + ` + ); + expect(res.components.schemas.Test.example).toEqual({ dob: "2021-01-01" }); + }); +}); + +describe("operation examples", () => { + it("set example on the request body", async () => { + const res: OpenAPI3Document = await openApiFor( + ` + @opExample(#{ + parameters: #{ + name: "Fluffy", + age: 2, + }, + }) + op createPet(name: string, age: int32): void; + + ` + ); + expect(res.paths["/"].post?.requestBody.content["application/json"].example).toEqual({ + name: "Fluffy", + age: 2, + }); + }); + + it("set examples on the request body if example has a title or description", async () => { + const res: OpenAPI3Document = await openApiFor( + ` + @opExample(#{ + parameters: #{ + name: "Fluffy", + age: 2, + }, + + }, #{ title: "MyExample" }) + op createPet(name: string, age: int32): void; + + ` + ); + expect(res.paths["/"].post?.requestBody.content["application/json"].examples).toEqual({ + MyExample: { + summary: "MyExample", + value: { + name: "Fluffy", + age: 2, + }, + }, + }); + }); + + it("set example on the response body", async () => { + const res: OpenAPI3Document = await openApiFor( + ` + @opExample(#{ + returnType: #{ + name: "Fluffy", + age: 2, + }, + }) + op getPet(): {name: string, age: int32}; + ` + ); + expect(res.paths["/"].get?.responses[200].content["application/json"].example).toEqual({ + name: "Fluffy", + age: 2, + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91b2587dd3b..c57158c5970 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,6 +267,9 @@ importers: semver: specifier: ^7.6.2 version: 7.6.2 + temporal-polyfill: + specifier: ^0.2.5 + version: 0.2.5 vscode-languageserver: specifier: ~9.0.1 version: 9.0.1 @@ -10994,6 +10997,12 @@ packages: resolution: {integrity: sha512-s0ZZzd0BzYv5tLSptZooSjK8oj6C+c19p7Vqta9+6NPOf7r+fxq0cJe6/oN4LTC79sy5NY8ucOJNgwsKCSbfqg==} engines: {node: '>=6.0.0'} + temporal-polyfill@0.2.5: + resolution: {integrity: sha512-ye47xp8Cb0nDguAhrrDS1JT1SzwEV9e26sSsrWzVu+yPZ7LzceEcH0i2gci9jWfOfSCCgM3Qv5nOYShVUUFUXA==} + + temporal-spec@0.2.4: + resolution: {integrity: sha512-lDMFv4nKQrSjlkHKAlHVqKrBG4DyFfa9F74cmBZ3Iy3ed8yvWnlWSIdi4IKfSqwmazAohBNwiN64qGx4y5Q3IQ==} + tempy@3.1.0: resolution: {integrity: sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==} engines: {node: '>=14.16'} @@ -24579,6 +24588,12 @@ snapshots: dependencies: rimraf: 2.6.3 + temporal-polyfill@0.2.5: + dependencies: + temporal-spec: 0.2.4 + + temporal-spec@0.2.4: {} + tempy@3.1.0: dependencies: is-stream: 3.0.0