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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .chronus/changes/lro-missing-polling-metadata-arm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
changeKind: feature
packages:
- "@azure-tools/typespec-azure-resource-manager"
---

Add the `lro-missing-polling-metadata` lint rule, consolidating `LroExtension` and `XMSLongRunningOperationProperty`. Require native polling metadata for PATCH and marked provider/collection POST operations returning `202`, and for PUT/PATCH/POST/DELETE operations whose `200`, `201`, `202`, or `204` responses contain `Location` or `Azure-AsyncOperation` headers.

Use asynchronous ARM templates or preserve supported semantic LRO headers. Check concrete operations within explicit service namespaces, including nested namespaces, without a global fallback. Report once on the authored operation even when both checks apply, and resolve each eligible operation once across overlapping service declarations, including compliant operations.

The rule is disabled in the central resource-manager ruleset and included in the ARM library's compiler-generated `all` ruleset.
7 changes: 7 additions & 0 deletions .chronus/changes/lro-missing-polling-metadata-rulesets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: internal
packages:
- "@azure-tools/typespec-azure-rulesets"
---

Register the ARM `lro-missing-polling-metadata` lint rule as disabled in the resource-manager ruleset.
1 change: 1 addition & 0 deletions packages/typespec-azure-resource-manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Available ruleSets:
| [`@azure-tools/typespec-azure-resource-manager/arm-resource-invalid-action-verb`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/arm-resource-invalid-action-verb) | Actions must be HTTP Post or Get operations. |
| [`@azure-tools/typespec-azure-resource-manager/improper-subscription-list-operation`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/improper-subscription-list-operation) | Tenant and Extension resources should not define a list by subscription operation. |
| [`@azure-tools/typespec-azure-resource-manager/lro-location-header`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/lro-location-header) | A 202 response should include a Location response header. |
| [`@azure-tools/typespec-azure-resource-manager/lro-missing-polling-metadata`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/lro-missing-polling-metadata) | ARM operations indicating asynchronous processing must have native polling metadata. |
| [`@azure-tools/typespec-azure-resource-manager/list-operation-missing-pageable`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/list-operation-missing-pageable) | ARM GET operations on list paths must define TypeSpec paging metadata. |
| [`@azure-tools/typespec-azure-resource-manager/missing-x-ms-identifiers`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/missing-x-ms-identifiers) | Array properties should describe their identifying properties with x-ms-identifiers. Decorate the property with @OpenAPI.extension("x-ms-identifiers", #[id-prop]) where "id-prop" is a list of the names of identifying properties in the item type. |
| [`@azure-tools/typespec-azure-resource-manager/no-response-body`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/no-response-body) | Check that the body is empty for 202 and 204 responses, and not empty for other success (2xx) responses. |
Expand Down
2 changes: 2 additions & 0 deletions packages/typespec-azure-resource-manager/src/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { improperSubscriptionListOperationRule } from "./rules/improper-subscrip
import { listOperationMissingPageableRule } from "./rules/list-operation-missing-pageable.js";
import { listResponseEnvelopeRule } from "./rules/list-response-envelope.js";
import { lroLocationHeaderRule } from "./rules/lro-location-header.js";
import { lroMissingPollingMetadataRule } from "./rules/lro-missing-polling-metadata.js";
import { lroResponseMismatchRule } from "./rules/lro-response-mismatch.js";
import { missingXmsIdentifiersRule } from "./rules/missing-x-ms-identifiers.js";
import { noEmptyModel } from "./rules/no-empty-model.js";
Expand Down Expand Up @@ -98,6 +99,7 @@ const rules = [
armResourceInvalidActionVerbRule,
improperSubscriptionListOperationRule,
lroLocationHeaderRule,
lroMissingPollingMetadataRule,
listOperationMissingPageableRule,
missingXmsIdentifiersRule,
noResponseBodyRule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
ARM operations indicating asynchronous processing must describe how clients
poll the operation and obtain its final result. This rule combines two checks:

| Trigger | Scope |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
| An explicit `202 Accepted` response | All PATCH operations, and POST operations marked as ARM provider or collection actions, even without a polling header |
| A `Location` or `Azure-AsyncOperation` response header | PUT, PATCH, POST, and DELETE operations with an explicit `200`, `201`, `202`, or `204` response |

Header names are case-insensitive. A plain polling URL or an output-only
`x-ms-long-running-operation: true` extension does not supply native polling
metadata. GET operations, including polling GETs, are outside both checks.

Use standard asynchronous ARM templates, and preserve their semantic LRO
headers when customizing responses. For example, retain
`ArmLroLocationHeader<FinalResult = Widget>` in a custom PATCH response.
Synchronous templates can also supply polling metadata through a customized
accepted response, but changing only the status code and header name is not
sufficient.

The rule checks HTTP operations in service namespaces, without requiring
provider-namespace metadata. Either trigger requires native LRO metadata.
Operations matching both triggers receive only one warning, on the authored
operation rather than a shared response header. Native polling metadata satisfies
both checks. Broad status-code ranges do not substitute for the explicit numeric
statuses above.

Registered resource-instance POST, PUT, and DELETE operations remain subject to
the polling-header check. Existing ARM response-code and Location-header rules
retain ownership of their separate status-code and header-presence requirements.

Each concrete operation within an explicitly declared service is checked once,
including operations in nested namespaces and overlapping services. Operations
outside explicit services are not checked. This avoids repeating diagnostics
from native polling-metadata resolution.

The combined rule retains one disabled (`false`) entry in the central Azure
resource-manager ruleset. The ARM library's compiler-generated `all` ruleset
includes registered rules, including this one. Consolidation adds no second
public rule or registration; selecting this rule selects both checks.

## Impact

- **Area:** API, SDK

Missing polling metadata prevents Azure tooling and generated clients from
describing the operation's polling and final-result behavior. Clients may treat
the initial accepted response as the final result instead of waiting for
completion.

## ❌ Incorrect

Replacing the standard headers with a string-valued `Location` header preserves
the accepted response but removes its polling semantics. These excerpts assume
the `Widget` resource model is already defined:

```tsp
model PlainLocationHeaders {
...Azure.Core.Foundations.RetryAfterHeader;
@header("Location") location?: string;
}

op update is ArmCustomPatchAsync<Widget, Widget, LroHeaders = PlainLocationHeaders>;
```

## ✅ Correct

Keep the same asynchronous operation and accepted response, but restore the
supported polling and final-result metadata:

```tsp
op update is ArmCustomPatchAsync<
Widget,
Widget,
LroHeaders = ArmLroLocationHeader<FinalResult = Widget> & Azure.Core.Foundations.RetryAfterHeader
>;
```

Custom `ArmProviderActionAsync` headers have the same requirement. The default
asynchronous provider template already carries supported polling metadata.

## Suppression

Prefer restoring the semantic headers or using the standard asynchronous
template. Suppress only when an externally defined polling contract cannot yet
be represented using supported TypeSpec metadata. Document that contract and
why a temporary exception is necessary.

Place
`#suppress "@azure-tools/typespec-azure-resource-manager/lro-missing-polling-metadata" "<justification>"`
on the affected operation.

## LintDiff Equivalent

| Swagger validator | Combined native coverage |
| ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [LroExtension](https://github.com/Azure/azure-openapi-validator/blob/main/docs/lro-extension.md) | Require polling metadata for PATCH and marked provider/collection POST operations returning `202`; this is partial native coverage of the broader Swagger selector. |
| [XMSLongRunningOperationProperty](https://github.com/Azure/azure-openapi-validator/blob/main/docs/xms-long-running-operation-property.md) | Require polling metadata for PUT/PATCH/POST/DELETE responses with qualifying polling headers and status codes. |

The reviewed lintdiff sources remain unchanged:
[LroExtension at `9afb5818`](https://github.com/Azure/typespec-azure/blob/9afb5818fba089835296258049ad7104e8a92c50/packages/typespec-lintdiff/src/rules/lro-extension.ts)
and
[XMSLongRunningOperationProperty at `2f7e1d5c`](https://github.com/Azure/typespec-azure/blob/2f7e1d5c3d04230666f7524235bd25eff364eaa8/packages/typespec-lintdiff/src/rules/xms-long-running-operation-property.ts).
Their official destination policies are consolidated here, preserving removal
of the mixed-runner provider-namespace isolation guards.

The native rule requires supported polling semantics instead of accepting an
emitted extension alone. It checks the supplied program rather than historical
Swagger projections and reports once per authored operation. It does not claim
identical emitted diagnostic counts.
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { getLroMetadata } from "@azure-tools/typespec-azure-core";
import {
createRule,
fileRef,
isTemplateDeclaration,
isTemplateDeclarationOrInstance,
listServices,
} from "@typespec/compiler";
import { getHttpOperation, type HttpOperationResponse } from "@typespec/http";
import { isArmCollectionAction } from "../operations.js";

const pollingStatusCodes = new Set([200, 201, 202, 204]);
const pollingHeaders = new Set(["location", "azure-asyncoperation"]);
const pollingVerbs = new Set(["put", "patch", "post", "delete"]);

export const lroMissingPollingMetadataRule = createRule({
name: "lro-missing-polling-metadata",
docs: fileRef.fromPackageRoot("src/rules/lro-missing-polling-metadata.md"),
description:
"ARM operations indicating asynchronous processing must have native polling metadata.",
severity: "warning",
url: "https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/lro-missing-polling-metadata",
messages: {
default:
"ARM operations indicating asynchronous processing must have native polling metadata. Use an asynchronous ARM template or preserve supported semantic LRO headers.",
},
create(context) {
const serviceNamespaces = new Set(listServices(context.program).map((service) => service.type));
return {
operation(operation) {
const container = operation.interface ?? operation.namespace;
if (
isTemplateDeclarationOrInstance(operation) ||
!container ||
container.operations.get(operation.name) !== operation ||
(operation.interface &&
(isTemplateDeclaration(operation.interface) ||
operation.interface.namespace?.interfaces.get(operation.interface.name) !==
operation.interface))
) {
return;
}

let namespace = operation.namespace;
while (namespace && !serviceNamespaces.has(namespace)) {
namespace = namespace.namespace;
}
if (!namespace) {
return;
}

const [httpOperation] = getHttpOperation(context.program, operation);
const requiresAcceptedMetadata =
httpOperation.verb === "patch" ||
(httpOperation.verb === "post" && isArmCollectionAction(context.program, operation));
const hasAcceptedResponse =
requiresAcceptedMetadata &&
httpOperation.responses.some((response) => response.statusCodes === 202);
const hasPollingResponse =
pollingVerbs.has(httpOperation.verb) && hasPollingHeader(httpOperation.responses);
if (hasAcceptedResponse || hasPollingResponse) {
// Metadata resolution can report Core diagnostics even for compliant operations.
if (getLroMetadata(context.program, operation) === undefined) {
context.reportDiagnostic({ target: operation });
}
}
},
};
},
});

function hasPollingHeader(responses: HttpOperationResponse[]): boolean {
return responses.some(
(response) =>
typeof response.statusCodes === "number" &&
pollingStatusCodes.has(response.statusCodes) &&
response.responses.some((content) =>
Object.keys(content.headers ?? {}).some((name) => pollingHeaders.has(name.toLowerCase())),
),
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { expect, it, vi } from "vitest";
import * as library from "../src/index.js";

vi.mock("@azure-tools/typespec-autorest", () => {
throw new Error("Native lint must not load the AutoRest emitter.");
});
vi.mock("@azure-tools/typespec-client-generator-core", () => {
throw new Error("ARM lint must not load TCGC.");
});

it("loads the ARM public entry point without emitter or TCGC dependencies", () => {
expect(library.$lib.name).toBe("@azure-tools/typespec-azure-resource-manager");
});
Loading