Skip to main content

openauth_core/api/
openapi.rs

1use std::collections::BTreeMap;
2
3use http::Method;
4use serde::{Deserialize, Serialize};
5use serde_json::{json, Value};
6
7use crate::context::AuthContext;
8
9use super::endpoint::AsyncAuthEndpoint;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct OpenApiOperation {
13    pub operation_id: Option<String>,
14    pub summary: Option<String>,
15    pub description: Option<String>,
16    pub tags: Vec<String>,
17    pub parameters: Vec<Value>,
18    pub request_body: Option<Value>,
19    pub responses: BTreeMap<String, Value>,
20}
21
22impl OpenApiOperation {
23    pub fn new(operation_id: impl Into<String>) -> Self {
24        Self {
25            operation_id: Some(operation_id.into()),
26            summary: None,
27            description: None,
28            tags: Vec::new(),
29            parameters: Vec::new(),
30            request_body: None,
31            responses: BTreeMap::new(),
32        }
33    }
34
35    #[must_use]
36    pub fn summary(mut self, summary: impl Into<String>) -> Self {
37        self.summary = Some(summary.into());
38        self
39    }
40
41    #[must_use]
42    pub fn description(mut self, description: impl Into<String>) -> Self {
43        self.description = Some(description.into());
44        self
45    }
46
47    #[must_use]
48    pub fn tag(mut self, tag: impl Into<String>) -> Self {
49        self.tags.push(tag.into());
50        self
51    }
52
53    #[must_use]
54    pub fn request_body(mut self, request_body: Value) -> Self {
55        self.request_body = Some(request_body);
56        self
57    }
58
59    #[must_use]
60    pub fn parameter(mut self, parameter: Value) -> Self {
61        self.parameters.push(parameter);
62        self
63    }
64
65    #[must_use]
66    pub fn response(mut self, status: impl Into<String>, response: Value) -> Self {
67        self.responses.insert(status.into(), response);
68        self
69    }
70}
71
72pub(super) fn openapi_operation_for_endpoint(endpoint: &AsyncAuthEndpoint) -> Value {
73    let mut operation = endpoint
74        .options
75        .openapi
76        .clone()
77        .unwrap_or_else(|| OpenApiOperation {
78            operation_id: endpoint.options.operation_id.clone(),
79            summary: None,
80            description: None,
81            tags: Vec::new(),
82            parameters: Vec::new(),
83            request_body: None,
84            responses: BTreeMap::new(),
85        });
86    let operation_id = operation
87        .operation_id
88        .clone()
89        .or_else(|| endpoint.options.operation_id.clone());
90    if operation.summary.is_none() {
91        operation.summary = operation_id.as_deref().map(humanize_operation_id);
92    }
93    if operation.description.is_none() {
94        operation.description = operation
95            .summary
96            .as_ref()
97            .map(|summary| format!("{summary} endpoint"));
98    }
99    add_missing_path_parameters(&mut operation.parameters, &endpoint.path);
100    let request_body = operation.request_body.or_else(|| {
101        endpoint
102            .options
103            .body_schema
104            .as_ref()
105            .map(|schema| {
106                json!({
107                    "required": true,
108                    "content": {
109                        "application/json": {
110                            "schema": schema.openapi_schema(),
111                        },
112                    },
113                })
114            })
115            .or_else(|| {
116                method_uses_request_body(&endpoint.method).then(|| {
117                    json!({
118                        "content": {
119                            "application/json": {
120                                "schema": {
121                                    "type": "object",
122                                    "properties": {},
123                                },
124                            },
125                        },
126                    })
127                })
128            })
129    });
130    let mut responses = default_openapi_responses();
131    for (status, response) in operation.responses {
132        responses.insert(status, response);
133    }
134    if !responses
135        .keys()
136        .any(|status| status.starts_with('2') || status.starts_with('3'))
137    {
138        responses.insert(
139            "200".to_owned(),
140            json_openapi_response(
141                "Success",
142                json!({
143                    "type": "object",
144                    "properties": {},
145                }),
146            ),
147        );
148    }
149    let mut tags = if operation.tags.is_empty() {
150        vec![tag_for_endpoint(endpoint, operation_id.as_deref())]
151    } else {
152        Vec::new()
153    };
154    for tag in operation.tags {
155        if !tags.iter().any(|existing| existing == &tag) {
156            tags.push(tag);
157        }
158    }
159
160    let mut value = serde_json::Map::new();
161    value.insert(
162        "tags".to_owned(),
163        Value::Array(tags.into_iter().map(Value::String).collect()),
164    );
165    if let Some(description) = operation.description {
166        value.insert("description".to_owned(), Value::String(description));
167    }
168    if let Some(summary) = operation.summary {
169        value.insert("summary".to_owned(), Value::String(summary));
170    }
171    if let Some(operation_id) = operation_id {
172        value.insert("operationId".to_owned(), Value::String(operation_id));
173    }
174    value.insert(
175        "security".to_owned(),
176        json!([
177            {
178                "bearerAuth": [],
179            },
180        ]),
181    );
182    value.insert("parameters".to_owned(), Value::Array(operation.parameters));
183    if let Some(request_body) = request_body {
184        value.insert("requestBody".to_owned(), request_body);
185    }
186    value.insert("responses".to_owned(), Value::Object(responses));
187    Value::Object(value)
188}
189
190fn add_missing_path_parameters(parameters: &mut Vec<Value>, path: &str) {
191    for name in path
192        .split('/')
193        .filter_map(|part| part.strip_prefix(':'))
194        .filter(|name| !name.is_empty())
195    {
196        let exists = parameters.iter().any(|parameter| {
197            parameter.get("name").and_then(Value::as_str) == Some(name)
198                && parameter.get("in").and_then(Value::as_str) == Some("path")
199        });
200        if !exists {
201            parameters.push(path_param(name, &format!("Path parameter `{name}`")));
202        }
203    }
204}
205
206fn humanize_operation_id(operation_id: &str) -> String {
207    let mut words = Vec::new();
208    let mut current = String::new();
209    for character in operation_id.chars() {
210        if character == '_' || character == '-' {
211            if !current.is_empty() {
212                words.push(std::mem::take(&mut current));
213            }
214            continue;
215        }
216        if character.is_uppercase() && !current.is_empty() {
217            words.push(std::mem::take(&mut current));
218        }
219        current.push(character.to_ascii_lowercase());
220    }
221    if !current.is_empty() {
222        words.push(current);
223    }
224
225    let mut summary = words.join(" ");
226    if let Some(first) = summary.get_mut(0..1) {
227        first.make_ascii_uppercase();
228    }
229    summary
230}
231
232fn tag_for_endpoint(endpoint: &AsyncAuthEndpoint, operation_id: Option<&str>) -> String {
233    if let Some(tag) = tag_for_operation_id(operation_id.unwrap_or_default()) {
234        return tag.to_owned();
235    }
236    let first_segment = endpoint
237        .path
238        .split('/')
239        .find(|segment| !segment.is_empty())
240        .unwrap_or_default();
241    tag_for_path_segment(first_segment)
242        .unwrap_or("Default")
243        .to_owned()
244}
245
246fn tag_for_operation_id(operation_id: &str) -> Option<&'static str> {
247    if operation_id.starts_with("mcp") || operation_id.starts_with("getMcp") {
248        Some("MCP")
249    } else if operation_id.contains("JWT")
250        || operation_id.contains("JSONWeb")
251        || operation_id.ends_with("JWT")
252    {
253        Some("JWT")
254    } else if operation_id.contains("OAuth2") {
255        Some("Generic OAuth")
256    } else if operation_id.contains("Siwe") {
257        Some("SIWE")
258    } else if operation_id.contains("PhoneNumber") {
259        Some("Phone Number")
260    } else if operation_id.contains("TwoFactor")
261        || operation_id.contains("BackupCode")
262        || operation_id.contains("Otp")
263    {
264        Some("Two Factor")
265    } else if operation_id.starts_with("organization") || operation_id.contains("Organization") {
266        Some("Organization")
267    } else {
268        None
269    }
270}
271
272fn tag_for_path_segment(segment: &str) -> Option<&'static str> {
273    match segment {
274        ".well-known" | "mcp" => Some("MCP"),
275        "admin" => Some("Admin"),
276        "anonymous" | "delete-anonymous-user" => Some("Anonymous"),
277        "device" | "device-authorization" => Some("Device Authorization"),
278        "email-otp" => Some("Email OTP"),
279        "oauth2" => Some("Generic OAuth"),
280        "jwt" | "jwks" | "token" => Some("JWT"),
281        "magic-link" => Some("Magic Link"),
282        "multi-session" => Some("Multi Session"),
283        "oauth-proxy" => Some("OAuth Proxy"),
284        "one-tap" => Some("One Tap"),
285        "one-time-token" => Some("One Time Token"),
286        "open-api" => Some("Open API"),
287        "organization" => Some("Organization"),
288        "phone-number" => Some("Phone Number"),
289        "siwe" => Some("SIWE"),
290        "two-factor" => Some("Two Factor"),
291        "username" => Some("Username"),
292        _ => None,
293    }
294}
295
296pub fn build_openapi_schema(context: &AuthContext, async_endpoints: &[AsyncAuthEndpoint]) -> Value {
297    let mut paths = serde_json::Map::new();
298    for endpoint in async_endpoints {
299        if endpoint.options.server_only || endpoint.options.hide_from_openapi {
300            continue;
301        }
302        let path = paths
303            .entry(to_openapi_path(&endpoint.path))
304            .or_insert_with(|| Value::Object(serde_json::Map::new()));
305        let Value::Object(methods) = path else {
306            continue;
307        };
308        methods.insert(
309            endpoint.method.as_str().to_ascii_lowercase(),
310            openapi_operation_for_endpoint(endpoint),
311        );
312    }
313    json!({
314        "openapi": "3.1.1",
315        "info": {
316            "title": "OpenAuth",
317            "description": "API Reference for your OpenAuth instance",
318            "version": crate::VERSION,
319        },
320        "components": {
321            "schemas": openapi_model_schemas(),
322            "securitySchemes": {
323                "apiKeyCookie": {
324                    "type": "apiKey",
325                    "in": "cookie",
326                    "name": "apiKeyCookie",
327                    "description": "API Key authentication via cookie",
328                },
329                "bearerAuth": {
330                    "type": "http",
331                    "scheme": "bearer",
332                    "description": "Bearer token authentication",
333                },
334            },
335        },
336        "security": [
337            {
338                "apiKeyCookie": [],
339                "bearerAuth": [],
340            },
341        ],
342        "servers": [
343            {
344                "url": context.base_url,
345            },
346        ],
347        "tags": [
348            {
349                "name": "Default",
350                "description": "Default endpoints that are included with OpenAuth by default. These endpoints are not part of any plugin.",
351            },
352        ],
353        "paths": paths,
354    })
355}
356
357fn method_uses_request_body(method: &Method) -> bool {
358    matches!(*method, Method::POST | Method::PATCH | Method::PUT)
359}
360
361pub(super) fn to_openapi_path(path: &str) -> String {
362    path.split('/')
363        .map(|part| {
364            part.strip_prefix(':')
365                .map(|name| format!("{{{name}}}"))
366                .unwrap_or_else(|| part.to_owned())
367        })
368        .collect::<Vec<_>>()
369        .join("/")
370}
371
372fn default_openapi_responses() -> serde_json::Map<String, Value> {
373    let mut responses = serde_json::Map::new();
374    responses.insert(
375        "400".to_owned(),
376        openapi_error_response(
377            "Bad Request. Usually due to missing parameters, or invalid parameters.",
378            true,
379        ),
380    );
381    responses.insert(
382        "401".to_owned(),
383        openapi_error_response(
384            "Unauthorized. Due to missing or invalid authentication.",
385            true,
386        ),
387    );
388    responses.insert(
389        "403".to_owned(),
390        openapi_error_response(
391            "Forbidden. You do not have permission to access this resource or to perform this action.",
392            false,
393        ),
394    );
395    responses.insert(
396        "404".to_owned(),
397        openapi_error_response("Not Found. The requested resource was not found.", false),
398    );
399    responses.insert(
400        "429".to_owned(),
401        openapi_error_response(
402            "Too Many Requests. You have exceeded the rate limit. Try again later.",
403            false,
404        ),
405    );
406    responses.insert(
407        "500".to_owned(),
408        openapi_error_response(
409            "Internal Server Error. This is a problem with the server that you cannot fix.",
410            false,
411        ),
412    );
413    responses
414}
415
416fn openapi_error_response(description: &str, require_message: bool) -> Value {
417    let required = require_message.then(|| json!(["message"]));
418    let mut schema = serde_json::Map::new();
419    schema.insert("type".to_owned(), Value::String("object".to_owned()));
420    schema.insert(
421        "properties".to_owned(),
422        json!({
423            "message": {
424                "type": "string",
425            },
426        }),
427    );
428    if let Some(required) = required {
429        schema.insert("required".to_owned(), required);
430    }
431    json!({
432        "content": {
433            "application/json": {
434                "schema": Value::Object(schema),
435            },
436        },
437        "description": description,
438    })
439}
440
441pub fn json_openapi_response(description: &str, schema: Value) -> Value {
442    json!({
443        "description": description,
444        "content": {
445            "application/json": {
446                "schema": schema,
447            },
448        },
449    })
450}
451
452pub fn empty_openapi_response(description: &str) -> Value {
453    json!({
454        "description": description,
455    })
456}
457
458pub fn redirect_openapi_response(description: &str) -> Value {
459    json!({
460        "description": description,
461        "headers": {
462            "Location": {
463                "description": "Redirect target",
464                "schema": {
465                    "type": "string",
466                    "format": "uri",
467                },
468            },
469        },
470    })
471}
472
473pub fn query_param(name: &str, description: &str) -> Value {
474    json!({
475        "name": name,
476        "in": "query",
477        "required": false,
478        "description": description,
479        "schema": {
480            "type": "string",
481        },
482    })
483}
484
485pub fn path_param(name: &str, description: &str) -> Value {
486    json!({
487        "name": name,
488        "in": "path",
489        "required": true,
490        "description": description,
491        "schema": {
492            "type": "string",
493        },
494    })
495}
496
497pub(super) fn openapi_model_schemas() -> Value {
498    json!({
499        "User": {
500            "type": "object",
501            "properties": {
502                "id": { "type": "string" },
503                "email": { "type": "string", "format": "email" },
504                "name": { "type": "string" },
505                "image": { "type": "string", "format": "uri", "nullable": true },
506                "emailVerified": { "type": "boolean" },
507                "createdAt": { "type": "string", "format": "date-time" },
508                "updatedAt": { "type": "string", "format": "date-time" },
509            },
510            "required": ["id", "email", "name", "emailVerified", "createdAt", "updatedAt"],
511        },
512        "Session": {
513            "type": "object",
514            "properties": {
515                "id": { "type": "string" },
516                "userId": { "type": "string" },
517                "expiresAt": { "type": "string", "format": "date-time" },
518                "token": { "type": "string" },
519                "ipAddress": { "type": "string", "nullable": true },
520                "userAgent": { "type": "string", "nullable": true },
521                "createdAt": { "type": "string", "format": "date-time" },
522                "updatedAt": { "type": "string", "format": "date-time" },
523            },
524            "required": ["id", "userId", "expiresAt", "token", "createdAt", "updatedAt"],
525        },
526        "Account": {
527            "type": "object",
528            "properties": {
529                "id": { "type": "string" },
530                "providerId": { "type": "string" },
531                "accountId": { "type": "string" },
532                "userId": { "type": "string" },
533                "accessToken": { "type": "string", "nullable": true },
534                "refreshToken": { "type": "string", "nullable": true },
535                "idToken": { "type": "string", "nullable": true },
536                "scope": { "type": "string", "nullable": true },
537                "password": { "type": "string", "nullable": true },
538                "createdAt": { "type": "string", "format": "date-time" },
539                "updatedAt": { "type": "string", "format": "date-time" },
540            },
541            "required": ["id", "providerId", "accountId", "userId", "createdAt", "updatedAt"],
542        },
543        "Verification": {
544            "type": "object",
545            "properties": {
546                "id": { "type": "string" },
547                "identifier": { "type": "string" },
548                "value": { "type": "string" },
549                "expiresAt": { "type": "string", "format": "date-time" },
550                "createdAt": { "type": "string", "format": "date-time" },
551                "updatedAt": { "type": "string", "format": "date-time" },
552            },
553            "required": ["id", "identifier", "value", "expiresAt", "createdAt", "updatedAt"],
554        },
555    })
556}