diff --git a/.gitignore b/.gitignore index e649ed1c6..225e8e49a 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,7 @@ docs/docs/reference/adapter # Sentry Config File .sentryclirc + +# python +__pycache__/ +.venv/ \ No newline at end of file diff --git a/apps/backend/scripts/generate-docs.ts b/apps/backend/scripts/generate-docs.ts index 488d6ba36..764d044d9 100644 --- a/apps/backend/scripts/generate-docs.ts +++ b/apps/backend/scripts/generate-docs.ts @@ -9,7 +9,7 @@ async function main() { for (const audience of ['client', 'server', 'admin'] as const) { const filePathPrefix = "src/app/api/v1"; const importPathPrefix = "@/app/api/v1"; - const filePaths = await glob(filePathPrefix + "/**/route.{js,jsx,ts,tsx}"); + const filePaths = [...await glob(filePathPrefix + "/**/route.{js,jsx,ts,tsx}")]; const openAPISchema = yaml.stringify(parseOpenAPI({ endpoints: new Map(await Promise.all(filePaths.map(async (filePath) => { if (!filePath.startsWith(filePathPrefix)) { @@ -18,7 +18,7 @@ async function main() { const suffix = filePath.slice(filePathPrefix.length); const midfix = suffix.slice(0, suffix.lastIndexOf("/route.")); const importPath = `${importPathPrefix}${suffix}`; - const urlPath = midfix.replace("[", "{").replace("]", "}"); + const urlPath = midfix.replaceAll("[", "{").replaceAll("]", "}"); const module = require(importPath); const handlersByMethod = new Map( HTTP_METHODS.map(method => [method, module[method]] as const) diff --git a/apps/backend/src/app/api/v1/auth/oauth/authorize/[provider]/route.tsx b/apps/backend/src/app/api/v1/auth/oauth/authorize/[provider]/route.tsx index 2ee963a4d..1c79486da 100644 --- a/apps/backend/src/app/api/v1/auth/oauth/authorize/[provider]/route.tsx +++ b/apps/backend/src/app/api/v1/auth/oauth/authorize/[provider]/route.tsx @@ -48,6 +48,7 @@ export const GET = createSmartRouteHandler({ response: yupObject({ // we never return as we always redirect statusCode: yupNumber().oneOf([302]).required(), + bodyType: yupString().oneOf(["empty"]).required(), }), async handler({ params, query }, fullReq) { const project = await getProject(query.client_id); diff --git a/apps/backend/src/app/api/v1/auth/oauth/callback/[provider]/route.tsx b/apps/backend/src/app/api/v1/auth/oauth/callback/[provider]/route.tsx index dc470bcb4..96c7105e4 100644 --- a/apps/backend/src/app/api/v1/auth/oauth/callback/[provider]/route.tsx +++ b/apps/backend/src/app/api/v1/auth/oauth/callback/[provider]/route.tsx @@ -13,6 +13,7 @@ import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-sh import { extractScopes } from "@stackframe/stack-shared/dist/utils/strings"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; +import { oauthResponseToSmartResponse } from "../../oauth-helpers"; const redirectOrThrowError = (error: KnownError, project: ProjectsCrud["Admin"]["Read"], errorRedirectUrl?: string) => { if (!errorRedirectUrl || !validateRedirectUrl(errorRedirectUrl, project.config.domains, project.config.allow_localhost)) { @@ -33,7 +34,7 @@ export const GET = createSmartRouteHandler({ query: yupMixed().required(), }), response: yupObject({ - statusCode: yupNumber().required(), + statusCode: yupNumber().oneOf([302]).required(), bodyType: yupString().oneOf(["json"]).required(), body: yupMixed().required(), headers: yupMixed().required(), @@ -44,7 +45,7 @@ export const GET = createSmartRouteHandler({ cookies().delete("stack-oauth-inner-" + query.state); if (cookieInfo?.value !== 'true') { - throw new StatusError(StatusError.BadRequest, "stack-oauth cookie not found"); + throw new StatusError(StatusError.BadRequest, "stack-oauth-inner- cookie not found"); } const outerInfoDB = await prismaClient.oAuthOuterInfo.findUnique({ @@ -54,7 +55,7 @@ export const GET = createSmartRouteHandler({ }); if (!outerInfoDB) { - throw new StatusError(StatusError.BadRequest, "Invalid stack-oauth cookie value. Please try signing in again."); + throw new StatusError(StatusError.BadRequest, "Invalid stack-oauth-inner- cookie value. Please try signing in again."); } let outerInfo: Awaited>; @@ -275,11 +276,6 @@ export const GET = createSmartRouteHandler({ throw error; } - return { - statusCode: oauthResponse.status || 200, - bodyType: "json", - body: oauthResponse.body, - headers: Object.fromEntries(Object.entries(oauthResponse.headers || {}).map(([k, v]) => ([k, [v]]))), - }; + return oauthResponseToSmartResponse(oauthResponse); }, }); diff --git a/apps/backend/src/app/api/v1/auth/oauth/oauth-helpers.tsx b/apps/backend/src/app/api/v1/auth/oauth/oauth-helpers.tsx new file mode 100644 index 000000000..9315ff72c --- /dev/null +++ b/apps/backend/src/app/api/v1/auth/oauth/oauth-helpers.tsx @@ -0,0 +1,46 @@ +import { SmartResponse } from "@/route-handlers/smart-response"; +import { Response as OAuthResponse } from "@node-oauth/oauth2-server"; +import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; + +export function oauthResponseToSmartResponse(oauthResponse: OAuthResponse): SmartResponse { + if (!oauthResponse.status) { + throw new StackAssertionError(`OAuth response status is missing`, { oauthResponse }); + } else if (oauthResponse.status >= 500 && oauthResponse.status < 600) { + throw new StackAssertionError(`OAuth server error: ${JSON.stringify(oauthResponse.body)}`, { oauthResponse }); + } else if (oauthResponse.status >= 400 && oauthResponse.status < 500) { + throw new StatusError(oauthResponse.status, oauthResponse.body); + } else if (oauthResponse.status >= 200 && oauthResponse.status < 400) { + return { + statusCode: oauthResponse.status, + bodyType: "json", + body: oauthResponse.body, + headers: Object.fromEntries(Object.entries(oauthResponse.headers || {}).map(([k, v]) => ([k, [v]]))), + }; + } else { + throw new StackAssertionError(`Invalid OAuth response status code: ${oauthResponse.status}`, { oauthResponse }); + } +} + +export abstract class OAuthResponseError extends StatusError { + public name = "OAuthResponseError"; + + constructor( + public readonly oauthResponse: OAuthResponse + ) { + super( + oauthResponse.status ?? throwErr(`OAuth response status is missing`), + JSON.stringify(oauthResponse.body), + ); + } + + public override getBody(): Uint8Array { + return new TextEncoder().encode(JSON.stringify(this.oauthResponse.body, undefined, 2)); + } + + public override getHeaders(): Record { + return { + "Content-Type": ["application/json; charset=utf-8"], + ...Object.fromEntries(Object.entries(this.oauthResponse.headers || {}).map(([k, v]) => ([k, [v]]))), + }; + } +} diff --git a/apps/backend/src/app/api/v1/auth/oauth/token/route.tsx b/apps/backend/src/app/api/v1/auth/oauth/token/route.tsx index baaad4739..0f5515d79 100644 --- a/apps/backend/src/app/api/v1/auth/oauth/token/route.tsx +++ b/apps/backend/src/app/api/v1/auth/oauth/token/route.tsx @@ -3,6 +3,7 @@ import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { InvalidClientError, InvalidGrantError, Request as OAuthRequest, Response as OAuthResponse } from "@node-oauth/oauth2-server"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { oauthResponseToSmartResponse } from "../oauth-helpers"; export const POST = createSmartRouteHandler({ metadata: { @@ -12,7 +13,7 @@ export const POST = createSmartRouteHandler({ }, request: yupObject({}), response: yupObject({ - statusCode: yupNumber().required(), + statusCode: yupNumber().oneOf([200]).required(), bodyType: yupString().oneOf(["json"]).required(), body: yupMixed().required(), headers: yupMixed().required(), @@ -50,11 +51,6 @@ export const POST = createSmartRouteHandler({ throw e; } - return { - statusCode: oauthResponse.status || 200, - bodyType: "json", - body: oauthResponse.body, - headers: Object.fromEntries(Object.entries(oauthResponse.headers || {}).map(([k, v]) => ([k, [v]]))), - }; + return oauthResponseToSmartResponse(oauthResponse); }, }); diff --git a/apps/backend/src/app/api/v1/auth/otp/sign-in/verification-code-handler.tsx b/apps/backend/src/app/api/v1/auth/otp/sign-in/verification-code-handler.tsx index af38759ef..0181b0107 100644 --- a/apps/backend/src/app/api/v1/auth/otp/sign-in/verification-code-handler.tsx +++ b/apps/backend/src/app/api/v1/auth/otp/sign-in/verification-code-handler.tsx @@ -28,6 +28,7 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({ }), response: yupObject({ statusCode: yupNumber().oneOf([200]).required(), + bodyType: yupString().oneOf(["json"]).required(), body: signInResponseSchema.required(), }), async send(codeObj, createOptions, sendOptions: { user: UsersCrud["Admin"]["Read"] }) { @@ -64,6 +65,7 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({ return { statusCode: 200, + bodyType: "json", body: { refresh_token: refreshToken, access_token: accessToken, diff --git a/apps/backend/src/app/api/v1/auth/sessions/current/route.tsx b/apps/backend/src/app/api/v1/auth/sessions/current/route.tsx index 3b37b0793..4515e95d6 100644 --- a/apps/backend/src/app/api/v1/auth/sessions/current/route.tsx +++ b/apps/backend/src/app/api/v1/auth/sessions/current/route.tsx @@ -17,15 +17,15 @@ export const DELETE = createSmartRouteHandler({ project: adaptSchema, }).required(), headers: yupObject({ - "x-stack-refresh-token": yupTuple([yupString()]), + "x-stack-refresh-token": yupTuple([yupString().required()]).required(), }), }), response: yupObject({ statusCode: yupNumber().oneOf([200]).required(), - bodyType: yupString().oneOf(["empty"]).required(), + bodyType: yupString().oneOf(["success"]).required(), }), async handler({ auth: { project }, headers: { "x-stack-refresh-token": refreshTokenHeaders } }) { - if (!refreshTokenHeaders || !refreshTokenHeaders[0]) { + if (!refreshTokenHeaders[0]) { throw new StackAssertionError("Signing out without the refresh token is currently not supported. TODO: implement"); } const refreshToken = refreshTokenHeaders[0]; @@ -50,7 +50,7 @@ export const DELETE = createSmartRouteHandler({ return { statusCode: 200, - bodyType: "empty", + bodyType: "success", }; }, }); diff --git a/apps/backend/src/app/api/v1/check-feature-support/route.tsx b/apps/backend/src/app/api/v1/check-feature-support/route.tsx index 049aa3bef..f4a86981c 100644 --- a/apps/backend/src/app/api/v1/check-feature-support/route.tsx +++ b/apps/backend/src/app/api/v1/check-feature-support/route.tsx @@ -4,6 +4,9 @@ import { deindent, typedCapitalize } from "@stackframe/stack-shared/dist/utils/s import { yupObject, yupString, yupNumber, yupMixed } from "@stackframe/stack-shared/dist/schema-fields"; export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, request: yupObject({ auth: yupObject({ type: yupMixed(), diff --git a/apps/backend/src/app/api/v1/contact-channels/send-verification-code/route.tsx b/apps/backend/src/app/api/v1/contact-channels/send-verification-code/route.tsx index 20e2ffe72..8064f1330 100644 --- a/apps/backend/src/app/api/v1/contact-channels/send-verification-code/route.tsx +++ b/apps/backend/src/app/api/v1/contact-channels/send-verification-code/route.tsx @@ -1,7 +1,6 @@ -import * as yup from "yup"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { adaptSchema, clientOrHigherAuthTypeSchema, signInEmailSchema, emailVerificationCallbackUrlSchema, yupObject, yupNumber, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { KnownErrors } from "@stackframe/stack-shared"; +import { adaptSchema, clientOrHigherAuthTypeSchema, emailVerificationCallbackUrlSchema, signInEmailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { contactChannelVerificationCodeHandler } from "../verify/verification-code-handler"; export const POST = createSmartRouteHandler({ @@ -25,7 +24,7 @@ export const POST = createSmartRouteHandler({ statusCode: yupNumber().oneOf([200]).required(), bodyType: yupString().oneOf(["success"]).required(), }), - async handler({ auth: { project, user }, body: { email, callback_url: callbackUrl } }, fullReq) { + async handler({ auth: { project, user }, body: { email, callback_url: callbackUrl } }) { if (user.primary_email !== email) { throw new KnownErrors.EmailIsNotPrimaryEmail(email, user.primary_email); } diff --git a/apps/backend/src/app/api/v1/internal/projects/crud.tsx b/apps/backend/src/app/api/v1/internal/projects/crud.tsx index 85150ad53..21f9f6328 100644 --- a/apps/backend/src/app/api/v1/internal/projects/crud.tsx +++ b/apps/backend/src/app/api/v1/internal/projects/crud.tsx @@ -16,7 +16,7 @@ export const internalProjectsCrudHandlers = createCrudHandlers(internalProjectsC if (!auth.user) { throw new KnownErrors.UserAuthenticationRequired(); } - if (auth.user.project_id !== 'internal') { + if (auth.project.id !== "internal") { throw new KnownErrors.ExpectedInternalProject(); } }, diff --git a/apps/backend/src/app/api/v1/route.ts b/apps/backend/src/app/api/v1/route.ts index 2164c1497..f8ea76c56 100644 --- a/apps/backend/src/app/api/v1/route.ts +++ b/apps/backend/src/app/api/v1/route.ts @@ -1,9 +1,13 @@ import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, projectIdSchema, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields"; import { deindent, typedCapitalize } from "@stackframe/stack-shared/dist/utils/strings"; -import * as yup from "yup"; -import { adaptSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; export const GET = createSmartRouteHandler({ + metadata: { + summary: "/api/v1", + description: "Returns a human-readable message with some useful information about the API.", + tags: [], + }, request: yupObject({ auth: yupObject({ type: adaptSchema, @@ -14,12 +18,22 @@ export const GET = createSmartRouteHandler({ // No query parameters // empty object means that it will fail if query parameters are given regardless }), + headers: yupObject({ + // we list all automatically parsed headers here so the documentation shows them + "X-Stack-Project-Id": yupTuple([projectIdSchema]), + "X-Stack-Access-Type": yupTuple([yupString().oneOf(["client", "server", "admin"])]), + "X-Stack-Access-Token": yupTuple([yupString()]), + "X-Stack-Refresh-Token": yupTuple([yupString()]), + "X-Stack-Publishable-Client-Key": yupTuple([yupString()]), + "X-Stack-Secret-Server-Key": yupTuple([yupString()]), + "X-Stack-Super-Secret-Admin-Key": yupTuple([yupString()]), + }), method: yupString().oneOf(["GET"]).required(), }), response: yupObject({ statusCode: yupNumber().oneOf([200]).required(), bodyType: yupString().oneOf(["text"]).required(), - body: yupString().required(), + body: yupString().required().meta({ openapiField: { exampleValue: "Welcome to the Stack API endpoint! Please refer to the documentation at https://docs.stack-auth.com/\n\nAuthentication: None" } }), }), handler: async (req) => { return { diff --git a/apps/backend/src/app/api/v1/team-memberships/crud.tsx b/apps/backend/src/app/api/v1/team-memberships/crud.tsx index 6c34c4d99..d9f711bcc 100644 --- a/apps/backend/src/app/api/v1/team-memberships/crud.tsx +++ b/apps/backend/src/app/api/v1/team-memberships/crud.tsx @@ -3,7 +3,6 @@ import { isTeamSystemPermission, teamSystemPermissionStringToDBType } from "@/li import { prismaClient } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { getIdFromUserIdOrMe } from "@/route-handlers/utils"; -import { KnownErrors } from "@stackframe/stack-shared"; import { teamMembershipsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-memberships"; import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; diff --git a/apps/backend/src/app/api/v1/team-permissions/crud.tsx b/apps/backend/src/app/api/v1/team-permissions/crud.tsx index 9fa1ab0fb..71ea6750f 100644 --- a/apps/backend/src/app/api/v1/team-permissions/crud.tsx +++ b/apps/backend/src/app/api/v1/team-permissions/crud.tsx @@ -7,10 +7,10 @@ import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; export const teamPermissionsCrudHandlers = createCrudHandlers(teamPermissionsCrud, { querySchema: yupObject({ - team_id: yupString().uuid().optional(), - user_id: userIdOrMeSchema.optional(), - permission_id: teamPermissionDefinitionIdSchema.optional(), - recursive: yupString().oneOf(['true', 'false']).optional(), + team_id: yupString().uuid().optional().meta({ openapiField: { description: 'Filter with the team ID. If set, only the permissions of the members in a specific team will be returned.', exampleValue: 'cce084a3-28b7-418e-913e-c8ee6d802ea4' } }), + user_id: userIdOrMeSchema.optional().meta({ openapiField: { description: 'Filter with the user ID. If set, only the permissions this user has will be returned. Client request must set `user_id=me`', exampleValue: 'me' } }), + permission_id: teamPermissionDefinitionIdSchema.optional().meta({ openapiField: { description: 'Filter with the permission ID. If set, only the permissions with this specific ID will be returned', exampleValue: '16399452-c4f3-4554-8e44-c2d67bb60360' } }), + recursive: yupString().oneOf(['true', 'false']).optional().meta({ openapiField: { description: 'Whether to list permissions recursively. If set to `false`, only the permission the users directly have will be listed. If set to `true` all the direct and indirect permissions will be listed.', exampleValue: 'true' } }), }), paramsSchema: yupObject({ team_id: yupString().uuid().required(), diff --git a/apps/backend/src/app/api/v1/teams/crud.tsx b/apps/backend/src/app/api/v1/teams/crud.tsx index de3a62825..94d7c109b 100644 --- a/apps/backend/src/app/api/v1/teams/crud.tsx +++ b/apps/backend/src/app/api/v1/teams/crud.tsx @@ -9,6 +9,7 @@ import { KnownErrors } from "@stackframe/stack-shared"; import { teamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams"; import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; export function teamPrismaToCrud(prisma: Prisma.TeamGetPayload<{}>) { @@ -20,10 +21,10 @@ export function teamPrismaToCrud(prisma: Prisma.TeamGetPayload<{}>) { }; } -export const teamsCrudHandlers = createCrudHandlers(teamsCrud, { +export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsCrud, { querySchema: yupObject({ - user_id: userIdOrMeSchema.optional(), - add_current_user: yupString().oneOf(["true", "false"]).optional(), + user_id: userIdOrMeSchema.optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: 'Filter for the teams that the user is a member of. Can be either `me` or an ID. Must be `me` in the client API', exampleValue: 'me' } }), + add_current_user: yupString().oneOf(["true", "false"]).optional().meta({ openapiField: { onlyShowInOperations: ['Create'], description: "If to add the current user to the team. If this is not `true`, the newly created team will have no members. Notice that if you didn't specify `add_current_user=true` on the client side, the user cannot join the team again without re-adding them on the server side.", exampleValue: 'true' } }), }), paramsSchema: yupObject({ team_id: yupString().uuid().required(), @@ -174,4 +175,4 @@ export const teamsCrudHandlers = createCrudHandlers(teamsCrud, { is_paginated: false, }; } -}); +})); diff --git a/apps/backend/src/app/api/v1/users/crud.tsx b/apps/backend/src/app/api/v1/users/crud.tsx index 42b7c759a..2aa5e5e11 100644 --- a/apps/backend/src/app/api/v1/users/crud.tsx +++ b/apps/backend/src/app/api/v1/users/crud.tsx @@ -28,7 +28,6 @@ const prismaToCrud = (prisma: Prisma.ProjectUserGetPayload<{ include: typeof ful throw new StackAssertionError("User cannot have more than one selected team; this should never happen"); } return { - project_id: prisma.projectId, id: prisma.projectUserId, display_name: prisma.displayName || null, primary_email: prisma.primaryEmail, @@ -52,7 +51,7 @@ const prismaToCrud = (prisma: Prisma.ProjectUserGetPayload<{ include: typeof ful export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersCrud, { querySchema: yupObject({ - team_id: yupString().uuid().optional(), + team_id: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ] }}) }), paramsSchema: yupObject({ user_id: userIdOrMeSchema.required(), diff --git a/apps/backend/src/lib/openapi.tsx b/apps/backend/src/lib/openapi.tsx index b4fb51550..98cc54347 100644 --- a/apps/backend/src/lib/openapi.tsx +++ b/apps/backend/src/lib/openapi.tsx @@ -1,6 +1,6 @@ import { SmartRouteHandler } from '@/route-handlers/smart-route-handler'; -import { EndpointDocumentation } from '@stackframe/stack-shared/dist/crud'; -import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors'; +import { CrudlOperation, EndpointDocumentation } from '@stackframe/stack-shared/dist/crud'; +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 } from '@stackframe/stack-shared/dist/utils/strings'; @@ -31,7 +31,8 @@ export function parseOpenAPI(options: { .filter(([_, handler]) => handler !== undefined) )] )) - .filter(([_, handlersByMethod]) => Object.keys(handlersByMethod).length > 0), + .filter(([_, handlersByMethod]) => Object.keys(handlersByMethod).length > 0) + .sort(([_a, handlersByMethodA], [_b, handlersByMethodB]) => ((Object.values(handlersByMethodA)[0] as any).tags[0] ?? "").localeCompare(((Object.values(handlersByMethodB)[0] as any).tags[0] ?? ""))), ), }; } @@ -57,6 +58,14 @@ function isSchemaTupleDescription(value: yup.SchemaFieldDescription): value is y 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 @@ -66,9 +75,10 @@ function isMaybeRequestSchemaForAudience(requestDescribe: yup.SchemaObjectDescri 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) { + if ("oneOf" in schemaAudience && schemaAudience.oneOf.length > 0) { return schemaAudience.oneOf.includes(audience); } + return true; } @@ -108,20 +118,27 @@ function parseRouteHandler(options: { path: options.path, pathDesc: undefinedIfMixed(requestDescribe.fields.params), parameterDesc: undefinedIfMixed(requestDescribe.fields.query), + headerDesc: undefinedIfMixed(requestDescribe.fields.headers), requestBodyDesc: undefinedIfMixed(requestDescribe.fields.body), 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): { type: string, items?: any, properties?: any, required?: any } | undefined { +function getFieldSchema(field: yup.SchemaFieldDescription, crudOperation?: Capitalize): { type: string, items?: any, properties?: any, required?: 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, @@ -140,15 +157,15 @@ function getFieldSchema(field: yup.SchemaFieldDescription): { type: string, item return { type: 'object', properties: typedFromEntries(typedEntries((field as any).fields) - .map(([key, field]) => [key, getFieldSchema(field)])), + .map(([key, field]) => [key, getFieldSchema(field, crudOperation)])), required: typedEntries((field as any).fields) - .filter(([_, field]) => !(field as any).optional && !(field as any).nullable) + .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), ...openapiFieldExtra }; + return { type: 'array', items: getFieldSchema((field as any).innerType, crudOperation), ...openapiFieldExtra }; } default: { throw new Error(`Unsupported field type: ${field.type}`); @@ -156,7 +173,7 @@ function getFieldSchema(field: yup.SchemaFieldDescription): { type: string, item } } -function toParameters(description: yup.SchemaFieldDescription, path?: string) { +function toParameters(description: yup.SchemaFieldDescription, crudOperation?: Capitalize, path?: string) { const pathParams: string[] = path ? path.match(/{[^}]+}/g) || [] : []; if (!isSchemaObjectDescription(description)) { throw new StackAssertionError('Parameters field must be an object schema', { actual: description }); @@ -164,57 +181,88 @@ function toParameters(description: yup.SchemaFieldDescription, path?: string) { return Object.entries(description.fields).map(([key, field]) => { if (path && !pathParams.includes(`{${key}}`)) { - return { schema: null }; + return { schema: undefined }; } + + const meta = "meta" in field ? field.meta : {}; + const schema = getFieldSchema(field, crudOperation); return { name: key, in: path ? 'path' : 'query', - schema: getFieldSchema(field as any), - required: !(field as any).optional && !(field as any).nullable, + schema, + description: meta?.openapiField?.description, + required: !(field as any).optional && !(field as any).nullable && schema, + }; + }).filter((x) => x.schema !== undefined); +} + +function toHeaderParameters(description: yup.SchemaFieldDescription, crudOperation?: Capitalize) { + 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', + type: 'string', + schema, + description: meta?.openapiField?.description, + example: meta?.openapiField?.exampleValue, + required: !(field as any).optional && !(field as any).nullable && !!schema, }; - }).filter((x) => x.schema !== null); + }).filter((x) => x.schema !== undefined); } -function toSchema(description: yup.SchemaFieldDescription): any { +function toSchema(description: yup.SchemaFieldDescription, crudOperation?: Capitalize): any { if (isSchemaObjectDescription(description)) { return { type: 'object', properties: Object.fromEntries(Object.entries(description.fields).map(([key, field]) => { - return [key, getFieldSchema(field)]; + return [key, getFieldSchema(field, crudOperation)]; }, {})) }; } else if (isSchemaArrayDescription(description)) { return { type: 'array', - items: toSchema(description.innerType), + items: toSchema(description.innerType, crudOperation), }; } else { - throw new StackAssertionError(`Unsupported schema type: ${description.type}`, { actual: description }); + throw new StackAssertionError(`Unsupported schema type in toSchema: ${description.type}`, { actual: description }); } } -function toRequired(description: yup.SchemaFieldDescription) { +function toRequired(description: yup.SchemaFieldDescription, crudOperation?: Capitalize) { let res: string[] = []; if (isSchemaObjectDescription(description)) { res = Object.entries(description.fields) - .filter(([_, field]) => !(field as any).optional && !(field as any).nullable) + .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: ${description.type}`, { actual: description }); + 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) { +function toExamples(description: yup.SchemaFieldDescription, crudOperation?: Capitalize) { 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); + const schema = getFieldSchema(field, crudOperation); if (!schema) return acc; const example = "meta" in field ? field.meta?.openapiField?.exampleValue : undefined; return { ...acc, [key]: example }; @@ -227,18 +275,23 @@ export function parseOverload(options: { path: string, pathDesc?: yup.SchemaFieldDescription, parameterDesc?: yup.SchemaFieldDescription, + headerDesc?: yup.SchemaFieldDescription, requestBodyDesc?: yup.SchemaFieldDescription, 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, options.path) : []; - const queryParameters = options.parameterDesc ? toParameters(options.parameterDesc) : []; - const responseSchema = options.responseDesc ? toSchema(options.responseDesc) : {}; - const responseRequired = options.responseDesc ? toRequired(options.responseDesc) : 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) { @@ -247,37 +300,116 @@ export function parseOverload(options: { content: { 'application/json': { schema: { - ...toSchema(options.requestBodyDesc), - required: toRequired(options.requestBodyDesc), - example: toExamples(options.requestBodyDesc), + ...toSchema(options.requestBodyDesc, endpointDocumentation.crudOperation), + required: toRequired(options.requestBodyDesc, endpointDocumentation.crudOperation), + example: toExamples(options.requestBodyDesc, endpointDocumentation.crudOperation), }, }, }, }; } - if (endpointDocumentation.hidden) { - return undefined; - } - - return { + const exRes = { summary: endpointDocumentation.summary, description: endpointDocumentation.description, - parameters: queryParameters.concat(pathParameters), + parameters: [...queryParameters, ...pathParameters, ...headerParameters], requestBody, tags: endpointDocumentation.tags ?? ["Others"], - responses: { - 200: { - description: 'Successful response', - content: { - 'application/json': { - schema: { - ...responseSchema, - required: responseRequired, + } as const; + + if (!isSchemaStringDescription(options.responseTypeDesc)) { + throw new StackAssertionError(`Expected response type to be a string`, { actual: options.responseTypeDesc, options }); + } + if (options.responseTypeDesc.oneOf.length !== 1) { + throw new StackAssertionError(`Expected response type to have exactly one value`, { actual: options.responseTypeDesc, options }); + } + const bodyType = options.responseTypeDesc.oneOf[0]; + + if (!isSchemaNumberDescription(options.statusCodeDesc)) { + throw new StackAssertionError('Expected status code to be a number', { actual: options.statusCodeDesc, options }); + } + if (options.statusCodeDesc.oneOf.length !== 1) { + throw new StackAssertionError('Expected status code to have exactly one value', { actual: options.statusCodeDesc.oneOf, options }); + } + const status = options.statusCodeDesc.oneOf[0] as number; + + switch (bodyType) { + case 'json': { + return { + ...exRes, + responses: { + [status]: { + description: 'Successful response', + content: { + 'application/json': { + schema: { + ...options.responseDesc ? toSchema(options.responseDesc, endpointDocumentation.crudOperation) : {}, + required: options.responseDesc ? toRequired(options.responseDesc, endpointDocumentation.crudOperation) : undefined, + }, + }, }, }, }, - }, - }, - }; + }; + } + case 'text': { + if (!options.responseDesc || !isSchemaStringDescription(options.responseDesc)) { + throw new StackAssertionError('Expected response body of bodyType=="text" to be a string schema', { actual: options.responseDesc }); + } + return { + ...exRes, + responses: { + [status]: { + description: 'Successful response', + content: { + 'text/plain': { + schema: { + type: 'string', + example: options.responseDesc.meta?.openapiField?.exampleValue, + }, + }, + }, + }, + }, + }; + } + case 'success': { + return { + ...exRes, + responses: { + [status]: { + description: 'Successful response', + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { + type: "boolean", + description: "Always equal to true.", + example: true, + }, + }, + required: ["success"], + }, + }, + }, + }, + }, + }; + } + case 'empty': { + return { + ...exRes, + responses: { + [status]: { + description: 'No content', + }, + }, + }; + } + default: { + throw new StackAssertionError(`Unsupported body type: ${bodyType}`); + } + } } diff --git a/apps/backend/src/route-handlers/crud-handler.tsx b/apps/backend/src/route-handlers/crud-handler.tsx index 333803167..624cc65ab 100644 --- a/apps/backend/src/route-handlers/crud-handler.tsx +++ b/apps/backend/src/route-handlers/crud-handler.tsx @@ -2,7 +2,7 @@ import "../polyfills"; import * as yup from "yup"; import { SmartRouteHandler, routeHandlerTypeHelper, createSmartRouteHandler } from "./smart-route-handler"; -import { CrudOperation, CrudSchema, CrudTypeOf, CrudlOperation } from "@stackframe/stack-shared/dist/crud"; +import { CrudSchema, CrudTypeOf, CrudlOperation } from "@stackframe/stack-shared/dist/crud"; import { FilterUndefined } from "@stackframe/stack-shared/dist/utils/objects"; import { typedIncludes } from "@stackframe/stack-shared/dist/utils/arrays"; import { deindent, typedToLowercase } from "@stackframe/stack-shared/dist/utils/strings"; @@ -131,7 +131,7 @@ export function createCrudHandlers< crudOperation === "List" ? yupObject({ items: yupArray(read).required(), - is_paginated: yupBoolean().oneOf([false]).required(), + is_paginated: yupBoolean().oneOf([false]).required().meta({ openapiField: { hidden: true } }), }).required() : crudOperation === "Delete" ? yupMixed().oneOf([undefined]) @@ -195,11 +195,11 @@ export function createCrudHandlers< query: (options.querySchema ?? yupObject({})) as QuerySchema, }), response: yupObject({ - statusCode: yupNumber().oneOf([200, 201]).required(), + statusCode: yupNumber().oneOf([crudOperation === "Create" ? 201 : 200]).required(), headers: yupObject({ location: yupArray(yupString().required()).default([]), }), - bodyType: yupString().oneOf([crudOperation === "Delete" ? "empty" : "json"]).required(), + bodyType: yupString().oneOf([crudOperation === "Delete" ? "success" : "json"]).required(), body: accessSchemas.output, }), handler: async (req, fullReq) => { @@ -217,14 +217,16 @@ export function createCrudHandlers< headers: { location: crudOperation === "Create" ? [req.url] : [], }, - bodyType: crudOperation === "Delete" ? "empty" : "json", + bodyType: crudOperation === "Delete" ? "success" : "json", body: result, }; }, }); + + const metadata = crud[accessType][`${typedToLowercase(crudOperation)}Docs`]; return { ...frw, - metadata: crud[accessType][`${typedToLowercase(crudOperation)}Docs`], + metadata: metadata ? (metadata.hidden ? metadata : { ...metadata, crudOperation }) : undefined, }; } ); diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index 0b652eaae..5d02433a0 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -139,6 +139,7 @@ export namespace Auth { expect(response).toMatchInlineSnapshot(` NiceResponse { "status": 200, + "body": { "success": true }, "headers": Headers {