Skip to main content

citum_server/
http.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Default HTTP transport for the JSON-RPC server.
7//!
8//! This module is compiled when the default-on `http` feature is enabled.
9//!
10//! The HTTP server exposes `POST /rpc` for JSON-RPC requests, `GET /rpc` as a
11//! method hint, and `GET /rpc/methods` as a static descriptor list. When the
12//! `schema` feature is enabled, `GET /rpc/schema` also exposes the schema
13//! mirror used by the HTTP API.
14//!
15//! Responses follow the same JSON-RPC shape as the stdio transport, including
16//! the document-level `format_document` result with `formatted_citations`,
17//! `bibliography`, and `warnings`.
18//!
19//! The server binds to `127.0.0.1` and is intended for local use. If you need
20//! to expose it beyond the local machine, put it behind authentication and
21//! transport security.
22//!
23//! ## Example
24//!
25//! ```text
26//! cargo run -q -p citum-server -- --http --port 9000
27//!
28//! curl -s http://localhost:9000/rpc \
29//!   -H 'Content-Type: application/json' \
30//!   -d '{"id":2,"method":"format_document","params":{"style":{"kind":"path","value":"styles/embedded/apa-7th.yaml"},"output_format":"html","refs":{"smith2010":{"id":"smith2010","class":"monograph","type":"book","title":"Nationalism: Theory, Ideology, History","author":[{"family":"Smith","given":"Anthony D."}],"issued":"2010","publisher":{"name":"Polity"}}},"citations":[{"id":"cite-1","items":[{"id":"smith2010","locator":{"label":"page","value":"10"}}]}],"document_options":{"show_semantics":true}}}'
31//! ```
32
33use 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
45/// Maximum accepted HTTP JSON-RPC request size.
46pub const DEFAULT_HTTP_BODY_LIMIT_BYTES: usize = 8 * 1024 * 1024;
47
48/// HTTP handler for JSON-RPC requests.
49/// Dispatches to the same RPC logic as stdin/stdout.
50async 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
72/// GET /rpc — returns 405 with a JSON hint about POST usage.
73///
74/// Includes `Allow: POST` as required by RFC 9110 §15.5.6.
75async 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
86/// GET /rpc/methods — returns a static descriptor list of all supported methods.
87async 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
257/// Build the HTTP router for JSON-RPC requests.
258pub 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
273/// Start the HTTP server on the given port.
274///
275/// # Errors
276///
277/// Returns an error when the socket cannot be bound or the HTTP server exits
278/// with a transport-level failure.
279pub 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    /// Absolute path to the APA style.
318    /// `CARGO_MANIFEST_DIR` is the crate root; workspace root is two levels up.
319    fn apa_style_path() -> String {
320        format!(
321            "{}/../../styles/embedded/apa-7th.yaml",
322            env!("CARGO_MANIFEST_DIR")
323        )
324    }
325
326    /// Minimal bibliography: one book (Hawking 1988) in native Citum schema format.
327    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}