Feature: Add support for `@returns` and `@errors` doc comment tags (#2436)

fix  #2384
This commit is contained in:
Timothee Guerin 2023-09-25 14:33:18 -07:00 коммит произвёл GitHub
Родитель 8415d52842
Коммит 9a2a1bf771
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
16 изменённых файлов: 554 добавлений и 23 удалений

Просмотреть файл

@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@typespec/compiler",
"comment": "Add support for `@returns` and `@errors` doc comment tags. `@returns`(or `@returnsDoc` decorator) can be used to describe the success return types of an operation. `@errors`(or `@errorsDoc` decorator) can be used to describe the error return types of an operation.",
"type": "none"
}
],
"packageName": "@typespec/compiler"
}

Просмотреть файл

@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@typespec/http",
"comment": "Add support for `@returns` and `@errors` doc comment tags.",
"type": "none"
}
],
"packageName": "@typespec/http"
}

Просмотреть файл

@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@typespec/openapi3",
"comment": "Add support for `@returns` and `@errors` doc comment tags.",
"type": "none"
}
],
"packageName": "@typespec/openapi3"
}

Просмотреть файл

@ -158,6 +158,32 @@ message: string;
```
### `@errorsDoc` {#@errorsDoc}
Attach a documentation string to describe the error return types of an operation.
If an operation returns a union of success and errors it only describe the errors. See `@errorsDoc` for success documentation.
```typespec
@errorsDoc(doc: valueof string)
```
#### Target
`Operation`
#### Parameters
| Name | Type | Description |
|------|------|-------------|
| doc | `valueof scalar string` | Documentation string |
#### Examples
```typespec
@errorsDoc("Returns doc")
op get(): Pet | NotFound;
```
### `@format` {#@format}
Specify a known data format hint for this string type. For example `uuid`, `uri`, etc.
@ -631,6 +657,32 @@ expireAt: int32;
```
### `@returnsDoc` {#@returnsDoc}
Attach a documentation string to describe the successful return types of an operation.
If an operation returns a union of success and errors it only describe the success. See `@errorsDoc` for error documentation.
```typespec
@returnsDoc(doc: valueof string)
```
#### Target
`Operation`
#### Parameters
| Name | Type | Description |
|------|------|-------------|
| doc | `valueof scalar string` | Documentation string |
#### Examples
```typespec
@returnsDoc("Returns doc")
op get(): Pet | NotFound;
```
### `@returnTypeVisibility` {#@returnTypeVisibility}
Sets which visibilities apply to the return type for the given operation.

Просмотреть файл

