Updated API 422 Error Response
This commit is contained in:
+2
-1
@@ -10,7 +10,8 @@ RUN bun run build
|
|||||||
FROM rust:alpine AS backend-builder
|
FROM rust:alpine AS backend-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconfig git
|
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconfig git
|
||||||
RUN git clone --depth=1 https://github.com/typst/typst.git typst
|
RUN git clone https://github.com/typst/typst.git typst \
|
||||||
|
&& git -C typst checkout 44b3f78ed37fedea75e911dde2269ef86c45316f
|
||||||
COPY server/Cargo.* server/
|
COPY server/Cargo.* server/
|
||||||
COPY server/src server/src
|
COPY server/src server/src
|
||||||
WORKDIR /app/server
|
WORKDIR /app/server
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ TypstDrive is a collaborative web editor for Typst. With built-in dark mode, mul
|
|||||||
- **Customizable Themes**: Choose from multiple editor themes (Catppuccin, Arch Linux, Cerberus) and toggle global dark mode.
|
- **Customizable Themes**: Choose from multiple editor themes (Catppuccin, Arch Linux, Cerberus) and toggle global dark mode.
|
||||||
- **Export Options**: Export your compiled documents directly to PDF, PNG, SVG, HTML, Markdown, Word, or LaTeX formats using internal conversion and Pandoc integrations.
|
- **Export Options**: Export your compiled documents directly to PDF, PNG, SVG, HTML, Markdown, Word, or LaTeX formats using internal conversion and Pandoc integrations.
|
||||||
- **Document Sharing**: Invite collaborators by email with Editor or Viewer roles. Collaborators' uploaded fonts and images are available to the compiler. A dedicated "Shared with me" folder on the dashboard surfaces all documents others have shared with you. Manage and remove collaborators directly from the Share modal in the editor.
|
- **Document Sharing**: Invite collaborators by email with Editor or Viewer roles. Collaborators' uploaded fonts and images are available to the compiler. A dedicated "Shared with me" folder on the dashboard surfaces all documents others have shared with you. Manage and remove collaborators directly from the Share modal in the editor.
|
||||||
- **Public REST API**: Programmatically render Typst documents to PNG or PDF via `POST /v1/render`. Manage API keys from the Settings panel, with a live usage chart supporting 1-hour, 1-day, and 1-week views. Full API reference available at `/api-docs`.
|
- **Public REST API**: Programmatically render Typst documents to PNG or PDF via `POST /v1/render`. Compilation failures return a `422` with a JSON body detailing each Typst error, including its message and source line and column. Manage API keys from the Settings panel, with a live usage chart supporting 1-hour, 1-day, and 1-week views. Full API reference available at `/api-docs`.
|
||||||
- **Admin System**: First-run setup wizard creates an admin account. Admins can manage all users, create new accounts with temporary passwords, toggle admin privileges, and delete accounts from the Settings panel.
|
- **Admin System**: First-run setup wizard creates an admin account. Admins can manage all users, create new accounts with temporary passwords, toggle admin privileges, and delete accounts from the Settings panel.
|
||||||
- **Presentation Mode**: Turn your documents into instant slideshows with built-in slide controls and a live drawing/annotation tool overlay.
|
- **Presentation Mode**: Turn your documents into instant slideshows with built-in slide controls and a live drawing/annotation tool overlay.
|
||||||
- **Asset Management**: Upload and seamlessly use custom fonts and images directly within your documents.
|
- **Asset Management**: Upload and seamlessly use custom fonts and images directly within your documents.
|
||||||
|
|||||||
+3
-3
@@ -14,12 +14,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.60.1",
|
"@sveltejs/kit": "^2.61.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"svelte": "^5.55.9",
|
"svelte": "^5.56.0",
|
||||||
"svelte-check": "^4.4.8",
|
"svelte-check": "^4.4.8",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
@@ -42,6 +42,6 @@
|
|||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"y-codemirror.next": "^0.3.5",
|
"y-codemirror.next": "^0.3.5",
|
||||||
"y-websocket": "^3.0.0",
|
"y-websocket": "^3.0.0",
|
||||||
"yjs": "^13.6.30"
|
"yjs": "^13.6.31"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ use crate::{
|
|||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
const USER_FIELDS: &str = "id, username, email, password_hash, is_admin";
|
|
||||||
|
|
||||||
use argon2::{
|
use argon2::{
|
||||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
Argon2,
|
Argon2,
|
||||||
|
|||||||
+20
-22
@@ -5,7 +5,8 @@ use typst::diag::{SourceDiagnostic, Warned};
|
|||||||
use typst::layout::{Frame, FrameItem};
|
use typst::layout::{Frame, FrameItem};
|
||||||
use typst_layout::PagedDocument;
|
use typst_layout::PagedDocument;
|
||||||
use typst_pdf::{pdf, PdfOptions};
|
use typst_pdf::{pdf, PdfOptions};
|
||||||
use typst_render::render;
|
use typst_render::{render, RenderOptions};
|
||||||
|
use typst_svg::SvgOptions;
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
pub struct DocumentStats {
|
pub struct DocumentStats {
|
||||||
@@ -71,9 +72,14 @@ impl TypstCompiler {
|
|||||||
warnings: _,
|
warnings: _,
|
||||||
} => {
|
} => {
|
||||||
let stats = extract_stats(&doc);
|
let stats = extract_stats(&doc);
|
||||||
let svgs = doc.pages().iter().map(typst_svg::svg).collect();
|
let options = SvgOptions::default();
|
||||||
|
let svgs = doc
|
||||||
|
.pages()
|
||||||
|
.iter()
|
||||||
|
.map(|page| typst_svg::svg(page, &options))
|
||||||
|
.collect();
|
||||||
let thumbnail = if let Some(page) = doc.pages().first() {
|
let thumbnail = if let Some(page) = doc.pages().first() {
|
||||||
typst_svg::svg(page)
|
typst_svg::svg(page, &options)
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
@@ -83,15 +89,11 @@ impl TypstCompiler {
|
|||||||
output: Err(errors),
|
output: Err(errors),
|
||||||
warnings: _,
|
warnings: _,
|
||||||
} => {
|
} => {
|
||||||
use typst::World;
|
use typst::WorldExt;
|
||||||
let diag = errors
|
let diag = errors
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|d| {
|
.map(|d| {
|
||||||
let range = d
|
let range = world.range(d.span);
|
||||||
.span
|
|
||||||
.id()
|
|
||||||
.and_then(|id| world.source(id).ok())
|
|
||||||
.and_then(|s| s.range(d.span));
|
|
||||||
(d, range)
|
(d, range)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -121,15 +123,11 @@ impl TypstCompiler {
|
|||||||
output: Err(errors),
|
output: Err(errors),
|
||||||
warnings: _,
|
warnings: _,
|
||||||
} => {
|
} => {
|
||||||
use typst::World;
|
use typst::WorldExt;
|
||||||
Err(errors
|
Err(errors
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|d| {
|
.map(|d| {
|
||||||
let range = d
|
let range = world.range(d.span);
|
||||||
.span
|
|
||||||
.id()
|
|
||||||
.and_then(|id| world.source(id).ok())
|
|
||||||
.and_then(|s| s.range(d.span));
|
|
||||||
(d, range)
|
(d, range)
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -149,7 +147,11 @@ impl TypstCompiler {
|
|||||||
warnings: _,
|
warnings: _,
|
||||||
} => {
|
} => {
|
||||||
if let Some(page) = doc.pages().first() {
|
if let Some(page) = doc.pages().first() {
|
||||||
let pixmap = render(page, 2.0);
|
let options = RenderOptions {
|
||||||
|
pixel_per_pt: 2.0,
|
||||||
|
..RenderOptions::default()
|
||||||
|
};
|
||||||
|
let pixmap = render(page, &options);
|
||||||
if let Ok(encoded) = pixmap.encode_png() {
|
if let Ok(encoded) = pixmap.encode_png() {
|
||||||
return Ok(encoded);
|
return Ok(encoded);
|
||||||
}
|
}
|
||||||
@@ -160,15 +162,11 @@ impl TypstCompiler {
|
|||||||
output: Err(errors),
|
output: Err(errors),
|
||||||
warnings: _,
|
warnings: _,
|
||||||
} => {
|
} => {
|
||||||
use typst::World;
|
use typst::WorldExt;
|
||||||
Err(errors
|
Err(errors
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|d| {
|
.map(|d| {
|
||||||
let range = d
|
let range = world.range(d.span);
|
||||||
.span
|
|
||||||
.id()
|
|
||||||
.and_then(|id| world.source(id).ok())
|
|
||||||
.and_then(|s| s.range(d.span));
|
|
||||||
(d, range)
|
(d, range)
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use axum::{
|
|||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Sha256, Digest};
|
use sha2::{Sha256, Digest};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -25,6 +25,37 @@ pub struct InlineFile {
|
|||||||
pub data: String, // base64-encoded
|
pub data: String, // base64-encoded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CompileErrorDetail {
|
||||||
|
message: String,
|
||||||
|
severity: String,
|
||||||
|
line: Option<usize>,
|
||||||
|
column: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CompileErrorResponse {
|
||||||
|
error: String,
|
||||||
|
details: Vec<CompileErrorDetail>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line_and_column(code: &str, offset: usize) -> (usize, usize) {
|
||||||
|
let mut line = 1;
|
||||||
|
let mut column = 1;
|
||||||
|
for (index, character) in code.char_indices() {
|
||||||
|
if index >= offset {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if character == '\n' {
|
||||||
|
line += 1;
|
||||||
|
column = 1;
|
||||||
|
} else {
|
||||||
|
column += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(line, column)
|
||||||
|
}
|
||||||
|
|
||||||
fn compute_cache_key(format: &str, code: &str, files: &Option<Vec<InlineFile>>) -> String {
|
fn compute_cache_key(format: &str, code: &str, files: &Option<Vec<InlineFile>>) -> String {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(format.as_bytes());
|
hasher.update(format.as_bytes());
|
||||||
@@ -206,6 +237,48 @@ pub async fn render_handler(
|
|||||||
|
|
||||||
(StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data).into_response()
|
(StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data).into_response()
|
||||||
}
|
}
|
||||||
Err(_) => (StatusCode::UNPROCESSABLE_ENTITY, "Typst compilation failed. Check your code for errors.").into_response(),
|
Err(diagnostics) => {
|
||||||
|
let details: Vec<CompileErrorDetail> = diagnostics
|
||||||
|
.into_iter()
|
||||||
|
.map(|(diagnostic, range)| {
|
||||||
|
let (line, column) = match range.as_ref() {
|
||||||
|
Some(range) => {
|
||||||
|
let (line, column) = line_and_column(&payload.code, range.start);
|
||||||
|
(Some(line), Some(column))
|
||||||
|
}
|
||||||
|
None => (None, None),
|
||||||
|
};
|
||||||
|
CompileErrorDetail {
|
||||||
|
message: diagnostic.message.to_string(),
|
||||||
|
severity: format!("{:?}", diagnostic.severity).to_lowercase(),
|
||||||
|
line,
|
||||||
|
column,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let summary = details
|
||||||
|
.iter()
|
||||||
|
.map(|detail| match (detail.line, detail.column) {
|
||||||
|
(Some(line), Some(column)) => {
|
||||||
|
format!("{} (line {}, column {})", detail.message, line, column)
|
||||||
|
}
|
||||||
|
_ => detail.message.clone(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ");
|
||||||
|
|
||||||
|
let error = if summary.is_empty() {
|
||||||
|
"Typst compilation failed.".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Typst compilation failed: {}", summary)
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
Json(CompileErrorResponse { error, details }),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,18 @@ with open("output.png", "wb") as f:
|
|||||||
]
|
]
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
const compileErrorJson = `{
|
||||||
|
"error": "Typst compilation failed: unknown variable: x (line 3, column 5)",
|
||||||
|
"details": [
|
||||||
|
{
|
||||||
|
"message": "unknown variable: x",
|
||||||
|
"severity": "error",
|
||||||
|
"line": 3,
|
||||||
|
"column": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
|
||||||
// Highlighted versions (derived so they update if baseUrl changes)
|
// Highlighted versions (derived so they update if baseUrl changes)
|
||||||
let hCurlPng = $derived(hljs.highlight(curlPng, { language: 'bash' }).value);
|
let hCurlPng = $derived(hljs.highlight(curlPng, { language: 'bash' }).value);
|
||||||
let hCurlPdf = $derived(hljs.highlight(curlPdf, { language: 'bash' }).value);
|
let hCurlPdf = $derived(hljs.highlight(curlPdf, { language: 'bash' }).value);
|
||||||
@@ -104,6 +116,7 @@ with open("output.png", "wb") as f:
|
|||||||
let hPython = $derived(hljs.highlight(pythonExample, { language: 'python' }).value);
|
let hPython = $derived(hljs.highlight(pythonExample, { language: 'python' }).value);
|
||||||
let hFiles = $derived(hljs.highlight(filesExample, { language: 'python' }).value);
|
let hFiles = $derived(hljs.highlight(filesExample, { language: 'python' }).value);
|
||||||
let hSchema = $derived(hljs.highlight(requestSchemaJson,{ language: 'json' }).value);
|
let hSchema = $derived(hljs.highlight(requestSchemaJson,{ language: 'json' }).value);
|
||||||
|
let hCompileErr = $derived(hljs.highlight(compileErrorJson, { language: 'json' }).value);
|
||||||
|
|
||||||
async function copy(id: string, text: string) {
|
async function copy(id: string, text: string) {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
@@ -321,6 +334,15 @@ with open("output.png", "wb") as f:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-bold text-gray-800 dark:text-gray-200 mb-3">Compilation errors</p>
|
||||||
|
<div class="p-3 mb-3 rounded-lg bg-red-50 dark:bg-red-900/10 border border-red-100 dark:border-red-800/30 text-sm">
|
||||||
|
<span class="font-mono text-xs font-bold text-red-700 dark:text-red-400">422 Unprocessable Entity</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400 ml-2">JSON body describing every Typst error. <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-2 rounded">error</code> is a readable summary; <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-2 rounded">details</code> lists each diagnostic with its message, severity, and source line and column.</span>
|
||||||
|
</div>
|
||||||
|
<pre class="rounded-xl border border-gray-700 overflow-x-auto"><code class="hljs block px-4 py-4 text-xs font-mono leading-relaxed rounded-xl">{@html hCompileErr}</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="p-4 rounded-xl bg-blue-50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-800/30 text-sm text-blue-800 dark:text-blue-300">
|
<div class="p-4 rounded-xl bg-blue-50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-800/30 text-sm text-blue-800 dark:text-blue-300">
|
||||||
<p class="font-semibold mb-1 flex items-center gap-2"><Icon icon="mdi:folder-account-outline" class="text-base" /> Account files available automatically</p>
|
<p class="font-semibold mb-1 flex items-center gap-2"><Icon icon="mdi:folder-account-outline" class="text-base" /> Account files available automatically</p>
|
||||||
<p>Files uploaded to your TypstDrive account are available by filename inside your Typst code. Pass additional files inline via the <code class="font-mono text-xs bg-blue-100 dark:bg-blue-800/40 px-1 rounded">files</code> array to supplement or override them.</p>
|
<p>Files uploaded to your TypstDrive account are available by filename inside your Typst code. Pass additional files inline via the <code class="font-mono text-xs bg-blue-100 dark:bg-blue-800/40 px-1 rounded">files</code> array to supplement or override them.</p>
|
||||||
@@ -399,7 +421,7 @@ with open("output.png", "wb") as f:
|
|||||||
{#each [
|
{#each [
|
||||||
{ code: '400', name: 'Bad Request', desc: 'Invalid format value, empty code, or malformed JSON body.' },
|
{ code: '400', name: 'Bad Request', desc: 'Invalid format value, empty code, or malformed JSON body.' },
|
||||||
{ code: '401', name: 'Unauthorized', desc: 'Missing or invalid Authorization header, or unknown API key.' },
|
{ code: '401', name: 'Unauthorized', desc: 'Missing or invalid Authorization header, or unknown API key.' },
|
||||||
{ code: '422', name: 'Unprocessable Entity', desc: 'Your Typst code compiled with errors. Fix the markup and retry.' },
|
{ code: '422', name: 'Unprocessable Entity', desc: 'Your Typst code compiled with errors. The JSON body lists each error message with its source line and column.' },
|
||||||
{ code: '429', name: 'Too Many Requests', desc: 'Rate limit exceeded. Wait for the current 60-second window to reset.' },
|
{ code: '429', name: 'Too Many Requests', desc: 'Rate limit exceeded. Wait for the current 60-second window to reset.' },
|
||||||
{ code: '500', name: 'Internal Server Error', desc: 'Unexpected server error. Try again after a short delay.' },
|
{ code: '500', name: 'Internal Server Error', desc: 'Unexpected server error. Try again after a short delay.' },
|
||||||
] as err}
|
] as err}
|
||||||
@@ -414,7 +436,7 @@ with open("output.png", "wb") as f:
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-6 p-4 rounded-xl bg-gray-50 dark:bg-black/30 border border-gray-200 dark:border-white/10">
|
<div class="mt-6 p-4 rounded-xl bg-gray-50 dark:bg-black/30 border border-gray-200 dark:border-white/10">
|
||||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1">Error body</p>
|
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1">Error body</p>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Error responses return plain text describing the issue — no JSON envelope.</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">Compilation failures (<code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">422</code>) return a JSON body with an <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">error</code> summary and a <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">details</code> array. All other errors return plain text describing the issue.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
+1
-1
Submodule typst updated: de6f400976...44b3f78ed3
Reference in New Issue
Block a user