Skip to main content

alibi_core/openapi/
metadata.rs

1//! Runtime documentation metadata, independent of route dispatch and database column names.
2use crate::{AuthRoute, HttpMethod};
3use indexmap::IndexMap;
4use serde_json::{Value, json};
5
6/// A model field's wire schema and input/output policy.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct OpenApiField {
9    pub name: String,
10    pub schema: Value,
11    pub required: bool,
12    pub input: bool,
13    pub returned: bool,
14    /// Includes callable defaults without evaluating them for documentation.
15    pub has_default: bool,
16}
17impl OpenApiField {
18    #[must_use]
19    pub fn new(name: impl Into<String>, schema: Value, required: bool) -> Self {
20        Self {
21            name: name.into(),
22            has_default: schema.get("default").is_some(),
23            schema,
24            required,
25            input: true,
26            returned: true,
27        }
28    }
29    fn from_policy(name: &str, policy: &crate::field_policy::FieldConfig) -> Self {
30        let mut schema = policy.schema.clone();
31        if let Some(object) = schema.as_object_mut() {
32            drop(object.remove("default"));
33            if let Some(crate::field_policy::FieldDefault::Value(value)) = &policy.default {
34                drop(object.insert(
35                    "default".into(),
36                    value.to_json_value().unwrap_or(Value::Null),
37                ));
38            }
39        }
40        Self {
41            name: name.into(),
42            schema,
43            required: policy.required,
44            input: policy.input,
45            returned: policy.returned,
46            has_default: policy.default.is_some(),
47        }
48    }
49
50    #[must_use]
51    pub const fn read_only(mut self) -> Self {
52        self.input = false;
53        self
54    }
55    #[must_use]
56    pub const fn hidden(mut self) -> Self {
57        self.returned = false;
58        self
59    }
60}
61
62/// An ordered model definition. Repeated registrations merge fields by wire name.
63#[derive(Debug, Clone)]
64pub struct OpenApiModel {
65    pub name: String,
66    pub fields: Vec<OpenApiField>,
67}
68impl OpenApiModel {
69    #[must_use]
70    pub fn new(name: impl Into<String>, fields: Vec<OpenApiField>) -> Self {
71        Self {
72            name: name.into(),
73            fields,
74        }
75    }
76    pub(crate) fn to_schema(&self) -> Value {
77        let mut properties = serde_json::Map::new();
78        drop(properties.insert("id".into(), json!({"type":"string","readOnly":true})));
79        let mut required = vec!["id".to_owned()];
80        for field in &self.fields {
81            let mut schema = field.schema.clone();
82            if !field.input
83                && let Some(object) = schema.as_object_mut()
84            {
85                drop(object.insert("readOnly".into(), json!(true)));
86            }
87            drop(properties.insert(field.name.clone(), schema));
88            if field.required && field.returned && !required.contains(&field.name) {
89                required.push(field.name.clone());
90            }
91        }
92        json!({"type":"object","properties":properties,"required":required})
93    }
94}
95
96/// Rich metadata for one route; dispatch still comes exclusively from `AuthRoute`.
97#[derive(Debug, Clone, Default)]
98pub struct OpenApiEndpoint {
99    /// Optional documentation owner for a route contributed by another plugin.
100    pub owner: Option<String>,
101    /// Override the plugin-level core documentation classification.
102    pub core: Option<bool>,
103    /// Canonical document template when dispatch uses equivalent parameter names.
104    pub document_path: Option<String>,
105    pub operation_id: Option<String>,
106    pub description: Option<String>,
107    pub tags: Option<Vec<String>>,
108    pub parameters: Vec<Value>,
109    pub request_body: Option<Value>,
110    pub responses: IndexMap<String, Value>,
111    pub server_only: bool,
112    /// Rust extension omitted by the pinned-equivalent document policy.
113    pub native_extension: bool,
114}
115
116/// A plugin's endpoint annotations and schema additions.
117#[derive(Debug, Clone, Default)]
118pub struct PluginOpenApiMetadata {
119    /// Use the core API documentation policy for this plugin.
120    pub core: bool,
121    pub endpoints: Vec<(HttpMethod, String, OpenApiEndpoint)>,
122    pub models: Vec<OpenApiModel>,
123}
124impl PluginOpenApiMetadata {
125    #[must_use]
126    pub fn endpoint(
127        mut self,
128        method: HttpMethod,
129        path: impl Into<String>,
130        metadata: OpenApiEndpoint,
131    ) -> Self {
132        self.endpoints.push((method, path.into(), metadata));
133        self
134    }
135    #[must_use]
136    pub fn model(mut self, model: OpenApiModel) -> Self {
137        self.models.push(model);
138        self
139    }
140}
141
142#[derive(Debug, Clone)]
143pub(super) struct RegisteredEndpoint {
144    pub route: AuthRoute,
145    pub plugin: String,
146    pub core: bool,
147    pub metadata: OpenApiEndpoint,
148}
149
150/// Immutable snapshot of the routes and model projections registered on one auth instance.
151#[derive(Debug, Clone, Default)]
152pub struct OpenApiRegistry {
153    pub(super) endpoints: Vec<RegisteredEndpoint>,
154    pub(crate) models: IndexMap<String, OpenApiModel>,
155    pub(crate) user_input_fields: IndexMap<String, OpenApiField>,
156    pub(crate) core_overrides: Vec<OpenApiModel>,
157}
158impl OpenApiRegistry {
159    /// Whether an installed endpoint is available only through server calls.
160    /// This is independent of document hiding and disabled-path configuration.
161    #[must_use]
162    pub fn is_server_only(&self, plugin: &str, route: &AuthRoute) -> bool {
163        self.endpoints.iter().any(|endpoint| {
164            endpoint.plugin == plugin
165                && endpoint.route.method == route.method
166                && endpoint.route.path == route.path
167                && endpoint.metadata.server_only
168        })
169    }
170
171    /// Actual registered routes, including endpoints unavailable over HTTP or omitted from documentation.
172    #[must_use]
173    pub fn registered_routes(&self) -> Vec<AuthRoute> {
174        self.endpoints
175            .iter()
176            .map(|endpoint| endpoint.route.clone())
177            .collect()
178    }
179
180    /// Apply application table policies before plugin collection. Documentation and
181    /// adapter tables use config-over-plugin precedence; input/output parsing has
182    /// its own source-distinct immutable field registry.
183    #[must_use]
184    pub fn configured(mut models: Vec<OpenApiModel>, config: &crate::AuthConfig) -> Self {
185        for (model_name, fields) in [
186            ("User", &config.user.additional_fields),
187            ("Session", &config.session.additional_fields),
188            ("Account", &config.account.additional_fields),
189        ] {
190            if let Some(model) = models.iter_mut().find(|model| model.name == model_name) {
191                for (name, policy) in fields {
192                    let field = OpenApiField::from_policy(name, policy);
193                    if let Some(current) = model.fields.iter_mut().find(|field| field.name == *name)
194                    {
195                        *current = field;
196                    } else {
197                        model.fields.push(field);
198                    }
199                }
200            }
201        }
202        Self::new(models)
203    }
204
205    #[must_use]
206    pub fn new(models: Vec<OpenApiModel>) -> Self {
207        let mut registry = Self::default();
208        let defaults = super::annotations::core_models();
209        for model in models {
210            if let Some(default) = defaults.iter().find(|default| default.name == model.name) {
211                let overrides = model
212                    .fields
213                    .iter()
214                    .filter(|field| !default.fields.contains(field))
215                    .cloned()
216                    .collect::<Vec<_>>();
217                if model.name == "User" {
218                    for field in &overrides {
219                        drop(
220                            registry
221                                .user_input_fields
222                                .insert(field.name.clone(), field.clone()),
223                        );
224                    }
225                }
226                if !overrides.is_empty() {
227                    registry
228                        .core_overrides
229                        .push(OpenApiModel::new(model.name.clone(), overrides));
230                }
231            }
232            registry.merge_model(model);
233        }
234        registry
235    }
236    pub fn register(
237        &mut self,
238        plugin: &str,
239        routes: Vec<AuthRoute>,
240        metadata: PluginOpenApiMetadata,
241    ) {
242        for route in routes {
243            let annotation = metadata
244                .endpoints
245                .iter()
246                .find(|(method, path, _)| *method == route.method && *path == route.path)
247                .map(|(_, _, value)| value.clone())
248                .unwrap_or_default();
249            self.endpoints.push(RegisteredEndpoint {
250                core: annotation.core.unwrap_or(metadata.core),
251                plugin: annotation.owner.as_deref().unwrap_or(plugin).into(),
252                route,
253                metadata: annotation,
254            });
255        }
256        for model in metadata.models {
257            if model.name == "User" {
258                for field in &model.fields {
259                    drop(
260                        self.user_input_fields
261                            .insert(field.name.clone(), field.clone()),
262                    );
263                }
264            }
265            self.merge_model(model);
266        }
267    }
268    /// Register the actual plugin field policies alongside its route annotations.
269    /// Configured model overlays remain distinct from plugin-wins user input.
270    pub fn register_fields(
271        &mut self,
272        model_name: &str,
273        fields: &crate::field_policy::FieldConfigs,
274    ) {
275        let fields = fields
276            .iter()
277            .map(|(name, policy)| OpenApiField::from_policy(name, policy))
278            .collect::<Vec<_>>();
279        if model_name == "User" {
280            for field in &fields {
281                drop(
282                    self.user_input_fields
283                        .insert(field.name.clone(), field.clone()),
284                );
285            }
286        }
287        self.merge_model(OpenApiModel::new(model_name, fields));
288    }
289
290    fn merge_model(&mut self, model: OpenApiModel) {
291        if let Some(existing) = self.models.get_mut(&model.name) {
292            for field in model.fields {
293                if let Some(current) = existing
294                    .fields
295                    .iter_mut()
296                    .find(|current| current.name == field.name)
297                {
298                    *current = field;
299                } else {
300                    existing.fields.push(field);
301                }
302            }
303        } else {
304            drop(self.models.insert(model.name.clone(), model));
305        }
306    }
307}