feat(asyncapi): generate ts types according asyncapi
This commit is contained in:
@@ -26,6 +26,7 @@ jobs:
|
||||
wasm: ${{ steps.filter.outputs.wasm }}
|
||||
plugins: ${{ steps.filter.outputs.plugins }}
|
||||
migrations: ${{ steps.filter.outputs.migrations }}
|
||||
realtime_spec: ${{ steps.filter.outputs.realtime_spec }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
@@ -52,6 +53,13 @@ jobs:
|
||||
- 'src/application/adapters/plugin_user_lifecycle_hook.rs'
|
||||
migrations:
|
||||
- 'migrations/**'
|
||||
realtime_spec:
|
||||
- 'src/application/ports/realtime_ports.rs'
|
||||
- 'src/bin/generate-asyncapi.rs'
|
||||
- 'resources/gen/asyncapi.json'
|
||||
- 'frontend/scripts/gen-realtime-types.mjs'
|
||||
- 'frontend/src/lib/generated/realtime/**'
|
||||
- 'frontend/package.json'
|
||||
|
||||
frontend-check:
|
||||
name: Frontend — svelte-check, ESLint, Stylelint, Prettier
|
||||
@@ -80,6 +88,62 @@ jobs:
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
|
||||
# Regenerates the AsyncAPI spec and its TypeScript projection from
|
||||
# scratch, then fails the PR if either output drifts from what was
|
||||
# committed. Same discipline as the OpenAPI + wasm-fixture approach
|
||||
# elsewhere in this file — the wire spec is a compile-time artefact
|
||||
# of the Rust source (`realtime_ports.rs`), and the TS DTOs are a
|
||||
# compile-time artefact of the spec, so both must be reproducible.
|
||||
#
|
||||
# Scoped by the `realtime_spec` path filter so a PR that doesn't
|
||||
# touch the wire (or its generator scripts, or the Modelina version)
|
||||
# skips this job entirely. Needs BOTH Rust and Node toolchains, so
|
||||
# it's slightly heavier than a single-toolchain job — the filter
|
||||
# keeps it off the hot path.
|
||||
realtime-spec-drift:
|
||||
name: Realtime spec — AsyncAPI + TypeScript DTO drift
|
||||
needs: changes
|
||||
if: needs.changes.outputs.realtime_spec == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Rust for `just asyncapi` — the JSON spec is built by
|
||||
# `cargo run --features dev_tools --bin generate-asyncapi`.
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# Node for `just asyncapi-ts` — Modelina projects the spec into
|
||||
# the FE `src/lib/generated/realtime/` folder.
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 26.3.0
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install frontend deps
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
- name: Regenerate AsyncAPI spec
|
||||
# `just asyncapi` = `cargo run --features dev_tools --bin generate-asyncapi`
|
||||
run: cargo run --features dev_tools --bin generate-asyncapi
|
||||
- name: Regenerate TS DTOs (Modelina)
|
||||
working-directory: frontend
|
||||
run: npm run gen:realtime
|
||||
- name: Fail if committed files drifted
|
||||
# A non-empty diff means a contributor edited the Rust wire
|
||||
# source (or Modelina config) without regenerating, or hand-
|
||||
# edited the generated files. Either is a bug; the message
|
||||
# below points at the fix.
|
||||
run: |
|
||||
if ! git diff --exit-code \
|
||||
resources/gen/asyncapi.json \
|
||||
frontend/src/lib/generated/realtime/; then
|
||||
echo ""
|
||||
echo "::error::Realtime spec drift: the committed files differ from what the"
|
||||
echo "::error::generator produces from source. Run \`just asyncapi-ts\` locally"
|
||||
echo "::error::and commit the result — that recipe re-runs both stages."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fails the PR if a new sqlx migration file has a timestamp NOT strictly
|
||||
# greater than every migration already on the target branch. Guards
|
||||
# against the "two branches in flight, whoever merges second breaks
|
||||
|
||||
Generated
+4110
-3
File diff suppressed because it is too large
Load Diff
@@ -17,9 +17,11 @@
|
||||
"format": "prettier --write .",
|
||||
"test:unit": "LANG=C vitest run",
|
||||
"test:unit:watch": "LANG=C vitest",
|
||||
"test:unit:coverage": "rm -rf ../tests/e2e/.nyc_output_unit && LANG=C COVERAGE=1 vitest run"
|
||||
"test:unit:coverage": "rm -rf ../tests/e2e/.nyc_output_unit && LANG=C COVERAGE=1 vitest run",
|
||||
"gen:realtime": "node scripts/gen-realtime-types.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@asyncapi/modelina": "^5.5.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.66.0",
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env node
|
||||
// Realtime bus — TypeScript DTOs generated from `resources/gen/asyncapi.json`.
|
||||
//
|
||||
// Sits on the same axis as `resources/gen/openapi.json`: the wire spec
|
||||
// (authored by `cargo run --features dev_tools --bin generate-asyncapi`)
|
||||
// is the source of truth, and this script projects it into typed FE
|
||||
// interfaces so `lib/composables/useTopic.ts` and every folder-view
|
||||
// switch statement is compile-time exhaustive over the `rt.event` variants.
|
||||
//
|
||||
// Regenerate: `just asyncapi-ts` (or `npm run gen:realtime`).
|
||||
// CI is expected to run the same command and fail if the working tree is
|
||||
// dirty afterwards — same discipline `just openapi` follows.
|
||||
//
|
||||
// Design notes:
|
||||
// * `modelType: 'interface'` — plain records, not classes-with-getters.
|
||||
// Matches the FE codebase style (see `lib/api/types.ts`).
|
||||
// * Output goes to `src/lib/generated/realtime/` — a directory reserved
|
||||
// for auto-generated files. Never hand-edit anything inside.
|
||||
// * Every file gets a `AUTO-GENERATED` banner via a preset so a stray
|
||||
// edit is obvious at review time.
|
||||
// * Modelina auto-detects AsyncAPI 3.0 from the top-level `asyncapi`
|
||||
// field. No explicit input-type flag needed.
|
||||
|
||||
import { execFile as execFileCb } from 'node:child_process';
|
||||
import { readFile, readdir, rm, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { TypeScriptFileGenerator } from '@asyncapi/modelina';
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
// Anchor everything on this script's location so `just asyncapi-ts` from
|
||||
// the repo root and `npm run gen:realtime` from the frontend both work.
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const frontendRoot = resolve(__dirname, '..');
|
||||
const repoRoot = resolve(frontendRoot, '..');
|
||||
|
||||
const specPath = resolve(repoRoot, 'resources/gen/asyncapi.json');
|
||||
const outputDir = resolve(frontendRoot, 'src/lib/generated/realtime');
|
||||
|
||||
// Load the spec. Failing here means the wire spec hasn't been generated
|
||||
// yet — hint the operator at the right command.
|
||||
let spec;
|
||||
try {
|
||||
spec = JSON.parse(await readFile(specPath, 'utf8'));
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`gen-realtime-types: cannot read ${specPath}: ${err.message}\n` +
|
||||
`\nDid you run \`just asyncapi\` first? The Rust generator writes\n` +
|
||||
`resources/gen/asyncapi.json; this script consumes it.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Fresh output directory every run — no stale files from a schema that
|
||||
// was removed since last run. CI dirty-tree check catches drift both
|
||||
// ways (missing new + leftover old).
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const generator = new TypeScriptFileGenerator({
|
||||
// Plain interfaces, no class scaffolding. FE consumers use structural
|
||||
// types via `useTopic<...>` and plain object literals.
|
||||
modelType: 'interface',
|
||||
// Use inline types where possible (nested objects) rather than
|
||||
// generating a separate model for every anonymous subschema — keeps
|
||||
// the file count tractable.
|
||||
rawPropertyNames: true,
|
||||
presets: [
|
||||
{
|
||||
// File-level banner. `class` preset covers both class and
|
||||
// interface output in Modelina's TS generator.
|
||||
class: {
|
||||
self({ content }) {
|
||||
const banner =
|
||||
'// AUTO-GENERATED — do not edit by hand.\n' +
|
||||
'// Regenerate with `just asyncapi-ts` (which runs\n' +
|
||||
'// `node frontend/scripts/gen-realtime-types.mjs`).\n' +
|
||||
'// Source of truth: resources/gen/asyncapi.json,\n' +
|
||||
'// authored by the Rust `generate-asyncapi` binary.\n';
|
||||
return `${banner}${content}`;
|
||||
}
|
||||
},
|
||||
interface: {
|
||||
self({ content }) {
|
||||
const banner =
|
||||
'// AUTO-GENERATED — do not edit by hand.\n' +
|
||||
'// Regenerate with `just asyncapi-ts`.\n';
|
||||
return `${banner}${content}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Modelina auto-detects AsyncAPI 3.0 from the `asyncapi` root field.
|
||||
// `generateToFiles` writes one file per top-level model and returns the
|
||||
// list of models. Any generation error propagates up as a rejection.
|
||||
const models = await generator.generateToFiles(spec, outputDir, {
|
||||
moduleSystem: 'ESM'
|
||||
});
|
||||
|
||||
// Post-process for `verbatimModuleSyntax: true` — Modelina 5.x emits
|
||||
// pre-verbatim shapes (`import X from`, `export default X`) that
|
||||
// modern strict TS rejects. Two mechanical rewrites make the output
|
||||
// pass `svelte-check` under the frontend's tsconfig:
|
||||
//
|
||||
// 1. `import X from './X';` → `import type X from './X';`
|
||||
// 2. `export default X;` → `export type { X as default };`
|
||||
//
|
||||
// Both rewrites are safe because we run Modelina in `modelType:
|
||||
// 'interface'` mode — every top-level export is a type, and every
|
||||
// cross-file default import is a type import. If we ever add
|
||||
// value-emitting output (enums, const objects), tighten this.
|
||||
const files = await readdir(outputDir);
|
||||
let rewritten = 0;
|
||||
for (const f of files) {
|
||||
if (!f.endsWith('.ts')) continue;
|
||||
const path = resolve(outputDir, f);
|
||||
let content = await readFile(path, 'utf8');
|
||||
const before = content;
|
||||
// Match `import <Ident> from '<relative-path>';` anywhere in the
|
||||
// file. Modelina puts these at the top; `^...$` with the `m` flag
|
||||
// scopes to whole lines.
|
||||
content = content.replace(/^import (\w+) from '(\.\/[\w_]+)';$/gm, "import type $1 from '$2';");
|
||||
// Match the trailing `export default <Ident>;`. Turn it into the
|
||||
// type-only default-export form the TS spec accepts.
|
||||
content = content.replace(/^export default (\w+);$/gm, 'export type { $1 as default };');
|
||||
// Modelina-limitation escape hatch: bare `any` → `unknown`.
|
||||
//
|
||||
// JSON Schema has no way to express "any JSON value" in a way
|
||||
// Modelina projects into TypeScript cleanly — a schema of
|
||||
// `{"type": ["object", "array", "string", "number", "boolean",
|
||||
// "null"]}` (every JSON type) or an untyped `{}` still comes out
|
||||
// as `any` in Modelina's default output. The two sites this
|
||||
// affects are:
|
||||
//
|
||||
// * `RtErrorObject.data` — JSON-RPC 2.0 spec: "A Primitive or
|
||||
// Structured value that contains additional information."
|
||||
// * `RtSuccessResponseBody.result` — the generic base; each
|
||||
// specific method has its own typed result schema.
|
||||
//
|
||||
// Both are honestly open on the wire; the client checks a
|
||||
// discriminator (`code` / `method`) before narrowing.
|
||||
//
|
||||
// `unknown` is the correct TS type here — strict supertype of
|
||||
// `any`, forces the consumer to narrow. Every OTHER wart (`Map`,
|
||||
// `additionalProperties`, `AnonymousSchema_N`) MUST be fixed at
|
||||
// the AsyncAPI schema level per project convention; this rewrite
|
||||
// is the sole exception, gated to a Modelina defect.
|
||||
content = content.replace(/\bany\b/g, 'unknown');
|
||||
if (content !== before) {
|
||||
await writeFile(path, content);
|
||||
rewritten++;
|
||||
}
|
||||
}
|
||||
|
||||
// Guard against reintroducing anonymous schemas. Modelina falls back
|
||||
// to `AnonymousSchema_N` for every inline / nested schema in the
|
||||
// AsyncAPI spec that doesn't have an explicit component name — the
|
||||
// resulting TS files are unreadable in code review, opaque in imports,
|
||||
// and don't refactor safely. Every real schema should be hoisted to
|
||||
// `#/components/schemas/<Name>` in `src/bin/generate-asyncapi.rs` and
|
||||
// referenced via `$ref` instead of embedded inline.
|
||||
//
|
||||
// If this guard trips, look at which inline schema in the AsyncAPI
|
||||
// spec triggered it — usually a nested `params`, `result`, `error`,
|
||||
// or an inline `enum` array — and hoist it to a named schema.
|
||||
const anonymous = files.filter((f) => f.endsWith('.ts') && /^AnonymousSchema_/i.test(f));
|
||||
if (anonymous.length > 0) {
|
||||
console.error(
|
||||
`gen-realtime-types: FAIL — Modelina produced ${anonymous.length} ` +
|
||||
`AnonymousSchema_N file(s):`
|
||||
);
|
||||
for (const f of anonymous) console.error(` - ${f}`);
|
||||
console.error(
|
||||
`\nHoist the corresponding inline schema in\n` +
|
||||
` src/bin/generate-asyncapi.rs\n` +
|
||||
`to a named entry under \`components.schemas\` and\n` +
|
||||
`reference it via \`ref_schema("<Name>")\` instead of\n` +
|
||||
`embedding the object inline. Regenerate with\n` +
|
||||
` just asyncapi-ts\n` +
|
||||
`and the file count for this run should show 0 AnonymousSchema.\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Run the repo's Prettier over the generated output so the committed
|
||||
// files match the same style as hand-written code — otherwise
|
||||
// `npm run check`'s `prettier --check` step fails. Uses the local
|
||||
// binary so config (.prettierrc, plugins) applies. Run via npx to
|
||||
// stay agnostic of monorepo hoisting.
|
||||
try {
|
||||
await execFile('npx', ['--no-install', 'prettier', '--write', outputDir, '--log-level', 'warn'], {
|
||||
cwd: frontendRoot
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`gen-realtime-types: prettier --write failed: ${err.message}\n` +
|
||||
`The generated files may still be usable but will fail\n` +
|
||||
`\`npm run check\` on the prettier step. Fix prettier setup\n` +
|
||||
`(is @prettier installed in frontend/node_modules?) then\n` +
|
||||
`re-run \`just asyncapi-ts\`.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`gen-realtime-types: wrote ${models.length} model(s) to ${outputDir}` +
|
||||
` (rewrote ${rewritten} for verbatimModuleSyntax, 0 AnonymousSchema,` +
|
||||
` prettier-formatted)`
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface FileCreatedData {
|
||||
actor: string;
|
||||
file_id: string;
|
||||
name: string;
|
||||
parent_id: string;
|
||||
}
|
||||
export type { FileCreatedData as default };
|
||||
@@ -0,0 +1,8 @@
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface FileDeletedData {
|
||||
actor: string;
|
||||
file_id: string;
|
||||
parent_id: string;
|
||||
}
|
||||
export type { FileDeletedData as default };
|
||||
@@ -0,0 +1,10 @@
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface FileMovedData {
|
||||
actor: string;
|
||||
file_id: string;
|
||||
from: string;
|
||||
name: string;
|
||||
to: string;
|
||||
}
|
||||
export type { FileMovedData as default };
|
||||
@@ -0,0 +1,10 @@
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface FileRenamedData {
|
||||
actor: string;
|
||||
file_id: string;
|
||||
new_name: string;
|
||||
old_name: string;
|
||||
parent_id: string;
|
||||
}
|
||||
export type { FileRenamedData as default };
|
||||
@@ -0,0 +1,4 @@
|
||||
import type RtSuccessResponseBody from './RtSuccessResponseBody';
|
||||
import type RtErrorResponseBody from './RtErrorResponseBody';
|
||||
type Folder = RtSuccessResponseBody | RtErrorResponseBody;
|
||||
export type { Folder as default };
|
||||
@@ -0,0 +1,9 @@
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface FolderCreatedData {
|
||||
actor: string;
|
||||
folder_id: string;
|
||||
name: string;
|
||||
parent_id: string;
|
||||
}
|
||||
export type { FolderCreatedData as default };
|
||||
@@ -0,0 +1,8 @@
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface FolderDeletedData {
|
||||
actor: string;
|
||||
folder_id: string;
|
||||
parent_id: string;
|
||||
}
|
||||
export type { FolderDeletedData as default };
|
||||
@@ -0,0 +1,10 @@
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface FolderMovedData {
|
||||
actor: string;
|
||||
folder_id: string;
|
||||
from: string;
|
||||
name: string;
|
||||
to: string;
|
||||
}
|
||||
export type { FolderMovedData as default };
|
||||
@@ -0,0 +1,10 @@
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface FolderRenamedData {
|
||||
actor: string;
|
||||
folder_id: string;
|
||||
new_name: string;
|
||||
old_name: string;
|
||||
parent_id: string;
|
||||
}
|
||||
export type { FolderRenamedData as default };
|
||||
@@ -0,0 +1,14 @@
|
||||
enum RtErrorCode {
|
||||
MINUS_32001 = -32001,
|
||||
MINUS_32002 = -32002,
|
||||
MINUS_32003 = -32003,
|
||||
MINUS_32004 = -32004,
|
||||
MINUS_32005 = -32005,
|
||||
MINUS_32006 = -32006,
|
||||
MINUS_32007 = -32007,
|
||||
MINUS_32603 = -32603,
|
||||
MINUS_32600 = -32600,
|
||||
MINUS_32601 = -32601,
|
||||
MINUS_32602 = -32602
|
||||
}
|
||||
export type { RtErrorCode as default };
|
||||
@@ -0,0 +1,14 @@
|
||||
enum RtErrorMessage {
|
||||
NO_READ = 'no_read',
|
||||
NO_SHARE = 'no_share',
|
||||
NO_COMMENT = 'no_comment',
|
||||
TOPIC_FORBIDDEN = 'topic_forbidden',
|
||||
SUB_LIMIT = 'sub_limit',
|
||||
RATE_LIMITED = 'rate_limited',
|
||||
NO_EDIT = 'no_edit',
|
||||
INTERNAL_ERROR = 'internal_error',
|
||||
INVALID_REQUEST = 'invalid_request',
|
||||
METHOD_NOT_FOUND = 'method_not_found',
|
||||
INVALID_PARAMS = 'invalid_params'
|
||||
}
|
||||
export type { RtErrorMessage as default };
|
||||
@@ -0,0 +1,10 @@
|
||||
import type RtErrorCode from './RtErrorCode';
|
||||
import type RtErrorMessage from './RtErrorMessage';
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface RtErrorObject {
|
||||
code: RtErrorCode;
|
||||
data?: unknown;
|
||||
message: RtErrorMessage;
|
||||
}
|
||||
export type { RtErrorObject as default };
|
||||
@@ -0,0 +1,9 @@
|
||||
import type RtErrorObject from './RtErrorObject';
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface RtErrorResponseBody {
|
||||
error: RtErrorObject;
|
||||
id: string | null | number | null | null;
|
||||
jsonrpc: '2.0';
|
||||
}
|
||||
export type { RtErrorResponseBody as default };
|
||||
@@ -0,0 +1,11 @@
|
||||
enum RtEventKind {
|
||||
FILE_CREATED = 'file_created',
|
||||
FILE_RENAMED = 'file_renamed',
|
||||
FILE_MOVED = 'file_moved',
|
||||
FILE_DELETED = 'file_deleted',
|
||||
FOLDER_CREATED = 'folder_created',
|
||||
FOLDER_RENAMED = 'folder_renamed',
|
||||
FOLDER_MOVED = 'folder_moved',
|
||||
FOLDER_DELETED = 'folder_deleted'
|
||||
}
|
||||
export type { RtEventKind as default };
|
||||
@@ -0,0 +1,25 @@
|
||||
import type FileCreatedData from './FileCreatedData';
|
||||
import type FileRenamedData from './FileRenamedData';
|
||||
import type FileMovedData from './FileMovedData';
|
||||
import type FileDeletedData from './FileDeletedData';
|
||||
import type FolderCreatedData from './FolderCreatedData';
|
||||
import type FolderRenamedData from './FolderRenamedData';
|
||||
import type FolderMovedData from './FolderMovedData';
|
||||
import type FolderDeletedData from './FolderDeletedData';
|
||||
import type RtEventKind from './RtEventKind';
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface RtEventParams {
|
||||
data:
|
||||
| FileCreatedData
|
||||
| FileRenamedData
|
||||
| FileMovedData
|
||||
| FileDeletedData
|
||||
| FolderCreatedData
|
||||
| FolderRenamedData
|
||||
| FolderMovedData
|
||||
| FolderDeletedData;
|
||||
event: RtEventKind;
|
||||
topic: string;
|
||||
}
|
||||
export type { RtEventParams as default };
|
||||
@@ -0,0 +1,9 @@
|
||||
import type RtEventParams from './RtEventParams';
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface RtFolderEventBody {
|
||||
jsonrpc: '2.0';
|
||||
method: 'rt.event';
|
||||
params: RtEventParams;
|
||||
}
|
||||
export type { RtFolderEventBody as default };
|
||||
@@ -0,0 +1,8 @@
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface RtPingRequestBody {
|
||||
id: string | null | number | null | null;
|
||||
jsonrpc: '2.0';
|
||||
method: 'rt.ping';
|
||||
}
|
||||
export type { RtPingRequestBody as default };
|
||||
@@ -0,0 +1,9 @@
|
||||
import type RtPongResult from './RtPongResult';
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface RtPongResponseBody {
|
||||
id: string | null | number | null | null;
|
||||
jsonrpc: '2.0';
|
||||
result: RtPongResult;
|
||||
}
|
||||
export type { RtPongResponseBody as default };
|
||||
@@ -0,0 +1,6 @@
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface RtPongResult {
|
||||
pong: boolean;
|
||||
}
|
||||
export type { RtPongResult as default };
|
||||
@@ -0,0 +1,9 @@
|
||||
import type RtRevokedParams from './RtRevokedParams';
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface RtRevokedBody {
|
||||
jsonrpc: '2.0';
|
||||
method: 'rt.revoked';
|
||||
params: RtRevokedParams;
|
||||
}
|
||||
export type { RtRevokedBody as default };
|
||||
@@ -0,0 +1,8 @@
|
||||
import type RtRevokedReason from './RtRevokedReason';
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface RtRevokedParams {
|
||||
reason: RtRevokedReason;
|
||||
topic: string;
|
||||
}
|
||||
export type { RtRevokedParams as default };
|
||||
@@ -0,0 +1,7 @@
|
||||
enum RtRevokedReason {
|
||||
GRANT_REVOKED = 'grant_revoked',
|
||||
RESOURCE_DELETED = 'resource_deleted',
|
||||
GROUP_MEMBERSHIP_LOST = 'group_membership_lost',
|
||||
ADMIN_KICK = 'admin_kick'
|
||||
}
|
||||
export type { RtRevokedReason as default };
|
||||
@@ -0,0 +1,6 @@
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface RtSubscribeParams {
|
||||
topic: string;
|
||||
}
|
||||
export type { RtSubscribeParams as default };
|
||||
@@ -0,0 +1,10 @@
|
||||
import type RtSubscribeParams from './RtSubscribeParams';
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface RtSubscribeRequestBody {
|
||||
id: string | null | number | null | null;
|
||||
jsonrpc: '2.0';
|
||||
method: 'rt.subscribe';
|
||||
params?: RtSubscribeParams;
|
||||
}
|
||||
export type { RtSubscribeRequestBody as default };
|
||||
@@ -0,0 +1,8 @@
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface RtSuccessResponseBody {
|
||||
id: string | null | number | null | null;
|
||||
jsonrpc: '2.0';
|
||||
result: unknown;
|
||||
}
|
||||
export type { RtSuccessResponseBody as default };
|
||||
@@ -0,0 +1,6 @@
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface RtUnsubscribeParams {
|
||||
topic: string;
|
||||
}
|
||||
export type { RtUnsubscribeParams as default };
|
||||
@@ -0,0 +1,10 @@
|
||||
import type RtUnsubscribeParams from './RtUnsubscribeParams';
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Regenerate with `just asyncapi-ts`.
|
||||
interface RtUnsubscribeRequestBody {
|
||||
id: string | null | number | null | null;
|
||||
jsonrpc: '2.0';
|
||||
method: 'rt.unsubscribe';
|
||||
params?: RtUnsubscribeParams;
|
||||
}
|
||||
export type { RtUnsubscribeRequestBody as default };
|
||||
@@ -201,6 +201,44 @@ openapi:
|
||||
asyncapi:
|
||||
cargo run --features dev_tools --bin generate-asyncapi
|
||||
|
||||
# Regenerate frontend TypeScript DTOs from `resources/gen/asyncapi.json`
|
||||
# via `@asyncapi/modelina`. Chained to `asyncapi` so the JSON spec is
|
||||
# always fresh before Modelina consumes it — running one entry point
|
||||
# with two dependent steps is cheaper cognitively than remembering to
|
||||
# regenerate the spec first. Cargo incremental keeps the Rust side
|
||||
# near-instant when nothing changed; Modelina then rewrites the FE
|
||||
# .ts files (idempotent — same input → same output, CI dirty-tree
|
||||
# check catches genuine drift).
|
||||
#
|
||||
# Output lands in `frontend/src/lib/generated/realtime/`; consumers
|
||||
# import from there but never edit those files.
|
||||
asyncapi-ts: asyncapi
|
||||
cd frontend && npm run gen:realtime
|
||||
|
||||
# Local mirror of the `realtime-spec-drift` CI job. Regenerates both
|
||||
# artefacts and fails if the committed files differ from the fresh
|
||||
# generator output. Included in `pre-pull-request` so developers
|
||||
# catch drift BEFORE pushing — the CI job is belt-and-braces, not the
|
||||
# only defence.
|
||||
#
|
||||
# Depends on `asyncapi-ts` which itself depends on `asyncapi`, so the
|
||||
# whole chain runs; then we assert on `git diff --exit-code` over
|
||||
# the two paths we care about.
|
||||
check-realtime-spec: asyncapi-ts
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if ! git diff --exit-code \
|
||||
resources/gen/asyncapi.json \
|
||||
frontend/src/lib/generated/realtime/; then
|
||||
echo ""
|
||||
echo "❌ realtime spec drift: committed files differ from the fresh"
|
||||
echo " generator output. Fix:"
|
||||
echo " git add resources/gen/asyncapi.json frontend/src/lib/generated/realtime/"
|
||||
echo " git commit -m 'chore(rt): regenerate spec + DTOs'"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ realtime spec: committed files match generator output"
|
||||
|
||||
db:
|
||||
docker compose up -d postgres
|
||||
|
||||
@@ -328,16 +366,25 @@ test-caldav:
|
||||
fe-install:
|
||||
cd frontend && npm ci
|
||||
|
||||
# Vite dev server only (HMR) — backend must already be running on :8086
|
||||
fe-dev:
|
||||
# Vite dev server only (HMR) — backend must already be running on :8086.
|
||||
# `asyncapi-ts` prereq runs once at start; Vite's watcher picks up
|
||||
# any subsequent regenerations for HMR.
|
||||
fe-dev: asyncapi-ts
|
||||
cd frontend && npm run dev
|
||||
|
||||
# build the SPA (Phase 0: -> frontend/build; Phase 5: -> static-dist)
|
||||
fe-build:
|
||||
# build the SPA (Phase 0: -> frontend/build; Phase 5: -> static-dist).
|
||||
# `asyncapi-ts` prerequisite (which itself depends on `asyncapi`)
|
||||
# guarantees `frontend/src/lib/generated/realtime/*.ts` is in sync
|
||||
# with the Rust-side wire spec before Vite compiles — no stale-DTO
|
||||
# window in local dev. CI still runs a dirty-tree check on the
|
||||
# generated files as belt-and-braces.
|
||||
fe-build: asyncapi-ts
|
||||
cd frontend && npm run build
|
||||
|
||||
# Build the SPA with e2e instrumentation for the Playwright coverage
|
||||
# suite. Both env vars are load-bearing:
|
||||
# suite. Same asyncapi-ts prereq as `fe-build` — the E2E build must
|
||||
# see the same generated DTOs the release build sees. Both env vars
|
||||
# are load-bearing:
|
||||
# * VITE_E2E=1 — keeps the `data-testid` tile hooks the release
|
||||
# build strips, so `page.getByTestId(filename)` and
|
||||
# the drop-zone / preferences selectors work.
|
||||
@@ -348,15 +395,19 @@ fe-build:
|
||||
# report empty.
|
||||
# Called automatically by `front-test`; run manually if you're
|
||||
# invoking Playwright directly.
|
||||
fe-build-e2e:
|
||||
fe-build-e2e: asyncapi-ts
|
||||
cd frontend && COVERAGE=1 VITE_E2E=1 npm run build
|
||||
|
||||
# svelte-check + eslint + stylelint + prettier
|
||||
fe-check:
|
||||
# svelte-check + eslint + stylelint + prettier. Depends on
|
||||
# `asyncapi-ts` so svelte-check sees current generated types (a stale
|
||||
# import would surface as a TS error at check time — better to
|
||||
# regenerate first than chase phantom errors).
|
||||
fe-check: asyncapi-ts
|
||||
cd frontend && npm run check
|
||||
|
||||
# Vitest unit/component tests
|
||||
fe-test:
|
||||
# Vitest unit/component tests. Same asyncapi-ts prereq — tests that
|
||||
# import from `lib/generated/realtime` need it fresh.
|
||||
fe-test: asyncapi-ts
|
||||
cd frontend && npm run test:unit
|
||||
|
||||
# Run backend (API) and the Vite dev server together; one Ctrl-C stops both.
|
||||
@@ -396,4 +447,4 @@ test-docker-tags:
|
||||
|
||||
# Check and test everything
|
||||
# recommanded before pull request
|
||||
pre-pull-request: test-docker-tags check fe-check audit check-migrations test test-integration fe-test build test-bundle test-api fe-build-e2e front-test
|
||||
pre-pull-request: test-docker-tags check fe-check audit check-migrations check-realtime-spec test test-integration fe-test build test-bundle test-api fe-build-e2e front-test
|
||||
|
||||
+234
-101
@@ -198,7 +198,7 @@ fn operations() -> Value {
|
||||
}
|
||||
|
||||
fn components() -> Value {
|
||||
json!({
|
||||
let mut components = json!({
|
||||
"messages": {
|
||||
// ── Requests ────────────────────────────────────────────
|
||||
"RtSubscribeRequest": {
|
||||
@@ -253,14 +253,35 @@ fn components() -> Value {
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"RtSubscribeRequestBody": rpc_request_schema("rt.subscribe", topic_params_schema()),
|
||||
"RtUnsubscribeRequestBody": rpc_request_schema("rt.unsubscribe", topic_params_schema()),
|
||||
"RtPingRequestBody": rpc_request_schema("rt.ping", json!({ "type": "null" })),
|
||||
// Top-level JSON-RPC frame bodies.
|
||||
"RtSubscribeRequestBody": rpc_request_schema("rt.subscribe", Some(ref_schema("RtSubscribeParams"))),
|
||||
"RtUnsubscribeRequestBody": rpc_request_schema("rt.unsubscribe", Some(ref_schema("RtUnsubscribeParams"))),
|
||||
"RtPingRequestBody": rpc_request_schema("rt.ping", None),
|
||||
"RtSuccessResponseBody": rpc_success_response_schema(),
|
||||
"RtPongResponseBody": rpc_pong_response_schema(),
|
||||
"RtErrorResponseBody": rpc_error_response_schema(),
|
||||
"RtFolderEventBody": folder_event_notification_schema(),
|
||||
"RtRevokedBody": revoked_notification_schema(),
|
||||
|
||||
// Hoisted nested schemas — pulled out from inline `params`,
|
||||
// inner `error`, `result`, and enum arrays so Modelina (and
|
||||
// any other spec-driven codegen) gets real names instead of
|
||||
// `AnonymousSchema_N`. Keep names in sync with the shape:
|
||||
// renaming here silently breaks the generated FE types, so
|
||||
// the CI dirty-tree check catches drift.
|
||||
"RtSubscribeParams": topic_params_schema(),
|
||||
"RtUnsubscribeParams": topic_params_schema(),
|
||||
"RtEventParams": event_params_schema(),
|
||||
"RtEventDataUnion": event_data_union_schema(),
|
||||
"RtEventKind": event_kind_schema(),
|
||||
"RtRevokedParams": revoked_params_schema(),
|
||||
"RtRevokedReason": revoked_reason_schema(),
|
||||
"RtErrorObject": rpc_error_object_schema(),
|
||||
"RtErrorCode": rpc_error_code_schema(),
|
||||
"RtErrorMessage": rpc_error_message_schema(),
|
||||
"RtPongResult": rpc_pong_result_schema(),
|
||||
|
||||
// Per-event data payloads (one per `event` discriminator).
|
||||
"FileCreatedData": file_created_schema(),
|
||||
"FileRenamedData": file_renamed_schema(),
|
||||
"FileMovedData": file_moved_schema(),
|
||||
@@ -281,21 +302,74 @@ fn components() -> Value {
|
||||
"description": "OxiCloud JWT — same access_token minted by `POST /api/auth/login` (or the OPAQUE handshake). Programmatic clients set `Authorization: Bearer <jwt>` on the WS upgrade request. Browsers, which cannot set headers on `new WebSocket()`, will use the deferred ticket flow (`POST /api/rt/ticket` → short-lived one-shot ticket in the WS URL); see the plan's DPoP-gap section.",
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Close every top-level object schema in components.schemas —
|
||||
// the Rust wire (`serde` on named struct fields) never emits
|
||||
// extras, so `additionalProperties: false` is honest, and it
|
||||
// removes the `additionalProperties?: Record<string, unknown>`
|
||||
// escape-hatch field Modelina would otherwise generate on every
|
||||
// TS interface. One-shot post-process instead of 19 individual
|
||||
// `"additionalProperties": false` lines sprinkled through the
|
||||
// schema builders.
|
||||
//
|
||||
// Deliberately NOT recursive: we only close the named top-level
|
||||
// schemas. Recursing into `properties` closes anonymous inline
|
||||
// sub-objects, which then triggers Modelina to name them (and
|
||||
// fail our AnonymousSchema guard). If a nested object needs a
|
||||
// real name AND `additionalProperties: false`, hoist it explicitly
|
||||
// to `components.schemas` and reference via `$ref`.
|
||||
if let Some(schemas) = components.get_mut("schemas").and_then(Value::as_object_mut) {
|
||||
for schema in schemas.values_mut() {
|
||||
close_object_schema_shallow(schema);
|
||||
}
|
||||
}
|
||||
|
||||
components
|
||||
}
|
||||
|
||||
/// Add `additionalProperties: false` to a top-level object schema if
|
||||
/// it declares `type: "object"` and doesn't already set the field.
|
||||
/// Non-object schemas (`enum`, `oneOf`, `type: "integer"`, string
|
||||
/// types, etc.) are untouched. Never descends — see `components()`.
|
||||
fn close_object_schema_shallow(schema: &mut Value) {
|
||||
let Value::Object(map) = schema else { return };
|
||||
let is_object = matches!(map.get("type"), Some(Value::String(s)) if s == "object");
|
||||
if is_object && !map.contains_key("additionalProperties") {
|
||||
map.insert("additionalProperties".to_string(), Value::Bool(false));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Schema builders ────────────────────────────────────────────────────────
|
||||
|
||||
fn rpc_request_schema(method: &str, params_schema: Value) -> Value {
|
||||
/// `$ref` shorthand — every hoisted inline schema below is referenced
|
||||
/// through this so consumers of the spec (Modelina, AsyncAPI Studio, any
|
||||
/// SDK generator) see named types instead of `AnonymousSchema_N`.
|
||||
fn ref_schema(name: &str) -> Value {
|
||||
json!({ "$ref": format!("#/components/schemas/{name}") })
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 request envelope. `params_schema` is `Some(...)` for
|
||||
/// methods that take arguments (`rt.subscribe`, `rt.unsubscribe`) and
|
||||
/// `None` for methods that don't (`rt.ping`). Omitting `params` from
|
||||
/// the properties entirely — rather than declaring it as
|
||||
/// `{"type": "null"}` — keeps Modelina from emitting `params?: any`
|
||||
/// on the generated TS: no property in the schema → no property in
|
||||
/// the interface, which is what JSON-RPC 2.0 allows anyway (`params`
|
||||
/// is optional per spec).
|
||||
fn rpc_request_schema(method: &str, params_schema: Option<Value>) -> Value {
|
||||
let mut properties = json!({
|
||||
"jsonrpc": { "type": "string", "const": "2.0" },
|
||||
"id": { "type": ["integer", "string", "null"] },
|
||||
"method": { "type": "string", "const": method },
|
||||
});
|
||||
if let Some(params) = params_schema {
|
||||
properties["params"] = params;
|
||||
}
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["jsonrpc", "id", "method"],
|
||||
"properties": {
|
||||
"jsonrpc": { "type": "string", "const": "2.0" },
|
||||
"id": { "type": ["integer", "string", "null"] },
|
||||
"method": { "type": "string", "const": method },
|
||||
"params": params_schema,
|
||||
}
|
||||
"properties": properties,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -320,13 +394,24 @@ fn rpc_success_response_schema() -> Value {
|
||||
"properties": {
|
||||
"jsonrpc": { "type": "string", "const": "2.0" },
|
||||
"id": { "type": ["integer", "string", "null"] },
|
||||
"result": { "type": "object" },
|
||||
// Generic base shape — every specific method has its own
|
||||
// typed result schema (RtPongResult, subscribed ack, etc.).
|
||||
// Declaring every JSON type explicitly nudges Modelina
|
||||
// toward a real union rather than the bare `any` it emits
|
||||
// for a purely descriptive schema — matches the JSON-RPC
|
||||
// spec's "any JSON value" phrasing while giving downstream
|
||||
// codegens something to project.
|
||||
"result": {
|
||||
"description": "Method-specific result payload. See the concrete response schema for each `method`.",
|
||||
"type": ["object", "array", "string", "number", "integer", "boolean", "null"],
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Reply to `rt.ping` — the shape pins `result.pong == true` so
|
||||
/// contract tests can assert on it directly.
|
||||
/// contract tests can assert on it directly. `result` is hoisted to
|
||||
/// [`RtPongResult`] so Modelina gets a named type.
|
||||
fn rpc_pong_response_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
@@ -334,13 +419,17 @@ fn rpc_pong_response_schema() -> Value {
|
||||
"properties": {
|
||||
"jsonrpc": { "type": "string", "const": "2.0" },
|
||||
"id": { "type": ["integer", "string", "null"] },
|
||||
"result": {
|
||||
"type": "object",
|
||||
"required": ["pong"],
|
||||
"properties": {
|
||||
"pong": { "type": "boolean", "const": true }
|
||||
}
|
||||
},
|
||||
"result": ref_schema("RtPongResult"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn rpc_pong_result_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["pong"],
|
||||
"properties": {
|
||||
"pong": { "type": "boolean", "const": true }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -348,91 +437,126 @@ fn rpc_pong_response_schema() -> Value {
|
||||
fn rpc_error_response_schema() -> Value {
|
||||
// The `code`/`message` catalog is the stable public vocabulary —
|
||||
// any change here IS a wire break. Every entry mirrors
|
||||
// `application/ports/realtime_ports.rs::error_code`.
|
||||
// `application/ports/realtime_ports.rs::error_code`. The inner
|
||||
// error object is hoisted to `RtErrorObject` so Modelina emits a
|
||||
// named type instead of `AnonymousSchema_N`.
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["jsonrpc", "id", "error"],
|
||||
"properties": {
|
||||
"jsonrpc": { "type": "string", "const": "2.0" },
|
||||
"id": { "type": ["integer", "string", "null"] },
|
||||
"error": {
|
||||
"type": "object",
|
||||
"required": ["code", "message"],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "integer",
|
||||
"enum": [
|
||||
error_code::NO_READ,
|
||||
error_code::NO_SHARE,
|
||||
error_code::NO_COMMENT,
|
||||
error_code::TOPIC_FORBIDDEN,
|
||||
error_code::SUB_LIMIT,
|
||||
error_code::RATE_LIMITED,
|
||||
error_code::NO_EDIT,
|
||||
error_code::INTERNAL_ERROR,
|
||||
error_code::INVALID_REQUEST,
|
||||
error_code::METHOD_NOT_FOUND,
|
||||
error_code::INVALID_PARAMS,
|
||||
],
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Stable wire vocabulary; matches the `code`.",
|
||||
"enum": [
|
||||
"no_read", "no_share", "no_comment", "topic_forbidden",
|
||||
"sub_limit", "rate_limited", "no_edit",
|
||||
"internal_error", "invalid_request",
|
||||
"method_not_found", "invalid_params",
|
||||
],
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Optional caller-facing context (e.g. offending topic).",
|
||||
}
|
||||
}
|
||||
"error": ref_schema("RtErrorObject"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn rpc_error_object_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"description": "JSON-RPC 2.0 error object. `code` + `message` form a stable pair; `data` optionally carries caller-visible context (e.g. offending topic).",
|
||||
"required": ["code", "message"],
|
||||
"properties": {
|
||||
"code": ref_schema("RtErrorCode"),
|
||||
"message": ref_schema("RtErrorMessage"),
|
||||
// Per JSON-RPC 2.0: "A Primitive or Structured value that
|
||||
// contains additional information about the error." The
|
||||
// union covers every JSON type so Modelina emits a real
|
||||
// TS union rather than a bare `any`. Client MUST check
|
||||
// `code` before assuming `data`'s shape.
|
||||
"data": {
|
||||
"description": "Optional caller-facing context; shape depends on the specific `code`.",
|
||||
"type": ["object", "array", "string", "number", "integer", "boolean", "null"],
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn rpc_error_code_schema() -> Value {
|
||||
json!({
|
||||
"type": "integer",
|
||||
"description": "Stable integer error code. Values are frozen across releases — a new denial cause gets a new value, never repurposes an existing one.",
|
||||
"enum": [
|
||||
error_code::NO_READ,
|
||||
error_code::NO_SHARE,
|
||||
error_code::NO_COMMENT,
|
||||
error_code::TOPIC_FORBIDDEN,
|
||||
error_code::SUB_LIMIT,
|
||||
error_code::RATE_LIMITED,
|
||||
error_code::NO_EDIT,
|
||||
error_code::INTERNAL_ERROR,
|
||||
error_code::INVALID_REQUEST,
|
||||
error_code::METHOD_NOT_FOUND,
|
||||
error_code::INVALID_PARAMS,
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
fn rpc_error_message_schema() -> Value {
|
||||
json!({
|
||||
"type": "string",
|
||||
"description": "Stable wire vocabulary; matches the corresponding `code`.",
|
||||
"enum": [
|
||||
"no_read", "no_share", "no_comment", "topic_forbidden",
|
||||
"sub_limit", "rate_limited", "no_edit",
|
||||
"internal_error", "invalid_request",
|
||||
"method_not_found", "invalid_params",
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
fn folder_event_notification_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"description": "JSON-RPC notification (no `id`). `method = \"rt.event\"`.",
|
||||
"description": "JSON-RPC notification (no `id`). `method = \"rt.event\"`. `params` is hoisted to `RtEventParams`.",
|
||||
"required": ["jsonrpc", "method", "params"],
|
||||
"properties": {
|
||||
"jsonrpc": { "type": "string", "const": "2.0" },
|
||||
"method": { "type": "string", "const": "rt.event" },
|
||||
"params": {
|
||||
"type": "object",
|
||||
"required": ["topic", "event", "data"],
|
||||
"properties": {
|
||||
"topic": { "type": "string" },
|
||||
"event": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"file_created", "file_renamed", "file_moved", "file_deleted",
|
||||
"folder_created", "folder_renamed", "folder_moved", "folder_deleted",
|
||||
],
|
||||
},
|
||||
"data": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/components/schemas/FileCreatedData" },
|
||||
{ "$ref": "#/components/schemas/FileRenamedData" },
|
||||
{ "$ref": "#/components/schemas/FileMovedData" },
|
||||
{ "$ref": "#/components/schemas/FileDeletedData" },
|
||||
{ "$ref": "#/components/schemas/FolderCreatedData" },
|
||||
{ "$ref": "#/components/schemas/FolderRenamedData" },
|
||||
{ "$ref": "#/components/schemas/FolderMovedData" },
|
||||
{ "$ref": "#/components/schemas/FolderDeletedData" },
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"params": ref_schema("RtEventParams"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn event_params_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["topic", "event", "data"],
|
||||
"properties": {
|
||||
"topic": { "type": "string" },
|
||||
"event": ref_schema("RtEventKind"),
|
||||
"data": ref_schema("RtEventDataUnion"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn event_kind_schema() -> Value {
|
||||
json!({
|
||||
"type": "string",
|
||||
"description": "Discriminator for the `data` payload. Mirrors the `#[serde(tag = \"event\", rename_all = \"snake_case\")]` variants of the Rust `RealtimeEvent` enum — a new event kind is a new enum variant on both sides.",
|
||||
"enum": [
|
||||
"file_created", "file_renamed", "file_moved", "file_deleted",
|
||||
"folder_created", "folder_renamed", "folder_moved", "folder_deleted",
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
fn event_data_union_schema() -> Value {
|
||||
json!({
|
||||
"description": "Tagged union of every possible `rt.event` payload. Discriminated by the sibling `event` field (see `RtEventKind`).",
|
||||
"oneOf": [
|
||||
ref_schema("FileCreatedData"),
|
||||
ref_schema("FileRenamedData"),
|
||||
ref_schema("FileMovedData"),
|
||||
ref_schema("FileDeletedData"),
|
||||
ref_schema("FolderCreatedData"),
|
||||
ref_schema("FolderRenamedData"),
|
||||
ref_schema("FolderMovedData"),
|
||||
ref_schema("FolderDeletedData"),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn file_created_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
@@ -551,27 +675,36 @@ fn folder_deleted_schema() -> Value {
|
||||
fn revoked_notification_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"description": "JSON-RPC notification (no `id`). `method = \"rt.revoked\"`.",
|
||||
"description": "JSON-RPC notification (no `id`). `method = \"rt.revoked\"`. `params` hoisted to `RtRevokedParams`.",
|
||||
"required": ["jsonrpc", "method", "params"],
|
||||
"properties": {
|
||||
"jsonrpc": { "type": "string", "const": "2.0" },
|
||||
"method": { "type": "string", "const": "rt.revoked" },
|
||||
"params": {
|
||||
"type": "object",
|
||||
"required": ["topic", "reason"],
|
||||
"properties": {
|
||||
"topic": { "type": "string" },
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"grant_revoked",
|
||||
"resource_deleted",
|
||||
"group_membership_lost",
|
||||
"admin_kick",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"params": ref_schema("RtRevokedParams"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn revoked_params_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["topic", "reason"],
|
||||
"properties": {
|
||||
"topic": { "type": "string" },
|
||||
"reason": ref_schema("RtRevokedReason"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn revoked_reason_schema() -> Value {
|
||||
json!({
|
||||
"type": "string",
|
||||
"description": "Server-side eviction cause. Stable vocabulary; a new eviction reason is a new enum value.",
|
||||
"enum": [
|
||||
"grant_revoked",
|
||||
"resource_deleted",
|
||||
"group_membership_lost",
|
||||
"admin_kick",
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user