From 9f742d9a83c66d3db3b4942f6ecbd8b6b1a370cb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 12:41:24 +0000 Subject: [PATCH 1/8] api: document every error, limit and field in the OpenAPI spec List the error codes each endpoint really returns (401 vs 403, the framework's plain-text 400/415/422, 500 and 502), add tags, operation descriptions, examples and reusable responses, and describe job statuses and failure codes with x-enum-descriptions. Facts come from routes.rs, jobs.rs, api_keys.rs and the worker. Synced to the web copy. Co-authored-by: Leo --- crates/convt-server/openapi.json | 840 +++++++++++++------------------ 1 file changed, 342 insertions(+), 498 deletions(-) diff --git a/crates/convt-server/openapi.json b/crates/convt-server/openapi.json index 356324f2..e121ef71 100644 --- a/crates/convt-server/openapi.json +++ b/crates/convt-server/openapi.json @@ -3,7 +3,8 @@ "info": { "title": "convt API", "version": "0.1.0", - "description": "Paid conversions. Upload directly using PUT to upload_url, then POST start. Poll status and download every output. Upload exactly input_bytes; the PUT signature binds Content-Length. Inputs and outputs expire after 24 hours. Each account may retain at most 100 job prefixes and 50 GB of declared input storage; cancellations retain this commitment until expiry cleanup succeeds. API price: 1 cent per successful job, pending launch price approval. 120 requests per minute per key. No webhooks." + "summary": "Convert files with the engines from the convt desktop app, from your own code.", + "description": "A conversion is a job. Reserve it with POST /v1/jobs, PUT the file to the returned upload_url, then POST /v1/jobs/{id}/start. Poll GET /v1/jobs/{id} until the job finishes, then GET /v1/jobs/{id}/download for signed output URLs. Inputs and outputs are deleted 24 hours after the job is created. The API sends no webhooks." }, "servers": [ { @@ -11,553 +12,177 @@ "description": "Interim host. https://api.convt.app is the intended canonical host and will replace this once its DNS is live." } ], + "tags": [ + { + "name": "Jobs", + "description": "Reserve, upload, start, poll, download and cancel conversions. Every job belongs to the account that owns the credential; another account's job is a 404." + }, + { + "name": "Formats", + "description": "The format table the API validates against. Public; no credential needed." + } + ], + "security": [{ "bearerAuth": [] }], "paths": { "/v1/jobs": { "post": { - "summary": "Reserve a conversion and get a 15-minute upload URL", + "tags": ["Jobs"], + "summary": "Create a job", + "description": "Reserves one conversion against your spend cap and returns a presigned upload URL that works for 15 minutes. PUT exactly input_bytes bytes to it: the signature binds Content-Length. Nothing is charged until the job succeeds. If the upload URL cannot be issued, the job is cancelled and the reservation released.", "operationId": "reserveJob", - "security": [ - { - "bearerAuth": [] + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateJob" }, + "example": { "input_format": "png", "target_format": "webp", "input_bytes": 48213 } + } } - ], + }, "responses": { "200": { - "description": "Success", + "description": "The job is reserved in status `created`.", "content": { "application/json": { - "schema": { - "type": "object", - "properties": { - "job": { - "$ref": "#/components/schemas/Job" - }, - "upload_url": { - "type": "string", - "format": "uri" - }, - "upload_expires_in": { - "type": "integer" - } - } - } + "schema": { "$ref": "#/components/schemas/JobReservation" } } } }, "400": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Request refused", + "description": "`file_too_large`: input_bytes is below 1 or above 2,000,000,000. `unsupported_format`: an unknown format, the same format twice, or a pair the cloud cannot convert. Malformed JSON also returns 400, as plain text.", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "401": { "$ref": "#/components/responses/MissingCredential" }, "403": { - "description": "Request refused", + "description": "`unauthorized`: the key is invalid or revoked, or the web credential expired. `not_enrolled`: no active API billing with a card on file. `limit_reached`: this job would pass your monthly spend cap. `storage_limit_reached`: 100 jobs or 50 GB of inputs are still stored; they are freed when their 24-hour cleanup runs.", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, - "429": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "502": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": false, - "required": ["input_format", "target_format", "input_bytes"], - "properties": { - "input_format": { - "type": "string" - }, - "target_format": { - "type": "string" - }, - "input_bytes": { - "type": "integer", - "minimum": 1, - "maximum": 2000000000 - } - } - } - } - } + "415": { "$ref": "#/components/responses/NotJson" }, + "422": { "$ref": "#/components/responses/InvalidBody" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/Internal" }, + "502": { "$ref": "#/components/responses/StorageUnavailable" } } } }, "/v1/jobs/{id}": { "get": { - "summary": "Read the status of an owned job", + "tags": ["Jobs"], + "summary": "Retrieve a job", + "description": "Returns the job's current status. Poll this after start; a job runs for at most 10 minutes per attempt. Expired jobs return 404.", "operationId": "readJob", - "security": [ - { - "bearerAuth": [] - } - ], + "security": [{ "bearerAuth": [] }], + "parameters": [{ "$ref": "#/components/parameters/JobId" }], "responses": { "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Job" - } - } - } - }, - "400": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "502": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] + "description": "The job.", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } + }, + "401": { "$ref": "#/components/responses/MissingCredential" }, + "403": { "$ref": "#/components/responses/InvalidCredential" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/Internal" } + } } }, "/v1/jobs/{id}/start": { "post": { - "summary": "Seal the upload, validate its actual size and queue the job", + "tags": ["Jobs"], + "summary": "Start a job", + "description": "Seals the uploaded file, checks that its size matches input_bytes and queues the job. Call it once the PUT has succeeded. Starting a job that is no longer `created` changes nothing and returns it as it is, so retries are safe.", "operationId": "sealStart", - "security": [ - { - "bearerAuth": [] - } - ], + "security": [{ "bearerAuth": [] }], + "parameters": [{ "$ref": "#/components/parameters/JobId" }], "responses": { "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Job" - } - } - } + "description": "The job, normally in status `queued`.", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } }, "400": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Request refused", + "description": "`size_mismatch`: the uploaded size differs from input_bytes or passes 2 GB. The job is cancelled and its reservation released.", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, - "404": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "502": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] + "401": { "$ref": "#/components/responses/MissingCredential" }, + "403": { "$ref": "#/components/responses/InvalidCredential" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/Internal" }, + "502": { "$ref": "#/components/responses/StorageUnavailable" } + } } }, "/v1/jobs/{id}/cancel": { "post": { - "summary": "Cancel an owned job and release its reservation", + "tags": ["Jobs"], + "summary": "Cancel a job", + "description": "Cancels a job that has not finished and releases its reservation, so it is never charged. A job that already succeeded, failed or was cancelled is returned unchanged; check its status to see which happened.", "operationId": "cancelCancel", - "security": [ - { - "bearerAuth": [] - } - ], + "security": [{ "bearerAuth": [] }], + "parameters": [{ "$ref": "#/components/parameters/JobId" }], "responses": { "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Job" - } - } - } - }, - "400": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "502": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] + "description": "The job after cancellation.", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } + }, + "401": { "$ref": "#/components/responses/MissingCredential" }, + "403": { "$ref": "#/components/responses/InvalidCredential" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/Internal" } + } } }, "/v1/jobs/{id}/download": { "get": { - "summary": "Get signed download URLs for completed outputs", + "tags": ["Jobs"], + "summary": "Download outputs", + "description": "Returns a signed GET URL for every output of a succeeded job. A conversion can produce several files, such as one image per PDF page. The URLs last at most 5 minutes and never past the job's expiry; call again for fresh ones.", "operationId": "getDownload", - "security": [ - { - "bearerAuth": [] - } - ], + "security": [{ "bearerAuth": [] }], + "parameters": [{ "$ref": "#/components/parameters/JobId" }], "responses": { "200": { - "description": "Success", + "description": "Signed output URLs.", "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "outputs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "url": { - "type": "string", - "format": "uri" - } - } - } - }, - "expires_in": { - "type": "integer" - } - } - } - } + "application/json": { "schema": { "$ref": "#/components/schemas/Downloads" } } } }, "400": { - "description": "Request refused", + "description": "`not_ready`: the job has not succeeded.", "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, - "401": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "502": { - "description": "Request refused", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] + "401": { "$ref": "#/components/responses/MissingCredential" }, + "403": { "$ref": "#/components/responses/InvalidCredential" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/Internal" }, + "502": { "$ref": "#/components/responses/StorageUnavailable" } + } } }, "/v1/formats": { "get": { + "tags": ["Formats"], "summary": "List formats", + "description": "Every format convt knows, with its id, extensions and MIME type. Use the ids in input_format and target_format. Not every pair converts in the cloud; creating a job for one that does not returns `unsupported_format`.", + "operationId": "listFormats", + "security": [], "responses": { "200": { - "description": "Success", + "description": "The format table.", "content": { "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "category": { - "type": "string" - }, - "extensions": { - "type": "array", - "items": { - "type": "string" - } - }, - "mime": { - "type": "string" - } - } - } - } + "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Format" } } } } } @@ -570,57 +195,276 @@ "bearerAuth": { "type": "http", "scheme": "bearer", - "description": "cvt_live_ API key. Browser converters use a 5-minute account-scoped cvt_web_ credential issued by the site Worker." + "description": "An API key: `cvt_live_` followed by 32 lowercase letters and digits, sent as `Authorization: Bearer `. Create keys on the dashboard under API once API billing has a card. The browser converter on convt.app uses a 5-minute `cvt_web_` credential that only convt.app can issue." + } + }, + "parameters": { + "JobId": { + "name": "id", + "in": "path", + "required": true, + "description": "The job id returned when the job was created.", + "schema": { "type": "string", "example": "job_01k6x0d3b2v8m9q4r7t5w1y3z6" } + } + }, + "responses": { + "MissingCredential": { + "description": "`unauthorized`: no `Authorization: Bearer` header.", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + }, + "InvalidCredential": { + "description": "`unauthorized`: the key is invalid or revoked, or the web credential expired.", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + }, + "NotFound": { + "description": "`not_found`: no such job for this account, or it expired.", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + }, + "RateLimited": { + "description": "`rate_limited`: more than 120 requests in a minute with this key. The window starts with the first request and lasts 60 seconds; no Retry-After header is sent.", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + }, + "Internal": { + "description": "`internal`: the request failed on our side. Retry with backoff.", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + }, + "StorageUnavailable": { + "description": "`storage_unavailable`: object storage did not respond. Retry shortly.", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + }, + "NotJson": { + "description": "The request has no `Content-Type: application/json` header. The body is plain text, not an Error.", + "content": { "text/plain": { "schema": { "type": "string" } } } + }, + "InvalidBody": { + "description": "The JSON is valid but a field is missing, has the wrong type, or is not one of the three allowed. The body is plain text, not an Error.", + "content": { "text/plain": { "schema": { "type": "string" } } } } }, "schemas": { - "Job": { + "CreateJob": { "type": "object", - "required": ["id", "status", "expires_at"], + "additionalProperties": false, + "required": ["input_format", "target_format", "input_bytes"], "properties": { - "id": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["created", "uploaded", "queued", "running", "succeeded", "failed", "cancelled"] - }, "input_format": { - "type": "string" + "type": "string", + "description": "Format id of the file you will upload, from GET /v1/formats.", + "example": "png" }, "target_format": { - "type": "string" + "type": "string", + "description": "Format id to convert to. Must differ from input_format.", + "example": "webp" + }, + "input_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 2000000000, + "description": "Exact size of the file in bytes. The upload must match it.", + "example": 48213 + } + } + }, + "JobReservation": { + "type": "object", + "required": ["job", "upload_url", "upload_expires_in"], + "example": { + "job": { + "id": "job_01k6x0d3b2v8m9q4r7t5w1y3z6", + "status": "created", + "input_format": "png", + "target_format": "webp", + "input_bytes": 48213, + "attempt": 0, + "error_code": null, + "expires_at": "2026-10-08T12:00:00Z" + }, + "upload_url": "https://storage.example/job_01k6x0d3b2v8m9q4r7t5w1y3z6/upload?X-Amz-Signature=0f3a9c", + "upload_expires_in": 900 + }, + "properties": { + "job": { "$ref": "#/components/schemas/Job" }, + "upload_url": { + "type": "string", + "format": "uri", + "description": "Presigned URL. PUT the file body to it with a Content-Length of exactly input_bytes.", + "example": "https://storage.example/job_01k6x0d3b2v8m9q4r7t5w1y3z6/upload?X-Amz-Signature=0f3a9c" + }, + "upload_expires_in": { + "type": "integer", + "description": "Seconds until upload_url stops working. Always 900.", + "example": 900 + } + } + }, + "Job": { + "type": "object", + "required": [ + "id", + "status", + "input_format", + "target_format", + "input_bytes", + "attempt", + "error_code", + "expires_at" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique id, prefixed `job_`.", + "example": "job_01k6x0d3b2v8m9q4r7t5w1y3z6" }, + "status": { "$ref": "#/components/schemas/JobStatus" }, + "input_format": { "type": "string", "example": "png" }, + "target_format": { "type": "string", "example": "webp" }, "input_bytes": { - "type": ["integer", "null"] + "type": ["integer", "null"], + "description": "The reserved input size in bytes.", + "example": 48213 }, "attempt": { - "type": "integer" + "type": "integer", + "description": "How many times a worker has started this job. 0 until it first runs; a job is tried at most 3 times.", + "example": 0 }, "error_code": { - "type": ["string", "null"] + "description": "Why the job failed, or null.", + "oneOf": [{ "$ref": "#/components/schemas/JobErrorCode" }, { "type": "null" }], + "example": null }, "expires_at": { "type": "string", - "format": "date-time" + "format": "date-time", + "description": "24 hours after creation. After this the job returns 404 and its files are deleted.", + "example": "2026-10-08T12:00:00Z" } } }, + "JobStatus": { + "type": "string", + "description": "Where the job is in its lifecycle. `succeeded`, `failed` and `cancelled` are final.", + "enum": ["created", "uploaded", "queued", "running", "succeeded", "failed", "cancelled"], + "x-enum-descriptions": { + "created": "Reserved and waiting for the upload and start.", + "uploaded": "Reserved for future use; the API does not return it today.", + "queued": "Upload sealed; waiting for a worker.", + "running": "A worker is converting it.", + "succeeded": "Outputs are ready to download. The conversion is charged.", + "failed": "The conversion did not finish. Not charged; see error_code.", + "cancelled": "Cancelled before it finished. Not charged." + }, + "example": "queued" + }, + "JobErrorCode": { + "type": "string", + "description": "The reason a job ended in `failed`.", + "enum": ["conversion_failed", "worker_shutdown", "expired", "expired_or_exhausted"], + "x-enum-descriptions": { + "conversion_failed": "The engine could not convert this file, or the attempt passed the 10-minute limit.", + "worker_shutdown": "The worker stopped during the attempt.", + "expired": "The job reached its 24-hour expiry before finishing.", + "expired_or_exhausted": "The job expired, or its last attempt's worker stopped responding." + } + }, + "Downloads": { + "type": "object", + "required": ["outputs", "expires_in"], + "properties": { + "outputs": { + "type": "array", + "items": { "$ref": "#/components/schemas/Output" } + }, + "expires_in": { + "type": "integer", + "description": "Seconds until the URLs stop working: at most 300.", + "example": 300 + } + } + }, + "Output": { + "type": "object", + "required": ["name", "url"], + "properties": { + "name": { + "type": "string", + "description": "File name the converter produced, such as `input.webp`. Rename it when saving.", + "example": "input.webp" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Signed GET URL for the file.", + "example": "https://storage.example/job_01k6x0d3b2v8m9q4r7t5w1y3z6/attempt-1/input.webp?X-Amz-Signature=0f3a9c" + } + } + }, + "Format": { + "type": "object", + "required": ["id", "name", "category", "extensions", "mime"], + "properties": { + "id": { "type": "string", "example": "webp" }, + "name": { "type": "string", "example": "WebP" }, + "category": { + "type": "string", + "enum": ["image", "video", "audio", "document"], + "example": "image" + }, + "extensions": { "type": "array", "items": { "type": "string" }, "example": ["webp"] }, + "mime": { "type": "string", "example": "image/webp" } + } + }, "Error": { "type": "object", + "required": ["error"], "properties": { "error": { "type": "object", + "required": ["code", "message"], "properties": { - "code": { - "type": "string" - }, + "code": { "$ref": "#/components/schemas/ErrorCode" }, "message": { - "type": "string" + "type": "string", + "description": "A sentence for people. May change; branch on code.", + "example": "Your allowance or spend cap has been reached." } } } } + }, + "ErrorCode": { + "type": "string", + "description": "Stable, machine-readable reason a request was refused.", + "enum": [ + "unauthorized", + "file_too_large", + "unsupported_format", + "size_mismatch", + "not_ready", + "not_enrolled", + "limit_reached", + "storage_limit_reached", + "not_found", + "rate_limited", + "internal", + "storage_unavailable" + ], + "x-enum-descriptions": { + "unauthorized": "401 without a Bearer header; 403 when the key is invalid or revoked, or the web credential expired.", + "file_too_large": "400. input_bytes is below 1 or above 2,000,000,000.", + "unsupported_format": "400. Unknown format, the same format twice, or a pair the cloud cannot convert.", + "size_mismatch": "400. The upload's size differs from input_bytes. The job is cancelled.", + "not_ready": "400. Download was called before the job succeeded.", + "not_enrolled": "403. API billing is not active with a card on file.", + "limit_reached": "403. The job would pass this month's spend cap.", + "storage_limit_reached": "403. 100 jobs or 50 GB of inputs are still stored until their 24-hour cleanup.", + "not_found": "404. No such job for this account, or it expired.", + "rate_limited": "429. More than 120 requests in a minute with this key.", + "internal": "500. Our side failed. Retry with backoff.", + "storage_unavailable": "502. Object storage did not respond. Retry shortly." + }, + "example": "limit_reached" } } } From d99c0553abf825cc82365d52715bc1e3853d2414 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 13:19:41 +0000 Subject: [PATCH 2/8] web: add the shared code panel and API samples for the dashboard A tabbed, highlighted code panel with a remembered language choice, and quick-start programs in cURL, Node.js, Python and the CLI built from apiBaseUrl. The public reference itself now lives in the Blume site. Co-authored-by: Leo --- apps/web/src/components/app/code-panel.tsx | 304 +++++++++++++++++++++ apps/web/src/lib/api-samples.ts | 198 ++++++++++++++ apps/web/src/lib/openapi.ts | 182 ++++++++++++ 3 files changed, 684 insertions(+) create mode 100644 apps/web/src/components/app/code-panel.tsx create mode 100644 apps/web/src/lib/api-samples.ts create mode 100644 apps/web/src/lib/openapi.ts diff --git a/apps/web/src/components/app/code-panel.tsx b/apps/web/src/components/app/code-panel.tsx new file mode 100644 index 00000000..38ca7a44 --- /dev/null +++ b/apps/web/src/components/app/code-panel.tsx @@ -0,0 +1,304 @@ +import { + createContext, + useContext, + useEffect, + useId, + useRef, + useState, + type ReactNode, +} from "react"; + +import type { Language, Sample } from "#/lib/api-samples"; + +import { cx } from "./ui"; + +// Dark code surface used by the API reference and the dashboard, in both themes. The +// colors are the landing page's code palette (components/landing/pricing.tsx). + +type Token = { text: string; tone?: keyof typeof tones }; +const tones = { + comment: "text-[#838985] italic", + string: "text-[#f2c46d]", + keyword: "text-[#7fd3a6]", + number: "text-[#f5a97f]", + property: "text-[#9cc9ff]", + flag: "text-[#9cc9ff]", + variable: "text-[#c9b6ff]", +}; + +const keywords: Record = { + node: /^(?:import|from|const|let|await|async|function|return|if|throw|new|while|of|in)$/, + python: /^(?:import|def|return|if|not|in|while|raise|for|None|True|False)$/, + curl: /^(?:while|do|done|case|esac|in|break|if|then|fi|curl|jq|xargs|sleep|wc|tr|convt)$/, + cli: /^(?:convt)$/, + json: /^(?:true|false|null)$/, +}; + +const patterns: Record = { + node: /(\/\/[^\n]*)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)|(\b\d[\d_.]*\b)|([A-Za-z_$][\w$]*)/g, + python: /(#[^\n]*)|(f?"(?:\\.|[^"\\])*"|f?'(?:\\.|[^'\\])*')|(\b\d[\d_.]*\b)|([A-Za-z_][\w]*)/g, + curl: /((?:^|(?<=\s))#[^\n]*)|("(?:\\.|[^"\\])*"|'[^']*')|(\s--?[a-zA-Z][\w-]*)|(\$\(?[A-Za-z_][\w]*|\$\{[^}]+\})|([A-Za-z_][\w]*)/g, + json: /("(?:\\.|[^"\\])*")(\s*:)?|(-?\b\d[\d.]*\b)|([a-z]+)/g, +}; + +export function tokenize(code: string, language: Language | "json"): Token[] { + const lang = language === "cli" ? "curl" : language; + const pattern = new RegExp(patterns[lang].source, "g"); + const tokens: Token[] = []; + let last = 0; + for (const m of code.matchAll(pattern)) { + const index = m.index ?? 0; + if (index > last) tokens.push({ text: code.slice(last, index) }); + last = index + m[0].length; + if (lang === "json") { + if (m[1]) { + tokens.push({ text: m[1], tone: m[2] ? "property" : "string" }); + if (m[2]) tokens.push({ text: m[2] }); + } else if (m[3]) tokens.push({ text: m[3], tone: "number" }); + else tokens.push({ text: m[0], tone: keywords.json.test(m[0]) ? "keyword" : undefined }); + continue; + } + if (lang === "curl") { + const [, comment, string, flag, variable] = m; + const tone = comment + ? "comment" + : string + ? "string" + : flag + ? "flag" + : variable + ? "variable" + : keywords.curl.test(m[0]) + ? "keyword" + : undefined; + tokens.push({ text: m[0], tone }); + continue; + } + const [, comment, string, number] = m; + const tone = comment + ? "comment" + : string + ? "string" + : number + ? "number" + : keywords[lang].test(m[0]) + ? "keyword" + : undefined; + tokens.push({ text: m[0], tone }); + } + if (last < code.length) tokens.push({ text: code.slice(last) }); + return tokens; +} + +export function Highlighted({ code, language }: { code: string; language: Language | "json" }) { + return ( + <> + {tokenize(code, language).map((t, i) => + t.tone ? ( + + {t.text} + + ) : ( + t.text + ), + )} + + ); +} + +const LanguageContext = createContext<{ + language: Language; + setLanguage: (l: Language) => void; +} | null>(null); +const storageKey = "convt:code-language"; + +/** Shares the chosen language across every CodePanel inside it and remembers it. */ +export function LanguageProvider({ children }: { children: ReactNode }) { + const [language, setState] = useState("curl"); + useEffect(() => { + const saved = localStorage.getItem(storageKey); + if (saved === "curl" || saved === "node" || saved === "python" || saved === "cli") + setState(saved); + }, []); + const setLanguage = (l: Language) => { + setState(l); + try { + localStorage.setItem(storageKey, l); + } catch {} + }; + return {children}; +} + +function CopyIcon() { + return ( + + ); +} + +export function CopyCode({ value, className }: { value: string; className?: string }) { + const [copied, setCopied] = useState(false); + const timer = useRef>(undefined); + useEffect(() => () => clearTimeout(timer.current), []); + return ( + + ); +} + +/** + * Tabbed code block. Inside a LanguageProvider the tab follows the shared choice when the + * panel has that language; on its own it keeps local state. + */ +export function CodePanel({ + samples, + title, + className, + maxHeight, +}: { + samples: Sample[]; + title?: string; + className?: string; + /** Tailwind max-height class for the code area; it scrolls past that. */ + maxHeight?: string; +}) { + const base = useId(); + const shared = useContext(LanguageContext); + const [local, setLocal] = useState(samples[0].language); + const wanted = shared?.language ?? local; + const current = samples.find((s) => s.language === wanted) ?? samples[0]; + const tabs = useRef>([]); + const choose = (l: Language) => (shared ? shared.setLanguage(l) : setLocal(l)); + + function onKeyDown(event: React.KeyboardEvent, index: number) { + const n = samples.length; + const next = + event.key === "ArrowRight" + ? (index + 1) % n + : event.key === "ArrowLeft" + ? (index - 1 + n) % n + : event.key === "Home" + ? 0 + : event.key === "End" + ? n - 1 + : -1; + if (next < 0) return; + event.preventDefault(); + choose(samples[next].language); + tabs.current[next]?.focus(); + } + + return ( +
+
+
+ {title && ( + {title} + )} + {samples.length > 1 && ( +
+ {samples.map((sample, index) => { + const selected = sample.language === current.language; + return ( + + ); + })} +
+ )} +
+ +
+
1 ? "tabpanel" : undefined} + id={`${base}-panel`} + aria-labelledby={samples.length > 1 ? `${base}-tab-${current.language}` : undefined} + tabIndex={0} + className={cx( + "overflow-auto px-4 py-4 outline-none focus-visible:ring-2 focus-visible:ring-[#3fcb84] focus-visible:ring-inset", + maxHeight, + )} + > +
+          
+            
+          
+        
+
+
+ ); +} + +/** Untabbed JSON block, for example responses. */ +export function JsonPanel({ value, title }: { value: unknown; title: string }) { + const code = JSON.stringify(value, null, 2); + return ( +
+
+ {title} + +
+
+
+          
+            
+          
+        
+
+
+ ); +} diff --git a/apps/web/src/lib/api-samples.ts b/apps/web/src/lib/api-samples.ts new file mode 100644 index 00000000..4088fa79 --- /dev/null +++ b/apps/web/src/lib/api-samples.ts @@ -0,0 +1,198 @@ +// Code samples for the API reference and the dashboard. Each quick start is a complete +// program that runs the whole job flow: create, upload, start, poll, download. They use +// plain HTTP because @convt/sdk is not published to npm yet. + +import type { Endpoint } from "./openapi"; + +export type Language = "curl" | "node" | "python" | "cli"; +export type Sample = { language: Language; label: string; code: string }; + +export const languageLabels: Record = { + curl: "cURL", + node: "Node.js", + python: "Python", + cli: "CLI", +}; + +export function quickStart(base: string): Sample[] { + return [ + { + language: "curl", + label: languageLabels.curl, + code: `# Needs curl 7.76+ and jq. Converts photo.png to photo.webp. +API=${base} +AUTH="Authorization: Bearer $CONVT_API_KEY" +size=$(wc -c < photo.png | tr -d ' ') + +# 1. Reserve the job and get an upload URL +created=$(curl -sS --fail-with-body "$API/v1/jobs" -H "$AUTH" \\ + -H "Content-Type: application/json" \\ + -d "{\\"input_format\\":\\"png\\",\\"target_format\\":\\"webp\\",\\"input_bytes\\":$size}") +id=$(jq -r .job.id <<<"$created") + +# 2. Upload exactly input_bytes, then start +curl -sS --fail-with-body -X PUT --upload-file photo.png "$(jq -r .upload_url <<<"$created")" +curl -sS --fail-with-body -X POST "$API/v1/jobs/$id/start" -H "$AUTH" + +# 3. Poll every 2 seconds until the job finishes +while :; do + status=$(curl -sS --fail-with-body "$API/v1/jobs/$id" -H "$AUTH" | jq -r .status) + case $status in succeeded|failed|cancelled) break ;; esac + sleep 2 +done + +# 4. Download the output +curl -sS --fail-with-body "$API/v1/jobs/$id/download" -H "$AUTH" \\ + | jq -r '.outputs[0].url' | xargs curl -sS --fail-with-body -o photo.webp`, + }, + { + language: "node", + label: languageLabels.node, + code: `// Node 18 or newer, as an ES module. Converts photo.png to photo.webp. +import { readFile, writeFile } from "node:fs/promises"; + +const API = "${base}"; +const auth = { Authorization: \`Bearer \${process.env.CONVT_API_KEY}\` }; + +async function api(path, init = {}) { + const res = await fetch(API + path, { ...init, headers: { ...auth, ...init.headers } }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error?.code ?? \`HTTP \${res.status}\`); + return body; +} + +// 1. Reserve the job and get an upload URL +const file = await readFile("photo.png"); +const { job, upload_url } = await api("/v1/jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ input_format: "png", target_format: "webp", input_bytes: file.byteLength }), +}); + +// 2. Upload exactly input_bytes, then start +const put = await fetch(upload_url, { method: "PUT", body: file }); +if (!put.ok) throw new Error(\`upload failed: \${put.status}\`); +let current = await api(\`/v1/jobs/\${job.id}/start\`, { method: "POST" }); + +// 3. Poll every 2 seconds until the job finishes +while (!["succeeded", "failed", "cancelled"].includes(current.status)) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + current = await api(\`/v1/jobs/\${job.id}\`); +} +if (current.status !== "succeeded") throw new Error(current.error_code ?? current.status); + +// 4. Download the output +const { outputs } = await api(\`/v1/jobs/\${job.id}/download\`); +const output = await fetch(outputs[0].url); +await writeFile("photo.webp", Buffer.from(await output.arrayBuffer()));`, + }, + { + language: "python", + label: languageLabels.python, + code: `# Python 3 with requests. Converts photo.png to photo.webp. +import os, time, requests + +API = "${base}" +AUTH = {"Authorization": f"Bearer {os.environ['CONVT_API_KEY']}"} + +def api(method, path, **kwargs): + res = requests.request(method, API + path, headers=AUTH, **kwargs) + if not res.ok: + raise RuntimeError(f"{res.status_code}: {res.text}") + return res.json() + +# 1. Reserve the job and get an upload URL +data = open("photo.png", "rb").read() +created = api("POST", "/v1/jobs", json={ + "input_format": "png", "target_format": "webp", "input_bytes": len(data), +}) +job_id = created["job"]["id"] + +# 2. Upload exactly input_bytes, then start +requests.put(created["upload_url"], data=data).raise_for_status() +job = api("POST", f"/v1/jobs/{job_id}/start") + +# 3. Poll every 2 seconds until the job finishes +while job["status"] not in ("succeeded", "failed", "cancelled"): + time.sleep(2) + job = api("GET", f"/v1/jobs/{job_id}") +if job["status"] != "succeeded": + raise RuntimeError(job["error_code"] or job["status"]) + +# 4. Download the output +url = api("GET", f"/v1/jobs/{job_id}/download")["outputs"][0]["url"] +open("photo.webp", "wb").write(requests.get(url).content)`, + }, + { + language: "cli", + label: languageLabels.cli, + code: `# The convt CLI ships with the desktop app and converts on your machine. +# It needs no API key and uploads nothing. +convt photo.png --to webp`, + }, + ]; +} + +function pathWithExamples(endpoint: Endpoint) { + return endpoint.path.replace(/\{(\w+)\}/g, (_, name: string) => { + const param = endpoint.params.find((p) => p.name === name); + const example = param?.schema && "example" in param.schema ? param.schema.example : undefined; + return typeof example === "string" ? example : `{${name}}`; + }); +} + +const pyLiteral = (value: unknown): string => + JSON.stringify(value, null, 4) + .replace(/\bnull\b/g, "None") + .replace(/\btrue\b/g, "True") + .replace(/\bfalse\b/g, "False"); + +/** cURL, Node.js and Python for one operation, with the spec's example values. */ +export function endpointSamples(endpoint: Endpoint, base: string): Sample[] { + const method = endpoint.method.toUpperCase(); + const url = `${base}${pathWithExamples(endpoint)}`; + const body = endpoint.bodyExample; + const json = body === undefined ? null : JSON.stringify(body, null, 2); + + const curl = [ + `curl${method === "GET" ? "" : ` -X ${method}`} ${url}`, + endpoint.authenticated && ` -H "Authorization: Bearer $CONVT_API_KEY"`, + json && ` -H "Content-Type: application/json"`, + json && ` -d '${json.replace(/\n/g, "\n ")}'`, + ] + .filter(Boolean) + .join(" \\\n"); + + const headers = [ + endpoint.authenticated && "Authorization: `Bearer ${process.env.CONVT_API_KEY}`", + json && `"Content-Type": "application/json"`, + ].filter(Boolean); + const init = [ + method !== "GET" && `method: "${method}"`, + headers.length && `headers: {\n ${headers.join(",\n ")},\n }`, + json && `body: JSON.stringify(${json.replace(/\n/g, "\n ")})`, + ].filter(Boolean); + const node = `const res = await fetch("${url}"${ + init.length ? `, {\n ${init.join(",\n ")},\n}` : "" + }); +const body = await res.json();`; + + const pyArgs = [ + `"${method}"`, + `"${url}"`, + endpoint.authenticated && `headers={"Authorization": f"Bearer {os.environ['CONVT_API_KEY']}"}`, + body !== undefined && `json=${pyLiteral(body).replace(/\n/g, "\n ")}`, + ].filter(Boolean); + const python = `${endpoint.authenticated ? "import os, requests" : "import requests"} + +res = requests.request( + ${pyArgs.join(",\n ")}, +) +body = res.json()`; + + return [ + { language: "curl", label: languageLabels.curl, code: curl }, + { language: "node", label: languageLabels.node, code: node }, + { language: "python", label: languageLabels.python, code: python }, + ]; +} diff --git a/apps/web/src/lib/openapi.ts b/apps/web/src/lib/openapi.ts new file mode 100644 index 00000000..5cd515ce --- /dev/null +++ b/apps/web/src/lib/openapi.ts @@ -0,0 +1,182 @@ +// Typed access to the subset of OpenAPI 3.1 that convt-server's spec uses: tagged +// operations, path parameters, JSON bodies, $ref into components, enums with +// `x-enum-descriptions`, and plain-text framework rejections. Unknown fields are ignored. + +export type Ref = { $ref: string }; +export type Schema = { + type?: string | string[]; + format?: string; + description?: string; + enum?: string[]; + "x-enum-descriptions"?: Record; + example?: unknown; + minimum?: number; + maximum?: number; + items?: Schema | Ref; + properties?: Record; + required?: string[]; + oneOf?: (Schema | Ref)[]; +}; +type MediaTypes = Record; +export type Parameter = { + name: string; + in: string; + required?: boolean; + description?: string; + schema?: Schema | Ref; +}; +export type Response = { description?: string; content?: MediaTypes }; +export type Operation = { + tags?: string[]; + operationId?: string; + summary?: string; + description?: string; + parameters?: (Parameter | Ref)[]; + requestBody?: { required?: boolean; content?: MediaTypes } | Ref; + responses?: Record; + security?: unknown[]; +}; +export type OpenApiDocument = { + "x-convt-placeholder"?: boolean; + info: { title: string; version: string; summary?: string; description?: string }; + servers?: { url: string; description?: string }[]; + tags?: { name: string; description?: string }[]; + security?: unknown[]; + paths: Record>; + components?: { + schemas?: Record; + parameters?: Record; + responses?: Record; + securitySchemes?: Record; + }; +}; + +const methods = ["get", "post", "put", "patch", "delete"] as const; + +export const isRef = (value: unknown): value is Ref => + typeof value === "object" && value !== null && "$ref" in value; +export const refName = (ref: string) => ref.split("/").pop() ?? ref; + +export type Resolve = (value: T | Ref) => T; + +export function resolver(doc: OpenApiDocument): Resolve { + return function resolve(value: T | Ref): T { + if (!isRef(value)) return value; + const [, , group, name] = value.$ref.split("/"); + const components = doc.components as Record> | undefined; + return components?.[group]?.[name] as T; + }; +} + +export type Endpoint = { + id: string; + method: string; + path: string; + op: Operation; + params: Parameter[]; + body: Schema | null; + bodyExample: unknown; + /** Status code, description, and the JSON schema if the body is an Error or object. */ + responses: { code: string; description: string; schema: Schema | Ref | null; text: boolean }[]; + authenticated: boolean; +}; + +export const slug = (method: string, path: string) => + `${method}-${path + .replace(/[{}]/g, "") + .replace(/[^a-z0-9]+/gi, "-") + .replace(/^-|-$/g, "")}`.toLowerCase(); + +export function endpoints(doc: OpenApiDocument): Endpoint[] { + const resolve = resolver(doc); + const list: Endpoint[] = []; + for (const [path, item] of Object.entries(doc.paths)) { + for (const method of methods) { + const op = item[method]; + if (!op) continue; + const body = op.requestBody ? resolve(op.requestBody) : undefined; + const json = body?.content?.["application/json"]; + const responses = Object.entries(op.responses ?? {}).map(([code, raw]) => { + const r = resolve(raw); + const content = r?.content ?? {}; + return { + code, + description: r?.description ?? "", + schema: content["application/json"]?.schema ?? null, + text: !content["application/json"] && "text/plain" in content, + }; + }); + const security = op.security ?? doc.security ?? []; + list.push({ + id: slug(method, path), + method, + path, + op, + params: (op.parameters ?? []).map((p) => resolve(p)), + body: json?.schema ? resolve(json.schema) : null, + bodyExample: json?.example ?? (json?.schema ? exampleFor(json.schema, resolve) : undefined), + responses, + authenticated: security.length > 0, + }); + } + } + return list; +} + +/** Endpoints grouped by their first tag, in the order the spec lists its tags. */ +export function byTag(doc: OpenApiDocument, list = endpoints(doc)) { + const groups = new Map(); + for (const tag of doc.tags ?? []) groups.set(tag.name, []); + for (const e of list) { + const tag = e.op.tags?.[0] ?? "Endpoints"; + if (!groups.has(tag)) groups.set(tag, []); + groups.get(tag)?.push(e); + } + return [...groups] + .filter(([, entries]) => entries.length) + .map(([name, entries]) => ({ + name, + description: doc.tags?.find((t) => t.name === name)?.description, + entries, + })); +} + +/** A value built from the schema's examples, enums and types, following $refs. */ +export function exampleFor(raw: Schema | Ref, resolve: Resolve, depth = 0): unknown { + const schema = resolve(raw); + if (!schema || depth > 6) return null; + if (schema.example !== undefined) return schema.example; + if (schema.oneOf?.length) return exampleFor(schema.oneOf[0], resolve, depth + 1); + if (schema.enum?.length) return schema.enum[0]; + const type = Array.isArray(schema.type) ? schema.type.find((t) => t !== "null") : schema.type; + if (type === "array") return schema.items ? [exampleFor(schema.items, resolve, depth + 1)] : []; + if (type === "object" || schema.properties) { + return Object.fromEntries( + Object.entries(schema.properties ?? {}).map(([name, prop]) => [ + name, + exampleFor(prop, resolve, depth + 1), + ]), + ); + } + if (type === "integer" || type === "number") return schema.minimum ?? 0; + if (type === "boolean") return false; + return "string"; +} + +/** Short type label: `string`, `integer | null`, `array of Output`, `JobStatus`. */ +export function typeLabel(raw: Schema | Ref | undefined): string { + if (!raw) return "any"; + if (isRef(raw)) return refName(raw.$ref); + if (raw.oneOf) return raw.oneOf.map((s) => typeLabel(s)).join(" | "); + if (raw.type === "array") return `array of ${typeLabel(raw.items)}`; + const type = Array.isArray(raw.type) ? raw.type.join(" | ") : (raw.type ?? "object"); + return raw.format && raw.format !== "uri" ? `${type} (${raw.format})` : type; +} + +/** Enum values with their descriptions, in spec order. */ +export function enumValues(schema: Schema | undefined) { + return (schema?.enum ?? []).map((value) => ({ + value, + description: schema?.["x-enum-descriptions"]?.[value] ?? "", + })); +} From 1d2b891ee8bc80f311aebcb3bab5eefe8cba4854 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 12:49:30 +0000 Subject: [PATCH 3/8] web: redesign the dashboard API keys and cloud converter pages /dashboard/api gets a spend meter, inline key creation with a copy-once callout, two-step revoke, the shared quick-start panel and the base URL. /dashboard/api/convert becomes a two-step dropzone and target picker with a progress stepper, cancel and retry. Drops the unused CodeSample. Co-authored-by: Leo --- apps/web/src/components/app/code-sample.tsx | 158 ----- apps/web/src/components/app/ui.tsx | 37 ++ .../src/routes/_app/_shell/dashboard/api.tsx | 508 ++++++++++------ .../_app/_shell/dashboard/api_.convert.tsx | 565 +++++++++++++----- 4 files changed, 776 insertions(+), 492 deletions(-) delete mode 100644 apps/web/src/components/app/code-sample.tsx diff --git a/apps/web/src/components/app/code-sample.tsx b/apps/web/src/components/app/code-sample.tsx deleted file mode 100644 index 85c571a6..00000000 --- a/apps/web/src/components/app/code-sample.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { useId, useRef, useState } from "react"; - -import { apiBaseUrl } from "#/lib/config"; - -import { cx } from "./ui"; - -// Each example follows the jobs API. The SDK handles upload and polling. -const samples = [ - { - id: "node", - label: "Node", - code: `import { Convt } from "@convt/sdk"; - -// Set CONVT_API_KEY in your environment. -const convt = new Convt(); -const out = await convt.convert("report.docx", { - to: "pdf", -}); -await out.save("report.pdf");`, - }, - { - id: "curl", - label: "cURL", - code: `# Create a reservation for a 104-byte SVG. -job=$(curl -fsS ${apiBaseUrl}/v1/jobs \\ - -H "Authorization: Bearer $CONVT_KEY" \\ - -H "Content-Type: application/json" \\ - -d '{"input_format":"svg","target_format":"png","input_bytes":104}') -# PUT your file to upload_url, then POST /v1/jobs/{id}/start. -# Poll GET /v1/jobs/{id}; on success, GET its /download URLs. -# The SDK performs each of these steps for you.`, - }, - { - id: "browser", - label: "Browser", - code: `import { Convt } from "@convt/sdk"; - -// Use a short-lived token issued by your server. -const convt = new Convt({ - token: () => fetchToken(), - baseUrl: "${apiBaseUrl}", -}); -const out = await convt.convert(file, { to: "pdf" }); -const blob = await out.blob();`, - }, - { - id: "cli", - label: "CLI", - code: `# The desktop CLI converts on your machine. -# It needs no API key and uploads nothing. -convt report.docx --to pdf`, - }, -] as const; - -type SampleId = (typeof samples)[number]["id"]; - -export function CodeSample() { - const base = useId(); - const [active, setActive] = useState("node"); - const [copied, setCopied] = useState(false); - const tabRefs = useRef>([]); - const current = samples.find((s) => s.id === active) ?? samples[0]; - - function onKeyDown(event: React.KeyboardEvent, index: number) { - let next = index; - if (event.key === "ArrowRight") next = (index + 1) % samples.length; - else if (event.key === "ArrowLeft") next = (index - 1 + samples.length) % samples.length; - else if (event.key === "Home") next = 0; - else if (event.key === "End") next = samples.length - 1; - else return; - event.preventDefault(); - setActive(samples[next].id); - setCopied(false); - tabRefs.current[next]?.focus(); - } - - async function copy() { - try { - await navigator.clipboard.writeText(current.code); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - setCopied(false); - } - } - - return ( -
-
-
- {samples.map((sample, index) => { - const selected = sample.id === active; - return ( - - ); - })} -
- -
-
-
-          {current.code}
-        
-
-
- ); -} diff --git a/apps/web/src/components/app/ui.tsx b/apps/web/src/components/app/ui.tsx index fa5d721c..8e2098ef 100644 --- a/apps/web/src/components/app/ui.tsx +++ b/apps/web/src/components/app/ui.tsx @@ -114,6 +114,43 @@ export function Badge({ ); } +/** + * Horizontal usage bar: `used` in solid green, `reserved` after it in a lighter green. + * Values are fractions of `limit`; the bar clamps at full. + */ +export function Meter({ + used, + reserved = 0, + limit, + label, +}: { + used: number; + reserved?: number; + limit: number; + label: string; +}) { + const part = (n: number) => (limit > 0 ? Math.min(100, Math.max(0, (n / limit) * 100)) : 0); + const usedPct = part(used); + const reservedPct = Math.min(100 - usedPct, part(reserved)); + const full = limit > 0 && used + reserved >= limit; + return ( +
+ + +
+ ); +} + /** Classes for the bordered tables (invoices, keys, sign-in methods, sessions). */ export const table = { /** Scroll wrapper so wide tables stay usable on a phone. */ diff --git a/apps/web/src/routes/_app/_shell/dashboard/api.tsx b/apps/web/src/routes/_app/_shell/dashboard/api.tsx index aafeb2e6..58728241 100644 --- a/apps/web/src/routes/_app/_shell/dashboard/api.tsx +++ b/apps/web/src/routes/_app/_shell/dashboard/api.tsx @@ -1,15 +1,16 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { addApiKey, removeApiKey, fetchApiSpend } from "#/server/cloud-fns"; import { createFileRoute, useRouter } from "@tanstack/react-router"; import { ApiEnrollmentCard } from "#/components/app/api-enrollment"; -import { CodeSample } from "#/components/app/code-sample"; +import { CodePanel, CopyCode } from "#/components/app/code-panel"; import { CopyButton } from "#/components/app/copy-button"; import { UsageChart } from "#/components/app/usage-chart"; import { Card, ChevronIcon, ExternalIcon, + Meter, PageTitle, PrimaryButton, SecondaryLink, @@ -17,11 +18,11 @@ import { TextButton, cx, focusRing, - table, } from "#/components/app/ui"; import { getApiOverview } from "#/lib/account"; -import { links } from "#/lib/config"; -import { formatDate, formatNumber, formatShortDate } from "#/lib/format"; +import { quickStart } from "#/lib/api-samples"; +import { apiBaseUrl, links } from "#/lib/config"; +import { formatDate, formatMoney, formatNumber, formatShortDate } from "#/lib/format"; export const Route = createFileRoute("/_app/_shell/dashboard/api")({ head: () => ({ meta: [{ title: "API · convt" }] }), @@ -30,9 +31,26 @@ export const Route = createFileRoute("/_app/_shell/dashboard/api")({ }); const docLinks = [ - { href: links.apiReference, title: "API reference", body: "Endpoints, options and errors" }, - { href: links.formats, title: "Supported formats", body: "Every input and target the API takes" }, - { href: links.apiReference, title: "Jobs and limits", body: "Upload, start, poll and download" }, + { + href: `${links.apiReference}#quick-start`, + title: "Quick start", + body: "Your first conversion in four requests", + }, + { + href: `${links.apiReference}#errors`, + title: "Errors", + body: "Every error code and when to retry", + }, + { + href: `${links.apiReference}#limits`, + title: "Limits and billing", + body: "File size, rate limit, retention", + }, + { + href: `${links.apiReference}#conversions`, + title: "Supported conversions", + body: "Every pair the cloud converts", + }, ]; function ApiPage() { @@ -41,8 +59,13 @@ function ApiPage() { const [creating, setCreating] = useState(false); const [name, setName] = useState(""); const [shownKey, setShownKey] = useState(null); + const [confirming, setConfirming] = useState(null); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); + const onSale = api.sales === "all"; + const enrolled = api.enrollment.state === "enrolled"; + const canCreate = onSale && enrolled; + async function create() { setBusy(true); setError(""); @@ -63,6 +86,7 @@ function ApiPage() { setError(""); try { await removeApiKey({ data: { id } }); + setConfirming(null); await router.invalidate(); } catch { setError("Revoking the key failed. Try again."); @@ -71,205 +95,320 @@ function ApiPage() { } } + const { used, reserved, limit } = api.spend; + const capReached = api.spend.allowed && used + reserved >= limit; + return ( -
-
+
+
API -

- Convert files from your own code. Billed per conversion at the end of each month. +

+ Convert files from your own code with the same engines as the app. Billed per successful + conversion at the end of each month.

-
+
- API docs + API reference - { - setCreating(true); - setShownKey(null); - }} - > - {api.sales === "all" ? "Create key" : "Coming soon"} - + {onSale ? ( + + Cloud converter + + ) : null}
-
+
- {api.sales === "all" ? ( - - Convert in your browser - - ) : ( -

Cloud conversions are coming soon.

+ {!onSale && ( + + )} + +
+ +
+ {formatMoney(used)} +
+ {limit > 0 ? ( + <> +
+ +
+
+ {capReached + ? "Spend cap reached. Raise it under API billing to create more jobs." + : `of ${formatMoney(limit)} cap${reserved > 0 ? ` · ${formatMoney(reserved)} reserved` : ""}`} +
+ + ) : ( +
No spend cap yet
+ )} +
+ +
+ {formatNumber(api.thisMonth)} +
+
Since {formatShortDate(api.since)}
+
+ +
+ {formatNumber(api.last30Days)} +
+
0 ? "text-error" : "text-ink-3")}> + {formatNumber(api.failed)} failed, not charged +
+
+
+ {error && (

{error}

)} - {creating && ( - - - setName(e.target.value)} - placeholder="Production server" - className={`rounded-lg border border-line bg-page px-3 py-2 text-sm ${focusRing}`} - /> -
- - {busy ? "Creating…" : "Create API key"} - - setCreating(false)}>Cancel -
-
- )} - {shownKey && ( - - Your new API key -

Save this key now. It will not be shown again.

-
- e.target.select()} - className={`min-w-0 w-full rounded-lg border border-line bg-page px-3 py-2 font-mono text-xs ${focusRing}`} - /> - -
- setShownKey(null)}> - I saved the key - -
- )} - -
- Spent - ${(api.spend.used / 100).toFixed(2)} -
-
- Reserved - ${(api.spend.reserved / 100).toFixed(2)} -
-
- Spend cap - ${(api.spend.limit / 100).toFixed(2)} -
- {api.spend.allowed && api.spend.used + api.spend.reserved >= api.spend.limit && ( -

- Spend cap reached. Raise it under API billing to create more jobs. -

- )} -
- - -
-
-
This month
-
- {formatNumber(api.thisMonth)} -
-
Conversions since {formatShortDate(api.since)}
-
-
-
Last 30 days
-
{formatNumber(api.last30Days)}
-
-
-
Failed
-
{formatNumber(api.failed)}
-
-
-
- +
+
+
+ +
+
+ Keys +

+ Send a key as Authorization: Bearer. + Keep keys on your server. +

+
+ {!creating && ( + { + setCreating(true); + setShownKey(null); + }} + > + {onSale ? "Create key" : "Coming soon"} + + )} +
+ + {creating && ( +
{ + e.preventDefault(); + if (name.trim()) void create(); + }} + > +
+ + setName(e.target.value)} + placeholder="Production server" + className={cx( + "h-9 rounded-lg bg-page px-3 text-sm shadow-input dark:bg-raised", + focusRing, + )} + /> +
+
+ + {busy ? "Creating…" : "Create API key"} + + setCreating(false)}> + Cancel + +
+
+ )} + + {shownKey && ( +
+
+

Your new API key

+

+ Copy it now. convt stores only a hash, so it will not be shown again. +

+
+
+ e.target.select()} + className={cx( + "h-9 w-full min-w-0 rounded-lg bg-page px-3 font-mono text-xs shadow-input dark:bg-raised", + focusRing, + )} + /> + +
+ setShownKey(null)}> + I saved the key + +
+ )} + + {api.keys.length === 0 ? ( +
+

No keys yet.

+

+ {!onSale + ? "Keys open when the API goes on sale." + : enrolled + ? "Create one to start converting." + : "Add a card under API billing, then create a key here."} +

+
+ ) : ( +
    + + {api.keys.map((key) => ( +
  • + {key.name} + + + {key.maskedKey} + + + + Created + {formatDate(key.created)} + + + Last used + {key.lastUsed} + + + {confirming === key.id ? ( + + revoke(key.id)} + aria-label={`Confirm revoking ${key.name}`} + className="font-medium" + > + Revoke now + + setConfirming(null)}> + Keep + + + ) : ( + setConfirming(key.id)} + aria-label={`Revoke ${key.name}`} + > + Revoke + + )} + +
  • + ))} +
