A better Rust ATProto crate
1//! Helper for serving did:web DID documents
2//!
3//! did:web DIDs resolve to HTTPS endpoints serving DID documents. This module
4//! provides a router that serves your service's DID document at `/.well-known/did.json`.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use axum::Router;
10//! use jacquard_axum::did_web::did_web_router;
11//! use jacquard_common::types::did_doc::DidDocument;
12//!
13//! #[tokio::main]
14//! async fn main() {
15//! // Your DID document (typically loaded from config or generated)
16//! let did_doc: DidDocument = serde_json::from_str(r#"{
17//! "id": "did:web:feedgen.example.com",
18//! "verificationMethod": [{
19//! "id": "did:web:feedgen.example.com#atproto",
20//! "type": "Multikey",
21//! "controller": "did:web:feedgen.example.com",
22//! "publicKeyMultibase": "zQ3sh..."
23//! }]
24//! }"#).unwrap();
25//!
26//! let app = Router::new()
27//! .merge(did_web_router(did_doc));
28//!
29//! let listener = tokio::net::TcpListener::bind("0.0.0.0:443")
30//! .await
31//! .unwrap();
32//! axum::serve(listener, app).await.unwrap();
33//! }
34//! ```
35
36use axum::{
37 Json, Router,
38 http::{HeaderValue, StatusCode, header},
39 response::IntoResponse,
40 routing::get,
41};
42use jacquard::deps::smol_str::SmolStr;
43use jacquard_common::types::did_doc::DidDocument;
44
45/// Create a router that serves a DID document at `/.well-known/did.json`
46///
47/// Returns a Router that can be merged into your main application router.
48/// The DID document is cloned on each request.
49///
50/// # Example
51///
52/// ```no_run
53/// use axum::Router;
54/// use jacquard_axum::did_web::did_web_router;
55/// use jacquard_common::types::did_doc::DidDocument;
56///
57/// # async fn example(did_doc: DidDocument) {
58/// let app = Router::new()
59/// .merge(did_web_router(did_doc));
60/// # }
61/// ```
62pub fn did_web_router(did_doc: DidDocument<SmolStr>) -> Router {
63 Router::new().route(
64 "/.well-known/did.json",
65 get(move || async move {
66 (
67 StatusCode::OK,
68 [(
69 header::CONTENT_TYPE,
70 HeaderValue::from_static("application/did+json"),
71 )],
72 Json(did_doc.clone()),
73 )
74 .into_response()
75 }),
76 )
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use axum::body::{Body, to_bytes};
83 use axum::http::{Request, StatusCode, header};
84 use jacquard::deps::smol_str::SmolStr;
85 use jacquard::types::string::Did;
86 use jacquard_common::types::did_doc::DidDocument;
87 use tower::ServiceExt;
88
89 // A minimal but spec-shaped DID document used by the did:web router tests.
90 fn sample_did_document() -> DidDocument<SmolStr> {
91 DidDocument {
92 context: vec![SmolStr::new_static("https://www.w3.org/ns/did/v1")],
93 id: Did::new_static("did:web:example.com").unwrap(),
94 also_known_as: None,
95 verification_method: None,
96 service: Some(vec![]),
97 extra_data: std::collections::BTreeMap::new(),
98 }
99 }
100
101 #[tokio::test]
102 async fn did_web_router_serves_document_at_well_known_path() {
103 let doc = sample_did_document();
104 let expected = serde_json::to_value(&doc).unwrap();
105 let app = did_web_router(doc);
106
107 let response = app
108 .oneshot(
109 Request::builder()
110 .uri("/.well-known/did.json")
111 .body(Body::empty())
112 .unwrap(),
113 )
114 .await
115 .unwrap();
116 assert_eq!(response.status(), StatusCode::OK);
117 assert_eq!(
118 response.headers().get(header::CONTENT_TYPE).unwrap(),
119 "application/did+json"
120 );
121 let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
122 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
123 assert_eq!(body, expected);
124 }
125
126 #[tokio::test]
127 async fn did_web_router_rejects_unknown_paths() {
128 let app = did_web_router(sample_did_document());
129
130 let response = app
131 .oneshot(
132 Request::builder()
133 .uri("/elsewhere")
134 .body(Body::empty())
135 .unwrap(),
136 )
137 .await
138 .unwrap();
139 assert_eq!(response.status(), StatusCode::NOT_FOUND);
140 }
141}