Skip to main content

churust_openapi/
lib.rs

1//! OpenAPI 3.1 descriptions for [Churust] applications.
2//!
3//! The router already knows every path and method that exists. This crate turns
4//! that inventory into an OpenAPI document, and gives you somewhere to hang the
5//! parts a router cannot know: what an operation is for, what it accepts, and
6//! what it answers with.
7//!
8//! ```
9//! use churust_core::{Call, Churust, TestClient};
10//! use churust_openapi::{OpenApi, Operation};
11//! use http::Method;
12//!
13//! # tokio::runtime::Runtime::new().unwrap().block_on(async {
14//! // 1. Register the application's own routes.
15//! let builder = Churust::server().routing(|r| {
16//!     r.get("/users/{id}", |_c: Call| async { "a user" });
17//!     r.post("/users", |_c: Call| async { "created" });
18//! });
19//!
20//! // 2. Describe them. Paths and parameters come from the router; the prose
21//! //    and the responses come from you.
22//! let api = OpenApi::new("Users API", "1.0.0")
23//!     .from_routes(builder.routes())
24//!     .operation(
25//!         Method::GET,
26//!         "/users/{id}",
27//!         Operation::new()
28//!             .summary("Fetch one user")
29//!             .response(200, "The user")
30//!             .response(404, "No such user"),
31//!     );
32//!
33//! // 3. Serve it.
34//! let app = builder.routing(|r| api.mount(r, "/openapi.json")).build();
35//!
36//! let res = TestClient::new(app).get("/openapi.json").send().await;
37//! assert_eq!(res.header("content-type"), Some("application/json"));
38//! assert!(res.text().contains("Fetch one user"));
39//! # });
40//! ```
41//!
42//! # What is generated and what is not
43//!
44//! Generated from the router: every path, every method on it, and a path
45//! parameter for each `{name}` in the pattern. Those cannot drift from the
46//! application, because they *are* the application.
47//!
48//! Not generated: request and response schemas. Churust handlers are ordinary
49//! Rust functions and their extractor types are erased by the time a router
50//! exists, so anything claiming to infer a schema here would be inferring it
51//! from an annotation you wrote anyway. Describing an operation is explicit,
52//! and the [`schema`](Operation::schema) helpers take a `serde_json::Value` so
53//! a schema derived by any other crate drops straight in.
54//!
55//! # Drift
56//!
57//! A description of a route that no longer exists is worse than no description
58//! at all. [`undescribed`](OpenApi::undescribed) and
59//! [`stale`](OpenApi::stale) report both directions of drift, so a test can
60//! fail the build when they disagree rather than a client finding out.
61//!
62//! [Churust]: churust_core::Churust
63
64#![deny(missing_docs)]
65
66use churust_core::{Call, IntoResponse, Response, RouteBuilder};
67use http::header::CONTENT_TYPE;
68use http::{HeaderValue, Method};
69use serde_json::{json, Map, Value};
70use std::collections::BTreeMap;
71
72/// One documented operation: a method on a path.
73///
74/// Everything is optional. An operation with nothing set still appears in the
75/// document, which is the point of seeding from the router: an undocumented
76/// endpoint shows up as an endpoint rather than as nothing.
77#[derive(Debug, Clone, Default)]
78pub struct Operation {
79    summary: Option<String>,
80    description: Option<String>,
81    operation_id: Option<String>,
82    tags: Vec<String>,
83    deprecated: bool,
84    request_body: Option<(String, Option<Value>, bool)>,
85    responses: BTreeMap<u16, (String, Option<Value>)>,
86    query_params: Vec<(String, bool, Option<String>)>,
87}
88
89impl Operation {
90    /// An operation with nothing described yet.
91    pub fn new() -> Self {
92        Self::default()
93    }
94
95    /// A one-line summary, shown as the operation's title in most viewers.
96    pub fn summary(mut self, text: impl Into<String>) -> Self {
97        self.summary = Some(text.into());
98        self
99    }
100
101    /// A longer description. CommonMark is allowed by the specification.
102    pub fn description(mut self, text: impl Into<String>) -> Self {
103        self.description = Some(text.into());
104        self
105    }
106
107    /// An explicit `operationId`.
108    ///
109    /// Client generators use it for the generated method name. Left unset, one
110    /// is derived from the method and path, which is stable as long as the
111    /// route is.
112    pub fn operation_id(mut self, id: impl Into<String>) -> Self {
113        self.operation_id = Some(id.into());
114        self
115    }
116
117    /// Group the operation under a tag.
118    pub fn tag(mut self, tag: impl Into<String>) -> Self {
119        self.tags.push(tag.into());
120        self
121    }
122
123    /// Mark the operation deprecated.
124    pub fn deprecated(mut self) -> Self {
125        self.deprecated = true;
126        self
127    }
128
129    /// Document a query parameter.
130    pub fn query(mut self, name: impl Into<String>, required: bool) -> Self {
131        self.query_params.push((name.into(), required, None));
132        self
133    }
134
135    /// Document a query parameter with a description.
136    pub fn query_described(
137        mut self,
138        name: impl Into<String>,
139        required: bool,
140        description: impl Into<String>,
141    ) -> Self {
142        self.query_params
143            .push((name.into(), required, Some(description.into())));
144        self
145    }
146
147    /// Document the request body's media type, with no schema.
148    pub fn accepts(mut self, media_type: impl Into<String>) -> Self {
149        self.request_body = Some((media_type.into(), None, true));
150        self
151    }
152
153    /// Document the request body with a JSON Schema.
154    ///
155    /// The schema is any `serde_json::Value`, so one produced by `schemars` or
156    /// written by hand fits equally well.
157    pub fn schema(mut self, media_type: impl Into<String>, schema: Value) -> Self {
158        self.request_body = Some((media_type.into(), Some(schema), true));
159        self
160    }
161
162    /// Document a response status and what it means.
163    pub fn response(mut self, status: u16, description: impl Into<String>) -> Self {
164        self.responses.insert(status, (description.into(), None));
165        self
166    }
167
168    /// Document a response with a JSON Schema for its body.
169    pub fn response_schema(
170        mut self,
171        status: u16,
172        description: impl Into<String>,
173        schema: Value,
174    ) -> Self {
175        self.responses
176            .insert(status, (description.into(), Some(schema)));
177        self
178    }
179}
180
181/// An OpenAPI 3.1 document under construction.
182#[derive(Debug, Clone)]
183pub struct OpenApi {
184    title: String,
185    version: String,
186    description: Option<String>,
187    servers: Vec<(String, Option<String>)>,
188    /// Registered routes, in the order the router saw them.
189    routes: Vec<(Method, String)>,
190    /// Descriptions, keyed by the route they describe.
191    operations: BTreeMap<(String, String), Operation>,
192}
193
194impl OpenApi {
195    /// A document for an API with this title and version.
196    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
197        Self {
198            title: title.into(),
199            version: version.into(),
200            description: None,
201            servers: Vec::new(),
202            routes: Vec::new(),
203            operations: BTreeMap::new(),
204        }
205    }
206
207    /// Describe the API as a whole.
208    pub fn description(mut self, text: impl Into<String>) -> Self {
209        self.description = Some(text.into());
210        self
211    }
212
213    /// Add a server URL clients should send requests to.
214    pub fn server(mut self, url: impl Into<String>, description: Option<&str>) -> Self {
215        self.servers
216            .push((url.into(), description.map(str::to_string)));
217        self
218    }
219
220    /// Seed the document from the router's inventory.
221    ///
222    /// Pass [`AppBuilder::routes`](churust_core::AppBuilder::routes) after the
223    /// application's own routes are registered. Every route becomes an
224    /// operation, whether or not it is described further.
225    pub fn from_routes(mut self, routes: &[(Method, String)]) -> Self {
226        for (method, path) in routes {
227            // `HEAD` and `OPTIONS` are answered automatically by the engine for
228            // every route, so listing them would describe the framework rather
229            // than the API.
230            if method == Method::HEAD || method == Method::OPTIONS {
231                continue;
232            }
233            if !self
234                .routes
235                .iter()
236                .any(|r| r == &(method.clone(), path.clone()))
237            {
238                self.routes.push((method.clone(), path.clone()));
239            }
240        }
241        self
242    }
243
244    /// Attach a description to one route.
245    ///
246    /// Describing a route that was never registered is allowed while building,
247    /// and reported by [`stale`](Self::stale) rather than refused here, so a
248    /// document can be assembled in any order.
249    pub fn operation(mut self, method: Method, path: &str, operation: Operation) -> Self {
250        self.operations
251            .insert((path.to_string(), method.as_str().to_string()), operation);
252        self
253    }
254
255    /// Registered routes with no description attached.
256    ///
257    /// ```
258    /// use churust_core::{Call, Churust};
259    /// use churust_openapi::OpenApi;
260    /// use http::Method;
261    ///
262    /// let builder = Churust::server().routing(|r| {
263    ///     r.get("/health", |_c: Call| async { "ok" });
264    /// });
265    /// let api = OpenApi::new("API", "1.0").from_routes(builder.routes());
266    /// assert_eq!(api.undescribed(), vec![(Method::GET, "/health".to_string())]);
267    /// ```
268    pub fn undescribed(&self) -> Vec<(Method, String)> {
269        self.routes
270            .iter()
271            .filter(|(method, path)| {
272                !self
273                    .operations
274                    .contains_key(&(path.clone(), method.as_str().to_string()))
275            })
276            .cloned()
277            .collect()
278    }
279
280    /// Descriptions whose route no longer exists.
281    ///
282    /// The direction of drift that actually misleads a client: a documented
283    /// endpoint that answers `404`.
284    pub fn stale(&self) -> Vec<(Method, String)> {
285        self.operations
286            .keys()
287            .filter(|(path, method)| {
288                !self
289                    .routes
290                    .iter()
291                    .any(|(m, p)| m.as_str() == method && p == path)
292            })
293            .filter_map(|(path, method)| {
294                Method::from_bytes(method.as_bytes())
295                    .ok()
296                    .map(|m| (m, path.clone()))
297            })
298            .collect()
299    }
300
301    /// Render the document as JSON.
302    pub fn to_value(&self) -> Value {
303        let mut paths = Map::new();
304
305        for (method, path) in &self.routes {
306            let documented_path = openapi_path(path);
307            let entry = paths
308                .entry(documented_path.clone())
309                .or_insert_with(|| json!({}));
310            let Some(item) = entry.as_object_mut() else {
311                continue;
312            };
313
314            let described = self
315                .operations
316                .get(&(path.clone(), method.as_str().to_string()));
317            item.insert(
318                method.as_str().to_ascii_lowercase(),
319                self.operation_value(method, path, described),
320            );
321        }
322
323        let mut info = json!({
324            "title": self.title,
325            "version": self.version,
326        });
327        if let (Some(description), Some(map)) = (&self.description, info.as_object_mut()) {
328            map.insert("description".into(), json!(description));
329        }
330
331        let mut doc = json!({
332            // 3.1.0 rather than 3.0.x: it is the first version whose schema
333            // dialect is plain JSON Schema, which is what makes a schema from
334            // any other crate usable here without translation.
335            "openapi": "3.1.0",
336            "info": info,
337            "paths": paths,
338        });
339
340        if !self.servers.is_empty() {
341            let servers: Vec<Value> = self
342                .servers
343                .iter()
344                .map(|(url, description)| match description {
345                    Some(d) => json!({ "url": url, "description": d }),
346                    None => json!({ "url": url }),
347                })
348                .collect();
349            if let Some(map) = doc.as_object_mut() {
350                map.insert("servers".into(), json!(servers));
351            }
352        }
353
354        doc
355    }
356
357    /// One operation object.
358    fn operation_value(&self, method: &Method, path: &str, described: Option<&Operation>) -> Value {
359        let mut out = Map::new();
360        let default = Operation::default();
361        let op = described.unwrap_or(&default);
362
363        out.insert(
364            "operationId".into(),
365            json!(op
366                .operation_id
367                .clone()
368                .unwrap_or_else(|| derive_operation_id(method, path))),
369        );
370        if let Some(summary) = &op.summary {
371            out.insert("summary".into(), json!(summary));
372        }
373        if let Some(description) = &op.description {
374            out.insert("description".into(), json!(description));
375        }
376        if !op.tags.is_empty() {
377            out.insert("tags".into(), json!(op.tags));
378        }
379        if op.deprecated {
380            out.insert("deprecated".into(), json!(true));
381        }
382
383        let mut parameters: Vec<Value> = path_parameters(path);
384        for (name, required, description) in &op.query_params {
385            let mut param = json!({
386                "name": name,
387                "in": "query",
388                "required": required,
389                "schema": { "type": "string" },
390            });
391            if let (Some(d), Some(map)) = (description, param.as_object_mut()) {
392                map.insert("description".into(), json!(d));
393            }
394            parameters.push(param);
395        }
396        if !parameters.is_empty() {
397            out.insert("parameters".into(), json!(parameters));
398        }
399
400        if let Some((media_type, schema, required)) = &op.request_body {
401            let content = match schema {
402                Some(schema) => json!({ media_type.clone(): { "schema": schema } }),
403                None => json!({ media_type.clone(): {} }),
404            };
405            out.insert(
406                "requestBody".into(),
407                json!({ "required": required, "content": content }),
408            );
409        }
410
411        let mut responses = Map::new();
412        if op.responses.is_empty() {
413            // Every operation must carry at least one response. An undescribed
414            // one says only that it answers, which is true and is better than
415            // a document that fails validation.
416            responses.insert(
417                "default".into(),
418                json!({ "description": "Undocumented response" }),
419            );
420        }
421        for (status, (description, schema)) in &op.responses {
422            let value = match schema {
423                Some(schema) => json!({
424                    "description": description,
425                    "content": { "application/json": { "schema": schema } },
426                }),
427                None => json!({ "description": description }),
428            };
429            responses.insert(status.to_string(), value);
430        }
431        out.insert("responses".into(), json!(responses));
432
433        Value::Object(out)
434    }
435
436    /// Register a route serving this document as JSON.
437    ///
438    /// Call it inside a [`routing`](churust_core::AppBuilder::routing) block
439    /// *after* the routes being described, so the inventory is complete:
440    ///
441    /// ```
442    /// # use churust_core::{Call, Churust};
443    /// # use churust_openapi::OpenApi;
444    /// let builder = Churust::server().routing(|r| {
445    ///     r.get("/health", |_c: Call| async { "ok" });
446    /// });
447    /// let api = OpenApi::new("API", "1.0").from_routes(builder.routes());
448    /// let app = builder.routing(|r| api.mount(r, "/openapi.json")).build();
449    /// # let _ = app;
450    /// ```
451    ///
452    /// The document is rendered once, here, rather than per request: it cannot
453    /// change after the router is built.
454    pub fn mount(&self, routes: &mut RouteBuilder, path: &str) {
455        let rendered = self.to_value().to_string();
456        routes.get(path, move |_c: Call| {
457            let body = rendered.clone();
458            async move { JsonDocument(body) }
459        });
460    }
461}
462
463/// A pre-rendered JSON body.
464///
465/// A newtype rather than `Response::bytes`, because the content type has to be
466/// `application/json` and `Response::bytes` takes a `&'static str` that a
467/// runtime-built string cannot satisfy.
468struct JsonDocument(String);
469
470impl IntoResponse for JsonDocument {
471    fn into_response(self) -> Response {
472        let mut res = Response::text(self.0);
473        res.headers
474            .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
475        res
476    }
477}
478
479/// Convert a Churust pattern to an OpenAPI path template.
480///
481/// `{id}` is already the OpenAPI spelling. A trailing `{rest...}` has no
482/// OpenAPI equivalent, since the specification has no notion of a parameter
483/// that spans slashes, so it is written as an ordinary `{rest}` and documented
484/// as covering the remaining path.
485fn openapi_path(pattern: &str) -> String {
486    pattern.replace("...}", "}")
487}
488
489/// The path parameters implied by a pattern.
490fn path_parameters(pattern: &str) -> Vec<Value> {
491    let mut out = Vec::new();
492    for segment in pattern.split('/') {
493        let Some(inner) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) else {
494            continue;
495        };
496        let (name, spans_slashes) = match inner.strip_suffix("...") {
497            Some(name) => (name, true),
498            None => (inner, false),
499        };
500        let mut param = json!({
501            "name": name,
502            "in": "path",
503            // A path parameter is required by definition: without it the path
504            // is a different path.
505            "required": true,
506            "schema": { "type": "string" },
507        });
508        if spans_slashes {
509            if let Some(map) = param.as_object_mut() {
510                map.insert(
511                    "description".into(),
512                    json!("Matches the remaining path, including slashes."),
513                );
514            }
515        }
516        out.push(param);
517    }
518    out
519}
520
521/// A stable `operationId` from the method and path.
522///
523/// `GET /users/{id}` becomes `getUsersById`. Derived rather than random so the
524/// same route yields the same identifier on every build, which is what stops a
525/// generated client from churning.
526fn derive_operation_id(method: &Method, path: &str) -> String {
527    let mut out = method.as_str().to_ascii_lowercase();
528    for segment in path.split('/').filter(|s| !s.is_empty()) {
529        match segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
530            Some(param) => {
531                let name = param.trim_end_matches("...");
532                out.push_str("By");
533                out.push_str(&capitalize(name));
534            }
535            None => out.push_str(&capitalize(segment)),
536        }
537    }
538    out
539}
540
541/// Upper-case the first character, leaving the rest alone.
542fn capitalize(s: &str) -> String {
543    let mut chars = s.chars();
544    match chars.next() {
545        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
546        None => String::new(),
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    fn routes() -> Vec<(Method, String)> {
555        vec![
556            (Method::GET, "/users/{id}".to_string()),
557            (Method::POST, "/users".to_string()),
558            (Method::GET, "/files/{path...}".to_string()),
559        ]
560    }
561
562    #[test]
563    fn every_route_becomes_an_operation() {
564        let doc = OpenApi::new("API", "1.0").from_routes(&routes()).to_value();
565        let paths = doc["paths"].as_object().unwrap();
566
567        assert!(paths.contains_key("/users/{id}"));
568        assert!(paths.contains_key("/users"));
569        assert!(paths["/users/{id}"].get("get").is_some());
570        assert!(paths["/users"].get("post").is_some());
571    }
572
573    #[test]
574    fn a_wildcard_becomes_an_ordinary_parameter() {
575        let doc = OpenApi::new("API", "1.0").from_routes(&routes()).to_value();
576        let paths = doc["paths"].as_object().unwrap();
577
578        assert!(
579            paths.contains_key("/files/{path}"),
580            "OpenAPI has no slash-spanning parameter: {:?}",
581            paths.keys().collect::<Vec<_>>()
582        );
583        let params = paths["/files/{path}"]["get"]["parameters"]
584            .as_array()
585            .unwrap();
586        assert_eq!(params[0]["name"], "path");
587        assert!(params[0]["description"]
588            .as_str()
589            .unwrap()
590            .contains("including slashes"));
591    }
592
593    #[test]
594    fn path_parameters_are_derived_and_required() {
595        let doc = OpenApi::new("API", "1.0").from_routes(&routes()).to_value();
596        let params = doc["paths"]["/users/{id}"]["get"]["parameters"]
597            .as_array()
598            .unwrap();
599
600        assert_eq!(params.len(), 1);
601        assert_eq!(params[0]["name"], "id");
602        assert_eq!(params[0]["in"], "path");
603        assert_eq!(params[0]["required"], true);
604    }
605
606    #[test]
607    fn an_undescribed_operation_still_carries_a_response() {
608        let doc = OpenApi::new("API", "1.0").from_routes(&routes()).to_value();
609        let responses = doc["paths"]["/users"]["post"]["responses"]
610            .as_object()
611            .unwrap();
612        assert!(
613            responses.contains_key("default"),
614            "an operation with no responses fails validation"
615        );
616    }
617
618    #[test]
619    fn a_description_reaches_the_document() {
620        let doc = OpenApi::new("API", "1.0")
621            .from_routes(&routes())
622            .operation(
623                Method::GET,
624                "/users/{id}",
625                Operation::new()
626                    .summary("Fetch one user")
627                    .tag("users")
628                    .response(200, "The user")
629                    .response(404, "No such user"),
630            )
631            .to_value();
632
633        let op = &doc["paths"]["/users/{id}"]["get"];
634        assert_eq!(op["summary"], "Fetch one user");
635        assert_eq!(op["tags"][0], "users");
636        assert_eq!(op["responses"]["200"]["description"], "The user");
637        assert_eq!(op["responses"]["404"]["description"], "No such user");
638        assert!(op["responses"].get("default").is_none());
639    }
640
641    #[test]
642    fn a_schema_is_carried_through_unchanged() {
643        let schema = json!({ "type": "object", "properties": { "name": { "type": "string" } } });
644        let doc = OpenApi::new("API", "1.0")
645            .from_routes(&routes())
646            .operation(
647                Method::POST,
648                "/users",
649                Operation::new().schema("application/json", schema.clone()),
650            )
651            .to_value();
652
653        assert_eq!(
654            doc["paths"]["/users"]["post"]["requestBody"]["content"]["application/json"]["schema"],
655            schema
656        );
657    }
658
659    #[test]
660    fn operation_ids_are_derived_and_stable() {
661        assert_eq!(
662            derive_operation_id(&Method::GET, "/users/{id}"),
663            "getUsersById"
664        );
665        assert_eq!(derive_operation_id(&Method::POST, "/users"), "postUsers");
666        assert_eq!(
667            derive_operation_id(&Method::GET, "/files/{path...}"),
668            "getFilesByPath"
669        );
670    }
671
672    #[test]
673    fn head_and_options_are_not_described() {
674        let mut routes = routes();
675        routes.push((Method::HEAD, "/users".to_string()));
676        routes.push((Method::OPTIONS, "/users".to_string()));
677
678        let doc = OpenApi::new("API", "1.0").from_routes(&routes).to_value();
679        assert!(doc["paths"]["/users"].get("head").is_none());
680        assert!(doc["paths"]["/users"].get("options").is_none());
681    }
682
683    #[test]
684    fn drift_is_reported_in_both_directions() {
685        let api = OpenApi::new("API", "1.0")
686            .from_routes(&routes())
687            .operation(Method::GET, "/users/{id}", Operation::new())
688            .operation(Method::DELETE, "/gone", Operation::new());
689
690        let undescribed = api.undescribed();
691        assert!(undescribed.contains(&(Method::POST, "/users".to_string())));
692        assert!(!undescribed.contains(&(Method::GET, "/users/{id}".to_string())));
693
694        assert_eq!(api.stale(), vec![(Method::DELETE, "/gone".to_string())]);
695    }
696
697    #[test]
698    fn servers_and_description_are_included_when_set() {
699        let doc = OpenApi::new("API", "1.0")
700            .description("Everything about users")
701            .server("https://api.example.com", Some("production"))
702            .from_routes(&routes())
703            .to_value();
704
705        assert_eq!(doc["info"]["description"], "Everything about users");
706        assert_eq!(doc["servers"][0]["url"], "https://api.example.com");
707        assert_eq!(doc["servers"][0]["description"], "production");
708    }
709
710    #[test]
711    fn the_document_declares_openapi_3_1() {
712        let doc = OpenApi::new("API", "1.0").to_value();
713        assert_eq!(doc["openapi"], "3.1.0");
714        assert_eq!(doc["info"]["title"], "API");
715        assert_eq!(doc["info"]["version"], "1.0");
716    }
717}