1use crate::rpc::{RpcDispatcher, RpcRequest, error_response};
34use axum::{
35 Json, Router,
36 extract::{DefaultBodyLimit, State},
37 http::{StatusCode, header},
38 response::IntoResponse,
39 routing::{get, post},
40};
41use serde_json::json;
42use std::net::SocketAddr;
43use std::sync::{Arc, Mutex};
44
45pub const DEFAULT_HTTP_BODY_LIMIT_BYTES: usize = 8 * 1024 * 1024;
47
48async fn rpc_handler(
51 State(dispatcher): State<Arc<Mutex<RpcDispatcher>>>,
52 Json(payload): Json<RpcRequest>,
53) -> impl IntoResponse {
54 let result = match dispatcher.lock() {
55 Ok(mut dispatcher) => dispatcher
56 .dispatch(payload.clone())
57 .map_err(|(id, error)| (StatusCode::BAD_REQUEST, error_response(id, error))),
58 Err(_) => Err((
59 StatusCode::INTERNAL_SERVER_ERROR,
60 json!({
61 "id": payload.id,
62 "error": "RPC dispatcher mutex poisoned"
63 }),
64 )),
65 };
66 match result {
67 Ok(result) => (StatusCode::OK, Json(result)),
68 Err((status, error)) => (status, Json(error)),
69 }
70}
71
72async fn rpc_get_hint() -> impl IntoResponse {
76 (
77 StatusCode::METHOD_NOT_ALLOWED,
78 [(header::ALLOW, "POST")],
79 Json(json!({
80 "error": "POST required",
81 "hint": "Send a JSON-RPC envelope via POST. See GET /rpc/methods for available methods."
82 })),
83 )
84}
85
86async fn rpc_methods() -> impl IntoResponse {
88 let methods = vec![
89 json!({
90 "method": "render_citation",
91 "description": "Render a single citation.",
92 "required": ["style_path", "refs", "citation"],
93 "optional": ["output_format", "inject_ast_indices"]
94 }),
95 json!({
96 "method": "render_bibliography",
97 "description": "Render a complete bibliography.",
98 "required": ["style_path", "refs"],
99 "optional": ["output_format", "inject_ast_indices"]
100 }),
101 json!({
102 "method": "validate_style",
103 "description": "Validate a Citum YAML style file.",
104 "required": ["style_path"],
105 "optional": []
106 }),
107 json!({
108 "method": "format_document",
109 "description": "Format all citations and bibliography in a document.",
110 "required": ["style", "refs", "citations"],
111 "optional": ["output_format", "locale", "document_options"]
112 }),
113 ];
114
115 #[cfg(feature = "session")]
116 let methods = {
117 let mut methods = methods;
118 methods.extend([
119 json!({
120 "method": "open_session",
121 "description": "Open a stateful document session.",
122 "required": ["style"],
123 "optional": ["output_format", "locale", "document_options"]
124 }),
125 json!({
126 "method": "put_references",
127 "description": "Replace the full reference set for a session.",
128 "required": ["session_id", "refs"],
129 "optional": []
130 }),
131 json!({
132 "method": "insert_citations_batch",
133 "description": "Replace the full ordered citation list for a session.",
134 "required": ["session_id", "citations"],
135 "optional": []
136 }),
137 json!({
138 "method": "insert_citation",
139 "description": "Insert one citation into a session.",
140 "required": ["session_id", "citation"],
141 "optional": ["position"]
142 }),
143 json!({
144 "method": "update_citation",
145 "description": "Update one citation in a session.",
146 "required": ["session_id", "citation_id", "citation"],
147 "optional": ["position"]
148 }),
149 json!({
150 "method": "delete_citation",
151 "description": "Delete one citation from a session.",
152 "required": ["session_id", "citation_id"],
153 "optional": []
154 }),
155 json!({
156 "method": "preview_citation",
157 "description": "Render a citation preview without mutating session state.",
158 "required": ["session_id", "items"],
159 "optional": ["position"]
160 }),
161 json!({
162 "method": "get_citations",
163 "description": "Return current formatted citations for a session.",
164 "required": ["session_id"],
165 "optional": []
166 }),
167 json!({
168 "method": "get_bibliography",
169 "description": "Return current bibliography for a session.",
170 "required": ["session_id"],
171 "optional": []
172 }),
173 json!({
174 "method": "close_session",
175 "description": "Close and free a session.",
176 "required": ["session_id"],
177 "optional": []
178 }),
179 ]);
180 methods
181 };
182
183 Json(json!(methods))
184}
185
186#[cfg(feature = "schema")]
187async fn rpc_schema() -> impl IntoResponse {
188 #[cfg(feature = "session")]
189 use crate::rpc::{
190 DeleteCitationParams, InsertCitationParams, InsertCitationsBatchParams, OpenSessionParams,
191 PreviewCitationParams, PutReferencesParams, SessionIdParams, SetNociteParams,
192 UpdateCitationParams,
193 };
194 use crate::rpc::{
195 FormatDocumentParams, RenderBibliographyParams, RenderCitationParams, ValidateStyleParams,
196 };
197 use schemars::schema_for;
198
199 let mut schema = serde_json::json!({
200 "render_citation": schema_for!(RenderCitationParams),
201 "render_bibliography": schema_for!(RenderBibliographyParams),
202 "validate_style": schema_for!(ValidateStyleParams),
203 "format_document": schema_for!(FormatDocumentParams),
204 });
205 #[cfg(feature = "session")]
206 {
207 if let Some(schema) = schema.as_object_mut() {
208 schema.insert(
209 "open_session".to_string(),
210 json!(schema_for!(OpenSessionParams)),
211 );
212 schema.insert(
213 "put_references".to_string(),
214 json!(schema_for!(PutReferencesParams)),
215 );
216 schema.insert(
217 "set_nocite".to_string(),
218 json!(schema_for!(SetNociteParams)),
219 );
220 schema.insert(
221 "insert_citations_batch".to_string(),
222 json!(schema_for!(InsertCitationsBatchParams)),
223 );
224 schema.insert(
225 "insert_citation".to_string(),
226 json!(schema_for!(InsertCitationParams)),
227 );
228 schema.insert(
229 "update_citation".to_string(),
230 json!(schema_for!(UpdateCitationParams)),
231 );
232 schema.insert(
233 "delete_citation".to_string(),
234 json!(schema_for!(DeleteCitationParams)),
235 );
236 schema.insert(
237 "preview_citation".to_string(),
238 json!(schema_for!(PreviewCitationParams)),
239 );
240 schema.insert(
241 "get_citations".to_string(),
242 json!(schema_for!(SessionIdParams)),
243 );
244 schema.insert(
245 "get_bibliography".to_string(),
246 json!(schema_for!(SessionIdParams)),
247 );
248 schema.insert(
249 "close_session".to_string(),
250 json!(schema_for!(SessionIdParams)),
251 );
252 }
253 }
254 Json(schema)
255}
256
257pub fn app() -> Router {
259 let dispatcher = Arc::new(Mutex::new(RpcDispatcher::new_http()));
260 let router = Router::new()
261 .route("/rpc", post(rpc_handler))
262 .route("/rpc", get(rpc_get_hint))
263 .route("/rpc/methods", get(rpc_methods))
264 .layer(DefaultBodyLimit::max(DEFAULT_HTTP_BODY_LIMIT_BYTES))
265 .with_state(dispatcher);
266
267 #[cfg(feature = "schema")]
268 let router = router.route("/rpc/schema", get(rpc_schema));
269
270 router
271}
272
273pub async fn run_http(port: u16) -> Result<(), Box<dyn std::error::Error>> {
280 let addr = SocketAddr::from(([127, 0, 0, 1], port));
281 let listener = tokio::net::TcpListener::bind(addr).await?;
282
283 eprintln!("Citum server listening on http://{addr}");
284
285 axum::serve(listener, app()).await?;
286
287 Ok(())
288}
289
290#[cfg(test)]
291#[allow(
292 clippy::unwrap_used,
293 clippy::expect_used,
294 clippy::panic,
295 clippy::indexing_slicing,
296 clippy::todo,
297 clippy::unimplemented,
298 clippy::unreachable,
299 clippy::get_unwrap,
300 reason = "Panicking is acceptable and often desired in tests."
301)]
302mod tests {
303 use super::{DEFAULT_HTTP_BODY_LIMIT_BYTES, app, rpc_handler};
304 use crate::rpc::RpcDispatcher;
305 use axum::{
306 Json,
307 body::{Body, to_bytes},
308 extract::State,
309 http::{Request, StatusCode},
310 response::IntoResponse,
311 };
312 use serde_json::json;
313 use std::panic;
314 use std::sync::{Arc, Mutex};
315 use tower::ServiceExt;
316
317 fn apa_style_path() -> String {
320 format!(
321 "{}/../../styles/embedded/apa-7th.yaml",
322 env!("CARGO_MANIFEST_DIR")
323 )
324 }
325
326 fn hawking_refs() -> serde_json::Value {
328 json!({
329 "ITEM-2": {
330 "id": "ITEM-2",
331 "class": "monograph",
332 "type": "book",
333 "title": "A Brief History of Time",
334 "author": [{"family": "Hawking", "given": "Stephen"}],
335 "issued": "1988"
336 }
337 })
338 }
339
340 async fn response_body_json(response: axum::response::Response<Body>) -> serde_json::Value {
341 let body = to_bytes(response.into_body(), usize::MAX)
342 .await
343 .expect("response body should be readable");
344 serde_json::from_slice(&body).expect("response body should be valid JSON")
345 }
346
347 fn test_dispatcher() -> State<Arc<Mutex<RpcDispatcher>>> {
348 State(Arc::new(Mutex::new(RpcDispatcher::new_http())))
349 }
350
351 #[tokio::test(flavor = "current_thread")]
352 async fn rpc_handler_poisoned_dispatcher_returns_internal_server_error() {
353 let dispatcher = Arc::new(Mutex::new(RpcDispatcher::new_http()));
354 let poisoned = Arc::clone(&dispatcher);
355 let _ = panic::catch_unwind(move || {
356 let _guard = poisoned
357 .lock()
358 .expect("dispatcher lock should be available");
359 panic!("poison dispatcher mutex");
360 });
361 let payload = serde_json::from_value(json!({
362 "id": 25,
363 "method": "validate_style",
364 "params": {
365 "style_path": apa_style_path()
366 }
367 }))
368 .expect("payload should deserialize");
369
370 let response = rpc_handler(State(dispatcher), Json(payload))
371 .await
372 .into_response();
373 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
374
375 let body = response_body_json(response).await;
376 assert_eq!(body["id"], 25);
377 assert_eq!(body["error"], "RPC dispatcher mutex poisoned");
378 }
379
380 #[tokio::test(flavor = "current_thread")]
381 async fn rpc_handler_render_citation_returns_ok() {
382 let payload = serde_json::from_value(json!({
383 "id": 1,
384 "method": "render_citation",
385 "params": {
386 "style_path": apa_style_path(),
387 "refs": hawking_refs(),
388 "citation": {
389 "id": "cite-1",
390 "items": [{"id": "ITEM-2"}]
391 }
392 }
393 }))
394 .expect("payload should deserialize");
395
396 let response = rpc_handler(test_dispatcher(), Json(payload))
397 .await
398 .into_response();
399 assert_eq!(response.status(), axum::http::StatusCode::OK);
400
401 let body = response_body_json(response).await;
402 let result = body["result"].as_str().expect("result should be a string");
403 assert!(
404 result.contains("Hawking") || result.contains("1988"),
405 "citation should reference the work: {result}"
406 );
407 }
408
409 #[tokio::test(flavor = "current_thread")]
410 async fn rpc_handler_render_bibliography_html_returns_ok() {
411 let payload = serde_json::from_value(json!({
412 "id": 4,
413 "method": "render_bibliography",
414 "params": {
415 "style_path": apa_style_path(),
416 "refs": hawking_refs(),
417 "output_format": "html"
418 }
419 }))
420 .expect("payload should deserialize");
421
422 let response = rpc_handler(test_dispatcher(), Json(payload))
423 .await
424 .into_response();
425 assert_eq!(response.status(), axum::http::StatusCode::OK);
426
427 let body = response_body_json(response).await;
428 assert_eq!(body["result"]["format"], "html");
429 let content = body["result"]["content"]
430 .as_str()
431 .expect("content should be a string");
432 assert!(
433 content.contains("citum-bibliography"),
434 "html bibliography should include wrapper markup"
435 );
436 }
437
438 #[tokio::test(flavor = "current_thread")]
439 async fn rpc_handler_unknown_method_returns_bad_request() {
440 let payload = serde_json::from_value(json!({
441 "id": 2,
442 "method": "frobnicate",
443 "params": {}
444 }))
445 .expect("payload should deserialize");
446
447 let response = rpc_handler(test_dispatcher(), Json(payload))
448 .await
449 .into_response();
450 assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
451
452 let body = response_body_json(response).await;
453 assert_eq!(body["id"], 2);
454 assert!(
455 body["error"]
456 .as_str()
457 .expect("error should be a string")
458 .contains("unknown method")
459 );
460 }
461
462 #[tokio::test(flavor = "current_thread")]
463 async fn rpc_handler_missing_field_returns_bad_request() {
464 let payload = serde_json::from_value(json!({
465 "id": 3,
466 "method": "render_bibliography",
467 "params": {}
468 }))
469 .expect("payload should deserialize");
470
471 let response = rpc_handler(test_dispatcher(), Json(payload))
472 .await
473 .into_response();
474 assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
475
476 let body = response_body_json(response).await;
477 assert_eq!(body["id"], 3);
478 assert!(
479 body["error"]
480 .as_str()
481 .expect("error should be a string")
482 .contains("style_path")
483 );
484 }
485
486 #[tokio::test(flavor = "current_thread")]
487 async fn app_rejects_oversized_http_request_body() {
488 let oversized = "x".repeat(DEFAULT_HTTP_BODY_LIMIT_BYTES + 1);
489 let request = Request::builder()
490 .method("POST")
491 .uri("/rpc")
492 .header("content-type", "application/json")
493 .body(Body::from(oversized))
494 .expect("request should build");
495
496 let response = app().oneshot(request).await.expect("request should run");
497
498 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
499 }
500
501 #[tokio::test(flavor = "current_thread")]
502 async fn get_rpc_returns_405_with_hint_and_allow_header() {
503 let request = Request::builder()
504 .method("GET")
505 .uri("/rpc")
506 .body(Body::empty())
507 .expect("request should build");
508
509 let response = app().oneshot(request).await.expect("request should run");
510 assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
511 assert_eq!(
512 response
513 .headers()
514 .get("allow")
515 .and_then(|v| v.to_str().ok()),
516 Some("POST"),
517 );
518
519 let body = response_body_json(response).await;
520 assert!(body["hint"].as_str().unwrap_or("").contains("POST"));
521 }
522
523 #[cfg(feature = "schema")]
524 #[tokio::test(flavor = "current_thread")]
525 async fn get_rpc_schema_returns_method_schemas() {
526 let request = Request::builder()
527 .method("GET")
528 .uri("/rpc/schema")
529 .body(Body::empty())
530 .expect("request should build");
531
532 let response = app().oneshot(request).await.expect("request should run");
533 assert_eq!(response.status(), StatusCode::OK);
534
535 let body = response_body_json(response).await;
536 assert!(
537 body["render_citation"].is_object(),
538 "render_citation schema missing"
539 );
540 assert!(
541 body["render_bibliography"].is_object(),
542 "render_bibliography schema missing"
543 );
544 assert!(
545 body["validate_style"].is_object(),
546 "validate_style schema missing"
547 );
548 assert!(
549 body["format_document"].is_object(),
550 "format_document schema missing"
551 );
552 #[cfg(feature = "session")]
553 {
554 assert!(
555 body["open_session"]["properties"]["style"].is_object(),
556 "open_session schema should describe style params"
557 );
558 assert!(
559 body["get_citations"]["properties"]["session_id"].is_object(),
560 "get_citations schema should describe session_id params"
561 );
562 }
563 }
564
565 #[tokio::test(flavor = "current_thread")]
566 async fn get_rpc_methods_returns_all_four_methods() {
567 let request = Request::builder()
568 .method("GET")
569 .uri("/rpc/methods")
570 .body(Body::empty())
571 .expect("request should build");
572
573 let response = app().oneshot(request).await.expect("request should run");
574 assert_eq!(response.status(), StatusCode::OK);
575
576 let body = response_body_json(response).await;
577 let methods: Vec<&str> = body
578 .as_array()
579 .expect("should be array")
580 .iter()
581 .filter_map(|m| m["method"].as_str())
582 .collect();
583 assert!(methods.contains(&"render_citation"));
584 assert!(methods.contains(&"render_bibliography"));
585 assert!(methods.contains(&"validate_style"));
586 assert!(methods.contains(&"format_document"));
587
588 let format_document = body
589 .as_array()
590 .expect("should be array")
591 .iter()
592 .find(|method| method["method"] == "format_document")
593 .expect("format_document descriptor should exist");
594 assert_eq!(
595 format_document["optional"],
596 serde_json::json!(["output_format", "locale", "document_options"])
597 );
598 }
599}