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::{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
44/// Maximum accepted HTTP JSON-RPC request size.
45pub const DEFAULT_HTTP_BODY_LIMIT_BYTES: usize = 8 * 1024 * 1024;
46
47/// HTTP handler for JSON-RPC requests.
48/// Dispatches to the same RPC logic as stdin/stdout.
49async 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
62/// GET /rpc — returns 405 with a JSON hint about POST usage.
63///
64/// Includes `Allow: POST` as required by RFC 9110 §15.5.6.
65async 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
76/// GET /rpc/methods — returns a static descriptor list of all supported methods.
77async 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
122/// Build the HTTP router for JSON-RPC requests.
123pub 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
136/// Start the HTTP server on the given port.
137///
138/// # Errors
139///
140/// Returns an error when the socket cannot be bound or the HTTP server exits
141/// with a transport-level failure.
142pub 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    /// Absolute path to the APA style.
177    /// `CARGO_MANIFEST_DIR` is the crate root; workspace root is two levels up.
178    fn apa_style_path() -> String {
179        format!(
180            "{}/../../styles/embedded/apa-7th.yaml",
181            env!("CARGO_MANIFEST_DIR")
182        )
183    }
184
185    /// Minimal bibliography: one book (Hawking 1988) in native Citum schema format.
186    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}