Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/api-v3-spec.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: API v3 Spec

on:
pull_request:
paths:
- "docs/api-v3-reference/**"
- ".github/workflows/api-v3-spec.yml"

concurrency:
group: api-v3-spec-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
validate:
name: Lint source & verify bundle freshness
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false

- name: Setup Node.js 22.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x

- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0

# Root importer only: installs the lockfile-pinned @redocly/cli (and other root devDeps)
# without building the whole workspace. bundle.mjs falls back to pinned npx when absent.
- name: Install spec tooling (root dev dependencies)
run: pnpm install --frozen-lockfile --ignore-scripts --filter formbricks

- name: Lint OpenAPI v3 source tree
run: node docs/api-v3-reference/scripts/bundle.mjs --lint

- name: Verify committed bundle is in sync with src/
run: node docs/api-v3-reference/scripts/bundle.mjs --check
2,729 changes: 1,505 additions & 1,224 deletions docs/api-v3-reference/openapi.yml

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions docs/api-v3-reference/redocly.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Lint configuration for the v3 OpenAPI source tree (used by `pnpm api:v3:lint` via scripts/bundle.mjs).
# Known accepted findings live in .redocly.lint-ignore.yaml; anything new fails CI as an error.
extends:
- recommended
rules:
# Parity with the v2 spec, which also ships without an info.license field.
info-license: off
# Raised from warn so NEW ambiguous paths fail CI. The six known overlaps between
# /api/v3/workflows/runs/{runId} and /api/v3/workflows/{workflowId}/* are a deliberate
# ENG-1101 decision (run ids are globally unique; cuid2 never equals the literal "runs")
# and are listed in .redocly.lint-ignore.yaml.
no-ambiguous-paths: error
# Raised from warn: response examples must validate against their schemas.
no-invalid-media-type-examples: error
109 changes: 109 additions & 0 deletions docs/api-v3-reference/scripts/bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env node
/* Single entry point for the v3 OpenAPI spec tooling (pins @redocly/cli in exactly one place).
*
* node docs/api-v3-reference/scripts/bundle.mjs # regenerate docs/api-v3-reference/openapi.yml
* node docs/api-v3-reference/scripts/bundle.mjs --check # fail if the committed artifact is stale
* node docs/api-v3-reference/scripts/bundle.mjs --lint # lint the multi-file source tree
*
* Mintlify only resolves $refs inside a single document, so the committed openapi.yml must stay a
* self-contained bundle. The --check mode compares canonical JSON (sorted keys) so comments and
* YAML formatting never cause false positives. Lint runs with docs/api-v3-reference as cwd so
* redocly.yaml and .redocly.lint-ignore.yaml are picked up. Stdlib only — safe to run in CI
* without installing workspace dependencies.
*/
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

// Fallback pin for environments without the workspace install. Keep in sync with the
// @redocly/cli entry in the root package.json devDependencies (the preferred, lockfile-pinned path).
const REDOCLY_NPX_FALLBACK = "@redocly/cli@1.34.3";
const here = dirname(fileURLToPath(import.meta.url));
const srcRoot = resolve(here, "../src/openapi.yml");
const artifact = resolve(here, "../openapi.yml");

const localRedoclyBin = (() => {
try {
const pkgPath = createRequire(import.meta.url).resolve("@redocly/cli/package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin.redocly;
return join(dirname(pkgPath), binRel);
} catch {
return null;
}
})();

const HEADER = `# GENERATED FILE — do not edit. Source of truth: docs/api-v3-reference/src/ (one file per path/schema).
# Regenerate with \`pnpm api:v3:bundle\`; CI verifies freshness with \`pnpm api:v3:check\`.
# V3 API — Surveys and Workflows extension (hand-maintained source; not produced by generate-api-specs).
`;

const redocly = (args, opts = {}) =>
localRedoclyBin
? execFileSync(process.execPath, [localRedoclyBin, ...args], {
stdio: ["ignore", "pipe", "inherit"],
...opts,
})
: execFileSync("npx", ["-y", REDOCLY_NPX_FALLBACK, ...args], {
stdio: ["ignore", "pipe", "inherit"],
...opts,
});

// Code-unit comparison, NOT localeCompare: the canonical form must be byte-identical across
// machines and locales (RFC 8785-style ordering), and localeCompare depends on the ICU build.
const compareCodeUnits = (a, b) => {
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
return 0;
};

const sortKeys = (value) => {
if (Array.isArray(value)) {
return value.map(sortKeys);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.keys(value)
.sort(compareCodeUnits)
.map((key) => [key, sortKeys(value[key])])
);
}
return value;
};

const canonical = (jsonFile) => JSON.stringify(sortKeys(JSON.parse(readFileSync(jsonFile, "utf8"))));

