From 8b567a2777a6faac1cded8e5c5f47a00b28182c5 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Tue, 20 Oct 2026 16:00:38 -0400 Subject: [PATCH 1/3] Add LRO operation ID metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d18612ec-bd77-4fdd-86d0-467051688ac6 --- .../lro-operation-id-metadata-2026-10-20.md | 9 + packages/azure-http-specs/spec-summary.md | 19 ++ .../lro/operation-id/main.tsp | 77 +++++ .../lro/operation-id/mockapi.ts | 96 ++++++ .../typespec-azure-core/src/lro-helpers.ts | 30 +- .../test/operations.test.ts | 3 + .../typespec-client-generator-core/README.md | 24 ++ .../Azure.ClientGenerator.Core.ts | 17 + .../lib/decorators.tsp | 12 + .../src/decorators.ts | 37 +++ .../diagnostics/lro-operation-id-duplicate.md | 7 + .../lro-operation-id-missing-link.md | 8 + .../diagnostics/lro-operation-id-not-path.md | 9 + .../src/interfaces.ts | 15 + .../typespec-client-generator-core/src/lib.ts | 23 ++ .../src/lro-operation-id.ts | 87 +++++ .../src/methods.ts | 31 ++ .../src/tsp-index.ts | 2 + .../src/validate.ts | 2 + .../test/methods/lro-operation-id.test.ts | 302 ++++++++++++++++++ .../guideline.md | 34 ++ .../reference/decorators.md | 23 ++ .../reference/index.mdx | 1 + 23 files changed, 863 insertions(+), 5 deletions(-) create mode 100644 .chronus/changes/lro-operation-id-metadata-2026-10-20.md create mode 100644 packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/main.tsp create mode 100644 packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/mockapi.ts create mode 100644 packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-duplicate.md create mode 100644 packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-missing-link.md create mode 100644 packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-not-path.md create mode 100644 packages/typespec-client-generator-core/src/lro-operation-id.ts create mode 100644 packages/typespec-client-generator-core/test/methods/lro-operation-id.test.ts diff --git a/.chronus/changes/lro-operation-id-metadata-2026-10-20.md b/.chronus/changes/lro-operation-id-metadata-2026-10-20.md new file mode 100644 index 0000000000..49869ed0ac --- /dev/null +++ b/.chronus/changes/lro-operation-id-metadata-2026-10-20.md @@ -0,0 +1,9 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-azure-core" + - "@azure-tools/typespec-client-generator-core" + - "@azure-tools/azure-http-specs" +--- + +Add scoped `@lroOperationId` metadata for legacy services that expose server-issued operation identity only through a linked status-monitor URI, plus a Document Intelligence-shaped Spector scenario. diff --git a/packages/azure-http-specs/spec-summary.md b/packages/azure-http-specs/spec-summary.md index 86bf422cbc..be4952c8c3 100644 --- a/packages/azure-http-specs/spec-summary.md +++ b/packages/azure-http-specs/spec-summary.md @@ -1316,6 +1316,25 @@ Expected calls: - GET the status-monitor URI returns a running status with nested `usage` - GET the status-monitor URI returns a succeeded status with nested `usage` and the result +### Azure_ClientGenerator_Core_Lro_OperationId_analyze + +- Endpoint: `post /azure/client-generator-core/lro/operation-id/documentModels/{modelId}:analyze` + +Declares a legacy Document Intelligence-shaped flow for emitter tests of +`@lroOperationId` on the status-monitor operation ID path parameter. + +The status-monitor body does not model the server-issued result ID. The service returns the +complete opaque status-monitor URI, including its API-version query, and polling must continue +with that URI. Emitter-specific client tests can separately expose `analysis-123` as operation +identity by binding the decorated `resultId` parameter to the returned URI. + +Expected calls: + +- POST `/documentModels/document-1:analyze` returns 202 with + `Operation-Location: {endpoint}/documentModels/document-1/analyzeResults/analysis-123?api-version=2022-12-01-preview` +- GET that exact status-monitor URI returns a running status +- GET that exact status-monitor URI returns a succeeded status with the analysis result + ### Azure_ClientGenerator_Core_NextLinkVerb_listItems - Endpoint: `post /azure/client-generator-core/next-link-verb/items` diff --git a/packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/main.tsp b/packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/main.tsp new file mode 100644 index 0000000000..1290e79602 --- /dev/null +++ b/packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/main.tsp @@ -0,0 +1,77 @@ +import "@azure-tools/typespec-azure-core"; +import "@azure-tools/typespec-client-generator-core"; +import "@typespec/spector"; +import "@typespec/versioning"; + +using Azure.ClientGenerator.Core; +using Azure.Core; +using Spector; +using TypeSpec.Http; +using TypeSpec.Versioning; + +@doc("Defines a legacy service-shaped HTTP flow for testing URI-derived LRO operation identity.") +@scenarioService( + "/azure/client-generator-core/lro/operation-id", + { + versioned: Versions, + } +) +@global.Azure.ClientGenerator.Core.clientNamespace( + "azure.clientgenerator.core.lro.operationid", + "java" +) +@global.Azure.ClientGenerator.Core.clientNamespace( + "specs.azure.clientgenerator.core.lro.operationid", + "python" +) +namespace _Specs_.Azure.ClientGenerator.Core.Lro.OperationId; + +enum Versions { + v2022_12_01_preview: "2022-12-01-preview", +} + +model DocumentAnalysisStatus { + status: global.Azure.Core.Foundations.OperationState; + result?: DocumentAnalysisResult; +} + +model DocumentAnalysisResult { + summary: string; +} + +@route("/documentModels/{modelId}/analyzeResults/{resultId}") +@get +op getDocumentAnalysisStatus( + @path modelId: string, + + @lroOperationId + @path + resultId: string, + + ...global.Azure.Core.Foundations.ApiVersionParameter, +): DocumentAnalysisStatus; + +@scenario +@scenarioDoc(""" + Declares a legacy Document Intelligence-shaped flow for emitter tests of + `@lroOperationId` on the status-monitor operation ID path parameter. + + The status-monitor body does not model the server-issued result ID. The service returns the + complete opaque status-monitor URI, including its API-version query, and polling must continue + with that URI. Emitter-specific client tests can separately expose `analysis-123` as operation + identity by binding the decorated `resultId` parameter to the returned URI. + + Expected calls: + - POST `/documentModels/document-1:analyze` returns 202 with + `Operation-Location: {endpoint}/documentModels/document-1/analyzeResults/analysis-123?api-version=2022-12-01-preview` + - GET that exact status-monitor URI returns a running status + - GET that exact status-monitor URI returns a succeeded status with the analysis result + """) +@route("/documentModels/{modelId}:analyze") +@post +@pollingOperation(getDocumentAnalysisStatus) +op analyze( + @path modelId: string, + ...global.Azure.Core.Foundations.ApiVersionParameter, +): global.Azure.Core.Foundations.AcceptedResponse & + global.Azure.Core.Foundations.LongRunningStatusLocation; diff --git a/packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/mockapi.ts b/packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/mockapi.ts new file mode 100644 index 0000000000..2ed6ea589e --- /dev/null +++ b/packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/mockapi.ts @@ -0,0 +1,96 @@ +import { + dyn, + dynItem, + json, + MockRequest, + passOnSuccess, + type ScenarioMockApi, +} from "@typespec/spec-api"; + +export const Scenarios: Record = {}; + +const apiVersion = "2022-12-01-preview"; +let analyzePollCount = 0; + +Scenarios.Azure_ClientGenerator_Core_Lro_OperationId_analyze = passOnSuccess([ + { + uri: "/azure/client-generator-core/lro/operation-id/documentModels/document-1:analyze", + method: "post", + request: { + query: { + "api-version": apiVersion, + }, + }, + response: { + status: 202, + headers: { + "operation-location": dyn`${dynItem("baseUrl")}/azure/client-generator-core/lro/operation-id/documentModels/document-1/analyzeResults/analysis-123?api-version=2022-12-01-preview`, + }, + }, + handler: (req: MockRequest) => { + req.expect.containsQueryParam("api-version", apiVersion); + analyzePollCount = 0; + return { + status: 202, + headers: { + "operation-location": `${req.baseUrl}/azure/client-generator-core/lro/operation-id/documentModels/document-1/analyzeResults/analysis-123?api-version=${apiVersion}`, + }, + }; + }, + kind: "MockApiDefinition", + }, + { + uri: "/azure/client-generator-core/lro/operation-id/documentModels/document-1/analyzeResults/analysis-123", + method: "get", + request: { + query: { + "api-version": apiVersion, + }, + }, + response: { + status: 200, + body: json({ + status: "Running", + }), + }, + handler: analyzeLroHandler, + kind: "MockApiDefinition", + }, + { + uri: "/azure/client-generator-core/lro/operation-id/documentModels/document-1/analyzeResults/analysis-123", + method: "get", + request: { + query: { + "api-version": apiVersion, + }, + }, + response: { + status: 200, + body: json({ + status: "Succeeded", + result: { + summary: "The document contains three sections.", + }, + }), + }, + handler: analyzeLroHandler, + kind: "MockApiDefinition", + }, +]); + +function analyzeLroHandler(req: MockRequest) { + req.expect.containsQueryParam("api-version", apiVersion); + const response = + analyzePollCount > 0 + ? { + status: "Succeeded", + result: { + summary: "The document contains three sections.", + }, + } + : { + status: "Running", + }; + analyzePollCount += 1; + return { status: 200, body: json(response) }; +} diff --git a/packages/typespec-azure-core/src/lro-helpers.ts b/packages/typespec-azure-core/src/lro-helpers.ts index 35b87ebe5b..30165a20b6 100644 --- a/packages/typespec-azure-core/src/lro-helpers.ts +++ b/packages/typespec-azure-core/src/lro-helpers.ts @@ -169,6 +169,11 @@ export interface NextOperationLink extends LogicalOperationStep { responseModel: Model; /** information on how to get the uri to the status monitor */ target: OperationLink; + /** + * The explicitly linked polling operation, when the link was declared with + * `@pollingOperation`. Absent for an inferred or standalone polling location. + */ + operation?: Operation; } /** @@ -355,15 +360,14 @@ export function getLroProtocolMetadata( if (context === undefined) return undefined; processFinalReference(program, operation, context); processFinalLink(program, operation, context); - const nextReference: - NextOperationReference | (NextOperationLink & { operation: Operation }) | undefined = - processStatusMonitorReference(program, operation, context); + const nextReference = processStatusMonitorReference(program, operation, context); if (nextReference !== undefined && nextReference.responseModel.kind === "Model") { context.statusMonitorStep = nextReference; const linkedOperation = nextReference.kind === "nextOperationLink" ? nextReference.operation : nextReference.target.operation; + if (!linkedOperation) return undefined; processFinalReference(program, linkedOperation, context); processFinalLink(program, linkedOperation, context); context.pollingStep = getPollingStep(program, nextReference.responseModel, context); @@ -490,6 +494,22 @@ function createLroProtocolMetadata( } } + const pollingLink = context.pollingOperationLink?.link; + const statusMonitorStep = + context.statusMonitorStep?.kind === "nextOperationReference" && pollingLink + ? { + ...context.statusMonitorStep, + target: { + ...context.statusMonitorStep.target, + link: { + kind: "link" as const, + location: pollingLink.location, + property: pollingLink.property, + }, + }, + } + : context.statusMonitorStep; + return { operation, initial: { @@ -498,7 +518,7 @@ function createLroProtocolMetadata( isAction, }, polling: { - statusMonitorStep: context.statusMonitorStep, + statusMonitorStep, pollingInfo: context.pollingStep, statusMonitorResult: context.statusMonitorResult, }, @@ -1227,7 +1247,7 @@ function processStatusMonitorReference( program: Program, referencedOperation: Operation, context: LroContext, -): NextOperationReference | (NextOperationLink & { operation: Operation }) | undefined { +): NextOperationReference | NextOperationLink | undefined { const references: Map | undefined = getOperationLinks( program, referencedOperation, diff --git a/packages/typespec-azure-core/test/operations.test.ts b/packages/typespec-azure-core/test/operations.test.ts index e1c71abcb2..9b4ab893de 100644 --- a/packages/typespec-azure-core/test/operations.test.ts +++ b/packages/typespec-azure-core/test/operations.test.ts @@ -845,6 +845,9 @@ describe("LongRunningOperation", () => { ok(metadata); deepStrictEqual(metadata.statusMonitorStep?.kind, "nextOperationLink"); deepStrictEqual(metadata.statusMonitorStep?.responseModel.name, "ResourceOperationStatus"); + if (metadata.statusMonitorStep?.kind === "nextOperationLink") { + deepStrictEqual(metadata.statusMonitorStep.operation?.name, "poll"); + } deepStrictEqual(metadata.pollingInfo.kind, "pollingOperationStep"); deepStrictEqual(metadata.pollingInfo.responseModel.name, "ResourceOperationStatus"); diff --git a/packages/typespec-client-generator-core/README.md b/packages/typespec-client-generator-core/README.md index 99cc21dd79..e6013f79d6 100644 --- a/packages/typespec-client-generator-core/README.md +++ b/packages/typespec-client-generator-core/README.md @@ -146,6 +146,7 @@ Available ruleSets: - [`@clientOption`](#@clientoption) - [`@convenientAPI`](#@convenientapi) - [`@deserializeEmptyStringAsNull`](#@deserializeemptystringasnull) +- [`@lroOperationId`](#@lrooperationid) - [`@lroStatusMetadata`](#@lrostatusmetadata) - [`@operationGroup`](#@operationgroup) - [`@override`](#@override) @@ -1001,6 +1002,29 @@ model MyModel { } ``` +#### `@lroOperationId` + +Identifies the status-monitor path parameter containing a server-issued long-running operation +identifier for a legacy service that exposes the identifier only through the returned polling +URI. This compatibility metadata does not drive polling and is distinct from a caller-supplied +`Operation-Id` API parameter. Conforming services with modeled operation identity do not need +this decorator. Emitters choose their idiomatic accessor and storage. + +```typespec +@Azure.ClientGenerator.Core.lroOperationId(scope?: valueof Azure.ClientGenerator.Core.DecoratorOptions | string) +``` + +##### Target + +The operation identifier path parameter on the linked status-monitor operation. +`ModelProperty` + +##### Parameters + +| Name | Type | Description | +| ----- | --------------------------------------------------------------- | --------------------------------------- | +| scope | `valueof Azure.ClientGenerator.Core.DecoratorOptions \| string` | Specifies the target language emitters. | + #### `@lroStatusMetadata` Marks a status-monitor model property as transient polling data that the SDK should expose to diff --git a/packages/typespec-client-generator-core/generated-defs/Azure.ClientGenerator.Core.ts b/packages/typespec-client-generator-core/generated-defs/Azure.ClientGenerator.Core.ts index e55409f5e5..5bc9ab5768 100644 --- a/packages/typespec-client-generator-core/generated-defs/Azure.ClientGenerator.Core.ts +++ b/packages/typespec-client-generator-core/generated-defs/Azure.ClientGenerator.Core.ts @@ -62,6 +62,22 @@ export type LroStatusMetadataDecorator = ( scope?: DecoratorOptions | string, ) => DecoratorValidatorCallbacks | void; +/** + * Identifies the status-monitor path parameter containing a server-issued long-running operation + * identifier for a legacy service that exposes the identifier only through the returned polling + * URI. This compatibility metadata does not drive polling and is distinct from a caller-supplied + * `Operation-Id` API parameter. Conforming services with modeled operation identity do not need + * this decorator. Emitters choose their idiomatic accessor and storage. + * + * @param target The operation identifier path parameter on the linked status-monitor operation. + * @param scope Specifies the target language emitters. + */ +export type LroOperationIdDecorator = ( + context: DecoratorContext, + target: ModelProperty, + scope?: DecoratorOptions | string, +) => DecoratorValidatorCallbacks | void; + /** * Overrides the generated name for client SDK elements including clients, methods, parameters, * unions, models, enums, and model properties. @@ -1181,6 +1197,7 @@ export type ClientOptionDecorator = ( export type AzureClientGeneratorCoreDecorators = { lroStatusMetadata: LroStatusMetadataDecorator; + lroOperationId: LroOperationIdDecorator; clientName: ClientNameDecorator; convenientAPI: ConvenientAPIDecorator; protocolAPI: ProtocolAPIDecorator; diff --git a/packages/typespec-client-generator-core/lib/decorators.tsp b/packages/typespec-client-generator-core/lib/decorators.tsp index 7d2ab8d352..7436e87967 100644 --- a/packages/typespec-client-generator-core/lib/decorators.tsp +++ b/packages/typespec-client-generator-core/lib/decorators.tsp @@ -46,6 +46,18 @@ model DecoratorOptions { */ extern dec lroStatusMetadata(target: ModelProperty, scope?: valueof DecoratorOptions | string); +/** + * Identifies the status-monitor path parameter containing a server-issued long-running operation + * identifier for a legacy service that exposes the identifier only through the returned polling + * URI. This compatibility metadata does not drive polling and is distinct from a caller-supplied + * `Operation-Id` API parameter. Conforming services with modeled operation identity do not need + * this decorator. Emitters choose their idiomatic accessor and storage. + * + * @param target The operation identifier path parameter on the linked status-monitor operation. + * @param scope Specifies the target language emitters. + */ +extern dec lroOperationId(target: ModelProperty, scope?: valueof DecoratorOptions | string); + /** * Overrides the generated name for client SDK elements including clients, methods, parameters, * unions, models, enums, and model properties. diff --git a/packages/typespec-client-generator-core/src/decorators.ts b/packages/typespec-client-generator-core/src/decorators.ts index 57ca62aa13..afe4c36a4d 100644 --- a/packages/typespec-client-generator-core/src/decorators.ts +++ b/packages/typespec-client-generator-core/src/decorators.ts @@ -50,6 +50,7 @@ import type { ClientOptionDecorator, ConvenientAPIDecorator, DeserializeEmptyStringAsNullDecorator, + LroOperationIdDecorator, LroStatusMetadataDecorator, OperationGroupDecorator, ParamAliasDecorator, @@ -148,6 +149,42 @@ export function getLroStatusMetadataDecorator( ); } +const lroOperationIdKey = createStateSymbol("lroOperationId"); +const [getLroOperationIdEntries, setLroOperationIdEntries, getLroOperationIdMap] = useStateMap< + ModelProperty, + ScopedLroDecorator[] +>(lroOperationIdKey); + +export const $lroOperationId: LroOperationIdDecorator = ( + context: DecoratorContext, + target: ModelProperty, + scope?: LanguageScopes | DecoratorOptions, +) => { + const entries = getLroOperationIdEntries(context.program, target) ?? []; + entries.unshift({ scope: normalizeScope(scope), target: context.decoratorTarget }); + setLroOperationIdEntries(context.program, target, entries); +}; + +export function validateLroDecoratorTargets(program: Program): void { + for (const [target, entries] of getLroOperationIdMap(program)) { + if (!isPathParam(program, target)) { + reportDiagnostic(program, { + code: "lro-operation-id-not-path", + target: entries[0].target, + }); + } + } +} + +export function getLroOperationIdDecorators( + context: TCGCContext, + target: ModelProperty, +): ScopedLroDecorator[] { + return (getLroOperationIdEntries(context.program, target) ?? []).filter( + (entry) => !entry.scope || isScopeApplicable(entry.scope, context.emitterName), + ); +} + /** * The set of emitters a scope string effectively selects. * - `all-except`: applies to every emitter except the excluded ones. diff --git a/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-duplicate.md b/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-duplicate.md new file mode 100644 index 0000000000..82377f8e75 --- /dev/null +++ b/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-duplicate.md @@ -0,0 +1,7 @@ +This diagnostic is issued when more than one path parameter on the same linked status-monitor +operation has an active `@lroOperationId` declaration for the current emitter. + +## How to fix + +Keep one active `@lroOperationId` path parameter for each emitter scope. Adjust overlapping positive +or negated scopes when language-specific declarations are required. diff --git a/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-missing-link.md b/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-missing-link.md new file mode 100644 index 0000000000..5f2d898aaa --- /dev/null +++ b/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-missing-link.md @@ -0,0 +1,8 @@ +This diagnostic is issued when an `@lroOperationId` path parameter belongs to the linked +status-monitor operation, but the initiating LRO does not receive the status-monitor URI in a +response header or URL-valued body property. + +## How to fix + +Model the linked polling operation with an `Operation-Location`-style response link, or remove +`@lroOperationId`. diff --git a/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-not-path.md b/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-not-path.md new file mode 100644 index 0000000000..7b9e7bd031 --- /dev/null +++ b/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-not-path.md @@ -0,0 +1,9 @@ +# `@lroOperationId` requires a path parameter + +`@lroOperationId` identifies the path parameter on an explicitly linked LRO status-monitor +operation that contains the server-issued operation identifier. + +## How to fix + +Move the decorator to the corresponding `@path` parameter of the operation referenced by +`@pollingOperation`. diff --git a/packages/typespec-client-generator-core/src/interfaces.ts b/packages/typespec-client-generator-core/src/interfaces.ts index 2a8a8a7515..769e66a10a 100644 --- a/packages/typespec-client-generator-core/src/interfaces.ts +++ b/packages/typespec-client-generator-core/src/interfaces.ts @@ -1222,6 +1222,12 @@ export interface SdkLroServiceMetadata { * in continuation tokens; the next poll repopulates them after rehydration. */ statusMetadata?: SdkLroStatusMetadata[]; + /** + * Server-issued SDK long-running operation identifier selected from the linked status-monitor + * operation, when requested with `@lroOperationId`. This does not drive polling and is distinct + * from a caller-supplied `Operation-Id` API parameter. + */ + operationId?: SdkLroOperationIdMetadata; } export interface SdkLroStatusMetadata { @@ -1232,6 +1238,13 @@ export interface SdkLroStatusMetadata { property: SdkModelPropertyType; } +export interface SdkLroOperationIdMetadata { + /** Exact URI link from the status-monitor step. */ + source: SdkOperationLink; + /** Exact path parameter selected from the linked status-monitor operation. */ + pathParameter: SdkPathParameter; +} + export interface SdkNextOperationReference extends SdkLogicalOperationStep { kind: "nextOperationReference"; responseModel: SdkModelType; @@ -1264,6 +1277,8 @@ export interface SdkNextOperationLink extends SdkLogicalOperationStep { kind: "nextOperationLink"; responseModel: SdkModelType; target: SdkOperationLink; + /** Explicitly linked polling operation, when one was declared. */ + operation?: SdkServiceOperation; } /** diff --git a/packages/typespec-client-generator-core/src/lib.ts b/packages/typespec-client-generator-core/src/lib.ts index 7c42744bb9..c768da8aa0 100644 --- a/packages/typespec-client-generator-core/src/lib.ts +++ b/packages/typespec-client-generator-core/src/lib.ts @@ -234,6 +234,29 @@ export const $lib = createTypeSpecLibrary({ default: paramMessage`@lroStatusMetadata cannot select the status monitor's ${"role"} property '${"propertyName"}'.`, }, }, + "lro-operation-id-not-path": { + ...doc("lro-operation-id-not-path"), + severity: "error", + messages: { + default: "@lroOperationId can only be applied to a status-monitor path parameter.", + }, + }, + "lro-operation-id-duplicate": { + ...doc("lro-operation-id-duplicate"), + severity: "error", + messages: { + default: + "@lroOperationId selects more than one path parameter on the linked status-monitor operation in the active emitter scope.", + }, + }, + "lro-operation-id-missing-link": { + ...doc("lro-operation-id-missing-link"), + severity: "error", + messages: { + default: + "@lroOperationId requires the linked status-monitor operation to be reached through a response header or URL-valued body property.", + }, + }, "conflicting-multipart-model-usage": { ...doc("conflicting-multipart-model-usage"), severity: "error", diff --git a/packages/typespec-client-generator-core/src/lro-operation-id.ts b/packages/typespec-client-generator-core/src/lro-operation-id.ts new file mode 100644 index 0000000000..261163dd2e --- /dev/null +++ b/packages/typespec-client-generator-core/src/lro-operation-id.ts @@ -0,0 +1,87 @@ +import { type LroMetadata, type OperationLink } from "@azure-tools/typespec-azure-core"; +import { + createDiagnosticCollector, + type Diagnostic, + ignoreDiagnostics, + type ModelProperty, +} from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; +import { getHttpOperation } from "@typespec/http"; +import { getLroOperationIdDecorators } from "./decorators.js"; +import type { TCGCContext } from "./interfaces.js"; +import { createDiagnostic } from "./lib.js"; + +type StatusMonitorLinkStep = + | { kind: "nextOperationLink"; target: TLink } + | { kind: "nextOperationReference"; target: { link?: TLink } }; + +/** @internal */ +export function getStatusMonitorLink( + statusMonitorStep: StatusMonitorLinkStep | undefined, +): TLink | undefined { + return statusMonitorStep?.kind === "nextOperationLink" + ? statusMonitorStep.target + : statusMonitorStep?.target.link; +} + +/** @internal */ +export function getLroOperationIdLink( + context: TCGCContext, + statusMonitorStep: LroMetadata["statusMonitorStep"], +): OperationLink | undefined { + const link = getStatusMonitorLink(statusMonitorStep); + if (link?.location !== "Self") return link; + return $(context.program).scalar.extendsUrl(link.property.type) ? link : undefined; +} + +export function resolveLroOperationId( + context: TCGCContext, + raw: LroMetadata | undefined, +): [ModelProperty | undefined, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + if (!raw) { + return diagnostics.wrap(undefined); + } + + const selections = getOperationIdSelections(context, raw); + if (selections.length > 1) { + diagnostics.add( + createDiagnostic({ + code: "lro-operation-id-duplicate", + target: selections[1].target, + }), + ); + } + + let property: ModelProperty | undefined = selections[0]?.property; + if (property && !getLroOperationIdLink(context, raw.statusMonitorStep)) { + diagnostics.add( + createDiagnostic({ + code: "lro-operation-id-missing-link", + target: selections[0].target, + }), + ); + property = undefined; + } + + return diagnostics.wrap(property); +} + +function getOperationIdSelections(context: TCGCContext, raw: LroMetadata) { + const statusMonitorOperation = + raw.statusMonitorStep?.kind === "nextOperationReference" + ? raw.statusMonitorStep.target.operation + : raw.statusMonitorStep?.operation; + if (!statusMonitorOperation) return []; + + const httpOperation = ignoreDiagnostics( + getHttpOperation(context.program, statusMonitorOperation), + ); + return httpOperation.parameters.parameters.flatMap((parameter) => { + if (parameter.type !== "path") return []; + return getLroOperationIdDecorators(context, parameter.param).map((decorator) => ({ + property: parameter.param, + target: decorator.target, + })); + }); +} diff --git a/packages/typespec-client-generator-core/src/methods.ts b/packages/typespec-client-generator-core/src/methods.ts index edd2c24f64..ac9a7d6489 100644 --- a/packages/typespec-client-generator-core/src/methods.ts +++ b/packages/typespec-client-generator-core/src/methods.ts @@ -52,6 +52,7 @@ import { type SdkOperationLink, type SdkOperationReference, type SdkPagingServiceMethod, + type SdkPathParameter, type SdkPollingOperationStep, type SdkPropertyMap, type SdkServiceMethod, @@ -79,6 +80,7 @@ import { } from "./internal-utils.js"; import { createDiagnostic } from "./lib.js"; import { getRawLroMetadata } from "./lro-metadata.js"; +import { getStatusMonitorLink, resolveLroOperationId } from "./lro-operation-id.js"; import { resolveLroStatusMetadata } from "./lro-status-metadata.js"; import { getCrossLanguageDefinitionId, @@ -437,6 +439,14 @@ function getServiceMethodLroMetadata ({ property: sdkProperty, })); + const operationIdSource = getStatusMonitorLink(statusMonitorStep); + const operationIdProperty = diagnostics.pipe(resolveLroOperationId(context, nativeMetadata)); + const operationIdPathParameter = operationIdProperty + ? getStatusMonitorOperation(statusMonitorStep)?.parameters.find( + (parameter): parameter is SdkPathParameter => + parameter.kind === "path" && parameter.__raw === operationIdProperty, + ) + : undefined; const pollingResponseBody = rawMetadata.pollingInfo.responseModel ? (diagnostics.pipe( getClientTypeWithDiagnostics(context, rawMetadata.pollingInfo.responseModel), @@ -463,8 +473,22 @@ function getServiceMethodLroMetadata 0 ? { statusMetadata } : {}), + ...(operationIdPathParameter && operationIdSource + ? { + operationId: { + source: operationIdSource, + pathParameter: operationIdPathParameter, + } as const, + } + : {}), }); + function getStatusMonitorOperation( + step: SdkNextOperationLink | SdkNextOperationReference | undefined, + ): SdkServiceOperation | undefined { + return step?.kind === "nextOperationReference" ? step.target.operation : step?.operation; + } + function getSdkLroServiceFinalStep( context: TCGCContext, step: FinalOperationStep | undefined, @@ -519,6 +543,13 @@ function getServiceMethodLroMetadata ` +@service +namespace TestService; + +@lroStatus +union LroStatus { + @lroSucceeded succeeded: "succeeded", + @lroFailed failed: "failed", + @lroCanceled canceled: "canceled", +} + +${code} +`, +); + +const operation = ` +model JobState { + status: LroStatus; +} + +@route("/jobs/{jobId}") +@get +op getJobStatus( + @lroOperationId + @path jobId: string +): JobState; + +@route("/jobs") +@pollingOperation(getJobStatus) +op start is Azure.Core.Foundations.LongRunningOperation< + {}, + AcceptedResponse, + {}, + {} +>; +`; + +describe("LRO operation ID", () => { + it("accepts a next-operation reference with a status-monitor link", () => { + const link = { + kind: "link", + location: "ResponseHeader", + property: {} as any, + } as const; + const step = { + kind: "nextOperationReference", + responseModel: {} as any, + target: { + kind: "reference", + operation: {} as any, + link, + }, + } as const; + strictEqual(getStatusMonitorLink(step), link); + }); + + it("requires the operation ID target to be a path parameter", async () => { + const diagnostics = await Tester.diagnose(` +model Invalid { + @lroOperationId + value: string; +} +`); + expectDiagnostics(diagnostics, { + code: "@azure-tools/typespec-client-generator-core/lro-operation-id-not-path", + }); + }); + + it("exposes the exact linked operation ID path parameter", async () => { + const { program } = await Tester.compile(operation); + const context = await createSdkContextForTester(program); + const method = context.sdkPackage.clients[0].methods.find((x) => x.name === "start"); + ok(method); + strictEqual(method.kind, "lro"); + strictEqual(method.lroMetadata.statusMonitorStep?.kind, "nextOperationLink"); + if (method.lroMetadata.statusMonitorStep?.kind === "nextOperationLink") { + strictEqual( + method.lroMetadata.operationId?.source, + method.lroMetadata.statusMonitorStep.target, + ); + strictEqual( + method.lroMetadata.operationId?.pathParameter, + method.lroMetadata.statusMonitorStep.operation?.parameters.find( + (x) => x.kind === "path" && x.name === "jobId", + ), + ); + } + strictEqual(method.lroMetadata.operationId?.pathParameter.name, "jobId"); + strictEqual(method.lroMetadata.operationId?.pathParameter.__raw?.name, "jobId"); + }); + + it("honors emitter scope", async () => { + const { program } = await Tester.compile( + operation.replace("@lroOperationId\n", '@lroOperationId("python")\n'), + ); + const pythonContext = await createSdkContextForTester(program, { + emitterName: "@azure-tools/typespec-python", + }); + const csharpContext = await createSdkContextForTester(program, { + emitterName: "@azure-tools/typespec-csharp", + }); + const pythonMethod = pythonContext.sdkPackage.clients[0].methods.find( + (x) => x.name === "start", + ); + const csharpMethod = csharpContext.sdkPackage.clients[0].methods.find( + (x) => x.name === "start", + ); + ok(pythonMethod); + ok(csharpMethod); + strictEqual(pythonMethod.kind, "lro"); + strictEqual(csharpMethod.kind, "lro"); + strictEqual(pythonMethod.lroMetadata.operationId?.pathParameter.name, "jobId"); + strictEqual(csharpMethod.lroMetadata.operationId, undefined); + }); + + it("reports duplicate active operation ID parameters", async () => { + const { program } = await Tester.compile( + operation.replace( + "@path jobId: string", + `@path jobId: string, + @lroOperationId + @path otherId: string`, + ), + ); + const context = await createSdkContextForTester(program); + expect(context.diagnostics.map((x) => x.code)).toContain( + "@azure-tools/typespec-client-generator-core/lro-operation-id-duplicate", + ); + }); + + it("rejects a next-operation reference without a status-monitor link", async () => { + const { program } = await Tester.compile(` +model PollingStatus { + @lroStatus status: LroStatus; +} +@route("/widgets/{id}/operations/{operationId}") +@get op getStatus( + @path id: string, + @lroOperationId @path operationId: string +): PollingStatus; +@pollingOperation(getStatus, { + id: ResponseProperty<"id">; + operationId: ResponseProperty<"operationId">; +}) +@route("/widgets/{id}") +@put +op createWidget(@path id: string): { + @statusCode _: 202; + @header id: string; + @header("operation-id") operationId: string; +}; +`); + const context = await createSdkContextForTester(program); + const method = context.sdkPackage.clients[0].methods.find((x) => x.name === "createWidget"); + ok(method); + strictEqual(method.kind, "lro"); + strictEqual(method.lroMetadata.operationId, undefined); + expect(context.diagnostics.map((x) => x.code)).toContain( + "@azure-tools/typespec-client-generator-core/lro-operation-id-missing-link", + ); + }); + + it("accepts a next-operation reference with a compiled status-monitor link", async () => { + const { program } = await Tester.compile(` +model PollingStatus { + @lroStatus status: LroStatus; +} +@route("/widgets/{id}/operations/{operationId}") +@get op getStatus( + @path id: string, + @lroOperationId @path operationId: string +): PollingStatus; +@pollingOperation(getStatus, { + id: RequestParameter<"id">; + operationId: ResponseProperty<"operationId">; +}) +@route("/widgets/{id}") +@put +op createWidget(@path id: string): { + @statusCode _: 202; + @header("operation-id") operationId: string; + @pollingLocation + @header("Operation-Location") + operationLocation: ResourceLocation; +}; +`); + const context = await createSdkContextForTester(program); + const method = context.sdkPackage.clients[0].methods.find((x) => x.name === "createWidget"); + ok(method); + strictEqual(method.kind, "lro"); + strictEqual(method.lroMetadata.statusMonitorStep?.kind, "nextOperationReference"); + if (method.lroMetadata.statusMonitorStep?.kind === "nextOperationReference") { + ok(method.lroMetadata.statusMonitorStep.target.link); + strictEqual( + method.lroMetadata.operationId?.source, + method.lroMetadata.statusMonitorStep.target.link, + ); + } + strictEqual(method.lroMetadata.operationId?.pathParameter.name, "operationId"); + }); + + it("retains a spread operation ID path parameter", async () => { + const { program } = await Tester.compile(` +model PollingStatus { + @lroStatus status: LroStatus; +} +model StatusParameters { + @lroOperationId + @path operationId: string; +} +@route("/widgets/operations/{operationId}") +@get op getStatus(...StatusParameters): PollingStatus; +@pollingOperation(getStatus) +@route("/widgets") +@post +op createWidget(): { + @statusCode _: 202; + @pollingLocation + @header("Operation-Location") + operationLocation: ResourceLocation; +}; +`); + const context = await createSdkContextForTester(program); + const method = context.sdkPackage.clients[0].methods.find((x) => x.name === "createWidget"); + ok(method); + strictEqual(method.kind, "lro"); + strictEqual(method.lroMetadata.operationId?.pathParameter.name, "operationId"); + }); + + it("accepts a URL-valued polling location in the response body", async () => { + const { program } = await Tester.compile(` +model Monitor { + @lroStatus status: LroStatus; +} + +@route("/widgets/operations/{operationId}") +@get +op getStatus(@lroOperationId @path operationId: string): Monitor; + +@pollingOperation(getStatus) +@route("/widgets") +@post +op createWidget(): { + @statusCode _: 202; + @pollingLocation + @body + location: ResourceLocation; +}; +`); + const context = await createSdkContextForTester(program); + const method = context.sdkPackage.clients[0].methods.find((x) => x.name === "createWidget"); + ok(method); + strictEqual(method.kind, "lro"); + strictEqual(method.lroMetadata.statusMonitorStep?.kind, "nextOperationLink"); + if (method.lroMetadata.statusMonitorStep?.kind === "nextOperationLink") { + strictEqual(method.lroMetadata.statusMonitorStep.target.location, "ResponseBody"); + strictEqual( + method.lroMetadata.operationId?.source, + method.lroMetadata.statusMonitorStep.target, + ); + } + }); + + it("rejects a Self link whose body value is not a URL", async () => { + const { program, Monitor } = await Tester.compile(` +@test model Monitor { + @lroStatus status: LroStatus; +} +`); + const context = await createSdkContextForTester(program); + const property = Monitor.properties.get("status"); + ok(property); + strictEqual( + getLroOperationIdLink(context, { + kind: "nextOperationLink", + responseModel: Monitor, + target: { + kind: "link", + location: "Self", + property, + }, + }), + undefined, + ); + }); + + it("does not add operation ID metadata to an undecorated LRO", async () => { + const { program } = await Tester.compile(operation.replace(" @lroOperationId\n", "")); + const context = await createSdkContextForTester(program); + const method = context.sdkPackage.clients[0].methods.find((x) => x.name === "start"); + ok(method); + strictEqual(method.kind, "lro"); + strictEqual(method.lroMetadata.operationId, undefined); + }); +}); diff --git a/website/src/content/docs/docs/libraries/typespec-client-generator-core/guideline.md b/website/src/content/docs/docs/libraries/typespec-client-generator-core/guideline.md index 4d88a3f972..bf13b33339 100644 --- a/website/src/content/docs/docs/libraries/typespec-client-generator-core/guideline.md +++ b/website/src/content/docs/docs/libraries/typespec-client-generator-core/guideline.md @@ -410,6 +410,40 @@ Separate `@lroStatusMetadata` declarations on one property may use disjoint emit Status metadata requires native Azure.Core LRO discovery. An operation detected only through the legacy `@markAsLro` fallback does not acquire status metadata selections. A decorator on a model unused by native LROs has no generation effect. +#### Legacy URI-derived LRO operation identity + +TCGC-only `@lroOperationId` lets an SDK preserve an operation-identity API for a legacy service +that returns a server-issued identifier only as part of its status-monitor URI: + +```typespec +model OperationStatus { + status: OperationState; +} + +@route("/operations/{operationId}") +@get +op getOperationStatus( + @lroOperationId + @path + operationId: string, +): OperationStatus; + +@pollingOperation(getOperationStatus) +op begin is MyLongRunningOperation; +``` + +`SdkLroServiceMetadata.operationId` contains the exact `SdkOperationLink` used by the status monitor +and the exact `SdkPathParameter` selected from the explicitly linked polling operation. It does not +drive polling and is distinct from a caller-supplied `Operation-Id` API parameter. Emitters bind the +selected path parameter from the returned polling URI and choose an idiomatic accessor and runtime +lifecycle. An LRO with only an opaque polling URI and no modeled polling operation cannot expose +operation-ID metadata. + +Conforming Azure services expose operation identity through their request and response API, +including `Operation-Id` and the status-monitor body's `id`, and do not need this decorator. +`@lroOperationId` is a compatibility opt-in for an established nonconforming wire contract; it is +not a standard polling mechanism. + ### HTTP Operation Parameters Handling The HTTP operation's parameters are inferred from TypeSpec HTTP lib type [`HttpOperationParameters`](https://typespec.io/docs/libraries/http/reference/js-api/interfaces/httpoperationparameters/). Different HTTP parameters are handled by specific logic based on the info from TypeSpec HTTP lib type [`HttpOperationParameter`](https://typespec.io/docs/libraries/http/reference/js-api/type-aliases/httpoperationparameter/). diff --git a/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/decorators.md b/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/decorators.md index 39155f954e..ebcda42fa3 100644 --- a/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/decorators.md +++ b/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/decorators.md @@ -853,6 +853,29 @@ model MyModel { } ``` +### `@lroOperationId` {#@Azure.ClientGenerator.Core.lroOperationId} + +Identifies the status-monitor path parameter containing a server-issued long-running operation +identifier for a legacy service that exposes the identifier only through the returned polling +URI. This compatibility metadata does not drive polling and is distinct from a caller-supplied +`Operation-Id` API parameter. Conforming services with modeled operation identity do not need +this decorator. Emitters choose their idiomatic accessor and storage. + +```typespec +@Azure.ClientGenerator.Core.lroOperationId(scope?: valueof Azure.ClientGenerator.Core.DecoratorOptions | string) +``` + +#### Target + +The operation identifier path parameter on the linked status-monitor operation. +`ModelProperty` + +#### Parameters + +| Name | Type | Description | +| ----- | --------------------------------------------------------------- | --------------------------------------- | +| scope | `valueof Azure.ClientGenerator.Core.DecoratorOptions \| string` | Specifies the target language emitters. | + ### `@lroStatusMetadata` {#@Azure.ClientGenerator.Core.lroStatusMetadata} Marks a status-monitor model property as transient polling data that the SDK should expose to diff --git a/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/index.mdx b/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/index.mdx index 559be17e35..0a13b4cd99 100644 --- a/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/index.mdx +++ b/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/index.mdx @@ -49,6 +49,7 @@ npm install --save-peer @azure-tools/typespec-client-generator-core - [`@clientOption`](./decorators.md#@Azure.ClientGenerator.Core.clientOption) - [`@convenientAPI`](./decorators.md#@Azure.ClientGenerator.Core.convenientAPI) - [`@deserializeEmptyStringAsNull`](./decorators.md#@Azure.ClientGenerator.Core.deserializeEmptyStringAsNull) +- [`@lroOperationId`](./decorators.md#@Azure.ClientGenerator.Core.lroOperationId) - [`@lroStatusMetadata`](./decorators.md#@Azure.ClientGenerator.Core.lroStatusMetadata) - [`@operationGroup`](./decorators.md#@Azure.ClientGenerator.Core.operationGroup) - [`@override`](./decorators.md#@Azure.ClientGenerator.Core.override) From bcd1973329c0cbb7622e9e0e9fb688e85c913db7 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Wed, 28 Oct 2026 14:20:59 -0400 Subject: [PATCH 2/3] Fix operation ID Spector formatting Apply canonical TypeSpec formatting and allow the generated operationid namespace segment in the spell checker. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89522062-e8a5-40cb-8ec1-1f5a5426133b --- .../specs/azure/client-generator-core/lro/operation-id/main.tsp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/main.tsp b/packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/main.tsp index 1290e79602..0a5ebbbee4 100644 --- a/packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/main.tsp +++ b/packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/main.tsp @@ -3,6 +3,8 @@ import "@azure-tools/typespec-client-generator-core"; import "@typespec/spector"; import "@typespec/versioning"; +// cspell:ignore operationid + using Azure.ClientGenerator.Core; using Azure.Core; using Spector; From 529cd5bff941a2d45d7dddd6d4c7dc10f24cf405 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Wed, 28 Oct 2026 16:15:27 -0400 Subject: [PATCH 3/3] Support body LRO operation IDs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../lro-operation-id-metadata-2026-10-20.md | 2 +- packages/azure-http-specs/spec-summary.md | 27 ++ .../lro/operation-id/main.tsp | 71 +++++ .../lro/operation-id/mockapi.ts | 176 +++++++++++++ .../typespec-client-generator-core/README.md | 13 +- .../Azure.ClientGenerator.Core.ts | 15 +- .../lib/decorators.tsp | 15 +- .../src/decorators.ts | 47 +++- .../diagnostics/lro-operation-id-duplicate.md | 10 +- .../lro-operation-id-invalid-target.md | 9 + .../lro-operation-id-missing-link.md | 7 +- .../diagnostics/lro-operation-id-not-path.md | 9 - .../src/interfaces.ts | 22 +- .../typespec-client-generator-core/src/lib.ts | 9 +- .../src/lro-operation-id.ts | 73 +++++- .../src/methods.ts | 42 +-- .../src/validate.ts | 2 +- .../test/methods/lro-operation-id.test.ts | 247 +++++++++++++++++- .../guideline.md | 27 +- .../reference/decorators.md | 13 +- 20 files changed, 728 insertions(+), 108 deletions(-) create mode 100644 packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-invalid-target.md delete mode 100644 packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-not-path.md diff --git a/.chronus/changes/lro-operation-id-metadata-2026-10-20.md b/.chronus/changes/lro-operation-id-metadata-2026-10-20.md index 49869ed0ac..01f9324ecd 100644 --- a/.chronus/changes/lro-operation-id-metadata-2026-10-20.md +++ b/.chronus/changes/lro-operation-id-metadata-2026-10-20.md @@ -6,4 +6,4 @@ packages: - "@azure-tools/azure-http-specs" --- -Add scoped `@lroOperationId` metadata for legacy services that expose server-issued operation identity only through a linked status-monitor URI, plus a Document Intelligence-shaped Spector scenario. +Add scoped, source-tagged `@lroOperationId` metadata for legacy services that expose server-issued operation identity through a linked status-monitor URI, its response body, or both, plus representative Spector scenarios. diff --git a/packages/azure-http-specs/spec-summary.md b/packages/azure-http-specs/spec-summary.md index be4952c8c3..f8d8f56371 100644 --- a/packages/azure-http-specs/spec-summary.md +++ b/packages/azure-http-specs/spec-summary.md @@ -1335,6 +1335,33 @@ Expected calls: - GET that exact status-monitor URI returns a running status - GET that exact status-monitor URI returns a succeeded status with the analysis result +### Azure_ClientGenerator_Core_Lro_OperationId_startTranslation + +- Endpoint: `post /azure/client-generator-core/lro/operation-id/translations:start` + +Declares a status-body operation ID flow where the polling URI does not contain the identifier. + +Expected calls: + +- POST `/translations:start` returns 202 with + `Operation-Location: {endpoint}/translations/status?api-version=2022-12-01-preview` +- GET that exact status-monitor URI returns a running status with `id: translation-123` +- GET that exact status-monitor URI returns a succeeded status with the same ID and result + +### Azure_ClientGenerator_Core_Lro_OperationId_startTranslationWithPath + +- Endpoint: `post /azure/client-generator-core/lro/operation-id/translations:start-with-path` + +Declares a Translation-shaped fallback flow with operation identity in both the +Operation-Location path and the status-monitor body. + +Expected calls: + +- POST `/translations:start-with-path` returns 202 with + `Operation-Location: {endpoint}/translations/operations/translation-456?api-version=2022-12-01-preview` +- GET that exact status-monitor URI returns a running status with `id: translation-456` +- GET that exact status-monitor URI returns a succeeded status with the same ID and result + ### Azure_ClientGenerator_Core_NextLinkVerb_listItems - Endpoint: `post /azure/client-generator-core/next-link-verb/items` diff --git a/packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/main.tsp b/packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/main.tsp index 0a5ebbbee4..57746299ad 100644 --- a/packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/main.tsp +++ b/packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/main.tsp @@ -77,3 +77,74 @@ op analyze( ...global.Azure.Core.Foundations.ApiVersionParameter, ): global.Azure.Core.Foundations.AcceptedResponse & global.Azure.Core.Foundations.LongRunningStatusLocation; + +model TranslationStatus { + @lroOperationId + id: string; + + status: global.Azure.Core.Foundations.OperationState; + result?: TranslationResult; +} + +model TranslationResult { + translatedCharacters: int32; +} + +@route("/translations/status") +@get +op getTranslationStatus(...global.Azure.Core.Foundations.ApiVersionParameter): TranslationStatus; + +@scenario +@scenarioDoc(""" + Declares a status-body operation ID flow where the polling URI does not contain the identifier. + + Expected calls: + - POST `/translations:start` returns 202 with + `Operation-Location: {endpoint}/translations/status?api-version=2022-12-01-preview` + - GET that exact status-monitor URI returns a running status with `id: translation-123` + - GET that exact status-monitor URI returns a succeeded status with the same ID and result + """) +@route("/translations:start") +@post +@pollingOperation(getTranslationStatus) +op startTranslation( + ...global.Azure.Core.Foundations.ApiVersionParameter, +): global.Azure.Core.Foundations.AcceptedResponse & + global.Azure.Core.Foundations.LongRunningStatusLocation; + +model TranslationWithPathStatus { + @lroOperationId + id: string; + + status: global.Azure.Core.Foundations.OperationState; + result?: TranslationResult; +} + +@route("/translations/operations/{operationId}") +@get +op getTranslationWithPathStatus( + @lroOperationId + @path + operationId: string, + + ...global.Azure.Core.Foundations.ApiVersionParameter, +): TranslationWithPathStatus; + +@scenario +@scenarioDoc(""" + Declares a Translation-shaped fallback flow with operation identity in both the + Operation-Location path and the status-monitor body. + + Expected calls: + - POST `/translations:start-with-path` returns 202 with + `Operation-Location: {endpoint}/translations/operations/translation-456?api-version=2022-12-01-preview` + - GET that exact status-monitor URI returns a running status with `id: translation-456` + - GET that exact status-monitor URI returns a succeeded status with the same ID and result + """) +@route("/translations:start-with-path") +@post +@pollingOperation(getTranslationWithPathStatus) +op startTranslationWithPath( + ...global.Azure.Core.Foundations.ApiVersionParameter, +): global.Azure.Core.Foundations.AcceptedResponse & + global.Azure.Core.Foundations.LongRunningStatusLocation; diff --git a/packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/mockapi.ts b/packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/mockapi.ts index 2ed6ea589e..0e6936e088 100644 --- a/packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/mockapi.ts +++ b/packages/azure-http-specs/specs/azure/client-generator-core/lro/operation-id/mockapi.ts @@ -11,6 +11,8 @@ export const Scenarios: Record = {}; const apiVersion = "2022-12-01-preview"; let analyzePollCount = 0; +let translationPollCount = 0; +let translationWithPathPollCount = 0; Scenarios.Azure_ClientGenerator_Core_Lro_OperationId_analyze = passOnSuccess([ { @@ -94,3 +96,177 @@ function analyzeLroHandler(req: MockRequest) { analyzePollCount += 1; return { status: 200, body: json(response) }; } + +Scenarios.Azure_ClientGenerator_Core_Lro_OperationId_startTranslation = passOnSuccess([ + { + uri: "/azure/client-generator-core/lro/operation-id/translations:start", + method: "post", + request: { + query: { + "api-version": apiVersion, + }, + }, + response: { + status: 202, + headers: { + "operation-location": dyn`${dynItem("baseUrl")}/azure/client-generator-core/lro/operation-id/translations/status?api-version=2022-12-01-preview`, + }, + }, + handler: (req: MockRequest) => { + req.expect.containsQueryParam("api-version", apiVersion); + translationPollCount = 0; + return { + status: 202, + headers: { + "operation-location": `${req.baseUrl}/azure/client-generator-core/lro/operation-id/translations/status?api-version=${apiVersion}`, + }, + }; + }, + kind: "MockApiDefinition", + }, + { + uri: "/azure/client-generator-core/lro/operation-id/translations/status", + method: "get", + request: { + query: { + "api-version": apiVersion, + }, + }, + response: { + status: 200, + body: json({ + id: "translation-123", + status: "Running", + }), + }, + handler: translationLroHandler, + kind: "MockApiDefinition", + }, + { + uri: "/azure/client-generator-core/lro/operation-id/translations/status", + method: "get", + request: { + query: { + "api-version": apiVersion, + }, + }, + response: { + status: 200, + body: json({ + id: "translation-123", + status: "Succeeded", + result: { + translatedCharacters: 42, + }, + }), + }, + handler: translationLroHandler, + kind: "MockApiDefinition", + }, +]); + +function translationLroHandler(req: MockRequest) { + req.expect.containsQueryParam("api-version", apiVersion); + const response = + translationPollCount > 0 + ? { + id: "translation-123", + status: "Succeeded", + result: { + translatedCharacters: 42, + }, + } + : { + id: "translation-123", + status: "Running", + }; + translationPollCount += 1; + return { status: 200, body: json(response) }; +} + +Scenarios.Azure_ClientGenerator_Core_Lro_OperationId_startTranslationWithPath = passOnSuccess([ + { + uri: "/azure/client-generator-core/lro/operation-id/translations:start-with-path", + method: "post", + request: { + query: { + "api-version": apiVersion, + }, + }, + response: { + status: 202, + headers: { + "operation-location": dyn`${dynItem("baseUrl")}/azure/client-generator-core/lro/operation-id/translations/operations/translation-456?api-version=2022-12-01-preview`, + }, + }, + handler: (req: MockRequest) => { + req.expect.containsQueryParam("api-version", apiVersion); + translationWithPathPollCount = 0; + return { + status: 202, + headers: { + "operation-location": `${req.baseUrl}/azure/client-generator-core/lro/operation-id/translations/operations/translation-456?api-version=${apiVersion}`, + }, + }; + }, + kind: "MockApiDefinition", + }, + { + uri: "/azure/client-generator-core/lro/operation-id/translations/operations/translation-456", + method: "get", + request: { + query: { + "api-version": apiVersion, + }, + }, + response: { + status: 200, + body: json({ + id: "translation-456", + status: "Running", + }), + }, + handler: translationWithPathLroHandler, + kind: "MockApiDefinition", + }, + { + uri: "/azure/client-generator-core/lro/operation-id/translations/operations/translation-456", + method: "get", + request: { + query: { + "api-version": apiVersion, + }, + }, + response: { + status: 200, + body: json({ + id: "translation-456", + status: "Succeeded", + result: { + translatedCharacters: 84, + }, + }), + }, + handler: translationWithPathLroHandler, + kind: "MockApiDefinition", + }, +]); + +function translationWithPathLroHandler(req: MockRequest) { + req.expect.containsQueryParam("api-version", apiVersion); + const response = + translationWithPathPollCount > 0 + ? { + id: "translation-456", + status: "Succeeded", + result: { + translatedCharacters: 84, + }, + } + : { + id: "translation-456", + status: "Running", + }; + translationWithPathPollCount += 1; + return { status: 200, body: json(response) }; +} diff --git a/packages/typespec-client-generator-core/README.md b/packages/typespec-client-generator-core/README.md index e6013f79d6..c003321996 100644 --- a/packages/typespec-client-generator-core/README.md +++ b/packages/typespec-client-generator-core/README.md @@ -1004,11 +1004,11 @@ model MyModel { #### `@lroOperationId` -Identifies the status-monitor path parameter containing a server-issued long-running operation -identifier for a legacy service that exposes the identifier only through the returned polling -URI. This compatibility metadata does not drive polling and is distinct from a caller-supplied -`Operation-Id` API parameter. Conforming services with modeled operation identity do not need -this decorator. Emitters choose their idiomatic accessor and storage. +Identifies a server-issued long-running operation identifier exposed by a legacy service through +either the status-monitor URI or its response body. This compatibility metadata does not drive +polling and is distinct from a caller-supplied `Operation-Id` API parameter. Conforming services +with modeled operation identity do not need this decorator. Emitters choose their idiomatic +accessor and storage. ```typespec @Azure.ClientGenerator.Core.lroOperationId(scope?: valueof Azure.ClientGenerator.Core.DecoratorOptions | string) @@ -1016,7 +1016,8 @@ this decorator. Emitters choose their idiomatic accessor and storage. ##### Target -The operation identifier path parameter on the linked status-monitor operation. +The operation identifier path parameter on the linked status-monitor operation, or +a string-like property on its response model. `ModelProperty` ##### Parameters diff --git a/packages/typespec-client-generator-core/generated-defs/Azure.ClientGenerator.Core.ts b/packages/typespec-client-generator-core/generated-defs/Azure.ClientGenerator.Core.ts index 5bc9ab5768..71eee94785 100644 --- a/packages/typespec-client-generator-core/generated-defs/Azure.ClientGenerator.Core.ts +++ b/packages/typespec-client-generator-core/generated-defs/Azure.ClientGenerator.Core.ts @@ -63,13 +63,14 @@ export type LroStatusMetadataDecorator = ( ) => DecoratorValidatorCallbacks | void; /** - * Identifies the status-monitor path parameter containing a server-issued long-running operation - * identifier for a legacy service that exposes the identifier only through the returned polling - * URI. This compatibility metadata does not drive polling and is distinct from a caller-supplied - * `Operation-Id` API parameter. Conforming services with modeled operation identity do not need - * this decorator. Emitters choose their idiomatic accessor and storage. - * - * @param target The operation identifier path parameter on the linked status-monitor operation. + * Identifies a server-issued long-running operation identifier exposed by a legacy service through + * either the status-monitor URI or its response body. This compatibility metadata does not drive + * polling and is distinct from a caller-supplied `Operation-Id` API parameter. Conforming services + * with modeled operation identity do not need this decorator. Emitters choose their idiomatic + * accessor and storage. + * + * @param target The operation identifier path parameter on the linked status-monitor operation, or + * a string-like property on its response model. * @param scope Specifies the target language emitters. */ export type LroOperationIdDecorator = ( diff --git a/packages/typespec-client-generator-core/lib/decorators.tsp b/packages/typespec-client-generator-core/lib/decorators.tsp index 7436e87967..d6be83ed3f 100644 --- a/packages/typespec-client-generator-core/lib/decorators.tsp +++ b/packages/typespec-client-generator-core/lib/decorators.tsp @@ -47,13 +47,14 @@ model DecoratorOptions { extern dec lroStatusMetadata(target: ModelProperty, scope?: valueof DecoratorOptions | string); /** - * Identifies the status-monitor path parameter containing a server-issued long-running operation - * identifier for a legacy service that exposes the identifier only through the returned polling - * URI. This compatibility metadata does not drive polling and is distinct from a caller-supplied - * `Operation-Id` API parameter. Conforming services with modeled operation identity do not need - * this decorator. Emitters choose their idiomatic accessor and storage. - * - * @param target The operation identifier path parameter on the linked status-monitor operation. + * Identifies a server-issued long-running operation identifier exposed by a legacy service through + * either the status-monitor URI or its response body. This compatibility metadata does not drive + * polling and is distinct from a caller-supplied `Operation-Id` API parameter. Conforming services + * with modeled operation identity do not need this decorator. Emitters choose their idiomatic + * accessor and storage. + * + * @param target The operation identifier path parameter on the linked status-monitor operation, or + * a string-like property on its response model. * @param scope Specifies the target language emitters. */ extern dec lroOperationId(target: ModelProperty, scope?: valueof DecoratorOptions | string); diff --git a/packages/typespec-client-generator-core/src/decorators.ts b/packages/typespec-client-generator-core/src/decorators.ts index afe4c36a4d..b2d569af35 100644 --- a/packages/typespec-client-generator-core/src/decorators.ts +++ b/packages/typespec-client-generator-core/src/decorators.ts @@ -1,4 +1,4 @@ -import { getLroProtocolMetadata } from "@azure-tools/typespec-azure-core"; +import { getLroProtocolMetadata, getOperationLinks } from "@azure-tools/typespec-azure-core"; import { compilerAssert, type DecoratorContext, @@ -165,11 +165,46 @@ export const $lroOperationId: LroOperationIdDecorator = ( setLroOperationIdEntries(context.program, target, entries); }; -export function validateLroDecoratorTargets(program: Program): void { - for (const [target, entries] of getLroOperationIdMap(program)) { - if (!isPathParam(program, target)) { - reportDiagnostic(program, { - code: "lro-operation-id-not-path", +export function validateLroDecoratorTargets(context: TCGCContext): void { + const validTargets: ModelProperty[] = []; + const operations = new Set(); + for (const namespace of listAllUserDefinedNamespaces(context)) { + for (const operation of namespace.operations.values()) operations.add(operation); + for (const iface of namespace.interfaces.values()) { + for (const operation of iface.operations.values()) operations.add(operation); + } + } + + for (const operation of operations) { + const pollingLink = getOperationLinks(context.program, operation)?.get("polling"); + if (!pollingLink) continue; + const httpOperation = ignoreDiagnostics( + getHttpOperation(context.program, pollingLink.linkedOperation), + ); + for (const parameter of httpOperation.parameters.parameters) { + if (parameter.type === "path") validTargets.push(parameter.param); + } + for ( + let model = pollingLink.result?.statusMonitor?.monitorType; + model; + model = model.baseModel + ) { + for (const property of model.properties.values()) { + validTargets.push(property); + } + } + } + + for (const [target, entries] of getLroOperationIdMap(context.program)) { + if (isPathParam(context.program, target)) continue; + if ( + !validTargets.some( + (validTarget) => findRootSourceProperty(validTarget) === findRootSourceProperty(target), + ) || + !$(context.program).scalar.extendsString(target.type) + ) { + reportDiagnostic(context.program, { + code: "lro-operation-id-invalid-target", target: entries[0].target, }); } diff --git a/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-duplicate.md b/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-duplicate.md index 82377f8e75..dc3d87befc 100644 --- a/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-duplicate.md +++ b/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-duplicate.md @@ -1,7 +1,9 @@ -This diagnostic is issued when more than one path parameter on the same linked status-monitor -operation has an active `@lroOperationId` declaration for the current emitter. +This diagnostic is issued when more than one path parameter or more than one response model +property on the same linked status-monitor operation has an active `@lroOperationId` declaration +for the current emitter. One path source and one property source may be selected together. ## How to fix -Keep one active `@lroOperationId` path parameter for each emitter scope. Adjust overlapping positive -or negated scopes when language-specific declarations are required. +Keep at most one active `@lroOperationId` path parameter and one active response model property for +each emitter scope. Adjust overlapping positive or negated scopes when language-specific +declarations are required. diff --git a/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-invalid-target.md b/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-invalid-target.md new file mode 100644 index 0000000000..8ef614d3b2 --- /dev/null +++ b/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-invalid-target.md @@ -0,0 +1,9 @@ +# `@lroOperationId` requires a status-monitor operation ID source + +`@lroOperationId` identifies either a path parameter of the linked LRO status-monitor operation or +a string-like property of its response model. + +## How to fix + +Move the decorator to the corresponding `@path` parameter of the operation referenced by +`@pollingOperation`, or to the string-like operation ID property on that operation's response model. diff --git a/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-missing-link.md b/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-missing-link.md index 5f2d898aaa..a1a4abd327 100644 --- a/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-missing-link.md +++ b/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-missing-link.md @@ -1,8 +1,9 @@ This diagnostic is issued when an `@lroOperationId` path parameter belongs to the linked status-monitor operation, but the initiating LRO does not receive the status-monitor URI in a -response header or URL-valued body property. +response header or URL-valued body property. A response-model property selection does not require a +URI link. ## How to fix -Model the linked polling operation with an `Operation-Location`-style response link, or remove -`@lroOperationId`. +Model the linked polling operation with an `Operation-Location`-style response link, move +`@lroOperationId` to the status-monitor response model's operation ID property, or remove it. diff --git a/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-not-path.md b/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-not-path.md deleted file mode 100644 index 7b9e7bd031..0000000000 --- a/packages/typespec-client-generator-core/src/diagnostics/lro-operation-id-not-path.md +++ /dev/null @@ -1,9 +0,0 @@ -# `@lroOperationId` requires a path parameter - -`@lroOperationId` identifies the path parameter on an explicitly linked LRO status-monitor -operation that contains the server-issued operation identifier. - -## How to fix - -Move the decorator to the corresponding `@path` parameter of the operation referenced by -`@pollingOperation`. diff --git a/packages/typespec-client-generator-core/src/interfaces.ts b/packages/typespec-client-generator-core/src/interfaces.ts index 769e66a10a..43ba08054a 100644 --- a/packages/typespec-client-generator-core/src/interfaces.ts +++ b/packages/typespec-client-generator-core/src/interfaces.ts @@ -1223,11 +1223,11 @@ export interface SdkLroServiceMetadata { */ statusMetadata?: SdkLroStatusMetadata[]; /** - * Server-issued SDK long-running operation identifier selected from the linked status-monitor - * operation, when requested with `@lroOperationId`. This does not drive polling and is distinct - * from a caller-supplied `Operation-Id` API parameter. + * Server-issued SDK long-running operation identifiers selected from the linked status-monitor + * operation, ordered by availability: polling URL path first, then status-monitor body property. + * This does not drive polling and is distinct from a caller-supplied `Operation-Id` API parameter. */ - operationId?: SdkLroOperationIdMetadata; + operationId?: SdkLroOperationIdMetadata[]; } export interface SdkLroStatusMetadata { @@ -1238,13 +1238,25 @@ export interface SdkLroStatusMetadata { property: SdkModelPropertyType; } -export interface SdkLroOperationIdMetadata { +export type SdkLroOperationIdMetadata = + SdkLroOperationIdPathMetadata | SdkLroOperationIdPropertyMetadata; + +export interface SdkLroOperationIdPathMetadata { + kind: "path"; /** Exact URI link from the status-monitor step. */ source: SdkOperationLink; /** Exact path parameter selected from the linked status-monitor operation. */ pathParameter: SdkPathParameter; } +export interface SdkLroOperationIdPropertyMetadata { + kind: "property"; + /** Exact property selected from the status-monitor response model. */ + property: SdkModelPropertyType; +} + +// A response-header variant can be added when a service needs a server-issued operation ID there. + export interface SdkNextOperationReference extends SdkLogicalOperationStep { kind: "nextOperationReference"; responseModel: SdkModelType; diff --git a/packages/typespec-client-generator-core/src/lib.ts b/packages/typespec-client-generator-core/src/lib.ts index c768da8aa0..644bf16345 100644 --- a/packages/typespec-client-generator-core/src/lib.ts +++ b/packages/typespec-client-generator-core/src/lib.ts @@ -234,11 +234,12 @@ export const $lib = createTypeSpecLibrary({ default: paramMessage`@lroStatusMetadata cannot select the status monitor's ${"role"} property '${"propertyName"}'.`, }, }, - "lro-operation-id-not-path": { - ...doc("lro-operation-id-not-path"), + "lro-operation-id-invalid-target": { + ...doc("lro-operation-id-invalid-target"), severity: "error", messages: { - default: "@lroOperationId can only be applied to a status-monitor path parameter.", + default: + "@lroOperationId can only be applied to a path parameter of the linked status-monitor operation or a string-like property of its response model.", }, }, "lro-operation-id-duplicate": { @@ -246,7 +247,7 @@ export const $lib = createTypeSpecLibrary({ severity: "error", messages: { default: - "@lroOperationId selects more than one path parameter on the linked status-monitor operation in the active emitter scope.", + "@lroOperationId selects more than one source of the same kind on the linked status-monitor operation in the active emitter scope.", }, }, "lro-operation-id-missing-link": { diff --git a/packages/typespec-client-generator-core/src/lro-operation-id.ts b/packages/typespec-client-generator-core/src/lro-operation-id.ts index 261163dd2e..a9587a1ad4 100644 --- a/packages/typespec-client-generator-core/src/lro-operation-id.ts +++ b/packages/typespec-client-generator-core/src/lro-operation-id.ts @@ -2,13 +2,16 @@ import { type LroMetadata, type OperationLink } from "@azure-tools/typespec-azur import { createDiagnosticCollector, type Diagnostic, + type DiagnosticTarget, ignoreDiagnostics, + type Model, type ModelProperty, } from "@typespec/compiler"; import { $ } from "@typespec/compiler/typekit"; -import { getHttpOperation } from "@typespec/http"; -import { getLroOperationIdDecorators } from "./decorators.js"; +import { getHttpOperation, isVisible, Visibility } from "@typespec/http"; +import { getLroOperationIdDecorators, isInScope } from "./decorators.js"; import type { TCGCContext } from "./interfaces.js"; +import { hasNoneVisibility } from "./internal-utils.js"; import { createDiagnostic } from "./lib.js"; type StatusMonitorLinkStep = @@ -37,37 +40,57 @@ export function getLroOperationIdLink( export function resolveLroOperationId( context: TCGCContext, raw: LroMetadata | undefined, -): [ModelProperty | undefined, readonly Diagnostic[]] { +): [LroOperationIdSelection[], readonly Diagnostic[]] { const diagnostics = createDiagnosticCollector(); if (!raw) { - return diagnostics.wrap(undefined); + return diagnostics.wrap([]); } - const selections = getOperationIdSelections(context, raw); - if (selections.length > 1) { + const pathSelections = getPathSelections(context, raw); + if (pathSelections.length > 1) { diagnostics.add( createDiagnostic({ code: "lro-operation-id-duplicate", - target: selections[1].target, + target: pathSelections[1].target, }), ); } - let property: ModelProperty | undefined = selections[0]?.property; - if (property && !getLroOperationIdLink(context, raw.statusMonitorStep)) { + const propertySelections = getPropertySelections(context, raw.pollingInfo.responseModel); + if (propertySelections.length > 1) { diagnostics.add( createDiagnostic({ - code: "lro-operation-id-missing-link", - target: selections[0].target, + code: "lro-operation-id-duplicate", + target: propertySelections[1].target, }), ); - property = undefined; } - return diagnostics.wrap(property); + const selections: LroOperationIdSelection[] = []; + const pathSelection = pathSelections[0]; + if (pathSelection) { + if (getLroOperationIdLink(context, raw.statusMonitorStep)) { + selections.push({ kind: "path", property: pathSelection.property }); + } else { + diagnostics.add( + createDiagnostic({ + code: "lro-operation-id-missing-link", + target: pathSelection.target, + }), + ); + } + } + if (propertySelections[0]) { + selections.push({ kind: "property", property: propertySelections[0].property }); + } + + return diagnostics.wrap(selections); } -function getOperationIdSelections(context: TCGCContext, raw: LroMetadata) { +export type LroOperationIdSelection = + { kind: "path"; property: ModelProperty } | { kind: "property"; property: ModelProperty }; + +function getPathSelections(context: TCGCContext, raw: LroMetadata) { const statusMonitorOperation = raw.statusMonitorStep?.kind === "nextOperationReference" ? raw.statusMonitorStep.target.operation @@ -85,3 +108,25 @@ function getOperationIdSelections(context: TCGCContext, raw: LroMetadata) { })); }); } + +function getPropertySelections(context: TCGCContext, responseModel: Model) { + const selections: { property: ModelProperty; target: DiagnosticTarget }[] = []; + for (let model: Model | undefined = responseModel; model; model = model.baseModel) { + for (const property of model.properties.values()) { + if ( + !isInScope(context, property) || + hasNoneVisibility(context, property) || + !isVisible(context.program, property, Visibility.Read) + ) { + continue; + } + selections.push( + ...getLroOperationIdDecorators(context, property).map((decorator) => ({ + property, + target: decorator.target, + })), + ); + } + } + return selections; +} diff --git a/packages/typespec-client-generator-core/src/methods.ts b/packages/typespec-client-generator-core/src/methods.ts index ac9a7d6489..d03b769ae5 100644 --- a/packages/typespec-client-generator-core/src/methods.ts +++ b/packages/typespec-client-generator-core/src/methods.ts @@ -37,6 +37,7 @@ import { type SdkBuiltInType, type SdkClient, type SdkClientType, + type SdkLroOperationIdMetadata, type SdkLroPagingServiceMethod, type SdkLroServiceFinalResponse, type SdkLroServiceFinalStep, @@ -440,18 +441,36 @@ function getServiceMethodLroMetadata - parameter.kind === "path" && parameter.__raw === operationIdProperty, - ) - : undefined; const pollingResponseBody = rawMetadata.pollingInfo.responseModel ? (diagnostics.pipe( getClientTypeWithDiagnostics(context, rawMetadata.pollingInfo.responseModel), ) as SdkModelType) : undefined; + const operationId: SdkLroOperationIdMetadata[] = []; + for (const selection of diagnostics.pipe(resolveLroOperationId(context, nativeMetadata))) { + if (selection.kind === "path") { + const pathParameter = getStatusMonitorOperation(statusMonitorStep)?.parameters.find( + (parameter): parameter is SdkPathParameter => + parameter.kind === "path" && parameter.__raw === selection.property, + ); + if (pathParameter && operationIdSource) { + operationId.push({ kind: "path", source: operationIdSource, pathParameter }); + } + continue; + } + + if (!pollingResponseBody) continue; + const property = getPropertySegmentsFromModelOrParameters( + pollingResponseBody, + (candidate) => + candidate.kind === "property" && + candidate.__raw?.kind === "ModelProperty" && + findRootSourceProperty(candidate.__raw) === findRootSourceProperty(selection.property), + )?.at(-1); + if (property?.kind === "property") { + operationId.push({ kind: "property", property }); + } + } return diagnostics.wrap({ __raw: rawMetadata, @@ -473,14 +492,7 @@ function getServiceMethodLroMetadata 0 ? { statusMetadata } : {}), - ...(operationIdPathParameter && operationIdSource - ? { - operationId: { - source: operationIdSource, - pathParameter: operationIdPathParameter, - } as const, - } - : {}), + ...(operationId.length > 0 ? { operationId } : {}), }); function getStatusMonitorOperation( diff --git a/packages/typespec-client-generator-core/src/validate.ts b/packages/typespec-client-generator-core/src/validate.ts index c61f38586c..fadcaee474 100644 --- a/packages/typespec-client-generator-core/src/validate.ts +++ b/packages/typespec-client-generator-core/src/validate.ts @@ -8,10 +8,10 @@ import { validatePackage } from "./validations/package.js"; import { validateTypes } from "./validations/types.js"; export function $onValidate(program: Program) { - validateLroDecoratorTargets(program); const tcgcContext = createTCGCContext(program, "@azure-tools/typespec-client-generator-core", { mutateNamespace: false, }); + validateLroDecoratorTargets(tcgcContext); validatePackage(tcgcContext); validateClients(tcgcContext); diff --git a/packages/typespec-client-generator-core/test/methods/lro-operation-id.test.ts b/packages/typespec-client-generator-core/test/methods/lro-operation-id.test.ts index bf2dea62d7..43193fb379 100644 --- a/packages/typespec-client-generator-core/test/methods/lro-operation-id.test.ts +++ b/packages/typespec-client-generator-core/test/methods/lro-operation-id.test.ts @@ -42,6 +42,27 @@ op start is Azure.Core.Foundations.LongRunningOperation< >; `; +const propertyOperation = ` +model PropertyJobState { + @lroOperationId + operationId: string; + status: LroStatus; +} + +@route("/property-jobs/status") +@get +op getPropertyJobStatus(): PropertyJobState; + +@route("/property-jobs") +@pollingOperation(getPropertyJobStatus) +op startPropertyJob is Azure.Core.Foundations.LongRunningOperation< + {}, + AcceptedResponse, + {}, + {} +>; +`; + describe("LRO operation ID", () => { it("accepts a next-operation reference with a status-monitor link", () => { const link = { @@ -61,7 +82,7 @@ describe("LRO operation ID", () => { strictEqual(getStatusMonitorLink(step), link); }); - it("requires the operation ID target to be a path parameter", async () => { + it("requires the operation ID target to belong to a linked status monitor", async () => { const diagnostics = await Tester.diagnose(` model Invalid { @lroOperationId @@ -69,7 +90,7 @@ model Invalid { } `); expectDiagnostics(diagnostics, { - code: "@azure-tools/typespec-client-generator-core/lro-operation-id-not-path", + code: "@azure-tools/typespec-client-generator-core/lro-operation-id-invalid-target", }); }); @@ -82,18 +103,25 @@ model Invalid { strictEqual(method.lroMetadata.statusMonitorStep?.kind, "nextOperationLink"); if (method.lroMetadata.statusMonitorStep?.kind === "nextOperationLink") { strictEqual( - method.lroMetadata.operationId?.source, + method.lroMetadata.operationId?.[0].kind === "path" + ? method.lroMetadata.operationId[0].source + : undefined, method.lroMetadata.statusMonitorStep.target, ); strictEqual( - method.lroMetadata.operationId?.pathParameter, + method.lroMetadata.operationId?.[0].kind === "path" + ? method.lroMetadata.operationId[0].pathParameter + : undefined, method.lroMetadata.statusMonitorStep.operation?.parameters.find( (x) => x.kind === "path" && x.name === "jobId", ), ); } - strictEqual(method.lroMetadata.operationId?.pathParameter.name, "jobId"); - strictEqual(method.lroMetadata.operationId?.pathParameter.__raw?.name, "jobId"); + strictEqual(method.lroMetadata.operationId?.[0].kind, "path"); + if (method.lroMetadata.operationId?.[0].kind === "path") { + strictEqual(method.lroMetadata.operationId[0].pathParameter.name, "jobId"); + strictEqual(method.lroMetadata.operationId[0].pathParameter.__raw?.name, "jobId"); + } }); it("honors emitter scope", async () => { @@ -116,10 +144,182 @@ model Invalid { ok(csharpMethod); strictEqual(pythonMethod.kind, "lro"); strictEqual(csharpMethod.kind, "lro"); - strictEqual(pythonMethod.lroMetadata.operationId?.pathParameter.name, "jobId"); + strictEqual(pythonMethod.lroMetadata.operationId?.[0].kind, "path"); + if (pythonMethod.lroMetadata.operationId?.[0].kind === "path") { + strictEqual(pythonMethod.lroMetadata.operationId[0].pathParameter.name, "jobId"); + } strictEqual(csharpMethod.lroMetadata.operationId, undefined); }); + it("exposes a status-monitor response property", async () => { + const { program } = await Tester.compile(propertyOperation); + const context = await createSdkContextForTester(program); + const method = context.sdkPackage.clients[0].methods.find((x) => x.name === "startPropertyJob"); + ok(method); + strictEqual(method.kind, "lro"); + strictEqual(method.lroMetadata.operationId?.length, 1); + strictEqual(method.lroMetadata.operationId?.[0].kind, "property"); + if (method.lroMetadata.operationId?.[0].kind === "property") { + strictEqual(method.lroMetadata.operationId[0].property.name, "operationId"); + strictEqual(method.lroMetadata.operationId[0].property.__raw?.name, "operationId"); + strictEqual( + method.lroMetadata.operationId[0].property, + method.lroMetadata.pollingInfo.responseModel.properties.find( + (x) => x.name === "operationId", + ), + ); + } + }); + + it("exposes an inherited status-monitor response property", async () => { + const { program } = await Tester.compile(` +model PropertyJobStateBase { + @lroOperationId + operationId: string; +} +model PropertyJobState extends PropertyJobStateBase { + status: LroStatus; +} +@route("/property-jobs/status") @get +op getPropertyJobStatus(): PropertyJobState; +@route("/property-jobs") @pollingOperation(getPropertyJobStatus) +op startPropertyJob is Azure.Core.Foundations.LongRunningOperation< + {}, AcceptedResponse, {}, {} +>; +`); + const context = await createSdkContextForTester(program); + const method = context.sdkPackage.clients[0].methods.find((x) => x.name === "startPropertyJob"); + ok(method); + strictEqual(method.kind, "lro"); + strictEqual(method.lroMetadata.operationId?.[0].kind, "property"); + if (method.lroMetadata.operationId?.[0].kind === "property") { + strictEqual( + method.lroMetadata.operationId[0].property, + method.lroMetadata.pollingInfo.responseModel.baseModel?.properties.find( + (x) => x.name === "operationId", + ), + ); + } + }); + + it("rejects a non-string status-monitor response property", async () => { + const diagnostics = await Tester.diagnose( + propertyOperation.replace("operationId: string", "operationId: int32"), + ); + expectDiagnostics(diagnostics, { + code: "@azure-tools/typespec-client-generator-core/lro-operation-id-invalid-target", + }); + }); + + it("rejects a property outside the linked status-monitor response model", async () => { + const diagnostics = await Tester.diagnose(` +model OtherStatus { + @lroOperationId operationId: string; +} +${propertyOperation.replace("@lroOperationId\n operationId: string;", "operationId: string;")} +`); + expectDiagnostics(diagnostics, { + code: "@azure-tools/typespec-client-generator-core/lro-operation-id-invalid-target", + }); + }); + + it("honors decorator scope for a status-monitor response property", async () => { + const { program } = await Tester.compile( + propertyOperation.replace("@lroOperationId\n", '@lroOperationId("python")\n'), + ); + const pythonContext = await createSdkContextForTester(program, { + emitterName: "@azure-tools/typespec-python", + }); + const csharpContext = await createSdkContextForTester(program, { + emitterName: "@azure-tools/typespec-csharp", + }); + const pythonMethod = pythonContext.sdkPackage.clients[0].methods.find( + (x) => x.name === "startPropertyJob", + ); + const csharpMethod = csharpContext.sdkPackage.clients[0].methods.find( + (x) => x.name === "startPropertyJob", + ); + ok(pythonMethod); + ok(csharpMethod); + strictEqual(pythonMethod.kind, "lro"); + strictEqual(csharpMethod.kind, "lro"); + strictEqual(pythonMethod.lroMetadata.operationId?.[0].kind, "property"); + strictEqual(csharpMethod.lroMetadata.operationId, undefined); + }); + + it("excludes a response property outside the emitter scope", async () => { + const { program } = await Tester.compile( + propertyOperation.replace( + "@lroOperationId\n operationId", + '@lroOperationId\n @scope("python")\n operationId', + ), + ); + const pythonContext = await createSdkContextForTester(program, { + emitterName: "@azure-tools/typespec-python", + }); + const csharpContext = await createSdkContextForTester(program, { + emitterName: "@azure-tools/typespec-csharp", + }); + const pythonMethod = pythonContext.sdkPackage.clients[0].methods.find( + (x) => x.name === "startPropertyJob", + ); + const csharpMethod = csharpContext.sdkPackage.clients[0].methods.find( + (x) => x.name === "startPropertyJob", + ); + ok(pythonMethod); + ok(csharpMethod); + strictEqual(pythonMethod.kind, "lro"); + strictEqual(csharpMethod.kind, "lro"); + strictEqual(pythonMethod.lroMetadata.operationId?.[0].kind, "property"); + strictEqual(csharpMethod.lroMetadata.operationId, undefined); + }); + + it("excludes a response property without read visibility", async () => { + const { program } = await Tester.compile( + propertyOperation.replace( + "@lroOperationId\n operationId", + "@lroOperationId\n @visibility(Lifecycle.Create)\n operationId", + ), + ); + const context = await createSdkContextForTester(program); + const method = context.sdkPackage.clients[0].methods.find((x) => x.name === "startPropertyJob"); + ok(method); + strictEqual(method.kind, "lro"); + strictEqual(method.lroMetadata.operationId, undefined); + }); + + it("preserves ordered operation IDs alongside status metadata", async () => { + const { program } = await Tester.compile(` +model CombinedJobState { + @lroOperationId operationId: string; + @lroStatusMetadata percentComplete?: int32; + status: LroStatus; +} +@route("/combined-jobs/{operationId}") @get +op getCombinedJobStatus( + @lroOperationId @path operationId: string +): CombinedJobState; +@route("/combined-jobs") @pollingOperation(getCombinedJobStatus) +op startCombinedJob is Azure.Core.Foundations.LongRunningOperation< + {}, AcceptedResponse, {}, {} +>; +`); + const context = await createSdkContextForTester(program); + expectDiagnostics(context.diagnostics, []); + const method = context.sdkPackage.clients[0].methods.find((x) => x.name === "startCombinedJob"); + ok(method); + strictEqual(method.kind, "lro"); + strictEqual(method.lroMetadata.operationId?.length, 2); + strictEqual(method.lroMetadata.operationId?.[0].kind, "path"); + strictEqual(method.lroMetadata.operationId?.[1].kind, "property"); + const progressProperty = method.lroMetadata.pollingInfo.responseModel.properties.find( + (x) => x.name === "percentComplete", + ); + ok(progressProperty); + strictEqual(method.lroMetadata.statusMetadata?.length, 1); + strictEqual(method.lroMetadata.statusMetadata[0].property, progressProperty); + }); + it("reports duplicate active operation ID parameters", async () => { const { program } = await Tester.compile( operation.replace( @@ -135,6 +335,21 @@ model Invalid { ); }); + it("reports duplicate active operation ID response properties", async () => { + const { program } = await Tester.compile( + propertyOperation.replace( + "status: LroStatus;", + `@lroOperationId + alternateOperationId: string; + status: LroStatus;`, + ), + ); + const context = await createSdkContextForTester(program); + expect(context.diagnostics.map((x) => x.code)).toContain( + "@azure-tools/typespec-client-generator-core/lro-operation-id-duplicate", + ); + }); + it("rejects a next-operation reference without a status-monitor link", async () => { const { program } = await Tester.compile(` model PollingStatus { @@ -199,11 +414,16 @@ op createWidget(@path id: string): { if (method.lroMetadata.statusMonitorStep?.kind === "nextOperationReference") { ok(method.lroMetadata.statusMonitorStep.target.link); strictEqual( - method.lroMetadata.operationId?.source, + method.lroMetadata.operationId?.[0].kind === "path" + ? method.lroMetadata.operationId[0].source + : undefined, method.lroMetadata.statusMonitorStep.target.link, ); } - strictEqual(method.lroMetadata.operationId?.pathParameter.name, "operationId"); + strictEqual(method.lroMetadata.operationId?.[0].kind, "path"); + if (method.lroMetadata.operationId?.[0].kind === "path") { + strictEqual(method.lroMetadata.operationId[0].pathParameter.name, "operationId"); + } }); it("retains a spread operation ID path parameter", async () => { @@ -231,7 +451,10 @@ op createWidget(): { const method = context.sdkPackage.clients[0].methods.find((x) => x.name === "createWidget"); ok(method); strictEqual(method.kind, "lro"); - strictEqual(method.lroMetadata.operationId?.pathParameter.name, "operationId"); + strictEqual(method.lroMetadata.operationId?.[0].kind, "path"); + if (method.lroMetadata.operationId?.[0].kind === "path") { + strictEqual(method.lroMetadata.operationId[0].pathParameter.name, "operationId"); + } }); it("accepts a URL-valued polling location in the response body", async () => { @@ -262,7 +485,9 @@ op createWidget(): { if (method.lroMetadata.statusMonitorStep?.kind === "nextOperationLink") { strictEqual(method.lroMetadata.statusMonitorStep.target.location, "ResponseBody"); strictEqual( - method.lroMetadata.operationId?.source, + method.lroMetadata.operationId?.[0].kind === "path" + ? method.lroMetadata.operationId[0].source + : undefined, method.lroMetadata.statusMonitorStep.target, ); } diff --git a/website/src/content/docs/docs/libraries/typespec-client-generator-core/guideline.md b/website/src/content/docs/docs/libraries/typespec-client-generator-core/guideline.md index bf13b33339..81dfd52769 100644 --- a/website/src/content/docs/docs/libraries/typespec-client-generator-core/guideline.md +++ b/website/src/content/docs/docs/libraries/typespec-client-generator-core/guideline.md @@ -410,13 +410,16 @@ Separate `@lroStatusMetadata` declarations on one property may use disjoint emit Status metadata requires native Azure.Core LRO discovery. An operation detected only through the legacy `@markAsLro` fallback does not acquire status metadata selections. A decorator on a model unused by native LROs has no generation effect. -#### Legacy URI-derived LRO operation identity +#### Legacy LRO operation identity TCGC-only `@lroOperationId` lets an SDK preserve an operation-identity API for a legacy service -that returns a server-issued identifier only as part of its status-monitor URI: +that returns a server-issued identifier in its status-monitor URI, response body, or both: ```typespec model OperationStatus { + @lroOperationId + id: string; + status: OperationState; } @@ -432,17 +435,23 @@ op getOperationStatus( op begin is MyLongRunningOperation; ``` -`SdkLroServiceMetadata.operationId` contains the exact `SdkOperationLink` used by the status monitor -and the exact `SdkPathParameter` selected from the explicitly linked polling operation. It does not -drive polling and is distinct from a caller-supplied `Operation-Id` API parameter. Emitters bind the -selected path parameter from the returned polling URI and choose an idiomatic accessor and runtime -lifecycle. An LRO with only an opaque polling URI and no modeled polling operation cannot expose -operation-ID metadata. +`SdkLroServiceMetadata.operationId` contains ordered, source-tagged metadata: the exact +`SdkOperationLink` and `SdkPathParameter` for the polling URI first, followed by the exact +`SdkModelPropertyType` for the status-monitor body. The path form is available from the initial +response and must remain derivable after poller rehydration from the stored polling URL. The +property form becomes available only after a status-monitor response is received. + +This metadata does not drive polling and is distinct from a caller-supplied `Operation-Id` API +parameter. Emitters choose their idiomatic accessor and lifecycle; expected homes include .NET +`Operation.Id`, a Java poll-result or poller accessor, a Python poller property, JavaScript poller +state, and a Go poller method. An LRO with a path selection but only an opaque polling URI and no +modeled polling operation cannot expose operation-ID metadata. Conforming Azure services expose operation identity through their request and response API, including `Operation-Id` and the status-monitor body's `id`, and do not need this decorator. `@lroOperationId` is a compatibility opt-in for an established nonconforming wire contract; it is -not a standard polling mechanism. +not a standard polling mechanism. A resource's own identifier or name, such as a Key Vault secret +URI or a delete-until-gone resource name, is not an operation identifier. ### HTTP Operation Parameters Handling diff --git a/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/decorators.md b/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/decorators.md index ebcda42fa3..e522ee852b 100644 --- a/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/decorators.md +++ b/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/decorators.md @@ -855,11 +855,11 @@ model MyModel { ### `@lroOperationId` {#@Azure.ClientGenerator.Core.lroOperationId} -Identifies the status-monitor path parameter containing a server-issued long-running operation -identifier for a legacy service that exposes the identifier only through the returned polling -URI. This compatibility metadata does not drive polling and is distinct from a caller-supplied -`Operation-Id` API parameter. Conforming services with modeled operation identity do not need -this decorator. Emitters choose their idiomatic accessor and storage. +Identifies a server-issued long-running operation identifier exposed by a legacy service through +either the status-monitor URI or its response body. This compatibility metadata does not drive +polling and is distinct from a caller-supplied `Operation-Id` API parameter. Conforming services +with modeled operation identity do not need this decorator. Emitters choose their idiomatic +accessor and storage. ```typespec @Azure.ClientGenerator.Core.lroOperationId(scope?: valueof Azure.ClientGenerator.Core.DecoratorOptions | string) @@ -867,7 +867,8 @@ this decorator. Emitters choose their idiomatic accessor and storage. #### Target -The operation identifier path parameter on the linked status-monitor operation. +The operation identifier path parameter on the linked status-monitor operation, or +a string-like property on its response model. `ModelProperty` #### Parameters