diff --git a/.chronus/changes/feat-response-doc-2025-11-22-13-13-51.md b/.chronus/changes/feat-response-doc-2025-11-22-13-13-51.md new file mode 100644 index 00000000000..d0fd76faec0 --- /dev/null +++ b/.chronus/changes/feat-response-doc-2025-11-22-13-13-51.md @@ -0,0 +1,8 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Parse inline doc comments. +Change formatter behavior to improve the readability of inline doc comments. diff --git a/.chronus/changes/feat-response-doc-2025-11-25-23-40-21.md b/.chronus/changes/feat-response-doc-2025-11-25-23-40-21.md new file mode 100644 index 00000000000..669d01ed556 --- /dev/null +++ b/.chronus/changes/feat-response-doc-2025-11-25-23-40-21.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/http" +--- + +Associate parsed inline doc comments with response descriptions. diff --git a/packages/compiler/src/core/parser.ts b/packages/compiler/src/core/parser.ts index e977aa02fe2..ac03dafb3db 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -417,6 +417,19 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return { pos, docs, directives, decorators }; } + function parseInlineDocComments(): { + pos: number; + docs: DocNode[]; + } { + const docs: DocNode[] = []; + const [pos, addedDocs] = parseDocList(); + for (const doc of addedDocs) { + docs.push(doc); + } + + return { pos, docs }; + } + function parseTypeSpecScriptItemList(): Statement[] { const stmts: Statement[] = []; let seenBlocklessNs = false; @@ -841,7 +854,9 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa if (token === Token.OpenParen) { const parameters = parseOperationParameters(); parseExpected(Token.Colon); - const returnType = parseExpression(); + // try to parse inline docs + const { docs } = parseInlineDocComments(); + const returnType = parseExpression(docs); signature = { kind: SyntaxKind.OperationSignatureDeclaration, @@ -1228,7 +1243,9 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa const { items: templateParameters, range: templateParametersRange } = parseTemplateParameterList(); parseExpected(Token.Equals); - const value = parseExpression(); + // try to parse inline docs + const { docs } = parseInlineDocComments(); + const value = parseExpression(docs); parseExpected(Token.Semicolon); return { kind: SyntaxKind.AliasStatement, @@ -1263,23 +1280,39 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return undefined; } - function parseExpression(): Expression { - return parseUnionExpressionOrHigher(); + function parseExpression(docs: DocNode[] = []): Expression { + return parseUnionExpressionOrHigher(docs); } - function parseUnionExpressionOrHigher(): Expression { - const pos = tokenPos(); + function parseUnionExpressionOrHigher(exprDocs: DocNode[]): Expression { parseOptional(Token.Bar); + // try to parse inline docs + const { docs: rideSideDocs, pos } = parseInlineDocComments(); + // doc comments right side of `|` take precedence over left side + // e.g. + // op foo: /** exprDocs */ | /** rideSideDocs */ MyModel; + const docs = rideSideDocs.length > 0 ? rideSideDocs : exprDocs; const node: Expression = parseIntersectionExpressionOrHigher(); + const expr = { + ...node, + docs, + pos, + }; if (token() !== Token.Bar) { - return node; + return expr; } - const options = [node]; + const options = [expr]; while (parseOptional(Token.Bar)) { + // try to parse inline docs + const { docs, pos } = parseInlineDocComments(); const expr = parseIntersectionExpressionOrHigher(); - options.push(expr); + options.push({ + ...expr, + docs, + pos, + }); } return { diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index fe9834ce1c4..8cdcbda79c6 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -22,6 +22,7 @@ import { EnumMemberNode, EnumSpreadMemberNode, EnumStatementNode, + Expression, FunctionDeclarationStatementNode, FunctionParameterNode, IdentifierNode, @@ -1267,12 +1268,29 @@ export function printNamespaceStatement( return [decorators, `namespace `, join(".", names), suffix]; } +function shouldWrapOperationSignatureDeclarationInNewLines(returnType: Expression) { + if ( + returnType.kind === SyntaxKind.TypeReference || + returnType.kind === SyntaxKind.ModelExpression + ) { + return returnType.docs && returnType.docs.length > 0; + } + return false; +} + export function printOperationSignatureDeclaration( path: AstPath, options: TypeSpecPrettierOptions, print: PrettierChildPrint, ) { - return ["(", path.call(print, "parameters"), "): ", path.call(print, "returnType")]; + const node = path.node; + let closeParenColon = "): "; + // if inline doc comments on return type, move to new line + const shouldAddNewLine = shouldWrapOperationSignatureDeclarationInNewLines(node.returnType); + if (shouldAddNewLine) { + closeParenColon = "):\n"; + } + return ["(", path.call(print, "parameters"), closeParenColon, path.call(print, "returnType")]; } export function printOperationSignatureReference( diff --git a/packages/compiler/test/formatter/formatter.test.ts b/packages/compiler/test/formatter/formatter.test.ts index 71434be8249..829404fcfda 100644 --- a/packages/compiler/test/formatter/formatter.test.ts +++ b/packages/compiler/test/formatter/formatter.test.ts @@ -766,6 +766,85 @@ op foo( }); }); }); + + describe("inline doc comments", () => { + it("single inline expression", async () => { + await assertFormat({ + code: ` +op foo(): /** inline doc comment */ { @statusCode _: 200; content: string; };`, + expected: ` +op foo(): +/** inline doc comment */ +{ + @statusCode _: 200; + content: string; +};`, + }); + }); + + it("single model statement", async () => { + await assertFormat({ + code: ` +model SuccessResponse { + @statusCode _: 200; + content: string; +} +op foo(): /** inline doc comment */ +SuccessResponse;`, + expected: ` +model SuccessResponse { + @statusCode _: 200; + content: string; +} +op foo(): +/** inline doc comment */ +SuccessResponse;`, + }); + }); + + it("union expression", async () => { + await assertFormat({ + code: ` +model Success201 { @statusCode _: 201; content: string; } +model Success204 { @statusCode _: 204; content: string; } +model Error400 { @statusCode _: 400; content: string; } + +op foo(): +/** 200 */ +{ @statusCode _: 200; content: string; } | +/** + * 201 + */ +Success201 | Success204 | Error400;`, + expected: ` +model Success201 { + @statusCode _: 201; + content: string; +} +model Success204 { + @statusCode _: 204; + content: string; +} +model Error400 { + @statusCode _: 400; + content: string; +} + +op foo(): + | /** 200 */ + { + @statusCode _: 200; + content: string; + } + | /** + * 201 + */ + Success201 + | Success204 + | Error400;`, + }); + }); + }); }); describe("scalar", () => { diff --git a/packages/compiler/test/parser.test.ts b/packages/compiler/test/parser.test.ts index 1e8d360c19d..0792b422f29 100644 --- a/packages/compiler/test/parser.test.ts +++ b/packages/compiler/test/parser.test.ts @@ -1091,6 +1091,82 @@ describe("compiler: parser", () => { strictEqual(comments[0].parsedAsDocs, true); }); + it("mark used block comment with parsedAsDocs at operation", () => { + const script = parse( + ` + op foo(): /** One-liner */ | + { bar: string }; + `, + { docs: true, comments: true }, + ); + const comments = script.comments; + strictEqual(comments[0].kind, SyntaxKind.BlockComment); + strictEqual(comments[0].parsedAsDocs, true); + }); + + describe("mark used block comment with parsedAsDocs at union", () => { + parseEach( + [ + [ + ` + op foo(): | /** First-union */ + { bar: string } | /** Second-union */ { baz: string }; + `, + (script) => { + const comments = script.comments; + strictEqual(comments[0].kind, SyntaxKind.BlockComment); + strictEqual(comments[0].parsedAsDocs, true); + strictEqual(comments[1].kind, SyntaxKind.BlockComment); + strictEqual(comments[1].parsedAsDocs, true); + }, + ], + [ + ` + alias MyResponse = /** First-union */ { bar: string } | /** Second-union */ { baz: string }; + op foo(): MyResponse; + `, + (script) => { + const comments = script.comments; + strictEqual(comments[0].kind, SyntaxKind.BlockComment); + strictEqual(comments[0].parsedAsDocs, true); + strictEqual(comments[1].kind, SyntaxKind.BlockComment); + strictEqual(comments[1].parsedAsDocs, true); + }, + ], + [ + ` + model Second { baz: string }; + union MyResponse { /** First-union */ { bar: string }; /** Second-union */ Second; }; + op foo(): MyResponse; + `, + (script) => { + const comments = script.comments; + strictEqual(comments[0].kind, SyntaxKind.BlockComment); + strictEqual(comments[0].parsedAsDocs, true); + strictEqual(comments[1].kind, SyntaxKind.BlockComment); + strictEqual(comments[1].parsedAsDocs, true); + }, + ], + [ + ` + /** This is Second model */ + model Second { baz: string }; + union MyResponse { /** First-union */ { bar: string }; /** Second-union */ Second; }; + op foo(): /** Override MyResponse union */ MyResponse | /** Override third-union */ { qux: string }; + `, + (script) => { + const comments = script.comments; + strictEqual(comments[0].kind, SyntaxKind.BlockComment); + strictEqual(comments[0].parsedAsDocs, true); + strictEqual(comments[1].kind, SyntaxKind.BlockComment); + strictEqual(comments[1].parsedAsDocs, true); + }, + ], + ], + { docs: true, comments: true }, + ); + }); + it("other comments are not marked with parsedAsDocs", () => { const script = parse( ` diff --git a/packages/http/src/responses.ts b/packages/http/src/responses.ts index c6b51553254..5468c92d2f8 100644 --- a/packages/http/src/responses.ts +++ b/packages/http/src/responses.ts @@ -1,7 +1,10 @@ import { + ArrayModelType, + compilerAssert, createDiagnosticCollector, Diagnostic, DiagnosticCollector, + DocContent, getDoc, getErrorsDoc, getReturnsDoc, @@ -14,6 +17,17 @@ import { Program, Type, } from "@typespec/compiler"; +import { + ArrayExpressionNode, + IntersectionExpressionNode, + ModelStatementNode, + Node, + OperationSignatureDeclarationNode, + SyntaxKind, + TypeReferenceNode, + UnionExpressionNode, + UnionStatementNode, +} from "@typespec/compiler/ast"; import { $ } from "@typespec/compiler/typekit"; import { getStatusCodeDescription, getStatusCodesWithDiagnostics } from "./decorators.js"; import { HttpProperty } from "./http-property.js"; @@ -33,6 +47,7 @@ export function getResponsesForOperation( const responseType = operation.returnType; const responses = new ResponseIndex(); const tk = $(program); + const inlineDocNodeTreeMap = generateInlineDocNodeTreeMap(program, operation); if (tk.union.is(responseType) && !tk.union.getDiscriminatedUnion(responseType)) { // Check if the union itself has a @doc to use as the response description const unionDescription = getDoc(program, responseType); @@ -47,11 +62,20 @@ export function getResponsesForOperation( operation, responses, option.type, + inlineDocNodeTreeMap, unionDescription, ); } } else { - processResponseType(program, diagnostics, operation, responses, responseType, undefined); + processResponseType( + program, + diagnostics, + operation, + responses, + responseType, + inlineDocNodeTreeMap, + undefined, + ); } return diagnostics.wrap(responses.values()); @@ -90,6 +114,7 @@ function processResponseType( operation: Operation, responses: ResponseIndex, responseType: Type, + inlineDocNodeTreeMap: InlineDocNodeTreeMap, parentDescription?: string, ) { const tk = $(program); @@ -111,6 +136,7 @@ function processResponseType( operation, responses, option.type, + inlineDocNodeTreeMap, unionDescription, ); } @@ -157,6 +183,7 @@ function processResponseType( responseType, statusCode, metadata, + inlineDocNodeTreeMap, parentDescription, ), responses: [], @@ -249,9 +276,21 @@ function getResponseDescription( responseType: Type, statusCode: HttpStatusCodes[number], metadata: HttpProperty[], + inlineDocNodeTreeMap: InlineDocNodeTreeMap, parentDescription?: string, ): string | undefined { - // If a parent union provided a description, use that first + // If an inline doc comment provided, use that first + const inlineDescription = getNearestInlineDescriptionFromOperationReturnTypeNode( + program, + operation, + inlineDocNodeTreeMap, + responseType.node, + ); + if (inlineDescription) { + return inlineDescription; + } + + // If a parent union provided a description, use that next if (parentDescription) { return parentDescription; } @@ -279,3 +318,262 @@ function getResponseDescription( return getStatusCodeDescription(statusCode); } + +/** + * Maps nodes to their semantic parents for tracling inline doc comment inheritance. + * It close to the concept of Concrete Syntax Tree (CST). + * + * Unlike AST parent relationships which reflect syntax structure, this map tracks + * semantic relationships after type resolution to enable proper doc comment + * inheritance through aliases, unions, and other TypeSpec constructs. + * + * The key is a {@link Node}, and the value is its semantic parent {@link Node} or `null` if none exists. + * It means that is a root {@link Node} if the value is `null`. + * + * NOTE: It's useful to change the type to a {@link Map} when you want to debug it. + */ +interface InlineDocNodeTreeMap extends WeakMap {} + +/** + * Collect inline doc comments from response type node by traversing the tree. + * This operation should do only once per operation due to it can traverse + * by the given {@link Operation.returnType}'s node. + */ +function generateInlineDocNodeTreeMap( + program: Program, + operation: Operation, +): InlineDocNodeTreeMap { + const node = getOperationReturnTypeNode(operation); + // set null to the parentNode explicitly to mark it as a root node + return traverseChild(program, new WeakMap(), node, null); +} + +/** + * If the {@link Operation.returnType} is an intrinsic type, and + * there is no {@link responseTypeNode} to start traversing, + * get the inline doc comment from the intrinsic type's node. + * + * @example + * ```typespec + * op read(): /** void type *\/ void; + * ``` + */ +function getInlineDescriptionFromOperationReturnTypeIntrinsic( + program: Program, + operation: Operation, + responseTypeNode?: Node, +): string | null { + const tk = $(program); + const returnTypeNode = getOperationReturnTypeNode(operation); + if (!responseTypeNode && returnTypeNode && tk.intrinsic.is(operation.returnType)) { + return getLastDocText(returnTypeNode); + } + return null; +} + +function getOperationReturnTypeNode(operation: Operation): Node | undefined { + let node = operation.returnType.node; + /** + * if the return type node of {@link operation} is a single type reference, which doesn't appear in AST + * about {@link operation.returnType.node} + * so we need to get the actual type reference node from {@link OperationSignatureDeclarationNode.returnType} + */ + if ( + operation.node?.kind === SyntaxKind.OperationStatement && + operation.node.signature.kind === SyntaxKind.OperationSignatureDeclaration + ) { + node = operation.node.signature.returnType; + } + return node; +} + +function traverseChild( + program: Program, + map: InlineDocNodeTreeMap, + node: Node | undefined, + parentNode: Node | null, +): InlineDocNodeTreeMap { + if (!node) return map; + switch (node.kind) { + case SyntaxKind.UnionExpression: + traverseUnionExpression(program, map, node, parentNode); + break; + case SyntaxKind.UnionStatement: + traverseUnionStatement(program, map, node, parentNode); + break; + case SyntaxKind.TypeReference: + traverseTypeReference(program, map, node, parentNode); + break; + case SyntaxKind.ArrayExpression: + traverseArrayExpression(program, map, node, parentNode); + break; + case SyntaxKind.IntersectionExpression: + traverseIntersectionExpression(program, map, node, parentNode); + break; + default: + map.set(node, parentNode); + break; + } + return map; +} + +/** + * This function traverse up the tree from the given resolved response type node + * which is the bottom of the traversal. + * Return the nearest inline description from the {@link Operation.returnType}'s node. + */ +function getNearestInlineDescriptionFromOperationReturnTypeNode( + program: Program, + operation: Operation, + map: InlineDocNodeTreeMap, + node?: Node, + nearestNodeHasDoc?: Node, +): string | null { + if (!node) { + return getInlineDescriptionFromOperationReturnTypeIntrinsic(program, operation, node); + } + const parentNode = map.get(node); + const nodeText = getLastDocText(node); + // if no parent, stop traversing and return the description + if (!parentNode) { + // if root node has no description, return the description + // from nearest node which could have inline doc comment + if (!nodeText && nearestNodeHasDoc) { + return getLastDocText(nearestNodeHasDoc); + } + // no parent and no nearest node with doc, return the description + // from current node which could have inline doc comment + return nodeText; + } + + const parentNodeText = getLastDocText(parentNode); + if (map.has(parentNode)) { + /** If parent node has no inline doc comment, and current node has inline doc comment, + * keep current node as {@link nearestNodeHasDoc} which could have inline doc comment */ + if (!parentNodeText && nodeText) { + return getNearestInlineDescriptionFromOperationReturnTypeNode( + program, + operation, + map, + parentNode, + node, + ); + } + /** keep {@link nearestNodeHasDoc} as nearest node which could have inline doc comment */ + return getNearestInlineDescriptionFromOperationReturnTypeNode( + program, + operation, + map, + parentNode, + nearestNodeHasDoc, + ); + } + return null; +} + +function traverseTypeReference( + program: Program, + map: InlineDocNodeTreeMap, + node: TypeReferenceNode, + parentNode: Node | null, +): void { + map.set(node, parentNode); + const type = program.checker.getTypeForNode(node); + + if (type.node) { + const parentNode = node; + traverseChild(program, map, type.node, parentNode); + } +} + +function traverseUnionExpression( + program: Program, + map: InlineDocNodeTreeMap, + node: UnionExpressionNode, + parentNode: Node | null, +): void { + for (const option of node.options) { + const node = option; + traverseChild(program, map, node, parentNode); + } +} + +function traverseUnionStatement( + program: Program, + map: InlineDocNodeTreeMap, + node: UnionStatementNode, + parentNode: Node | null, +): void { + for (const option of node.options) { + const node = option.value; + traverseChild(program, map, node, parentNode); + } +} + +function traverseArrayExpression( + program: Program, + map: InlineDocNodeTreeMap, + node: ArrayExpressionNode, + parentNode: Node | null, +): void { + map.set(node, parentNode); + /** + * {@link ArrayModelType} or {@link SyntaxKind.ArrayLiteral []} is a reference type, + * we need to resolve its original Array model + */ + const type = program.checker.getTypeForNode(node); + + if (type.node) { + const parentNode = node; + const childNode = type.node; + map.set(childNode, parentNode); + const grandChildNode = parentNode.elementType; + traverseChild(program, map, grandChildNode, childNode); + } +} + +function traverseIntersectionExpression( + program: Program, + map: InlineDocNodeTreeMap, + node: IntersectionExpressionNode, + parentNode: Node | null, +): void { + for (const option of node.options) { + const node = option; + traverseChild(program, map, node, parentNode); + } +} + +function getLastDocText(node: Node): string | null { + /** + * the doc node isn't an inline doc comment when it belongs to a {@link ModelStatementNode} + * this {@link isAllowedNodeKind} condition should be an allowlist for nodes which can have inline doc comments + */ + const isAllowedNodeKind = + node.kind !== SyntaxKind.TypeReference && + node.kind !== SyntaxKind.ModelExpression && + node.kind !== SyntaxKind.IntersectionExpression && + node.kind !== SyntaxKind.ArrayExpression && + node.kind !== SyntaxKind.VoidKeyword && + node.kind !== SyntaxKind.UnknownKeyword; + if (isAllowedNodeKind) return null; + const docs = node.docs; + if (!docs || docs.length === 0) return null; + const lastDoc = docs[docs.length - 1]; + return getDocContent(lastDoc.content); +} + +/** + * same as {@link file://./../../compiler/src/core/checker.ts getDocContent} + */ +function getDocContent(content: readonly DocContent[]) { + const docs = []; + for (const node of content) { + compilerAssert( + node.kind === SyntaxKind.DocText, + "No other doc content node kinds exist yet. Update this code appropriately when more are added.", + ); + docs.push(node.text); + } + return docs.join(""); +} diff --git a/packages/http/test/response-descriptions.test.ts b/packages/http/test/response-descriptions.test.ts index e64fe46cc8a..2b7f8241f8e 100644 --- a/packages/http/test/response-descriptions.test.ts +++ b/packages/http/test/response-descriptions.test.ts @@ -80,4 +80,417 @@ describe("http: response descriptions", () => { ); strictEqual(op.responses[0].description, "Explicit doc"); }); + + it("inline doc comments for an operation returnType", async () => { + const op = await getHttpOp( + ` + op read(): + /** 🍋 */ + { @statusCode _: 200, content: string }; + `, + ); + strictEqual(op.responses[0].description, "🍋"); + }); + + it("inline doc comments for an operation returnType with union expression", async () => { + const op = await getHttpOp( + ` + op read(): | + /** 🍌 */ + { @statusCode _: 200, content: string } | + /** 🍎 */ + { @statusCode _: 201, content: string } | + { @statusCode _: 202, content: string }; + `, + ); + strictEqual(op.responses[0].description, "🍌"); + strictEqual(op.responses[1].description, "🍎"); + strictEqual( + op.responses[2].description, + "The request has been accepted for processing, but processing has not yet completed.", + ); + }); + + it("inline doc comments for an operation returnType with an array", async () => { + const op = await getHttpOp( + ` + model Widget { + id: string; + + weight: int32; + color: "red" | "blue"; + } + op read(): /** List widgets */ Widget[]; + `, + ); + + strictEqual(op.responses[0].description, "List widgets"); + }); + + it("inline doc comments for an operation returnType with a void type", async () => { + const op = await getHttpOp( + ` + op read(): /** void type */ void; + `, + ); + + strictEqual(op.responses[0].description, "void type"); + }); + + it("inline doc comments for an operation returnType with a scalar", async () => { + const op = await getHttpOp( + ` + op read(): /** List widgets */ string; + `, + ); + + strictEqual(op.responses[0].description, "List widgets"); + }); + + it("inline doc comments for an operation returnType with union alias", async () => { + const op = await getHttpOp( + ` + /** 🥝 */ + model MySuccess200 { @statusCode _: 200, content: string }; + alias MyResponse = + /** 🌰 */ + MySuccess200 | + /** 🍇 */ + { @statusCode _: 201, content: string } | + { @statusCode _: 202, content: string }; + op read(): MyResponse; + `, + ); + strictEqual(op.responses[0].description, "🌰"); + strictEqual(op.responses[1].description, "🍇"); + strictEqual( + op.responses[2].description, + "The request has been accepted for processing, but processing has not yet completed.", + ); + }); + + it("limitation: inline doc comments for an operation returnType with an alias", async () => { + // NOTE: Inline doc comments are not valid + // when an alias statement points to a single existing node rather than creating a new collection. + // Due to syntax resolution, Response directly points to Widget, so the inline comment is ignored. + // This is a limitation of TypeSpec's syntax resolution system, where aliases to existing nodes + // don't create intermediate nodes that can carry inline doc. + const op = await getHttpOp( + ` + model Widget { + id: string; + + weight: int32; + color: "red" | "blue"; + } + alias Response = /** invalid */ Widget; + op read(): Response; + `, + ); + + strictEqual(op.responses[0].description, "The request has succeeded."); + }); + + it("inline doc comments and @doc and @returnsDoc for an operation returnType with union declaration", async () => { + const op = await getHttpOp( + ` + @doc("🍉") + union MyResponse { + /** 🥭 */ + { @statusCode _: 200, content: string }; + /** 🍍 */ + { @statusCode _: 201, content: string }; + { @statusCode _: 202, content: string }; + }; + @returnsDoc("✅") + op read(): MyResponse | + { @statusCode _: 400, content: string }; + `, + ); + strictEqual(op.responses[0].description, "🥭"); + strictEqual(op.responses[1].description, "🍍"); + strictEqual(op.responses[2].description, "🍉"); + strictEqual(op.responses[3].description, "✅"); + }); + + it("inline doc comments for an operation returnType with union declaration ovrrided", async () => { + const op = await getHttpOp( + ` + alias My400 = /** 🍎 */ { @statusCode _: 400, content: string }; + union My401 { + /** 🍐 */ + { @statusCode _: 401, content: string }; + }; + union My400_401 { + /** 🥭 */ + My400; + /** 🍍 */ + My401; + }; + union MyError { + /** 🍊 */ + My400_401; + } + @doc("🍉") + union MyResponse { + /** 🍌 */ + MyError; + { @statusCode _: 403, content: string }; + }; + op read(): + { @statusCode _: 200, content: string } | + /** 🍏 */ + MyResponse; + `, + ); + strictEqual(op.responses[0].description, "The request has succeeded."); + strictEqual(op.responses[1].description, "🍏"); + strictEqual(op.responses[2].description, "🍏"); + strictEqual(op.responses[3].description, "🍏"); + }); + + it("complex inline doc comments for an operation returnType with union alias", async () => { + const op = await getHttpOp( + ` + /** 🥝 */ + union My400_401 { + /** 🌰 */ + { @statusCode _: 400, content: string }; + /** 🍎 */ + { @statusCode _: 401, content: string }; + }; + alias _MyResponse = + /** 🍇 */ + { @statusCode _: 201, content: string } | + { @statusCode _: 202, content: string } | + My400_401; + alias MyResponse = _MyResponse | { @statusCode _: 200, content: string }; + op read(): MyResponse; + `, + ); + strictEqual(op.responses[0].description, "🍇"); + strictEqual( + op.responses[1].description, + "The request has been accepted for processing, but processing has not yet completed.", + ); + strictEqual(op.responses[2].description, "🌰"); + strictEqual(op.responses[3].description, "🍎"); + }); + + it("inline doc comments deeply nested aliases with mixed doc patterns", async () => { + const op = await getHttpOp( + ` + /** Base error doc */ + model BaseError { @statusCode _: 500, message: string } + + /** Custom 404 */ + alias NotFound = { @statusCode _: 404, error: string }; + + alias Level1 = + /** Level1 doc */ + NotFound | BaseError; + + alias Level2 = + /** Level2 doc */ + Level1 | { @statusCode _: 403, reason: string }; + + /** Top level union doc */ + union TopUnion { + /** Variant A doc */ + Level2; + /** Variant B doc */ + { @statusCode _: 429, retryAfter: string }; + } + + op test(): + /** Inline success doc */ + { @statusCode _: 200, data: string } | + TopUnion; + `, + ); + + strictEqual(op.responses[0].description, "Inline success doc"); + strictEqual(op.responses[1].description, "Variant A doc"); + strictEqual(op.responses[2].description, "Variant A doc"); + strictEqual(op.responses[3].description, "Variant A doc"); + strictEqual(op.responses[4].description, "Variant B doc"); + }); + + it("inline doc comments circular alias references with docs", async () => { + const op = await getHttpOp( + ` + alias ErrorA = + /** Error A doc */ + { @statusCode _: 400, type: "A" } | ErrorB; + + alias ErrorB = + /** Error B doc */ + { @statusCode _: 401, type: "B" } | ErrorC; + + alias ErrorC = + /** Error C doc */ + { @statusCode _: 402, type: "C" }; + + op test(): + { @statusCode _: 200, success: true } | ErrorA; + `, + ); + + strictEqual(op.responses[0].description, "The request has succeeded."); + strictEqual(op.responses[1].description, "Error A doc"); + strictEqual(op.responses[2].description, "Error B doc"); + strictEqual(op.responses[3].description, "Error C doc"); + }); + + it("multiple inline doc comments at same level", async () => { + const op = await getHttpOp( + ` + /** First doc */ + /** Second doc */ + /** Third doc */ + model Response200 { @statusCode _: 200, data: string } + + op test(): + /** Inline first */ + /** Inline second */ + Response200 | + /** Error doc 1 */ + /** Error doc 2 */ + { @statusCode _: 400, error: string }; + `, + ); + + strictEqual(op.responses[0].description, "Inline second"); + strictEqual(op.responses[1].description, "Error doc 2"); + }); + + it("inline doc comments with intersections", async () => { + const op = await getHttpOp( + ` + /** Base response doc */ + model SuccessResponse200 { @statusCode _: 200 } + model SuccessResponse201 { @statusCode _: 201 } + + /** Data mixin doc */ + model DataMixin { data: string } + + alias IntersectionResponse = + /** Intersection doc */ + SuccessResponse200 & DataMixin; + + op test(): + IntersectionResponse | + /** Intersection inline doc */ + (SuccessResponse201 & DataMixin) | + { @statusCode _: 400, error: string }; + `, + ); + + strictEqual(op.responses[0].description, "Intersection doc"); + strictEqual(op.responses[1].description, "Intersection inline doc"); + strictEqual( + op.responses[2].description, + "The server could not understand the request due to invalid syntax.", + ); + }); + + it("inline doc comments extreme nesting stress test", async () => { + const op = await getHttpOp( + ` + /** L0 doc */ + alias L0 = { @statusCode _: 200, l0: true }; + + /** L1 doc */ + alias L1 = L0 | { @statusCode _: 201, l1: true }; + + /** L2 doc */ + alias L2 = L1 | { @statusCode _: 202, l2: true }; + + /** L3 doc */ + alias L3 = L2 | { @statusCode _: 203, l3: true }; + + /** L4 doc */ + alias L4 = L3 | { @statusCode _: 204, l4: true }; + + /** L5 doc */ + alias L5 = L4 | { @statusCode _: 205, l5: true }; + + op test(): + /** Final doc */ + L5 | { @statusCode _: 400, error: string }; + `, + ); + + strictEqual(op.responses[0].description, "Final doc"); + strictEqual(op.responses[1].description, "Final doc"); + strictEqual(op.responses[2].description, "Final doc"); + strictEqual(op.responses[3].description, "Final doc"); + strictEqual(op.responses[4].description, "Final doc"); + strictEqual(op.responses[5].description, "Final doc"); + strictEqual( + op.responses[6].description, + "The server could not understand the request due to invalid syntax.", + ); + }); + + // it.only("common", async () => { + // const op = await getHttpOp( + // ` + // model ResponseMeta { + // status?: integer; + // } + + // model SuccessResponse { + // meta: ResponseMeta; + // data: T; + // } + // op read(): /** 📦 */ SuccessResponse<{ content: string }>; + // `, + // ); + + // strictEqual(op.responses[0].description, "📦"); + // }); + + it("ordering check", async () => { + const docs = [ + "/** 1 */", + '@@doc(ReadRespose, "2");', + '@doc("3")', + "/** 4 */", + '@doc("5")', + "/** 6 */", + '@returnsDoc("7")', + ]; + for (const [index, doc] of docs.entries()) { + /** + * This test should keep in sync with the example in the documentation + * {@link file://./../../../website/src/content/docs/docs/getting-started/typespec-for-openapi-dev.md#description-ordering} + */ + const code = ` + ${docs[5]} + ${docs[4]} + model SuccessResponse { + @statusCode _: 200; + content: string; + } + + ${docs[3]} + ${docs[2]} + union ReadRespose { + SuccessResponse; + } + + ${docs[1]} + + ${docs[6]} + op read(): ${docs[0]} + ReadRespose; + `; + const op = await getHttpOp(code); + const description = String(index + 1); + strictEqual(op.responses[0].description, description); + // comment out current doc to test the next one + docs[index] = `// ${doc}`; + } + }); }); diff --git a/website/src/content/docs/docs/getting-started/typespec-for-openapi-dev.md b/website/src/content/docs/docs/getting-started/typespec-for-openapi-dev.md index ca02a4d3ac5..1b8891a50e3 100644 --- a/website/src/content/docs/docs/getting-started/typespec-for-openapi-dev.md +++ b/website/src/content/docs/docs/getting-started/typespec-for-openapi-dev.md @@ -456,14 +456,14 @@ elements common to both. The fields in an OpenAPI response object are specified with the following TypeSpec constructs: -| OpenAPI `response` field | TypeSpec construct | Notes | -| ------------------------ | --------------------------------------------------- | --------------------------------------------------- | -| `description` | `@doc` decorator | | -| `headers` | fields in the return type with `@header` decorator | Required or optional based on optionality of field. | -| `schema` (OAS2) | return type or type of `@body`` property | | -| `content` (OAS3) | return type or type of `@body`` property | | -| `examples` (OAS3) | `@opExample` to describe return types of operations | Supported on an operation. | -| `links` (OAS3) | | Not currently supported. | +| OpenAPI `response` field | TypeSpec construct | Notes | +| ------------------------ | --------------------------------------------------------------------- | --------------------------------------------------- | +| `description` | `/** */` or `@doc`, `@@doc`, `@returnsDoc` and `@errorsDoc` decorator | see [Description ordering](#description-ordering) | +| `headers` | fields in the return type with `@header` decorator | Required or optional based on optionality of field. | +| `schema` (OAS2) | return type or type of `@body`` property | | +| `content` (OAS3) | return type or type of `@body`` property | | +| `examples` (OAS3) | `@opExample` to describe return types of operations | Supported on an operation. | +| `links` (OAS3) | | Not currently supported. | ```typespec @get op read(@path id: string): { @@ -495,6 +495,110 @@ namespace ResponseContent { } ``` +### Description ordering + +In TypeSpec, you can use various ways to configure the `description`, which is one of the OpenAPI `response` fields. +Here is an example of a documentation feature ordering, which will be the real output of the `description` field in the `response` for an operation. + +1. An [Inline doc comment](#inline-doc-comment) +1. A `@@doc` [Augment decorators](../language-basics/decorators.md#augmenting-decorators) +1. A [`@doc`](../standard-library/built-in-decorators.md#@doc) decorator on [Union](../language-basics/unions.md) +1. A [`/** */`](../language-basics/documentation.md#comments) doc comment on [Unions](../language-basics/unions.md) +1. A [`@doc`](../standard-library/built-in-decorators.md#@doc) decorator on [Models](../language-basics/models.md) +1. A [`/** */`](../language-basics/documentation.md#comments) doc comment on [Models](../language-basics/models.md) +1. A [`@returnsDoc`](../standard-library/built-in-decorators.md#@returnsDoc) on [Operations](../language-basics/operations.md) + + +```typespec +/** 6 */ +@doc("5") +model SuccessResponse { + @statusCode _: 200; + content: string; +} + +/** 4 */ +@doc("3") +union ReadRespose { + SuccessResponse, +} + +@@doc(ReadRespose, "2"); + +@returnsDoc("7") +op read(): +/** 1 */ +ReadRespose; +``` + +results in + +```yaml title=openapi.yaml +# ... + responses: + '200': + description: '1' + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' +components: + schemas: + SuccessResponse: + type: object + required: + - content + properties: + content: + type: string + description: '5' +``` + +### Inline doc comment + +Inline [`/** */`](../language-basics/documentation.md#comments) doc comments provide more flexibility to configure the `description` field in the `response`. + + +```typespec +op getUser(@path id: string): +| /** User details retrieved successfully. */ + { + @statusCode _: 200; + @body body: { + id: string; + name: string; + } + } +| /** User not found. */ + { + @statusCode _: 404; + }; +``` + +results in + +```yaml title=openapi.yaml +# ... + responses: + '200': + description: User details retrieved successfully. + content: + application/json: + schema: + type: object + properties: + id: + type: string + name: + type: string + required: + - id + - name + '404': + description: User not found. +components: {} +``` + ## Schema Object OpenAPI schemas are represented in TypeSpec by [models](https://typespec.io/docs/language-basics/models/).