if (process.argv.includes("--lint")) {
try {
redocly(["lint", "src/openapi.yml"], { stdio: "inherit", cwd: resolve(here, "..") });
} catch (error) {
process.exit(typeof error.status === "number" ? error.status : 1);
}
} else if (process.argv.includes("--check")) {
const tmp = mkdtempSync(join(tmpdir(), "v3-spec-"));
try {
redocly(["bundle", srcRoot, "-o", join(tmp, "from-src.json"), "--ext", "json"]);
redocly(["bundle", artifact, "-o", join(tmp, "from-artifact.json"), "--ext", "json"]);
if (canonical(join(tmp, "from-src.json")) !== canonical(join(tmp, "from-artifact.json"))) {
console.error(
"✗ docs/api-v3-reference/openapi.yml is out of sync with docs/api-v3-reference/src/.\n" +
" Run `pnpm api:v3:bundle` and commit the result."
);
process.exit(1);
}
console.log("✓ openapi.yml bundle is in sync with src/.");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
} else {
redocly(["bundle", srcRoot, "-o", artifact]);
writeFileSync(artifact, HEADER + readFileSync(artifact, "utf8"));
console.log(`✓ bundled src/ → ${artifact}`);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
in: query
name: cursor
schema:
type: string
description: >-
Opaque cursor returned as `meta.nextCursor` from the previous page. Omit on
the first request.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
in: query
name: limit
schema:
type: integer
minimum: 1
maximum: 100
default: 20
description: Page size (max 100).
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
in: query
name: workspaceId
required: true
schema:
type: string
format: cuid2
description: Workspace identifier. This is the canonical container ID for v3 APIs.
16 changes: 16 additions & 0 deletions docs/api-v3-reference/src/components/responses/V3BadRequest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
description: >-
Bad Request — malformed JSON, invalid query/body/params, duplicate name, or
unsupported field.
headers:
X-Request-Id:
schema:
type: string
description: Request correlation ID
Cache-Control:
schema:
type: string
example: private, no-store
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yml
17 changes: 17 additions & 0 deletions docs/api-v3-reference/src/components/responses/V3Conflict.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
description: >-
Conflict — an `Idempotency-Key` retry arrived while the original request was still being
processed (IETF `draft-ietf-httpapi-idempotency-key-header` semantics). Retry after the original
request completes to receive its stored result.
headers:
X-Request-Id:
schema:
type: string
description: Request correlation ID
Cache-Control:
schema:
type: string
example: private, no-store
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yml
16 changes: 16 additions & 0 deletions docs/api-v3-reference/src/components/responses/V3Forbidden.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
description: >-
Forbidden — no workspace access, or resource does not exist (404 not used;
avoids existence leak).
headers:
X-Request-Id:
schema:
type: string
description: Request correlation ID
Cache-Control:
schema:
type: string
example: private, no-store
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
description: Internal Server Error.
headers:
X-Request-Id:
schema:
type: string
description: Request correlation ID
Cache-Control:
schema:
type: string
example: private, no-store
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
description: Rate limit exceeded.
headers:
X-Request-Id:
schema:
type: string
description: Request correlation ID
Cache-Control:
schema:
type: string
example: private, no-store
Retry-After:
schema:
type: integer
description: Seconds until the current rate-limit window resets.
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yml
14 changes: 14 additions & 0 deletions docs/api-v3-reference/src/components/responses/V3Unauthorized.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
description: Not authenticated (no valid session or API key).
headers:
X-Request-Id:
schema:
type: string
description: Request correlation ID
Cache-Control:
schema:
type: string
example: private, no-store
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
description: >-
Unprocessable Content — semantic validation failed or the requested lifecycle
transition is invalid.
headers:
X-Request-Id:
schema:
type: string
description: Request correlation ID
Cache-Control:
schema:
type: string
example: private, no-store
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
type: string
enum:
- de-DE
- en-US
- es-ES
- fr-FR
- hu-HU
- ja-JP
- nl-NL
- pt-BR
- pt-PT
- ro-RO
- ru-RU
- sv-SE
- tr-TR
- zh-Hans-CN
- zh-Hant-TW
description: Supported app locale code that AI survey creation can return.
example: en-US
45 changes: 45 additions & 0 deletions docs/api-v3-reference/src/components/schemas/CreateSurveyBlock.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
type: object
description: >
Block accepted by `POST /api/v3/surveys`. `id` may be omitted and will be
generated by the

server. Provide an explicit cuid2 id when logic in the same request needs to
jump to this block.

For normal sequential flow, omit `logic` and `logicFallback`. `logicFallback`
is only valid

when this same block has at least one `logic` rule.
required:
- name
- elements
properties:
id:
type: string
format: cuid2
description: Optional stable block id. Generated when omitted.
name:
type: string
minLength: 1
elements:
type: array
minItems: 1
items:
$ref: ./SurveyElement.yml
logic:
type: array
items:
$ref: ./SurveyBlockLogic.yml
logicFallback:
type: string
format: cuid2
description: >
Block or ending id used when no logic condition matches. Only valid when
this same block

has at least one `logic` rule; omit it for normal sequential flow.
buttonLabel:
$ref: ./TranslatableText.yml
backButtonLabel:
$ref: ./TranslatableText.yml
additionalProperties: false
Loading
Loading