diff --git a/packages/http-api-docs/lib/main.tsp b/packages/http-api-docs/lib/main.tsp new file mode 100644 index 00000000000..06847d5a6d8 --- /dev/null +++ b/packages/http-api-docs/lib/main.tsp @@ -0,0 +1 @@ +import "../dist/src/index.js"; diff --git a/packages/http-api-docs/package.json b/packages/http-api-docs/package.json new file mode 100644 index 00000000000..57e704ab990 --- /dev/null +++ b/packages/http-api-docs/package.json @@ -0,0 +1,65 @@ +{ + "name": "@typespec/http-api-docs", + "version": "0.1.0", + "author": "Microsoft Corporation", + "description": "TypeSpec emitter that generates markdown reference documentation for HTTP-based services", + "homepage": "https://typespec.io", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/typespec.git" + }, + "bugs": { + "url": "https://github.com/microsoft/typespec/issues" + }, + "keywords": [ + "typespec" + ], + "type": "module", + "main": "dist/src/index.js", + "tspMain": "lib/main.tsp", + "exports": { + ".": { + "typespec": "./lib/main.tsp", + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + }, + "./testing": { + "types": "./dist/src/testing/index.d.ts", + "default": "./dist/src/testing/index.js" + } + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "clean": "rimraf ./dist ./temp", + "build": "tsc -p tsconfig.build.json", + "quickbuild": "tsc -p tsconfig.build.json", + "watch": "tsc -p tsconfig.build.json --watch", + "test": "vitest run", + "test:watch": "vitest -w", + "test:ci": "vitest run --coverage --reporter=junit --reporter=default", + "lint": "eslint . --max-warnings=0", + "lint:fix": "eslint . --fix" + }, + "files": [ + "lib/*.tsp", + "dist/**", + "!dist/test/**" + ], + "peerDependencies": { + "@typespec/compiler": "workspace:^", + "@typespec/http": "workspace:^" + }, + "devDependencies": { + "@typespec/compiler": "workspace:^", + "@typespec/http": "workspace:^", + "@typespec/rest": "workspace:^", + "@vitest/coverage-v8": "^4.0.15", + "@vitest/ui": "^4.0.15", + "rimraf": "~6.1.2", + "typescript": "~5.9.2", + "vitest": "^4.0.15" + } +} diff --git a/packages/http-api-docs/src/emitter.ts b/packages/http-api-docs/src/emitter.ts new file mode 100644 index 00000000000..f4f21822a03 --- /dev/null +++ b/packages/http-api-docs/src/emitter.ts @@ -0,0 +1,639 @@ +import { + type EmitContext, + type Model, + type Scalar, + type Type, + type Union, + emitFile, + getDoc, + getNamespaceFullName, + getService, + getSummary, + interpolatePath, + resolvePath, +} from "@typespec/compiler"; +import { + type HttpOperation, + type HttpOperationResponse, + type HttpService, + getAllHttpServices, + getServers, + getStatusCodeDescription, +} from "@typespec/http"; + +import type { HttpApiDocsEmitterOptions } from "./lib.js"; + +export async function emitHttpApiDocs( + context: EmitContext, +): Promise { + const program = context.program; + const [httpServices, diagnostics] = getAllHttpServices(program); + + if (diagnostics.length > 0) { + program.reportDiagnostics(diagnostics); + } + + if (program.hasError()) { + return; + } + + const multipleServices = httpServices.length > 1; + + for (const httpService of httpServices) { + const markdown = generateServiceDoc(program, httpService); + const outputFileName = context.options["output-file"] ?? `{service-name}.md`; + + const resolvedFileName = interpolatePath(outputFileName, { + "service-name": getNamespaceFullName(httpService.namespace), + "service-name-if-multiple": multipleServices + ? getNamespaceFullName(httpService.namespace) + : undefined, + }); + + await emitFile(program, { + path: resolvePath(context.emitterOutputDir, resolvedFileName), + content: markdown, + newLine: "lf", + }); + } +} + +function generateServiceDoc( + program: import("@typespec/compiler").Program, + service: HttpService, +): string { + const lines: string[] = []; + const serviceInfo = getService(program, service.namespace); + const serviceName = serviceInfo?.title ?? service.namespace.name ?? "API"; + const serviceDoc = getDoc(program, service.namespace); + + lines.push(`# ${serviceName} API Reference`); + lines.push(""); + + if (serviceDoc) { + lines.push(serviceDoc); + lines.push(""); + } + + // Emit server info + const servers = getServers(program, service.namespace); + if (servers && servers.length > 0) { + lines.push("## Server"); + lines.push(""); + for (const server of servers) { + lines.push(`- \`${server.url}\`${server.description ? ` - ${server.description}` : ""}`); + } + lines.push(""); + } + + // Collect all operations and identify common error responses + const operationsByTag = groupOperations(program, service.operations); + const allOperations = [...operationsByTag.values()].flat(); + const commonErrors = findCommonErrors(program, allOperations); + + for (const [group, operations] of operationsByTag) { + if (group) { + lines.push(`## ${group}`); + lines.push(""); + } + + for (const httpOp of operations) { + lines.push(...generateOperationDoc(program, httpOp, commonErrors)); + } + } + + // Emit common errors section + if (commonErrors.size > 0) { + lines.push("## Common Errors"); + lines.push(""); + lines.push("The following error responses apply to all operations in this API."); + lines.push(""); + + for (const [, errorDoc] of commonErrors) { + lines.push(...errorDoc.lines); + } + } + + return lines.join("\n"); +} + +function groupOperations( + program: import("@typespec/compiler").Program, + operations: HttpOperation[], +): Map { + const groups = new Map(); + + for (const op of operations) { + // Group by interface/container name + const groupName = op.container.kind === "Interface" ? op.container.name : ""; + if (!groups.has(groupName)) { + groups.set(groupName, []); + } + groups.get(groupName)!.push(op); + } + + return groups; +} + +/** Fingerprint for an error response based on status code and body structure. */ +function getResponseFingerprint( + program: import("@typespec/compiler").Program, + response: HttpOperationResponse, +): string { + const statusCode = formatStatusCode(response.statusCodes); + const bodyParts: string[] = []; + for (const content of response.responses) { + if (content.body && content.body.bodyKind === "single") { + bodyParts.push(getTypeName(content.body.type)); + if (content.body.type.kind === "Model") { + for (const [name, prop] of content.body.type.properties) { + bodyParts.push(`${name}:${getTypeName(prop.type)}`); + } + } + } + } + return `${statusCode}|${bodyParts.join(",")}`; +} + +interface CommonErrorEntry { + fingerprint: string; + lines: string[]; +} + +/** Find error responses that appear in the majority of operations. */ +function findCommonErrors( + program: import("@typespec/compiler").Program, + operations: HttpOperation[], +): Map { + if (operations.length <= 1) { + return new Map(); + } + + // Count how many operations each error fingerprint appears in + const errorCounts = new Map(); + const errorResponses = new Map(); + + for (const op of operations) { + const seen = new Set(); + for (const response of op.responses) { + if (!isErrorResponse(response)) continue; + const fp = getResponseFingerprint(program, response); + if (!seen.has(fp)) { + seen.add(fp); + errorCounts.set(fp, (errorCounts.get(fp) ?? 0) + 1); + if (!errorResponses.has(fp)) { + errorResponses.set(fp, response); + } + } + } + } + + // Consider an error "common" if it appears in more than half the operations + const threshold = Math.ceil(operations.length / 2); + const commonErrors = new Map(); + + for (const [fp, count] of errorCounts) { + if (count >= threshold) { + const response = errorResponses.get(fp)!; + commonErrors.set(fp, { + fingerprint: fp, + lines: generateResponseDoc(program, response), + }); + } + } + + return commonErrors; +} + +/** Check if a response is an error response (4xx, 5xx, or *). */ +function isErrorResponse(response: HttpOperationResponse): boolean { + const sc = response.statusCodes; + if (sc === "*") return true; + if (typeof sc === "number") return sc >= 400; + return sc.start >= 400; +} + +function generateOperationDoc( + program: import("@typespec/compiler").Program, + httpOp: HttpOperation, + commonErrors: Map, +): string[] { + const lines: string[] = []; + const verb = httpOp.verb.toUpperCase(); + const path = httpOp.path; + const opName = httpOp.operation.name; + const summary = getSummary(program, httpOp.operation); + const doc = getDoc(program, httpOp.operation); + + lines.push(`### ${summary ?? opName}`); + lines.push(""); + lines.push(`\`${verb} ${path}\``); + lines.push(""); + + if (doc) { + lines.push(doc); + lines.push(""); + } + + // Parameters + const params = httpOp.parameters.parameters; + if (params.length > 0) { + lines.push("#### Parameters"); + lines.push(""); + lines.push("| Name | In | Type | Required | Description |"); + lines.push("| --- | --- | --- | --- | --- |"); + for (const param of params) { + const paramDoc = getDoc(program, param.param) ?? ""; + const location = param.type; + const typeName = getTypeName(param.param.type); + const required = !param.param.optional ? "Yes" : "No"; + const description = buildDescription(paramDoc, param.param.type); + lines.push( + `| ${escapeForTable(param.param.name)} | ${location} | \`${escapeForTable(typeName)}\` | ${required} | ${escapeForTable(description)} |`, + ); + } + lines.push(""); + } + + // Request body + const body = httpOp.parameters.body; + if (body && body.bodyKind === "single" && body.type.kind !== "Intrinsic") { + lines.push("#### Request Body"); + lines.push(""); + const contentTypes = body.contentTypes; + if (contentTypes.length > 0) { + lines.push(`Content-Type: ${contentTypes.join(", ")}`); + lines.push(""); + } + + if (isJsonContentType(contentTypes)) { + const example = generateExampleJson(body.type, 0); + lines.push("```json"); + lines.push(JSON.stringify(example, null, 2)); + lines.push("```"); + lines.push(""); + } + + // Body properties table + if (body.type.kind === "Model" && body.type.properties.size > 0) { + lines.push("| Property | Type | Required | Description |"); + lines.push("| --- | --- | --- | --- |"); + for (const [name, prop] of body.type.properties) { + const propDoc = getDoc(program, prop) ?? ""; + const typeName = getTypeName(prop.type); + const required = !prop.optional ? "Yes" : "No"; + const description = buildDescription(propDoc, prop.type); + lines.push( + `| ${escapeForTable(name)} | \`${escapeForTable(typeName)}\` | ${required} | ${escapeForTable(description)} |`, + ); + } + lines.push(""); + } + } + + // Responses + if (httpOp.responses.length > 0) { + const uniqueResponses: HttpOperationResponse[] = []; + const skippedCommon: string[] = []; + + for (const response of httpOp.responses) { + const fp = getResponseFingerprint(program, response); + if (isErrorResponse(response) && commonErrors.has(fp)) { + skippedCommon.push(formatStatusCode(response.statusCodes)); + } else { + uniqueResponses.push(response); + } + } + + if (uniqueResponses.length > 0 || skippedCommon.length > 0) { + lines.push("#### Responses"); + lines.push(""); + + for (const response of uniqueResponses) { + lines.push(...generateResponseDoc(program, response)); + } + + if (skippedCommon.length > 0) { + lines.push( + `This operation also returns [common errors](#common-errors) (${skippedCommon.join(", ")}).`, + ); + lines.push(""); + } + } + } + + lines.push("---"); + lines.push(""); + + return lines; +} + +function generateResponseDoc( + program: import("@typespec/compiler").Program, + response: HttpOperationResponse, +): string[] { + const lines: string[] = []; + const statusCode = formatStatusCode(response.statusCodes); + const description = response.description ?? getStatusCodeDescription(statusCode) ?? ""; + + lines.push(`##### ${statusCode}${description ? ` ${description}` : ""}`); + lines.push(""); + + for (const content of response.responses) { + if (content.body && content.body.bodyKind === "single") { + const contentTypes = content.body.contentTypes; + if (contentTypes.length > 0) { + lines.push(`Content-Type: ${contentTypes.join(", ")}`); + lines.push(""); + } + + if (isJsonContentType(contentTypes)) { + const example = generateExampleJson(content.body.type, 0); + lines.push("```json"); + lines.push(JSON.stringify(example, null, 2)); + lines.push("```"); + lines.push(""); + } + + // Body properties table + if (content.body.type.kind === "Model" && content.body.type.properties.size > 0) { + lines.push("| Property | Type | Required | Description |"); + lines.push("| --- | --- | --- | --- |"); + for (const [name, prop] of content.body.type.properties) { + const propDoc = getDoc(program, prop) ?? ""; + const typeName = getTypeName(prop.type); + const required = !prop.optional ? "Yes" : "No"; + const description = buildDescription(propDoc, prop.type); + lines.push( + `| ${escapeForTable(name)} | \`${escapeForTable(typeName)}\` | ${required} | ${escapeForTable(description)} |`, + ); + } + lines.push(""); + } + } + } + + return lines; +} + +function formatStatusCode(statusCodes: HttpOperationResponse["statusCodes"]): string { + if (typeof statusCodes === "number") { + return statusCodes.toString(); + } + if (statusCodes === "*") { + return "*"; + } + return `${statusCodes.start}-${statusCodes.end}`; +} + +function isJsonContentType(contentTypes: readonly string[]): boolean { + return ( + contentTypes.length === 0 || + contentTypes.some((ct) => ct === "application/json" || ct.endsWith("+json")) + ); +} + +/** Escape content for use inside a markdown table cell. + * - Replaces newlines with `
` to keep the row on one line + * - Escapes pipe characters so they don't break column boundaries + */ +function escapeForTable(value: string): string { + return value.replace(/\r?\n/g, "
").replace(/\|/g, "\\|"); +} + +/** Return an extra description for types that lose information when shown as + * a plain JSON type name (e.g. Record types shown as `object`). + */ +function getTypeDescription(type: Type): string { + if (type.kind === "Model" && type.indexer && type.indexer.key.name === "string") { + const valueType = getTypeName(type.indexer.value); + return `A map of string keys to ${valueType} values.`; + } + if (type.kind === "Union") { + for (const [, variant] of type.variants) { + const desc = getTypeDescription(variant.type); + if (desc) return desc; + } + } + return ""; +} + +/** Combine the doc string with any auto-generated type description. */ +function buildDescription(doc: string, type: Type): string { + const typeDesc = getTypeDescription(type); + if (!typeDesc) return doc; + return doc ? `${typeDesc} ${doc}` : typeDesc; +} + +function getTypeName(type: Type): string { + switch (type.kind) { + case "Scalar": + return type.name; + case "Model": + if (type.name === "Array" && type.indexer) { + return `${getTypeName(type.indexer.value)}[]`; + } + return "object"; + case "Enum": + return formatEnumName(type); + case "Union": + return formatUnionName(type); + case "Intrinsic": + return type.name; + case "String": + return `"${type.value}"`; + case "Number": + return type.value.toString(); + case "Boolean": + return type.value.toString(); + case "EnumMember": + if (type.value !== undefined) { + return typeof type.value === "string" ? `"${type.value}"` : type.value.toString(); + } + return `"${type.name}"`; + case "StringTemplate": + return type.stringValue !== undefined ? `"${type.stringValue}"` : "string"; + case "Tuple": + return "[]"; + default: + return "unknown"; + } +} + +/** Max number of enum/union variants to inline before falling back to the name. */ +const MAX_INLINE_VARIANTS = 10; + +function formatEnumName(enumType: import("@typespec/compiler").Enum): string { + if (enumType.members.size > MAX_INLINE_VARIANTS) { + return enumType.name; + } + const members: string[] = []; + for (const [, member] of enumType.members) { + if (member.value !== undefined) { + members.push( + typeof member.value === "string" ? `"${member.value}"` : member.value.toString(), + ); + } else { + members.push(`"${member.name}"`); + } + } + return members.join(" | "); +} + +function formatUnionName(union: Union): string { + if (union.variants.size > MAX_INLINE_VARIANTS && union.name) { + return union.name; + } + const variants: string[] = []; + for (const [, variant] of union.variants) { + variants.push(getTypeName(variant.type)); + } + return variants.join(" | "); +} + +/** + * Generate example JSON from a TypeSpec type. This creates synthetic + * placeholder values when no explicit @example is provided. + */ +export function generateExampleJson(type: Type, depth: number): unknown { + // Prevent infinite recursion for circular references + if (depth > 5) { + return {}; + } + + switch (type.kind) { + case "Model": + return generateModelExample(type, depth); + case "Scalar": + return generateScalarExample(type); + case "Enum": + return generateEnumExample(type); + case "Union": + return generateUnionExample(type, depth); + case "Intrinsic": + if (type.name === "void" || type.name === "never") { + return undefined; + } + if (type.name === "null") { + return null; + } + return {}; + case "String": + return type.value; + case "Number": + return type.value; + case "Boolean": + return type.value; + case "EnumMember": + return type.value ?? type.name; + case "StringTemplate": + return type.stringValue ?? "string"; + case "Tuple": { + const items: unknown[] = []; + for (const value of type.values) { + items.push(generateExampleJson(value, depth + 1)); + } + return items; + } + default: + return {}; + } +} + +function generateModelExample(model: Model, depth: number): unknown { + // Handle array types + if (model.name === "Array" && model.indexer) { + const itemExample = generateExampleJson(model.indexer.value, depth + 1); + return [itemExample]; + } + + // Handle Record types + if (model.indexer && model.indexer.key.name === "string") { + const valueExample = generateExampleJson(model.indexer.value, depth + 1); + return { key: valueExample }; + } + + const result: Record = {}; + for (const [name, prop] of model.properties) { + const value = generateExampleJson(prop.type, depth + 1); + if (value !== undefined) { + result[name] = value; + } + } + return result; +} + +function generateScalarExample(scalar: Scalar): unknown { + const name = getBaseScalarName(scalar); + switch (name) { + case "string": + return "string"; + case "boolean": + return false; + case "int8": + case "int16": + case "int32": + case "int64": + case "uint8": + case "uint16": + case "uint32": + case "uint64": + case "safeint": + case "integer": + case "numeric": + return 0; + case "float": + case "float16": + case "float32": + case "float64": + case "decimal": + case "decimal128": + return 0.0; + case "plainDate": + return "2024-01-01"; + case "plainTime": + return "12:00:00"; + case "utcDateTime": + case "offsetDateTime": + return "2024-01-01T00:00:00Z"; + case "duration": + return "PT1H"; + case "bytes": + return "base64EncodedString"; + case "url": + return "https://example.com"; + default: + return "string"; + } +} + +function getBaseScalarName(scalar: Scalar): string { + let current: Scalar | undefined = scalar; + while (current) { + if (!current.baseScalar) { + return current.name; + } + current = current.baseScalar; + } + return scalar.name; +} + +function generateEnumExample(enumType: import("@typespec/compiler").Enum): unknown { + const firstMember = enumType.members.values().next(); + if (firstMember.done) { + return "string"; + } + return firstMember.value.value ?? firstMember.value.name; +} + +function generateUnionExample(union: Union, depth: number): unknown { + // Return example for the first non-null variant. + // Don't increment depth here — resolving a union variant is type + // unwrapping, not structural nesting. + for (const [, variant] of union.variants) { + if (variant.type.kind !== "Intrinsic" || variant.type.name !== "null") { + return generateExampleJson(variant.type, depth); + } + } + return null; +} diff --git a/packages/http-api-docs/src/index.ts b/packages/http-api-docs/src/index.ts new file mode 100644 index 00000000000..94f6623768f --- /dev/null +++ b/packages/http-api-docs/src/index.ts @@ -0,0 +1,4 @@ +export { $lib } from "./lib.js"; +export type { HttpApiDocsEmitterOptions } from "./lib.js"; +export { emitHttpApiDocs as $onEmit } from "./emitter.js"; +export { generateExampleJson } from "./emitter.js"; diff --git a/packages/http-api-docs/src/lib.ts b/packages/http-api-docs/src/lib.ts new file mode 100644 index 00000000000..75bade501fd --- /dev/null +++ b/packages/http-api-docs/src/lib.ts @@ -0,0 +1,30 @@ +import { createTypeSpecLibrary, type JSONSchemaType } from "@typespec/compiler"; + +export interface HttpApiDocsEmitterOptions { + /** + * Name of the output markdown file. + * @defaultValue `{service-name}.md` + */ + "output-file"?: string; +} + +const EmitterOptionsSchema: JSONSchemaType = { + type: "object", + additionalProperties: false, + properties: { + "output-file": { + type: "string", + nullable: true, + description: "Name of the output markdown file.", + }, + }, + required: [], +}; + +export const $lib = createTypeSpecLibrary({ + name: "@typespec/http-api-docs", + diagnostics: {}, + emitter: { + options: EmitterOptionsSchema, + }, +}); diff --git a/packages/http-api-docs/src/testing/index.ts b/packages/http-api-docs/src/testing/index.ts new file mode 100644 index 00000000000..4672559c913 --- /dev/null +++ b/packages/http-api-docs/src/testing/index.ts @@ -0,0 +1,10 @@ +import { + type TypeSpecTestLibrary, + createTestLibrary, + findTestPackageRoot, +} from "@typespec/compiler/testing"; + +export const HttpApiDocsTestLibrary: TypeSpecTestLibrary = createTestLibrary({ + name: "@typespec/http-api-docs", + packageRoot: await findTestPackageRoot(import.meta.url), +}); diff --git a/packages/http-api-docs/test/emitter.test.ts b/packages/http-api-docs/test/emitter.test.ts new file mode 100644 index 00000000000..c3c834f2a68 --- /dev/null +++ b/packages/http-api-docs/test/emitter.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from "vitest"; +import { emitMarkdownFor } from "./test-host.js"; + +describe("http-api-docs emitter", () => { + it("emits markdown with service title", async () => { + const markdown = await emitMarkdownFor(` + @service(#{title: "Pet Store"}) + @route("/") + namespace PetStore { + op list(): string; + } + `); + + expect(markdown).toContain("# Pet Store API Reference"); + }); + + it("emits operation with verb and path", async () => { + const markdown = await emitMarkdownFor(` + @service(#{title: "My API"}) + @route("/pets") + namespace MyAPI { + @get op list(): string; + } + `); + + expect(markdown).toContain("GET /pets"); + }); + + it("emits path parameters", async () => { + const markdown = await emitMarkdownFor(` + @service(#{title: "My API"}) + @route("/pets") + namespace MyAPI { + @get op read(@path id: string): string; + } + `); + + expect(markdown).toContain("#### Parameters"); + expect(markdown).toContain("| id | path |"); + }); + + it("emits query parameters", async () => { + const markdown = await emitMarkdownFor(` + @service(#{title: "My API"}) + @route("/pets") + namespace MyAPI { + @get op list(@query limit: int32): string; + } + `); + + expect(markdown).toContain("| limit | query |"); + }); + + it("emits request body with JSON example", async () => { + const markdown = await emitMarkdownFor(` + model Pet { + name: string; + age: int32; + } + + @service(#{title: "My API"}) + @route("/pets") + namespace MyAPI { + @post op create(@body pet: Pet): Pet; + } + `); + + expect(markdown).toContain("#### Request Body"); + expect(markdown).toContain('"name": "string"'); + expect(markdown).toContain('"age": 0'); + }); + + it("emits response body with JSON example", async () => { + const markdown = await emitMarkdownFor(` + model Pet { + name: string; + age: int32; + } + + @service(#{title: "My API"}) + @route("/pets") + namespace MyAPI { + @get op read(@path id: string): Pet; + } + `); + + expect(markdown).toContain("#### Responses"); + expect(markdown).toContain('"name": "string"'); + expect(markdown).toContain('"age": 0'); + }); + + it("emits documentation from @doc decorator", async () => { + const markdown = await emitMarkdownFor(` + @service(#{title: "My API"}) + @doc("A sample API for managing pets") + @route("/pets") + namespace MyAPI { + @doc("List all available pets") + @get op list(): string; + } + `); + + expect(markdown).toContain("A sample API for managing pets"); + expect(markdown).toContain("List all available pets"); + }); + + it("emits response status codes", async () => { + const markdown = await emitMarkdownFor(` + model Pet { + name: string; + } + + @service(#{title: "My API"}) + @route("/pets") + namespace MyAPI { + @get op read(@path id: string): { + @statusCode statusCode: 200; + @body body: Pet; + }; + } + `); + + expect(markdown).toContain("##### 200"); + }); + + it("emits example for nested models", async () => { + const markdown = await emitMarkdownFor(` + model Address { + street: string; + city: string; + } + + model Person { + name: string; + address: Address; + } + + @service(#{title: "My API"}) + @route("/people") + namespace MyAPI { + @get op read(@path id: string): Person; + } + `); + + expect(markdown).toContain('"street": "string"'); + expect(markdown).toContain('"city": "string"'); + }); + + it("emits example for array types", async () => { + const markdown = await emitMarkdownFor(` + model Pet { + name: string; + } + + @service(#{title: "My API"}) + @route("/pets") + namespace MyAPI { + @get op list(): Pet[]; + } + `); + + // Should have array in the response example + expect(markdown).toContain("["); + expect(markdown).toContain('"name": "string"'); + }); + + it("emits example for enum types", async () => { + const markdown = await emitMarkdownFor(` + enum Color { + Red: "red", + Blue: "blue", + } + + model Pet { + name: string; + color: Color; + } + + @service(#{title: "My API"}) + @route("/pets") + namespace MyAPI { + @get op read(@path id: string): Pet; + } + `); + + expect(markdown).toContain('"color": "red"'); + }); + + it("emits example for optional properties", async () => { + const markdown = await emitMarkdownFor(` + model Pet { + name: string; + nickname?: string; + } + + @service(#{title: "My API"}) + @route("/pets") + namespace MyAPI { + @get op read(@path id: string): Pet; + } + `); + + expect(markdown).toContain("| nickname |"); + expect(markdown).toContain("No"); + }); + + it("emits example for boolean and date types", async () => { + const markdown = await emitMarkdownFor(` + model Event { + active: boolean; + createdAt: utcDateTime; + } + + @service(#{title: "My API"}) + @route("/events") + namespace MyAPI { + @get op read(@path id: string): Event; + } + `); + + expect(markdown).toContain('"active": false'); + expect(markdown).toContain('"createdAt": "2024-01-01T00:00:00Z"'); + }); + + it("handles multiple operations", async () => { + const markdown = await emitMarkdownFor(` + model Pet { + name: string; + } + + @service(#{title: "My API"}) + @route("/pets") + namespace MyAPI { + @get op list(): Pet[]; + @get op read(@path id: string): Pet; + @post op create(@body pet: Pet): Pet; + } + `); + + expect(markdown).toContain("GET /pets"); + expect(markdown).toContain("GET /pets/{id}"); + expect(markdown).toContain("POST /pets"); + }); + + it("emits property descriptions in body tables", async () => { + const markdown = await emitMarkdownFor(` + model Pet { + @doc("The name of the pet") + name: string; + @doc("Age in years") + age: int32; + } + + @service(#{title: "My API"}) + @route("/pets") + namespace MyAPI { + @post op create(@body pet: Pet): Pet; + } + `); + + expect(markdown).toContain("The name of the pet"); + expect(markdown).toContain("Age in years"); + }); + + it("emits example for string literal types", async () => { + const markdown = await emitMarkdownFor(` + model Config { + kind: "standard"; + mode: "active" | "inactive"; + } + + @service(#{title: "My API"}) + @route("/config") + namespace MyAPI { + @get op read(): Config; + } + `); + + expect(markdown).toContain('"kind": "standard"'); + expect(markdown).toContain('"mode": "active"'); + }); + + it("emits type name for string literal properties", async () => { + const markdown = await emitMarkdownFor(` + model Config { + kind: "standard"; + } + + @service(#{title: "My API"}) + @route("/config") + namespace MyAPI { + @post op create(@body config: Config): Config; + } + `); + + expect(markdown).toContain('`"standard"`'); + }); + + it("emits example for numeric literal types", async () => { + const markdown = await emitMarkdownFor(` + model TestResult { + code: 200; + } + + @service(#{title: "My API"}) + @route("/test") + namespace MyAPI { + @get op read(): TestResult; + } + `); + + expect(markdown).toContain('"code": 200'); + }); + + it("emits example for boolean literal types", async () => { + const markdown = await emitMarkdownFor(` + model TestResult { + success: true; + } + + @service(#{title: "My API"}) + @route("/test") + namespace MyAPI { + @get op read(): TestResult; + } + `); + + expect(markdown).toContain('"success": true'); + }); +}); diff --git a/packages/http-api-docs/test/test-host.ts b/packages/http-api-docs/test/test-host.ts new file mode 100644 index 00000000000..e767d43d274 --- /dev/null +++ b/packages/http-api-docs/test/test-host.ts @@ -0,0 +1,21 @@ +import { resolvePath } from "@typespec/compiler"; +import { createTester } from "@typespec/compiler/testing"; + +export const ApiTester = createTester(resolvePath(import.meta.dirname, ".."), { + libraries: ["@typespec/http", "@typespec/rest", "@typespec/http-api-docs"], +}); + +export const SimpleTester = ApiTester.import("@typespec/http", "@typespec/rest", "@typespec/http-api-docs") + .using("Http") + .emit("@typespec/http-api-docs"); + +export async function emitMarkdownFor(code: string, options: Record = {}): Promise { + const host = await SimpleTester.createInstance(); + const { outputs } = await host.compile(code, { + compilerOptions: { + options: { "@typespec/http-api-docs": { ...options, "output-file": "api.md" } }, + }, + }); + + return outputs["api.md"] ?? ""; +} diff --git a/packages/http-api-docs/tsconfig.build.json b/packages/http-api-docs/tsconfig.build.json new file mode 100644 index 00000000000..d389807a318 --- /dev/null +++ b/packages/http-api-docs/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts", "test/**/*.ts"], + "references": [ + { "path": "../compiler/tsconfig.build.json" }, + { "path": "../http/tsconfig.build.json" } + ] +} diff --git a/packages/http-api-docs/tsconfig.json b/packages/http-api-docs/tsconfig.json new file mode 100644 index 00000000000..80c66a0c8df --- /dev/null +++ b/packages/http-api-docs/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "references": [ + { "path": "../compiler/tsconfig.json" }, + { "path": "../http/tsconfig.json" } + ], + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "tsBuildInfoFile": "temp/tsconfig.tsbuildinfo" + } +} diff --git a/packages/http-api-docs/vitest.config.ts b/packages/http-api-docs/vitest.config.ts new file mode 100644 index 00000000000..63cad767f57 --- /dev/null +++ b/packages/http-api-docs/vitest.config.ts @@ -0,0 +1,4 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import { defaultTypeSpecVitestConfig } from "../../vitest.config.js"; + +export default mergeConfig(defaultTypeSpecVitestConfig, defineConfig({})); diff --git a/packages/samples/package.json b/packages/samples/package.json index c180eb2caee..8968ac62be2 100644 --- a/packages/samples/package.json +++ b/packages/samples/package.json @@ -48,6 +48,7 @@ "@typespec/events": "workspace:^", "@typespec/html-program-viewer": "workspace:^", "@typespec/http": "workspace:^", + "@typespec/http-api-docs": "workspace:^", "@typespec/http-server-csharp": "workspace:^", "@typespec/http-server-js": "workspace:^", "@typespec/json-schema": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42ac27e3349..93ae7de83e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -658,6 +658,33 @@ importers: specifier: ^4.0.15 version: 4.0.17(@types/node@25.0.9)(@vitest/ui@4.0.17)(happy-dom@20.3.4)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2) + packages/http-api-docs: + devDependencies: + '@typespec/compiler': + specifier: workspace:^ + version: link:../compiler + '@typespec/http': + specifier: workspace:^ + version: link:../http + '@typespec/rest': + specifier: workspace:^ + version: link:../rest + '@vitest/coverage-v8': + specifier: ^4.0.15 + version: 4.0.17(vitest@4.0.17) + '@vitest/ui': + specifier: ^4.0.15 + version: 4.0.17(vitest@4.0.17) + rimraf: + specifier: ~6.1.2 + version: 6.1.2 + typescript: + specifier: ~5.9.2 + version: 5.9.3 + vitest: + specifier: ^4.0.15 + version: 4.0.17(@types/node@25.0.9)(@vitest/ui@4.0.17)(happy-dom@20.3.4)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2) + packages/http-canonicalization: dependencies: '@typespec/compiler': @@ -9099,12 +9126,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} @@ -19530,7 +19557,7 @@ snapshots: algoliasearch: 4.25.3 clipanion: 4.0.0-rc.4(typanion@3.14.0) diff: 5.2.2 - ink: 3.2.0(@types/react@19.2.9)(react@19.2.3) + ink: 3.2.0(@types/react@19.2.9)(react@17.0.2) ink-text-input: 4.0.3(ink@3.2.0(@types/react@19.2.9)(react@17.0.2))(react@17.0.2) react: 17.0.2 semver: 7.7.3 @@ -19680,7 +19707,7 @@ snapshots: '@yarnpkg/plugin-git': 3.1.4(@yarnpkg/core@4.5.0(typanion@3.14.0))(typanion@3.14.0) clipanion: 4.0.0-rc.4(typanion@3.14.0) es-toolkit: 1.44.0 - ink: 3.2.0(@types/react@19.2.9)(react@17.0.2) + ink: 3.2.0(@types/react@19.2.9)(react@19.2.3) react: 17.0.2 semver: 7.7.3 tslib: 2.8.1 diff --git a/tsconfig.ws.json b/tsconfig.ws.json index 57204acadb4..d06a10cf878 100644 --- a/tsconfig.ws.json +++ b/tsconfig.ws.json @@ -29,7 +29,8 @@ { "path": "packages/xml/tsconfig.build.json" }, { "path": "./packages/http-server-js/tsconfig.json" }, { "path": "packages/http-server-csharp/tsconfig.build.json" }, - { "path": "packages/astro-utils/tsconfig.build.json" } + { "path": "packages/astro-utils/tsconfig.build.json" }, + { "path": "packages/http-api-docs/tsconfig.build.json" } ], "files": [] }