alibi_plugins/open_api/
mod.rs1use 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 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 ®istry,
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 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}