Skip to main content

alibi_plugins/open_api/
mod.rs

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