Skip to main content

better_auth_core/
openapi.rs

1use serde::Serialize;
2use std::collections::BTreeMap;
3
4use crate::plugin::AuthPlugin;
5use crate::types::HttpMethod;
6
7/// Minimal OpenAPI 3.1.0 spec builder that collects routes from plugins.
8///
9/// Produces a JSON document compatible with the OpenAPI 3.1.0 specification.
10/// This is intentionally lightweight — it captures paths and methods from
11/// registered plugins without requiring schema derives on every type.
12#[derive(Debug, Serialize)]
13pub struct OpenApiSpec {
14    pub openapi: String,
15    pub info: OpenApiInfo,
16    pub paths: BTreeMap<String, BTreeMap<String, OpenApiOperation>>,
17}
18
19#[derive(Debug, Serialize)]
20pub struct OpenApiInfo {
21    pub title: String,
22    pub version: String,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub description: Option<String>,
25}
26
27#[derive(Debug, Clone, Serialize)]
28pub struct OpenApiOperation {
29    #[serde(rename = "operationId")]
30    pub operation_id: String,
31    pub summary: String,
32    pub tags: Vec<String>,
33    pub responses: BTreeMap<String, OpenApiResponse>,
34}
35
36#[derive(Debug, Clone, Serialize)]
37pub struct OpenApiResponse {
38    pub description: String,
39}
40
41/// Builder for constructing an OpenAPI spec from plugins and core routes.
42pub struct OpenApiBuilder {
43    title: String,
44    version: String,
45    description: Option<String>,
46    paths: BTreeMap<String, BTreeMap<String, OpenApiOperation>>,
47}
48
49impl OpenApiBuilder {
50    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
51        Self {
52            title: title.into(),
53            version: version.into(),
54            description: None,
55            paths: BTreeMap::new(),
56        }
57    }
58
59    pub fn description(mut self, desc: impl Into<String>) -> Self {
60        self.description = Some(desc.into());
61        self
62    }
63
64    /// Add a single route entry.
65    pub fn route(mut self, method: &HttpMethod, path: &str, operation_id: &str, tag: &str) -> Self {
66        let method_str = match method {
67            HttpMethod::Get => "get",
68            HttpMethod::Post => "post",
69            HttpMethod::Put => "put",
70            HttpMethod::Delete => "delete",
71            HttpMethod::Patch => "patch",
72            HttpMethod::Options => "options",
73            HttpMethod::Head => "head",
74        };
75
76        let operation = OpenApiOperation {
77            operation_id: operation_id.to_string(),
78            summary: operation_id.replace('_', " "),
79            tags: vec![tag.to_string()],
80            responses: {
81                let mut r = BTreeMap::new();
82                r.insert(
83                    "200".to_string(),
84                    OpenApiResponse {
85                        description: "Successful response".to_string(),
86                    },
87                );
88                r
89            },
90        };
91
92        self.paths
93            .entry(path.to_string())
94            .or_default()
95            .insert(method_str.to_string(), operation);
96        self
97    }
98
99    /// Register all routes from a plugin.
100    pub fn plugin(mut self, plugin: &dyn AuthPlugin) -> Self {
101        let tag = plugin.name();
102        for route in plugin.routes() {
103            self = self.route(&route.method, &route.path, &route.handler, tag);
104        }
105        self
106    }
107
108    /// Register core routes that are not part of any plugin.
109    pub fn core_routes(self) -> Self {
110        self.route(&HttpMethod::Get, "/ok", "ok", "core")
111            .route(&HttpMethod::Get, "/error", "error", "core")
112            .route(&HttpMethod::Post, "/update-user", "update_user", "core")
113            .route(&HttpMethod::Post, "/delete-user", "delete_user", "core")
114            .route(&HttpMethod::Post, "/change-email", "change_email", "core")
115            .route(
116                &HttpMethod::Get,
117                "/delete-user/callback",
118                "delete_user_callback",
119                "core",
120            )
121    }
122
123    /// Build the final OpenAPI spec.
124    pub fn build(self) -> OpenApiSpec {
125        OpenApiSpec {
126            openapi: "3.1.0".to_string(),
127            info: OpenApiInfo {
128                title: self.title,
129                version: self.version,
130                description: self.description,
131            },
132            paths: self.paths,
133        }
134    }
135}
136
137impl OpenApiSpec {
138    /// Serialize the spec to a JSON string.
139    pub fn to_json(&self) -> serde_json::Result<String> {
140        serde_json::to_string_pretty(self)
141    }
142
143    /// Serialize the spec to a `serde_json::Value`.
144    pub fn to_value(&self) -> serde_json::Result<serde_json::Value> {
145        serde_json::to_value(self)
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn test_builder_core_routes() {
155        let spec = OpenApiBuilder::new("Better Auth", "0.1.0")
156            .description("Authentication API")
157            .core_routes()
158            .build();
159
160        assert_eq!(spec.openapi, "3.1.0");
161        assert_eq!(spec.info.title, "Better Auth");
162        assert!(spec.paths.contains_key("/ok"));
163        assert!(spec.paths.contains_key("/error"));
164        assert!(spec.paths.contains_key("/update-user"));
165        assert!(spec.paths.contains_key("/delete-user"));
166
167        // /ok should have a GET operation
168        let ok_path = &spec.paths["/ok"];
169        assert!(ok_path.contains_key("get"));
170        assert_eq!(ok_path["get"].operation_id, "ok");
171    }
172
173    #[test]
174    fn test_builder_custom_route() {
175        let spec = OpenApiBuilder::new("Test", "1.0.0")
176            .route(
177                &HttpMethod::Post,
178                "/sign-in/email",
179                "sign_in_email",
180                "email-password",
181            )
182            .build();
183
184        let path = &spec.paths["/sign-in/email"];
185        assert!(path.contains_key("post"));
186        assert_eq!(path["post"].tags, vec!["email-password"]);
187    }
188
189    #[test]
190    fn test_spec_to_json() {
191        let spec = OpenApiBuilder::new("Test", "1.0.0").core_routes().build();
192
193        let json = spec.to_json().unwrap();
194        assert!(json.contains("\"openapi\": \"3.1.0\""));
195        assert!(json.contains("\"/ok\""));
196    }
197
198    #[test]
199    fn test_spec_to_value() {
200        let spec = OpenApiBuilder::new("Test", "1.0.0").core_routes().build();
201
202        let value = spec.to_value().unwrap();
203        assert_eq!(value["openapi"], "3.1.0");
204        assert!(value["paths"]["/ok"]["get"]["operationId"].is_string());
205    }
206}