Skip to main content

alibi_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            _ = 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    pub fn annotated(
182        mut self,
183        method: &HttpMethod,
184        path: &str,
185        plugin: &str,
186        core: bool,
187        metadata: &OpenApiEndpoint,
188    ) -> Self {
189        let method_name = match method {
190            HttpMethod::Get => "get",
191            HttpMethod::Post => "post",
192            HttpMethod::Put => "put",
193            HttpMethod::Delete => "delete",
194            HttpMethod::Patch => "patch",
195            HttpMethod::Options | HttpMethod::Head => return self,
196        };
197        if metadata.server_only {
198            return self;
199        }
200        let mut parameters = metadata.parameters.clone();
201        let documented_path = metadata.document_path.as_deref().unwrap_or(path);
202        let path = path_template(documented_path, &mut parameters);
203        let operation_id = metadata
204            .operation_id
205            .as_deref()
206            .map(|id| self.unique_operation_id(id, method_name))
207            .unwrap_or_default();
208        let mut responses = default_responses();
209        for (status, response) in &metadata.responses {
210            let mut fields = response.as_object().cloned().unwrap_or_default();
211            let description = fields
212                .remove("description")
213                .and_then(|value| value.as_str().map(str::to_owned))
214                .unwrap_or_default();
215            _ = responses.insert(
216                status.clone(),
217                OpenApiResponse {
218                    description,
219                    metadata: fields,
220                },
221            );
222        }
223        let tags = if core {
224            let mut tags = vec!["Default".into()];
225            tags.extend(metadata.tags.clone().unwrap_or_default());
226            tags
227        } else {
228            metadata
229                .tags
230                .clone()
231                .unwrap_or_else(|| vec![capitalize(plugin)])
232        };
233        let request_body = matches!(
234            method,
235            HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch
236        )
237        .then(|| {
238            metadata.request_body.clone().or_else(|| {
239                core.then(|| {
240                    json!({"content":{"application/json":{"schema":{"type":"object","properties":{}}}}})
241                })
242            })
243        })
244        .flatten();
245        _ = self.spec.paths.entry(path).or_default().insert(
246            method_name.into(),
247            OpenApiOperation {
248                operation_id,
249                summary: String::new(),
250                description: metadata.description.clone(),
251                tags,
252                security: vec![json!({"bearerAuth":[]})],
253                parameters,
254                request_body,
255                responses,
256            },
257        );
258        self
259    }
260
261    /// Reserve `id`, suffixing the HTTP method (then a counter) when already taken.
262    fn unique_operation_id(&mut self, id: &str, method_name: &str) -> String {
263        let mut candidate = id.to_owned();
264        if self.used_ids.contains(&candidate) {
265            let suffix = capitalize(method_name);
266            candidate = format!("{id}{suffix}");
267            let mut index = 2;
268            while self.used_ids.contains(&candidate) {
269                candidate = format!("{id}{suffix}{index}");
270                index += 1;
271            }
272        }
273        _ = self.used_ids.insert(candidate.clone());
274        candidate
275    }
276
277    #[must_use]
278    pub fn route(self, method: &HttpMethod, path: &str, operation_id: &str, tag: &str) -> Self {
279        let metadata = OpenApiEndpoint {
280            operation_id: Some(operation_id.into()),
281            tags: Some(vec![tag.into()]),
282            ..Default::default()
283        };
284        self.annotated(method, path, tag, false, &metadata)
285    }
286
287    /// Register all routes from a plugin.
288    #[must_use]
289    pub fn plugin<S: AuthSchema>(mut self, plugin: &dyn AuthPlugin<S>) -> Self {
290        let routes = plugin.routes();
291        let metadata = plugin.static_openapi_metadata();
292        for route in routes {
293            let annotation = metadata
294                .endpoints
295                .iter()
296                .find(|(method, path, _)| *method == route.method && *path == route.path)
297                .map(|(_, _, metadata)| metadata.clone())
298                .unwrap_or_default();
299            self = self.annotated(
300                &route.method,
301                &route.path,
302                plugin.name(),
303                metadata.core,
304                &annotation,
305            );
306        }
307        self
308    }
309
310    /// Register core routes that are not part of any plugin.
311    #[must_use]
312    pub fn core_routes(self) -> Self {
313        self.route(&HttpMethod::Get, "/ok", "ok", "core")
314            .route(&HttpMethod::Get, "/error", "error", "core")
315            .route(&HttpMethod::Post, "/update-user", "update_user", "core")
316    }
317
318    /// Build the final `OpenAPI` spec.
319    #[must_use]
320    pub fn build(self) -> OpenApiSpec {
321        self.spec
322    }
323}
324
325impl OpenApiSpec {
326    /// # Errors
327    ///
328    /// Returns an error if the `OpenAPI` document cannot be serialized.
329    pub fn to_json(&self) -> serde_json::Result<String> {
330        serde_json::to_string_pretty(self)
331    }
332    /// # Errors
333    ///
334    /// Returns an error if the `OpenAPI` document cannot be serialized.
335    pub fn to_value(&self) -> serde_json::Result<Value> {
336        serde_json::to_value(self)
337    }
338}
339
340fn capitalize(text: &str) -> String {
341    let mut chars = text.chars();
342    chars
343        .next()
344        .map(|first| {
345            let mut name = first.to_uppercase().collect::<String>();
346            name.push_str(chars.as_str());
347            name
348        })
349        .unwrap_or_default()
350}
351
352/// Rewrite `:name` and `{name}` segments to `{name}`, documenting each as a path parameter.
353fn path_template(path: &str, parameters: &mut Vec<Value>) -> String {
354    path.split('/')
355        .map(|segment| {
356            let name = segment.strip_prefix(':').or_else(|| {
357                segment
358                    .strip_prefix('{')
359                    .and_then(|name| name.strip_suffix('}'))
360            });
361            let Some(name) = name else {
362                return segment.to_owned();
363            };
364            if !parameters
365                .iter()
366                .any(|parameter| parameter["in"] == "path" && parameter["name"] == name)
367            {
368                parameters.push(
369                    json!({"name":name,"in":"path","required":true,"schema":{"type":"string"}}),
370                );
371            }
372            format!("{{{name}}}")
373        })
374        .collect::<Vec<_>>()
375        .join("/")
376}
377
378fn default_responses() -> BTreeMap<String, OpenApiResponse> {
379    [
380        ("400", "Bad Request. Usually due to missing parameters, or invalid parameters."),
381        ("401", "Unauthorized. Due to missing or invalid authentication."),
382        ("403", "Forbidden. You do not have permission to access this resource or to perform this action."),
383        ("404", "Not Found. The requested resource was not found."),
384        ("429", "Too Many Requests. You have exceeded the rate limit. Try again later."),
385        ("500", "Internal Server Error. This is a problem with the server that you cannot fix."),
386    ]
387    .into_iter()
388    .map(|(status, description)| {
389        let mut schema = json!({"type":"object","properties":{"message":{"type":"string"}}});
390        if matches!(status, "400" | "401")
391            && let Some(object) = schema.as_object_mut()
392        {
393            _ = object.insert("required".into(), json!(["message"]));
394        }
395        let metadata = serde_json::Map::from_iter([(
396            "content".to_owned(),
397            json!({"application/json":{"schema":schema}}),
398        )]);
399        (
400            status.to_owned(),
401            OpenApiResponse {
402                description: description.to_owned(),
403                metadata,
404            },
405        )
406    })
407    .collect()
408}
409
410// LCOV_EXCL_START
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    // Rust-specific surface: `OpenApiBuilder` and `OpenApiSpec` are Rust-specific public APIs for embedded schema generation.
416    #[test]
417    fn test_builder_core_routes() {
418        let spec = OpenApiBuilder::new("Better Auth", "0.1.0")
419            .description("Authentication API")
420            .core_routes()
421            .build();
422
423        assert_eq!(spec.openapi, "3.1.1");
424        assert_eq!(spec.info.title, "Better Auth");
425        assert!(spec.paths.contains_key("/ok"));
426        assert!(spec.paths.contains_key("/error"));
427        assert!(spec.paths.contains_key("/update-user"));
428
429        // /ok should have a GET operation
430        let ok_path = (spec.paths)
431            .get("/ok")
432            .expect("fixture contains the requested index");
433        assert!(ok_path.contains_key("get"));
434        assert_eq!(ok_path["get"].operation_id, "ok");
435    }
436
437    // Rust-specific surface: `OpenApiBuilder` and `OpenApiSpec` are Rust-specific public APIs for embedded schema generation.
438    #[test]
439    fn test_builder_custom_route() {
440        let spec = OpenApiBuilder::new("Test", "1.0.0")
441            .route(
442                &HttpMethod::Post,
443                "/sign-in/email",
444                "sign_in_email",
445                "email-password",
446            )
447            .build();
448
449        let path = (spec.paths)
450            .get("/sign-in/email")
451            .expect("fixture contains the requested index");
452        assert!(path.contains_key("post"));
453        assert_eq!(path["post"].tags, vec!["email-password"]);
454    }
455
456    // Rust-specific surface: `OpenApiBuilder` and `OpenApiSpec` are Rust-specific public APIs for embedded schema generation.
457    #[test]
458    fn test_spec_to_json() {
459        let spec = OpenApiBuilder::new("Test", "1.0.0").core_routes().build();
460
461        let json = spec.to_json().unwrap();
462        assert!(json.contains("\"openapi\": \"3.1.1\""));
463        assert!(json.contains("\"/ok\""));
464    }
465
466    // Rust-specific surface: `OpenApiBuilder` and `OpenApiSpec` are Rust-specific public APIs for embedded schema generation.
467    #[test]
468    fn test_spec_to_value() {
469        let spec = OpenApiBuilder::new("Test", "1.0.0").core_routes().build();
470
471        let value = spec.to_value().unwrap();
472        assert_eq!((*(value).get("openapi").unwrap_or(&Value::Null)), "3.1.1");
473        assert!(
474            (*(*(*(*(value).get("paths").unwrap_or(&Value::Null))
475                .get("/ok")
476                .unwrap_or(&Value::Null))
477            .get("get")
478            .unwrap_or(&Value::Null))
479            .get("operationId")
480            .unwrap_or(&Value::Null))
481            .is_string()
482        );
483    }
484}
485// LCOV_EXCL_STOP