diff --git a/docs/lro-azure-async-operation-header.md b/docs/lro-azure-async-operation-header.md new file mode 100644 index 00000000..d74aefb5 --- /dev/null +++ b/docs/lro-azure-async-operation-header.md @@ -0,0 +1,88 @@ +# LroAzureAsyncOperationHeader + +## Category + +ARM Error + +## Applies to + +ARM OpenAPI(swagger) specs + +## Related ARM Guideline Code + +- RPC-Async-V1-06 + +## Output Message + +All long-running operations must include an `Azure-AsyncOperation` response header. + +## Description + +ARM relies on the async operation header to poll for the status of the long running operation. Based on this and the +final state of the operation, downstream services like ARN and ARG are notified of the current state of the operation +and the status of the resource. If you are a brownfield service that does not implement this header, you may add a +suppression using the following TSG indicating the same. +TSG link - https://github.com/Azure/autorest/blob/main/docs/generate/suppress-warnings.md. +In the description for the suppression, please provide a rough timeline by which the header will be supported by your +service. More details about this header can be found in the ARM Resource Provider Contract documentation here - https://github.com/cloud-and-ai-microsoft/resource-provider-contract/blob/master/v1.0/async-api-reference.md#azure-asyncoperation-resource-format + +## CreatedAt + +Oct 11, 2024 + +## How to fix the violation + +Adding the Azure-AsyncOperation header to the response. + +## Good Example + +```json + "/api/configServers": { + "put": { + "operationId": "ConfigServers_Update", + "responses": { + "202": { + "description": "Accepted", + "headers": { + "Azure-AsyncOperation": { + "type": "string", + }, + }, + }, + }, + }, + }, +``` + +## Bad Example 1 + +```json + "/api/configServers": { + "put": { + "operationId": "ConfigServers_Update", + "responses": { + "202": { + "description": "Success", + "headers": { + //No Azure-AsyncOperation header + }, + }, + }, + }, + }, +``` + +## Bad Example 2 + +```json + "/api/configServers": { + "put": { + "operationId": "ConfigServers_Update", + "responses": { + "202": { + "description": "Success", + //No headers + }, + }, + }, + }, \ No newline at end of file diff --git a/docs/rules.md b/docs/rules.md index 99e90084..3c57720b 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -495,6 +495,18 @@ For Data plane spec, the allowed response status codes for a long DELETE operati Please refer to [long-running-response-status-code-data-plane.md](./long-running-response-status-code-data-plane.md) for details. +### LroAzureAsyncOperationHeader + +ARM relies on the async operation header to poll for the status of the long running operation. Based on this and the +final state of the operation, downstream services like ARN and ARG are notified of the current state of the operation +and the status of the resource. If you are a brownfield service that does not implement this header, you may add a +suppression using the following TSG indicating the same. +TSG link - https://github.com/Azure/autorest/blob/main/docs/generate/suppress-warnings.md. +In the description for the suppression, please provide a rough timeline by which the header will be supported by your +service. More details about this header can be found in the ARM Resource Provider Contract documentation here - https://github.com/cloud-and-ai-microsoft/resource-provider-contract/blob/master/v1.0/async-api-reference.md#azure-asyncoperation-resource-format + +Please refer to [lro-azure-async-operation-header.md](./lro-azure-async-operation-header.md) for details. + ### LroErrorContent Error response content of long running operations must follow the error schema provided in the common types v2 and above. diff --git a/packages/rulesets/generated/spectral/az-arm.js b/packages/rulesets/generated/spectral/az-arm.js index 12cb91f1..eafd311c 100644 --- a/packages/rulesets/generated/spectral/az-arm.js +++ b/packages/rulesets/generated/spectral/az-arm.js @@ -1743,6 +1743,18 @@ const locationMustHaveXmsMutability = (scheme, _opts, paths) => { }]; }; +const lroAzureAsyncOperationHeader = (headers, _opts, ctx) => { + if (!Object.keys(headers).includes("headers") || !Object.keys(headers.headers).includes("Azure-AsyncOperation")) { + return [ + { + message: "All long-running operations must include an `Azure-AsyncOperation` response header.", + path: ctx.path.concat("headers"), + }, + ]; + } + return []; +}; + const validateOriginalUri = (lroOptions, opts, ctx) => { if (!lroOptions || typeof lroOptions !== "object") { return []; @@ -3311,6 +3323,17 @@ const ruleset = { function: provisioningState, }, }, + LroAzureAsyncOperationHeader: { + rpcGuidelineCode: "RPC-Async-V1-06", + description: "All long-running operations must include an `Azure-AsyncOperation` response header.", + message: "{{description}}", + severity: "error", + formats: [oas2], + given: ["$[paths,'x-ms-paths'].*.*[?(@property === 'x-ms-long-running-operation' && @ === true)]^.responses.*"], + then: { + function: lroAzureAsyncOperationHeader, + }, + }, LroLocationHeader: { rpcGuidelineCode: "RPC-Async-V1-07", description: "Location header must be supported for all async operations that return 202.", diff --git a/packages/rulesets/src/spectral/az-arm.ts b/packages/rulesets/src/spectral/az-arm.ts index 0f11042e..20950362 100644 --- a/packages/rulesets/src/spectral/az-arm.ts +++ b/packages/rulesets/src/spectral/az-arm.ts @@ -15,6 +15,7 @@ import hasheader from "./functions/has-header" import httpsSupportedScheme from "./functions/https-supported-scheme" import { latestVersionOfCommonTypesMustBeUsed } from "./functions/latest-version-of-common-types-must-be-used" import locationMustHaveXmsMutability from "./functions/location-must-have-xms-mutability" +import { lroAzureAsyncOperationHeader } from "./functions/lro-azure-async-operation-header" import validateOriginalUri from "./functions/lro-original-uri" import { lroPatch202 } from "./functions/lro-patch-202" import provisioningStateSpecifiedForLROPatch from "./functions/lro-patch-provisioning-state-specified" @@ -158,6 +159,19 @@ const ruleset: any = { }, }, + // RPC Code: RPC-Async-V1-06 + LroAzureAsyncOperationHeader: { + rpcGuidelineCode: "RPC-Async-V1-06", + description: "All long-running operations must include an `Azure-AsyncOperation` response header.", + message: "{{description}}", + severity: "error", + formats: [oas2], + given: ["$[paths,'x-ms-paths'].*.*[?(@property === 'x-ms-long-running-operation' && @ === true)]^.responses.*"], + then: { + function: lroAzureAsyncOperationHeader, + }, + }, + // RPC Code: RPC-Async-V1-07 LroLocationHeader: { rpcGuidelineCode: "RPC-Async-V1-07", diff --git a/packages/rulesets/src/spectral/functions/lro-azure-async-operation-header.ts b/packages/rulesets/src/spectral/functions/lro-azure-async-operation-header.ts new file mode 100644 index 00000000..082dd419 --- /dev/null +++ b/packages/rulesets/src/spectral/functions/lro-azure-async-operation-header.ts @@ -0,0 +1,11 @@ +export const lroAzureAsyncOperationHeader = (headers: any, _opts: any, ctx: any) => { + if (!Object.keys(headers).includes("headers") || !Object.keys(headers.headers).includes("Azure-AsyncOperation")) { + return [ + { + message: "All long-running operations must include an `Azure-AsyncOperation` response header.", + path: ctx.path.concat("headers"), + }, + ] + } + return [] +} diff --git a/packages/rulesets/src/spectral/test/lro-azure-async-operation-header.test.ts b/packages/rulesets/src/spectral/test/lro-azure-async-operation-header.test.ts new file mode 100644 index 00000000..a90d446b --- /dev/null +++ b/packages/rulesets/src/spectral/test/lro-azure-async-operation-header.test.ts @@ -0,0 +1,138 @@ +import { Spectral } from "@stoplight/spectral-core" +import linterForRule from "./utils" + +let linter: Spectral + +beforeAll(async () => { + linter = await linterForRule("LroAzureAsyncOperationHeader") + return linter +}) + +const ERROR_MESSAGE = "All long-running operations must include an `Azure-AsyncOperation` response header." + +test("LroAzureAsyncOperationHeader should find no errors", () => { + const myOpenApiDocument = { + swagger: "2.0", + paths: { + "/foo1/operations": { + get: { + operationId: "foo_get", + responses: { + 202: { + description: "Accepted", + // no header scenario and no x-ms-long-running-operation + }, + }, + }, + post: { + operationId: "foo_post", + "x-ms-long-running-operation": true, + responses: { + 202: { + description: "Accepted", + headers: { + "Azure-AsyncOperation": { + description: "The URL where the status of the asynchronous operation can be checked.", + type: "string", + }, + }, + }, + }, + }, + put: { + operationId: "foo_put", + "x-ms-long-running-operation": true, + responses: { + 204: { + description: "Accepted", + headers: { + "Azure-AsyncOperation": { + description: "The URL where the status of the asynchronous operation can be checked.", + type: "string", + }, + }, + }, + }, + }, + }, + }, + } + return linter.run(myOpenApiDocument).then((results) => { + expect(results.length).toBe(0) + }) +}) + +test("LroAzureAsyncOperationHeader should find errors with no Azure-AsyncOperation header", () => { + const myOpenApiDocument = { + swagger: "2.0", + paths: { + "/foo1/operations": { + get: { + operationId: "foo_get", + "x-ms-long-running-operation": true, + responses: { + 202: { + description: "Accepted", + headers: { + Location: { + description: "No Azure-AsyncOperation header", + type: "string", + }, + }, + }, + }, + }, + post: { + operationId: "foo_post", + "x-ms-long-running-operation": true, + responses: { + 202: { + description: "No header case", + }, + }, + }, + put: { + operationId: "foo_put", + "x-ms-long-running-operation": true, + responses: { + 202: { + description: "Accepted", + headers: { + "azure-asyncOperation1": { + description: "check the wrong wording", + type: "string", + }, + }, + }, + }, + }, + delete: { + operationId: "foo_delete", + "x-ms-long-running-operation": true, + responses: { + 202: { + description: "Accepted", + headers: { + "azure-asyncOperation": { + description: "check the camel case", + type: "string", + }, + }, + }, + }, + }, + }, + }, + } + return linter.run(myOpenApiDocument).then((results) => { + expect(results.length).toBe(4) + expect(results[0].path.join(".")).toBe("paths./foo1/operations.get.responses.202.headers") + expect(results[0].message).toEqual(ERROR_MESSAGE) + expect(results[1].path.join(".")).toBe("paths./foo1/operations.post.responses.202") + expect(results[1].message).toEqual(ERROR_MESSAGE) + expect(results[2].path.join(".")).toBe("paths./foo1/operations.put.responses.202.headers") + expect(results[2].message).toEqual(ERROR_MESSAGE) + expect(results[3].path.join(".")).toBe("paths./foo1/operations.delete.responses.202.headers") + expect(results[3].message).toEqual(ERROR_MESSAGE) + }) +})