Skip to content

feat(apidocs): Build an internal OpenAPI spec of every endpoint method - #129559

Open
billyvg wants to merge 8 commits into
masterfrom
billyvg/feat/apidocs-internal-spec
Open

billyvg wants to merge 8 commits into
masterfrom
billyvg/feat/apidocs-internal-spec

Conversation

@billyvg

@billyvg billyvg commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Build an internal-use only OpenAPI spec of all endpoints (incl private and experimental endpoints). This will be used by our frontend as well as Seer code mode.

Slop

Why?

The published OpenAPI spec only describes published endpoint methods, so it can't serve as a contract for the private and experimental endpoints the frontend calls. The PR below builds an internal spec for that, but switches it on with an environment flag checked throughout the shared apidocs code. This pulls that build out on its own, so the published build never runs any of it.

Depends on the PR below, which fixes schema declarations on unpublished endpoints. Until it lands, make build-internal-api-docs fails --validate on the csrf endpoint.

How?

make build-internal-api-docs runs drf-spectacular with sentry.apidocs.internal.InternalGenerator. It switches the internal behavior on for its own build only, through keyword parameters on the shared hooks and a type-hint policy whose defaults are the published build's, so the published spec is byte-identical. The internal build describes every method with its publish status and permission classes, reads undeclared responses from Response[T] annotations, and closes TypedDicts.

Decisions

  • Nothing is omitted. Omissions keep fields out of the public reference and SDKs, but this spec describes what the backend sends. With closed TypedDicts, an omitted field would be one the spec says can't exist; 31 fields are affected, such as the organization's streamlineOnly. With nothing omitted, the build skips the omission check's second build. Deprecation markers are kept.
  • Sentry's postprocessing still runs. Routing, idempotency and nullable-enum fixes apply to every operation. The published reference's checks skip unpublished operations.
  • Permission classes are recorded per operation. x-sentry-permissions lists the classes in the view's permission_classes, with composed ones (A & B, ~A) unwrapped, for consumers that filter operations by how they're guarded.

Generated with Claude Code

@linear-code

linear-code Bot commented Nov 3, 2026

Copy link
Copy Markdown

ENG-8960

billyvg added a commit that referenced this pull request Nov 3, 2026
…129568)

### Why?

Five unpublished methods declare responses or request bodies that
drf-spectacular can't describe. The published build skips unpublished
methods, so nothing has caught them. A build that describes every method
does: the csrf endpoint makes its spec invalid, and the other four come
out wrong or untyped. This is split out of the internal-spec PR below,
which needs it first, so each endpoint's owners can review their change
on its own.

- #129559

### How?

Each declaration now keys responses by status code and describes
TypedDicts through an inline response serializer, as published endpoints
do. The SCIM replace body gets its own component name. The published
spec is byte-identical.

<sub>Generated with Claude Code</sub>
@billyvg
billyvg marked this pull request as ready for review November 3, 2026 20:22
@billyvg
billyvg requested review from a team as code owners November 3, 2026 20:22

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c781b98. Configure here.

The published spec describes only published methods, so it can't serve as a contract for the
private and experimental endpoints the frontend calls. sentry.apidocs.internal builds one that
does, reading undeclared responses from Response[T] annotations and omitting nothing. Its
generator switches this on for its own build through hook parameters and a type-hint policy
whose defaults are the published build's, so the published spec is unchanged.

Extracted from the internal-spec half of #129315.
Consumers that filter operations by the permissions guarding them need the class names, which
only the view classes have. Each operation the internal build admits now carries its view's
permission_classes as x-sentry-permissions, with ones composed by &, | and ~ unwrapped to the
classes they compose.
@billyvg
billyvg force-pushed the billyvg/feat/apidocs-internal-spec branch from c781b98 to ba526f9 Compare November 3, 2026 20:44
billyvg and others added 3 commits November 3, 2026 15:54
…s view

DRF's ViewInspector.__set__ binds an assigned schema to the view instance,
the same way drf-spectacular's create_view swaps in @extend_schema classes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue endpoints declare IssueParams.ISSUES_OR_GROUPS, a `var` path
parameter for the published build's `{var}` spelling of (?:issues|groups),
which the published postprocessing drops along with the `{var}`. The
internal build spells the path `issues/` and kept the parameter, so 16
issue operations declared a required `var` that is not in their path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ations

