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