1use crate::rpc::{RpcRequest, dispatch};
34use axum::{
35 Json, Router,
36 extract::DefaultBodyLimit,
37 http::{StatusCode, header},
38 response::IntoResponse,
39 routing::{get, post},
40};
41use serde_json::json;
42use std::net::SocketAddr;
43
44pub const DEFAULT_HTTP_BODY_LIMIT_BYTES: usize = 8 * 1024 * 1024;
46
47async fn rpc_handler(Json(payload): Json<RpcRequest>) -> impl IntoResponse {
50 match dispatch(payload.clone()) {
51 Ok(result) => (StatusCode::OK, Json(result)),
52 Err((id, error)) => (
53 StatusCode::BAD_REQUEST,
54 Json(json!({
55 "id": id,
56 "error": error
57 })),
58 ),
59 }
60}
61
62async fn rpc_get_hint() -> impl IntoResponse {
66 (
67 StatusCode::METHOD_NOT_ALLOWED,
68 [(header::ALLOW, "POST")],
69 Json(json!({
70 "error": "POST required",
71 "hint": "Send a JSON-RPC envelope via POST. See GET /rpc/methods for available methods."
72 })),
73 )
74}
75
76async fn rpc_methods() -> impl IntoResponse {
78 Json(json!([
79 {
80 "method": "render_citation",
81 "description": "Render a single citation.",
82 "required": ["style_path", "refs", "citation"],
83 "optional": ["output_format", "inject_ast_indices"]
84 },
85 {
86 "method": "render_bibliography",
87 "description": "Render a complete bibliography.",
88 "required": ["style_path", "refs"],
89 "optional": ["output_format", "inject_ast_indices"]
90 },
91 {
92 "method": "validate_style",
93 "description": "Validate a Citum YAML style file.",
94 "required": ["style_path"],
95 "optional": []
96 },
97 {
98 "method": "format_document",
99 "description": "Format all citations and bibliography in a document.",
100 "required": ["style", "refs", "citations"],
101 "optional": ["output_format", "locale", "document_options"]
102 }
103 ]))
104}
105
106#[cfg(feature = "schema")]
107async fn rpc_schema() -> impl IntoResponse {
108 use crate::rpc::{
109 FormatDocumentParams, RenderBibliographyParams, RenderCitationParams, ValidateStyleParams,
110 };
111 use schemars::schema_for;
112
113 let schema = serde_json::json!({
114 "render_citation": schema_for!(RenderCitationParams),
115 "render_bibliography": schema_for!(RenderBibliographyParams),
116 "validate_style": schema_for!(ValidateStyleParams),
117 "format_document": schema_for!(FormatDocumentParams),
118 });
119 Json(schema)
120}
121
122pub fn app() -> Router {
124 let router = Router::new()
125 .route("/rpc", post(rpc_handler))
126 .route("/rpc", get(rpc_get_hint))
127 .route("/rpc/methods", get(rpc_methods))
128 .layer(DefaultBodyLimit::max(DEFAULT_HTTP_BODY_LIMIT_BYTES));
129
130 #[cfg(feature = "schema")]
131 let router = router.route("/rpc/schema", get(rpc_schema));
132
133 router
134}
135
136pub async fn run_http(port: u16) -> Result<(), Box<dyn std::error::Error>> {
143 let addr = SocketAddr::from(([127, 0, 0, 1], port));
144 let listener = tokio::net::TcpListener::bind(addr).await?;
145
146 eprintln!("Citum server listening on http://{addr}");
147
148 axum::serve(listener, app()).await?;
149
150 Ok(())
151}
152
153#[cfg(test)]
154#[allow(
155 clippy::unwrap_used,
156 clippy::expect_used,
157 clippy::panic,
158 clippy::indexing_slicing,
159 clippy::todo,
160 clippy::unimplemented,
161 clippy::unreachable,
162 clippy::get_unwrap,
163 reason = "Panicking is acceptable and often desired in tests."
164)]
165mod tests {
166 use super::{DEFAULT_HTTP_BODY_LIMIT_BYTES, app, rpc_handler};
167 use axum::{
168 Json,
169 body::{Body, to_bytes},
170 http::{Request, StatusCode},
171 response::IntoResponse,
172 };
173 use serde_json::json;
174 use tower::ServiceExt;
175
176 fn apa_style_path() -> String {
179 format!(
180 "{}/../../styles/embedded/apa-7th.yaml",
181 env!("CARGO_MANIFEST_DIR")
182 )
183 }
184
185 fn hawking_refs() -> serde_json::Value {
187 json!({
188 "ITEM-2": {
189 "id": "ITEM-2",
190 "class": "monograph",
191 "type": "book",
192 "title": "A Brief History of Time",
193 "author": [{"family": "Hawking", "given": "Stephen"}],
194 "issued": "1988"
195 }
196 })
197 }
198
199 async fn response_body_json(response: axum::response::Response<Body>) -> serde_json::Value {
200 let body = to_bytes(response.into_body(), usize::MAX)
201 .await
202 .expect("response body should be readable");
203 serde_json::from_slice(&body).expect("response body should be valid JSON")
204 }
205
206 #[tokio::test(flavor = "current_thread")]
207 async fn rpc_handler_render_citation_returns_ok() {
208 let payload = serde_json::from_value(json!({
209 "id": 1,
210 "method": "render_citation",
211 "params": {
212 "style_path": apa_style_path(),
213 "refs": hawking_refs(),
214 "citation": {
215 "id": "cite-1",
216 "items": [{"id": "ITEM-2"}]
217 }
218 }
219 }))
220 .expect("payload should deserialize");
221
222 let response = rpc_handler(Json(payload)).await.into_response();
223 assert_eq!(response.status(), axum::http::StatusCode::OK);
224
225 let body = response_body_json(response).await;
226 let result = body["result"].as_str().expect("result should be a string");
227 assert!(
228 result.contains("Hawking") || result.contains("1988"),
229 "citation should reference the work: {result}"
230 );
231 }
232
233 #[tokio::test(flavor = "current_thread")]
234 async fn rpc_handler_render_bibliography_html_returns_ok() {
235 let payload = serde_json::from_value(json!({
236 "id": 4,
237 "method": "render_bibliography",
238 "params": {
239 "style_path": apa_style_path(),
240 "refs": hawking_refs(),
241 "output_format": "html"
242 }
243 }))
244 .expect("payload should deserialize");
245
246 let response = rpc_handler(Json(payload)).await.into_response();
247 assert_eq!(response.status(), axum::http::StatusCode::OK);
248
249 let body = response_body_json(response).await;
250 assert_eq!(body["result"]["format"], "html");
251 let content = body["result"]["content"]
252 .as_str()
253 .expect("content should be a string");
254 assert!(
255 content.contains("citum-bibliography"),
256 "html bibliography should include wrapper markup"
257 );
258 }
259
260 #[tokio::test(flavor = "current_thread")]
261 async fn rpc_handler_unknown_method_returns_bad_request() {
262 let payload = serde_json::from_value(json!({
263 "id": 2,
264 "method": "frobnicate",
265 "params": {}
266 }))
267 .expect("payload should deserialize");
268
269 let response = rpc_handler(Json(payload)).await.into_response();
270 assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
271
272 let body = response_body_json(response).await;
273 assert_eq!(body["id"], 2);
274 assert!(
275 body["error"]
276 .as_str()
277 .expect("error should be a string")
278 .contains("unknown method")
279 );
280 }
281
282 #[tokio::test(flavor = "current_thread")]
283 async fn rpc_handler_missing_field_returns_bad_request() {
284 let payload = serde_json::from_value(json!({
285 "id": 3,
286 "method": "render_bibliography",
287 "params": {}
288 }))
289 .expect("payload should deserialize");
290
291 let response = rpc_handler(Json(payload)).await.into_response();
292 assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
293
294 let body = response_body_json(response).await;
295 assert_eq!(body["id"], 3);
296 assert!(
297 body["error"]
298 .as_str()
299 .expect("error should be a string")
300 .contains("style_path")
301 );
302 }
303
304 #[tokio::test(flavor = "current_thread")]
305 async fn app_rejects_oversized_http_request_body() {
306 let oversized = "x".repeat(DEFAULT_HTTP_BODY_LIMIT_BYTES + 1);
307 let request = Request::builder()
308 .method("POST")
309 .uri("/rpc")
310 .header("content-type", "application/json")
311 .body(Body::from(oversized))
312 .expect("request should build");
313
314 let response = app().oneshot(request).await.expect("request should run");
315
316 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
317 }
318
319 #[tokio::test(flavor = "current_thread")]
320 async fn get_rpc_returns_405_with_hint_and_allow_header() {
321 let request = Request::builder()
322 .method("GET")
323 .uri("/rpc")
324 .body(Body::empty())
325 .expect("request should build");
326
327 let response = app().oneshot(request).await.expect("request should run");
328 assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
329 assert_eq!(
330 response
331 .headers()
332 .get("allow")
333 .and_then(|v| v.to_str().ok()),
334 Some("POST"),
335 );
336
337 let body = response_body_json(response).await;
338 assert!(body["hint"].as_str().unwrap_or("").contains("POST"));
339 }
340
341 #[cfg(feature = "schema")]
342 #[tokio::test(flavor = "current_thread")]
343 async fn get_rpc_schema_returns_all_four_method_schemas() {
344 let request = Request::builder()
345 .method("GET")
346 .uri("/rpc/schema")
347 .body(Body::empty())
348 .expect("request should build");
349
350 let response = app().oneshot(request).await.expect("request should run");
351 assert_eq!(response.status(), StatusCode::OK);
352
353 let body = response_body_json(response).await;
354 assert!(
355 body["render_citation"].is_object(),
356 "render_citation schema missing"
357 );
358 assert!(
359 body["render_bibliography"].is_object(),
360 "render_bibliography schema missing"
361 );
362 assert!(
363 body["validate_style"].is_object(),
364 "validate_style schema missing"
365 );
366 assert!(
367 body["format_document"].is_object(),
368 "format_document schema missing"
369 );
370 }
371
372 #[tokio::test(flavor = "current_thread")]
373 async fn get_rpc_methods_returns_all_four_methods() {
374 let request = Request::builder()
375 .method("GET")
376 .uri("/rpc/methods")
377 .body(Body::empty())
378 .expect("request should build");
379
380 let response = app().oneshot(request).await.expect("request should run");
381 assert_eq!(response.status(), StatusCode::OK);
382
383 let body = response_body_json(response).await;
384 let methods: Vec<&str> = body
385 .as_array()
386 .expect("should be array")
387 .iter()
388 .filter_map(|m| m["method"].as_str())
389 .collect();
390 assert!(methods.contains(&"render_citation"));
391 assert!(methods.contains(&"render_bibliography"));
392 assert!(methods.contains(&"validate_style"));
393 assert!(methods.contains(&"format_document"));
394
395 let format_document = body
396 .as_array()
397 .expect("should be array")
398 .iter()
399 .find(|method| method["method"] == "format_document")
400 .expect("format_document descriptor should exist");
401 assert_eq!(
402 format_document["optional"],
403 serde_json::json!(["output_format", "locale", "document_options"])
404 );
405 }
406}