diff --git a/README.md b/README.md index 5122d2b..f32506e 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,9 @@ const logOperations = operations.filter(operation => console.log(logOperations.map(({ functionName, method, path }) => ({ functionName, method, path }))); ``` -Each `ApiOperation` records the actual exported `functionName`, original OpenAPI `operationId`, method, path, summary, description, tags, path parameter names, query metadata, and explicit deprecation flag. `ApiQueryParameter` includes the name, required flag, description, type, and declared enum values (item values for arrays). Operation parameters override inherited path parameters. +Each `ApiOperation` records the actual exported `functionName`, original OpenAPI `operationId`, method, path, summary, description, tags, path parameter names, query metadata, explicit deprecation flag, and `idempotent` flag. `ApiQueryParameter` includes the name, required flag, description, type, and declared enum values (item values for arrays). Operation parameters override inherited path parameters. + +`idempotent` tells retrying clients whether resending an identical request after a timeout or 5xx response is safe. It is always `true` for `GET`, `HEAD`, `OPTIONS`, and `TRACE`. Other methods are `true` only when Sentry marks the operation with the `x-sentry-idempotent` OpenAPI extension; `false` means not known to be idempotent, not known to be unsafe. The catalog is generated from the same schema and SDK output as each release. Consumers own search, grouping, ranking, and command aliases; the catalog does not fetch a live schema or infer undocumented API behavior. diff --git a/lib/operation-catalog.mjs b/lib/operation-catalog.mjs index f05029d..d6c9091 100644 --- a/lib/operation-catalog.mjs +++ b/lib/operation-catalog.mjs @@ -2,6 +2,8 @@ import { parseSdkOperations } from "./sdk-operations.mjs"; const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options", "trace"]); +// RFC 9110 9.2.1: safe methods never change server state, so they are always idempotent. +const SAFE_METHODS = new Set(["get", "head", "options", "trace"]); /** Operation-level parameters replace path-level parameters with the same name/in. */ function queryParameters(pathItem, operation) { @@ -53,6 +55,8 @@ export function extractOperations(spec, sdkSource) { pathParameters: [...path.matchAll(/\{([^}]+)\}/g)].map(match => match[1]), queryParameters: queryParameters(pathItem, operation), deprecated: operation.deprecated === true, + // Other methods count only when Sentry marks them; PUT/DELETE handlers are not all idempotent. + idempotent: SAFE_METHODS.has(method) || operation["x-sentry-idempotent"] === true, }); } } diff --git a/lib/operation-types.ts b/lib/operation-types.ts index f461286..2a62917 100644 --- a/lib/operation-types.ts +++ b/lib/operation-types.ts @@ -34,4 +34,10 @@ export type ApiOperation = { readonly queryParameters: readonly ApiQueryParameter[]; /** Whether OpenAPI explicitly marks the operation deprecated. */ readonly deprecated: boolean; + /** + * Whether resending an identical request after a timeout or 5xx cannot change the outcome. + * Always true for GET, HEAD, OPTIONS, and TRACE; other methods only when Sentry marks them + * with `x-sentry-idempotent`. False means "not known to be idempotent". + */ + readonly idempotent: boolean; }; diff --git a/test/operations.test.ts b/test/operations.test.ts index 77acd23..232298a 100644 --- a/test/operations.test.ts +++ b/test/operations.test.ts @@ -70,6 +70,7 @@ describe("operation discovery", () => { { name: "field", required: false, type: "array", enum: ["id", "timestamp"] }, ], deprecated: false, + idempotent: true, }]); const next = fixture(["logs", "spans", "new_dataset"]); @@ -86,6 +87,7 @@ describe("operation discovery", () => { expect(grown.catalog).toHaveLength(2); expect(grown.catalog.find(operation => operation.path === newPath)).toMatchObject({ functionName: "queryNewCapability", operationId: "queryNewCapability", method: "POST", deprecated: true, + idempotent: false, }); expect(grown.catalog.find(operation => operation.path === eventsPath)?.queryParameters[0]?.enum) .toEqual(["logs", "spans", "new_dataset"]); @@ -98,6 +100,24 @@ describe("operation discovery", () => { .toThrow(`Multiple SDK operations for GET ${eventsPath}`); }); + test("marks non-safe methods idempotent only when Sentry declares it", async () => { + const response = { responses: { "200": { description: "OK" } } }; + const { catalog } = await generate({ + ...fixture(), + paths: { + "/api/0/assemble/": { post: { operationId: "assemble", ...response, "x-sentry-idempotent": true } }, + "/api/0/things/": { + post: { operationId: "createThing", ...response }, + put: { operationId: "updateThing", ...response }, + delete: { operationId: "deleteThing", ...response }, + }, + }, + }); + expect(Object.fromEntries(catalog.map(operation => [operation.functionName, operation.idempotent]))).toEqual({ + assemble: true, createThing: false, updateThing: false, deleteThing: false, + }); + }); + test("ships one catalog entry for every actual standalone SDK export", () => { expect(operations.map(operation => operation.functionName).sort()).toEqual(Object.keys(sdk).sort()); const original = JSON.parse(readFileSync(new URL("../openapi-derefed.json", import.meta.url), "utf8"));