Skip to main content

alibi_plugins/open_api/
mod.rs

1//! `OpenAPI` schema generation and Scalar reference page.
2use alibi_core::{
3    AuthContext, AuthError, AuthInitContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult,
4    AuthRoute, AuthSchema, HttpMethod, OpenApiBuilder, OpenApiEndpoint, OpenApiRegistry,
5    PluginOpenApiMetadata, core_paths,
6};
7use async_trait::async_trait;
8
9#[derive(Debug, Clone, Default)]
10pub struct OpenApiConfig {
11    pub path: Option<String>,
12    pub theme: Option<String>,
13    pub nonce: Option<String>,
14    pub disable_default_reference: bool,
15    /// Include native Rust extensions; defaults to pinned-equivalent documentation.
16    pub include_native_extensions: bool,
17}
18impl OpenApiConfig {
19    #[must_use]
20    pub fn path(mut self, path: impl Into<String>) -> Self {
21        self.path = Some(path.into());
22        self
23    }
24    #[must_use]
25    pub fn theme(mut self, theme: impl Into<String>) -> Self {
26        self.theme = Some(theme.into());
27        self
28    }
29    #[must_use]
30    pub fn nonce(mut self, nonce: impl Into<String>) -> Self {
31        self.nonce = Some(nonce.into());
32        self
33    }
34    #[must_use]
35    pub const fn include_native_extensions(mut self, included: bool) -> Self {
36        self.include_native_extensions = included;
37        self
38    }
39    #[must_use]
40    pub const fn disable_default_reference(mut self, disabled: bool) -> Self {
41        self.disable_default_reference = disabled;
42        self
43    }
44}
45#[derive(Debug, Clone, Default)]
46pub struct OpenApiPlugin {
47    config: OpenApiConfig,
48}
49impl OpenApiPlugin {
50    #[must_use]
51    pub fn new() -> Self {
52        Self::default()
53    }
54    #[must_use]
55    pub const fn with_config(config: OpenApiConfig) -> Self {
56        Self { config }
57    }
58    fn path(&self) -> &str {
59        self.config.path.as_deref().unwrap_or("/reference")
60    }
61}
62#[async_trait]
63impl<S: AuthSchema> AuthPlugin<S> for OpenApiPlugin {
64    fn static_openapi_metadata(&self) -> alibi_core::PluginOpenApiMetadata {
65        crate::metadata::plugin_metadata(
66            <Self as alibi_core::AuthPlugin<S>>::name(self),
67            &<Self as alibi_core::AuthPlugin<S>>::routes(self),
68        )
69    }
70
71    fn name(&self) -> &'static str {
72        "open-api"
73    }
74    fn routes(&self) -> Vec<AuthRoute> {
75        vec![
76            AuthRoute::get(core_paths::OPENAPI_SPEC, "openapi_spec"),
77            AuthRoute::get("/open-api/generate-schema", "generate_open_api_schema"),
78            AuthRoute::get(self.path(), "open_api_reference"),
79        ]
80    }
81    fn openapi_metadata(&self, _ctx: &AuthInitContext<S>) -> PluginOpenApiMetadata {
82        PluginOpenApiMetadata::default().endpoint(
83            HttpMethod::Get,
84            core_paths::OPENAPI_SPEC,
85            OpenApiEndpoint {
86                native_extension: true,
87                ..Default::default()
88            },
89        )
90    }
91    async fn on_request(
92        &self,
93        req: &AuthRequest,
94        ctx: &AuthContext<S>,
95    ) -> AuthResult<Option<AuthResponse>> {
96        let path = req.path();
97        let is_schema = path == "/open-api/generate-schema" || path == core_paths::OPENAPI_SPEC;
98        if req.method() != &HttpMethod::Get || !(is_schema || path == self.path()) {
99            return Ok(None);
100        }
101        if !is_schema && self.config.disable_default_reference {
102            return Ok(Some(
103                AuthResponse::new(404).with_header("content-type", "application/json"),
104            ));
105        }
106        let registry = ctx
107            .extensions
108            .get::<OpenApiRegistry>()
109            .ok_or_else(|| AuthError::internal("OpenAPI registry is not initialized"))?;
110        let spec = OpenApiBuilder::registered_with_native_extensions(
111            &ctx.config,
112            &registry,
113            path != core_paths::OPENAPI_SPEC && self.config.include_native_extensions,
114        )
115        .build();
116        if is_schema {
117            return Ok(Some(AuthResponse::json(200, &spec)?));
118        }
119        let schema = serde_json::to_string(&spec)?;
120        Ok(Some(
121            AuthResponse::html(200, reference_html(&schema, &self.config))
122                .with_header("content-type", "text/html"),
123        ))
124    }
125}
126fn reference_html(schema: &str, config: &OpenApiConfig) -> String {
127    let nonce = config
128        .nonce
129        .as_deref()
130        .filter(|nonce| !nonce.is_empty())
131        .map(|nonce| format!("nonce=\"{nonce}\""))
132        .unwrap_or_default();
133    let theme = config
134        .theme
135        .as_deref()
136        .filter(|theme| !theme.is_empty())
137        .unwrap_or("default");
138    // The bundled upstream brand asset is MIT-licensed Better Auth artwork.
139    let favicon = encode_uri_component(include_str!("logo.svg"));
140    format!(
141        r#"<!doctype html>
142<html>
143  <head>
144    <title>Scalar API Reference</title>
145    <meta charset="utf-8" />
146    <meta
147      name="viewport"
148      content="width=device-width, initial-scale=1" />
149  </head>
150  <body>
151    <script
152      id="api-reference"
153      type="application/json">
154    {schema}
155    </script>
156	 <script {nonce}>
157      var configuration = {{
158	  	favicon: "data:image/svg+xml;utf8,{favicon}",
159	   	theme: "{theme}",
160        metaData: {{
161			title: "Better Auth API",
162			description: "API Reference for your Better Auth Instance",
163		}}
164      }}
165
166      document.getElementById('api-reference').dataset.configuration =
167        JSON.stringify(configuration)
168    </script>
169	  <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference" {nonce}></script>
170  </body>
171</html>"#
172    )
173}
174fn encode_uri_component(input: &str) -> String {
175    let mut output = String::new();
176
177    for byte in input.bytes() {
178        if byte.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&byte) {
179            output.push(char::from(byte));
180        } else {
181            output.push('%');
182            output.push(hex_digit(byte >> 4));
183            output.push(hex_digit(byte & 15));
184        }
185    }
186    output
187}
188
189fn hex_digit(digit: u8) -> char {
190    char::from(if digit < 10 {
191        b'0' + digit
192    } else {
193        b'A' + digit - 10
194    })
195}