Skip to content
Merged
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions lib/operation-catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
});
}
}
Expand Down
6 changes: 6 additions & 0 deletions lib/operation-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
20 changes: 20 additions & 0 deletions test/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand All @@ -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"]);
Expand All @@ -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"));
Expand Down
Loading