Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@azure-tools/typespec-azure-core"
---

Allow `original-uri` final state via for POST operations in addition to PUT and PATCH. When `@useFinalStateVia("original-uri")` is specified and there is no GET operation at the original URI, a `no-operation-at-original-uri` warning diagnostic is emitted and the final result is treated as `void`.
Comment thread
markcowl marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,18 @@ it("emits error for missing header", async () => {
message: `There was no header corresponding to the desired final-state-via value 'location'.`,
});
});
it("emits error for original-uri on non-PUT request", async () => {
it("emits error for original-uri on non-PUT/PATCH/POST request", async () => {
const diagnostics = await Tester.diagnose(`
@pollingOperation(bar)
@useFinalStateVia("original-uri")
@post op foo(): {};
@delete op foo(): {};

@route("/polling")
@get op bar(): {status: "Succeeded" | "Failed" | "Cancelled"};
`);
expectDiagnostics(diagnostics, {
code: "@azure-tools/typespec-azure-core/invalid-final-state",
message: "The final state value 'original-uri' can only be used in http PUT operations",
message:
"The final state value 'original-uri' can only be used in http PUT, PATCH, or POST operations",
});
});
10 changes: 9 additions & 1 deletion packages/typespec-azure-core/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,18 @@ export const $lib = createTypeSpecLibrary({
severity: "warning",
messages: {
badValue: paramMessage`Specified final state value '${"finalStateValue"}' is not valid. It must be one of ("operation-location", "original-uri", "location", "azure-async-operation")`,
notPut: "The final state value 'original-uri' can only be used in http PUT operations",
notPut:
"The final state value 'original-uri' can only be used in http PUT, PATCH, or POST operations",
noHeader: paramMessage`There was no header corresponding to the desired final-state-via value '${"finalStateValue"}'.`,
},
},
"no-operation-at-original-uri": {
severity: "warning",
messages: {
default:
"The 'original-uri' final state is specified, but there is no GET operation at the original URI. The final result will be treated as 'void'.",
},
},
"bad-record-type": {
severity: "warning",
messages: {
Expand Down
64 changes: 52 additions & 12 deletions packages/typespec-azure-core/src/lro-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from "@typespec/compiler";
import { $ } from "@typespec/compiler/typekit";
import {
getAllHttpServices,
getHeaderFieldName,
getHttpOperation,
getOperationVerb,
Expand Down Expand Up @@ -61,6 +62,7 @@ import {
type StatusMonitorMetadata,
} from "./decorators/polling-location.js";
import { PollingOperationKey } from "./decorators/polling-operation.js";
import { reportDiagnostic } from "./lib.js";
import type { PropertyMap } from "./lro-info.js";
import { FinalStateValue, getFinalStateOverride } from "./state/final-state.js";

Expand Down Expand Up @@ -404,24 +406,46 @@ function createLroMetadata(
context: LroContext,
): LroMetadata | undefined {
const [finalState, model] = getFinalStateVia(program, operation, context);
if (finalState === undefined || model === undefined || context.pollingStep === undefined)
return undefined;
if (finalState === undefined || context.pollingStep === undefined) return undefined;

// If original-uri is explicitly specified via @useFinalStateVia and there's no GET
// operation at the same path, emit a warning and treat the final result as void.
// We check both finalState and finalStateOverride to only emit the diagnostic when
// the user explicitly opted into original-uri (via @useFinalStateVia), not when
// original-uri is the natural computed state (e.g., for standard resource create operations).
let resolvedModel = model;
const finalStateOverride = getFinalStateOverride(program, operation);
if (
finalState === FinalStateValue.originalUri &&
finalStateOverride === FinalStateValue.originalUri
) {
if (!hasGetOperationAtSamePath(program, context.httpOperation)) {
reportDiagnostic(program, {
code: "no-operation-at-original-uri",
target: operation,
});
// When there's no GET at the original URI, treat the final result as void
resolvedModel = $(program).intrinsic.void;
}
}

if (resolvedModel === undefined) return undefined;
const logicalPathName =
context.finalStep?.kind === "pollingSuccessProperty"
? context.finalStep.target.name
: undefined;

let finalResult: Model | Scalar | UnknownType | "void" =
model.kind === "Model" ||
model.kind === "Scalar" ||
(model.kind === "Intrinsic" && !isVoidType(model))
? model
resolvedModel.kind === "Model" ||
resolvedModel.kind === "Scalar" ||
(resolvedModel.kind === "Intrinsic" && !isVoidType(resolvedModel))
? resolvedModel
: "void";
let finalEnvelopeResult: Model | Scalar | UnknownType | "void" =
model.kind === "Model" ||
model.kind === "Scalar" ||
(model.kind === "Intrinsic" && !isVoidType(model))
? model
resolvedModel.kind === "Model" ||
resolvedModel.kind === "Scalar" ||
(resolvedModel.kind === "Intrinsic" && !isVoidType(resolvedModel))
? resolvedModel
: "void";
if (context.finalStep && context.finalStep.kind === "pollingSuccessProperty") {
finalEnvelopeResult = context.pollingStep.responseModel;
Expand All @@ -432,9 +456,9 @@ function createLroMetadata(
return {
operation: operation,
logicalResult:
model.kind === "Intrinsic" || model.kind === "Scalar"
resolvedModel.kind === "Intrinsic" || resolvedModel.kind === "Scalar"
? context.pollingStep.responseModel
: model,
: resolvedModel,
finalStateVia: finalState,
statusMonitorStep: context.statusMonitorStep,
pollingInfo: context.pollingStep,
Expand Down Expand Up @@ -954,6 +978,22 @@ function isMatchingGetOperation(
return sourceHttp.path === targetHttp.path && targetHttp.verb === "get";
}

/**
* Checks if there is a GET operation at the same path as the given operation
* in the program's HTTP services.
*/
function hasGetOperationAtSamePath(program: Program, httpOperation: HttpOperation): boolean {
const [services] = getAllHttpServices(program);
for (const service of services) {
for (const op of service.operations) {
if (op.verb === "get" && op.path === httpOperation.path) {
return true;
}
}
}
return false;
}

function processFinalLink(
program: Program,
modelOrOperation: Model | Operation,
Expand Down
2 changes: 1 addition & 1 deletion packages/typespec-azure-core/src/state/final-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function validateFinalState(
finalState: FinalStateValue,
): FinalStateValue | undefined {
if (finalState === FinalStateValue.originalUri) {
if (operation.verb !== "put" && operation.verb !== "patch") {
if (operation.verb !== "put" && operation.verb !== "patch" && operation.verb !== "post") {
reportDiagnostic(program, {
code: "invalid-final-state",
target: operation.operation,
Expand Down
Loading
Loading