🔧 Restore OpenAPI docs generation pipeline (#2553)

## Summary

- Restores OpenAPI docs generation, lost in the oRPC migration: new
`generate-openapi` nx targets regenerate
`apps/docs/openapi/{builder,viewer}.json` offline from the oRPC routers,
reusing the exact options served by the live `/api/openapi.json`
endpoints. Both specs are regenerated in this PR (builder.json was stale
since March).
- Fixes spec generation crashing on the spaces router (Effect Schema had
no JSON Schema converter — this also breaks the live builder
`/api/openapi.json` today) and sanitizes an `AppendValue(s)` component
name that made Mintlify silently render every builder API page empty.
- Keeps the docs from going stale again: pre-commit regenerates affected
specs and auto-stages them, CI fails PRs with stale specs, and a new
check requires lockstep `@typebot.io/js` / `@typebot.io/react` version
bumps whenever embed files change.

## Test plan

- [x] `bunx nx generate-openapi builder viewer` produces valid OpenAPI
3.1 files matching the live endpoints (works without env: isolated-vm
stubbed, dummy DATABASE_URL auto-matched to the generated Prisma client)
- [x] All 34 `openapi:` references in `apps/docs/api-reference/*.mdx`
resolve against the new specs; pages verified rendering in `mintlify
dev` (params, unions, curl samples)
- [x] Embeds version check: fails when js or react files change without
both bumps, passes on the historical 0.10.5 bump commit, ignores
unrelated changes
- [x] Pre-commit pipeline (lint, tests, generation, embeds check) passes
end to end
- [ ] Confirm the "Check OpenAPI docs are up to date" step passes on
this PR

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Baptiste Arnaud 2026-07-03 20:19:08 +02:00 committed by GitHub
parent d039794797
commit 9d2d7ead8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 96187 additions and 39129 deletions

View File

@ -0,0 +1,22 @@
name: Check embeds version bump
on:
pull_request:
paths:
- "packages/embeds/js/**"
- "packages/embeds/react/**"
permissions:
contents: read
jobs:
check-version-bump:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Require version bump
env:
VERSION_BUMP_BASE_REF: ${{ github.event.pull_request.base.sha }}
run: bash scripts/assert-embeds-version-bumped.sh

View File

@ -29,3 +29,10 @@ jobs:
working-directory: apps/landing-page
- run: bunx nx build @typebot.io/react --configuration=production
- run: bunx nx affected -t typecheck --base=${{ github.event.pull_request.base.sha }}
- name: Check OpenAPI docs are up to date
run: |
bunx nx run-many -t generate-openapi
git diff --exit-code apps/docs/openapi || {
echo "::error::OpenAPI docs are stale. Run 'bunx nx run-many -t generate-openapi' and commit the result."
exit 1
}

View File

@ -40,6 +40,24 @@
"cwd": "apps/builder",
"command": "bun test"
}
},
"generate-openapi": {
"executor": "nx:run-commands",
"options": {
"cwd": "apps/builder",
"command": "SKIP_ENV_CHECK=true bun --preload ./src/scripts/preloadOpenApiGenerationStubs.ts src/scripts/generateOpenApiSpec.ts"
},
"cache": true,
"inputs": [
"default",
"^default",
{
"runtime": "grep -m1 -Eo 'provider *= *\"[a-z]+\"' node_modules/.prisma/client/schema.prisma"
}
],
"outputs": [
"{workspaceRoot}/apps/docs/openapi/builder.json"
]
}
}
},

View File

