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, UpdateCitationParams,
192 };
193 use crate::rpc::{
194 FormatDocumentParams, RenderBibliographyParams, RenderCitationParams, ValidateStyleParams,
195 };
196 use schemars::schema_for;
197
198 let mut schema = serde_json::json!({
199 "render_citation": schema_for!(RenderCitationParams),
200 "render_bibliography": schema_for!(RenderBibliographyParams),
201 "validate_style": schema_for!(ValidateStyleParams),
202 "format_document": schema_for!(FormatDocumentParams),
203 });
204 #[cfg(feature = "session")]
205 {
206 if let Some(schema) = schema.as_object_mut() {
207 schema.insert(
208 "open_session".to_string(),
209 json!(schema_for!(OpenSessionParams)),
210 );
211 schema.insert(
212 "put_references".to_string(),
213 json!(schema_for!(PutReferencesParams)),
214 );
215 schema.insert(
216 "insert_citations_batch".to_string(),
217 json!(schema_for!(InsertCitationsBatchParams)),
218 );
219 schema.insert(
220 "insert_citation".to_string(),
221 json!(schema_for!(InsertCitationParams)),
222 );
223 schema.insert(
224 "update_citation".to_string(),
225 json!(schema_for!(UpdateCitationParams)),
226 );
227 schema.insert(
228 "delete_citation".to_string(),
229 json!(schema_for!(DeleteCitationParams)),
230 );
231 schema.insert(
232 "preview_citation".to_string(),
233 json!(schema_for!(PreviewCitationParams)),
234 );
235 schema.insert(
236 "get_citations".to_string(),
237 json!(schema_for!(SessionIdParams)),
238 );
239 schema.insert(
240 "get_bibliography".to_string(),
241 json!(schema_for!(SessionIdParams)),
242 );
243 schema.insert(
244 "close_session".to_string(),
245 json!(schema_for!(SessionIdParams)),
246 );
247 }
248 }
249 Json(schema)
250}
251
252pub fn app() -> Router {
254 let dispatcher = Arc::new(Mutex::new(RpcDispatcher::new_http()));
255 let router = Router::new()
256 .route("/rpc", post(rpc_handler))
257 .route("/rpc", get(rpc_get_hint))
258 .route("/rpc/methods", get(rpc_methods))
259 .layer(DefaultBodyLimit::max(DEFAULT_HTTP_BODY_LIMIT_BYTES))
260 .with_state(dispatcher);
261
262 #[cfg(feature = "schema")]
263 let router = router.route("/rpc/schema", get(rpc_schema));
264
265 router
266}
267
268pub async fn run_http(port: u16) -> Result<(), Box<dyn std::error::Error>> {
275 let addr = SocketAddr::from(([127, 0, 0, 1], port));
276 let listener = tokio::net::TcpListener::bind(addr).await?;
277
278 eprintln!("Citum server listening on http://{addr}");
279
280 axum::serve(listener, app()).await?;
281
282 Ok(())
283}
284
285#[cfg(test)]
286#[allow(
287 clippy::unwrap_used,
288 clippy::expect_used,
289 clippy::panic,
290 clippy::indexing_slicing,
291 clippy::todo,
292 clippy::unimplemented,
293 clippy::unreachable,
294 clippy::get_unwrap,
295 reason = "Panicking is acceptable and often desired in tests."
296)]
297mod tests {
298 use super::{DEFAULT_HTTP_BODY_LIMIT_BYTES, app, rpc_handler};
299 use crate::rpc::RpcDispatcher;
300 use axum::{
301 Json,
302 body::{Body, to_bytes},
303 extract::State,
304 http::{Request, StatusCode},
305 response::IntoResponse,
306 };
307 use serde_json::json;
308 use std::panic;
309 use std::sync::{Arc, Mutex};
310 use tower::ServiceExt;
311
312 fn apa_style_path() -> String {
315 format!(
316 "{}/../../styles/embedded/apa-7th.yaml",
317 env!("CARGO_MANIFEST_DIR")
318 )
319 }
320
321 fn hawking_refs() -> serde_json::Value {
323 json!({
324 "ITEM-2": {
325 "id": "ITEM-2",
326 "class": "monograph",
327 "type": "book",
328 "title": "A Brief History of Time",
329 "author": [{"family": "Hawking", "given": "Stephen"}],
330 "issued": "1988"
331 }
332 })
333 }
334
335 async fn response_body_json(response: axum::response::Response<Body>) -> serde_json::Value {
336 let body = to_bytes(response.into_body(), usize::MAX)
337 .await
338 .expect("response body should be readable");
339 serde_json::from_slice(&body).expect("response body should be valid JSON")
340 }
341
342 fn test_dispatcher() -> State<Arc<Mutex<RpcDispatcher>>> {
343 State(Arc::new(Mutex::new(RpcDispatcher::new_http())))
344 }
345
346 #[tokio::test(flavor = "current_thread")]
347 async fn rpc_handler_poisoned_dispatcher_returns_internal_server_error() {
348 let dispatcher = Arc::new(Mutex::new(RpcDispatcher::new_http()));
349 let poisoned = Arc::clone(&dispatcher);
350 let _ = panic::catch_unwind(move || {
351 let _guard = poisoned
352 .lock()
353 .expect("dispatcher lock should be available");
354 panic!("poison dispatcher mutex");
355 });
356 let payload = serde_json::from_value(json!({
357 "id": 25,
358 "method": "validate_style",
359 "params": {
360 "style_path": apa_style_path()
361 }
362 }))
363 .expect("payload should deserialize");
364
365 let response = rpc_handler(State(dispatcher), Json(payload))
366 .await
367 .into_response();
368 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
369
370 let body = response_body_json(response).await;
371 assert_eq!(body["id"], 25);
372 assert_eq!(body["error"], "RPC dispatcher mutex poisoned");
373 }
374
375 #[tokio::test(flavor = "current_thread")]
376 async fn rpc_handler_render_citation_returns_ok() {
377 let payload = serde_json::from_value(json!({
378 "id": 1,
379 "method": "render_citation",
380 "params": {
381 "style_path": apa_style_path(),
382 "refs": hawking_refs(),
383 "citation": {
384 "id": "cite-1",
385 "items": [{"id": "ITEM-2"}]
386 }
387 }
388 }))
389 .expect("payload should deserialize");
390
391 let response = rpc_handler(test_dispatcher(), Json(payload))
392 .await
393 .into_response();
394 assert_eq!(response.status(), axum::http::StatusCode::OK);
395
396 let body = response_body_json(response).await;
397 let result = body["result"].as_str().expect("result should be a string");
398 assert!(
399 result.contains("Hawking") || result.contains("1988"),
400 "citation should reference the work: {result}"
401 );
402 }
403
404 #[tokio::test(flavor = "current_thread")]
405 async fn rpc_handler_render_bibliography_html_returns_ok() {
406 let payload = serde_json::from_value(json!({
407 "id": 4,
408 "method": "render_bibliography",
409 "params": {
410 "style_path": apa_style_path(),
411 "refs": hawking_refs(),
412 "output_format": "html"
413 }
414 }))
415 .expect("payload should deserialize");
416
417 let response = rpc_handler(test_dispatcher(), Json(payload))
418 .await
419 .into_response();
420 assert_eq!(response.status(), axum::http::StatusCode::OK);
421
422 let body = response_body_json(response).await;
423 assert_eq!(body["result"]["format"], "html");
424 let content = body["result"]["content"]
425 .as_str()
426 .expect("content should be a string");
427 assert!(
428 content.contains("citum-bibliography"),
429 "html bibliography should include wrapper markup"
430 );
431 }
432
433 #[tokio::test(flavor = "current_thread")]
434 async fn rpc_handler_unknown_method_returns_bad_request() {
435 let payload = serde_json::from_value(json!({
436 "id": 2,
437 "method": "frobnicate",
438 "params": {}
439 }))
440 .expect("payload should deserialize");
441
442 let response = rpc_handler(test_dispatcher(), Json(payload))
443 .await
444 .into_response();
445 assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
446
447 let body = response_body_json(response).await;
448 assert_eq!(body["id"], 2);
449 assert!(
450 body["error"]
451 .as_str()
452 .expect("error should be a string")
453 .contains("unknown method")
454 );
455 }
456
457 #[tokio::test(flavor = "current_thread")]
458 async fn rpc_handler_missing_field_returns_bad_request() {
459 let payload = serde_json::from_value(json!({
460 "id": 3,
461 "method": "render_bibliography",
462 "params": {}
463 }))
464 .expect("payload should deserialize");
465
466 let response = rpc_handler(test_dispatcher(), Json(payload))
467 .await
468 .into_response();
469 assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
470
471 let body = response_body_json(response).await;
472 assert_eq!(body["id"], 3);
473 assert!(
474 body["error"]
475 .as_str()
476 .expect("error should be a string")
477 .contains("style_path")
478 );
479 }
480
481 #[tokio::test(flavor = "current_thread")]
482 async fn app_rejects_oversized_http_request_body() {
483 let oversized = "x".repeat(DEFAULT_HTTP_BODY_LIMIT_BYTES + 1);
484 let request = Request::builder()
485 .method("POST")
486 .uri("/rpc")
487 .header("content-type", "application/json")
488 .body(Body::from(oversized))
489 .expect("request should build");
490
491 let response = app().oneshot(request).await.expect("request should run");
492
493 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
494 }
495
496 #[tokio::test(flavor = "current_thread")]
497 async fn get_rpc_returns_405_with_hint_and_allow_header() {
498 let request = Request::builder()
499 .method("GET")
500 .uri("/rpc")
501 .body(Body::empty())
502 .expect("request should build");
503
504 let response = app().oneshot(request).await.expect("request should run");
505 assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
506 assert_eq!(
507 response
508 .headers()
509 .get("allow")
510 .and_then(|v| v.to_str().ok()),
511 Some("POST"),
512 );
513
514 let body = response_body_json(response).await;
515 assert!(body["hint"].as_str().unwrap_or("").contains("POST"));
516 }
517
518 #[cfg(feature = "schema")]
519 #[tokio::test(flavor = "current_thread")]
520 async fn get_rpc_schema_returns_method_schemas() {
521 let request = Request::builder()
522 .method("GET")
523 .uri("/rpc/schema")
524 .body(Body::empty())
525 .expect("request should build");
526
527 let response = app().oneshot(request).await.expect("request should run");
528 assert_eq!(response.status(), StatusCode::OK);
529
530 let body = response_body_json(response).await;
531 assert!(
532 body["render_citation"].is_object(),
533 "render_citation schema missing"
534 );
535 assert!(
536 body["render_bibliography"].is_object(),
537 "render_bibliography schema missing"
538 );
539 assert!(
540 body["validate_style"].is_object(),
541 "validate_style schema missing"
542 );
543 assert!(
544 body["format_document"].is_object(),
545 "format_document schema missing"
546 );
547 #[cfg(feature = "session")]
548 {
549 assert!(
550 body["open_session"]["properties"]["style"].is_object(),
551 "open_session schema should describe style params"
552 );
553 assert!(
554 body["get_citations"]["properties"]["session_id"].is_object(),
555 "get_citations schema should describe session_id params"
556 );
557 }
558 }
559
560 #[tokio::test(flavor = "current_thread")]
561 async fn get_rpc_methods_returns_all_four_methods() {
562 let request = Request::builder()
563 .method("GET")
564 .uri("/rpc/methods")
565 .body(Body::empty())
566 .expect("request should build");
567
568 let response = app().oneshot(request).await.expect("request should run");
569 assert_eq!(response.status(), StatusCode::OK);
570
571 let body = response_body_json(response).await;
572 let methods: Vec<&str> = body
573 .as_array()
574 .expect("should be array")
575 .iter()
576 .filter_map(|m| m["method"].as_str())
577 .collect();
578 assert!(methods.contains(&"render_citation"));
579 assert!(methods.contains(&"render_bibliography"));
580 assert!(methods.contains(&"validate_style"));
581 assert!(methods.contains(&"format_document"));
582
583 let format_document = body
584 .as_array()
585 .expect("should be array")
586 .iter()
587 .find(|method| method["method"] == "format_document")
588 .expect("format_document descriptor should exist");
589 assert_eq!(
590 format_document["optional"],
591 serde_json::json!(["output_format", "locale", "document_options"])
592 );
593 }
594}