Skip to main content

alibi_api/metadata/
mod.rs

1//! Built-in endpoint and model declarations owned by the feature crate.
2mod account_annotations;
3mod email_annotations;
4mod model_annotations;
5mod oauth_annotations;
6mod password_annotations;
7mod session_annotations;
8mod sign_in_annotations;
9mod user_annotations;
10#[rustfmt::skip]
11#[allow(warnings, clippy::all, clippy::pedantic, clippy::nursery, clippy::restriction, reason = "Generated API declarations retain the upstream generator output")]
12mod source_endpoints;
13#[rustfmt::skip]
14#[allow(warnings, clippy::all, clippy::pedantic, clippy::nursery, clippy::restriction, reason = "Generated API declarations retain the upstream generator output")]
15mod source_models;
16use alibi_core::AuthRoute;
17use alibi_core::{OpenApiEndpoint, OpenApiField, OpenApiModel, PluginOpenApiMetadata};
18use alibi_schema_registry::{FieldDef, plugin_schemas};
19use serde_json::{Value, json};
20fn wire_name(name: &str) -> String {
21    let mut words = name.split('_');
22    let mut output = words.next().unwrap_or_default().to_owned();
23    for word in words {
24        let mut letters = word.chars();
25        if let Some(first) = letters.next() {
26            output.extend(first.to_uppercase());
27            output.extend(letters);
28        }
29    }
30    output
31}
32fn field(field: &FieldDef, core: bool) -> Option<OpenApiField> {
33    // `active` is an internal Rust revocation flag, not a public session field.
34    if field.is_primary_key || (core && field.name == "active") {
35        return None;
36    }
37    let ty = field.ty.trim_start_matches("Option<").trim_end_matches('>');
38    let schema = match ty {
39        "DateTimeUtc" => json!({"type":"string","format":"date-time"}),
40        "bool" => json!({"type":"boolean"}),
41        "i64" | "f64" => json!({"type":"number"}),
42        "Json" => json!({"type":"json"}),
43        _ => json!({"type":"string"}),
44    };
45    let mut field = OpenApiField::new(
46        wire_name(field.name),
47        schema,
48        !field.ty.starts_with("Option<"),
49    );
50    if core && matches!(field.name.as_str(), "name" | "email") {
51        field.required = true;
52    }
53    if core && field.name == "emailVerified" {
54        field.input = false;
55        if let Some(object) = field.schema.as_object_mut() {
56            drop(object.insert("default".into(), json!(false)));
57        }
58    }
59    Some(field)
60}
61/// Default annotations for registered built-in routes and plugin model fields.
62/// Custom plugins should override their metadata hook to describe additional constraints.
63#[must_use]
64pub fn plugin_metadata(plugin: &str, routes: &[AuthRoute]) -> PluginOpenApiMetadata {
65    let is_core = |owner: &str| {
66        matches!(
67            owner,
68            "core"
69                | "email-password"
70                | "session-management"
71                | "email-verification"
72                | "password-management"
73                | "user-management"
74                | "account-management"
75                | "oauth"
76        )
77    };
78    let mut metadata = PluginOpenApiMetadata {
79        core: is_core(plugin),
80        ..Default::default()
81    };
82    for route in routes {
83        let owner = if plugin == "email-password"
84            && matches!(
85                route.path.as_str(),
86                "/sign-in/username" | "/is-username-available"
87            ) {
88            "username"
89        } else {
90            plugin
91        };
92        let mut annotation = source_endpoints::endpoint(owner, &route.path)
93            .or_else(|| endpoint(&route.path))
94            .unwrap_or_else(|| OpenApiEndpoint {
95                operation_id: Some(route.operation_id.clone()),
96                ..Default::default()
97            });
98        if owner != plugin {
99            annotation.owner = Some(owner.into());
100            annotation.core = Some(is_core(owner));
101        }
102        metadata
103            .endpoints
104            .push((route.method.clone(), route.path.clone(), annotation));
105    }
106    if let Some(models) = source_models::models(plugin) {
107        metadata.models = models;
108        return metadata;
109    }
110    if let Some(schema) = plugin_schemas().iter().find(|schema| schema.name == plugin) {
111        for (name, definitions) in [
112            ("User", schema.user_fields),
113            ("Session", schema.session_fields),
114        ] {
115            let fields = definitions
116                .iter()
117                .filter_map(|definition| field(definition, false))
118                .collect::<Vec<_>>();
119            if !fields.is_empty() {
120                metadata.models.push(OpenApiModel::new(name, fields));
121            }
122        }
123        for entity in schema.extra_entities {
124            let wire = if plugin == "jwt" {
125                "jwks".into()
126            } else {
127                wire_name(entity.mod_name)
128            };
129            let mut chars = wire.chars();
130            let name = chars
131                .next()
132                .map(|first| {
133                    let mut name = first.to_uppercase().collect::<String>();
134                    name.push_str(chars.as_str());
135                    name
136                })
137                .unwrap_or_default();
138            metadata.models.push(OpenApiModel::new(
139                name,
140                entity
141                    .fields
142                    .iter()
143                    .filter_map(|definition| field(definition, false))
144                    .collect(),
145            ));
146        }
147    }
148    metadata
149}
150#[must_use]
151pub fn instance_plugin_metadata<S: alibi_core::AuthSchema>(
152    plugin: &str,
153    routes: &[AuthRoute],
154    ctx: &alibi_core::AuthInitContext<S>,
155) -> PluginOpenApiMetadata {
156    let mut metadata = plugin_metadata(plugin, routes);
157    model_annotations::apply(plugin, ctx, &mut metadata);
158    metadata
159}
160#[must_use]
161pub fn core_routes() -> Vec<AuthRoute> {
162    vec![
163        AuthRoute::get("/ok", "ok"),
164        AuthRoute::get("/error", "error"),
165        AuthRoute::post("/update-user", "update_user"),
166    ]
167}
168fn response(description: &str, schema: &Value) -> Value {
169    json!({"description":description,"content":{"application/json":{"schema":schema}}})
170}
171fn endpoint(path: &str) -> Option<OpenApiEndpoint> {
172    if let Some(metadata) =
173        sign_in_annotations::endpoint(path).or_else(|| oauth_annotations::endpoint(path))
174    {
175        return Some(metadata);
176    }
177    if let Some(metadata) = user_annotations::endpoint(path) {
178        return Some(metadata);
179    }
180    if let Some(metadata) = account_annotations::endpoint(path)
181        .or_else(|| password_annotations::endpoint(path))
182        .or_else(|| email_annotations::endpoint(path))
183    {
184        return Some(metadata);
185    }
186    if let Some(metadata) = session_annotations::endpoint(path) {
187        return Some(metadata);
188    }
189    let mut metadata = OpenApiEndpoint::default();
190    match path {
191        "/callback/{provider}" | "/callback/:id" => {
192            metadata.document_path = Some("/callback/:id".into());
193            let mut properties = serde_json::Map::new();
194            for name in [
195                "code",
196                "error",
197                "device_id",
198                "error_description",
199                "state",
200                "user",
201                "iss",
202            ] {
203                drop(properties.insert(name.into(), json!({"type":"string"})));
204            }
205            metadata.request_body = Some(
206                json!({"required":false,"content":{"application/json":{"schema":{"type":"object","properties":properties}}}}),
207            );
208        }
209        "/ok" => {
210            metadata.description = Some("Check if the API is working".into());
211            drop(metadata.responses.insert("200".into(), response("API is working",&(json!({"type":"object","properties":{"ok":{"type":"boolean","description":"Indicates if the API is working"}},"required":["ok"]})))));
212        }
213        "/error" => {
214            metadata.description = Some("Displays an error page".into());
215            drop(metadata.responses.insert("200".into(),json!({"description":"Success","content":{"text/html":{"schema":{"type":"string","description":"The HTML content of the error page"}}}})));
216        }
217        "/get-session" => {
218            metadata.operation_id = Some("getSession".into());
219            metadata.description = Some("Get the current session".into());
220            // The upstream query is an optional wrapper; getParameters only
221            // reflects a direct object, so its generated parameter list is empty.
222            drop(metadata.responses.insert("200".into(),response("Success",&(json!({"type":["object","null"],"properties":{"session":{"$ref":"#/components/schemas/Session"},"user":{"$ref":"#/components/schemas/User"}},"required":["session","user"]})))));
223        }
224        _ => return None,
225    }
226    Some(metadata)
227}