@ -1,19 +1,16 @@
import { OpenAPIHandler } from "@orpc/openapi/fetch";
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
import { onError } from "@orpc/server";
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import { authenticateWithBearerToken } from "@typebot.io/auth/helpers/authenticateWithBearerToken";
import { auth } from "@typebot.io/auth/lib/nextAuth";
import { createContext } from "@typebot.io/config/orpc/builder/context";
import { convertSchemasListToCommonSchemas } from "@typebot.io/lib/convertSchemasListToCommonSchemas";
import { UserId } from "@typebot.io/shared-core/domain";
import { logServerRequest } from "@typebot.io/telemetry/logServerRequest";
import {
publicTypebotSchemaV5,
publicTypebotSchemaV6,
} from "@typebot.io/typebot/schemas/publicTypebot";
import { typebotSchema } from "@typebot.io/typebot/schemas/typebot";
import { after, type NextRequest } from "next/server";
import {
openApiSchemaConverters,
openApiSpecGenerateOptions,
} from "../openApiSpecGenerateOptions";
import { appRouter } from "../router";
type RouteContext<_T> = {
@ -62,35 +59,8 @@ const handler = new OpenAPIHandler(appRouter, {
plugins: [
new OpenAPIReferencePlugin({
specPath: "/openapi.json",
schemaConverters: [new ZodToJsonSchemaConverter()],
specGenerateOptions: {
filter: ({ contract }) =>
Boolean(
contract["~orpc"].route.method &&
!contract["~orpc"].route.deprecated,
),
info: {
title: "Builder API",
version: "1.0.0",
},
servers: [{ url: "https://app.typebot.com/api" }],
externalDocs: {
url: "https://docs.typebot.com/api-reference",
},
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
},
},
},
commonSchemas: {
...convertSchemasListToCommonSchemas(typebotSchema),
"Public Typebot V5": { schema: publicTypebotSchemaV5 },
"Public Typebot V6": { schema: publicTypebotSchemaV6 },
},
},
schemaConverters: openApiSchemaConverters,
specGenerateOptions: openApiSpecGenerateOptions,
}),
],
});

View File

@ -0,0 +1,42 @@
import type { OpenAPIGeneratorGenerateOptions } from "@orpc/openapi";
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import { EffectSchemaToJsonSchemaConverter } from "@typebot.io/config/orpc/EffectSchemaToJsonSchemaConverter";
import { convertSchemasListToCommonSchemas } from "@typebot.io/lib/convertSchemasListToCommonSchemas";
import {
publicTypebotSchemaV5,
publicTypebotSchemaV6,
} from "@typebot.io/typebot/schemas/publicTypebot";
import { typebotSchema } from "@typebot.io/typebot/schemas/typebot";
export const openApiSchemaConverters = [
new ZodToJsonSchemaConverter(),
new EffectSchemaToJsonSchemaConverter(),
];
export const openApiSpecGenerateOptions: OpenAPIGeneratorGenerateOptions = {
filter: ({ contract }) =>
Boolean(
contract["~orpc"].route.method && !contract["~orpc"].route.deprecated,
),
info: {
title: "Builder API",
version: "1.0.0",
},
servers: [{ url: "https://app.typebot.com/api" }],
externalDocs: {
url: "https://docs.typebot.com/api-reference",
},
components: {
securitySchemes: {
Authorization: {
type: "http",
scheme: "bearer",
},
},
},
commonSchemas: {
...convertSchemasListToCommonSchemas(typebotSchema),
PublicTypebotV5: { schema: publicTypebotSchemaV5 },
PublicTypebotV6: { schema: publicTypebotSchemaV6 },
},
};

View File

