kroki_rs/server/
handlers.rs

1// use crate::config::SUPPORTED_FORMATS;
2use crate::interface::{DiagramRequest, ProblemDetails, RenderRequestDto};
3use crate::server::AppState;
4use crate::utils::decode;
5use crate::utils::image_converter;
6use axum::{
7    extract::{Path, State},
8    http::StatusCode,
9    response::{IntoResponse, Response},
10    Json,
11};
12
13use axum::response::Html;
14
15/// Root discovery page handler.
16pub async fn root(State(state): State<AppState>) -> impl IntoResponse {
17    let capabilities = state.registry.known_types();
18    let admin_port = state.config.server.admin_port;
19
20    let auth_status = if state.config.server.auth.enabled {
21        "Enabled"
22    } else {
23        "Disabled (Dev Mode)"
24    };
25    let rate_limit_status = if state.config.server.rate_limit.enabled {
26        "Enabled"
27    } else {
28        "Disabled"
29    };
30    let metrics_status = if state.config.server.metrics.enabled {
31        "Enabled"
32    } else {
33        "Disabled"
34    };
35
36    let html = format!(
37        r#"
38<!DOCTYPE html>
39<html lang="en">
40<head>
41    <meta charset="UTF-8">
42    <meta name="viewport" content="width=device-width, initial-scale=1.0">
43    <title>Kroki-rs | Discovery</title>
44    <style>
45        :root {{
46            --primary: #6366f1;
47            --primary-dark: #4f46e5;
48            --bg: #0f172a;
49            --card-bg: #1e293b;
50            --text: #f8fafc;
51            --text-muted: #94a3b8;
52            --success: #22c55e;
53            --warning: #eab308;
54        }}
55        body {{
56            font-family: 'Inter', -apple-system, sans-serif;
57            background-color: var(--bg);
58            color: var(--text);
59            margin: 0;
60            display: flex;
61            flex-direction: column;
62            align-items: center;
63            min-height: 100vh;
64            padding: 2rem;
65        }}
66        .container {{
67            max-width: 900px;
68            width: 100%;
69        }}
70        header {{
71            text-align: center;
72            margin-bottom: 3rem;
73        }}
74        h1 {{
75            font-size: 3rem;
76            margin: 0;
77            background: linear-gradient(to right, #818cf8, #c084fc);
78            -webkit-background-clip: text;
79            -webkit-text-fill-color: transparent;
80        }}
81        .version {{
82            font-size: 0.875rem;
83            color: var(--text-muted);
84            margin-top: 0.5rem;
85        }}
86        .grid {{
87            display: grid;
88            grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
89            gap: 1.5rem;
90            margin-bottom: 3rem;
91        }}
92        .card {{
93            background: var(--card-bg);
94            border-radius: 1rem;
95            padding: 1.5rem;
96            border: 1px solid #334155;
97            transition: transform 0.2s, border-color 0.2s;
98        }}
99        .card:hover {{
100            transform: translateY(-4px);
101            border-color: var(--primary);
102        }}
103        .card h2 {{
104            margin-top: 0;
105            font-size: 1.25rem;
106            display: flex;
107            align-items: center;
108            gap: 0.5rem;
109        }}
110        .status-pill {{
111            font-size: 0.75rem;
112            padding: 0.25rem 0.625rem;
113            border-radius: 9999px;
114            font-weight: 600;
115            text-transform: uppercase;
116        }}
117        .status-enabled {{ background: #064e3b; color: #6ee7b7; }}
118        .status-disabled {{ background: #450a0a; color: #fca5a5; }}
119        
120        .endpoints {{
121            list-style: none;
122            padding: 0;
123            margin: 1.5rem 0 0;
124        }}
125        .endpoints li {{
126            margin-bottom: 0.75rem;
127        }}
128        .endpoints a {{
129            color: var(--primary);
130            text-decoration: none;
131            display: flex;
132            align-items: center;
133            gap: 0.5rem;
134            font-weight: 500;
135        }}
136        .endpoints a:hover {{
137            text-decoration: underline;
138        }}
139        
140        .showcase {{
141            background: var(--card-bg);
142            border-radius: 1rem;
143            padding: 2rem;
144            border: 1px solid #334155;
145        }}
146        .showcase h2 {{ margin-top: 0; }}
147        .provider-list {{
148            display: flex;
149            flex-wrap: wrap;
150            gap: 0.75rem;
151            margin-top: 1.5rem;
152        }}
153        .provider-tag {{
154            background: #334155;
155            padding: 0.5rem 1rem;
156            border-radius: 0.5rem;
157            font-size: 0.875rem;
158            font-weight: 500;
159        }}
160        .footer {{
161            margin-top: auto;
162            padding-top: 3rem;
163            color: var(--text-muted);
164            font-size: 0.875rem;
165            text-align: center;
166        }}
167    </style>
168</head>
169<body>
170    <div class="container">
171        <header>
172            <h1>Kroki-rs</h1>
173            <div class="version">v{} Discovery Service</div>
174        </header>
175
176        <div class="grid">
177            <div class="card">
178                <h2>Service Status</h2>
179                <div style="display: flex; flex-direction: column; gap: 0.75rem; margin-top: 1rem;">
180                    <div style="display: flex; justify-content: space-between; align-items: center;">
181                        <span>Auth</span>
182                        <span class="status-pill {}">{}</span>
183                    </div>
184                    <div style="display: flex; justify-content: space-between; align-items: center;">
185                        <span>Rate Limit</span>
186                        <span class="status-pill {}">{}</span>
187                    </div>
188                    <div style="display: flex; justify-content: space-between; align-items: center;">
189                        <span>Metrics</span>
190                        <span class="status-pill {}">{}</span>
191                    </div>
192                </div>
193            </div>
194
195            <div class="card">
196                <h2>Endpoints</h2>
197                <ul class="endpoints">
198                    <li><a href="http://localhost:{}/health">🔍 Health Check</a></li>
199                    {}
200                    <li><a href="http://localhost:{}">⚙️ Admin Dashboard</a></li>
201                    <li><a href="https://softmentor.github.io/kroki-rs/" target="_blank">📖 Documentation & Help</a></li>
202                </ul>
203            </div>
204        </div>
205
206        <div class="showcase">
207            <h2>Available Providers ({} registered)</h2>
208            <div class="provider-list">
209                {}
210            </div>
211        </div>
212
213        <div class="footer">
214            Built with Rust & Axum • <a href="https://github.com/softmentor/kroki-rs" style="color: inherit;">GitHub</a>
215        </div>
216    </div>
217</body>
218</html>
219"#,
220        env!("CARGO_PKG_VERSION"),
221        if state.config.server.auth.enabled {
222            "status-enabled"
223        } else {
224            "status-disabled"
225        },
226        auth_status,
227        if state.config.server.rate_limit.enabled {
228            "status-enabled"
229        } else {
230            "status-disabled"
231        },
232        rate_limit_status,
233        if state.config.server.metrics.enabled {
234            "status-enabled"
235        } else {
236            "status-disabled"
237        },
238        metrics_status,
239        admin_port, // Health is on admin port
240        if state.config.server.metrics.enabled && state.config.server.metrics.export_endpoint {
241            {
242                format!(
243                    r#"<li><a href="http://localhost:{}/metrics">📊 Prometheus Metrics</a></li>"#,
244                    admin_port
245                )
246            }
247        } else {
248            {
249                "".to_string()
250            }
251        },
252        admin_port,
253        capabilities.len(),
254        capabilities
255            .iter()
256            .map(|c| format!(r#"<div class="provider-tag">{}</div>"#, c))
257            .collect::<Vec<_>>()
258            .join("\n")
259    );
260
261    Html(html)
262}
263
264pub async fn get_diagram(
265    Path((type_, format, source_encoded)): Path<(String, String, String)>,
266    State(state): State<AppState>,
267) -> Response {
268    let start_time = std::time::Instant::now();
269
270    // 1. Initial mapping to Domain Request
271    let request = match decode(&source_encoded) {
272        Ok(source) => DiagramRequest {
273            source,
274            format,
275            provider: type_,
276        },
277        Err(e) => {
278            return (
279                StatusCode::BAD_REQUEST,
280                Json(
281                    ProblemDetails::new(
282                        "https://kroki.io/errors/decode-failed",
283                        "Input Decoding Failed",
284                        400,
285                    )
286                    .with_detail(&e.to_string()),
287                ),
288            )
289                .into_response();
290        }
291    };
292
293    render_diagram(request, state, start_time).await
294}
295
296/// Handler for retrieving diagrams via JSON POST request.
297pub async fn post_render(
298    Path((type_, format)): Path<(String, String)>,
299    State(state): State<AppState>,
300    Json(dto): Json<RenderRequestDto>,
301) -> Response {
302    let start_time = std::time::Instant::now();
303
304    let request = DiagramRequest {
305        source: dto.source,
306        format: dto.format.unwrap_or(format),
307        provider: dto.provider.unwrap_or(type_),
308    };
309
310    render_diagram(request, state, start_time).await
311}
312
313async fn render_diagram(
314    request: DiagramRequest,
315    state: AppState,
316    start_time: std::time::Instant,
317) -> Response {
318    tracing::info!(
319        "Request: type={}, format={}",
320        request.provider,
321        request.format
322    );
323
324    // 2. Metrics
325    if state.config.server.metrics.enabled {
326        crate::server::metrics::Metrics::increment_requests(&request.provider, &request.format);
327        crate::server::metrics::Metrics::record_payload_size(
328            &request.provider,
329            &request.format,
330            request.source.len() as f64,
331        );
332    }
333
334    // 3. Validate input size (TD-19)
335    if request.source.len() > state.config.server.max_input_size {
336        if state.config.server.metrics.enabled {
337            crate::server::metrics::Metrics::increment_errors(
338                &request.provider,
339                &request.format,
340                "payload_too_large",
341            );
342        }
343        return (
344            StatusCode::PAYLOAD_TOO_LARGE,
345            Json(
346                ProblemDetails::new(
347                    "https://kroki.io/errors/payload-too-large",
348                    "Payload Too Large",
349                    413,
350                )
351                .with_detail(&format!(
352                    "Input too large ({} bytes). Maximum allowed: {} bytes",
353                    request.source.len(),
354                    state.config.server.max_input_size
355                )),
356            ),
357        )
358            .into_response();
359    }
360
361    // 4. Find provider from pre-built registry (TD-04)
362    let provider = match state.registry.get(&request.provider) {
363        Some(p) => p,
364        None => {
365            let known = state.registry.known_types();
366            let msg = if known.is_empty() {
367                "No diagram tools are available on this server".to_string()
368            } else {
369                format!(
370                    "Diagram type '{}' is not available. Supported types: {}",
371                    request.provider,
372                    known.join(", ")
373                )
374            };
375            tracing::warn!("{}", msg);
376            if state.config.server.metrics.enabled {
377                crate::server::metrics::Metrics::increment_errors(
378                    &request.provider,
379                    &request.format,
380                    "provider_not_found",
381                );
382            }
383            return (
384                StatusCode::NOT_FOUND,
385                Json(
386                    ProblemDetails::new(
387                        "https://kroki.io/errors/provider-not-found",
388                        "Provider Not Found",
389                        404,
390                    )
391                    .with_detail(&msg),
392                ),
393            )
394                .into_response();
395        }
396    };
397
398    // 5. Check circuit breaker for this provider type
399    if let Some(ref cb) = state.circuit_breaker {
400        if !cb.should_allow(&request.provider) {
401            tracing::warn!(
402                "Circuit breaker OPEN for provider '{}' — rejecting request",
403                request.provider
404            );
405            if state.config.server.metrics.enabled {
406                crate::server::metrics::Metrics::increment_errors(
407                    &request.provider,
408                    &request.format,
409                    "circuit_breaker_open",
410                );
411                crate::server::metrics::Metrics::set_circuit_breaker_state(&request.provider, 1.0);
412            }
413            return (
414                StatusCode::SERVICE_UNAVAILABLE,
415                Json(ProblemDetails::new(
416                    "https://kroki.io/errors/circuit-breaker-open",
417                    "Service Unavailable",
418                    503,
419                ).with_detail(&format!(
420                    "Provider '{}' is temporarily unavailable due to repeated failures. Please retry later.",
421                    request.provider
422                )))
423            ).into_response();
424        }
425    }
426
427    let is_webp = request.format.to_lowercase() == "webp";
428    let base_format = if is_webp {
429        if request.provider.to_lowercase() == "ditaa" {
430            "png"
431        } else {
432            "svg"
433        }
434    } else {
435        &request.format
436    };
437
438    // 6. Generate
439    let render_start = std::time::Instant::now();
440    let timeout_duration = std::time::Duration::from_millis(state.config.server.timeout_ms);
441
442    match tokio::time::timeout(
443        timeout_duration,
444        provider.generate(&request.source, base_format),
445    )
446    .await
447    {
448        Ok(Ok(mut bytes)) => {
449            let render_duration = render_start.elapsed().as_secs_f64();
450            if state.config.server.metrics.enabled {
451                crate::server::metrics::Metrics::record_conversion_time(
452                    &request.provider,
453                    &request.format,
454                    render_duration,
455                );
456            }
457
458            // Record success for circuit breaker
459            if let Some(ref cb) = state.circuit_breaker {
460                cb.record_success(&request.provider);
461                if state.config.server.metrics.enabled {
462                    crate::server::metrics::Metrics::set_circuit_breaker_state(
463                        &request.provider,
464                        0.0,
465                    );
466                    // 0 = Closed
467                }
468            }
469
470            // Output size validation (TD-20)
471            if bytes.len() > state.config.server.max_output_size {
472                tracing::error!(
473                    "Output too large ({} bytes, max: {} bytes) for type={}",
474                    bytes.len(),
475                    state.config.server.max_output_size,
476                    request.provider
477                );
478                if state.config.server.metrics.enabled {
479                    crate::server::metrics::Metrics::increment_errors(
480                        &request.provider,
481                        &request.format,
482                        "output_too_large",
483                    );
484                }
485                return (
486                    StatusCode::INTERNAL_SERVER_ERROR,
487                    Json(ProblemDetails::new(
488                        "https://kroki.io/errors/output-too-large",
489                        "Output Too Large",
490                        500,
491                    ).with_detail(&format!(
492                        "Generated output exceeds size limit ({} bytes). Consider simplifying the diagram.",
493                        bytes.len()
494                    )))
495                ).into_response();
496            }
497
498            if is_webp {
499                let fonts = state.config.all_fonts();
500
501                let convert_result = if base_format == "png" {
502                    image_converter::png_to_webp(&bytes, image_converter::WebpQuality::Lossless)
503                        .await
504                } else {
505                    let cache_dir = crate::config::Config::resolve_cache_dir(None);
506                    image_converter::svg_to_webp(
507                        &bytes,
508                        image_converter::WebpQuality::Lossless,
509                        &fonts,
510                        cache_dir.as_deref(),
511                    )
512                    .await
513                };
514
515                match convert_result {
516                    Ok(webp_bytes) => {
517                        bytes = webp_bytes;
518                    }
519                    Err(e) => {
520                        tracing::error!("WebP conversion failed for {}: {}", request.provider, e);
521                        if state.config.server.metrics.enabled {
522                            crate::server::metrics::Metrics::increment_errors(
523                                &request.provider,
524                                &request.format,
525                                "webp_conversion_error",
526                            );
527                        }
528                        return (
529                            StatusCode::INTERNAL_SERVER_ERROR,
530                            Json(
531                                ProblemDetails::new(
532                                    "https://kroki.io/errors/conversion-failed",
533                                    "WebP Conversion Failed",
534                                    500,
535                                )
536                                .with_detail(&e.to_string()),
537                            ),
538                        )
539                            .into_response();
540                    }
541                }
542            }
543
544            let content_type = match request.format.as_str() {
545                "svg" => "image/svg+xml",
546                "png" => "image/png",
547                "pdf" => "application/pdf",
548                "txt" => "text/plain",
549                "webp" => "image/webp",
550                _ => "application/octet-stream",
551            };
552
553            let total_duration = start_time.elapsed().as_secs_f64();
554            if state.config.server.metrics.enabled {
555                crate::server::metrics::Metrics::record_duration(
556                    &request.provider,
557                    &request.format,
558                    total_duration,
559                );
560            }
561
562            (
563                StatusCode::OK,
564                [(axum::http::header::CONTENT_TYPE, content_type)],
565                bytes,
566            )
567                .into_response()
568        }
569        Ok(Err(e)) => {
570            // Record failure for circuit breaker
571            if let Some(ref cb) = state.circuit_breaker {
572                cb.record_failure(&request.provider);
573                if state.config.server.metrics.enabled {
574                    crate::server::metrics::Metrics::set_circuit_breaker_state(
575                        &request.provider,
576                        1.0,
577                    );
578                }
579            }
580
581            if state.config.server.metrics.enabled {
582                crate::server::metrics::Metrics::increment_errors(
583                    &request.provider,
584                    &request.format,
585                    "render_error",
586                );
587            }
588
589            tracing::error!("Generation failed for {}: {}", request.provider, e);
590            let problem: ProblemDetails = e.into();
591            (
592                StatusCode::from_u16(problem.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
593                Json(problem),
594            )
595                .into_response()
596        }
597        Err(_) => {
598            // Global timeout exceeded
599            tracing::warn!(
600                "Global timeout of {}ms exceeded for provider '{}'",
601                state.config.server.timeout_ms,
602                request.provider
603            );
604
605            if state.config.server.metrics.enabled {
606                crate::server::metrics::Metrics::increment_errors(
607                    &request.provider,
608                    &request.format,
609                    "request_timeout",
610                );
611            }
612
613            (
614                StatusCode::GATEWAY_TIMEOUT,
615                Json(ProblemDetails::new(
616                    "https://kroki.io/errors/request-timeout",
617                    "Gateway Timeout",
618                    504,
619                ).with_detail(&format!(
620                    "The diagram generation for '{}' timed out after {}ms. Consider a smaller diagram or increasing KROKI_TIMEOUT.",
621                    request.provider,
622                    state.config.server.timeout_ms
623                )))
624            ).into_response()
625        }
626    }
627}