alibi_api/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 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 ®istry,
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 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}