Skip to main content

rws_server/openapi/
mod.rs

1//! OpenAPI 3.1 documents generated from registered routes, with request and
2//! response schemas from serde types.
3//!
4//! Every route [`Router::route_entries`](crate::router::Router::route_entries)
5//! knows becomes an operation (method, path, path parameters). Describe one
6//! further with an [`Operation`] — summary, JSON request body, responses,
7//! query parameters — whose schemas come from [`JsonSchema`]
8//! (`#[derive(JsonSchema)]`, the same derivation MCP tools use for
9//! `inputSchema`/`outputSchema`, so it follows `#[serde]` renames). Rust has
10//! no runtime reflection, so the types are named where the operation is
11//! described rather than read off the handler. The document is OpenAPI 3.1,
12//! whose schema dialect is JSON Schema 2020-12 — what `JsonSchema` emits.
13//! No new dependency: the JSON is built by hand like the rest of the crate's.
14//!
15//! # Example
16//!
17//! ```rust
18//! # extern crate rws_server as rust_web_server;
19//! # #[cfg(all(feature = "openapi", feature = "macros"))]
20//! # fn example() {
21//! use rust_web_server::app::App;
22//! use rust_web_server::openapi::{OpenApiConfig, Operation};
23//! use rust_web_server::response::Response;
24//! use rust_web_server::core::New;
25//! use rust_web_server::JsonSchema;
26//!
27//! #[derive(JsonSchema)]
28//! struct User { id: u64, name: String }
29//! #[derive(JsonSchema)]
30//! struct NewUser { name: String }
31//!
32//! let app = App::with_state(())
33//!     .get("/users/:id", |_req, _params, _conn, _state| Response::new())
34//!     .post("/users", |_req, _params, _conn, _state| Response::new())
35//!     .openapi(
36//!         OpenApiConfig::new("My API", "1.0.0")
37//!             .operation("GET", "/users/:id", Operation::new("Look up a user").path_param::<u64>("id").response::<User>(200, "The user").status(404, "No such user"))
38//!             .operation("POST", "/users", Operation::new("Create a user").body::<NewUser>().response::<User>(201, "Created")),
39//!     );
40//! // Serves GET /openapi.json (the document) and GET /docs (Swagger UI).
41//! # }
42//! ```
43
44#[cfg(test)]
45mod tests;
46
47use crate::router::RouteInfo;
48use crate::schema::JsonSchema;
49
50/// Metadata for the generated OpenAPI document, and the described operations.
51#[derive(Clone, Debug)]
52pub struct OpenApiConfig {
53    pub title: String,
54    pub version: String,
55    pub description: Option<String>,
56    /// `(method, route pattern, operation)`, as given to [`OpenApiConfig::operation`].
57    pub operations: Vec<(String, String, Operation)>,
58}
59
60impl OpenApiConfig {
61    /// Create a config with the required `title` and `version` fields.
62    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
63        OpenApiConfig { title: title.into(), version: version.into(), description: None, operations: Vec::new() }
64    }
65
66    /// Set the OpenAPI document's `info.description`.
67    pub fn description(mut self, description: impl Into<String>) -> Self {
68        self.description = Some(description.into());
69        self
70    }
71
72    /// Describe the operation for `method` and route `pattern` (as
73    /// registered: `/users/:id`). An operation for a pattern no route has is
74    /// still documented — for routes served outside the router.
75    pub fn operation(mut self, method: &str, pattern: &str, operation: Operation) -> Self {
76        let method = method.to_ascii_uppercase();
77        self.operations.retain(|(m, p, _)| !(*m == method && to_openapi_path(p) == to_openapi_path(pattern)));
78        self.operations.push((method, pattern.to_string(), operation));
79        self
80    }
81}
82
83/// What a client needs to know about one operation beyond its method and
84/// path. Schemas are JSON text, normally from [`JsonSchema`].
85#[derive(Clone, Debug, Default)]
86pub struct Operation {
87    summary: String,
88    description: Option<String>,
89    operation_id: Option<String>,
90    tags: Vec<String>,
91    deprecated: bool,
92    /// `(name, schema, description)` overriding the default string schema.
93    path_params: Vec<(String, String, String)>,
94    /// `(name, schema, required)`.
95    query: Vec<(String, String, bool)>,
96    /// `(content type, schema, required)`.
97    body: Option<(String, String, bool)>,
98    /// `(status, description, (content type, schema))`, in order added.
99    responses: Vec<(u16, String, Option<(String, String)>)>,
100}
101
102impl Operation {
103    /// An operation with a one-line `summary`.
104    pub fn new(summary: impl Into<String>) -> Self {
105        Operation { summary: summary.into(), ..Operation::default() }
106    }
107
108    /// A longer description (CommonMark).
109    pub fn description(mut self, description: impl Into<String>) -> Self {
110        self.description = Some(description.into());
111        self
112    }
113
114    /// A unique `operationId` (client generators name methods after it).
115    pub fn operation_id(mut self, id: impl Into<String>) -> Self {
116        self.operation_id = Some(id.into());
117        self
118    }
119
120    /// Group the operation under `tag`.
121    pub fn tag(mut self, tag: impl Into<String>) -> Self {
122        self.tags.push(tag.into());
123        self
124    }
125
126    /// Mark the operation deprecated.
127    pub fn deprecated(mut self) -> Self {
128        self.deprecated = true;
129        self
130    }
131
132    /// Path parameter `name` holds a `T` (default: a string).
133    pub fn path_param<T: JsonSchema + ?Sized>(self, name: &str) -> Self {
134        self.path_param_schema(name, &T::json_schema(), "")
135    }
136
137    /// Path parameter `name` with an explicit schema and description.
138    pub fn path_param_schema(mut self, name: &str, schema: &str, description: &str) -> Self {
139        self.path_params.retain(|(n, _, _)| n != name);
140        self.path_params.push((name.to_string(), schema.to_string(), description.to_string()));
141        self
142    }
143
144    /// Query parameters: one per property of `T` (a struct), required as the
145    /// struct requires them — the type a handler parses the query into.
146    pub fn query<T: JsonSchema + ?Sized>(mut self) -> Self {
147        self.query.extend(object_properties(&T::json_schema()));
148        self
149    }
150
151    /// One query parameter with an explicit schema.
152    pub fn query_param(mut self, name: &str, schema: &str, required: bool) -> Self {
153        self.query.push((name.to_string(), schema.to_string(), required));
154        self
155    }
156
157    /// A required `application/json` request body holding a `T`.
158    pub fn body<T: JsonSchema + ?Sized>(self) -> Self {
159        self.body_schema("application/json", &T::json_schema(), true)
160    }
161
162    /// A request body of `content_type` with an explicit schema.
163    pub fn body_schema(mut self, content_type: &str, schema: &str, required: bool) -> Self {
164        self.body = Some((content_type.to_string(), schema.to_string(), required));
165        self
166    }
167
168    /// Response `status` with an `application/json` body holding a `T`.
169    pub fn response<T: JsonSchema + ?Sized>(self, status: u16, description: &str) -> Self {
170        self.response_schema(status, description, "application/json", &T::json_schema())
171    }
172
173    /// Response `status` with a body of `content_type` and an explicit schema.
174    pub fn response_schema(mut self, status: u16, description: &str, content_type: &str, schema: &str) -> Self {
175        self.responses.retain(|(s, _, _)| *s != status);
176        self.responses.push((status, description.to_string(), Some((content_type.to_string(), schema.to_string()))));
177        self
178    }
179
180    /// Response `status` without a documented body (an error, `204`, …).
181    pub fn status(mut self, status: u16, description: &str) -> Self {
182        self.responses.retain(|(s, _, _)| *s != status);
183        self.responses.push((status, description.to_string(), None));
184        self
185    }
186}
187
188/// The properties of an object schema as query parameters.
189fn object_properties(schema: &str) -> Vec<(String, String, bool)> {
190    use crate::mcp::json_rpc::object_members;
191    let Some(members) = object_members(schema) else { return Vec::new() };
192    let required: Vec<String> = members
193        .iter()
194        .find(|(k, _)| k == "required")
195        .map(|(_, v)| {
196            v.trim().trim_start_matches('[').trim_end_matches(']').split(',').map(|n| n.trim().trim_matches('"').to_string()).collect()
197        })
198        .unwrap_or_default();
199    let Some(properties) = members.iter().find(|(k, _)| k == "properties").and_then(|(_, v)| object_members(v)) else { return Vec::new() };
200    properties.into_iter().map(|(name, schema)| { let required = required.contains(&name); (name, schema, required) }).collect()
201}
202
203/// Builds an OpenAPI 3.1 JSON document from `routes` and the operations in
204/// `config`.
205///
206/// Routes sharing a path (different methods) are merged into one `paths`
207/// entry. `:name` and `*name` segments both become `{name}` with a
208/// `parameters` entry — OpenAPI has no "rest of the path" parameter, so
209/// `*name` is represented like `:name`.
210pub fn build_spec(config: &OpenApiConfig, routes: &[RouteInfo]) -> String {
211    // Keyed by the OpenAPI path, so `/users/:id` and `/users/{id}` are the same route.
212    let mut all: Vec<(String, String)> = routes.iter().map(|r| (r.method.to_ascii_uppercase(), r.pattern.clone())).collect();
213    for (method, pattern, _) in &config.operations {
214        if !all.iter().any(|(m, p)| m == method && to_openapi_path(p) == to_openapi_path(pattern)) {
215            all.push((method.clone(), pattern.clone()));
216        }
217    }
218    let mut paths: Vec<(String, Vec<(String, String)>)> = Vec::new();
219    for (method, pattern) in all {
220        let openapi_path = to_openapi_path(&pattern);
221        match paths.iter_mut().find(|(p, _)| *p == openapi_path) {
222            Some(entry) => entry.1.push((method, pattern)),
223            None => paths.push((openapi_path, vec![(method, pattern)])),
224        }
225    }
226
227    let paths_json: Vec<String> = paths
228        .iter()
229        .map(|(path, methods)| {
230            let operations: Vec<String> = methods
231                .iter()
232                .map(|(method, pattern)| {
233                    let described = config.operations.iter().find(|(m, p, _)| m == method && to_openapi_path(p) == *path).map(|(_, _, o)| o);
234                    build_operation_json(method, pattern, described)
235                })
236                .collect();
237            format!(r#""{}":{{{}}}"#, json_escape(path), operations.join(","))
238        })
239        .collect();
240
241    let description_json = match &config.description {
242        Some(d) => format!(r#","description":"{}""#, json_escape(d)),
243        None => String::new(),
244    };
245
246    format!(
247        r#"{{"openapi":"3.1.0","info":{{"title":"{}","version":"{}"{}}},"paths":{{{}}}}}"#,
248        json_escape(&config.title),
249        json_escape(&config.version),
250        description_json,
251        paths_json.join(",")
252    )
253}
254
255fn build_operation_json(method: &str, pattern: &str, operation: Option<&Operation>) -> String {
256    let default = Operation::new(format!("{method} {pattern}"));
257    let operation = operation.unwrap_or(&default);
258    let mut fields: Vec<String> = Vec::new();
259    let summary = if operation.summary.is_empty() { format!("{method} {pattern}") } else { operation.summary.clone() };
260    fields.push(format!(r#""summary":"{}""#, json_escape(&summary)));
261    if let Some(description) = &operation.description {
262        fields.push(format!(r#""description":"{}""#, json_escape(description)));
263    }
264    if let Some(id) = &operation.operation_id {
265        fields.push(format!(r#""operationId":"{}""#, json_escape(id)));
266    }
267    if !operation.tags.is_empty() {
268        let tags: Vec<String> = operation.tags.iter().map(|t| format!(r#""{}""#, json_escape(t))).collect();
269        fields.push(format!(r#""tags":[{}]"#, tags.join(",")));
270    }
271    if operation.deprecated {
272        fields.push(r#""deprecated":true"#.to_string());
273    }
274
275    let mut parameters: Vec<String> = path_param_names(pattern)
276        .iter()
277        .map(|name| {
278            let (schema, description) = operation
279                .path_params
280                .iter()
281                .find(|(n, _, _)| n == name)
282                .map(|(_, schema, description)| (schema.as_str(), description.as_str()))
283                .unwrap_or((r#"{"type":"string"}"#, ""));
284            let description = if description.is_empty() { String::new() } else { format!(r#","description":"{}""#, json_escape(description)) };
285            format!(r#"{{"name":"{}","in":"path","required":true{description},"schema":{schema}}}"#, json_escape(name))
286        })
287        .collect();
288    parameters.extend(operation.query.iter().map(|(name, schema, required)| {
289        format!(r#"{{"name":"{}","in":"query","required":{required},"schema":{schema}}}"#, json_escape(name))
290    }));
291    if !parameters.is_empty() {
292        fields.push(format!(r#""parameters":[{}]"#, parameters.join(",")));
293    }
294
295    if let Some((content_type, schema, required)) = &operation.body {
296        fields.push(format!(
297            r#""requestBody":{{"required":{required},"content":{{"{}":{{"schema":{schema}}}}}}}"#,
298            json_escape(content_type)
299        ));
300    }
301
302    let responses: Vec<String> = if operation.responses.is_empty() {
303        vec![r#""200":{"description":"OK"}"#.to_string()]
304    } else {
305        operation
306            .responses
307            .iter()
308            .map(|(status, description, content)| {
309                let content = match content {
310                    Some((content_type, schema)) => format!(r#","content":{{"{}":{{"schema":{schema}}}}}"#, json_escape(content_type)),
311                    None => String::new(),
312                };
313                format!(r#""{status}":{{"description":"{}"{content}}}"#, json_escape(description))
314            })
315            .collect()
316    };
317    fields.push(format!(r#""responses":{{{}}}"#, responses.join(",")));
318
319    format!(r#""{}":{{{}}}"#, method.to_lowercase(), fields.join(","))
320}
321
322/// Converts a router pattern (`/users/:id`) to an OpenAPI path template
323/// (`/users/{id}`).
324fn to_openapi_path(pattern: &str) -> String {
325    pattern
326        .split('/')
327        .map(|seg| {
328            match param_name(seg) {
329                Some(name) => format!("{{{}}}", name),
330                None => seg.replace("{{", "{").replace("}}", "}"),
331            }
332        })
333        .collect::<Vec<_>>()
334        .join("/")
335}
336
337/// The parameter a pattern segment names: `:id`, `*rest`, `{id}`, `{*rest}`.
338fn param_name(segment: &str) -> Option<&str> {
339    segment
340        .strip_prefix(':')
341        .or_else(|| segment.strip_prefix('*'))
342        .or_else(|| crate::router::matcher::braced(segment).map(|inner| inner.trim_start_matches('*')))
343}
344
345fn path_param_names(pattern: &str) -> Vec<String> {
346    pattern
347        .split('/')
348        .filter_map(param_name)
349        .map(|s| s.to_string())
350        .collect()
351}
352
353fn json_escape(s: &str) -> String {
354    crate::core::json::escape(s)
355}
356
357/// Self-contained Swagger UI HTML page, loading the `swagger-ui-dist` bundle
358/// from a CDN and pointing it at `spec_url` (typically `/openapi.json`).
359pub fn swagger_ui_html(spec_url: &str) -> String {
360    format!(
361        r#"<!DOCTYPE html>
362<html>
363<head>
364  <meta charset="utf-8">
365  <title>API Docs</title>
366  <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
367</head>
368<body>
369  <div id="swagger-ui"></div>
370  <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
371  <script>
372    window.onload = function() {{
373      window.ui = SwaggerUIBundle({{
374        url: '{}',
375        dom_id: '#swagger-ui',
376      }});
377    }};
378  </script>
379</body>
380</html>"#,
381        spec_url
382    )
383}