+ )} + {confirming && ( +

+ Requests with a revoked key fail at once with 403. This can't be undone. +

+ )} +
+
+ + + + + +
+ + +
- -
- Keys - {api.keys.length === 0 ? ( - - No keys yet. Create one to start converting. +
-
- Quick start - -
+ +
); } + +function Stat({ label, children }: { label: string; children: ReactNode }) { + return ( + +
{label}
+ {children} +
+ ); +} diff --git a/apps/web/src/routes/_app/_shell/dashboard/api_.convert.tsx b/apps/web/src/routes/_app/_shell/dashboard/api_.convert.tsx index 1bbcc40f..17a88e0b 100644 --- a/apps/web/src/routes/_app/_shell/dashboard/api_.convert.tsx +++ b/apps/web/src/routes/_app/_shell/dashboard/api_.convert.tsx @@ -7,13 +7,17 @@ import { type Job, } from "@convt/sdk"; import { createFileRoute, useRouter } from "@tanstack/react-router"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { Card, + Meter, PageTitle, PrimaryButton, + PrimaryLink, + SecondaryButton, SecondaryLink, TextButton, + cx, focusRing, } from "#/components/app/ui"; import capabilities from "#/generated/cloud-formats.json"; @@ -24,29 +28,65 @@ export const Route = createFileRoute("/_app/_shell/dashboard/api_/convert")({ loader: () => fetchCloudAccess(), component: Converter, }); + +const MAX_BYTES = 2_000_000_000; + +type Stage = "idle" | "uploading" | "queued" | "running" | "succeeded" | "failed" | "cancelled"; +const steps = [ + { stage: "uploading", label: "Upload" }, + { stage: "queued", label: "Queue" }, + { stage: "running", label: "Convert" }, + { stage: "succeeded", label: "Ready" }, +] as const; + +const gb = (bytes: number) => `${(bytes / 1e9).toFixed(2)} GB`; +function size(bytes: number) { + if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB`; + if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`; + return `${Math.max(1, Math.round(bytes / 1e3))} KB`; +} +const detect = (file: File | null) => + formats.find((f) => + (f.extensions as readonly string[]).includes(file?.name.split(".").pop()?.toLowerCase() ?? ""), + ); +const targetsFor = (id: string | undefined) => + (capabilities.formats.find((f) => f.id === id)?.targets ?? []) as Format[]; + function Converter() { const access = Route.useLoaderData(); const router = useRouter(); const [file, setFile] = useState(null); - const [target, setTarget] = useState("pdf"); - const [stage, setStage] = useState("idle"); + const [target, setTarget] = useState(null); + const [stage, setStage] = useState("idle"); const [job, setJob] = useState(null); const [result, setResult] = useState(null); const [error, setError] = useState(""); const [pendingCancel, setPendingCancel] = useState(null); + const [dragging, setDragging] = useState(false); const controller = useRef(null); + const fileInput = useRef(null); useEffect(() => () => controller.current?.abort(), []); - const inputFormat = formats.find((f) => - (f.extensions as readonly string[]).includes(file?.name.split(".").pop()?.toLowerCase() ?? ""), - ); + const inputFormat = detect(file); + const targets = targetsFor(inputFormat?.id); const busy = stage === "uploading" || stage === "queued" || stage === "running"; + + function choose(next: File | null) { + setFile(next); + const reachable = targetsFor(detect(next)?.id); + setTarget((current) => (current && reachable.includes(current) ? current : null)); + setResult(null); + setError(""); + setJob(null); + setStage("idle"); + } + async function convert() { - if (!file || !inputFormat) return; + if (!file || !inputFormat || !target) return; setError(""); setPendingCancel(null); setResult(null); setJob(null); - if (file.size > 2_000_000_000) { + if (file.size > MAX_BYTES) { setError("This file exceeds the 2 GB limit. Use the desktop app for larger files."); return; } @@ -74,14 +114,15 @@ function Converter() { signal: controller.current.signal, onProgress: (j) => { setJob(j); - setStage(j.status); + setStage(j.status === "created" || j.status === "uploaded" ? "uploading" : j.status); }, }); setResult(converted); setStage("succeeded"); await router.invalidate(); } catch (e) { - setStage("failed"); + const cancelled = controller.current?.signal.aborted; + setStage(cancelled ? "cancelled" : "failed"); if (e instanceof ConvtCancellationError) setPendingCancel(e.jobId); setError(e instanceof Error ? e.message : "Conversion failed. Try again."); await router.invalidate(); @@ -96,6 +137,7 @@ function Converter() { if (terminal.status === "succeeded") { const outputs = await client.download(pendingCancel); setResult(new Conversion(terminal, outputs.outputs)); + setStage("succeeded"); setError("Conversion completed before cancellation. Your allowance was settled."); } else if (terminal.status === "cancelled" || terminal.status === "failed") { setError("Conversion cancelled. Your allowance was released."); @@ -106,172 +148,387 @@ function Converter() { setError(e instanceof Error ? e.message : "Retry cancellation shortly."); } } + function reset() { + choose(null); + if (fileInput.current) fileInput.current.value = ""; + } + + const remaining = Math.max(0, access.limit - access.used - access.reserved); + return (
-
- Cloud converter -

- Convert from your browser or phone with Pro. Files are deleted after 24 hours. -

-
- - API keys and usage - - - - {(access.used / 1e9).toFixed(2)} GB - of 50 GB used this month + +
+ Cloud converter +

+ Convert from your browser or phone with Pro. Your file is uploaded to convt cloud storage + for this conversion and deleted after 24 hours. The desktop app converts without uploading + anything. +

+
+ + +
+

+ {gb(access.used)} + of 50 GB used this month +

+

+ {access.reserved > 0 ? `${gb(access.reserved)} reserved · ` : ""}2 GB per file +

+
+
+ {!access.allowed ? ( - -

Cloud conversion needs paid Pro

-

- {access.state === "trialing" - ? "Cloud conversions start after your trial becomes a paid subscription." - : "An active Pro subscription includes 50 GB of input each month."} -

- - View Pro billing - -
+ + View Pro billing + + } + > + {access.state === "trialing" + ? "Cloud conversions start after your trial becomes a paid subscription." + : "An active Pro subscription includes 50 GB of input each month."} + ) : !access.configured ? ( - - Cloud conversion is being connected. You can use the desktop app now. - + + You can use the desktop app now; it converts on your machine. + ) : ( - -
- @@ -482,7 +482,7 @@ function Progress({ stage, job }: { stage: Stage; job: Job | null }) { {stage !== "succeeded" && (

{job - ? `Attempt ${job.attempt || 1}. Keep this page open until your download is ready.` + ? `${job.attempt > 1 ? `Retrying, attempt ${job.attempt}. ` : ""}Keep this page open until your download is ready.` : "Your file goes to convt cloud storage for this conversion."}

)} From 3c55cbe3aeb15307d5f367c676799a053b034d2b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 13:30:48 +0000 Subject: [PATCH 6/8] docs: retarget the overlay at the spec's components and check every target The create request body and its 200 response are now CreateJob and JobReservation components, so their five field descriptions target those. The id parameter targets the JobId component, and the shared 401, 403, 404, 429 and 502 wildcards are dropped: those responses are components with their own descriptions, and the 403 wildcard overwrote create's. Blume skips unmatched targets silently, so generate.ts now fails the build when any overlay target resolves to nothing. Co-authored-by: Leo --- apps/docs/openapi/public.yaml | 30 ++--- apps/docs/scripts/check-overlay.ts | 35 ++++++ apps/docs/scripts/generate.ts | 1 + apps/web/src/lib/openapi.ts | 182 ----------------------------- 4 files changed, 44 insertions(+), 204 deletions(-) create mode 100644 apps/docs/scripts/check-overlay.ts delete mode 100644 apps/web/src/lib/openapi.ts diff --git a/apps/docs/openapi/public.yaml b/apps/docs/openapi/public.yaml index 2b9cf948..11075cc6 100644 --- a/apps/docs/openapi/public.yaml +++ b/apps/docs/openapi/public.yaml @@ -42,13 +42,13 @@ actions: input_format: png target_format: webp input_bytes: 48213 - - target: $.paths['/v1/jobs'].post.requestBody.content['application/json'].schema.properties.input_format + - target: $.components.schemas.CreateJob.properties.input_format update: description: Format id of the file you upload, such as `png` or `docx`. See [Formats](https://convt.app/docs/reference/formats). - - target: $.paths['/v1/jobs'].post.requestBody.content['application/json'].schema.properties.target_format + - target: $.components.schemas.CreateJob.properties.target_format update: description: Format id to convert to. It must differ from `input_format` and be a target the input can reach. - - target: $.paths['/v1/jobs'].post.requestBody.content['application/json'].schema.properties.input_bytes + - target: $.components.schemas.CreateJob.properties.input_bytes update: description: Exact size of the upload in bytes. - target: $.paths['/v1/jobs'].post.responses['200'] @@ -68,10 +68,10 @@ actions: expires_at: "2026-10-08T12:00:00Z" upload_url: https://bucket.example/job_01k6z7v4q8m3x2a9b5c0d1e2f3/upload?X-Amz-Signature=… upload_expires_in: 900 - - target: $.paths['/v1/jobs'].post.responses['200'].content['application/json'].schema.properties.upload_url + - target: $.components.schemas.JobReservation.properties.upload_url update: description: Signed URL. Send the file bytes to it with `PUT`. - - target: $.paths['/v1/jobs'].post.responses['200'].content['application/json'].schema.properties.upload_expires_in + - target: $.components.schemas.JobReservation.properties.upload_expires_in update: description: Seconds until `upload_url` stops accepting uploads. Always 900. - target: $.paths['/v1/jobs'].post.responses['400'] @@ -225,26 +225,12 @@ actions: extensions: [webp] mime: image/webp - # Shared - - target: $.paths.*.*.parameters[?@.name == 'id'] + # Shared. The 401, 403, 404, 429, 500 and 502 responses every job route shares are + # components in the spec and carry their own descriptions. + - target: $.components.parameters.JobId update: description: The job id returned when you created the job, such as `job_01k6z7v4q8m3x2a9b5c0d1e2f3`. example: job_01k6z7v4q8m3x2a9b5c0d1e2f3 - - target: $.paths.*.*.responses['401'] - update: - description: "`unauthorized`: no `Authorization: Bearer` header." - - target: $.paths.*.*.responses['403'] - update: - description: "`unauthorized` (key invalid or revoked) or a billing limit." - - target: $.paths.*.*.responses['404'] - update: - description: "`not_found`: the job does not exist, belongs to another account, or has expired." - - target: $.paths.*.*.responses['429'] - update: - description: "`rate_limited`: more than 120 requests in a minute for this key." - - target: $.paths.*.*.responses['502'] - update: - description: "`storage_unavailable`: object storage did not answer. Retry with backoff." - target: $.components.securitySchemes.bearerAuth update: description: "A `cvt_live_` API key from the [dashboard](https://convt.app/dashboard/api), sent as `Authorization: Bearer cvt_live_…`. Keep it on your server." diff --git a/apps/docs/scripts/check-overlay.ts b/apps/docs/scripts/check-overlay.ts new file mode 100644 index 00000000..4cce0a51 --- /dev/null +++ b/apps/docs/scripts/check-overlay.ts @@ -0,0 +1,35 @@ +// Fails the build when an action in openapi/public.yaml targets nothing in the spec. +// Blume skips unmatched targets silently, so a spec refactor (an inline schema moving +// to components, say) would otherwise drop the overlay's prose without a warning. +// Targets are plain paths: `$`, `.name` and `['name']`. Wildcards and filters are +// refused because they can match nothing without being wrong. + +const spec = await Bun.file( + new URL("../../../crates/convt-server/openapi.json", import.meta.url), +).json(); +const overlay = Bun.YAML.parse( + await Bun.file(new URL("../openapi/public.yaml", import.meta.url)).text(), +) as { actions: { target: string }[] }; + +const segment = /\.([A-Za-z_$][\w$-]*)|\['([^']+)'\]/y; + +function resolve(target: string): unknown { + if (!target.startsWith("$")) throw new Error(`${target}: targets start with $`); + let value: unknown = spec; + segment.lastIndex = 1; + while (segment.lastIndex < target.length) { + const at = segment.lastIndex; + const match = segment.exec(target); + if (!match) throw new Error(`${target}: unsupported syntax at "${target.slice(at)}"`); + value = (value as Record | undefined)?.[match[1] ?? match[2]]; + } + return value; +} + +const unmatched = overlay.actions + .map((a) => a.target) + .filter((target) => resolve(target) === undefined); +if (unmatched.length) { + throw new Error(`openapi/public.yaml targets nothing in the spec:\n ${unmatched.join("\n ")}`); +} +console.log(`public.yaml: ${overlay.actions.length} overlay targets match the spec`); diff --git a/apps/docs/scripts/generate.ts b/apps/docs/scripts/generate.ts index 5909c8bc..e88e4e07 100644 --- a/apps/docs/scripts/generate.ts +++ b/apps/docs/scripts/generate.ts @@ -20,3 +20,4 @@ actions: await Bun.write(new URL("../openapi/servers.yaml", import.meta.url), servers); await import("./generate-formats.ts"); +await import("./check-overlay.ts"); diff --git a/apps/web/src/lib/openapi.ts b/apps/web/src/lib/openapi.ts deleted file mode 100644 index 5cd515ce..00000000 --- a/apps/web/src/lib/openapi.ts +++ /dev/null @@ -1,182 +0,0 @@ -// Typed access to the subset of OpenAPI 3.1 that convt-server's spec uses: tagged -// operations, path parameters, JSON bodies, $ref into components, enums with -// `x-enum-descriptions`, and plain-text framework rejections. Unknown fields are ignored. - -export type Ref = { $ref: string }; -export type Schema = { - type?: string | string[]; - format?: string; - description?: string; - enum?: string[]; - "x-enum-descriptions"?: Record; - example?: unknown; - minimum?: number; - maximum?: number; - items?: Schema | Ref; - properties?: Record; - required?: string[]; - oneOf?: (Schema | Ref)[]; -}; -type MediaTypes = Record; -export type Parameter = { - name: string; - in: string; - required?: boolean; - description?: string; - schema?: Schema | Ref; -}; -export type Response = { description?: string; content?: MediaTypes }; -export type Operation = { - tags?: string[]; - operationId?: string; - summary?: string; - description?: string; - parameters?: (Parameter | Ref)[]; - requestBody?: { required?: boolean; content?: MediaTypes } | Ref; - responses?: Record; - security?: unknown[]; -}; -export type OpenApiDocument = { - "x-convt-placeholder"?: boolean; - info: { title: string; version: string; summary?: string; description?: string }; - servers?: { url: string; description?: string }[]; - tags?: { name: string; description?: string }[]; - security?: unknown[]; - paths: Record>; - components?: { - schemas?: Record; - parameters?: Record; - responses?: Record; - securitySchemes?: Record; - }; -}; - -const methods = ["get", "post", "put", "patch", "delete"] as const; - -export const isRef = (value: unknown): value is Ref => - typeof value === "object" && value !== null && "$ref" in value; -export const refName = (ref: string) => ref.split("/").pop() ?? ref; - -export type Resolve = (value: T | Ref) => T; - -export function resolver(doc: OpenApiDocument): Resolve { - return function resolve(value: T | Ref): T { - if (!isRef(value)) return value; - const [, , group, name] = value.$ref.split("/"); - const components = doc.components as Record> | undefined; - return components?.[group]?.[name] as T; - }; -} - -export type Endpoint = { - id: string; - method: string; - path: string; - op: Operation; - params: Parameter[]; - body: Schema | null; - bodyExample: unknown; - /** Status code, description, and the JSON schema if the body is an Error or object. */ - responses: { code: string; description: string; schema: Schema | Ref | null; text: boolean }[]; - authenticated: boolean; -}; - -export const slug = (method: string, path: string) => - `${method}-${path - .replace(/[{}]/g, "") - .replace(/[^a-z0-9]+/gi, "-") - .replace(/^-|-$/g, "")}`.toLowerCase(); - -export function endpoints(doc: OpenApiDocument): Endpoint[] { - const resolve = resolver(doc); - const list: Endpoint[] = []; - for (const [path, item] of Object.entries(doc.paths)) { - for (const method of methods) { - const op = item[method]; - if (!op) continue; - const body = op.requestBody ? resolve(op.requestBody) : undefined; - const json = body?.content?.["application/json"]; - const responses = Object.entries(op.responses ?? {}).map(([code, raw]) => { - const r = resolve(raw); - const content = r?.content ?? {}; - return { - code, - description: r?.description ?? "", - schema: content["application/json"]?.schema ?? null, - text: !content["application/json"] && "text/plain" in content, - }; - }); - const security = op.security ?? doc.security ?? []; - list.push({ - id: slug(method, path), - method, - path, - op, - params: (op.parameters ?? []).map((p) => resolve(p)), - body: json?.schema ? resolve(json.schema) : null, - bodyExample: json?.example ?? (json?.schema ? exampleFor(json.schema, resolve) : undefined), - responses, - authenticated: security.length > 0, - }); - } - } - return list; -} - -/** Endpoints grouped by their first tag, in the order the spec lists its tags. */ -export function byTag(doc: OpenApiDocument, list = endpoints(doc)) { - const groups = new Map(); - for (const tag of doc.tags ?? []) groups.set(tag.name, []); - for (const e of list) { - const tag = e.op.tags?.[0] ?? "Endpoints"; - if (!groups.has(tag)) groups.set(tag, []); - groups.get(tag)?.push(e); - } - return [...groups] - .filter(([, entries]) => entries.length) - .map(([name, entries]) => ({ - name, - description: doc.tags?.find((t) => t.name === name)?.description, - entries, - })); -} - -/** A value built from the schema's examples, enums and types, following $refs. */ -export function exampleFor(raw: Schema | Ref, resolve: Resolve, depth = 0): unknown { - const schema = resolve(raw); - if (!schema || depth > 6) return null; - if (schema.example !== undefined) return schema.example; - if (schema.oneOf?.length) return exampleFor(schema.oneOf[0], resolve, depth + 1); - if (schema.enum?.length) return schema.enum[0]; - const type = Array.isArray(schema.type) ? schema.type.find((t) => t !== "null") : schema.type; - if (type === "array") return schema.items ? [exampleFor(schema.items, resolve, depth + 1)] : []; - if (type === "object" || schema.properties) { - return Object.fromEntries( - Object.entries(schema.properties ?? {}).map(([name, prop]) => [ - name, - exampleFor(prop, resolve, depth + 1), - ]), - ); - } - if (type === "integer" || type === "number") return schema.minimum ?? 0; - if (type === "boolean") return false; - return "string"; -} - -/** Short type label: `string`, `integer | null`, `array of Output`, `JobStatus`. */ -export function typeLabel(raw: Schema | Ref | undefined): string { - if (!raw) return "any"; - if (isRef(raw)) return refName(raw.$ref); - if (raw.oneOf) return raw.oneOf.map((s) => typeLabel(s)).join(" | "); - if (raw.type === "array") return `array of ${typeLabel(raw.items)}`; - const type = Array.isArray(raw.type) ? raw.type.join(" | ") : (raw.type ?? "object"); - return raw.format && raw.format !== "uri" ? `${type} (${raw.format})` : type; -} - -/** Enum values with their descriptions, in spec order. */ -export function enumValues(schema: Schema | undefined) { - return (schema?.enum ?? []).map((value) => ({ - value, - description: schema?.["x-enum-descriptions"]?.[value] ?? "", - })); -} From dd47d0673747b45d85308530a51f3a03f7d74b35 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 13:30:48 +0000 Subject: [PATCH 7/8] web: point the dashboard at the Blume docs and drop the retired reference code Docs links go to /docs, /docs/api, and the quick start, errors, limits and formats pages instead of anchors on the removed single page. The unit test reads crates/convt-server/openapi.json directly and checks that each linked page exists in apps/docs and that both apps show the same API host. Co-authored-by: Leo --- apps/web/src/lib/api-samples.ts | 72 +--------- apps/web/src/lib/config.ts | 9 +- apps/web/src/routeTree.gen.ts | 10 ++ .../src/routes/_app/_shell/dashboard/api.tsx | 14 +- apps/web/test/unit/api-reference.test.ts | 136 +++++++++--------- 5 files changed, 91 insertions(+), 150 deletions(-) diff --git a/apps/web/src/lib/api-samples.ts b/apps/web/src/lib/api-samples.ts index 4088fa79..5ab7a86d 100644 --- a/apps/web/src/lib/api-samples.ts +++ b/apps/web/src/lib/api-samples.ts @@ -1,8 +1,6 @@ -// Code samples for the API reference and the dashboard. Each quick start is a complete -// program that runs the whole job flow: create, upload, start, poll, download. They use -// plain HTTP because @convt/sdk is not published to npm yet. - -import type { Endpoint } from "./openapi"; +// Quick-start samples for the dashboard. Each is a complete program that runs the whole +// job flow: create, upload, start, poll, download. They use plain HTTP because +// @convt/sdk is not published to npm yet. export type Language = "curl" | "node" | "python" | "cli"; export type Sample = { language: Language; label: string; code: string }; @@ -132,67 +130,3 @@ convt photo.png --to webp`, }, ]; } - -function pathWithExamples(endpoint: Endpoint) { - return endpoint.path.replace(/\{(\w+)\}/g, (_, name: string) => { - const param = endpoint.params.find((p) => p.name === name); - const example = param?.schema && "example" in param.schema ? param.schema.example : undefined; - return typeof example === "string" ? example : `{${name}}`; - }); -} - -const pyLiteral = (value: unknown): string => - JSON.stringify(value, null, 4) - .replace(/\bnull\b/g, "None") - .replace(/\btrue\b/g, "True") - .replace(/\bfalse\b/g, "False"); - -/** cURL, Node.js and Python for one operation, with the spec's example values. */ -export function endpointSamples(endpoint: Endpoint, base: string): Sample[] { - const method = endpoint.method.toUpperCase(); - const url = `${base}${pathWithExamples(endpoint)}`; - const body = endpoint.bodyExample; - const json = body === undefined ? null : JSON.stringify(body, null, 2); - - const curl = [ - `curl${method === "GET" ? "" : ` -X ${method}`} ${url}`, - endpoint.authenticated && ` -H "Authorization: Bearer $CONVT_API_KEY"`, - json && ` -H "Content-Type: application/json"`, - json && ` -d '${json.replace(/\n/g, "\n ")}'`, - ] - .filter(Boolean) - .join(" \\\n"); - - const headers = [ - endpoint.authenticated && "Authorization: `Bearer ${process.env.CONVT_API_KEY}`", - json && `"Content-Type": "application/json"`, - ].filter(Boolean); - const init = [ - method !== "GET" && `method: "${method}"`, - headers.length && `headers: {\n ${headers.join(",\n ")},\n }`, - json && `body: JSON.stringify(${json.replace(/\n/g, "\n ")})`, - ].filter(Boolean); - const node = `const res = await fetch("${url}"${ - init.length ? `, {\n ${init.join(",\n ")},\n}` : "" - }); -const body = await res.json();`; - - const pyArgs = [ - `"${method}"`, - `"${url}"`, - endpoint.authenticated && `headers={"Authorization": f"Bearer {os.environ['CONVT_API_KEY']}"}`, - body !== undefined && `json=${pyLiteral(body).replace(/\n/g, "\n ")}`, - ].filter(Boolean); - const python = `${endpoint.authenticated ? "import os, requests" : "import requests"} - -res = requests.request( - ${pyArgs.join(",\n ")}, -) -body = res.json()`; - - return [ - { language: "curl", label: languageLabels.curl, code: curl }, - { language: "node", label: languageLabels.node, code: node }, - { language: "python", label: languageLabels.python, code: python }, - ]; -} diff --git a/apps/web/src/lib/config.ts b/apps/web/src/lib/config.ts index dcbeb798..54565f7f 100644 --- a/apps/web/src/lib/config.ts +++ b/apps/web/src/lib/config.ts @@ -2,9 +2,14 @@ // PLACEHOLDER do not exist yet; swap them when the real services are live. export const links = { - /** The API reference on this site (renders convt-server's OpenAPI spec). */ - docs: "/docs/api", + /** The Blume docs site (apps/docs), served on convt.app/docs* by its own Worker. */ + docs: "/docs", + /** Blume's reference, rendered from crates/convt-server/openapi.json. */ apiReference: "/docs/api", + docsQuickStart: "/docs/quick-start", + docsErrors: "/docs/reference/errors", + docsLimits: "/docs/reference/limits", + docsFormats: "/docs/reference/formats", formats: "/formats", /** PLACEHOLDER: webhooks are not designed in the API yet. */ webhooks: "https://docs.convt.app/webhooks", diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3e4cd3eb..efcb9819 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -747,3 +747,13 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { startInstance } from './start.ts' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + config: Awaited> + } +} diff --git a/apps/web/src/routes/_app/_shell/dashboard/api.tsx b/apps/web/src/routes/_app/_shell/dashboard/api.tsx index a4e77cf8..b4dcdd29 100644 --- a/apps/web/src/routes/_app/_shell/dashboard/api.tsx +++ b/apps/web/src/routes/_app/_shell/dashboard/api.tsx @@ -32,22 +32,22 @@ export const Route = createFileRoute("/_app/_shell/dashboard/api")({ const docLinks = [ { - href: `${links.apiReference}#quick-start`, + href: links.docsQuickStart, title: "Quick start", body: "Your first conversion in four requests", }, { - href: `${links.apiReference}#errors`, + href: links.docsErrors, title: "Errors", body: "Every error code and when to retry", }, { - href: `${links.apiReference}#limits`, + href: links.docsLimits, title: "Limits and billing", body: "File size, rate limit, retention", }, { - href: `${links.apiReference}#conversions`, + href: links.docsFormats, title: "Supported conversions", body: "Every pair the cloud converts", }, @@ -109,7 +109,7 @@ function ApiPage() {

- + API reference @@ -365,7 +365,7 @@ function ApiPage() {
Quick start >; +const schemas = spec.components.schemas as Record< + string, + { required?: string[]; enum?: string[]; "x-enum-descriptions"?: Record } +>; const base = "https://api.example.test"; -const list = endpoints(doc); -const resolve = resolver(doc); - -const specPath = (url: string) => { - const path = url.replace(base, "").replace(/[?#].*$/, ""); - return Object.keys(doc.paths).find((p) => - new RegExp(`^${p.replace(/\{[^}]+\}/g, "[^/]+")}$`).test(path), - ); -}; -test("the web copy of the spec is the server's spec", () => { - expect(spec).toEqual(serverSpec); -}); +const specPath = (path: string) => + Object.keys(paths).find((p) => new RegExp(`^${p.replace(/\{[^}]+\}/g, "[^/]+")}$`).test(path)); -describe("endpoints", () => { +describe("OpenAPI spec", () => { test("lists exactly the operations convt-server routes", () => { - expect(list.map((e) => `${e.method.toUpperCase()} ${e.path}`).sort()).toEqual([ + const ops = Object.entries(paths).flatMap(([path, item]) => + Object.keys(item).map((method) => `${method.toUpperCase()} ${path}`), + ); + expect(ops.sort()).toEqual([ "GET /v1/formats", "GET /v1/jobs/{id}", "GET /v1/jobs/{id}/download", @@ -42,67 +35,66 @@ describe("endpoints", () => { }); test("only the formats list is public", () => { - expect(list.filter((e) => !e.authenticated).map((e) => e.path)).toEqual(["/v1/formats"]); + const open = Object.entries(paths).flatMap(([path, item]) => + Object.values(item) + .filter((op) => (op.security ?? spec.security).length === 0) + .map(() => path), + ); + expect(open).toEqual(["/v1/formats"]); }); - test("anchors are stable and unique", () => { - expect(slug("get", "/v1/jobs/{id}/download")).toBe("get-v1-jobs-id-download"); - expect(new Set(list.map((e) => e.id)).size).toBe(list.length); + test("every error code's description starts with its HTTP status", () => { + const { enum: codes = [], "x-enum-descriptions": text = {} } = schemas.ErrorCode; + expect(codes.length).toBeGreaterThan(0); + for (const code of codes) expect(text[code], code).toMatch(/^\d{3}(, \d{3})*\. /); }); +}); - test("groups by tag in spec order", () => { - expect(byTag(doc).map((g) => g.name)).toEqual(["Jobs", "Formats"]); - }); +describe("dashboard quick start", () => { + const samples = quickStart(base).filter((s) => s.language !== "cli"); - test("every $ref resolves", () => { - for (const e of list) { - for (const p of e.params) expect(p?.name).toBeString(); - for (const r of e.responses) expect(r.description).not.toBe(""); + test("calls only documented paths, on the given base URL", () => { + for (const sample of samples) { + expect(sample.code, sample.label).toContain(base); + expect(sample.code).not.toContain("api.convt.app"); + const used = (sample.code.match(/\/v1\/[^\s"'`)]*/g) ?? []).map((path) => + path.replace(/\$?\{[^}]+\}|\$\w+/g, "x"), + ); + expect(used.length, sample.label).toBeGreaterThan(0); + for (const path of used) expect(specPath(path), path).toBeDefined(); } }); - test("create's example body only uses declared fields", () => { - const create = list.find((e) => e.id === "post-v1-jobs"); - const body = create?.bodyExample as Record; - expect(Object.keys(body).every((k) => k in (create?.body?.properties ?? {}))).toBe(true); - for (const field of create?.body?.required ?? []) expect(body).toHaveProperty(field); + test("sends every field create requires", () => { + const required = schemas.CreateJob.required ?? []; + expect(required.length).toBeGreaterThan(0); + for (const sample of samples) { + for (const field of required) expect(sample.code, sample.label).toContain(field); + } }); }); -describe("objects", () => { - test("the Job example has every documented field", () => { - const job = doc.components?.schemas?.Job; - const example = exampleFor({ $ref: "#/components/schemas/Job" }, resolve) as object; - expect(Object.keys(example).sort()).toEqual(Object.keys(job?.properties ?? {}).sort()); - }); +describe("docs links", () => { + const docs = new URL("../../../docs/", import.meta.url); + const page = (href: string) => href.replace(/^\/docs\/?/, "").replace(/#.*$/, "") || "index"; - test("every error code has a description that starts with its HTTP status", () => { - const codes = enumValues(doc.components?.schemas?.ErrorCode); - expect(codes.length).toBeGreaterThan(0); - for (const { value, description } of codes) { - expect(description, value).toMatch(/^\d{3}(, \d{3})*\. /); + test("each dashboard link is a page in the Blume site", () => { + const pages = [links.docs, links.docsQuickStart, links.docsErrors, links.docsLimits]; + for (const href of pages) { + expect(existsSync(new URL(`content/${page(href)}.mdx`, docs)), href).toBe(true); } + const formats = readFileSync(new URL("scripts/generate-formats.ts", docs), "utf8"); + expect(formats).toContain(`content/${page(links.docsFormats)}.mdx`); }); -}); -describe("samples", () => { - test("every sample calls a path the spec documents, on the given base URL", () => { - const samples = [...quickStart(base), ...list.flatMap((e) => endpointSamples(e, base))]; - for (const sample of samples.filter((s) => s.language !== "cli")) { - expect(sample.code, sample.label).toContain(base); - const paths = (sample.code.match(/\/v1\/[^\s"'`)]*/g) ?? []).map( - (path) => `${base}${path.replace(/\$?\{[^}]+\}|\$\w+/g, "x")}`, - ); - expect(paths.length, sample.label).toBeGreaterThan(0); - for (const url of paths) expect(specPath(url), url).toBeDefined(); - expect(sample.code).not.toContain("api.convt.app"); - } + test("the dashboard and the docs show the same API host", () => { + const host = readFileSync(new URL("api-host.ts", docs), "utf8"); + expect(host).toContain(`export const apiBase = "${apiBaseUrl}";`); }); - test("authenticated samples send a bearer key; the public one does not", () => { - for (const e of list) { - const curl = endpointSamples(e, base).find((s) => s.language === "curl")?.code ?? ""; - expect(curl.includes("Authorization: Bearer"), e.id).toBe(e.authenticated); - } + test("the API reference link is Blume's OpenAPI route", () => { + const config = readFileSync(new URL("blume.config.ts", docs), "utf8"); + expect(config).toContain(`route: "/${page(links.apiReference)}"`); + expect(config).toContain(`spec: "../../crates/convt-server/openapi.json"`); }); }); From 5fe74389e85d8709230ba4e225e9182cd828fda2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 13:31:18 +0000 Subject: [PATCH 8/8] web: keep the committed route tree The dev server's regenerated route tree came along in the previous commit; main's copy is the one the build expects. Co-authored-by: Leo --- apps/web/src/routeTree.gen.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index efcb9819..3e4cd3eb 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -747,13 +747,3 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() - -import type { getRouter } from './router.tsx' -import type { startInstance } from './start.ts' -declare module '@tanstack/react-start' { - interface Register { - ssr: true - router: Awaited> - config: Awaited> - } -}