Skip to main content

alibi_api/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        if req.method() != &HttpMethod::Get
97            || (req.path() != "/open-api/generate-schema"
98                && req.path() != core_paths::OPENAPI_SPEC
99                && req.path() != self.path())
100        {
101            return Ok(None);
102        }
103        if req.path() != "/open-api/generate-schema"
104            && req.path() != core_paths::OPENAPI_SPEC
105            && self.config.disable_default_reference
106        {
107            return Ok(Some(
108                AuthResponse::new(404).with_header("content-type", "application/json"),
109            ));
110        }
111        let registry = ctx
112            .extensions
113            .get::<OpenApiRegistry>()
114            .ok_or_else(|| AuthError::internal("OpenAPI registry is not initialized"))?;
115        let spec = OpenApiBuilder::registered_with_native_extensions(
116            &ctx.config,
117            &registry,
118            req.path() != core_paths::OPENAPI_SPEC && self.config.include_native_extensions,
119        )
120        .build();
121        if req.path() == "/open-api/generate-schema" || req.path() == core_paths::OPENAPI_SPEC {
122            return Ok(Some(AuthResponse::json(200, &spec)?));
123        }
124        let schema = serde_json::to_string(&spec)?;
125        Ok(Some(
126            AuthResponse::html(200, reference_html(&schema, &self.config))
127                .with_header("content-type", "text/html"),
128        ))
129    }
130}
131fn reference_html(schema: &str, config: &OpenApiConfig) -> String {
132    let nonce = config
133        .nonce
134        .as_deref()
135        .filter(|nonce| !nonce.is_empty())
136        .map(|nonce| format!("nonce=\"{nonce}\""))
137        .unwrap_or_default();
138    let theme = config
139        .theme
140        .as_deref()
141        .filter(|theme| !theme.is_empty())
142        .unwrap_or("default");
143    // The bundled upstream brand asset is MIT-licensed Better Auth artwork.
144    let favicon = encode_uri_component(include_str!("logo.svg"));
145    format!(
146        r#"<!doctype html>
147<html>
148  <head>
149    <title>Scalar API Reference</title>
150    <meta charset="utf-8" />
151    <meta
152      name="viewport"
153      content="width=device-width, initial-scale=1" />
154  </head>
155  <body>
156    <script
157      id="api-reference"
158      type="application/json">
159    {schema}
160    </script>
161	 <script {nonce}>
162      var configuration = {{
163	  	favicon: "data:image/svg+xml;utf8,{favicon}",
164	   	theme: "{theme}",
165        metaData: {{
166			title: "Better Auth API",
167			description: "API Reference for your Better Auth Instance",
168		}}
169      }}
170
171      document.getElementById('api-reference').dataset.configuration =
172        JSON.stringify(configuration)
173    </script>
174	  <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference" {nonce}></script>
175  </body>
176</html>"#
177    )
178}
179fn encode_uri_component(input: &str) -> String {
180    let mut output = String::new();
181
182    for byte in input.bytes() {
183        if byte.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&byte) {
184            output.push(char::from(byte));
185        } else {
186            output.push('%');
187            output.push(hex_digit(byte >> 4));
188            output.push(hex_digit(byte & 15));
189        }
190    }
191    output
192}
193
194fn hex_digit(digit: u8) -> char {
195    char::from(if digit < 10 {
196        b'0' + digit
197    } else {
198        b'A' + digit - 10
199    })
200}