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, 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
252/// Build the HTTP router for JSON-RPC requests.
253pub 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
268/// Start the HTTP server on the given port.
269///
270/// # Errors
271///
272/// Returns an error when the socket cannot be bound or the HTTP server exits
273/// with a transport-level failure.
274pub 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    /// Absolute path to the APA style.
313    /// `CARGO_MANIFEST_DIR` is the crate root; workspace root is two levels up.
314    fn apa_style_path() -> String {
315        format!(
316            "{}/../../styles/embedded/apa-7th.yaml",
317            env!("CARGO_MANIFEST_DIR")
318        )
319    }
320
321    /// Minimal bibliography: one book (Hawking 1988) in native Citum schema format.
322    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}