Skip to main content

better_auth_core/openapi/
builder.rs

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/// Documentation supplied by an endpoint. The endpoint retains its own input validation.
12#[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}