diff --git a/.chronus/changes/operation-id-strategy-2025-9-1-16-38-40.md b/.chronus/changes/operation-id-strategy-2025-9-1-16-38-40.md new file mode 100644 index 00000000000..3d54c75aaf7 --- /dev/null +++ b/.chronus/changes/operation-id-strategy-2025-9-1-16-38-40.md @@ -0,0 +1,12 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: feature +packages: + - "@typespec/openapi3" +--- + +Add a new `operation-id-strategy` option. + +- `parent-container` (default and previous behavior) Join operation name with its parent if applicable with an underscore +- `fqn` Join the path from the service root to the operation with `.` +- `none` Do not generate operation ids, only include explicit ones set with `@operationId` diff --git a/.chronus/changes/operation-id-strategy-2025-9-1-16-38-41.md b/.chronus/changes/operation-id-strategy-2025-9-1-16-38-41.md new file mode 100644 index 00000000000..077fd673926 --- /dev/null +++ b/.chronus/changes/operation-id-strategy-2025-9-1-16-38-41.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: fix +packages: + - "@typespec/openapi3" +--- + +Deduplicate operation ids that would resolve to the same one diff --git a/.chronus/changes/operation-id-strategy-2025-9-1-18-43-58.md b/.chronus/changes/operation-id-strategy-2025-9-1-18-43-58.md new file mode 100644 index 00000000000..d1b62944b65 --- /dev/null +++ b/.chronus/changes/operation-id-strategy-2025-9-1-18-43-58.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: feature +packages: + - "@typespec/compiler" +--- + +[API] Allow using union in emitter schemas diff --git a/packages/compiler/src/core/schema-validator.ts b/packages/compiler/src/core/schema-validator.ts index f14ba3c01b0..f5b7d4439c1 100644 --- a/packages/compiler/src/core/schema-validator.ts +++ b/packages/compiler/src/core/schema-validator.ts @@ -35,6 +35,7 @@ export function createJSONSchemaValidator( const ajv = new Ajv({ strict: options.strict, coerceTypes: options.coerceTypes, + allowUnionTypes: true, allErrors: true, } satisfies Options); diff --git a/packages/openapi3/README.md b/packages/openapi3/README.md index f7d781be2e0..1c5c6efb4ed 100644 --- a/packages/openapi3/README.md +++ b/packages/openapi3/README.md @@ -134,6 +134,10 @@ 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:** `undefined` + ## Decorators ### TypeSpec.OpenAPI diff --git a/packages/openapi3/package.json b/packages/openapi3/package.json index f9f6c71e2b1..aff70a38dd3 100644 --- a/packages/openapi3/package.json +++ b/packages/openapi3/package.json @@ -33,6 +33,9 @@ "default": "./dist/src/testing/index.js" } }, + "imports": { + "#test/*": "./test/*" + }, "engines": { "node": ">=20.0.0" }, diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index ae5cee57c8b..12b5a1437b4 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -91,8 +91,40 @@ export interface OpenAPI3EmitterOptions { * @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; + }; } +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, @@ -201,6 +233,23 @@ const EmitterOptionsSchema: JSONSchemaType = { "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, }, required: [], }; diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 380382d01bb..14d6c598db0 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -78,15 +78,21 @@ import { getParameterKey, getTagsMetadata, isReadonlyProperty, - resolveOperationId, shouldInline, } from "@typespec/openapi"; import { stringify } from "yaml"; import { getRef } from "./decorators.js"; import { getExampleOrExamples, OperationExamples, resolveOperationExamples } from "./examples.js"; import { JsonSchemaModule, resolveJsonSchemaModule } from "./json-schema.js"; -import { createDiagnostic, FileType, OpenAPI3EmitterOptions, OpenAPIVersion } from "./lib.js"; +import { + createDiagnostic, + FileType, + OpenAPI3EmitterOptions, + OpenAPIVersion, + OperationIdStrategy, +} from "./lib.js"; import { getOpenApiSpecProps } from "./openapi-spec-mappings.js"; +import { OperationIdResolver } from "./operation-id-resolver/operation-id-resolver.js"; import { getParameterStyle } from "./parameters.js"; import { getOpenAPI3StatusCodes } from "./status-codes.js"; import { @@ -201,7 +207,6 @@ export function resolveOptions( const openapiVersions = resolvedOptions["openapi-versions"] ?? ["3.0.0"]; const specDir = openapiVersions.length > 1 ? "{openapi-version}" : ""; - return { fileType, newLine: resolvedOptions["new-line"], @@ -212,9 +217,37 @@ export function resolveOptions( openapiVersions, sealObjectSchemas: resolvedOptions["seal-object-schemas"], parameterExamplesStrategy: resolvedOptions["experimental-parameter-examples"], + operationIdStrategy: resolveOperationIdStrategy(resolvedOptions["operation-id-strategy"]), }; } +const defaultOperationIdStrategy = { kind: "parent-container", separator: "_" } as const; +function resolveOperationIdStrategy( + strategy?: OperationIdStrategy | { kind: OperationIdStrategy; separator?: string }, +): { kind: OperationIdStrategy; separator: string } { + if (strategy === undefined) { + return defaultOperationIdStrategy; + } + if (typeof strategy === "string") { + return { kind: strategy, separator: resolveOperationIdDefaultStrategySeparator(strategy) }; + } + return { + kind: strategy.kind, + separator: strategy.separator ?? resolveOperationIdDefaultStrategySeparator(strategy.kind), + }; +} + +function resolveOperationIdDefaultStrategySeparator(strategy: OperationIdStrategy) { + switch (strategy) { + case "parent-container": + return "_"; + case "fqn": + return "."; + case "explicit-only": + return ""; + } +} + export interface ResolvedOpenAPI3EmitterOptions { fileType: FileType; outputFile: string; @@ -225,6 +258,7 @@ export interface ResolvedOpenAPI3EmitterOptions { safeintStrategy: "double-int" | "int64"; sealObjectSchemas: boolean; parameterExamplesStrategy?: "data" | "serialized"; + operationIdStrategy: { kind: OperationIdStrategy; separator: string }; } function createOAPIEmitter( @@ -241,7 +275,7 @@ function createOAPIEmitter( } = getOpenApiSpecProps(specVersion); const program = context.program; let schemaEmitter: AssetEmitter; - + let operationIdResolver: OperationIdResolver; let root: SupportedOpenAPIDocuments; let diagnostics: DiagnosticCollector; let currentService: Service; @@ -368,6 +402,10 @@ function createOAPIEmitter( options, optionalDependencies, }); + operationIdResolver = new OperationIdResolver(program, { + strategy: options.operationIdStrategy.kind, + separator: options.operationIdStrategy.separator, + }); const securitySchemes = getOpenAPISecuritySchemes(allHttpAuthentications); const security = getOpenAPISecurity(defaultAuth); @@ -730,7 +768,8 @@ function createOAPIEmitter( } function computeSharedOperationId(shared: SharedHttpOperation) { - const operationIds = shared.operations.map((op) => resolveOperationId(program, op.operation)); + if (options.operationIdStrategy.kind === "explicit-only") return undefined; + const operationIds = shared.operations.map((op) => operationIdResolver.resolve(op.operation)!); const uniqueOpIds = new Set(operationIds); if (uniqueOpIds.size === 1) return uniqueOpIds.values().next().value; return operationIds.join("_"); @@ -842,7 +881,7 @@ function createOAPIEmitter( parameterExamplesStrategy: options.parameterExamplesStrategy, }); const oai3Operation: OpenAPI3Operation = { - operationId: resolveOperationId(program, operation.operation), + operationId: operationIdResolver.resolve(operation.operation), summary: getSummary(program, operation.operation), description: getDoc(program, operation.operation), parameters: getEndpointParameters(parameters.properties, visibility, examples), diff --git a/packages/openapi3/src/operation-id-resolver/operation-id-resolver.test.ts b/packages/openapi3/src/operation-id-resolver/operation-id-resolver.test.ts new file mode 100644 index 00000000000..b1178db12c3 --- /dev/null +++ b/packages/openapi3/src/operation-id-resolver/operation-id-resolver.test.ts @@ -0,0 +1,176 @@ +import { ApiTester } from "#test/test-host.js"; +import { t } from "@typespec/compiler/testing"; +import { ok, strictEqual } from "assert"; +import { describe, expect, it } from "vitest"; +import { OperationIdStrategy } from "../lib.js"; +import { OperationIdResolver } from "./operation-id-resolver.js"; + +async function testResolveOperationId(code: string, strategy: OperationIdStrategy) { + const { foo, program } = await ApiTester.import("@typespec/openapi").compile(code); + ok(foo); + strictEqual(foo.entityKind, "Type"); + strictEqual(foo.kind, "Operation"); + const resolver = new OperationIdResolver(program, { + strategy, + separator: strategy === "parent-container" ? "_" : ".", + }); + return resolver.resolve(foo); +} + +describe("parent-container strategy", () => { + it("return operation name if operation is defined at the root", async () => { + const id = await testResolveOperationId(`@test op foo(): string;`, "parent-container"); + expect(id).toEqual("foo"); + }); + + it("return operation name if operation is defined under service namespace", async () => { + const id = await testResolveOperationId( + ` + @service namespace MyService; + + @test op foo(): string; + `, + "parent-container", + ); + expect(id).toEqual("foo"); + }); + + it("return interface and operation name", async () => { + const id = await testResolveOperationId( + ` + interface Bar { + @test op foo(): string; + } + `, + "parent-container", + ); + expect(id).toEqual("Bar_foo"); + }); + + it("return namespace and operation name", async () => { + const id = await testResolveOperationId( + ` + @service namespace MyService; + + namespace Bar { + @test op foo(): string; + } + `, + "parent-container", + ); + expect(id).toEqual("Bar_foo"); + }); + + it("return one level of namespace only and operation name", async () => { + const id = await testResolveOperationId( + ` + @service namespace MyService; + + namespace Baz { + namespace Bar { + @test op foo(): string; + } + } + `, + "parent-container", + ); + expect(id).toEqual("Bar_foo"); + }); + + it("deduplicates operation IDs", async () => { + const { op1, op2, program } = await ApiTester.compile(t.code` + @service namespace MyService; + + namespace One { + op /*op1*/test(): string; + } + + namespace Two { + interface One { + /*op2*/test(): string; + } + } + `); + const resolver = new OperationIdResolver(program, { + strategy: "parent-container", + separator: "_", + }); + expect(resolver.resolve(op1 as any)).toEqual("One_test"); + expect(resolver.resolve(op2 as any)).toEqual("One_test_2"); + }); +}); + +describe("fqn", () => { + it("return operation name if operation is defined at the root", async () => { + const id = await testResolveOperationId(`@test op foo(): string;`, "fqn"); + expect(id).toEqual("foo"); + }); + + it("return operation name if operation is defined under service namespace", async () => { + const id = await testResolveOperationId( + ` + @service namespace MyService; + + @test op foo(): string; + `, + "fqn", + ); + expect(id).toEqual("foo"); + }); + + it("return interface name and operation name", async () => { + const id = await testResolveOperationId( + ` + interface Bar { + @test op foo(): string; + } + `, + "fqn", + ); + expect(id).toEqual("Bar.foo"); + }); + + it("return namespace and operation name", async () => { + const id = await testResolveOperationId( + ` + @service namespace MyService; + + namespace Bar { + @test op foo(): string; + } + `, + "fqn", + ); + expect(id).toEqual("Bar.foo"); + }); + + it("returns full path", async () => { + const id = await testResolveOperationId( + ` + @service namespace MyService; + + namespace Baz { + namespace Bar { + @test op foo(): string; + } + } + `, + "fqn", + ); + expect(id).toEqual("Baz.Bar.foo"); + }); +}); + +describe("explicit-only", () => { + it("return operationId explicitly set", async () => { + const id = await testResolveOperationId( + `@test @OpenAPI.operationId("explicit_foo") op foo(): string;`, + "explicit-only", + ); + expect(id).toEqual("explicit_foo"); + }); + it("return undefined", async () => { + const id = await testResolveOperationId(`@test op foo(): string;`, "explicit-only"); + expect(id).toEqual(undefined); + }); +}); diff --git a/packages/openapi3/src/operation-id-resolver/operation-id-resolver.ts b/packages/openapi3/src/operation-id-resolver/operation-id-resolver.ts new file mode 100644 index 00000000000..c6270667572 --- /dev/null +++ b/packages/openapi3/src/operation-id-resolver/operation-id-resolver.ts @@ -0,0 +1,88 @@ +import { isGlobalNamespace, isService, type Operation, type Program } from "@typespec/compiler"; +import { getOperationId } from "@typespec/openapi"; +import { OperationIdStrategy } from "../lib.js"; + +export interface OperationIdResolverOptions { + strategy: OperationIdStrategy; + separator?: string; +} +export class OperationIdResolver { + #program: Program; + #strategy: OperationIdStrategy; + #used = new Set(); + #cache = new Map(); + #separator: string; + + constructor(program: Program, options: OperationIdResolverOptions) { + this.#program = program; + this.#strategy = options.strategy; + this.#separator = options.separator ?? "."; + } + + /** + * Resolve the OpenAPI operation ID for the given operation using the following logic: + * - If `@operationId` was specified use that value + * - Otherwise follow the {@link OperationIdStrategy} + * + * This will deduplicate operation ids + */ + resolve(operation: Operation): string | undefined { + const existing = this.#cache.get(operation); + if (existing) return existing; + const explicitOperationId = getOperationId(this.#program, operation); + if (explicitOperationId) { + return explicitOperationId; + } + + let name = this.#resolveInternal(operation); + if (name === undefined) return undefined; + + if (this.#used.has(name)) { + name = this.#findNextAvailableName(name); + } + this.#used.add(name); + this.#cache.set(operation, name); + return name; + } + + #findNextAvailableName(name: string) { + let count = 1; + while (true) { + count++; + const newName = `${name}_${count}`; + if (!this.#used.has(newName)) { + return newName; + } + } + } + + #resolveInternal(operation: Operation): string | undefined { + const operationPath = this.#getOperationPath(operation); + + switch (this.#strategy) { + case "parent-container": + return operationPath.slice(-2).join(this.#separator); + case "fqn": + return operationPath.join(this.#separator); + case "explicit-only": + return undefined; + } + } + + #getOperationPath(operation: Operation): string[] { + const path = [operation.name]; + let current = operation.interface ?? operation.namespace; + while (current) { + if ( + current === undefined || + (current.kind === "Namespace" && + (isGlobalNamespace(this.#program, current) || isService(this.#program, current))) + ) { + break; + } + path.unshift(current.name); + current = current.namespace; + } + return path; + } +} 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 726054886b8..d9b78f2f494 100644 --- a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md @@ -127,3 +127,7 @@ 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:** `undefined`