diff --git a/.chronus/changes/fix-response-examples-2024-6-29-22-38-45.md b/.chronus/changes/fix-response-examples-2024-6-29-22-38-45.md new file mode 100644 index 0000000000..5693cecb99 --- /dev/null +++ b/.chronus/changes/fix-response-examples-2024-6-29-22-38-45.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: fix +packages: + - "@typespec/compiler" +--- + +Fix type comparison of literal and scalar when in projection context diff --git a/.chronus/changes/fix-response-examples-2024-6-30-15-26-28.md b/.chronus/changes/fix-response-examples-2024-6-30-15-26-28.md new file mode 100644 index 0000000000..440741f71e --- /dev/null +++ b/.chronus/changes/fix-response-examples-2024-6-30-15-26-28.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/openapi3" +--- + +Fix issue where operation example would produce an empty object when `@body`/`@bodyRoot` was used diff --git a/.chronus/changes/fix-response-examples-2024-6-30-15-59-29.md b/.chronus/changes/fix-response-examples-2024-6-30-15-59-29.md new file mode 100644 index 0000000000..33457d4cdc --- /dev/null +++ b/.chronus/changes/fix-response-examples-2024-6-30-15-59-29.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: fix +packages: + - "@typespec/openapi3" +--- + +Fix operation response body examples showing up for each response. diff --git a/.chronus/changes/fix-response-examples-2024-6-30-22-22-9.md b/.chronus/changes/fix-response-examples-2024-6-30-22-22-9.md new file mode 100644 index 0000000000..559242ce36 --- /dev/null +++ b/.chronus/changes/fix-response-examples-2024-6-30-22-22-9.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: feature +packages: + - "@typespec/http" +--- + +API: Expose `properties: HttpProperty[]` on operation parameter and response which reference all the properties of interest(Body, statusCode, contentType, implicitBodyProperty, etc.) diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 0b7575688c..82487c8996 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -7874,7 +7874,7 @@ export function createChecker(program: Program): Checker { function isNumericAssignableToNumericScalar(source: Numeric, target: Scalar) { // if the target does not derive from numeric, then it can't be assigned a numeric literal - if (!areScalarsRelated(target, getStdType("numeric"))) { + if (!areScalarsRelated((target.projectionBase as any) ?? target, getStdType("numeric"))) { return false; } @@ -7902,7 +7902,7 @@ export function createChecker(program: Program): Checker { } function isStringLiteralRelatedTo(source: StringLiteral | StringTemplate, target: Scalar) { - if (!areScalarsRelated(target, getStdType("string"))) { + if (!areScalarsRelated((target.projectionBase as any) ?? target, getStdType("string"))) { return false; } if (source.kind === "StringTemplate") { diff --git a/packages/http/src/http-property.ts b/packages/http/src/http-property.ts index fffa6b4047..0208161c3f 100644 --- a/packages/http/src/http-property.ts +++ b/packages/http/src/http-property.ts @@ -9,7 +9,6 @@ import { type ModelProperty, type Program, } from "@typespec/compiler"; -import { Queue } from "@typespec/compiler/utils"; import { getHeaderFieldOptions, getPathParamOptions, @@ -36,6 +35,8 @@ export type HttpProperty = export interface HttpPropertyBase { readonly property: ModelProperty; + /** Path from the root of the operation parameters/returnType to the property. */ + readonly path: (string | number)[]; } export interface HeaderProperty extends HttpPropertyBase { @@ -78,14 +79,17 @@ export interface GetHttpPropertyOptions { /** * Find the type of a property in a model */ -export function getHttpProperty( +function getHttpProperty( program: Program, property: ModelProperty, + path: (string | number)[], options: GetHttpPropertyOptions = {} ): [HttpProperty, readonly Diagnostic[]] { const diagnostics: Diagnostic[] = []; - function createResult(opts: T): [T, readonly Diagnostic[]] { - return [{ ...opts, property } as any, diagnostics]; + function createResult>( + opts: T + ): [HttpProperty & T, readonly Diagnostic[]] { + return [{ ...opts, property, path } as any, diagnostics]; } const annotations = { @@ -106,10 +110,9 @@ export function getHttpProperty( name: property.name, type: "path", }, - property, }); } - return [{ kind: "bodyProperty", property }, []]; + return createResult({ kind: "bodyProperty" }); } else if (defined.length > 1) { diagnostics.push( createDiagnostic({ @@ -122,24 +125,24 @@ export function getHttpProperty( if (annotations.header) { if (annotations.header.name.toLowerCase() === "content-type") { - return createResult({ kind: "contentType", property }); + return createResult({ kind: "contentType" }); } else { - return createResult({ kind: "header", options: annotations.header, property }); + return createResult({ kind: "header", options: annotations.header }); } } else if (annotations.query) { - return createResult({ kind: "query", options: annotations.query, property }); + return createResult({ kind: "query", options: annotations.query }); } else if (annotations.path) { - return createResult({ kind: "path", options: annotations.path, property }); + return createResult({ kind: "path", options: annotations.path }); } else if (annotations.statusCode) { - return createResult({ kind: "statusCode", property }); + return createResult({ kind: "statusCode" }); } else if (annotations.body) { - return createResult({ kind: "body", property }); + return createResult({ kind: "body" }); } else if (annotations.bodyRoot) { - return createResult({ kind: "bodyRoot", property }); + return createResult({ kind: "bodyRoot" }); } else if (annotations.multipartBody) { - return createResult({ kind: "multipartBody", property }); + return createResult({ kind: "multipartBody" }); } - compilerAssert(false, `Unexpected http property type`, property); + compilerAssert(false, `Unexpected http property type`); } /** @@ -161,53 +164,57 @@ export function resolvePayloadProperties( } const visited = new Set(); - const queue = new Queue<[Model, ModelProperty | undefined]>([[type, undefined]]); - - while (!queue.isEmpty()) { - const [model, rootOpt] = queue.dequeue(); + function checkModel(model: Model, path: string[]) { visited.add(model); - + let foundBody = false; + let foundBodyProperty = false; for (const property of walkPropertiesInherited(model)) { - const root = rootOpt ?? property; + const propPath = [...path, property.name]; if (!isVisible(program, property, visibility)) { continue; } - let httpProperty = diagnostics.pipe(getHttpProperty(program, property, options)); + let httpProperty = diagnostics.pipe(getHttpProperty(program, property, propPath, options)); if (shouldTreatAsBodyProperty(httpProperty, visibility)) { - httpProperty = { kind: "bodyProperty", property }; + httpProperty = { kind: "bodyProperty", property, path: propPath }; } - httpProperties.set(property, httpProperty); + if ( - property !== root && - (httpProperty.kind === "body" || - httpProperty.kind === "bodyRoot" || - httpProperty.kind === "multipartBody") + httpProperty.kind === "body" || + httpProperty.kind === "bodyRoot" || + httpProperty.kind === "multipartBody" ) { - const parent = httpProperties.get(root); - if (parent?.kind === "bodyProperty") { - httpProperties.delete(root); - } - } - if (httpProperty.kind === "body" || httpProperty.kind === "multipartBody") { - continue; // We ignore any properties under `@body` or `@multipartBody` + foundBody = true; } if ( - property.type.kind === "Model" && - !type.indexer && - type.properties.size > 0 && + !(httpProperty.kind === "body" || httpProperty.kind === "multipartBody") && + isModelWithProperties(property.type) && !visited.has(property.type) ) { - queue.enqueue([property.type, root]); + if (checkModel(property.type, propPath)) { + foundBody = true; + continue; + } + } + if (httpProperty.kind === "bodyProperty") { + foundBodyProperty = true; } + httpProperties.set(property, httpProperty); } + return foundBody && !foundBodyProperty; } + checkModel(type, []); + return diagnostics.wrap([...httpProperties.values()]); } +function isModelWithProperties(type: Type): type is Model { + return type.kind === "Model" && !type.indexer && type.properties.size > 0; +} + function shouldTreatAsBodyProperty(property: HttpProperty, visibility: Visibility): boolean { if (visibility & Visibility.Read) { return property.kind === "query" || property.kind === "path"; diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index c8daf7f58b..3a25fd0bfd 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -5,6 +5,7 @@ export { HttpPartOptions } from "../generated-defs/TypeSpec.Http.Private.js"; export * from "./auth.js"; export * from "./content-types.js"; export * from "./decorators.js"; +export type { HttpProperty } from "./http-property.js"; export * from "./metadata.js"; export * from "./operations.js"; export * from "./parameters.js"; diff --git a/packages/http/src/parameters.ts b/packages/http/src/parameters.ts index 9486c10193..39c4c6c681 100644 --- a/packages/http/src/parameters.ts +++ b/packages/http/src/parameters.ts @@ -96,6 +96,7 @@ function getOperationParametersForVerb( const body = resolvedBody; return diagnostics.wrap({ + properties: metadata, parameters, verb, body, diff --git a/packages/http/src/responses.ts b/packages/http/src/responses.ts index 6e2d952d55..feccf086f8 100644 --- a/packages/http/src/responses.ts +++ b/packages/http/src/responses.ts @@ -135,9 +135,10 @@ function processResponseType( response.responses.push({ body: resolvedBody, headers, + properties: metadata, }); } else { - response.responses.push({ headers }); + response.responses.push({ headers, properties: metadata }); } responses.set(statusCode, response); } diff --git a/packages/http/src/types.ts b/packages/http/src/types.ts index 5732936799..002df43e22 100644 --- a/packages/http/src/types.ts +++ b/packages/http/src/types.ts @@ -10,7 +10,7 @@ import { Tuple, Type, } from "@typespec/compiler"; -import { HeaderProperty } from "./http-property.js"; +import { HeaderProperty, HttpProperty } from "./http-property.js"; /** * @deprecated use `HttpOperation`. To remove in November 2022 release. @@ -332,6 +332,9 @@ export type HttpOperationRequestBody = HttpOperationBody; export type HttpOperationResponseBody = HttpOperationBody; export interface HttpOperationParameters { + /** Http properties */ + readonly properties: HttpProperty[]; + parameters: HttpOperationParameter[]; body?: HttpOperationBody | HttpOperationMultipartBody; @@ -441,6 +444,9 @@ export interface HttpOperationResponse { } export interface HttpOperationResponseContent { + /** Http properties for this response */ + readonly properties: HttpProperty[]; + headers?: Record; body?: HttpOperationBody | HttpOperationMultipartBody; } diff --git a/packages/http/test/parameters.test.ts b/packages/http/test/parameters.test.ts index 24fcd345c7..403ed72cb6 100644 --- a/packages/http/test/parameters.test.ts +++ b/packages/http/test/parameters.test.ts @@ -41,6 +41,30 @@ it("emit diagnostic when there is an unannotated parameter and a @body param", a }); }); +it("emit diagnostic when there is an unannotated parameter and a nested @body param", async () => { + const [_, diagnostics] = await compileOperations(` + @get op get(param1: string, nested: {@body param2: string}): void; + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/http/duplicate-body", + message: + "Operation has a @body and an unannotated parameter. There can only be one representing the body", + }); +}); + +it("emit diagnostic when there is annotated param and @body nested together", async () => { + const [_, diagnostics] = await compileOperations(` + @get op get(nested: {param1: string, @body param2: string}): void; + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/http/duplicate-body", + message: + "Operation has a @body and an unannotated parameter. There can only be one representing the body", + }); +}); + it("emit diagnostic when there are multiple @body param", async () => { const [_, diagnostics] = await compileOperations(` @get op get(@query select: string, @body param1: string, @body param2: string): string; diff --git a/packages/openapi3/src/examples.ts b/packages/openapi3/src/examples.ts new file mode 100644 index 0000000000..ded14c9612 --- /dev/null +++ b/packages/openapi3/src/examples.ts @@ -0,0 +1,245 @@ +import { + Example, + getOpExamples, + ignoreDiagnostics, + OpExample, + Program, + serializeValueAsJson, + Type, + Value, +} from "@typespec/compiler"; +import type { + HttpOperation, + HttpOperationResponse, + HttpOperationResponseContent, + HttpProperty, + HttpStatusCodeRange, +} from "@typespec/http"; +import { getOpenAPI3StatusCodes } from "./status-codes.js"; +import { OpenAPI3Example, OpenAPI3MediaType } from "./types.js"; +import { isSharedHttpOperation, SharedHttpOperation } from "./util.js"; + +export interface OperationExamples { + requestBody: Record; + responses: Record>; +} + +export function resolveOperationExamples( + program: Program, + operation: HttpOperation | SharedHttpOperation +): OperationExamples { + const examples = findOperationExamples(program, operation); + const result: OperationExamples = { requestBody: {}, responses: {} }; + if (examples.length === 0) { + return result; + } + for (const [op, example] of examples) { + if (example.parameters && op.parameters.body) { + const contentTypeValue = + getContentTypeValue(example.parameters, op.parameters.properties) ?? "application/json"; + result.requestBody[contentTypeValue] ??= []; + const value = getBodyValue(example.parameters, op.parameters.properties); + if (value) { + result.requestBody[contentTypeValue].push([ + { + value, + title: example.title, + description: example.description, + }, + op.parameters.body.type, + ]); + } + } + if (example.returnType && op.responses) { + const match = findResponseForExample(program, example.returnType, op.responses); + if (match) { + const value = getBodyValue(example.returnType, match.response.properties); + if (value) { + for (const statusCode of match.statusCodes) { + result.responses[statusCode] ??= {}; + result.responses[statusCode][match.contentType] ??= []; + result.responses[statusCode][match.contentType].push([ + { + value, + title: example.title, + description: example.description, + }, + match.response.body!.type, + ]); + } + } + } + } + } + return result; +} + +function findOperationExamples( + program: Program, + operation: HttpOperation | SharedHttpOperation +): [HttpOperation, OpExample][] { + if (isSharedHttpOperation(operation)) { + return operation.operations.flatMap((op) => + getOpExamples(program, op.operation).map((x): [HttpOperation, OpExample] => [op, x]) + ); + } else { + return getOpExamples(program, operation.operation).map((x) => [operation, x]); + } +} + +function isStatusCodeIn( + exampleStatusCode: number, + statusCodes: number | HttpStatusCodeRange | "*" +) { + if (statusCodes === "*") { + return true; + } + if (typeof statusCodes === "number") { + return exampleStatusCode === statusCodes; + } + + return exampleStatusCode >= statusCodes.start && exampleStatusCode <= statusCodes.end; +} +function findResponseForExample( + program: Program, + exampleValue: Value, + responses: HttpOperationResponse[] +): + | { contentType: string; statusCodes: string[]; response: HttpOperationResponseContent } + | undefined { + const tentatives: [ + { response: HttpOperationResponseContent; contentType?: string; statusCodes?: string[] }, + number, + ][] = []; + for (const statusCodeResponse of responses) { + for (const response of statusCodeResponse.responses) { + if (response.body === undefined) { + continue; + } + const contentType = getContentTypeValue(exampleValue, response.properties); + const statusCode = getStatusCodeValue(exampleValue, response.properties); + const contentTypeProp = response.properties.find((x) => x.kind === "contentType"); // if undefined MUST be application/json + const statusCodeProp = response.properties.find((x) => x.kind === "statusCode"); // if undefined MUST be 200 + + const statusCodeMatch = + statusCode && statusCodeProp && isStatusCodeIn(statusCode, statusCodeResponse.statusCodes); + const contentTypeMatch = contentType && response.body?.contentTypes.includes(contentType); + if (statusCodeMatch && contentTypeMatch) { + return { + contentType, + statusCodes: ignoreDiagnostics( + getOpenAPI3StatusCodes(program, statusCodeResponse.statusCodes, statusCodeResponse.type) + ), + response, + }; + } else if (statusCodeMatch && contentTypeProp === undefined) { + tentatives.push([ + { + response, + statusCodes: ignoreDiagnostics( + getOpenAPI3StatusCodes( + program, + statusCodeResponse.statusCodes, + statusCodeResponse.type + ) + ), + }, + 1, + ]); + } else if (contentTypeMatch && statusCodeMatch === undefined) { + tentatives.push([{ response, contentType }, 1]); + } else if (contentTypeProp === undefined && statusCodeProp === undefined) { + tentatives.push([{ response }, 0]); + } + } + } + const tentative = tentatives.sort((a, b) => a[1] - b[1]).pop(); + if (tentative) { + return { + contentType: tentative[0].contentType ?? "application/json", + statusCodes: tentative[0].statusCodes ?? ["200"], + response: tentative[0].response, + }; + } + return undefined; +} + +export function getExampleOrExamples( + program: Program, + 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 }; + } +} + +export function getStatusCodeValue(value: Value, properties: HttpProperty[]): number | undefined { + const statusCodeProperty = properties.find((p) => p.kind === "statusCode"); + if (statusCodeProperty === undefined) { + return undefined; + } + + const statusCode = getValueByPath(value, statusCodeProperty.path); + if (statusCode?.valueKind === "NumericValue") { + return statusCode.value.asNumber() ?? undefined; + } + return undefined; +} + +export function getContentTypeValue(value: Value, properties: HttpProperty[]): string | undefined { + const contentTypeProperty = properties.find((p) => p.kind === "contentType"); + if (contentTypeProperty === undefined) { + return undefined; + } + + const statusCode = getValueByPath(value, contentTypeProperty.path); + if (statusCode?.valueKind === "StringValue") { + return statusCode.value; + } + return undefined; +} + +export function getBodyValue(value: Value, properties: HttpProperty[]): Value | undefined { + const bodyProperty = properties.find((p) => p.kind === "body" || p.kind === "bodyRoot"); + if (bodyProperty !== undefined) { + return getValueByPath(value, bodyProperty.path); + } + + return value; +} + +function getValueByPath(value: Value, path: (string | number)[]): Value | undefined { + let current: Value | undefined = value; + for (const key of path) { + switch (current?.valueKind) { + case "ObjectValue": + current = current.properties.get(key.toString())?.value; + break; + case "ArrayValue": + current = current.values[key as number]; + break; + default: + return undefined; + } + } + return current; +} diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 073e469567..e0aed5d521 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -3,7 +3,6 @@ import { createDiagnosticCollector, Diagnostic, DiagnosticCollector, - DiagnosticTarget, EmitContext, emitFile, Example, @@ -21,7 +20,6 @@ import { getMinValue, getMinValueExclusive, getNamespaceFullName, - getOpExamples, getPattern, getService, getSummary, @@ -37,13 +35,10 @@ import { Namespace, navigateTypesInNamespace, NewLine, - Operation, - OpExample, Program, ProjectionApplication, projectProgram, resolvePath, - serializeValueAsJson, Service, Type, TypeNameOptions, @@ -69,8 +64,6 @@ import { HttpOperationResponseContent, HttpServer, HttpServiceAuthentication, - HttpStatusCodeRange, - HttpStatusCodesEntry, isContentTypeHeader, isOrExtendsHttpFile, isOverloadSameEndpoint, @@ -86,7 +79,6 @@ import { getExternalDocs, getOpenAPITypeName, getParameterKey, - isDefaultResponse, isReadonlyProperty, resolveInfo, resolveOperationId, @@ -96,12 +88,13 @@ import { buildVersionProjections, VersionProjections } from "@typespec/versionin import { stringify } from "yaml"; import { getRef } from "./decorators.js"; import { applyEncoding } from "./encoding.js"; +import { getExampleOrExamples, OperationExamples, resolveOperationExamples } from "./examples.js"; import { createDiagnostic, FileType, OpenAPI3EmitterOptions } from "./lib.js"; import { getDefaultValue, isBytesKeptRaw, OpenAPI3SchemaEmitter } from "./schema-emitter.js"; +import { getOpenAPI3StatusCodes } from "./status-codes.js"; import { OpenAPI3Document, OpenAPI3Encoding, - OpenAPI3Example, OpenAPI3Header, OpenAPI3MediaType, OpenAPI3OAuthFlows, @@ -119,7 +112,7 @@ import { OpenAPI3VersionedServiceRecord, Refable, } from "./types.js"; -import { deepEquals, isDefined } from "./util.js"; +import { deepEquals, isSharedHttpOperation, SharedHttpOperation } from "./util.js"; import { resolveVisibilityUsage, VisibilityUsageTracker } from "./visibility-usage.js"; const defaultFileType: FileType = "yaml"; @@ -581,71 +574,6 @@ function createOAPIEmitter( return finalParams; } - interface SharedHttpOperation { - kind: "shared"; - operations: HttpOperation[]; - } - - function getOpenAPI3StatusCodes( - statusCodes: HttpStatusCodesEntry, - response: Type - ): OpenAPI3StatusCode[] { - if (isDefaultResponse(program, response) || statusCodes === "*") { - return ["default"]; - } else if (typeof statusCodes === "number") { - return [String(statusCodes)]; - } else { - return rangeToOpenAPI(statusCodes, response); - } - } - - function rangeToOpenAPI( - range: HttpStatusCodeRange, - diagnosticTarget: DiagnosticTarget - ): OpenAPI3StatusCode[] { - const reportInvalid = () => - diagnostics.add( - createDiagnostic({ - code: "unsupported-status-code-range", - format: { start: String(range.start), end: String(range.end) }, - target: diagnosticTarget, - }) - ); - - const codes: OpenAPI3StatusCode[] = []; - let start = range.start; - let end = range.end; - - if (range.start < 100) { - reportInvalid(); - start = 100; - codes.push("default"); - } else if (range.end > 599) { - reportInvalid(); - codes.push("default"); - end = 599; - } - const groups = [1, 2, 3, 4, 5]; - - for (const group of groups) { - if (start > end) { - break; - } - const groupStart = group * 100; - const groupEnd = groupStart + 99; - if (start >= groupStart && start <= groupEnd) { - codes.push(`${group}XX`); - if (start !== groupStart || end < groupEnd) { - reportInvalid(); - } - - start = groupStart + 100; - } - } - - return codes; - } - function buildSharedOperation(operations: HttpOperation[]): SharedHttpOperation { return { kind: "shared", @@ -764,12 +692,13 @@ function createOAPIEmitter( const operations = shared.operations; const verb = operations[0].verb; const path = operations[0].path; + const examples = resolveOperationExamples(program, shared); const oai3Operation: OpenAPI3Operation = { operationId: computeSharedOperationId(shared), parameters: [], description: joinOps(operations, getDoc, " "), summary: joinOps(operations, getSummary, " "), - responses: getSharedResponses(shared), + responses: getSharedResponses(shared, examples), }; for (const op of operations) { @@ -820,7 +749,7 @@ function createOAPIEmitter( ...new Set(operations.map((op) => op.parameters.body).filter((x) => x !== undefined)), ]; if (bodies) { - oai3Operation.requestBody = getRequestBody(shared, bodies, visibility); + oai3Operation.requestBody = getRequestBody(bodies, visibility, examples); } const authReference = serviceAuth.operationsAuth.get(shared.operations[0].operation); if (authReference) { @@ -840,13 +769,13 @@ function createOAPIEmitter( return undefined; } const visibility = resolveRequestVisibility(program, operation.operation, verb); - + const examples = resolveOperationExamples(program, operation); const oai3Operation: OpenAPI3Operation = { operationId: resolveOperationId(program, operation.operation), summary: getSummary(program, operation.operation), description: getDoc(program, operation.operation), parameters: getEndpointParameters(parameters.parameters, visibility), - responses: getResponses(operation, operation.responses), + responses: getResponses(operation, operation.responses, examples), }; const currentTags = getAllTags(program, op); if (currentTags) { @@ -861,9 +790,9 @@ function createOAPIEmitter( if (parameters.body) { oai3Operation.requestBody = getRequestBody( - operation, parameters.body && [parameters.body], - visibility + visibility, + examples ); } const authReference = serviceAuth.operationsAuth.get(operation.operation); @@ -878,12 +807,15 @@ function createOAPIEmitter( } function getSharedResponses( - operation: SharedHttpOperation + operation: SharedHttpOperation, + examples: OperationExamples ): Record> { const responseMap = new Map(); for (const op of operation.operations) { for (const response of op.responses) { - const statusCodes = getOpenAPI3StatusCodes(response.statusCodes, op.operation); + const statusCodes = diagnostics.pipe( + getOpenAPI3StatusCodes(program, response.statusCodes, op.operation) + ); for (const statusCode of statusCodes) { if (responseMap.has(statusCode)) { responseMap.get(statusCode)!.push(response); @@ -897,19 +829,27 @@ function createOAPIEmitter( for (const [statusCode, statusCodeResponses] of responseMap) { const dedupeResponses = deduplicateCommonResponses(statusCodeResponses); - result[statusCode] = getResponseForStatusCode(operation, statusCode, dedupeResponses); + result[statusCode] = getResponseForStatusCode( + operation, + statusCode, + dedupeResponses, + examples + ); } return result; } function getResponses( operation: HttpOperation, - responses: HttpOperationResponse[] + responses: HttpOperationResponse[], + examples: OperationExamples ): Record> { const result: Record> = {}; for (const response of responses) { - for (const statusCode of getOpenAPI3StatusCodes(response.statusCodes, response.type)) { - result[statusCode] = getResponseForStatusCode(operation, statusCode, [response]); + for (const statusCode of diagnostics.pipe( + getOpenAPI3StatusCodes(program, response.statusCodes, response.type) + )) { + result[statusCode] = getResponseForStatusCode(operation, statusCode, [response], examples); } } return result; @@ -927,7 +867,8 @@ function createOAPIEmitter( function getResponseForStatusCode( operation: HttpOperation | SharedHttpOperation, statusCode: OpenAPI3StatusCode, - responses: HttpOperationResponse[] + responses: HttpOperationResponse[], + examples: OperationExamples ): Refable { const openApiResponse: OpenAPI3Response = { description: "", @@ -944,7 +885,14 @@ function createOAPIEmitter( : response.description; } emitResponseHeaders(openApiResponse, response.responses, response.type); - emitResponseContent(operation, openApiResponse, response.responses, schemaMap); + emitResponseContent( + operation, + openApiResponse, + response.responses, + statusCode, + examples, + schemaMap + ); if (!openApiResponse.description) { openApiResponse.description = getResponseDescriptionForStatusCode(statusCode); } @@ -984,6 +932,8 @@ function createOAPIEmitter( operation: HttpOperation | SharedHttpOperation, obj: OpenAPI3Response, responses: HttpOperationResponseContent[], + statusCode: OpenAPI3StatusCode, + examples: OperationExamples, schemaMap: Map | undefined = undefined ) { schemaMap ??= new Map(); @@ -994,11 +944,10 @@ function createOAPIEmitter( obj.content ??= {}; for (const contentType of data.body.contentTypes) { const contents = getBodyContentEntry( - operation, - "response", data.body, Visibility.Read, - contentType + contentType, + examples.responses[statusCode]?.[contentType] ); if (schemaMap.has(contentType)) { schemaMap.get(contentType)!.push(contents); @@ -1006,6 +955,7 @@ function createOAPIEmitter( schemaMap.set(contentType, [contents]); } } + for (const [contentType, contents] of schemaMap) { if (contents.length === 1) { obj.content[contentType] = contents[0]; @@ -1102,84 +1052,18 @@ 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.operation).map((x): [Operation, OpExample] => [op.operation, 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 + contentType: string, + examples?: [Example, Type][] ): OpenAPI3MediaType { const isBinary = isBinaryPayload(body.type, contentType); if (isBinary) { return { schema: { type: "string", format: "binary" } }; } + const oai3Examples = examples && getExampleOrExamples(program, examples); switch (body.bodyKind) { case "single": return { @@ -1189,12 +1073,12 @@ function createOAPIEmitter( body.isExplicit && body.containsMetadataAnnotations, contentType.startsWith("multipart/") ? contentType : undefined ), - ...getExamplesForBodyContentEntry(operation, target), + ...oai3Examples, }; case "multipart": return { ...getBodyContentForMultipartBody(body, visibility, contentType), - ...getExamplesForBodyContentEntry(operation, target), + ...oai3Examples, }; } } @@ -1379,9 +1263,9 @@ function createOAPIEmitter( } function getRequestBody( - operation: HttpOperation | SharedHttpOperation, bodies: (HttpOperationBody | HttpOperationMultipartBody)[] | undefined, - visibility: Visibility + visibility: Visibility, + examples: OperationExamples ): OpenAPI3RequestBody | undefined { if (bodies === undefined || bodies.every((x) => isVoidType(x.type))) { return undefined; @@ -1400,8 +1284,13 @@ function createOAPIEmitter( } const contentTypes = body.contentTypes.length > 0 ? body.contentTypes : ["application/json"]; for (const contentType of contentTypes) { - const entry = getBodyContentEntry(operation, "request", body, visibility, contentType); const existing = schemaMap.get(contentType); + const entry = getBodyContentEntry( + body, + visibility, + contentType, + examples.requestBody[contentType] + ); if (existing) { existing.push(entry); } else { diff --git a/packages/openapi3/src/status-codes.ts b/packages/openapi3/src/status-codes.ts new file mode 100644 index 0000000000..a4e3ab1839 --- /dev/null +++ b/packages/openapi3/src/status-codes.ts @@ -0,0 +1,67 @@ +import { Diagnostic, DiagnosticTarget, Program, Type } from "@typespec/compiler"; +import { HttpStatusCodeRange, HttpStatusCodesEntry } from "@typespec/http"; +import { isDefaultResponse } from "@typespec/openapi"; +import { createDiagnostic } from "./lib.js"; +import { OpenAPI3StatusCode } from "./types.js"; + +export function getOpenAPI3StatusCodes( + program: Program, + statusCodes: HttpStatusCodesEntry, + response: Type +): [OpenAPI3StatusCode[], readonly Diagnostic[]] { + if (isDefaultResponse(program, response) || statusCodes === "*") { + return [["default"], []]; + } else if (typeof statusCodes === "number") { + return [[String(statusCodes)], []]; + } else { + return rangeToOpenAPI(statusCodes, response); + } +} + +function rangeToOpenAPI( + range: HttpStatusCodeRange, + diagnosticTarget: DiagnosticTarget +): [OpenAPI3StatusCode[], readonly Diagnostic[]] { + const diagnostics: Diagnostic[] = []; + const reportInvalid = () => + diagnostics.push( + createDiagnostic({ + code: "unsupported-status-code-range", + format: { start: String(range.start), end: String(range.end) }, + target: diagnosticTarget, + }) + ); + + const codes: OpenAPI3StatusCode[] = []; + let start = range.start; + let end = range.end; + + if (range.start < 100) { + reportInvalid(); + start = 100; + codes.push("default"); + } else if (range.end > 599) { + reportInvalid(); + codes.push("default"); + end = 599; + } + const groups = [1, 2, 3, 4, 5]; + + for (const group of groups) { + if (start > end) { + break; + } + const groupStart = group * 100; + const groupEnd = groupStart + 99; + if (start >= groupStart && start <= groupEnd) { + codes.push(`${group}XX`); + if (start !== groupStart || end < groupEnd) { + reportInvalid(); + } + + start = groupStart + 100; + } + } + + return [codes, diagnostics]; +} diff --git a/packages/openapi3/src/util.ts b/packages/openapi3/src/util.ts index cc1cd59e90..1cb9800c93 100644 --- a/packages/openapi3/src/util.ts +++ b/packages/openapi3/src/util.ts @@ -1,3 +1,5 @@ +import { HttpOperation } from "@typespec/http"; + /** * Checks if two objects are deeply equal. * @@ -75,3 +77,13 @@ export function mapEquals( export function isDefined(arg: T | undefined): arg is T { return arg !== undefined; } + +export interface SharedHttpOperation { + kind: "shared"; + operations: HttpOperation[]; +} +export function isSharedHttpOperation( + operation: HttpOperation | SharedHttpOperation +): operation is SharedHttpOperation { + return (operation as SharedHttpOperation).kind === "shared"; +} diff --git a/packages/openapi3/test/examples.test.ts b/packages/openapi3/test/examples.test.ts index ff704d74f0..6dae36af30 100644 --- a/packages/openapi3/test/examples.test.ts +++ b/packages/openapi3/test/examples.test.ts @@ -95,4 +95,118 @@ describe("operation examples", () => { age: 2, }); }); + + describe("Map to the right status code", () => { + it("set example on the corresponding response body with union", async () => { + const res: OpenAPI3Document = await openApiFor( + ` + @opExample(#{ + returnType: #{ + name: "Fluffy", + age: 2, + }, + }, #{ title: "Ok" }) + @opExample( + #{ returnType: #{ _: 404, error: "No user with this name" } }, + #{ title: "Not found" } + ) + op getPet(): {name: string, age: int32} | { + @statusCode _: 404; + error: string; + }; + ` + ); + expect(res.paths["/"].get?.responses[200].content["application/json"].examples).toEqual({ + Ok: { + summary: "Ok", + value: { name: "Fluffy", age: 2 }, + }, + }); + expect(res.paths["/"].get?.responses[404].content["application/json"].examples).toEqual({ + "Not found": { + summary: "Not found", + value: { + error: "No user with this name", + }, + }, + }); + }); + + it("apply to status code ranges", async () => { + const res: OpenAPI3Document = await openApiFor( + ` + @opExample(#{ + returnType: #{ statusCode: 200, data: "Ok" }, + }, #{ title: "Ok" }) + @opExample( + #{ returnType: #{ statusCode: 404, error: "No user with this name" } }, + #{ title: "Not found" } + ) + op getPet(): {@statusCode statusCode: 200, data: string} | { + @minValue(400) + @maxValue(599) + @statusCode + statusCode: int32; + + error: string; + }; + + ` + ); + expect(res.paths["/"].get?.responses[200].content["application/json"].examples).toEqual({ + Ok: { + summary: "Ok", + value: { data: "Ok" }, + }, + }); + expect(res.paths["/"].get?.responses["4XX"].content["application/json"].examples).toEqual({ + "Not found": { + summary: "Not found", + value: { + error: "No user with this name", + }, + }, + }); + }); + }); + + it("set example on the response body when using @body", async () => { + const res: OpenAPI3Document = await openApiFor( + ` + @opExample(#{ + returnType: #{ + pet: #{ + name: "Fluffy", + age: 2, + } + }, + }) + op getPet(): {@body pet: {name: string, age: int32}}; + ` + ); + expect(res.paths["/"].get?.responses[200].content["application/json"].example).toEqual({ + name: "Fluffy", + age: 2, + }); + }); + + it("set example on the response body when using @bodyRoot", async () => { + const res: OpenAPI3Document = await openApiFor( + ` + @opExample(#{ + returnType: #{ + pet: #{ + name: "Fluffy", + age: 2, + } + }, + }) + op getPet(): {@bodyRoot pet: {name: string, age: int32}}; + ` + ); + expect(res.paths["/"].get?.responses[200].content["application/json"].example).toEqual({ + name: "Fluffy", + age: 2, + }); + }); }); diff --git a/packages/openapi3/test/metadata.test.ts b/packages/openapi3/test/metadata.test.ts index bb76b033a4..cf4e6d3632 100644 --- a/packages/openapi3/test/metadata.test.ts +++ b/packages/openapi3/test/metadata.test.ts @@ -951,9 +951,6 @@ describe("openapi3: metadata", () => { post: { operationId: "create", parameters: [ - { - $ref: "#/components/parameters/Pet.id", - }, { name: "h1", in: "header", @@ -970,6 +967,9 @@ describe("openapi3: metadata", () => { type: "string", }, }, + { + $ref: "#/components/parameters/Pet.id", + }, ], responses: { "200": {