Skip to main content

better_auth_api/plugins/open_api/
mod.rs

1//! Instance-specific schema generation and the upstream Scalar reference page.
2
3use async_trait::async_trait;
4use better_auth_core::{
5    AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
6    AuthSchema, HttpMethod, OpenApiSpec, openapi::OpenApiRegistry,
7};
8
9#[derive(better_auth_core::PluginConfig)]
10#[plugin(name = "OpenApiPlugin")]
11pub struct OpenApiConfig {
12    /// HTTP path for the Scalar reference page.
13    #[config(default = "/reference".to_owned())]
14    pub path: String,
15    /// Keep the reference endpoint registered but return an empty 404 response.
16    #[config(default = false)]
17    pub disable_default_reference: bool,
18    /// Scalar theme. An empty value selects the upstream default.
19    #[config(default = None)]
20    pub theme: Option<String>,
21    /// Nonce applied to the two executable scripts.
22    #[config(default = None)]
23    pub nonce: Option<String>,
24}
25
26pub struct OpenApiPlugin {
27    config: OpenApiConfig,
28}
29
30#[async_trait]
31impl<S: AuthSchema> AuthPlugin<S> for OpenApiPlugin {
32    fn name(&self) -> &'static str {
33        "open-api"
34    }
35
36    fn routes(&self) -> Vec<AuthRoute> {
37        vec![
38            AuthRoute::get("/open-api/generate-schema", "generateOpenAPISchema"),
39            AuthRoute::get(&self.config.path, "openAPIReference"),
40        ]
41    }
42
43    async fn on_request(
44        &self,
45        request: &AuthRequest,
46        context: &AuthContext<S>,
47    ) -> AuthResult<Option<AuthResponse>> {
48        if request.method() != &HttpMethod::Get {
49            return Ok(None);
50        }
51        if request.path() == "/open-api/generate-schema" {
52            let mut response =
53                AuthResponse::new(200).with_header("content-type", "application/json");
54            response.body = better_auth_core::ResponseBody::Bytes(
55                better_auth_core::utils::json::stringify(&schema(context)?.to_value()?)?
56                    .into_bytes(),
57            );
58            return Ok(Some(response));
59        }
60        if request.path() != self.config.path {
61            return Ok(None);
62        }
63        if self.config.disable_default_reference {
64            return Err(AuthResponse::new(404)
65                .with_header("content-type", "application/json")
66                .into());
67        }
68        let html = reference_html(&schema(context)?, &self.config)?;
69        let mut response = AuthResponse::html(200, html);
70        let _ = response.headers.insert("content-type", "text/html");
71        Ok(Some(response))
72    }
73}
74
75fn schema(context: &AuthContext<impl AuthSchema>) -> AuthResult<OpenApiSpec> {
76    context
77        .extensions
78        .get::<OpenApiRegistry>()
79        .map(|registry| registry.generate(context.base_url()))
80        .ok_or_else(|| AuthError::config("OpenAPI registry is not initialized"))
81}
82
83fn reference_html(spec: &OpenApiSpec, config: &OpenApiConfig) -> AuthResult<String> {
84    let nonce = config
85        .nonce
86        .as_deref()
87        .filter(|value| !value.is_empty())
88        .map(|value| format!("nonce=\"{value}\""))
89        .unwrap_or_default();
90    let theme = config
91        .theme
92        .as_deref()
93        .filter(|value| !value.is_empty())
94        .unwrap_or("default");
95    // Upstream treats theme and nonce as trusted application configuration.
96    Ok(format!(
97        include_str!("reference.html"),
98        schema = better_auth_core::utils::json::stringify(&spec.to_value()?)?,
99        // Preserve the upstream template's mixed indentation in the response bytes.
100        favicon_indent = "\t  \t",
101        theme_indent = "\t   \t",
102        theme = theme,
103        nonce = nonce,
104    ))
105}