diff --git a/common/changes/@cadl-lang/openapi/http-common-responses_2022-03-23-01-35.json b/common/changes/@cadl-lang/openapi/http-common-responses_2022-03-23-01-35.json new file mode 100644 index 00000000000..adee6879d9d --- /dev/null +++ b/common/changes/@cadl-lang/openapi/http-common-responses_2022-03-23-01-35.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@cadl-lang/openapi", + "comment": "`@defaultResponse` set status code for model", + "type": "minor" + } + ], + "packageName": "@cadl-lang/openapi" +} \ No newline at end of file diff --git a/common/changes/@cadl-lang/openapi3/http-common-responses_2022-03-22-21-51.json b/common/changes/@cadl-lang/openapi3/http-common-responses_2022-03-22-21-51.json new file mode 100644 index 00000000000..2d093c740d0 --- /dev/null +++ b/common/changes/@cadl-lang/openapi3/http-common-responses_2022-03-22-21-51.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@cadl-lang/openapi3", + "comment": "Moved http response interpretation to @cadl-lang/rest library.", + "type": "minor" + } + ], + "packageName": "@cadl-lang/openapi3" +} diff --git a/common/changes/@cadl-lang/rest/http-common-responses_2022-03-22-21-51.json b/common/changes/@cadl-lang/rest/http-common-responses_2022-03-22-21-51.json new file mode 100644 index 00000000000..839b6cfc69d --- /dev/null +++ b/common/changes/@cadl-lang/rest/http-common-responses_2022-03-22-21-51.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@cadl-lang/rest", + "comment": "Add logic to interpret the http responses.", + "type": "minor" + } + ], + "packageName": "@cadl-lang/rest" +} diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index 979475a4f03..601b08274bc 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -5,6 +5,7 @@ import { validateDecoratorParamType, validateDecoratorTarget, } from "@cadl-lang/compiler"; +import { http } from "@cadl-lang/rest"; import { reportDiagnostic } from "./lib.js"; const operationIdsKey = Symbol(); @@ -64,6 +65,7 @@ export function $defaultResponse({ program }: DecoratorContext, entity: Type) { if (!validateDecoratorTarget(program, entity, "@defaultResponse", "Model")) { return; } + http.setStatusCode(program, entity, ["*"]); program.stateSet(defaultResponseKey).add(entity); } diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index 0c33eabf6cb..e4007bf88fc 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -27,30 +27,12 @@ export const libDef = { default: "Duplicate @body declarations on response type", }, }, - "duplicate-response": { - severity: "error", - messages: { - default: paramMessage`Multiple return types for content type ${"contentType"} and status code ${"statusCode"}`, - }, - }, "duplicate-header": { severity: "error", messages: { default: paramMessage`The header ${"header"} is defined across multiple content types`, }, }, - "content-type-ignored": { - severity: "warning", - messages: { - default: "content-type header ignored because return type has no body", - }, - }, - "content-type-string": { - severity: "error", - messages: { - default: "contentType parameter must be a string literal or union of string literals", - }, - }, "status-code-in-default-response": { severity: "error", messages: { diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 150351e6eca..4a827969466 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -25,13 +25,11 @@ import { getServiceVersion, getSummary, getVisibility, - isErrorModel, isErrorType, isIntrinsic, isNumericType, isSecret, isStringType, - isVoidType, mapChildModels, ModelType, ModelTypeProperty, @@ -44,19 +42,16 @@ import { UnionTypeVariant, validateDecoratorTarget, } from "@cadl-lang/compiler"; -import { - getExtensions, - getExternalDocs, - getOperationId, - isDefaultResponse, -} from "@cadl-lang/openapi"; +import { getExtensions, getExternalDocs, getOperationId } from "@cadl-lang/openapi"; import { Discriminator, getAllRoutes, + getContentTypes, getDiscriminator, http, HttpOperationParameter, HttpOperationParameters, + HttpOperationResponse, OperationDetails, } from "@cadl-lang/rest"; import { getVersionRecords } from "@cadl-lang/versioning"; @@ -67,10 +62,7 @@ const { getHeaderFieldName, getPathParamName, getQueryParamName, - isBody, - isHeader, isStatusCode, - getStatusCodes, getStatusCodeDescription, } = http; @@ -348,16 +340,12 @@ function createOAPIEmitter(program: Program, options: OpenAPIEmitterOptions) { emitEndpointParameters(op, op.parameters, parameters.parameters); emitRequestBody(op, op.parameters, parameters); - emitResponses(op.returnType); + emitResponses(operation.responses); } - function emitResponses(responseType: Type) { - if (responseType.kind === "Union") { - for (const option of responseType.options) { - emitResponseObject(option); - } - } else { - emitResponseObject(responseType); + function emitResponses(responses: HttpOperationResponse[]) { + for (const response of responses) { + emitResponseObject(response); } } @@ -370,222 +358,61 @@ function createOAPIEmitter(program: Program, options: OpenAPIEmitterOptions) { ); } - function emitResponseObject(responseModel: Type) { - // Get explicity defined status codes - const statusCodes = getResponseStatusCodes(responseModel); - - // Get explicitly defined content types - const contentTypes = getResponseContentTypes(responseModel); - - // Get response headers - const headers = getResponseHeaders(responseModel); - - // Get explicitly defined body - let bodyModel = getResponseBody(responseModel); - // If there is no explicit body, it should be conjured from the return type - // if it is a primitive type or it contains more than just response metadata - if (!bodyModel) { - if (responseModel.kind === "Model") { - if (mapCadlTypeToOpenAPI(responseModel)) { - bodyModel = responseModel; - } else { - const isResponseMetadata = (p: ModelTypeProperty) => - isHeader(program, p) || isStatusCode(program, p); - const allProperties = (p: ModelType): ModelTypeProperty[] => { - return [...p.properties.values(), ...(p.baseModel ? allProperties(p.baseModel) : [])]; - }; - if (allProperties(responseModel).some((p) => !isResponseMetadata(p))) { - bodyModel = responseModel; - } - } - } else { - // body is array or possibly something else - bodyModel = responseModel; - } - } - - // If there is no explicit status code, set the default - if (statusCodes.length === 0) { - statusCodes.push(getDefaultStatusCode(responseModel, bodyModel)); - } - - // If there is a body but no explicit content types, use application/json - if (bodyModel && !isVoidType(bodyModel) && contentTypes.length === 0) { - contentTypes.push("application/json"); - } - - if (!bodyModel && contentTypes.length > 0) { - reportDiagnostic(program, { - code: "content-type-ignored", - target: responseModel, - }); + function getOpenAPIStatuscode(response: HttpOperationResponse): string { + switch (response.statusCode) { + case "*": + return "default"; + default: + return response.statusCode; } + } - // Assertion: bodyModel <=> contentTypes.length > 0 - - // Put them into currentEndpoint.responses - - for (const statusCode of statusCodes) { - // the first model for this statusCode/content type pair carries the - // description for the endpoint. This could probably be improved. - const response = currentEndpoint.responses[statusCode] ?? { - description: getResponseDescription(responseModel, statusCode), - }; - - // check for duplicates - if (response.content) { - for (const contentType of contentTypes) { - if (response.content[contentType]) { - reportDiagnostic(program, { - code: "duplicate-response", - format: { statusCode, contentType }, - target: responseModel, - }); - } - } - } - - if (Object.keys(headers).length > 0) { - response.headers ??= {}; + function emitResponseObject(response: Readonly) { + const statusCode = getOpenAPIStatuscode(response); + const openapiResponse = currentEndpoint.responses[statusCode] ?? { + description: response.description ?? getResponseDescriptionForStatusCode(statusCode), + }; + for (const data of response.responses) { + if (data.headers && Object.keys(data.headers).length > 0) { + openapiResponse.headers ??= {}; // OpenAPI can't represent different headers per content type. // So we merge headers here, and report any duplicates. // It may be possible in principle to not error for identically declared // headers. - for (const [key, value] of Object.entries(headers)) { - if (response.headers[key]) { + for (const [key, value] of Object.entries(data.headers)) { + if (openapiResponse.headers[key]) { reportDiagnostic(program, { code: "duplicate-header", format: { header: key }, - target: responseModel, + target: response.type, }); continue; } - - response.headers[key] = value; + openapiResponse.headers[key] = getResponseHeader(value); } } - for (const contentType of contentTypes) { - response.content ??= {}; - const isBinary = isBinaryPayload(bodyModel!, contentType); - const schema = isBinary ? { type: "string", format: "binary" } : getSchemaOrRef(bodyModel!); - response.content[contentType] = { schema }; + if (data.body !== undefined) { + openapiResponse.content ??= {}; + const isBinary = isBinaryPayload(data.body.type, data.body.contentType); + const schema = isBinary + ? { type: "string", format: "binary" } + : getSchemaOrRef(data.body.type); + openapiResponse.content[data.body.contentType] = { schema }; } - currentEndpoint.responses[statusCode] = response; } - } - /** - * Return the default status code for the given response/body - * @param model representing the body - */ - function getDefaultStatusCode(responseModel: Type, bodyModel: Type | undefined) { - if (bodyModel === undefined || isVoidType(bodyModel)) { - return "204"; - } else { - return isErrorModel(program, responseModel) ? "default" : "200"; - } + currentEndpoint.responses[statusCode] = openapiResponse; } - // Get explicity defined status codes from response Model - // Return is an array of strings, possibly empty, which indicates no explicitly defined status codes. - // We do not check for duplicates here -- that will be done by the caller. - function getResponseStatusCodes(responseModel: Type): string[] { - const codes: string[] = []; - if (responseModel.kind === "Model") { - if (responseModel.baseModel) { - codes.push(...getResponseStatusCodes(responseModel.baseModel)); - } - for (const prop of responseModel.properties.values()) { - if (isStatusCode(program, prop)) { - codes.push(...getStatusCodes(program, prop)); - } - } - } - if (isDefaultResponse(program, responseModel)) { - if (codes.length > 0) { - reportDiagnostic(program, { - code: "status-code-in-default-response", - target: responseModel, - }); - } else { - codes.push("default"); - } - } - return codes; - } - - function getResponseDescription(responseModel: Type, statusCode: string) { - const desc = getDoc(program, responseModel); - if (desc) { - return desc; - } - + function getResponseDescriptionForStatusCode(statusCode: string) { if (statusCode === "default") { return "An unexpected error response"; } return getStatusCodeDescription(statusCode) ?? "unknown"; } - // Get explicity defined content-types from response Model - // Return is an array of strings, possibly empty, which indicates no explicitly defined content-type. - // We do not check for duplicates here -- that will be done by the caller. - function getResponseContentTypes(responseModel: Type): string[] { - const contentTypes: string[] = []; - if (responseModel.kind === "Model") { - if (responseModel.baseModel) { - contentTypes.push(...getResponseContentTypes(responseModel.baseModel)); - } - for (const prop of responseModel.properties.values()) { - if (isHeader(program, prop) && getHeaderFieldName(program, prop) === "content-type") { - contentTypes.push(...getContentTypes(prop)); - } - } - } - return contentTypes; - } - - // Get response headers from response Model - function getResponseHeaders(responseModel: Type) { - if (responseModel.kind === "Model") { - const responseHeaders: any = responseModel.baseModel - ? getResponseHeaders(responseModel.baseModel) - : {}; - for (const prop of responseModel.properties.values()) { - const headerName = getHeaderFieldName(program, prop); - if (isHeader(program, prop) && headerName !== "content-type") { - responseHeaders[headerName] = getResponseHeader(prop); - } - } - return responseHeaders; - } - return {}; - } - - // Get explicity defined response body from response Model - // Search inheritance chain and error on any duplicates found - function getResponseBody(responseModel: Type): Type | undefined { - if (responseModel.kind === "Model") { - const getAllBodyProps = (m: ModelType): ModelTypeProperty[] => { - const bodyProps = [...m.properties.values()].filter((t) => isBody(program, t)); - if (m.baseModel) { - bodyProps.push(...getAllBodyProps(m.baseModel)); - } - return bodyProps; - }; - const bodyProps = getAllBodyProps(responseModel); - if (bodyProps.length > 0) { - // Report all but first body as duplicate - for (const prop of bodyProps.slice(1)) { - reportDiagnostic(program, { code: "duplicate-body", target: prop }); - } - return bodyProps[0].type; - } - } - return undefined; - } - function getResponseHeader(prop: ModelTypeProperty) { const header: any = {}; populateParameter(header, prop, undefined); @@ -730,7 +557,7 @@ function createOAPIEmitter(program: Program, options: OpenAPIEmitterOptions) { (p) => p.type === "header" && p.name === "content-type" ); const contentTypes = contentTypeParam - ? getContentTypes(contentTypeParam.param) + ? getContentTypes(program, contentTypeParam.param) : ["application/json"]; for (const contentType of contentTypes) { const isBinary = isBinaryPayload(bodyType, contentType); @@ -744,31 +571,6 @@ function createOAPIEmitter(program: Program, options: OpenAPIEmitterOptions) { currentEndpoint.requestBody = requestBody; } - function getContentTypes(param: ModelTypeProperty): string[] { - if (param.type.kind === "String") { - return [param.type.value]; - } else if (param.type.kind === "Union") { - const contentTypes = []; - for (const option of param.type.options) { - if (option.kind === "String") { - contentTypes.push(option.value); - } else { - reportDiagnostic(program, { - code: "content-type-string", - target: param, - }); - continue; - } - } - - return contentTypes; - } - - reportDiagnostic(program, { code: "content-type-string", target: param }); - - return []; - } - function emitParameter(parent: ModelType | undefined, param: ModelTypeProperty, kind: string) { const ph = getParamPlaceholder(parent, param); currentEndpoint.parameters.push(ph); diff --git a/packages/openapi3/test/test-return-types.ts b/packages/openapi3/test/test-return-types.ts index 87ef7c341b5..ac323d4d4a7 100644 --- a/packages/openapi3/test/test-return-types.ts +++ b/packages/openapi3/test/test-return-types.ts @@ -262,48 +262,6 @@ describe("openapi3: return types", () => { ok(res.paths["/"].get.responses["200"].content["text/csv"]); }); - it("issues diagnostics for duplicate body decorator", async () => { - const diagnostics = await checkFor( - ` - model Foo { - foo: string; - } - model Bar { - bar: string; - } - @route("/") - namespace root { - @get - op read(): { @body body1: Foo, @body body2: Bar }; - } - ` - ); - expectDiagnostics(diagnostics, [{ code: "@cadl-lang/openapi3/duplicate-body" }]); - }); - - it("issues diagnostics for return type with duplicate status code", async () => { - const diagnostics = await checkFor( - ` - model Foo { - foo: string; - } - model Error { - code: string; - } - @route("/") - namespace root { - @get - op read(): Foo | Error; - } - ` - ); - expectDiagnostics(diagnostics, [{ code: "@cadl-lang/openapi3/duplicate-response" }]); - strictEqual( - diagnostics[0].message, - "Multiple return types for content type application/json and status code 200" - ); - }); - it("issues diagnostics for invalid status codes", async () => { const diagnostics = await checkFor( ` @@ -338,37 +296,6 @@ describe("openapi3: return types", () => { ok(diagnostics[2].message.includes("must be a numeric or string literal")); }); - it("issues diagnostics for invalid content types", async () => { - const diagnostics = await checkFor( - ` - model Foo { - foo: string; - } - - model TextPlain { - contentType: "text/plain"; - } - - namespace root { - @route("/test1") - @get - op test1(): { @header contentType: string, @body body: Foo }; - @route("/test2") - @get - op test2(): { @header contentType: 42, @body body: Foo }; - @route("/test3") - @get - op test3(): { @header contentType: "application/json" | TextPlain, @body body: Foo }; - } - ` - ); - expectDiagnostics(diagnostics, [ - { code: "@cadl-lang/openapi3/content-type-string" }, - { code: "@cadl-lang/openapi3/content-type-string" }, - { code: "@cadl-lang/openapi3/content-type-string" }, - ]); - }); - it("defines responses with primitive types", async () => { const res = await openApiFor( ` diff --git a/packages/rest/src/diagnostics.ts b/packages/rest/src/diagnostics.ts index 444bd8623a0..9d20b2d94e3 100644 --- a/packages/rest/src/diagnostics.ts +++ b/packages/rest/src/diagnostics.ts @@ -101,6 +101,24 @@ const libDefinition = { value: "statusCode value must be a three digit code between 100 and 599", }, }, + "content-type-string": { + severity: "error", + messages: { + default: "contentType parameter must be a string literal or union of string literals", + }, + }, + "duplicate-response": { + severity: "error", + messages: { + default: paramMessage`Multiple return types for content type ${"contentType"} and status code ${"statusCode"}`, + }, + }, + "content-type-ignored": { + severity: "warning", + messages: { + default: "content-type header ignored because return type has no body", + }, + }, }, } as const; diff --git a/packages/rest/src/http.ts b/packages/rest/src/http.ts index abcd4d95c4e..ac3fdbda44f 100644 --- a/packages/rest/src/http.ts +++ b/packages/rest/src/http.ts @@ -1,5 +1,7 @@ import { DecoratorContext, + ModelType, + ModelTypeProperty, Program, setDecoratorNamespace, Type, @@ -128,6 +130,14 @@ export function $statusCode({ program }: DecoratorContext, entity: Type) { } else { reportDiagnostic(program, { code: "status-code-invalid", target: entity }); } + setStatusCode(program, entity, codes); +} + +export function setStatusCode( + program: Program, + entity: ModelType | ModelTypeProperty, + codes: string[] +) { program.stateMap(statusCodeKey).set(entity, codes); } @@ -150,8 +160,8 @@ export function isStatusCode(program: Program, entity: Type) { return program.stateMap(statusCodeKey).has(entity); } -export function getStatusCodes(program: Program, entity: Type) { - return program.stateMap(statusCodeKey).get(entity); +export function getStatusCodes(program: Program, entity: Type): string[] { + return program.stateMap(statusCodeKey).get(entity) ?? []; } // Note: these descriptions come from https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html diff --git a/packages/rest/src/lib.ts b/packages/rest/src/lib.ts index d854de9710a..c1169037ff7 100644 --- a/packages/rest/src/lib.ts +++ b/packages/rest/src/lib.ts @@ -1,6 +1,7 @@ export * as http from "./http.js"; export * from "./resource.js"; export * as resource from "./resource.js"; +export * from "./responses.js"; export * from "./rest.js"; export * as rest from "./rest.js"; export * from "./route.js"; diff --git a/packages/rest/src/responses.ts b/packages/rest/src/responses.ts new file mode 100644 index 00000000000..517a9788448 --- /dev/null +++ b/packages/rest/src/responses.ts @@ -0,0 +1,281 @@ +import { + getDoc, + getIntrinsicModelName, + isErrorModel, + isIntrinsic, + isVoidType, + ModelType, + ModelTypeProperty, + OperationType, + Program, + Type, +} from "@cadl-lang/compiler"; +import { reportDiagnostic } from "./diagnostics.js"; +import { + getHeaderFieldName, + getStatusCodeDescription, + getStatusCodes, + isBody, + isHeader, + isStatusCode, +} from "./http.js"; + +export type StatusCode = `${number}` | "*"; +export interface HttpOperationResponse { + statusCode: StatusCode; + type: Type; + description?: string; + responses: HttpOperationResponseContent[]; +} + +export interface HttpOperationResponseContent { + headers?: Record; + body?: HttpOperationBody; +} + +export interface HttpOperationBody { + contentType: string; + type: Type; +} + +/** + * Get the responses for a given operation. + */ +export function getResponsesForOperation( + program: Program, + operation: OperationType +): HttpOperationResponse[] { + const responseType = operation.returnType; + const responses: Record = {}; + if (responseType.kind === "Union") { + for (const option of responseType.options) { + if (isNullType(program, option)) { + // TODO how should we treat this? https://github.com/microsoft/cadl/issues/356 + continue; + } + processResponseType(program, responses, option); + } + } else { + processResponseType(program, responses, responseType); + } + + return Object.values(responses); +} + +function isNullType(program: Program, type: Type): boolean { + return isIntrinsic(program, type) && getIntrinsicModelName(program, type) === "null"; +} + +function processResponseType( + program: Program, + responses: Record, + responseModel: Type +) { + // Get explicity defined status codes + const statusCodes: Array = getResponseStatusCodes(program, responseModel); + + // Get explicitly defined content types + const contentTypes = getResponseContentTypes(program, responseModel); + + // Get response headers + const headers = getResponseHeaders(program, responseModel); + + // Get explicitly defined body + let bodyModel = getResponseBody(program, responseModel); + // If there is no explicit body, it should be conjured from the return type + // if it is a primitive type or it contains more than just response metadata + if (!bodyModel) { + if (responseModel.kind === "Model") { + if (isIntrinsic(program, responseModel)) { + bodyModel = responseModel; + } else { + const isResponseMetadata = (p: ModelTypeProperty) => + isHeader(program, p) || isStatusCode(program, p); + const allProperties = (p: ModelType): ModelTypeProperty[] => { + return [...p.properties.values(), ...(p.baseModel ? allProperties(p.baseModel) : [])]; + }; + if (allProperties(responseModel).some((p) => !isResponseMetadata(p))) { + bodyModel = responseModel; + } + } + } else { + // body is array or possibly something else + bodyModel = responseModel; + } + } + + // If there is no explicit status code, check if it should be 204 + if (statusCodes.length === 0) { + if (bodyModel === undefined || isVoidType(bodyModel)) { + statusCodes.push("204"); + } else if (isErrorModel(program, responseModel)) { + statusCodes.push("*"); + } else { + statusCodes.push("200"); + } + } + + // If there is a body but no explicit content types, use application/json + if (bodyModel && !isVoidType(bodyModel) && contentTypes.length === 0) { + contentTypes.push("application/json"); + } + + // Put them into currentEndpoint.responses + for (const statusCode of statusCodes) { + // the first model for this statusCode/content type pair carries the + // description for the endpoint. This could probably be improved. + const response: HttpOperationResponse = responses[statusCode] ?? { + statusCode: statusCode, + type: responseModel, + description: getResponseDescription(program, responseModel, statusCode), + responses: [], + }; + + // check for duplicates + for (const contentType of contentTypes) { + if (response.responses.find((x) => x.body?.contentType === contentType)) { + reportDiagnostic(program, { + code: "duplicate-response", + format: { statusCode: statusCode.toString(), contentType }, + target: responseModel, + }); + } + } + + if (bodyModel !== undefined) { + for (const contentType of contentTypes) { + response.responses.push({ body: { contentType, type: bodyModel }, headers }); + } + } else if (contentTypes.length > 0) { + reportDiagnostic(program, { + code: "content-type-ignored", + target: responseModel, + }); + } else { + response.responses = [{ headers }]; + } + responses[statusCode] = response; + } +} + +/** + * Get explicity defined status codes from response Model + * Return is an array of strings, possibly empty, which indicates no explicitly defined status codes. + * We do not check for duplicates here -- that will be done by the caller. + */ +function getResponseStatusCodes(program: Program, responseModel: Type): string[] { + const codes: string[] = []; + if (responseModel.kind === "Model") { + if (responseModel.baseModel) { + codes.push(...getResponseStatusCodes(program, responseModel.baseModel)); + } + codes.push(...getStatusCodes(program, responseModel)); + for (const prop of responseModel.properties.values()) { + if (isStatusCode(program, prop)) { + codes.push(...getStatusCodes(program, prop)); + } + } + } + return codes; +} + +/** + * Get explicity defined content-types from response Model + * Return is an array of strings, possibly empty, which indicates no explicitly defined content-type. + * We do not check for duplicates here -- that will be done by the caller. + */ +function getResponseContentTypes(program: Program, responseModel: Type): string[] { + const contentTypes: string[] = []; + if (responseModel.kind === "Model") { + if (responseModel.baseModel) { + contentTypes.push(...getResponseContentTypes(program, responseModel.baseModel)); + } + for (const prop of responseModel.properties.values()) { + if (isHeader(program, prop) && getHeaderFieldName(program, prop) === "content-type") { + contentTypes.push(...getContentTypes(program, prop)); + } + } + } + return contentTypes; +} + +export function getContentTypes(program: Program, param: ModelTypeProperty): string[] { + if (param.type.kind === "String") { + return [param.type.value]; + } else if (param.type.kind === "Union") { + const contentTypes = []; + for (const option of param.type.options) { + if (option.kind === "String") { + contentTypes.push(option.value); + } else { + reportDiagnostic(program, { + code: "content-type-string", + target: param, + }); + continue; + } + } + + return contentTypes; + } + + reportDiagnostic(program, { code: "content-type-string", target: param }); + + return []; +} + +/** + * Get response headers from response Model + */ +function getResponseHeaders( + program: Program, + responseModel: Type +): Record { + if (responseModel.kind === "Model") { + const responseHeaders: any = responseModel.baseModel + ? getResponseHeaders(program, responseModel.baseModel) + : {}; + for (const prop of responseModel.properties.values()) { + const headerName = getHeaderFieldName(program, prop); + if (isHeader(program, prop) && headerName !== "content-type") { + responseHeaders[headerName] = prop; + } + } + return responseHeaders; + } + return {}; +} + +function getResponseBody(program: Program, responseModel: Type): Type | undefined { + if (responseModel.kind === "Model") { + const getAllBodyProps = (m: ModelType): ModelTypeProperty[] => { + const bodyProps = [...m.properties.values()].filter((t) => isBody(program, t)); + if (m.baseModel) { + bodyProps.push(...getAllBodyProps(m.baseModel)); + } + return bodyProps; + }; + const bodyProps = getAllBodyProps(responseModel); + if (bodyProps.length > 0) { + // Report all but first body as duplicate + for (const prop of bodyProps.slice(1)) { + reportDiagnostic(program, { code: "duplicate-body", target: prop }); + } + return bodyProps[0].type; + } + } + return undefined; +} + +function getResponseDescription( + program: Program, + responseModel: Type, + statusCode: string +): string | undefined { + const desc = getDoc(program, responseModel); + if (desc) { + return desc; + } + + return getStatusCodeDescription(statusCode); +} diff --git a/packages/rest/src/route.ts b/packages/rest/src/route.ts index a1ca379f005..14c16766764 100644 --- a/packages/rest/src/route.ts +++ b/packages/rest/src/route.ts @@ -19,6 +19,7 @@ import { HttpVerb, isBody, } from "./http.js"; +import { getResponsesForOperation, HttpOperationResponse } from "./responses.js"; import { getAction, getResourceOperation, getSegment } from "./rest.js"; export type OperationContainer = NamespaceType | InterfaceType; @@ -41,6 +42,7 @@ export interface OperationDetails { groupName: string; container: OperationContainer; parameters: HttpOperationParameters; + responses: HttpOperationResponse[]; operation: OperationType; } @@ -347,6 +349,7 @@ function buildRoutes( const route = getPathForOperation(program, op, parentFragments); const verb = getVerbForOperation(program, op, route.parameters); + const responses = getResponsesForOperation(program, op); operations.push({ path: route.path, pathFragment: route.pathFragment, @@ -355,6 +358,7 @@ function buildRoutes( groupName: container.name, parameters: route.parameters, operation: op, + responses, }); } diff --git a/packages/rest/test/test-responses.ts b/packages/rest/test/test-responses.ts new file mode 100644 index 00000000000..a689dfbb36d --- /dev/null +++ b/packages/rest/test/test-responses.ts @@ -0,0 +1,76 @@ +import { expectDiagnostics } from "@cadl-lang/compiler/testing"; +import { compileOperations } from "./test-host.js"; + +describe("cadl: rest: responses", () => { + it("issues diagnostics for duplicate body decorator", async () => { + const [_, diagnostics] = await compileOperations( + ` + model Foo { + foo: string; + } + model Bar { + bar: string; + } + @route("/") + namespace root { + @get + op read(): { @body body1: Foo, @body body2: Bar }; + } + ` + ); + expectDiagnostics(diagnostics, [{ code: "@cadl-lang/rest/duplicate-body" }]); + }); + + it("issues diagnostics for return type with duplicate status code", async () => { + const [_, diagnostics] = await compileOperations( + ` + model Foo { + foo: string; + } + model Error { + code: string; + } + @route("/") + namespace root { + @get + op read(): Foo | Error; + } + ` + ); + expectDiagnostics(diagnostics, { + code: "@cadl-lang/rest/duplicate-response", + message: "Multiple return types for content type application/json and status code 200", + }); + }); + + it("issues diagnostics for invalid content types", async () => { + const [_, diagnostics] = await compileOperations( + ` + model Foo { + foo: string; + } + + model TextPlain { + contentType: "text/plain"; + } + + namespace root { + @route("/test1") + @get + op test1(): { @header contentType: string, @body body: Foo }; + @route("/test2") + @get + op test2(): { @header contentType: 42, @body body: Foo }; + @route("/test3") + @get + op test3(): { @header contentType: "application/json" | TextPlain, @body body: Foo }; + } + ` + ); + expectDiagnostics(diagnostics, [ + { code: "@cadl-lang/rest/content-type-string" }, + { code: "@cadl-lang/rest/content-type-string" }, + { code: "@cadl-lang/rest/content-type-string" }, + ]); + }); +}); diff --git a/packages/samples/tags/tagged-operations.cadl b/packages/samples/tags/tagged-operations.cadl index e8b22b41827..1ea4af3764d 100644 --- a/packages/samples/tags/tagged-operations.cadl +++ b/packages/samples/tags/tagged-operations.cadl @@ -8,13 +8,13 @@ namespace Foo { @tag("tag1") @doc("includes namespace tag") @get - op read(@path id: int32): null; + op read(@path id: int32): void; @tag("tag1") @tag("tag2") @doc("includes namespace tag and two operations tags") @post - op create(@path id: int32): null; + op create(@path id: int32): void; } @route("/bar") @@ -26,7 +26,7 @@ namespace Bar { @doc("one operation tag") @tag("tag3") @post - op create(@path id: int32): null; + op create(@path id: int32): void; } @tag("outer") @@ -38,7 +38,7 @@ namespace NestedOuter { namespace NestedMoreInner { @tag("innerOp") @post - op createOther(@path id: int32): null; + op createOther(@path id: int32): void; } } } diff --git a/packages/samples/test/output/testserver/body-boolean/openapi.json b/packages/samples/test/output/testserver/body-boolean/openapi.json index 4ffd36236bd..5b2249d8365 100644 --- a/packages/samples/test/output/testserver/body-boolean/openapi.json +++ b/packages/samples/test/output/testserver/body-boolean/openapi.json @@ -157,9 +157,6 @@ } } }, - "204": { - "description": "No Content" - }, "default": { "description": "Error", "content": {