Skip to main content

better_auth_core/openapi/
mod.rs

1pub mod annotations;
2
3mod input_annotations;
4
5mod metadata;
6
7use crate::{AuthConfig, AuthPlugin, AuthSchema, HttpMethod};
8pub use metadata::{
9    OpenApiEndpoint, OpenApiField, OpenApiModel, OpenApiRegistry, PluginOpenApiMetadata,
10};
11use serde::Serialize;
12use serde_json::{Value, json};
13use std::collections::{BTreeMap, HashSet};
14
15#[derive(Debug, Serialize)]
16pub struct OpenApiSpec {
17    pub openapi: String,
18    pub info: OpenApiInfo,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub components: Option<Value>,
21    #[serde(skip_serializing_if = "Vec::is_empty")]
22    pub security: Vec<Value>,
23    #[serde(skip_serializing_if = "Vec::is_empty")]
24    pub servers: Vec<Value>,
25    #[serde(skip_serializing_if = "Vec::is_empty")]
26    pub tags: Vec<Value>,
27    pub paths: BTreeMap<String, BTreeMap<String, OpenApiOperation>>,
28}
29
30#[derive(Debug, Serialize)]
31pub struct OpenApiInfo {
32    pub title: String,
33    pub version: String,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub description: Option<String>,
36}
37
38#[derive(Debug, Clone, Serialize)]
39pub struct OpenApiOperation {
40    #[serde(rename = "operationId", skip_serializing_if = "String::is_empty")]
41    pub operation_id: String,
42    #[serde(skip_serializing_if = "String::is_empty")]
43    pub summary: String,
44    pub tags: Vec<String>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub description: Option<String>,
47    pub security: Vec<Value>,
48    pub parameters: Vec<Value>,
49    #[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
50    pub request_body: Option<Value>,
51    pub responses: BTreeMap<String, OpenApiResponse>,
52}
53
54#[derive(Debug, Clone, Serialize)]
55pub struct OpenApiResponse {
56    pub description: String,
57    #[serde(flatten)]
58    pub metadata: serde_json::Map<String, Value>,
59}
60
61/// Embedded builder, also used by the HTTP `OpenAPI` plugin.
62#[derive(Debug)]
63pub struct OpenApiBuilder {
64    spec: OpenApiSpec,
65    used_ids: HashSet<String>,
66}
67
68impl OpenApiBuilder {
69    #[must_use]
70    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
71        Self {
72            spec: OpenApiSpec {
73                openapi: "3.1.1".into(),
74                info: OpenApiInfo {
75                    title: title.into(),
76                    version: version.into(),
77                    description: None,
78                },
79                components: None,
80                security: vec![],
81                servers: vec![],
82                tags: vec![],
83                paths: BTreeMap::new(),
84            },
85            used_ids: HashSet::new(),
86        }
87    }
88
89    #[must_use]
90    pub fn description(mut self, description: impl Into<String>) -> Self {
91        self.spec.info.description = Some(description.into());
92        self
93    }
94
95    /// Construct the upstream document defaults from an immutable instance snapshot.
96    #[must_use]
97    pub fn registered(config: &AuthConfig, registry: &OpenApiRegistry) -> Self {
98        Self::registered_with_native_extensions(config, registry, false)
99    }
100
101    /// Include documented Rust extensions while retaining upstream generator defaults.
102    #[must_use]
103    pub fn registered_with_native_extensions(
104        config: &AuthConfig,
105        registry: &OpenApiRegistry,
106        include_native_extensions: bool,
107    ) -> Self {
108        let mut builder = Self::new("Better Auth", "1.1.0")
109            .description("API Reference for your Better Auth Instance");
110        let mut schemas = serde_json::Map::new();
111        for model in registry.models.values() {
112            let mut model = model.clone();
113            if let Some(overrides) = registry
114                .core_overrides
115                .iter()
116                .find(|overrides| overrides.name == model.name)
117            {
118                for field in &overrides.fields {
119                    if let Some(current) = model
120                        .fields
121                        .iter_mut()
122                        .find(|current| current.name == field.name)
123                    {
124                        *current = field.clone();
125                    } else {
126                        model.fields.push(field.clone());
127                    }
128                }
129            }
130            drop(schemas.insert(model.name.clone(), model.to_schema()));
131        }
132        builder.spec.components = Some(json!({"schemas":schemas,"securitySchemes":{
133            "apiKeyCookie":{"type":"apiKey","in":"cookie","name":"apiKeyCookie","description":"API Key authentication via cookie"},
134            "bearerAuth":{"type":"http","scheme":"bearer","description":"Bearer token authentication"}
135        }}));
136        builder.spec.security = vec![json!({"apiKeyCookie":[],"bearerAuth":[]})];
137        builder.spec.servers = vec![
138            json!({"url":format!("{}{}",config.base_url.trim_end_matches('/'),config.base_path)}),
139        ];
140        builder.spec.tags = vec![
141            json!({"name":"Default","description":"Default endpoints that are included with Better Auth by default. These endpoints are not part of any plugin."}),
142        ];
143        // Core endpoints precede plugin endpoints, regardless of the Rust plugin layout.
144        for core in [true, false] {
145            for endpoint in registry
146                .endpoints
147                .iter()
148                .filter(|endpoint| endpoint.core == core)
149            {
150                if (endpoint.plugin == "open-api" && !endpoint.metadata.native_extension)
151                    || endpoint.metadata.server_only
152                    || (endpoint.metadata.native_extension && !include_native_extensions)
153                    || config.is_path_disabled(&endpoint.route.path)
154                    || endpoint
155                        .metadata
156                        .document_path
157                        .as_ref()
158                        .is_some_and(|path| config.is_path_disabled(path))
159                {
160                    continue;
161                }
162                let mut metadata = endpoint.metadata.clone();
163                input_annotations::apply(
164                    &endpoint.route.path,
165                    &mut metadata,
166                    &registry.user_input_fields,
167                );
168                builder = builder.annotated(
169                    &endpoint.route.method,
170                    &endpoint.route.path,
171                    &endpoint.plugin,
172                    endpoint.core,
173                    &metadata,
174                );
175            }
176        }
177        builder
178    }
179
180    #[must_use]
181    #[expect(
182        clippy::too_many_lines,
183        reason = "Apply endpoint annotations in the same precedence order as the registered metadata"
184    )]
185    pub fn annotated(
186        mut self,
187        method: &HttpMethod,
188        path: &str,
189        plugin: &str,
190        core: bool,
191        metadata: &OpenApiEndpoint,
192    ) -> Self {
193        let method_name = match method {
194            HttpMethod::Get => "get",
195            HttpMethod::Post => "post",
196            HttpMethod::Put => "put",
197            HttpMethod::Delete => "delete",
198            HttpMethod::Patch => "patch",
199            HttpMethod::Options | HttpMethod::Head => return self,
200        };
201        if metadata.server_only {
202            return self;
203        }
204        let mut parameters = metadata.parameters.clone();
205        let documented_path = metadata.document_path.as_deref().unwrap_or(path);
206        let path_2=documented_path.split('/').map(|segment| {
207            segment.strip_prefix(':').or_else(|| segment.strip_prefix('{').and_then(|name| name.strip_suffix('}'))).map_or_else(|| segment.to_owned(), |name| {
208                if !parameters.iter().any(|parameter|parameter["in"]=="path" && parameter["name"]==name) { parameters.push(json!({"name":name,"in":"path","required":true,"schema":{"type":"string"}})); }
209                format!("{{{name}}}")
210            })
211        }).collect::<Vec<_>>().join("/");
212        let operation_id = metadata
213            .operation_id
214            .as_ref()
215            .map(|id| {
216                let mut candidate = id.clone();
217                if self.used_ids.contains(&candidate) {
218                    let mut chars = method_name.chars();
219                    let suffix = chars
220                        .next()
221                        .map(|first| {
222                            let mut name = first.to_uppercase().collect::<String>();
223                            name.push_str(chars.as_str());
224                            name
225                        })
226                        .unwrap_or_default();
227                    candidate = format!("{id}{suffix}");
228                    let mut index = 2;
229                    while self.used_ids.contains(&candidate) {
230                        candidate = format!("{id}{suffix}{index}");
231                        index += 1;
232                    }
233                }
234                let _ignored_clone = self.used_ids.insert(candidate.clone());
235                candidate
236            })
237            .unwrap_or_default();
238        let mut responses = default_responses();
239        for (status, response) in &metadata.responses {
240            let mut fields = response.as_object().cloned().unwrap_or_default();
241            let description = fields
242                .remove("description")
243                .and_then(|value| value.as_str().map(str::to_owned))
244                .unwrap_or_default();
245            drop(responses.insert(
246                status.clone(),
247                OpenApiResponse {
248                    description,
249                    metadata: fields,
250                },
251            ));
252        }
253        let tags = if core {
254            let mut tags = vec!["Default".into()];
255            tags.extend(metadata.tags.clone().unwrap_or_default());
256            tags
257        } else {
258            metadata.tags.clone().unwrap_or_else(|| {
259                let mut chars = plugin.chars();
260                vec![
261                    chars
262                        .next()
263                        .map(|first| {
264                            let mut name = first.to_uppercase().collect::<String>();
265                            name.push_str(chars.as_str());
266                            name
267                        })
268                        .unwrap_or_default(),
269                ]
270            })
271        };
272        let mut request_body = if matches!(
273            method,
274            HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch
275        ) {
276            metadata.request_body.clone()
277        } else {
278            None
279        };
280        if core
281            && request_body.is_none()
282            && matches!(
283                method,
284                HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch
285            )
286        {
287            request_body = Some(
288                json!({"content":{"application/json":{"schema":{"type":"object","properties":{}}}}}),
289            );
290        }
291        drop(self.spec.paths.entry(path_2).or_default().insert(
292            method_name.into(),
293            OpenApiOperation {
294                operation_id,
295                summary: String::new(),
296                description: metadata.description.clone(),
297                tags,
298                security: vec![json!({"bearerAuth":[]})],
299                parameters,
300                request_body,
301                responses,
302            },
303        ));
304        self
305    }
306
307    #[must_use]
308    pub fn route(self, method: &HttpMethod, path: &str, operation_id: &str, tag: &str) -> Self {
309        let metadata = OpenApiEndpoint {
310            operation_id: Some(operation_id.into()),
311            tags: Some(vec![tag.into()]),
312            ..Default::default()
313        };
314        self.annotated(method, path, tag, false, &metadata)
315    }
316
317    /// Register all routes from a plugin.
318    #[must_use]
319    pub fn plugin<S: AuthSchema>(mut self, plugin: &dyn AuthPlugin<S>) -> Self {
320        let routes = plugin.routes();
321        let metadata = plugin.static_openapi_metadata();
322        for route in routes {
323            let annotation = metadata
324                .endpoints
325                .iter()
326                .find(|(method, path, _)| *method == route.method && *path == route.path)
327                .map(|(_, _, metadata)| metadata.clone())
328                .unwrap_or_default();
329            self = self.annotated(
330                &route.method,
331                &route.path,
332                plugin.name(),
333                metadata.core,
334                &annotation,
335            );
336        }
337        self
338    }
339
340    /// Register core routes that are not part of any plugin.
341    #[must_use]
342    pub fn core_routes(self) -> Self {
343        self.route(&HttpMethod::Get, "/ok", "ok", "core")
344            .route(&HttpMethod::Get, "/error", "error", "core")
345            .route(&HttpMethod::Post, "/update-user", "update_user", "core")
346    }
347
348    /// Build the final `OpenAPI` spec.
349    #[must_use]
350    pub fn build(self) -> OpenApiSpec {
351        self.spec
352    }
353}
354
355impl OpenApiSpec {
356    ///
357    /// # Errors
358    ///
359    /// Returns an error if the `OpenAPI` document cannot be serialized.
360    pub fn to_json(&self) -> serde_json::Result<String> {
361        serde_json::to_string_pretty(self)
362    }
363    ///
364    /// # Errors
365    ///
366    /// Returns an error if the `OpenAPI` document cannot be serialized.
367    pub fn to_value(&self) -> serde_json::Result<Value> {
368        serde_json::to_value(self)
369    }
370}
371
372fn default_responses() -> BTreeMap<String, OpenApiResponse> {
373    [
374        ("400","Bad Request. Usually due to missing parameters, or invalid parameters."),
375        ("401","Unauthorized. Due to missing or invalid authentication."),
376        ("403","Forbidden. You do not have permission to access this resource or to perform this action."),
377        ("404","Not Found. The requested resource was not found."),
378        ("429","Too Many Requests. You have exceeded the rate limit. Try again later."),
379        ("500","Internal Server Error. This is a problem with the server that you cannot fix."),
380    ].into_iter().map(|(status,description)| {
381        let mut schema=json!({"type":"object","properties":{"message":{"type":"string"}}});
382        if (status=="400" || status=="401") && let Some(object)=schema.as_object_mut() {drop(object.insert("required".into(),json!(["message"])));}
383        let mut metadata=serde_json::Map::new();drop(metadata.insert("content".into(),json!({"application/json":{"schema":schema}})));
384        (status.into(),OpenApiResponse {description:description.into(),metadata})
385    }).collect()
386}
387
388// LCOV_EXCL_START
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    // Rust-specific surface: `OpenApiBuilder` and `OpenApiSpec` are Rust-specific public APIs for embedded schema generation.
394    #[test]
395    fn test_builder_core_routes() {
396        let spec = OpenApiBuilder::new("Better Auth", "0.1.0")
397            .description("Authentication API")
398            .core_routes()
399            .build();
400
401        assert_eq!(spec.openapi, "3.1.1");
402        assert_eq!(spec.info.title, "Better Auth");
403        assert!(spec.paths.contains_key("/ok"));
404        assert!(spec.paths.contains_key("/error"));
405        assert!(spec.paths.contains_key("/update-user"));
406
407        // /ok should have a GET operation
408        let ok_path = (spec.paths)
409            .get("/ok")
410            .expect("fixture contains the requested index");
411        assert!(ok_path.contains_key("get"));
412        assert_eq!(ok_path["get"].operation_id, "ok");
413    }
414
415    // Rust-specific surface: `OpenApiBuilder` and `OpenApiSpec` are Rust-specific public APIs for embedded schema generation.
416    #[test]
417    fn test_builder_custom_route() {
418        let spec = OpenApiBuilder::new("Test", "1.0.0")
419            .route(
420                &HttpMethod::Post,
421                "/sign-in/email",
422                "sign_in_email",
423                "email-password",
424            )
425            .build();
426
427        let path = (spec.paths)
428            .get("/sign-in/email")
429            .expect("fixture contains the requested index");
430        assert!(path.contains_key("post"));
431        assert_eq!(path["post"].tags, vec!["email-password"]);
432    }
433
434    // Rust-specific surface: `OpenApiBuilder` and `OpenApiSpec` are Rust-specific public APIs for embedded schema generation.
435    #[test]
436    fn test_spec_to_json() {
437        let spec = OpenApiBuilder::new("Test", "1.0.0").core_routes().build();
438
439        let json = spec.to_json().unwrap();
440        assert!(json.contains("\"openapi\": \"3.1.1\""));
441        assert!(json.contains("\"/ok\""));
442    }
443
444    // Rust-specific surface: `OpenApiBuilder` and `OpenApiSpec` are Rust-specific public APIs for embedded schema generation.
445    #[test]
446    fn test_spec_to_value() {
447        let spec = OpenApiBuilder::new("Test", "1.0.0").core_routes().build();
448
449        let value = spec.to_value().unwrap();
450        assert_eq!((*(value).get("openapi").unwrap_or(&Value::Null)), "3.1.1");
451        assert!(
452            (*(*(*(*(value).get("paths").unwrap_or(&Value::Null))
453                .get("/ok")
454                .unwrap_or(&Value::Null))
455            .get("get")
456            .unwrap_or(&Value::Null))
457            .get("operationId")
458            .unwrap_or(&Value::Null))
459            .is_string()
460        );
461    }
462}
463// LCOV_EXCL_STOP