@ -13,6 +13,7 @@ export const analyticsRouter = {
.route({
method: "GET",
path: "/v1/typebots/{typebotId}/analytics/inDepthData",
operationId: "analytics-getInDepthAnalyticsData",
summary:
"List total answers in blocks and off-default paths visited edges",
tags: ["Analytics"],
@ -30,6 +31,7 @@ export const analyticsRouter = {
.route({
method: "GET",
path: "/v1/typebots/{typebotId}/analytics/stats",
operationId: "analytics-getStats",
summary: "Get results stats",
tags: ["Analytics"],
})

View File

@ -27,6 +27,7 @@ export const httpRequestRouter = {
.route({
method: "GET",
path: "/v1/typebots/{typebotId}/webhookBlocks",
operationId: "httpRequest-listHttpRequestBlocks",
summary: "List HTTP request blocks",
description:
"Returns a list of all the HTTP request blocks that you can subscribe to.",
@ -56,6 +57,7 @@ export const httpRequestRouter = {
.route({
method: "GET",
path: "/v1/typebots/{typebotId}/webhookBlocks/{blockId}/getResultExample",
operationId: "httpRequest-getResultExample",
summary: "Get result example",
description:
'Returns "fake" result for http request block to help you anticipate how the webhook will behave.',
@ -75,6 +77,7 @@ export const httpRequestRouter = {
.route({
method: "POST",
path: "/v1/typebots/{typebotId}/webhookBlocks/{blockId}/subscribe",
operationId: "httpRequest-subscribeHttpRequest",
summary: "Subscribe to HTTP request block",
tags: ["HTTP request"],
})
@ -91,6 +94,7 @@ export const httpRequestRouter = {
.route({
method: "POST",
path: "/v1/typebots/{typebotId}/webhookBlocks/{blockId}/unsubscribe",
operationId: "httpRequest-unsubscribeHttpRequest",
summary: "Unsubscribe from HTTP request block",
tags: ["HTTP request"],
})

View File

@ -35,6 +35,7 @@ export const getLinkedTypebots = authenticatedProcedure
.route({
method: "GET",
path: "/v1/typebots/{typebotId}/linkedTypebots",
operationId: "getLinkedTypebots",
summary: "Get linked typebots",
tags: ["Typebot"],
})

View File

@ -43,6 +43,7 @@ export const collaboratorsRouter = {
.route({
method: "GET",
path: "/v1/typebots/{typebotId}/collaborators",
operationId: "collaborators-getCollaborators",
summary: "Get collaborators",
tags: ["Collaborators"],
})

View File

@ -27,6 +27,7 @@ export const customDomainsRouter = {
.route({
method: "POST",
path: "/v1/custom-domains",
operationId: "customDomains-createCustomDomain",
summary: "Create custom domain",
tags: ["Custom domains"],
})
@ -45,6 +46,7 @@ export const customDomainsRouter = {
.route({
method: "DELETE",
path: "/v1/custom-domains/{name}",
operationId: "customDomains-deleteCustomDomain",
summary: "Delete custom domain",
tags: ["Custom domains"],
})
@ -60,6 +62,7 @@ export const customDomainsRouter = {
.route({
method: "GET",
path: "/v1/custom-domains",
operationId: "customDomains-listCustomDomains",
summary: "List custom domains",
tags: ["Custom domains"],
})
@ -80,6 +83,7 @@ export const customDomainsRouter = {
.route({
method: "GET",
path: "/v1/custom-domains/{name}/verify",
operationId: "customDomains-verifyCustomDomain",
summary: "Verify domain config",
tags: ["Custom domains"],
})

View File

@ -21,6 +21,7 @@ export const folderRouter = {
.route({
method: "GET",
path: "/v1/folders/{folderId}",
operationId: "folders-getFolder",
summary: "Get folder",
tags: ["Folder"],
})
@ -36,6 +37,7 @@ export const folderRouter = {
.route({
method: "POST",
path: "/v1/folders",
operationId: "folders-createFolder",
summary: "Create a folder",
tags: ["Folder"],
})
@ -51,6 +53,7 @@ export const folderRouter = {
.route({
method: "PATCH",
path: "/v1/folders/{folderId}",
operationId: "folders-updateFolder",
summary: "Update a folder",
tags: ["Folder"],
})
@ -66,6 +69,7 @@ export const folderRouter = {
.route({
method: "DELETE",
path: "/v1/folders/{folderId}",
operationId: "folders-deleteFolder",
summary: "Delete a folder",
tags: ["Folder"],
})
@ -81,6 +85,7 @@ export const folderRouter = {
.route({
method: "GET",
path: "/v1/folders",
operationId: "folders-listFolders",
summary: "List folders",
tags: ["Folder"],
})

View File

@ -38,6 +38,7 @@ export const resultsRouter = {
.route({
method: "GET",
path: "/v1/typebots/{typebotId}/results",
operationId: "results-getResults",
summary: "List results ordered by descending creation date",
tags: ["Results"],
})
@ -54,6 +55,7 @@ export const resultsRouter = {
.route({
method: "GET",
path: "/v1/typebots/{typebotId}/results/{resultId}",
operationId: "results-getResult",
summary: "Get result by id",
tags: ["Results"],
})
@ -69,6 +71,7 @@ export const resultsRouter = {
.route({
method: "GET",
path: "/v1/typebots/{typebotId}/results/{resultId}/transcript",
operationId: "results-getResultTranscript",
summary: "Get result transcript",
tags: ["Results"],
})
@ -93,6 +96,7 @@ export const resultsRouter = {
.route({
method: "GET",
path: "/v1/typebots/{typebotId}/results/{resultId}/logs",
operationId: "results-getResultLogs",
summary: "List result logs",
tags: ["Results"],
})
@ -104,6 +108,7 @@ export const resultsRouter = {
.route({
method: "DELETE",
path: "/v1/typebots/{typebotId}/results",
operationId: "results-deleteResults",
summary: "Delete results",
tags: ["Results"],
})

View File

@ -19,6 +19,7 @@ export const themeRouter = {
.route({
method: "GET",
path: "/v1/themeTemplates",
operationId: "theme-listThemeTemplates",
summary: "List theme templates",
tags: ["Theme template"],
})
@ -40,6 +41,7 @@ export const themeRouter = {
.route({
method: "PUT",
path: "/v1/themeTemplates/{themeTemplateId}",
operationId: "theme-saveThemeTemplate",
summary: "Save theme template",
tags: ["Theme template"],
})
@ -55,6 +57,7 @@ export const themeRouter = {
.route({
method: "DELETE",
path: "/v1/themeTemplates/{themeTemplateId}",
operationId: "theme-deleteThemeTemplate",
summary: "Delete a theme template",
tags: ["Theme template"],
})

View File

@ -60,6 +60,7 @@ const createTypebot = authenticatedProcedure
.route({
method: "POST",
path: "/v1/typebots",
operationId: "typebot-createTypebot",
tags: ["Typebot"],
summary: "Create a typebot",
})
@ -71,6 +72,7 @@ const getTypebot = publicProcedureWithOptionalUser
.route({
method: "GET",
path: "/v1/typebots/{typebotId}",
operationId: "typebot-getTypebot",
tags: ["Typebot"],
summary: "Get a typebot",
})
@ -87,6 +89,7 @@ const updateTypebot = authenticatedProcedure
.route({
method: "PATCH",
path: "/v1/typebots/{typebotId}",
operationId: "typebot-updateTypebot",
tags: ["Typebot"],
summary: "Update a typebot",
})
@ -98,6 +101,7 @@ const deleteTypebot = authenticatedProcedure
.route({
method: "DELETE",
path: "/v1/typebots/{typebotId}",
operationId: "typebot-deleteTypebot",
summary: "Delete a typebot",
tags: ["Typebot"],
})
@ -109,6 +113,7 @@ const listTypebots = authenticatedProcedure
.route({
method: "GET",
path: "/v1/typebots",
operationId: "typebot-listTypebots",
summary: "List typebots",
tags: ["Typebot"],
})
@ -139,6 +144,7 @@ const publishTypebot = authenticatedProcedure
.route({
method: "POST",
path: "/v1/typebots/{typebotId}/publish",
operationId: "typebot-publishTypebot",
summary: "Publish a typebot",
tags: ["Typebot"],
})
@ -155,6 +161,7 @@ const unpublishTypebot = authenticatedProcedure
.route({
method: "POST",
path: "/v1/typebots/{typebotId}/unpublish",
operationId: "typebot-unpublishTypebot",
summary: "Unpublish a typebot",
tags: ["Typebot"],
})
@ -166,6 +173,7 @@ const getPublishedTypebot = authenticatedProcedure
.route({
method: "GET",
path: "/v1/typebots/{typebotId}/publishedTypebot",
operationId: "typebot-getPublishedTypebot",
summary: "Get published typebot",
tags: ["Typebot"],
})
@ -190,6 +198,7 @@ const importTypebot = authenticatedProcedure
.route({
method: "POST",
path: "/v1/typebots/import",
operationId: "typebot-importTypebot",
summary: "Import a typebot",
tags: ["Typebot"],
})

View File

@ -28,6 +28,7 @@ export const userRouter = {
.route({
method: "PATCH",
path: "/v1/users/me",
operationId: "user-update",
tags: ["User"],
})
.handler(handleUpdateUser),
@ -36,6 +37,7 @@ export const userRouter = {
.route({
method: "GET",
path: "/v1/users/me/api-tokens",
operationId: "user-listApiTokens",
tags: ["User"],
})
.output(
@ -49,6 +51,7 @@ export const userRouter = {
.route({
method: "POST",
path: "/v1/users/me/api-tokens",
operationId: "user-createApiToken",
tags: ["User"],
})
.input(createApiTokenInputSchema)
@ -63,6 +66,7 @@ export const userRouter = {
.route({
method: "DELETE",
path: "/v1/users/me/api-tokens/{tokenId}",
operationId: "user-deleteApiToken",
tags: ["User"],
})
.input(deleteApiTokenInputSchema)

View File

@ -57,6 +57,7 @@ export const workspaceRouter = {
.route({
method: "GET",
path: "/v1/workspaces",
operationId: "workspace-listWorkspaces",
summary: "List workspaces",
tags: ["Workspace"],
})
@ -78,6 +79,7 @@ export const workspaceRouter = {
.route({
method: "GET",
path: "/v1/workspaces/{workspaceId}",
operationId: "workspace-getWorkspace",
summary: "Get workspace",
tags: ["Workspace"],
})
@ -94,6 +96,7 @@ export const workspaceRouter = {
.route({
method: "POST",
path: "/v1/workspaces",
operationId: "workspace-createWorkspace",
summary: "Create workspace",
tags: ["Workspace"],
})
@ -120,6 +123,7 @@ export const workspaceRouter = {
.route({
method: "PATCH",
path: "/v1/workspaces/{workspaceId}",
operationId: "workspace-updateWorkspace",
summary: "Update workspace",
tags: ["Workspace"],
})
@ -135,6 +139,7 @@ export const workspaceRouter = {
.route({
method: "DELETE",
path: "/v1/workspaces/{workspaceId}",
operationId: "workspace-deleteWorkspace",
summary: "Delete workspace",
tags: ["Workspace"],
})
@ -146,6 +151,7 @@ export const workspaceRouter = {
.route({
method: "GET",
path: "/v1/workspaces/{workspaceId}/members",
operationId: "workspace-listMembersInWorkspace",
summary: "List members in workspace",
tags: ["Workspace"],
})

View File

@ -0,0 +1,20 @@
import { writeFileSync } from "node:fs";
import { OpenAPIGenerator } from "@orpc/openapi";
import {
openApiSchemaConverters,
openApiSpecGenerateOptions,
} from "@/app/api/openApiSpecGenerateOptions";
import { appRouter } from "@/app/api/router";
writeFileSync(
new URL("../../../docs/openapi/builder.json", import.meta.url),
JSON.stringify(
await new OpenAPIGenerator({
schemaConverters: openApiSchemaConverters,
}).generate(appRouter, openApiSpecGenerateOptions),
null,
2,
),
);
process.exit(0);

View File

@ -0,0 +1,33 @@
import { readFileSync } from "node:fs";
import { plugin } from "bun";
// OpenAPI generation only traverses contracts and never runs handlers, so
// runtime-only dependencies can be stubbed:
// - isolated-vm is a Node-only native addon that can't load under Bun.
// - The Prisma client is instantiated at import time and requires a
// DATABASE_URL whose protocol matches the generated client's provider.
plugin({
name: "isolated-vm-stub",
setup(build) {
build.module("isolated-vm", () => ({
exports: {
default: {},
Isolate: class {},
Context: class {},
Reference: class {},
},
loader: "object",
}));
},
});
process.env.DATABASE_URL = readFileSync(
new URL(
"../../../../node_modules/.prisma/client/schema.prisma",
import.meta.url,
),
"utf-8",
).match(/provider\s*=\s*"mysql"/)
? "mysql://user:password@localhost:3306/typebot"
: "postgresql://user:password@localhost:5432/typebot";

View File

@ -1,4 +1,4 @@
---
title: 'Continue chat'
openapi: POST /v1/sessions/{sessionId}/continueChat
openapi: /openapi/viewer.json POST /v1/sessions/{sessionId}/continueChat
---

View File

@ -1,6 +1,6 @@
---
title: 'Generate upload URL'
openapi: POST /v3/generate-upload-url
openapi: /openapi/viewer.json POST /v3/generate-upload-url
---
The `presignedUrl` and `formData` fields can be then used to upload the file to the S3 bucket, directly from the browser if necessary. Here is an example:

View File

@ -1,4 +1,4 @@
---
title: 'Start preview chat'
openapi: POST /v1/typebots/{typebotId}/preview/startChat
openapi: /openapi/viewer.json POST /v1/typebots/{typebotId}/preview/startChat
---

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -43,6 +43,24 @@
"cwd": "apps/viewer",
"command": "playwright test"
}
},
"generate-openapi": {
"executor": "nx:run-commands",
"options": {
"cwd": "apps/viewer",
"command": "SKIP_ENV_CHECK=true bun --preload ./src/scripts/preloadOpenApiGenerationStubs.ts src/scripts/generateOpenApiSpec.ts"
},
"cache": true,
"inputs": [
"default",
"^default",
{
"runtime": "grep -m1 -Eo 'provider *= *\"[a-z]+\"' node_modules/.prisma/client/schema.prisma"
}
],
"outputs": [
"{workspaceRoot}/apps/docs/openapi/viewer.json"
]
}
}
},

View File

@ -2,18 +2,14 @@ import { OpenAPIHandler } from "@orpc/openapi/fetch";
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
import { onError } from "@orpc/server";
import { CORSPlugin } from "@orpc/server/plugins";
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import { authenticateWithBearerToken } from "@typebot.io/auth/helpers/authenticateWithBearerToken";
import {
audioMessageSchema,
commandMessageSchema,
startFromEventSchema,
startFromGroupSchema,
textMessageSchema,
} from "@typebot.io/chat-api/schemas";
import { createContext } from "@typebot.io/config/orpc/viewer/context";
import { logServerRequest } from "@typebot.io/telemetry/logServerRequest";
import { after, type NextRequest } from "next/server";
import {
openApiSchemaConverters,
openApiSpecGenerateOptions,
} from "../openApiSpecGenerateOptions";
import { appRouter } from "./router";
type RouteContext<_T> = {
@ -62,47 +58,8 @@ const handler = new OpenAPIHandler(appRouter, {
new CORSPlugin(),
new OpenAPIReferencePlugin({
specPath: "/openapi.json",
schemaConverters: [new ZodToJsonSchemaConverter()],
specGenerateOptions: {
filter: ({ contract }) =>
Boolean(
contract["~orpc"].route.method &&
!contract["~orpc"].route.deprecated,
),
info: {
title: "Chat API",
version: "3.0.0",
},
servers: [{ url: "https://typebot.io/api" }],
externalDocs: {
url: "https://docs.typebot.com/api-reference",
},
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
},
},
},
commonSchemas: {
Text: {
schema: textMessageSchema,
},
Audio: {
schema: audioMessageSchema,
},
Command: {
schema: commandMessageSchema,
},
Group: {
schema: startFromGroupSchema,
},
Event: {
schema: startFromEventSchema,
},
},
},
schemaConverters: openApiSchemaConverters,
specGenerateOptions: openApiSpecGenerateOptions,
}),
],
});

View File

@ -0,0 +1,55 @@
import type { OpenAPIGeneratorGenerateOptions } from "@orpc/openapi";
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import {
audioMessageSchema,
commandMessageSchema,
startFromEventSchema,
startFromGroupSchema,
textMessageSchema,
} from "@typebot.io/chat-api/schemas";
import { EffectSchemaToJsonSchemaConverter } from "@typebot.io/config/orpc/EffectSchemaToJsonSchemaConverter";
export const openApiSchemaConverters = [
new ZodToJsonSchemaConverter(),
new EffectSchemaToJsonSchemaConverter(),
];
export const openApiSpecGenerateOptions: OpenAPIGeneratorGenerateOptions = {
filter: ({ contract }) =>
Boolean(
contract["~orpc"].route.method && !contract["~orpc"].route.deprecated,
),
info: {
title: "Chat API",
version: "3.0.0",
},
servers: [{ url: "https://typebot.io/api" }],
externalDocs: {
url: "https://docs.typebot.com/api-reference",
},
components: {
securitySchemes: {
Authorization: {
type: "http",
scheme: "bearer",
},
},
},
commonSchemas: {
Text: {
schema: textMessageSchema,
},
Audio: {
schema: audioMessageSchema,
},
Command: {
schema: commandMessageSchema,
},
Group: {
schema: startFromGroupSchema,
},
Event: {
schema: startFromEventSchema,
},
},
};

View File

@ -0,0 +1,20 @@
import { writeFileSync } from "node:fs";
import { OpenAPIGenerator } from "@orpc/openapi";
import { appRouter } from "@/app/api/[[...rest]]/router";
import {
openApiSchemaConverters,
openApiSpecGenerateOptions,
} from "@/app/api/openApiSpecGenerateOptions";
writeFileSync(
new URL("../../../docs/openapi/viewer.json", import.meta.url),
JSON.stringify(
await new OpenAPIGenerator({
schemaConverters: openApiSchemaConverters,
}).generate(appRouter, openApiSpecGenerateOptions),
null,
2,
),
);
process.exit(0);

View File

@ -0,0 +1,33 @@
import { readFileSync } from "node:fs";
import { plugin } from "bun";
// OpenAPI generation only traverses contracts and never runs handlers, so
// runtime-only dependencies can be stubbed:
// - isolated-vm is a Node-only native addon that can't load under Bun.
// - The Prisma client is instantiated at import time and requires a
// DATABASE_URL whose protocol matches the generated client's provider.
plugin({
name: "isolated-vm-stub",
setup(build) {
build.module("isolated-vm", () => ({
exports: {
default: {},
Isolate: class {},
Context: class {},
Reference: class {},
},
loader: "object",
}));
},
});
process.env.DATABASE_URL = readFileSync(
new URL(
"../../../../node_modules/.prisma/client/schema.prisma",
import.meta.url,
),
"utf-8",
).match(/provider\s*=\s*"mysql"/)
? "mysql://user:password@localhost:3306/typebot"
: "postgresql://user:password@localhost:5432/typebot";

View File

@ -29,7 +29,7 @@
"scripts": {
"postinstall": "cd packages/env && bun run compile && nx db:generate prisma",
"prepare": "husky && effect-language-service patch",
"pre-commit": "nx affected -t format-and-lint,lint-repo,check-broken-links,test --parallel=4",
"pre-commit": "bash scripts/assert-embeds-version-bumped.sh && nx affected -t format-and-lint,lint-repo,check-broken-links,test,generate-openapi --parallel=4 && git add apps/docs/openapi",
"dev": "nx run-many --configuration=development -t dev,watch-deps -p builder,viewer,workflows,@typebot.io/partykit",
"format-and-lint": "biome check .",
"format-and-lint:fix": "biome check . --write --unsafe",

View File

@ -57,6 +57,7 @@ export const billingRouter = {
.route({
method: "GET",
path: "/v1/billing/usage",
operationId: "billing-getUsage",
summary: "Get current plan usage",
tags: ["Billing"],
})
@ -68,6 +69,7 @@ export const billingRouter = {
.route({
method: "GET",
path: "/v1/billing/invoices",
operationId: "billing-listInvoices",
summary: "List invoices",
tags: ["Billing"],
})

View File

@ -56,6 +56,7 @@ export const fileUploadViewerRouter = {
.route({
method: "POST",
path: "/v3/generate-upload-url",
operationId: "generateUploadUrl",
summary: "Generate upload URL",
description: "Used to upload anything from the client to S3 bucket",
tags: ["File upload"],

View File

@ -70,6 +70,7 @@ export const chatRouter = {
.route({
method: "POST",
path: "/v1/typebots/{publicId}/startChat",
operationId: "startChat",
summary: "Start chat",
tags: ["Chat"],
})
@ -80,6 +81,7 @@ export const chatRouter = {
.route({
method: "POST",
path: "/v1/sessions/{sessionId}/continueChat",
operationId: "continueChat",
summary: "Continue chat",
tags: ["Chat"],
})
@ -90,6 +92,7 @@ export const chatRouter = {
.route({
method: "POST",
path: "/v2/sessions/{sessionId}/clientLogs",
operationId: "saveClientLogs",
summary: "Save logs",
tags: ["Chat"],
})
@ -100,6 +103,7 @@ export const chatRouter = {
.route({
method: "POST",
path: "/v1/typebots/{typebotId}/preview/startChat",
operationId: "startChatPreview",
summary: "Start preview chat",
description:
'Use this endpoint to test your bot. The answers will not be saved. And some blocks like "Send email" will be skipped.',
@ -112,6 +116,7 @@ export const chatRouter = {
.route({
method: "POST",
path: "/v1/sessions/{sessionId}/updateTypebot",
operationId: "updateTypebotInSession",
summary: "Update typebot in session",
description:
"Update chat session with latest typebot modifications. This is useful when you want to update the typebot in an ongoing session after making changes to it.",

View File

@ -0,0 +1,32 @@
import type { AnySchema } from "@orpc/contract";
import type {
ConditionalSchemaConverter,
JSONSchema,
SchemaConvertOptions,
} from "@orpc/openapi";
import { Schema } from "effect";
export class EffectSchemaToJsonSchemaConverter
implements ConditionalSchemaConverter
{
condition(schema: AnySchema | undefined): boolean {
return schema !== undefined && schema["~standard"].vendor === "effect";
}
convert(
schema: AnySchema | undefined,
_options: SchemaConvertOptions,
): [required: boolean, jsonSchema: JSONSchema] {
if (!Schema.isSchema(schema))
throw new Error(
"Schemas with the effect vendor are expected to be Effect schemas",
);
const document = Schema.toJsonSchemaDocument(schema);
return [
true,
Object.keys(document.definitions).length > 0
? { ...document.schema, $defs: document.definitions }
: document.schema,
];
}
}

View File

@ -4,7 +4,13 @@ import * as Sentry from "@sentry/nextjs";
import { env } from "@typebot.io/env";
import type { Context } from "./context";
export const os = baseOs.$context<Context>();
export const os = baseOs.$context<Context>().errors({
BAD_REQUEST: {},
UNAUTHORIZED: {},
FORBIDDEN: {},
NOT_FOUND: {},
INTERNAL_SERVER_ERROR: {},
});
const webhookUrlPaths = [
"builderWhatsAppRouter/previewWebhookProcedure",
@ -61,7 +67,7 @@ const requireAuth = oo.spec(
});
}),
{
security: [{ bearerAuth: [] }],
security: [{ Authorization: [] }],
},
);

View File

@ -3,7 +3,13 @@ import { os as baseOs, ORPCError } from "@orpc/server";
import * as Sentry from "@sentry/nextjs";
import type { Context } from "./context";
export const os = baseOs.$context<Context>();
export const os = baseOs.$context<Context>().errors({
BAD_REQUEST: {},
UNAUTHORIZED: {},
FORBIDDEN: {},
NOT_FOUND: {},
INTERNAL_SERVER_ERROR: {},
});
const webhookUrlPaths = [
"chatWhatsAppRouter/productionWebhookProcedure",
@ -90,7 +96,7 @@ const requireAuth = oo.spec(
});
}),
{
security: [{ bearerAuth: [] }],
security: [{ Authorization: [] }],
},
);

View File

@ -173,7 +173,9 @@ const toPascalCase = (str: string): string =>
str
.split(/[-_\s]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("");
.join("")
// OpenAPI component keys must match ^[a-zA-Z0-9._-]+$
.replace(/[^a-zA-Z0-9._-]/g, "");
type ZodObjectAny = z.ZodObject<z.ZodRawShape>;

View File

@ -56,6 +56,7 @@ export const chatWhatsAppRouter = {
.route({
method: "GET",
path: "/v1/workspaces/{workspaceId}/whatsapp/{credentialsId}/webhook",
operationId: "whatsAppRouter-subscribeWebhook",
summary: "Subscribe webhook",
tags: ["WhatsApp"],
})
@ -89,6 +90,7 @@ export const builderWhatsAppRouter = {
.route({
method: "POST",
path: "/v1/typebots/{typebotId}/whatsapp/start-preview",
operationId: "whatsApp-startWhatsAppPreview",
summary: "Start preview",
tags: ["WhatsApp"],
})
@ -104,6 +106,7 @@ export const builderWhatsAppRouter = {
.route({
method: "GET",
path: "/v1/whatsapp/preview/webhook",
operationId: "whatsApp-subscribePreviewWebhook",
summary: "Subscribe webhook",
tags: ["WhatsApp"],
})

View File

@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Fails when files under packages/embeds/js or packages/embeds/react changed
# without bumping both package versions to the same value: they are published
# in lockstep since @typebot.io/react bundles @typebot.io/js.
#
# Changed files are detected against the merge base with main (or
# VERSION_BUMP_BASE_REF), while versions must be strictly greater than the
# ones at the base ref tip so a bump that races another merged release is
# rejected too.
set -euo pipefail
base_ref="${VERSION_BUMP_BASE_REF:-$(git rev-parse --verify -q origin/main >/dev/null && echo origin/main || echo main)}"
merge_base=$(git merge-base HEAD "$base_ref")
changed() {
git diff --name-only "$merge_base" -- "$1" | grep -q .
}
bumped() {
local base_version current_version
base_version=$(git show "$base_ref:packages/embeds/$1/package.json" | extract_version)
current_version=$(current_version_of "$1")
node -e "
const [base, current] = process.argv.slice(1).map((v) => v.split('.').map(Number));
const isGreater = current[0] !== base[0] ? current[0] > base[0] : current[1] !== base[1] ? current[1] > base[1] : current[2] > base[2];
process.exit(isGreater ? 0 : 1);
" "$base_version" "$current_version"
}
current_version_of() {
extract_version <"packages/embeds/$1/package.json"
}
extract_version() {
node -e "let s='';process.stdin.on('data',(c)=>{s+=c}).on('end',()=>{console.log(JSON.parse(s).version)})"
}
require_bump() {
if bumped "$1"; then
echo "@typebot.io/$1: version bump detected"
else
echo "Embed sources changed but @typebot.io/$1 was not bumped above its version on $base_ref. Bump the versions in both packages/embeds/js and packages/embeds/react package.json files: they are published in lockstep since @typebot.io/react bundles @typebot.io/js."
failed=1
fi
}
failed=0
if changed packages/embeds/js || changed packages/embeds/react; then
require_bump js
require_bump react
if [ "$(current_version_of js)" != "$(current_version_of react)" ]; then
echo "@typebot.io/js ($(current_version_of js)) and @typebot.io/react ($(current_version_of react)) must be bumped to the same version: they are published in lockstep."
failed=1
fi
fi
exit $failed