From d0fcc06b83d7ca4078f3340467e16f0a65a9dc3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 21:59:56 +0000 Subject: [PATCH 1/6] Initial plan From 0a3a4eb8b009877a98b0b10c8b23834e416934cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:19:13 +0000 Subject: [PATCH 2/6] feat: add @typespec/http-api-docs emitter for markdown API reference documentation Co-authored-by: johanste <15110018+johanste@users.noreply.github.com> --- packages/http-api-docs/lib/main.tsp | 1 + packages/http-api-docs/package.json | 65 +++ packages/http-api-docs/src/emitter.ts | 425 ++++++++++++++++++++ packages/http-api-docs/src/index.ts | 4 + packages/http-api-docs/src/lib.ts | 30 ++ packages/http-api-docs/src/testing/index.ts | 10 + packages/http-api-docs/test/emitter.test.ts | 264 ++++++++++++ packages/http-api-docs/test/test-host.ts | 21 + packages/http-api-docs/tsconfig.build.json | 8 + packages/http-api-docs/tsconfig.json | 12 + packages/http-api-docs/vitest.config.ts | 4 + pnpm-lock.yaml | 35 +- 12 files changed, 875 insertions(+), 4 deletions(-) create mode 100644 packages/http-api-docs/lib/main.tsp create mode 100644 packages/http-api-docs/package.json create mode 100644 packages/http-api-docs/src/emitter.ts create mode 100644 packages/http-api-docs/src/index.ts create mode 100644 packages/http-api-docs/src/lib.ts create mode 100644 packages/http-api-docs/src/testing/index.ts create mode 100644 packages/http-api-docs/test/emitter.test.ts create mode 100644 packages/http-api-docs/test/test-host.ts create mode 100644 packages/http-api-docs/tsconfig.build.json create mode 100644 packages/http-api-docs/tsconfig.json create mode 100644 packages/http-api-docs/vitest.config.ts 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..c7e366c91ef --- /dev/null +++ b/packages/http-api-docs/src/emitter.ts @@ -0,0 +1,425 @@ +import { + type EmitContext, + type Model, + type ModelProperty, + 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(""); + } + + // Group operations by path + const operationsByTag = groupOperations(program, service.operations); + + for (const [group, operations] of operationsByTag) { + if (group) { + lines.push(`## ${group}`); + lines.push(""); + } + + for (const httpOp of operations) { + lines.push(...generateOperationDoc(program, httpOp)); + } + } + + 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; +} + +function generateOperationDoc(program: import("@typespec/compiler").Program, httpOp: HttpOperation): 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"; + lines.push(`| ${param.param.name} | ${location} | \`${typeName}\` | ${required} | ${paramDoc} |`); + } + 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"; + lines.push(`| ${name} | \`${typeName}\` | ${required} | ${propDoc} |`); + } + lines.push(""); + } + } + + // Responses + if (httpOp.responses.length > 0) { + lines.push("#### Responses"); + lines.push(""); + + for (const response of httpOp.responses) { + lines.push(...generateResponseDoc(program, response)); + } + } + + 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"; + lines.push(`| ${name} | \`${typeName}\` | ${required} | ${propDoc} |`); + } + 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")) + ); +} + +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 type.name || "object"; + case "Enum": + return type.name; + case "Union": + return formatUnionName(type); + case "Intrinsic": + return type.name; + default: + return "unknown"; + } +} + +function formatUnionName(union: Union): string { + if (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 {}; + 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 + for (const [, variant] of union.variants) { + if (variant.type.kind !== "Intrinsic" || variant.type.name !== "null") { + return generateExampleJson(variant.type, depth + 1); + } + } + 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..82a2785c989 --- /dev/null +++ b/packages/http-api-docs/test/emitter.test.ts @@ -0,0 +1,264 @@ +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"); + }); +}); 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/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 From 526f99ce86e80c403ed8abd43563d9174b5c026b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:24:59 +0000 Subject: [PATCH 3/6] refactor: remove unused ModelProperty import from emitter Co-authored-by: johanste <15110018+johanste@users.noreply.github.com> --- packages/http-api-docs/src/emitter.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/http-api-docs/src/emitter.ts b/packages/http-api-docs/src/emitter.ts index c7e366c91ef..eef80d840b3 100644 --- a/packages/http-api-docs/src/emitter.ts +++ b/packages/http-api-docs/src/emitter.ts @@ -1,7 +1,6 @@ import { type EmitContext, type Model, - type ModelProperty, type Scalar, type Type, type Union, From 8b711e03a4026d4a0f4bf663df39be53d4063b52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 01:05:42 +0000 Subject: [PATCH 4/6] fix: add http-api-docs to tsconfig.ws.json project references Co-authored-by: johanste <15110018+johanste@users.noreply.github.com> --- tsconfig.ws.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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": [] } From 7f7ff56eee8b38fd782c7c903bfdfe66533a8bac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 18:50:14 +0000 Subject: [PATCH 5/6] fix: handle string/number/boolean literal types in http-api-docs emitter type resolution and example generation Co-authored-by: johanste <15110018+johanste@users.noreply.github.com> --- packages/http-api-docs/src/emitter.ts | 12 ++++ packages/http-api-docs/test/emitter.test.ts | 66 +++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/packages/http-api-docs/src/emitter.ts b/packages/http-api-docs/src/emitter.ts index eef80d840b3..b672d5c1067 100644 --- a/packages/http-api-docs/src/emitter.ts +++ b/packages/http-api-docs/src/emitter.ts @@ -279,6 +279,12 @@ function getTypeName(type: Type): string { 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(); default: return "unknown"; } @@ -322,6 +328,12 @@ export function generateExampleJson(type: Type, depth: number): unknown { return null; } return {}; + case "String": + return type.value; + case "Number": + return type.value; + case "Boolean": + return type.value; default: return {}; } diff --git a/packages/http-api-docs/test/emitter.test.ts b/packages/http-api-docs/test/emitter.test.ts index 82a2785c989..c3c834f2a68 100644 --- a/packages/http-api-docs/test/emitter.test.ts +++ b/packages/http-api-docs/test/emitter.test.ts @@ -261,4 +261,70 @@ describe("http-api-docs emitter", () => { 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'); + }); }); From 2bc9c6c3e83608ce02ba627da55e7f3f7a21c1f6 Mon Sep 17 00:00:00 2001 From: Johan Stenberg Date: Thu, 12 Feb 2026 17:04:35 -0800 Subject: [PATCH 6/6] Improve api docs format --- packages/http-api-docs/src/emitter.ts | 243 +++++++++++++++++++++++--- packages/samples/package.json | 1 + 2 files changed, 224 insertions(+), 20 deletions(-) diff --git a/packages/http-api-docs/src/emitter.ts b/packages/http-api-docs/src/emitter.ts index b672d5c1067..f4f21822a03 100644 --- a/packages/http-api-docs/src/emitter.ts +++ b/packages/http-api-docs/src/emitter.ts @@ -23,7 +23,9 @@ import { import type { HttpApiDocsEmitterOptions } from "./lib.js"; -export async function emitHttpApiDocs(context: EmitContext): Promise { +export async function emitHttpApiDocs( + context: EmitContext, +): Promise { const program = context.program; const [httpServices, diagnostics] = getAllHttpServices(program); @@ -39,8 +41,7 @@ export async function emitHttpApiDocs(context: EmitContext 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); } } @@ -117,7 +135,89 @@ function groupOperations( return groups; } -function generateOperationDoc(program: import("@typespec/compiler").Program, httpOp: HttpOperation): string[] { +/** 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; @@ -147,7 +247,10 @@ function generateOperationDoc(program: import("@typespec/compiler").Program, htt const location = param.type; const typeName = getTypeName(param.param.type); const required = !param.param.optional ? "Yes" : "No"; - lines.push(`| ${param.param.name} | ${location} | \`${typeName}\` | ${required} | ${paramDoc} |`); + const description = buildDescription(paramDoc, param.param.type); + lines.push( + `| ${escapeForTable(param.param.name)} | ${location} | \`${escapeForTable(typeName)}\` | ${required} | ${escapeForTable(description)} |`, + ); } lines.push(""); } @@ -179,7 +282,10 @@ function generateOperationDoc(program: import("@typespec/compiler").Program, htt const propDoc = getDoc(program, prop) ?? ""; const typeName = getTypeName(prop.type); const required = !prop.optional ? "Yes" : "No"; - lines.push(`| ${name} | \`${typeName}\` | ${required} | ${propDoc} |`); + const description = buildDescription(propDoc, prop.type); + lines.push( + `| ${escapeForTable(name)} | \`${escapeForTable(typeName)}\` | ${required} | ${escapeForTable(description)} |`, + ); } lines.push(""); } @@ -187,11 +293,32 @@ function generateOperationDoc(program: import("@typespec/compiler").Program, htt // Responses if (httpOp.responses.length > 0) { - lines.push("#### Responses"); - lines.push(""); + const uniqueResponses: HttpOperationResponse[] = []; + const skippedCommon: string[] = []; for (const response of httpOp.responses) { - lines.push(...generateResponseDoc(program, response)); + 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(""); + } } } @@ -207,8 +334,7 @@ function generateResponseDoc( ): string[] { const lines: string[] = []; const statusCode = formatStatusCode(response.statusCodes); - const description = - response.description ?? getStatusCodeDescription(statusCode) ?? ""; + const description = response.description ?? getStatusCodeDescription(statusCode) ?? ""; lines.push(`##### ${statusCode}${description ? ` ${description}` : ""}`); lines.push(""); @@ -237,7 +363,10 @@ function generateResponseDoc( const propDoc = getDoc(program, prop) ?? ""; const typeName = getTypeName(prop.type); const required = !prop.optional ? "Yes" : "No"; - lines.push(`| ${name} | \`${typeName}\` | ${required} | ${propDoc} |`); + const description = buildDescription(propDoc, prop.type); + lines.push( + `| ${escapeForTable(name)} | \`${escapeForTable(typeName)}\` | ${required} | ${escapeForTable(description)} |`, + ); } lines.push(""); } @@ -264,6 +393,38 @@ function isJsonContentType(contentTypes: readonly string[]): boolean { ); } +/** 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": @@ -272,9 +433,9 @@ function getTypeName(type: Type): string { if (type.name === "Array" && type.indexer) { return `${getTypeName(type.indexer.value)}[]`; } - return type.name || "object"; + return "object"; case "Enum": - return type.name; + return formatEnumName(type); case "Union": return formatUnionName(type); case "Intrinsic": @@ -285,13 +446,42 @@ function getTypeName(type: Type): string { 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.name) { + if (union.variants.size > MAX_INLINE_VARIANTS && union.name) { return union.name; } const variants: string[] = []; @@ -334,6 +524,17 @@ export function generateExampleJson(type: Type, depth: number): unknown { 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 {}; } @@ -426,10 +627,12 @@ function generateEnumExample(enumType: import("@typespec/compiler").Enum): unkno } function generateUnionExample(union: Union, depth: number): unknown { - // Return example for the first non-null variant + // 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 + 1); + return generateExampleJson(variant.type, depth); } } return null; 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:^",