1use std::collections::HashSet;
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value, json};
6
7use crate::{HttpMethod, user_fields::UserConfig};
8
9use super::metadata::{ordered_properties, property_key_order};
10
11#[derive(Clone, Debug, Default, Deserialize, Serialize)]
13#[serde(rename_all = "camelCase")]
14pub struct OpenApiRouteMetadata {
15 pub description: Option<String>,
16 pub operation_id: Option<String>,
17 pub tags: Option<Vec<String>>,
18 pub parameters: Option<Vec<Value>>,
19 pub request_body: Option<Value>,
20 #[serde(default)]
21 pub responses: Map<String, Value>,
22 #[serde(default)]
23 pub server_only: bool,
24}
25
26#[derive(Debug, Serialize)]
27pub struct OpenApiSpec {
28 pub openapi: String,
29 pub info: OpenApiInfo,
30 pub components: Value,
31 pub security: Vec<Value>,
32 pub servers: Vec<Value>,
33 pub tags: Vec<Value>,
34 pub paths: IndexMap<String, IndexMap<String, Value>>,
35}
36
37#[derive(Debug, Serialize)]
38pub struct OpenApiInfo {
39 pub title: String,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub description: Option<String>,
42 pub version: String,
43}
44
45pub struct OpenApiBuilder {
46 spec: OpenApiSpec,
47 used_ids: HashSet<String>,
48 models: Map<String, Value>,
49 user_input: Map<String, Value>,
50 user_required: Vec<String>,
51}
52
53impl OpenApiBuilder {
54 pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
55 Self {
56 spec: OpenApiSpec {
57 openapi: "3.1.1".into(),
58 info: OpenApiInfo {
59 title: title.into(),
60 version: version.into(),
61 description: None,
62 },
63 components: Value::Null,
64 security: vec![json!({"apiKeyCookie":[],"bearerAuth":[]})],
65 servers: Vec::new(),
66 tags: vec![
67 json!({"name":"Default","description":"Default endpoints that are included with Better Auth by default. These endpoints are not part of any plugin."}),
68 ],
69 paths: IndexMap::new(),
70 },
71 used_ids: HashSet::new(),
72 models: Map::new(),
73 user_input: Map::new(),
74 user_required: Vec::new(),
75 }
76 }
77
78 pub fn description(mut self, description: impl Into<String>) -> Self {
79 self.spec.info.description = Some(description.into());
80 self
81 }
82
83 pub fn base_url(mut self, base_url: &str) -> Self {
84 self.spec.servers = vec![json!({"url":base_url})];
85 self
86 }
87
88 pub fn user_input(self, mut fields: UserConfig) -> crate::AuthResult<Self> {
89 let mut properties = Map::new();
90 let mut required = Vec::new();
91 fields
92 .fields_mut()
93 .sort_by(|left, _, right, _| property_key_order(left).cmp(&property_key_order(right)));
94 for (name, field) in fields.fields() {
95 if !field.input() {
96 continue;
97 }
98 let _ = properties.insert(name.clone(), super::field_schema::input_property(field)?);
99 if field.required == Some(true)
100 && field.default_value.is_none()
101 && field.default_value_fn.is_none()
102 {
103 required.push(name.clone());
104 }
105 }
106 Ok(self.user_input_properties(properties, required))
107 }
108
109 pub fn user_input_properties(
110 mut self,
111 properties: Map<String, Value>,
112 required: Vec<String>,
113 ) -> Self {
114 self.user_input = properties;
115 self.user_required = required;
116 self
117 }
118
119 pub fn model(self, logical_name: &str, fields: &UserConfig) -> crate::AuthResult<Self> {
120 let mut properties = Map::new();
121 let mut required = Vec::new();
122 let mut fields: Vec<_> = fields.fields().iter().collect();
123 fields.sort_by_key(|(name, _)| property_key_order(name));
124 for (name, field) in fields {
125 let _ = properties.insert(
126 name.clone(),
127 super::field_schema::component_property(field)?,
128 );
129 if field.required == Some(true) && field.returned() && !required.contains(name) {
130 required.push(name.clone());
131 }
132 }
133 Ok(self.model_properties(logical_name, properties, required))
134 }
135
136 pub fn model_properties(
137 mut self,
138 logical_name: &str,
139 fields: Map<String, Value>,
140 required_fields: Vec<String>,
141 ) -> Self {
142 let mut properties =
143 Map::from_iter([("id".into(), json!({"type":"string","readOnly":true}))]);
144 properties.extend(fields);
145 let properties = ordered_properties(properties);
146 let mut required = vec!["id".to_owned()];
147 for name in required_fields {
148 if !required.contains(&name) {
149 required.push(name);
150 }
151 }
152 let name = capitalize(logical_name);
153 let _ = self.models.insert(
154 name,
155 json!({"type":"object","properties":properties,"required":required}),
156 );
157 self
158 }
159
160 pub fn documented_route(
161 mut self,
162 method: &HttpMethod,
163 path: &str,
164 plugin: Option<&str>,
165 metadata: &OpenApiRouteMetadata,
166 ) -> Self {
167 let method_name = match method {
168 HttpMethod::Get => "get",
169 HttpMethod::Delete => "delete",
170 HttpMethod::Post => "post",
171 HttpMethod::Put => "put",
172 HttpMethod::Patch => "patch",
173 HttpMethod::Options | HttpMethod::Head => return self,
174 };
175 if metadata.server_only {
176 return self;
177 }
178 let tags = match plugin {
179 None => std::iter::once("Default".to_owned())
180 .chain(metadata.tags.clone().unwrap_or_default())
181 .collect(),
182 Some(plugin) => metadata
183 .tags
184 .clone()
185 .unwrap_or_else(|| vec![capitalize(plugin)]),
186 };
187 let mut parameters = metadata.parameters.clone().unwrap_or_default();
188 for parameter in path.split('/').filter_map(|segment| {
189 segment.strip_prefix(':').or_else(|| {
190 segment
191 .strip_prefix('{')
192 .and_then(|segment| segment.strip_suffix('}'))
193 })
194 }) {
195 if !parameters.iter().any(|value| {
196 value.get("in").and_then(Value::as_str) == Some("path")
197 && value.get("name").and_then(Value::as_str) == Some(parameter)
198 }) {
199 parameters.push(json!({"name":parameter,"in":"path","required":true,"schema":{"type":"string"}}));
200 }
201 }
202 let mut operation = Map::from_iter([("tags".into(), json!(tags))]);
203 if let Some(description) = &metadata.description {
204 let _ = operation.insert("description".into(), json!(description));
205 }
206 if let Some(id) = metadata.operation_id.as_deref().filter(|id| !id.is_empty()) {
207 let _ = operation.insert(
208 "operationId".into(),
209 json!(self.operation_id(id, method_name)),
210 );
211 }
212 let _ = operation.insert("security".into(), json!([{"bearerAuth":[]}]));
213 let _ = operation.insert("parameters".into(), json!(parameters));
214 if matches!(
215 method,
216 HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch
217 ) {
218 let mut body = metadata.request_body.clone();
219 if plugin.is_none() {
220 body = self.apply_user_input(path, body);
221 if body.is_none() {
222 body = Some(
223 json!({"content":{"application/json":{"schema":{"type":"object","properties":{}}}}}),
224 );
225 }
226 }
227 if let Some(body) = body {
228 let _ = operation.insert("requestBody".into(), body);
229 }
230 }
231 let _ = operation.insert("responses".into(), responses(&metadata.responses));
232 let path = path
233 .split('/')
234 .map(|segment| {
235 segment
236 .strip_prefix(':')
237 .map_or_else(|| segment.to_owned(), |name| format!("{{{name}}}"))
238 })
239 .collect::<Vec<_>>()
240 .join("/");
241 let _ = self
242 .spec
243 .paths
244 .entry(path)
245 .or_default()
246 .insert(method_name.into(), Value::Object(operation));
247 self
248 }
249
250 fn operation_id(&mut self, id: &str, method: &str) -> String {
251 if self.used_ids.insert(id.to_owned()) {
252 return id.to_owned();
253 }
254 let prefix = format!("{id}{}", capitalize(method));
255 let mut candidate = prefix.clone();
256 let mut suffix = 2;
257 while !self.used_ids.insert(candidate.clone()) {
258 candidate = format!("{prefix}{suffix}");
259 suffix += 1;
260 }
261 candidate
262 }
263
264 fn apply_user_input(&self, path: &str, body: Option<Value>) -> Option<Value> {
265 if !matches!(path, "/sign-up/email" | "/update-user") {
266 return body;
267 }
268 if self.user_input.is_empty() {
269 return body;
270 }
271 let body = body.unwrap_or_else(|| json!({}));
272 let mut schema = body
273 .pointer("/content/application~1json/schema")
274 .and_then(Value::as_object)
275 .cloned()
276 .unwrap_or_default();
277 let mut body = body.as_object().cloned().unwrap_or_default();
278 if schema.get("type").is_none_or(Value::is_null) {
279 let _ = schema.insert("type".into(), json!("object"));
280 }
281 let mut properties = schema
282 .get("properties")
283 .and_then(Value::as_object)
284 .cloned()
285 .unwrap_or_default();
286 let mut required = schema
287 .get("required")
288 .and_then(Value::as_array)
289 .cloned()
290 .unwrap_or_default();
291 for (name, field) in &self.user_input {
292 let _ = properties
293 .entry(name.clone())
294 .or_insert_with(|| field.clone());
295 }
296 if path == "/sign-up/email" {
297 for name in &self.user_required {
298 let name = json!(name);
299 if !required.contains(&name) {
300 required.push(name);
301 }
302 }
303 }
304 let _ = schema.insert("properties".into(), json!(ordered_properties(properties)));
305 if !required.is_empty() {
306 let _ = schema.insert("required".into(), json!(required));
307 }
308 let _ = body.insert(
309 "content".into(),
310 json!({"application/json":{"schema":schema}}),
311 );
312 Some(Value::Object(body))
313 }
314
315 pub fn build(mut self) -> OpenApiSpec {
316 self.spec.components = json!({
317 "schemas": ordered_properties(self.models),
318 "securitySchemes": {
319 "apiKeyCookie": {"type":"apiKey","in":"cookie","name":"apiKeyCookie","description":"API Key authentication via cookie"},
320 "bearerAuth": {"type":"http","scheme":"bearer","description":"Bearer token authentication"},
321 },
322 });
323 self.spec
324 }
325}
326
327fn capitalize(value: &str) -> String {
328 let mut chars = value.chars();
329 chars.next().map_or_else(String::new, |first| {
330 if first.len_utf16() == 2 {
331 value.to_owned()
332 } else {
333 first.to_uppercase().chain(chars).collect()
334 }
335 })
336}
337
338fn responses(explicit: &Map<String, Value>) -> Value {
339 let mut responses = Map::new();
340 for (status, description, required) in [
341 (
342 "400",
343 "Bad Request. Usually due to missing parameters, or invalid parameters.",
344 true,
345 ),
346 (
347 "401",
348 "Unauthorized. Due to missing or invalid authentication.",
349 true,
350 ),
351 (
352 "403",
353 "Forbidden. You do not have permission to access this resource or to perform this action.",
354 false,
355 ),
356 (
357 "404",
358 "Not Found. The requested resource was not found.",
359 false,
360 ),
361 (
362 "429",
363 "Too Many Requests. You have exceeded the rate limit. Try again later.",
364 false,
365 ),
366 (
367 "500",
368 "Internal Server Error. This is a problem with the server that you cannot fix.",
369 false,
370 ),
371 ] {
372 let mut schema = Map::from_iter([
373 ("type".into(), json!("object")),
374 ("properties".into(), json!({"message":{"type":"string"}})),
375 ]);
376 if required {
377 let _ = schema.insert("required".into(), json!(["message"]));
378 }
379 let _ = responses.insert(
380 status.into(),
381 json!({"content":{"application/json":{"schema":schema}},"description":description}),
382 );
383 }
384 responses.extend(explicit.clone());
385 Value::Object(ordered_properties(responses))
386}
387
388impl OpenApiSpec {
389 pub fn to_json(&self) -> serde_json::Result<String> {
390 serde_json::to_string_pretty(self)
391 }
392 pub fn to_value(&self) -> serde_json::Result<Value> {
393 serde_json::to_value(self)
394 }
395}