diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index dff00b9..cf35b6a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,14 @@ {"id":"openapi-generator-dsu","title":"OpenAPI 3.1 type:[X,null] on required properties generates non-Option field","description":"For OpenAPI 3.1 schemas where a property is BOTH listed in 'required' AND declared nullable via the 3.1 type-array form (type: [\"string\",\"null\"]), the generator emits a non-Option field. Deserialization then fails against real responses that send null.\n\nVERIFIED AGAINST THE LIVE RUNPOD v2 API. Two operations fail outright today:\n- GET /v2/catalog/gpus -\u003e GpuType.pool is null in production; spec says type: [\"string\",\"null\"], required. Generated: 'pub pool: String'.\n- GET /v2/pods -\u003e Pod.template is null in production. Generated: 'pub template: String'.\n\nConfirmed affected fields in https://api.runpod.io/v2/openapi.json (4, walking allOf branches):\n GpuType.pool, Pod.dataCenterId, Pod.template, Pod.startedAt\n\nPod.startedAt is the worst latent case: any pod that has not started returns null, which takes out the entire listPods call for that account.\n\nNote Pod composes via allOf, so the required list and properties live in an allOf branch — a fix must traverse composition, not just top-level component properties.\n\nRoot cause reported by live-testing agent (unverified by me): nullability is computed as is_nullable() || is_nullable_pattern() in src/analysis.rs (~1936-1937, ~2506-2514), covering only 3.0 'nullable: true' and the anyOf-with-null shape. Schema::type_array_contains_null() (src/openapi.rs ~649-656) is called from only one site (analysis.rs ~1555). generator.rs (~2497-2517) then yields a plain String.\n\nClosed issue openapi-generator-bgo fixed only the anyOf case; this type-array case is uncovered.\n\nSeverity: 3.1 is the modern dialect and this silently produces a client that cannot read production responses. This is first-run breakage of the worst kind — it compiles, then fails at runtime.","acceptance_criteria":"A required property declared type: [\"string\",\"null\"] generates Option\u003cString\u003e, including when declared inside an allOf branch. Regression test covers both the direct component-schema case and the allOf-composed case. Generating the RunPod v2 spec yields Option for GpuType.pool, Pod.template, Pod.startedAt, and Pod.dataCenterId.","status":"closed","priority":0,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T23:51:03Z","created_by":"James Lal","updated_at":"2026-07-27T00:59:39Z","closed_at":"2026-07-27T00:59:39Z","close_reason":"Fixed in fad3ed3; verified against the live RunPod v2 API (list_gpu_types and list_pods now deserialize, with real nulls present in the responses). 362 tests pass.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-1fz","title":"Make scratch Cargo directories portable across CI runners","description":"PR #46 clean Linux runner cannot create hard-coded /private/tmp Cargo target/build directories in generated-crate integration tests. Derive all scratch build paths from each TempDir and verify every affected test plus full CI.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-29T00:10:21Z","created_by":"James Lal","updated_at":"2026-07-29T00:17:39Z","started_at":"2026-07-29T00:10:24Z","closed_at":"2026-07-29T00:17:39Z","close_reason":"Replaced all hard-coded /private/tmp Cargo build and target paths with per-test TempDir paths; focused fresh-build tests, full all-features suite, fmt, and clippy pass.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-6vy","title":"Fix clean-runner scratch-crate dependency resolution","description":"PR #46 CI fails because newly added generated-crate integration tests invoke Cargo with --offline before emitted dependencies such as async-trait exist in a clean runner cache. Remove unconditional offline resolution from the new scratch tests and verify with a cold Cargo cache.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-28T23:59:37Z","created_by":"James Lal","updated_at":"2026-07-29T00:05:51Z","started_at":"2026-07-28T23:59:42Z","closed_at":"2026-07-29T00:05:51Z","close_reason":"Removed unconditional offline mode from all new scratch-crate Cargo invocations; cold-cache focused suite and full all-features CI tests pass.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-60d","title":"Compile-check all 55 supported corpus specs","description":"Run scripts/spec-compile.sh across the complete supported OpenAPI corpus after the Storyden generator fixes, with generation and isolated cargo check for every supported spec.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-28T22:51:36Z","created_by":"James Lal","updated_at":"2026-07-28T23:15:21Z","started_at":"2026-07-28T22:51:41Z","closed_at":"2026-07-28T23:15:21Z","close_reason":"All 55 supported OpenAPI corpus documents generated and compiled successfully with isolated manifests; Microsoft Graph was force-compiled despite the standard resource gate. Gitea remains the expected Swagger 2.0 exclusion.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-ndb","title":"Bound every buffered generated client response","description":"Generated clients currently call reqwest Response::bytes/text for buffered JSON, text, binary, and error responses, which can allocate without a limit. SSE success must remain streaming. Add a finite default response-body ceiling, configurable on the generated HttpClient, and a chunked bounded reader used by every buffered response path.","acceptance_criteria":"Every generated buffered response path enforces max_response_body_bytes before extending one buffer; over-limit responses return a distinct inspectable HttpError without allocating beyond the ceiling; SSE success remains streaming; runtime tests cover chunked/no-content-length oversized JSON/text/binary/error responses and an under-limit response.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-28T22:02:29Z","created_by":"James Lal","updated_at":"2026-07-28T22:26:30Z","started_at":"2026-07-28T22:02:31Z","closed_at":"2026-07-28T22:26:30Z","close_reason":"Implemented and verified against the vendored Storyden spec: generated clients and Axum servers compile and round-trip; response/request media handling is representation-safe and all buffered client responses are bounded.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-qwd","title":"Make generated response handling status- and representation-safe","description":"Review of non-JSON response support found five correctness gaps: generated clients apply one body parser to every successful status; multiple supported response representations are narrowed without an Accept header; binary error bodies are lossy through String; text/* can be emitted as an invalid literal Content-Type; and schema-less application/json shadows schema-bearing vendor JSON. Make response selection and runtime behavior explicit and safe without regressing JSON, SSE, text, or binary handling.","acceptance_criteria":"Generated clients reject incompatible success-status body contracts at generation time or dispatch parsers by status; narrowed representations send a concrete Accept value; ApiError retains raw error bytes while preserving ergonomic text access; wildcard text is not emitted as a literal Content-Type; schema-bearing JSON alternatives are preferred; regression tests cover every case.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-28T21:44:13Z","created_by":"James Lal","updated_at":"2026-07-28T22:26:29Z","started_at":"2026-07-28T21:44:14Z","closed_at":"2026-07-28T22:26:29Z","close_reason":"Implemented and verified against the vendored Storyden spec: generated clients and Axum servers compile and round-trip; response/request media handling is representation-safe and all buffered client responses are bounded.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-8nj","title":"Support text/plain request bodies in generated Axum servers","description":"Storyden SendBeacon declares a text/plain request body. Axum server generation rejects it with 'text server extraction is not implemented'. Generate bounded UTF-8 String extraction with declared-media checks and existing sanitized 400/413/415 behavior.","acceptance_criteria":"Required and optional text/plain request bodies generate String/Option\u003cString\u003e Axum trait inputs; invalid UTF-8 is 400, wrong/missing media is 415, oversized input is 413, and SendBeacon generates and compiles with runtime regression coverage.","notes":"Approach: add bounded text decoder alongside raw binary decoder, reuse media_type_is/read_body and sanitized rejection helpers, decode UTF-8 strictly to String, and map required/optional trait types. Files: src/server/codegen.rs, src/server/validation.rs, possibly RequestBodyContent representation in src/analysis.rs, focused generated client-to-generated Axum server runtime test. Required roundtrip: generated text/plain client method into generated server router; raw HTTP covers invalid UTF-8, wrong/missing media, and oversize.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-28T21:37:59Z","created_by":"James Lal","updated_at":"2026-07-28T22:26:29Z","started_at":"2026-07-28T21:38:05Z","closed_at":"2026-07-28T22:26:29Z","close_reason":"Implemented and verified against the vendored Storyden spec: generated clients and Axum servers compile and round-trip; response/request media handling is representation-safe and all buffered client responses are bounded.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-6lq","title":"Support raw binary request bodies in generated Axum servers","description":"Storyden has five raw upload operations: AccountSetAvatar, AssetUpload, BannerUpload, and IconUpload declare application/octet-stream; PluginUpdatePackage declares application/zip with format: binary. After fixing earlier blockers, Axum generation rejects them because raw binary server extraction is not implemented/unsupported. Model declared binary request media explicitly and generate bounded bytes::Bytes extraction with correct 415/413 semantics and exact dependencies.","acceptance_criteria":"Axum server generation supports required and optional raw binary request bodies (including application/octet-stream and application/zip) as bytes::Bytes with configured size limits and 415 behavior; all five Storyden binary upload operations generate and compile; focused runtime tests cover arbitrary bytes, exact declared media, missing/wrong media type, optionality, and oversized input.","notes":"Approach: generalize RequestBodyContent to carry binary media type; map server body type to bytes::Bytes; emit conditional bounded decoder reusing read_body/media_type_is and problem-details 413/415; handle required/optional signatures and validation-enabled/disabled modes; assert bytes dependency. Files: src/analysis.rs, src/server/codegen.rs, src/server/validation.rs, focused generated client-to-generated Axum server runtime test. Required roundtrips: application/octet-stream and application/zip with arbitrary invalid-UTF8 bytes; raw HTTP cases cover wrong/missing media and oversize.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-28T21:35:25Z","created_by":"James Lal","updated_at":"2026-07-28T22:26:28Z","started_at":"2026-07-28T21:35:31Z","closed_at":"2026-07-28T22:26:28Z","close_reason":"Implemented and verified against the vendored Storyden spec: generated clients and Axum servers compile and round-trip; response/request media handling is representation-safe and all buffered client responses are bounded.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-8c6","title":"Avoid Axum response-enum collisions with generated schema names","description":"Axum emits \u003cOperationId\u003eResponse enums into server/errors.rs while glob-importing generated model types. Storyden contains model schemas with the same names for eight operations: AuthProviderListResponse, CollectionListResponse, LikePostGetResponse, GetSpecResponse, NodeDeleteResponse, NodeUpdateChildrenPropertySchemaResponse, NodeUpdatePropertiesResponse, and NodeUpdatePropertySchemaResponse. Each generated enum becomes self-recursive (for example enum AuthProviderListResponse { Ok(AuthProviderListResponse), ... }), creates ambiguous glob imports, and fails cargo check with E0659/E0072. Reserve/disambiguate server response enum identifiers against retained schema identifiers and ensure API/router references use the chosen names consistently.","acceptance_criteria":"Server response enum names cannot collide with any retained generated type; Storyden's eight collisions generate finite, unambiguous response variants; generated multi-operation Axum output passes cargo check; a minimal collision regression test is included.","notes":"Approach: deterministically reserve canonical retained schema identifiers before allocating Axum per-operation response enum names; keep \u003cOp\u003eResponse when free, fall back to \u003cOp\u003eServerResponse then numeric suffixes; thread one allocation map through API traits and errors emission. Files: src/server/codegen.rs and focused server response semantics/compile test. Tests: direct collision plus forced fallback compile smoke.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-28T21:11:51Z","created_by":"James Lal","updated_at":"2026-07-28T21:25:09Z","started_at":"2026-07-28T21:15:06Z","closed_at":"2026-07-28T21:25:09Z","close_reason":"Implemented deterministic collision-free Axum response enum allocation and validated direct/numeric-fallback collisions with generated-crate compile smoke.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-6z1","title":"Resolve scalar aliases in form-style array query parameters","description":"Storyden exposes valid form/explode arrays whose items are refs to string aliases (for example AccountList.roles items -\u003e Identifier -\u003e string, plus DatagraphSearch.categories, NodeListChildren.tags, ProfileList.roles, and ThreadList.tags via reusable arrays). The analyzer's array_param_item_type/analyzed_array_item_type only accepts primitive items or refs to string enums, so client methods fall back to Option\u003cimpl AsRef\u003cstr\u003e\u003e and serialize one pair, while Axum server generation hard-fails with 'form array query parameters require scalar or string-enum items'. Resolve ref chains to primitive scalar aliases and emit Vec\u003cT\u003e/the correct repeated pairs in client and server code.","acceptance_criteria":"Inline and reusable form-style arrays whose item ref resolves to a scalar alias generate typed Vec values with correct explode semantics in both client and Axum server; Storyden's five affected operations no longer fail or fall back to opaque strings; regression tests cover direct and transitive aliases.","notes":"Approach: generalize ArrayItemType's enum-only named-ref variant to any referenced scalar/string-enum schema; use existing cycle-safe alias resolution while preserving the outer alias name for Vec\u003cT\u003e and pruning roots; keep wire serialization unchanged. Files: src/analysis.rs, src/client_generator.rs, src/server/codegen.rs, tests/exploded_query_params_test.rs, and server query roundtrip coverage if needed. Tests: direct and transitive alias arrays, repeated/comma form semantics, pruning/server compile.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-28T21:10:42Z","created_by":"James Lal","updated_at":"2026-07-28T21:25:07Z","started_at":"2026-07-28T21:15:05Z","closed_at":"2026-07-28T21:25:07Z","close_reason":"Implemented cycle-safe scalar-alias resolution for form arrays, preserved named Vec aliases/pruning roots, and validated direct/transitive client+Axum round trips (17 focused tests plus 3 server-query tests).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-9en","title":"Preserve non-JSON success response bodies in generated clients and Axum servers","description":"Storyden has valid success bodies declared as text/plain, text/html, image/png, application/zip, and wildcard binary media. Generated client methods currently consume these bodies as text and return Result\u003c(), ...\u003e, silently discarding payloads (for example GetVersion, GetDocs, AccountGetAvatar, AssetGet, BannerGet, IconGet, OAuthAuthorise, PluginDownloadPackage). Axum server generation explicitly fails those same operations as unsupported media content. Add text and binary success body modeling without regressing JSON/SSE handling.","acceptance_criteria":"text/plain and text/html success responses return/accept String; binary formats return/accept bytes without UTF-8 decoding; supported wildcard binary responses retain content type behavior; Storyden's eight affected operations generate and compile for client and server; regression tests pin media and body semantics.","notes":"Approach: add an analyzed response-body representation for JSON, text, and binary while keeping SSE orthogonal; clients return String/bytes::Bytes and use text()/bytes(); Axum emits corresponding response variants, with runtime Content-Type for wildcard binary. Files: src/openapi.rs, src/analysis.rs, src/client_generator.rs, src/server/codegen.rs, response/dependency/runtime tests. Tests: text, invalid UTF-8 binary, wildcard content type, JSON/SSE preservation, Storyden generation/compile.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-28T21:10:42Z","created_by":"James Lal","updated_at":"2026-07-28T22:26:28Z","started_at":"2026-07-28T21:15:06Z","closed_at":"2026-07-28T22:26:28Z","close_reason":"Implemented and verified against the vendored Storyden spec: generated clients and Axum servers compile and round-trip; response/request media handling is representation-safe and all buffered client responses are bounded.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-lnj","title":"full-spec-compile CI job is killed (SIGTERM 143) partway through the corpus","description":"The full-spec-compile job dies with exit code 143 (SIGTERM) roughly 25 minutes in, well under its configured timeout-minutes: 240. This is NOT a timeout.\n\nEvidence it is pre-existing and environmental, not a code regression:\n- 2026-07-20 scheduled run on main: full-spec-compile failed after 23 min; every other job passed.\n- 2026-07-27 workflow_dispatch on a fix branch: failed after 29 min; every other job passed.\n- The same scripts/spec-compile.sh passes locally on all 54 specs (54 passed, 0 gen-failed, 0 check-failed), run twice.\n\nFailure point: the log shows an 11-minute silence after 'meta-llama PASS' and then SIGTERM. The next spec alphabetically is microsoft-graph, by far the largest in the corpus.\n\nLikely cause (inference, not yet proven): runner resource exhaustion, most likely disk. The script routes all 54 isolated scratch crates through one shared SPEC_COMPILE_TARGET_DIR; locally that directory reaches 41 GB, while ubuntu-latest provides roughly 14 GB free. No explicit ENOSPC appears in the log, which is consistent with the runner terminating the process rather than cargo reporting it.\n\nImpact: the README states a scheduled CI tier compile-checks all 54 OpenAPI specs. That tier has not been passing, so the claim is currently untrue and the corpus is effectively only verified locally.\n\nSuggested approach:\n1. Instrument first — log 'df -h' and memory before and after each spec's cargo check to confirm which resource is exhausted before changing anything.\n2. If disk: free space on the runner (the standard ubuntu cleanup of preinstalled toolchains reclaims ~30 GB), and/or 'cargo clean' per spec, and/or shard the corpus across a job matrix.\n3. If memory: reduce codegen parallelism for the largest specs.","acceptance_criteria":"A scheduled or dispatched full-spec-compile run completes and reports the summary line rather than being killed, and the resource hypothesis is confirmed by instrumentation rather than assumed.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-27T03:21:16Z","created_by":"James Lal","updated_at":"2026-07-27T04:05:13Z","closed_at":"2026-07-27T04:05:13Z","close_reason":"Root cause was memory, not disk or timeout: microsoft-graph generates 2.4M lines (16,153 operations) and peaks at ~14.3 GB RSS in a single rustc process, against 16 GB on ubuntu-latest. CARGO_BUILD_JOBS=1 moved the peak 0.1%, ruling out parallelism/sharding fixes. Fixed in PR #42 by adding GENERATE_ONLY_SPECS to scripts/spec-compile.sh: microsoft-graph is still generated, only its cargo check is skipped, and it is reported in its own bucket so a green run cannot be mistaken for full verification. SPEC_COMPILE_FORCE_CHECK=1 checks it where there is headroom. Verified: full-spec-compile passed in CI (run 30235070971) for the first time — 53 passed, 0 failed, 1 generate-only, 1 skipped.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-x9v","title":"Client generator ignores SSE detection: streaming ops return () and hang forever","description":"Operations whose response is text/event-stream generate a client method returning Result\u003c(), ApiOpError\u003c..\u003e\u003e. The generated body calls response.text().await on an open SSE stream and discards the result. Since the stream never ends, the call never returns.\n\nVERIFIED: GET /v2/pods/{id}/logs on RunPod v2 generates:\n pub async fn get_pod_logs(..) -\u003e Result\u003c(), ApiOpError\u003cGetPodLogsApiError\u003e\u003e\nThe live-testing agent measured the call hung past 30s and past 600s. The generated HttpClient sets no timeout, so it deadlocks the caller's task rather than erroring. Raw SSE against the same endpoint works and returns well-formed frames.\n\nThe detection already exists and is correct: supports_streaming is computed in src/analysis.rs (~4562-4577) and the SERVER generator honors it (src/server/codegen.rs ~2263-2290). The CLIENT generator never reads it — reported as zero occurrences in client_generator.rs.\n\nTwo defects in one: (1) the streaming contract is dropped, (2) a discarded body plus no default timeout turns it into a hang instead of a visible failure.\n\nMinimum fix: have client_generator consult supports_streaming and emit a streaming return type instead of (). Independently worth doing: give the generated client a default request timeout so a mis-generated call fails loudly rather than hanging.","acceptance_criteria":"An operation declaring a text/event-stream response generates a client method returning a stream type rather than (), and does not call response.text() on it. Covered by a test over a spec with an SSE endpoint.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T23:51:19Z","created_by":"James Lal","updated_at":"2026-07-27T00:59:39Z","closed_at":"2026-07-27T00:59:39Z","close_reason":"Fixed in fad3ed3; verified against the live RunPod v2 API (list_gpu_types and list_pods now deserialize, with real nulls present in the responses). 362 tests pass.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-upz","title":"Generated multipart clients miss reqwest multipart feature in REQUIRED_DEPS","description":"For specs with multipart/form-data operations, the generator emits client code calling reqwest::multipart::Form and RequestBuilder::multipart(), and correctly adds features=[\"multipart\"] to reqwest-middleware in REQUIRED_DEPS.toml — but NOT to reqwest itself, which is pinned with default-features=false. The generated crate therefore fails to compile with E0433 (cannot find multipart in reqwest) and E0599 (no method named multipart on reqwest_middleware::RequestBuilder when its feature is also absent).\n\nReproducer: generate a client from https://api.studio.nebius.com/openapi.json (Nebius AI Studio, OpenAI-compatible, has POST /v1/files with multipart/form-data), then cargo check. Fails at client.rs with the two errors above.\n\nImpact: hits any spec with a file-upload endpoint, including OpenAI's own spec. This is first-run breakage — the user runs the tool, the output does not compile, and they leave.\n\nFix: when any operation uses multipart/form-data, add \"multipart\" to the reqwest feature list in the emitted REQUIRED_DEPS.toml fragment (alongside the existing reqwest-middleware feature).","acceptance_criteria":"Generating from a spec with a multipart/form-data operation produces a REQUIRED_DEPS.toml whose reqwest entry includes the multipart feature, and the resulting crate compiles clean. Covered by a corpus compile-check.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T23:35:12Z","created_by":"James Lal","updated_at":"2026-07-27T00:59:40Z","closed_at":"2026-07-27T00:59:40Z","close_reason":"Fixed in fad3ed3; verified against the live RunPod v2 API (list_gpu_types and list_pods now deserialize, with real nulls present in the responses). 362 tests pass.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -43,6 +53,9 @@ {"id":"openapi-generator-cv4","title":"Support exploded query parameters (GH #27)","description":"GH issue 27: query params with style=form, explode=true (OAS defaults) and object schemas currently map to Option\u003cimpl AsRef\u003cstr\u003e\u003e and are sent as one opaque string. Per OAS/RFC6570 form-explode, each object property must become its own query pair (?color=red). Fix: analyzer synthesizes/resolves a typed struct for object query params with form+explode semantics; client generator emits req.query(\u0026struct) so reqwest/serde_urlencoded serializes properties as individual pairs.","notes":"Implemented on branch issue-27-exploded-query-params, draft PR https://github.com/gpu-cli/openapi-to-rust/pull/28. Close when PR merges. Follow-ups: openapi-generator-anu (deepObject/explode=false/arrays), openapi-generator-0jz (server side).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T23:22:24Z","created_by":"James Lal","updated_at":"2026-07-13T04:21:19Z","started_at":"2026-07-12T23:22:49Z","closed_at":"2026-07-13T04:21:19Z","close_reason":"Released in v0.6.0 (PR #28)","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-5q8","title":"DateStrategy::Time emits broken serde codec for time::Date/time::Time (GH #25)","description":"GitHub issue gpu-cli/openapi-to-rust#25: fields with format: date/time under DateStrategy::Time get #[serde(with = \"time::serde::iso8601\")], but that module only supports OffsetDateTime — generated code fails to compile. Fix: emit time::serde::format_description! helper codec modules (time_date_format / time_time_format) into generated code, and correct the time dep requirement features (serde alone doesn't even enable rfc3339).","notes":"Fixed in PR https://github.com/gpu-cli/openapi-to-rust/pull/26 (branch worktree-issue-25-time-date-serde). Close when PR merges.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T18:36:28Z","created_by":"James Lal","updated_at":"2026-07-11T18:57:02Z","started_at":"2026-07-11T18:36:45Z","closed_at":"2026-07-11T18:57:02Z","close_reason":"Fixed in PR #26, merged to main, released in v0.5.3","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-dpd","title":"Hybrid string-or-object discriminated unions deserialize-fail","description":"When an anyOf/oneOf union contains a discriminator AND a non-object branch (e.g. string-enum like ToolChoiceOptions), the generator emits a tagged enum that cannot deserialize the string form. Real-world hit: OpenAI ToolChoiceParam returns 'auto' in Response.tool_choice, but generated type is #[serde(tag=\"type\")] enum with no untagged String variant. Need to fall back to #[serde(untagged)] when the union mixes string/scalar branches with tagged-object branches, OR add a String fallback variant before the tagged variants.","notes":"Live repro: `ToolChoiceParam` from openai.yaml line 52518. anyOf has 8 branches; the first (`ToolChoiceOptions`) is a string-enum (\"none\"|\"auto\"|\"required\"), the rest are objects with discriminator propertyName=type. Generator emits `#[serde(tag=\"type\")] enum ToolChoiceParam { ToolChoiceOptions(ToolChoiceOptions), ... }` which cannot deserialize the string \"auto\" because serde tries to read a \"type\" field from a JSON string. Fix: when an anyOf/oneOf branch is a non-object schema (string/number/etc), the generator must emit `#[serde(untagged)]` with the scalar branch first OR add a String variant before the tagged variants. Hit on real OpenAI Responses API `Response.tool_choice` field.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-10T23:47:35Z","created_by":"James Lal","updated_at":"2026-05-11T00:12:46Z","started_at":"2026-05-10T23:54:10Z","closed_at":"2026-05-11T00:12:46Z","close_reason":"Fixed in src/analysis.rs: (dpd) analyze_oneof_union now downgrades to untagged when any branch is non-object — verified live against OpenAI Response.tool_choice='auto' which now deserializes as ToolChoiceParam::ToolChoiceOptions(Auto). (bgo) merge_schema_into_properties now ORs in is_nullable_pattern() for allOf-merged props — verified live against OpenAI Response.incomplete_details which is now Option\u003cResponseIncompleteDetails\u003e and deserializes null cleanly. All 4 smoke tests (OpenAI+Anthropic, sync+stream) pass.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-36a","title":"Run playground WASM lock bump only for release tags","description":"The playground WASM workflow currently runs on matching pushes to main and commits website/playground-wasm.lock. Restrict automatic runs to v* release tag pushes, while ensuring the generated lock bump is committed back to main rather than attempting to update the tag ref.","acceptance_criteria":"Pushes to main do not trigger the workflow; v* tag pushes publish the WASM asset; the lock bump is committed to main; manual dispatch remains safe.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-28T21:28:57Z","created_by":"James Lal","updated_at":"2026-07-28T21:30:21Z","started_at":"2026-07-28T21:29:00Z","closed_at":"2026-07-28T21:30:21Z","close_reason":"Restricted automatic playground WASM publishing to v* tag pushes and made tag-triggered lock commits target main; actionlint and diff checks pass.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-zeg","title":"Add Storyden OpenAPI document to the real-world corpus","description":"Vendor Southclaws/storyden api/openapi.yaml into specs, register its canonical download source, preserve its MPL-2.0 per-file licensing notice, update documented corpus counts, and run Storyden generation/compile coverage after the three discovered fixes.","acceptance_criteria":"specs/storyden.yaml is reproducibly sourced and carries clear MPL-2.0 attribution; corpus documentation counts are updated; parse-only and compile generation for Storyden pass with the fixed generator.","notes":"Approach: copy the canonical main-branch spec to specs/storyden.yaml, add an MPL-2.0/source notice at the file boundary, register the raw GitHub URL in specs/download.sh, update README/CONTRIBUTING counts from 55/54 to 56/55, then run scripts/spec-compile.sh storyden and the Storyden all-operation Axum compile smoke. Files: specs/storyden.yaml, specs/download.sh, README.md, CONTRIBUTING.md. License: upstream repository LICENSE is MPL-2.0; keep the vendored file under MPL-2.0 with explicit source/license notice.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-28T21:26:54Z","created_by":"James Lal","updated_at":"2026-07-28T22:26:29Z","started_at":"2026-07-28T21:27:01Z","closed_at":"2026-07-28T22:26:29Z","close_reason":"Implemented and verified against the vendored Storyden spec: generated clients and Axum servers compile and round-trip; response/request media handling is representation-safe and all buffered client responses are bounded.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-2yk","title":"Exercise Storyden OpenAPI document through generators","description":"Fetch Southclaws/storyden api/openapi.yaml, run validation and representative generation workflows against this checkout, investigate reproducible failures or malformed output, and record concrete generator bugs.","acceptance_criteria":"The specification is exercised through relevant generator paths; failures are reduced to actionable evidence; confirmed generator bugs are tracked or fixed and verified.","notes":"Fetched Southclaws/storyden main/api/openapi.yaml (13,105 lines, 457,079 bytes). Direct client generation parsed 615 schemas and 237 operations, emitted types/client/mod/dependency manifest, and passed cargo check. Semantic inspection found scalar-alias array query fallback and discarded non-JSON success bodies. Per-operation Axum sweep found 13 front-end failures: 5 scalar-alias query arrays and 8 non-JSON response media operations. After excluding those, combined server output exposed 8 response enum/schema naming collisions (E0659/E0072). After also excluding collision operations, remaining 216 operations generated together with validation enabled and passed cargo check. Follow-up bugs: openapi-generator-6z1, openapi-generator-9en, openapi-generator-8c6.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-28T21:05:25Z","created_by":"James Lal","updated_at":"2026-07-28T21:12:16Z","started_at":"2026-07-28T21:05:30Z","closed_at":"2026-07-28T21:12:16Z","close_reason":"Investigation complete; three concrete generator bug classes reproduced, reduced, tracked, and remaining Storyden server surface compile-verified.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-brl","title":"Emit Content-Length zero for bodyless requests","description":"Implement GitHub issue #43 by generating explicit Content-Length: 0 framing for bodyless POST, PUT, and PATCH client operations, including optional bodies passed as None, with regression coverage.","acceptance_criteria":"Generated bodyless POST, PUT, and PATCH requests set Content-Length: 0; methods without content semantics do not; optional bodies only set zero when absent; tests pass; draft PR references issue #43.","notes":"Implemented explicit Content-Length: 0 framing in client codegen for bodyless POST, PUT, and PATCH requests, including schema-less operations and optional bodies passed as None. Added positive, negative-method, schema-less, and optional-body regression tests. cargo fmt --check, cargo clippy --all-features -- -D warnings, and cargo test --all-features pass.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-28T20:34:26Z","created_by":"James Lal","updated_at":"2026-07-28T20:43:25Z","started_at":"2026-07-28T20:34:37Z","closed_at":"2026-07-28T20:43:25Z","close_reason":"Implemented and validated; publishing draft PR for GitHub issue #43.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-oug","title":"Default(ErrorResponse) error variant is generated but never constructed","description":"Every generated per-operation error enum includes a Default(ErrorResponse) variant for the spec's 'default' response, but no code path ever constructs it: 'grep -c \"ApiError::Default(\"' returns 0 in generated clients.\n\nVERIFIED on the RunPod v2 client: Default(ErrorResponse) is declared (types/client around lines 298, 308, 318 of the generated client) and constructed zero times.\n\nConsequence: a live 422 whose body parses cleanly into ErrorResponse still yields typed: None, so callers fall back to raw body strings. The per-operation typed-error feature — one of the generator's headline selling points — silently does not work for default responses.\n\nFound by live-testing the RunPod v2 API against the generated client.","acceptance_criteria":"A response matched only by the spec's 'default' response constructs the Default(..) variant with the typed body, and a test asserts typed is Some for that case.","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T23:51:30Z","created_by":"James Lal","updated_at":"2026-07-27T00:59:39Z","closed_at":"2026-07-27T00:59:39Z","close_reason":"Fixed in fad3ed3; verified against the live RunPod v2 API (list_gpu_types and list_pods now deserialize, with real nulls present in the responses). 362 tests pass.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-igg","title":"Default client base_url from servers[0].url when config omits it","description":"HttpClient::new() initializes base_url to String::new() when the TOML config has no [http_client] base_url, even when the OpenAPI document declares servers[0].url. Users must read the docs and supply the base URL manually or every request 404s.\n\nObserved generating from https://api.runpod.io/v2/openapi.json, whose spec declares servers[0].url = https://api.runpod.io; the generated client still starts with an empty base_url.\n\nThis matters most for published, generated client crates, where HttpClient::new() working out of the box is the difference between a crate that feels native and one that appears broken on first use.\n\nFix: when [http_client].base_url is absent, fall back to servers[0].url from the spec. Explicit config keeps precedence. Consider also emitting it as a pub const BASE_URL so downstream crates can reference it without hardcoding a string.","acceptance_criteria":"Generating from a spec with a servers entry and no configured base_url yields a client that targets servers[0].url by default; an explicitly configured base_url still wins.","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-26T23:35:28Z","created_by":"James Lal","updated_at":"2026-07-27T00:59:40Z","closed_at":"2026-07-27T00:59:40Z","close_reason":"Fixed in fad3ed3; verified against the live RunPod v2 API (list_gpu_types and list_pods now deserialize, with real nulls present in the responses). 362 tests pass.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d597140 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +specs/storyden.yaml whitespace=-trailing-space diff --git a/.github/workflows/playground-wasm.yml b/.github/workflows/playground-wasm.yml index 2719828..5ac5be8 100644 --- a/.github/workflows/playground-wasm.yml +++ b/.github/workflows/playground-wasm.yml @@ -1,22 +1,13 @@ name: Playground WASM -# Builds the website playground's WASM bundle whenever the generator changes, -# publishes it as a public release asset, and bumps website/playground-wasm.lock. +# Builds the website playground's WASM bundle for each release tag, publishes it +# as a public release asset, and bumps website/playground-wasm.lock on main. # The lock-bump commit triggers Vercel's git auto-deploy, whose build fetches # the asset via website/scripts/fetch-playground-wasm.mjs. No Vercel secrets. -# -# Self-retrigger is impossible by construction: the lock file lives under -# website/, outside this workflow's path filter. on: push: - branches: [main] - paths: - - "src/**" - - "wasm/**" - - "Cargo.toml" - - "Cargo.lock" - - "scripts/build-playground-wasm.sh" + tags: ["v*"] workflow_dispatch: permissions: @@ -46,6 +37,7 @@ jobs: run: ./scripts/build-playground-wasm.sh - name: Package and publish release asset + id: publish env: GH_TOKEN: ${{ github.token }} run: | @@ -58,13 +50,22 @@ jobs: --prerelease \ --title "Playground WASM $version (${GITHUB_SHA::7})" \ --notes "WASM bundle for openapi-to-rust.dev/playground, built from ${GITHUB_SHA}." - echo "$tag" > website/playground-wasm.lock + echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Commit lock bump - # Pushes back to the ref the workflow ran on: main for the normal - # push-triggered flow, the dispatched branch for pre-merge test runs. + # Tag-triggered runs update main. Manual pre-merge runs update the + # branch they were dispatched from. + env: + WASM_RELEASE_TAG: ${{ steps.publish.outputs.tag }} run: | set -euo pipefail + target_branch="$GITHUB_REF_NAME" + if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then + target_branch="main" + fi + git fetch origin "$target_branch" + git checkout -B "$target_branch" "origin/$target_branch" + echo "$WASM_RELEASE_TAG" > website/playground-wasm.lock if git diff --quiet -- website/playground-wasm.lock; then echo "lock unchanged; nothing to deploy" exit 0 @@ -73,5 +74,4 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add website/playground-wasm.lock git commit -m "chore: bump playground wasm to $(cat website/playground-wasm.lock) [skip ci]" - git pull --rebase origin "$GITHUB_REF_NAME" - git push origin "HEAD:$GITHUB_REF_NAME" + git push origin "HEAD:$target_branch" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 70664b5..d3f6213 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,7 +77,7 @@ scripts/spec-compile.sh anthropic openai # generator/client output scripts/spec-compile.sh # broad generator/type changes ``` -The full corpus generates and compile-checks 54 OpenAPI documents and can take +The full corpus generates and compile-checks 55 OpenAPI documents and can take several minutes. CI runs a fast generation tier on pull requests and the full compile tier weekly or on manual dispatch. diff --git a/README.md b/README.md index ff0ca97..b3d9807 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,10 @@ committed output. We originally built this internally at [GPU CLI](https://gpu-cli.sh) to generate typed Rust clients for OpenAI, Anthropic, Cloudflare, and other large APIs. After battle-testing it against real-world specs with complex union types, discriminated enums, streaming endpoints, and the occasional spec/API drift, we decided to open source it. -The repository contains **55 real-world specs**. The supported OpenAPI corpus is -54 specs (one Gitea document is Swagger 2.0 and intentionally skipped). Pull +The repository contains **56 real-world specs**. The supported OpenAPI corpus is +55 specs (one Gitea document is Swagger 2.0 and intentionally skipped). Pull requests compile-check the OpenAI and Anthropic production specs; a scheduled -and manually runnable CI tier checks all 54 OpenAPI specs. +and manually runnable CI tier checks all 55 OpenAPI specs. Release history and breaking changes live in the [changelog](CHANGELOG.md). @@ -110,6 +110,7 @@ enable_async_client = true [http_client] base_url = "https://api.example.com" timeout_seconds = 30 +max_response_body_bytes = 8388608 [http_client.retry] max_retries = 3 @@ -261,6 +262,7 @@ use crate::generated::types::*; async fn main() -> Result<(), Box> { let client = HttpClient::new() .with_base_url("https://api.example.com") + .with_max_response_body_bytes(8 * 1024 * 1024) .with_api_key(std::env::var("API_KEY")?); let req = CreateResourceRequest { /* … */ }; @@ -269,6 +271,14 @@ async fn main() -> Result<(), Box> { } ``` +Generated clients cap every response they buffer in memory at 8 MiB by +default. Set `http_client.max_response_body_bytes` when generating, or use the +runtime builder above for a particular client. If a cumulative chunked or +fixed-length body crosses the limit, the call returns +`HttpError::ResponseTooLarge { limit }` before appending the chunk that would +exceed it. Successful SSE responses remain streaming; only SSE error responses +are buffered under the same cap. + ## What the generated types look like A tour of patterns the generator emits, from real outputs. @@ -572,6 +582,7 @@ registry_only = false # only generate the registry (skip types [http_client] base_url = "https://api.example.com" timeout_seconds = 30 # 1-3600 +max_response_body_bytes = 8388608 # buffered responses; 8 MiB default [http_client.retry] max_retries = 3 # 0-10 @@ -665,10 +676,10 @@ scripts/spec-compile.sh # generate + cargo-check every spec in specs/ (full co The compile tiers are intentionally different: -- Every pull request and push to `main` generates all 54 supported OpenAPI +- Every pull request and push to `main` generates all 55 supported OpenAPI specs, then compile-checks the Anthropic and OpenAI production specs against each generated `REQUIRED_DEPS.toml`. -- Weekly scheduled CI and manual workflow runs compile-check all 54 supported +- Weekly scheduled CI and manual workflow runs compile-check all 55 supported OpenAPI specs. The bundled Gitea Swagger 2.0 document is reported as skipped. - Local `scripts/spec-compile.sh` runs the same full tier; pass spec names as arguments for a smaller targeted run. diff --git a/examples/complete_workflow.rs b/examples/complete_workflow.rs index e7ffe4a..380c9f2 100644 --- a/examples/complete_workflow.rs +++ b/examples/complete_workflow.rs @@ -292,6 +292,7 @@ fn demonstrate_rust_api( http_client_config: Some(HttpClientConfig { base_url: Some("https://api.example.com".to_string()), timeout_seconds: Some(30), + max_response_body_bytes: Some(8 * 1024 * 1024), default_headers: { let mut headers = std::collections::HashMap::new(); headers.insert("content-type".to_string(), "application/json".to_string()); diff --git a/specs/download.sh b/specs/download.sh index eae5f0b..c3acbd6 100755 --- a/specs/download.sh +++ b/specs/download.sh @@ -232,6 +232,11 @@ download "cloudflare-dns" \ "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json" \ "cloudflare-dns.json" +# --- Community Platforms --- +download "storyden" \ + "https://raw.githubusercontent.com/Southclaws/storyden/main/api/openapi.yaml" \ + "storyden.yaml" + # --- Misc DevTools --- download "railway" \ "https://docs.railway.com/reference/public-api-spec.json" \ diff --git a/specs/storyden.yaml b/specs/storyden.yaml new file mode 100644 index 0000000..e666097 --- /dev/null +++ b/specs/storyden.yaml @@ -0,0 +1,13110 @@ +# Source: https://raw.githubusercontent.com/Southclaws/storyden/main/api/openapi.yaml +# SPDX-License-Identifier: MPL-2.0 +# This vendored OpenAPI document remains available under the Mozilla Public +# License 2.0; see https://www.mozilla.org/MPL/2.0/. + +openapi: 3.1.0 + +# +# ╓███, +# ▄██▀"███▄ +# ╚█▀ `▀██▄ +# ,, ╓███, ▀██▌_ +# ╓███ ▄██▀"███▄ ╙███_ +# ▐██" ▄██▀ `▀██▄ ╙██ +# ▐██ ╙██▄ ╫██Γ ██─ +# ▐██ ▀██▄ ,▓██▀ ██─ +# ▐██ ╙█████▀ ██─ +# ▐██ ╓█████_ ██─ +# ▐██ ▄██▀` ╙███╥ ██─ +# └███████▀ `▀██ ██ +# + +info: + contact: + name: Barnaby Keene + description: > + Storyden social API for building community driven platforms. + + The Storyden API does not adhere to semantic versioning but instead applies + a rolling strategy with deprecations and minimal breaking changes. This has + been done mainly for a simpler development process and it may be changed to + a more fixed versioning strategy in the future. Ultimately, the primary way + Storyden tracks versions is dates, there are no set release tags currently. + title: storyden + version: "v1.26.13-post" + +servers: + - url: "/api" + description: > + The HTTP interface that this document describes is mounted on the `/api/` + path and any requests outside of the base path will not be covered by the + API specification and will be used for experimental features and plugins. + +# All endpoints are by default accessible via a browser session cookie or an API +# key for either human accounts or bot accounts unless explicitly specified. +security: + - browser: [] + - access_key: [] + - oauth_token: [] + +x-types: + cache_response_headers: &cache_response_headers + Cache-Control: { $ref: "#/components/headers/Cache-Control" } + Last-Modified: { $ref: "#/components/headers/Last-Modified" } + ETag: { $ref: "#/components/headers/ETag" } + +tags: + - name: misc + description: General metadata for the instance and uncategorised routes. + - name: admin + description: Administration and configuration settings. + - name: plugins + description: Management of plugins. + - name: roles + description: Roles for permissions and aesthetics. + - name: auth + description: Authentication resources. + - name: accounts + description: User accounts. + - name: invitations + description: Account invitations. + - name: notifications + description: Event notifications. + - name: reports + description: Content and user reports. + - name: profiles + description: Public profiles. + - name: categories + description: Thread categories. + - name: tags + description: Organisational tags for discussion posts and library nodes. + - name: posts + description: Any operations for any kind of Post resource. + - name: threads + description: Forum threads. + - name: replies + description: Posts within a specific thread. + - name: assets + description: File uploads and downloads. + - name: likes + description: Likes/votes for posts and library nodes. + - name: collections + description: User curated collections of posts and library nodes. + - name: nodes + description: Structured knowledgebase content tree. + - name: links + description: Social bookmarks. + - name: datagraph + description: Content graph (posts, nodes, links) APIs. + - name: events + description: Event scheduling, invites and management. + - name: robots + description: Robots (AI Agents) + +# +# 8888888b. d8888 88888888888 888 888 .d8888b. +# 888 Y88b d88888 888 888 888 d88P Y88b +# 888 888 d88P888 888 888 888 Y88b. +# 888 d88P d88P 888 888 8888888888 "Y888b. +# 8888888P" d88P 888 888 888 888 "Y88b. +# 888 d88P 888 888 888 888 "888 +# 888 d8888888888 888 888 888 Y88b d88P +# 888 d88P 888 888 888 888 "Y8888P" +# + +paths: + # + # d8b + # Y8P + # + # 88888b.d88b. 888 .d8888b .d8888b + # 888 "888 "88b 888 88K d88P" + # 888 888 888 888 "Y8888b. 888 + # 888 888 888 888 X88 Y88b. + # 888 888 888 888 88888P' "Y8888P + # + + /version: + get: + operationId: GetVersion + summary: Get the software version string. + description: | + The version number includes the date and time of the release build as + well as a short representation of the Git commit hash. + security: [] # TODO: Maybe remove from public access? + tags: [misc] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "200": + description: OK + content: + text/plain: + schema: + type: string + + /openapi.json: + get: + operationId: GetSpec + summary: OpenAPI specification + description: | + This endpoint returns the OpenAPI specification for the Storyden API in + JSON format. This is useful for clients that want to dynamically load + the API specification for documentation or code generation. + security: [] # TODO: Remove this from default public access, add a perm. + tags: [misc] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "200": + description: OK + content: + application/vnd.oai.openapi+json;version=3.1.0: + schema: + type: object + + /docs: + get: + operationId: GetDocs + summary: API documentation + description: | + This endpoint returns the OpenAPI documentation for the Storyden API in + an interactive HTML format. This is useful for developers who want to + explore the API and test endpoints without writing code. + security: [] # TODO: Remove this from default public access, add a perm. + tags: [misc] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "200": + description: OK + content: + text/html: + schema: + type: string + + /session: + get: + operationId: GetSession + description: | + Provides the instance settings and, if authenticated, the member's + settings as well. This is effectively the same as calling `GetInfo` and + `AccountGet` at the same time. This is a convenience endpoint to reduce + round-trips for root level data needed to render a client's initial UI. + security: [] + tags: [misc] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "200": { $ref: "#/components/responses/GetSessionOK" } + + /info: + get: + operationId: GetInfo + description: | + Get the basic forum installation info such as title, description, etc. + + This is a fully public endpoint as it drives the ability to render stuff + like OpenGraph metadata, favicon, titles, descriptions, for crawlers. + security: [] + tags: [misc] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "200": { $ref: "#/components/responses/GetInfoOK" } + + /info/icon/{icon_size}: + get: + operationId: IconGet + description: Get the logo icon image. + security: [] + tags: [misc] + parameters: [{ $ref: "#/components/parameters/IconSize" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "200": { $ref: "#/components/responses/AssetGetOK" } + /info/icon: + post: + operationId: IconUpload + description: Upload and process the installation's logo image. + tags: [misc] + requestBody: { $ref: "#/components/requestBodies/AssetUpload" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": + description: OK + + /info/banner: + get: + operationId: BannerGet + description: Get the banner image. + security: [] + tags: [misc] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "200": { $ref: "#/components/responses/AssetGetOK" } + post: + operationId: BannerUpload + description: Upload and process the installation's banner image. + tags: [misc] + requestBody: { $ref: "#/components/requestBodies/AssetUpload" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": + description: OK + + /beacon: + post: + operationId: SendBeacon + description: | + A catch-all endpoint for tracking read states and other things that are + not critical to the functioning of the platform. This endpoint is fire + and forget and does not return any meaningful data. It is designed to + be used with the `navigator.sendBeacon` API in browsers to mark things + such as how far down a thread a member has read, or whether or not a + Library Page has been visited recently. It may queue the work for later + processing and is not guaranteed to be processed immediately or at all. + security: [] + tags: [misc] + requestBody: { $ref: "#/components/requestBodies/Beacon" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "202": + description: Accepted + + # + # 888 d8b + # 888 Y8P + # 888 + # 8888b. .d88888 88888b.d88b. 888 88888b. + # "88b d88" 888 888 "888 "88b 888 888 "88b + # .d888888 888 888 888 888 888 888 888 888 + # 888 888 Y88b 888 888 888 888 888 888 888 + # "Y888888 "Y88888 888 888 888 888 888 888 + # + + /admin: + get: + operationId: AdminSettingsGet + description: | + Retrieve all configuration settings for installation. This includes the + publicly accessible information for the instance as well as admin-only + access to sensitive configuration (environment variables) and settings. + tags: [admin] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AdminSettingsGetOK" } + + patch: + operationId: AdminSettingsUpdate + description: Update non-env configuration settings for installation. + tags: [admin] + requestBody: { $ref: "#/components/requestBodies/AdminSettingsUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AdminSettingsUpdateOK" } + + /admin/accounts: + get: + operationId: AccountList + description: | + List accounts for administrative moderation purposes. This endpoint is + intended for staff-facing member search and returns denser account data + than the public profile listing such as email addresses, held auth + services and administrative flags. + + Requires VIEW_ACCOUNTS. This is a read-only permission; account mutation + endpoints require narrower management permissions like MANAGE_ACCOUNTS, + MANAGE_SUSPENSIONS or ADMINISTRATOR. + tags: [accounts] + parameters: + - $ref: "#/components/parameters/SearchQuery" + - $ref: "#/components/parameters/PaginationQuery" + - $ref: "#/components/parameters/ProfilesSortByQuery" + - $ref: "#/components/parameters/ProfilesRoleFilterQuery" + - $ref: "#/components/parameters/ProfilesJoinRangeQuery" + - $ref: "#/components/parameters/ProfilesInvitedByQuery" + - $ref: "#/components/parameters/AdminAccountsSuspendedQuery" + - $ref: "#/components/parameters/AdminAccountsAdminQuery" + - $ref: "#/components/parameters/AdminAccountsKindQuery" + - $ref: "#/components/parameters/AdminAccountsAuthServicesQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AccountListOK" } + + /admin/audit-events: + get: + operationId: AuditEventList + description: | + List audit events for the installation. Audit events track important + actions taken by accounts with elevated permissions such as admin + accounts and moderators. It also provides error logs for system internal + components that could not be responded to the client directly such as + background job failures and configuration issues. + tags: [admin] + parameters: + - $ref: "#/components/parameters/PaginationQuery" + - $ref: "#/components/parameters/AuditEventTypeFilterQuery" + - $ref: "#/components/parameters/AuditEventTimeRangeQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AuditEventListOK" } + + /admin/email-queue: + get: + operationId: EmailQueueList + description: | + List durable email queue records for this installation. Shows pending, + failed and sent deliveries with attempt history for diagnostics. + tags: [admin] + parameters: + - $ref: "#/components/parameters/PaginationQuery" + - $ref: "#/components/parameters/SearchQuery" + - $ref: "#/components/parameters/EmailQueueStatusFilterQuery" + - $ref: "#/components/parameters/EmailQueueTimeRangeQuery" + responses: + "400": { $ref: "#/components/responses/BadRequest" } + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/EmailQueueListOK" } + + /admin/email-queue/{email_id}/attempt: + post: + operationId: EmailQueueRetry + description: | + Manually retry a delivery for an email queue record. This is used for + failed deliveries. This will create a new delivery attempt and update + the email queue record accordingly. + tags: [admin] + parameters: [{ $ref: "#/components/parameters/EmailIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/EmailQueueGetOK" } + + /admin/audit-events/{audit_event_id}: + get: + operationId: AuditEventGet + description: Retrieve a specific audit event by ID. + tags: [admin] + parameters: [{ $ref: "#/components/parameters/AuditEventIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AuditEventGetOK" } + + /admin/actions: + post: + operationId: ModerationActionCreate + description: Create a new moderation action such as a ban or content purge. + tags: [admin] + requestBody: { $ref: "#/components/requestBodies/ModerationActionCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "201": { $ref: "#/components/responses/AuditEventCreatedOK" } + + /admin/bans/{account_handle}: + post: + operationId: AdminAccountBanCreate + description: | + Suspend an account - soft delete. This disables the ability for the + account owner to log in and use the platform. It keeps the account on + record for linkage to content so UI doesn't break. It does not change + anything else about the account such as the avatar, name, etc. + tags: [admin] + parameters: [$ref: "#/components/parameters/AccountHandleParam"] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AccountGetOK" } + delete: + operationId: AdminAccountBanRemove + description: Given the account is suspended, remove the suspended state. + tags: [admin] + parameters: [$ref: "#/components/parameters/AccountHandleParam"] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AccountGetOK" } + + /admin/access-keys: + get: + operationId: AdminAccessKeyList + description: | + List all access keys for the entire instance. This is only available to + admin accounts and is used to manage access keys from other accounts. + tags: [admin] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/AdminAccessKeyListOK" } + + /admin/access-keys/{access_key_id}: + delete: + operationId: AdminAccessKeyDelete + description: | + Revoke an access key. This will immediately invalidate the key and it + will no longer be usable for authentication. + tags: [admin] + parameters: [{ $ref: "#/components/parameters/AccessKeyIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "204": { $ref: "#/components/responses/NoContent" } + + /admin/oauth/clients: + get: + operationId: AdminOAuthClientList + description: | + List OAuth clients registered for this instance. + + This admin view includes both member-created third-party clients and + built-in first-party clients. Built-in clients will not be owned by an + account; their grants and refresh tokens are owned by the approving + account instead. + tags: [admin] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/OAuthClientListOK" } + post: + operationId: AdminOAuthClientCreate + description: | + Create an OAuth client. + + Confidential clients receive a generated secret once at creation time + and must authenticate to `/oauth/token` when using confidential grants. + Public clients do not receive or use a client secret. + tags: [admin] + requestBody: { $ref: "#/components/requestBodies/OAuthClientCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/OAuthClientOK" } + + /admin/oauth/clients/{oauth_client_id}: + get: + operationId: AdminOAuthClientGet + description: | + Read an OAuth client. + + OAuth clients represent application/software identity. They are not the + same thing as a user authorisation; user-owned authorisations are + represented by device authorisations, authorisation requests, and + refresh tokens. + tags: [admin] + parameters: [{ $ref: "#/components/parameters/OAuthClientIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "200": { $ref: "#/components/responses/OAuthClientOK" } + patch: + operationId: AdminOAuthClientUpdate + description: | + Update an OAuth client. + + Changing allowed grants or scopes only affects future authorisation and + refresh operations. Already-issued JWT access tokens remain valid until + their expiry unless their signing key is rotated. + + For account-owned clients, allowed permission scopes must be grantable + by the owning account. An account with `ADMINISTRATOR` may configure any + Storyden permission scope because `ADMINISTRATOR` implicitly grants all + permissions. + tags: [admin] + parameters: [{ $ref: "#/components/parameters/OAuthClientIDParam" }] + requestBody: { $ref: "#/components/requestBodies/OAuthClientUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "200": { $ref: "#/components/responses/OAuthClientOK" } + delete: + operationId: AdminOAuthClientDelete + description: | + Delete an OAuth client. + + Deleting a client also removes its pending OAuth records and refresh + tokens, preventing existing grants from being renewed. Existing JWT + access tokens are self-contained and remain valid until expiry. + tags: [admin] + parameters: [{ $ref: "#/components/parameters/OAuthClientIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "204": { $ref: "#/components/responses/NoContent" } + + /admin/oauth/device-authorizations: + get: + operationId: AdminOAuthDeviceAuthorisationList + description: | + List OAuth device authorisation records. + + Device authorisation records are short-lived records created by the + OAuth 2.0 Device Authorization Grant. They are not owned by an account + until a signed-in user claims and approves or denies the user code. + tags: [admin] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/OAuthDeviceAuthorisationListOK" } + + /admin/oauth/refresh-tokens: + get: + operationId: AdminOAuthRefreshTokenList + description: | + List OAuth refresh tokens. + + Refresh tokens are account-owned grants for an OAuth client. Revoking a + refresh token prevents future token renewal, but does not immediately + invalidate already-issued JWT access tokens. + tags: [admin] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/OAuthRefreshTokenListOK" } + + /admin/oauth/refresh-tokens/{oauth_refresh_token_id}: + delete: + operationId: AdminOAuthRefreshTokenDelete + description: | + Revoke an OAuth refresh token. + + This prevents future refresh-token use. Because Storyden OAuth access + tokens are JWTs, any access token already issued from this grant remains + valid until its normal expiry. + tags: [admin] + parameters: [{ $ref: "#/components/parameters/OAuthRefreshTokenIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "204": { $ref: "#/components/responses/NoContent" } + + /admin/oauth/remote/discover: + post: + operationId: OAuthRemoteDiscover + description: | + Discover OAuth configuration for a remote protected resource URL. + + Storyden fetches protected resource metadata, follows the advertised + authorization server, and chooses CIMD, DCR, or manual setup according + to the discovered authorization server metadata. + tags: [admin] + requestBody: { $ref: "#/components/requestBodies/OAuthRemoteDiscover" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/OAuthRemoteDiscoverOK" } + + /admin/oauth/remote/connections: + get: + operationId: OAuthRemoteConnectionList + description: | + List remote OAuth connections configured for this Storyden instance. + tags: [admin] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/OAuthRemoteConnectionListOK" } + post: + operationId: OAuthRemoteConnectionCreate + description: | + Create a remote OAuth connection using CIMD, DCR, or manual + configuration. CIMD is preferred when discovery supports it. + tags: [admin] + requestBody: + { $ref: "#/components/requestBodies/OAuthRemoteConnectionCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/OAuthRemoteConnectionOK" } + + /admin/oauth/remote/connections/{oauth_remote_connection_id}/authorize: + post: + operationId: OAuthRemoteConnectionAuthorize + description: | + Start OAuth authorization code with PKCE for a remote OAuth + connection and return the authorization URL to open in a browser. + tags: [admin] + parameters: + - $ref: "#/components/parameters/OAuthRemoteConnectionIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "200": { $ref: "#/components/responses/OAuthRemoteAuthorizeOK" } + + # + # 888 d8b + # 888 Y8P + # 888 + # 88888b. 888 888 888 .d88b. 888 88888b. .d8888b + # 888 "88b 888 888 888 d88P"88b 888 888 "88b 88K + # 888 888 888 888 888 888 888 888 888 888 "Y8888b. + # 888 d88P 888 Y88b 888 Y88b 888 888 888 888 X88 + # 88888P" 888 "Y88888 "Y88888 888 888 888 88888P' + # 888 888 + # 888 Y8b d88P + # 888 "Y88P" + # + + /plugins: + get: + operationId: PluginList + description: List all plugins that are installed on the instance. + tags: [plugins] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "200": { $ref: "#/components/responses/PluginListOK" } + post: + operationId: PluginAdd + description: | + Add a plugin to the instance. This will not install or activate the + plugin immediately. It will validate and prepare the plugin for install. + + Plugins can be uploaded directly as files or via a URL to a repository. + tags: [plugins] + requestBody: { $ref: "#/components/requestBodies/PluginAdd" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/PluginGetOK" } + + /plugins/{plugin_instance_id}: + get: + operationId: PluginGet + description: Get information about a specific plugin. + tags: [plugins] + parameters: [{ $ref: "#/components/parameters/PluginIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "200": { $ref: "#/components/responses/PluginGetOK" } + delete: + operationId: PluginDelete + description: | + Completely delete a plugin from the instance. This will uninstall the + plugin first then remove its binary file from the storage backend. + + Some plugins may write additional data to the instance, this will not + be removed by this operation unless the plugin cleans up after itself. + tags: [plugins] + parameters: [{ $ref: "#/components/parameters/PluginIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "204": { $ref: "#/components/responses/NoContent" } + + /plugins/{plugin_instance_id}/active: + patch: + operationId: PluginSetActiveState + description: | + Change the active state of a plugin. + + This operation only applies to supervised plugins. + + - `active`: starts the supervised plugin process. + - `inactive`: stops the supervised plugin process. + + External plugins cannot be managed with this endpoint and will return + a bad request error. + tags: [plugins] + parameters: [{ $ref: "#/components/parameters/PluginIDParam" }] + requestBody: { $ref: "#/components/requestBodies/PluginSetActiveState" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/PluginGetOK" } + + /plugins/{plugin_instance_id}/logs: + get: + operationId: PluginGetLogs + description: | + Stream logs for a supervised plugin. + + If the plugin is running, this endpoint streams live logs after + existing log history is sent. + + External plugins do not have host-managed logs and this endpoint + returns a bad request error for external mode. + tags: [plugins] + parameters: [{ $ref: "#/components/parameters/PluginIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/PluginGetLogsOK" } + + /plugins/{plugin_instance_id}/token: + post: + operationId: PluginCycleToken + description: | + Cycles the static bearer token for an external plugin and returns the + newly generated token. This operation is only valid for external + plugins. Supervised plugins cycle their connection token automatically. + tags: [plugins] + parameters: [{ $ref: "#/components/parameters/PluginIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/PluginCycleTokenOK" } + + /plugins/{plugin_instance_id}/manifest: + patch: + operationId: PluginUpdateManifest + description: | + Update the manifest for a plugin. This is used for development of plugins + where the manifest may change frequently and it's useful to be able to + update it without re-uploading the entire plugin bundle. + + This only works for External plugins that were created by uploading a + manifest directly. It does not work for Supervised plugins. + tags: [plugins] + parameters: [{ $ref: "#/components/parameters/PluginIDParam" }] + requestBody: { $ref: "#/components/requestBodies/PluginUpdateManifest" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/PluginGetOK" } + + /plugins/{plugin_instance_id}/package: + get: + operationId: PluginDownloadPackage + description: | + Download the original package archive for a supervised plugin + installation. + + The response body is the same zip archive bytes that were uploaded + when the plugin was installed or last updated. + tags: [plugins] + parameters: [{ $ref: "#/components/parameters/PluginIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/PluginDownloadPackageOK" } + + patch: + operationId: PluginUpdatePackage + description: | + Replace the package archive for a supervised plugin installation. + + The uploaded package manifest must have the same plugin ID as the + currently installed plugin. If the plugin is active, it is restarted + using the new package. If inactive, the package is replaced without + changing active state. + tags: [plugins] + parameters: [{ $ref: "#/components/parameters/PluginIDParam" }] + requestBody: { $ref: "#/components/requestBodies/PluginUpdatePackage" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/PluginGetOK" } + + /plugins/{plugin_instance_id}/configuration-schema: + get: + operationId: PluginGetConfigurationSchema + description: | + Returns the configuration schema for a plugin as defined in its manifest + file. The schema should be used to render a configuration form for the + plugin in the client so that administrators can configure the plugin. + tags: [plugins] + parameters: [{ $ref: "#/components/parameters/PluginIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/PluginGetConfigurationSchemaOK" } + + /plugins/{plugin_instance_id}/configuration: + get: + operationId: PluginGetConfiguration + description: | + Get the current configuration values for a plugin. The shape of the + object is defined by the plugin's manifest and should be used to render + the current configuration state in the client, using the layout driven + by the result of `PluginGetConfigurationSchema` to build a form-like UI. + tags: [plugins] + parameters: [{ $ref: "#/components/parameters/PluginIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/PluginGetConfigurationOK" } + + patch: + operationId: PluginUpdateConfiguration + description: | + Update the configuration for a plugin. Each plugin defines its own set + of configuration parameters in its manifest and this endpoint accepts + any object validated against that schema. When a valid configuration is + received, it is sent to the plugin via RPC and the plugin is expected to + apply the new configuration to itself internally. + tags: [plugins] + parameters: [{ $ref: "#/components/parameters/PluginIDParam" }] + requestBody: + { $ref: "#/components/requestBodies/PluginUpdateConfiguration" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/PluginGetConfigurationOK" } + + # + # 888 + # 888 + # 888 + # 888d888 .d88b. 888 .d88b. .d8888b + # 888P" d88""88b 888 d8P Y8b 88K + # 888 888 888 888 88888888 "Y8888b. + # 888 Y88..88P 888 Y8b. X88 + # 888 "Y88P" 888 "Y8888 88888P' + # + + /roles: + post: + operationId: RoleCreate + description: Creates a role with the specified permissions granted. + tags: [roles] + requestBody: { $ref: "#/components/requestBodies/RoleCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/RoleCreateOK" } + get: + operationId: RoleList + description: List all roles and their permissions. + tags: [roles] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "200": { $ref: "#/components/responses/RoleListOK" } + + /roles/order: + patch: + operationId: RoleUpdateOrder + description: | + Update the global ordering of custom roles. The request body must + include every non-default role identifier exactly once in the desired + order of precedence. + tags: [roles] + requestBody: { $ref: "#/components/requestBodies/RoleUpdateOrder" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/RoleListOK" } + + /roles/{role_id}: + get: + operationId: RoleGet + description: Retreives a role and all its permissions. + tags: [roles] + parameters: [{ $ref: "#/components/parameters/RoleIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/RoleGetOK" } + patch: + operationId: RoleUpdate + description: Updates a role's attributes. + tags: [roles] + parameters: [{ $ref: "#/components/parameters/RoleIDParam" }] + requestBody: { $ref: "#/components/requestBodies/RoleUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/RoleGetOK" } + delete: + operationId: RoleDelete + description: Deletes a role. + tags: [roles] + parameters: [{ $ref: "#/components/parameters/RoleIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { description: OK } + + # + # 888 888 + # 888 888 + # 888 888 + # 8888b. 888 888 888888 88888b. + # "88b 888 888 888 888 "88b + # .d888888 888 888 888 888 888 + # 888 888 Y88b 888 Y88b. 888 888 + # "Y888888 "Y88888 "Y888 888 888 + # + + /auth: + get: + operationId: AuthProviderList + description: | + Retrieve a list of authentication providers. Storyden supports a few + ways to authenticate, from simple passwords to OAuth and WebAuthn. This + endpoint tells a client which auth capabilities are enabled. + tags: [auth] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "200": { $ref: "#/components/responses/AuthProviderListOK" } + + /auth/password/signup: + post: + operationId: AuthPasswordSignup + description: Register a new account with a username and password. + tags: [auth] + parameters: [$ref: "#/components/parameters/InvitationIDQueryParam"] + requestBody: { $ref: "#/components/requestBodies/AuthPassword" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "409": { $ref: "#/components/responses/Conflict" } + "200": { $ref: "#/components/responses/AuthSuccessOK" } + + /auth/password/signin: + post: + operationId: AuthPasswordSignin + description: Sign in to an existing account with a username and password. + tags: [auth] + requestBody: { $ref: "#/components/requestBodies/AuthPassword" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/AuthSuccessOK" } + + /auth/password: + post: + operationId: AuthPasswordCreate + description: | + Given the requesting account does not have a password authentication, + add a password authentication method to it with the given password. + tags: [auth] + security: [browser: []] + requestBody: { $ref: "#/components/requestBodies/AuthPasswordCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AuthSuccessOK" } + patch: + operationId: AuthPasswordUpdate + description: | + Given the requesting account has a password authentication, update the + password on file. + tags: [auth] + security: [browser: []] + requestBody: { $ref: "#/components/requestBodies/AuthPasswordUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AuthSuccessOK" } + + /auth/password/reset: + post: + operationId: AuthPasswordReset + description: | + Complete a password-reset flow using a token that was provided to the + member via a reset request operation such as `AuthEmailPasswordReset`. + tags: [auth] + requestBody: { $ref: "#/components/requestBodies/AuthPasswordReset" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/AuthSuccessOK" } + + /auth/email-password/signup: + post: + operationId: AuthEmailPasswordSignup + description: Register a new account with a email and password. + tags: [auth] + parameters: [$ref: "#/components/parameters/InvitationIDQueryParam"] + requestBody: { $ref: "#/components/requestBodies/AuthEmailPassword" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "409": { $ref: "#/components/responses/Conflict" } + "200": { $ref: "#/components/responses/AuthSuccessOK" } + + /auth/email-password/signin: + post: + operationId: AuthEmailPasswordSignin + description: Sign in to an existing account with a email and password. + tags: [auth] + requestBody: { $ref: "#/components/requestBodies/AuthEmailPassword" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/AuthSuccessOK" } + + /auth/email-password/reset: + post: + operationId: AuthPasswordResetRequestEmail + description: | + Request password reset email to be sent to the specified email address. + tags: [auth] + requestBody: { $ref: "#/components/requestBodies/AuthEmailPasswordReset" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { description: OK } + + /auth/email/signup: + post: + operationId: AuthEmailSignup + description: | + Register a new account with an email and optional password. The password + requirement is dependent on how the instance is configured for account + authentication with email addresses (password vs magic link.) + + When the email address has not been registered, this endpoint will send + a verification email however it will also return a session cookie to + facilitate pre-verification usage of the platform. If the email address + already exists, no session cookie will be returned in order to prevent + arbitrary account control by a malicious actor. In this case, the email + will be sent again with the same OTP for the case where the user has + cleared their cookies or switched device but hasn't yet verified due to + missing the email or a delivery failure. In this sense, the endpoint can + act as a "resend verification email" operation as well as registration. + + In the first case, a 200 response is provided with the session cookie, + in the second case, a 422 response is provided without a session cookie. + + Given that this is an unauthenticated endpoint that triggers an email to + be sent to any public address, it MUST be heavily rate limited. + x-storyden: + rate-limit-cost: 10 + tags: [auth] + parameters: [$ref: "#/components/parameters/InvitationIDQueryParam"] + requestBody: { $ref: "#/components/requestBodies/AuthEmail" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "422": + description: Verification email sent but no session will be provided. + "200": { $ref: "#/components/responses/AuthSuccessOK" } + + /auth/email/signin: + post: + operationId: AuthEmailSignin + description: | + Sign in to an existing account with an email and optional password. The + behaviour of this endpoint depends on how the instance is configured. If + email+password is the preferred method, a cookie is returned on success + but if magic links are preferred, the endpoint will start the code flow. + tags: [auth] + requestBody: { $ref: "#/components/requestBodies/AuthEmail" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/AuthSuccessOK" } + + /auth/email/verify: + post: + operationId: AuthEmailVerify + description: | + Verify an email address using a token that was emailed to one of the + account's email addresses either set via sign up or added later. + tags: [auth] + requestBody: { $ref: "#/components/requestBodies/AuthEmailVerify" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/AuthSuccessOK" } + + /auth/oauth/{oauth_provider}/callback: + post: + operationId: OAuthProviderCallback + description: OAuth2 callback. + tags: [auth] + parameters: [$ref: "#/components/parameters/OAuthProvider"] + requestBody: { $ref: "#/components/requestBodies/OAuthProviderCallback" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "404": { $ref: "#/components/responses/NotFound" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/AuthSuccessOK" } + + /oauth/jwks: + get: + operationId: OAuthJWKS + description: | + List public JSON Web Keys that clients can use to validate Storyden + OAuth access tokens and OpenID Connect ID tokens. + + This is advertised by `/.well-known/openid-configuration` as `jwks_uri`. + Storyden serves this under the API mount because the key set is an API + resource; the well-known discovery document itself is mounted at the + instance root and is intentionally not part of this OpenAPI document. + tags: [auth] + security: [] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "200": { $ref: "#/components/responses/OAuthJWKSOK" } + + /oauth/device_authorization: + post: + operationId: OAuthDeviceAuthorisation + description: | + Start the OAuth 2.0 Device Authorization Grant for clients that cannot + receive a browser redirect directly, such as CLIs, terminals, and + desktop tools. + + The `scope` parameter follows OAuth 2.0 and is optional. Storyden + applies additional client policy after parsing the request: + + - Built-in first-party device clients, such as the default Storyden CLI + client, must request exactly `openid profile offline_access`. On + approval Storyden expands the issued scope to the approving account's + current permissions. + - Third-party explicit-scope clients may omit `scope`; omitted scope + means no requested scopes. + + `verification_uri` and `verification_uri_complete` point at the + configured frontend consent page, not at an API-rendered HTML page. + Custom frontends can change this URL with + `OAUTH_DEVICE_AUTHORISATION_CONSENT_URL`. + tags: [auth] + security: [] + requestBody: + { $ref: "#/components/requestBodies/OAuthDeviceAuthorisation" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/OAuthError" } + "200": { $ref: "#/components/responses/OAuthDeviceAuthorisationOK" } + + /oauth/device/consent: + get: + operationId: OAuthDeviceConsent + description: | + Read a pending OAuth device authorisation request for a signed-in user + before they approve or deny consent in the frontend. + + This is a Storyden frontend/API integration endpoint, not an OAuth + protocol endpoint. The API never renders the consent UI directly. A + frontend reads this JSON, displays the client and scopes, then submits + the user's decision. + + Reading consent claims the user code for the signed-in account. This + prevents another account from approving the same code after it has been + displayed. + tags: [auth] + security: [browser: []] + parameters: [{ $ref: "#/components/parameters/OAuthUserCodeQuery" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/OAuthError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/OAuthDeviceConsentOK" } + post: + operationId: OAuthDeviceConsentSubmit + description: | + Approve or deny a pending OAuth device authorisation request for the + currently signed-in account. + + On approval Storyden recomputes the granted scope from the current + account permissions and client policy. For first-party inherited clients + this means the final token scope may include Storyden permission scopes + that were not present in the original device authorisation request. + tags: [auth] + security: [browser: []] + requestBody: + $ref: "#/components/requestBodies/OAuthDeviceConsentSubmit" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/OAuthError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/OAuthDeviceConsentSubmitOK" } + + /oauth/authorize: + get: + operationId: OAuthAuthorise + description: | + Start the browser-based OAuth 2.0 Authorization Code flow with PKCE. + + This endpoint requires a browser session. If the account is not signed + in, Storyden redirects to the frontend login route instead of returning + a protocol redirect to the client application. + + Unlike many OAuth servers, Storyden does not render a consent page from + this API endpoint. A valid request creates a short-lived pending + authorisation request and redirects the browser to the configured + frontend authorisation-code consent URL. Custom frontends can change + this URL with `OAUTH_AUTHORISATION_CODE_CONSENT_URL`. + + The `scope` parameter follows OAuth 2.0 and is optional. Empty or + omitted scope means no requested scopes. Storyden permission scopes are + granted only when allowed by the client and by the signed-in account's + current permissions. + tags: [auth] + security: [browser: []] + parameters: + - { $ref: "#/components/parameters/OAuthResponseTypeQuery" } + - { $ref: "#/components/parameters/OAuthClientIDQuery" } + - { $ref: "#/components/parameters/OAuthRedirectURIQuery" } + - { $ref: "#/components/parameters/OAuthScopeQuery" } + - { $ref: "#/components/parameters/OAuthStateQuery" } + - { $ref: "#/components/parameters/OAuthNonceQuery" } + - { $ref: "#/components/parameters/OAuthCodeChallengeQuery" } + - { $ref: "#/components/parameters/OAuthCodeChallengeMethodQuery" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "200": { $ref: "#/components/responses/OAuthAuthoriseOK" } + "302": { $ref: "#/components/responses/OAuthAuthoriseFound" } + "400": { $ref: "#/components/responses/OAuthError" } + + /oauth/authorize/consent: + get: + operationId: OAuthAuthoriseConsent + description: | + Read a pending OAuth authorisation code request for a signed-in user + before they approve or deny consent in the frontend. + + This is a Storyden frontend/API integration endpoint, not an OAuth + protocol endpoint. It returns the client, redirect URI, requested + scopes, and currently grantable scopes so the frontend can render a + consent screen. + tags: [auth] + security: [browser: []] + parameters: + - $ref: "#/components/parameters/OAuthAuthorizationRequestIDQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/OAuthError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/OAuthAuthoriseConsentOK" } + post: + operationId: OAuthAuthoriseConsentSubmit + description: | + Approve or deny a pending OAuth authorisation code request for the + currently signed-in account. + + On approval this creates a short-lived authorisation code and returns + the client redirect URI containing `code` and optional `state`. On + denial the returned redirect URI contains `error=access_denied`. + + Storyden recomputes the granted scope at approval time from current + account permissions and client policy. + tags: [auth] + security: [browser: []] + requestBody: + $ref: "#/components/requestBodies/OAuthAuthoriseConsentSubmit" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/OAuthError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/OAuthAuthoriseConsentSubmitOK" } + + /oauth/token: + post: + operationId: OAuthToken + description: | + Exchange an OAuth authorisation code, device code, refresh token, or + client credentials grant for tokens. + + Supported grants are advertised by `/.well-known/openid-configuration`. + Public clients authenticate with `client_id` only and must use grants + suitable for public clients, such as device code or authorisation code + with PKCE. Confidential clients must provide `client_secret` for + authorisation-code, refresh-token, and client-credentials exchanges. + + Storyden access tokens are short-lived JWTs containing the issued + `scope`. Revoking a refresh token or changing account permissions does + not revoke an already-issued access token; permission changes are + applied on the next token issuance or refresh. + + Device-code polling returns OAuth-compatible errors such as + `authorization_pending`, `slow_down`, `expired_token`, + `access_denied`, and `invalid_grant`. + tags: [auth] + security: [] + requestBody: { $ref: "#/components/requestBodies/OAuthToken" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "200": { $ref: "#/components/responses/OAuthTokenOK" } + "400": { $ref: "#/components/responses/OAuthTokenError" } + "401": { $ref: "#/components/responses/OAuthTokenUnauthorised" } + + /oauth/userinfo: + get: + operationId: OAuthUserInfo + description: | + Return OpenID Connect UserInfo claims for the account represented by a + valid bearer access token. + + Claims are scope-gated: + + - `openid` identifies the subject. + - `profile` enables profile claims such as display name. + - `email` enables email claims when the account has an email address. + + Storyden accounts do not always have email addresses, so email claims + may be absent even when the `email` scope is present. + tags: [auth] + security: [oauth_token: []] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/OAuthUserInfoOK" } + + /oauth/remote/callback: + get: + operationId: OAuthRemoteCallback + description: | + Complete a remote OAuth authorization code callback. This validates the + saved state, exchanges the code with PKCE, and stores returned tokens + on the remote connection. + tags: [auth] + security: [browser: []] + parameters: + - $ref: "#/components/parameters/OAuthStateQuery" + - $ref: "#/components/parameters/OAuthCodeQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/OAuthRemoteCallbackOK" } + + /oauth/register: + post: + operationId: OAuthClientRegister + description: | + RFC 7591 OAuth 2.0 Dynamic Client Registration. + + Allows clients such as MCP connectors to register themselves without + prior administrator configuration. Dynamically registered clients are + tenant-owned (they have no account owner), use the explicit scope + policy, and are restricted to a conservative grant and scope allowlist. + + Authorization Code clients must use PKCE; Storyden enforces PKCE (S256) + at the authorize and token endpoints for all clients. + + Public clients register with `token_endpoint_auth_method: none` and + receive no client secret. Confidential clients register with + `client_secret_basic` or `client_secret_post` and receive a one-time + `client_secret` in the response. The registration endpoint is advertised + as `registration_endpoint` by the authorization server metadata + documents. + + This is an unauthenticated endpoint that creates server state, so it + is heavily rate limited to prevent abuse. + x-storyden: + rate-limit-cost: 10 + tags: [auth] + security: [] + requestBody: { $ref: "#/components/requestBodies/OAuthClientRegister" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "201": { $ref: "#/components/responses/OAuthClientRegisterOK" } + "400": { $ref: "#/components/responses/OAuthClientRegisterError" } + + /auth/webauthn/make/{account_handle}: + get: + operationId: WebAuthnRequestCredential + description: | + Start the WebAuthn registration process by requesting a credential. + tags: [auth] + parameters: [$ref: "#/components/parameters/AccountHandleParam"] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "200": { $ref: "#/components/responses/WebAuthnRequestCredentialOK" } + + /auth/webauthn/make: + post: + operationId: WebAuthnMakeCredential + description: Complete WebAuthn registration by creating a new credential. + tags: [auth] + security: [webauthn: []] + parameters: [$ref: "#/components/parameters/InvitationIDQueryParam"] + requestBody: { $ref: "#/components/requestBodies/WebAuthnMakeCredential" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "409": { $ref: "#/components/responses/Conflict" } + "200": { $ref: "#/components/responses/AuthSuccessOK" } + + /auth/webauthn/assert/{account_handle}: + get: + operationId: WebAuthnGetAssertion + description: Start the WebAuthn assertion for an existing account. + tags: [auth] + parameters: [$ref: "#/components/parameters/AccountHandleParam"] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": + $ref: "#/components/responses/WebAuthnGetAssertionOK" + + /auth/webauthn/assert: + post: + operationId: WebAuthnMakeAssertion + description: Complete the credential assertion and sign in to an account. + tags: [auth] + security: [webauthn: []] + requestBody: { $ref: "#/components/requestBodies/WebAuthnMakeAssertion" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AuthSuccessOK" } + + /auth/phone: + post: + operationId: PhoneRequestCode + description: | + Start the authentication flow with a phone number. The handler will send + a one-time code to the provided phone number which must then be sent to + the other phone endpoint to verify the number and validate the account. + tags: [auth] + parameters: [$ref: "#/components/parameters/InvitationIDQueryParam"] + requestBody: { $ref: "#/components/requestBodies/PhoneRequestCode" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "409": { $ref: "#/components/responses/Conflict" } + "200": { $ref: "#/components/responses/AuthSuccessOK" } + + /auth/phone/{account_handle}: + put: + operationId: PhoneSubmitCode + description: | + Complete the phone number authentication flow by submitting the one-time + code that was sent to the user's phone. + tags: [auth] + parameters: [$ref: "#/components/parameters/AccountHandleParam"] + requestBody: { $ref: "#/components/requestBodies/PhoneSubmitCode" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "200": { $ref: "#/components/responses/AuthSuccessOK" } + + /auth/access-keys: + get: + operationId: AccessKeyList + description: | + List all access keys for the authenticated account or all access keys + that have been issued for the entire instance if and only if the request + parameters specify all keys and the requesting account is an admin. + tags: [auth] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/AccessKeyListOK" } + + post: + operationId: AccessKeyCreate + description: | + Create a new access key for the authenticated account. Access keys are + used to authenticate API requests on behalf of the account in a more + granular and service-friendly way than a session cookie. + + Access keys share the same roles and permissions as the owning account + and only provide a way to use an `Authorization` header as an way of + interacting with the Storyden API. + + Access keys also allow an expiry date to be set to limit how long a key + can be used to authenticate against the API. + tags: [auth] + security: [browser: []] + requestBody: { $ref: "#/components/requestBodies/AccessKeyCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/AccessKeyCreateOK" } + + /auth/access-keys/{access_key_id}: + delete: + operationId: AccessKeyDelete + description: | + Revoke an access key. This will immediately invalidate the key and it + will no longer be usable for authentication. + tags: [auth] + parameters: [{ $ref: "#/components/parameters/AccessKeyIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "204": { $ref: "#/components/responses/NoContent" } + + /auth/oauth/tokens: + get: + operationId: OAuthRefreshTokenList + description: | + List OAuth refresh tokens issued to the authenticated account. + + This is the member-facing "authorised applications" view: it lists apps + the signed-in account has authorised and can revoke. In OAuth terms, + these rows are grants/tokens, not application definitions. + + This may include grants for built-in first-party clients such as the + default Storyden CLI. Those clients are not created by the member and + therefore do not appear in the member OAuth client list. + tags: [auth] + security: [browser: []] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/OAuthRefreshTokenListOK" } + + /auth/oauth/tokens/{oauth_refresh_token_id}: + delete: + operationId: OAuthRefreshTokenDelete + description: | + Revoke one OAuth refresh token issued to the authenticated account. + + This prevents future refresh-token use for the selected grant. Existing + JWT access tokens remain valid until their expiry. + tags: [auth] + security: [browser: []] + parameters: [{ $ref: "#/components/parameters/OAuthRefreshTokenIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/OAuthError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "204": { $ref: "#/components/responses/NoContent" } + + /auth/oauth/clients: + get: + operationId: OAuthClientList + description: | + List OAuth clients created by the authenticated account. + + This is the member-facing "apps I created" view. OAuth clients are + application definitions: client ID, client type, redirect URIs, allowed + scopes, and allowed grants. + + This does not list built-in first-party clients or third-party apps the + member has merely authorised. Use `/auth/oauth/tokens` for the + "apps I have authorised" view. + tags: [auth] + security: [browser: []] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/OAuthClientListOK" } + post: + operationId: OAuthClientCreate + description: | + Create an OAuth client owned by the authenticated account. + + Member-created clients are third-party explicit-scope clients. The + requested allowed scopes must be a subset of the authenticated account's + current permissions. + tags: [auth] + security: [browser: []] + requestBody: { $ref: "#/components/requestBodies/OAuthClientSelfCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/OAuthClientIssuedOK" } + + /auth/oauth/clients/{oauth_client_id}: + get: + operationId: OAuthClientGet + description: | + Read an OAuth client created by the authenticated account. + + Member-created clients are third-party application identities. They may + be public or confidential but are never first-party inherited-permission + clients. + tags: [auth] + security: [browser: []] + parameters: [{ $ref: "#/components/parameters/OAuthClientIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/OAuthError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/OAuthClientOK" } + patch: + operationId: OAuthClientUpdate + description: | + Update an OAuth client created by the authenticated account. + + Allowed scopes must remain within the authenticated account's current + permissions. If the account has `ADMINISTRATOR`, it may configure any + Storyden permission scope because `ADMINISTRATOR` implicitly grants all + permissions. + + Changing allowed scopes affects future grants and refreshes but does + not immediately invalidate already-issued JWT access tokens. + tags: [auth] + security: [browser: []] + parameters: [{ $ref: "#/components/parameters/OAuthClientIDParam" }] + requestBody: { $ref: "#/components/requestBodies/OAuthClientSelfUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/OAuthClientOK" } + delete: + operationId: OAuthClientDelete + description: | + Delete an OAuth client created by the authenticated account. + + This prevents new OAuth flows for the client and removes associated + pending OAuth records and refresh tokens, preventing existing grants + from being renewed. Existing JWT access tokens remain valid until + expiry. + tags: [auth] + security: [browser: []] + parameters: [{ $ref: "#/components/parameters/OAuthClientIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/OAuthError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "204": { $ref: "#/components/responses/NoContent" } + + /auth/logout: + post: + operationId: AuthProviderLogout + description: | + Performs a HTTP logout by clearing the session cookie and redirecting to + to the requested path at the frontend's `WEB_ADDRESS`. Typically this + may be a secondary logout route on the frontend implementation that can + handle any frontend-specific logout tasks. This is necessary in cases + where the frontend is running on a different origin to the API service + such as api.site.com vs site.com because Clear-Site-Data and other + headers are same-origin compliant and won't work cross-origin. + tags: [auth] + security: [browser: []] + parameters: + - name: redirect + in: query + description: | + Path relative to the `WEB_ADDRESS` to redirect to. Note that this is + a path only and not a full URL to prevent cross-origin or cross-site + redirects. If not provided, redirects to `WEB_ADDRESS` index page. + required: false + schema: + type: string + responses: + "302": + description: Redirect to specified URL or home page + headers: + Set-Cookie: { schema: { type: string } } + Clear-Site-Data: { schema: { type: string } } + Cache-Control: { schema: { type: string } } + Location: { schema: { type: string } } + + # + # 888 + # 888 + # 888 + # 8888b. .d8888b .d8888b .d88b. 888 888 88888b. 888888 .d8888b + # "88b d88P" d88P" d88""88b 888 888 888 "88b 888 88K + # .d888888 888 888 888 888 888 888 888 888 888 "Y8888b. + # 888 888 Y88b. Y88b. Y88..88P Y88b 888 888 888 Y88b. X88 + # "Y888888 "Y8888P "Y8888P "Y88P" "Y88888 888 888 "Y888 88888P' + # + + /accounts: + post: + operationId: AccountManageCreate + description: | + Create a human account without creating an authentication method. This + is intended for admin and integration driven account provisioning. + tags: [accounts] + requestBody: { $ref: "#/components/requestBodies/AccountManageCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "200": { $ref: "#/components/responses/AccountGetOK" } + + get: + operationId: AccountGet + description: Get the information for the currently authenticated account. + tags: [accounts] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AccountGetOK" } + "304": { $ref: "#/components/responses/NotModified" } + + patch: + operationId: AccountUpdate + description: Update the information for the currently authenticated account. + tags: [accounts] + requestBody: { $ref: "#/components/requestBodies/AccountUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AccountUpdateOK" } + + /accounts/{account_id}: + get: + operationId: AccountView + description: | + Get detailed account information by ID. Requires either the permissions + VIEW_ACCOUNTS or ADMINISTRATOR. Users with VIEW_ACCOUNTS can view any + account that is not ADMINISTRATOR including those with VIEW_ACCOUNTS. + Only members with ADMINISTRATOR can view other ADMINISTRATOR accounts. + tags: [accounts] + parameters: [{ $ref: "#/components/parameters/AccountIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/AccountGetOK" } + + patch: + operationId: AccountManageUpdate + description: Update staff-managed account lifecycle fields. + tags: [accounts] + parameters: [{ $ref: "#/components/parameters/AccountIDParam" }] + requestBody: { $ref: "#/components/requestBodies/AccountManageUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/AccountGetOK" } + + /accounts/{account_id}/auth/password/reset-token: + post: + operationId: AccountPasswordResetTokenGet + description: | + Provides the caller with a token that can be used with the endpoint + `/auth/password/reset` to reset the account's password. This is intended + for admin usage for when the instance is not using email authentication + mode or if the target account has no email addresses to send resets. + tags: [accounts] + parameters: [{ $ref: "#/components/parameters/AccountIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/AccountPasswordResetTokenGetOK" } + + /accounts/{account_id}/auth/email-password/reset: + post: + operationId: AccountEmailPasswordReset + description: | + Send a password reset email for the specified account. This is intended + for admin. This will trigger a password reset email to the specified + email address. The email address must be associated with the account. + tags: [accounts] + parameters: [{ $ref: "#/components/parameters/AccountIDParam" }] + requestBody: + $ref: "#/components/requestBodies/AccountEmailPasswordReset" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "204": { description: OK } + + /accounts/{account_id}/emails/{email_address_id}/verified: + patch: + operationId: AccountManageUpdateEmailVerifiedStatus + description: | + Override the verified status of an email address. This is intended for + admin and integration driven account management and is not intended to + be used for normal verification flows. Changing the verified status of + an email address here will not trigger any verification emails or other + side effects, it will simply set the field to the provided value. + tags: [accounts] + parameters: + - { $ref: "#/components/parameters/AccountIDParam" } + - { $ref: "#/components/parameters/EmailAddressIDParam" } + requestBody: + $ref: "#/components/requestBodies/AccountEmailVerifiedStatusUpdate" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/AccountEmailUpdateOK" } + + /accounts/{account_id}/warnings: + get: + operationId: AccountWarningList + description: | + List internal moderation warnings for an account. Warnings are never + public. Members may view their own warning history, and staff with the + `MANAGE_WARNINGS` permission may review warnings for any account. + tags: [accounts] + parameters: [{ $ref: "#/components/parameters/AccountIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "200": { $ref: "#/components/responses/AccountWarningListOK" } + post: + operationId: AccountWarningCreate + description: | + Create an internal moderation warning for an account. Requires the + `MANAGE_WARNINGS` permission. + tags: [accounts] + parameters: [{ $ref: "#/components/parameters/AccountIDParam" }] + requestBody: { $ref: "#/components/requestBodies/AccountWarningCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "200": { $ref: "#/components/responses/AccountWarningCreateOK" } + + /accounts/{account_id}/warnings/{warning_id}: + patch: + operationId: AccountWarningUpdate + description: | + Update the reason text for an existing warning. Requires the + `MANAGE_WARNINGS` permission. + tags: [accounts] + parameters: + - { $ref: "#/components/parameters/AccountIDParam" } + - { $ref: "#/components/parameters/WarningIDParam" } + requestBody: { $ref: "#/components/requestBodies/AccountWarningUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "200": { $ref: "#/components/responses/AccountWarningUpdateOK" } + delete: + operationId: AccountWarningDelete + description: | + Permanently delete a warning record. Requires the `MANAGE_WARNINGS` + permission. + tags: [accounts] + parameters: + - { $ref: "#/components/parameters/AccountIDParam" } + - { $ref: "#/components/parameters/WarningIDParam" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "204": { description: Warning deleted. } + + /accounts/{account_id}/moderation-notes: + get: + operationId: AccountModerationNoteList + description: | + List internal moderation notes for an account. Notes are never public + and are only visible to staff with VIEW_MODERATION_NOTES permission. + tags: [accounts] + parameters: [{ $ref: "#/components/parameters/AccountIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "200": { $ref: "#/components/responses/AccountModerationNoteListOK" } + post: + operationId: AccountModerationNoteCreate + description: | + Create an internal moderation note for an account. Notes are immutable + and always include the author and timestamp for auditing. + tags: [accounts] + parameters: [{ $ref: "#/components/parameters/AccountIDParam" }] + requestBody: + { $ref: "#/components/requestBodies/AccountModerationNoteCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "200": { $ref: "#/components/responses/AccountModerationNoteCreateOK" } + + /accounts/{account_id}/moderation-notes/{moderation_note_id}: + delete: + operationId: AccountModerationNoteDelete + description: | + Delete an internal moderation note for an account. + tags: [accounts] + parameters: + - { $ref: "#/components/parameters/AccountIDParam" } + - { $ref: "#/components/parameters/ModerationNoteIDParam" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "204": { $ref: "#/components/responses/NoContent" } + + /accounts/self/auth-methods: + get: + operationId: AccountAuthProviderList + description: | + Retrieve a list of authentication providers with a flag indicating which + ones are active for the currently authenticated account. + tags: [accounts] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "200": { $ref: "#/components/responses/AccountAuthProviderListOK" } + + /accounts/self/auth-methods/{auth_method_id}: + delete: + operationId: AccountAuthMethodDelete + description: | + Deletes the specified authentication method from the account. This is + irreversible however if this authentication method is the only remaining + method for the account, this operation will fail with a 400 bad request. + tags: [accounts] + parameters: + - name: auth_method_id + required: true + in: path + schema: + type: string + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "200": { $ref: "#/components/responses/AccountAuthProviderListOK" } + + /accounts/self/emails: + post: + operationId: AccountEmailAdd + description: Add an email address to the authenticated account. + tags: [accounts] + requestBody: { $ref: "#/components/requestBodies/AccountEmailAdd" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "409": { $ref: "#/components/responses/Conflict" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AccountEmailUpdateOK" } + + /accounts/self/emails/{email_address_id}: + delete: + operationId: AccountEmailRemove + description: Remove an email address from the authenticated account. + tags: [accounts] + parameters: [{ $ref: "#/components/parameters/EmailAddressIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { description: OK } + + /accounts/self/avatar: + post: + operationId: AccountSetAvatar + description: Upload an avatar for the authenticated account. + tags: [accounts] + requestBody: { $ref: "#/components/requestBodies/AccountSetAvatar" } + parameters: [$ref: "#/components/parameters/ContentLength"] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { description: "OK" } + + /accounts/{account_handle}/avatar: + get: + operationId: AccountGetAvatar + description: Get an avatar for the specified account. + tags: [accounts] + parameters: [$ref: "#/components/parameters/AccountHandleParam"] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AccountGetAvatar" } + + /accounts/{account_handle}/roles/{role_id}: + put: + operationId: AccountAddRole + description: | + Adds a role to an account. Members without the MANAGE_ROLES permission + cannot use this operation. + tags: [accounts] + parameters: + - $ref: "#/components/parameters/RoleIDParam" + - $ref: "#/components/parameters/AccountHandleParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AccountUpdateOK" } + delete: + operationId: AccountRemoveRole + description: | + Removes a role from an account. Members without the MANAGE_ROLES cannot + use this operation. Admins cannot remove the admin role from themselves. + tags: [accounts] + parameters: + - $ref: "#/components/parameters/RoleIDParam" + - $ref: "#/components/parameters/AccountHandleParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AccountUpdateOK" } + + /accounts/{account_handle}/roles/{role_id}/badge: + put: + operationId: AccountRoleSetBadge + description: | + Desgiantes the specified role as a badge for the profile. Only one role + may be set as a badge for the profile. Setting a role as a badge is + entirely aesthetic and does not grant any additional permissions. Roles + may be created without any permissions in order to be used as badges. + tags: [accounts] + parameters: + - $ref: "#/components/parameters/RoleIDParam" + - $ref: "#/components/parameters/AccountHandleParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AccountUpdateOK" } + delete: + operationId: AccountRoleRemoveBadge + description: | + Removes the badge from the profile. This does not remove the role from + the account, only the visual badge-status representation of the role. + tags: [accounts] + parameters: + - $ref: "#/components/parameters/RoleIDParam" + - $ref: "#/components/parameters/AccountHandleParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AccountUpdateOK" } + + # + # d8b d8b 888 888 d8b + # Y8P Y8P 888 888 Y8P + # 888 888 + # 888 88888b. 888 888 888 888888 8888b. 888888 888 .d88b. 88888b. .d8888b + # 888 888 "88b 888 888 888 888 "88b 888 888 d88""88b 888 "88b 88K + # 888 888 888 Y88 88P 888 888 .d888888 888 888 888 888 888 888 "Y8888b. + # 888 888 888 Y8bd8P 888 Y88b. 888 888 Y88b. 888 Y88..88P 888 888 X88 + # 888 888 888 Y88P 888 "Y888 "Y888888 "Y888 888 "Y88P" 888 888 88888P' + # + + /invitations: + get: + operationId: InvitationList + description: | + Retrieve all invitations for the authenticated account. This endpoint + is useful for showing the user which invitations they have sent out and + which ones have been accepted. + + If the requesting account is not an admin, the account_id query param + must be equal to the ID of the requesting session account's ID. + + If the requesting account is an admin, the account_id query param may + be used to retrieve invitations for a specific account. Otherwise, the + endpoint will return all invitations for all accounts. + tags: [invitations] + parameters: + - { $ref: "#/components/parameters/AccountIDQueryParam" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/InvitationListOK" } + post: + operationId: InvitationCreate + description: | + Create an invitation for the authenticated account. Responds with the + invitation data which can be used to construct a public vendor-specific + registration URL using the invitation's identifier which can be used in + calls to registration operations to indicate the account was invited. + tags: [invitations] + requestBody: { $ref: "#/components/requestBodies/InvitationCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/InvitationCreateOK" } + + /invitations/{invitation_id}: + get: + operationId: InvitationGet + description: | + Retrieve the details of an invitation by its identifier. This endpoint + is publicly accessible and can be used to show invitation details before + the client's registration flow. + tags: [invitations] + parameters: [{ $ref: "#/components/parameters/InvitationIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/InvitationGetOK" } + delete: + operationId: InvitationDelete + description: Delete an invitation. After deletion, it cannot be used. + tags: [invitations] + parameters: [{ $ref: "#/components/parameters/InvitationIDParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { description: "OK" } + + # + # 888 d8b .d888 d8b 888 d8b + # 888 Y8P d88P" Y8P 888 Y8P + # 888 888 888 + # 88888b. .d88b. 888888 888 888888 888 .d8888b 8888b. 888888 888 .d88b. 88888b. .d8888b + # 888 "88b d88""88b 888 888 888 888 d88P" "88b 888 888 d88""88b 888 "88b 88K + # 888 888 888 888 888 888 888 888 888 .d888888 888 888 888 888 888 888 "Y8888b. + # 888 888 Y88..88P Y88b. 888 888 888 Y88b. 888 888 Y88b. 888 Y88..88P 888 888 X88 + # 888 888 "Y88P" "Y888 888 888 888 "Y8888P "Y888888 "Y888 888 "Y88P" 888 888 88888P' + # + + /notifications: + get: + operationId: NotificationList + description: Retreive all notifications. + tags: [notifications] + parameters: + - $ref: "#/components/parameters/PaginationQuery" + - $ref: "#/components/parameters/NotificationStatusQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/NotificationListOK" } + patch: + operationId: NotificationUpdateMany + description: | + Update the status of multiple notifications in a single request. + + This endpoint accepts a list of notification IDs with their new status + values. Used for "Mark all as read". + tags: [notifications] + requestBody: { $ref: "#/components/requestBodies/NotificationUpdateMany" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/NotificationUpdateManyOK" } + + /notifications/{notification_id}: + patch: + operationId: NotificationUpdate + description: Change the read status for a notification. + tags: [notifications] + parameters: [$ref: "#/components/parameters/NotificationIDParam"] + requestBody: { $ref: "#/components/requestBodies/NotificationUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/NotificationUpdateOK" } + + # + # 888 + # 888 + # 888 + # 888d888 .d88b. 88888b. .d88b. 888d888 888888 .d8888b + # 888P" d8P Y8b 888 "88b d88""88b 888P" 888 88K + # 888 88888888 888 888 888 888 888 888 "Y8888b. + # 888 Y8b. 888 d88P Y88..88P 888 Y88b. X88 + # 888 "Y8888 88888P" "Y88P" 888 "Y888 88888P' + # 888 + # 888 + # 888 + # + + /reports: + post: + operationId: ReportCreate + description: | + Create a new report for content or user violations. + + Reports can be against any kind of user-generated content as well as + members themselves. The kind of report is specified in the request body + which dictates which resource the `id` field refers to. + tags: [reports] + requestBody: { $ref: "#/components/requestBodies/ReportCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/ReportCreateOK" } + get: + operationId: ReportList + description: | + List reports. Regular members see only their own reports. Members with + `MANAGE_REPORTS` permission will see all submitted reports. Reports can + be filtered by status. By default filters open and acknowledged reports. + Returns a list ordered by most recently updated. + tags: [reports] + parameters: + - $ref: "#/components/parameters/PaginationQuery" + - $ref: "#/components/parameters/ReportStatusQuery" + - $ref: "#/components/parameters/ReportKindQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/ReportListOK" } + + /reports/{report_id}: + patch: + operationId: ReportUpdate + description: | + Update a report's status and optionally assign handler. Requires the + `MANAGE_REPORTS` permission to set the status to anything other than + closed. In other words, regular members can only close their own reports + while "moderators" can acknowledge, assign and resolve reports. + tags: [reports] + parameters: [$ref: "#/components/parameters/ReportIDParam"] + requestBody: { $ref: "#/components/requestBodies/ReportUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "200": { $ref: "#/components/responses/ReportUpdateOK" } + + # + # .d888 d8b 888 + # d88P" Y8P 888 + # 888 888 + # 88888b. 888d888 .d88b. 888888 888 888 .d88b. .d8888b + # 888 "88b 888P" d88""88b 888 888 888 d8P Y8b 88K + # 888 888 888 888 888 888 888 888 88888888 "Y8888b. + # 888 d88P 888 Y88..88P 888 888 888 Y8b. X88 + # 88888P" 888 "Y88P" 888 888 888 "Y8888 88888P' + # 888 + # 888 + # 888 + # + + /profiles: + get: + operationId: ProfileList + description: Query and search profiles. + tags: [profiles] + parameters: + - $ref: "#/components/parameters/SearchQuery" + - $ref: "#/components/parameters/PaginationQuery" + - $ref: "#/components/parameters/ProfilesSortByQuery" + - $ref: "#/components/parameters/ProfilesRoleFilterQuery" + - $ref: "#/components/parameters/ProfilesJoinRangeQuery" + - $ref: "#/components/parameters/ProfilesInvitedByQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/ProfileListOK" } + + /profiles/{account_handle}: + get: + operationId: ProfileGet + description: Get a public profile by ID. + tags: [profiles] + parameters: [$ref: "#/components/parameters/AccountHandleParam"] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "304": { $ref: "#/components/responses/NotModified" } + "200": { $ref: "#/components/responses/ProfileGetOK" } + + /profiles/{account_handle}/followers: + get: + operationId: ProfileFollowersGet + description: Get the followers and following details for a profile. + tags: [profiles] + parameters: + - $ref: "#/components/parameters/PaginationQuery" + - $ref: "#/components/parameters/AccountHandleParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/ProfileFollowersGetOK" } + put: + operationId: ProfileFollowersAdd + description: Follow the specified profile as the authenticated account. + tags: [profiles] + parameters: [$ref: "#/components/parameters/AccountHandleParam"] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { description: OK } + delete: + operationId: ProfileFollowersRemove + description: Unfollow the specified profile as the authenticated account. + tags: [profiles] + parameters: [$ref: "#/components/parameters/AccountHandleParam"] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { description: OK } + + /profiles/{account_handle}/following: + get: + operationId: ProfileFollowingGet + description: Get the profiles that this account is following. + tags: [profiles] + parameters: + - $ref: "#/components/parameters/PaginationQuery" + - $ref: "#/components/parameters/AccountHandleParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/ProfileFollowingGetOK" } + "304": { $ref: "#/components/responses/NotModified" } + + # + # 888 d8b + # 888 Y8P + # 888 + # .d8888b 8888b. 888888 .d88b. .d88b. .d88b. 888d888 888 .d88b. .d8888b + # d88P" "88b 888 d8P Y8b d88P"88b d88""88b 888P" 888 d8P Y8b 88K + # 888 .d888888 888 88888888 888 888 888 888 888 888 88888888 "Y8888b. + # Y88b. 888 888 Y88b. Y8b. Y88b 888 Y88..88P 888 888 Y8b. X88 + # "Y8888P "Y888888 "Y888 "Y8888 "Y88888 "Y88P" 888 888 "Y8888 88888P' + # 888 + # Y8b d88P + # "Y88P" + # + + /categories: + post: + operationId: CategoryCreate + description: Create a category for organising posts. + tags: [categories] + requestBody: { $ref: "#/components/requestBodies/CategoryCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/CategoryCreateOK" } + get: + operationId: CategoryList + description: Get a list of all categories on the site. + tags: [categories] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "200": { $ref: "#/components/responses/CategoryListOK" } + + /categories/{category_slug}: + get: + operationId: CategoryGet + description: Get information about a category. + tags: [categories] + parameters: [{ $ref: "#/components/parameters/CategorySlugParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "304": { $ref: "#/components/responses/NotModified" } + "200": { $ref: "#/components/responses/CategoryGetOK" } + patch: + operationId: CategoryUpdate + description: Update a category's information. + tags: [categories] + parameters: [$ref: "#/components/parameters/CategorySlugParam"] + requestBody: { $ref: "#/components/requestBodies/CategoryUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/CategoryUpdateOK" } + delete: + operationId: CategoryDelete + description: Delete a category. All posts in this category will be moved to the specified target category. + tags: [categories] + parameters: [$ref: "#/components/parameters/CategorySlugParam"] + requestBody: { $ref: "#/components/requestBodies/CategoryDelete" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "404": { $ref: "#/components/responses/NotFound" } + "200": { $ref: "#/components/responses/CategoryDeleteOK" } + + /categories/{category_slug}/position: + patch: + operationId: CategoryUpdatePosition + description: | + Update the category's position in the tree. You may change the parent + using `parent`, and/or reposition the category among its siblings using + either `before` or `after`. Use this operation for drag-and-drop + interfaces. + tags: [categories] + parameters: [$ref: "#/components/parameters/CategorySlugParam"] + requestBody: { $ref: "#/components/requestBodies/CategoryUpdatePosition" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "404": { $ref: "#/components/responses/NotFound" } + "200": { $ref: "#/components/responses/CategoryListOK" } + + # + # 888 + # 888 + # 888 + # 888888 8888b. .d88b. .d8888b + # 888 "88b d88P"88b 88K + # 888 .d888888 888 888 "Y8888b. + # Y88b. 888 888 Y88b 888 X88 + # "Y888 "Y888888 "Y88888 88888P' + # 888 + # Y8b d88P + # "Y88P" + # + + /tags: + get: + operationId: TagList + description: Get a list of all tags on the site. + tags: [tags] + parameters: [$ref: "#/components/parameters/SearchQuery"] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "200": { $ref: "#/components/responses/TagListOK" } + + /tags/{tag_name}: + get: + operationId: TagGet + description: Get information about a tag. + tags: [tags] + parameters: [{ $ref: "#/components/parameters/TagNameParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "200": { $ref: "#/components/responses/TagGetOK" } + + # + # 888 888 888 + # 888 888 888 + # 888 888 888 + # 888888 88888b. 888d888 .d88b. 8888b. .d88888 .d8888b + # 888 888 "88b 888P" d8P Y8b "88b d88" 888 88K + # 888 888 888 888 88888888 .d888888 888 888 "Y8888b. + # Y88b. 888 888 888 Y8b. 888 888 Y88b 888 X88 + # "Y888 888 888 888 "Y8888 "Y888888 "Y88888 88888P' + # + + /threads: + post: + operationId: ThreadCreate + description: Create a new thread within the specified category. + tags: [threads] + requestBody: { $ref: "#/components/requestBodies/ThreadCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/ThreadCreateOK" } + + get: + operationId: ThreadList + description: Get a list of all threads. + tags: [threads] + parameters: + - $ref: "#/components/parameters/SearchQuery" + - $ref: "#/components/parameters/PaginationQuery" + - $ref: "#/components/parameters/ThreadsIgnorePinnedQuery" + - name: author + description: Show only results creeated by this user. + required: false + in: query + schema: { $ref: "#/components/schemas/AccountHandle" } + - $ref: "#/components/parameters/VisibilityParam" + - name: tags + description: Show only results with these tags + required: false + in: query + schema: { $ref: "#/components/schemas/TagListIDs" } + - $ref: "#/components/parameters/CategorySlugListQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/ThreadListOK" } + + /threads/{thread_mark}: + get: + operationId: ThreadGet + summary: Get information about a thread and the posts within the thread. + description: | + Get information about a thread such as its title, author, when it was + created as well as a list of the posts within the thread. + tags: [threads] + parameters: + - $ref: "#/components/parameters/ThreadMarkParam" + - $ref: "#/components/parameters/PaginationQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/ThreadGet" } + "304": { $ref: "#/components/responses/NotModified" } + patch: + operationId: ThreadUpdate + description: Publish changes to a thread. + tags: [threads] + parameters: [$ref: "#/components/parameters/ThreadMarkParam"] + requestBody: { $ref: "#/components/requestBodies/ThreadUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/ThreadUpdateOK" } + delete: + operationId: ThreadDelete + description: Archive a thread using soft-delete. + tags: [threads] + parameters: [$ref: "#/components/parameters/ThreadMarkParam"] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { description: OK } + + # + # 888 d8b + # 888 Y8P + # 888 + # 888d888 .d88b. 88888b. 888 888 .d88b. .d8888b + # 888P" d8P Y8b 888 "88b 888 888 d8P Y8b 88K + # 888 88888888 888 888 888 888 88888888 "Y8888b. + # 888 Y8b. 888 d88P 888 888 Y8b. X88 + # 888 "Y8888 88888P" 888 888 "Y8888 88888P' + # 888 + # 888 + # 888 + # + + /threads/{thread_mark}/replies: + post: + operationId: ReplyCreate + description: Create a new post within a thread. + tags: [replies] + parameters: [$ref: "#/components/parameters/ThreadMarkParam"] + requestBody: { $ref: "#/components/requestBodies/ReplyCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/ReplyCreateOK" } + + # + # 888 + # 888 + # 888 + # 88888b. .d88b. .d8888b 888888 .d8888b + # 888 "88b d88""88b 88K 888 88K + # 888 888 888 888 "Y8888b. 888 "Y8888b. + # 888 d88P Y88..88P X88 Y88b. X88 + # 88888P" "Y88P" 88888P' "Y888 88888P' + # 888 + # 888 + # 888 + # + + /posts/{post_id}: + patch: + operationId: PostUpdate + description: Publish changes to a single post. + tags: [posts] + parameters: [$ref: "#/components/parameters/PostIDParam"] + requestBody: { $ref: "#/components/requestBodies/PostUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/PostUpdateOK" } + delete: + operationId: PostDelete + description: Archive a post using soft-delete. + tags: [posts] + parameters: [$ref: "#/components/parameters/PostIDParam"] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { description: OK } + + /posts/{post_id}/reacts: + put: + operationId: PostReactAdd + description: Add a reaction to a post. + tags: [posts] + parameters: [$ref: "#/components/parameters/PostIDParam"] + requestBody: { $ref: "#/components/requestBodies/PostReactAdd" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/PostReactAddOK" } + + /posts/{post_id}/reacts/{react_id}: + delete: + operationId: PostReactRemove + description: Remove a reaction from a post. + tags: [posts] + parameters: + - $ref: "#/components/parameters/PostIDParam" + - $ref: "#/components/parameters/ReactIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { description: OK } + + /posts/location: + get: + operationId: PostLocationGet + description: | + Locate a post just from its ID. This will tell you what kind of post it + is and where to find it. Where "a post is" is simple for threads, just + the slug. For replies, it will give you the thread slug and the position + within the thread: the index, the page and the position on the page. + tags: [posts] + parameters: + - name: id + in: query + required: true + schema: { $ref: "#/components/schemas/Identifier" } + description: The post ID to locate + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "404": { $ref: "#/components/responses/NotFound" } + "200": { $ref: "#/components/responses/PostLocationGetOK" } + + # + # 888 + # 888 + # 888 + # 8888b. .d8888b .d8888b .d88b. 888888 .d8888b + # "88b 88K 88K d8P Y8b 888 88K + # .d888888 "Y8888b. "Y8888b. 88888888 888 "Y8888b. + # 888 888 X88 X88 Y8b. Y88b. X88 + # "Y888888 88888P' 88888P' "Y8888 "Y888 88888P' + # + + /assets: + post: + operationId: AssetUpload + description: Upload and process a media file. + tags: [assets] + requestBody: { $ref: "#/components/requestBodies/AssetUpload" } + parameters: + - $ref: "#/components/parameters/ContentLength" + - $ref: "#/components/parameters/AssetNameQuery" + - $ref: "#/components/parameters/ParentAssetIDQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AssetUploadOK" } + /assets/{asset_filename}: + get: + operationId: AssetGet + description: Download an asset by its ID. + tags: [assets] + parameters: [$ref: "#/components/parameters/AssetPathParam"] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/AssetGetOK" } + + # + # 888 d8b 888 + # 888 Y8P 888 + # 888 888 + # 888 888 888 888 .d88b. .d8888b + # 888 888 888 .88P d8P Y8b 88K + # 888 888 888888K 88888888 "Y8888b. + # 888 888 888 "88b Y8b. X88 + # 888 888 888 888 "Y8888 88888P' + # + + /likes/posts/{post_id}: + get: + operationId: LikePostGet + description: Retreives all likes for the given post. Not paginated (yet.) + tags: [likes] + parameters: + - $ref: "#/components/parameters/PostIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/LikePostGetOK" } + put: + operationId: LikePostAdd + description: | + Add a like/vote to a post. A "like" is pretty much what you'd expect for + any modern social platform, it will inform the feed algorithm and the + account's recommendations as well as listing the post on their profile. + Idempotent operation where repeated use will do nothing. + tags: [likes] + parameters: + - $ref: "#/components/parameters/PostIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { description: OK } + delete: + operationId: LikePostRemove + description: | + Removes a like/vote from the authenticated account for the post. It will + perform the inverse of any changes to the account's algorithm. Also is + idempotent, so repeated use will do nothing after being actioned once. + tags: [likes] + parameters: + - $ref: "#/components/parameters/PostIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { description: OK } + + /likes/profiles/{account_handle}: + get: + operationId: LikeProfileGet + description: Retreives all the likes that the given profile has given. + tags: [likes] + parameters: + - $ref: "#/components/parameters/AccountHandleParam" + - $ref: "#/components/parameters/PaginationQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/LikeProfileGetOK" } + + # + # 888 888 888 d8b + # 888 888 888 Y8P + # 888 888 888 + # .d8888b .d88b. 888 888 .d88b. .d8888b 888888 888 .d88b. 88888b. .d8888b + # d88P" d88""88b 888 888 d8P Y8b d88P" 888 888 d88""88b 888 "88b 88K + # 888 888 888 888 888 88888888 888 888 888 888 888 888 888 "Y8888b. + # Y88b. Y88..88P 888 888 Y8b. Y88b. Y88b. 888 Y88..88P 888 888 X88 + # "Y8888P "Y88P" 888 888 "Y8888 "Y8888P "Y888 888 "Y88P" 888 888 88888P' + # + + /collections: + post: + operationId: CollectionCreate + description: | + Create a collection for curating posts under the authenticated account. + tags: [collections] + requestBody: { $ref: "#/components/requestBodies/CollectionCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/CollectionCreateOK" } + get: + operationId: CollectionList + description: List all collections using the filtering options. + tags: [collections] + parameters: + - { $ref: "#/components/parameters/AccountHandleQueryParam" } + - { $ref: "#/components/parameters/CollectionHasItemQueryParam" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "200": { $ref: "#/components/responses/CollectionListOK" } + + /collections/{collection_mark}: + get: + operationId: CollectionGet + description: | + Get a collection by its ID. Collections can be public or private so the + response will depend on which account is making the request and if the + target collection is public, private, owned or not owned by the account. + tags: [collections] + parameters: [$ref: "#/components/parameters/CollectionMarkParam"] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/CollectionGetOK" } + patch: + operationId: CollectionUpdate + description: Update a collection owned by the authenticated account. + tags: [collections] + parameters: [$ref: "#/components/parameters/CollectionMarkParam"] + requestBody: { $ref: "#/components/requestBodies/CollectionUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/CollectionUpdateOK" } + delete: + operationId: CollectionDelete + description: Delete a collection owned by the authenticated account. + tags: [collections] + parameters: [$ref: "#/components/parameters/CollectionMarkParam"] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { description: OK } + + /collections/{collection_mark}/posts/{post_id}: + put: + operationId: CollectionAddPost + description: | + Add a post to a collection. The collection must be owned by the account + making the request. The post can be any published post of any kind. + tags: [collections] + parameters: + - $ref: "#/components/parameters/CollectionMarkParam" + - $ref: "#/components/parameters/PostIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/CollectionAddPostOK" } + delete: + operationId: CollectionRemovePost + description: | + Remove a post from a collection. The collection must be owned by the + account making the request. + tags: [collections] + parameters: + - $ref: "#/components/parameters/CollectionMarkParam" + - $ref: "#/components/parameters/PostIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/CollectionRemovePostOK" } + + /collections/{collection_mark}/nodes/{node_id}: + put: + operationId: CollectionAddNode + description: | + Add a node to a collection. The collection must be owned by the account + making the request. The node can be any published node or any node + not published but owned by the collection owner. + tags: [collections] + parameters: + - $ref: "#/components/parameters/CollectionMarkParam" + - $ref: "#/components/parameters/NodeIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/CollectionAddNodeOK" } + delete: + operationId: CollectionRemoveNode + description: | + Remove a node from a collection. The collection must be owned by the + account making the request. + tags: [collections] + parameters: + - $ref: "#/components/parameters/CollectionMarkParam" + - $ref: "#/components/parameters/NodeIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/CollectionRemoveNodeOK" } + + # + # 888 + # 888 + # 888 + # 88888b. .d88b. .d88888 .d88b. .d8888b + # 888 "88b d88""88b d88" 888 d8P Y8b 88K + # 888 888 888 888 888 888 88888888 "Y8888b. + # 888 888 Y88..88P Y88b 888 Y8b. X88 + # 888 888 "Y88P" "Y88888 "Y8888 88888P' + # + + /nodes: + post: + operationId: NodeCreate + description: | + Create a node for curating structured knowledge together. + tags: [nodes] + requestBody: { $ref: "#/components/requestBodies/NodeCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/NodeCreateOK" } + get: + operationId: NodeList + description: | + List nodes using the given filters. Can be used to get a full tree. + tags: [nodes] + parameters: + - $ref: "#/components/parameters/SearchQuery" + - $ref: "#/components/parameters/PaginationQuery" + - name: node_id + description: List this node and all child nodes. + required: false + in: query + schema: { $ref: "#/components/schemas/Identifier" } + - name: author + description: Show only results owned by this account. + required: false + in: query + schema: { $ref: "#/components/schemas/AccountHandle" } + - $ref: "#/components/parameters/VisibilityParam" + - $ref: "#/components/parameters/TreeDepthParam" + - $ref: "#/components/parameters/NodeListFormatParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "200": { $ref: "#/components/responses/NodeListOK" } + + /nodes/drafts: + get: + operationId: NodeDraftList + description: | + List all draft versions across all nodes visible to the caller. + + This endpoint is designed for moderation and queue screens where you need + to see all pending draft proposals in one request. Each draft includes a + reference to its target node for context. + + Drafts are visible based on the caller's permissions: + - Draft authors can see their own drafts + - Members with `MANAGE_LIBRARY` can see all drafts + - Unauthenticated requests receive 401 Unauthorized + + Results are ordered by `updated_at` descending so recently updated drafts + appear first. + tags: [nodes] + parameters: + - $ref: "#/components/parameters/PaginationQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/NodeDraftListOK" } + + /nodes/{node_slug}: + get: + operationId: NodeGet + description: Get a node by its URL slug. + tags: [nodes] + parameters: + - $ref: "#/components/parameters/NodeSlugParam" + - $ref: "#/components/parameters/NodeChildrenSortParam" + - $ref: "#/components/parameters/PaginationQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "304": { $ref: "#/components/responses/NotModified" } + "200": { $ref: "#/components/responses/NodeGetOK" } + patch: + operationId: NodeUpdate + description: | + Update a node directly. + + Direct updates are intended for fast edits by members who can manage + the target node. If a node has a working draft version, direct updates + to versioned page fields are rejected until the draft is applied or + deleted. When a direct update changes versioned page fields and no + draft exists, the node's `current_version_id` pointer is cleared + because the live node no longer exactly represents an applied + checkpoint. + tags: [nodes] + parameters: + - $ref: "#/components/parameters/NodeSlugParam" + requestBody: { $ref: "#/components/requestBodies/NodeUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/NodeUpdateOK" } + delete: + operationId: NodeDelete + description: Delete a node and move all children to its parent or root. + tags: [nodes] + parameters: + - $ref: "#/components/parameters/NodeSlugParam" + - $ref: "#/components/parameters/TargetNodeSlugQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/NodeDeleteOK" } + + /nodes/{node_slug}/title: + post: + operationId: NodeGenerateTitle + description: | + Generate a proposed title for the specified node. Will not actually + mutate the specified node, instead will return a proposal based on the + output from a language model call. + tags: [nodes] + parameters: [$ref: "#/components/parameters/NodeSlugParam"] + requestBody: { $ref: "#/components/requestBodies/NodeGenerateTitle" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "501": { $ref: "#/components/responses/NotImplemented" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "400": { $ref: "#/components/responses/BadRequest" } + "200": { $ref: "#/components/responses/NodeGenerateTitleOK" } + + /nodes/{node_slug}/tags: + post: + operationId: NodeGenerateTags + description: | + Generate proposed tags for the specified node. Will not actually mutate + the specified node, instead will return a proposal based on the output + from a language model call. + tags: [nodes] + parameters: [$ref: "#/components/parameters/NodeSlugParam"] + requestBody: { $ref: "#/components/requestBodies/NodeGenerateTags" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "501": { $ref: "#/components/responses/NotImplemented" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "400": { $ref: "#/components/responses/BadRequest" } + "200": { $ref: "#/components/responses/NodeGenerateTagsOK" } + + /nodes/{node_slug}/content: + post: + operationId: NodeGenerateContent + description: | + Generate proposed content for the specified node. Will not actually + mutate the specified node, instead will return a proposal based on the + output from a language model call. + tags: [nodes] + parameters: [$ref: "#/components/parameters/NodeSlugParam"] + requestBody: { $ref: "#/components/requestBodies/NodeGenerateContent" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "501": { $ref: "#/components/responses/NotImplemented" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "400": { $ref: "#/components/responses/BadRequest" } + "200": { $ref: "#/components/responses/NodeGenerateContentOK" } + + /nodes/{node_slug}/children: + get: + operationId: NodeListChildren + description: | + Get all the children of a given node using the provided filters and page + parameters. This can be used for rendering the child nodes of the given + node as an interactive table where properties can be used as columns. + tags: [nodes] + parameters: + - $ref: "#/components/parameters/NodeSlugParam" + - $ref: "#/components/parameters/NodeChildrenSortParam" + - $ref: "#/components/parameters/PaginationQuery" + - $ref: "#/components/parameters/SearchQuery" + - $ref: "#/components/parameters/TagNameListQueryParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/NodeListOK" } + + /nodes/{node_slug}/children/property-schema: + patch: + operationId: NodeUpdateChildrenPropertySchema + description: | + Updates the property schema of the children of this node. All children + of a node use the same schema for properties resulting in a table-like + structure and behaviour. See also: NodeUpdatePropertySchema + tags: [nodes] + parameters: [$ref: "#/components/parameters/NodeSlugParam"] + requestBody: + { $ref: "#/components/requestBodies/NodeUpdatePropertySchema" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": + $ref: "#/components/responses/NodeUpdatePropertySchemaOK" + + /nodes/{node_slug}/property-schema: + patch: + operationId: NodeUpdatePropertySchema + description: | + Updates the property schema of this node and its siblings. All children + of a node use the same schema for properties resulting in a table-like + structure and behaviour. Property schemas are loosely structured and can + automatically cast their values sometimes. A failed cast will not change + data and instead just yield an empty value when reading however changing + the schema back to the original type (or a type compatible with what the + type was before changing) will retain the original data upon next read. + This permits clients to undo changes to the schema easily while allowing + quick schema changes without the need to remove or update values before. + tags: [nodes] + parameters: [$ref: "#/components/parameters/NodeSlugParam"] + requestBody: + { $ref: "#/components/requestBodies/NodeUpdatePropertySchema" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": + $ref: "#/components/responses/NodeUpdatePropertySchemaOK" + + /nodes/{node_slug}/properties: + patch: + operationId: NodeUpdateProperties + description: | + Update the properties of a node. New schema fields will result in the + schema of the node being updated before values are assigned. This will + also propagate to all sibling nodes as they all share the same schema. + tags: [nodes] + parameters: [$ref: "#/components/parameters/NodeSlugParam"] + requestBody: { $ref: "#/components/requestBodies/NodeUpdateProperties" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "400": { $ref: "#/components/responses/BadRequest" } + "200": { $ref: "#/components/responses/NodeUpdatePropertiesOK" } + + /nodes/{node_slug}/visibility: + patch: + operationId: NodeUpdateVisibility + description: | + Update the visibility of a node. When changed, this may trigger other + operations such as notifications/newsletters. Changing the visibility of + anything to "published" is often accompanied by some other side effects. + tags: [nodes] + parameters: [$ref: "#/components/parameters/NodeSlugParam"] + requestBody: { $ref: "#/components/requestBodies/VisibilityUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/NodeUpdateOK" } + + /nodes/{node_slug}/versions: + get: + operationId: NodeVersionList + description: | + List edit versions for a node. + + Versions have two states: draft and applied. A version is a draft when + it is pre-published. There can only be a single draft of a node. + + Applied versions are immutable historical snapshots of the page fields + that were copied into the node. The single draft version, when present, + is the working snapshot ahead of the live node and is visible only to + its author and members with `MANAGE_LIBRARY`. + + Results are ordered by `updated_at` descending so draft autosaves and + recently applied checkpoints appear before older history. + tags: [nodes] + parameters: + - $ref: "#/components/parameters/NodeSlugParam" + - $ref: "#/components/parameters/PaginationQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/NodeVersionListOK" } + post: + operationId: NodeVersionCreate + description: | + Create the single mutable draft checkpoint for a node. + + This operation requires either `SUBMIT_LIBRARY_NODE_CHANGES` or + `MANAGE_LIBRARY` permission. The draft starts as a full snapshot of the + node's current versioned page fields. Fields supplied in the request + overlay that snapshot, omitted fields keep the snapshotted value, and + explicit null values clear nullable fields. + + A node can have only one draft. If a draft already exists for the node, + this operation returns a conflict. Drafts do not mutate the target node + until the draft is applied through the version status endpoint by a + member with `MANAGE_LIBRARY`. + tags: [nodes] + parameters: [$ref: "#/components/parameters/NodeSlugParam"] + requestBody: { $ref: "#/components/requestBodies/NodeVersionCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "409": { $ref: "#/components/responses/Conflict" } + "400": { $ref: "#/components/responses/BadRequest" } + "200": { $ref: "#/components/responses/NodeVersionCreateOK" } + + /nodes/{node_slug}/versions/draft: + get: + operationId: NodeVersionDraftGet + description: | + Get the node's single working draft checkpoint. + + This is a stable alias for the draft version of a node. It allows + clients to read "the draft" without listing versions and inspecting + status values. If the node has no draft, or the draft is not visible to + the caller, this operation returns not found. + + The draft is visible only to its author and members with + `MANAGE_LIBRARY`. + tags: [nodes] + parameters: [$ref: "#/components/parameters/NodeSlugParam"] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/NodeVersionGetOK" } + patch: + operationId: NodeVersionDraftUpdate + description: | + Update the node's single working draft checkpoint. + + This is a stable alias for patching the draft version of a node without + first listing versions or knowing the draft version identifier. The + node must already have a draft visible to the caller. This operation + does not create a draft and does not apply the draft to the target node. + + The caller must be the draft author or have `MANAGE_LIBRARY`. Fields + omitted from the request are left unchanged on the draft snapshot. + Explicit null values clear nullable fields. Properties are a complete + desired-state list for the target node properties. + tags: [nodes] + parameters: [$ref: "#/components/parameters/NodeSlugParam"] + requestBody: { $ref: "#/components/requestBodies/NodeVersionUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "409": { $ref: "#/components/responses/Conflict" } + "400": { $ref: "#/components/responses/BadRequest" } + "200": { $ref: "#/components/responses/NodeVersionUpdateOK" } + + /nodes/{node_slug}/versions/{version_id}: + get: + operationId: NodeVersionGet + description: | + Get an edit version for a node. + + The version must belong to the node identified by `node_slug`. Applied + versions are immutable historical snapshots and are visible to callers + who can read the target node. The draft version is visible only to its + author and members with `MANAGE_LIBRARY`. + tags: [nodes] + parameters: + - $ref: "#/components/parameters/NodeSlugParam" + - $ref: "#/components/parameters/NodeVersionIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/NodeVersionGetOK" } + patch: + operationId: NodeVersionUpdate + description: | + Update the node's single draft checkpoint. + + This operation is for draft autosave and editing only. It does not + change version status and cannot apply a version to the target node. + The version must still have draft status and the caller must be the + draft author or have `MANAGE_LIBRARY`. + + Fields omitted from the request are left unchanged on the draft + snapshot. Explicit null values clear nullable fields. Properties are a + complete desired-state list for the target node properties: when the + version is applied, the list replaces the node's existing property set + rather than merging with it. + tags: [nodes] + parameters: + - $ref: "#/components/parameters/NodeSlugParam" + - $ref: "#/components/parameters/NodeVersionIDParam" + requestBody: { $ref: "#/components/requestBodies/NodeVersionUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "409": { $ref: "#/components/responses/Conflict" } + "400": { $ref: "#/components/responses/BadRequest" } + "200": { $ref: "#/components/responses/NodeVersionUpdateOK" } + delete: + operationId: NodeVersionDelete + description: | + Delete the working draft checkpoint. + + A draft author can discard their own draft. Members with `MANAGE_LIBRARY` + permission can discard any draft for the node. The draft row is removed + from history. Applied versions are immutable history entries and cannot + be deleted through this endpoint. + tags: [nodes] + parameters: + - $ref: "#/components/parameters/NodeSlugParam" + - $ref: "#/components/parameters/NodeVersionIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/NodeVersionDeleteOK" } + + /nodes/{node_slug}/versions/{version_id}/status: + patch: + operationId: NodeVersionUpdateStatus + description: | + Update the lifecycle status of a checkpoint. + + This endpoint is separate from the content patch endpoint because status + changes have side effects. For the initial checkpoint workflow, the + only supported transition is draft to applied. Applying a version is + restricted to members with `MANAGE_LIBRARY`. + + Applying a draft copies the full draft snapshot into the target node, + applies properties as a complete desired-state list, marks the version + immutable, and updates the node's `current_version_id` pointer to the + applied version. This is a linear operation; applying a draft does not + merge against other draft or historical versions. + + Clients must use this endpoint for lifecycle transitions and must not + combine status changes with regular draft content updates. + tags: [nodes] + parameters: + - $ref: "#/components/parameters/NodeSlugParam" + - $ref: "#/components/parameters/NodeVersionIDParam" + requestBody: + { $ref: "#/components/requestBodies/NodeVersionUpdateStatus" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "400": { $ref: "#/components/responses/BadRequest" } + "200": { $ref: "#/components/responses/NodeVersionUpdateOK" } + + /nodes/{node_slug}/assets/{asset_id}: + put: + operationId: NodeAddAsset + description: Add an asset to a node. + tags: [nodes] + parameters: + - $ref: "#/components/parameters/NodeSlugParam" + - $ref: "#/components/parameters/AssetIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/NodeUpdateOK" } + delete: + operationId: NodeRemoveAsset + description: Remove an asset from a node. + tags: [nodes] + parameters: + - $ref: "#/components/parameters/NodeSlugParam" + - $ref: "#/components/parameters/AssetIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/NodeUpdateOK" } + + /nodes/{node_slug}/nodes/{node_slug_child}: + put: + operationId: NodeAddNode + description: Set a node's parent to the specified node + tags: [nodes] + parameters: + - $ref: "#/components/parameters/NodeSlugParam" + - $ref: "#/components/parameters/NodeSlugChildParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/NodeAddChildOK" } + delete: + operationId: NodeRemoveNode + description: | + Remove a node from its parent node and back to the top level. + tags: [nodes] + parameters: + - $ref: "#/components/parameters/NodeSlugParam" + - $ref: "#/components/parameters/NodeSlugChildParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/NodeRemoveChildOK" } + + /nodes/{node_slug}/position: + patch: + operationId: NodeUpdatePosition + description: | + Update the node's position in the tree, which optionally allows for + changing the node's parent either to another node or to `null` which + severs the parent and moves the node to the root. This endpoint also + allows for moving the node's sort position within either its current + parent, or when moving it to a new parent. Use this operation for a + draggable tree interface or a table interface. + tags: [nodes] + parameters: [$ref: "#/components/parameters/NodeSlugParam"] + requestBody: { $ref: "#/components/requestBodies/NodeUpdatePosition" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "400": { $ref: "#/components/responses/BadRequest" } + "200": { $ref: "#/components/responses/NodeUpdateOK" } + + # + # 888 d8b 888 + # 888 Y8P 888 + # 888 888 + # 888 888 88888b. 888 888 .d8888b + # 888 888 888 "88b 888 .88P 88K + # 888 888 888 888 888888K "Y8888b. + # 888 888 888 888 888 "88b X88 + # 888 888 888 888 888 888 88888P' + # + + /links: + post: + operationId: LinkCreate + description: | + Add a link to the community bookmarks. This will also scrape the content + at the site the link points to, if possible. If the submitted link is an + invalid link for whatever reason (invalid URL structure or page is dead) + then the API will fail. The metadata for the link is indexed on success. + + If the submitted link already exists it will be an idempotent operation, + unless the body contains additional metadata. In these cases, the link's + metadata will be updated with the new metadata and the URL is unchanged. + + When a link is submitted, it is first "cleaned" to remove any fragments. + tags: [links] + requestBody: { $ref: "#/components/requestBodies/LinkCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/LinkCreateOK" } + get: + operationId: LinkList + description: List all links using the filtering options. + tags: [links] + parameters: + - $ref: "#/components/parameters/SearchQuery" + - $ref: "#/components/parameters/PaginationQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "200": { $ref: "#/components/responses/LinkListOK" } + + /links/{link_slug}: + get: + operationId: LinkGet + description: | + Get the details for a specific link. Such as where it's been posted, + which resources it's linked to and how many times it's been opened. + tags: [links] + parameters: [{ $ref: "#/components/parameters/LinkSlugParam" }] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "200": { $ref: "#/components/responses/LinkGetOK" } + + # + # 888 888 888 + # 888 888 888 + # 888 888 888 + # .d88888 8888b. 888888 8888b. .d88b. 888d888 8888b. 88888b. 88888b. + # d88" 888 "88b 888 "88b d88P"88b 888P" "88b 888 "88b 888 "88b + # 888 888 .d888888 888 .d888888 888 888 888 .d888888 888 888 888 888 + # Y88b 888 888 888 Y88b. 888 888 Y88b 888 888 888 888 888 d88P 888 888 + # "Y88888 "Y888888 "Y888 "Y888888 "Y88888 888 "Y888888 88888P" 888 888 + # 888 888 + # Y8b d88P 888 + # "Y88P" 888 + # + + /datagraph: + get: + operationId: DatagraphSearch + description: Query and search content. + tags: [datagraph] + parameters: + - $ref: "#/components/parameters/SearchQuery" + - $ref: "#/components/parameters/DatagraphKindQuery" + - $ref: "#/components/parameters/DatagraphAuthorQuery" + - $ref: "#/components/parameters/DatagraphCategoryQuery" + - $ref: "#/components/parameters/TagNameListQueryParam" + - $ref: "#/components/parameters/PaginationQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/DatagraphSearchOK" } + + /datagraph/matches: + get: + operationId: DatagraphMatches + description: | + Query the datagraph optimised for typeahead scenarios. This endpoint is + only active when a `SEARCH_PROVIDER` that supports fast access is used. + This includes providers such as Bleve and Redis. + + This endpoint will return a minified set of results directly from the + configured search index, without hitting the database. This makes it + suitable for performance sensitive use-cases such as type-ahead search, + @ mentioning threads/pages, CTRL+K style menus, and more. + + Results will include a `kind` field and short content, but will not + contain graph edges (such as authorship, links, etc.) due to constraints + of the underlying search index and to keep payload sizes smaller. + tags: [datagraph] + parameters: + - $ref: "#/components/parameters/RequiredSearchQuery" + - $ref: "#/components/parameters/DatagraphKindQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/DatagraphMatchesOK" } + + # + # 888 + # 888 + # 888 + # .d88b. 888 888 .d88b. 88888b. 888888 .d8888b + # d8P Y8b 888 888 d8P Y8b 888 "88b 888 88K + # 88888888 Y88 88P 88888888 888 888 888 "Y8888b. + # Y8b. Y8bd8P Y8b. 888 888 Y88b. X88 + # "Y8888 Y88P "Y8888 888 888 "Y888 88888P' + # + + /events: + get: + operationId: EventList + description: List all events using the filtering options. + tags: [events] + parameters: + - $ref: "#/components/parameters/SearchQuery" + - $ref: "#/components/parameters/PaginationQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "200": { $ref: "#/components/responses/EventListOK" } + post: + operationId: EventCreate + description: | + Create a new event. When an event is created, a thread is also created + which provides the means for discussion via the thread and reply APIs. + tags: [events] + requestBody: { $ref: "#/components/requestBodies/EventCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/EventCreateOK" } + + /events/{event_mark}: + get: + operationId: EventGet + description: Get an event by its ID. + tags: [events] + parameters: [$ref: "#/components/parameters/EventMarkParam"] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/EventGetOK" } + patch: + operationId: EventUpdate + description: | + Update an event. If the content field is updated, this is stored on the + thread associated with the event, rather than the event itself. It's + possible to update that thread directly using `threads` operations. + tags: [events] + parameters: [$ref: "#/components/parameters/EventMarkParam"] + requestBody: { $ref: "#/components/requestBodies/EventUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/EventUpdateOK" } + delete: + operationId: EventDelete + description: Delete an event. + tags: [events] + parameters: [$ref: "#/components/parameters/EventMarkParam"] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { description: OK } + + /events/{event_mark}/participants/{account_id}: + put: + operationId: EventParticipantUpdate + description: | + Add a participant to an event or change an existing participant's state. + + If the requesting account is an admin or holds MANAGE_EVENTS permission, + they can change the participation properties of any account. Otherwise, + they can only change their own participation properties. + + For non-managing members (i.e. not an admin and not a host) this will + follow a stricter state machine for the participation status. If the + participation status is not set (no participation record is present) or + set to "declined", the member may only set their status to "requested" + if the event policy is set to "invite_only". Otherwise, they may set it + to "attending". If the member is already set to one of these states, + they may change it to "declined". + + A non-managing member cannot change their role and the default is + "attendee", only managing members can change participant roles. + + If the event participation policy is set to "invite_only" then members + can only set their status to "requested" or delete their participation. + + If the event participation policy is set to "closed", it's a no-op. + + Requests to this resource are idempotent given identical request bodies. + It acts as a create-or-update action as participation is account-unique. + tags: [events] + parameters: + - $ref: "#/components/parameters/EventMarkParam" + - $ref: "#/components/parameters/AccountIDParam" + requestBody: { $ref: "#/components/requestBodies/EventParticipantUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { description: OK } + delete: + operationId: EventParticipantRemove + description: | + Remove a participant from an event. Same rules as EventParticipantUpdate + where non-managing members may only remove themselves. Not soft-delete. + tags: [events] + parameters: + - $ref: "#/components/parameters/EventMarkParam" + - $ref: "#/components/parameters/AccountIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { description: OK } + + # + # 888 888 + # 888 888 + # 888 888 + # 888d888 .d88b. 88888b. .d88b. 888888 .d8888b + # 888P" d88""88b 888 "88b d88""88b 888 88K + # 888 888 888 888 888 888 888 888 "Y8888b. + # 888 Y88..88P 888 d88P Y88..88P Y88b. X88 + # 888 "Y88P" 88888P" "Y88P" "Y888 88888P' + # + + /robots: + get: + operationId: RobotsList + summary: List robots + description: Get a paginated list of all available robots. + tags: [robots] + parameters: + - $ref: "#/components/parameters/PaginationQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotsListOK" } + post: + operationId: RobotCreate + summary: Create a robot + description: | + Create a new Robot with the specified configuration. A Robot in Storyden + consists of a name and description (for humans) as well as a playbook, + and a set of available tools to interact with Storyden or plugins. The + playbook is a detailed set of instructions that guides behaviour of the + Robot to help it assist members in achieving a specific automation goal. + Tools are available from either Storyden or plugins that allow it to + perform actions or query data. Robots never need all tools and it's best + to build goal-specific Robots with minimal sets of tools. + tags: [robots] + requestBody: { $ref: "#/components/requestBodies/RobotCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotCreateOK" } + + /robots/tools: + get: + operationId: RobotToolsList + summary: List robot tools + description: | + List all tools that may be assigned to Robots, including native Storyden + tools and discovered MCP tools. + tags: [robots] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotToolsListOK" } + + /robots/chat/sse: + post: + operationId: RobotChatSSE + description: | + Send a message to a Robot and receive its response. This endpoint + manages sessions automatically, creating new sessions as needed or + continuing existing sessions based on the provided session ID. + + Each message sent to the Robot is processed according to its playbook + and available tools, allowing it to perform actions or retrieve data + as part of the conversation. The response from the Robot includes its + reply message along with any actions taken during the interaction. + + This endpoint is a Server Sent Events (SSE) stream, meaning that the + response is streamed back to the client in real-time as the Robot + generates its reply. + tags: [robots] + requestBody: { $ref: "#/components/requestBodies/RobotChatStart" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotChatStream" } + + /robots/providers: + get: + operationId: RobotProvidersList + summary: List robot providers + description: | + Retrieve supported Robot model providers, redacted settings, cache + status and cached models. + tags: [robots] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotProvidersListOK" } + + /robots/providers/{provider}: + patch: + operationId: RobotProviderUpdate + summary: Update a robot provider + description: | + Update a Robot model provider configuration. API keys are write-only and + are redacted from responses. + tags: [robots] + parameters: + - $ref: "#/components/parameters/RobotModelProviderParam" + requestBody: { $ref: "#/components/requestBodies/RobotProviderUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotProviderGetOK" } + + /robots/providers/{provider}/models/refresh: + post: + operationId: RobotProviderModelsRefresh + summary: Refresh robot provider models + description: | + Force refresh the cached model list for a Robot model provider. + tags: [robots] + parameters: + - $ref: "#/components/parameters/RobotModelProviderParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotProviderGetOK" } + + /robots/models: + get: + operationId: RobotModelsList + summary: List robot models + description: | + Retrieve a list of all enabled models from all providers. Model names + are in the format `provider/model` some provider models may include + further slashes, such as with OpenRouter: `openrouter/openai/gpt-4`. + tags: [robots] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotModelsListOK" } + + /robots/workspace-providers: + get: + operationId: RobotWorkspaceProvidersList + summary: List robot workspace providers + description: Retrieve the registered Robot workspace providers. + tags: [robots] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotWorkspaceProvidersListOK" } + + /robots/workspaces: + get: + operationId: RobotWorkspacesList + summary: List robot workspaces + description: Get a paginated list of reusable Robot workspace templates. + tags: [robots] + parameters: + - $ref: "#/components/parameters/PaginationQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotWorkspacesListOK" } + post: + operationId: RobotWorkspaceCreate + summary: Create a robot workspace + description: Create a reusable Robot workspace template. + tags: [robots] + requestBody: { $ref: "#/components/requestBodies/RobotWorkspaceCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotWorkspaceCreateOK" } + + /robots/workspaces/{workspace_id}: + get: + operationId: RobotWorkspaceGet + summary: Get a robot workspace + description: Retrieve a reusable Robot workspace template. + tags: [robots] + parameters: + - $ref: "#/components/parameters/RobotWorkspaceIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/RobotWorkspaceGetOK" } + patch: + operationId: RobotWorkspaceUpdate + summary: Update a robot workspace + description: Update a reusable Robot workspace template. + tags: [robots] + parameters: + - $ref: "#/components/parameters/RobotWorkspaceIDParam" + requestBody: { $ref: "#/components/requestBodies/RobotWorkspaceUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotWorkspaceGetOK" } + delete: + operationId: RobotWorkspaceDelete + summary: Delete a robot workspace + description: Delete a reusable Robot workspace template. + tags: [robots] + parameters: + - $ref: "#/components/parameters/RobotWorkspaceIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { description: OK } + + /robots/workspace-instances: + get: + operationId: RobotWorkspaceInstancesList + summary: List robot workspace instances + description: Get a paginated list of live reusable Robot workspace instances. + tags: [robots] + parameters: + - $ref: "#/components/parameters/PaginationQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotWorkspaceInstancesListOK" } + + /robots/workspace-instances/{workspace_instance_id}: + get: + operationId: RobotWorkspaceInstanceGet + summary: Get a robot workspace instance + description: Retrieve a live reusable Robot workspace instance. + tags: [robots] + parameters: + - $ref: "#/components/parameters/RobotWorkspaceInstanceIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/RobotWorkspaceInstanceGetOK" } + delete: + operationId: RobotWorkspaceInstanceDelete + summary: Delete a robot workspace instance + description: Delete a live reusable Robot workspace instance. + tags: [robots] + parameters: + - $ref: "#/components/parameters/RobotWorkspaceInstanceIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { description: OK } + + /robots/{robot_id}: + get: + operationId: RobotGet + summary: Get a robot + description: | + Retrieve a specific Robot by its ID. Does not include any messages or + sessions associated with the Robot, just provides metadata about it. + tags: [robots] + parameters: + - $ref: "#/components/parameters/RobotIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/RobotGetOK" } + patch: + operationId: RobotUpdate + summary: Update a robot + description: | + Update a Robot's name, description, playbook or available tools. + tags: [robots] + parameters: + - $ref: "#/components/parameters/RobotIDParam" + requestBody: { $ref: "#/components/requestBodies/RobotUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotGetOK" } + delete: + operationId: RobotDelete + summary: Delete a robot + description: Delete a Robot. + tags: [robots] + parameters: + - $ref: "#/components/parameters/RobotIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { description: OK } + + /robots/sessions: + get: + operationId: RobotSessionsList + summary: List robot sessions + description: | + Get a paginated list of Robot sessions. These are chat sessions with the + Robot system. One session may span multiple Robots as members can switch + which Robot they are talking to mid conversation, or the Robot itself + may choose to switch to another Robot to achieve a goal. A session is a + representation of an entire conversation thread with the Robot system. + + You may include an account ID to filter sessions by account. Only those + with "USE_ROBOTS" permission can use Robots, however sessions, messages + and usage is not considered hidden to other accounts with the usage + permission. Robots are intended as administrative or moderation tools + to be shared among the team rather than private assistants. + tags: [robots] + parameters: + - $ref: "#/components/parameters/PaginationQuery" + - $ref: "#/components/parameters/AccountIDQueryParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/RobotSessionsListOK" } + + /robots/sessions/{session_id}: + get: + operationId: RobotSessionGet + summary: Get a robot session + description: | + Retrieve a specific robot session with all of its messages. Sessions can + involve interactions with multiple Robots so the Robot is specified on + each message. Messages may not be representative of exactly what is sent + into a language model, as certain optimisations may be performed before + this such as compaction, summarisation or removal of irrelevant context. + + Any member with "USE_ROBOTS" can see any other members' sessions and + messages with a Robot. Robots are not considered private assistants, but + rather shared tools for the team to use for managing their community. + tags: [robots] + parameters: + - $ref: "#/components/parameters/RobotSessionIDParam" + - $ref: "#/components/parameters/RobotSessionMessageBeforeQuery" + - $ref: "#/components/parameters/RobotSessionMessageLimitQuery" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "200": { $ref: "#/components/responses/RobotSessionGetOK" } + + /robots/mcp-servers: + get: + operationId: RobotMCPServersList + summary: List Robot MCP servers + description: | + List external MCP servers configured for Robot tool discovery. + tags: [robots] + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotMCPServersListOK" } + post: + operationId: RobotMCPServerCreate + summary: Create Robot MCP server + description: | + Configure an external streamable HTTP MCP server for Robot tool + discovery. + tags: [robots] + requestBody: { $ref: "#/components/requestBodies/RobotMCPServerCreate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotMCPServerCreateOK" } + + /robots/mcp-servers/probe: + post: + operationId: RobotMCPServerProbe + summary: Probe Robot MCP server + description: | + Resolve an MCP endpoint from a URL, optionally using an MCP Server + Card, and attempt a streamable HTTP MCP handshake. + tags: [robots] + requestBody: { $ref: "#/components/requestBodies/RobotMCPServerProbe" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotMCPServerProbeOK" } + + /robots/mcp-servers/{mcp_server_id}: + get: + operationId: RobotMCPServerGet + summary: Get Robot MCP server + description: | + Retrieve an external MCP server configuration and its cached tools. + tags: [robots] + parameters: + - $ref: "#/components/parameters/RobotMCPServerIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotMCPServerGetOK" } + patch: + operationId: RobotMCPServerUpdate + summary: Update Robot MCP server + description: | + Update an external MCP server configuration. Bearer tokens are + write-only and are redacted from responses. + tags: [robots] + parameters: + - $ref: "#/components/parameters/RobotMCPServerIDParam" + requestBody: { $ref: "#/components/requestBodies/RobotMCPServerUpdate" } + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotMCPServerUpdateOK" } + delete: + operationId: RobotMCPServerDelete + summary: Delete Robot MCP server + description: | + Delete an external MCP server configuration and remove its tools from + the runtime Robot tool registry. + tags: [robots] + parameters: + - $ref: "#/components/parameters/RobotMCPServerIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotMCPServerDeleteOK" } + + /robots/mcp-servers/{mcp_server_id}/refresh: + post: + operationId: RobotMCPServerRefresh + summary: Refresh Robot MCP server tools + description: | + Connect to the external MCP server, refresh its tool cache, and update + the runtime Robot tool registry. + tags: [robots] + parameters: + - $ref: "#/components/parameters/RobotMCPServerIDParam" + responses: + default: { $ref: "#/components/responses/InternalServerError" } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "401": { $ref: "#/components/responses/Unauthorised" } + "403": { $ref: "#/components/responses/Forbidden" } + "200": { $ref: "#/components/responses/RobotMCPServerRefreshOK" } + +components: + # + # 8888888b. d8888 8888888b. d8888 888b d888 8888888888 88888888888 8888888888 8888888b. .d8888b. + # 888 Y88b d88888 888 Y88b d88888 8888b d8888 888 888 888 888 Y88b d88P Y88b + # 888 888 d88P888 888 888 d88P888 88888b.d88888 888 888 888 888 888 Y88b. + # 888 d88P d88P 888 888 d88P d88P 888 888Y88888P888 8888888 888 8888888 888 d88P "Y888b. + # 8888888P" d88P 888 8888888P" d88P 888 888 Y888P 888 888 888 888 8888888P" "Y88b. + # 888 d88P 888 888 T88b d88P 888 888 Y8P 888 888 888 888 888 T88b "888 + # 888 d8888888888 888 T88b d8888888888 888 " 888 888 888 888 888 T88b Y88b d88P + # 888 d88P 888 888 T88b d88P 888 888 888 8888888888 888 8888888888 888 T88b "Y8888P" + # + + parameters: + IconSize: + description: Icon sizes. + example: "512x512" + name: icon_size + in: path + required: true + schema: + type: string + enum: + - 512x512 # Generic big icon + - 32x32 # Generic small icon + - 180x180 # iOS high DPI + - 120x120 # iOS low DPI + - 167x167 # iPad OS + - 152x152 # iPad Mini + + ContentLength: + description: Body content length in bytes. + name: Content-Length + in: header + required: true + schema: + type: integer + x-go-type: int64 + + AuditEventTypeFilterQuery: + description: Audit event type filter query + name: types + in: query + explode: true + schema: + type: array + items: { $ref: "#/components/schemas/AuditEventType" } + + AuditEventTimeRangeQuery: + description: Audit event time range query + name: range + in: query + schema: + type: string + format: iso8601-interval + + AuditEventIDParam: + description: Audit event ID + in: path + name: audit_event_id + required: true + schema: + $ref: "#/components/schemas/Identifier" + + EmailIDParam: + description: Email ID. + in: path + name: email_id + required: true + schema: + $ref: "#/components/schemas/Identifier" + + EmailQueueStatusFilterQuery: + description: Email queue status filter query + name: statuses + in: query + explode: true + schema: + type: array + items: { $ref: "#/components/schemas/EmailQueueStatus" } + + EmailQueueTimeRangeQuery: + description: Email queue time range query + name: range + in: query + schema: + type: string + format: iso8601-interval + + PluginIDParam: + description: Plugin ID. + in: path + name: plugin_instance_id + required: true + schema: + $ref: "#/components/schemas/Identifier" + + RoleIDParam: + description: Role ID + in: path + name: role_id + required: true + schema: + $ref: "#/components/schemas/Identifier" + + AccessKeyIDParam: + description: Access key ID. + in: path + name: access_key_id + required: true + schema: + $ref: "#/components/schemas/Identifier" + + OAuthClientIDParam: + description: OAuth client ID. + in: path + name: oauth_client_id + required: true + schema: + $ref: "#/components/schemas/Identifier" + + OAuthRefreshTokenIDParam: + description: OAuth refresh token ID. + in: path + name: oauth_refresh_token_id + required: true + schema: + $ref: "#/components/schemas/Identifier" + + OAuthRemoteConnectionIDParam: + description: Remote OAuth connection ID. + in: path + name: oauth_remote_connection_id + required: true + schema: + $ref: "#/components/schemas/Identifier" + + AccountIDParam: + description: Account ID. + name: account_id + in: path + required: true + schema: + $ref: "#/components/schemas/Identifier" + + WarningIDParam: + description: Warning ID. + name: warning_id + in: path + required: true + schema: + $ref: "#/components/schemas/Identifier" + + ModerationNoteIDParam: + description: Moderation note ID. + name: moderation_note_id + in: path + required: true + schema: + $ref: "#/components/schemas/Identifier" + + AccountHandleParam: + description: Account handle. + example: southclaws + name: account_handle + in: path + required: true + schema: + $ref: "#/components/schemas/AccountHandle" + + ProfilesSortByQuery: + description: Profiles sort by query + name: sort + in: query + schema: + type: string + + ProfilesRoleFilterQuery: + description: Profiles role filter query + name: roles + in: query + explode: true + schema: + type: array + items: { $ref: "#/components/schemas/Identifier" } + + ProfilesJoinRangeQuery: + description: Profiles join range query + name: joined + in: query + schema: + type: string + format: iso8601-interval + + ProfilesInvitedByQuery: + description: Profiles invited by query (account handles) + name: invited_by + in: query + explode: true + schema: + type: array + items: { $ref: "#/components/schemas/AccountHandle" } + + AdminAccountsSuspendedQuery: + description: Filter accounts by suspension state. + name: suspended + in: query + required: false + schema: + type: boolean + + AdminAccountsAdminQuery: + description: Filter accounts by administrator status. + name: admin + in: query + required: false + schema: + type: boolean + + AdminAccountsKindQuery: + description: Filter accounts by account type. + name: kind + in: query + required: false + schema: + $ref: "#/components/schemas/AccountKind" + + AdminAccountsAuthServicesQuery: + description: Filter accounts by one or more active authentication services. + name: auth_service + in: query + required: false + explode: true + schema: + $ref: "#/components/schemas/AuthProviderIdentifierList" + + AccountHandleQueryParam: + description: Account handle. + example: southclaws + name: account_handle + in: query + required: false + schema: + $ref: "#/components/schemas/AccountHandle" + + AccountIDQueryParam: + description: Account ID. + name: account_id + in: query + required: false + schema: + $ref: "#/components/schemas/Identifier" + + EmailAddressIDParam: + description: An email address ID associated with the requesting account. + name: email_address_id + in: path + required: true + schema: + $ref: "#/components/schemas/Identifier" + + InvitationIDParam: + description: Unique invitation ID. + name: invitation_id + in: path + required: true + schema: + $ref: "#/components/schemas/Identifier" + + InvitationIDQueryParam: + description: Unique invitation ID. + name: invitation_id + in: query + required: false + schema: + $ref: "#/components/schemas/Identifier" + + NotificationStatusQuery: + description: Notification status. + name: status + in: query + required: false + schema: + $ref: "#/components/schemas/NotificationStatusList" + + NotificationIDParam: + description: Unique notification ID. + name: notification_id + in: path + required: true + schema: + $ref: "#/components/schemas/Identifier" + + ReportIDParam: + description: Unique report ID. + name: report_id + in: path + required: true + schema: + $ref: "#/components/schemas/Identifier" + + ReportStatusQuery: + description: Report status filter. + name: status + in: query + required: false + schema: + $ref: "#/components/schemas/ReportStatus" + + ReportKindQuery: + description: Report target kind filter. + name: kind + in: query + required: false + schema: + type: string + + ThreadMarkParam: + description: Thread unique and permanent identifier. + name: thread_mark + in: path + required: true + schema: + $ref: "#/components/schemas/ThreadMark" + + PostIDParam: + description: Unique post ID. + name: post_id + in: path + required: true + schema: + $ref: "#/components/schemas/Identifier" + + ReactIDParam: + description: Unique react ID. + name: react_id + in: path + required: true + schema: + $ref: "#/components/schemas/Identifier" + + NodeIDParam: + description: Unique node ID. + name: node_id + in: path + required: true + schema: + $ref: "#/components/schemas/Identifier" + + OAuthProvider: + description: The identifier for an OAuth2 provider such as "twitter". + name: oauth_provider + in: path + required: true + example: twitter + schema: + type: string + + OAuthUserCodeQuery: + description: OAuth device authorisation user code. + name: user_code + in: query + required: false + schema: + type: string + + OAuthAuthorizationRequestIDQuery: + description: OAuth authorisation request identifier. + name: request_id + in: query + required: false + schema: + type: string + + OAuthResponseTypeQuery: + description: OAuth response type. Storyden currently supports authorisation code. + name: response_type + in: query + required: true + schema: + type: string + enum: [code] + + OAuthClientIDQuery: + description: OAuth client identifier. + name: client_id + in: query + required: true + schema: + type: string + + OAuthRedirectURIQuery: + description: Registered redirect URI for the OAuth client. + name: redirect_uri + in: query + required: true + schema: + type: string + format: uri + + OAuthScopeQuery: + description: Space-separated OAuth scopes requested by the client. + name: scope + in: query + required: false + schema: + type: string + + OAuthStateQuery: + description: Client-provided opaque state returned to the redirect URI. + name: state + in: query + required: false + schema: + type: string + + OAuthCodeQuery: + description: OAuth authorization code returned to the redirect URI. + name: code + in: query + required: true + schema: + type: string + + OAuthNonceQuery: + description: | + OpenID Connect nonce. When provided, it is returned unmodified as the + `nonce` claim in the issued ID token (OIDC Core §3.1.2). + name: nonce + in: query + required: false + schema: + type: string + + OAuthCodeChallengeQuery: + description: PKCE code challenge. + name: code_challenge + in: query + required: true + schema: + type: string + + OAuthCodeChallengeMethodQuery: + description: PKCE code challenge method. + name: code_challenge_method + in: query + required: true + schema: + type: string + enum: [S256] + + SearchQuery: + description: Search query string. + name: q + in: query + required: false + allowEmptyValue: true + schema: + type: string + minLength: 0 + + RequiredSearchQuery: + description: Search query string. + name: q + in: query + required: true + allowEmptyValue: true + schema: + type: string + minLength: 0 + + DatagraphKindQuery: + description: Datagraph item kind query. + name: kind + in: query + required: false + allowEmptyValue: true + explode: true + schema: + type: array + items: { $ref: "#/components/schemas/DatagraphItemKind" } + + DatagraphAuthorQuery: + description: | + Datagraph item author query. When set, only items authored by the + specified members will be returned. This query supports either account + IDs or handles for filtering. + name: authors + in: query + required: false + allowEmptyValue: true + explode: true + schema: + type: array + items: + type: string + + DatagraphCategoryQuery: + description: | + Datagraph item category query. When set, only items assigned to the + specified category slugs will be returned. + name: categories + in: query + required: false + allowEmptyValue: true + explode: true + schema: + type: array + items: { $ref: "#/components/schemas/Identifier" } + + PaginationQuery: + description: Pagination query parameters. + name: page + in: query + required: false + schema: + type: string + + RobotSessionMessageBeforeQuery: + description: Load messages older than this message ID. + name: before + in: query + required: false + schema: + $ref: "#/components/schemas/Identifier" + + RobotSessionMessageLimitQuery: + description: Maximum number of messages to return. + name: limit + in: query + required: false + schema: + type: string + + AssetPathParam: + description: Asset ID. + name: asset_filename + in: path + required: true + schema: + type: string + + AssetIDParam: + description: Asset ID. + name: asset_id + in: path + required: true + schema: + type: string + + ParentAssetIDQuery: + description: | + For uploading new versions of an existing asset, set this parameter to + the asset ID of the parent asset. This must be an ID and not a filename. + This feature is used for situations where you want to replace an asset + in its usage context, but retain the original with a way to reference it + for features such as editable/croppable images or file version history. + name: parent_asset_id + in: query + required: false + schema: + type: string + + AssetNameQuery: + description: The client-provided file name for the asset. + name: filename + in: query + required: false + schema: + type: string + + ThreadsIgnorePinnedQuery: + description: | + When set to true, pinned threads will be ignored in the results and the + result will be ordered entirely by the most recent reply. By default, + this is not set and pinned threads will appear at the top of the first + page. + name: ignore_pinned + in: query + required: false + schema: + type: boolean + + CategorySlugParam: + description: Unique category URL slug. + name: category_slug + in: path + required: true + schema: + type: string + + CategorySlugListQuery: + name: categories + description: | + Category slugs to filter by. Multiple instances of this parameter can be + used to filter by many categories. If not provided, no filtering will be + applied and all threads will be returned. If ANY of the provided values + is set to the exact value of "null" then only uncategorised threads will + be returned. When filtering for uncategorised threads, all other values + will be ignored, only the value containing "null" will be considered. + required: false + in: query + schema: + $ref: "#/components/schemas/CategorySlugList" + + TagNameParam: + description: Tag name. + name: tag_name + in: path + required: true + schema: + type: string + + TagNameListQueryParam: + description: Tags to filter by. + name: tags + in: query + required: false + schema: { $ref: "#/components/schemas/TagNameList" } + + CollectionMarkParam: + description: | + Either: + - The unique collection ID. + - A string of the form - + name: collection_mark + in: path + required: true + schema: + $ref: "#/components/schemas/Mark" + + CollectionHasItemQueryParam: + description: | + When specified, will include a field in the response indicating whether + or not the specified item is present in the collection. This saves you + needing to make two queries to check if an item is in a collection. + name: has_item + in: query + required: false + schema: + $ref: "#/components/schemas/Identifier" + + NodeSlugParam: + description: Unique node Slug. + name: node_slug + in: path + required: true + schema: + $ref: "#/components/schemas/Identifier" + + NodeSlugChildParam: + description: Unique node Slug. + name: node_slug_child + in: path + required: true + schema: + $ref: "#/components/schemas/Identifier" + + NodeVersionIDParam: + description: Node version ID. + name: version_id + in: path + required: true + schema: + $ref: "#/components/schemas/Identifier" + + NodeChildrenSortParam: + description: | + The field (either in schema or in property schema) to sort by. + name: children_sort + in: query + required: false + schema: + type: string + + TargetNodeSlugQuery: + description: | + If set, child nodes will be moved to the target node. If not set, child + nodes will be moved to the root. + name: target_node + in: query + required: false + schema: + type: string + + LinkSlugParam: + description: Unique link Slug. + name: link_slug + in: path + required: true + schema: + type: string + + VisibilityParam: + name: visibility + description: | + Filter content with specific visibility values. Note that by default, + only published items are returned. When 'draft' is specified, only + drafts owned by the requesting account are included. When 'review' is + specified, the request will fail if the requesting account does not have + the necessary permission to view in-review items. + required: false + in: query + explode: true + schema: + type: array + items: { $ref: "#/components/schemas/Visibility" } + + TreeDepthParam: + name: depth + description: | + When set to a positive value, the nodes in the response will include all + child nodes up to the specified depth. When set to zero, then if the + request includes a node ID only that node will be returned, otherwise + only top-level (root) nodes will be returned. + required: false + in: query + schema: + type: string + + NodeListFormatParam: + name: format + description: | + List format, either a tree where each item contains a children array or + flat where children items contain an ID that references their parent. + required: false + in: query + schema: + default: tree + type: string + enum: [tree, flat] + + EventMarkParam: + description: | + Either: + - The unique event ID. + - The unique event slug. + - A string of the form - + name: event_mark + in: path + required: true + schema: + $ref: "#/components/schemas/Mark" + + RobotIDParam: + description: Robot ID + in: path + name: robot_id + required: true + schema: + $ref: "#/components/schemas/Identifier" + + RobotModelProviderParam: + description: Robot model provider namespace + in: path + name: provider + required: true + schema: + $ref: "#/components/schemas/RobotModelProvider" + + RobotMCPServerIDParam: + description: Robot MCP server ID + in: path + name: mcp_server_id + required: true + schema: + $ref: "#/components/schemas/Identifier" + + RobotSessionIDParam: + description: Robot session ID + in: path + name: session_id + required: true + schema: + $ref: "#/components/schemas/Identifier" + + RobotWorkspaceIDParam: + description: Robot workspace ID + in: path + name: workspace_id + required: true + schema: + $ref: "#/components/schemas/Identifier" + + RobotWorkspaceInstanceIDParam: + description: Robot workspace instance ID + in: path + name: workspace_instance_id + required: true + schema: + $ref: "#/components/schemas/Identifier" + + # + # 8888888b. 8888888888 .d88888b. 888 888 8888888888 .d8888b. 88888888888 .d8888b. + # 888 Y88b 888 d88P" "Y88b 888 888 888 d88P Y88b 888 d88P Y88b + # 888 888 888 888 888 888 888 888 Y88b. 888 Y88b. + # 888 d88P 8888888 888 888 888 888 8888888 "Y888b. 888 "Y888b. + # 8888888P" 888 888 888 888 888 888 "Y88b. 888 "Y88b. + # 888 T88b 888 888 Y8b 888 888 888 888 "888 888 "888 + # 888 T88b 888 Y88b.Y8b88P Y88b. .d88P 888 Y88b d88P 888 Y88b d88P + # 888 T88b 8888888888 "Y888888" "Y88888P" 8888888888 "Y8888P" 888 "Y8888P" + # Y8b + # + + requestBodies: + Beacon: + content: + text/plain: + schema: { $ref: "#/components/schemas/BeaconProps" } + + AdminSettingsUpdate: + content: + application/json: + schema: { $ref: "#/components/schemas/AdminSettingsMutableProps" } + + ModerationActionCreate: + content: + application/json: + schema: { $ref: "#/components/schemas/ModerationActionInitialProps" } + + PluginAdd: + content: + application/zip: + schema: + type: string + format: binary + application/json: + schema: { $ref: "#/components/schemas/PluginInitialProps" } + + PluginSetActiveState: + content: + application/json: + schema: { $ref: "#/components/schemas/PluginActiveStateMutableProps" } + + PluginUpdateManifest: + content: + application/json: + schema: { $ref: "#/components/schemas/PluginManifest" } + + PluginUpdatePackage: + content: + application/zip: + schema: + type: string + format: binary + + PluginUpdateConfiguration: + content: + application/json: + schema: { $ref: "#/components/schemas/PluginConfiguration" } + + RoleCreate: + content: + application/json: + schema: { $ref: "#/components/schemas/RoleInitialProps" } + + RoleUpdate: + content: + application/json: + schema: { $ref: "#/components/schemas/RoleMutableProps" } + + RoleUpdateOrder: + content: + application/json: + schema: { $ref: "#/components/schemas/RoleOrderMutableProps" } + + AuthPassword: + content: + application/json: + schema: { $ref: "#/components/schemas/AuthPair" } + + AuthEmailPassword: + content: + application/json: + schema: { $ref: "#/components/schemas/AuthEmailPasswordInitialProps" } + + AuthEmailPasswordReset: + content: + application/json: + schema: { $ref: "#/components/schemas/AuthEmailPasswordReset" } + + AuthEmail: + content: + application/json: + schema: { $ref: "#/components/schemas/AuthEmailInitialProps" } + + AuthEmailVerify: + content: + application/json: + schema: { $ref: "#/components/schemas/AuthEmailVerifyProps" } + + AuthPasswordCreate: + content: + application/json: + schema: { $ref: "#/components/schemas/AuthPasswordInitialProps" } + + AuthPasswordUpdate: + content: + application/json: + schema: { $ref: "#/components/schemas/AuthPasswordMutableProps" } + + AuthPasswordReset: + content: + application/json: + schema: { $ref: "#/components/schemas/AuthPasswordResetProps" } + + OAuthProviderCallback: + content: + application/json: + schema: { $ref: "#/components/schemas/OAuthCallback" } + + OAuthDeviceAuthorisation: + required: true + content: + application/x-www-form-urlencoded: + schema: { $ref: "#/components/schemas/OAuthDeviceAuthorisationProps" } + + OAuthDeviceConsentSubmit: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/OAuthDeviceConsentSubmitProps" } + + OAuthAuthoriseConsentSubmit: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthAuthoriseConsentSubmitProps" + + OAuthToken: + required: true + content: + application/x-www-form-urlencoded: + schema: { $ref: "#/components/schemas/OAuthTokenProps" } + + OAuthClientCreate: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/OAuthClientCreateProps" } + + OAuthClientUpdate: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/OAuthClientUpdateProps" } + + OAuthClientSelfCreate: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/OAuthClientSelfCreateProps" } + + OAuthClientSelfUpdate: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/OAuthClientSelfUpdateProps" } + + OAuthClientRegister: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/OAuthClientRegisterProps" } + + OAuthRemoteDiscover: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/OAuthRemoteDiscoverProps" } + + OAuthRemoteConnectionCreate: + required: true + content: + application/json: + schema: + { $ref: "#/components/schemas/OAuthRemoteConnectionCreateProps" } + + WebAuthnMakeCredential: + content: + application/json: + schema: { $ref: "#/components/schemas/PublicKeyCredential" } + + WebAuthnMakeAssertion: + content: + application/json: + schema: { $ref: "#/components/schemas/PublicKeyCredential" } + + PhoneRequestCode: + content: + application/json: + schema: { $ref: "#/components/schemas/PhoneRequestCodeProps" } + + PhoneSubmitCode: + content: + application/json: + schema: { $ref: "#/components/schemas/PhoneSubmitCodeProps" } + + AccessKeyCreate: + content: + application/json: + schema: { $ref: "#/components/schemas/AccessKeyInitialProps" } + + AccountUpdate: + content: + application/json: + schema: { $ref: "#/components/schemas/AccountMutableProps" } + + AccountManageCreate: + content: + application/json: + schema: { $ref: "#/components/schemas/AccountManageInitialProps" } + + AccountManageUpdate: + content: + application/json: + schema: { $ref: "#/components/schemas/AccountManageMutableProps" } + + AccountEmailAdd: + content: + application/json: + schema: { $ref: "#/components/schemas/AccountEmailInitialProps" } + + AccountEmailVerifiedStatusUpdate: + content: + application/json: + schema: + $ref: "#/components/schemas/AccountEmailVerifiedStatusMutableProps" + + AccountEmailPasswordReset: + content: + application/json: + schema: + $ref: "#/components/schemas/AccountEmailPasswordResetProps" + + AccountModerationNoteCreate: + content: + application/json: + schema: { $ref: "#/components/schemas/ModerationNoteInitialProps" } + + AccountWarningCreate: + content: + application/json: + schema: { $ref: "#/components/schemas/WarningInitialProps" } + + AccountWarningUpdate: + content: + application/json: + schema: { $ref: "#/components/schemas/WarningMutableProps" } + + AccountSetAvatar: + content: + application/octet-stream: + schema: + type: string + format: binary + + InvitationCreate: + content: + application/json: + schema: { $ref: "#/components/schemas/InvitationInitialProps" } + + NotificationUpdate: + content: + application/json: + schema: { $ref: "#/components/schemas/NotificationMutableProps" } + + NotificationUpdateMany: + content: + application/json: + schema: { $ref: "#/components/schemas/NotificationListUpdate" } + + ReportCreate: + content: + application/json: + schema: { $ref: "#/components/schemas/ReportInitialProps" } + + ReportUpdate: + content: + application/json: + schema: { $ref: "#/components/schemas/ReportMutableProps" } + + CategoryCreate: + content: + application/json: + schema: { $ref: "#/components/schemas/CategoryInitialProps" } + + CategoryUpdate: + content: + application/json: + schema: { $ref: "#/components/schemas/CategoryMutableProps" } + + CategoryUpdatePosition: + content: + application/json: + schema: { $ref: "#/components/schemas/CategoryPositionMutableProps" } + + CategoryDelete: + content: + application/json: + schema: { $ref: "#/components/schemas/CategoryDeleteProps" } + + ThreadCreate: + content: + application/json: + schema: { $ref: "#/components/schemas/ThreadInitialProps" } + + ThreadUpdate: + content: + application/json: + schema: { $ref: "#/components/schemas/ThreadMutableProps" } + + ReplyCreate: + description: Create a reply, which is a post within a thread. + content: + application/json: + schema: { $ref: "#/components/schemas/ReplyInitialProps" } + + PostUpdate: + description: Create a post within a thread. + content: + application/json: + schema: { $ref: "#/components/schemas/PostMutableProps" } + + PostReactAdd: + description: Add a reaction to a post. + content: + application/json: + schema: { $ref: "#/components/schemas/ReactInitialProps" } + + AssetUpload: + description: Upload a file. + content: + application/octet-stream: + schema: + type: string + format: binary + + CollectionCreate: + content: + application/json: + schema: { $ref: "#/components/schemas/CollectionInitialProps" } + + CollectionUpdate: + content: + application/json: + schema: { $ref: "#/components/schemas/CollectionMutableProps" } + + NodeCreate: + content: + application/json: + schema: { $ref: "#/components/schemas/NodeInitialProps" } + + NodeUpdate: + content: + application/json: + schema: { $ref: "#/components/schemas/NodeMutableProps" } + + NodeVersionCreate: + content: + application/json: + schema: { $ref: "#/components/schemas/NodeVersionInitialProps" } + + NodeVersionUpdate: + content: + application/json: + schema: { $ref: "#/components/schemas/NodeVersionMutableProps" } + + NodeVersionUpdateStatus: + content: + application/json: + schema: + { $ref: "#/components/schemas/NodeVersionStatusMutationProps" } + + NodeGenerateTitle: + content: + application/json: + schema: { $ref: "#/components/schemas/NodeGenerateTitleRequest" } + + NodeGenerateTags: + content: + application/json: + schema: { $ref: "#/components/schemas/NodeGenerateTagsRequest" } + + NodeGenerateContent: + content: + application/json: + schema: { $ref: "#/components/schemas/NodeGenerateContentRequest" } + + NodeUpdateProperties: + content: + application/json: + schema: { $ref: "#/components/schemas/PropertyMutableProps" } + + NodeUpdatePropertySchema: + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/PropertySchemaMutableProps" } + + NodeUpdatePosition: + content: + application/json: + schema: { $ref: "#/components/schemas/NodePositionMutableProps" } + + LinkCreate: + content: + application/json: + schema: { $ref: "#/components/schemas/LinkInitialProps" } + + VisibilityUpdate: + content: + application/json: + schema: { $ref: "#/components/schemas/VisibilityMutationProps" } + + EventCreate: + content: + application/json: + schema: { $ref: "#/components/schemas/EventInitialProps" } + + EventUpdate: + content: + application/json: + schema: { $ref: "#/components/schemas/EventMutableProps" } + + EventParticipantUpdate: + content: + application/json: + schema: { $ref: "#/components/schemas/EventParticipantMutableProps" } + + RobotCreate: + content: + application/json: + schema: { $ref: "#/components/schemas/RobotInitialProps" } + + RobotChatStart: + content: + application/json: + schema: { $ref: "#/components/schemas/RobotChatRequest" } + + RobotWorkspaceCreate: + content: + application/json: + schema: { $ref: "#/components/schemas/RobotWorkspaceInitialProps" } + + RobotWorkspaceUpdate: + content: + application/json: + schema: { $ref: "#/components/schemas/RobotWorkspaceMutableProps" } + + RobotProviderUpdate: + content: + application/json: + schema: { $ref: "#/components/schemas/RobotProviderMutableSettings" } + + RobotMCPServerCreate: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RobotMCPServerInitialProps" } + + RobotMCPServerProbe: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RobotMCPServerProbeProps" } + + RobotMCPServerUpdate: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RobotMCPServerMutableProps" } + + RobotUpdate: + content: + application/json: + schema: { $ref: "#/components/schemas/RobotMutableProps" } + + # + # 8888888b. 8888888888 .d8888b. 8888888b. .d88888b. 888b 888 .d8888b. 8888888888 .d8888b. + # 888 Y88b 888 d88P Y88b 888 Y88b d88P" "Y88b 8888b 888 d88P Y88b 888 d88P Y88b + # 888 888 888 Y88b. 888 888 888 888 88888b 888 Y88b. 888 Y88b. + # 888 d88P 8888888 "Y888b. 888 d88P 888 888 888Y88b 888 "Y888b. 8888888 "Y888b. + # 8888888P" 888 "Y88b. 8888888P" 888 888 888 Y88b888 "Y88b. 888 "Y88b. + # 888 T88b 888 "888 888 888 888 888 Y88888 "888 888 "888 + # 888 T88b 888 Y88b d88P 888 Y88b. .d88P 888 Y8888 Y88b d88P 888 Y88b d88P + # 888 T88b 8888888888 "Y8888P" 888 "Y88888P" 888 Y888 "Y8888P" 8888888888 "Y8888P" + # + + responses: + NotImplemented: + description: Not implemented + NotModified: + description: Not modified + headers: { <<: *cache_response_headers } + BadRequest: + description: Bad request + Conflict: + description: Conflict + NotFound: + description: Not found + Unauthorised: + description: Unauthorized + Forbidden: + description: Forbidden + NoContent: + description: No content + InternalServerError: + description: Internal Server Error + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + + GetInfoOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Info" + + GetSessionOK: + description: OK + headers: { <<: *cache_response_headers } + content: + application/json: + schema: + $ref: "#/components/schemas/SessionInfo" + + AdminSettingsGetOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/AdminSettingsProps" + + AdminSettingsUpdateOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/AdminSettingsProps" + + AuditEventListOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/AuditEventListResult" + + AuditEventGetOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/AuditEvent" + + EmailQueueListOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/EmailQueueListResult" + + EmailQueueGetOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/EmailQueueItem" + + PluginListOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/PluginListResult" + + PluginGetOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Plugin" + + PluginGetLogsOK: + description: OK + content: + text/event-stream: + schema: + type: string + + PluginDownloadPackageOK: + description: OK + headers: + Content-Disposition: + schema: + type: string + description: Suggested attachment filename for the plugin package. + content: + application/zip: + schema: + type: string + format: binary + + PluginCycleTokenOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/PluginCycleToken" + + PluginGetConfigurationSchemaOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/PluginConfigurationSchema" + + PluginGetConfigurationOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/PluginConfiguration" + + AuditEventCreatedOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/AuditEventProps" + + RoleCreateOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + + RoleListOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/RoleListResult" + + RoleGetOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + + AuthSuccessOK: + description: OK + headers: + "Set-Cookie": + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/AuthSuccess" + + AuthProviderListOK: + description: OK + content: + application/json: + schema: + type: object + required: [providers, mode] + properties: + providers: { $ref: "#/components/schemas/AuthProviderList" } + mode: { $ref: "#/components/schemas/AuthMode" } + + OAuthJWKSOK: + description: JSON Web Key Set. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthJWKS" + + OAuthDeviceAuthorisationOK: + description: Device authorisation response. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthDeviceAuthorisation" + + OAuthDeviceConsentOK: + description: Device consent details. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthDeviceConsent" + + OAuthDeviceConsentSubmitOK: + description: Device consent decision result. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthDeviceConsentResult" + + OAuthAuthoriseConsentOK: + description: Authorization code consent details. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthAuthoriseConsent" + + OAuthAuthoriseConsentSubmitOK: + description: Authorization code consent decision result. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthAuthoriseConsentResult" + + OAuthAuthoriseOK: + description: OAuth authorisation page. + content: + text/html: + schema: + type: string + + OAuthAuthoriseFound: + description: Redirect to client callback. + headers: + Location: + schema: + type: string + + OAuthError: + description: OAuth-compatible error response. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthError" + + OAuthTokenOK: + description: Token response. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthToken" + + OAuthTokenError: + description: OAuth token error response. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthError" + + OAuthTokenUnauthorised: + description: | + OAuth token error response for `invalid_client` when the client + attempted authentication via the `Authorization` header (RFC 6749 + §5.2). Carries a `WWW-Authenticate` Bearer challenge. + headers: + WWW-Authenticate: { $ref: "#/components/headers/WWW-Authenticate" } + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthError" + + OAuthUserInfoOK: + description: User info claims. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthUserInfo" + + OAuthClientOK: + description: OAuth client. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthClient" + + OAuthClientListOK: + description: OAuth clients. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthClientListResult" + + OAuthClientIssuedOK: + description: OAuth client and one-time client secret. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthClientIssued" + + OAuthClientRegisterOK: + description: | + RFC 7591 client information response. Contains the generated + `client_id`, the one-time `client_secret` for confidential clients, and + the registered client metadata. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthClientRegistration" + + OAuthClientRegisterError: + description: RFC 7591 client registration error response. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthError" + + OAuthRemoteDiscoverOK: + description: Remote OAuth discovery result. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthRemoteDiscoveryResult" + + OAuthRemoteConnectionOK: + description: Remote OAuth connection. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthRemoteConnection" + + OAuthRemoteConnectionListOK: + description: Remote OAuth connections. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthRemoteConnectionListResult" + + OAuthRemoteAuthorizeOK: + description: Remote OAuth authorization URL. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthRemoteAuthorizeResult" + + OAuthRemoteCallbackOK: + description: Remote OAuth callback result. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthRemoteCallbackResult" + + OAuthDeviceAuthorisationListOK: + description: OAuth device authorisations. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthDeviceAuthorisationListResult" + + OAuthRefreshTokenListOK: + description: OAuth refresh tokens. + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthRefreshTokenListResult" + + WebAuthnRequestCredentialOK: + description: OK + headers: + "Set-Cookie": + schema: + type: string + content: + application/json: + schema: + { $ref: "#/components/schemas/WebAuthnPublicKeyCreationOptions" } + + WebAuthnGetAssertionOK: + description: OK + headers: + "Set-Cookie": + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/CredentialRequestOptions" + + AccessKeyCreateOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/AccessKeyIssued" + + AdminAccessKeyListOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/OwnedAccessKeyListResult" + + AccountListOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/AccountListResult" + + AccessKeyListOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/AccessKeyListResult" + + AccountGetOK: + description: OK + headers: { <<: *cache_response_headers } + content: + application/json: + schema: + $ref: "#/components/schemas/Account" + + AccountUpdateOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Account" + + AccountEmailUpdateOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/AccountEmailAddress" + + AccountPasswordResetTokenGetOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/AccountPasswordResetToken" + + AccountAuthProviderListOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/AccountAuthMethods" + + AccountModerationNoteListOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ModerationNoteListResult" + + AccountModerationNoteCreateOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ModerationNote" + + AccountWarningListOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/WarningListResult" + + AccountWarningCreateOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Warning" + + AccountWarningUpdateOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Warning" + + AccountGetAvatar: + description: OK + headers: { <<: *cache_response_headers } + content: + image/png: + schema: + type: string + format: binary + + InvitationListOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/InvitationListResult" + + InvitationCreateOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Invitation" + + InvitationGetOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Invitation" + + NotificationListOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/NotificationListResult" + + NotificationUpdateOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Notification" + + NotificationUpdateManyOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/NotificationListResult" + + ReportCreateOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Report" + + ReportListOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ReportListResult" + + ReportUpdateOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Report" + + ProfileListOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/PublicProfileListResult" + + ProfileGetOK: + description: OK + headers: { <<: *cache_response_headers } + content: + application/json: + schema: + $ref: "#/components/schemas/PublicProfile" + + ProfileFollowersGetOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/PublicProfileFollowersResult" + + ProfileFollowingGetOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/PublicProfileFollowingResult" + + CategoryCreateOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Category" + + CategoryGetOK: + description: OK + headers: { <<: *cache_response_headers } + content: + application/json: + schema: + $ref: "#/components/schemas/Category" + + CategoryUpdateOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Category" + + CategoryDeleteOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Category" + + CategoryListOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CategoryListResult" + + TagListOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/TagListResult" + + TagGetOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Tag" + + ThreadCreateOK: + description: Thread created. + content: + application/json: + schema: + $ref: "#/components/schemas/Thread" + + ThreadUpdateOK: + description: Thread updated. + content: + application/json: + schema: + $ref: "#/components/schemas/Thread" + + ThreadListOK: + description: List of all threads. + headers: { <<: *cache_response_headers } + content: + application/json: + schema: { $ref: "#/components/schemas/ThreadListResult" } + + ThreadGet: + description: The information about a thread and its posts. + headers: { <<: *cache_response_headers } + content: + application/json: + schema: { $ref: "#/components/schemas/Thread" } + + ReplyCreateOK: + description: Thread reply created successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/Reply" + + PostUpdateOK: + description: Post updated successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + + PostLocationGetOK: + description: Post location retrieved successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/PostLocation" + + PostReactAddOK: + description: Post reaction added. + content: + application/json: + schema: + $ref: "#/components/schemas/React" + + AssetUploadOK: + description: The new URL of an uploaded file. + content: + application/json: + schema: + $ref: "#/components/schemas/Asset" + + AssetGetOK: + description: The new URL of an uploaded file. + headers: { <<: *cache_response_headers } + content: + "*/*": + schema: + type: string + format: binary + + LikePostGetOK: + description: All likes for a post. + content: + application/json: + schema: + type: object + required: [likes] + properties: + likes: { $ref: "#/components/schemas/ItemLikeList" } + + LikeProfileGetOK: + description: Likes that an account has given. + content: + application/json: + schema: + $ref: "#/components/schemas/ProfileLikeListResult" + + CollectionCreateOK: + description: Collection created. + content: + application/json: + schema: + $ref: "#/components/schemas/Collection" + + CollectionListOK: + description: Collection list. + content: + application/json: + schema: + type: object + required: [collections] + properties: + collections: { $ref: "#/components/schemas/CollectionList" } + + CollectionGetOK: + description: Collection information and content. + content: + application/json: + schema: + $ref: "#/components/schemas/CollectionWithItems" + + CollectionUpdateOK: + description: Collection updated. + content: + application/json: + schema: + $ref: "#/components/schemas/Collection" + + CollectionAddPostOK: + description: Collection content added. + content: + application/json: + schema: + $ref: "#/components/schemas/CollectionWithItems" + + CollectionRemovePostOK: + description: Collection content added. + content: + application/json: + schema: + $ref: "#/components/schemas/CollectionWithItems" + + CollectionAddNodeOK: + description: Collection content added. + content: + application/json: + schema: + $ref: "#/components/schemas/CollectionWithItems" + + CollectionRemoveNodeOK: + description: Collection content added. + content: + application/json: + schema: + $ref: "#/components/schemas/CollectionWithItems" + + NodeCreateOK: + description: Node created. + content: + application/json: + schema: + $ref: "#/components/schemas/Node" + + NodeListOK: + description: Node list. + content: + application/json: + schema: { $ref: "#/components/schemas/NodeListResult" } + + NodeGetOK: + description: Node information and content. + headers: { <<: *cache_response_headers } + content: + application/json: + schema: + $ref: "#/components/schemas/NodeWithChildren" + + NodeUpdateOK: + description: Node updated. + headers: { <<: *cache_response_headers } + content: + application/json: + schema: + $ref: "#/components/schemas/NodeWithChildren" + + NodeVersionListOK: + description: Node versions listed. + content: + application/json: + schema: + $ref: "#/components/schemas/NodeVersionListResult" + + NodeDraftListOK: + description: All draft versions listed. + content: + application/json: + schema: + $ref: "#/components/schemas/NodeDraftListResult" + + NodeVersionCreateOK: + description: Node version created. + content: + application/json: + schema: + $ref: "#/components/schemas/NodeVersion" + + NodeVersionGetOK: + description: Node version retrieved. + content: + application/json: + schema: + $ref: "#/components/schemas/NodeVersion" + + NodeVersionUpdateOK: + description: Node version updated. + content: + application/json: + schema: + $ref: "#/components/schemas/NodeVersion" + + NodeVersionDeleteOK: + description: Node version deleted. + + NodeGenerateTitleOK: + description: Node title generated. + content: + application/json: + schema: { $ref: "#/components/schemas/NodeGenerateTitleResult" } + + NodeGenerateTagsOK: + description: Node tags generated. + content: + application/json: + schema: { $ref: "#/components/schemas/NodeGenerateTagsResult" } + + NodeGenerateContentOK: + description: Node content generated. + content: + application/json: + schema: { $ref: "#/components/schemas/NodeGenerateContentResult" } + + NodeUpdatePropertiesOK: + description: Node properties updated. + content: + application/json: + schema: + type: object + required: [properties] + properties: + properties: + $ref: "#/components/schemas/PropertyList" + + NodeUpdatePropertySchemaOK: + description: Node children schema updated. + content: + application/json: + schema: + type: object + required: [properties] + properties: + properties: + $ref: "#/components/schemas/PropertySchemaList" + + NodeDeleteOK: + description: Node deleted. + content: + application/json: + schema: + type: object + properties: + destination: + $ref: "#/components/schemas/Node" + + NodeAddChildOK: + description: Node child added. Returns parent node. + content: + application/json: + schema: + $ref: "#/components/schemas/Node" + + NodeRemoveChildOK: + description: Node child removed. Returns parent node. + content: + application/json: + schema: + $ref: "#/components/schemas/Node" + + LinkCreateOK: + description: Link indexed + content: + application/json: + schema: + $ref: "#/components/schemas/LinkReference" + + LinkListOK: + description: Link list. + content: + application/json: + schema: { $ref: "#/components/schemas/LinkListResult" } + + LinkGetOK: + description: Link data. + content: + application/json: + schema: + $ref: "#/components/schemas/Link" + + DatagraphSearchOK: + description: Search results. + content: + application/json: + schema: { $ref: "#/components/schemas/DatagraphSearchResult" } + + DatagraphMatchesOK: + description: Search results. + content: + application/json: + schema: { $ref: "#/components/schemas/DatagraphMatchResult" } + + EventListOK: + description: Event list. + content: + application/json: + schema: { $ref: "#/components/schemas/EventListResult" } + + EventCreateOK: + description: Event create. + content: + application/json: + schema: { $ref: "#/components/schemas/Event" } + + EventGetOK: + description: Event get. + content: + application/json: + schema: { $ref: "#/components/schemas/Event" } + + EventUpdateOK: + description: Event update. + content: + application/json: + schema: { $ref: "#/components/schemas/Event" } + + RobotsListOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/RobotsListResult" + + RobotCreateOK: + description: Robot created. + content: + application/json: + schema: { $ref: "#/components/schemas/Robot" } + + RobotGetOK: + description: Robot retrieved. + content: + application/json: + schema: { $ref: "#/components/schemas/Robot" } + + RobotToolsListOK: + description: Robot tools retrieved. + content: + application/json: + schema: { $ref: "#/components/schemas/RobotToolListResult" } + + RobotChatStream: + description: Server-sent events stream of robot chat responses. + content: + text/event-stream: + schema: + $ref: "#/components/schemas/StreamPart" + + RobotProvidersListOK: + description: Robot providers retrieved. + content: + application/json: + schema: { $ref: "#/components/schemas/RobotProviderListResult" } + + RobotProviderGetOK: + description: Robot provider retrieved. + content: + application/json: + schema: { $ref: "#/components/schemas/RobotProviderStatus" } + + RobotModelsListOK: + description: Robot models retrieved. + content: + application/json: + schema: { $ref: "#/components/schemas/RobotModelListResult" } + + RobotWorkspaceProvidersListOK: + description: Robot workspace providers retrieved. + content: + application/json: + schema: + { $ref: "#/components/schemas/RobotWorkspaceProviderListResult" } + + RobotWorkspacesListOK: + description: Robot workspaces retrieved. + content: + application/json: + schema: { $ref: "#/components/schemas/RobotWorkspacesListResult" } + + RobotWorkspaceCreateOK: + description: Robot workspace created. + content: + application/json: + schema: { $ref: "#/components/schemas/RobotWorkspace" } + + RobotWorkspaceGetOK: + description: Robot workspace retrieved. + content: + application/json: + schema: { $ref: "#/components/schemas/RobotWorkspace" } + + RobotWorkspaceInstancesListOK: + description: Robot workspace instances retrieved. + content: + application/json: + schema: + { $ref: "#/components/schemas/RobotWorkspaceInstancesListResult" } + + RobotWorkspaceInstanceGetOK: + description: Robot workspace instance retrieved. + content: + application/json: + schema: { $ref: "#/components/schemas/RobotWorkspaceInstance" } + + RobotSessionsListOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/RobotSessionsListResult" + + RobotSessionGetOK: + description: Robot session with messages. + content: + application/json: + schema: + $ref: "#/components/schemas/RobotSession" + + RobotMCPServersListOK: + description: Robot MCP servers retrieved. + content: + application/json: + schema: { $ref: "#/components/schemas/RobotMCPServerListResult" } + + RobotMCPServerCreateOK: + description: Robot MCP server created. + content: + application/json: + schema: { $ref: "#/components/schemas/RobotMCPServer" } + + RobotMCPServerProbeOK: + description: Robot MCP server probe completed. + content: + application/json: + schema: { $ref: "#/components/schemas/RobotMCPServerProbeResult" } + + RobotMCPServerGetOK: + description: Robot MCP server retrieved. + content: + application/json: + schema: { $ref: "#/components/schemas/RobotMCPServer" } + + RobotMCPServerUpdateOK: + description: Robot MCP server updated. + content: + application/json: + schema: { $ref: "#/components/schemas/RobotMCPServer" } + + RobotMCPServerDeleteOK: + description: Robot MCP server deleted. + + RobotMCPServerRefreshOK: + description: Robot MCP server refreshed. + content: + application/json: + schema: { $ref: "#/components/schemas/RobotMCPServer" } + + # + # .d8888b. .d8888b. 888 888 8888888888 888b d888 d8888 .d8888b. + # d88P Y88b d88P Y88b 888 888 888 8888b d8888 d88888 d88P Y88b + # Y88b. 888 888 888 888 888 88888b.d88888 d88P888 Y88b. + # "Y888b. 888 8888888888 8888888 888Y88888P888 d88P 888 "Y888b. + # "Y88b. 888 888 888 888 888 Y888P 888 d88P 888 "Y88b. + # "888 888 888 888 888 888 888 Y8P 888 d88P 888 "888 + # Y88b d88P Y88b d88P 888 888 888 888 " 888 d8888888888 Y88b d88P + # "Y8888P" "Y8888P" 888 888 8888888888 888 888 d88P 888 "Y8888P" + # + + headers: + Cache-Control: + schema: + type: string + Last-Modified: + schema: + type: string + format: RFC1123 + ETag: + schema: + type: string + WWW-Authenticate: + description: | + RFC 6750 Bearer authentication challenge. Present on 401 responses from + OAuth-protected resources, optionally carrying a `resource_metadata` + pointer per RFC 9728. + schema: + type: string + + schemas: + # + # .d8888b. + # d88P Y88b + # 888 888 + # 888 .d88b. 88888b.d88b. 88888b.d88b. .d88b. 88888b. + # 888 d88""88b 888 "888 "88b 888 "888 "88b d88""88b 888 "88b + # 888 888 888 888 888 888 888 888 888 888 888 888 888 888 + # Y88b d88P Y88..88P 888 888 888 888 888 888 Y88..88P 888 888 + # "Y8888P" "Y88P" 888 888 888 888 888 888 "Y88P" 888 888 + # + + Identifier: + type: string + format: xid + # NOTE: Does not work currently with oapi-codegen because it generates a + # new type declaration instead of a type alias so the unmarshalling fails. + # To work around this, there are special conversion APIs in the oapi pkg. + x-go-type: string + # x-go-type-import: + # name: xid + # path: github.com/rs/xid + example: "cc5lnd2s1s4652adtu50" + description: A unique identifier for this resource. + + NullableIdentifier: + type: string + format: xid + nullable: true + example: "cc5lnd2s1s4652adtu50" + description: A unique identifier for this resource. + + Mark: + description: | + A polymorphic identifier which is either a raw ID, a slug or both values + combined and separated by a hyphen. This allows endpoints to respond to + varying forms of a resource's ID which may be present in different app + contexts. For example, a slug may be used in a URL but raw IDs are often + exposed as part of API responses or in certain endpoint parameters. This + type allows flexibility in user experience as well as the API surface + while ensuring performance during database queries and other operations. + + For example, given a thread with the ID `cc5lnd2s1s4652adtu50` and the + slug `top-10-movies-thread`, Storyden will understand both the forms: + `cc5lnd2s1s4652adtu50-top-10-movies-thread` or `cc5lnd2s1s4652adtu50` or + `top-10-movies-thread` as the identifier for that thread. + + Marks are only ever used on the read path as they are a derivative data + type and are not stored in the database as-is, while IDs and slugs are. + The write path typically exposes slugs as writable and IDs as immutable. + x-go-type: string + type: string + format: xid-prefixed-kebab-case-string + example: "cc5lnd2s1s4652adtu50-top-10-movies-thread" + + Info: + description: Basic public information about the Storyden installation. + type: object + required: + - title + - description + - content + - accent_colour + - authentication_mode + - registration_mode + - capabilities + - onboarding_status + - web_address + - api_address + properties: + title: + type: string + description: + type: string + content: + $ref: "#/components/schemas/PostContent" + accent_colour: + type: string + onboarding_status: + $ref: "#/components/schemas/OnboardingStatus" + authentication_mode: + $ref: "#/components/schemas/AuthMode" + registration_mode: + $ref: "#/components/schemas/RegistrationMode" + capabilities: + $ref: "#/components/schemas/InstanceCapabilityList" + web_address: + type: string + format: uri + description: | + The public web frontend address for this Storyden instance. + api_address: + type: string + format: uri + description: | + The public API address for this Storyden instance. + metadata: + $ref: "#/components/schemas/Metadata" + motd: + $ref: "#/components/schemas/MessageOfTheDay" + + SessionInfo: + description: | + The combination of public info and the requesting account's settings. + required: [info] + properties: + info: { $ref: "#/components/schemas/Info" } + account: { $ref: "#/components/schemas/Account" } + client: { $ref: "#/components/schemas/ClientInfo" } + + ClientInfo: + description: Information about the client making the request. + type: object + required: [ip_address] + properties: + ip_address: + type: string + description: | + The client's IP address, resolved using the configured rules set by + the `client_ip_mode` admin setting. May be IPv4 or IPv6. + ip_address_ssr: + type: string + description: | + If the frontend being used supports server-side rendering this field + will be populated on an SSR request showing the backend-resolved + client IP address when the X-Storyden-SSR header is present. + May be IPv4 or IPv6. + + OnboardingStatus: + description: | + Derived from data state, indicates what stage in the onboarding process + the Storyden installation is in for directing first-time setup steps. + type: string + enum: + - requires_first_account + - requires_category + - requires_more_accounts + - requires_first_post + - complete + + InstanceCapability: + type: string + enum: + - plugins + - gen_ai + - semdex + - email_client + - sms_client + - oauth + - robots + + InstanceCapabilityList: + type: array + items: { $ref: "#/components/schemas/InstanceCapability" } + + Visibility: + type: string + enum: + - draft + - unlisted + - review + - published + + VisibilityMutationProps: + type: object + required: [visibility] + properties: + visibility: { $ref: "#/components/schemas/Visibility" } + + Slug: + description: A URL-safe slug for uniquely identifying resources. + type: string + + ThreadMark: + description: | + A thread's ID and optional slug separated by a dash = it's unique mark. + This allows endpoints to respond to varying forms of a thread's ID. + + For example, given a thread with the ID `cc5lnd2s1s4652adtu50` and the + slug `top-10-movies-thread`, Storyden will understand both the forms: + `cc5lnd2s1s4652adtu50-top-10-movies-thread` and `cc5lnd2s1s4652adtu50` + as the identifier for that thread. + x-go-type: string + type: string + format: xid-prefixed-kebab-case-string + example: "cc5lnd2s1s4652adtu50-top-10-movies-thread" + + CommonProperties: + type: object + required: + - id + - createdAt + - updatedAt + properties: + id: { $ref: "#/components/schemas/Identifier" } + createdAt: + type: string + format: date-time + description: The time the resource was created. + updatedAt: + type: string + format: date-time + description: The time the resource was updated. + deletedAt: + type: string + format: date-time + description: The time the resource was soft-deleted. + misc: + type: object + description: Arbitrary extra data stored with the resource. + + CreatedAt: + type: string + format: date-time + description: The time the resource was created. + + UpdatedAt: + type: string + format: date-time + description: The time the resource was updated. + + APIError: + type: object + description: RFC 7807 Problem Details spec compliant error response. + required: [trace_id] + properties: + type: + description: > + A URI reference [RFC3986] that identifies the problem type. This + specification encourages that, when dereferenced, it provide + human-readable documentation for the problem type (e.g., using HTML + [W3C.REC-html5-20141028]). When this member is not present, its + value is assumed to be "about:blank". + type: string + title: + description: A short, human-readable summary of the problem type. + type: string + detail: + description: > + A human-readable explanation specific to this occurrence of the + problem. + type: string + trace_id: + description: > + A unique identifier for the request that can be used to trace the + request through the system. + type: string + metadata: + description: Any additional metadata related to the error. + type: object + additionalProperties: true + + Metadata: + type: object + additionalProperties: true + description: Arbitrary metadata for the resource. + + PaginatedResult: + description: To be composed with paginated resource responses. + type: object + required: [page_size, results, total_pages, current_page] + properties: + page_size: + type: integer + results: + type: integer + total_pages: + type: integer + current_page: + type: integer + next_page: + type: integer + + URL: + description: A web address + type: string + format: url + + AccountName: + type: string + description: The account owners display name. + example: Barnaby Keene + + AccountHandle: + type: string + x-go-type: string + description: The unique @ handle of an account. + example: Southclaws + + AccountBio: + type: string + description: The rich-text bio for an account's public profile. + example:

hi, my name is

southclaws

+ + AccountSignature: + type: string + description: The rich-text signature shown beneath a member's posts. + example:

Sent with love from London!

+ + EmailAddress: + description: A valid email address. + type: string + example: "hello@storyden.org" + + ProfileReference: + type: object + description: A minimal reference to an account. + required: [id, joined, handle, name, roles] + properties: + id: { $ref: "#/components/schemas/Identifier" } + joined: { $ref: "#/components/schemas/MemberJoinedDate" } + suspended: { $ref: "#/components/schemas/MemberSuspendedDate" } + handle: { $ref: "#/components/schemas/AccountHandle" } + name: { $ref: "#/components/schemas/AccountName" } + signature: { $ref: "#/components/schemas/AccountSignature" } + roles: { $ref: "#/components/schemas/AccountRoleRefList" } + + ThreadTitle: + type: string + description: The title of a thread. + example: Hello world! + + PinnedRank: + type: integer + description: | + If the post is pinned, this indicates its rank among other pinned + threads. Lower numbers are pinned higher up. A value of zero indicates + that the post is not pinned. + + PostContent: + description: | + Rich HTML content. + + Storyden owns the HTML `id` attribute in this content. Client-supplied + `id` attributes are ignored and removed during sanitisation. + + For block-aware resources such as threads, replies, library nodes, and + node versions, successful create and update operations assign stable + server-owned block IDs to addressable block elements using the `sdb_` + prefix, for example `

`. + On updates, Storyden preserves existing `sdb_` block IDs where the + corresponding block can be matched across edits, so links and references + to specific blocks can remain stable as content moves. + + Read operations do not assign missing block IDs. Content created before + block IDs were assigned, or content where a particular block has no + server-owned ID yet, may be returned without block IDs until that + resource is next written. + type: string + + RelevanceScore: + description: | + For recommendations and other uses, only available when a Semdex is + configured for content indexing and contextual relativity scoring. + type: number + + BeaconProps: + x-go-type: string + description: | + A beacon is a lightweight reference to an object used for tracking + purposes. It contains only the kind and ID of the object. This is mostly + used for tracking read states of threads. But may be used for more. + + I should clarify, not the morally questionable kind of "tracking". + type: object + required: [k, id] + properties: + k: { $ref: "#/components/schemas/DatagraphItemKind" } + id: + type: string + description: The identifier for the object related to tracking. + + # + # d8888 888 d8b + # d88888 888 Y8P + # d88P888 888 + # d88P 888 .d88888 88888b.d88b. 888 88888b. + # d88P 888 d88" 888 888 "888 "88b 888 888 "88b + # d88P 888 888 888 888 888 888 888 888 888 + # d8888888888 Y88b 888 888 888 888 888 888 888 + # d88P 888 "Y88888 888 888 888 888 888 888 + # + + AdminSettingsProps: + description: Storyden installation and administration settings. + type: object + required: + - title + - description + - content + - accent_colour + - authentication_mode + - registration_mode + - api_address + - web_address + properties: + title: + type: string + description: + type: string + content: + $ref: "#/components/schemas/PostContent" + accent_colour: + type: string + authentication_mode: + $ref: "#/components/schemas/AuthMode" + registration_mode: + $ref: "#/components/schemas/RegistrationMode" + web_address: + type: string + format: uri + description: | + The public web frontend address for this Storyden instance. + api_address: + type: string + format: uri + description: | + The public API address for this Storyden instance. + capabilities: + $ref: "#/components/schemas/InstanceCapabilityList" + services: + $ref: "#/components/schemas/AdminSettingsServiceProps" + metadata: + $ref: "#/components/schemas/Metadata" + motd: + $ref: "#/components/schemas/MessageOfTheDay" + headers: + $ref: "#/components/schemas/NetworkHeadersSample" + + AdminSettingsMutableProps: + type: object + properties: + title: + type: string + description: + type: string + content: + $ref: "#/components/schemas/PostContent" + accent_colour: + type: string + authentication_mode: + $ref: "#/components/schemas/AuthMode" + registration_mode: + $ref: "#/components/schemas/RegistrationMode" + services: + $ref: "#/components/schemas/AdminSettingsServiceProps" + metadata: + description: | + The settings metadata may be used by frontends to store arbitrary + vendor-specific configuration data specific to the frontend itself. + $ref: "#/components/schemas/Metadata" + motd: + $ref: "#/components/schemas/MessageOfTheDayMutableProps" + + MessageOfTheDay: + type: object + required: [content] + properties: + content: + $ref: "#/components/schemas/PostContent" + start_at: + type: string + format: date-time + end_at: + type: string + format: date-time + metadata: + $ref: "#/components/schemas/Metadata" + + MessageOfTheDayMutableProps: + type: object + properties: + content: + $ref: "#/components/schemas/PostContent" + start_at: + type: string + format: date-time + end_at: + type: string + format: date-time + metadata: + $ref: "#/components/schemas/Metadata" + + AdminSettingsServiceProps: + type: object + properties: + client_ip: + $ref: "#/components/schemas/ClientIPServiceSettings" + rate_limiting: + $ref: "#/components/schemas/RateLimitServiceSettings" + moderation: + $ref: "#/components/schemas/ModerationServiceSettings" + robots: + $ref: "#/components/schemas/RobotServiceSettings" + + RobotServiceSettings: + type: object + properties: + default_model: + $ref: "#/components/schemas/RobotModelRef" + + ClientIPServiceSettings: + type: object + properties: + client_ip_mode: + type: string + enum: [remote_addr, single_header, xff_trusted_proxies] + description: | + Strategy used to derive the client IP address stored in request info context. + + - `remote_addr`: use only the request socket remote address. + - `single_header`: trust a single configured header. + - `xff_trusted_proxies`: trust `X-Forwarded-For` only when the + request comes from a trusted proxy, selecting the first non-proxy + IP from the right of the list in `X-Forwarded-For`. + + client_ip_header: + type: string + description: | + Header name used when `client_ip_mode` is `single_header`. + Common values include `CF-Connecting-IP`, `Fly-Client-IP`, or `X-Real-IP`. + + trusted_proxy_cidrs: + type: array + description: | + Trusted proxy CIDR ranges. `X-Forwarded-For` is only used if + `RemoteAddr` is within these ranges; the first non-proxy IP from the + right is selected as the client. + items: + type: string + + RateLimitServiceSettings: + type: object + properties: + rate_limit: + type: integer + description: | + Maximum number of "units" allowed within the sliding window defined by `rate_limit_period`. + + Most incoming requests consume 1 unit for authenticated users, and `rate_limit_guest_cost` + units for unauthenticated (guest) visitors. + + Certain endpoints are more expensive by default, such as those that trigger password resets, sending emails, etc. + + rate_limit_period: + type: integer + description: | + Sliding window duration used to enforce `rate_limit`, in seconds. + + On each request, Storyden considers the total number of units consumed in the last + `rate_limit_period` (not aligned to the hour/minute boundary). + + For example: 3600 = 1 hour, 1800 = 30 minutes. + + rate_limit_bucket: + type: integer + description: | + Bucket size (granularity) used to approximate the sliding window, in seconds. + + Requests are counted into discrete time buckets of this size (e.g. 1 minute = 60 seconds). When + checking the limit, Storyden sums all buckets whose timestamps fall within the last + `rate_limit_period` and discards older buckets. + + For example: 60 = 1 minute, 300 = 5 minutes. + + rate_limit_guest_cost: + type: integer + description: | + The cost multiplier applied to rate limit operations for unauthenticated (guest) visitors. + + For example, if set to 5, each operation will consume 5 units from a guest's rate limit + quota instead of 1. This allows you to apply more restrictive rate limiting to guests while + authenticated users consume the normal cost. + + cost_overrides: + type: object + description: | + A map of operation names to their rate limiting cost overrides. + additionalProperties: + type: integer + description: The cost value for the corresponding action. + + ModerationServiceSettings: + type: object + properties: + thread_body_length_max: + type: integer + description: | + The maximum allowed size (in bytes) for thread bodies. Posts that + exceed this size will be rejected by the moderation service. + reply_body_length_max: + type: integer + description: | + The maximum allowed size (in bytes) for reply bodies. Posts that + exceed this size will be rejected by the moderation service. + signature_length_max: + type: integer + description: | + The maximum allowed size (in visual characters) for account + signatures. Signatures exceeding this length are rejected. + word_block_list: + type: array + description: | + A list of blocklisted words that reject posts without reporting. + items: + type: string + word_report_list: + type: array + description: | + A list of blocklisted words that will automatically report and hide + any posts that contain them. + items: + type: string + + NetworkHeadersSample: + type: object + description: | + A sample of network headers received by the backend from the client or + proxy. This is used for configuring the client IP mode. + properties: + raw_client_address: + type: string + description: | + The raw client address as seen by the backend, without any + processing or trust-based overrides. This is typically the remote + address of a reverse proxy. Use this to configure trusted proxies. + headers: + type: object + description: | + A sample of the headers directly received by the backend from the + client or proxy if any. + additionalProperties: + type: string + headers_ssr: + type: object + description: | + If the frontend being used supports server-side rendering this field + will be populated on an SSR request showing the headers that the + frontend program passed to the backend via Storyden specific header. + additionalProperties: + type: string + + AuditEvent: + type: object + allOf: + - $ref: "#/components/schemas/AuditEventProps" + - $ref: "#/components/schemas/AuditEventTypeProps" + + AuditEventType: + type: string + enum: + - thread_deleted + - thread_reply_deleted + - account_suspended + - account_unsuspended + - account_content_purged + - moderation_note_created + - moderation_note_deleted + - account_warned + - account_warning_updated + - account_warning_deleted + - account_password_reset_token_issued + - account_password_reset_email_sent + + AuditEventProps: + required: [id, type, timestamp] + properties: + id: { $ref: "#/components/schemas/Identifier" } + type: { $ref: "#/components/schemas/AuditEventType" } + timestamp: + type: string + format: date-time + enacted_by: + $ref: "#/components/schemas/ProfileReference" + + AuditEventTypeProps: + type: object + discriminator: + propertyName: type + mapping: + thread_deleted: "#/components/schemas/AuditEventThreadDeleted" + thread_reply_deleted: "#/components/schemas/AuditEventThreadReplyDeleted" + account_suspended: "#/components/schemas/AuditEventAccountSuspended" + account_unsuspended: "#/components/schemas/AuditEventAccountUnsuspended" + account_content_purged: "#/components/schemas/AuditEventAccountContentPurged" + moderation_note_created: "#/components/schemas/AuditEventModerationNoteCreated" + moderation_note_deleted: "#/components/schemas/AuditEventModerationNoteDeleted" + account_warned: "#/components/schemas/AuditEventAccountWarned" + account_warning_updated: "#/components/schemas/AuditEventAccountWarningUpdated" + account_warning_deleted: "#/components/schemas/AuditEventAccountWarningDeleted" + account_password_reset_token_issued: "#/components/schemas/AuditEventAccountPasswordResetTokenIssued" + account_password_reset_email_sent: "#/components/schemas/AuditEventAccountPasswordResetEmailSent" + oneOf: + - $ref: "#/components/schemas/AuditEventThreadDeleted" + - $ref: "#/components/schemas/AuditEventThreadReplyDeleted" + - $ref: "#/components/schemas/AuditEventAccountSuspended" + - $ref: "#/components/schemas/AuditEventAccountUnsuspended" + - $ref: "#/components/schemas/AuditEventAccountContentPurged" + - $ref: "#/components/schemas/AuditEventModerationNoteCreated" + - $ref: "#/components/schemas/AuditEventModerationNoteDeleted" + - $ref: "#/components/schemas/AuditEventAccountWarned" + - $ref: "#/components/schemas/AuditEventAccountWarningUpdated" + - $ref: "#/components/schemas/AuditEventAccountWarningDeleted" + - $ref: "#/components/schemas/AuditEventAccountPasswordResetTokenIssued" + - $ref: "#/components/schemas/AuditEventAccountPasswordResetEmailSent" + + AuditEventThreadDeleted: + type: object + required: [type, thread_id] + properties: + type: { $ref: "#/components/schemas/AuditEventType" } + thread_id: { $ref: "#/components/schemas/Identifier" } + + AuditEventThreadReplyDeleted: + type: object + required: [type, reply_id] + properties: + type: { $ref: "#/components/schemas/AuditEventType" } + reply_id: { $ref: "#/components/schemas/Identifier" } + + AuditEventAccountSuspended: + type: object + required: [type, account_id] + properties: + type: { $ref: "#/components/schemas/AuditEventType" } + account_id: { $ref: "#/components/schemas/Identifier" } + + AuditEventAccountUnsuspended: + type: object + required: [type, account_id] + properties: + type: { $ref: "#/components/schemas/AuditEventType" } + account_id: { $ref: "#/components/schemas/Identifier" } + + AuditEventAccountContentPurged: + type: object + required: [type, account_id] + properties: + type: { $ref: "#/components/schemas/AuditEventType" } + account_id: { $ref: "#/components/schemas/Identifier" } + included: + $ref: "#/components/schemas/ModerationActionPurgeAccountContentTypeList" + + AuditEventModerationNoteCreated: + type: object + required: [type, account_id, note_id] + properties: + type: { $ref: "#/components/schemas/AuditEventType" } + account_id: { $ref: "#/components/schemas/Identifier" } + note_id: { $ref: "#/components/schemas/Identifier" } + + AuditEventModerationNoteDeleted: + type: object + required: [type, account_id, note_id] + properties: + type: { $ref: "#/components/schemas/AuditEventType" } + account_id: { $ref: "#/components/schemas/Identifier" } + note_id: { $ref: "#/components/schemas/Identifier" } + + AuditEventAccountWarned: + type: object + required: [type, account_id, warning_id] + properties: + type: { $ref: "#/components/schemas/AuditEventType" } + account_id: { $ref: "#/components/schemas/Identifier" } + warning_id: { $ref: "#/components/schemas/Identifier" } + + AuditEventAccountWarningUpdated: + type: object + required: [type, account_id, warning_id, previous_reason, reason] + properties: + type: { $ref: "#/components/schemas/AuditEventType" } + account_id: { $ref: "#/components/schemas/Identifier" } + warning_id: { $ref: "#/components/schemas/Identifier" } + previous_reason: { $ref: "#/components/schemas/WarningReason" } + reason: { $ref: "#/components/schemas/WarningReason" } + + AuditEventAccountWarningDeleted: + type: object + required: [type, account_id, warning_id] + properties: + type: { $ref: "#/components/schemas/AuditEventType" } + account_id: { $ref: "#/components/schemas/Identifier" } + warning_id: { $ref: "#/components/schemas/Identifier" } + + AuditEventAccountPasswordResetTokenIssued: + type: object + required: [type, account_id] + properties: + type: { $ref: "#/components/schemas/AuditEventType" } + account_id: + description: Target account ID that received password reset access. + $ref: "#/components/schemas/Identifier" + + AuditEventAccountPasswordResetEmailSent: + type: object + required: [type, account_id, email_address_id] + properties: + type: { $ref: "#/components/schemas/AuditEventType" } + account_id: + description: Target account ID that received the password reset email. + $ref: "#/components/schemas/Identifier" + email_address_id: + description: Email address ID that the reset email was sent to. + $ref: "#/components/schemas/Identifier" + + AuditEventList: + type: array + items: + $ref: "#/components/schemas/AuditEvent" + + AuditEventListResult: + type: object + required: [events] + allOf: + - { $ref: "#/components/schemas/PaginatedResult" } + - type: object + properties: + events: { $ref: "#/components/schemas/AuditEventList" } + + EmailQueueItem: + type: object + required: + [ + id, + queued_at, + updated_at, + recipient_address, + recipient_name, + subject, + status, + attempts, + ] + properties: + id: { $ref: "#/components/schemas/Identifier" } + queued_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + processed_at: + type: string + format: date-time + status: { $ref: "#/components/schemas/EmailQueueStatus" } + recipient_address: + type: string + format: email + recipient_name: + type: string + subject: + type: string + attempts: + type: array + items: + $ref: "#/components/schemas/EmailQueueAttempt" + + EmailQueueStatus: + type: string + enum: [pending, processing, sent, failed] + + EmailQueueAttemptStatus: + type: string + enum: [sent, failed] + + EmailQueueAttempt: + type: object + required: [timestamp, status] + properties: + timestamp: + type: string + format: date-time + status: + $ref: "#/components/schemas/EmailQueueAttemptStatus" + error: + type: string + + EmailQueueList: + type: array + items: + $ref: "#/components/schemas/EmailQueueItem" + + EmailQueueListResult: + type: object + required: [emails] + allOf: + - { $ref: "#/components/schemas/PaginatedResult" } + - type: object + properties: + emails: { $ref: "#/components/schemas/EmailQueueList" } + + ModerationActionInitialProps: + type: object + discriminator: + propertyName: action + mapping: + purge_account: "#/components/schemas/ModerationActionCreatePurgeAccount" + oneOf: + - $ref: "#/components/schemas/ModerationActionCreatePurgeAccount" + + ModerationActionCreatePurgeAccount: + description: | + A moderation action to purge content created by a specific account. + type: object + required: [account_id, action, include] + properties: + action: + type: string + enum: [purge_account] + account_id: { $ref: "#/components/schemas/Identifier" } + include: + $ref: "#/components/schemas/ModerationActionPurgeAccountContentTypeList" + + ModerationActionPurgeAccountContentTypeList: + type: array + items: + $ref: "#/components/schemas/ModerationActionPurgeAccountContentType" + + ModerationActionPurgeAccountContentType: + type: string + enum: + - threads + - replies + - reacts + - likes + - nodes + - collections + - profile_bio + + # + # 8888888b. 888 d8b + # 888 Y88b 888 Y8P + # 888 888 888 + # 888 d88P 888 888 888 .d88b. 888 88888b. + # 8888888P" 888 888 888 d88P"88b 888 888 "88b + # 888 888 888 888 888 888 888 888 888 + # 888 888 Y88b 888 Y88b 888 888 888 888 + # 888 888 "Y88888 "Y88888 888 888 888 + # 888 + # Y8b d88P + # "Y88P" + # + + Plugin: + type: object + allOf: + - $ref: "#/components/schemas/PluginProps" + - $ref: "#/components/schemas/PluginInstanceProps" + + PluginProps: + type: object + required: [id, added_at, name, status, manifest] + properties: + id: { $ref: "#/components/schemas/Identifier" } + added_at: + type: string + format: date-time + description: The time the plugin was installed. + name: { $ref: "#/components/schemas/PluginName" } + description: { $ref: "#/components/schemas/PluginDescription" } + version: { $ref: "#/components/schemas/PluginVersion" } + status: { $ref: "#/components/schemas/PluginStatus" } + manifest: { $ref: "#/components/schemas/PluginManifest" } + + PluginInstanceProps: + type: object + required: [connection] + properties: + connection: + $ref: "#/components/schemas/PluginModeUnion" + + PluginModeUnion: + type: object + discriminator: + propertyName: mode + mapping: + supervised: "#/components/schemas/PluginSupervisedProps" + external: "#/components/schemas/PluginExternalProps" + oneOf: + - $ref: "#/components/schemas/PluginSupervisedProps" + - $ref: "#/components/schemas/PluginExternalProps" + + PluginSupervisedProps: + type: object + required: [mode] + properties: + mode: { $ref: "#/components/schemas/PluginMode" } + + PluginExternalProps: + type: object + required: [mode, token] + properties: + mode: { $ref: "#/components/schemas/PluginMode" } + token: + type: string + description: | + Static bearer token for external plugins. This field is present + for external plugins and omitted for supervised plugins. + + PluginCycleToken: + type: object + required: [token] + properties: + token: + type: string + + PluginInitialProps: + type: object + discriminator: + propertyName: mode + mapping: + supervised: "#/components/schemas/PluginInitialSupervised" + external: "#/components/schemas/PluginInitialExternal" + oneOf: + - $ref: "#/components/schemas/PluginInitialSupervised" + - $ref: "#/components/schemas/PluginInitialExternal" + + PluginInitialSupervised: + description: | + A supervised plugin is a plugin that is managed and hosted by the + Storyden runtime itself. The URL points to the plugin's source file + which the runtime will read, verify and then host itself for the plugin + process to connect to. + + This is identical to uploading a plugin directly via binary file, the + only difference is it's fetched from a URL rather than uploaded. + type: object + required: [mode, url] + properties: + mode: { $ref: "#/components/schemas/PluginMode" } + url: + type: string + format: uri + description: A remote URL pointing to a Storyden plugin file. + + PluginInitialExternal: + description: | + An external plugin is a plugin that is hosted and managed outside of the + Storyden runtime. The payload accepts a manifest as defined in the + Plugin RPC spec (`plugin.yaml`) which will be validated upon submission. + type: object + required: [mode, manifest] + properties: + mode: { $ref: "#/components/schemas/PluginMode" } + manifest: + $ref: "#/components/schemas/PluginManifest" + + PluginActiveStateMutableProps: + type: object + required: [active] + properties: + active: { $ref: "#/components/schemas/PluginActiveState" } + + PluginManifest: + type: object + description: | + The plugin's manifest information read from the plugin itself. This + describes the version information, the author, its requested access and + other information that the plugin has provided. + + This data structure is direct from the plugin itself and is not modified + by the runtime. As such, it's not part of the Storyden HTTP API contract + so it's left as a free-form object here so that HTTP clients don't rely + on its structure here. That being said, the manifest format is defined + in a contract as part of the Plugin RPC spec which is a separate spec. + + As a HTTP API consumer, you probably won't need to interact with this in + such a way that requires validation, however if you do need the schema, + it is located in `plugin.yaml` in the same folder as this specification. + additionalProperties: true + + PluginConfigurationSchema: + type: object + properties: + fields: + type: array + items: + $ref: "#/components/schemas/PluginConfigurationFieldUnion" + + PluginConfigurationFieldUnion: + allOf: + - $ref: "#/components/schemas/PluginConfigurationFieldBase" + - $ref: "#/components/schemas/PluginConfigurationField" + + PluginConfigurationField: + discriminator: + propertyName: type + mapping: + string: "#/components/schemas/PluginConfigurationFieldString" + number: "#/components/schemas/PluginConfigurationFieldNumber" + boolean: "#/components/schemas/PluginConfigurationFieldBoolean" + oneOf: + - $ref: "#/components/schemas/PluginConfigurationFieldString" + - $ref: "#/components/schemas/PluginConfigurationFieldNumber" + - $ref: "#/components/schemas/PluginConfigurationFieldBoolean" + + PluginConfigurationFieldBase: + type: object + required: [name] + properties: + id: + type: string + description: | + A unique identifier for this configuration field, used for + referencing the field in the plugin configuration object. + label: + type: string + description: A human-readable label for the configuration field. + description: + type: string + description: A description of the configuration field. + + PluginConfigurationFieldString: + type: object + required: [type, name] + properties: + type: { type: string, enum: [string] } + default: { type: string } + + PluginConfigurationFieldNumber: + type: object + required: [type, name] + properties: + type: { type: string, enum: [number] } + default: { type: number } + + PluginConfigurationFieldBoolean: + type: object + required: [type, name] + properties: + type: { type: string, enum: [boolean] } + default: { type: boolean } + + PluginConfiguration: + type: object + description: | + The plugin's configuration as defined by the plugin itself. This is a + free-form object that the plugin can define and use for its own purposes. + The Storyden runtime does not interpret or modify this data in any way, + but simply stores it and provides it to the plugin when requested. + + This is not part of the plugin manifest and is not required to be + provided when adding a plugin, but can be set and updated separately + after the plugin is added. This allows for dynamic configuration of + plugins without needing to modify their manifest or re-add them. + additionalProperties: true + + PluginName: + description: | + The name of the plugin, as defined in the plugin's manifest. This is not + a unique identifier and is only used for display purposes. + type: string + + PluginDescription: + description: | + The description of the plugin, as defined in the plugin's manifest. + type: string + + PluginVersion: + description: | + The version of the plugin, as defined in the plugin's manifest. This is + not used for any versioning or compatibility purposes by the runtime and + is only used for display purposes currently. + type: string + + PluginMode: + type: string + description: | + The mode of the plugin, whether it is supervised or external. A + supervised plugin is managed and hosted by the Storyden runtime itself, + while an external plugin is hosted and managed outside of the Storyden + runtime. The mode determines how the plugin interacts with RPCs. + enum: [supervised, external] + + PluginActiveState: + type: string + description: | + The state of the plugin, whether it is active, inactive, starting or in + an error state. When plugins are added, they are initially inactive and + must be activated before being used. When activating, the plugin + transitions through the "starting" state while the process starts and + connects. An active plugin can be deactivated or if it crashes or + encounters an error, it will be set to the "restarting" state and a + restart will be attempted. If this succeeds, it will transition back to + "active", but if it fails again it will transition to the "error" state. + enum: + - inactive + - starting + - connecting + - active + - error + - restarting + + PluginStatus: + type: object + discriminator: + propertyName: active_state + mapping: + inactive: "#/components/schemas/PluginStatusInactive" + starting: "#/components/schemas/PluginStatusStarting" + connecting: "#/components/schemas/PluginStatusConnecting" + active: "#/components/schemas/PluginStatusActive" + error: "#/components/schemas/PluginStatusError" + restarting: "#/components/schemas/PluginStatusRestarting" + oneOf: + - $ref: "#/components/schemas/PluginStatusInactive" + - $ref: "#/components/schemas/PluginStatusStarting" + - $ref: "#/components/schemas/PluginStatusConnecting" + - $ref: "#/components/schemas/PluginStatusActive" + - $ref: "#/components/schemas/PluginStatusError" + - $ref: "#/components/schemas/PluginStatusRestarting" + + PluginStatusInactive: + type: object + required: [active_state, deactivated_at] + properties: + active_state: + type: string + enum: [inactive] + deactivated_at: + type: string + format: date-time + description: The time the plugin was deactivated. + + PluginStatusStarting: + type: object + required: [active_state, starting_at] + properties: + active_state: + type: string + enum: [starting] + starting_at: + type: string + format: date-time + description: The time the plugin process started launching. + + PluginStatusConnecting: + type: object + required: [active_state, connecting_at] + properties: + active_state: + type: string + enum: [connecting] + connecting_at: + type: string + format: date-time + description: The time the plugin process started waiting for websocket connection. + + PluginStatusActive: + type: object + required: [active_state, activated_at] + properties: + active_state: + type: string + enum: [active] + activated_at: + type: string + format: date-time + description: The time the plugin was activated. + + PluginStatusError: + type: object + description: | + If the plugin status is "error" this object will contain the message and + any additional technical information provided by the plugin or runtime. + required: [active_state, message, details] + properties: + active_state: + type: string + enum: [error] + message: + type: string + details: + type: object + additionalProperties: true + + PluginStatusRestarting: + type: object + description: | + If the plugin status is "restarting", this object will contain the last + error that caused the restart. + required: [active_state, message, details] + properties: + active_state: + type: string + enum: [restarting] + message: + type: string + details: + type: object + additionalProperties: true + + PluginList: + type: array + items: + $ref: "#/components/schemas/Plugin" + + PluginListResult: + type: object + required: [plugins] + properties: + plugins: { $ref: "#/components/schemas/PluginList" } + + # + # 8888888b. 888 + # 888 Y88b 888 + # 888 888 888 + # 888 d88P .d88b. 888 .d88b. + # 8888888P" d88""88b 888 d8P Y8b + # 888 T88b 888 888 888 88888888 + # 888 T88b Y88..88P 888 Y8b. + # 888 T88b "Y88P" 888 "Y8888 + # + + Role: + type: object + allOf: + - $ref: "#/components/schemas/CommonProperties" + - $ref: "#/components/schemas/RoleProps" + + RoleListResult: + type: object + required: [roles] + properties: + roles: { $ref: "#/components/schemas/RoleList" } + + RoleList: + type: array + items: + $ref: "#/components/schemas/Role" + + AccountRole: + type: object + allOf: + - $ref: "#/components/schemas/Role" + - $ref: "#/components/schemas/AccountRoleProps" + + AccountRoleList: + type: array + items: + $ref: "#/components/schemas/AccountRole" + + AccountRoleProps: + type: object + required: [badge, default] + properties: + badge: + description: | + One role may be designated as a badge for the account. If true, it + should be displayed prominently on the profile or in other contexts. + type: boolean + default: + description: | + There are two built-in roles: everyone and admin, this boolean flag + is set if this role is one of the default built-in roles. + type: boolean + + RoleProps: + type: object + required: [name, colour, permissions] + properties: + name: + type: string + colour: + type: string + permissions: + $ref: "#/components/schemas/PermissionList" + meta: + $ref: "#/components/schemas/Metadata" + + AccountRoleRef: + description: | + A lightweight reference to a held role, without the list of permissions + within the role itself. This is for profile references where permissions + are not necessary, it's primarily useful for aesthetic display features. + allOf: + - $ref: "#/components/schemas/AccountRoleProps" + - $ref: "#/components/schemas/AccountRoleRefProps" + + AccountRoleRefProps: + type: object + required: [id, name, colour] + properties: + id: { $ref: "#/components/schemas/Identifier" } + name: { type: string } + colour: { type: string } + meta: { $ref: "#/components/schemas/Metadata" } + + AccountRoleRefList: + type: array + items: { $ref: "#/components/schemas/AccountRoleRef" } + + RoleInitialProps: + type: object + required: [name, colour, permissions] + properties: + name: + type: string + colour: + type: string + permissions: + $ref: "#/components/schemas/PermissionList" + meta: + $ref: "#/components/schemas/Metadata" + + RoleMutableProps: + type: object + properties: + name: + type: string + colour: + type: string + permissions: + $ref: "#/components/schemas/PermissionList" + meta: + $ref: "#/components/schemas/Metadata" + + RoleOrderMutableProps: + type: object + required: [role_ids] + properties: + role_ids: + description: | + Ordered list of custom role identifiers in the desired sort order. + Must include all custom roles exactly once without default roles. + type: array + items: + $ref: "#/components/schemas/Identifier" + + Permission: + type: string + enum: + # Posts (Threads, replies and posts) + - "CREATE_POST" + - "READ_PUBLISHED_THREADS" + - "CREATE_REACTION" + - "MANAGE_POSTS" + - "MANAGE_CATEGORIES" + - "CREATE_INVITATION" + # Library (Page tree nodes) + - "READ_PUBLISHED_LIBRARY" + - "MANAGE_LIBRARY" + - "SUBMIT_LIBRARY_NODE" + - "SUBMIT_LIBRARY_NODE_CHANGES" + # Assets + - "UPLOAD_ASSET" + # Events + - "MANAGE_EVENTS" + # Profiles (listing and viewing member profiles) + - "LIST_PROFILES" + - "READ_PROFILE" + # Collections + - "CREATE_COLLECTION" + - "LIST_COLLECTIONS" + - "READ_COLLECTION" + - "MANAGE_COLLECTIONS" + - "COLLECTION_SUBMIT" + # External automation and application authorisation + - "USE_PERSONAL_ACCESS_KEYS" + - "USE_OAUTH_CLIENTS" + # Administrative (Settings, bans, etc) + - "MANAGE_SETTINGS" + - "MANAGE_ACCOUNTS" + - "MANAGE_WARNINGS" + - "MANAGE_SUSPENSIONS" + - "MANAGE_ROLES" + - "MANAGE_REPORTS" + - "VIEW_ACCOUNTS" + - "VIEW_MODERATION_NOTES" + - "MANAGE_MODERATION_NOTES" + # Robots + - "USE_ROBOTS" + - "MANAGE_ROBOTS" + # Administrator implicitly has all permissions. + - "ADMINISTRATOR" + + PermissionList: + type: array + items: + $ref: "#/components/schemas/Permission" + + # + # d8888 888 888 + # d88888 888 888 + # d88P888 888 888 + # d88P 888 888 888 888888 88888b. + # d88P 888 888 888 888 888 "88b + # d88P 888 888 888 888 888 888 + # d8888888888 Y88b 888 Y88b. 888 888 + # d88P 888 "Y88888 "Y888 888 888 + # + + AuthProviderList: + type: array + items: + $ref: "#/components/schemas/AuthProvider" + + AuthMode: + type: string + enum: + - handle + - email + - phone + + RegistrationMode: + description: | + The mode of registration for the instance, which determines how new + accounts can be created. This is a global setting that affects the whole + instance and is used to control whether new users can register on their + own, need an invitation, or if registration is completely disabled. + + When set to "public", anyone can register for a new account without any + special prerequisites. When set to "invitation", new accounts can only + be created if they have a valid invitation code, which can be generated + by existing users with the required permission. When set to "disabled", + new account registration is completely disabled and accounts can only be + provisioned using the API. + type: string + enum: + - public + - invitation + - disabled + + AuthProviderIdentifier: + type: string + + AuthProviderIdentifierList: + type: array + items: + $ref: "#/components/schemas/AuthProviderIdentifier" + + AuthProvider: + type: object + required: [provider, name] + properties: + provider: { $ref: "#/components/schemas/AuthProviderIdentifier" } + name: + description: The human-readable name of the provider. + type: string + link: + description: The hyperlink to render for the user. + type: string + + AuthPair: + type: object + required: [identifier, token] + properties: + identifier: + example: "odin" + type: string + token: + example: "password" + type: string + + AuthEmailPasswordInitialProps: + type: object + required: [email, password] + properties: + email: { $ref: "#/components/schemas/EmailAddress" } + password: + type: string + handle: + $ref: "#/components/schemas/AccountHandle" + + AuthEmailPasswordReset: + type: object + required: [email, token_url] + properties: + email: { $ref: "#/components/schemas/EmailAddress" } + token_url: + type: object + required: [url, query] + properties: + url: + description: | + The URL to include in the password reset email. This URL's host + must match the configured Storyden instance's web address value. + type: string + query: + description: | + The query parameters to store the reset token in. This is a + frontend client specific value. + type: string + + AuthEmailInitialProps: + type: object + required: [email] + properties: + email: { $ref: "#/components/schemas/EmailAddress" } + handle: + $ref: "#/components/schemas/AccountHandle" + + AuthEmailVerifyProps: + type: object + required: [email, code] + properties: + email: + description: | + The email address to be verified, only necessary for when submitting + a verification without a session cookie present. + $ref: "#/components/schemas/EmailAddress" + code: + example: "728562" + type: string + + AuthPasswordInitialProps: + type: object + required: [password] + properties: + password: + example: "password123" + type: string + + AuthPasswordMutableProps: + type: object + required: [old, new] + properties: + old: + example: "password123" + type: string + new: + example: "password456" + type: string + + AuthPasswordResetProps: + type: object + required: [token, new] + properties: + token: + type: string + new: + example: "password456" + type: string + + AuthSuccess: + type: object + required: [id] + properties: + id: + type: string + + OAuthCallback: + type: object + required: + - state + - code + properties: + state: + type: string + code: + type: string + + OAuthJWK: + description: JSON Web Key used to validate OAuth tokens. + type: object + required: [kty, use, alg, kid, n, e] + properties: + kty: { type: string } + use: { type: string } + alg: { type: string } + kid: { type: string } + n: { type: string } + e: { type: string } + + OAuthJWKS: + description: JSON Web Key Set. + type: object + required: [keys] + properties: + keys: + type: array + items: { $ref: "#/components/schemas/OAuthJWK" } + + OAuthError: + description: OAuth-compatible error object. + type: object + required: [error] + properties: + error: + type: string + error_description: + type: string + + OAuthDeviceAuthorisationProps: + type: object + required: [client_id] + properties: + client_id: + type: string + scope: + type: string + + OAuthDeviceAuthorisation: + type: object + properties: + device_code: + type: string + user_code: + type: string + verification_uri: + type: string + format: uri + verification_uri_complete: + type: string + format: uri + expires_in: + type: integer + interval: + type: integer + error: + type: string + + OAuthDeviceDecision: + type: string + enum: [approve, deny] + + OAuthAuthoriseDecision: + type: string + enum: [approve, deny] + + OAuthDeviceConsent: + type: object + required: + - user_code + - client_id + - client_name + - expires_at + - requested_scopes + - granted_scopes + - inherits_user_permissions + properties: + user_code: + type: string + client_id: + type: string + client_name: + type: string + expires_at: + type: string + format: date-time + requested_scopes: + type: array + items: { type: string } + granted_scopes: + type: array + items: { type: string } + inherits_user_permissions: + type: boolean + + OAuthDeviceConsentResult: + type: object + required: [status] + properties: + status: + type: string + enum: [approved, denied] + + OAuthDeviceConsentSubmitProps: + type: object + required: [user_code, decision] + properties: + user_code: + type: string + decision: + $ref: "#/components/schemas/OAuthDeviceDecision" + + OAuthAuthoriseConsent: + type: object + required: + - request_id + - client_id + - client_name + - redirect_uri + - expires_at + - requested_scopes + - granted_scopes + - inherits_user_permissions + properties: + request_id: + type: string + client_id: + type: string + client_name: + type: string + redirect_uri: + type: string + format: uri + expires_at: + type: string + format: date-time + requested_scopes: + type: array + items: { type: string } + granted_scopes: + type: array + items: { type: string } + inherits_user_permissions: + type: boolean + + OAuthAuthoriseConsentResult: + type: object + required: [status, location] + properties: + status: + type: string + enum: [approved, denied] + location: + type: string + format: uri + + OAuthAuthoriseConsentSubmitProps: + type: object + required: [request_id, decision] + properties: + request_id: + type: string + decision: + $ref: "#/components/schemas/OAuthAuthoriseDecision" + + OAuthTokenProps: + type: object + required: [grant_type, client_id] + properties: + grant_type: + type: string + client_id: + type: string + client_secret: + type: string + scope: + type: string + device_code: + type: string + code: + type: string + redirect_uri: + type: string + format: uri + code_verifier: + type: string + refresh_token: + type: string + + OAuthToken: + type: object + properties: + access_token: + type: string + token_type: + type: string + expires_in: + type: integer + scope: + type: string + id_token: + type: string + refresh_token: + type: string + error: + type: string + + OAuthUserInfo: + type: object + properties: + sub: + type: string + email: + type: string + email_verified: + type: boolean + name: + type: string + preferred_username: + type: string + error: + type: string + + OAuthClientType: + type: string + enum: [public, confidential] + + OAuthClientScopePolicy: + type: string + enum: [explicit, inherit] + + OAuthClient: + type: object + required: + - id + - createdAt + - updatedAt + - client_id + - name + - type + - scope_policy + - redirect_uris + - allowed_scopes + - allowed_grants + properties: + id: { $ref: "#/components/schemas/Identifier" } + createdAt: { $ref: "#/components/schemas/CreatedAt" } + updatedAt: { $ref: "#/components/schemas/UpdatedAt" } + account_id: { $ref: "#/components/schemas/Identifier" } + client_id: { type: string } + name: { type: string } + type: { $ref: "#/components/schemas/OAuthClientType" } + scope_policy: { $ref: "#/components/schemas/OAuthClientScopePolicy" } + redirect_uris: + type: array + items: { type: string, format: uri } + allowed_scopes: + type: array + items: { type: string } + allowed_grants: + type: array + items: { type: string } + + OAuthClientCreateProps: + type: object + required: + - account_id + - client_id + - name + - type + - redirect_uris + - allowed_scopes + - allowed_grants + properties: + account_id: { $ref: "#/components/schemas/Identifier" } + client_id: { type: string } + client_secret_hash: { type: string } + name: { type: string } + type: { $ref: "#/components/schemas/OAuthClientType" } + scope_policy: { $ref: "#/components/schemas/OAuthClientScopePolicy" } + redirect_uris: + type: array + items: { type: string, format: uri } + allowed_scopes: + type: array + items: { type: string } + allowed_grants: + type: array + items: { type: string } + + OAuthClientUpdateProps: + type: object + properties: + client_secret_hash: { type: string } + name: { type: string } + scope_policy: { $ref: "#/components/schemas/OAuthClientScopePolicy" } + redirect_uris: + type: array + items: { type: string, format: uri } + allowed_scopes: + type: array + items: { type: string } + allowed_grants: + type: array + items: { type: string } + + OAuthClientSelfCreateProps: + type: object + required: [name, type, allowed_grants, allowed_scopes, pkce_required] + properties: + name: { type: string } + type: { $ref: "#/components/schemas/OAuthClientType" } + redirect_uris: + type: array + items: { type: string, format: uri } + allowed_scopes: + type: array + items: { type: string } + allowed_grants: + type: array + items: { type: string } + pkce_required: { type: boolean } + + OAuthClientSelfUpdateProps: + type: object + properties: + name: { type: string } + redirect_uris: + type: array + items: { type: string, format: uri } + allowed_scopes: + type: array + items: { type: string } + + OAuthClientIssued: + type: object + required: [client] + properties: + client: { $ref: "#/components/schemas/OAuthClient" } + client_secret: + type: string + + OAuthClientRegisterProps: + description: | + RFC 7591 client metadata supplied by a dynamically registering client. + All fields are optional; Storyden applies safe defaults and rejects + metadata that requests unsupported grants, response types, redirect URIs + or scopes. + type: object + properties: + client_name: { type: string } + redirect_uris: + type: array + items: { type: string, format: uri } + grant_types: + type: array + items: { type: string } + response_types: + type: array + items: { type: string } + scope: { type: string } + token_endpoint_auth_method: { type: string } + application_type: { type: string } + logo_uri: { type: string, format: uri } + client_uri: { type: string, format: uri } + tos_uri: { type: string, format: uri } + policy_uri: { type: string, format: uri } + + OAuthClientRegistration: + description: | + RFC 7591 client information response containing issued credentials and + the registered client metadata. `client_secret` is only present for + confidential clients. `client_secret_expires_at` is always `0`, + indicating the secret does not expire. + type: object + required: + - client_id + - client_id_issued_at + - client_secret_expires_at + - redirect_uris + - grant_types + - response_types + - token_endpoint_auth_method + properties: + client_id: { type: string } + client_secret: { type: string } + client_id_issued_at: { type: integer, format: int64 } + client_secret_expires_at: { type: integer, format: int64 } + client_name: { type: string } + redirect_uris: + type: array + items: { type: string, format: uri } + grant_types: + type: array + items: { type: string } + response_types: + type: array + items: { type: string } + scope: { type: string } + token_endpoint_auth_method: { type: string } + application_type: { type: string } + logo_uri: { type: string, format: uri } + client_uri: { type: string, format: uri } + tos_uri: { type: string, format: uri } + policy_uri: { type: string, format: uri } + + OAuthRemoteMode: + type: string + enum: [cimd, dcr, manual] + + OAuthRemoteStatus: + type: string + enum: [pending, connected, error] + + OAuthProtectedResourceMetadata: + type: object + required: [authorization_servers] + properties: + resource: { type: string } + resource_name: { type: string } + authorization_servers: + type: array + items: { type: string, format: uri } + bearer_methods_supported: + type: array + items: { type: string } + + OAuthAuthorizationServerMetadata: + type: object + properties: + issuer: { type: string } + authorization_endpoint: { type: string, format: uri } + token_endpoint: { type: string, format: uri } + registration_endpoint: { type: string, format: uri } + response_types_supported: + type: array + items: { type: string } + grant_types_supported: + type: array + items: { type: string } + token_endpoint_auth_methods_supported: + type: array + items: { type: string } + code_challenge_methods_supported: + type: array + items: { type: string } + client_id_metadata_document_supported: + type: boolean + + OAuthRemoteDiscoverProps: + type: object + required: [resource_url] + properties: + resource_url: + type: string + format: uri + + OAuthRemoteDiscoveryResult: + type: object + required: + - resource_url + - protected_resource_metadata + - authorization_server + - authorization_server_metadata + - mode + - client_id + - redirect_uri + properties: + resource_url: { type: string, format: uri } + protected_resource_metadata: + $ref: "#/components/schemas/OAuthProtectedResourceMetadata" + authorization_server: + type: string + format: uri + authorization_server_metadata: + $ref: "#/components/schemas/OAuthAuthorizationServerMetadata" + mode: { $ref: "#/components/schemas/OAuthRemoteMode" } + client_id: + type: string + redirect_uri: + type: string + format: uri + + OAuthRemoteManualConfig: + type: object + properties: + client_id: { type: string } + client_secret: { type: string } + authorization_endpoint: { type: string, format: uri } + token_endpoint: { type: string, format: uri } + redirect_uri: { type: string, format: uri } + authorization_server: { type: string, format: uri } + scope: { type: string } + + OAuthRemoteConnectionCreateProps: + type: object + required: [resource_url] + properties: + resource_url: + type: string + format: uri + mode: { $ref: "#/components/schemas/OAuthRemoteMode" } + scope: { type: string } + manual: + $ref: "#/components/schemas/OAuthRemoteManualConfig" + + OAuthRemoteConnection: + type: object + required: + - id + - createdAt + - updatedAt + - resource_url + - mode + - status + - has_client_secret + - has_access_token + - has_refresh_token + properties: + id: { $ref: "#/components/schemas/Identifier" } + createdAt: { $ref: "#/components/schemas/CreatedAt" } + updatedAt: { $ref: "#/components/schemas/UpdatedAt" } + resource_url: { type: string, format: uri } + resource: { type: string } + resource_name: { type: string } + authorization_server: { type: string, format: uri } + mode: { $ref: "#/components/schemas/OAuthRemoteMode" } + status: { $ref: "#/components/schemas/OAuthRemoteStatus" } + client_id: { type: string } + has_client_secret: { type: boolean } + authorization_endpoint: { type: string, format: uri } + token_endpoint: { type: string, format: uri } + registration_endpoint: { type: string, format: uri } + token_endpoint_auth_method: { type: string } + redirect_uri: { type: string, format: uri } + redirect_uris: + type: array + items: { type: string, format: uri } + scope: { type: string } + has_access_token: { type: boolean } + has_refresh_token: { type: boolean } + token_type: { type: string } + token_expiry: { type: string, format: date-time } + last_error: { type: string } + + OAuthRemoteConnectionList: + type: array + items: { $ref: "#/components/schemas/OAuthRemoteConnection" } + + OAuthRemoteConnectionListResult: + type: object + required: [connections] + properties: + connections: + $ref: "#/components/schemas/OAuthRemoteConnectionList" + + OAuthRemoteAuthorizeResult: + type: object + required: [connection, authorization_url] + properties: + connection: + $ref: "#/components/schemas/OAuthRemoteConnection" + authorization_url: + type: string + format: uri + + OAuthRemoteCallbackResult: + type: object + required: [connection] + properties: + connection: + $ref: "#/components/schemas/OAuthRemoteConnection" + + OAuthClientList: + type: array + items: { $ref: "#/components/schemas/OAuthClient" } + + OAuthClientListResult: + type: object + required: [clients] + properties: + clients: { $ref: "#/components/schemas/OAuthClientList" } + + OAuthDeviceAuthorisationRecord: + type: object + required: + - id + - createdAt + - client_id + - user_code + - scope + - expires_at + - poll_interval_seconds + properties: + id: { $ref: "#/components/schemas/Identifier" } + createdAt: { $ref: "#/components/schemas/CreatedAt" } + client_id: { $ref: "#/components/schemas/Identifier" } + user_code: { type: string } + scope: { type: string } + expires_at: { type: string, format: date-time } + poll_interval_seconds: { type: integer } + last_polled_at: { type: string, format: date-time } + approved_by_account_id: { $ref: "#/components/schemas/Identifier" } + approved_at: { type: string, format: date-time } + denied_at: { type: string, format: date-time } + consumed_at: { type: string, format: date-time } + + OAuthDeviceAuthorisationList: + type: array + items: { $ref: "#/components/schemas/OAuthDeviceAuthorisationRecord" } + + OAuthDeviceAuthorisationListResult: + type: object + required: [device_authorisations] + properties: + device_authorisations: + $ref: "#/components/schemas/OAuthDeviceAuthorisationList" + + OAuthRefreshToken: + type: object + required: + - id + - createdAt + - oauth_client_id + - client_id + - client_name + - account_id + - scope + - expires_at + properties: + id: { $ref: "#/components/schemas/Identifier" } + createdAt: { $ref: "#/components/schemas/CreatedAt" } + oauth_client_id: { $ref: "#/components/schemas/Identifier" } + client_id: { type: string } + client_name: { type: string } + account_id: { $ref: "#/components/schemas/Identifier" } + scope: { type: string } + expires_at: { type: string, format: date-time } + revoked_at: { type: string, format: date-time } + replaced_by_token_id: { $ref: "#/components/schemas/Identifier" } + last_used_at: { type: string, format: date-time } + + OAuthRefreshTokenList: + type: array + items: { $ref: "#/components/schemas/OAuthRefreshToken" } + + OAuthRefreshTokenListResult: + type: object + required: [tokens] + properties: + tokens: { $ref: "#/components/schemas/OAuthRefreshTokenList" } + + WebAuthnPublicKeyCreationOptions: + description: | + https://www.w3.org/TR/webauthn-2/#sctn-credentialcreationoptions-extension + type: object + required: [publicKey] + properties: + publicKey: + $ref: "#/components/schemas/PublicKeyCredentialCreationOptions" + + PublicKeyCredentialCreationOptions: + description: | + https://www.w3.org/TR/webautehn-2/#dictdef-publickeycredentialcreationoptions + type: object + required: + - rp + - user + - challenge + - excludeCredentials + - pubKeyCredParams + properties: + rp: { $ref: "#/components/schemas/PublicKeyCredentialRpEntity" } + user: { $ref: "#/components/schemas/PublicKeyCredentialUserEntity" } + + challenge: + type: string + pubKeyCredParams: + type: array + items: { $ref: "#/components/schemas/PublicKeyCredentialParameters" } + + timeout: + type: integer + excludeCredentials: + type: array + items: { $ref: "#/components/schemas/PublicKeyCredentialDescriptor" } + authenticatorSelection: + { $ref: "#/components/schemas/AuthenticatorSelectionCriteria" } + attestation: + { $ref: "#/components/schemas/AttestationConveyancePreference" } + extensions: + { $ref: "#/components/schemas/AuthenticationExtensionsClientInputs" } + + PublicKeyCredentialRpEntity: + description: | + https://www.w3.org/TR/webauthn-2/#dictdef-publickeycredentialrpentity + type: object + required: [name, id] + properties: + id: + type: string + name: + type: string + + PublicKeyCredentialUserEntity: + description: | + https://www.w3.org/TR/webauthn-2/#dictdef-publickeycredentialuserentity + type: object + required: [id, name, displayName] + properties: + id: + type: string + name: + type: string + displayName: + type: string + + PublicKeyCredentialParameters: + description: | + https://www.w3.org/TR/webauthn-2/#dictdef-publickeycredentialparameters + type: object + required: [type, alg] + properties: + type: + $ref: "#/components/schemas/PublicKeyCredentialType" + alg: + type: number + + PublicKeyCredentialType: + description: | + https://www.w3.org/TR/webauthn-2/#enumdef-publickeycredentialtype + type: string + enum: [public-key] + + PublicKeyCredentialDescriptor: + description: | + https://www.w3.org/TR/webauthn-2/#dictdef-publickeycredentialdescriptor + type: object + required: [type, id] + properties: + type: + $ref: "#/components/schemas/PublicKeyCredentialType" + id: + type: string + transports: + type: array + items: + type: string + enum: ["ble", "internal", "nfc", "usb", "cable", "hybrid"] + + AuthenticatorSelectionCriteria: + description: | + https://www.w3.org/TR/webauthn-2/#dictdef-authenticatorselectioncriteria + type: object + required: + - authenticatorAttachment + - residentKey + properties: + authenticatorAttachment: + $ref: "#/components/schemas/AuthenticatorAttachment" + residentKey: + $ref: "#/components/schemas/ResidentKeyRequirement" + requireResidentKey: + type: boolean + userVerification: + $ref: "#/components/schemas/UserVerificationRequirement" + + AuthenticatorAttachment: + description: | + https://www.w3.org/TR/webauthn-2/#enumdef-authenticatorattachment + type: string + enum: [platform, cross-platform] + + ResidentKeyRequirement: + description: | + https://www.w3.org/TR/webauthn-2/#enumdef-residentkeyrequirement + type: string + enum: + - discouraged + - preferred + - required + + UserVerificationRequirement: + description: | + https://www.w3.org/TR/webauthn-2/#enumdef-userverificationrequirement + type: string + default: preferred + enum: + - discouraged + - preferred + - required + + AttestationConveyancePreference: + description: | + https://www.w3.org/TR/webauthn-2/#enum-attestation-convey + type: string + enum: + - direct + - enterprise + - indirect + - none + + AuthenticationExtensionsClientInputs: + description: | + https://www.w3.org/TR/webauthn-2/#dictdef-authenticationextensionsclientinputs + type: object + additionalProperties: true + + PublicKeyCredential: + description: | + https://www.w3.org/TR/webauthn-2/#iface-pkcredential + type: object + required: + - id + - rawId + - response + - type + properties: + id: + type: string + rawId: + type: string + response: { $ref: "#/components/schemas/AuthenticatorResponse" } + type: + type: string + clientExtensionResults: + type: object + authenticatorAttachment: + type: string + + AuthenticatorResponse: + description: | + https://www.w3.org/TR/webauthn-2/#authenticatorresponse + type: object + required: [clientDataJSON] + properties: + clientDataJSON: + type: string + attestationObject: + type: string + transports: + type: array + items: + type: string + authenticatorData: + type: string + signature: + type: string + userHandle: + type: string + + CredentialRequestOptions: + description: | + https://www.w3.org/TR/webauthn-2/#sctn-credentialrequestoptions-extension + type: object + required: [publicKey] + properties: + publicKey: + $ref: "#/components/schemas/PublicKeyCredentialRequestOptions" + + PublicKeyCredentialRequestOptions: + description: | + https://www.w3.org/TR/webauthn-2/#dictdef-publickeycredentialrequestoptions + type: object + required: [challenge] + properties: + challenge: + type: string + timeout: + type: integer + rpId: + type: string + allowCredentials: + type: array + items: + $ref: "#/components/schemas/PublicKeyCredentialDescriptor" + userVerification: + type: string + enum: ["discouraged", "preferred", "required"] + + PhoneRequestCodeProps: + description: The phone number request payload. + type: object + required: [identifier, phone_number] + properties: + identifier: + description: The desired username to link to the phone number. + example: "southclaws" + type: string + phone_number: + description: The phone number to receive the one-time code on. + type: string + + PhoneSubmitCodeProps: + description: The Phone submit code payload. + type: object + required: [code] + properties: + code: + type: string + + AccessKey: + type: object + allOf: + - $ref: "#/components/schemas/CommonProperties" + - $ref: "#/components/schemas/AccessKeyProps" + + OwnedAccessKey: + description: | + An owned access key is a key that has been already issued to an account, + it is used specifically for administrator listing of all access keys + where it's necessary to include the account that created the key. + type: object + allOf: + - $ref: "#/components/schemas/CommonProperties" + - $ref: "#/components/schemas/AccessKeyProps" + - required: [created_by] + properties: + created_by: { $ref: "#/components/schemas/ProfileReference" } + + AccessKeyIssued: + description: | + An access key issued to an account, this is the full access key object + including the secret. This is only exposed upon creation of the key and + the secret value is never stored. The caller that receives this object + is responsible for securely storing the secret value for later use. + type: object + allOf: + - $ref: "#/components/schemas/AccessKey" + - $ref: "#/components/schemas/AccessKeySecret" + + AccessKeyProps: + type: object + required: [name, enabled] + properties: + name: + description: The name of the access key. + type: string + enabled: + type: boolean + expires_at: + type: string + format: date-time + description: When the access key expires, if null, it never expires. + + AccessKeyInitialProps: + type: object + required: [name] + properties: + name: + description: The name of the access key. + type: string + expires_at: + type: string + format: date-time + description: When the access key expires, if null, it never expires. + + AccessKeySecret: + type: object + required: [secret] + properties: + secret: + description: | + The secret key used to authenticate with the API. + + Keys are prefixed with a kind identifier, "sdpak" refers to a + Storyden Personal Access Key and "sdbak" refers to a Storyden + Bot Access Key. The two kinds are not interchangeable however they + do not have behavioural differences and is merely a visual cue. + type: string + example: "sdpak_a1b2c3d4cfc00d429ba841f9425c62f82341381b" + + AccessKeyList: + type: array + items: { $ref: "#/components/schemas/AccessKey" } + + AccessKeyListResult: + type: object + required: [keys] + properties: + keys: { $ref: "#/components/schemas/AccessKeyList" } + + OwnedAccessKeyList: + type: array + items: { $ref: "#/components/schemas/OwnedAccessKey" } + + OwnedAccessKeyListResult: + type: object + required: [keys] + properties: + keys: { $ref: "#/components/schemas/OwnedAccessKeyList" } + + # + # d8888 888 + # d88888 888 + # d88P888 888 + # d88P 888 .d8888b .d8888b .d88b. 888 888 88888b. 888888 + # d88P 888 d88P" d88P" d88""88b 888 888 888 "88b 888 + # d88P 888 888 888 888 888 888 888 888 888 888 + # d8888888888 Y88b. Y88b. Y88..88P Y88b 888 888 888 Y88b. + # d88P 888 "Y8888P "Y8888P "Y88P" "Y88888 888 888 "Y888 + # + + Account: + type: object + allOf: + - $ref: "#/components/schemas/CommonProperties" + - $ref: "#/components/schemas/AccountCommonProps" + + AccountList: + type: array + items: { $ref: "#/components/schemas/Account" } + + AccountKind: + type: string + enum: [human, bot] + + AccountCommonProps: + required: + [ + joined, + handle, + name, + roles, + bio, + links, + meta, + verified_status, + kind, + email_addresses, + auth_services, + admin, + ] + properties: + joined: { $ref: "#/components/schemas/MemberJoinedDate" } + suspended: { $ref: "#/components/schemas/MemberSuspendedDate" } + handle: + $ref: "#/components/schemas/AccountHandle" + name: + $ref: "#/components/schemas/AccountName" + roles: + $ref: "#/components/schemas/AccountRoleList" + bio: + $ref: "#/components/schemas/AccountBio" + signature: + $ref: "#/components/schemas/AccountSignature" + links: + $ref: "#/components/schemas/ProfileExternalLinkList" + meta: + $ref: "#/components/schemas/Metadata" + verified_status: + $ref: "#/components/schemas/AccountVerifiedStatus" + kind: + $ref: "#/components/schemas/AccountKind" + email_addresses: + $ref: "#/components/schemas/AccountEmailAddressList" + auth_services: + $ref: "#/components/schemas/AuthProviderIdentifierList" + notifications: + $ref: "#/components/schemas/NotificationCount" + admin: + type: boolean + invited_by: + $ref: "#/components/schemas/ProfileReference" + + AccountMutableProps: + type: object + properties: + handle: + $ref: "#/components/schemas/AccountHandle" + name: + $ref: "#/components/schemas/AccountName" + bio: + $ref: "#/components/schemas/AccountBio" + signature: + $ref: "#/components/schemas/AccountSignature" + interests: + $ref: "#/components/schemas/TagNameList" + links: + $ref: "#/components/schemas/ProfileExternalLinkList" + meta: + $ref: "#/components/schemas/Metadata" + + AccountManageInitialProps: + type: object + allOf: + - required: [handle] + properties: + handle: + $ref: "#/components/schemas/AccountHandle" + email_address: + $ref: "#/components/schemas/EmailAddress" + - $ref: "#/components/schemas/AccountMutableProps" + - $ref: "#/components/schemas/AccountMutablePropsAsAdmin" + + AccountManageMutableProps: + allOf: + - $ref: "#/components/schemas/AccountMutableProps" + - $ref: "#/components/schemas/AccountMutablePropsAsAdmin" + + AccountMutablePropsAsAdmin: + type: object + properties: + admin: + type: boolean + verified_status: + $ref: "#/components/schemas/AccountVerifiedStatus" + + AccountAuthMethods: + type: object + required: [active, available] + properties: + active: { $ref: "#/components/schemas/AccountAuthMethodList" } + available: { $ref: "#/components/schemas/AuthProviderList" } + + ModerationNote: + type: object + allOf: + - $ref: "#/components/schemas/ModerationNoteMetadata" + - $ref: "#/components/schemas/ModerationNoteProps" + + ModerationNoteProps: + type: object + required: [content] + properties: + author: + $ref: "#/components/schemas/ProfileReference" + content: + $ref: "#/components/schemas/ModerationNoteContent" + + ModerationNoteMetadata: + type: object + required: [id, created_at] + properties: + id: + $ref: "#/components/schemas/Identifier" + created_at: + type: string + format: date-time + + ModerationNoteInitialProps: + type: object + required: [content] + properties: + content: + $ref: "#/components/schemas/ModerationNoteContent" + + ModerationNoteContent: + type: string + minLength: 1 + maxLength: 2000 + + ModerationNoteList: + type: array + items: + $ref: "#/components/schemas/ModerationNote" + + ModerationNoteListResult: + type: object + required: [notes] + properties: + notes: + $ref: "#/components/schemas/ModerationNoteList" + + Warning: + type: object + allOf: + - $ref: "#/components/schemas/WarningMetadata" + - $ref: "#/components/schemas/WarningProps" + + WarningProps: + type: object + required: [reason] + properties: + reason: + $ref: "#/components/schemas/WarningReason" + issued_by: + $ref: "#/components/schemas/ProfileReference" + + WarningMetadata: + type: object + required: [id, issued_at] + properties: + id: + $ref: "#/components/schemas/Identifier" + issued_at: + type: string + format: date-time + + WarningInitialProps: + type: object + required: [reason] + properties: + reason: + $ref: "#/components/schemas/WarningReason" + + WarningMutableProps: + type: object + required: [reason] + properties: + reason: + $ref: "#/components/schemas/WarningReason" + + WarningReason: + type: string + minLength: 1 + maxLength: 2000 + + WarningList: + type: array + items: + $ref: "#/components/schemas/Warning" + + WarningListResult: + type: object + required: [warnings, total] + properties: + warnings: + $ref: "#/components/schemas/WarningList" + total: + type: integer + + AccountAuthMethodList: + type: array + items: { $ref: "#/components/schemas/AccountAuthMethod" } + + AccountAuthMethod: + description: | + An authentication method is an active instance of an authentication + provider associated with an account. Use this to display a user's active + authentication methods so they can edit or remove it. + type: object + required: [id, created_at, name, identifier, provider] + properties: + id: + description: The internal unique ID this method has. + type: string + created_at: + type: string + format: date-time + description: When this auth method was registered to the account. + name: + description: The personal name given to the method. + type: string + identifier: + description: The external identifier (third party ID or device ID) + type: string + provider: { $ref: "#/components/schemas/AuthProvider" } + + AccountVerifiedStatus: + type: string + enum: [none, verified_email, manual] + + AccountEmailAddressList: + description: | + If the instance is configured to not use any email features for auth or + transactional/content communications, this will always be empty. + type: array + items: { $ref: "#/components/schemas/AccountEmailAddress" } + + AccountEmailAddress: + type: object + required: [id, email_address, verified] + properties: + id: { $ref: "#/components/schemas/Identifier" } + email_address: { $ref: "#/components/schemas/EmailAddress" } + verified: + description: Is the email address verified to be owned by the account? + type: boolean + + AccountEmailInitialProps: + type: object + required: [email_address] + properties: + email_address: { $ref: "#/components/schemas/EmailAddress" } + + AccountEmailVerifiedStatusMutableProps: + type: object + required: [verified] + properties: + verified: + type: boolean + + AccountEmailPasswordResetProps: + type: object + required: [email_address_id, token_url] + properties: + email_address_id: + description: | + The email address ID to send the reset email to. The email address + must belong to the specified account. + $ref: "#/components/schemas/Identifier" + token_url: + type: object + required: [url, query] + properties: + url: + description: | + The URL to include in the password reset email. This URL's host + must match the configured Storyden instance's web address value. + type: string + query: + description: | + The query parameters to store the reset token in. This is a + frontend client specific value. + type: string + + AccountPasswordResetToken: + type: object + required: [token] + properties: + token: + description: | + Password reset token that can be used on the `/auth/password/reset` + endpoint to reset the account's password. This token is valid for + one hour and can only be used once. The token is identical in format + to tokens sent via email in the user-facing password reset flow. + type: string + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + + AccountListResult: + allOf: + - { $ref: "#/components/schemas/PaginatedResult" } + - type: object + required: [accounts] + properties: + accounts: { $ref: "#/components/schemas/AccountList" } + + # + # 8888888 d8b 888 888 d8b + # 888 Y8P 888 888 Y8P + # 888 888 888 + # 888 88888b. 888 888 888 888888 8888b. 888888 888 .d88b. 88888b. + # 888 888 "88b 888 888 888 888 "88b 888 888 d88""88b 888 "88b + # 888 888 888 Y88 88P 888 888 .d888888 888 888 888 888 888 888 + # 888 888 888 Y8bd8P 888 Y88b. 888 888 Y88b. 888 Y88..88P 888 888 + # 8888888 888 888 Y88P 888 "Y888 "Y888888 "Y888 888 "Y88P" 888 888 + # + + Invitation: + type: object + allOf: + - $ref: "#/components/schemas/CommonProperties" + - $ref: "#/components/schemas/InvitationProps" + + InvitationList: + type: array + items: { $ref: "#/components/schemas/Invitation" } + + InvitationListResult: + type: object + allOf: + - { $ref: "#/components/schemas/PaginatedResult" } + - type: object + required: [invitations] + properties: + invitations: { $ref: "#/components/schemas/InvitationList" } + + InvitationProps: + type: object + required: [creator] + properties: + creator: { $ref: "#/components/schemas/ProfileReference" } + message: + type: string + + InvitationInitialProps: + type: object + properties: + message: + type: string + + # + # 888b 888 888 d8b .d888 d8b 888 d8b + # 8888b 888 888 Y8P d88P" Y8P 888 Y8P + # 88888b 888 888 888 888 + # 888Y88b 888 .d88b. 888888 888 888888 888 .d8888b 8888b. 888888 888 .d88b. 88888b. + # 888 Y88b888 d88""88b 888 888 888 888 d88P" "88b 888 888 d88""88b 888 "88b + # 888 Y88888 888 888 888 888 888 888 888 .d888888 888 888 888 888 888 888 + # 888 Y8888 Y88..88P Y88b. 888 888 888 Y88b. 888 888 Y88b. 888 Y88..88P 888 888 + # 888 Y888 "Y88P" "Y888 888 888 888 "Y8888P "Y888888 "Y888 888 "Y88P" 888 888 + # + + Notification: + type: object + required: [id, created_at, event, status] + properties: + id: { $ref: "#/components/schemas/Identifier" } + created_at: + type: string + format: date-time + description: The time the resource was created. + event: { $ref: "#/components/schemas/NotificationEvent" } + item: { $ref: "#/components/schemas/DatagraphItem" } + source: { $ref: "#/components/schemas/ProfileReference" } + status: { $ref: "#/components/schemas/NotificationStatus" } + + NotificationMutableProps: + type: object + properties: + status: { $ref: "#/components/schemas/NotificationStatus" } + + NotificationWithID: + type: object + required: [id] + properties: + id: { $ref: "#/components/schemas/Identifier" } + + NotificationMutation: + allOf: + - { $ref: "#/components/schemas/NotificationWithID" } + - { $ref: "#/components/schemas/NotificationMutableProps" } + + NotificationMutationList: + type: array + items: { $ref: "#/components/schemas/NotificationMutation" } + + NotificationListUpdate: + type: object + required: [notifications] + properties: + notifications: { $ref: "#/components/schemas/NotificationMutationList" } + + NotificationList: + type: array + items: { $ref: "#/components/schemas/Notification" } + + NotificationListResult: + type: object + allOf: + - { $ref: "#/components/schemas/PaginatedResult" } + - type: object + required: [notifications] + properties: + notifications: { $ref: "#/components/schemas/NotificationList" } + + NotificationEvent: + description: | + The kind of event that triggered the notification. + Identical to the `notification.Event` enumerated type. + type: string + enum: + - thread_reply + - reply_to_reply + - post_like + - follow + - profile_mention + - event_host_added + - member_attending_event + - member_declined_event + - attendee_removed + - report_submitted + - report_updated + - warning_issued + - node_version_created + - node_version_applied + - node_version_deleted + + NotificationStatus: + type: string + enum: [unread, read] + + NotificationStatusList: + type: array + items: { $ref: "#/components/schemas/NotificationStatus" } + + NotificationCount: + type: integer + + # + # 8888888b. 888 + # 888 Y88b 888 + # 888 888 888 + # 888 d88P .d88b. 88888b. .d88b. 888d888 888888 + # 8888888P" d8P Y8b 888 "88b d88""88b 888P" 888 + # 888 T88b 88888888 888 888 888 888 888 888 + # 888 T88b Y8b. 888 d88P Y88..88P 888 Y88b. + # 888 T88b "Y8888 88888P" "Y88P" 888 "Y888 + # 888 + # 888 + # 888 + # + + Report: + type: object + allOf: + - $ref: "#/components/schemas/CommonProperties" + - $ref: "#/components/schemas/ReportRefProps" + - $ref: "#/components/schemas/ReportProps" + + ReportList: + type: array + items: { $ref: "#/components/schemas/Report" } + + ReportListResult: + type: object + allOf: + - { $ref: "#/components/schemas/PaginatedResult" } + - type: object + required: [reports] + properties: + reports: { $ref: "#/components/schemas/ReportList" } + + ReportProps: + type: object + properties: + item: { $ref: "#/components/schemas/DatagraphItem" } + + ReportRefProps: + type: object + required: [target_id, target_kind, status] + properties: + target_id: + $ref: "#/components/schemas/Identifier" + target_kind: + $ref: "#/components/schemas/DatagraphItemKind" + reported_by: + $ref: "#/components/schemas/ProfileReference" + handled_by: + $ref: "#/components/schemas/ProfileReference" + comment: + type: string + status: + $ref: "#/components/schemas/ReportStatus" + + ReportInitialProps: + type: object + required: [target_id, target_kind] + properties: + target_id: + $ref: "#/components/schemas/Identifier" + target_kind: + $ref: "#/components/schemas/DatagraphItemKind" + comment: + type: string + + ReportMutableProps: + type: object + properties: + status: + $ref: "#/components/schemas/ReportStatus" + handled_by: + $ref: "#/components/schemas/Identifier" + + ReportStatus: + type: string + enum: [submitted, acknowledged, resolved] + + # + # 8888888b. .d888 d8b 888 + # 888 Y88b d88P" Y8P 888 + # 888 888 888 888 + # 888 d88P 888d888 .d88b. 888888 888 888 .d88b. + # 8888888P" 888P" d88""88b 888 888 888 d8P Y8b + # 888 888 888 888 888 888 888 88888888 + # 888 888 Y88..88P 888 888 888 Y8b. + # 888 888 "Y88P" 888 888 888 "Y8888 + # + + PublicProfile: + type: object + allOf: + - $ref: "#/components/schemas/CommonProperties" + - required: + - createdAt + - joined + - handle + - name + - roles + - bio + - followers + - following + - like_score + - interests + - links + - meta + properties: + createdAt: + deprecated: true + type: string + joined: { $ref: "#/components/schemas/MemberJoinedDate" } + suspended: { $ref: "#/components/schemas/MemberSuspendedDate" } + handle: + $ref: "#/components/schemas/AccountHandle" + name: + $ref: "#/components/schemas/AccountName" + roles: + $ref: "#/components/schemas/AccountRoleList" + bio: + $ref: "#/components/schemas/AccountBio" + signature: + $ref: "#/components/schemas/AccountSignature" + image: + type: string + followers: + $ref: "#/components/schemas/ProfileFollowersCount" + following: + $ref: "#/components/schemas/ProfileFollowingCount" + like_score: + $ref: "#/components/schemas/LikeScore" + interests: + $ref: "#/components/schemas/TagReferenceList" + links: + $ref: "#/components/schemas/ProfileExternalLinkList" + invited_by: + $ref: "#/components/schemas/ProfileReference" + meta: + $ref: "#/components/schemas/Metadata" + + PublicProfileFollowersResult: + allOf: + - { $ref: "#/components/schemas/PaginatedResult" } + - type: object + required: [followers] + properties: + followers: { $ref: "#/components/schemas/ProfileFollowersList" } + + PublicProfileFollowingResult: + allOf: + - { $ref: "#/components/schemas/PaginatedResult" } + - type: object + required: [following] + properties: + following: { $ref: "#/components/schemas/ProfileFollowingList" } + + ProfileExternalLinkList: + type: array + items: + $ref: "#/components/schemas/ProfileExternalLink" + + ProfileExternalLink: + type: object + required: [text, url] + properties: + text: + type: string + url: + type: string + + PublicProfileListResult: + allOf: + - { $ref: "#/components/schemas/PaginatedResult" } + - type: object + required: [profiles] + properties: + profiles: { $ref: "#/components/schemas/PublicProfileList" } + + PublicProfileList: + type: array + items: { $ref: "#/components/schemas/PublicProfile" } + + ProfileFollowersList: + type: array + items: { $ref: "#/components/schemas/ProfileReference" } + + ProfileFollowingList: + type: array + items: { $ref: "#/components/schemas/ProfileReference" } + + ProfileFollowersCount: + type: integer + + ProfileFollowingCount: + type: integer + + MemberJoinedDate: + type: string + format: date-time + description: The time the resource was created. + + MemberSuspendedDate: + type: string + format: date-time + description: The time the resource was created. + + # + # .d8888b. 888 + # d88P Y88b 888 + # 888 888 888 + # 888 8888b. 888888 .d88b. .d88b. .d88b. 888d888 888 888 + # 888 "88b 888 d8P Y8b d88P"88b d88""88b 888P" 888 888 + # 888 888 .d888888 888 88888888 888 888 888 888 888 888 888 + # Y88b d88P 888 888 Y88b. Y8b. Y88b 888 Y88..88P 888 Y88b 888 + # "Y8888P" "Y888888 "Y888 "Y8888 "Y88888 "Y88P" 888 "Y88888 + # 888 888 + # Y8b d88P Y8b d88P + # "Y88P" "Y88P" + # + + Category: + type: object + allOf: + - $ref: "#/components/schemas/CommonProperties" + - $ref: "#/components/schemas/CategoryCommonProps" + - $ref: "#/components/schemas/CategoryAdditional" + + CategoryReference: + type: object + allOf: + - $ref: "#/components/schemas/CommonProperties" + - $ref: "#/components/schemas/CategoryCommonProps" + + CategoryCommonProps: + type: object + required: [name, slug, description, colour, sort, children] + properties: + name: + $ref: "#/components/schemas/CategoryName" + slug: + $ref: "#/components/schemas/CategorySlug" + description: + type: string + colour: + type: string + sort: + type: integer + parent: + $ref: "#/components/schemas/Identifier" + description: Parent category identifier. Unset indicates a root-level category. + cover_image: + $ref: "#/components/schemas/Asset" + children: { $ref: "#/components/schemas/CategoryList" } + meta: { $ref: "#/components/schemas/Metadata" } + + CategoryInitialProps: + type: object + required: [name, description, colour] + properties: + name: + $ref: "#/components/schemas/CategoryName" + slug: + $ref: "#/components/schemas/CategorySlug" + description: + type: string + colour: + type: string + parent: + $ref: "#/components/schemas/Identifier" + description: Parent category identifier. Unset indicates a root-level category. + cover_image_asset_id: { $ref: "#/components/schemas/Identifier" } + meta: { $ref: "#/components/schemas/Metadata" } + + CategoryMutableProps: + type: object + properties: + name: + $ref: "#/components/schemas/CategoryName" + slug: + $ref: "#/components/schemas/CategorySlug" + description: + type: string + colour: + type: string + cover_image_asset_id: + allOf: + - $ref: "#/components/schemas/NullableIdentifier" + nullable: true + description: Optional cover image asset identifier for the category. + meta: { $ref: "#/components/schemas/Metadata" } + + CategoryDeleteProps: + type: object + required: [move_to] + properties: + move_to: + $ref: "#/components/schemas/Identifier" + description: Category ID to move all posts to before deleting this category. + + CategoryName: + description: A category's user-facing name. + type: string + + CategorySlug: + description: A category's URL-safe slug. + type: string + + CategorySlugList: + description: A list of category names. + type: array + items: + $ref: "#/components/schemas/CategorySlug" + + CategoryAdditional: + type: object + required: [postCount] + properties: + postCount: + type: integer + + CategoryList: + type: array + items: { $ref: "#/components/schemas/Category" } + + CategoryListResult: + type: object + required: [categories] + properties: + categories: + $ref: "#/components/schemas/CategoryList" + + CategoryPositionMutableProps: + type: object + description: | + Parameters for repositioning a category in the hierarchy. Update the + parent using `parent`, and/or reposition among siblings using `before` + or `after`. Using both `before` and `after` is not allowed. + properties: + parent: + $ref: "#/components/schemas/NullableIdentifier" + description: Optional new parent category identifier. Set to null to move to the root level. + before: + $ref: "#/components/schemas/Identifier" + description: Move this category before the sibling with this identifier. + after: + $ref: "#/components/schemas/Identifier" + description: Move this category after the sibling with this identifier. + + # + # 88888888888 + # 888 + # 888 + # 888 8888b. .d88b. + # 888 "88b d88P"88b + # 888 .d888888 888 888 + # 888 888 888 Y88b 888 + # 888 "Y888888 "Y88888 + # 888 + # Y8b d88P + # "Y88P" + # + + Tag: + type: object + description: | + A tag is a label that can be applied to posts or pages to organise + related content. They can be used to filter and search for content. + The Tag schema provides all the data for a tag including its items, so + it's quite a heavy object if referencing a lot of items. For a lighter + weight version, use a TagReference for use-cases such as tag searches. + allOf: + - $ref: "#/components/schemas/TagReferenceProps" + - $ref: "#/components/schemas/TagProps" + + TagReference: + description: | + A minimal representation of a tag for use in most contexts where you + don't need the full list of items associated with the tag. + type: object + allOf: + - $ref: "#/components/schemas/TagReferenceProps" + + TagProps: + type: object + required: [id, items] + properties: + id: { $ref: "#/components/schemas/Identifier" } + items: { $ref: "#/components/schemas/DatagraphItemList" } + + TagReferenceProps: + type: object + required: [name, colour, item_count] + properties: + name: { $ref: "#/components/schemas/TagName" } + colour: { $ref: "#/components/schemas/TagColour" } + item_count: { $ref: "#/components/schemas/TagItemCount" } + + TagName: + type: string + description: The name of a tag. + + TagColour: + type: string + description: The colour of a tag. + + TagItemCount: + type: integer + description: The number of items tagged with this tag. + + TagReferenceList: + type: array + description: A list of tags. + items: { $ref: "#/components/schemas/TagReference" } + + TagNameList: + type: array + items: { $ref: "#/components/schemas/TagName" } + + TagListIDs: + type: array + description: A list of tags IDs. + items: { $ref: "#/components/schemas/Identifier" } + + TagListResult: + type: object + required: [tags] + properties: + tags: { $ref: "#/components/schemas/TagReferenceList" } + + # + # 8888888b. 888 + # 888 Y88b 888 + # 888 888 888 + # 888 d88P .d88b. .d8888b 888888 + # 8888888P" d88""88b 88K 888 + # 888 888 888 "Y8888b. 888 + # 888 Y88..88P X88 Y88b. + # 888 "Y88P" 88888P' "Y888 + # + + Post: + type: object + description: | + A post represents a temporal piece of content, it can be a thread, or a + reply to a thread or something else such as a blog, announcement, etc. + Post is used in generic use-cases where it may not matter whether you + want a thread or a reply, such as search results or recommendations. + allOf: + - { $ref: "#/components/schemas/CommonProperties" } + - { $ref: "#/components/schemas/PostReferenceProps" } + - { $ref: "#/components/schemas/PostProps" } + + PostReference: + description: | + A minimal object used to refer to a post without providing a lot of + unnecessary data such as the full content or child items. + type: object + allOf: + - { $ref: "#/components/schemas/CommonProperties" } + - { $ref: "#/components/schemas/PostReferenceProps" } + + PostProps: + description: | + The general properties required for any post-like resource. Is composed + with Threads or Replies to provide the basic common properties. + type: object + required: [body, body_links] + properties: + body: { $ref: "#/components/schemas/PostContent" } + body_links: { $ref: "#/components/schemas/LinkReferenceList" } + + PostReferenceProps: + type: object + required: + - title + - slug + - author + - visibility + - assets + - reacts + - collections + - likes + properties: + title: { $ref: "#/components/schemas/ThreadTitle" } + description: { $ref: "#/components/schemas/PostDescription" } + slug: { $ref: "#/components/schemas/ThreadMark" } + author: { $ref: "#/components/schemas/ProfileReference" } + visibility: { $ref: "#/components/schemas/Visibility" } + assets: { $ref: "#/components/schemas/AssetList" } + reacts: { $ref: "#/components/schemas/ReactList" } + collections: { $ref: "#/components/schemas/CollectionStatus" } + likes: { $ref: "#/components/schemas/LikeData" } + meta: { $ref: "#/components/schemas/Metadata" } + + PostReferenceList: + type: array + items: { $ref: "#/components/schemas/PostReference" } + + PostMutableProps: + type: object + properties: + body: { $ref: "#/components/schemas/PostContent" } + meta: { $ref: "#/components/schemas/Metadata" } + visibility: { $ref: "#/components/schemas/Visibility" } + url: { $ref: "#/components/schemas/URL" } + + PostDescription: + type: string + description: A short version of the post's body text for use in previews. + + # + # 88888888888 888 888 + # 888 888 888 + # 888 888 888 + # 888 88888b. 888d888 .d88b. 8888b. .d88888 + # 888 888 "88b 888P" d8P Y8b "88b d88" 888 + # 888 888 888 888 88888888 .d888888 888 888 + # 888 888 888 888 Y8b. 888 888 Y88b 888 + # 888 888 888 888 "Y8888 "Y888888 "Y88888 + # + + Thread: + allOf: + - $ref: "#/components/schemas/ThreadReference" + - $ref: "#/components/schemas/DatagraphRecommendations" + - type: object + required: [replies] + properties: + replies: { $ref: "#/components/schemas/PaginatedReplyList" } + + ThreadInitialProps: + type: object + required: [title] + properties: + title: { $ref: "#/components/schemas/ThreadTitle" } + pinned: { $ref: "#/components/schemas/PinnedRank" } + body: { $ref: "#/components/schemas/PostContent" } + tags: { $ref: "#/components/schemas/TagNameList" } + meta: { $ref: "#/components/schemas/Metadata" } + category: { $ref: "#/components/schemas/Identifier" } + visibility: { $ref: "#/components/schemas/Visibility" } + url: { $ref: "#/components/schemas/URL" } + + ThreadMutableProps: + type: object + properties: + title: { $ref: "#/components/schemas/ThreadTitle" } + pinned: { $ref: "#/components/schemas/PinnedRank" } + body: { $ref: "#/components/schemas/PostContent" } + tags: { $ref: "#/components/schemas/TagNameList" } + meta: { $ref: "#/components/schemas/Metadata" } + category: { $ref: "#/components/schemas/Identifier" } + visibility: { $ref: "#/components/schemas/Visibility" } + url: { $ref: "#/components/schemas/URL" } + + ThreadReference: + type: object + description: | + A thread reference includes most of the information about a thread but + does not include the posts within the thread. Useful for rendering large + lists of threads or other situations when you don't need the full data. + allOf: + - $ref: "#/components/schemas/CommonProperties" + - $ref: "#/components/schemas/PostReferenceProps" + - $ref: "#/components/schemas/PostProps" + - $ref: "#/components/schemas/ThreadReferenceProps" + + ThreadReferenceProps: + type: object + required: + - pinned + - visibility + - tags + - reply_status + - collections + properties: + pinned: { $ref: "#/components/schemas/PinnedRank" } + read_status: { $ref: "#/components/schemas/ReadStatus" } + reply_status: { $ref: "#/components/schemas/ReplyStatus" } + category: { $ref: "#/components/schemas/CategoryReference" } + link: { $ref: "#/components/schemas/LinkReference" } + tags: { $ref: "#/components/schemas/TagReferenceList" } + last_reply_at: + type: string + format: date-time + description: The time of the last reply to the thread. + + ReadStatus: + description: | + Information about the read status of a thread for the requesting + authenticated user. If the user is not authenticated or they have not + read the thread before, this will not be included in the Thread object. + type: object + required: [last_read_at, replies_since] + properties: + last_read_at: + type: string + format: date-time + description: | + When requested by an authenticated account, shows the last time they + last read the thread at. + replies_since: + type: integer + description: | + When requested by an authenticated account, shows the number of new + replies since they last read the thread. + + ReplyStatus: + type: object + required: [replies, replied] + properties: + replies: + description: The total number of replies to the thread. + type: integer + replied: + description: | + If requested by an authenticated account, the number of replies that + were made by that account to the thread. + type: integer + + ThreadList: + type: array + items: + $ref: "#/components/schemas/ThreadReference" + + ThreadListResult: + allOf: + - { $ref: "#/components/schemas/PaginatedResult" } + - type: object + required: [threads] + properties: + threads: { $ref: "#/components/schemas/ThreadList" } + + # + # 8888888b. 888 + # 888 Y88b 888 + # 888 888 888 + # 888 d88P .d88b. 88888b. 888 888 888 + # 8888888P" d8P Y8b 888 "88b 888 888 888 + # 888 T88b 88888888 888 888 888 888 888 + # 888 T88b Y8b. 888 d88P 888 Y88b 888 + # 888 T88b "Y8888 88888P" 888 "Y88888 + # 888 888 + # 888 Y8b d88P + # 888 "Y88P" + # + + Reply: + type: object + description: | + A new post within a thread of posts. A post may reply to another post in + the thread by specifying the `reply_to` property. The identifier in the + `reply_to` value must be post within the same thread. + allOf: + - { $ref: "#/components/schemas/CommonProperties" } + - { $ref: "#/components/schemas/PostReferenceProps" } + - { $ref: "#/components/schemas/PostProps" } + - { $ref: "#/components/schemas/ReplyProps" } + + PaginatedReplyList: + allOf: + - $ref: "#/components/schemas/PaginatedResult" + - type: object + required: [replies] + properties: + replies: { $ref: "#/components/schemas/ReplyList" } + + ReplyList: + type: array + items: { $ref: "#/components/schemas/Reply" } + + ReplyProps: + type: object + required: [root_id, root_slug] + properties: + root_id: { $ref: "#/components/schemas/Identifier" } + root_slug: { $ref: "#/components/schemas/ThreadMark" } + reply_to: { $ref: "#/components/schemas/Reply" } + + ReplyInitialProps: + type: object + required: [body] + properties: + body: { $ref: "#/components/schemas/PostContent" } + meta: { $ref: "#/components/schemas/Metadata" } + reply_to: { $ref: "#/components/schemas/Identifier" } + url: { $ref: "#/components/schemas/URL" } + + PostLocation: + type: object + description: | + The location of a post. For threads, this is just the slug. For replies, + this includes the thread slug, the index, page number and position. + required: [slug, kind] + properties: + slug: + type: string + description: The thread slug for a thread or root slug for a reply. + kind: + type: string + enum: [thread, reply] + description: Whether this is a thread or a reply + index: + type: integer + description: The zero-based index of a reply within its thread. + page: + type: integer + description: The page number where the reply appears. + position: + type: integer + description: The position of the reply on its page. + + # + # 8888888b. 888 + # 888 Y88b 888 + # 888 888 888 + # 888 d88P .d88b. 8888b. .d8888b 888888 + # 8888888P" d8P Y8b "88b d88P" 888 + # 888 T88b 88888888 .d888888 888 888 + # 888 T88b Y8b. 888 888 Y88b. Y88b. + # 888 T88b "Y8888 "Y888888 "Y8888P "Y888 + # + + React: + type: object + required: [id, emoji, author] + properties: + id: { $ref: "#/components/schemas/Identifier" } + emoji: { $ref: "#/components/schemas/ReactEmoji" } + author: { $ref: "#/components/schemas/ProfileReference" } + + ReactInitialProps: + description: Reactions are currently just simple emoji characters. + type: object + required: [emoji] + properties: + emoji: { $ref: "#/components/schemas/ReactEmoji" } + + ReactList: + description: A list of reactions this post has had from people. + type: array + items: { $ref: "#/components/schemas/React" } + + ReactEmoji: + description: | + A single emoji character representing a reaction. In future, this will + be augmented with a more fully fledged custom emoji system. + type: string + + # + # 888b d888 888 d8b + # 8888b d8888 888 Y8P + # 88888b.d88888 888 + # 888Y88888P888 .d88b. .d88888 888 8888b. + # 888 Y888P 888 d8P Y8b d88" 888 888 "88b + # 888 Y8P 888 88888888 888 888 888 .d888888 + # 888 " 888 Y8b. Y88b 888 888 888 888 + # 888 888 "Y8888 "Y88888 888 "Y888888 + # + + AssetID: + $ref: "#/components/schemas/Identifier" + + AssetIDs: + type: array + items: { $ref: "#/components/schemas/AssetID" } + + AssetList: + type: array + items: { $ref: "#/components/schemas/Asset" } + + Asset: + type: object + required: [id, filename, path, mime_type, width, height] + properties: + id: + $ref: "#/components/schemas/AssetID" + filename: + type: string + path: + description: | + The API path of the asset, conforms to the schema's GET `/assets`. + type: string + mime_type: + type: string + width: + type: number + height: + type: number + # NOTE: Presence is dictated by the callee, not the API (currently.) + parent: { $ref: "#/components/schemas/Asset" } + + AssetSourceURL: + description: + An asset source URL holds the address of an off-platform media asset + which is not hosted on a Storyden instance. It may represent a source + URL for an intended download or an asset which is stored elsewhere. + type: string + + AssetSourceList: + type: array + items: { $ref: "#/components/schemas/AssetSourceURL" } + + # + # 888 d8b 888 + # 888 Y8P 888 + # 888 888 + # 888 888 888 888 .d88b. + # 888 888 888 .88P d8P Y8b + # 888 888 888888K 88888888 + # 888 888 888 "88b Y8b. + # 88888888 888 888 888 "Y8888 + # + + ItemLike: + description: A like on an item, contains the owner only. + type: object + allOf: + - $ref: "#/components/schemas/LikeProps" + - required: [owner] + properties: + owner: { $ref: "#/components/schemas/ProfileReference" } + + ItemLikeList: + type: array + items: { $ref: "#/components/schemas/ItemLike" } + + ProfileLike: + description: A like on an item, contains the item only. + type: object + allOf: + - $ref: "#/components/schemas/LikeProps" + - required: [item] + properties: + item: { $ref: "#/components/schemas/DatagraphItem" } + + ProfileLikeList: + type: array + items: { $ref: "#/components/schemas/ProfileLike" } + + LikeProps: + type: object + required: [id, created_at] + properties: + id: { $ref: "#/components/schemas/Identifier" } + created_at: + type: string + format: date-time + + ProfileLikeListResult: + allOf: + - $ref: "#/components/schemas/PaginatedResult" + - type: object + required: [likes] + properties: + likes: { $ref: "#/components/schemas/ProfileLikeList" } + + LikeData: + type: object + required: [likes, liked] + properties: + likes: { $ref: "#/components/schemas/LikeCount" } + liked: { $ref: "#/components/schemas/LikeStatus" } + + LikeCount: + description: | + A simple count of likes for contexts where pulling the full list would + be overkill. For use on minimal item reference schemas. + type: integer + + LikeScore: + description: The total number of likes received by a member. + type: integer + + LikeStatus: + description: | + A boolean indicating if the account in context has liked this item. + type: boolean + + # + # .d8888b. 888 888 888 d8b + # d88P Y88b 888 888 888 Y8P + # 888 888 888 888 888 + # 888 .d88b. 888 888 .d88b. .d8888b 888888 888 .d88b. 88888b. + # 888 d88""88b 888 888 d8P Y8b d88P" 888 888 d88""88b 888 "88b + # 888 888 888 888 888 888 88888888 888 888 888 888 888 888 888 + # Y88b d88P Y88..88P 888 888 Y8b. Y88b. Y88b. 888 Y88..88P 888 888 + # "Y8888P" "Y88P" 888 888 "Y8888 "Y8888P "Y888 888 "Y88P" 888 888 + # + + Collection: + description: | + A collection is a group of threads owned by a user. It allows users to + curate their own lists of content from the site. Collections can only + contain root level posts (threads) with titles and slugs to link to. + type: object + allOf: + - $ref: "#/components/schemas/CommonProperties" + - $ref: "#/components/schemas/CollectionCommonProps" + - $ref: "#/components/schemas/CollectionAdditionalProps" + + CollectionWithItems: + description: | + The full properties of a collection, for rendering a single collection + somewhere where you can afford to show all the items in the collection. + type: object + required: [items] + allOf: + - $ref: "#/components/schemas/CommonProperties" + - $ref: "#/components/schemas/CollectionCommonProps" + - required: [items] + properties: + items: { $ref: "#/components/schemas/CollectionItemList" } + + CollectionCommonProps: + description: A reference to the collection + type: object + required: [name, slug, owner] + properties: + name: { $ref: "#/components/schemas/CollectionName" } + slug: { $ref: "#/components/schemas/CollectionSlug" } + description: { $ref: "#/components/schemas/CollectionDescription" } + owner: { $ref: "#/components/schemas/ProfileReference" } + + CollectionAdditionalProps: + type: object + required: [item_count, has_queried_item] + properties: + item_count: + type: integer + has_queried_item: + type: boolean + + CollectionList: + type: array + items: { $ref: "#/components/schemas/Collection" } + + CollectionInitialProps: + type: object + required: [name] + properties: + name: { $ref: "#/components/schemas/CollectionName" } + slug: { $ref: "#/components/schemas/CollectionSlug" } + description: { $ref: "#/components/schemas/CollectionDescription" } + + CollectionMutableProps: + type: object + properties: + name: { $ref: "#/components/schemas/CollectionName" } + slug: { $ref: "#/components/schemas/CollectionSlug" } + description: { $ref: "#/components/schemas/CollectionDescription" } + + CollectionItemList: + type: array + items: { $ref: "#/components/schemas/CollectionItem" } + + CollectionItem: + allOf: + - $ref: "#/components/schemas/CommonProperties" + - $ref: "#/components/schemas/CollectionItemMetadata" + - required: [item] + properties: + item: { $ref: "#/components/schemas/DatagraphItem" } + + CollectionItemMetadata: + type: object + required: [owner, added_at, membership_type] + properties: + owner: + $ref: "#/components/schemas/ProfileReference" + added_at: + type: string + format: date-time + description: The time that the item was added to the collection. + membership_type: + $ref: "#/components/schemas/CollectionItemMembershipType" + relevance_score: + $ref: "#/components/schemas/RelevanceScore" + + CollectionItemMembershipType: + type: string + enum: [normal, submission_review, submission_accepted] + + CollectionStatus: + type: object + required: [in_collections, has_collected] + properties: + in_collections: { $ref: "#/components/schemas/CollectionCount" } + has_collected: { $ref: "#/components/schemas/HasCollected" } + + CollectionCount: + description: How many collections has this item been added to? + type: integer + + HasCollected: + description: | + A boolean indicating if the account in context has collected this item. + type: boolean + + CollectionName: + type: string + + CollectionSlug: + $ref: "#/components/schemas/Mark" + + CollectionDescription: + type: string + + # + # 888b 888 888 + # 8888b 888 888 + # 88888b 888 888 + # 888Y88b 888 .d88b. .d88888 .d88b. + # 888 Y88b888 d88""88b d88" 888 d8P Y8b + # 888 Y88888 888 888 888 888 88888888 + # 888 Y8888 Y88..88P Y88b 888 Y8b. + # 888 Y888 "Y88P" "Y88888 "Y8888 + # + + Node: + description: | + A node is a text document with children and assets. It serves as an + abstraction for grouping structured data objects. It can represent + things such as brands, manufacturers, authors, directors, etc. Nodes + can be referenced in content posts and they also have their own content. + type: object + allOf: + - $ref: "#/components/schemas/CommonProperties" + - $ref: "#/components/schemas/NodeCommonProps" + + NodeWithChildren: + description: | + The full properties of a node including all child nodes. + type: object + required: [children] + allOf: + - $ref: "#/components/schemas/Node" + - $ref: "#/components/schemas/DatagraphRecommendations" + - required: [properties, child_property_schema, children] + properties: + properties: { $ref: "#/components/schemas/PropertyList" } + child_property_schema: + $ref: "#/components/schemas/PropertySchemaList" + children: + type: array + items: { $ref: "#/components/schemas/NodeWithChildren" } + + NodeName: + type: string + + NodeSlug: + $ref: "#/components/schemas/Slug" + + NodeDescription: + type: string + + NodeCommonProps: + description: The main properties of a node. + type: object + required: + - name + - slug + - assets + - description + - owner + - hide_child_tree + - tags + - visibility + - meta + properties: + name: { $ref: "#/components/schemas/NodeName" } + slug: { $ref: "#/components/schemas/NodeSlug" } + current_version_id: + description: | + The applied checkpoint that the live node currently represents. If + this is unset, the node either has no applied checkpoint history or + has been directly edited since the last applied checkpoint. + $ref: "#/components/schemas/Identifier" + assets: { $ref: "#/components/schemas/AssetList" } + link: { $ref: "#/components/schemas/LinkReference" } + description: { $ref: "#/components/schemas/NodeDescription" } + primary_image: { $ref: "#/components/schemas/Asset" } + content: { $ref: "#/components/schemas/PostContent" } + owner: { $ref: "#/components/schemas/ProfileReference" } + parent: { $ref: "#/components/schemas/Node" } + hide_child_tree: + description: | + A boolean indicating if the children of this node tree are hidden + when querying the full tree. This is useful for nodes that contain + a large amount of children and do not need to be rendered in a tree + view (such as a sidebar navigation). These children can still be + accessed via the node or node children GET operations for rendering. + type: boolean + tags: { $ref: "#/components/schemas/TagReferenceList" } + visibility: { $ref: "#/components/schemas/Visibility" } + relevance_score: { $ref: "#/components/schemas/RelevanceScore" } + meta: { $ref: "#/components/schemas/Metadata" } + + NodeTree: + type: array + items: { $ref: "#/components/schemas/NodeWithChildren" } + + NodeList: + type: array + items: { $ref: "#/components/schemas/Node" } + + NodeListResult: + allOf: + - { $ref: "#/components/schemas/PaginatedResult" } + - type: object + required: [nodes] + properties: + nodes: { $ref: "#/components/schemas/NodeTree" } + + NodeInitialProps: + type: object + required: [name] + properties: + name: { $ref: "#/components/schemas/NodeName" } + slug: { $ref: "#/components/schemas/NodeSlug" } + asset_ids: { $ref: "#/components/schemas/AssetIDs" } + asset_sources: { $ref: "#/components/schemas/AssetSourceList" } + url: { $ref: "#/components/schemas/URL" } + description: { $ref: "#/components/schemas/NodeDescription" } + primary_image_asset_id: { $ref: "#/components/schemas/AssetID" } + content: { $ref: "#/components/schemas/PostContent" } + parent: { $ref: "#/components/schemas/NodeSlug" } + hide_child_tree: + type: boolean + properties: { $ref: "#/components/schemas/PropertyMutationList" } + tags: { $ref: "#/components/schemas/TagNameList" } + visibility: { $ref: "#/components/schemas/Visibility" } + meta: { $ref: "#/components/schemas/Metadata" } + + NodeMutableProps: + description: | + Note: Properties are replace-all and are not merged with existing. + type: object + properties: + name: { $ref: "#/components/schemas/NodeName" } + slug: { $ref: "#/components/schemas/NodeSlug" } + asset_ids: { $ref: "#/components/schemas/AssetIDs" } + asset_sources: { $ref: "#/components/schemas/AssetSourceList" } + url: + type: string + format: url + nullable: true + description: { $ref: "#/components/schemas/NodeDescription" } + primary_image_asset_id: + { $ref: "#/components/schemas/NullableIdentifier" } + content: { $ref: "#/components/schemas/PostContent" } + parent: { $ref: "#/components/schemas/NodeSlug" } + hide_child_tree: + type: boolean + properties: { $ref: "#/components/schemas/PropertyMutationList" } + tags: { $ref: "#/components/schemas/TagNameList" } + meta: { $ref: "#/components/schemas/Metadata" } + + NodeVersion: + description: | + A draft or applied edit for a library node. Draft versions are mutable + working snapshots. Applied versions are immutable snapshots that were + copied into the target node upon publishing of the changes. + type: object + required: + - id + - created_at + - updated_at + - node_id + - author + - status + - name + - slug + - properties + - meta + properties: + id: { $ref: "#/components/schemas/Identifier" } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + node_id: { $ref: "#/components/schemas/Identifier" } + author: { $ref: "#/components/schemas/ProfileReference" } + status: { $ref: "#/components/schemas/NodeVersionStatus" } + previous: + description: | + Previous applied version for historical comparison. Only present + when retrieving an individual applied version and an earlier + applied version exists. + $ref: "#/components/schemas/NodeVersionReference" + name: { $ref: "#/components/schemas/NodeName" } + slug: { $ref: "#/components/schemas/NodeSlug" } + description: + allOf: [{ $ref: "#/components/schemas/NodeDescription" }] + nullable: true + content: + allOf: [{ $ref: "#/components/schemas/PostContent" }] + nullable: true + properties: + description: | + Complete desired-state property list for the target node. + Properties are replace-all and are not merged with existing values + when the version is applied. + $ref: "#/components/schemas/PropertyMutationList" + meta: { $ref: "#/components/schemas/Metadata" } + + NodeVersionReference: + description: | + Lightweight reference to a tracked library node version. + type: object + required: + - id + - created_at + - updated_at + - author + - status + properties: + id: { $ref: "#/components/schemas/Identifier" } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + author: { $ref: "#/components/schemas/ProfileReference" } + status: { $ref: "#/components/schemas/NodeVersionStatus" } + + NodeVersionStatus: + type: string + enum: + - draft + - applied + + NodeVersionList: + type: array + items: { $ref: "#/components/schemas/NodeVersion" } + + NodeVersionListResult: + allOf: + - { $ref: "#/components/schemas/PaginatedResult" } + - type: object + required: [versions] + properties: + versions: { $ref: "#/components/schemas/NodeVersionList" } + + NodeDraftListResult: + allOf: + - { $ref: "#/components/schemas/PaginatedResult" } + - type: object + required: [drafts] + properties: + drafts: { $ref: "#/components/schemas/NodeDraftList" } + + NodeDraftList: + type: array + items: { $ref: "#/components/schemas/NodeDraft" } + + NodeDraft: + description: | + A draft version with its target node reference. This combines the version + data with enough node context to display the draft in a queue or list view. + allOf: + - { $ref: "#/components/schemas/NodeVersion" } + - type: object + required: [node] + properties: + node: + description: Reference to the target node being edited by this draft + $ref: "#/components/schemas/Node" + + NodeVersionInitialProps: + description: | + Initial overlay for a draft checkpoint. Omitted fields keep the target + node's current value in the new draft snapshot. Nullable fields can be + set to null to clear them. Properties are replace-all and are not merged + with existing values when the version is applied. + type: object + properties: + name: { $ref: "#/components/schemas/NodeName" } + slug: { $ref: "#/components/schemas/NodeSlug" } + description: + allOf: [{ $ref: "#/components/schemas/NodeDescription" }] + nullable: true + content: + allOf: [{ $ref: "#/components/schemas/PostContent" }] + nullable: true + properties: { $ref: "#/components/schemas/PropertyMutationList" } + meta: { $ref: "#/components/schemas/Metadata" } + + NodeVersionMutableProps: + description: | + Updates to a draft checkpoint snapshot. This does not change version + status. Omitted fields keep the current draft value. Nullable fields can + be set to null to clear them. Properties are replace-all and are not + merged with existing values when the version is applied. + type: object + properties: + name: { $ref: "#/components/schemas/NodeName" } + slug: { $ref: "#/components/schemas/NodeSlug" } + description: + allOf: [{ $ref: "#/components/schemas/NodeDescription" }] + nullable: true + content: + allOf: [{ $ref: "#/components/schemas/PostContent" }] + nullable: true + properties: { $ref: "#/components/schemas/PropertyMutationList" } + meta: { $ref: "#/components/schemas/Metadata" } + + NodeVersionStatusMutationProps: + type: object + required: [status] + properties: + status: { $ref: "#/components/schemas/NodeVersionStatus" } + + NodeGenerateTitleRequest: + description: | + A request for a generated title for a node. A request for a generated + title does not use the existing content on a node but takes the current + client's content state (from an "edit mode" text box for example) and + sends that in order to generate a potential title using an LLM. + type: object + required: [content] + properties: + content: + type: string + + NodeGenerateTitleResult: + description: The result of a title generation request from an LLM. + type: object + required: [title] + properties: + title: + type: string + + NodeGenerateTagsRequest: + description: | + A request for generated tags for a node. A request for generated tags + does not use the existing content on a node but takes the current + client's content state (from an "edit mode" text box for example) and + sends that in order to generate potential tags using an LLM. + type: object + required: [content] + properties: + content: + type: string + + NodeGenerateTagsResult: + description: The result of a tag generation request from an LLM. + type: object + required: [tags] + properties: + tags: { $ref: "#/components/schemas/TagNameList" } + + NodeGenerateContentRequest: + description: | + A request for generated content for a node. A request for generated content + does not use the existing content on a node but takes the current + client's content state (from an "edit mode" text box for example) and + sends that in order to generate potential content using an LLM. + type: object + required: [content] + properties: + content: + type: string + + NodeGenerateContentResult: + description: The result of a content generation request from an LLM. + type: object + required: [content] + properties: + content: + type: string + + PropertyMutableProps: + description: | + Note: Properties are replace-all and are not merged with existing. + type: object + required: [properties] + properties: + properties: { $ref: "#/components/schemas/PropertyMutationList" } + + Property: + type: object + required: [fid, name, type, sort, value] + properties: + fid: { $ref: "#/components/schemas/Identifier" } + name: { $ref: "#/components/schemas/PropertyName" } + type: { $ref: "#/components/schemas/PropertyType" } + value: { $ref: "#/components/schemas/PropertyValue" } + sort: + type: string + + PropertyList: + type: array + items: { $ref: "#/components/schemas/Property" } + + PropertyName: + type: string + + PropertyType: + type: string + enum: + - text + - number + - timestamp + - boolean + + PropertyValue: + type: string + + PropertySortKey: + type: string + + PropertyMutation: + description: | + A property mutation is a change to a property on a node. It can be used + to update existing properties or add new properties to a node. When a + property already exists by name/fid, type and sort columns are optional. + type: object + required: [name, value] + properties: + fid: { $ref: "#/components/schemas/Identifier" } + name: { $ref: "#/components/schemas/PropertyName" } + value: { $ref: "#/components/schemas/PropertyValue" } + type: { $ref: "#/components/schemas/PropertyType" } + sort: { $ref: "#/components/schemas/PropertySortKey" } + + PropertyMutationList: + type: array + items: { $ref: "#/components/schemas/PropertyMutation" } + + PropertySchema: + type: object + required: [fid, name, type, sort] + properties: + fid: { $ref: "#/components/schemas/Identifier" } + name: { $ref: "#/components/schemas/PropertyName" } + type: { $ref: "#/components/schemas/PropertyType" } + sort: + type: string + + PropertySchemaList: + type: array + items: { $ref: "#/components/schemas/PropertySchema" } + + PropertySchemaMutableProps: + description: | + Mutating property schemas permits updating existing fields as well as + adding new fields. The discinction is determined by the presence of the + `id` field. When an `id` field is provided, the operation is treated as + an update operation where any of the other fields will be used to write + new values. If an `id` field is omitted, the schema is considered a new + field and is subject to a uniqueness constraint on the `name` field. + type: object + required: [name, type, sort] + properties: + fid: { $ref: "#/components/schemas/Identifier" } + name: { $ref: "#/components/schemas/PropertyName" } + type: { $ref: "#/components/schemas/PropertyType" } + sort: + type: string + + NodePositionMutableProps: + type: object + description: | + Parameters for repositioning a node in the hierarchy. You may change the + node's parent using `parent`, and/or reposition it among its siblings + using one of: `before`, `after`, or `index`. Using multiple reordering + properties is not allowed. + properties: + parent: + type: string + nullable: true + description: | + Optional new parent node slug. Set to null to move node to the root. + before: + type: string + description: Move this node before the sibling with this ID. + after: + type: string + description: Move this node after the sibling with this ID. + example: + parent: "d031a8do2dtqtahe9nl0" + before: "cc5lnd2s1s4652adtu50" + + # + # 888 d8b 888 + # 888 Y8P 888 + # 888 888 + # 888 888 88888b. 888 888 + # 888 888 888 "88b 888 .88P + # 888 888 888 888 888888K + # 888 888 888 888 888 "88b + # 88888888 888 888 888 888 888 + # + + Link: + description: | + A web address with content information such as title, description, etc. + type: object + required: [url, assets, slug, domain] + allOf: + - { $ref: "#/components/schemas/CommonProperties" } + - { $ref: "#/components/schemas/LinkReferenceProps" } + - { $ref: "#/components/schemas/LinkProps" } + - { $ref: "#/components/schemas/DatagraphRecommendations" } + + LinkReference: + description: | + A minimal object used to refer to a link without sending too much data. + type: object + allOf: + - { $ref: "#/components/schemas/CommonProperties" } + - { $ref: "#/components/schemas/LinkReferenceProps" } + + LinkProps: + description: | + All the resources that a link has been referenced in. May be large. + required: [assets, posts, nodes, collections] + properties: + assets: { $ref: "#/components/schemas/AssetList" } + posts: { $ref: "#/components/schemas/PostReferenceList" } + nodes: { $ref: "#/components/schemas/NodeList" } + + LinkReferenceProps: + type: object + required: [url, slug, domain] + properties: + url: { $ref: "#/components/schemas/URL" } + slug: { $ref: "#/components/schemas/LinkSlug" } + domain: { $ref: "#/components/schemas/LinkDomain" } + title: { $ref: "#/components/schemas/LinkTitle" } + description: { $ref: "#/components/schemas/LinkDescription" } + favicon_image: { $ref: "#/components/schemas/Asset" } + primary_image: { $ref: "#/components/schemas/Asset" } + + LinkTitle: + type: string + example: "The Open Graph Protocol" + + LinkDescription: + type: string + example: "The Open Graph protocol enables any web page to become a rich object in a social graph." + + LinkSlug: + type: string + example: github-com-southclaws-storyden + + LinkDomain: + type: string + example: github.com + + LinkReferenceList: + type: array + items: { $ref: "#/components/schemas/LinkReference" } + + LinkListResult: + allOf: + - { $ref: "#/components/schemas/PaginatedResult" } + - type: object + required: [links] + properties: + links: { $ref: "#/components/schemas/LinkReferenceList" } + + LinkInitialProps: + type: object + required: [url] + properties: + url: { $ref: "#/components/schemas/URL" } + title: { $ref: "#/components/schemas/LinkTitle" } + description: { $ref: "#/components/schemas/LinkDescription" } + + # + # 8888888b. 888 888 + # 888 "Y88b 888 888 + # 888 888 888 888 + # 888 888 8888b. 888888 8888b. .d88b. 888d888 8888b. 88888b. 88888b. + # 888 888 "88b 888 "88b d88P"88b 888P" "88b 888 "88b 888 "88b + # 888 888 .d888888 888 .d888888 888 888 888 .d888888 888 888 888 888 + # 888 .d88P 888 888 Y88b. 888 888 Y88b 888 888 888 888 888 d88P 888 888 + # 8888888P" "Y888888 "Y888 "Y888888 "Y88888 888 "Y888888 88888P" 888 888 + # 888 888 + # Y8b d88P 888 + # "Y88P" 888 + # + + DatagraphSearchResult: + type: object + allOf: + - { $ref: "#/components/schemas/PaginatedResult" } + - type: object + required: [items] + properties: + items: { $ref: "#/components/schemas/DatagraphItemList" } + + DatagraphMatchResult: + type: object + required: [items] + properties: + items: { $ref: "#/components/schemas/DatagraphMatchList" } + + DatagraphItemList: + type: array + items: { $ref: "#/components/schemas/DatagraphItem" } + + DatagraphMatchList: + type: array + items: { $ref: "#/components/schemas/DatagraphMatch" } + + DatagraphItem: + type: object + discriminator: + propertyName: kind + mapping: + post: "#/components/schemas/DatagraphItemPost" + thread: "#/components/schemas/DatagraphItemThread" + reply: "#/components/schemas/DatagraphItemReply" + node: "#/components/schemas/DatagraphItemNode" + profile: "#/components/schemas/DatagraphItemProfile" + oneOf: + - $ref: "#/components/schemas/DatagraphItemPost" + - $ref: "#/components/schemas/DatagraphItemThread" + - $ref: "#/components/schemas/DatagraphItemReply" + - $ref: "#/components/schemas/DatagraphItemNode" + - $ref: "#/components/schemas/DatagraphItemProfile" + + DatagraphItemPost: + type: object + required: [kind, ref] + properties: + kind: { $ref: "#/components/schemas/DatagraphItemKind" } + ref: { $ref: "#/components/schemas/Post" } + + DatagraphItemThread: + type: object + required: [kind, ref] + properties: + kind: { $ref: "#/components/schemas/DatagraphItemKind" } + ref: { $ref: "#/components/schemas/Thread" } + + DatagraphItemReply: + type: object + required: [kind, ref] + properties: + kind: { $ref: "#/components/schemas/DatagraphItemKind" } + ref: { $ref: "#/components/schemas/Reply" } + + DatagraphItemNode: + type: object + required: [kind, ref] + properties: + kind: { $ref: "#/components/schemas/DatagraphItemKind" } + ref: { $ref: "#/components/schemas/Node" } + + DatagraphItemProfile: + type: object + required: [kind, ref] + properties: + kind: { $ref: "#/components/schemas/DatagraphItemKind" } + ref: { $ref: "#/components/schemas/PublicProfile" } + + DatagraphMatch: + type: object + required: [id, kind, slug, name] + properties: + id: { $ref: "#/components/schemas/Identifier" } + kind: { $ref: "#/components/schemas/DatagraphItemKind" } + slug: + type: string + name: + type: string + description: + type: string + + DatagraphItemKind: + type: string + enum: [post, thread, reply, node, collection, profile, event] + + DatagraphRecommendations: + required: [recomentations] + properties: + recomentations: { $ref: "#/components/schemas/DatagraphItemList" } + + # + # 8888888888 888 + # 888 888 + # 888 888 + # 8888888 888 888 .d88b. 88888b. 888888 + # 888 888 888 d8P Y8b 888 "88b 888 + # 888 Y88 88P 88888888 888 888 888 + # 888 Y8bd8P Y8b. 888 888 Y88b. + # 8888888888 Y88P "Y8888 888 888 "Y888 + # + + Event: + description: | + An event represents any kind of event, such as an online or in-person + gathering, a conference, a workshop, a webinar, etc. Events will contain + a start and end timestamp and may have a location and other metadata. + + Each event also gets its own thread for discussion and planning. This is + automatically created for every new event and is linked to the event. + type: object + allOf: + - { $ref: "#/components/schemas/CommonProperties" } + - { $ref: "#/components/schemas/EventReferenceProps" } + - { $ref: "#/components/schemas/EventProps" } + + EventReference: + description: | + A minimal object used to refer to an event without providing all data. + type: object + allOf: + - { $ref: "#/components/schemas/CommonProperties" } + - { $ref: "#/components/schemas/EventReferenceProps" } + + EventList: + type: array + items: { $ref: "#/components/schemas/EventReference" } + + EventListResult: + allOf: + - { $ref: "#/components/schemas/PaginatedResult" } + - type: object + required: [events] + properties: + events: { $ref: "#/components/schemas/EventList" } + + EventProps: + type: object + required: [thread] + properties: + thread: { $ref: "#/components/schemas/Thread" } + + EventReferenceProps: + type: object + required: + - name + - slug + - description + - time_range + - participants + - participation_policy + - visibility + - location + properties: + name: { $ref: "#/components/schemas/EventName" } + slug: { $ref: "#/components/schemas/EventSlug" } + description: { $ref: "#/components/schemas/EventDescription" } + time_range: { $ref: "#/components/schemas/EventTimeRange" } + primary_image: { $ref: "#/components/schemas/Asset" } + participants: { $ref: "#/components/schemas/EventParticipantList" } + participation_policy: + { $ref: "#/components/schemas/EventParticipationPolicy" } + visibility: { $ref: "#/components/schemas/Visibility" } + location: { $ref: "#/components/schemas/EventLocation" } + capacity: { $ref: "#/components/schemas/EventCapacity" } + meta: { $ref: "#/components/schemas/Metadata" } + + EventInitialProps: + type: object + required: + - name + - content + - time_range + - participation_policy + - visibility + - thread_category_id + properties: + name: { $ref: "#/components/schemas/EventName" } + slug: { $ref: "#/components/schemas/EventSlug" } + description: { $ref: "#/components/schemas/EventDescription" } + content: { $ref: "#/components/schemas/PostContent" } + time_range: { $ref: "#/components/schemas/EventTimeRange" } + primary_image_asset_id: { $ref: "#/components/schemas/AssetID" } + participation_policy: + { $ref: "#/components/schemas/EventParticipationPolicy" } + visibility: { $ref: "#/components/schemas/Visibility" } + location: { $ref: "#/components/schemas/EventLocation" } + capacity: { $ref: "#/components/schemas/EventCapacity" } + thread_category_id: { $ref: "#/components/schemas/Identifier" } + meta: { $ref: "#/components/schemas/Metadata" } + + EventMutableProps: + type: object + properties: + name: { $ref: "#/components/schemas/EventName" } + slug: { $ref: "#/components/schemas/EventSlug" } + description: { $ref: "#/components/schemas/EventDescription" } + content: { $ref: "#/components/schemas/PostContent" } + time_range: { $ref: "#/components/schemas/EventTimeRange" } + primary_image_asset_id: { $ref: "#/components/schemas/AssetID" } + participation_policy: + { $ref: "#/components/schemas/EventParticipationPolicy" } + visibility: { $ref: "#/components/schemas/Visibility" } + location: { $ref: "#/components/schemas/EventLocation" } + capacity: { $ref: "#/components/schemas/EventCapacity" } + meta: { $ref: "#/components/schemas/Metadata" } + + EventName: + type: string + example: "Friday beers, coding and design hack night" + + EventSlug: + type: string + example: friday-beers-coding-design-hack-night + + EventDescription: + type: string + example: "Join us for a night of coding, design and beers!" + + EventTimeRange: + description: | + A time range for an event, which may span multiple days or times of day. + type: object + required: [start, end] + properties: + start: + type: string + format: date-time + end: + type: string + format: date-time + + EventParticipantList: + description: A list of attendees, hosts and invites for an event. + type: array + items: { $ref: "#/components/schemas/EventParticipant" } + + EventParticipant: + type: object + required: [profile, role, status] + properties: + profile: { $ref: "#/components/schemas/ProfileReference" } + role: { $ref: "#/components/schemas/EventParticipantRole" } + status: { $ref: "#/components/schemas/EventParticipationStatus" } + + EventParticipantMutableProps: + type: object + default: { status: "requested", role: "attendee" } + properties: + role: { $ref: "#/components/schemas/EventParticipantRole" } + status: { $ref: "#/components/schemas/EventParticipationStatus" } + + EventParticipantRole: + type: string + enum: [attendee, host] + + EventParticipationStatus: + type: string + enum: [requested, invited, attending, declined] + + EventParticipationPolicy: + type: string + enum: [open, closed, invite_only] + + EventLocation: + description: | + An event location can be either physical or virtual. A physical location + may have an address or coordinates. A virtual location may have a link. + type: object + discriminator: + propertyName: location_type + mapping: + physical: "#/components/schemas/EventLocationPhysical" + virtual: "#/components/schemas/EventLocationVirtual" + oneOf: + - $ref: "#/components/schemas/EventLocationPhysical" + - $ref: "#/components/schemas/EventLocationVirtual" + + EventLocationType: + type: string + enum: [physical, virtual] + + EventLocationPhysical: + description: | + A physical location for an event, such as a venue, a park, a street + address, etc. This location may have a name, address, and coordinates. + A URL may also be added for a Google maps link etc. + type: object + required: [location_type, name] + properties: + location_type: + $ref: "#/components/schemas/EventLocationType" + name: + type: string + address: + type: string + latitude: + type: number + longitude: + type: number + url: + type: string + + EventLocationVirtual: + description: | + A virtual location for an event, such as a URL, a video conference + link, a Discord server, etc. This location may have a URL. + type: object + required: [location_type, name] + properties: + location_type: + $ref: "#/components/schemas/EventLocationType" + name: + type: string + url: + type: string + + EventCapacity: + description: The maximum number of attendees that can attend the event. + type: integer + + # + # 8888888b. 888 888 + # 888 Y88b 888 888 + # 888 888 888 888 + # 888 d88P .d88b. 88888b. .d88b. 888888 + # 8888888P" d88""88b 888 "88b d88""88b 888 + # 888 T88b 888 888 888 888 888 888 888 + # 888 T88b Y88..88P 888 d88P Y88..88P Y88b. + # 888 T88b "Y88P" 88888P" "Y88P" "Y888 + # + + Robot: + allOf: + - $ref: "#/components/schemas/CommonProperties" + - $ref: "#/components/schemas/RobotProps" + + RobotReference: + type: object + description: A minimal reference to a Robot for message attribution. + required: [id, name] + properties: + id: { $ref: "#/components/schemas/Identifier" } + name: + type: string + description: The name of the robot + + RobotProps: + type: object + required: [name, description, playbook, model, author, tools] + properties: + name: + type: string + description: The name of the robot + description: + type: string + description: Human-readable description of the robot's purpose + playbook: + type: string + description: The directive/system prompt that defines the robot's behavior + model: + $ref: "#/components/schemas/RobotModelRef" + workspace_id: + allOf: + - $ref: "#/components/schemas/NullableIdentifier" + nullable: true + description: Optional default workspace template for this robot. + author: { $ref: "#/components/schemas/ProfileReference" } + tools: { $ref: "#/components/schemas/RobotToolNameList" } + meta: { $ref: "#/components/schemas/Metadata" } + + RobotWorkspaceProvider: + description: Robot workspace provider type. + type: string + enum: [local, sprites] + + RobotWorkspaceProviderInfo: + type: object + required: [provider, name] + properties: + provider: + $ref: "#/components/schemas/RobotWorkspaceProvider" + name: + type: string + description: Display name for the workspace provider. + + RobotWorkspace: + allOf: + - $ref: "#/components/schemas/CommonProperties" + - $ref: "#/components/schemas/RobotWorkspaceProps" + + RobotWorkspaceProps: + type: object + required: + [ + name, + description, + provider, + config, + allow_untrusted_commands, + meta, + created_by, + ] + properties: + name: + type: string + description: The name of the workspace template. + description: + type: string + description: Human-readable description of the workspace template. + provider: + $ref: "#/components/schemas/RobotWorkspaceProvider" + config: + type: object + additionalProperties: true + description: Provider-specific template configuration. + allow_untrusted_commands: + type: boolean + description: Allow robots using this workspace template to run arbitrary shell commands. + meta: { $ref: "#/components/schemas/Metadata" } + created_by: { $ref: "#/components/schemas/ProfileReference" } + + RobotWorkspaceInstance: + allOf: + - $ref: "#/components/schemas/CommonProperties" + - $ref: "#/components/schemas/RobotWorkspaceInstanceProps" + + RobotWorkspaceInstanceProps: + type: object + required: [workspace_id, provider, provider_state, meta, created_by] + properties: + workspace_id: + $ref: "#/components/schemas/Identifier" + provider: + $ref: "#/components/schemas/RobotWorkspaceProvider" + provider_state: + type: object + additionalProperties: true + description: Provider-specific live instance state. + meta: { $ref: "#/components/schemas/Metadata" } + created_by: { $ref: "#/components/schemas/ProfileReference" } + + RobotWorkspaceMount: + type: object + required: + - workspace_id + - workspace_instance_id + - provider + - allow_untrusted_commands + properties: + workspace_id: + $ref: "#/components/schemas/Identifier" + workspace_instance_id: + $ref: "#/components/schemas/Identifier" + provider: + $ref: "#/components/schemas/RobotWorkspaceProvider" + allow_untrusted_commands: + type: boolean + description: | + Whether this mounted workspace allows arbitrary shell commands. + meta: { $ref: "#/components/schemas/Metadata" } + + RobotSession: + allOf: + - $ref: "#/components/schemas/CommonProperties" + - $ref: "#/components/schemas/RobotSessionRefProps" + - $ref: "#/components/schemas/RobotSessionProps" + + RobotSessionProps: + type: object + required: [message_list] + properties: + message_list: { $ref: "#/components/schemas/PaginatedRobotMessageList" } + active_robot_id: + type: string + description: Most recently active Robot ID for this session, including built-in Robot IDs. + active_workspace: + $ref: "#/components/schemas/RobotWorkspaceMount" + + RobotSessionRef: + allOf: + - $ref: "#/components/schemas/CommonProperties" + - $ref: "#/components/schemas/RobotSessionRefProps" + + RobotSessionRefProps: + type: object + required: [name, created_by] + properties: + name: { type: string } + created_by: { $ref: "#/components/schemas/ProfileReference" } + + # - + # Vercel AI SDK UIMessage format + # These schemas match the structure from @ai-sdk/react for compatibility + # - + + UIMessage: + type: object + required: [id, role, parts] + description: | + A message in the Vercel AI SDK format. This is compatible with the + UIMessage interface from @ai-sdk/react and can be used directly with + the useChat hook. + properties: + id: + type: string + description: Unique message identifier + role: + type: string + enum: [system, user, assistant] + description: The role of the message sender + parts: + type: array + items: { $ref: "#/components/schemas/UIMessagePart" } + description: The parts that make up the message content + metadata: + type: object + additionalProperties: true + description: Optional metadata associated with the message + + UIMessagePart: + oneOf: + - $ref: "#/components/schemas/TextUIPart" + - $ref: "#/components/schemas/ReasoningUIPart" + - $ref: "#/components/schemas/ToolUIPart" + - $ref: "#/components/schemas/FileUIPart" + - $ref: "#/components/schemas/DataPart" + discriminator: + propertyName: type + mapping: + text: "#/components/schemas/TextUIPart" + reasoning: "#/components/schemas/ReasoningUIPart" + "dynamic-tool": "#/components/schemas/ToolUIPart" + file: "#/components/schemas/FileUIPart" + "data-render_card": "#/components/schemas/DataPart" + description: | + A part of a UIMessage. Can be text, reasoning, tool invocation, file, or custom data. + required: [type] + properties: + type: + type: string + description: The type of the message part + enum: [text, reasoning, dynamic-tool, file, data-render_card] + + TextUIPart: + type: object + required: [type, text] + description: A text part of a message + properties: + type: + type: string + enum: [text] + text: + type: string + description: The text content + state: + type: string + enum: [streaming, done] + description: The state of the text part + + ReasoningUIPart: + type: object + required: [type, text] + description: A reasoning part of a message (extended thinking/reasoning) + properties: + type: + type: string + enum: [reasoning] + text: + type: string + description: The reasoning text + state: + type: string + enum: [streaming, done] + description: The state of the reasoning part + + ToolUIPart: + oneOf: + - $ref: "#/components/schemas/ToolUIPartInputStreaming" + - $ref: "#/components/schemas/ToolUIPartInputAvailable" + - $ref: "#/components/schemas/ToolUIPartApprovalRequested" + - $ref: "#/components/schemas/ToolUIPartApprovalResponded" + - $ref: "#/components/schemas/ToolUIPartOutputAvailable" + - $ref: "#/components/schemas/ToolUIPartOutputError" + discriminator: + propertyName: state + mapping: + input-streaming: "#/components/schemas/ToolUIPartInputStreaming" + input-available: "#/components/schemas/ToolUIPartInputAvailable" + approval-requested: "#/components/schemas/ToolUIPartApprovalRequested" + approval-responded: "#/components/schemas/ToolUIPartApprovalResponded" + output-available: "#/components/schemas/ToolUIPartOutputAvailable" + output-error: "#/components/schemas/ToolUIPartOutputError" + description: A tool invocation part (dynamic-tool format matching Vercel AI SDK) + + ToolUIPartBase: + type: object + required: [type, toolCallId, toolName] + properties: + type: + type: string + enum: [dynamic-tool] + description: Type identifier for dynamic tool calls + toolCallId: + type: string + description: Unique ID for this tool call + toolName: + type: string + description: Name of the tool being called + title: + type: string + description: Optional title for the tool call + providerExecuted: + type: boolean + description: Whether the tool was executed by the provider + callProviderMetadata: + $ref: "#/components/schemas/ArbitraryData" + + ToolUIPartInputStreaming: + allOf: + - $ref: "#/components/schemas/ToolUIPartBase" + - type: object + required: [state, input] + properties: + state: + type: string + enum: [input-streaming] + input: + type: object + additionalProperties: true + description: Partial input (streaming) + + ToolUIPartInputAvailable: + allOf: + - $ref: "#/components/schemas/ToolUIPartBase" + - type: object + required: [state, input] + properties: + state: + type: string + enum: [input-available] + input: + type: object + additionalProperties: true + description: Complete input arguments + + ToolApprovalState: + type: object + required: [id] + properties: + id: + type: string + approved: + type: boolean + reason: + type: string + + ToolUIPartApprovalRequested: + allOf: + - $ref: "#/components/schemas/ToolUIPartBase" + - type: object + required: [state, input, approval] + properties: + state: + type: string + enum: [approval-requested] + input: + type: object + additionalProperties: true + description: Complete input arguments awaiting approval + approval: + $ref: "#/components/schemas/ToolApprovalState" + + ToolUIPartApprovalResponded: + allOf: + - $ref: "#/components/schemas/ToolUIPartBase" + - type: object + required: [state, input, approval] + properties: + state: + type: string + enum: [approval-responded] + input: + type: object + additionalProperties: true + description: Complete input arguments that received an approval decision + approval: + $ref: "#/components/schemas/ToolApprovalState" + + ToolUIPartOutputAvailable: + allOf: + - $ref: "#/components/schemas/ToolUIPartBase" + - type: object + required: [state, input, output] + properties: + state: + type: string + enum: [output-available] + input: + type: object + additionalProperties: true + description: Input arguments used + output: + type: object + additionalProperties: true + description: Output result from the tool + preliminary: + type: boolean + description: Whether this is a preliminary result + + ToolUIPartOutputError: + allOf: + - $ref: "#/components/schemas/ToolUIPartBase" + - type: object + required: [state, input, errorText] + properties: + state: + type: string + enum: [output-error] + input: + type: object + additionalProperties: true + description: Input that caused the error + errorText: + type: string + description: Error message + + FileUIPart: + type: object + required: [type, mediaType, url] + description: A file part of a message + properties: + type: + type: string + enum: [file] + mediaType: + type: string + description: IANA media type of the file + filename: + type: string + description: Optional filename + url: + type: string + description: URL to the file (can be a data URL or hosted URL) + + # - + # Robot session messages - extends UIMessage with our metadata + # - + + RobotSessionMessage: + allOf: + - $ref: "#/components/schemas/UIMessage" + - type: object + required: [created_at] + properties: + created_at: + type: string + format: date-time + description: When the message was sent + robot: + $ref: "#/components/schemas/RobotReference" + description: Robot that generated this message + author: + $ref: "#/components/schemas/ProfileReference" + description: Human author of the message + + RobotSessionMessageList: + type: array + items: { $ref: "#/components/schemas/RobotSessionMessage" } + + PaginatedRobotMessageList: + type: object + required: [page_size, results, messages] + properties: + page_size: + type: integer + results: + type: integer + next_before: + $ref: "#/components/schemas/Identifier" + messages: { $ref: "#/components/schemas/RobotSessionMessageList" } + + RobotToolNameList: + description: A list of tool names that the robot can use. + type: array + items: + type: string + + RobotToolSource: + type: string + enum: [native, mcp, plugin] + + RobotToolInfo: + type: object + required: + - id + - callable_name + - source + - available + - description + - requires_confirmation + properties: + id: + type: string + description: Stable tool ID stored on Robot configurations. + callable_name: + type: string + description: ADK/model-safe function name used at runtime. + name: + type: string + description: Human-readable tool title. + description: + type: string + source: + $ref: "#/components/schemas/RobotToolSource" + available: + type: boolean + description: Whether the tool can currently be attached to a Robot run. + requires_confirmation: + type: boolean + description: Whether Robot runs must pause for human approval before executing this tool. + + RobotToolInfoList: + type: array + items: { $ref: "#/components/schemas/RobotToolInfo" } + + RobotToolListResult: + type: object + required: [tools] + properties: + tools: { $ref: "#/components/schemas/RobotToolInfoList" } + + RobotMCPTool: + type: object + required: + - id + - remote_name + - callable_name + - description + - enabled + - available + - last_seen_at + properties: + id: + type: string + remote_name: + type: string + callable_name: + type: string + title: + type: string + description: + type: string + enabled: + type: boolean + available: + type: boolean + last_seen_at: + type: string + format: date-time + + RobotMCPToolList: + type: array + items: { $ref: "#/components/schemas/RobotMCPTool" } + + RobotMCPServer: + allOf: + - $ref: "#/components/schemas/CommonProperties" + - type: object + required: + [ + name, + slug, + description, + endpoint_url, + enabled, + has_bearer_token, + has_oauth_token, + tools, + ] + properties: + name: + type: string + slug: + type: string + description: Immutable namespace used in MCP tool IDs. + description: + type: string + endpoint_url: + type: string + format: uri + oauth_remote_connection_id: + $ref: "#/components/schemas/Identifier" + enabled: + type: boolean + has_bearer_token: + type: boolean + description: | + Whether this server has a stored Bearer token. The token value + is never returned. + has_oauth_token: + type: boolean + description: | + Whether this server is linked to a remote OAuth connection with + a stored access token. + last_refreshed_at: + type: string + format: date-time + last_error: + type: string + tools: + $ref: "#/components/schemas/RobotMCPToolList" + + RobotMCPServerList: + type: array + items: { $ref: "#/components/schemas/RobotMCPServer" } + + RobotMCPServerListResult: + type: object + required: [servers] + properties: + servers: { $ref: "#/components/schemas/RobotMCPServerList" } + + RobotMCPServerInitialProps: + type: object + required: [name, endpoint_url] + properties: + name: + type: string + slug: + type: string + description: Optional immutable namespace. If omitted, Storyden derives one from the name. + description: + type: string + endpoint_url: + type: string + format: uri + oauth_remote_connection_id: + $ref: "#/components/schemas/Identifier" + description: | + Optional remote OAuth connection that supplies the Bearer token for + this MCP server after authorization completes. + enabled: + type: boolean + default: true + bearer_token: + type: string + description: Write-only Bearer token for the MCP server. + + RobotMCPServerProbeProps: + type: object + required: [url] + properties: + url: + type: string + format: uri + description: | + User-provided MCP URL. This may be a root domain when the host + exposes an MCP Server Card. + bearer_token: + type: string + description: Optional Bearer token to use for the active probe. + + RobotMCPServerCardRemote: + type: object + required: [type, url] + properties: + type: + type: string + url: + type: string + format: uri + supportedProtocolVersions: + type: array + items: { type: string } + + RobotMCPServerCard: + type: object + required: [name, version, description] + properties: + name: + type: string + version: + type: string + description: + type: string + title: + type: string + websiteUrl: + type: string + format: uri + remotes: + type: array + items: { $ref: "#/components/schemas/RobotMCPServerCardRemote" } + + RobotMCPServerProbeResult: + type: object + required: [input_url, endpoint_url, active] + properties: + input_url: + type: string + format: uri + endpoint_url: + type: string + format: uri + server_card_url: + type: string + format: uri + server_card: + $ref: "#/components/schemas/RobotMCPServerCard" + remote_type: + type: string + active: + type: boolean + probe_error: + type: string + + RobotMCPServerMutableProps: + type: object + properties: + name: + type: string + description: + type: string + endpoint_url: + type: string + format: uri + enabled: + type: boolean + bearer_token: + type: string + description: Write-only Bearer token. Omit to preserve the current token. + clear_bearer_token: + type: boolean + description: Remove the stored Bearer token. + + RobotModelProvider: + description: The model provider namespace, such as openai, anthropic, or a plugin-declared provider. + type: string + example: openai + + RobotModelName: + description: The provider-local model name. + type: string + example: gpt-4.1-mini + + RobotModelRef: + description: Fully-qualified Robot model reference in provider/model format. + type: string + pattern: "^[^/]+/.+$" + example: openai/gpt-4.1-mini + + RobotProviderSettings: + type: object + required: [enabled, has_api_key, requires_api_key] + properties: + enabled: + type: boolean + has_api_key: + type: boolean + description: Whether this provider has an API key configured. The key value is never returned. + requires_api_key: + type: boolean + description: Whether this provider expects API key configuration through the Robots provider settings API. + + RobotProviderMutableSettings: + type: object + properties: + enabled: + type: boolean + api_key: + type: string + description: Write-only provider API key. Omit to preserve the current key. + clear_api_key: + type: boolean + description: Remove the stored provider API key. + + RobotModelInfo: + type: object + required: [ref, provider, model] + properties: + ref: + $ref: "#/components/schemas/RobotModelRef" + provider: + $ref: "#/components/schemas/RobotModelProvider" + model: + $ref: "#/components/schemas/RobotModelName" + + RobotModelInfoList: + type: array + items: { $ref: "#/components/schemas/RobotModelInfo" } + + RobotModelCacheStatus: + type: object + required: [stale] + properties: + last_refreshed_at: + type: string + format: date-time + last_error: + type: string + stale: + type: boolean + + RobotProviderStatus: + type: object + required: [provider, supported, settings, cache, models] + properties: + provider: + $ref: "#/components/schemas/RobotModelProvider" + supported: + type: boolean + settings: + $ref: "#/components/schemas/RobotProviderSettings" + cache: + $ref: "#/components/schemas/RobotModelCacheStatus" + models: + $ref: "#/components/schemas/RobotModelInfoList" + + RobotProviderStatusList: + type: array + items: { $ref: "#/components/schemas/RobotProviderStatus" } + + RobotProviderListResult: + type: object + required: [providers] + properties: + providers: { $ref: "#/components/schemas/RobotProviderStatusList" } + + RobotModelListResult: + type: object + required: [models] + properties: + models: + $ref: "#/components/schemas/RobotModelInfoList" + + # - + # Robot mutation types + # - + + RobotInitialProps: + type: object + required: [name, description, playbook] + properties: + name: + type: string + description: The name of the robot + description: + type: string + description: Human-readable description of the robot's purpose + playbook: + type: string + description: The directive/system prompt that defines the robot's behavior + model: + $ref: "#/components/schemas/RobotModelRef" + workspace_id: + $ref: "#/components/schemas/Identifier" + tools: + $ref: "#/components/schemas/RobotToolNameList" + meta: { $ref: "#/components/schemas/Metadata" } + + RobotMutableProps: + type: object + properties: + name: + type: string + description: The name of the robot + description: + type: string + description: Human-readable description of the robot's purpose + playbook: + type: string + description: The directive/system prompt that defines the robot's behavior + model: + $ref: "#/components/schemas/RobotModelRef" + workspace_id: + allOf: + - $ref: "#/components/schemas/NullableIdentifier" + nullable: true + tools: + $ref: "#/components/schemas/RobotToolNameList" + meta: { $ref: "#/components/schemas/Metadata" } + + RobotWorkspaceInitialProps: + type: object + required: [name, description] + properties: + name: + type: string + description: The name of the workspace template. + description: + type: string + description: Human-readable description of the workspace template. + provider: + $ref: "#/components/schemas/RobotWorkspaceProvider" + config: + type: object + additionalProperties: true + description: Provider-specific template configuration. + allow_untrusted_commands: + type: boolean + description: | + Allow robots using this template to run arbitrary shell commands. + meta: { $ref: "#/components/schemas/Metadata" } + + RobotWorkspaceMutableProps: + type: object + properties: + name: + type: string + description: The name of the workspace template. + description: + type: string + description: Human-readable description of the workspace template. + config: + type: object + additionalProperties: true + description: Provider-specific template configuration. + allow_untrusted_commands: + type: boolean + description: | + Allow robots using this template to run arbitrary shell commands. + meta: { $ref: "#/components/schemas/Metadata" } + + RobotWorkspaceMountRequest: + type: object + description: Workspace mount request. Provide either workspace_id or workspace_instance_id, not both. + properties: + workspace_id: + $ref: "#/components/schemas/Identifier" + workspace_instance_id: + $ref: "#/components/schemas/Identifier" + + RobotChatRequest: + type: object + required: [id, messages] + properties: + id: + type: string + description: Unique identifier for this chat request + threadId: + type: string + description: Optional thread ID if this chat is part of a thread context + sessionId: + type: string + description: Session ID to continue an existing conversation + robotId: + type: string + description: Specific robot ID to use for this message + messages: + type: array + description: Array of chat messages in the conversation + items: + $ref: "#/components/schemas/UIMessage" + data: + type: object + description: Additional data for the chat request + additionalProperties: true + context: + $ref: "#/components/schemas/RobotChatContext" + description: Context about the page/item the user is viewing + workspace: + $ref: "#/components/schemas/RobotWorkspaceMountRequest" + + RobotChatContext: + type: object + properties: + datagraph_item: + $ref: "#/components/schemas/DatagraphItemRef" + description: Optional reference to a datagraph item if the user is viewing one + page_type: + type: string + description: Human-readable page type if not viewing a specific datagraph item + + DatagraphItemRef: + type: object + properties: + id: + $ref: "#/components/schemas/Identifier" + description: The unique identifier of the datagraph item + kind: + type: string + enum: [thread, node, profile, link, collection] + description: The type of datagraph item + slug: + type: string + description: Human-readable URL slug for the item + name: + type: string + description: Display name of the item + + # - + # Robot listing types + # - + + RobotList: + type: array + items: { $ref: "#/components/schemas/Robot" } + + RobotWorkspaceList: + type: array + items: { $ref: "#/components/schemas/RobotWorkspace" } + + RobotWorkspaceInstanceList: + type: array + items: { $ref: "#/components/schemas/RobotWorkspaceInstance" } + + RobotWorkspaceProviderList: + type: array + items: { $ref: "#/components/schemas/RobotWorkspaceProviderInfo" } + + RobotsListResult: + allOf: + - $ref: "#/components/schemas/PaginatedResult" + - type: object + required: [robots] + properties: + robots: { $ref: "#/components/schemas/RobotList" } + + RobotWorkspacesListResult: + allOf: + - $ref: "#/components/schemas/PaginatedResult" + - type: object + required: [workspaces] + properties: + workspaces: { $ref: "#/components/schemas/RobotWorkspaceList" } + + RobotWorkspaceInstancesListResult: + allOf: + - $ref: "#/components/schemas/PaginatedResult" + - type: object + required: [workspace_instances] + properties: + workspace_instances: + { $ref: "#/components/schemas/RobotWorkspaceInstanceList" } + + RobotWorkspaceProviderListResult: + type: object + required: [providers] + properties: + providers: { $ref: "#/components/schemas/RobotWorkspaceProviderList" } + + RobotSessionList: + type: array + items: { $ref: "#/components/schemas/RobotSessionRef" } + + RobotSessionsListResult: + allOf: + - $ref: "#/components/schemas/PaginatedResult" + - type: object + required: [sessions] + properties: + sessions: { $ref: "#/components/schemas/RobotSessionList" } + + # - + # Vercel Streaming Protocol + # - + + NonEmptyString: + type: string + minLength: 1 + + Id: + type: string + minLength: 1 + + MediaType: + type: string + minLength: 1 + description: IANA media type (MIME type) such as image/png, application/pdf, text/plain. + pattern: "^[\\w!#$&^_.+-]+\\/[\\w!#$&^_.+-]+$" + + ArbitraryData: {} + # description: Arbitrary structured data for the custom data part. + # oneOf: + # - type: object + # additionalProperties: true + # - type: array + # items: + # $ref: "#/components/schemas/ArbitraryData" + # - type: string + # - type: number + # - type: integer + # - type: boolean + + StreamPart: + type: object + required: + - type + properties: + type: + "$ref": "#/components/schemas/NonEmptyString" + oneOf: + - "$ref": "#/components/schemas/StartPart" + - "$ref": "#/components/schemas/TextStartPart" + - "$ref": "#/components/schemas/TextDeltaPart" + - "$ref": "#/components/schemas/TextEndPart" + - "$ref": "#/components/schemas/ReasoningStartPart" + - "$ref": "#/components/schemas/ReasoningDeltaPart" + - "$ref": "#/components/schemas/ReasoningEndPart" + - "$ref": "#/components/schemas/SourceUrlPart" + - "$ref": "#/components/schemas/SourceDocumentPart" + - "$ref": "#/components/schemas/FilePart" + - "$ref": "#/components/schemas/DataPart" + - "$ref": "#/components/schemas/ErrorPart" + - "$ref": "#/components/schemas/ToolInputStartPart" + - "$ref": "#/components/schemas/ToolInputDeltaPart" + - "$ref": "#/components/schemas/ToolInputAvailablePart" + - "$ref": "#/components/schemas/ToolApprovalRequestPart" + - "$ref": "#/components/schemas/ToolOutputAvailablePart" + - "$ref": "#/components/schemas/StartStepPart" + - "$ref": "#/components/schemas/FinishStepPart" + - "$ref": "#/components/schemas/FinishMessagePart" + - "$ref": "#/components/schemas/AbortPart" + discriminator: + propertyName: type + mapping: + start: "#/components/schemas/StartPart" + text-start: "#/components/schemas/TextStartPart" + text-delta: "#/components/schemas/TextDeltaPart" + text-end: "#/components/schemas/TextEndPart" + reasoning-start: "#/components/schemas/ReasoningStartPart" + reasoning-delta: "#/components/schemas/ReasoningDeltaPart" + reasoning-end: "#/components/schemas/ReasoningEndPart" + source-url: "#/components/schemas/SourceUrlPart" + source-document: "#/components/schemas/SourceDocumentPart" + file: "#/components/schemas/FilePart" + error: "#/components/schemas/ErrorPart" + tool-input-start: "#/components/schemas/ToolInputStartPart" + tool-input-delta: "#/components/schemas/ToolInputDeltaPart" + tool-input-available: "#/components/schemas/ToolInputAvailablePart" + tool-approval-request: "#/components/schemas/ToolApprovalRequestPart" + tool-output-available: "#/components/schemas/ToolOutputAvailablePart" + start-step: "#/components/schemas/StartStepPart" + finish-step: "#/components/schemas/FinishStepPart" + finish: "#/components/schemas/FinishMessagePart" + abort: "#/components/schemas/AbortPart" + + StartPart: + type: object + required: + - type + - messageId + properties: + type: + const: start + messageId: + "$ref": "#/components/schemas/Id" + + TextStartPart: + type: object + required: + - type + - id + properties: + type: + const: text-start + id: + "$ref": "#/components/schemas/Id" + + TextDeltaPart: + type: object + required: + - type + - id + - delta + properties: + type: + const: text-delta + id: + "$ref": "#/components/schemas/Id" + delta: + type: string + + TextEndPart: + type: object + required: + - type + - id + properties: + type: + const: text-end + id: + "$ref": "#/components/schemas/Id" + + ReasoningStartPart: + type: object + required: + - type + - id + properties: + type: + const: reasoning-start + id: + "$ref": "#/components/schemas/Id" + + ReasoningDeltaPart: + type: object + required: + - type + - id + - delta + properties: + type: + const: reasoning-delta + id: + "$ref": "#/components/schemas/Id" + delta: + type: string + + ReasoningEndPart: + type: object + required: + - type + - id + properties: + type: + const: reasoning-end + id: + "$ref": "#/components/schemas/Id" + + SourceUrlPart: + type: object + required: + - type + - sourceId + - url + properties: + type: + const: source-url + sourceId: + "$ref": "#/components/schemas/NonEmptyString" + url: + "$ref": "#/components/schemas/URL" + + SourceDocumentPart: + type: object + required: + - type + - sourceId + - mediaType + - title + properties: + type: + const: source-document + sourceId: + "$ref": "#/components/schemas/NonEmptyString" + mediaType: + type: string + minLength: 1 + description: + Protocol doc example uses "file" here. Kept as a non-empty string + to avoid over-constraining. + title: + "$ref": "#/components/schemas/NonEmptyString" + url: + "$ref": "#/components/schemas/URL" + description: + Not shown in the provided example, but some implementations may + include a URL for the document. + + FilePart: + type: object + required: + - type + - url + - mediaType + properties: + type: + const: file + url: + "$ref": "#/components/schemas/URL" + mediaType: + "$ref": "#/components/schemas/MediaType" + + DataPart: + type: object + required: + - type + - data + properties: + type: + type: string + pattern: "^data-[A-Za-z0-9][A-Za-z0-9._-]*$" + description: Custom data part types, e.g. "data-weather". + id: + type: string + description: Optional identifier for this data part. + data: + $ref: "#/components/schemas/ArbitraryData" + + ErrorPart: + type: object + required: + - type + - errorText + properties: + type: + const: error + errorText: + type: string + + ToolInputStartPart: + type: object + required: + - type + - toolCallId + - toolName + properties: + type: + const: tool-input-start + toolCallId: + "$ref": "#/components/schemas/Id" + toolName: + "$ref": "#/components/schemas/NonEmptyString" + providerMetadata: + $ref: "#/components/schemas/ArbitraryData" + + ToolInputDeltaPart: + type: object + required: + - type + - toolCallId + - inputTextDelta + properties: + type: + const: tool-input-delta + toolCallId: + "$ref": "#/components/schemas/Id" + inputTextDelta: + type: string + + ToolInputAvailablePart: + type: object + required: + - type + - toolCallId + - toolName + - input + properties: + type: + const: tool-input-available + toolCallId: + "$ref": "#/components/schemas/Id" + toolName: + "$ref": "#/components/schemas/NonEmptyString" + input: + $ref: "#/components/schemas/ArbitraryData" + providerMetadata: + $ref: "#/components/schemas/ArbitraryData" + + ToolApprovalRequestPart: + type: object + required: + - type + - toolCallId + - approvalId + properties: + type: + const: tool-approval-request + toolCallId: + "$ref": "#/components/schemas/Id" + approvalId: + "$ref": "#/components/schemas/Id" + isAutomatic: + type: boolean + + ToolOutputAvailablePart: + type: object + required: + - type + - toolCallId + - output + properties: + type: + const: tool-output-available + toolCallId: + "$ref": "#/components/schemas/Id" + output: + $ref: "#/components/schemas/ArbitraryData" + + StartStepPart: + type: object + required: + - type + properties: + type: + const: start-step + + FinishStepPart: + type: object + required: + - type + properties: + type: + const: finish-step + + FinishMessagePart: + type: object + required: + - type + properties: + type: + const: finish + + AbortPart: + type: object + required: + - type + - reason + properties: + type: + const: abort + reason: + type: string + + securitySchemes: + browser: + type: apiKey + in: cookie + name: storyden-session + access_key: + type: http + scheme: bearer + oauth_token: + type: http + scheme: bearer + webauthn: + type: apiKey + in: cookie + name: "storyden-webauthn-session" +# +# ..:. .***= +# .+#%%%%%#+ ... :%%%+ +# #%%%+==+#. =%%%. :%%%+ +# :%%%%: -*%%%=-. :==+==: ---: :== ---- .---- .-===-:%%%+ :==+=-: .---..-==-. +# +%%%%%#*=. #%%%%%%-.+%%%%#%%%%+ .%%%##%%% -%%%#. .#%%#=*%%%%%%%%%%+ =%%#+=+#%#- -%%%#%#%%%%* +# .-+#%%%%%- +%%%:..#%%%= +%%%*.%%%#-. . :#%%#..#%%#:#%%%= .+%%%+-%%%*===+%%%:-%%%+ .#%%%. +# +%%%# =%%%. .%%%# .%%%%.%%%+ .#%%##%%#:.%%%# :%%%+=%%%*+++++++:-%%%: +%%%. +# .##*===#%%%= =%%%. +%%%#=-=#%%%-.%%%+ .#%%%%*. +%%%#=-=#%%%+.#%%+:..=++= -%%%: +%%%. +# -*%%%%%%%*- =%%%. :*%%%%%%#+: .%%%+ -%%%#. -*%%%%%*%%%+ .+#%%%%%%+. -%%%: +%%%. +# .:::. .:::. -%%%*. .:. .:::. +# -%%%* +# -***+ +# diff --git a/src/analysis.rs b/src/analysis.rs index 13e4864..8396c0c 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -109,6 +109,10 @@ pub struct OperationResponse { pub schema_name: Option, /// Exact declared JSON-compatible media type selected for `schema_name`. pub media_type: Option, + /// Preferred buffered response representation for this status. JSON keeps + /// its generated schema name; text and binary bodies are represented + /// directly by the generated client/server runtime types. + pub body: Option, /// Whether this response also declares `text/event-stream` content. pub supports_streaming: bool, /// Whether the Response Object declared at least one content entry. @@ -117,6 +121,26 @@ pub struct OperationResponse { pub unsupported_media_types: Vec, } +/// Buffered response representation selected from one OpenAPI Response Object. +/// SSE remains orthogonal on [`OperationResponse::supports_streaming`] because +/// a response may advertise both a buffered JSON representation and an event +/// stream. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum OperationResponseBody { + Json { + schema_name: String, + media_type: String, + }, + Text { + media_type: String, + }, + Binary { + media_type: String, + wildcard: bool, + }, +} + #[derive(Debug, Clone, Default)] pub struct ValidationContext { pub openapi_version: String, @@ -369,8 +393,15 @@ pub enum RequestBodyContent { validation_schema: Value, }, Multipart, - OctetStream, - TextPlain, + OctetStream { + media_type: String, + }, + Binary { + media_type: String, + }, + TextPlain { + media_type: String, + }, /// A declared request media type without a schema. Client generation /// preserves its historical no-body signature, while server generation /// rejects the operation because there is no contract to validate. @@ -390,8 +421,9 @@ impl RequestBodyContent { Some(schema_name) } Self::Multipart - | Self::OctetStream - | Self::TextPlain + | Self::OctetStream { .. } + | Self::Binary { .. } + | Self::TextPlain { .. } | Self::SchemaLess { .. } | Self::Unsupported { .. } => None, } @@ -509,15 +541,15 @@ pub enum QuerySerialization { /// Item type of a typed array query parameter. The two variants need /// different handling in codegen: scalars are already Rust type strings /// (possibly paths like `rust_decimal::Decimal` from `[type_mappings]`), -/// while enum refs are raw *schema names* that must run through +/// while schema refs are raw *schema names* that must run through /// `to_rust_type_name` sanitization (cloudflare: /// `resource-sharing_resource_type`). #[derive(Debug, Clone, PartialEq, serde::Serialize)] pub enum ArrayItemType { /// A Rust scalar type string from the TypeMapper (`String`, `i32`, …). Scalar(String), - /// The schema name of a referenced string enum (emits `Display`). - EnumRef(String), + /// The schema name of a referenced scalar alias or string enum. + SchemaRef(String), } impl Default for DependencyGraph { @@ -4501,7 +4533,10 @@ impl SchemaAnalyzer { // Extract request body schema with content-type awareness if let Some(request_body) = &request_body { - use crate::openapi::{is_form_urlencoded_media_type, is_json_media_type}; + use crate::openapi::{ + is_binary_media_type, is_form_urlencoded_media_type, is_json_media_type, + media_type_essence, + }; if let Some((content_type, maybe_schema)) = request_body.best_content() { op_info.request_body = if is_json_media_type(content_type) { match maybe_schema { @@ -4545,13 +4580,28 @@ impl SchemaAnalyzer { media_type: content_type.to_string(), }), } - } else { - match content_type { - "multipart/form-data" => Some(RequestBodyContent::Multipart), - "application/octet-stream" => Some(RequestBodyContent::OctetStream), - "text/plain" => Some(RequestBodyContent::TextPlain), - _ => None, + } else if media_type_essence(content_type) + .eq_ignore_ascii_case("multipart/form-data") + { + Some(RequestBodyContent::Multipart) + } else if is_binary_media_type(content_type, maybe_schema) { + if media_type_essence(content_type) + .eq_ignore_ascii_case("application/octet-stream") + { + Some(RequestBodyContent::OctetStream { + media_type: content_type.to_string(), + }) + } else { + Some(RequestBodyContent::Binary { + media_type: content_type.to_string(), + }) } + } else if media_type_essence(content_type).eq_ignore_ascii_case("text/plain") { + Some(RequestBodyContent::TextPlain { + media_type: content_type.to_string(), + }) + } else { + None }; } if op_info.request_body.is_none() { @@ -4602,6 +4652,10 @@ impl SchemaAnalyzer { .insert(status_code.clone(), schema_name.to_string()); response_info.schema_name = Some(schema_name.to_string()); response_info.media_type = Some(media_type.to_string()); + response_info.body = Some(OperationResponseBody::Json { + schema_name: schema_name.to_string(), + media_type: media_type.to_string(), + }); } } else { // Inline schema - generate a synthetic type name and analyze it @@ -4615,19 +4669,86 @@ impl SchemaAnalyzer { op_info .response_schemas .insert(status_code.clone(), synthetic_name.clone()); + response_info.body = Some(OperationResponseBody::Json { + schema_name: synthetic_name.clone(), + media_type: media_type.to_string(), + }); response_info.schema_name = Some(synthetic_name); response_info.media_type = Some(media_type.to_string()); } } + if response_info.body.is_none() + && let Some(content) = response.content.as_ref() + { + let selected = content + .iter() + .find(|(media_type, media)| { + matches!( + crate::openapi::classify_response_media_type( + media_type, + media.schema.as_ref() + ), + crate::openapi::ResponseMediaKind::Text + ) + }) + .or_else(|| { + content.iter().find(|(media_type, media)| { + matches!( + crate::openapi::classify_response_media_type( + media_type, + media.schema.as_ref() + ), + crate::openapi::ResponseMediaKind::Binary + ) && !crate::openapi::is_wildcard_media_type(media_type) + }) + }) + .or_else(|| { + content.iter().find(|(media_type, media)| { + matches!( + crate::openapi::classify_response_media_type( + media_type, + media.schema.as_ref() + ), + crate::openapi::ResponseMediaKind::Binary + ) + }) + }); + if let Some((media_type, media)) = selected { + response_info.body = match crate::openapi::classify_response_media_type( + media_type, + media.schema.as_ref(), + ) { + crate::openapi::ResponseMediaKind::Text => { + Some(OperationResponseBody::Text { + media_type: media_type.clone(), + }) + } + crate::openapi::ResponseMediaKind::Binary => { + Some(OperationResponseBody::Binary { + media_type: media_type.clone(), + wildcard: crate::openapi::is_wildcard_media_type(media_type), + }) + } + _ => None, + }; + } + } response_info.unsupported_media_types = response .content .as_ref() .into_iter() .flat_map(|content| content.iter()) .filter(|(media_type, content)| { - !crate::openapi::is_event_stream_media_type(media_type) - && (!crate::openapi::is_json_media_type(media_type) - || content.schema.is_none()) + match crate::openapi::classify_response_media_type( + media_type, + content.schema.as_ref(), + ) { + crate::openapi::ResponseMediaKind::Json => content.schema.is_none(), + crate::openapi::ResponseMediaKind::Unsupported => true, + crate::openapi::ResponseMediaKind::EventStream + | crate::openapi::ResponseMediaKind::Text + | crate::openapi::ResponseMediaKind::Binary => false, + } }) .map(|(media_type, _)| media_type.clone()) .collect(); @@ -5255,8 +5376,8 @@ impl SchemaAnalyzer { /// Rust item type for a typed array query parameter /// (openapi-generator-anu). Scalar items map through the TypeMapper; - /// $ref items resolve only when the target is a generated string enum - /// (those emit `Display`, so `item.to_string()` works in the client). + /// $ref items resolve when the target is a scalar alias or generated + /// string enum (both support the client/server string wire projection). /// Anything else — objects, nested arrays — returns None and the /// parameter keeps the opaque-string fallback. Inline-enum'd string /// items stay plain `String`: the op-scoped enum synthesis (issue #10) @@ -5265,9 +5386,7 @@ impl SchemaAnalyzer { let items = schema.details().items.as_deref()?; if let Some(ref_str) = items.reference() { let name = self.extract_schema_name(ref_str)?; - return self - .referenced_schema_is_string_enum(name) - .then(|| ArrayItemType::EnumRef(name.to_string())); + return self.referenced_array_scalar_item_type(name); } let format = items.details().format.clone(); let scalar = match items.schema_type()? { @@ -5299,18 +5418,52 @@ impl SchemaAnalyzer { SchemaType::Primitive { rust_type, .. } => { Some(ArrayItemType::Scalar(rust_type.clone())) } - SchemaType::Reference { target } => { - let resolved = self.resolve_cached_schema(target)?; - matches!( - resolved.schema_type, - SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. } - ) - .then(|| ArrayItemType::EnumRef(target.clone())) - } + SchemaType::Reference { target } => self.referenced_array_scalar_item_type(target), _ => None, } } + /// Resolve a referenced array item through any alias chain while + /// preserving the outer schema name used by the public `Vec` type. + /// + /// `SchemaType::Primitive` also represents dynamic JSON/object fallbacks, + /// so require an actual OpenAPI scalar `type` before accepting it as a + /// form-style query item. Unresolved and cyclic chains are rejected by + /// `resolve_cached_schema`. + fn referenced_array_scalar_item_type(&self, name: &str) -> Option { + let resolved = self.resolve_cached_schema(name)?; + let supported = match &resolved.schema_type { + SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. } => true, + SchemaType::Primitive { .. } => resolved + .original + .get("type") + .is_some_and(Self::query_scalar_type_value), + _ => false, + }; + supported.then(|| ArrayItemType::SchemaRef(name.to_string())) + } + + fn query_scalar_type_value(value: &Value) -> bool { + const SCALARS: [&str; 4] = ["string", "integer", "number", "boolean"]; + if let Some(value) = value.as_str() { + return SCALARS.contains(&value); + } + let Some(values) = value.as_array() else { + return false; + }; + if !values.iter().all(Value::is_string) { + return false; + } + let mut non_null = values + .iter() + .filter_map(Value::as_str) + .filter(|value| *value != "null"); + let Some(scalar) = non_null.next() else { + return false; + }; + non_null.next().is_none() && SCALARS.contains(&scalar) + } + /// True when a component (following `$ref` aliases) analyzes to an object. /// Used to decide whether a referenced query parameter can use a typed /// object serialization plan (issue #27). diff --git a/src/client_generator.rs b/src/client_generator.rs index 1e157f2..abda011 100644 --- a/src/client_generator.rs +++ b/src/client_generator.rs @@ -147,7 +147,7 @@ //! 5. Handles query parameters and request bodies //! 6. Configures middleware stack based on generator config -use crate::analysis::{OperationInfo, ParameterInfo, SchemaAnalysis}; +use crate::analysis::{OperationInfo, OperationResponseBody, ParameterInfo, SchemaAnalysis}; use crate::generator::CodeGenerator; use heck::{ToPascalCase, ToSnakeCase}; use proc_macro2::TokenStream; @@ -168,6 +168,22 @@ struct BodyFieldPlan { access_path: Vec, } +#[derive(Clone, Copy)] +enum ClientSuccessBody<'a> { + Json(&'a str), + Text, + Binary, + EventStream, + Empty, +} + +#[derive(Clone)] +struct ClientSuccessSelection<'a> { + statuses: Vec<&'a str>, + body: ClientSuccessBody<'a>, + accept: Option<&'a str>, +} + enum RequiredBodyConstruction { Default, New(Vec), @@ -222,6 +238,9 @@ impl CodeGenerator { use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; use std::collections::BTreeMap; + /// Default upper bound for any response body buffered in memory. + pub const DEFAULT_MAX_RESPONSE_BODY_BYTES: usize = 8 * 1024 * 1024; + /// HTTP client for making API requests #[derive(Clone)] pub struct HttpClient { @@ -229,6 +248,22 @@ impl CodeGenerator { api_key: Option, http_client: ClientWithMiddleware, custom_headers: BTreeMap, + max_response_body_bytes: usize, + } + + async fn __read_bounded_response_body( + mut response: reqwest::Response, + limit: usize, + ) -> Result, HttpError> { + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(HttpError::Network)? { + let next_len = body.len().checked_add(chunk.len()); + if next_len.is_none_or(|next_len| next_len > limit) { + return Err(HttpError::ResponseTooLarge { limit }); + } + body.extend_from_slice(&chunk); + } + Ok(body) } }; @@ -298,6 +333,12 @@ impl CodeGenerator { .and_then(|http| http.base_url.as_deref()) .unwrap_or_default(); let default_base_url = quote! { #configured_base_url.to_string() }; + let max_response_body_bytes = self + .config() + .http_client_config + .as_ref() + .and_then(|http| http.max_response_body_bytes) + .unwrap_or(8 * 1024 * 1024); let retry_param = if has_retry { quote! { retry_config: Option, } @@ -376,6 +417,7 @@ impl CodeGenerator { api_key: None, http_client, custom_headers: BTreeMap::new(), + max_response_body_bytes: #max_response_body_bytes, } } } @@ -400,6 +442,7 @@ impl CodeGenerator { api_key: None, http_client, custom_headers: BTreeMap::new(), + max_response_body_bytes: #max_response_body_bytes, } } } @@ -423,6 +466,12 @@ impl CodeGenerator { self } + /// Set the maximum number of response-body bytes buffered in memory. + pub fn with_max_response_body_bytes(mut self, limit: usize) -> Self { + self.max_response_body_bytes = limit; + self + } + /// Add a custom header to all requests pub fn with_header(mut self, name: impl Into, value: impl Into) -> Self { self.custom_headers.insert(name.into(), value.into()); @@ -469,7 +518,7 @@ impl CodeGenerator { let methods: Vec = operations .iter() .copied() - .map(|op| self.generate_single_operation_method(op)) + .map(|op| self.generate_single_operation_method(analysis, op)) .collect(); let (operation_builders, builder_entries) = @@ -571,6 +620,7 @@ impl CodeGenerator { let builder_ident = format_ident!("{builder_name}"); let (definition, entry) = self.generate_single_operation_builder( + analysis, operation, &allocated_params, body_plan, @@ -585,8 +635,10 @@ impl CodeGenerator { (definitions, entries) } + #[allow(clippy::too_many_arguments)] fn generate_single_operation_builder( &self, + analysis: &SchemaAnalysis, operation: &OperationInfo, allocated_params: &[AllocatedOperationParam<'_>], body_plan: Option, @@ -743,7 +795,7 @@ impl CodeGenerator { call_arguments.push(quote! { self.#body_ident }); } - let response_type = self.get_response_type(operation); + let response_type = self.get_response_type(analysis, operation); let error_type = self.op_error_type_token(operation); let operation_id = &operation.operation_id; let definition = quote! { @@ -887,7 +939,9 @@ impl CodeGenerator { optional_fields: Vec::new(), }); } - RequestBodyContent::OctetStream | RequestBodyContent::Unsupported { .. } => { + RequestBodyContent::OctetStream { .. } + | RequestBodyContent::Binary { .. } + | RequestBodyContent::Unsupported { .. } => { return Some(BodyModelPlan { body_ident: format_ident!("body"), body_type: quote! { Vec }, @@ -895,7 +949,7 @@ impl CodeGenerator { optional_fields: Vec::new(), }); } - RequestBodyContent::TextPlain => { + RequestBodyContent::TextPlain { .. } => { return Some(BodyModelPlan { body_ident: format_ident!("body"), body_type: quote! { String }, @@ -1271,7 +1325,11 @@ impl CodeGenerator { } /// Generate a single operation method - fn generate_single_operation_method(&self, op: &OperationInfo) -> TokenStream { + fn generate_single_operation_method( + &self, + analysis: &SchemaAnalysis, + op: &OperationInfo, + ) -> TokenStream { let method_name = self.get_method_name(op); let http_method_call = self.http_method_call(op); let path = &op.path; @@ -1281,10 +1339,34 @@ impl CodeGenerator { let header_params = self.generate_header_params(op); let cookie_params = self.generate_cookie_params(op); let auth_application = self.generate_auth_application(); - let response_type = self.get_response_type(op); - let has_response_body = self.get_success_response_schema(op).is_some(); + let success = self.get_success_response(analysis, op); + let response_type = self.get_response_type(analysis, op); let op_error_type = self.op_error_type_token(op); - let error_handling = self.generate_error_handling(op, has_response_body); + let accept = success.accept; + let error_handling = self.generate_error_handling(op, success); + let (custom_headers, accept_header) = if let Some(media_type) = accept { + ( + quote! { + for (name, value) in &self.custom_headers { + if !name.eq_ignore_ascii_case("accept") { + req = req.header(name, value); + } + } + }, + quote! { + req = req.header(reqwest::header::ACCEPT, #media_type); + }, + ) + } else { + ( + quote! { + for (name, value) in &self.custom_headers { + req = req.header(name, value); + } + }, + TokenStream::new(), + ) + }; let url_construction = self.generate_url_construction(path, op); let doc_comment = self.generate_operation_doc_comment(op); @@ -1308,9 +1390,11 @@ impl CodeGenerator { #auth_application // Add custom headers - for (name, value) in &self.custom_headers { - req = req.header(name, value); - } + #custom_headers + + // Keep content negotiation aligned with the generated return type, + // replacing any custom Accept value for this operation. + #accept_header let response = req.send().await?; #error_handling @@ -1939,8 +2023,10 @@ impl CodeGenerator { quote! { #request_ident } } RequestBodyContent::Multipart => quote! { reqwest::multipart::Form }, - RequestBodyContent::OctetStream => quote! { Vec }, - RequestBodyContent::TextPlain => quote! { String }, + RequestBodyContent::OctetStream { .. } | RequestBodyContent::Binary { .. } => { + quote! { Vec } + } + RequestBodyContent::TextPlain { .. } => quote! { String }, RequestBodyContent::Unsupported { .. } => quote! { Vec }, RequestBodyContent::SchemaLess { .. } => unreachable!( "schema-less request bodies preserve the historical client signature" @@ -1948,8 +2034,9 @@ impl CodeGenerator { }; let body_ident = match rb { RequestBodyContent::Multipart => quote! { form }, - RequestBodyContent::OctetStream - | RequestBodyContent::TextPlain + RequestBodyContent::OctetStream { .. } + | RequestBodyContent::Binary { .. } + | RequestBodyContent::TextPlain { .. } | RequestBodyContent::Unsupported { .. } => quote! { body }, RequestBodyContent::SchemaLess { .. } => unreachable!( "schema-less request bodies preserve the historical client signature" @@ -1989,7 +2076,7 @@ impl CodeGenerator { use crate::analysis::QuerySerialization; // Typed form-style arrays take Vec (openapi-generator-anu). // Scalars parse as-is (they may be type paths from [type_mappings]); - // enum refs are raw schema names and go through the same + // schema refs are raw schema names and go through the same // to_rust_type_name sanitization as every other schema reference // (cloudflare has enum schemas like `resource-sharing_resource_type`). if let Some( @@ -2001,10 +2088,10 @@ impl CodeGenerator { let item_ty: syn::Type = match item_type { ArrayItemType::Scalar(rust_type) => syn::parse_str(rust_type) .unwrap_or_else(|_| panic!("invalid scalar item type `{rust_type}`")), - ArrayItemType::EnumRef(schema_name) => { + ArrayItemType::SchemaRef(schema_name) => { let rust_name = self.to_rust_type_name(schema_name); syn::parse_str(&rust_name) - .unwrap_or_else(|_| panic!("invalid enum item type `{rust_name}`")) + .unwrap_or_else(|_| panic!("invalid schema item type `{rust_name}`")) } }; return quote! { Vec<#item_ty> }; @@ -2068,27 +2155,55 @@ impl CodeGenerator { req = req.multipart(form); }, ), - RequestBodyContent::OctetStream => ( + RequestBodyContent::OctetStream { media_type } => ( quote! { body }, quote! { req = req .body(body) - .header("content-type", "application/octet-stream"); + .header("content-type", #media_type); }, ), - RequestBodyContent::TextPlain => ( + RequestBodyContent::Binary { media_type } => ( quote! { body }, quote! { req = req .body(body) - .header("content-type", "text/plain"); + .header("content-type", #media_type); + }, + ), + RequestBodyContent::TextPlain { media_type } => ( + quote! { body }, + quote! { + req = req + .body(body) + .header("content-type", #media_type); }, ), RequestBodyContent::Unsupported { media_types } => { let media_type = media_types - .first() - .map(String::as_str) - .unwrap_or("application/octet-stream"); + .iter() + .find(|media_type| !crate::openapi::is_wildcard_media_type(media_type)) + .map(String::as_str); + let Some(media_type) = media_type else { + let ranges = media_types.join(", "); + let message = format!( + "request body for operation `{}` declares only wildcard media ranges ({ranges}); a concrete Content-Type is required", + op.operation_id, + ); + return if required { + quote! { + let _ = body; + return Err(HttpError::Config(#message.to_string()).into()); + } + } else { + quote! { + if body.is_some() { + return Err(HttpError::Config(#message.to_string()).into()); + } + #empty_request_framing + } + }; + }; ( quote! { body }, quote! { @@ -2135,29 +2250,195 @@ impl CodeGenerator { /// Only considers 2xx status codes. Error schemas (4xx, 5xx) are ignored /// so that endpoints like 204 No Content correctly return `()` instead of /// accidentally picking up the error schema (e.g. `BadRequestError`). - fn get_success_response_schema<'a>(&self, op: &'a OperationInfo) -> Option<&'a String> { + fn get_success_response_schema<'a>( + &self, + op: &'a OperationInfo, + ) -> Option<(&'a str, &'a String)> { op.response_schemas - .get("200") - .or_else(|| op.response_schemas.get("201")) + .get_key_value("200") + .or_else(|| op.response_schemas.get_key_value("201")) .or_else(|| { op.response_schemas .iter() .find(|(code, _)| code.starts_with('2')) - .map(|(_, v)| v) }) + .map(|(status, schema)| (status.as_str(), schema)) } - /// Get response type - fn get_response_type(&self, op: &OperationInfo) -> TokenStream { - if let Some(response_type) = self.get_success_response_schema(op) { - // Convert schema name to Rust type name (handles underscores, etc.) - let rust_type_name = self.to_rust_type_name(response_type); - let response_ident = syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site()); - quote! { #response_ident } + fn get_success_response<'a>( + &self, + analysis: &'a SchemaAnalysis, + op: &'a OperationInfo, + ) -> ClientSuccessSelection<'a> { + if let Some(responses) = analysis.operation_responses.get(&op.operation_id) { + let mut candidates = Vec::new(); + for preferred in ["200", "201"] { + if let Some((status, response)) = responses.get_key_value(preferred) { + candidates.push((status.as_str(), response)); + } + } + candidates.extend( + responses + .iter() + .filter(|(status, _)| { + status.starts_with('2') + && status.as_str() != "200" + && status.as_str() != "201" + }) + .map(|(status, response)| (status.as_str(), response)), + ); + + let selected = candidates + .iter() + .copied() + .find(|(_, response)| { + matches!( + Self::response_body(response), + ClientSuccessBody::Json(_) + | ClientSuccessBody::Text + | ClientSuccessBody::Binary + ) + }) + .or_else(|| { + candidates.iter().copied().find(|(_, response)| { + matches!( + Self::response_body(response), + ClientSuccessBody::EventStream + ) + }) + }) + .or_else(|| candidates.first().copied()); + + if let Some((_, response)) = selected { + let body = Self::response_body(response); + let statuses = candidates + .iter() + .filter_map(|(status, candidate)| { + Self::success_bodies_are_compatible(body, Self::response_body(candidate)) + .then_some(*status) + }) + .collect(); + let accept = match &response.body { + Some(OperationResponseBody::Json { media_type, .. }) + | Some(OperationResponseBody::Text { media_type }) => Some(media_type.as_str()), + Some(OperationResponseBody::Binary { + media_type, + wildcard, + }) => (!wildcard).then_some(media_type.as_str()), + None if response.schema_name.is_some() => { + response.media_type.as_deref().or(Some("application/json")) + } + None if response.supports_streaming => Some("text/event-stream"), + None => None, + }; + return ClientSuccessSelection { + statuses, + body, + accept, + }; + } + } + + if let Some((_status, schema_name)) = self.get_success_response_schema(op) { + let statuses = op + .response_schemas + .iter() + .filter_map(|(candidate_status, candidate_schema)| { + (candidate_status.starts_with('2') && candidate_schema == schema_name) + .then_some(candidate_status.as_str()) + }) + .collect(); + ClientSuccessSelection { + statuses, + body: ClientSuccessBody::Json(schema_name), + accept: Some("application/json"), + } } else if Self::returns_raw_event_stream(op) { - quote! { impl futures_util::Stream> } + ClientSuccessSelection { + statuses: Vec::new(), + body: ClientSuccessBody::EventStream, + accept: Some("text/event-stream"), + } } else { - quote! { () } + ClientSuccessSelection { + statuses: Vec::new(), + body: ClientSuccessBody::Empty, + accept: None, + } + } + } + + fn response_body(response: &crate::analysis::OperationResponse) -> ClientSuccessBody<'_> { + match &response.body { + Some(OperationResponseBody::Json { schema_name, .. }) => { + ClientSuccessBody::Json(schema_name) + } + Some(OperationResponseBody::Text { .. }) => ClientSuccessBody::Text, + Some(OperationResponseBody::Binary { .. }) => ClientSuccessBody::Binary, + None if response.schema_name.is_some() => { + ClientSuccessBody::Json(response.schema_name.as_deref().unwrap_or_default()) + } + None if response.supports_streaming => ClientSuccessBody::EventStream, + None => ClientSuccessBody::Empty, + } + } + + fn success_bodies_are_compatible( + selected: ClientSuccessBody<'_>, + candidate: ClientSuccessBody<'_>, + ) -> bool { + match (selected, candidate) { + (ClientSuccessBody::Json(selected), ClientSuccessBody::Json(candidate)) => { + selected == candidate + } + (ClientSuccessBody::Text, ClientSuccessBody::Text) + | (ClientSuccessBody::Binary, ClientSuccessBody::Binary) + | (ClientSuccessBody::EventStream, ClientSuccessBody::EventStream) + | (ClientSuccessBody::Empty, ClientSuccessBody::Empty) => true, + _ => false, + } + } + + /// Get response type + fn get_response_type(&self, analysis: &SchemaAnalysis, op: &OperationInfo) -> TokenStream { + match self.get_success_response(analysis, op).body { + ClientSuccessBody::Json(response_type) => { + // Convert schema name to Rust type name (handles underscores, etc.) + let rust_type_name = self.to_rust_type_name(response_type); + let response_ident = + syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site()); + quote! { #response_ident } + } + ClientSuccessBody::Text => quote! { String }, + ClientSuccessBody::Binary => quote! { bytes::Bytes }, + ClientSuccessBody::EventStream => { + quote! { impl futures_util::Stream> } + } + ClientSuccessBody::Empty => quote! { () }, + } + } + + fn success_status_guard(statuses: &[&str]) -> TokenStream { + if statuses.is_empty() { + return quote! { status.is_success() }; + } + let guards = statuses + .iter() + .map(|status| Self::single_status_guard(status)); + quote! { false #( || #guards )* } + } + + fn single_status_guard(status: &str) -> TokenStream { + match status { + status if status.chars().all(|character| character.is_ascii_digit()) => { + let status: u16 = status.parse().unwrap_or_default(); + quote! { status_code == #status } + } + status if matches!(status.as_bytes(), [b'1'..=b'5', b'X' | b'x', b'X' | b'x']) => { + let class = u16::from(status.as_bytes()[0] - b'0'); + quote! { status_code / 100 == #class } + } + _ => quote! { status.is_success() }, } } @@ -2180,23 +2461,33 @@ impl CodeGenerator { /// Generate error handling. /// - /// Always reads the response body to a string before attempting any typed - /// deserialization, so the raw body and headers are preserved on the error - /// path even when JSON parsing fails. On 2xx the body is parsed into the - /// success type; on non-2xx the body is parsed into the matching variant - /// of the per-operation error enum (when one is declared) and wrapped in - /// `ApiError`. - fn generate_error_handling(&self, op: &OperationInfo, has_response_body: bool) -> TokenStream { + /// Buffers non-streaming responses once, retaining both exact bytes and a + /// lossy UTF-8 view for compatibility. Only the selected declared success + /// status is parsed into the generated return type; other 2xx statuses are + /// inspectable `ApiError`s rather than being fed to an incompatible parser. + fn generate_error_handling( + &self, + op: &OperationInfo, + success: ClientSuccessSelection<'_>, + ) -> TokenStream { let op_error_type = self.op_error_type_token(op); + let success_body = success.body; + let success_status_guard = Self::success_status_guard(&success.statuses); + let selected_status = if success.statuses.is_empty() { + "any declared 2xx response".to_string() + } else { + success.statuses.join(", ") + }; - let success_branch = if has_response_body { - quote! { + let success_branch = match success_body { + ClientSuccessBody::Json(_) => quote! { match serde_json::from_str(&body_text) { Ok(body) => Ok(body), Err(e) => Err(ApiOpError::Api(ApiError { status: status_code, headers: headers, body: body_text, + raw_body, typed: None, parse_error: Some(format!( "failed to deserialize 2xx response body: {}", @@ -2204,13 +2495,18 @@ impl CodeGenerator { )), })), } - } - } else { - quote! { + }, + ClientSuccessBody::Text => quote! { + let _ = raw_body; + Ok(body_text) + }, + ClientSuccessBody::Empty => quote! { let _ = body_text; + let _ = raw_body; let _ = headers; Ok(()) - } + }, + ClientSuccessBody::Binary | ClientSuccessBody::EventStream => quote! {}, }; let error_match_arms = self.generate_error_match_arms(op); @@ -2219,17 +2515,79 @@ impl CodeGenerator { // buffering it. Reading an SSE body to a string blocks until the server // closes the connection, which is precisely what it will not do. // The error path still buffers — an error response is finite. - if !has_response_body && Self::returns_raw_event_stream(op) { + if matches!(success_body, ClientSuccessBody::EventStream) { return quote! { let status = response.status(); let status_code = status.as_u16(); let headers = response.headers().clone(); - if status.is_success() { + if #success_status_guard { Ok(response.bytes_stream()) } else { - let body_text = response.text().await - .map_err(|e| ApiOpError::Transport(HttpError::Network(e)))?; + if status.is_success() { + return Err(ApiOpError::Api(ApiError { + status: status_code, + headers, + body: String::new(), + raw_body: Vec::new(), + typed: None, + parse_error: Some(format!( + "unexpected successful status {}; generated return type selects `{}`; live response body was not buffered", + status_code, + #selected_status, + )), + })); + } + let body_bytes = __read_bounded_response_body( + response, + self.max_response_body_bytes, + ).await?; + let raw_body = body_bytes; + let body_text = String::from_utf8_lossy(&raw_body).into_owned(); + let typed: Option<#op_error_type>; + let parse_error: Option; + #error_match_arms + Err(ApiOpError::Api(ApiError { + status: status_code, + headers, + body: body_text, + raw_body, + typed, + parse_error, + })) + } + }; + } + + if matches!(success_body, ClientSuccessBody::Binary) { + return quote! { + let status = response.status(); + let status_code = status.as_u16(); + let headers = response.headers().clone(); + + let body_bytes = __read_bounded_response_body( + response, + self.max_response_body_bytes, + ).await?; + if #success_status_guard { + Ok(bytes::Bytes::from(body_bytes)) + } else { + let raw_body = body_bytes; + let body_text = String::from_utf8_lossy(&raw_body).into_owned(); + if status.is_success() { + return Err(ApiOpError::Api(ApiError { + status: status_code, + headers, + body: body_text, + raw_body, + typed: None, + parse_error: Some(format!( + "unexpected successful status {}; generated return type selects `{}`", + status_code, + #selected_status, + )), + })); + } let typed: Option<#op_error_type>; let parse_error: Option; #error_match_arms @@ -2237,6 +2595,7 @@ impl CodeGenerator { status: status_code, headers, body: body_text, + raw_body, typed, parse_error, })) @@ -2248,11 +2607,28 @@ impl CodeGenerator { let status = response.status(); let status_code = status.as_u16(); let headers = response.headers().clone(); - let body_text = response.text().await - .map_err(|e| ApiOpError::Transport(HttpError::Network(e)))?; - - if status.is_success() { + let body_bytes = __read_bounded_response_body( + response, + self.max_response_body_bytes, + ).await?; + let raw_body = body_bytes; + let body_text = String::from_utf8_lossy(&raw_body).into_owned(); + + if #success_status_guard { #success_branch + } else if status.is_success() { + Err(ApiOpError::Api(ApiError { + status: status_code, + headers, + body: body_text, + raw_body, + typed: None, + parse_error: Some(format!( + "unexpected successful status {}; generated return type selects `{}`", + status_code, + #selected_status, + )), + })) } else { let typed: Option<#op_error_type>; let parse_error: Option; @@ -2261,6 +2637,7 @@ impl CodeGenerator { status: status_code, headers, body: body_text, + raw_body, typed, parse_error, })) diff --git a/src/config.rs b/src/config.rs index de98f49..06389d0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,6 +27,7 @@ //! [http_client] //! base_url = "https://api.example.com" //! timeout_seconds = 30 +//! max_response_body_bytes = 8388608 //! //! [http_client.retry] //! max_retries = 3 @@ -78,6 +79,7 @@ //! [http_client] //! base_url = "https://api.example.com" //! timeout_seconds = 30 +//! max_response_body_bytes = 8388608 # 8 MiB default //! //! [http_client.retry] //! max_retries = 3 # 0-10 retries @@ -344,6 +346,7 @@ impl ServerSection { pub struct HttpClientSection { pub base_url: Option, pub timeout_seconds: Option, + pub max_response_body_bytes: Option, pub auth: Option, #[serde(default)] pub headers: Vec, @@ -837,6 +840,7 @@ impl ConfigFile { let http_client_config = self.http_client.as_ref().map(|http| HttpClientConfig { base_url: http.base_url.clone(), timeout_seconds: http.timeout_seconds, + max_response_body_bytes: http.max_response_body_bytes, default_headers: http .headers .iter() @@ -988,6 +992,7 @@ enable_async_client = true [http_client] base_url = "https://api.example.com" timeout_seconds = 30 +max_response_body_bytes = 8388608 [http_client.retry] max_retries = 3 diff --git a/src/generator.rs b/src/generator.rs index a66eeb1..33ff607 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -247,6 +247,7 @@ impl GeneratorConfig { self.http_client_config = Some(crate::http_config::HttpClientConfig { base_url: Some(url.to_string()), timeout_seconds: None, + max_response_body_bytes: None, default_headers: Default::default(), }) } @@ -989,8 +990,8 @@ impl CodeGenerator { } } - /// Transport-level errors: failures where we never received an - /// inspectable HTTP response from the server. + /// Transport-level errors: failures where no safely inspectable + /// HTTP response is available to the caller. /// /// HTTP responses with non-2xx status codes are surfaced as /// [`ApiError`] inside [`ApiOpError::Api`], not here, so callers can @@ -1018,6 +1019,10 @@ impl CodeGenerator { #[error("Request timeout")] Timeout, + /// A response body exceeded the configured in-memory limit + #[error("Response body exceeded configured limit of {limit} bytes")] + ResponseTooLarge { limit: usize }, + /// Invalid configuration #[error("Configuration error: {0}")] Config(String), @@ -1044,9 +1049,9 @@ impl CodeGenerator { /// /// Includes both non-2xx responses and 2xx responses whose body /// failed to deserialize into the expected success type. `status`, - /// `headers`, and `body` are always populated so callers can - /// inspect what the server sent without modifying the generated - /// code. `typed` carries the parsed per-operation error variant + /// `headers`, and `raw_body` preserve what the server actually sent, + /// while `body` is a convenient lossy UTF-8 rendering. `typed` + /// carries the parsed per-operation error variant /// when the body matched a declared schema. Formatting the error /// limits only the displayed body preview; the public fields /// retain the complete response and parsing details. @@ -1055,6 +1060,8 @@ impl CodeGenerator { pub status: u16, pub headers: reqwest::header::HeaderMap, pub body: String, + /// Exact response bytes before lossy UTF-8 conversion. + pub raw_body: Vec, pub typed: Option, pub parse_error: Option, } @@ -3578,6 +3585,8 @@ impl CodeGenerator { Api(String), #[error("Timeout error: {0}")] Timeout(String), + #[error("Response body exceeded configured limit of {limit} bytes")] + ResponseTooLarge { limit: usize }, #[error("JSON serialization/deserialization error: {0}")] Json(#[from] serde_json::Error), #[error("Request error: {0}")] @@ -3697,6 +3706,7 @@ impl CodeGenerator { quote! { api_key: Option }, quote! { http_client: reqwest::Client }, quote! { custom_headers: std::collections::BTreeMap }, + quote! { max_response_body_bytes: usize }, ]; let has_optional_headers = !streaming_config @@ -3720,6 +3730,12 @@ impl CodeGenerator { } else { "https://api.example.com" }; + let max_response_body_bytes = self + .config() + .http_client_config + .as_ref() + .and_then(|http| http.max_response_body_bytes) + .unwrap_or(8 * 1024 * 1024); // Build constructor fields based on what the struct has let constructor_fields = if has_optional_headers { @@ -3728,6 +3744,7 @@ impl CodeGenerator { api_key: None, http_client: reqwest::Client::new(), custom_headers: std::collections::BTreeMap::new(), + max_response_body_bytes: #max_response_body_bytes, optional_headers: std::collections::BTreeMap::new(), } } else { @@ -3736,6 +3753,7 @@ impl CodeGenerator { api_key: None, http_client: reqwest::Client::new(), custom_headers: std::collections::BTreeMap::new(), + max_response_body_bytes: #max_response_body_bytes, } }; @@ -3772,6 +3790,12 @@ impl CodeGenerator { self } + /// Set the maximum number of error-response bytes buffered in memory. + pub fn with_max_response_body_bytes(mut self, limit: usize) -> Self { + self.max_response_body_bytes = limit; + self + } + /// Add a custom header to all requests pub fn with_header( mut self, @@ -3990,7 +4014,10 @@ impl CodeGenerator { .headers(headers); debug!("Creating SSE stream from request"); - let stream = parse_sse_stream::<#event_type>(request_builder).await?; + let stream = parse_sse_stream_with_limit::<#event_type>( + request_builder, + self.max_response_body_bytes, + ).await?; info!("SSE stream created successfully"); Ok(Box::pin(stream)) } @@ -4080,7 +4107,10 @@ impl CodeGenerator { .json(&streaming_request); debug!("Creating SSE stream from request"); - let stream = parse_sse_stream::<#event_type>(request_builder).await?; + let stream = parse_sse_stream_with_limit::<#event_type>( + request_builder, + self.max_response_body_bytes, + ).await?; info!("SSE stream created successfully"); Ok(Box::pin(stream)) } @@ -4094,10 +4124,38 @@ impl CodeGenerator { _streaming_config: &crate::streaming::StreamingConfig, ) -> Result { Ok(quote! { + /// Default upper bound for an SSE error response buffered in memory. + pub const DEFAULT_MAX_SSE_ERROR_BODY_BYTES: usize = 8 * 1024 * 1024; + + async fn __read_bounded_streaming_error_body( + mut response: reqwest::Response, + limit: usize, + ) -> Result, StreamingError> { + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await? { + let next_len = body.len().checked_add(chunk.len()); + if next_len.is_none_or(|next_len| next_len > limit) { + return Err(StreamingError::ResponseTooLarge { limit }); + } + body.extend_from_slice(&chunk); + } + Ok(body) + } + /// Parse SSE stream from HTTP request using reqwest-eventsource pub async fn parse_sse_stream( request_builder: reqwest::RequestBuilder ) -> Result>, StreamingError> + where + T: serde::de::DeserializeOwned + Send + 'static, + { + parse_sse_stream_with_limit(request_builder, DEFAULT_MAX_SSE_ERROR_BODY_BYTES).await + } + + async fn parse_sse_stream_with_limit( + request_builder: reqwest::RequestBuilder, + max_response_body_bytes: usize, + ) -> Result>, StreamingError> where T: serde::de::DeserializeOwned + Send + 'static, { @@ -4105,7 +4163,7 @@ impl CodeGenerator { StreamingError::Connection(format!("Failed to create event source: {}", e)) })?; - let stream = event_source.filter_map(|event_result| async move { + let stream = event_source.filter_map(move |event_result| async move { match event_result { Ok(reqwest_eventsource::Event::Open) => { debug!("SSE connection opened"); @@ -4168,9 +4226,12 @@ impl CodeGenerator { let status_code = status.as_u16(); // Read the response body to get error details - let error_body = match response.text().await { - Ok(body) => body, - Err(_) => "Failed to read error response body".to_string() + let error_body = match __read_bounded_streaming_error_body( + response, + max_response_body_bytes, + ).await { + Ok(body) => String::from_utf8_lossy(&body).into_owned(), + Err(error) => return Some(Err(error)), }; error!("SSE connection error - HTTP {}: {}", status_code, error_body); diff --git a/src/http_config.rs b/src/http_config.rs index 6811776..7fa8708 100644 --- a/src/http_config.rs +++ b/src/http_config.rs @@ -12,6 +12,8 @@ pub struct HttpClientConfig { pub base_url: Option, /// Request timeout in seconds pub timeout_seconds: Option, + /// Maximum response-body bytes buffered in memory + pub max_response_body_bytes: Option, /// Default headers to include in all requests pub default_headers: HashMap, } diff --git a/src/http_error.rs b/src/http_error.rs index cfcfbd2..d043e8e 100644 --- a/src/http_error.rs +++ b/src/http_error.rs @@ -178,6 +178,10 @@ pub enum HttpError { #[error("Request timeout")] Timeout, + /// A response body exceeded the configured in-memory limit + #[error("Response body exceeded configured limit of {limit} bytes")] + ResponseTooLarge { limit: usize }, + /// Invalid configuration #[error("Configuration error: {0}")] Config(String), @@ -238,9 +242,9 @@ pub type HttpResult = Result; /// /// `ApiError` is returned whenever the server actually responded — whether the /// status was non-2xx, or the 2xx body failed to deserialize into the expected -/// type. `status`, `headers`, and `body` are always populated so callers can -/// inspect what the server actually sent without having to hack the generated -/// client. `typed` is `Some(_)` when the raw body was successfully parsed into a +/// type. `status`, `headers`, and `raw_body` preserve what the server actually +/// sent, while `body` is a convenient lossy UTF-8 rendering. `typed` is `Some(_)` +/// when the response body was successfully parsed into a /// per-operation error type; `parse_error` records why parsing failed when not. /// Formatting the error limits only the displayed body preview; the public /// fields retain the complete response and parsing details. @@ -249,6 +253,8 @@ pub struct ApiError { pub status: u16, pub headers: reqwest::header::HeaderMap, pub body: String, + /// Exact response bytes before lossy UTF-8 conversion. + pub raw_body: Vec, pub typed: Option, pub parse_error: Option, } diff --git a/src/openapi.rs b/src/openapi.rs index fdd3af6..62ef429 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -1016,6 +1016,34 @@ pub struct RequestBody { pub extensions: Extensions, } +/// Semantic representation used for a response media entry. +/// +/// This deliberately keeps server-sent events separate from ordinary text: +/// although `text/event-stream` belongs to the `text` top-level type, callers +/// must stream it rather than buffer and UTF-8 decode it like `text/plain`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ResponseMediaKind { + Json, + EventStream, + Text, + Binary, + Unsupported, +} + +/// Return the media type essence, excluding parameters and surrounding space. +/// +/// Media type comparisons remain ASCII-case-insensitive at their call sites; +/// this helper only provides one consistent way to discard parameters such as +/// `charset=utf-8` without allocating. +pub fn media_type_essence(content_type: &str) -> &str { + content_type + .split(';') + .next() + .unwrap_or(content_type) + .trim() +} + /// Returns true for media types whose payload is JSON. /// /// Matches `application/json` exactly, plus any RFC 6839 structured-syntax @@ -1024,12 +1052,7 @@ pub struct RequestBody { /// `application/problem+json`). Trailing parameters such as /// `; charset=utf-8` are tolerated. pub fn is_json_media_type(ct: &str) -> bool { - let essence = ct - .split(';') - .next() - .unwrap_or(ct) - .trim() - .to_ascii_lowercase(); + let essence = media_type_essence(ct).to_ascii_lowercase(); if essence == "application/json" { return true; } @@ -1042,12 +1065,7 @@ pub fn is_json_media_type(ct: &str) -> bool { /// Returns true for `application/x-www-form-urlencoded` (with optional /// parameters). pub fn is_form_urlencoded_media_type(ct: &str) -> bool { - let essence = ct - .split(';') - .next() - .unwrap_or(ct) - .trim() - .to_ascii_lowercase(); + let essence = media_type_essence(ct).to_ascii_lowercase(); essence == "application/x-www-form-urlencoded" } @@ -1057,21 +1075,116 @@ pub fn is_form_urlencoded_media_type(ct: &str) -> bool { /// the essence, so values such as `Text/Event-Stream; charset=utf-8` match, /// while similarly prefixed subtypes such as `text/event-streaming` do not. pub fn is_event_stream_media_type(ct: &str) -> bool { - ct.split(';') - .next() - .unwrap_or(ct) - .trim() - .eq_ignore_ascii_case("text/event-stream") + media_type_essence(ct).eq_ignore_ascii_case("text/event-stream") +} + +/// Returns true for non-SSE media types in the `text` top-level family. +pub fn is_text_media_type(ct: &str) -> bool { + let Some((top_level, subtype)) = media_type_essence(ct).split_once('/') else { + return false; + }; + top_level.eq_ignore_ascii_case("text") && !subtype.is_empty() && !is_event_stream_media_type(ct) +} + +/// Returns true for OpenAPI media ranges with a wildcard subtype. +/// +/// This recognizes both `*/*` and type-specific ranges such as `image/*`. +/// A wildcard is meaningful only as the complete subtype, so values such as +/// `image/*+json` do not match. +pub fn is_wildcard_media_type(ct: &str) -> bool { + let Some((top_level, subtype)) = media_type_essence(ct).split_once('/') else { + return false; + }; + !top_level.is_empty() && subtype == "*" +} + +fn schema_has_binary_format(schema: Option<&Schema>) -> bool { + schema.is_some_and(|schema| { + schema + .details() + .format + .as_deref() + .is_some_and(|format| format.eq_ignore_ascii_case("binary")) + }) +} + +/// Returns true when a response representation must be handled as raw bytes. +/// +/// An explicit schema `format: binary` takes precedence over a textual-looking +/// media type. Without that schema signal, known binary families and formats +/// are recognized, as are non-text OpenAPI wildcard media ranges. Text media +/// ranges cannot be emitted as a concrete response `Content-Type` and remain +/// unsupported unless their schema explicitly declares the binary format. +pub fn is_binary_media_type(ct: &str, schema: Option<&Schema>) -> bool { + if schema_has_binary_format(schema) { + return true; + } + + let essence = media_type_essence(ct); + let Some((top_level, _)) = essence.split_once('/') else { + return false; + }; + if top_level.eq_ignore_ascii_case("image") + || top_level.eq_ignore_ascii_case("audio") + || top_level.eq_ignore_ascii_case("video") + { + return true; + } + if essence.eq_ignore_ascii_case("application/octet-stream") + || essence.eq_ignore_ascii_case("application/zip") + { + return true; + } + + !top_level.eq_ignore_ascii_case("text") && is_wildcard_media_type(ct) +} + +/// Classify one declared response representation for client/server analysis. +/// +/// JSON and exact SSE retain their established behavior. A binary schema wins +/// over the media family so bytes are never accidentally UTF-8 decoded. +pub fn classify_response_media_type(ct: &str, schema: Option<&Schema>) -> ResponseMediaKind { + if is_json_media_type(ct) { + ResponseMediaKind::Json + } else if is_event_stream_media_type(ct) { + ResponseMediaKind::EventStream + } else if schema_has_binary_format(schema) { + ResponseMediaKind::Binary + } else if is_text_media_type(ct) { + if is_wildcard_media_type(ct) { + ResponseMediaKind::Unsupported + } else { + ResponseMediaKind::Text + } + } else if is_binary_media_type(ct, schema) { + ResponseMediaKind::Binary + } else { + ResponseMediaKind::Unsupported + } } fn find_json_content(content: &BTreeMap) -> Option<(&str, &MediaType)> { - if let Some(mt) = content.get("application/json") { + if let Some(mt) = content + .get("application/json") + .filter(|media_type| media_type.schema.is_some()) + { return Some(("application/json", mt)); } content .iter() - .find(|(ct, _)| is_json_media_type(ct)) + .find(|(ct, media_type)| is_json_media_type(ct) && media_type.schema.is_some()) .map(|(ct, mt)| (ct.as_str(), mt)) + .or_else(|| { + content + .get("application/json") + .map(|media_type| ("application/json", media_type)) + }) + .or_else(|| { + content + .iter() + .find(|(ct, _)| is_json_media_type(ct)) + .map(|(ct, mt)| (ct.as_str(), mt)) + }) } impl RequestBody { @@ -1101,12 +1214,25 @@ impl RequestBody { "application/octet-stream", "text/plain", ]; - for ct in PRIORITY { - if let Some(media_type) = content.get(*ct) { - return Some((*ct, media_type.schema.as_ref())); + for preferred_essence in PRIORITY { + if let Some((ct, media_type)) = content + .iter() + .find(|(ct, _)| media_type_essence(ct).eq_ignore_ascii_case(preferred_essence)) + { + return Some((ct.as_str(), media_type.schema.as_ref())); } } - None + content + .iter() + // A request media range is not a concrete Content-Type value. The + // generated client cannot send `image/*` or `*/*`, and the server + // cannot compare either range to one exact request representation, + // so leave wildcard request content unsupported instead of + // emitting a contract that always fails at runtime. + .find(|(ct, media_type)| { + !is_wildcard_media_type(ct) && is_binary_media_type(ct, media_type.schema.as_ref()) + }) + .map(|(ct, media_type)| (ct.as_str(), media_type.schema.as_ref())) } } @@ -1316,6 +1442,126 @@ mod tests { assert!(!is_json_media_type("text/something+json")); } + #[test] + fn response_media_helpers_normalize_parameters_and_case() { + assert_eq!( + media_type_essence(" Text/Plain ; charset=utf-8 "), + "Text/Plain" + ); + assert!(is_text_media_type("TEXT/HTML; charset=UTF-8")); + assert!(!is_text_media_type("Text/Event-Stream; charset=utf-8")); + assert!(is_wildcard_media_type("*/*; q=0.8")); + assert!(is_wildcard_media_type("IMAGE/*")); + assert!(is_wildcard_media_type("text/*")); + assert!(!is_wildcard_media_type("image/*+json")); + assert!(!is_wildcard_media_type("application/json")); + } + + #[test] + fn response_media_classifier_keeps_json_sse_and_text_distinct() { + for media_type in [ + "application/json", + "APPLICATION/PROBLEM+JSON; charset=utf-8", + ] { + assert_eq!( + classify_response_media_type(media_type, None), + ResponseMediaKind::Json, + "{media_type}" + ); + } + + assert_eq!( + classify_response_media_type("Text/Event-Stream; charset=utf-8", None), + ResponseMediaKind::EventStream + ); + for media_type in ["text/plain", "TEXT/HTML; charset=UTF-8"] { + assert_eq!( + classify_response_media_type(media_type, None), + ResponseMediaKind::Text, + "{media_type}" + ); + } + assert_eq!( + classify_response_media_type("text/event-streaming", None), + ResponseMediaKind::Text + ); + assert_eq!( + classify_response_media_type("text/*", None), + ResponseMediaKind::Unsupported, + "a media range is not a valid concrete response Content-Type" + ); + } + + #[test] + fn response_json_content_skips_schema_less_canonical_entry() { + let response: Response = serde_json::from_value(json!({ + "description": "mixed JSON", + "content": { + "application/json": {}, + "application/vnd.example+json": { + "schema": { "type": "string" } + } + } + })) + .unwrap(); + + let (media_type, schema) = response.json_content().expect("schema-bearing JSON"); + assert_eq!(media_type, "application/vnd.example+json"); + assert!(matches!(schema.schema_type(), Some(SchemaType::String))); + } + + #[test] + fn response_media_classifier_recognizes_binary_formats_and_wildcards() { + for media_type in [ + "image/png", + "IMAGE/*; version=1", + "audio/mpeg", + "video/mp4", + "application/octet-stream", + "APPLICATION/ZIP; version=1", + "application/*", + "*/*", + ] { + assert_eq!( + classify_response_media_type(media_type, None), + ResponseMediaKind::Binary, + "{media_type}" + ); + } + + let binary_schema: Schema = serde_json::from_value(json!({ + "type": "string", + "format": "BINARY" + })) + .unwrap(); + assert_eq!( + classify_response_media_type("application/x-custom", Some(&binary_schema)), + ResponseMediaKind::Binary + ); + assert_eq!( + classify_response_media_type("text/plain", Some(&binary_schema)), + ResponseMediaKind::Binary, + "an explicit binary schema must prevent UTF-8 decoding" + ); + assert!(is_binary_media_type( + "application/x-custom", + Some(&binary_schema) + )); + } + + #[test] + fn response_media_classifier_leaves_ambiguous_formats_unsupported() { + let string_schema: Schema = serde_json::from_value(json!({ "type": "string" })).unwrap(); + for media_type in ["application/xml", "application/pdf", "not-a-media-type"] { + assert_eq!( + classify_response_media_type(media_type, Some(&string_schema)), + ResponseMediaKind::Unsupported, + "{media_type}" + ); + } + assert!(!is_binary_media_type("text/plain", None)); + } + #[test] fn request_body_json_schema_finds_vnd_api_plus_json() { // Mirrors Latitude.sh: request body declared under @@ -1374,6 +1620,43 @@ mod tests { assert_eq!(ct, "application/vnd.api+json"); } + #[test] + fn request_body_best_content_does_not_select_wildcard_media_ranges() { + let body: RequestBody = serde_json::from_value(json!({ + "required": true, + "content": { + "image/*": { + "schema": { "type": "string", "format": "binary" } + }, + "*/*": { + "schema": { "type": "string", "format": "binary" } + } + } + })) + .unwrap(); + + assert!( + body.best_content().is_none(), + "request media ranges require a runtime concrete Content-Type" + ); + } + + #[test] + fn request_body_best_content_matches_parameterized_text_plain_by_essence() { + let body: RequestBody = serde_json::from_value(json!({ + "required": true, + "content": { + "Text/Plain; charset=utf-8": { + "schema": { "type": "string" } + } + } + })) + .unwrap(); + + let (media_type, _) = body.best_content().expect("parameterized text body"); + assert_eq!(media_type, "Text/Plain; charset=utf-8"); + } + #[test] fn response_json_schema_finds_vnd_api_plus_json() { // Mirrors every Latitude.sh response: schema lives under diff --git a/src/registry_generator.rs b/src/registry_generator.rs index e52428f..bfcae40 100644 --- a/src/registry_generator.rs +++ b/src/registry_generator.rs @@ -286,10 +286,13 @@ impl CodeGenerator { RequestBodyContent::Multipart => { (quote! { BodyContentType::Multipart }, quote! { None }) } - RequestBodyContent::OctetStream => { + RequestBodyContent::OctetStream { .. } => { (quote! { BodyContentType::OctetStream }, quote! { None }) } - RequestBodyContent::TextPlain => { + RequestBodyContent::Binary { .. } => { + (quote! { BodyContentType::OctetStream }, quote! { None }) + } + RequestBodyContent::TextPlain { .. } => { (quote! { BodyContentType::TextPlain }, quote! { None }) } RequestBodyContent::Unsupported { .. } => { diff --git a/src/server/codegen.rs b/src/server/codegen.rs index 2c1a3d7..4373a47 100644 --- a/src/server/codegen.rs +++ b/src/server/codegen.rs @@ -8,8 +8,8 @@ //! Router wiring, extractors, and SSE response variants are P5. use crate::analysis::{ - ObjectAdditionalProperties, OperationInfo, OperationResponse, ParameterInfo, - QuerySerialization, RequestBodyContent, SchemaAnalysis, SchemaType, + ObjectAdditionalProperties, OperationInfo, OperationResponse, OperationResponseBody, + ParameterInfo, QuerySerialization, RequestBodyContent, SchemaAnalysis, SchemaType, }; use crate::config::ServerSection; use crate::generator::{CodeGenerator, GeneratedFile, GeneratorConfig}; @@ -74,10 +74,10 @@ pub fn reachable_schemas_with_roots( } if let Some( QuerySerialization::FormExplodedArray { - item_type: crate::analysis::ArrayItemType::EnumRef(name), + item_type: crate::analysis::ArrayItemType::SchemaRef(name), } | QuerySerialization::FormArray { - item_type: crate::analysis::ArrayItemType::EnumRef(name), + item_type: crate::analysis::ArrayItemType::SchemaRef(name), }, ) = &p.query_serialization { @@ -356,6 +356,19 @@ impl<'a> ServerCodegen<'a> { // Group by primary tag (first tag wins; untagged → "Server"). let groups = group_by_tag(&ops); + let response_enum_names = self.allocate_response_enum_names(&ops); + let has_binary_body = ops.iter().any(|operation| { + matches!( + operation.request_body, + Some(RequestBodyContent::OctetStream { .. } | RequestBodyContent::Binary { .. }) + ) + }); + let has_text_body = ops.iter().any(|operation| { + matches!( + operation.request_body, + Some(RequestBodyContent::TextPlain { .. }) + ) + }); let validation_bundle = if self.server.validation.enabled { Some( @@ -372,10 +385,12 @@ impl<'a> ServerCodegen<'a> { None }; - let api_rs = self.emit_api(&groups); - let errors_rs = self.emit_errors(&ops, validation_bundle.is_some()); + let transport_validation = has_binary_body || has_text_body; + let validation_module_enabled = validation_bundle.is_some() || transport_validation; + let api_rs = self.emit_api(&groups, &response_enum_names); + let errors_rs = self.emit_errors(&ops, validation_module_enabled, &response_enum_names); let router_rs = self.emit_router(&groups, validation_bundle.as_ref())?; - let mod_rs = self.emit_mod(validation_bundle.is_some()); + let mod_rs = self.emit_mod(validation_module_enabled); let mut files = vec![ GeneratedFile { @@ -401,6 +416,16 @@ impl<'a> ServerCodegen<'a> { content: format_or_raw(super::validation::emit_validation_module( bundle, self.server.validation.max_errors, + has_binary_body, + has_text_body, + )), + }); + } else if transport_validation { + files.push(GeneratedFile { + path: PathBuf::from("server").join("validation.rs"), + content: format_or_raw(super::validation::emit_transport_validation_module( + has_binary_body, + has_text_body, )), }); } @@ -551,20 +576,6 @@ impl<'a> ServerCodegen<'a> { reason: "typed multipart server extraction is not implemented".to_string(), }); } - Some(RequestBodyContent::OctetStream) => { - return Err(ServerCodegenError::UnsupportedRequestBody { - operation_id: operation.operation_id.clone(), - media_type: "application/octet-stream".to_string(), - reason: "binary server extraction is not implemented".to_string(), - }); - } - Some(RequestBodyContent::TextPlain) => { - return Err(ServerCodegenError::UnsupportedRequestBody { - operation_id: operation.operation_id.clone(), - media_type: "text/plain".to_string(), - reason: "text server extraction is not implemented".to_string(), - }); - } Some(RequestBodyContent::SchemaLess { media_type }) => { return Err(ServerCodegenError::UnsupportedRequestBody { operation_id: operation.operation_id.clone(), @@ -598,10 +609,11 @@ impl<'a> ServerCodegen<'a> { { for (status, response) in responses { // A Response Object may advertise multiple representations. - // Generation is viable whenever at least one JSON or SSE + // Generation is viable whenever at least one buffered or SSE // representation can be emitted; unsupported alternatives // do not invalidate that supported path. if response.has_content + && response.body.is_none() && response.schema_name.is_none() && !response.supports_streaming { @@ -626,6 +638,53 @@ impl<'a> ServerCodegen<'a> { CodeGenerator::to_field_ident(&generator.param_ident_str(parameter)) } + /// Allocate public response-enum identifiers without colliding with the + /// retained models that the generated server modules glob-import. + fn allocate_response_enum_names(&self, ops: &[&OperationInfo]) -> BTreeMap { + let generator = CodeGenerator::new(self.config.clone()); + let mut used_names: std::collections::BTreeSet = self + .analysis + .schemas + .keys() + .map(|name| generator.to_rust_type_name(name)) + .collect(); + + // Parameter enums are emitted directly into api.rs rather than into + // analysis.schemas, but share that module's type namespace with the + // imported response enums. + used_names.extend( + ops.iter() + .flat_map(|operation| operation.parameters.iter()) + .filter(|parameter| parameter.enum_values.is_some()) + .map(|parameter| parameter.rust_type.clone()), + ); + + // Selector ordering is user-controlled. Allocate in operation-id order + // so reversing selectors cannot change generated public identifiers. + let mut sorted_ops = ops.to_vec(); + sorted_ops.sort_by(|left, right| left.operation_id.cmp(&right.operation_id)); + + let mut names = BTreeMap::new(); + for operation in sorted_ops { + let operation_name = operation.operation_id.to_pascal_case(); + let preferred = format!("{operation_name}Response"); + let chosen = if used_names.insert(preferred.clone()) { + preferred + } else { + let fallback = format!("{operation_name}ServerResponse"); + let mut candidate = fallback.clone(); + let mut suffix = 2; + while !used_names.insert(candidate.clone()) { + candidate = format!("{fallback}{suffix}"); + suffix += 1; + } + candidate + }; + names.insert(operation.operation_id.clone(), format_ident!("{chosen}")); + } + names + } + fn validation_target( &self, bundle: Option<&super::validation::ValidationBundle>, @@ -1498,13 +1557,52 @@ impl<'a> ServerCodegen<'a> { let body_ty_opt = body_type(op); if let Some(body_ty) = &body_ty_opt { let body_ty_tokens = parse_type(body_ty); + let transport_body = match &op.request_body { + Some(RequestBodyContent::OctetStream { media_type }) => { + Some((format_ident!("decode_binary_body"), media_type.clone())) + } + Some(RequestBodyContent::Binary { media_type }) => { + Some((format_ident!("decode_binary_body"), media_type.clone())) + } + Some(RequestBodyContent::TextPlain { media_type }) => { + Some((format_ident!("decode_text_body"), media_type.clone())) + } + _ => None, + }; let validated_json = matches!(&op.request_body, Some(RequestBodyContent::Json { .. })) && validation_bundle.is_some(); let validated_form = matches!( &op.request_body, Some(RequestBodyContent::FormUrlEncoded { .. }) ) && validation_bundle.is_some(); - if validated_json { + if let Some((decoder, media_type)) = transport_body { + extractors.push(quote! { __request: ::axum::extract::Request }); + let required = op.request_body_required; + let max_body_bytes = self.server.validation.max_body_bytes; + body_decode = quote! { + let body: ::std::option::Option<#body_ty_tokens> = + match super::validation::#decoder( + __request, + #media_type, + #required, + #max_body_bytes, + ).await { + Ok(body) => body, + Err(rejection) => return ::axum::response::IntoResponse::into_response(rejection), + }; + }; + if required { + body_decode.extend(quote! { + let body = match body { + Some(body) => body, + None => return ::axum::response::IntoResponse::into_response( + super::validation::generated_contract_error() + ), + }; + }); + } + call_args.push(quote! { body }); + } else if validated_json { extractors.push(quote! { __request: ::axum::extract::Request }); let Some(RequestBodyContent::Json { media_type, .. }) = &op.request_body else { return Err(ServerCodegenError::Internal( @@ -1612,7 +1710,6 @@ impl<'a> ServerCodegen<'a> { } } - let _ = format_ident!("{}Response", op.operation_id.to_pascal_case()); // Keep referencing trait_ident so the where-bound name is // visible to downstream readers — clippy would otherwise flag // it as unused in some configurations. @@ -1656,11 +1753,15 @@ impl<'a> ServerCodegen<'a> { }) } - fn emit_api(&self, groups: &BTreeMap>) -> TokenStream { + fn emit_api( + &self, + groups: &BTreeMap>, + response_enum_names: &BTreeMap, + ) -> TokenStream { let provenance_attribute = self.provenance_attribute(); let traits: Vec = groups .iter() - .map(|(tag, ops)| self.emit_trait(tag, ops)) + .map(|(tag, ops)| self.emit_trait(tag, ops, response_enum_names)) .collect(); // Inline string enums declared on parameters get synthetic @@ -1702,9 +1803,17 @@ impl<'a> ServerCodegen<'a> { } } - fn emit_trait(&self, tag: &str, ops: &[&OperationInfo]) -> TokenStream { + fn emit_trait( + &self, + tag: &str, + ops: &[&OperationInfo], + response_enum_names: &BTreeMap, + ) -> TokenStream { let trait_ident = trait_ident_for_tag(tag); - let methods: Vec = ops.iter().map(|op| self.emit_method_sig(op)).collect(); + let methods: Vec = ops + .iter() + .map(|op| self.emit_method_sig(op, response_enum_names)) + .collect(); let doc = format!(" Operations under the `{tag}` tag."); quote! { #[doc = #doc] @@ -1715,9 +1824,13 @@ impl<'a> ServerCodegen<'a> { } } - fn emit_method_sig(&self, op: &OperationInfo) -> TokenStream { + fn emit_method_sig( + &self, + op: &OperationInfo, + response_enum_names: &BTreeMap, + ) -> TokenStream { let name = format_ident!("{}", op.operation_id.to_snake_case()); - let response_ty = format_ident!("{}Response", op.operation_id.to_pascal_case()); + let response_ty = &response_enum_names[&op.operation_id]; // Order: path → query → header → body. Required params keep // their declared rust_type; optional params wrap in Option<…>. @@ -2037,10 +2150,18 @@ impl<'a> ServerCodegen<'a> { }) } - fn emit_errors(&self, ops: &[&OperationInfo], validation_enabled: bool) -> TokenStream { + fn emit_errors( + &self, + ops: &[&OperationInfo], + validation_enabled: bool, + response_enum_names: &BTreeMap, + ) -> TokenStream { let provenance_attribute = self.provenance_attribute(); let any_streaming = ops.iter().any(|op| op.supports_streaming); - let enums: Vec = ops.iter().map(|op| self.emit_response_enum(op)).collect(); + let enums: Vec = ops + .iter() + .map(|op| self.emit_response_enum(op, response_enum_names)) + .collect(); let problem_types = validation_enabled.then(|| { quote! { /// RFC 9457 Problem Details profile used for rejected requests. @@ -2158,8 +2279,12 @@ impl<'a> ServerCodegen<'a> { } } - fn emit_response_enum(&self, op: &OperationInfo) -> TokenStream { - let enum_ident = format_ident!("{}Response", op.operation_id.to_pascal_case()); + fn emit_response_enum( + &self, + op: &OperationInfo, + response_enum_names: &BTreeMap, + ) -> TokenStream { + let enum_ident = &response_enum_names[&op.operation_id]; let mut variants: Vec = Vec::new(); let mut arms: Vec = Vec::new(); @@ -2185,6 +2310,10 @@ impl<'a> ServerCodegen<'a> { OperationResponse { schema_name: Some(schema_name.clone()), media_type: Some("application/json".to_string()), + body: Some(OperationResponseBody::Json { + schema_name: schema_name.clone(), + media_type: "application/json".to_string(), + }), supports_streaming: false, has_content: true, unsupported_media_types: Vec::new(), @@ -2202,17 +2331,84 @@ impl<'a> ServerCodegen<'a> { let status_expr = status_token(status); let status_guard = runtime_status_guard(status, quote! { status }); - if let Some(schema_name) = &response.schema_name { - let body_ty = parse_type(schema_name); - let media_type = response.media_type.as_deref().unwrap_or("application/json"); - if runtime_status { + let buffered_body = response.body.clone().or_else(|| { + response + .schema_name + .as_ref() + .map(|schema_name| OperationResponseBody::Json { + schema_name: schema_name.clone(), + media_type: response + .media_type + .clone() + .unwrap_or_else(|| "application/json".to_string()), + }) + }); + + if let Some(body) = buffered_body { + let (body_ty, response_body, media_type, wildcard) = match body { + OperationResponseBody::Json { + schema_name, + media_type, + } => ( + parse_type(&schema_name), + quote! { Json(body) }, + media_type, + false, + ), + OperationResponseBody::Text { media_type } => { + (quote! { String }, quote! { body }, media_type, false) + } + OperationResponseBody::Binary { + media_type, + wildcard, + } => ( + quote! { bytes::Bytes }, + quote! { body }, + media_type, + wildcard, + ), + }; + if wildcard { + if runtime_status { + variants.push(quote! { + #variant(StatusCode, ::axum::http::HeaderValue, #body_ty) + }); + arms.push(quote! { + Self::#variant(status, content_type, body) => { + if !(#status_guard) { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + let mut response = (status, #response_body).into_response(); + response.headers_mut().insert( + ::axum::http::header::CONTENT_TYPE, + content_type, + ); + response + } + }); + } else { + variants.push(quote! { + #variant(::axum::http::HeaderValue, #body_ty) + }); + arms.push(quote! { + Self::#variant(content_type, body) => { + let mut response = (#status_expr, #response_body).into_response(); + response.headers_mut().insert( + ::axum::http::header::CONTENT_TYPE, + content_type, + ); + response + } + }); + } + } else if runtime_status { variants.push(quote! { #variant(StatusCode, #body_ty) }); arms.push(quote! { Self::#variant(status, body) => { if !(#status_guard) { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } - let mut response = (status, Json(body)).into_response(); + let mut response = (status, #response_body).into_response(); let Ok(content_type) = ::axum::http::HeaderValue::from_bytes(#media_type.as_bytes()) else { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); }; @@ -2227,7 +2423,7 @@ impl<'a> ServerCodegen<'a> { variants.push(quote! { #variant(#body_ty) }); arms.push(quote! { Self::#variant(body) => { - let mut response = (#status_expr, Json(body)).into_response(); + let mut response = (#status_expr, #response_body).into_response(); let Ok(content_type) = ::axum::http::HeaderValue::from_bytes(#media_type.as_bytes()) else { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); }; @@ -2479,6 +2675,10 @@ fn body_type(op: &OperationInfo) -> Option { match &op.request_body { Some(RequestBodyContent::Json { schema_name, .. }) | Some(RequestBodyContent::FormUrlEncoded { schema_name, .. }) => Some(schema_name.clone()), + Some(RequestBodyContent::OctetStream { .. } | RequestBodyContent::Binary { .. }) => { + Some("bytes::Bytes".to_string()) + } + Some(RequestBodyContent::TextPlain { .. }) => Some("String".to_string()), _ => None, } } diff --git a/src/server/validation.rs b/src/server/validation.rs index 4f5b2f2..c4e056f 100644 --- a/src/server/validation.rs +++ b/src/server/validation.rs @@ -430,7 +430,85 @@ fn unescape_pointer_token(value: &str) -> String { value.replace("~1", "/").replace("~0", "~") } -pub(crate) fn emit_validation_module(bundle: &ValidationBundle, max_errors: usize) -> TokenStream { +fn emit_transport_decoders(has_binary_body: bool, has_text_body: bool) -> TokenStream { + let binary_decoder = has_binary_body.then(|| { + quote! { + pub(crate) async fn decode_binary_body( + request: ::axum::extract::Request, + expected_media_type: &str, + required: bool, + max_body_bytes: usize, + ) -> ::std::result::Result< + ::std::option::Option<::bytes::Bytes>, + RequestValidationRejection, + > { + let (parts, body) = request.into_parts(); + let content_type = parts.headers + .get(::axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()); + if !content_type.is_some_and(|value| media_type_is(value, expected_media_type)) { + if content_type.is_none() && !required { + let bytes = read_body(body, max_body_bytes).await?; + if bytes.is_empty() { + return Ok(None); + } + } + return Err(unsupported_media_type()); + } + let bytes = read_body(body, max_body_bytes).await?; + if bytes.is_empty() && !required { + Ok(None) + } else { + Ok(Some(bytes)) + } + } + } + }); + let text_decoder = has_text_body.then(|| { + quote! { + pub(crate) async fn decode_text_body( + request: ::axum::extract::Request, + expected_media_type: &str, + required: bool, + max_body_bytes: usize, + ) -> ::std::result::Result< + ::std::option::Option, + RequestValidationRejection, + > { + let (parts, body) = request.into_parts(); + let content_type = parts.headers + .get(::axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()); + if !content_type.is_some_and(|value| media_type_is(value, expected_media_type)) { + if content_type.is_none() && !required { + let bytes = read_body(body, max_body_bytes).await?; + if bytes.is_empty() { + return Ok(None); + } + } + return Err(unsupported_media_type()); + } + let bytes = read_body(body, max_body_bytes).await?; + if bytes.is_empty() && !required { + return Ok(None); + } + let text = String::from_utf8(bytes.to_vec()).map_err(|_| malformed_request())?; + Ok(Some(text)) + } + } + }); + quote! { + #binary_decoder + #text_decoder + } +} + +pub(crate) fn emit_validation_module( + bundle: &ValidationBundle, + max_errors: usize, + has_binary_body: bool, + has_text_body: bool, +) -> TokenStream { let document = &bundle.document_json; let draft = match bundle.draft { ValidationDraft::Draft4 => quote! { ::jsonschema::Draft::Draft4 }, @@ -441,6 +519,7 @@ pub(crate) fn emit_validation_module(bundle: &ValidationBundle, max_errors: usiz let pointer = &target.pointer; quote! { pub(crate) const #ident: &str = #pointer; } }); + let transport_decoders = emit_transport_decoders(has_binary_body, has_text_body); let form_decoder = bundle.has_form_body.then(|| { quote! { pub(crate) async fn decode_form_body( @@ -580,6 +659,8 @@ pub(crate) fn emit_validation_module(bundle: &ValidationBundle, max_errors: usiz #form_decoder + #transport_decoders + pub(crate) fn decode_parameter( raw: &str, target: &str, @@ -680,6 +761,12 @@ pub(crate) fn emit_validation_module(bundle: &ValidationBundle, max_errors: usiz }; content_type.type_() == expected.type_() && content_type.subtype() == expected.subtype() + && content_type.suffix() == expected.suffix() + && expected.params().all(|(name, value)| { + content_type + .get_param(name) + .is_some_and(|actual| actual == value) + }) } pub(crate) fn validate( @@ -880,6 +967,110 @@ pub(crate) fn emit_validation_module(bundle: &ValidationBundle, max_errors: usiz } } +/// Emit only bounded transport decoding when JSON-Schema validation is +/// disabled. Media-type and size enforcement are protocol safety properties, +/// so raw request bodies retain them independently of schema validation. +pub(crate) fn emit_transport_validation_module( + has_binary_body: bool, + has_text_body: bool, +) -> TokenStream { + let transport_decoders = emit_transport_decoders(has_binary_body, has_text_body); + quote! { + //! Bounded raw request-body decoders and normalized public rejections. + #![allow(dead_code)] + + use super::errors::{ProblemDetails, RequestValidationRejection}; + + #transport_decoders + + async fn read_body( + body: ::axum::body::Body, + max_body_bytes: usize, + ) -> ::std::result::Result<::axum::body::Bytes, RequestValidationRejection> { + ::axum::body::to_bytes(body, max_body_bytes) + .await + .map_err(|error| { + let source = ::std::error::Error::source(&error); + if source.is_some_and(|source| { + source.is::<::http_body_util::LengthLimitError>() + }) { + request_body_too_large() + } else { + malformed_request() + } + }) + } + + fn media_type_is(content_type: &str, expected: &str) -> bool { + let Ok(content_type) = content_type.parse::<::mime::Mime>() else { + return false; + }; + let Ok(expected) = expected.parse::<::mime::Mime>() else { + return false; + }; + content_type.type_() == expected.type_() + && content_type.subtype() == expected.subtype() + && content_type.suffix() == expected.suffix() + && expected.params().all(|(name, value)| { + content_type + .get_param(name) + .is_some_and(|actual| actual == value) + }) + } + + pub(crate) fn malformed_request() -> RequestValidationRejection { + public_problem( + 400, + "https://openapi-to-rust.dev/problems/malformed-request", + "Malformed request", + "malformed_request", + ) + } + + pub(crate) fn request_body_too_large() -> RequestValidationRejection { + public_problem( + 413, + "https://openapi-to-rust.dev/problems/request-body-too-large", + "Request body too large", + "request_body_too_large", + ) + } + + pub(crate) fn unsupported_media_type() -> RequestValidationRejection { + public_problem( + 415, + "https://openapi-to-rust.dev/problems/unsupported-media-type", + "Unsupported media type", + "unsupported_media_type", + ) + } + + pub(crate) fn generated_contract_error() -> RequestValidationRejection { + public_problem( + 500, + "https://openapi-to-rust.dev/problems/generated-contract-error", + "Internal server error", + "generated_contract_error", + ) + } + + fn public_problem( + status: u16, + problem_type: &str, + title: &str, + code: &str, + ) -> RequestValidationRejection { + RequestValidationRejection(ProblemDetails { + r#type: problem_type.to_string(), + title: title.to_string(), + status, + code: code.to_string(), + errors: Vec::new(), + }) + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/streaming.rs b/src/streaming.rs index e3bf96f..e64b196 100644 --- a/src/streaming.rs +++ b/src/streaming.rs @@ -154,6 +154,8 @@ pub enum StreamingError { Authentication(String), /// Rate limiting error RateLimit(String), + /// An error response exceeded the configured in-memory limit + ResponseTooLarge { limit: usize }, /// Generic API error Api(String), } @@ -165,6 +167,12 @@ impl std::fmt::Display for StreamingError { StreamingError::Parsing(msg) => write!(f, "Parsing error: {msg}"), StreamingError::Authentication(msg) => write!(f, "Authentication error: {msg}"), StreamingError::RateLimit(msg) => write!(f, "Rate limit error: {msg}"), + StreamingError::ResponseTooLarge { limit } => { + write!( + f, + "Response body exceeded configured limit of {limit} bytes" + ) + } StreamingError::Api(msg) => write!(f, "API error: {msg}"), } } diff --git a/tests/client_response_body_limit_test.rs b/tests/client_response_body_limit_test.rs new file mode 100644 index 0000000..439fb46 --- /dev/null +++ b/tests/client_response_body_limit_test.rs @@ -0,0 +1,291 @@ +use openapi_to_rust::http_config::HttpClientConfig; +use openapi_to_rust::streaming::{HttpMethod, StreamingConfig, StreamingEndpoint}; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::json; +use std::collections::HashMap; +use std::process::Command; + +fn response_limit_spec() -> serde_json::Value { + let success = |content_type: &str, schema: serde_json::Value| { + json!({ + "200": { + "description": "success", + "content": { (content_type): { "schema": schema } } + } + }) + }; + json!({ + "openapi": "3.1.0", + "info": { "title": "response limits", "version": "1.0.0" }, + "paths": { + "/oversized-json": { "get": { + "operationId": "getOversizedJson", + "responses": success("application/json", json!({ "type": "string" })) + }}, + "/oversized-text": { "get": { + "operationId": "getOversizedText", + "responses": success("text/plain", json!({ "type": "string" })) + }}, + "/oversized-binary": { "get": { + "operationId": "getOversizedBinary", + "responses": success("application/octet-stream", json!({ + "type": "string", "format": "binary" + })) + }}, + "/oversized-error": { "get": { + "operationId": "getOversizedError", + "responses": { + "200": { + "description": "success", + "content": { "text/plain": { "schema": { "type": "string" } } } + }, + "400": { + "description": "error", + "content": { "application/json": { "schema": { + "$ref": "#/components/schemas/ErrorBody" + } } } + } + } + }}, + "/ok-json": { "get": { + "operationId": "getOkJson", + "responses": success("application/json", json!({ "type": "string" })) + }}, + "/ok-text": { "get": { + "operationId": "getOkText", + "responses": success("text/plain", json!({ "type": "string" })) + }}, + "/ok-binary": { "get": { + "operationId": "getOkBinary", + "responses": success("application/octet-stream", json!({ + "type": "string", "format": "binary" + })) + }}, + "/oversized-sse-error": { "get": { + "operationId": "streamEvents", + "responses": { + "200": { + "description": "events", + "content": { "text/event-stream": { "schema": { + "$ref": "#/components/schemas/StreamEvent" + } } } + } + } + }} + }, + "components": { "schemas": { + "ErrorBody": { + "type": "object", + "required": ["message"], + "properties": { "message": { "type": "string" } } + }, + "StreamEvent": { + "type": "object", + "required": ["message"], + "properties": { "message": { "type": "string" } } + } + }} + }) +} + +#[test] +fn generated_clients_bound_chunked_responses_without_content_length() { + let temp = tempfile::TempDir::new().unwrap(); + let output_dir = temp.path().join("src/generated"); + let mut analysis = SchemaAnalyzer::new(response_limit_spec()) + .unwrap() + .analyze() + .unwrap(); + let generator = CodeGenerator::new(GeneratorConfig { + output_dir: output_dir.clone(), + enable_async_client: true, + enable_sse_client: true, + tracing_enabled: false, + http_client_config: Some(HttpClientConfig { + base_url: None, + timeout_seconds: None, + max_response_body_bytes: Some(32), + default_headers: HashMap::new(), + }), + streaming_config: Some(StreamingConfig { + endpoints: vec![StreamingEndpoint { + operation_id: "streamEvents".into(), + path: "/oversized-sse-error".into(), + http_method: HttpMethod::Get, + event_union_type: "StreamEvent".into(), + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + }); + let result = generator.generate_all(&mut analysis).unwrap(); + generator.write_files(&result).unwrap(); + + let client = result + .files + .iter() + .find(|file| file.path == std::path::Path::new("client.rs")) + .unwrap(); + assert!(client.content.contains("DEFAULT_MAX_RESPONSE_BODY_BYTES")); + assert!(client.content.contains("with_max_response_body_bytes")); + assert!(client.content.contains("checked_add(chunk.len())")); + assert!(!client.content.contains("response.bytes().await")); + + let streaming = result + .files + .iter() + .find(|file| file.path == std::path::Path::new("streaming.rs")) + .unwrap(); + assert!(streaming.content.contains("with_max_response_body_bytes")); + assert!(streaming.content.contains("ResponseTooLarge")); + assert!(!streaming.content.contains("response.text().await")); + + std::fs::write( + temp.path().join("src/lib.rs"), + r#"pub mod generated; + +#[cfg(test)] +mod tests { + use super::generated::client::{ApiOpError, HttpClient, HttpError}; + use super::generated::streaming::{ + StreamEventsStreamingClient, StreamingClient, StreamingError, + }; + use futures_util::StreamExt; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + async fn spawn_chunked_server() -> std::net::SocketAddr { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + for _ in 0..8 { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + loop { + let mut buffer = [0_u8; 1024]; + let read = socket.read(&mut buffer).await.unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let request = String::from_utf8_lossy(&request); + let path = request.split_whitespace().nth(1).unwrap(); + let (status, content_type, chunks): (&str, &str, Vec<&[u8]>) = match path { + "/ok-json" => ("200 OK", "application/json", vec![b"\"ok\""]), + "/ok-text" => ("200 OK", "text/plain", vec![b"hello"]), + "/ok-binary" => ( + "200 OK", + "application/octet-stream", + vec![&[0, 1, 2, 3, 4]], + ), + "/oversized-error" => ( + "400 Bad Request", + "application/json", + vec![b"12345", b"67890"], + ), + "/oversized-sse-error" => ( + "500 Internal Server Error", + "application/json", + vec![b"12345", b"67890"], + ), + "/oversized-text" => ("200 OK", "text/plain", vec![b"12345", b"67890"]), + "/oversized-binary" => ( + "200 OK", + "application/octet-stream", + vec![b"12345", b"67890"], + ), + _ => ("200 OK", "application/json", vec![b"12345", b"67890"]), + }; + let headers = format!( + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n" + ); + socket.write_all(headers.as_bytes()).await.unwrap(); + for chunk in chunks { + socket + .write_all(format!("{:X}\r\n", chunk.len()).as_bytes()) + .await + .unwrap(); + socket.write_all(chunk).await.unwrap(); + socket.write_all(b"\r\n").await.unwrap(); + } + socket.write_all(b"0\r\n\r\n").await.unwrap(); + } + }); + address + } + + macro_rules! assert_too_large { + ($future:expr) => { + match $future.await.unwrap_err() { + ApiOpError::Transport(HttpError::ResponseTooLarge { limit }) => { + assert_eq!(limit, 8) + } + other => panic!("unexpected error: {other:?}"), + } + }; + } + + #[tokio::test] + async fn cumulative_chunk_limit_applies_to_every_buffered_response() { + let address = spawn_chunked_server().await; + let client = HttpClient::new() + .with_base_url(format!("http://{address}")) + .with_max_response_body_bytes(8); + + assert_too_large!(client.get_oversized_json()); + assert_too_large!(client.get_oversized_text()); + assert_too_large!(client.get_oversized_binary()); + assert_too_large!(client.get_oversized_error()); + assert_eq!(client.get_ok_json().await.unwrap(), "ok"); + assert_eq!(client.get_ok_text().await.unwrap(), "hello"); + assert_eq!(client.get_ok_binary().await.unwrap().as_ref(), &[0, 1, 2, 3, 4]); + + let streaming_client = StreamingClient::new() + .with_base_url(format!("http://{address}")) + .with_max_response_body_bytes(8); + let mut stream = streaming_client.stream_stream_events().await.unwrap(); + match stream.next().await.unwrap().unwrap_err() { + StreamingError::ResponseTooLarge { limit } => assert_eq!(limit, 8), + other => panic!("unexpected streaming error: {other:?}"), + } + } +} +"#, + ) + .unwrap(); + + let dependencies = std::fs::read_to_string(output_dir.join("REQUIRED_DEPS.toml")).unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + format!( + r#"[package] +name = "bounded-response-client" +version = "0.0.0" +edition = "2024" +publish = false + +{dependencies} +tokio = {{ version = "1", features = ["io-util", "macros", "net", "rt-multi-thread"] }} +"# + ), + ) + .unwrap(); + + let output = Command::new("cargo") + .args(["test", "--quiet"]) + .current_dir(temp.path()) + .env("CARGO_BUILD_BUILD_DIR", temp.path().join("cargo-build")) + .env("CARGO_TARGET_DIR", temp.path().join("cargo-target")) + .output() + .unwrap(); + assert!( + output.status.success(), + "generated client runtime failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} diff --git a/tests/config_test.rs b/tests/config_test.rs index 7e86322..0ad2c70 100644 --- a/tests/config_test.rs +++ b/tests/config_test.rs @@ -106,6 +106,7 @@ enable_specta = true [http_client] base_url = "https://api.example.com" timeout_seconds = 60 +max_response_body_bytes = 4194304 [http_client.retry] max_retries = 5 @@ -164,6 +165,7 @@ value = "test-client" "https://api.example.com" ); assert_eq!(http_client.timeout_seconds, Some(60)); + assert_eq!(http_client.max_response_body_bytes, Some(4_194_304)); let retry = http_client.retry.as_ref().unwrap(); assert_eq!(retry.max_retries, 5); diff --git a/tests/exploded_query_params_test.rs b/tests/exploded_query_params_test.rs index 4b0c96a..9ee3eb8 100644 --- a/tests/exploded_query_params_test.rs +++ b/tests/exploded_query_params_test.rs @@ -329,6 +329,77 @@ fn array_of_ref_string_enum_items_uses_enum_type() { ); } +#[test] +fn inline_array_of_scalar_alias_items_preserves_alias_and_pruning_root() { + let mut spec = spec_with_filter_param(json!({ + "name": "tags", + "in": "query", + "schema": { + "type": "array", + "items": {"$ref": "#/components/schemas/Identifier"} + } + })); + spec["components"] = json!({ + "schemas": { + "Identifier": {"type": "string", "format": "xid"}, + "Unused": {"type": "string"} + } + }); + + let code = generate_methods(spec.clone()); + assert!( + code.contains("tags : Option < Vec < Identifier > >"), + "direct scalar alias items should preserve their named type; got:\n{code}" + ); + assert!( + code.contains("for item in v") + && code.contains("\"tags\" . to_string () , item . to_string ()"), + "exploded scalar aliases must still emit one pair per item; got:\n{code}" + ); + + let mut analyzer = SchemaAnalyzer::new(spec).expect("analyzer construction"); + let analysis = analyzer.analyze().expect("analysis"); + let operation = analysis.operations.get("findWidgets").expect("operation"); + let reachable = openapi_to_rust::server::codegen::reachable_schemas(&analysis, &[operation]); + assert!(reachable.contains("Identifier")); + assert!(!reachable.contains("Unused")); +} + +#[test] +fn reusable_array_of_transitive_scalar_alias_items_preserves_outer_alias() { + let mut spec = spec_with_filter_param(json!({ + "name": "scores", + "in": "query", + "explode": false, + "schema": {"$ref": "#/components/schemas/ScoreListAlias"} + })); + spec["components"] = json!({ + "schemas": { + "ScoreListAlias": {"$ref": "#/components/schemas/ScoreList"}, + "ScoreList": { + "type": "array", + "items": {"$ref": "#/components/schemas/PublicScore"} + }, + "PublicScore": {"$ref": "#/components/schemas/Score"}, + "Score": {"type": "integer", "format": "int32"} + } + }); + + let code = generate_methods(spec); + assert!( + code.contains("scores : Option < Vec < PublicScore > >"), + "transitive scalar aliases should retain the array item's outer alias; got:\n{code}" + ); + assert!( + code.contains("parts . join (\",\")"), + "explode=false alias arrays must retain comma-joined serialization; got:\n{code}" + ); + assert!( + !code.contains("scores : Option < impl AsRef < str > >"), + "supported alias arrays must not use the opaque string fallback; got:\n{code}" + ); +} + #[test] fn array_of_objects_keeps_string_fallback() { // Arrays of objects have no defined form serialization — fallback. diff --git a/tests/http_config_integration_test.rs b/tests/http_config_integration_test.rs index 582b4ca..cfa4bd7 100644 --- a/tests/http_config_integration_test.rs +++ b/tests/http_config_integration_test.rs @@ -32,6 +32,7 @@ enable_specta = false [http_client] base_url = "https://api.example.com" timeout_seconds = 30 +max_response_body_bytes = 4194304 [[http_client.headers]] name = "content-type" @@ -56,6 +57,7 @@ value = "rust-client" Some("https://api.example.com".to_string()) ); assert_eq!(http_config.timeout_seconds, Some(30)); + assert_eq!(http_config.max_response_body_bytes, Some(4_194_304)); assert_eq!(http_config.default_headers.len(), 2); assert_eq!( http_config.default_headers.get("content-type"), diff --git a/tests/http_error_test.rs b/tests/http_error_test.rs index 480f456..1af2fb3 100644 --- a/tests/http_error_test.rs +++ b/tests/http_error_test.rs @@ -11,10 +11,12 @@ fn api_error( typed: Option, parse_error: Option<&str>, ) -> ApiError { + let body = body.into(); ApiError { status: 422, headers: HeaderMap::new(), - body: body.into(), + raw_body: body.as_bytes().to_vec(), + body, typed, parse_error: parse_error.map(str::to_owned), } @@ -73,6 +75,22 @@ fn test_api_error_display_normal_body() { assert_eq!(error.to_string(), "API error 422: small response"); } +#[test] +fn api_error_preserves_raw_non_utf8_body() { + let raw_body = vec![0, 159, 146, 150, 255]; + let error = ApiError:: { + status: 500, + headers: HeaderMap::new(), + body: String::from_utf8_lossy(&raw_body).into_owned(), + raw_body: raw_body.clone(), + typed: None, + parse_error: None, + }; + + assert_eq!(error.raw_body, raw_body); + assert!(error.body.contains('\u{fffd}')); +} + #[test] fn test_api_error_display_truncates_body_without_mutating_it() { let body = "é".repeat(600); @@ -125,6 +143,12 @@ fn test_http_error_creation() { let timeout_error = HttpError::Timeout; assert!(matches!(timeout_error, HttpError::Timeout)); + let response_too_large = HttpError::ResponseTooLarge { limit: 1024 }; + assert!(matches!( + response_too_large, + HttpError::ResponseTooLarge { limit: 1024 } + )); + // Test config error let config_error = HttpError::Config("invalid config".to_string()); assert!(matches!(config_error, HttpError::Config(_))); @@ -247,6 +271,9 @@ fn test_retryable_errors() { let config_error = HttpError::Config("invalid".to_string()); assert!(!config_error.is_retryable()); + + let response_too_large = HttpError::ResponseTooLarge { limit: 1024 }; + assert!(!response_too_large.is_retryable()); } #[test] @@ -395,6 +422,10 @@ fn test_generated_error_code() { client_content.contains("pub struct ApiError"), "Generated code should contain ApiError struct" ); + assert!( + client_content.contains("pub raw_body: Vec"), + "Generated ApiError should preserve exact response bytes" + ); assert!( client_content.contains("pub enum ApiOpError"), "Generated code should contain ApiOpError enum" diff --git a/tests/non_json_response_test.rs b/tests/non_json_response_test.rs new file mode 100644 index 0000000..131c390 --- /dev/null +++ b/tests/non_json_response_test.rs @@ -0,0 +1,222 @@ +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::json; +use std::process::Command; + +fn non_json_spec() -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "non-json responses", "version": "1.0.0" }, + "components": { "schemas": { + "Reply": { + "type": "object", + "required": ["value"], + "properties": { "value": { "type": "string" } } + } + }}, + "paths": { + "/text": { "get": { + "operationId": "getText", + "responses": { "200": { "description": "text", "content": { + "text/plain": { "schema": { "type": "string" } } + }}} + }}, + "/binary": { "get": { + "operationId": "getBinary", + "responses": { "200": { "description": "binary", "content": { + "application/octet-stream": { + "schema": { "type": "string", "format": "binary" } + } + }}} + }}, + "/negotiated": { "get": { + "operationId": "getNegotiated", + "responses": { "200": { "description": "negotiated", "content": { + "text/plain": { "schema": { "type": "string" } }, + "application/zip": { + "schema": { "type": "string", "format": "binary" } + } + }}} + }}, + "/mixed-status": { "get": { + "operationId": "getMixedStatus", + "responses": { + "200": { "description": "text", "content": { + "text/plain": { "schema": { "type": "string" } } + }}, + "201": { "description": "binary", "content": { + "application/octet-stream": { + "schema": { "type": "string", "format": "binary" } + } + }} + } + }}, + "/created-body": { "get": { + "operationId": "getCreatedBody", + "responses": { + "200": { "description": "accepted without a body" }, + "201": { "description": "created text", "content": { + "text/plain": { "schema": { "type": "string" } } + }} + } + }}, + "/same-json": { "get": { + "operationId": "getSameJson", + "responses": { + "200": { "description": "ok", "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/Reply" } } + }}, + "201": { "description": "created", "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/Reply" } } + }} + } + }} + } + }) +} + +#[test] +fn generated_client_returns_text_and_preserves_invalid_utf8_binary() { + let temp = tempfile::TempDir::new().unwrap(); + let output_dir = temp.path().join("src/generated"); + let mut analysis = SchemaAnalyzer::new(non_json_spec()) + .unwrap() + .analyze() + .unwrap(); + let generator = CodeGenerator::new(GeneratorConfig { + output_dir: output_dir.clone(), + enable_async_client: true, + enable_sse_client: false, + tracing_enabled: false, + ..Default::default() + }); + let result = generator.generate_all(&mut analysis).unwrap(); + generator.write_files(&result).unwrap(); + + let client = result + .files + .iter() + .find(|file| file.path == std::path::Path::new("client.rs")) + .expect("generated client.rs"); + assert!( + client + .content + .contains("pub async fn get_text(&self) -> Result Result { + assert_eq!(error.status, 201); + assert_eq!(error.raw_body, vec![0, 159, 146, 150, 255]); + assert!(error.body.contains('\u{fffd}')); + assert!(error.parse_error.as_deref().unwrap().contains("unexpected successful status")); + } + other => panic!("unexpected error: {other:?}"), + } + } +} +"#, + ) + .unwrap(); + let dependencies = std::fs::read_to_string(output_dir.join("REQUIRED_DEPS.toml")).unwrap(); + assert!(dependencies.contains("bytes ="), "{dependencies}"); + std::fs::write( + temp.path().join("Cargo.toml"), + format!( + r#"[package] +name = "non-json-response-client" +version = "0.0.0" +edition = "2024" +publish = false + +{dependencies} +axum = "0.8" +tokio = {{ version = "1", features = ["macros", "net", "rt-multi-thread"] }} +"# + ), + ) + .unwrap(); + + let output = Command::new("cargo") + .args(["test", "--quiet"]) + .current_dir(temp.path()) + .env("CARGO_BUILD_BUILD_DIR", temp.path().join("cargo-build")) + .env("CARGO_TARGET_DIR", temp.path().join("cargo-target")) + .output() + .unwrap(); + assert!( + output.status.success(), + "generated client runtime failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} diff --git a/tests/operation_extraction_test.rs b/tests/operation_extraction_test.rs index d2fd9eb..3e07705 100644 --- a/tests/operation_extraction_test.rs +++ b/tests/operation_extraction_test.rs @@ -447,7 +447,7 @@ fn test_extract_octet_stream_body() { assert!(op.request_body.is_some()); assert!(matches!( op.request_body.as_ref().unwrap(), - RequestBodyContent::OctetStream + RequestBodyContent::OctetStream { .. } )); } @@ -467,7 +467,7 @@ fn test_extract_text_plain_body() { assert!(op.request_body.is_some()); assert!(matches!( op.request_body.as_ref().unwrap(), - RequestBodyContent::TextPlain + RequestBodyContent::TextPlain { .. } )); } diff --git a/tests/operation_generation_test.rs b/tests/operation_generation_test.rs index 6719a63..c72d731 100644 --- a/tests/operation_generation_test.rs +++ b/tests/operation_generation_test.rs @@ -152,6 +152,39 @@ fn schema_less_request_content_preserves_client_operation_without_a_body() { ); } +#[test] +fn wildcard_only_request_content_is_non_sendable_without_a_concrete_media_type() { + let generator = CodeGenerator::new(create_test_config()); + let operation = OperationInfo { + operation_id: "uploadAnyImage".to_string(), + method: "POST".to_string(), + path: "/images".to_string(), + summary: None, + description: None, + request_body: Some(RequestBodyContent::Unsupported { + media_types: vec!["*/*".to_string(), "image/*".to_string()], + }), + response_schemas: BTreeMap::new(), + parameters: vec![], + request_body_required: true, + supports_streaming: false, + stream_parameter: None, + tags: Vec::new(), + }; + + let analysis = create_test_analysis_with_operations(vec![operation]); + let generated = generator.generate_operation_methods(&analysis).to_string(); + + assert!(generated.contains("body : Vec < u8 >"), "{generated}"); + assert!( + generated.contains("declares only wildcard media ranges"), + "{generated}" + ); + assert!(generated.contains("HttpError :: Config"), "{generated}"); + assert!(!generated.contains("header (\"content-type\" , \"*/*\")")); + assert!(!generated.contains("header (\"content-type\" , \"image/*\")")); +} + #[test] fn bodyless_content_methods_emit_zero_content_length() { let config = create_test_config(); @@ -505,12 +538,14 @@ fn test_error_handling_generation() { let result = generator.generate_operation_methods(&analysis); let result_str = result.to_string(); - // Verify error handling logic — the new envelope reads the body to a - // string before deserializing and wraps non-2xx (and 2xx parse failures) + // Verify error handling logic — the new envelope reads the body as bytes, + // keeps exact bytes plus a lossy string, and wraps non-2xx (and 2xx parse failures) // in `ApiOpError::Api(ApiError { ... })`. See issue #8. assert!(result_str.contains("let status = response . status ()")); assert!(result_str.contains("if status . is_success ()")); - assert!(result_str.contains("response . text ()")); + assert!(result_str.contains("__read_bounded_response_body")); + assert!(result_str.contains("String :: from_utf8_lossy")); + assert!(result_str.contains("raw_body")); assert!(result_str.contains("ApiOpError :: Api (ApiError")); } @@ -816,7 +851,9 @@ fn test_generate_octet_stream_operation() { path: "/data".to_string(), summary: None, description: None, - request_body: Some(RequestBodyContent::OctetStream), + request_body: Some(RequestBodyContent::OctetStream { + media_type: "application/octet-stream; profile=v2".to_string(), + }), response_schemas: BTreeMap::new(), parameters: vec![], request_body_required: true, @@ -834,7 +871,7 @@ fn test_generate_octet_stream_operation() { // Verify body is used directly assert!(result_str.contains(". body (body)")); // Verify content-type header - assert!(result_str.contains("application/octet-stream")); + assert!(result_str.contains("application/octet-stream; profile=v2")); } #[test] @@ -848,7 +885,9 @@ fn test_generate_text_plain_operation() { path: "/echo".to_string(), summary: None, description: None, - request_body: Some(RequestBodyContent::TextPlain), + request_body: Some(RequestBodyContent::TextPlain { + media_type: "text/plain; charset=utf-8".to_string(), + }), response_schemas: BTreeMap::new(), parameters: vec![], request_body_required: true, @@ -866,7 +905,7 @@ fn test_generate_text_plain_operation() { // Verify body is used directly assert!(result_str.contains(". body (body)")); // Verify content-type header - assert!(result_str.contains("text/plain")); + assert!(result_str.contains("text/plain; charset=utf-8")); } #[test] diff --git a/tests/server_query_roundtrip_test.rs b/tests/server_query_roundtrip_test.rs index 4525404..a06a36d 100644 --- a/tests/server_query_roundtrip_test.rs +++ b/tests/server_query_roundtrip_test.rs @@ -98,7 +98,7 @@ fn round_trip_spec() -> Value { "explode": true, "schema": { "type": "array", - "items": { "type": "integer", "format": "int64" } + "items": { "$ref": "#/components/schemas/QueryId" } } }, { @@ -118,8 +118,11 @@ fn round_trip_spec() -> Value { "ScoresAlias": { "$ref": "#/components/schemas/Scores" }, "Scores": { "type": "array", - "items": { "type": "integer", "format": "int32" } - } + "items": { "$ref": "#/components/schemas/PublicScore" } + }, + "QueryId": { "type": "integer", "format": "int64" }, + "PublicScore": { "$ref": "#/components/schemas/Score" }, + "Score": { "type": "integer", "format": "int32" } } } }) @@ -215,8 +218,8 @@ mod tests { optional_expanded: Option, compact: QueryRoundTripCompact, deep_filter: Option, - ids: Vec, - scores: Option>, + ids: Vec, + scores: Option>, ) -> QueryRoundTripResponse { self.captured .send(json!({ diff --git a/tests/server_raw_body_roundtrip_test.rs b/tests/server_raw_body_roundtrip_test.rs new file mode 100644 index 0000000..6d797ab --- /dev/null +++ b/tests/server_raw_body_roundtrip_test.rs @@ -0,0 +1,472 @@ +use openapi_to_rust::config::{ClientSection, ServerSection, ServerValidationSection}; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::json; +use std::process::Command; + +fn raw_body_spec() -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "raw request bodies", "version": "1.0.0" }, + "paths": { + "/octets": { "post": { + "operationId": "uploadOctets", "tags": ["Binary"], + "requestBody": { "required": true, "content": { + "application/octet-stream; profile=v2": { "schema": { "type": "string", "format": "binary" } } + }}, + "responses": { "204": { "description": "ok" } } + }}, + "/archive": { "post": { + "operationId": "uploadArchive", "tags": ["Binary"], + "requestBody": { "required": true, "content": { + "application/vnd.acme+zip; profile=v2": { "schema": { "type": "string", "format": "binary" } } + }}, + "responses": { "204": { "description": "ok" } } + }}, + "/optional-octets": { "post": { + "operationId": "maybeOctets", "tags": ["Binary"], + "requestBody": { "content": { + "application/octet-stream": { "schema": { "type": "string", "format": "binary" } } + }}, + "responses": { "204": { "description": "ok" } } + }}, + "/beacon": { "post": { + "operationId": "sendBeacon", "tags": ["Text"], + "requestBody": { "required": true, "content": { + "text/plain; charset=utf-8": { "schema": { "type": "string" } } + }}, + "responses": { "204": { "description": "ok" } } + }}, + "/optional-beacon": { "post": { + "operationId": "maybeBeacon", "tags": ["Text"], + "requestBody": { "content": { + "text/plain": { "schema": { "type": "string" } } + }}, + "responses": { "204": { "description": "ok" } } + }} + } + }) +} + +#[test] +fn raw_transport_decoders_coexist_with_schema_validation() { + let mut analysis = SchemaAnalyzer::new(raw_body_spec()) + .unwrap() + .analyze() + .unwrap(); + let generator = CodeGenerator::new(GeneratorConfig { + enable_async_client: false, + server: Some(ServerSection { + framework: "axum".into(), + operations: vec!["tag:Binary".into(), "tag:Text".into()], + prune_models: true, + validation: Default::default(), + }), + ..Default::default() + }); + let result = generator.generate_all(&mut analysis).unwrap(); + let validation = result + .files + .iter() + .find(|file| file.path.ends_with("server/validation.rs")) + .unwrap(); + assert!(validation.content.contains("decode_binary_body")); + assert!(validation.content.contains("decode_text_body")); + assert!(validation.content.contains("jsonschema")); +} + +fn generated_client_and_server_round_trip_bounded_raw_bodies(validation_enabled: bool) { + let temp = tempfile::TempDir::new().expect("temp crate"); + let output_dir = temp.path().join("src/generated"); + let server = ServerSection { + framework: "axum".into(), + operations: vec!["tag:Binary".into(), "tag:Text".into()], + prune_models: true, + validation: ServerValidationSection { + enabled: validation_enabled, + max_body_bytes: 8, + max_errors: 2, + }, + }; + let config = GeneratorConfig { + output_dir: output_dir.clone(), + module_name: "raw_body".into(), + enable_async_client: true, + tracing_enabled: false, + server: Some(server), + ..Default::default() + }; + let mut analysis = SchemaAnalyzer::new(raw_body_spec()) + .expect("analyzer") + .analyze() + .expect("analysis"); + assert!(matches!( + analysis.operations["uploadArchive"].request_body, + Some(openapi_to_rust::analysis::RequestBodyContent::Binary { ref media_type }) + if media_type == "application/vnd.acme+zip; profile=v2" + )); + assert!(matches!( + analysis.operations["uploadOctets"].request_body, + Some(openapi_to_rust::analysis::RequestBodyContent::OctetStream { ref media_type }) + if media_type == "application/octet-stream; profile=v2" + )); + assert!(matches!( + analysis.operations["sendBeacon"].request_body, + Some(openapi_to_rust::analysis::RequestBodyContent::TextPlain { ref media_type }) + if media_type == "text/plain; charset=utf-8" + )); + let generator = CodeGenerator::new(config); + let result = generator + .generate_all(&mut analysis) + .expect("client and server generate"); + generator + .write_files(&result) + .expect("generated files write"); + + let dependency_fragment = + std::fs::read_to_string(output_dir.join("REQUIRED_DEPS.toml")).unwrap(); + for dependency in ["bytes", "http-body-util", "mime", "axum", "reqwest"] { + assert!( + dependency_fragment.contains(dependency), + "{dependency_fragment}" + ); + } + assert_eq!( + dependency_fragment.contains("jsonschema"), + validation_enabled, + "{dependency_fragment}" + ); + + let package = r#"[package] +name = "generated-raw-body-roundtrip" +version = "0.0.0" +edition = "2024" +publish = false +"#; + std::fs::write( + temp.path().join("Cargo.toml"), + format!( + "{package}\n{dependency_fragment}\n[dev-dependencies]\naxum = {{ version = \"0.8\", features = [\"tokio\", \"http1\"] }}\ntokio = {{ version = \"1\", features = [\"macros\", \"rt-multi-thread\", \"net\", \"sync\", \"time\"] }}\n" + ), + ) + .unwrap(); + std::fs::write( + temp.path().join("src/lib.rs"), + r#"pub mod generated; + +#[cfg(test)] +mod tests { + use super::generated::*; + use tokio::sync::mpsc::{UnboundedSender, unbounded_channel}; + + #[derive(Clone)] + struct Api { + captured: UnboundedSender<(String, Option>)>, + } + + #[async_trait::async_trait] + impl BinaryApi for Api { + async fn upload_octets(&self, body: bytes::Bytes) -> UploadOctetsResponse { + self.captured.send(("octets".into(), Some(body.to_vec()))).unwrap(); + UploadOctetsResponse::NoContent + } + + async fn upload_archive(&self, body: bytes::Bytes) -> UploadArchiveResponse { + self.captured.send(("archive".into(), Some(body.to_vec()))).unwrap(); + UploadArchiveResponse::NoContent + } + + async fn maybe_octets(&self, body: Option) -> MaybeOctetsResponse { + self.captured.send(("maybe-octets".into(), body.map(|value| value.to_vec()))).unwrap(); + MaybeOctetsResponse::NoContent + } + } + + #[async_trait::async_trait] + impl TextApi for Api { + async fn send_beacon(&self, body: String) -> SendBeaconResponse { + self.captured.send(("beacon".into(), Some(body.into_bytes()))).unwrap(); + SendBeaconResponse::NoContent + } + + async fn maybe_beacon(&self, body: Option) -> MaybeBeaconResponse { + self.captured.send(("maybe-beacon".into(), body.map(String::into_bytes))).unwrap(); + MaybeBeaconResponse::NoContent + } + } + + #[tokio::test] + async fn raw_transport_contract() { + let (captured_tx, mut captured_rx) = unbounded_channel(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let binary_api = Api { captured: captured_tx.clone() }; + let text_api = Api { captured: captured_tx }; + let server = tokio::spawn(async move { + axum::serve(listener, build_router(binary_api, text_api)).await.unwrap(); + }); + let base_url = format!("http://{address}"); + let client = HttpClient::new().with_base_url(base_url.clone()); + + let octets = vec![0, 159, 146, 150, 255]; + client.upload_octets(octets.clone()).await.unwrap(); + assert_eq!(captured_rx.recv().await.unwrap(), ("octets".into(), Some(octets))); + + let archive = vec![0x50, 0x4b, 0xff]; + client.upload_archive(archive.clone()).await.unwrap(); + assert_eq!(captured_rx.recv().await.unwrap(), ("archive".into(), Some(archive))); + + client.send_beacon("signal".to_string()).await.unwrap(); + assert_eq!(captured_rx.recv().await.unwrap(), ("beacon".into(), Some(b"signal".to_vec()))); + + client.maybe_octets(None).await.unwrap(); + assert_eq!(captured_rx.recv().await.unwrap(), ("maybe-octets".into(), None)); + client.maybe_beacon(None).await.unwrap(); + assert_eq!(captured_rx.recv().await.unwrap(), ("maybe-beacon".into(), None)); + + let http = reqwest::Client::new(); + let wrong = http.post(format!("{base_url}/octets")) + .header("content-type", "text/plain").body(vec![1]).send().await.unwrap(); + assert_eq!(wrong.status(), reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE); + + let wrong_suffix = http.post(format!("{base_url}/archive")) + .header("content-type", "application/vnd.acme+json; profile=v2") + .body(vec![1]).send().await.unwrap(); + assert_eq!(wrong_suffix.status(), reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE); + + let missing_profile = http.post(format!("{base_url}/archive")) + .header("content-type", "application/vnd.acme+zip") + .body(vec![1]).send().await.unwrap(); + assert_eq!(missing_profile.status(), reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE); + + let missing = http.post(format!("{base_url}/octets")) + .body(vec![1]).send().await.unwrap(); + assert_eq!(missing.status(), reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE); + + let oversized = http.post(format!("{base_url}/octets")) + .header("content-type", "application/octet-stream; profile=v2") + .body(vec![7; 9]).send().await.unwrap(); + assert_eq!(oversized.status(), reqwest::StatusCode::PAYLOAD_TOO_LARGE); + + let invalid_utf8 = http.post(format!("{base_url}/beacon")) + .header("content-type", "text/plain; charset=utf-8") + .body(vec![0xff]).send().await.unwrap(); + assert_eq!(invalid_utf8.status(), reqwest::StatusCode::BAD_REQUEST); + + let optional_nonempty_without_media = http.post(format!("{base_url}/optional-beacon")) + .body("x").send().await.unwrap(); + assert_eq!(optional_nonempty_without_media.status(), reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE); + + let required_empty = http.post(format!("{base_url}/octets")) + .header("content-type", "application/octet-stream; profile=v2; version=1") + .body(Vec::new()).send().await.unwrap(); + assert_eq!(required_empty.status(), reqwest::StatusCode::NO_CONTENT); + assert_eq!(captured_rx.recv().await.unwrap(), ("octets".into(), Some(Vec::new()))); + + server.abort(); + } +} +"#, + ) + .unwrap(); + + let scratch_target = temp.path().join("cargo-target"); + let scratch_build = temp.path().join("cargo-build"); + let check = Command::new("cargo") + .args(["check", "--lib"]) + .current_dir(temp.path()) + .env("CARGO_TARGET_DIR", &scratch_target) + .env("CARGO_BUILD_BUILD_DIR", &scratch_build) + .output() + .unwrap(); + assert!( + check.status.success(), + "dependency-fragment compile failed:\n{}", + String::from_utf8_lossy(&check.stderr) + ); + + let test = Command::new("cargo") + .args(["test", "--quiet"]) + .current_dir(temp.path()) + .env("CARGO_TARGET_DIR", &scratch_target) + .env("CARGO_BUILD_BUILD_DIR", &scratch_build) + .output() + .unwrap(); + assert!( + test.status.success(), + "generated client/server runtime failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&test.stdout), + String::from_utf8_lossy(&test.stderr) + ); +} + +#[test] +fn generated_client_and_server_round_trip_bounded_raw_bodies_with_validation() { + generated_client_and_server_round_trip_bounded_raw_bodies(true); +} + +#[test] +fn generated_client_and_server_round_trip_bounded_raw_bodies_without_validation() { + generated_client_and_server_round_trip_bounded_raw_bodies(false); +} + +#[test] +fn storyden_generated_client_round_trips_raw_bodies_through_generated_server() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let source = std::fs::read_to_string(manifest_dir.join("specs/storyden.yaml")) + .expect("read vendored Storyden spec"); + let spec = serde_yaml::from_str(&source).expect("parse vendored Storyden spec"); + let selected = vec![ + "IconUpload".to_string(), + "PluginUpdatePackage".to_string(), + "SendBeacon".to_string(), + ]; + let temp = tempfile::TempDir::new().expect("temp crate"); + let output_dir = temp.path().join("src/generated"); + let config = GeneratorConfig { + output_dir: output_dir.clone(), + module_name: "storyden".into(), + enable_async_client: true, + tracing_enabled: false, + client: Some(ClientSection { + operations: selected.clone(), + prune_models: true, + }), + server: Some(ServerSection { + framework: "axum".into(), + operations: selected, + prune_models: true, + validation: ServerValidationSection { + enabled: false, + max_body_bytes: 32, + max_errors: 2, + }, + }), + ..Default::default() + }; + let mut analysis = SchemaAnalyzer::new(spec) + .expect("analyzer") + .analyze() + .expect("analysis"); + let generator = CodeGenerator::new(config); + let result = generator + .generate_all(&mut analysis) + .expect("selected Storyden client and server generate"); + generator + .write_files(&result) + .expect("generated Storyden files write"); + + let dependency_fragment = + std::fs::read_to_string(output_dir.join("REQUIRED_DEPS.toml")).unwrap(); + let package = r#"[package] +name = "generated-storyden-roundtrip" +version = "0.0.0" +edition = "2024" +publish = false +"#; + std::fs::write( + temp.path().join("Cargo.toml"), + format!( + "{package}\n{dependency_fragment}\n[dev-dependencies]\naxum = {{ version = \"0.8\", features = [\"tokio\", \"http1\"] }}\ntokio = {{ version = \"1\", features = [\"macros\", \"rt-multi-thread\", \"net\", \"sync\"] }}\n" + ), + ) + .unwrap(); + std::fs::write( + temp.path().join("src/lib.rs"), + r#"pub mod generated; + +#[cfg(test)] +mod tests { + use super::generated::*; + use tokio::sync::mpsc::{UnboundedSender, unbounded_channel}; + + #[derive(Clone)] + struct Api { + captured: UnboundedSender<(String, Option>)>, + } + + #[async_trait::async_trait] + impl MiscApi for Api { + async fn icon_upload(&self, body: Option) -> IconUploadResponse { + self.captured + .send(("icon".into(), body.map(|value| value.to_vec()))) + .unwrap(); + IconUploadResponse::Ok + } + + async fn send_beacon(&self, body: Option) -> SendBeaconResponse { + self.captured + .send(("beacon".into(), body.map(String::into_bytes))) + .unwrap(); + SendBeaconResponse::Accepted + } + } + + #[async_trait::async_trait] + impl PluginsApi for Api { + async fn plugin_update_package( + &self, + plugin_instance_id: String, + body: Option, + ) -> PluginUpdatePackageResponse { + assert_eq!(plugin_instance_id, "plugin-one"); + self.captured + .send(("plugin".into(), body.map(|value| value.to_vec()))) + .unwrap(); + PluginUpdatePackageResponse::Ok(serde_json::json!({"updated": true})) + } + } + + #[tokio::test] + async fn selected_storyden_contract() { + let (captured_tx, mut captured_rx) = unbounded_channel(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let misc_api = Api { captured: captured_tx.clone() }; + let plugins_api = Api { captured: captured_tx }; + let server = tokio::spawn(async move { + axum::serve(listener, build_router(misc_api, plugins_api)).await.unwrap(); + }); + let client = HttpClient::new().with_base_url(format!("http://{address}")); + + let icon = vec![0, 159, 146, 150, 255]; + client.icon_upload(Some(icon.clone())).await.unwrap(); + assert_eq!(captured_rx.recv().await.unwrap(), ("icon".into(), Some(icon))); + + let archive = vec![0x50, 0x4b, 0x03, 0x04, 0xff]; + let response = client + .plugin_update_package("plugin-one", Some(archive.clone())) + .await + .unwrap(); + assert_eq!(response, serde_json::json!({"updated": true})); + assert_eq!( + captured_rx.recv().await.unwrap(), + ("plugin".into(), Some(archive)) + ); + + client.send_beacon(Some("read-state".into())).await.unwrap(); + assert_eq!( + captured_rx.recv().await.unwrap(), + ("beacon".into(), Some(b"read-state".to_vec())) + ); + + server.abort(); + } +} +"#, + ) + .unwrap(); + + let test = Command::new("cargo") + .args(["test", "--quiet"]) + .current_dir(temp.path()) + .env("CARGO_TARGET_DIR", temp.path().join("cargo-target")) + .env("CARGO_BUILD_BUILD_DIR", temp.path().join("cargo-build")) + .output() + .unwrap(); + assert!( + test.status.success(), + "generated Storyden client/server runtime failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&test.stdout), + String::from_utf8_lossy(&test.stderr) + ); +} diff --git a/tests/server_response_collision_test.rs b/tests/server_response_collision_test.rs new file mode 100644 index 0000000..0e42103 --- /dev/null +++ b/tests/server_response_collision_test.rs @@ -0,0 +1,140 @@ +use openapi_to_rust::config::{ServerSection, ServerValidationSection}; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::json; +use std::process::Command; + +#[test] +fn response_enums_avoid_retained_model_names_and_compile() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "response enum collisions", "version": "1.0.0" }, + "paths": { + "/thing": { "get": { + "operationId": "getThing", + "tags": ["Things"], + "responses": { "200": { + "description": "ok", + "content": { "application/json": { + "schema": { "$ref": "#/components/schemas/GetThingResponse" } + }} + }} + }}, + "/things": { "get": { + "operationId": "listThings", + "tags": ["Things"], + "responses": { + "200": { + "description": "ok", + "content": { "application/json": { + "schema": { "$ref": "#/components/schemas/ListThingsResponse" } + }} + }, + "400": { + "description": "bad request", + "content": { "application/json": { + "schema": { "$ref": "#/components/schemas/ListThingsServerResponse" } + }} + } + } + }} + }, + "components": { "schemas": { + "GetThingResponse": { "type": "string" }, + "ListThingsResponse": { "type": "string" }, + "ListThingsServerResponse": { "type": "string" } + }} + }); + let mut analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let temp = tempfile::TempDir::new().unwrap(); + let output_dir = temp.path().join("src/generated"); + let server = ServerSection { + framework: "axum".into(), + // Reverse lexical order to verify allocation is not selector-order dependent. + operations: vec!["listThings".into(), "getThing".into()], + prune_models: true, + validation: ServerValidationSection { + enabled: false, + ..Default::default() + }, + }; + let generator = CodeGenerator::new(GeneratorConfig { + output_dir: output_dir.clone(), + module_name: "collision".into(), + enable_async_client: false, + server: Some(server), + ..Default::default() + }); + let result = generator.generate_all(&mut analysis).unwrap(); + generator.write_files(&result).unwrap(); + + let types = std::fs::read_to_string(output_dir.join("types.rs")).unwrap(); + assert!(types.contains("pub type GetThingResponse = String;")); + assert!(types.contains("pub type ListThingsResponse = String;")); + assert!(types.contains("pub type ListThingsServerResponse = String;")); + + let errors = std::fs::read_to_string(output_dir.join("server/errors.rs")).unwrap(); + assert!(errors.contains("pub enum GetThingServerResponse")); + assert!(errors.contains("Ok(GetThingResponse)")); + assert!(errors.contains("pub enum ListThingsServerResponse2")); + assert!(errors.contains("Ok(ListThingsResponse)")); + assert!(errors.contains("BadRequest(ListThingsServerResponse)")); + assert!(!errors.contains("pub enum GetThingResponse")); + assert!(!errors.contains("pub enum ListThingsResponse")); + assert!(!errors.contains("pub enum ListThingsServerResponse {")); + + let api = std::fs::read_to_string(output_dir.join("server/api.rs")).unwrap(); + assert!(api.contains("async fn get_thing(&self) -> GetThingServerResponse;")); + assert!(api.contains("async fn list_things(&self) -> ListThingsServerResponse2;")); + + std::fs::write( + temp.path().join("src/lib.rs"), + r#"pub mod generated; + +use generated::server::{ + GetThingServerResponse, ListThingsServerResponse2, ThingsApi, +}; + +#[derive(Clone)] +pub struct Api; + +#[async_trait::async_trait] +impl ThingsApi for Api { + async fn get_thing(&self) -> GetThingServerResponse { + GetThingServerResponse::Ok("one".to_string()) + } + + async fn list_things(&self) -> ListThingsServerResponse2 { + ListThingsServerResponse2::Ok("many".to_string()) + } +} +"#, + ) + .unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "server-response-collision-smoke" +version = "0.1.0" +edition = "2024" + +[dependencies] +async-trait = "0.1" +axum = "0.8" +serde = { version = "1", features = ["derive"] } +"#, + ) + .unwrap(); + + let output = Command::new("cargo") + .arg("check") + .arg("--quiet") + .current_dir(temp.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "generated collision server failed to compile:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} diff --git a/tests/server_response_semantics_test.rs b/tests/server_response_semantics_test.rs index 93dbae0..5818b17 100644 --- a/tests/server_response_semantics_test.rs +++ b/tests/server_response_semantics_test.rs @@ -40,6 +40,33 @@ fn response_spec() -> serde_json::Value { "text/event-streaming": {} }} } + }}, + "/text": { "get": { + "operationId": "getText", + "tags": ["Things"], + "responses": { + "200": { "description": "text", "content": { + "text/plain; charset=utf-8": { "schema": { "type": "string" } } + }} + } + }}, + "/binary": { "get": { + "operationId": "getBinary", + "tags": ["Things"], + "responses": { + "200": { "description": "binary", "content": { + "image/png": { "schema": { "type": "string", "format": "binary" } } + }} + } + }}, + "/wildcard": { "get": { + "operationId": "getWildcard", + "tags": ["Things"], + "responses": { + "2XX": { "description": "binary", "content": { + "*/*": { "schema": { "type": "string", "format": "binary" } } + }} + } }} }, "components": { @@ -75,6 +102,21 @@ fn analysis_retains_resolved_bodyless_media_and_exact_sse_responses() { assert!(create["204"].schema_name.is_none()); assert!(analysis.operation_responses["streamThing"]["202"].supports_streaming); assert!(!analysis.operation_responses["notStream"]["200"].supports_streaming); + assert!(matches!( + analysis.operation_responses["getText"]["200"].body, + Some(openapi_to_rust::analysis::OperationResponseBody::Text { .. }) + )); + assert!(matches!( + analysis.operation_responses["getBinary"]["200"].body, + Some(openapi_to_rust::analysis::OperationResponseBody::Binary { + wildcard: false, + .. + }) + )); + assert!(matches!( + analysis.operation_responses["getWildcard"]["2XX"].body, + Some(openapi_to_rust::analysis::OperationResponseBody::Binary { wildcard: true, .. }) + )); } #[test] @@ -193,7 +235,6 @@ fn cyclic_response_reference_chain_is_rejected() { #[test] fn generated_responses_preserve_status_ranges_media_and_sse_status() { - let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); let temp = tempfile::TempDir::new().unwrap(); let output_dir = temp.path().join("src/generated"); let mut analysis = SchemaAnalyzer::new(response_spec()) @@ -202,7 +243,13 @@ fn generated_responses_preserve_status_ranges_media_and_sse_status() { .unwrap(); let server = ServerSection { framework: "axum".into(), - operations: vec!["createThing".into(), "streamThing".into()], + operations: vec![ + "createThing".into(), + "streamThing".into(), + "getText".into(), + "getBinary".into(), + "getWildcard".into(), + ], prune_models: true, validation: ServerValidationSection { enabled: false, @@ -261,6 +308,31 @@ mod tests { assert_eq!(default.status(), StatusCode::IM_A_TEAPOT); assert_eq!(default.headers()[CONTENT_TYPE], "application/problem+json"); + let text = GetTextResponse::Ok("hello".to_string()).into_response(); + assert_eq!(text.status(), StatusCode::OK); + assert_eq!(text.headers()[CONTENT_TYPE], "text/plain; charset=utf-8"); + + let binary = GetBinaryResponse::Ok(bytes::Bytes::from_static(&[0, 159, 146, 150])) + .into_response(); + assert_eq!(binary.status(), StatusCode::OK); + assert_eq!(binary.headers()[CONTENT_TYPE], "image/png"); + + let wildcard = GetWildcardResponse::Success( + StatusCode::MULTI_STATUS, + axum::http::HeaderValue::from_static("image/webp"), + bytes::Bytes::from_static(&[0, 255]), + ).into_response(); + assert_eq!(wildcard.status(), StatusCode::MULTI_STATUS); + assert_eq!(wildcard.headers()[CONTENT_TYPE], "image/webp"); + assert_eq!( + GetWildcardResponse::Success( + StatusCode::BAD_REQUEST, + axum::http::HeaderValue::from_static("image/webp"), + bytes::Bytes::new(), + ).into_response().status(), + StatusCode::INTERNAL_SERVER_ERROR, + ); + let stream = futures_util::stream::empty::< Result >(); @@ -282,6 +354,7 @@ edition = "2024" [dependencies] async-trait = "0.1" axum = "0.8" +bytes = "1" futures-core = "0.3" futures-util = "0.3" serde = { version = "1", features = ["derive"] } @@ -296,10 +369,8 @@ serde_urlencoded = "0.7" .arg("test") .arg("--quiet") .current_dir(temp.path()) - .env( - "CARGO_TARGET_DIR", - manifest_dir.join("target/server-response-semantics-smoke"), - ) + .env("CARGO_BUILD_BUILD_DIR", temp.path().join("cargo-build")) + .env("CARGO_TARGET_DIR", temp.path().join("cargo-target")) .output() .unwrap(); assert!( @@ -311,7 +382,7 @@ serde_urlencoded = "0.7" } #[test] -fn unsupported_response_content_fails_instead_of_becoming_a_unit_variant() { +fn text_response_content_generates_a_string_variant() { let spec = json!({ "openapi": "3.1.0", "info": { "title": "unsupported response", "version": "1.0.0" }, @@ -323,6 +394,45 @@ fn unsupported_response_content_fails_instead_of_becoming_a_unit_variant() { }}} }); let analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let server = ServerSection { + framework: "axum".into(), + operations: vec!["getReport".into()], + prune_models: false, + validation: Default::default(), + }; + let config = GeneratorConfig { + enable_async_client: false, + server: Some(server.clone()), + ..Default::default() + }; + let files = ServerCodegen::new(&config, &analysis, &server) + .generate() + .expect("text/plain is a supported response body"); + let errors = files + .iter() + .find(|file| file.path.ends_with("errors.rs")) + .expect("generated errors.rs"); + assert!(errors.content.contains("Ok(String)"), "{}", errors.content); + assert!(errors.content.contains("text/plain"), "{}", errors.content); +} + +#[test] +fn text_wildcard_response_is_rejected_instead_of_emitting_wildcard_content_type() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "wildcard text response", "version": "1.0.0" }, + "paths": { "/report": { "get": { + "operationId": "getReport", + "responses": { "200": { "description": "report", "content": { + "text/*": { "schema": { "type": "string" } } + }}} + }}} + }); + let analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let response = &analysis.operation_responses["getReport"]["200"]; + assert!(response.body.is_none()); + assert_eq!(response.unsupported_media_types, ["text/*"]); + let server = ServerSection { framework: "axum".into(), operations: vec!["getReport".into()], @@ -336,10 +446,9 @@ fn unsupported_response_content_fails_instead_of_becoming_a_unit_variant() { }; let error = ServerCodegen::new(&config, &analysis, &server) .generate() - .unwrap_err() + .expect_err("text wildcard must not become a literal Content-Type") .to_string(); - assert!(error.contains("getReport"), "{error}"); - assert!(error.contains("text/plain"), "{error}"); + assert!(error.contains("text/*"), "{error}"); } #[test] @@ -356,6 +465,12 @@ fn supported_json_representation_allows_unsupported_alternatives() { }}} }); let analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let response = &analysis.operation_responses["getReport"]["200"]; + assert!(matches!( + response.body, + Some(openapi_to_rust::analysis::OperationResponseBody::Json { .. }) + )); + assert_eq!(response.unsupported_media_types, ["application/xml"]); let server = ServerSection { framework: "axum".into(), operations: vec!["getReport".into()], @@ -371,3 +486,26 @@ fn supported_json_representation_allows_unsupported_alternatives() { .generate() .expect("the supported JSON representation should be generated"); } + +#[test] +fn schema_bearing_vendor_json_wins_over_schema_less_canonical_json() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "JSON fallback", "version": "1.0.0" }, + "paths": { "/report": { "get": { + "operationId": "getReport", + "responses": { "200": { "description": "report", "content": { + "application/json": {}, + "application/vnd.example+json": { "schema": { "type": "string" } } + }} } + }} } + }); + let analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let response = &analysis.operation_responses["getReport"]["200"]; + assert!(matches!( + &response.body, + Some(openapi_to_rust::analysis::OperationResponseBody::Json { media_type, .. }) + if media_type == "application/vnd.example+json" + )); + assert_eq!(response.unsupported_media_types, ["application/json"]); +}