Skip to main content

alibi_plugins/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, OpenApiEndpoint, OpenApiField, OpenApiModel, PluginOpenApiMetadata};
17use alibi_schema_registry::{FieldDef, plugin_schemas};
18use serde_json::{Value, json};
19
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            _ = 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    sign_in_annotations::endpoint(path)
173        .or_else(|| oauth_annotations::endpoint(path))
174        .or_else(|| user_annotations::endpoint(path))
175        .or_else(|| account_annotations::endpoint(path))
176        .or_else(|| password_annotations::endpoint(path))
177        .or_else(|| email_annotations::endpoint(path))
178        .or_else(|| session_annotations::endpoint(path))
179        .or_else(|| builtin_endpoint(path))
180}
181
182fn builtin_endpoint(path: &str) -> Option<OpenApiEndpoint> {
183    let mut metadata = OpenApiEndpoint::default();
184    match path {
185        "/callback/{provider}" | "/callback/:id" => {
186            metadata.document_path = Some("/callback/:id".into());
187            let mut properties = serde_json::Map::new();
188            for name in [
189                "code",
190                "error",
191                "device_id",
192                "error_description",
193                "state",
194                "user",
195                "iss",
196            ] {
197                _ = properties.insert(name.into(), json!({"type":"string"}));
198            }
199            metadata.request_body = Some(
200                json!({"required":false,"content":{"application/json":{"schema":{"type":"object","properties":properties}}}}),
201            );
202        }
203        "/ok" => {
204            metadata.description = Some("Check if the API is working".into());
205            _ = 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"]}))));
206        }
207        "/error" => {
208            metadata.description = Some("Displays an error page".into());
209            _ = metadata.responses.insert("200".into(),json!({"description":"Success","content":{"text/html":{"schema":{"type":"string","description":"The HTML content of the error page"}}}}));
210        }
211        "/get-session" => {
212            metadata.operation_id = Some("getSession".into());
213            metadata.description = Some("Get the current session".into());
214            // The upstream query is an optional wrapper; getParameters only
215            // reflects a direct object, so its generated parameter list is empty.
216            _ = 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"]}))));
217        }
218        _ => return None,
219    }
220    Some(metadata)
221}