diff --git a/.chronus/changes/bundler-skip-typespec-only-exports-2026-6-26-10-45-0.md b/.chronus/changes/bundler-skip-typespec-only-exports-2026-6-26-10-45-0.md new file mode 100644 index 00000000000..f63d3c479b5 --- /dev/null +++ b/.chronus/changes/bundler-skip-typespec-only-exports-2026-6-26-10-45-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/bundler" +--- + +Skip exports that only expose a `typespec` entrypoint (e.g. `./emitter`, `./options`) when building the JS bundle. These exports have no JS module to bundle and their TypeSpec source files are already included via the sub-export compilation, so the bundler no longer fails with a "missing import or default entrypoint" error. diff --git a/.chronus/changes/emitter-options-typegraph-2026-6-25-12-51-0.md b/.chronus/changes/emitter-options-typegraph-2026-6-25-12-51-0.md new file mode 100644 index 00000000000..79e405df093 --- /dev/null +++ b/.chronus/changes/emitter-options-typegraph-2026-6-25-12-51-0.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/compiler" +--- + +Introduce internal `TypeGraph` concept (a self-contained compilation result) and experimental support for defining emitter options as a TypeSpec file (`exports["./options"].typespec`). Emitters opt into validating user options against their exported `EmitterOptions` model via the `experimentalEmitterOptions` package flag (`definePackageFlags`). diff --git a/.chronus/changes/json-schema-tsp-options-2026-6-26-10-20-0.md b/.chronus/changes/json-schema-tsp-options-2026-6-26-10-20-0.md new file mode 100644 index 00000000000..74e204dd685 --- /dev/null +++ b/.chronus/changes/json-schema-tsp-options-2026-6-26-10-20-0.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/json-schema" +--- + +Migrate the JSON Schema emitter to define its options as a TypeSpec model (`options/main.tsp`, exported via `exports["./options"].typespec`) instead of a hand-written JSON schema. The compiler now validates user options against that model. Removes the internal `EmitterOptionsSchema` export. diff --git a/.chronus/changes/openapi3-tsp-options-2026-6-26-10-30-0.md b/.chronus/changes/openapi3-tsp-options-2026-6-26-10-30-0.md new file mode 100644 index 00000000000..3df01625eb4 --- /dev/null +++ b/.chronus/changes/openapi3-tsp-options-2026-6-26-10-30-0.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/openapi3" +--- + +Migrate the OpenAPI3 emitter to define its options as a TypeSpec model (`options/main.tsp`, exported via `exports["./options"].typespec`) instead of a hand-written JSON schema. The compiler now validates user options against that model. Removes the internal `EmitterOptionsSchema` export. diff --git a/.chronus/changes/tspd-emitter-options-model-docs-2026-6-26-11-0-0.md b/.chronus/changes/tspd-emitter-options-model-docs-2026-6-26-11-0-0.md new file mode 100644 index 00000000000..a88b904a1aa --- /dev/null +++ b/.chronus/changes/tspd-emitter-options-model-docs-2026-6-26-11-0-0.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/tspd" +--- + +Generate emitter options reference docs from an emitter's TypeSpec `options/main.tsp` model when no legacy JSON-schema validator is present. diff --git a/packages/bundler/src/bundler.ts b/packages/bundler/src/bundler.ts index 367c42ad482..c94311c668e 100644 --- a/packages/bundler/src/bundler.ts +++ b/packages/bundler/src/bundler.ts @@ -210,11 +210,15 @@ async function createEsBuildContext( }); const extraEntry = Object.fromEntries( - Object.entries(definition.exports).map(([key, value]) => { - return [ - key.replace("./", ""), - normalizePath(resolve(libraryPath, getExportEntryPoint(value))), - ]; + Object.entries(definition.exports).flatMap(([key, value]) => { + const entryPoint = getExportEntryPoint(value); + // Skip exports that only expose a TypeSpec entrypoint (e.g. `./emitter`, + // `./options`). Those have no JS module to bundle and their source files are + // already included via the sub-export compilation loop above. + if (entryPoint === undefined) { + return []; + } + return [[key.replace("./", ""), normalizePath(resolve(libraryPath, entryPoint))]]; }), ); @@ -316,16 +320,13 @@ async function resolveTypeSpecBundle( }; } -function getExportEntryPoint(value: string | ExportData) { - const resolved = typeof value === "string" ? value : (value.import ?? value.default); - - if (!resolved) { - throw new Error( - `Exports ${JSON.stringify(value, null, 2)} is missing import or default entrypoint`, - ); - } - - return resolved; +/** + * Resolve the JS entrypoint for an export entry, or `undefined` if the export only + * exposes a TypeSpec entrypoint (e.g. `{ "typespec": "./options/main.tsp" }`) and has + * no JS module to bundle. + */ +function getExportEntryPoint(value: string | ExportData): string | undefined { + return typeof value === "string" ? value : (value.import ?? value.default); } async function readLibraryPackageJson(path: string): Promise { const file = await readFile(join(path, "package.json")); diff --git a/packages/compiler/lib/emitter/main.tsp b/packages/compiler/lib/emitter/main.tsp new file mode 100644 index 00000000000..eb3d8aaff2c --- /dev/null +++ b/packages/compiler/lib/emitter/main.tsp @@ -0,0 +1,5 @@ +/** + * Define an config value that should resolve to an absolute path. + * `{project-dir}`, `{cwd}`, and other can be used to define a relative path. + */ +scalar absolutePath extends string; diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 4ebaa9f1536..4734bbdca4b 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -62,6 +62,9 @@ }, "./casing": { "import": "./dist/src/casing/index.js" + }, + "./emitter": { + "typespec": "./lib/emitter/main.tsp" } }, "browser": { diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index c1ea436b487..6ed891ede4d 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -537,9 +537,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const postCheckValidators: ValidatorFn[] = []; const typespecNamespaceBinding = resolver.symbols.global.exports!.get("TypeSpec"); - if (typespecNamespaceBinding) { - initializeTypeSpecIntrinsics(); - } + initializeTypeSpecIntrinsics(); /** * Tracking the template parameters used or not. diff --git a/packages/compiler/src/core/diagnostics.ts b/packages/compiler/src/core/diagnostics.ts index 180661c70d7..a3fa0126895 100644 --- a/packages/compiler/src/core/diagnostics.ts +++ b/packages/compiler/src/core/diagnostics.ts @@ -342,6 +342,10 @@ export function createDiagnosticCollector(): DiagnosticCollector { } } +export function err(diagnostic: Diagnostic): DiagnosticResult { + return [undefined, [diagnostic]]; +} + /** * Ignore the diagnostics emitted by the diagnostic accessor pattern and just return the actual result. * @param result Accessor pattern tuple result including the actual result and the list of diagnostics. diff --git a/packages/compiler/src/core/emitter-options.ts b/packages/compiler/src/core/emitter-options.ts new file mode 100644 index 00000000000..4fb72d45908 --- /dev/null +++ b/packages/compiler/src/core/emitter-options.ts @@ -0,0 +1,82 @@ +import { getLocationInYamlScript } from "../yaml/diagnostics.js"; +import { YamlScript } from "../yaml/types.js"; +import { createDiagnosticCollector, err } from "./diagnostics.js"; +import { validateEmitterOptions } from "./emitter-options/validator.js"; +import { createDiagnostic } from "./messages.js"; +import { Program, TypeGraph } from "./program.js"; +import { createSourceFile } from "./source-file.js"; +import { Diagnostic, Model, NoTarget } from "./types.js"; + +export function resolveEmitterOptions( + typeGraph: TypeGraph, +): [Model | undefined, readonly Diagnostic[]] { + const [root] = typeGraph.resolveTypeReference("EmitterOptions"); + const diagnostics = createDiagnosticCollector(); + + if (root === undefined) { + return [ + undefined, + [ + createDiagnostic({ + code: "missing-emitter-options", + target: { file: createSourceFile("", typeGraph.entrypoint), pos: 0, end: 0 }, + }), + ], + ]; + } + if (root.kind !== "Model") { + return err( + createDiagnostic({ + code: "emitter-options-not-model", + target: root, + }), + ); + } + return diagnostics.wrap(root); +} + +/** + * Where to anchor diagnostics produced while validating emitter options. + * `script` is the parsed `tspconfig.yaml` and `basePath` the path to the + * emitter's options inside it (e.g. `["options", "@typespec/openapi3"]`). + */ +export interface EmitterOptionsConfigTarget { + readonly script: YamlScript; + readonly basePath: string[]; +} + +/** + * Validate user provided emitter options against the `EmitterOptions` model + * declared by an emitter and turn validation errors into diagnostics anchored in + * the `tspconfig.yaml` when available. + */ +export function validateEmitterOptionsAgainstModel( + program: Program, + options: Record, + model: Model, + target: EmitterOptionsConfigTarget | typeof NoTarget, +): readonly Diagnostic[] { + const errors = validateEmitterOptions(program, options, model); + return errors.map((error): Diagnostic => { + const diagnosticTarget = + target === NoTarget + ? NoTarget + : getLocationInYamlScript(target.script, [...target.basePath, ...error.target], "key"); + + // Re-emit the dedicated `config-path-absolute` diagnostic so options typed with the + // `absolutePath` scalar keep parity with the legacy JSON-schema `format: absolute-path`. + if (error.code === "config-path-absolute") { + return createDiagnostic({ + code: "config-path-absolute", + format: { path: error.value ?? "" }, + target: diagnosticTarget, + }); + } + + return createDiagnostic({ + code: "invalid-emitter-options", + format: { message: error.message }, + target: diagnosticTarget, + }); + }); +} diff --git a/packages/compiler/src/core/emitter-options/validator.test.ts b/packages/compiler/src/core/emitter-options/validator.test.ts new file mode 100644 index 00000000000..c51e7ae66b0 --- /dev/null +++ b/packages/compiler/src/core/emitter-options/validator.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from "vitest"; +import { Tester } from "../../../test/tester.js"; +import { $ } from "../../typekit/index.js"; +import { compilerAssert } from "../diagnostics.js"; +import { validateEmitterOptions, ValidationError } from "./validator.js"; + +async function validateOptions(code: string, value: unknown): Promise { + const { program } = await Tester.compile(code); + + const type = $(program).type.resolve("EmitterOptions"); + compilerAssert(type, "EmitterOptions type not found"); + return validateEmitterOptions(program, value, type); +} + +describe("scalars", () => { + it("pass", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop: string; + }`, + { prop: "hello" }, + ); + expect(errors).toEqual([]); + }); + + describe("supported numeric scalars", () => { + it.each([ + ["int64", 1], + ["uint64", 1], + ["integer", 1], + ["float", 1.5], + ["decimal", 1.5], + ["numeric", 1], + ["safeint", 1], + ])("%s accepts a number", async (typeStr, value) => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop: ${typeStr}; + }`, + { prop: value }, + ); + expect(errors).toEqual([]); + }); + + it.each([["int64"], ["integer"], ["float"], ["numeric"]])( + "%s rejects a non-number", + async (typeStr) => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop: ${typeStr}; + }`, + { prop: "not a number" }, + ); + expect(errors).toEqual([ + { + code: "type-mismatch", + message: "Expected type number", + target: ["prop"], + }, + ]); + }, + ); + }); +}); + +describe("absolutePath", () => { + it("passes for an absolute path", async () => { + const errors = await validateOptions( + ` + scalar absolutePath extends string; + model EmitterOptions { + prop: absolutePath; + }`, + { prop: "/out/dir" }, + ); + expect(errors).toEqual([]); + }); + + it("errors for a relative path starting with `./`", async () => { + const errors = await validateOptions( + ` + scalar absolutePath extends string; + model EmitterOptions { + prop: absolutePath; + }`, + { prop: "./out" }, + ); + expect(errors).toEqual([ + { + code: "config-path-absolute", + message: `Path "./out" cannot be relative. Use {cwd} or {project-root} to specify what the path should be relative to.`, + target: ["prop"], + value: "./out", + }, + ]); + }); + + it("errors for a bare relative path", async () => { + const errors = await validateOptions( + ` + scalar absolutePath extends string; + model EmitterOptions { + prop: absolutePath; + }`, + { prop: "out" }, + ); + expect(errors).toEqual([ + { + code: "config-path-absolute", + message: `Path "out" cannot be relative. Use {cwd} or {project-root} to specify what the path should be relative to.`, + target: ["prop"], + value: "out", + }, + ]); + }); +}); + +describe("@pattern", () => { + it("validate @pattern defined on property", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + @pattern("^hello$") + prop: string; + }`, + { prop: "hellobar" }, + ); + expect(errors).toEqual([ + { + code: "invalid-pattern", + message: "hellobar does not match pattern /^hello$/", + target: ["prop"], + }, + ]); + }); +}); + +describe("arrays", () => { + it("pass", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop: string[]; + }`, + { prop: ["hello", "world"] }, + ); + expect(errors).toEqual([]); + }); + + it("error if passing non array", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop: string[]; + }`, + { prop: "hello" }, + ); + expect(errors).toEqual([ + { + code: "type-mismatch", + message: "Expected type array", + target: ["prop"], + }, + ]); + }); +}); + +describe("optional/required properties", () => { + it("missing optional property passes", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop?: string; + }`, + {}, + ); + expect(errors).toEqual([]); + }); + + it("missing required property errors", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop: string; + }`, + {}, + ); + expect(errors).toEqual([ + { + code: "missing-property", + message: `Missing required property "prop"`, + target: ["prop"], + }, + ]); + }); +}); + +describe("unknown properties", () => { + it("errors on unknown property", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop?: string; + }`, + { other: "hello" }, + ); + expect(errors).toEqual([ + { + code: "unknown-property", + message: `Unknown property "other"`, + target: ["other"], + }, + ]); + }); +}); + +describe("unions", () => { + it("passes when value matches a string literal variant", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop?: "yaml" | "json"; + }`, + { prop: "json" }, + ); + expect(errors).toEqual([]); + }); + + it("errors with allowed values when no literal variant matches", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop?: "yaml" | "json"; + }`, + { prop: "xml" }, + ); + expect(errors).toEqual([ + { + code: "invalid-value", + message: `Value "xml" is not one of the allowed values: "yaml", "json"`, + target: ["prop"], + }, + ]); + }); + + it("passes when value matches a model variant", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop?: "a" | { kind: "a" | "b", separator?: string }; + }`, + { prop: { kind: "b", separator: "/" } }, + ); + expect(errors).toEqual([]); + }); +}); + +describe("enums", () => { + it("passes when value matches an enum member", async () => { + const errors = await validateOptions( + ` + enum Color { Red: "red", Blue: "blue" } + model EmitterOptions { + prop?: Color; + }`, + { prop: "red" }, + ); + expect(errors).toEqual([]); + }); + + it("errors when value is not an enum member", async () => { + const errors = await validateOptions( + ` + enum Color { Red: "red", Blue: "blue" } + model EmitterOptions { + prop?: Color; + }`, + { prop: "green" }, + ); + expect(errors).toEqual([ + { + code: "invalid-value", + message: `Value "green" is not one of the allowed values: "red", "blue"`, + target: ["prop"], + }, + ]); + }); +}); + +describe("custom scalars", () => { + it("validates a custom scalar against its built-in base", async () => { + const errors = await validateOptions( + ` + scalar myPath extends string; + model EmitterOptions { + prop?: myPath; + }`, + { prop: 123 }, + ); + expect(errors).toEqual([ + { + code: "type-mismatch", + message: "Expected type string", + target: ["prop"], + }, + ]); + }); +}); + +describe("Record", () => { + it("validates every entry against the value type", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop?: Record; + }`, + { prop: { a: "x", b: 1 } }, + ); + expect(errors).toEqual([ + { + code: "type-mismatch", + message: "Expected type string", + target: ["prop", "b"], + }, + ]); + }); +}); diff --git a/packages/compiler/src/core/emitter-options/validator.ts b/packages/compiler/src/core/emitter-options/validator.ts new file mode 100644 index 00000000000..8d94c563657 --- /dev/null +++ b/packages/compiler/src/core/emitter-options/validator.ts @@ -0,0 +1,332 @@ +import { getPattern } from "../../lib/decorators.js"; +import { isPathAbsolute } from "../path-utils.js"; +import { Program } from "../program.js"; +import { isArrayModelType } from "../type-utils.js"; +import type { ArrayModelType, Enum, Model, Scalar, StdTypeName, Type, Union } from "../types.js"; + +export interface ValidationError { + code: string; + message: string; + target: string[]; + /** Raw offending value, carried for codes that re-emit a specific diagnostic (e.g. `config-path-absolute`). */ + value?: string; +} + +const knownScalarNames = new Set([ + "string", + "url", + "boolean", + "bytes", + "numeric", + "integer", + "float", + "decimal", + "decimal128", + "int8", + "int16", + "int32", + "int64", + "uint8", + "uint16", + "uint32", + "uint64", + "safeint", + "float32", + "float64", +]); + +export function validateEmitterOptions( + program: Program, + value: unknown, + type: Type, +): readonly ValidationError[] { + switch (type.kind) { + case "Model": + if (isArrayModelType(type)) { + return validateArray(program, value, type); + } + return validateModel(program, value, type); + case "Scalar": + return validateScalar(value, type); + case "Union": + return validateUnion(program, value, type); + case "Enum": + return validateEnum(value, type); + case "String": + case "Number": + case "Boolean": + return validateLiteral(value, type.value); + } + return []; +} + +function validateModel(program: Program, value: unknown, type: Model): readonly ValidationError[] { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return [ + { + code: "type-mismatch", + message: `Expected type object`, + target: [], + }, + ]; + } + + const errors: ValidationError[] = []; + const valObj = value as Record; + + // `Record` style models: validate every entry against the indexer value type. + if (type.indexer && type.indexer.key.name === "string") { + for (const [key, entryValue] of Object.entries(valObj)) { + const entryErrors = validateEmitterOptions(program, entryValue, type.indexer.value); + for (const err of entryErrors) { + errors.push({ ...err, target: [key, ...err.target] }); + } + } + return errors; + } + + for (const propType of type.properties.values()) { + const propValue = valObj[propType.name]; + if (propValue === undefined) { + if (!propType.optional) { + errors.push({ + code: "missing-property", + message: `Missing required property "${propType.name}"`, + target: [propType.name], + }); + } + continue; + } + const propErrors = validateEmitterOptions(program, propValue, propType.type); + for (const err of propErrors) { + errors.push({ + ...err, + target: [propType.name, ...err.target], + }); + } + const pattern = getPattern(program, propType); + if (pattern) { + if (typeof propValue !== "string" || !new RegExp(pattern).test(propValue)) { + errors.push({ + code: "invalid-pattern", + message: `${propValue} does not match pattern /${pattern}/`, + target: [propType.name], + }); + } + } + } + + // Reject unknown properties for plain (non-indexed) models. + for (const key of Object.keys(valObj)) { + if (!type.properties.has(key)) { + errors.push({ + code: "unknown-property", + message: `Unknown property "${key}"`, + target: [key], + }); + } + } + return errors; +} + +function validateArray( + program: Program, + value: unknown, + type: ArrayModelType, +): readonly ValidationError[] { + if (!Array.isArray(value)) { + return [ + { + code: "type-mismatch", + message: `Expected type array`, + target: [], + }, + ]; + } + const errors: ValidationError[] = []; + for (let i = 0; i < value.length; i++) { + const itemErrors = validateEmitterOptions(program, value[i], type.indexer.value); + for (const err of itemErrors) { + errors.push({ + ...err, + target: [i.toString(), ...err.target], + }); + } + } + return errors; +} + +function validateUnion(program: Program, value: unknown, type: Union): readonly ValidationError[] { + const variants = [...type.variants.values()]; + for (const variant of variants) { + if (validateEmitterOptions(program, value, variant.type).length === 0) { + return []; + } + } + + const literals = collectLiteralValues(variants); + const message = + literals !== undefined + ? `Value ${JSON.stringify(value)} is not one of the allowed values: ${literals + .map((l) => JSON.stringify(l)) + .join(", ")}` + : `Value ${JSON.stringify(value)} does not match any of the expected types.`; + return [{ code: "invalid-value", message, target: [] }]; +} + +function validateEnum(value: unknown, type: Enum): readonly ValidationError[] { + const allowed: (string | number)[] = []; + for (const member of type.members.values()) { + const memberValue = member.value ?? member.name; + allowed.push(memberValue); + if (value === memberValue) { + return []; + } + } + return [ + { + code: "invalid-value", + message: `Value ${JSON.stringify(value)} is not one of the allowed values: ${allowed + .map((l) => JSON.stringify(l)) + .join(", ")}`, + target: [], + }, + ]; +} + +function validateLiteral( + value: unknown, + expected: string | number | boolean, +): readonly ValidationError[] { + if (value === expected) { + return []; + } + return [ + { + code: "invalid-value", + message: `Expected ${JSON.stringify(expected)}`, + target: [], + }, + ]; +} + +/** + * If every variant of a union resolves to a literal (or enum) value, return the + * flattened list of allowed values so we can produce a friendly error message. + */ +function collectLiteralValues( + variants: { type: Type }[], +): (string | number | boolean)[] | undefined { + const values: (string | number | boolean)[] = []; + for (const variant of variants) { + const type = variant.type; + switch (type.kind) { + case "String": + case "Number": + case "Boolean": + values.push(type.value); + break; + case "Enum": + for (const member of type.members.values()) { + values.push(member.value ?? member.name); + } + break; + default: + return undefined; + } + } + return values; +} + +function validateScalar(value: unknown, type: Scalar): readonly ValidationError[] { + // Special-case the built-in `absolutePath` scalar (from `@typespec/compiler/emitter`): + // it extends `string` but additionally requires the value to be an absolute path. This + // mirrors the legacy JSON-schema `format: absolute-path` validation. + for (let scalar: Scalar | undefined = type; scalar; scalar = scalar.baseScalar) { + if (scalar.name === "absolutePath") { + if (typeof value === "string" && (value.startsWith(".") || !isPathAbsolute(value))) { + return [ + { + code: "config-path-absolute", + message: `Path "${value}" cannot be relative. Use {cwd} or {project-root} to specify what the path should be relative to.`, + target: [], + value, + }, + ]; + } + break; + } + } + + // Resolve custom scalars (e.g. `scalar absolutePath extends string`) to their + // known built-in base so they validate against the underlying representation. + let current: Scalar | undefined = type; + while (current && !knownScalarNames.has(current.name as StdTypeName)) { + current = current.baseScalar; + } + if (current === undefined) { + return [ + { + code: "unsupported", + message: `${type.name} is not supported for emitter options.`, + target: [], + }, + ]; + } + return validateBuiltinScalar(value, current.name as StdTypeName, []); +} + +function validateBuiltinScalar( + value: unknown, + name: StdTypeName, + target: string[], +): readonly ValidationError[] { + switch (name) { + case "string": + case "url": + return assertType(value, "string", target); + case "boolean": + return assertType(value, "boolean", target); + case "numeric": + case "integer": + case "float": + case "decimal": + case "decimal128": + case "int8": + case "int16": + case "int32": + case "int64": + case "uint8": + case "uint16": + case "uint32": + case "uint64": + case "safeint": + case "float32": + case "float64": + return assertType(value, "number", target); + case "bytes": + if (value instanceof Uint8Array) { + return []; + } + return [{ code: "type-mismatch", message: `Expected type bytes`, target }]; + default: + return [ + { + code: "unsupported", + message: `${name} is not supported for emitter options.`, + target, + }, + ]; + } +} + +function assertType( + value: unknown, + expectedType: string, + target: string[], +): readonly ValidationError[] { + if (typeof value === expectedType) { + return []; + } + return [{ code: "type-mismatch", message: `Expected type ${expectedType}`, target }]; +} diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index f4ba1c4fbbd..a281862ca83 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -767,6 +767,30 @@ const diagnostics = { "Value interpolated in this string template cannot be converted to a string. Only literal types can be automatically interpolated.", }, }, + "missing-emitter-options": { + severity: "error", + messages: { + default: "Emitter options should export an `EmitterOptions` model at the top level.", + }, + }, + "emitter-options-not-model": { + severity: "error", + messages: { + default: "Emitter options should be a model type.", + }, + }, + "invalid-emitter-options": { + severity: "error", + messages: { + default: paramMessage`${"message"}`, + }, + }, + "invalid-emitter-options-definition": { + severity: "error", + messages: { + default: paramMessage`Emitter "${"emitter"}" has an invalid options definition:\n${"message"}`, + }, + }, /** * Binder diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index cf03ca50562..33529662d9c 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -12,6 +12,7 @@ import { createBinder } from "./binder.js"; import { Checker, createChecker } from "./checker.js"; import { createSuppressCodeFix } from "./compiler-code-fixes/suppress.codefix.js"; import { compilerAssert } from "./diagnostics.js"; +import { resolveEmitterOptions, validateEmitterOptionsAgainstModel } from "./emitter-options.js"; import { getEmittedFilesForProgram } from "./emitter-utils.js"; import { resolveTypeSpecEntrypoint } from "./entrypoint-resolution.js"; import { ExternalError } from "./external-error.js"; @@ -62,6 +63,7 @@ import { Namespace, NoTarget, Node, + PackageFlags, PerfReporter, SourceFile, Sym, @@ -137,6 +139,9 @@ export interface Program { * Project root. If a tsconfig was found/specified this is the directory for the tsconfig.json. Otherwise directory where the entrypoint is located. */ readonly projectRoot: string; + + /** @internal Main type graph. */ + readonly typeGraph: TypeGraph; } interface EmitterRef { @@ -207,146 +212,114 @@ export async function compile( return program; } -async function createProgram( - host: CompilerHost, - mainFile: string, - options: CompilerOptions = {}, +export interface TypeGraph { + readonly globalNamespace: Namespace; + /** Complexity statistics */ + readonly complexityStats: ComplexityStats; + /** Runtime statistics */ + readonly runtimeStats: RuntimeStats; + + /** + * Checker used + * @internal + */ + readonly checker: Checker; + + /** + * Entry point of that type graph + */ + readonly entrypoint: string; + + /** + * TypeSpec source files that make up this type graph. + * @internal + */ + readonly sourceFiles: Map; + + /** + * JS source files that make up this type graph. + * @internal + */ + readonly jsSourceFiles: Map; + + /** @internal */ + sourceResolution: SourceResolution; + + /** @internal */ + resolveTypeReference(reference: string): [Type | undefined, readonly Diagnostic[]]; + /** @internal */ + resolveTypeOrValueReference(reference: string): [Entity | undefined, readonly Diagnostic[]]; +} + +async function createTypeGraph( + program: Program, + resolvedMain: string, + options: CompilerOptions, + sourceFileCache: Map | undefined, oldProgram?: Program, -): Promise<{ program: Program; shouldAbort: boolean }> { +): Promise<{ typeGraph: TypeGraph; reusedProgram?: Program }> { + const host = program.host; + const binder = createBinder(program); const runtimeStats: Partial = {}; - const validateCbs: Validator[] = []; - const stateMaps = new Map>(); - const stateSets = new Map>(); - const diagnostics: Diagnostic[] = []; - const duplicateSymbols = new Set(); - const emitters: EmitterRef[] = []; - const requireImports = new Map(); const complexityStats: ComplexityStats = {} as any; - let sourceResolution: SourceResolution = undefined!; - let error = false; - let continueToNextStage = true; - // eslint-disable-next-line prefer-const -- reassigned after source resolution - let suppressionTracker: SuppressionTracker | undefined; - const logger = createLogger({ sink: host.logSink }); - const tracer = createTracer(logger, { filter: options.trace }); - const resolvedMain = await resolveTypeSpecEntrypoint(host, mainFile, reportDiagnostic); - const program: Program = { - checker: undefined!, - resolver: undefined!, - compilerOptions: resolveOptions(options), - sourceFiles: new Map(), - jsSourceFiles: new Map(), - literalTypes: new Map(), - host, - diagnostics, - emitters, - loadTypeSpecScript, - getOption, - stateMaps, - stateSets, - stats: { - complexity: complexityStats, - runtime: runtimeStats as any, - }, + const sourceLoader = await createSourceLoader(host, { + parseOptions: options.parseOptions, + tracer: program.tracer, + getCachedScript: (file) => sourceFileCache?.get(file.path) ?? host.parseCache?.get(file), + }); - tracer, - trace, - ...createStateAccessors(stateMaps, stateSets), - reportDiagnostic, - reportDiagnostics, - reportDuplicateSymbols, - get suppressionTracker() { - return suppressionTracker; - }, - hasError() { - return error; - }, - onValidate(cb, metadata) { - validateCbs.push({ callback: cb, metadata }); - }, - getGlobalNamespaceType, + const typeGraph: TypeGraph = { + entrypoint: resolvedMain, + globalNamespace: undefined!, + checker: undefined!, + sourceFiles: undefined!, + jsSourceFiles: undefined!, + complexityStats, + runtimeStats: runtimeStats as any, + sourceResolution: sourceLoader.resolution, resolveTypeReference, - /** @internal */ resolveTypeOrValueReference, - getSourceFileLocationContext, - projectRoot: getDirectoryPath(options.config ?? resolvedMain ?? ""), }; + mutate(program).typeGraph = typeGraph; - trace("compiler.options", JSON.stringify(options, null, 2)); - - function trace(area: string, message: string) { - tracer.trace(area, message); - } - const binder = createBinder(program); - - if (resolvedMain === undefined) { - return { program, shouldAbort: true }; - } - const basedir = getDirectoryPath(resolvedMain) || "/"; - await checkForCompilerVersionMismatch(basedir); - - runtimeStats.loader = await perf.timeAsync(() => loadSources(resolvedMain)); - - const emit = options.noEmit ? [] : (options.emit ?? []); - const emitterOptions = options.options; - - await loadEmitters(basedir, emit, emitterOptions ?? {}); + runtimeStats.loader = await perf.timeAsync(() => loadSources(sourceLoader, resolvedMain)); + // Incremental reuse: if the source files and compiler options are unchanged from + // a previous compilation, skip resolving/checking entirely and reuse the old + // program. This is the fast-path the language server / watch mode rely on. if ( oldProgram && mapEquals(oldProgram.sourceFiles, program.sourceFiles) && deepEquals(oldProgram.compilerOptions, program.compilerOptions) ) { - return { program: oldProgram, shouldAbort: true }; + return { typeGraph, reusedProgram: oldProgram }; } - // let GC reclaim old program, we do not reuse it beyond this point. - oldProgram = undefined; + // Set up suppression tracking for this graph now that sources are resolved, so + // diagnostics reported during checking can be marked as suppressed. + mutate(program).suppressionTracker = createSuppressionTracker(sourceLoader.resolution); - suppressionTracker = createSuppressionTracker(sourceResolution); + const resolver = createResolver(program); - const resolver = (program.resolver = createResolver(program)); + program.resolver = resolver; // Update the current resolver for back compat runtimeStats.resolver = perf.time(() => resolver.resolveProgram()); + const checker = createChecker(program, resolver); + mutate(typeGraph).checker = checker; + mutate(typeGraph).globalNamespace = checker.getGlobalNamespaceType(); + program.checker = checker; // Update current checker for back compat - const linter = createLinter(program, (name) => loadLibrary(basedir, name)); - linter.registerLinterLibrary(builtInLinterLibraryName, createBuiltInLinterLibrary()); - if (options.linterRuleSet) { - program.reportDiagnostics(await linter.extendRuleSet(options.linterRuleSet)); - } - - program.checker = createChecker(program, resolver); - runtimeStats.checker = perf.time(() => program.checker.checkProgram()); - - complexityStats.createdTypes = program.checker.stats.createdTypes; - complexityStats.finishedTypes = program.checker.stats.finishedTypes; - - if (!continueToNextStage) { - return { program, shouldAbort: true }; - } - - // onValidate stage - await runValidators(); - - validateRequiredImports(); - - await validateLoadedLibraries(); - - if (!continueToNextStage) { - return { program, shouldAbort: true }; - } - - // Linter stage - const lintResult = await linter.lint(); - runtimeStats.linter = lintResult.stats.runtime; - program.reportDiagnostics(lintResult.diagnostics); + runtimeStats.checker = perf.time(() => checker.checkProgram()); - return { program, shouldAbort: false }; + complexityStats.createdTypes = checker.stats.createdTypes; + complexityStats.finishedTypes = checker.stats.finishedTypes; + await validateLoadedLibraries(sourceLoader); + return { typeGraph }; /** * Validate the libraries loaded during the compilation process are compatible. */ - async function validateLoadedLibraries() { + async function validateLoadedLibraries(sourceLoader: SourceLoader) { const loadedRoots = new Set(); // Check all the files that were loaded for (const fileUrl of getLibraryUrlsLoaded()) { @@ -358,7 +331,7 @@ async function createProgram( } } - const libraries = new Map([...sourceResolution.loadedLibraries.entries()]); + const libraries = new Map([...sourceLoader.resolution.loadedLibraries.entries()]); const incompatibleLibraries = new Map(); for (const root of loadedRoots) { const packageJsonPath = joinPaths(root, "package.json"); @@ -380,7 +353,7 @@ async function createProgram( } for (const [name, incompatibleLibs] of incompatibleLibraries) { - reportDiagnostic( + program.reportDiagnostic( createDiagnostic({ code: "incompatible-library", format: { @@ -395,14 +368,7 @@ async function createProgram( } } - async function loadSources(entrypoint: string) { - const sourceLoader = await createSourceLoader(host, { - parseOptions: options.parseOptions, - tracer, - getCachedScript: (file) => - oldProgram?.sourceFiles.get(file.path) ?? host.parseCache?.get(file), - }); - + async function loadSources(sourceLoader: SourceLoader, entrypoint: string) { // intrinsic.tsp await loadIntrinsicTypes(sourceLoader); @@ -421,8 +387,10 @@ async function createProgram( }); } - sourceResolution = sourceLoader.resolution; + const sourceResolution = sourceLoader.resolution; + mutate(typeGraph).sourceFiles = sourceResolution.sourceFiles; + mutate(typeGraph).jsSourceFiles = sourceResolution.jsSourceFiles; program.sourceFiles = sourceResolution.sourceFiles; program.jsSourceFiles = sourceResolution.jsSourceFiles; @@ -452,6 +420,161 @@ async function createProgram( } } + // NOTE: These resolve* closures use this graph's own `checker` (via `typeGraph.checker`) + // and `resolver` so that resolution stays scoped to the graph that produced them, even + // if `program.checker` is later repointed at a different graph (e.g. an emitter-options + // graph compiled during `loadEmitter`). Other `program` state (sourceFiles, resolver, + // stateMaps, ...) is still shared and mutated across graphs; full per-graph isolation is + // intentionally deferred. + function resolveTypeReference(reference: string): [Type | undefined, readonly Diagnostic[]] { + const [node, parseDiagnostics] = parseStandaloneTypeReference(reference); + if (parseDiagnostics.length > 0) { + return [undefined, parseDiagnostics]; + } + const binder = createBinder(program); + binder.bindNode(node); + mutate(node).parent = resolver.symbols.global.declarations[0]; + resolver.resolveTypeReference(node); + return typeGraph.checker.resolveTypeReference(node); + } + + function resolveTypeOrValueReference( + reference: string, + ): [Entity | undefined, readonly Diagnostic[]] { + const [node, parseDiagnostics] = parseStandaloneTypeReference(reference); + if (parseDiagnostics.length > 0) { + return [undefined, parseDiagnostics]; + } + const binder = createBinder(program); + binder.bindNode(node); + mutate(node).parent = resolver.symbols.global.declarations[0]; + resolver.resolveTypeReference(node); + return typeGraph.checker.resolveTypeOrValueReference(node); + } +} + +async function createProgram( + host: CompilerHost, + mainFile: string, + options: CompilerOptions = {}, + oldProgram?: Program, +): Promise<{ program: Program; shouldAbort: boolean }> { + const runtimeStats: Partial = {}; + const validateCbs: Validator[] = []; + const stateMaps = new Map>(); + const stateSets = new Map>(); + const diagnostics: Diagnostic[] = []; + const duplicateSymbols = new Set(); + const emitters: EmitterRef[] = []; + const requireImports = new Map(); + const complexityStats: ComplexityStats = {} as any; + let error = false; + let continueToNextStage = true; + + const logger = createLogger({ sink: host.logSink }); + const tracer = createTracer(logger, { filter: options.trace }); + const resolvedMain = await resolveTypeSpecEntrypoint(host, mainFile, reportDiagnostic); + const program: Program = { + checker: undefined!, + resolver: undefined!, + typeGraph: undefined!, + compilerOptions: resolveOptions(options), + sourceFiles: new Map(), + jsSourceFiles: new Map(), + literalTypes: new Map(), + host, + diagnostics, + emitters, + loadTypeSpecScript, + getOption, + stateMaps, + stateSets, + stats: { + complexity: complexityStats, + runtime: runtimeStats as any, + }, + + tracer, + trace, + ...createStateAccessors(stateMaps, stateSets), + reportDiagnostic, + reportDiagnostics, + reportDuplicateSymbols, + suppressionTracker: undefined, + hasError() { + return error; + }, + onValidate(cb, metadata) { + validateCbs.push({ callback: cb, metadata }); + }, + getGlobalNamespaceType, + resolveTypeReference, + /** @internal */ + resolveTypeOrValueReference, + getSourceFileLocationContext, + projectRoot: getDirectoryPath(options.config ?? resolvedMain ?? ""), + }; + + trace("compiler.options", JSON.stringify(options, null, 2)); + + function trace(area: string, message: string) { + tracer.trace(area, message); + } + + if (resolvedMain === undefined) { + return { program, shouldAbort: true }; + } + const basedir = getDirectoryPath(resolvedMain) || "/"; + await checkForCompilerVersionMismatch(basedir); + + const binder = createBinder(program); + + const emit = options.noEmit ? [] : (options.emit ?? []); + const emitterOptions = options.options; + + await loadEmitters(basedir, emit, emitterOptions ?? {}); + + const linter = createLinter(program, (name) => loadLibrary(basedir, name)); + linter.registerLinterLibrary(builtInLinterLibraryName, createBuiltInLinterLibrary()); + if (options.linterRuleSet) { + program.reportDiagnostics(await linter.extendRuleSet(options.linterRuleSet)); + } + + // let GC reclaim old program, we do not reuse it beyond this point. + const { typeGraph, reusedProgram } = await createTypeGraph( + program, + resolvedMain, + options, + oldProgram?.sourceFiles, + oldProgram, + ); + if (reusedProgram) { + return { program: reusedProgram, shouldAbort: true }; + } + oldProgram = undefined; + program.checker = typeGraph.checker; + Object.assign(complexityStats, typeGraph.complexityStats); + + if (!continueToNextStage) { + return { program, shouldAbort: true }; + } + + // onValidate stage + await runValidators(); + + validateRequiredImports(); + + if (!continueToNextStage) { + return { program, shouldAbort: true }; + } + + // Linter stage + const lintResult = await linter.lint(); + runtimeStats.linter = lintResult.stats.runtime; + program.reportDiagnostics(lintResult.diagnostics); + + return { program, shouldAbort: false }; + async function loadTypeSpecScript(file: SourceFile): Promise { // This is not a diagnostic because the compiler should never reuse the same path. // It's the caller's responsibility to use unique paths. @@ -477,7 +600,7 @@ async function createProgram( } function getSourceFileLocationContext(sourcefile: SourceFile): LocationContext { - const locationContext = sourceResolution.locationContexts.get(sourcefile); + const locationContext = program.typeGraph.sourceResolution.locationContexts.get(sourcefile); compilerAssert(locationContext, "SourceFile should have a declaration locationContext."); return locationContext; } @@ -568,7 +691,16 @@ async function createProgram( } } if (emitFunction !== undefined) { + // TODO-TIM reuse the module resolution logic for m more robust resolution. + const optionsEntrypoint = + library.module.type === "module" && + (library.module.manifest.exports as any)?.["./options"]?.["typespec"]; if (libDefinition?.emitter?.options) { + // Legacy JSON-schema based validation. While an emitter still ships a + // legacy validator it remains authoritative; the TypeSpec-options graph is + // only enforced once an emitter has fully migrated (see `else if` below). + // This is transitional: the experimental option models are not yet a + // complete source of truth for already-shipped emitters. const diagnostics = libDefinition?.emitterOptionValidator?.validate( emitterOptions, options.configFile?.file @@ -583,6 +715,65 @@ async function createProgram( program.reportDiagnostics(diagnostics); return; } + } else if ( + optionsEntrypoint && + (entrypoint.esmExports.$flags as PackageFlags | undefined)?.experimentalEmitterOptions + ) { + // Emitter declares its options as a TypeSpec file (and has no legacy + // validator): compile it into its own type graph and validate the user + // options against the exported `EmitterOptions` model. + // + // Gated behind the per-emitter, experimental `experimentalEmitterOptions` + // package flag (`definePackageFlags`). When an emitter does not opt in this + // path is skipped entirely and such emitters get no options validation (the + // pre-existing behavior). + const fullPath = resolvePath(library.module.path, optionsEntrypoint); + const diagnosticStart = program.diagnostics.length; + const { typeGraph } = await createTypeGraph( + program, + fullPath, + options, + program.sourceFiles, + ); + const [model, optionDiagnostics] = resolveEmitterOptions(typeGraph); + + // Diagnostics produced while compiling the emitter's OWN options file are + // emitter-author problems, not user problems. If compiling it produced errors, + // collapse them into a single clearly-attributed diagnostic instead of leaking + // confusing library-internal diagnostics onto the user's program. + const optionsFileErrors = program.diagnostics + .slice(diagnosticStart) + .filter((d) => d.severity === "error"); + if (optionsFileErrors.length > 0) { + (program.diagnostics as Diagnostic[]).length = diagnosticStart; + program.reportDiagnostics([ + createDiagnostic({ + code: "invalid-emitter-options-definition", + format: { + emitter: metadata.name ?? emitterNameOrPath, + message: optionsFileErrors.map((d) => d.message).join("\n"), + }, + target: NoTarget, + }), + ]); + return; + } + + program.reportDiagnostics(optionDiagnostics); + if (model) { + const validationDiagnostics = validateEmitterOptionsAgainstModel( + program, + emitterOptions, + model, + options.configFile?.file + ? { script: options.configFile.file, basePath: ["options", emitterNameOrPath] } + : NoTarget, + ); + if (validationDiagnostics.length > 0) { + program.reportDiagnostics(validationDiagnostics); + return; + } + } } return { main: entrypoint.file.path, @@ -685,7 +876,7 @@ async function createProgram( function validateRequiredImports() { for (const [requiredImport, emitterName] of requireImports) { - if (!sourceResolution.loadedLibraries.has(requiredImport)) { + if (!typeGraph.sourceResolution.loadedLibraries.has(requiredImport)) { program.reportDiagnostic( createDiagnostic({ code: "missing-import", @@ -832,7 +1023,7 @@ async function createProgram( if (suppressing) { if (diagnostic.severity === "error") { // Cannot suppress errors. - suppressionTracker?.markUsed(suppressing.node); + program.suppressionTracker?.markUsed(suppressing.node); diagnostics.push({ severity: "error", code: "suppress-error", @@ -842,7 +1033,7 @@ async function createProgram( return false; } else { - suppressionTracker?.markUsed(suppressing.node); + program.suppressionTracker?.markUsed(suppressing.node); return true; } } @@ -896,29 +1087,13 @@ async function createProgram( } function resolveTypeReference(reference: string): [Type | undefined, readonly Diagnostic[]] { - const [node, parseDiagnostics] = parseStandaloneTypeReference(reference); - if (parseDiagnostics.length > 0) { - return [undefined, parseDiagnostics]; - } - const binder = createBinder(program); - binder.bindNode(node); - mutate(node).parent = resolver.symbols.global.declarations[0]; - resolver.resolveTypeReference(node); - return program.checker.resolveTypeReference(node); + return program.typeGraph.resolveTypeReference(reference); } function resolveTypeOrValueReference( reference: string, ): [Entity | undefined, readonly Diagnostic[]] { - const [node, parseDiagnostics] = parseStandaloneTypeReference(reference); - if (parseDiagnostics.length > 0) { - return [undefined, parseDiagnostics]; - } - const binder = createBinder(program); - binder.bindNode(node); - mutate(node).parent = resolver.symbols.global.declarations[0]; - resolver.resolveTypeReference(node); - return program.checker.resolveTypeOrValueReference(node); + return program.typeGraph.resolveTypeOrValueReference(reference); } } diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 50c36efbf4b..e0aac824f01 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -2530,7 +2530,15 @@ export interface FunctionImplementations { }; } -export interface PackageFlags {} +export interface PackageFlags { + /** + * Opt into validating user-provided emitter options against the `EmitterOptions` TypeSpec + * model exported by this emitter (via the `./options` package export). This is an + * experimental, per-emitter opt-in: when omitted (the default) the emitter's TypeSpec + * options model is not used for runtime validation. + */ + readonly experimentalEmitterOptions?: boolean; +} export interface LinterDefinition { rules: LinterRuleDefinition[]; diff --git a/packages/compiler/test/core/emitter-options.test.ts b/packages/compiler/test/core/emitter-options.test.ts index d0aa5077980..775ef1b7ee9 100644 --- a/packages/compiler/test/core/emitter-options.test.ts +++ b/packages/compiler/test/core/emitter-options.test.ts @@ -128,3 +128,112 @@ describe("compiler: emitter options", () => { }); }); }); + +describe("compiler: emitter options defined in TypeSpec", () => { + const tspOptionsEmitter = createTypeSpecLibrary({ + name: "tsp-options-emitter", + diagnostics: {}, + }); + + async function diagnoseEmitterOptions( + options: Record, + { optedIn = true }: { optedIn?: boolean } = {}, + ): Promise { + return Tester.files({ + "node_modules/tsp-options-emitter/package.json": JSON.stringify({ + main: "index.js", + exports: { + ".": "./index.js", + "./options": { typespec: "./options.tsp" }, + }, + }), + "node_modules/tsp-options-emitter/options.tsp": `model EmitterOptions { + name?: string; + count?: int32; + format?: "yaml" | "json"; + }`, + "node_modules/tsp-options-emitter/index.js": mockFile.js({ + $lib: tspOptionsEmitter, + $onEmit: () => {}, + ...(optedIn ? { $flags: { experimentalEmitterOptions: true } } : {}), + }), + }).diagnose("", { + compilerOptions: { + emit: ["tsp-options-emitter"], + options: { + "tsp-options-emitter": options, + }, + }, + }); + } + + it("passes valid options", async () => { + const diagnostics = await diagnoseEmitterOptions({ + "emitter-output-dir": "/out", + name: "hello", + count: 3, + format: "json", + }); + expectDiagnosticEmpty(diagnostics); + }); + + it("emits diagnostic for an unknown property", async () => { + const diagnostics = await diagnoseEmitterOptions({ "not-an-option": true }); + expectDiagnostics(diagnostics, { + code: "invalid-emitter-options", + message: `Unknown property "not-an-option"`, + }); + }); + + it("emits diagnostic for an invalid value type", async () => { + const diagnostics = await diagnoseEmitterOptions({ count: "not a number" }); + expectDiagnostics(diagnostics, { + code: "invalid-emitter-options", + message: "Expected type number", + }); + }); + + it("emits diagnostic for a value outside an allowed union", async () => { + const diagnostics = await diagnoseEmitterOptions({ format: "xml" }); + expectDiagnostics(diagnostics, { + code: "invalid-emitter-options", + message: `Value "xml" is not one of the allowed values: "yaml", "json"`, + }); + }); + + it("does not validate options when the emitter has not opted in", async () => { + const diagnostics = await diagnoseEmitterOptions( + { "not-an-option": true, count: "not a number" }, + { optedIn: false }, + ); + expectDiagnosticEmpty(diagnostics); + }); + + it("attributes errors in the emitter's own options file to the emitter author", async () => { + const diagnostics = await Tester.files({ + "node_modules/tsp-options-emitter/package.json": JSON.stringify({ + main: "index.js", + exports: { + ".": "./index.js", + "./options": { typespec: "./options.tsp" }, + }, + }), + "node_modules/tsp-options-emitter/options.tsp": `model EmitterOptions { + name?: NotARealType; + }`, + "node_modules/tsp-options-emitter/index.js": mockFile.js({ + $lib: tspOptionsEmitter, + $onEmit: () => {}, + $flags: { experimentalEmitterOptions: true }, + }), + }).diagnose("", { + compilerOptions: { + emit: ["tsp-options-emitter"], + options: { "tsp-options-emitter": {} }, + }, + }); + expectDiagnostics(diagnostics, { + code: "invalid-emitter-options-definition", + }); + }); +}); diff --git a/packages/json-schema/README.md b/packages/json-schema/README.md index 1e3af685da6..1f917705f4f 100644 --- a/packages/json-schema/README.md +++ b/packages/json-schema/README.md @@ -65,33 +65,39 @@ See [Configuring output directory for more info](https://typespec.io/docs/handbo **Type:** `"yaml" | "json"` Serialize the schema as either yaml or json. +Default `yaml`, if not specified infer from the `output-file` extension. ### `int64-strategy` **Type:** `"string" | "number"` -How to handle 64 bit integers on the wire. Options are: +How to handle 64-bit integers on the wire. Options are: -- string: serialize as a string (widely interoperable) -- number: serialize as a number (not widely interoperable) +- string: Serialize as a string (widely interoperable) +- number: Serialize as a number (not widely interoperable) ### `bundleId` **Type:** `string` -When provided, bundle all the schemas into a single json schema document with schemas under $defs. The provided id is the id of the root document and is also used for the file name. +When provided, bundle all the schemas into a single JSON Schema document +with schemas under $defs. The provided id is the id of the root document +and is also used for the file name. ### `emitAllModels` **Type:** `boolean` -When true, emit all model declarations to JSON Schema without requiring the @jsonSchema decorator. +When true, emit all model declarations to JSON Schema without requiring +the `@jsonSchema` decorator. ### `emitAllRefs` **Type:** `boolean` -When true, emit all references as json schema files, even if the referenced type does not have the `@jsonSchema` decorator or is not within a namespace with the `@jsonSchema` decorator. +When true, emit all references as JSON Schema files, even if the referenced +type does not have the `@jsonSchema` decorator or is not within a namespace +with the `@jsonSchema` decorator. ### `seal-object-schemas` @@ -101,7 +107,6 @@ When true, emit all references as json schema files, even if the referenced type If true, then for models emitted as object schemas we default `unevaluatedProperties` to `{ not: {} }`, if not explicitly specified elsewhere. -Default: `false` ### `polymorphic-models-strategy` @@ -109,16 +114,12 @@ Default: `false` **Default:** `"ignore"` -Strategy for emitting models with the @discriminator decorator: +Strategy for emitting models with the discriminator decorator. -- ignore: Emit as regular object schema (default). Derived models use allOf to reference their base model. +- ignore: Emit as regular object schema (default) - oneOf: Emit a oneOf schema with references to all derived models (closed union) - anyOf: Emit an anyOf schema with references to all derived models (open union) -When using oneOf or anyOf, derived models will inline all properties from their base model -instead of using allOf references. This avoids circular references in the generated schemas, -since the base model references derived models via oneOf/anyOf. - ## Decorators ### TypeSpec.JsonSchema diff --git a/packages/json-schema/generated-defs/emitter-options.ts b/packages/json-schema/generated-defs/emitter-options.ts new file mode 100644 index 00000000000..48e48a5f683 --- /dev/null +++ b/packages/json-schema/generated-defs/emitter-options.ts @@ -0,0 +1,46 @@ +/** + * Json schema emitter options + */ +export interface EmitterOptions { + /** + * Serialize the schema as either yaml or json. + * Default `yaml`, if not specified infer from the `output-file` extension. + */ + "file-type"?: "yaml" | "json"; + /** + * How to handle 64-bit integers on the wire. Options are: + * + * - string: Serialize as a string (widely interoperable) + * - number: Serialize as a number (not widely interoperable) + */ + "int64-strategy"?: "string" | "number"; + /** + * When provided, bundle all the schemas into a single JSON Schema document + * with schemas under $defs. The provided id is the id of the root document + * and is also used for the file name. + */ + bundleId?: string; + /** + * When true, emit all model declarations to JSON Schema without requiring + * the `@jsonSchema` decorator. + */ + emitAllModels?: boolean; + /** + * When true, emit all references as JSON Schema files, even if the referenced + * type does not have the `@jsonSchema` decorator or is not within a namespace + * with the `@jsonSchema` decorator. + */ + emitAllRefs?: boolean; + /** + * If true, then for models emitted as object schemas we default `unevaluatedProperties` to `{ not: {} }`, + * if not explicitly specified elsewhere. + */ + "seal-object-schemas"?: boolean; + /** + * Strategy for emitting models with the discriminator decorator. + * - ignore: Emit as regular object schema (default) + * - oneOf: Emit a oneOf schema with references to all derived models (closed union) + * - anyOf: Emit an anyOf schema with references to all derived models (open union) + */ + "polymorphic-models-strategy"?: "ignore" | "oneOf" | "anyOf"; +} diff --git a/packages/json-schema/options/main.tsp b/packages/json-schema/options/main.tsp new file mode 100644 index 00000000000..dad6fb22dcc --- /dev/null +++ b/packages/json-schema/options/main.tsp @@ -0,0 +1,77 @@ +import "@typespec/compiler/emitter"; + +/** + * Json schema emitter options + */ +model EmitterOptions { + /** + * Serialize the schema as either yaml or json. + * Default `yaml`, if not specified infer from the `output-file` extension. + */ + `file-type`?: FileType; + + /** + * How to handle 64-bit integers on the wire. Options are: + * + * - string: Serialize as a string (widely interoperable) + * - number: Serialize as a number (not widely interoperable) + */ + `int64-strategy`?: Int64Strategy; + + /** + * When provided, bundle all the schemas into a single JSON Schema document + * with schemas under $defs. The provided id is the id of the root document + * and is also used for the file name. + */ + bundleId?: string; + + /** + * When true, emit all model declarations to JSON Schema without requiring + * the `@jsonSchema` decorator. + */ + emitAllModels?: boolean; + + /** + * When true, emit all references as JSON Schema files, even if the referenced + * type does not have the `@jsonSchema` decorator or is not within a namespace + * with the `@jsonSchema` decorator. + */ + emitAllRefs?: boolean; + + /** + * If true, then for models emitted as object schemas we default `unevaluatedProperties` to `{ not: {} }`, + * if not explicitly specified elsewhere. + * @default false + */ + `seal-object-schemas`?: boolean; + + /** + * Strategy for emitting models with the discriminator decorator. + * - ignore: Emit as regular object schema (default) + * - oneOf: Emit a oneOf schema with references to all derived models (closed union) + * - anyOf: Emit an anyOf schema with references to all derived models (open union) + * @default "ignore" + */ + `polymorphic-models-strategy`?: PolymorphicModelsStrategy; +} + +/** + * File type + */ +alias FileType = "yaml" | "json"; + +/** + * Strategy for handling the int64 type in the resulting json schema. + * - string: As a string + * - number: As a number (In JavaScript, int64 cannot be accurately represented as number) + * + */ +alias Int64Strategy = "string" | "number"; + +/** + * Strategy for emitting models with the `@discriminator` decorator. + * - ignore: Emit as regular object schema (default) + * - oneOf: Emit a oneOf schema with references to all derived models (closed union) + * - anyOf: Emit an anyOf schema with references to all derived models (open union) + */ +alias PolymorphicModelsStrategy = "ignore" | "oneOf" | "anyOf"; diff --git a/packages/json-schema/package.json b/packages/json-schema/package.json index 47e877acb76..66ac77afe63 100644 --- a/packages/json-schema/package.json +++ b/packages/json-schema/package.json @@ -28,6 +28,9 @@ "./testing": { "types": "./dist/src/testing/index.d.ts", "default": "./dist/src/testing/index.js" + }, + "./options": { + "typespec": "./options/main.tsp" } }, "tspMain": "lib/main.tsp", @@ -36,9 +39,10 @@ }, "scripts": { "clean": "rimraf ./dist ./temp", - "build": "pnpm gen-extern-signature && tsc -p tsconfig.build.json && pnpm lint-typespec-library && pnpm api-extractor", + "build": "pnpm gen-extern-signature && pnpm gen-emitter-options-types && tsc -p tsconfig.build.json && pnpm lint-typespec-library && pnpm api-extractor", "watch": "tsc -p tsconfig.build.json --watch", "gen-extern-signature": "tspd --enable-experimental gen-extern-signature .", + "gen-emitter-options-types": "tspd --enable-experimental gen-emitter-options-types options/main.tsp --interface-name EmitterOptions --output-dir generated-defs", "lint-typespec-library": "tsp compile . --warn-as-error --import @typespec/library-linter --no-emit", "test": "vitest run", "test:ui": "vitest --ui", @@ -50,6 +54,7 @@ }, "files": [ "lib/*.tsp", + "options/*.tsp", "dist/**", "!dist/test/**" ], diff --git a/packages/json-schema/src/index.ts b/packages/json-schema/src/index.ts index bd181361ae8..d5df7e34555 100644 --- a/packages/json-schema/src/index.ts +++ b/packages/json-schema/src/index.ts @@ -19,7 +19,7 @@ export type { /** @internal */ export { JsonSchemaEmitter } from "./json-schema-emitter.js"; -export { $flags, $lib, EmitterOptionsSchema } from "./lib.js"; +export { $flags, $lib } from "./lib.js"; export type { JSONSchemaEmitterOptions } from "./lib.js"; /** @internal */ diff --git a/packages/json-schema/src/lib.ts b/packages/json-schema/src/lib.ts index ccca2095106..46b5d38f8e9 100644 --- a/packages/json-schema/src/lib.ts +++ b/packages/json-schema/src/lib.ts @@ -1,9 +1,4 @@ -import { - createTypeSpecLibrary, - definePackageFlags, - type JSONSchemaType, - paramMessage, -} from "@typespec/compiler"; +import { createTypeSpecLibrary, definePackageFlags, paramMessage } from "@typespec/compiler"; /** * File type @@ -29,127 +24,7 @@ export type PolymorphicModelsStrategy = "ignore" | "oneOf" | "anyOf"; /** * Json schema emitter options */ -export interface JSONSchemaEmitterOptions { - /** - * Serialize the schema as either yaml or json. - * @defaultValue yaml it not specified infer from the `output-file` extension - */ - "file-type"?: FileType; - - /** - * How to handle 64-bit integers on the wire. Options are: - * - * - string: Serialize as a string (widely interoperable) - * - number: Serialize as a number (not widely interoperable) - */ - "int64-strategy"?: Int64Strategy; - - /** - * When provided, bundle all the schemas into a single JSON Schema document - * with schemas under $defs. The provided id is the id of the root document - * and is also used for the file name. - */ - bundleId?: string; - - /** - * When true, emit all model declarations to JSON Schema without requiring - * the `@jsonSchema` decorator. - */ - emitAllModels?: boolean; - - /** - * When true, emit all references as JSON Schema files, even if the referenced - * type does not have the `@jsonSchema` decorator or is not within a namespace - * with the `@jsonSchema` decorator. - */ - emitAllRefs?: boolean; - - /** - * If true, then for models emitted as object schemas we default `unevaluatedProperties` to `{ not: {} }`, - * if not explicitly specified elsewhere. - * @defaultValue false - */ - "seal-object-schemas"?: boolean; - - /** - * Strategy for emitting models with the discriminator decorator. - * - ignore: Emit as regular object schema (default) - * - oneOf: Emit a oneOf schema with references to all derived models (closed union) - * - anyOf: Emit an anyOf schema with references to all derived models (open union) - * @defaultValue "ignore" - */ - "polymorphic-models-strategy"?: PolymorphicModelsStrategy; -} - -/** - * Internal: Json Schema emitter options schema - */ -export const EmitterOptionsSchema: JSONSchemaType = { - type: "object", - additionalProperties: false, - properties: { - "file-type": { - type: "string", - enum: ["yaml", "json"], - nullable: true, - description: "Serialize the schema as either yaml or json.", - }, - "int64-strategy": { - type: "string", - enum: ["string", "number"], - nullable: true, - description: `How to handle 64 bit integers on the wire. Options are: - -* string: serialize as a string (widely interoperable) -* number: serialize as a number (not widely interoperable)`, - }, - bundleId: { - type: "string", - nullable: true, - description: - "When provided, bundle all the schemas into a single json schema document with schemas under $defs. The provided id is the id of the root document and is also used for the file name.", - }, - emitAllModels: { - type: "boolean", - nullable: true, - description: - "When true, emit all model declarations to JSON Schema without requiring the @jsonSchema decorator.", - }, - emitAllRefs: { - type: "boolean", - nullable: true, - description: - "When true, emit all references as json schema files, even if the referenced type does not have the `@jsonSchema` decorator or is not within a namespace with the `@jsonSchema` decorator.", - }, - "seal-object-schemas": { - type: "boolean", - nullable: true, - default: false, - description: [ - "If true, then for models emitted as object schemas we default `unevaluatedProperties` to `{ not: {} }`,", - "if not explicitly specified elsewhere.", - "Default: `false`", - ].join("\n"), - }, - "polymorphic-models-strategy": { - type: "string", - enum: ["ignore", "oneOf", "anyOf"], - nullable: true, - default: "ignore", - description: [ - "Strategy for emitting models with the @discriminator decorator:", - "- ignore: Emit as regular object schema (default). Derived models use allOf to reference their base model.", - "- oneOf: Emit a oneOf schema with references to all derived models (closed union)", - "- anyOf: Emit an anyOf schema with references to all derived models (open union)", - "", - "When using oneOf or anyOf, derived models will inline all properties from their base model", - "instead of using allOf references. This avoids circular references in the generated schemas,", - "since the base model references derived models via oneOf/anyOf.", - ].join("\n"), - }, - }, - required: [], -}; +export type { EmitterOptions as JSONSchemaEmitterOptions } from "../generated-defs/emitter-options.js"; /** Internal: TypeSpec library definition */ export const $lib = createTypeSpecLibrary({ @@ -174,9 +49,6 @@ export const $lib = createTypeSpecLibrary({ }, }, }, - emitter: { - options: EmitterOptionsSchema as JSONSchemaType, - }, state: { JsonSchema: { description: "State indexing types marked with @jsonSchema" }, "JsonSchema.baseURI": { description: "Contains data configured with @baseUri decorator" }, @@ -216,7 +88,9 @@ export const $lib = createTypeSpecLibrary({ } as const); /** Internal: TypeSpec flags */ -export const $flags = definePackageFlags({}); +export const $flags = definePackageFlags({ + experimentalEmitterOptions: true, +}); export const { reportDiagnostic, createStateSymbol, stateKeys: JsonSchemaStateKeys } = $lib; diff --git a/packages/json-schema/src/on-emit.ts b/packages/json-schema/src/on-emit.ts index deae7af1b86..a0da5b3f00d 100644 --- a/packages/json-schema/src/on-emit.ts +++ b/packages/json-schema/src/on-emit.ts @@ -9,7 +9,7 @@ import { import { getJsonSchemaTypes } from "./decorators.js"; import { JsonSchemaEmitter } from "./json-schema-emitter.js"; import type { JSONSchemaEmitterOptions } from "./lib.js"; -export { $flags, $lib, EmitterOptionsSchema, type JSONSchemaEmitterOptions } from "./lib.js"; +export { $flags, $lib, type JSONSchemaEmitterOptions } from "./lib.js"; export const namespace = "TypeSpec.JsonSchema"; export type JsonSchemaDeclaration = Model | Union | Enum | Scalar; diff --git a/packages/json-schema/test/emitter-options.test.ts b/packages/json-schema/test/emitter-options.test.ts new file mode 100644 index 00000000000..2d89942badb --- /dev/null +++ b/packages/json-schema/test/emitter-options.test.ts @@ -0,0 +1,47 @@ +import { expectDiagnostics } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { emitSchema, emitSchemaWithDiagnostics } from "./utils.js"; + +// The json-schema emitter declares its options as a TypeSpec model +// (`options/main.tsp`, exported via package.json `exports["./options"].typespec`). +// These tests make sure the compiler validates user options against that model. +describe("json-schema: emitter options validation", () => { + it("accepts all documented options", async () => { + await emitSchema(`model Foo {}`, { + "file-type": "json", + "int64-strategy": "string", + bundleId: "bundle", + emitAllModels: true, + emitAllRefs: true, + "seal-object-schemas": true, + "polymorphic-models-strategy": "oneOf", + }); + }); + + it("rejects a value outside a union option", async () => { + const [, diagnostics] = await emitSchemaWithDiagnostics(`model Foo {}`, { + "polymorphic-models-strategy": "not-valid", + } as any); + expectDiagnostics(diagnostics, { + code: "invalid-emitter-options", + message: `Value "not-valid" is not one of the allowed values: "ignore", "oneOf", "anyOf"`, + }); + }); + + it("rejects a value of the wrong type", async () => { + const [, diagnostics] = await emitSchemaWithDiagnostics(`model Foo {}`, { + emitAllModels: "yes", + } as any); + expectDiagnostics(diagnostics, { + code: "invalid-emitter-options", + message: "Expected type boolean", + }); + }); + + it("rejects an unknown option", async () => { + const [, diagnostics] = await emitSchemaWithDiagnostics(`model Foo {}`, { + "totally-unknown": true, + } as any); + expect(diagnostics.some((d) => d.code === "invalid-emitter-options")).toBe(true); + }); +}); diff --git a/packages/openapi3/README.md b/packages/openapi3/README.md index a9f822d64a7..5d71868503e 100644 --- a/packages/openapi3/README.md +++ b/packages/openapi3/README.md @@ -46,7 +46,9 @@ See [Configuring output directory for more info](https://typespec.io/docs/handbo **Type:** `"yaml" | "json" | ("yaml" | "json")[]` -If the content should be serialized as YAML or JSON. Can be a single value or an array to emit multiple formats. Default 'yaml', if not specified infer from the `output-file` extension +If the content should be serialized as YAML or JSON. +Can be a single value or an array to emit multiple formats. +Default `yaml`, if not specified infer from the `output-file` extension. **Options:** @@ -65,8 +67,8 @@ Output file will interpolate the following values: - version: Version of the service if multiple - file-type: The file type being emitted (json or yaml). Useful when `file-type` is an array. -Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"` -When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}` +Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"`. +When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}`. Example Single service no versioning @@ -89,12 +91,6 @@ Example Multiple service with versioning - `openapi.Org1.Service2.v1.0.yaml` - `openapi.Org1.Service2.v1.1.yaml` -### `openapi-versions` - -**Type:** `"3.0.0" | "3.1.0" | "3.2.0"` - -**Default:** `["3.0.0"]` - ### `new-line` **Type:** `"crlf" | "lf"` @@ -130,8 +126,6 @@ How to handle safeint type. Options are: - `double-int`: Will produce `type: integer, format: double-int` - `int64`: Will produce `type: integer, format: int64` -Default: `int64` - ### `seal-object-schemas` **Type:** `boolean` @@ -140,37 +134,37 @@ Default: `int64` If true, then for models emitted as object schemas we default `additionalProperties` to false for OpenAPI 3.0, and `unevaluatedProperties` to false for OpenAPI 3.1, if not explicitly specified elsewhere. -Default: `false` ### `experimental-parameter-examples` **Type:** `"data" | "serialized"` Determines how to emit examples on parameters. + Note: This is an experimental feature and may change in future versions. -See https://spec.openapis.org/oas/v3.0.4.html#style-examples for parameter example serialization rules -See https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion on handling parameter examples. ### `operation-id-strategy` **Type:** `"parent-container" | "fqn" | "explicit-only" | object { kind, separator }` -**Options:** - -- `"parent-container" | "fqn" | "explicit-only"` (default: `"parent-container"`) +**Default:** `"parent-container"` - Determines how to generate operation IDs when `@operationId` is not used. - Avaliable options are: +How should operation ID be generated when `@operationId` is not used. +Available options are -- `parent-container`: Uses the parent namespace and operation name to generate the ID. -- `fqn`: Uses the fully qualified name of the operation to generate the ID. +- `parent-container`: Uses the parent namespace/interface and operation name to generate the ID. +- `fqn`: Uses the fully qualified name(from service root) of the operation to generate the ID. - `explicit-only`: Only use explicitly defined operation IDs. + +**Options:** + +- `"parent-container" | "fqn" | "explicit-only"` - `object { kind, separator }` -| Name | Type | Default | Description | -| ----------- | ------------------------------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `kind` | `"parent-container" \| "fqn" \| "explicit-only"` | `"parent-container"` | Determines how to generate operation IDs when `@operationId` is not used.
Avaliable options are:
- `parent-container`: Uses the parent namespace and operation name to generate the ID.
- `fqn`: Uses the fully qualified name of the operation to generate the ID.
- `explicit-only`: Only use explicitly defined operation IDs. | -| `separator` | `string` | | Separator used to join segment in the operation name. | +| Name | Type | Default | Description | +| ----------- | ------------------------------------------------ | -------------------- | ----------------------------------------------------- | +| `kind` | `"parent-container" \| "fqn" \| "explicit-only"` | `"parent-container"` | Strategy used to generate the operation ID. | +| `separator` | `string` | | Separator used to join segment in the operation name. | ### `enum-strategy` @@ -182,8 +176,7 @@ How to emit TypeSpec enums. Options are: - `default`: Emit as a single schema using the `enum` keyword. - `annotated`: Emit as a `oneOf` of `const` subschemas annotated with `title` and `description` - from each member's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern. - Only supported by OpenAPI 3.1.0 and above; on 3.0.0 the `default` style is used and a warning is reported. + from each member's `@summary` and `@doc`. Only supported by OpenAPI 3.1.0 and above. ## Decorators diff --git a/packages/openapi3/generated-defs/emitter-options.ts b/packages/openapi3/generated-defs/emitter-options.ts new file mode 100644 index 00000000000..c6c4bf387c7 --- /dev/null +++ b/packages/openapi3/generated-defs/emitter-options.ts @@ -0,0 +1,85 @@ +export interface EmitterOptions { + /** + * If the content should be serialized as YAML or JSON. + * Can be a single value or an array to emit multiple formats. + * Default `yaml`, if not specified infer from the `output-file` extension. + */ + "file-type"?: "yaml" | "json" | ("yaml" | "json")[]; + /** + * Name of the output file. + * Output file will interpolate the following values: + * - service-name: Name of the service + * - service-name-if-multiple: Name of the service if multiple + * - version: Version of the service if multiple + * - file-type: The file type being emitted (json or yaml). Useful when `file-type` is an array. + * + * Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"`. + * When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}`. + */ + "output-file"?: string; + /** + * The Open API specification versions to emit. + * If more than one version is specified, then the output file + * will be created inside a directory matching each specification version. + */ + "openapi-versions"?: ("3.0.0" | "3.1.0" | "3.2.0")[]; + /** + * Set the newline character for emitting files. + */ + "new-line"?: "crlf" | "lf"; + /** + * Omit unreachable types. + * By default all types declared under the service namespace will be included. With this flag on only types references in an operation will be emitted. + */ + "omit-unreachable-types"?: boolean; + /** + * If the generated openapi types should have the `x-typespec-name` extension set with the name of the TypeSpec type that created it. + * This extension is meant for debugging and should not be depended on. + */ + "include-x-typespec-name"?: "inline-only" | "never"; + /** + * How to handle safeint type. Options are: + * - `double-int`: Will produce `type: integer, format: double-int` + * - `int64`: Will produce `type: integer, format: int64` + */ + "safeint-strategy"?: "double-int" | "int64"; + /** + * If true, then for models emitted as object schemas we default `additionalProperties` to false for + * OpenAPI 3.0, and `unevaluatedProperties` to false for OpenAPI 3.1, if not explicitly specified elsewhere. + */ + "seal-object-schemas"?: boolean; + /** + * Determines how to emit examples on parameters. + * + * Note: This is an experimental feature and may change in future versions. + */ + "experimental-parameter-examples"?: "data" | "serialized"; + /** + * How should operation ID be generated when `@operationId` is not used. + * Available options are + * - `parent-container`: Uses the parent namespace/interface and operation name to generate the ID. + * - `fqn`: Uses the fully qualified name(from service root) of the operation to generate the ID. + * - `explicit-only`: Only use explicitly defined operation IDs. + */ + "operation-id-strategy"?: + | "parent-container" + | "fqn" + | "explicit-only" + | { + /** + * Strategy used to generate the operation ID. + */ + kind: "parent-container" | "fqn" | "explicit-only"; + /** + * Separator used to join segment in the operation name. + */ + separator?: string; + }; + /** + * How to emit TypeSpec enums. Options are: + * - `default`: Emit as a single schema using the `enum` keyword. + * - `annotated`: Emit as a `oneOf` of `const` subschemas annotated with `title` and `description` + * from each member's `@summary` and `@doc`. Only supported by OpenAPI 3.1.0 and above. + */ + "enum-strategy"?: "default" | "annotated"; +} diff --git a/packages/openapi3/options/main.tsp b/packages/openapi3/options/main.tsp new file mode 100644 index 00000000000..2b3e94430cb --- /dev/null +++ b/packages/openapi3/options/main.tsp @@ -0,0 +1,126 @@ +import "@typespec/compiler/emitter"; + +model EmitterOptions { + /** + * If the content should be serialized as YAML or JSON. + * Can be a single value or an array to emit multiple formats. + * Default `yaml`, if not specified infer from the `output-file` extension. + */ + `file-type`?: FileType | FileType[]; + + /** + * Name of the output file. + * Output file will interpolate the following values: + * - service-name: Name of the service + * - service-name-if-multiple: Name of the service if multiple + * - version: Version of the service if multiple + * - file-type: The file type being emitted (json or yaml). Useful when `file-type` is an array. + * + * Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"`. + * When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}`. + * + * @example Single service no versioning + * - `openapi.yaml` + * + * @example Multiple services no versioning + * - `openapi.Org1.Service1.yaml` + * - `openapi.Org1.Service2.yaml` + * + * @example Single service with versioning + * - `openapi.v1.yaml` + * - `openapi.v2.yaml` + * + * @example Multiple service with versioning + * - `openapi.Org1.Service1.v1.yaml` + * - `openapi.Org1.Service1.v2.yaml` + * - `openapi.Org1.Service2.v1.0.yaml` + * - `openapi.Org1.Service2.v1.1.yaml` + */ + `output-file`?: string; + + /** + * The Open API specification versions to emit. + * If more than one version is specified, then the output file + * will be created inside a directory matching each specification version. + * + * @default ["3.0.0"] + * @internal + */ + `openapi-versions`?: OpenAPIVersion[]; + + /** + * Set the newline character for emitting files. + * @default "lf" + */ + `new-line`?: "crlf" | "lf"; + + /** + * Omit unreachable types. + * By default all types declared under the service namespace will be included. With this flag on only types references in an operation will be emitted. + */ + `omit-unreachable-types`?: boolean; + + /** + * If the generated openapi types should have the `x-typespec-name` extension set with the name of the TypeSpec type that created it. + * This extension is meant for debugging and should not be depended on. + * @default "never" + */ + `include-x-typespec-name`?: "inline-only" | "never"; + + /** + * How to handle safeint type. Options are: + * - `double-int`: Will produce `type: integer, format: double-int` + * - `int64`: Will produce `type: integer, format: int64` + * @default "int64" + */ + `safeint-strategy`?: "double-int" | "int64"; + + /** + * If true, then for models emitted as object schemas we default `additionalProperties` to false for + * OpenAPI 3.0, and `unevaluatedProperties` to false for OpenAPI 3.1, if not explicitly specified elsewhere. + * @default false + */ + `seal-object-schemas`?: boolean; + + /** + * Determines how to emit examples on parameters. + * + * Note: This is an experimental feature and may change in future versions. + * @see https://spec.openapis.org/oas/v3.0.4.html#style-examples for parameter example serialization rules. + * @see https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion on handling parameter examples. + */ + `experimental-parameter-examples`?: ExperimentalParameterExamplesStrategy; + + /** + * How should operation ID be generated when `@operationId` is not used. + * Available options are + * - `parent-container`: Uses the parent namespace/interface and operation name to generate the ID. + * - `fqn`: Uses the fully qualified name(from service root) of the operation to generate the ID. + * - `explicit-only`: Only use explicitly defined operation IDs. + * @default "parent-container" + */ + `operation-id-strategy`?: + | OperationIdStrategy + | { + /** Strategy used to generate the operation ID. */ + kind: OperationIdStrategy; + + /** Separator used to join segment in the operation name. */ + separator?: string; + }; + + /** + * How to emit TypeSpec enums. Options are: + * - `default`: Emit as a single schema using the `enum` keyword. + * - `annotated`: Emit as a `oneOf` of `const` subschemas annotated with `title` and `description` + * from each member's `@summary` and `@doc`. Only supported by OpenAPI 3.1.0 and above. + * @default "default" + */ + `enum-strategy`?: EnumStrategy; +} + +alias FileType = "yaml" | "json"; +alias OpenAPIVersion = "3.0.0" | "3.1.0" | "3.2.0"; +alias ExperimentalParameterExamplesStrategy = "data" | "serialized"; +alias OperationIdStrategy = "parent-container" | "fqn" | "explicit-only"; +alias EnumStrategy = "default" | "annotated"; diff --git a/packages/openapi3/package.json b/packages/openapi3/package.json index f3903fe4a35..bd2de93dc51 100644 --- a/packages/openapi3/package.json +++ b/packages/openapi3/package.json @@ -31,6 +31,9 @@ "./testing": { "types": "./dist/src/testing/index.d.ts", "default": "./dist/src/testing/index.js" + }, + "./options": { + "typespec": "./options/main.tsp" } }, "imports": { @@ -41,10 +44,11 @@ }, "scripts": { "clean": "rimraf ./dist ./temp", - "build": "pnpm gen-version && pnpm gen-extern-signature && pnpm quickbuild && pnpm lint-typespec-library", + "build": "pnpm gen-version && pnpm gen-extern-signature && pnpm gen-emitter-options-types && pnpm quickbuild && pnpm lint-typespec-library", "quickbuild": "tsc -p tsconfig.build.json", "watch": "tsc -p tsconfig.build.json --watch", "gen-extern-signature": "tspd --enable-experimental gen-extern-signature .", + "gen-emitter-options-types": "tspd --enable-experimental gen-emitter-options-types options/main.tsp --interface-name EmitterOptions --output-dir generated-defs", "lint-typespec-library": "tsp compile . --warn-as-error --import @typespec/library-linter --no-emit", "test": "vitest run", "test:watch": "vitest -w", @@ -59,6 +63,7 @@ }, "files": [ "lib/*.tsp", + "options/*.tsp", "dist/**", "!dist/test/**" ], diff --git a/packages/openapi3/src/index.ts b/packages/openapi3/src/index.ts index c65e0b5de96..dd46756298c 100644 --- a/packages/openapi3/src/index.ts +++ b/packages/openapi3/src/index.ts @@ -2,7 +2,7 @@ export const namespace = "TypeSpec.OpenAPI"; export { convertOpenAPI3Document } from "./cli/actions/convert/convert.js"; export { $oneOf, $useRef, getOneOf, getRef } from "./decorators.js"; -export { $lib } from "./lib.js"; +export { $flags, $lib } from "./lib.js"; export { $onEmit, getOpenAPI3, diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index 48e6ef75d12..65c8dafebfa 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -1,304 +1,11 @@ -import { createTypeSpecLibrary, JSONSchemaType, paramMessage } from "@typespec/compiler"; +import { createTypeSpecLibrary, definePackageFlags, paramMessage } from "@typespec/compiler"; export type FileType = "yaml" | "json"; export type OpenAPIVersion = "3.0.0" | "3.1.0" | "3.2.0"; export type ExperimentalParameterExamplesStrategy = "data" | "serialized"; export type EnumStrategy = "default" | "annotated"; -export interface OpenAPI3EmitterOptions { - /** - * If the content should be serialized as YAML or JSON. Can be a single value or an array to emit multiple file types. - * When an array is provided, the `{file-type}` variable can be used in `output-file` to produce distinct filenames. - * @default yaml, it not specified infer from the `output-file` extension - */ - - "file-type"?: FileType | FileType[]; - - /** - * Name of the output file. - * Output file will interpolate the following values: - * - service-name: Name of the service - * - service-name-if-multiple: Name of the service if multiple - * - version: Version of the service if multiple - * - * @default `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if {@link OpenAPI3EmitterOptions["file-type"]} is `"json"`. When `file-type` is an array, uses `{file-type}` variable. - * - * @example Single service no versioning - * - `openapi.yaml` - * - * @example Multiple services no versioning - * - `openapi.Org1.Service1.yaml` - * - `openapi.Org1.Service2.yaml` - * - * @example Single service with versioning - * - `openapi.v1.yaml` - * - `openapi.v2.yaml` - * - * @example Multiple service with versioning - * - `openapi.Org1.Service1.v1.yaml` - * - `openapi.Org1.Service1.v2.yaml` - * - `openapi.Org1.Service2.v1.0.yaml` - * - `openapi.Org1.Service2.v1.1.yaml` - */ - "output-file"?: string; - - /** - * The Open API specification versions to emit. - * If more than one version is specified, then the output file - * will be created inside a directory matching each specification version. - * - * @default ["3.0.0"] - */ - "openapi-versions"?: OpenAPIVersion[]; - - /** - * Set the newline character for emitting files. - * @default lf - */ - "new-line"?: "crlf" | "lf"; - - /** - * Omit unreachable types. - * By default all types declared under the service namespace will be included. With this flag on only types references in an operation will be emitted. - */ - "omit-unreachable-types"?: boolean; - - /** - * If the generated openapi types should have the `x-typespec-name` extension set with the name of the TypeSpec type that created it. - * This extension is meant for debugging and should not be depended on. - * @default "never" - */ - "include-x-typespec-name"?: "inline-only" | "never"; - - /** - * How to handle safeint type. Options are: - * - `double-int`: Will produce `type: integer, format: double-int` - * - `int64`: Will produce `type: integer, format: int64` - * @default "int64" - */ - "safeint-strategy"?: "double-int" | "int64"; - - /** - * If true, then for models emitted as object schemas we default `additionalProperties` to false for - * OpenAPI 3.0, and `unevaluatedProperties` to false for OpenAPI 3.1, if not explicitly specified elsewhere. - * @default false - */ - "seal-object-schemas"?: boolean; - - /** - * Determines how to emit examples on parameters. - * - * Note: This is an experimental feature and may change in future versions. - * @see https://spec.openapis.org/oas/v3.0.4.html#style-examples for parameter example serialization rules. - * @see https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion on handling parameter examples. - */ - "experimental-parameter-examples"?: ExperimentalParameterExamplesStrategy; - - /** - * How should operation ID be generated when `@operationId` is not used. - * Available options are - * - `parent-container`: Uses the parent namespace/interface and operation name to generate the ID. - * - `fqn`: Uses the fully qualified name(from service root) of the operation to generate the ID. - * - `explicit-only`: Only use explicitly defined operation IDs. - * @default parent-container - */ - "operation-id-strategy"?: - | OperationIdStrategy - | { - /** Strategy used to generate the operation ID. */ - kind: OperationIdStrategy; - /** Separator used to join segment in the operation name. */ - separator?: string; - }; - - /** - * How to emit TypeSpec enums. - * - * - `default`: Emit as a single schema using the `enum` keyword. - * - `annotated`: Emit as a `oneOf` of `const` subschemas, each annotated with `title` and `description` - * when the corresponding enum member has `@summary` or `@doc`. This follows the OpenAPI 3.1.1 - * [annotated enumerations](https://spec.openapis.org/oas/v3.1.1.html#annotated-enumerations) pattern. - * Only supported by OpenAPI 3.1.0 and above. When emitting OpenAPI 3.0.0, a warning will be reported - * and the `default` style will be used instead. - * - * @default "default" - */ - "enum-strategy"?: EnumStrategy; -} - export type OperationIdStrategy = "parent-container" | "fqn" | "explicit-only"; - -const operationIdStrategySchema = { - type: "string", - enum: ["parent-container", "fqn", "explicit-only"], - default: "parent-container", - description: [ - "Determines how to generate operation IDs when `@operationId` is not used.", - "Avaliable options are:", - " - `parent-container`: Uses the parent namespace and operation name to generate the ID.", - " - `fqn`: Uses the fully qualified name of the operation to generate the ID.", - " - `explicit-only`: Only use explicitly defined operation IDs.", - ].join("\n"), -} as const; - -const EmitterOptionsSchema: JSONSchemaType = { - type: "object", - additionalProperties: false, - properties: { - "file-type": { - type: ["string", "array"], - nullable: true, - oneOf: [ - { - type: "string", - enum: ["yaml", "json"], - }, - { - type: "array", - items: { - type: "string", - enum: ["yaml", "json"], - }, - uniqueItems: true, - minItems: 1, - }, - ], - description: - "If the content should be serialized as YAML or JSON. Can be a single value or an array to emit multiple formats. Default 'yaml', if not specified infer from the `output-file` extension", - }, - "output-file": { - type: "string", - nullable: true, - description: [ - "Name of the output file.", - " Output file will interpolate the following values:", - " - service-name: Name of the service", - " - service-name-if-multiple: Name of the service if multiple", - " - version: Version of the service if multiple", - " - file-type: The file type being emitted (json or yaml). Useful when `file-type` is an array.", - "", - ' Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"`', - " When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}`", - "", - " Example Single service no versioning", - " - `openapi.yaml`", - "", - " Example Multiple services no versioning", - " - `openapi.Org1.Service1.yaml`", - " - `openapi.Org1.Service2.yaml`", - "", - " Example Single service with versioning", - " - `openapi.v1.yaml`", - " - `openapi.v2.yaml`", - "", - " Example Multiple service with versioning", - " - `openapi.Org1.Service1.v1.yaml`", - " - `openapi.Org1.Service1.v2.yaml`", - " - `openapi.Org1.Service2.v1.0.yaml`", - " - `openapi.Org1.Service2.v1.1.yaml` ", - ].join("\n"), - }, - "openapi-versions": { - title: "OpenAPI Versions", - type: "array", - items: { - type: "string", - enum: ["3.0.0", "3.1.0", "3.2.0"], - nullable: true, - description: "The versions of OpenAPI to emit. Defaults to `[3.0.0]`", - }, - nullable: true, - uniqueItems: true, - minItems: 1, - default: ["3.0.0"], - }, - "new-line": { - type: "string", - enum: ["crlf", "lf"], - default: "lf", - nullable: true, - description: "Set the newline character for emitting files.", - }, - "omit-unreachable-types": { - type: "boolean", - nullable: true, - description: - "Omit unreachable types.\nBy default all types declared under the service namespace will be included. With this flag on only types references in an operation will be emitted.", - }, - "include-x-typespec-name": { - type: "string", - enum: ["inline-only", "never"], - nullable: true, - default: "never", - description: - "If the generated openapi types should have the `x-typespec-name` extension set with the name of the TypeSpec type that created it.\nThis extension is meant for debugging and should not be depended on.", - }, - "safeint-strategy": { - type: "string", - enum: ["double-int", "int64"], - nullable: true, - default: "int64", - description: [ - "How to handle safeint type. Options are:", - " - `double-int`: Will produce `type: integer, format: double-int`", - " - `int64`: Will produce `type: integer, format: int64`", - "", - "Default: `int64`", - ].join("\n"), - }, - "seal-object-schemas": { - type: "boolean", - nullable: true, - default: false, - description: [ - "If true, then for models emitted as object schemas we default `additionalProperties` to false for", - "OpenAPI 3.0, and `unevaluatedProperties` to false for OpenAPI 3.1, if not explicitly specified elsewhere.", - "Default: `false`", - ].join("\n"), - }, - "experimental-parameter-examples": { - type: "string", - enum: ["data", "serialized"], - nullable: true, - description: [ - "Determines how to emit examples on parameters.", - "Note: This is an experimental feature and may change in future versions.", - "See https://spec.openapis.org/oas/v3.0.4.html#style-examples for parameter example serialization rules", - "See https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion on handling parameter examples.", - ].join("\n"), - }, - "operation-id-strategy": { - oneOf: [ - operationIdStrategySchema, - { - type: "object", - properties: { - kind: operationIdStrategySchema, - separator: { - type: "string", - nullable: true, - description: "Separator used to join segment in the operation name.", - }, - }, - required: ["kind"], - }, - ], - } as any, - "enum-strategy": { - type: "string", - enum: ["default", "annotated"], - nullable: true, - default: "default", - description: [ - "How to emit TypeSpec enums. Options are:", - " - `default`: Emit as a single schema using the `enum` keyword.", - " - `annotated`: Emit as a `oneOf` of `const` subschemas annotated with `title` and `description`", - " from each member's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern.", - " Only supported by OpenAPI 3.1.0 and above; on 3.0.0 the `default` style is used and a warning is reported.", - ].join("\n"), - }, - }, - required: [], -}; +export type { EmitterOptions as OpenAPI3EmitterOptions } from "../generated-defs/emitter-options.js"; export const $lib = createTypeSpecLibrary({ name: "@typespec/openapi3", @@ -464,10 +171,12 @@ export const $lib = createTypeSpecLibrary({ }, }, }, - emitter: { - options: EmitterOptionsSchema as JSONSchemaType, - }, }); export const { createDiagnostic, reportDiagnostic, createStateSymbol } = $lib; +/** Internal: TypeSpec flags */ +export const $flags = definePackageFlags({ + experimentalEmitterOptions: true, +}); + export type OpenAPILibrary = typeof $lib; diff --git a/packages/openapi3/test/emitter-options.test.ts b/packages/openapi3/test/emitter-options.test.ts new file mode 100644 index 00000000000..7418890efd1 --- /dev/null +++ b/packages/openapi3/test/emitter-options.test.ts @@ -0,0 +1,69 @@ +import { expectDiagnostics } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { diagnoseOpenApiFor } from "./test-host.js"; + +// The openapi3 emitter declares its options as a TypeSpec model +// (`options/main.tsp`, exported via package.json `exports["./options"].typespec`). +// These tests make sure the compiler validates user options against that model. +describe("openapi3: emitter options validation", () => { + it("accepts all documented options", async () => { + const diagnostics = await diagnoseOpenApiFor(`op test(): void;`, { + "file-type": "json", + "new-line": "lf", + "omit-unreachable-types": true, + "include-x-typespec-name": "inline-only", + "safeint-strategy": "int64", + "seal-object-schemas": true, + "operation-id-strategy": "fqn", + }); + expectDiagnostics(diagnostics, []); + }); + + it("accepts file-type as an array of values", async () => { + const diagnostics = await diagnoseOpenApiFor(`op test(): void;`, { + "file-type": ["json", "yaml"], + } as any); + expectDiagnostics(diagnostics, []); + }); + + it("accepts openapi-versions including 3.2.0", async () => { + const diagnostics = await diagnoseOpenApiFor(`op test(): void;`, { + "openapi-versions": ["3.0.0", "3.1.0", "3.2.0"], + } as any); + expectDiagnostics(diagnostics, []); + }); + + it("accepts operation-id-strategy as an object", async () => { + const diagnostics = await diagnoseOpenApiFor(`op test(): void;`, { + "operation-id-strategy": { kind: "parent-container", separator: "_" }, + } as any); + expectDiagnostics(diagnostics, []); + }); + + it("rejects a value outside a union option", async () => { + const diagnostics = await diagnoseOpenApiFor(`op test(): void;`, { + "enum-strategy": "not-valid", + } as any); + expectDiagnostics(diagnostics, { + code: "invalid-emitter-options", + message: `Value "not-valid" is not one of the allowed values: "default", "annotated"`, + }); + }); + + it("rejects a value of the wrong type", async () => { + const diagnostics = await diagnoseOpenApiFor(`op test(): void;`, { + "omit-unreachable-types": "yes", + } as any); + expectDiagnostics(diagnostics, { + code: "invalid-emitter-options", + message: "Expected type boolean", + }); + }); + + it("rejects an unknown option", async () => { + const diagnostics = await diagnoseOpenApiFor(`op test(): void;`, { + "totally-unknown": true, + } as any); + expect(diagnostics.some((d) => d.code === "invalid-emitter-options")).toBe(true); + }); +}); diff --git a/packages/tspd/src/cli.ts b/packages/tspd/src/cli.ts index 4ecdd3fa5d9..d5cddb160a8 100644 --- a/packages/tspd/src/cli.ts +++ b/packages/tspd/src/cli.ts @@ -2,6 +2,7 @@ import { NodeHost, logDiagnostics, resolvePath } from "@typespec/compiler"; import pc from "picocolors"; import yargs from "yargs"; +import { generateEmitterOptionsTypes } from "./gen-emitter-options-types/gen-emitter-options-types.js"; import { generateExternSignatures } from "./gen-extern-signatures/gen-extern-signatures.js"; import { generateLibraryDocs } from "./ref-doc/experimental.js"; @@ -130,6 +131,41 @@ async function main() { } }, ) + .command( + "gen-emitter-options-types ", + "Generate TypeScript emitter option types from a TypeSpec EmitterOptions model.", + (cmd) => { + return cmd + .positional("entrypoint", { + description: "Path to the emitter options entrypoint.", + type: "string", + demandOption: true, + }) + .option("output-dir", { + alias: "output", + description: "Directory where generated files should be written.", + type: "string", + default: "generated-defs", + }) + .option("interface-name", { + description: "Name of the exported TypeScript interface.", + type: "string", + default: "EmitterOptions", + }); + }, + async (args) => { + const resolvedMain = resolvePath(process.cwd(), args.entrypoint); + const outputDir = resolvePath(process.cwd(), args["output-dir"]); + const host = NodeHost; + const diagnostics = await generateEmitterOptionsTypes(host, resolvedMain, { + outputDir, + interfaceName: args["interface-name"], + }); + if (diagnostics.length > 0) { + logDiagnostics(diagnostics, host.logSink); + } + }, + ) .demandCommand(1, "You must use one of the supported commands.").argv; } diff --git a/packages/tspd/src/gen-emitter-options-types/gen-emitter-options-types.ts b/packages/tspd/src/gen-emitter-options-types/gen-emitter-options-types.ts new file mode 100644 index 00000000000..614bebefeb7 --- /dev/null +++ b/packages/tspd/src/gen-emitter-options-types/gen-emitter-options-types.ts @@ -0,0 +1,235 @@ +import { + CompilerHost, + Diagnostic, + Model, + ModelProperty, + NoTarget, + Program, + Scalar, + Type, + compile, + createTypeSpecLibrary, + getDoc, + isArrayModelType, + isRecordModelType, + joinPaths, + walkPropertiesInherited, +} from "@typespec/compiler"; +import prettier from "prettier"; + +const { createDiagnostic } = createTypeSpecLibrary({ + name: "@typespec/tspd", + diagnostics: { + "emitter-options-model-missing": { + severity: "error", + messages: { + default: `Couldn't find an EmitterOptions model in the global namespace.`, + }, + }, + }, +} as const); + +const numericScalars = new Set([ + "int8", + "int16", + "int32", + "int64", + "uint8", + "uint16", + "uint32", + "uint64", + "integer", + "safeint", + "float", + "float32", + "float64", + "numeric", + "decimal", + "decimal128", +]); + +export interface GenerateEmitterOptionsTypesOptions { + readonly outputDir: string; + readonly interfaceName?: string; +} + +export interface GenerateEmitterOptionsTypeOptions { + readonly interfaceName?: string; + readonly prettierConfig?: prettier.Options; +} + +export async function generateEmitterOptionsTypes( + host: CompilerHost, + entrypoint: string, + options: GenerateEmitterOptionsTypesOptions, +): Promise { + const program = await compile(host, entrypoint, { + parseOptions: { comments: true, docs: true }, + }); + + if (program.hasError()) { + return program.diagnostics; + } + + const emitterOptions = program.getGlobalNamespaceType().models.get("EmitterOptions"); + if (emitterOptions === undefined) { + return [ + createDiagnostic({ + code: "emitter-options-model-missing", + target: NoTarget, + }), + ]; + } + + const prettierConfig = await prettier.resolveConfig(entrypoint); + const content = await generateEmitterOptionsType(program, emitterOptions, { + interfaceName: options.interfaceName, + prettierConfig: prettierConfig ?? undefined, + }); + + await host.mkdirp(options.outputDir); + await host.writeFile(joinPaths(options.outputDir, "emitter-options.ts"), content); + return program.diagnostics; +} + +export async function generateEmitterOptionsType( + program: Program, + emitterOptions: Model, + options: GenerateEmitterOptionsTypeOptions = {}, +): Promise { + const interfaceName = options.interfaceName ?? "EmitterOptions"; + const source = `${emitDocComment(getDoc(program, emitterOptions))}export interface ${interfaceName} ${emitObject( + program, + emitterOptions, + new Set(), + )}`; + + return prettier.format(source, { + ...options.prettierConfig, + parser: "typescript", + }); +} + +function emitType(program: Program, type: Type, seenModels: Set): string { + switch (type.kind) { + case "Scalar": + return emitScalar(type); + case "String": + return JSON.stringify(type.value); + case "Number": + return type.valueAsString; + case "Boolean": + return String(type.value); + case "Enum": + return joinUnion( + [...type.members.values()].map((member) => + typeof member.value === "string" || typeof member.value === "number" + ? JSON.stringify(member.value) + : JSON.stringify(member.name), + ), + ); + case "Union": + return joinUnion( + [...type.variants.values()].map((variant) => emitType(program, variant.type, seenModels)), + ); + case "Model": + if (isArrayModelType(type)) { + return `${parenthesizeArrayElement(emitType(program, type.indexer.value, seenModels))}[]`; + } + if (isRecordModelType(type)) { + return `Record`; + } + return emitObject(program, type, seenModels); + case "Tuple": + return `[${type.values.map((x) => emitType(program, x, seenModels)).join(", ")}]`; + case "Intrinsic": + if (type.name === "null") { + return "null"; + } + return "unknown"; + default: + return "unknown"; + } +} + +function emitScalar(type: Scalar): string { + if (extendsScalar(type, "string")) { + return "string"; + } + if (extendsScalar(type, "boolean")) { + return "boolean"; + } + if (extendsScalar(type, "bytes")) { + return "Uint8Array"; + } + if (extendsAnyScalar(type, numericScalars)) { + return "number"; + } + return "unknown"; +} + +function extendsScalar(type: Scalar, name: string): boolean { + for (let current: Scalar | undefined = type; current; current = current.baseScalar) { + if (current.name === name) { + return true; + } + } + return false; +} + +function extendsAnyScalar(type: Scalar, names: Set): boolean { + for (let current: Scalar | undefined = type; current; current = current.baseScalar) { + if (names.has(current.name)) { + return true; + } + } + return false; +} + +function emitObject(program: Program, model: Model, seenModels: Set): string { + if (seenModels.has(model)) { + return "Record"; + } + const nextSeenModels = new Set(seenModels); + nextSeenModels.add(model); + + const properties = [...walkPropertiesInherited(model)]; + if (properties.length === 0) { + return "{}"; + } + + return `{ +${properties.map((property) => emitProperty(program, property, nextSeenModels)).join("\n")} +}`; +} + +function emitProperty(program: Program, property: ModelProperty, seenModels: Set): string { + const doc = emitDocComment(getDoc(program, property), " "); + const key = isValidIdentifier(property.name) ? property.name : JSON.stringify(property.name); + return `${doc} ${key}${property.optional ? "?" : ""}: ${emitType(program, property.type, seenModels)};`; +} + +function emitDocComment(doc: string | undefined, indent = ""): string { + if (doc === undefined || doc.length === 0) { + return ""; + } + + const lines = doc.split(/\r?\n/); + return `${indent}/** +${lines.map((line) => `${indent} *${line.length === 0 ? "" : ` ${line.replaceAll("*/", "*\\/")}`}`).join("\n")} +${indent} */ +`; +} + +function joinUnion(types: string[]): string { + const dedupedTypes = [...new Set(types)]; + return dedupedTypes.length === 0 ? "never" : dedupedTypes.join(" | "); +} + +function parenthesizeArrayElement(type: string): string { + return type.includes(" | ") ? `(${type})` : type; +} + +function isValidIdentifier(name: string): boolean { + return /^[$A-Z_a-z][$\w]*$/.test(name); +} diff --git a/packages/tspd/src/ref-doc/extractor.ts b/packages/tspd/src/ref-doc/extractor.ts index 55614f5d95a..a8b3e688eae 100644 --- a/packages/tspd/src/ref-doc/extractor.ts +++ b/packages/tspd/src/ref-doc/extractor.ts @@ -13,6 +13,7 @@ import { getSourceLocation, getTypeName, Interface, + isArrayModelType, isDeclaredType, isTemplateDeclaration, joinPaths, @@ -122,6 +123,20 @@ export async function extractLibraryRefDocs( refDoc.emitter = { options: extractEmitterOptionsRefDoc(lib.emitter.options), }; + } else { + // No legacy JSON-schema options: look for a `./options` export pointing at a + // TypeSpec model (the `EmitterOptions` model) and extract the options from it. + const optionsEntry = getExport(pkgJson, "./options", "typespec"); + if (optionsEntry) { + const options = await extractEmitterOptionsRefDocFromTypeSpec( + libraryPath, + optionsEntry, + diagnostics, + ); + if (options) { + refDoc.emitter = { options }; + } + } } const linter = entrypoint.$linter; if (lib && linter) { @@ -781,6 +796,303 @@ function extractEmitterOptionsRefDoc( }); } +/** + * Extract emitter option ref docs from an emitter that declares its options as a + * TypeSpec model. The `./options` export is expected to point at a TypeSpec entrypoint + * defining an `EmitterOptions` model. + */ +async function extractEmitterOptionsRefDocFromTypeSpec( + libraryPath: string, + tspEntry: string, + diagnostics: { add: (d: Diagnostic) => void }, +): Promise { + const main = resolvePath(libraryPath, tspEntry); + let program: Program; + try { + program = await compile(NodeHost, main, { + parseOptions: { comments: true, docs: true }, + }); + } catch { + return undefined; + } + for (const diag of program.diagnostics ?? []) { + diagnostics.add(diag); + } + + const model = program.getGlobalNamespaceType().models.get("EmitterOptions"); + if (model === undefined) { + return undefined; + } + + return extractEmitterOptionsRefDocFromModel(program, model); +} + +/** + * Extract emitter option ref docs from an `EmitterOptions` TypeSpec model, mapping each + * model property to an {@link EmitterOptionRefDoc}. + */ +export function extractEmitterOptionsRefDocFromModel( + program: Program, + model: Model, +): EmitterOptionRefDoc[] { + return getDocumentedEmitterOptionProperties(model).map((prop) => + extractEmitterOptionFromModelProperty(program, prop), + ); +} + +interface OptionTypeDescription { + type: string; + allowedValues?: string[]; + nestedOptions?: EmitterOptionRefDoc[]; + variants?: EmitterOptionVariantRefDoc[]; +} + +function extractEmitterOptionFromModelProperty( + program: Program, + prop: ModelProperty, + inheritedDefault?: string, +): EmitterOptionRefDoc { + const defaultValue = getOptionDefaultDoc(prop) ?? inheritedDefault; + const desc = describeEmitterOptionType(program, prop.type, { defaultValue }); + const option: Mutable = { + name: prop.name, + type: desc.type, + doc: getEmitterOptionDoc(program, prop), + }; + + if (desc.allowedValues) option.allowedValues = desc.allowedValues; + if (desc.nestedOptions) option.nestedOptions = desc.nestedOptions; + if (desc.variants) option.variants = desc.variants; + + if (defaultValue !== undefined) option.default = defaultValue; + + const deprecated = getDeprecated(program, prop); + if (deprecated !== undefined) option.deprecated = deprecated; + + return option; +} + +function getEmitterOptionDoc(program: Program, prop: ModelProperty): string { + const doc = getDoc(program, prop) ?? ""; + const examples = extractExamples(prop); + if (examples.length === 0) { + return doc; + } + + return [doc, ...examples.map(renderEmitterOptionExample)] + .filter((section) => section.length > 0) + .join("\n\n"); +} + +function renderEmitterOptionExample(example: ExampleRefDoc): string { + const title = example.title ? `Example ${example.title}` : "Example"; + return [title, example.content.trimEnd()].filter((section) => section.length > 0).join("\n\n"); +} + +function getDocumentedEmitterOptionProperties(model: Model): ModelProperty[] { + return [...model.properties.values()].filter((prop) => !isEmitterOptionInternal(prop)); +} + +function isEmitterOptionInternal(prop: ModelProperty): boolean { + return hasDocTag(prop, "internal"); +} + +function hasDocTag(type: Type, tagName: string): boolean { + return ( + type.node?.docs?.some((doc) => + doc.tags.some((tag) => tag.kind === SyntaxKind.DocUnknownTag && tag.tagName.sv === tagName), + ) ?? false + ); +} + +function getInheritedNestedDefault( + prop: ModelProperty, + parentDefault?: string, +): string | undefined { + if (parentDefault === undefined || prop.name !== "kind") { + return undefined; + } + + return literalOptionValues(prop.type)?.includes(parentDefault) ? parentDefault : undefined; +} + +/** Read the `@default` doc tag value (verbatim) from a type's doc comment, if present. */ +function getOptionDefaultDoc(type: Type): string | undefined { + for (const doc of type.node?.docs ?? []) { + for (const tag of doc.tags) { + if (tag.kind === SyntaxKind.DocUnknownTag && tag.tagName.sv === "default") { + const content = getDocContent(tag.content).trim(); + return content.length > 0 ? content : undefined; + } + } + } + return undefined; +} + +interface OptionTypeDescriptionContext { + defaultValue?: string; +} + +function describeEmitterOptionType( + program: Program, + type: Type, + context: OptionTypeDescriptionContext = {}, +): OptionTypeDescription { + switch (type.kind) { + case "Scalar": + return { type: scalarToOptionType(type) }; + case "String": + case "Number": + case "Boolean": + case "Enum": { + const values = literalOptionValues(type)!; + return { type: values.join(" | "), allowedValues: values }; + } + case "Model": { + if (isArrayModelType(type)) { + const element = type.indexer!.value; + const elementValues = literalOptionValues(element); + if (elementValues) { + return { type: `(${elementValues.join(" | ")})[]`, allowedValues: elementValues }; + } + return { type: `${optionTypeToString(program, element)}[]` }; + } + const properties = getDocumentedEmitterOptionProperties(type); + return { + type: `object { ${properties.map((p) => p.name).join(", ")} }`, + nestedOptions: properties.map((p) => + extractEmitterOptionFromModelProperty( + program, + p, + getInheritedNestedDefault(p, context.defaultValue), + ), + ), + }; + } + case "Union": { + const variantTypes = [...type.variants.values()].map((v) => v.type); + const literals = variantTypes.filter((v) => isLiteralOptionType(v)); + const complex = variantTypes.filter((v) => !isLiteralOptionType(v)); + + if (complex.length === 0) { + const values = literalOptionValues(type)!; + return { type: values.join(" | "), allowedValues: values }; + } + + const variants: EmitterOptionVariantRefDoc[] = []; + const typeParts: string[] = []; + const literalValues = literals.flatMap((l) => literalOptionValues(l) ?? []); + if (literalValues.length > 0) { + variants.push({ type: literalValues.join(" | "), allowedValues: literalValues }); + typeParts.push(literalValues.join(" | ")); + } + for (const variantType of complex) { + const desc = describeEmitterOptionType(program, variantType, context); + // Complex variants (arrays/objects/scalars) display their full type string; + // do not copy `allowedValues` (which would hide e.g. the array brackets). + const variant: Mutable = { type: desc.type }; + if (desc.nestedOptions) variant.nestedOptions = desc.nestedOptions; + variants.push(variant); + typeParts.push(desc.type); + } + return { type: typeParts.join(" | "), variants }; + } + default: + return { type: getTypeName(type) }; + } +} + +/** Map a TypeSpec scalar to a simple JSON-schema-like type name for documentation. */ +function scalarToOptionType(type: Scalar): string { + let scalar: Scalar | undefined = type; + while (scalar) { + switch (scalar.name) { + case "boolean": + return "boolean"; + case "string": + case "url": + return "string"; + case "numeric": + case "integer": + case "float": + case "decimal": + return "number"; + } + scalar = scalar.baseScalar; + } + return type.name; +} + +/** Whether a type is a literal, an enum, or a union of only literals/enums. */ +function isLiteralOptionType(type: Type): boolean { + switch (type.kind) { + case "String": + case "Number": + case "Boolean": + case "Enum": + case "EnumMember": + return true; + case "Union": + return [...type.variants.values()].every((v) => isLiteralOptionType(v.type)); + default: + return false; + } +} + +/** Return the list of quoted allowed values for a literal/enum/literal-union type. */ +function literalOptionValues(type: Type): string[] | undefined { + switch (type.kind) { + case "String": + return [`"${type.value}"`]; + case "Number": + return [String(type.value)]; + case "Boolean": + return [String(type.value)]; + case "EnumMember": + return [typeof type.value === "string" ? `"${type.value}"` : String(type.value ?? type.name)]; + case "Enum": + return [...type.members.values()].flatMap((m) => literalOptionValues(m) ?? []); + case "Union": { + const all = [...type.variants.values()].map((v) => literalOptionValues(v.type)); + if (all.every((x) => x !== undefined)) { + return all.flat() as string[]; + } + return undefined; + } + default: + return undefined; + } +} + +function optionTypeToString(program: Program, type: Type): string { + switch (type.kind) { + case "String": + return `"${type.value}"`; + case "Number": + return String(type.value); + case "Boolean": + return String(type.value); + case "Scalar": + return scalarToOptionType(type); + case "Enum": + return literalOptionValues(type)!.join(" | "); + case "Union": + return [...type.variants.values()] + .map((v) => optionTypeToString(program, v.type)) + .join(" | "); + case "Model": + if (isArrayModelType(type)) { + const element = optionTypeToString(program, type.indexer!.value); + return element.includes("|") ? `(${element})[]` : `${element}[]`; + } + return `object { ${getDocumentedEmitterOptionProperties(type) + .map((p) => p.name) + .join(", ")} }`; + default: + return getTypeName(type); + } +} + function extractEmitterOptionInfo(name: string, prop: any): EmitterOptionRefDoc { // Handle oneOf: extract variants if (prop.oneOf) { diff --git a/packages/tspd/test/gen-emitter-options-types/gen-emitter-options-types.test.ts b/packages/tspd/test/gen-emitter-options-types/gen-emitter-options-types.test.ts new file mode 100644 index 00000000000..582f418a491 --- /dev/null +++ b/packages/tspd/test/gen-emitter-options-types/gen-emitter-options-types.test.ts @@ -0,0 +1,119 @@ +import { resolvePath } from "@typespec/compiler"; +import { createTester, expectDiagnosticEmpty } from "@typespec/compiler/testing"; +import { expect, it } from "vitest"; +import { generateEmitterOptionsType } from "../../src/gen-emitter-options-types/gen-emitter-options-types.js"; + +const Tester = createTester(resolvePath(import.meta.dirname, "../.."), { + libraries: [], +}); + +async function generateOptions(code: string) { + const [{ program }] = await Tester.compileAndDiagnose(code, { + compilerOptions: { + parseOptions: { comments: true, docs: true }, + }, + }); + expectDiagnosticEmpty(program.diagnostics); + + const emitterOptions = program.getGlobalNamespaceType().models.get("EmitterOptions"); + expect(emitterOptions).toBeDefined(); + return generateEmitterOptionsType(program, emitterOptions!, { + interfaceName: "TestEmitterOptions", + prettierConfig: { plugins: [] }, + }); +} + +it("maps emitter options model to a TypeScript interface", async () => { + const result = await generateOptions(` + scalar absolutePath extends string; + enum Format { yaml, json } + + /** + * Test emitter options. + */ + model EmitterOptions { + /** + * File type. + */ + \`file-type\`?: Format | Format[]; + + /** + * Numeric value. + */ + count: int32; + + /** + * Custom string scalar. + */ + path?: absolutePath; + + /** + * Byte payload. + */ + payload?: bytes; + + /** + * String-indexed metadata. + */ + metadata?: Record; + + /** + * Nested object. + */ + nested?: { + /** + * Strategy kind. + */ + kind: "parent" | "fqn"; + + /** + * Optional separator. + */ + separator?: string; + }; + } + `); + + expect(result.trim()).toEqual( + ` +/** + * Test emitter options. + */ +export interface TestEmitterOptions { + /** + * File type. + */ + "file-type"?: "yaml" | "json" | ("yaml" | "json")[]; + /** + * Numeric value. + */ + count: number; + /** + * Custom string scalar. + */ + path?: string; + /** + * Byte payload. + */ + payload?: Uint8Array; + /** + * String-indexed metadata. + */ + metadata?: Record; + /** + * Nested object. + */ + nested?: { + /** + * Strategy kind. + */ + kind: "parent" | "fqn"; + /** + * Optional separator. + */ + separator?: string; + }; +} +`.trim(), + ); +}); diff --git a/packages/tspd/test/ref-doc/emitter-options-model.test.ts b/packages/tspd/test/ref-doc/emitter-options-model.test.ts new file mode 100644 index 00000000000..1c7603a7a88 --- /dev/null +++ b/packages/tspd/test/ref-doc/emitter-options-model.test.ts @@ -0,0 +1,215 @@ +import { Model, resolvePath } from "@typespec/compiler"; +import { createTester } from "@typespec/compiler/testing"; +import { expect, it } from "vitest"; +import { extractEmitterOptionsRefDocFromModel } from "../../src/ref-doc/extractor.js"; + +const Tester = createTester(resolvePath(import.meta.dirname, "..", ".."), { + libraries: [], +}); + +async function extractOptions(code: string) { + const [{ program }] = await Tester.compileAndDiagnose(code); + const model = program.getGlobalNamespaceType().models.get("EmitterOptions"); + if (model === undefined) { + throw new Error("Expected an `EmitterOptions` model in the compiled program"); + } + return extractEmitterOptionsRefDocFromModel(program, model as Model); +} + +it("maps scalar and boolean options", async () => { + const options = await extractOptions(` + model EmitterOptions { + name?: string; + flag?: boolean; + } + `); + expect(options).toEqual([ + { name: "name", type: "string", doc: "" }, + { name: "flag", type: "boolean", doc: "" }, + ]); +}); + +it("maps a union of string literals to allowed values", async () => { + const options = await extractOptions(` + model EmitterOptions { + strategy?: "a" | "b" | "c"; + } + `); + expect(options[0]).toEqual({ + name: "strategy", + type: `"a" | "b" | "c"`, + doc: "", + allowedValues: [`"a"`, `"b"`, `"c"`], + }); +}); + +it("reads doc and @default tag", async () => { + const options = await extractOptions(` + model EmitterOptions { + /** + * The newline character. + * @default "lf" + */ + \`new-line\`?: "crlf" | "lf"; + } + `); + expect(options[0]).toMatchObject({ + name: "new-line", + type: `"crlf" | "lf"`, + doc: "The newline character.", + default: `"lf"`, + allowedValues: [`"crlf"`, `"lf"`], + }); +}); + +it("maps an array of literals using allowed values", async () => { + const options = await extractOptions(` + model EmitterOptions { + versions?: ("3.0.0" | "3.1.0")[]; + } + `); + expect(options[0]).toEqual({ + name: "versions", + type: `("3.0.0" | "3.1.0")[]`, + doc: "", + allowedValues: [`"3.0.0"`, `"3.1.0"`], + }); +}); + +it("maps a union of literals and an array to variants", async () => { + const options = await extractOptions(` + model EmitterOptions { + \`file-type\`?: ("yaml" | "json") | ("yaml" | "json")[]; + } + `); + expect(options[0]).toMatchObject({ + name: "file-type", + type: `"yaml" | "json" | ("yaml" | "json")[]`, + variants: [ + { type: `"yaml" | "json"`, allowedValues: [`"yaml"`, `"json"`] }, + { type: `("yaml" | "json")[]` }, + ], + }); +}); + +it("maps a union of literals and an object to variants with nested options", async () => { + const options = await extractOptions(` + model EmitterOptions { + strategy?: "fqn" | { + kind: "fqn" | "explicit-only"; + separator?: string; + }; + } + `); + const option = options[0]; + expect(option.name).toEqual("strategy"); + expect(option.type).toEqual(`"fqn" | object { kind, separator }`); + expect(option.variants?.[0]).toEqual({ + type: `"fqn"`, + allowedValues: [`"fqn"`], + }); + const objectVariant = option.variants?.[1]; + expect(objectVariant?.type).toEqual("object { kind, separator }"); + expect(objectVariant?.nestedOptions).toEqual([ + { + name: "kind", + type: `"fqn" | "explicit-only"`, + doc: "", + allowedValues: [`"fqn"`, `"explicit-only"`], + }, + { name: "separator", type: "string", doc: "" }, + ]); +}); + +it("appends @example tags to option docs", async () => { + const options = await extractOptions(` + model EmitterOptions { + /** + * Name of the output file. + * + * @example Single service no versioning + * - \`openapi.yaml\` + * + * @example Multiple services no versioning + * - \`openapi.Org1.Service1.yaml\` + * - \`openapi.Org1.Service2.yaml\` + */ + \`output-file\`?: string; + } + `); + + expect(options[0].doc).toContain("Name of the output file."); + expect(options[0].doc).toContain("Example Single service no versioning"); + expect(options[0].doc).toContain("- `openapi.yaml`"); + expect(options[0].doc).toContain("Example Multiple services no versioning"); + expect(options[0].doc).toContain("- `openapi.Org1.Service2.yaml`"); +}); + +it("reads @default tags from nested object properties", async () => { + const options = await extractOptions(` + model EmitterOptions { + strategy?: "fqn" | { + /** + * Strategy kind. + * @default "parent-container" + */ + kind: "parent-container" | "fqn" | "explicit-only"; + }; + } + `); + + expect(options[0].variants?.[1].nestedOptions?.[0]).toMatchObject({ + name: "kind", + default: `"parent-container"`, + }); +}); + +it("propagates shorthand defaults to nested kind properties", async () => { + const options = await extractOptions(` + model EmitterOptions { + /** + * Strategy option. + * @default "parent-container" + */ + strategy?: "parent-container" | "fqn" | { + kind: "parent-container" | "fqn"; + separator?: string; + }; + } + `); + + expect(options[0].variants?.[1].nestedOptions?.[0]).toMatchObject({ + name: "kind", + default: `"parent-container"`, + }); +}); + +it("excludes @internal option properties", async () => { + const options = await extractOptions(` + model EmitterOptions { + /** Public option. */ + visible?: string; + + /** + * Internal option. + * @internal + */ + hidden?: string; + + nested?: { + /** Public nested option. */ + enabled?: boolean; + + /** + * Internal nested option. + * @internal + */ + secret?: string; + }; + } + `); + + expect(options.map((x) => x.name)).toEqual(["visible", "nested"]); + expect(options[1].type).toEqual("object { enabled }"); + expect(options[1].nestedOptions?.map((x) => x.name)).toEqual(["enabled"]); +}); diff --git a/website/src/content/docs/docs/emitters/json-schema/reference/emitter.md b/website/src/content/docs/docs/emitters/json-schema/reference/emitter.md index 609cc318b8f..0e3f1f994f8 100644 --- a/website/src/content/docs/docs/emitters/json-schema/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/json-schema/reference/emitter.md @@ -41,33 +41,39 @@ See [Configuring output directory for more info](https://typespec.io/docs/handbo **Type:** `"yaml" | "json"` Serialize the schema as either yaml or json. +Default `yaml`, if not specified infer from the `output-file` extension. ### `int64-strategy` **Type:** `"string" | "number"` -How to handle 64 bit integers on the wire. Options are: +How to handle 64-bit integers on the wire. Options are: -- string: serialize as a string (widely interoperable) -- number: serialize as a number (not widely interoperable) +- string: Serialize as a string (widely interoperable) +- number: Serialize as a number (not widely interoperable) ### `bundleId` **Type:** `string` -When provided, bundle all the schemas into a single json schema document with schemas under $defs. The provided id is the id of the root document and is also used for the file name. +When provided, bundle all the schemas into a single JSON Schema document +with schemas under $defs. The provided id is the id of the root document +and is also used for the file name. ### `emitAllModels` **Type:** `boolean` -When true, emit all model declarations to JSON Schema without requiring the @jsonSchema decorator. +When true, emit all model declarations to JSON Schema without requiring +the `@jsonSchema` decorator. ### `emitAllRefs` **Type:** `boolean` -When true, emit all references as json schema files, even if the referenced type does not have the `@jsonSchema` decorator or is not within a namespace with the `@jsonSchema` decorator. +When true, emit all references as JSON Schema files, even if the referenced +type does not have the `@jsonSchema` decorator or is not within a namespace +with the `@jsonSchema` decorator. ### `seal-object-schemas` @@ -77,7 +83,6 @@ When true, emit all references as json schema files, even if the referenced type If true, then for models emitted as object schemas we default `unevaluatedProperties` to `{ not: {} }`, if not explicitly specified elsewhere. -Default: `false` ### `polymorphic-models-strategy` @@ -85,12 +90,8 @@ Default: `false` **Default:** `"ignore"` -Strategy for emitting models with the @discriminator decorator: +Strategy for emitting models with the discriminator decorator. -- ignore: Emit as regular object schema (default). Derived models use allOf to reference their base model. +- ignore: Emit as regular object schema (default) - oneOf: Emit a oneOf schema with references to all derived models (closed union) - anyOf: Emit an anyOf schema with references to all derived models (open union) - -When using oneOf or anyOf, derived models will inline all properties from their base model -instead of using allOf references. This avoids circular references in the generated schemas, -since the base model references derived models via oneOf/anyOf. diff --git a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md index 39f7fc1e342..530a76672e2 100644 --- a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md @@ -40,7 +40,9 @@ See [Configuring output directory for more info](https://typespec.io/docs/handbo **Type:** `"yaml" | "json" | ("yaml" | "json")[]` -If the content should be serialized as YAML or JSON. Can be a single value or an array to emit multiple formats. Default 'yaml', if not specified infer from the `output-file` extension +If the content should be serialized as YAML or JSON. +Can be a single value or an array to emit multiple formats. +Default `yaml`, if not specified infer from the `output-file` extension. **Options:** @@ -59,8 +61,8 @@ Output file will interpolate the following values: - version: Version of the service if multiple - file-type: The file type being emitted (json or yaml). Useful when `file-type` is an array. -Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"` -When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}` +Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"`. +When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}`. Example Single service no versioning @@ -83,12 +85,6 @@ Example Multiple service with versioning - `openapi.Org1.Service2.v1.0.yaml` - `openapi.Org1.Service2.v1.1.yaml` -### `openapi-versions` - -**Type:** `"3.0.0" | "3.1.0" | "3.2.0"` - -**Default:** `["3.0.0"]` - ### `new-line` **Type:** `"crlf" | "lf"` @@ -124,8 +120,6 @@ How to handle safeint type. Options are: - `double-int`: Will produce `type: integer, format: double-int` - `int64`: Will produce `type: integer, format: int64` -Default: `int64` - ### `seal-object-schemas` **Type:** `boolean` @@ -134,37 +128,37 @@ Default: `int64` If true, then for models emitted as object schemas we default `additionalProperties` to false for OpenAPI 3.0, and `unevaluatedProperties` to false for OpenAPI 3.1, if not explicitly specified elsewhere. -Default: `false` ### `experimental-parameter-examples` **Type:** `"data" | "serialized"` Determines how to emit examples on parameters. + Note: This is an experimental feature and may change in future versions. -See https://spec.openapis.org/oas/v3.0.4.html#style-examples for parameter example serialization rules -See https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion on handling parameter examples. ### `operation-id-strategy` **Type:** `"parent-container" | "fqn" | "explicit-only" | object { kind, separator }` -**Options:** - -- `"parent-container" | "fqn" | "explicit-only"` (default: `"parent-container"`) +**Default:** `"parent-container"` - Determines how to generate operation IDs when `@operationId` is not used. - Avaliable options are: +How should operation ID be generated when `@operationId` is not used. +Available options are -- `parent-container`: Uses the parent namespace and operation name to generate the ID. -- `fqn`: Uses the fully qualified name of the operation to generate the ID. +- `parent-container`: Uses the parent namespace/interface and operation name to generate the ID. +- `fqn`: Uses the fully qualified name(from service root) of the operation to generate the ID. - `explicit-only`: Only use explicitly defined operation IDs. + +**Options:** + +- `"parent-container" | "fqn" | "explicit-only"` - `object { kind, separator }` -| Name | Type | Default | Description | -| ----------- | ------------------------------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `kind` | `"parent-container" \| "fqn" \| "explicit-only"` | `"parent-container"` | Determines how to generate operation IDs when `@operationId` is not used.
Avaliable options are:
- `parent-container`: Uses the parent namespace and operation name to generate the ID.
- `fqn`: Uses the fully qualified name of the operation to generate the ID.
- `explicit-only`: Only use explicitly defined operation IDs. | -| `separator` | `string` | | Separator used to join segment in the operation name. | +| Name | Type | Default | Description | +| ----------- | ------------------------------------------------ | -------------------- | ----------------------------------------------------- | +| `kind` | `"parent-container" \| "fqn" \| "explicit-only"` | `"parent-container"` | Strategy used to generate the operation ID. | +| `separator` | `string` | | Separator used to join segment in the operation name. | ### `enum-strategy` @@ -176,5 +170,4 @@ How to emit TypeSpec enums. Options are: - `default`: Emit as a single schema using the `enum` keyword. - `annotated`: Emit as a `oneOf` of `const` subschemas annotated with `title` and `description` - from each member's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern. - Only supported by OpenAPI 3.1.0 and above; on 3.0.0 the `default` style is used and a warning is reported. + from each member's `@summary` and `@doc`. Only supported by OpenAPI 3.1.0 and above.