drf-spectacular appends APPEND_PATHS over whole path items, so a hand-written
path item dropped every method its view serves that the item does not
describe. The published build only describes the hand-written ones anyway;
the internal one dropped POST on organization sentry-app-installations.
The internal build now appends them method by method, hand-written still
winning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@billyvg

billyvg commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

Re Bugbot's Replacement schema drops view binding (resolved):

False positive: the binding happens on assignment. DRF's ViewInspector (which drf-spectacular's AutoSchema subclasses) is a data descriptor, so view.schema = schema_class() goes through ViewInspector.__set__, which sets other.view = instance. drf-spectacular's own SchemaGenerator.create_view swaps in @extend_schema classes the same way (view.schema = action_schema_class(), no explicit .view).

test_internal_build_end_to_end covers it, since InternalSchema.get_response_serializers reads self.view, and make build-internal-api-docs builds and validates the full spec. 2495526 adds a comment saying so.

…nd-written one

The published build appends APPEND_PATHS, then _fix_issue_paths moves the
generated issue operations onto the hand-written paths, so the published
reference describes GET issues/{issue_id}/tags/{key}/values/ with its
generated operation. The internal build spells that path issues/ up front,
and the hand-written operation won instead, with another operationId,
parameters and response schema. A published method's generated operation
now wins here too; hand-written ones still stand in for the rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/sentry/apidocs/hooks.py Outdated
Comment on lines +340 to +341
def _in_reference(operation: Mapping[str, Any]) -> bool:
return True

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the point of this function if it always returns true?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None: it was only the published build's default for in_reference, and _public_spelling (identity, the default for public_path) was the same. 1aa8b71 removes both and defaults those parameters to None, so only the internal build passes them. The published build behaves the same as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@billyvg

billyvg commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

Internal spec compared with master's published spec

Built from this branch at cf75375 (master merged in) and from master at cce6621. With master merged in, this branch's own published build is byte-identical to master's.

Every one of the 273 operations in master's published spec is in the internal spec. Comparing each pair with $refs resolved, every difference falls into one of these categories; 67 operations differ only by the stamped extensions:

Difference Public operations
x-sentry-publish-status, x-sentry-permissions and (for alternation routes) x-sentry-path-aliases stamped 271 (the other 2 are getsentry's hand-written spike-protection docs, which have no view in sentry)
TypedDict objects closed with additionalProperties: false 198
Fields declared in omit_from_public_schema described (44 fields on 19 components) 24
The null member of an optional union admits only null (enum: [null]) 16
x-sentry-routing.silo resolved to cell where master has unknown 5
Choices declared in omit_from_public_schema listed 3
A query parameter declared in omit_from_public_schema described 1

The internal spec also describes 680 operations master's published spec doesn't (540 private, 140 experimental).

Examples

Each diff is master's published spec (-) against this branch's internal spec (+), with the components each operation references directly. Parameters are sorted by location and name first, since the build lists required parameters first.

Update an organization: PUT /organizations/{organization_id_or_slug}/

The ten fields the published spec omits from the response (omit_from_public_schema) are described, and TypedDicts are closed.

operation PUT /api/0/organizations/{organization_id_or_slug}/ (4 changed lines)
--- master: paths['/api/0/organizations/{organization_id_or_slug}/'].put
+++ internal: paths['/api/0/organizations/{organization_id_or_slug}/'].put
@@ -460,4 +460,8 @@
     }
   },
+  "x-sentry-publish-status": "public",
+  "x-sentry-permissions": [
+    "OrganizationPermission"
+  ],
   "x-sentry-routing": {
     "silo": "cell",
component OrganizationWithProjectsAndTeams (77 changed lines)
--- master: components.schemas.OrganizationWithProjectsAndTeams
+++ internal: components.schemas.OrganizationWithProjectsAndTeams
@@ -44,4 +44,5 @@
           "data": {}
         },
+        "additionalProperties": false,
         "required": [
           "completionSeen",
@@ -69,4 +70,5 @@
         }
       },
+      "additionalProperties": false,
       "required": [
         "id",
@@ -101,5 +103,6 @@
           "nullable": true
         }
-      }
+      },
+      "additionalProperties": false
     },
     "links": {
@@ -113,4 +116,5 @@
         }
       },
+      "additionalProperties": false,
       "required": [
         "organizationUrl",
@@ -149,4 +153,19 @@
       "format": "double"
     },
+    "ingestThroughTrustedRelaysOnly": {
+      "type": "boolean"
+    },
+    "enabledConsolePlatforms": {
+      "type": "array",
+      "items": {
+        "type": "string"
+      }
+    },
+    "consoleSdkInviteQuota": {
+      "type": "integer"
+    },
+    "dashboardsAsyncQueueParallelLimit": {
+      "type": "integer"
+    },
     "experiments": {
       "type": "object",
@@ -160,4 +179,8 @@
     "defaultRole": {
       "type": "string"
+    },
+    "availableRoles": {
+      "type": "array",
+      "items": {}
     },
     "orgRoleList": {
@@ -203,4 +226,5 @@
           }
         },
+        "additionalProperties": false,
         "required": [
           "allowed",
@@ -255,4 +279,5 @@
           }
         },
+        "additionalProperties": false,
         "required": [
           "allowed",
@@ -349,5 +374,6 @@
             "format": "date-time"
           }
-        }
+        },
+        "additionalProperties": false
       }
     },
@@ -361,4 +387,7 @@
       "type": "boolean"
     },
+    "genAIConsent": {
+      "type": "boolean"
+    },
     "isDynamicallySampled": {
       "type": "boolean"
@@ -371,4 +400,10 @@
     },
     "requiresSso": {
+      "type": "boolean"
+    },
+    "rollbackEnabled": {
+      "type": "boolean"
+    },
+    "streamlineOnly": {
       "type": "boolean"
     },
@@ -402,4 +437,13 @@
       "items": {
         "type": "string"
+      }
+    },
+    "hasGranularReplayPermissions": {
+      "type": "boolean"
+    },
+    "replayAccessMembers": {
+      "type": "array",
+      "items": {
+        "type": "integer"
       }
     },
@@ -465,5 +509,6 @@
                 "nullable": true
               }
-            }
+            },
+            "additionalProperties": false
           },
           "externalTeams": {
@@ -494,4 +539,5 @@
                 }
               },
+              "additionalProperties": false,
               "required": [
                 "externalName",
@@ -547,4 +593,5 @@
                     "data": {}
                   },
+                  "additionalProperties": false,
                   "required": [
                     "completionSeen",
@@ -572,4 +619,5 @@
                   }
                 },
+                "additionalProperties": false,
                 "required": [
                   "id",
@@ -604,5 +652,6 @@
                     "nullable": true
                   }
-                }
+                },
+                "additionalProperties": false
               },
               "links": {
@@ -616,4 +665,5 @@
                   }
                 },
+                "additionalProperties": false,
                 "required": [
                   "organizationUrl",
@@ -634,4 +684,5 @@
               }
             },
+            "additionalProperties": false,
             "required": [
               "allowMemberInvite",
@@ -704,4 +755,5 @@
                     }
                   },
+                  "additionalProperties": false,
                   "required": [
                     "currentCrashFreeRate",
@@ -836,5 +888,6 @@
                       "nullable": true
                     }
-                  }
+                  },
+                  "additionalProperties": false
                 },
                 "color": {
@@ -845,4 +898,5 @@
                 }
               },
+              "additionalProperties": false,
               "required": [
                 "access",
@@ -887,4 +941,5 @@
           }
         },
+        "additionalProperties": false,
         "required": [
           "access",
@@ -971,4 +1026,5 @@
               }
             },
+            "additionalProperties": false,
             "required": [
               "currentCrashFreeRate",
@@ -1096,4 +1152,5 @@
               }
             },
+            "additionalProperties": false,
             "required": [
               "id",
@@ -1118,4 +1175,5 @@
                 }
               },
+              "additionalProperties": false,
               "required": [
                 "id",
@@ -1147,4 +1205,5 @@
               }
             },
+            "additionalProperties": false,
             "required": [
               "version"
@@ -1153,4 +1212,5 @@
           }
         },
+        "additionalProperties": false,
         "required": [
           "access",
@@ -1196,4 +1256,5 @@
     }
   },
