Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .chronus/changes/operation-id-strategy-2025-9-1-16-38-40.md
Original file line number Diff line number Diff line change
@@ -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`
8 changes: 8 additions & 0 deletions .chronus/changes/operation-id-strategy-2025-9-1-16-38-41.md
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions .chronus/changes/operation-id-strategy-2025-9-1-18-43-58.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions packages/compiler/src/core/schema-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function createJSONSchemaValidator<T>(
const ajv = new Ajv({
strict: options.strict,
coerceTypes: options.coerceTypes,
allowUnionTypes: true,
allErrors: true,
} satisfies Options);

Expand Down
4 changes: 4 additions & 0 deletions packages/openapi3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/openapi3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
"default": "./dist/src/testing/index.js"
}
},
"imports": {
"#test/*": "./test/*"
},
"engines": {
"node": ">=20.0.0"
},
Expand Down
49 changes: 49 additions & 0 deletions packages/openapi3/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpenAPI3EmitterOptions> = {
type: "object",
additionalProperties: false,
Expand Down Expand Up @@ -201,6 +233,23 @@ const EmitterOptionsSchema: JSONSchemaType<OpenAPI3EmitterOptions> = {
"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: [],
};
Expand Down
51 changes: 45 additions & 6 deletions packages/openapi3/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"],
Expand All @@ -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;
Expand All @@ -225,6 +258,7 @@ export interface ResolvedOpenAPI3EmitterOptions {
safeintStrategy: "double-int" | "int64";
sealObjectSchemas: boolean;
parameterExamplesStrategy?: "data" | "serialized";
operationIdStrategy: { kind: OperationIdStrategy; separator: string };
}

function createOAPIEmitter(
Expand All @@ -241,7 +275,7 @@ function createOAPIEmitter(
} = getOpenApiSpecProps(specVersion);
const program = context.program;
let schemaEmitter: AssetEmitter<OpenAPI3Schema | OpenAPISchema3_1, OpenAPI3EmitterOptions>;

let operationIdResolver: OperationIdResolver;
let root: SupportedOpenAPIDocuments;
let diagnostics: DiagnosticCollector;
let currentService: Service;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string>(operationIds);
if (uniqueOpIds.size === 1) return uniqueOpIds.values().next().value;
return operationIds.join("_");
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading