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 _ = 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 pub fn annotated(
182 mut self,
183 method: &HttpMethod,
184 path: &str,
185 plugin: &str,
186 core: bool,
187 metadata: &OpenApiEndpoint,
188 ) -> Self {
189 let method_name = match method {
190 HttpMethod::Get => "get",
191 HttpMethod::Post => "post",
192 HttpMethod::Put => "put",
193 HttpMethod::Delete => "delete",
194 HttpMethod::Patch => "patch",
195 HttpMethod::Options | HttpMethod::Head => return self,
196 };
197 if metadata.server_only {
198 return self;
199 }
200 let mut parameters = metadata.parameters.clone();
201 let documented_path = metadata.document_path.as_deref().unwrap_or(path);
202 let path = path_template(documented_path, &mut parameters);
203 let operation_id = metadata
204 .operation_id
205 .as_deref()
206 .map(|id| self.unique_operation_id(id, method_name))
207 .unwrap_or_default();
208 let mut responses = default_responses();
209 for (status, response) in &metadata.responses {
210 let mut fields = response.as_object().cloned().unwrap_or_default();
211 let description = fields
212 .remove("description")
213 .and_then(|value| value.as_str().map(str::to_owned))
214 .unwrap_or_default();
215 _ = responses.insert(
216 status.clone(),
217 OpenApiResponse {
218 description,
219 metadata: fields,
220 },
221 );
222 }
223 let tags = if core {
224 let mut tags = vec!["Default".into()];
225 tags.extend(metadata.tags.clone().unwrap_or_default());
226 tags
227 } else {
228 metadata
229 .tags
230 .clone()
231 .unwrap_or_else(|| vec![capitalize(plugin)])
232 };
233 let request_body = matches!(
234 method,
235 HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch
236 )
237 .then(|| {
238 metadata.request_body.clone().or_else(|| {
239 core.then(|| {
240 json!({"content":{"application/json":{"schema":{"type":"object","properties":{}}}}})
241 })
242 })
243 })
244 .flatten();
245 _ = self.spec.paths.entry(path).or_default().insert(
246 method_name.into(),
247 OpenApiOperation {
248 operation_id,
249 summary: String::new(),
250 description: metadata.description.clone(),
251 tags,
252 security: vec![json!({"bearerAuth":[]})],
253 parameters,
254 request_body,
255 responses,
256 },
257 );
258 self
259 }
260
261 fn unique_operation_id(&mut self, id: &str, method_name: &str) -> String {
263 let mut candidate = id.to_owned();
264 if self.used_ids.contains(&candidate) {
265 let suffix = capitalize(method_name);
266 candidate = format!("{id}{suffix}");
267 let mut index = 2;
268 while self.used_ids.contains(&candidate) {
269 candidate = format!("{id}{suffix}{index}");
270 index += 1;
271 }
272 }
273 _ = self.used_ids.insert(candidate.clone());
274 candidate
275 }
276
277 #[must_use]
278 pub fn route(self, method: &HttpMethod, path: &str, operation_id: &str, tag: &str) -> Self {
279 let metadata = OpenApiEndpoint {
280 operation_id: Some(operation_id.into()),
281 tags: Some(vec![tag.into()]),
282 ..Default::default()
283 };
284 self.annotated(method, path, tag, false, &metadata)
285 }
286
287 #[must_use]
289 pub fn plugin<S: AuthSchema>(mut self, plugin: &dyn AuthPlugin<S>) -> Self {
290 let routes = plugin.routes();
291 let metadata = plugin.static_openapi_metadata();
292 for route in routes {
293 let annotation = metadata
294 .endpoints
295 .iter()
296 .find(|(method, path, _)| *method == route.method && *path == route.path)
297 .map(|(_, _, metadata)| metadata.clone())
298 .unwrap_or_default();
299 self = self.annotated(
300 &route.method,
301 &route.path,
302 plugin.name(),
303 metadata.core,
304 &annotation,
305 );
306 }
307 self
308 }
309
310 #[must_use]
312 pub fn core_routes(self) -> Self {
313 self.route(&HttpMethod::Get, "/ok", "ok", "core")
314 .route(&HttpMethod::Get, "/error", "error", "core")
315 .route(&HttpMethod::Post, "/update-user", "update_user", "core")
316 }
317
318 #[must_use]
320 pub fn build(self) -> OpenApiSpec {
321 self.spec
322 }
323}
324
325impl OpenApiSpec {
326 pub fn to_json(&self) -> serde_json::Result<String> {
330 serde_json::to_string_pretty(self)
331 }
332 pub fn to_value(&self) -> serde_json::Result<Value> {
336 serde_json::to_value(self)
337 }
338}
339
340fn capitalize(text: &str) -> String {
341 let mut chars = text.chars();
342 chars
343 .next()
344 .map(|first| {
345 let mut name = first.to_uppercase().collect::<String>();
346 name.push_str(chars.as_str());
347 name
348 })
349 .unwrap_or_default()
350}
351
352fn path_template(path: &str, parameters: &mut Vec<Value>) -> String {
354 path.split('/')
355 .map(|segment| {
356 let name = segment.strip_prefix(':').or_else(|| {
357 segment
358 .strip_prefix('{')
359 .and_then(|name| name.strip_suffix('}'))
360 });
361 let Some(name) = name else {
362 return segment.to_owned();
363 };
364 if !parameters
365 .iter()
366 .any(|parameter| parameter["in"] == "path" && parameter["name"] == name)
367 {
368 parameters.push(
369 json!({"name":name,"in":"path","required":true,"schema":{"type":"string"}}),
370 );
371 }
372 format!("{{{name}}}")
373 })
374 .collect::<Vec<_>>()
375 .join("/")
376}
377
378fn default_responses() -> BTreeMap<String, OpenApiResponse> {
379 [
380 ("400", "Bad Request. Usually due to missing parameters, or invalid parameters."),
381 ("401", "Unauthorized. Due to missing or invalid authentication."),
382 ("403", "Forbidden. You do not have permission to access this resource or to perform this action."),
383 ("404", "Not Found. The requested resource was not found."),
384 ("429", "Too Many Requests. You have exceeded the rate limit. Try again later."),
385 ("500", "Internal Server Error. This is a problem with the server that you cannot fix."),
386 ]
387 .into_iter()
388 .map(|(status, description)| {
389 let mut schema = json!({"type":"object","properties":{"message":{"type":"string"}}});
390 if matches!(status, "400" | "401")
391 && let Some(object) = schema.as_object_mut()
392 {
393 _ = object.insert("required".into(), json!(["message"]));
394 }
395 let metadata = serde_json::Map::from_iter([(
396 "content".to_owned(),
397 json!({"application/json":{"schema":schema}}),
398 )]);
399 (
400 status.to_owned(),
401 OpenApiResponse {
402 description: description.to_owned(),
403 metadata,
404 },
405 )
406 })
407 .collect()
408}
409
410#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
417 fn test_builder_core_routes() {
418 let spec = OpenApiBuilder::new("Better Auth", "0.1.0")
419 .description("Authentication API")
420 .core_routes()
421 .build();
422
423 assert_eq!(spec.openapi, "3.1.1");
424 assert_eq!(spec.info.title, "Better Auth");
425 assert!(spec.paths.contains_key("/ok"));
426 assert!(spec.paths.contains_key("/error"));
427 assert!(spec.paths.contains_key("/update-user"));
428
429 let ok_path = (spec.paths)
431 .get("/ok")
432 .expect("fixture contains the requested index");
433 assert!(ok_path.contains_key("get"));
434 assert_eq!(ok_path["get"].operation_id, "ok");
435 }
436
437 #[test]
439 fn test_builder_custom_route() {
440 let spec = OpenApiBuilder::new("Test", "1.0.0")
441 .route(
442 &HttpMethod::Post,
443 "/sign-in/email",
444 "sign_in_email",
445 "email-password",
446 )
447 .build();
448
449 let path = (spec.paths)
450 .get("/sign-in/email")
451 .expect("fixture contains the requested index");
452 assert!(path.contains_key("post"));
453 assert_eq!(path["post"].tags, vec!["email-password"]);
454 }
455
456 #[test]
458 fn test_spec_to_json() {
459 let spec = OpenApiBuilder::new("Test", "1.0.0").core_routes().build();
460
461 let json = spec.to_json().unwrap();
462 assert!(json.contains("\"openapi\": \"3.1.1\""));
463 assert!(json.contains("\"/ok\""));
464 }
465
466 #[test]
468 fn test_spec_to_value() {
469 let spec = OpenApiBuilder::new("Test", "1.0.0").core_routes().build();
470
471 let value = spec.to_value().unwrap();
472 assert_eq!((*(value).get("openapi").unwrap_or(&Value::Null)), "3.1.1");
473 assert!(
474 (*(*(*(*(value).get("paths").unwrap_or(&Value::Null))
475 .get("/ok")
476 .unwrap_or(&Value::Null))
477 .get("get")
478 .unwrap_or(&Value::Null))
479 .get("operationId")
480 .unwrap_or(&Value::Null))
481 .is_string()
482 );
483 }
484}
485