+  "additionalProperties": false,
   "required": [
     "aggregatedDataConsent",
@@ -1207,4 +1268,5 @@
     "autoEnableCodeReview",
     "autoOpenPrs",
+    "availableRoles",
     "avatar",
     "dataScrubber",
@@ -1223,5 +1285,7 @@
     "eventsMemberAdmin",
     "experiments",
+    "genAIConsent",
     "hasAuthProvider",
+    "hasGranularReplayPermissions",
     "hideAiFeatures",
     "id",
@@ -1239,6 +1303,8 @@
     "relayDsnEndpoint",
     "relayPiiConfig",
+    "replayAccessMembers",
     "require2FA",
     "requiresSso",
+    "rollbackEnabled",
     "safeFields",
     "scrapeJavaScript",
@@ -1248,4 +1314,5 @@
     "status",
     "storeCrashReports",
+    "streamlineOnly",
     "teamRoleList",
     "teams",

Retrieve an issue: GET /organizations/{organization_id_or_slug}/issues/{issue_id}/

Operation-level stamps: publish status, permission classes, and every path the route answers on. Its response component differs only by closed objects, so it is left out.

operation GET /api/0/organizations/{organization_id_or_slug}/issues/{issue_id}/ (8 changed lines)
--- master: paths['/api/0/organizations/{organization_id_or_slug}/issues/{issue_id}/'].get
+++ internal: paths['/api/0/organizations/{organization_id_or_slug}/issues/{issue_id}/'].get
@@ -210,4 +210,12 @@
     }
   },
+  "x-sentry-publish-status": "public",
+  "x-sentry-permissions": [
+    "GroupDetailsPermission"
+  ],
+  "x-sentry-path-aliases": [
+    "/api/0/organizations/{organization_id_or_slug}/issues/{issue_id}/",
+    "/api/0/organizations/{organization_id_or_slug}/groups/{issue_id}/"
+  ],
   "x-sentry-routing": {
     "silo": "cell",

Update a team member's role: PUT /organizations/{organization_id_or_slug}/members/{member_id}/teams/{team_id_or_slug}/

A request field the published spec omits (isActive) is described, so the body becomes required.

operation PUT /api/0/organizations/{organization_id_or_slug}/members/{member_id}/teams/{team_id_or_slug}/ (7 changed lines)
--- master: paths['/api/0/organizations/{organization_id_or_slug}/members/{member_id}/teams/{team_id_or_slug}/'].put
+++ internal: paths['/api/0/organizations/{organization_id_or_slug}/members/{member_id}/teams/{team_id_or_slug}/'].put
@@ -42,5 +42,6 @@
         }
       }
-    }
+    },
+    "required": true
   },
   "security": [
@@ -85,4 +86,8 @@
     }
   },
+  "x-sentry-publish-status": "public",
+  "x-sentry-permissions": [
+    "OrganizationTeamMemberPermission"
+  ],
   "x-sentry-routing": {
     "silo": "cell",
component OrganizationMemberTeam (8 changed lines)
--- master: components.schemas.OrganizationMemberTeam
+++ internal: components.schemas.OrganizationMemberTeam
@@ -2,4 +2,7 @@
   "type": "object",
   "properties": {
+    "isActive": {
+      "type": "boolean"
+    },
     "teamRole": {
       "enum": [
@@ -11,4 +14,7 @@
       "description": "The team-level role to switch to. Valid roles include:\n\n* `contributor` - Contributors can view and act on events, as well as view most other data within the team's projects.\n* `admin` - Admin privileges on the team. They can create and remove projects, and can manage the team's memberships."
     }
-  }
+  },
+  "required": [
+    "isActive"
+  ]
 }
component OrganizationMemberTeamDetails (1 changed lines)
--- master: components.schemas.OrganizationMemberTeamDetails
+++ internal: components.schemas.OrganizationMemberTeamDetails
@@ -13,4 +13,5 @@
     }
   },
+  "additionalProperties": false,
   "required": [
     "isActive",

Retrieve a count of replays: GET /organizations/{organization_id_or_slug}/replay-count/

Choices the published spec omits (deprecated data_source values) are listed, with the real default.

operation GET /api/0/organizations/{organization_id_or_slug}/replay-count/ (11 changed lines)
--- master: paths['/api/0/organizations/{organization_id_or_slug}/replay-count/'].get
+++ internal: paths['/api/0/organizations/{organization_id_or_slug}/replay-count/'].get
@@ -18,13 +18,16 @@
       "schema": {
         "enum": [
+          "discover",
           "errors",
+          "events",
+          "transactions",
           "search_issues",
           "spans"
         ],
         "type": "string",
+        "default": "discover",
         "minLength": 1
       },
-      "description": "The data source to query replays from.\n\n* `errors`\n* `search_issues`\n* `spans`",
-      "required": true
+      "description": "The data source to query replays from.\n\n* `discover`\n* `errors`\n* `events`\n* `transactions`\n* `search_issues`\n* `spans`"
     },
     {
@@ -156,4 +159,8 @@
     }
   },
+  "x-sentry-publish-status": "public",
+  "x-sentry-permissions": [
+    "OrganizationPermission"
+  ],
   "x-sentry-routing": {
     "silo": "cell",

Retrieve trace item stats: GET /organizations/{organization_id_or_slug}/trace-items/stats/

A query parameter the published spec omits (limit) is described.

operation GET /api/0/organizations/{organization_id_or_slug}/trace-items/stats/ (11 changed lines)
--- master: paths['/api/0/organizations/{organization_id_or_slug}/trace-items/stats/'].get
+++ internal: paths['/api/0/organizations/{organization_id_or_slug}/trace-items/stats/'].get
@@ -55,4 +55,11 @@
       },
       "description": "The trace item dataset to compute statistics for. Defaults to `spans`.\n\n* `spans`\n* `occurrences`"
+    },
+    {
+      "in": "query",
+      "name": "limit",
+      "schema": {
+        "type": "integer"
+      }
     },
     {
@@ -204,4 +211,8 @@
     }
   },
+  "x-sentry-publish-status": "public",
+  "x-sentry-permissions": [
+    "OrganizationPermission"
+  ],
   "x-sentry-routing": {
     "silo": "cell",
component TraceItemStatsResponse (4 changed lines)
--- master: components.schemas.TraceItemStatsResponse
+++ internal: components.schemas.TraceItemStatsResponse
@@ -25,4 +25,5 @@
                       }
                     },
+                    "additionalProperties": false,
                     "required": [
                       "label",
@@ -33,4 +34,5 @@
               }
             },
+            "additionalProperties": false,
             "required": [
               "data"
@@ -38,4 +40,5 @@
           }
         },
+        "additionalProperties": false,
         "required": [
           "attributeDistributions"
@@ -44,4 +47,5 @@
     }
   },
+  "additionalProperties": false,
   "required": [
     "data"

Retrieve an organization release's file: GET /organizations/{organization_id_or_slug}/releases/{version}/files/{file_id}/

The null member of an optional union admits only null.

operation GET /api/0/organizations/{organization_id_or_slug}/releases/{version}/files/{file_id}/ (4 changed lines)
--- master: paths['/api/0/organizations/{organization_id_or_slug}/releases/{version}/files/{file_id}/'].get
+++ internal: paths['/api/0/organizations/{organization_id_or_slug}/releases/{version}/files/{file_id}/'].get
@@ -75,4 +75,8 @@
     }
   },
+  "x-sentry-publish-status": "public",
+  "x-sentry-permissions": [
+    "OrganizationReleasePermission"
+  ],
   "x-sentry-routing": {
     "silo": "cell",
component ReleaseFileResponse (6 changed lines)
--- master: components.schemas.ReleaseFileResponse
+++ internal: components.schemas.ReleaseFileResponse
@@ -13,5 +13,8 @@
         {
           "type": "object",
-          "nullable": true
+          "nullable": true,
+          "enum": [
+            null
+          ]
         }
       ]
@@ -41,4 +44,5 @@
     }
   },
+  "additionalProperties": false,
   "required": [
     "dateCreated",

List a project's user feedback: GET /projects/{organization_id_or_slug}/{project_id_or_slug}/user-feedback/

A hand-written legacy (APPEND_PATHS) operation. Master looks its silo up under the {var} spelling of (?:user-feedback|user-reports) and finds none; the internal build finds cell. Its publish status is the view's declared PRIVATE, although the hand-written docs put it in the public reference.

operation GET /api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/user-feedback/ (10 changed lines)
--- master: paths['/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/user-feedback/'].get
+++ internal: paths['/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/user-feedback/'].get
@@ -130,6 +130,14 @@
     }
   ],
+  "x-sentry-publish-status": "private",
+  "x-sentry-permissions": [
+    "ProjectPermission"
+  ],
+  "x-sentry-path-aliases": [
+    "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/user-feedback/",
+    "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/user-reports/"
+  ],
   "x-sentry-routing": {
-    "silo": "unknown",
+    "silo": "cell",
     "organizationParameter": "organization_id_or_slug"
   }

Generated with Claude Code

`_in_reference` always returned True and `_public_spelling` returned its
argument; each existed only as the published build's default. Default
`in_reference` and `public_path` to None instead, which leaves the
published build's behavior unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active (outdated) deployment
Preview — cf753753 Deployed Nov 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant