A better Rust ATProto crate
at main 6.8 kB view raw
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}