1use 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 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 #[must_use]
61 pub fn generate_open_api_schema_endpoint() -> ServerEndpoint<serde_json::Value> {
62 ServerEndpoint::new("open-api", "generateOpenAPISchema")
63 }
64
65 #[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 ®istry,
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 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}