stack/apps/backend/src/lib/openapi.tsx
Mantra c808e23b7d
Data-grid overhaul + session-replays / team-payments dashboard surfaces (#1424)
## Summary

Refactors the dashboard data-grid into a smaller, URL-state-aware
primitive and lands several new dashboard surfaces around it: per-user
session replays, team-level analytics and payments, and pagination for
permission definitions. Also moves session replays out from under
`/analytics` to a top-level surface and adds a
`project_user.last_active_at` index that the new weekly-active metrics
depend on.

**Base:** `dev` → **Head:** `refactor/data-grid-and-dashboard-surfaces`
**Scope:** 91 files, +5,644 / −1,858. Assets in [this
gist](https://gist.github.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7).

## Screenshots

Captured from a local dev server (dashboard at `:8101`, dummy project
seeded with 26 users). Standard viewport **1920×1200**, widescreen
**2560×1440**.

### Users list — data-grid overhaul in context

| Light | Dark |
| --- | --- |
|
![users-list-light](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/users-list-light.png)
|
![users-list-dark](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/users-list-dark.png)
|

Widescreen:

| Light | Dark |
| --- | --- |
|
![users-list-light-wide](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/users-list-light-wide.png)
|
![users-list-dark-wide](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/users-list-dark-wide.png)
|

### User detail — new session-replays card + weekly metrics

| Light | Dark |
| --- | --- |
|
![user-detail-light](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/user-detail-light.png)
|
![user-detail-dark](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/user-detail-dark.png)
|

Widescreen:

| Light | Dark |
| --- | --- |
|
![user-detail-light-wide](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/user-detail-light-wide.png)
|
![user-detail-dark-wide](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/user-detail-dark-wide.png)
|

### Session replays — moved out of `/analytics`

| Light | Dark |
| --- | --- |
|
![session-replays-light](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/session-replays-light.png)
|
![session-replays-dark](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/session-replays-dark.png)
|

Widescreen:

| Light | Dark |
| --- | --- |
|
![session-replays-light-wide](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/session-replays-light-wide.png)
|
![session-replays-dark-wide](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/session-replays-dark-wide.png)
|

### Project permissions — new pagination

| Light | Dark |
| --- | --- |
|
![project-permissions-light](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/project-permissions-light.png)
|
![project-permissions-dark](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/project-permissions-dark.png)
|

Widescreen:

| Light | Dark |
| --- | --- |
|
![project-permissions-light-wide](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/project-permissions-light-wide.png)
|
![project-permissions-dark-wide](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/project-permissions-dark-wide.png)
|

### Other migrated surfaces

| Page | Light | Dark |
| --- | --- | --- |
| Project picker |
![projects-light](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/projects-light.png)
|
![projects-dark](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/projects-dark.png)
|
| Overview / setup |
![overview-light](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/overview-light.png)
|
![overview-dark](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/overview-dark.png)
|
| Teams list |
![teams-list-light](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/teams-list-light.png)
|
![teams-list-dark](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/teams-list-dark.png)
|
| Team permissions |
![team-permissions-light](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/team-permissions-light.png)
|
![team-permissions-dark](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/team-permissions-dark.png)
|
| API keys |
![api-keys-light](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/api-keys-light.png)
|
![api-keys-dark](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/api-keys-dark.png)
|

### Scroll behaviour — new data-grid on the users list

| Light | Dark |
| --- | --- |
|
![users-list-scroll-light](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/users-list-scroll-light.gif)
|
![users-list-scroll-dark](https://gist.githubusercontent.com/mantrakp04/01bf8db4c71ec7a119b73d6ee60717a7/raw/users-list-scroll-dark.gif)
|

## What's new

- **`packages/dashboard-ui-components/src/components/data-grid`** —
rewritten. Trimmed `data-grid.tsx` from ~1.7k LOC, split sizing logic
into `data-grid-sizing.ts`, added `use-url-state.ts` for URL-synced
state, and added `data-grid.test.tsx`.
- **Session replays** moved from `…/analytics/replays` to
`…/session-replays` (top-level surface). New `user-session-replays.tsx`
card on the user detail page; new internal `route.tsx` to feed it.
- **Teams** detail page gains `team-analytics.tsx` and
`team-payments.tsx`.
- **Permissions** — new shared `permission-definitions-pagination.ts`
consumed by both project and team permission CRUD routes.
- **Backend** — Prisma migration `add_project_user_last_active_at_idx` +
a `lastActiveAt` index that backs the new weekly-active metrics.
- **Polish** — `editable-input`, `inline-save-discard`, `settings.tsx`,
walkthrough steps, and several data-table components touched in line
with the data-grid rewrite.

## Notes for reviewers

- The data-grid rewrite changes the *shape* of state (now URL-synced),
not just internals. Consumers in
`apps/dashboard/src/components/data-table/*` were updated to match —
please scan those for any missed knobs.
- The `analytics/replays` → `session-replays` rename is git-tracked as
renames; diffs should be small in those files.
- New SDK surface in
`packages/template/src/lib/stack-app/session-replays/index.ts` and
additions in `admin-app-impl.ts` / `server-app-impl.ts` mean OpenAPI
specs (`docs-mintlify/openapi/{admin,client}.json`) regenerate; the diff
is mostly mechanical.

## Test plan

- [ ] `pnpm typecheck` clean
- [ ] `pnpm lint` clean
- [ ] Data-grid unit tests pass (`packages/dashboard-ui-components`)
- [ ] Manual: users list — column resize, sort, filter, paginate; URL
state reflects each change and survives reload
- [ ] Manual: user detail — session-replays card lists replays;
weekly-metrics card renders without `lastActiveAt` index migration
applied (i.e. on a fresh DB) and after applying it
- [ ] Manual: project + team permissions — pagination cursor advances
and stays consistent under search
- [ ] Manual: session-replays top-level page loads; old
`/analytics/replays/...` URL path is no longer expected to be linked
anywhere


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Session Replays app (embedded mode, search, sorting, share links)
  * Tabbed Team pages with Team Analytics and Team Payments dashboards
* Server-backed cursor pagination, debounced search, and infinite-scroll
for teams/users/permissions

* **UX**
* Permission and member tables refresh after edits; permission creation
triggers table refresh
  * Users list supports sorting by last-active

* **Performance**
  * Index added to speed ProjectUser last-active queries

* **Documentation**
  * API/SDK docs updated for pagination and new query params
* Contributor guidance: explicit git-safety rules added (no destructive
git ops without consent)

* **Tests**
  * Added e2e tests for pagination and filtering on list endpoints
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-15 14:16:47 -07:00

487 lines
19 KiB
TypeScript

import { SmartRouteHandler } from '@/route-handlers/smart-route-handler';
import { CrudlOperation, EndpointDocumentation } from '@stackframe/stack-shared/dist/crud';
import { WebhookEvent } from '@stackframe/stack-shared/dist/interface/webhooks';
import { yupNumber, yupObject, yupString } from '@stackframe/stack-shared/dist/schema-fields';
import { StackAssertionError, throwErr } from '@stackframe/stack-shared/dist/utils/errors';
import { HttpMethod } from '@stackframe/stack-shared/dist/utils/http';
import { typedEntries, typedFromEntries } from '@stackframe/stack-shared/dist/utils/objects';
import { deindent, stringCompare } from '@stackframe/stack-shared/dist/utils/strings';
import * as yup from 'yup';
export function parseOpenAPI(options: {
endpoints: Map<string, Map<HttpMethod, SmartRouteHandler>>,
audience: 'client' | 'server' | 'admin',
}) {
return {
openapi: '3.1.0',
info: {
title: 'Stack REST API',
version: '1.0.0',
},
servers: [{
url: 'https://api.stack-auth.com/api/v1',
description: 'Stack REST API',
}],
paths: Object.fromEntries(
[...options.endpoints]
.map(([path, handlersByMethod]) => (
[path, Object.fromEntries(
[...handlersByMethod]
.map(([method, handler]) => (
[method.toLowerCase(), parseRouteHandler({ handler, method, path, audience: options.audience })]
))
.filter(([_, handler]) => handler !== undefined)
)]
))
.filter(([_, handlersByMethod]) => Object.keys(handlersByMethod).length > 0)
.sort(([pathA, handlersByMethodA], [pathB, handlersByMethodB]) => {
const tagComparison = stringCompare((Object.values(handlersByMethodA)[0] as any).tags[0] ?? "", (Object.values(handlersByMethodB)[0] as any).tags[0] ?? "");
if (tagComparison !== 0) {
return tagComparison;
}
return stringCompare(pathA, pathB);
}),
),
};
}
export function parseWebhookOpenAPI(options: {
webhooks: readonly WebhookEvent<any>[],
}) {
return {
openapi: '3.1.0',
info: {
title: 'Stack Webhooks API',
version: '1.0.0',
},
webhooks: options.webhooks.reduce((acc, webhook) => {
return {
...acc,
[webhook.type]: {
post: {
...parseOverload({
metadata: webhook.metadata,
method: 'POST',
path: `/webhooks/${webhook.type}`,
requestBodyDesc: undefinedIfMixed(yupObject({
type: yupString().defined().meta({ openapiField: { description: webhook.type, exampleValue: webhook.type } }),
data: webhook.schema.defined(),
}).describe()) || yupObject().describe(),
responseVariants: [{
responseTypeDesc: yupString().oneOf(['json']).describe(),
statusCodeDesc: yupNumber().oneOf([200]).describe(),
}],
}),
operationId: webhook.type,
summary: webhook.type,
}
},
};
}, {}),
};
}
function undefinedIfMixed(value: yup.SchemaFieldDescription | undefined): yup.SchemaFieldDescription | undefined {
if (!value) return undefined;
return value.type === 'mixed' ? undefined : value;
}
function isSchemaObjectDescription(value: yup.SchemaFieldDescription): value is yup.SchemaObjectDescription & { type: 'object' } {
return value.type === 'object';
}
function isSchemaMixedDescription(value: yup.SchemaFieldDescription): value is yup.SchemaDescription & { type: 'mixed' } {
return value.type === 'mixed';
}
function isSchemaArrayDescription(value: yup.SchemaFieldDescription): value is yup.SchemaInnerTypeDescription & { type: 'array', innerType: yup.SchemaInnerTypeDescription } {
return value.type === 'array';
}
function isSchemaTupleDescription(value: yup.SchemaFieldDescription): value is yup.SchemaInnerTypeDescription & { type: 'tuple', innerType: yup.SchemaInnerTypeDescription[] } {
return value.type === 'tuple';
}
function isSchemaStringDescription(value: yup.SchemaFieldDescription): value is yup.SchemaDescription & { type: 'string' } {
return value.type === 'string';
}
function isSchemaNumberDescription(value: yup.SchemaFieldDescription): value is yup.SchemaDescription & { type: 'number' } {
return value.type === 'number';
}
function isMaybeRequestSchemaForAudience(requestDescribe: yup.SchemaObjectDescription, audience: 'client' | 'server' | 'admin') {
const schemaAuth = requestDescribe.fields.auth;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- yup types are wrong and claim that fields always exist
if (!schemaAuth) return true;
if (isSchemaMixedDescription(schemaAuth)) return true;
if (!isSchemaObjectDescription(schemaAuth)) return true;
const schemaAudience = schemaAuth.fields.type;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- same as above
if (!schemaAudience) return true;
if ("oneOf" in schemaAudience && schemaAudience.oneOf.length > 0) {
return schemaAudience.oneOf.includes(audience);
}
return true;
}
function parseRouteHandler(options: {
handler: SmartRouteHandler,
path: string,
method: HttpMethod,
audience: 'client' | 'server' | 'admin',
}) {
let result: ReturnType<typeof parseOverload> | undefined;
for (const overload of options.handler.overloads.values()) {
if (overload.metadata?.hidden) continue;
const requestDescribe = overload.request.describe();
if (!isSchemaObjectDescription(requestDescribe)) throw new Error('Request schema must be a yup.ObjectSchema');
// estimate whether this overload is the right one based on a heuristic
if (!isMaybeRequestSchemaForAudience(requestDescribe, options.audience)) {
// This overload is definitely not for the audience
continue;
}
if (result) {
throw new StackAssertionError(deindent`
OpenAPI generator matched multiple overloads for audience ${options.audience} on endpoint ${options.method} ${options.path}.
This does not necessarily mean there is a bug in the endpoint; the OpenAPI generator uses a heuristic to pick the allowed overloads, and may pick too many. Currently, this heuristic checks whether the request.auth.type property in the schema is a yup.string.oneOf(...) and matches it to the expected audience of the schema. If there are multiple overloads matching a single audience, for example because none of the overloads specify request.auth.type, the OpenAPI generator will not know which overload to generate specs for, and hence fails.
Either specify request.auth.type on the schema of the specified endpoint or update the OpenAPI generator to support your use case.
`);
}
const responseSchemaInfo = overload.response.meta()?.stackSchemaInfo;
const responseSchemas: yup.AnySchema[] = responseSchemaInfo?.type === "union" ? responseSchemaInfo.items : [overload.response];
result = parseOverload({
metadata: overload.metadata,
method: options.method,
path: options.path,
pathDesc: undefinedIfMixed(requestDescribe.fields.params),
parameterDesc: undefinedIfMixed(requestDescribe.fields.query),
headerDesc: undefinedIfMixed(requestDescribe.fields.headers),
requestBodyDesc: undefinedIfMixed(requestDescribe.fields.body),
responseVariants: responseSchemas.map((schema) => {
const responseDescribe = schema.describe();
if (!isSchemaObjectDescription(responseDescribe)) {
throw new Error('Response schema must be a yup.ObjectSchema');
}
return {
responseDesc: undefinedIfMixed(responseDescribe.fields.body),
responseTypeDesc: undefinedIfMixed(responseDescribe.fields.bodyType) ?? throwErr('Response type must be defined and not mixed', { options, bodyTypeField: responseDescribe.fields.bodyType }),
statusCodeDesc: undefinedIfMixed(responseDescribe.fields.statusCode) ?? throwErr('Status code must be defined and not mixed', { options, statusCodeField: responseDescribe.fields.statusCode }),
};
}),
});
}
return result;
}
function getFieldSchema(field: yup.SchemaFieldDescription, crudOperation?: Capitalize<CrudlOperation>): { type: string, items?: any, properties?: any, required?: any, default?: any } | undefined {
const meta = "meta" in field ? field.meta : {};
if (meta?.openapiField?.hidden) {
return undefined;
}
if (meta?.openapiField?.onlyShowInOperations && !meta.openapiField.onlyShowInOperations.includes(crudOperation as any)) {
return undefined;
}
const openapiFieldExtra = {
example: meta?.openapiField?.exampleValue,
description: meta?.openapiField?.description,
default: (field as any).default,
};
switch (field.type) {
case 'string': {
const oneOf = (field as any).oneOf as unknown[] | undefined;
return {
type: 'string',
...oneOf && oneOf.length > 0 ? { enum: oneOf } : {},
...openapiFieldExtra,
};
}
case 'number': {
const tests = (field as any).tests as Array<{ name?: string }> | undefined;
const isInteger = tests?.some(t => t.name === 'integer') ?? false;
return { type: isInteger ? 'integer' : 'number', ...openapiFieldExtra };
}
case 'boolean': {
return { type: field.type, ...openapiFieldExtra };
}
case 'mixed': {
return { type: 'object', ...openapiFieldExtra };
}
case 'object': {
return {
type: 'object',
properties: typedFromEntries(typedEntries((field as any).fields)
.map(([key, field]) => [key, getFieldSchema(field, crudOperation)])),
required: typedEntries((field as any).fields)
.filter(([_, field]) => !(field as any).optional && !(field as any).nullable && getFieldSchema(field as any, crudOperation))
.map(([key]) => key),
...openapiFieldExtra
};
}
case 'array': {
return { type: 'array', items: getFieldSchema((field as any).innerType, crudOperation), ...openapiFieldExtra };
}
case 'tuple': {
// For OpenAPI, treat tuples as arrays since OpenAPI doesn't have native tuple support
// This is commonly used for headers which are arrays of strings
const tupleField = field as any;
if (tupleField.innerType && tupleField.innerType.length > 0) {
// Use the first element's schema as the array item type
return { type: 'array', items: getFieldSchema(tupleField.innerType[0], crudOperation), ...openapiFieldExtra };
}
// Fallback to string array if no inner type
return { type: 'array', items: { type: 'string' }, ...openapiFieldExtra };
}
default: {
throw new Error(`Unsupported field type: ${field.type}`);
}
}
}
function toParameters(description: yup.SchemaFieldDescription, crudOperation?: Capitalize<CrudlOperation>, path?: string) {
const pathParams: string[] = path ? path.match(/{[^}]+}/g) || [] : [];
if (!isSchemaObjectDescription(description)) {
throw new StackAssertionError('Parameters field must be an object schema', { actual: description });
}
return Object.entries(description.fields).map(([key, field]) => {
if (path && !pathParams.includes(`{${key}}`)) {
return { schema: undefined };
}
const meta = "meta" in field ? field.meta : {};
const schema = getFieldSchema(field, crudOperation);
return {
name: key,
in: path ? 'path' : 'query',
schema,
description: meta?.openapiField?.description,
required: !(field as any).optional && !!schema,
};
}).filter((x) => x.schema !== undefined);
}
function toHeaderParameters(description: yup.SchemaFieldDescription, crudOperation?: Capitalize<CrudlOperation>) {
if (!isSchemaObjectDescription(description)) {
throw new StackAssertionError('Parameters field must be an object schema', { actual: description });
}
return Object.entries(description.fields).map(([key, tupleField]) => {
if (!isSchemaTupleDescription(tupleField)) {
throw new StackAssertionError('Header field must be a tuple schema', { actual: tupleField, key });
}
if (tupleField.innerType.length !== 1) {
throw new StackAssertionError('Header fields of length !== 1 not currently supported', { actual: tupleField, key });
}
const field = tupleField.innerType[0];
const meta = "meta" in field ? field.meta : {};
const schema = getFieldSchema(field, crudOperation);
return {
name: key,
in: 'header',
schema,
description: meta?.openapiField?.description,
example: meta?.openapiField?.exampleValue,
required: !(field as any).optional && !(field as any).nullable && !!schema,
};
}).filter((x) => x.schema !== undefined);
}
function toSchema(description: yup.SchemaFieldDescription, crudOperation?: Capitalize<CrudlOperation>): any {
if (isSchemaObjectDescription(description)) {
return {
type: 'object',
properties: Object.fromEntries(Object.entries(description.fields).map(([key, field]) => {
return [key, getFieldSchema(field, crudOperation)];
}, {}))
};
} else if (isSchemaArrayDescription(description)) {
return {
type: 'array',
items: toSchema(description.innerType, crudOperation),
};
} else {
throw new StackAssertionError(`Unsupported schema type in toSchema: ${description.type}`, { actual: description });
}
}
function toRequired(description: yup.SchemaFieldDescription, crudOperation?: Capitalize<CrudlOperation>) {
let res: string[] = [];
if (isSchemaObjectDescription(description)) {
res = Object.entries(description.fields)
.filter(([_, field]) => !(field as any).optional && !(field as any).nullable && getFieldSchema(field, crudOperation))
.map(([key]) => key);
} else if (isSchemaArrayDescription(description)) {
res = [];
} else {
throw new StackAssertionError(`Unsupported schema type in toRequired: ${description.type}`, { actual: description });
}
if (res.length === 0) return undefined;
return res;
}
function toExamples(description: yup.SchemaFieldDescription, crudOperation?: Capitalize<CrudlOperation>) {
if (!isSchemaObjectDescription(description)) {
throw new StackAssertionError('Examples field must be an object schema', { actual: description });
}
return Object.entries(description.fields).reduce((acc, [key, field]) => {
const schema = getFieldSchema(field, crudOperation);
if (!schema) return acc;
const example = "meta" in field ? field.meta?.openapiField?.exampleValue : undefined;
return { ...acc, [key]: example };
}, {});
}
export function parseOverload(options: {
metadata: EndpointDocumentation | undefined,
method: string,
path: string,
pathDesc?: yup.SchemaFieldDescription,
parameterDesc?: yup.SchemaFieldDescription,
headerDesc?: yup.SchemaFieldDescription,
requestBodyDesc?: yup.SchemaFieldDescription,
responseVariants: Array<{
responseDesc?: yup.SchemaFieldDescription,
responseTypeDesc: yup.SchemaFieldDescription,
statusCodeDesc: yup.SchemaFieldDescription,
}>,
}) {
const endpointDocumentation = options.metadata ?? {
summary: `${options.method} ${options.path}`,
description: `No documentation available for this endpoint.`,
};
if (endpointDocumentation.hidden) {
return undefined;
}
const pathParameters = options.pathDesc ? toParameters(options.pathDesc, endpointDocumentation.crudOperation, options.path) : [];
const queryParameters = options.parameterDesc ? toParameters(options.parameterDesc, endpointDocumentation.crudOperation) : [];
const headerParameters = options.headerDesc ? toHeaderParameters(options.headerDesc, endpointDocumentation.crudOperation) : [];
let requestBody;
if (options.requestBodyDesc) {
requestBody = {
required: true,
content: {
'application/json': {
schema: {
...toSchema(options.requestBodyDesc, endpointDocumentation.crudOperation),
required: toRequired(options.requestBodyDesc, endpointDocumentation.crudOperation),
example: toExamples(options.requestBodyDesc, endpointDocumentation.crudOperation),
},
},
},
};
}
const allResponses: Record<number, unknown> = {};
for (const { responseDesc, responseTypeDesc, statusCodeDesc } of options.responseVariants) {
if (!isSchemaStringDescription(responseTypeDesc)) {
throw new StackAssertionError(`Expected response type to be a string`, { actual: responseTypeDesc, options });
}
if (responseTypeDesc.oneOf.length !== 1) {
throw new StackAssertionError(`Expected response type to have exactly one value`, { actual: responseTypeDesc, options });
}
const bodyType = responseTypeDesc.oneOf[0];
if (!isSchemaNumberDescription(statusCodeDesc)) {
throw new StackAssertionError('Expected status code to be a number', { actual: statusCodeDesc, options });
}
// Get all status codes or use 200 as default if none specified
const statusCodes: number[] = statusCodeDesc.oneOf.length > 0
? statusCodeDesc.oneOf as number[]
: [200]; // TODO HACK hardcoded, used in case all status codes may be returned, should be configurable per endpoint
for (const status of statusCodes) {
switch (bodyType) {
case 'json': {
allResponses[status] = {
description: 'Successful response',
content: {
'application/json': {
schema: {
...responseDesc ? toSchema(responseDesc, endpointDocumentation.crudOperation) : {},
required: responseDesc ? toRequired(responseDesc, endpointDocumentation.crudOperation) : undefined,
},
},
},
};
break;
}
case 'text': {
if (!responseDesc || !isSchemaStringDescription(responseDesc)) {
throw new StackAssertionError('Expected response body of bodyType=="text" to be a string schema', { actual: responseDesc });
}
allResponses[status] = {
description: 'Successful response',
content: {
'text/plain': {
schema: {
type: 'string',
example: isSchemaStringDescription(responseDesc) ? responseDesc.meta?.openapiField?.exampleValue : undefined,
},
},
},
};
break;
}
case 'success': {
allResponses[status] = {
description: 'Successful response',
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: {
type: "boolean",
description: "Always equal to true.",
example: true,
},
},
required: ["success"],
},
},
},
};
break;
}
case 'empty': {
allResponses[status] = {
description: 'No content',
};
break;
}
default: {
throw new StackAssertionError(`Unsupported body type: ${bodyType}`);
}
}
}
}
return {
summary: endpointDocumentation.summary,
description: endpointDocumentation.description,
parameters: [...queryParameters, ...pathParameters, ...headerParameters],
requestBody,
tags: endpointDocumentation.tags ?? ["Others"],
'x-full-url': `https://api.stack-auth.com/api/v1${options.path}`,
responses: allResponses,
};
}