1use 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
15pub 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, 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 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
296pub 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 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 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 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 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 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 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 }
468 }
469
470 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 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 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}