@ -29,6 +29,32 @@ extern dec summary(target: unknown, summary: valueof string);
*/
extern dec doc(target: unknown, doc: valueof string, formatArgs?: {});
/**
* Attach a documentation string to describe the successful return types of an operation.
* If an operation returns a union of success and errors it only describe the success. See `@errorsDoc` for error documentation.
* @param doc Documentation string
*
* @example
* ```typespec
* @returnsDoc("Returns doc")
* op get(): Pet | NotFound;
* ```
*/
extern dec returnsDoc(target: Operation, doc: valueof string);
/**
* Attach a documentation string to describe the error return types of an operation.
* If an operation returns a union of success and errors it only describe the errors. See `@errorsDoc` for success documentation.
* @param doc Documentation string
*
* @example
* ```typespec
* @errorsDoc("Returns doc")
* op get(): Pet | NotFound;
* ```
*/
extern dec errorsDoc(target: Operation, doc: valueof string);
/**
* Mark this type as deprecated.
*

Просмотреть файл

@ -3179,10 +3179,7 @@ export function createChecker(program: Program): Checker {
) {
const doc = extractParamDoc(prop.parent.parent.parent, type.name);
if (doc) {
type.decorators.unshift({
decorator: $docFromComment,
args: [{ value: createLiteralType(doc), jsValue: doc }],
});
type.decorators.unshift(createDocFromCommentDecorator("self", doc));
}
}
finishType(type);
@ -3193,6 +3190,16 @@ export function createChecker(program: Program): Checker {
return type;
}
function createDocFromCommentDecorator(key: "self" | "returns" | "errors", doc: string) {
return {
decorator: $docFromComment,
args: [
{ value: createLiteralType(key), jsValue: key },
{ value: createLiteralType(doc), jsValue: doc },
],
};
}
function isValueType(type: Type): boolean {
if (type === nullType) {
return true;
@ -3439,10 +3446,16 @@ export function createChecker(program: Program): Checker {
// Doc comment should always be the first decorator in case an explicit @doc must override it.
const docComment = extractMainDoc(targetType);
if (docComment) {
decorators.unshift({
decorator: $docFromComment,
args: [{ value: createLiteralType(docComment), jsValue: docComment }],
});
decorators.unshift(createDocFromCommentDecorator("self", docComment));
}
if (targetType.kind === "Operation") {
const returnTypesDocs = extractReturnsDocs(targetType);
if (returnTypesDocs.returns) {
decorators.unshift(createDocFromCommentDecorator("returns", returnTypesDocs.returns));
}
if (returnTypesDocs.errors) {
decorators.unshift(createDocFromCommentDecorator("errors", returnTypesDocs.errors));
}
}
return decorators;
}
@ -5827,6 +5840,30 @@ function extractMainDoc(type: Type): string | undefined {
return trimmed === "" ? undefined : trimmed;
}
function extractReturnsDocs(type: Type): {
returns: string | undefined;
errors: string | undefined;
} {
const result: { returns: string | undefined; errors: string | undefined } = {
returns: undefined,
errors: undefined,
};
if (type.node?.docs === undefined) {
return result;
}
for (const doc of type.node.docs) {
for (const tag of doc.tags) {
if (tag.kind === SyntaxKind.DocReturnsTag) {
result.returns = getDocContent(tag.content);
}
if (tag.kind === SyntaxKind.DocErrorsTag) {
result.errors = getDocContent(tag.content);
}
}
}
return result;
}
function extractParamDoc(node: OperationStatementNode, paramName: string): string | undefined {
if (node.docs === undefined) {
return undefined;

Просмотреть файл

@ -27,6 +27,7 @@ import {
DirectiveArgument,
DirectiveExpressionNode,
DocContent,
DocErrorsTagNode,
DocNode,
DocParamTagNode,
DocReturnsTagNode,
@ -2399,7 +2400,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa
}
type ParamLikeTag = DocTemplateTagNode | DocParamTagNode;
type SimpleTag = DocReturnsTagNode | DocUnknownTagNode;
type SimpleTag = DocReturnsTagNode | DocErrorsTagNode | DocUnknownTagNode;
function parseDocTag(): DocTag {
const pos = tokenPos();
@ -2413,6 +2414,8 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa
case "return":
case "returns":
return parseDocSimpleTag(pos, tagName, SyntaxKind.DocReturnsTag);
case "errors":
return parseDocSimpleTag(pos, tagName, SyntaxKind.DocErrorsTag);
default:
return parseDocSimpleTag(pos, tagName, SyntaxKind.DocUnknownTag);
}
@ -3127,6 +3130,7 @@ export function visitChildren<T>(node: Node, cb: NodeCallback<T>): T | undefined
visitNode(cb, node.tagName) || visitNode(cb, node.paramName) || visitEach(cb, node.content)
);
case SyntaxKind.DocReturnsTag:
case SyntaxKind.DocErrorsTag:
case SyntaxKind.DocUnknownTag:
return visitNode(cb, node.tagName) || visitEach(cb, node.content);

Просмотреть файл

@ -752,6 +752,7 @@ export enum SyntaxKind {
DocText,
DocParamTag,
DocReturnsTag,
DocErrorsTag,
DocTemplateTag,
DocUnknownTag,
Projection,
@ -1558,7 +1559,12 @@ export interface DocTagBaseNode extends BaseNode {
readonly content: readonly DocContent[];
}
export type DocTag = DocReturnsTagNode | DocParamTagNode | DocTemplateTagNode | DocUnknownTagNode;
export type DocTag =
| DocReturnsTagNode
| DocErrorsTagNode
| DocParamTagNode
| DocTemplateTagNode
| DocUnknownTagNode;
export type DocContent = DocTextNode;
export interface DocTextNode extends BaseNode {
@ -1570,6 +1576,10 @@ export interface DocReturnsTagNode extends DocTagBaseNode {
readonly kind: SyntaxKind.DocReturnsTag;
}
export interface DocErrorsTagNode extends DocTagBaseNode {
readonly kind: SyntaxKind.DocErrorsTag;
}
export interface DocParamTagNode extends DocTagBaseNode {
readonly kind: SyntaxKind.DocParamTag;
readonly paramName: IdentifierNode;

Просмотреть файл

@ -347,6 +347,7 @@ export function printNode(
case SyntaxKind.DocParamTag:
case SyntaxKind.DocTemplateTag:
case SyntaxKind.DocReturnsTag:
case SyntaxKind.DocErrorsTag:
case SyntaxKind.DocUnknownTag:
// https://github.com/microsoft/typespec/issues/1319 Tracks pretty-printing doc comments.
compilerAssert(

Просмотреть файл

@ -77,6 +77,10 @@ export function getSummary(program: Program, type: Type): string | undefined {
}
const docsKey = createStateSymbol("docs");
const returnsDocsKey = createStateSymbol("returnsDocs");
const errorsDocsKey = createStateSymbol("errorDocs");
type DocTarget = "self" | "returns" | "errors";
export interface DocData {
/**
* Doc value.
@ -88,7 +92,7 @@ export interface DocData {
* - `@doc` means the `@doc` decorator was used
* - `comment` means it was set from a `/** comment * /`
*/
source: "@doc" | "comment";
source: "decorator" | "comment";
}
/**
* @doc attaches a documentation string. Works great with multi-line string literals.
@ -103,19 +107,50 @@ export function $doc(context: DecoratorContext, target: Type, text: string, sour
if (sourceObject) {
text = replaceTemplatedStringFromProperties(text, sourceObject);
}
setDocData(context.program, target, { value: text, source: "@doc" });
setDocData(context.program, target, "self", { value: text, source: "decorator" });
}
/**
* @internal to be used to set the `@doc` from doc comment.
*/
export function $docFromComment(context: DecoratorContext, target: Type, text: string) {
setDocData(context.program, target, { value: text, source: "comment" });
export function $docFromComment(
context: DecoratorContext,
target: Type,
key: DocTarget,
text: string
) {
setDocData(context.program, target, key, { value: text, source: "comment" });
}
function setDocData(program: Program, target: Type, data: DocData) {
program.stateMap(docsKey).set(target, data);
function getDocKey(target: DocTarget): symbol {
switch (target) {
case "self":
return docsKey;
case "returns":
return returnsDocsKey;
case "errors":
return errorsDocsKey;
}
}
function setDocData(program: Program, target: Type, key: DocTarget, data: DocData) {
program.stateMap(getDocKey(key)).set(target, data);
}
/**
* Get the documentation information for the given type. In most cases you probably just want to use {@link getDoc}
* @param program Program
* @param target Type
* @returns Doc data with source information.
*/
export function getDocDataInternal(
program: Program,
target: Type,
key: DocTarget
): DocData | undefined {
return program.stateMap(getDocKey(key)).get(target);
}
/**
* Get the documentation information for the given type. In most cases you probably just want to use {@link getDoc}
* @param program Program
@ -123,7 +158,7 @@ function setDocData(program: Program, target: Type, data: DocData) {
* @returns Doc data with source information.
*/
export function getDocData(program: Program, target: Type): DocData | undefined {
return program.stateMap(docsKey).get(target);
return getDocDataInternal(program, target, "self");
}
/**
@ -133,7 +168,57 @@ export function getDocData(program: Program, target: Type): DocData | undefined
* @returns Documentation value
*/
export function getDoc(program: Program, target: Type): string | undefined {
return getDocData(program, target)?.value;
return getDocDataInternal(program, target, "self")?.value;
}
export function $returnsDoc(context: DecoratorContext, target: Operation, text: string) {
validateDecoratorUniqueOnNode(context, target, $doc);
setDocData(context.program, target, "returns", { value: text, source: "decorator" });
}
/**
* Get the documentation information for the return success types of an operation. In most cases you probably just want to use {@link getReturnsDoc}
* @param program Program
* @param target Type
* @returns Doc data with source information.
*/
export function getReturnsDocData(program: Program, target: Operation): DocData | undefined {
return getDocDataInternal(program, target, "returns");
}
/**
* Get the documentation string for the return success types of an operation.
* @param program Program
* @param target Type
* @returns Documentation value
*/
export function getReturnsDoc(program: Program, target: Operation): string | undefined {
return getDocDataInternal(program, target, "returns")?.value;
}
export function $errorsDoc(context: DecoratorContext, target: Operation, text: string) {
validateDecoratorUniqueOnNode(context, target, $doc);
setDocData(context.program, target, "errors", { value: text, source: "decorator" });
}
/**
* Get the documentation information for the return errors types of an operation. In most cases you probably just want to use {@link getErrorsDoc}
* @param program Program
* @param target Type
* @returns Doc data with source information.
*/
export function getErrorsDocData(program: Program, target: Operation): DocData | undefined {
return getDocDataInternal(program, target, "errors");
}
/**
* Get the documentation string for the return errors types of an operation.
* @param program Program
* @param target Type
* @returns Documentation value
*/
export function getErrorsDoc(program: Program, target: Operation): string | undefined {
return getDocDataInternal(program, target, "errors")?.value;
}
export function $inspectType(program: Program, target: Type, text: string) {

Просмотреть файл

@ -65,7 +65,7 @@ function getSymbolDocumentation(program: Program, symbol: Sym) {
const type = symbol.type ?? program.checker.getTypeForNode(symbol.declarations[0]);
const apiDocs = getDocData(program, type);
// The doc comment is already included above we don't want to duplicate
if (apiDocs && apiDocs.source === "@doc") {
if (apiDocs && apiDocs.source === "comment") {
docs.push(apiDocs.value);
}

Просмотреть файл

@ -1,6 +1,6 @@
import { ok, strictEqual } from "assert";
import { Model, Operation } from "../../src/core/index.js";
import { getDoc } from "../../src/lib/decorators.js";
import { getDoc, getErrorsDoc, getReturnsDoc } from "../../src/lib/decorators.js";
import { BasicTestRunner, createTestRunner } from "../../src/testing/index.js";
describe("compiler: checker: doc comments", () => {
@ -142,6 +142,94 @@ describe("compiler: checker: doc comments", () => {
});
});
describe("@returns", () => {
it("set the returnsDoc on an operation", async () => {
const { test } = (await runner.compile(`
/**
* @returns A string
*/
@test op test(): string;
`)) as { test: Operation };
strictEqual(getReturnsDoc(runner.program, test), "A string");
});
it("@returnsDoc decorator override the doc comment", async () => {
const { test } = (await runner.compile(`
/**
* @returns A string
*/
@returnsDoc("Another string")
@test op test(): string;
`)) as { test: Operation };
strictEqual(getReturnsDoc(runner.program, test), "Another string");
});
it("doc comment on op is override the base comment", async () => {
const { test } = (await runner.compile(`
/**
* @returns A string
*/
op base(): string;
/**
* @returns Another string
*/
@test op test(): string;
`)) as { test: Operation };
strictEqual(getReturnsDoc(runner.program, test), "Another string");
});
});
describe("@errors", () => {
it("set the errorsDoc on an operation", async () => {
const { test } = (await runner.compile(`
/**
* @errors A string
*/
@test op test(): string;
`)) as { test: Operation };
strictEqual(getErrorsDoc(runner.program, test), "A string");
});
it("@errorsDoc decorator override the doc comment", async () => {
const { test } = (await runner.compile(`
/**
* @errors A string
*/
@errorsDoc("Another string")
@test op test(): string;
`)) as { test: Operation };
strictEqual(getErrorsDoc(runner.program, test), "Another string");
});
it("doc comment on op is override the base comment", async () => {
const { test } = (await runner.compile(`
/**
* @errors A string
*/
op base(): string;
/**
* @errors Another string
*/
@test op test(): string;
`)) as { test: Operation };
strictEqual(getErrorsDoc(runner.program, test), "Another string");
});
});
it("using @param in doc comment of operation applies doc on the parameters", async () => {
const { addUser } = (await runner.compile(`

Просмотреть файл

@ -3,11 +3,13 @@ import { Model, Operation, Scalar, getVisibility, isSecret } from "../../src/ind
import {
getDoc,
getEncode,
getErrorsDoc,
getFriendlyName,
getKeyName,
getKnownValues,
getOverloadedOperation,
getOverloads,
getReturnsDoc,
isErrorModel,
} from "../../src/lib/decorators.js";
import { BasicTestRunner, createTestRunner, expectDiagnostics } from "../../src/testing/index.js";
@ -139,6 +141,60 @@ describe("compiler: built-in decorators", () => {
});
});
describe("@returnsDoc", () => {
it("applies @returnsDoc on operation", async () => {
const { test } = (await runner.compile(
`
@test
@returnsDoc("A string")
op test(): string;
`
)) as { test: Operation };
strictEqual(getReturnsDoc(runner.program, test), "A string");
});
it("emit diagnostic if doc is not a string", async () => {
const diagnostics = await runner.diagnose(`
@test
@returnsDoc(123)
op test(): string;
`);
expectDiagnostics(diagnostics, {
code: "invalid-argument",
message: `Argument '123' is not assignable to parameter of type 'valueof string'`,
});
});
});
describe("@errorsDoc", () => {
it("applies @errorsDoc on operation", async () => {
const { test } = (await runner.compile(
`
@test
@errorsDoc("An error")
op test(): string;
`
)) as { test: Operation };
strictEqual(getErrorsDoc(runner.program, test), "An error");
});
it("emit diagnostic if doc is not a string", async () => {
const diagnostics = await runner.diagnose(`
@test
@errorsDoc(123)
op test(): string;
`);
expectDiagnostics(diagnostics, {
code: "invalid-argument",
message: `Argument '123' is not assignable to parameter of type 'valueof string'`,
});
});
});
describe("@friendlyName", () => {
it("applies @friendlyName on model", async () => {
const { A, B, C } = await runner.compile(`

Просмотреть файл

@ -3,6 +3,8 @@ import {
Diagnostic,
DiagnosticCollector,
getDoc,
getErrorsDoc,
getReturnsDoc,
isArrayModelType,
isErrorModel,
isNullType,
@ -43,10 +45,10 @@ export function getResponsesForOperation(
// TODO how should we treat this? https://github.com/microsoft/typespec/issues/356
continue;
}
processResponseType(program, diagnostics, responses, option.type);
processResponseType(program, diagnostics, operation, responses, option.type);
}
} else {
processResponseType(program, diagnostics, responses, responseType);
processResponseType(program, diagnostics, operation, responses, responseType);
}
return diagnostics.wrap(Object.values(responses));
@ -55,6 +57,7 @@ export function getResponsesForOperation(
function processResponseType(
program: Program,
diagnostics: DiagnosticCollector,
operation: Operation,
responses: Record<string, HttpOperationResponse>,
responseType: Type
) {
@ -96,7 +99,7 @@ function processResponseType(
const response: HttpOperationResponse = responses[statusCode] ?? {
statusCode,
type: responseType,
description: getResponseDescription(program, responseType, statusCode, bodyType),
description: getResponseDescription(program, operation, responseType, statusCode, bodyType),
responses: [],
};
@ -223,6 +226,7 @@ function getResponseBody(
function getResponseDescription(
program: Program,
operation: Operation,
responseType: Type,
statusCode: string,
bodyType: Type | undefined
@ -241,5 +245,12 @@ function getResponseDescription(
}
}
const desc = isErrorModel(program, responseType)
? getErrorsDoc(program, operation)
: getReturnsDoc(program, operation);
if (desc) {
return desc;
}
return getStatusCodeDescription(statusCode);
}

Просмотреть файл

@ -0,0 +1,68 @@
import { expectDiagnosticEmpty } from "@typespec/compiler/testing";
import { strictEqual } from "assert";
import { getOperationsWithServiceNamespace } from "./test-host.js";
describe("http: response descriptions", () => {
async function getHttpOp(code: string) {
const [ops, diagnostics] = await getOperationsWithServiceNamespace(code);
expectDiagnosticEmpty(diagnostics);
strictEqual(ops.length, 1);
return ops[0];
}
it("use a default message by status code if not specified", async () => {
const op = await getHttpOp(
`
op read(): {@statusCode _: 200, content: string};
`
);
strictEqual(op.responses[0].description, "The request has succeeded.");
});
it("@returns set doc for all success responses", async () => {
const op = await getHttpOp(
`
@error model Error {}
@returnsDoc("A string")
op read(): { @statusCode _: 200, content: string } | { @statusCode _: 201, content: string } | Error;
`
);
strictEqual(op.responses[0].description, "A string");
strictEqual(op.responses[1].description, "A string");
strictEqual(op.responses[2].description, undefined);
});
it("@errors set doc for all success responses", async () => {
const op = await getHttpOp(
`
@error model Error {}
@errorsDoc("Generic error")
op read(): { @statusCode _: 200, content: string } | { @statusCode _: 201, content: string } | Error;
`
);
strictEqual(op.responses[0].description, "The request has succeeded.");
strictEqual(
op.responses[1].description,
"The request has succeeded and a new resource has been created as a result."
);
strictEqual(op.responses[2].description, "Generic error");
});
it("@doc explicitly on a response override the operation returns doc", async () => {
const op = await getHttpOp(
`
@error model Error {}
@error @doc("Not found model") model NotFound {@statusCode _: 404}
@errorsDoc("Generic error")
op read(): { @statusCode _: 200, content: string } | { @statusCode _: 201, content: string } | Error | NotFound;
`
);
strictEqual(op.responses[0].description, "The request has succeeded.");
strictEqual(
op.responses[1].description,
"The request has succeeded and a new resource has been created as a result."
);
strictEqual(op.responses[2].description, "Not found model");
strictEqual(op.responses[3].description, "Generic error");
});
});

Просмотреть файл

@ -0,0 +1,63 @@
import { strictEqual } from "assert";
import { openApiFor } from "./test-host.js";
describe("openapi3: response descriptions", () => {
it("use a default message by status code if not specified", async () => {
const res = await openApiFor(
`
op read(): {@statusCode _: 200, content: string};
`
);
strictEqual(res.paths["/"].get.responses["200"].description, "The request has succeeded.");
});
it("@returns set doc for all success responses", async () => {
const res = await openApiFor(
`
@error model Error {}
@returnsDoc("A string")
op read(): { @statusCode _: 200, content: string } | { @statusCode _: 201, content: string } | Error;
`
);
strictEqual(res.paths["/"].get.responses["200"].description, "A string");
strictEqual(res.paths["/"].get.responses["201"].description, "A string");
strictEqual(
res.paths["/"].get.responses["default"].description,
"An unexpected error response."
);
});
it("@errors set doc for all success responses", async () => {
const res = await openApiFor(
`
@error model Error {}
@errorsDoc("Generic error")
op read(): { @statusCode _: 200, content: string } | { @statusCode _: 201, content: string } | Error;
`
);
strictEqual(res.paths["/"].get.responses["200"].description, "The request has succeeded.");
strictEqual(
res.paths["/"].get.responses["201"].description,
"The request has succeeded and a new resource has been created as a result."
);
strictEqual(res.paths["/"].get.responses["default"].description, "Generic error");
});
it("@doc explicitly on a response override the operation returns doc", async () => {
const res = await openApiFor(
`
@error model Error {}
@error @doc("Not found model") model NotFound {@statusCode _: 404}
@errorsDoc("Generic error")
op read(): { @statusCode _: 200, content: string } | { @statusCode _: 201, content: string } | Error | NotFound;
`
);
strictEqual(res.paths["/"].get.responses["200"].description, "The request has succeeded.");
strictEqual(
res.paths["/"].get.responses["201"].description,
"The request has succeeded and a new resource has been created as a result."
);
strictEqual(res.paths["/"].get.responses["404"].description, "Not found model");
strictEqual(res.paths["/"].get.responses["default"].description, "Generic error");
});
});