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            _ = object.remove("default");
33            if let Some(crate::field_policy::FieldDefault::Value(value)) = &policy.default {
34                _ = 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        _ = 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                _ = object.insert("readOnly".into(), json!(true));
86            }
87            _ = 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                        _ = registry
220                            .user_input_fields
221                            .insert(field.name.clone(), field.clone());
222                    }
223                }
224                if !overrides.is_empty() {
225                    registry
226                        .core_overrides
227                        .push(OpenApiModel::new(model.name.clone(), overrides));
228                }
229            }
230            registry.merge_model(model);
231        }
232        registry
233    }
234    pub fn register(
235        &mut self,
236        plugin: &str,
237        routes: Vec<AuthRoute>,
238        metadata: PluginOpenApiMetadata,
239    ) {
240        for route in routes {
241            let annotation = metadata
242                .endpoints
243                .iter()
244                .find(|(method, path, _)| *method == route.method && *path == route.path)
245                .map(|(_, _, value)| value.clone())
246                .unwrap_or_default();
247            self.endpoints.push(RegisteredEndpoint {
248                core: annotation.core.unwrap_or(metadata.core),
249                plugin: annotation.owner.as_deref().unwrap_or(plugin).into(),
250                route,
251                metadata: annotation,
252            });
253        }
254        for model in metadata.models {
255            if model.name == "User" {
256                for field in &model.fields {
257                    _ = self
258                        .user_input_fields
259                        .insert(field.name.clone(), field.clone());
260                }
261            }
262            self.merge_model(model);
263        }
264    }
265    /// Register the actual plugin field policies alongside its route annotations.
266    /// Configured model overlays remain distinct from plugin-wins user input.
267    pub fn register_fields(
268        &mut self,
269        model_name: &str,
270        fields: &crate::field_policy::FieldConfigs,
271    ) {
272        let fields = fields
273            .iter()
274            .map(|(name, policy)| OpenApiField::from_policy(name, policy))
275            .collect::<Vec<_>>();
276        if model_name == "User" {
277            for field in &fields {
278                _ = self
279                    .user_input_fields
280                    .insert(field.name.clone(), field.clone());
281            }
282        }
283        self.merge_model(OpenApiModel::new(model_name, fields));
284    }
285
286    fn merge_model(&mut self, model: OpenApiModel) {
287        if let Some(existing) = self.models.get_mut(&model.name) {
288            for field in model.fields {
289                if let Some(current) = existing
290                    .fields
291                    .iter_mut()
292                    .find(|current| current.name == field.name)
293                {
294                    *current = field;
295                } else {
296                    existing.fields.push(field);
297                }
298            }
299        } else {
300            _ = self.models.insert(model.name.clone(), model);
301        }
302    }
303}