1pub mod annotations;
2
3mod input_annotations;
4
5mod metadata;
6
7use crate::{AuthConfig, AuthPlugin, AuthSchema, HttpMethod};
8pub use metadata::{
9 OpenApiEndpoint, OpenApiField, OpenApiModel, OpenApiRegistry, PluginOpenApiMetadata,
10};
11use serde::Serialize;
12use serde_json::{Value, json};
13use std::collections::{BTreeMap, HashSet};
14
15#[derive(Debug, Serialize)]
16pub struct OpenApiSpec {
17 pub openapi: String,
18 pub info: OpenApiInfo,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub components: Option<Value>,
21 #[serde(skip_serializing_if = "Vec::is_empty")]
22 pub security: Vec<Value>,
23 #[serde(skip_serializing_if = "Vec::is_empty")]
24 pub servers: Vec<Value>,
25 #[serde(skip_serializing_if = "Vec::is_empty")]
26 pub tags: Vec<Value>,
27 pub paths: BTreeMap<String, BTreeMap<String, OpenApiOperation>>,
28}
29
30#[derive(Debug, Serialize)]
31pub struct OpenApiInfo {
32 pub title: String,
33 pub version: String,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub description: Option<String>,
36}
37
38#[derive(Debug, Clone, Serialize)]
39pub struct OpenApiOperation {
40 #[serde(rename = "operationId", skip_serializing_if = "String::is_empty")]
41 pub operation_id: String,
42 #[serde(skip_serializing_if = "String::is_empty")]
43 pub summary: String,
44 pub tags: Vec<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub description: Option<String>,
47 pub security: Vec<Value>,
48 pub parameters: Vec<Value>,
49 #[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
50 pub request_body: Option<Value>,
51 pub responses: BTreeMap<String, OpenApiResponse>,
52}
53
54#[derive(Debug, Clone, Serialize)]
55pub struct OpenApiResponse {
56 pub description: String,
57 #[serde(flatten)]
58 pub metadata: serde_json::Map<String, Value>,
59}
60
61#[derive(Debug)]
63pub struct OpenApiBuilder {
64 spec: OpenApiSpec,
65 used_ids: HashSet<String>,
66}
67
68impl OpenApiBuilder {
69 #[must_use]
70 pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
71 Self {
72 spec: OpenApiSpec {
73 openapi: "3.1.1".into(),
74 info: OpenApiInfo {
75 title: title.into(),
76 version: version.into(),
77 description: None,
78 },
79 components: None,
80 security: vec![],
81 servers: vec![],
82 tags: vec![],
83 paths: BTreeMap::new(),
84 },
85 used_ids: HashSet::new(),
86 }
87 }
88
89 #[must_use]
90 pub fn description(mut self, description: impl Into<String>) -> Self {
91 self.spec.info.description = Some(description.into());
92 self
93 }
94
95 #[must_use]
97 pub fn registered(config: &AuthConfig, registry: &OpenApiRegistry) -> Self {
98 Self::registered_with_native_extensions(config, registry, false)
99 }
100
101 #[must_use]
103 pub fn registered_with_native_extensions(
104 config: &AuthConfig,
105 registry: &OpenApiRegistry,
106 include_native_extensions: bool,
107 ) -> Self {
108 let mut builder = Self::new("Better Auth", "1.1.0")
109 .description("API Reference for your Better Auth Instance");
110 let mut schemas = serde_json::Map::new();
111 for model in registry.models.values() {
112 let mut model = model.clone();
113 if let Some(overrides) = registry
114 .core_overrides
115 .iter()
116 .find(|overrides| overrides.name == model.name)
117 {
118 for field in &overrides.fields {
119 if let Some(current) = model
120 .fields
121 .iter_mut()
122 .find(|current| current.name == field.name)
123 {
124 *current = field.clone();
125 } else {
126 model.fields.push(field.clone());
127 }
128 }
129 }
130 drop(schemas.insert(model.name.clone(), model.to_schema()));
131 }
132 builder.spec.components = Some(json!({"schemas":schemas,"securitySchemes":{
133 "apiKeyCookie":{"type":"apiKey","in":"cookie","name":"apiKeyCookie","description":"API Key authentication via cookie"},
134 "bearerAuth":{"type":"http","scheme":"bearer","description":"Bearer token authentication"}
135 }}));
136 builder.spec.security = vec![json!({"apiKeyCookie":[],"bearerAuth":[]})];
137 builder.spec.servers = vec![
138 json!({"url":format!("{}{}",config.base_url.trim_end_matches('/'),config.base_path)}),
139 ];
140 builder.spec.tags = vec![
141 json!({"name":"Default","description":"Default endpoints that are included with Better Auth by default. These endpoints are not part of any plugin."}),
142 ];
143 for core in [true, false] {
145 for endpoint in registry
146 .endpoints
147 .iter()
148 .filter(|endpoint| endpoint.core == core)
149 {
150 if (endpoint.plugin == "open-api" && !endpoint.metadata.native_extension)
151 || endpoint.metadata.server_only
152 || (endpoint.metadata.native_extension && !include_native_extensions)
153 || config.is_path_disabled(&endpoint.route.path)
154 || endpoint
155 .metadata
156 .document_path
157 .as_ref()
158 .is_some_and(|path| config.is_path_disabled(path))
159 {
160 continue;
161 }
162 let mut metadata = endpoint.metadata.clone();
163 input_annotations::apply(
164 &endpoint.route.path,
165 &mut metadata,
166 ®istry.user_input_fields,
167 );
168 builder = builder.annotated(
169 &endpoint.route.method,
170 &endpoint.route.path,
171 &endpoint.plugin,
172 endpoint.core,
173 &metadata,
174 );
175 }
176 }
177 builder
178 }
179
180 #[must_use]
181 #[expect(
182 clippy::too_many_lines,
183 reason = "Apply endpoint annotations in the same precedence order as the registered metadata"
184 )]
185 pub fn annotated(
186 mut self,
187 method: &HttpMethod,
188 path: &str,
189 plugin: &str,
190 core: bool,
191 metadata: &OpenApiEndpoint,
192 ) -> Self {
193 let method_name = match method {
194 HttpMethod::Get => "get",
195 HttpMethod::Post => "post",
196 HttpMethod::Put => "put",
197 HttpMethod::Delete => "delete",
198 HttpMethod::Patch => "patch",
199 HttpMethod::Options | HttpMethod::Head => return self,
200 };
201 if metadata.server_only {
202 return self;
203 }
204 let mut parameters = metadata.parameters.clone();
205 let documented_path = metadata.document_path.as_deref().unwrap_or(path);
206 let path_2=documented_path.split('/').map(|segment| {
207 segment.strip_prefix(':').or_else(|| segment.strip_prefix('{').and_then(|name| name.strip_suffix('}'))).map_or_else(|| segment.to_owned(), |name| {
208 if !parameters.iter().any(|parameter|parameter["in"]=="path" && parameter["name"]==name) { parameters.push(json!({"name":name,"in":"path","required":true,"schema":{"type":"string"}})); }
209 format!("{{{name}}}")
210 })
211 }).collect::<Vec<_>>().join("/");
212 let operation_id = metadata
213 .operation_id
214 .as_ref()
215 .map(|id| {
216 let mut candidate = id.clone();
217 if self.used_ids.contains(&candidate) {
218 let mut chars = method_name.chars();
219 let suffix = chars
220 .next()
221 .map(|first| {
222 let mut name = first.to_uppercase().collect::<String>();
223 name.push_str(chars.as_str());
224 name
225 })
226 .unwrap_or_default();
227 candidate = format!("{id}{suffix}");
228 let mut index = 2;
229 while self.used_ids.contains(&candidate) {
230 candidate = format!("{id}{suffix}{index}");
231 index += 1;
232 }
233 }
234 let _ignored_clone = self.used_ids.insert(candidate.clone());
235 candidate
236 })
237 .unwrap_or_default();
238 let mut responses = default_responses();
239 for (status, response) in &metadata.responses {
240 let mut fields = response.as_object().cloned().unwrap_or_default();
241 let description = fields
242 .remove("description")
243 .and_then(|value| value.as_str().map(str::to_owned))
244 .unwrap_or_default();
245 drop(responses.insert(
246 status.clone(),
247 OpenApiResponse {
248 description,
249 metadata: fields,
250 },
251 ));
252 }
253 let tags = if core {
254 let mut tags = vec!["Default".into()];
255 tags.extend(metadata.tags.clone().unwrap_or_default());
256 tags
257 } else {
258 metadata.tags.clone().unwrap_or_else(|| {
259 let mut chars = plugin.chars();
260 vec![
261 chars
262 .next()
263 .map(|first| {
264 let mut name = first.to_uppercase().collect::<String>();
265 name.push_str(chars.as_str());
266 name
267 })
268 .unwrap_or_default(),
269 ]
270 })
271 };
272 let mut request_body = if matches!(
273 method,
274 HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch
275 ) {
276 metadata.request_body.clone()
277 } else {
278 None
279 };
280 if core
281 && request_body.is_none()
282 && matches!(
283 method,
284 HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch
285 )
286 {
287 request_body = Some(
288 json!({"content":{"application/json":{"schema":{"type":"object","properties":{}}}}}),
289 );
290 }
291 drop(self.spec.paths.entry(path_2).or_default().insert(
292 method_name.into(),
293 OpenApiOperation {
294 operation_id,
295 summary: String::new(),
296 description: metadata.description.clone(),
297 tags,
298 security: vec![json!({"bearerAuth":[]})],
299 parameters,
300 request_body,
301 responses,
302 },
303 ));
304 self
305 }
306
307 #[must_use]
308 pub fn route(self, method: &HttpMethod, path: &str, operation_id: &str, tag: &str) -> Self {
309 let metadata = OpenApiEndpoint {
310 operation_id: Some(operation_id.into()),
311 tags: Some(vec![tag.into()]),
312 ..Default::default()
313 };
314 self.annotated(method, path, tag, false, &metadata)
315 }
316
317 #[must_use]
319 pub fn plugin<S: AuthSchema>(mut self, plugin: &dyn AuthPlugin<S>) -> Self {
320 let routes = plugin.routes();
321 let metadata = plugin.static_openapi_metadata();
322 for route in routes {
323 let annotation = metadata
324 .endpoints
325 .iter()
326 .find(|(method, path, _)| *method == route.method && *path == route.path)
327 .map(|(_, _, metadata)| metadata.clone())
328 .unwrap_or_default();
329 self = self.annotated(
330 &route.method,
331 &route.path,
332 plugin.name(),
333 metadata.core,
334 &annotation,
335 );
336 }
337 self
338 }
339
340 #[must_use]
342 pub fn core_routes(self) -> Self {
343 self.route(&HttpMethod::Get, "/ok", "ok", "core")
344 .route(&HttpMethod::Get, "/error", "error", "core")
345 .route(&HttpMethod::Post, "/update-user", "update_user", "core")
346 }
347
348 #[must_use]
350 pub fn build(self) -> OpenApiSpec {
351 self.spec
352 }
353}
354
355impl OpenApiSpec {
356 pub fn to_json(&self) -> serde_json::Result<String> {
361 serde_json::to_string_pretty(self)
362 }
363 pub fn to_value(&self) -> serde_json::Result<Value> {
368 serde_json::to_value(self)
369 }
370}
371
372fn default_responses() -> BTreeMap<String, OpenApiResponse> {
373 [
374 ("400","Bad Request. Usually due to missing parameters, or invalid parameters."),
375 ("401","Unauthorized. Due to missing or invalid authentication."),
376 ("403","Forbidden. You do not have permission to access this resource or to perform this action."),
377 ("404","Not Found. The requested resource was not found."),
378 ("429","Too Many Requests. You have exceeded the rate limit. Try again later."),
379 ("500","Internal Server Error. This is a problem with the server that you cannot fix."),
380 ].into_iter().map(|(status,description)| {
381 let mut schema=json!({"type":"object","properties":{"message":{"type":"string"}}});
382 if (status=="400" || status=="401") && let Some(object)=schema.as_object_mut() {drop(object.insert("required".into(),json!(["message"])));}
383 let mut metadata=serde_json::Map::new();drop(metadata.insert("content".into(),json!({"application/json":{"schema":schema}})));
384 (status.into(),OpenApiResponse {description:description.into(),metadata})
385 }).collect()
386}
387
388#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
395 fn test_builder_core_routes() {
396 let spec = OpenApiBuilder::new("Better Auth", "0.1.0")
397 .description("Authentication API")
398 .core_routes()
399 .build();
400
401 assert_eq!(spec.openapi, "3.1.1");
402 assert_eq!(spec.info.title, "Better Auth");
403 assert!(spec.paths.contains_key("/ok"));
404 assert!(spec.paths.contains_key("/error"));
405 assert!(spec.paths.contains_key("/update-user"));
406
407 let ok_path = (spec.paths)
409 .get("/ok")
410 .expect("fixture contains the requested index");
411 assert!(ok_path.contains_key("get"));
412 assert_eq!(ok_path["get"].operation_id, "ok");
413 }
414
415 #[test]
417 fn test_builder_custom_route() {
418 let spec = OpenApiBuilder::new("Test", "1.0.0")
419 .route(
420 &HttpMethod::Post,
421 "/sign-in/email",
422 "sign_in_email",
423 "email-password",
424 )
425 .build();
426
427 let path = (spec.paths)
428 .get("/sign-in/email")
429 .expect("fixture contains the requested index");
430 assert!(path.contains_key("post"));
431 assert_eq!(path["post"].tags, vec!["email-password"]);
432 }
433
434 #[test]
436 fn test_spec_to_json() {
437 let spec = OpenApiBuilder::new("Test", "1.0.0").core_routes().build();
438
439 let json = spec.to_json().unwrap();
440 assert!(json.contains("\"openapi\": \"3.1.1\""));
441 assert!(json.contains("\"/ok\""));
442 }
443
444 #[test]
446 fn test_spec_to_value() {
447 let spec = OpenApiBuilder::new("Test", "1.0.0").core_routes().build();
448
449 let value = spec.to_value().unwrap();
450 assert_eq!((*(value).get("openapi").unwrap_or(&Value::Null)), "3.1.1");
451 assert!(
452 (*(*(*(*(value).get("paths").unwrap_or(&Value::Null))
453 .get("/ok")
454 .unwrap_or(&Value::Null))
455 .get("get")
456 .unwrap_or(&Value::Null))
457 .get("operationId")
458 .unwrap_or(&Value::Null))
459 .is_string()
460 );
461 }
462}
463