Skip to main content

better_auth_core/
openapi.rs

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