swadhinbiswas 🚀 release: 2.0.8 — write-path fix (ADR-080), fork-safe GIL pool (ADR-081), HTTP/3 (ADR-082), Security DI fix, docs honesty pass · 8/6/2026
731fffe
185 lines · 6.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
pub mod alerting;
pub mod audit;
pub mod batching;
pub mod coalesce;
pub mod compress;
#[cfg(feature = "db")]
pub mod db;
pub mod dx;
pub mod error;
pub mod error_catalog;
pub mod extract;
pub mod gateway;
#[cfg(feature = "graphql")]
pub mod graphql;
pub mod health;
#[cfg(feature = "http3")]
pub mod http3;
#[cfg(feature = "mail")]
pub mod mail;
pub mod memory;
pub mod metrics;
pub mod middleware;
pub mod multipart;
#[cfg(feature = "inference")]
pub mod openai;
pub mod openapi;
pub mod panic;
pub mod plugin;
#[cfg(feature = "redis-rate-limit")]
pub mod rate_limit;
pub mod resilience;
pub mod router;
pub mod secrets;
pub mod serialize;
pub mod server;
pub mod static_files;
pub mod testing;
pub mod trace_context;
pub mod tracing_setup;
pub mod validate;
#[cfg(feature = "wasm")]
pub mod wasm;
pub mod xml;

pub use server::{serve, Server};

use std::str::FromStr;

use hyper::Method;

/// The HTTP QUERY method (RFC 10008): a safe, idempotent request that
/// carries a representation (like POST) but must not change state (like GET).
///
/// `hyper` does not ship a `Method::QUERY` constant, so JustAPI provides a
/// helper that returns one.
pub fn query_method() -> Method {
    Method::from_str("QUERY").expect("QUERY is a valid HTTP method name")
}

use anyhow::Result;
use futures::StreamExt;
use http_body_util::combinators::UnsyncBoxBody;
use http_body_util::BodyExt;
use http_body_util::Full;
use hyper::body::{Bytes, Frame};
use hyper::Response;

/// Unified response body type supporting both static and streaming payloads.
pub type ResponseBody = UnsyncBoxBody<Bytes, anyhow::Error>;

/// Lift a static string into a JSON response.
pub fn json_response(status: hyper::StatusCode, body: &str) -> Response<ResponseBody> {
    Response::builder()
        .status(status)
        .header("content-type", "application/json")
        .header("content-length", body.len().to_string())
        .body(UnsyncBoxBody::new(
            Full::new(Bytes::from(body.to_string()))
                .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
        ))
        .expect("Response::builder with valid inputs should never fail")
}

/// Canonical error envelope following RFC 9457 Problem Details. Every non-2xx
/// response in justapi uses this shape so clients have one contract to parse.
/// The `type` URI identifies the error class, `title` is a short human-readable
/// label, `status` is the HTTP status code, and `detail` carries the full message.
pub fn error_response(status: hyper::StatusCode, detail: &str) -> Response<ResponseBody> {
    let status_code: u16 = status.into();
    let title = match status_code {
        400 => "Bad Request",
        401 => "Unauthorized",
        403 => "Forbidden",
        404 => "Not Found",
        405 => "Method Not Allowed",
        413 => "Payload Too Large",
        422 => "Unprocessable Entity",
        429 => "Too Many Requests",
        500 => "Internal Server Error",
        502 => "Bad Gateway",
        503 => "Service Unavailable",
        504 => "Gateway Timeout",
        _ => "Error",
    };
    let body = serde_json::json!({
        "type": format!("https://justapi.dev/errors/{}", title.to_lowercase().replace(' ', "-")),
        "title": title,
        "status": status_code,
        "detail": detail,
    })
    .to_string();
    json_response(status, &body)
}

/// 422 validation error using RFC 9457 Problem Details.
pub fn validation_response(detail: &str) -> Response<ResponseBody> {
    error_response(hyper::StatusCode::UNPROCESSABLE_ENTITY, detail)
}

/// 503 Service Unavailable with a `Retry-After` hint, using RFC 9457 Problem Details.
pub fn service_unavailable_response(detail: &str) -> Response<ResponseBody> {
    let body = serde_json::json!({
        "type": "https://justapi.dev/errors/service-unavailable",
        "title": "Service Unavailable",
        "status": 503,
        "detail": detail,
    })
    .to_string();
    Response::builder()
        .status(hyper::StatusCode::SERVICE_UNAVAILABLE)
        .header("content-type", "application/problem+json")
        .header("retry-after", "1")
        .header("content-length", body.len().to_string())
        .body(UnsyncBoxBody::new(
            Full::new(Bytes::from(body))
                .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
        ))
        .expect("Response::builder with valid inputs should never fail")
}

/// Map a SQLx error from a request-path DB operation to the right status.
/// Saturation (`PoolTimedOut`/`PoolClosed`) becomes `503` (backpressure);
/// SQLite lock contention (`SQLITE_BUSY` after `busy_timeout`) is also a
/// transient, retryable condition → `503` with `Retry-After`, so a concurrent
/// write crunch doesn't surface as a 5s stall + 500. Everything else is a 500.
#[cfg(feature = "db")]
pub fn db_error_response(e: &sqlx::Error) -> Response<ResponseBody> {
    match e {
        sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed => {
            service_unavailable_response("database pool saturated; please retry shortly")
        }
        sqlx::Error::Database(db_err) => {
            let code = db_err.code().map(|c| c.to_string()).unwrap_or_default();
            // SQLite extended result codes: 5 = SQLITE_BUSY, 6 = SQLITE_LOCKED
            // (517 = BUSY_SNAPSHOT, 518 = BUSY_RECOVERY, 261 = LOCKED_SHAREDCACHE).
            let is_lock = matches!(code.as_str(), "5" | "6" | "517" | "518" | "261");
            if is_lock {
                service_unavailable_response("database is locked; please retry shortly")
            } else {
                error_response(hyper::StatusCode::INTERNAL_SERVER_ERROR, "database error")
            }
        }
        _ => error_response(hyper::StatusCode::INTERNAL_SERVER_ERROR, "database error"),
    }
}

/// Lift an `anyhow::Result<Bytes>` stream into a streaming response.
pub fn streaming_response(
    status: hyper::StatusCode,
    content_type: &str,
    stream: impl futures::Stream<Item = Result<Bytes>> + Send + 'static,
) -> Response<ResponseBody> {
    let frame_stream = stream.map(|r| r.map(Frame::data));
    Response::builder()
        .status(status)
        .header("content-type", content_type)
        .body(UnsyncBoxBody::new(http_body_util::StreamBody::new(frame_stream)))
        .expect("Response::builder with valid inputs should never fail")
}
pub mod dummy_extract;
#[cfg(feature = "grpc")]
pub mod grpc;
#[cfg(feature = "grpc")]
pub mod test_codec;