1//! # Axum helpers for jacquard XRPC server implementations
2//!
3//! ## Usage
4//!
5//! ```no_run
6//! use axum::{Router, routing::get, http::StatusCode, response::IntoResponse, Json};
7//! use jacquard_axum::{ ExtractXrpc, IntoRouter };
8//! use std::collections::BTreeMap;
9//! use miette::{IntoDiagnostic, Result};
10//! use jacquard::api::com_atproto::identity::resolve_handle::{ResolveHandle, ResolveHandleRequest, ResolveHandleOutput};
11//! use jacquard_common::types::string::Did;
12//!
13//! async fn handle_resolve(
14//! ExtractXrpc(req): ExtractXrpc<ResolveHandleRequest>
15//! ) -> Result<Json<ResolveHandleOutput<'static>>, StatusCode> {
16//! // req is ResolveHandle<'static>, ready to use
17//! let handle = req.handle;
18//! // ... resolve logic
19//! # let output = ResolveHandleOutput { did: Did::new_static("did:plc:test").unwrap(), extra_data: BTreeMap::new() };
20//! Ok(Json(output))
21//! }
22//!
23//! #[tokio::main]
24//! async fn main() -> Result<()> {
25//! let app = Router::new()
26//! .route("/", axum::routing::get(|| async { "hello world!" }))
27//! .merge(ResolveHandleRequest::into_router(handle_resolve));
28//!
29//! let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
30//! .await
31//! .into_diagnostic()?;
32//! axum::serve(listener, app).await.unwrap();
33//! Ok(())
34//! }
35//! ```
36//!
37//!
38//! The extractor uses the [`XrpcEndpoint`] trait to determine request type:
39//! - **Query**: Deserializes from query string parameters
40//! - **Procedure**: Deserializes from request body (supports custom encodings via `decode_body`)
41//!
42//! Deserialization errors return a 400 Bad Request with a JSON error body matching
43//! the XRPC error format.
44//!
45//! The extractor deserializes to borrowed types first, then converts to `'static` via
46//! [`IntoStatic`], avoiding the DeserializeOwned requirement of the Json axum extractor and similar.
47
48pub mod did_web;
49#[cfg(feature = "service-auth")]
50pub mod service_auth;
51
52use axum::{
53 Json, Router,
54 body::Bytes,
55 extract::{FromRequest, Request},
56 http::{HeaderValue, StatusCode, header},
57 response::{IntoResponse, Response},
58};
59use jacquard::{
60 IntoStatic,
61 xrpc::{XrpcEndpoint, XrpcMethod, XrpcRequest},
62};
63use serde_json::json;
64
65/// Axum extractor for XRPC requests
66///
67/// Deserializes incoming requests based on the endpoint's method type (Query or Procedure)
68/// and returns the owned (`'static`) request type ready for handler logic.
69
70pub struct ExtractXrpc<E: XrpcEndpoint>(pub E::Request<'static>);
71
72impl<S, R> FromRequest<S> for ExtractXrpc<R>
73where
74 S: Send + Sync,
75 R: XrpcEndpoint,
76 for<'a> R::Request<'a>: IntoStatic<Output = R::Request<'static>>,
77{
78 type Rejection = Response;
79
80 fn from_request(
81 req: Request,
82 state: &S,
83 ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
84 async {
85 match R::METHOD {
86 XrpcMethod::Procedure(_) => {
87 let body = Bytes::from_request(req, state)
88 .await
89 .map_err(IntoResponse::into_response)?;
90 let decoded = R::Request::decode_body(&body);
91 match decoded {
92 Ok(value) => Ok(ExtractXrpc(*value.into_static())),
93 Err(err) => Err((
94 StatusCode::BAD_REQUEST,
95 [(
96 header::CONTENT_TYPE,
97 HeaderValue::from_static("application/json"),
98 )],
99 Json(json!({
100 "error": "InvalidRequest",
101 "message": format!("failed to decode request: {}", err)
102 })),
103 )
104 .into_response()),
105 }
106 }
107 XrpcMethod::Query => {
108 if let Some(path_query) = req.uri().path_and_query() {
109 let query = path_query.query().unwrap_or("");
110 let value: R::Request<'_> = serde_html_form::from_str::<R::Request<'_>>(
111 query,
112 )
113 .map_err(|e| {
114 (
115 StatusCode::BAD_REQUEST,
116 [(
117 header::CONTENT_TYPE,
118 HeaderValue::from_static("application/json"),
119 )],
120 Json(json!({
121 "error": "InvalidRequest",
122 "message": format!("failed to decode request: {}", e)
123 })),
124 )
125 .into_response()
126 })?;
127 Ok(ExtractXrpc(value.into_static()))
128 } else {
129 Err((
130 StatusCode::BAD_REQUEST,
131 [(
132 header::CONTENT_TYPE,
133 HeaderValue::from_static("application/json"),
134 )],
135 Json(json!({
136 "error": "InvalidRequest",
137 "message": "wrong nsid for wherever this ended up"
138 })),
139 )
140 .into_response())
141 }
142 }
143 }
144 }
145 }
146}
147
148#[derive(Debug, thiserror::Error, miette::Diagnostic)]
149pub enum XrpcRequestError {
150 #[error("Unsupported encoding: {0}")]
151 UnsupportedEncoding(String),
152 #[error("JSON decode error: {0}")]
153 JsonDecodeError(serde_json::Error),
154 #[error("UTF-8 decode error: {0}")]
155 Utf8DecodeError(std::string::FromUtf8Error),
156}
157
158/// Conversion trait to turn an XrpcEndpoint and a handler into an axum Router
159pub trait IntoRouter {
160 fn into_router<T, S, U>(handler: U) -> Router<S>
161 where
162 T: 'static,
163 S: Clone + Send + Sync + 'static,
164 U: axum::handler::Handler<T, S>;
165}
166
167impl<X> IntoRouter for X
168where
169 X: XrpcEndpoint,
170{
171 /// Creates an axum router that will invoke `handler` in response to xrpc
172 /// request `X`.
173 fn into_router<T, S, U>(handler: U) -> Router<S>
174 where
175 T: 'static,
176 S: Clone + Send + Sync + 'static,
177 U: axum::handler::Handler<T, S>,
178 {
179 Router::new().route(
180 X::PATH,
181 (match X::METHOD {
182 XrpcMethod::Query => axum::routing::get,
183 XrpcMethod::Procedure(_) => axum::routing::post,
184 })(handler),
185 )
186 }
187}