Skip to main content

minco_http/
plugin.rs

1use crate::middleware::{
2    HttpConfigurationError, HttpHeaderPolicy, HttpRuntimeConfig, apply_standard_middleware,
3};
4use axum::Router;
5use minco_core::{ApplicationGraph, FrozenContributions, PluginContext, PluginId};
6use std::{
7    collections::{BTreeMap, BTreeSet},
8    sync::Arc,
9};
10use thiserror::Error;
11
12/// One fully state-bound Axum router contributed by a statically linked plugin.
13///
14/// `minco-core` remains independent of Axum. HTTP-aware plugins contribute this
15/// type through [`PluginContext::contributions`], and the application composition
16/// root validates and merges all modules after plugin installation.
17#[derive(Clone)]
18pub struct HttpModule {
19    pub plugin_id: PluginId,
20    pub router: Router,
21    /// OpenAPI/descriptor operation IDs implemented by this router fragment.
22    ///
23    /// Minco validates the union of these IDs against the operations declared by
24    /// the plugin descriptor. The field does not attempt to introspect Axum's
25    /// router internals; ownership remains explicit and machine-readable.
26    pub operation_ids: BTreeSet<String>,
27    /// Largest request body accepted by any route in this module.
28    ///
29    /// The application composition root uses this value to prevent a global
30    /// Tower body limit from accidentally rejecting a route-specific upload
31    /// limit. Individual routes must still configure their own smaller limits.
32    pub max_request_body_bytes: Option<usize>,
33    /// Exact header policy required only when this module is installed.
34    pub header_policy: HttpHeaderPolicy,
35}
36
37impl HttpModule {
38    pub const fn new(plugin_id: PluginId, router: Router) -> Self {
39        Self {
40            plugin_id,
41            router,
42            operation_ids: BTreeSet::new(),
43            max_request_body_bytes: None,
44            header_policy: HttpHeaderPolicy::empty(),
45        }
46    }
47
48    /// Declares the contract operations implemented by this module.
49    #[must_use]
50    pub fn with_operations<I, S>(mut self, operation_ids: I) -> Self
51    where
52        I: IntoIterator<Item = S>,
53        S: Into<String>,
54    {
55        self.operation_ids = operation_ids.into_iter().map(Into::into).collect();
56        self
57    }
58
59    #[must_use]
60    pub const fn with_max_request_body_bytes(mut self, maximum: usize) -> Self {
61        self.max_request_body_bytes = Some(maximum);
62        self
63    }
64
65    #[must_use]
66    pub fn with_header_policy(mut self, policy: HttpHeaderPolicy) -> Self {
67        self.header_policy = policy;
68        self
69    }
70
71    /// Registers this module in deterministic plugin-installation order.
72    pub fn contribute(self, context: &mut PluginContext<'_>) {
73        context.contributions().push(Arc::new(self));
74    }
75}
76
77impl std::fmt::Debug for HttpModule {
78    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        formatter
80            .debug_struct("HttpModule")
81            .field("plugin_id", &self.plugin_id)
82            .field("operation_ids", &self.operation_ids)
83            .field("max_request_body_bytes", &self.max_request_body_bytes)
84            .field("header_policy", &self.header_policy)
85            .finish_non_exhaustive()
86    }
87}
88
89/// Verifies that HTTP contributions and plugin descriptors have an exact
90/// operation-ID correspondence.
91///
92/// This closes the drift boundary between a plugin's contract/deployment graph
93/// and its delivery module. Route method/path duplication is validated by
94/// `minco-core`; this function proves that every declared operation is owned by
95/// exactly one installed HTTP module and that no undeclared operation is exposed.
96pub fn validate_plugin_http_modules(
97    graph: &ApplicationGraph,
98    contributions: &FrozenContributions,
99) -> Result<(), HttpCompositionError> {
100    let expected = graph
101        .plugins
102        .iter()
103        .map(|plugin| {
104            (
105                plugin.id.clone(),
106                plugin
107                    .operations
108                    .iter()
109                    .map(|operation| operation.operation_id.clone())
110                    .collect::<BTreeSet<_>>(),
111            )
112        })
113        .collect::<BTreeMap<_, _>>();
114
115    let mut actual = BTreeMap::<PluginId, BTreeSet<String>>::new();
116    let mut owners = BTreeMap::<String, PluginId>::new();
117
118    for module in contributions.get::<HttpModule>() {
119        if !expected.contains_key(&module.plugin_id) {
120            return Err(HttpCompositionError::UnknownPlugin(
121                module.plugin_id.clone(),
122            ));
123        }
124        let plugin_operations = actual.entry(module.plugin_id.clone()).or_default();
125        for operation_id in &module.operation_ids {
126            if !plugin_operations.insert(operation_id.clone()) {
127                return Err(HttpCompositionError::DuplicateModuleOperation {
128                    plugin: module.plugin_id.clone(),
129                    operation_id: operation_id.clone(),
130                });
131            }
132            if let Some(first) = owners.insert(operation_id.clone(), module.plugin_id.clone()) {
133                return Err(HttpCompositionError::OperationOwnedByMultiplePlugins {
134                    operation_id: operation_id.clone(),
135                    first,
136                    second: module.plugin_id.clone(),
137                });
138            }
139        }
140    }
141
142    for (plugin, expected_operations) in expected {
143        let actual_operations = actual.remove(&plugin).unwrap_or_default();
144        if expected_operations != actual_operations {
145            let missing = expected_operations
146                .difference(&actual_operations)
147                .cloned()
148                .collect();
149            let undeclared = actual_operations
150                .difference(&expected_operations)
151                .cloned()
152                .collect();
153            return Err(HttpCompositionError::OperationMismatch {
154                plugin,
155                missing,
156                undeclared,
157            });
158        }
159    }
160
161    Ok(())
162}
163
164/// Merges every plugin-contributed router in deterministic installation order.
165///
166/// Call [`validate_plugin_http_modules`] first, or use [`compose_plugin_http`],
167/// when the modules expose contract operations.
168pub fn merge_plugin_http_modules(
169    mut router: Router,
170    contributions: &FrozenContributions,
171) -> Router {
172    for module in contributions.get::<HttpModule>() {
173        router = router.merge(module.router.clone());
174    }
175    router
176}
177
178/// Returns the global request-body ceiling required by all installed HTTP modules.
179///
180/// This is intentionally the maximum, not a replacement for route-level limits.
181/// It keeps the global middleware compatible with upload-capable plugins while
182/// allowing ordinary JSON routes to retain stricter extractor limits.
183#[must_use]
184pub fn required_request_body_bytes(baseline: usize, contributions: &FrozenContributions) -> usize {
185    contributions
186        .get::<HttpModule>()
187        .into_iter()
188        .filter_map(|module| module.max_request_body_bytes)
189        .fold(baseline, usize::max)
190}
191
192/// Returns the application policy plus the exact requirements of installed HTTP modules.
193pub fn required_header_policy(
194    baseline: &HttpHeaderPolicy,
195    contributions: &FrozenContributions,
196) -> Result<HttpHeaderPolicy, HttpConfigurationError> {
197    let mut policy = baseline.clone();
198    for module in contributions.get::<HttpModule>() {
199        policy.merge(&module.header_policy)?;
200    }
201    Ok(policy)
202}
203
204/// Validates and merges plugin routes, then applies Minco's standard middleware
205/// with an automatically expanded global body ceiling.
206pub fn compose_plugin_http(
207    router: Router,
208    configuration: &HttpRuntimeConfig,
209    graph: &ApplicationGraph,
210    contributions: &FrozenContributions,
211) -> Result<Router, HttpCompositionError> {
212    validate_plugin_http_modules(graph, contributions)?;
213    let mut effective = configuration.clone();
214    effective.max_request_body_bytes =
215        required_request_body_bytes(configuration.max_request_body_bytes, contributions);
216    effective.header_policy = required_header_policy(&configuration.header_policy, contributions)?;
217    apply_standard_middleware(merge_plugin_http_modules(router, contributions), &effective)
218        .map_err(HttpCompositionError::InvalidConfiguration)
219}
220
221#[derive(Debug, Error)]
222pub enum HttpCompositionError {
223    #[error("HTTP module references plugin that is not in the application graph: {0}")]
224    UnknownPlugin(PluginId),
225    #[error("plugin {plugin} contributes operation {operation_id} more than once")]
226    DuplicateModuleOperation {
227        plugin: PluginId,
228        operation_id: String,
229    },
230    #[error("operation {operation_id} is contributed by both plugin {first} and plugin {second}")]
231    OperationOwnedByMultiplePlugins {
232        operation_id: String,
233        first: PluginId,
234        second: PluginId,
235    },
236    #[error(
237        "HTTP operations for plugin {plugin} do not match its descriptor; missing={missing:?}, undeclared={undeclared:?}"
238    )]
239    OperationMismatch {
240        plugin: PluginId,
241        missing: BTreeSet<String>,
242        undeclared: BTreeSet<String>,
243    },
244    #[error("invalid HTTP middleware configuration: {0}")]
245    InvalidConfiguration(#[from] HttpConfigurationError),
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use axum::{body::Body, routing::get};
252    use minco_core::{ContributionCollection, GraphBuilder, OperationDescriptor, PluginDescriptor};
253    use semver::Version;
254    use tower::ServiceExt;
255
256    fn graph_with_operations(plugin_id: &str, operation_ids: &[&str]) -> ApplicationGraph {
257        let id = PluginId::new(plugin_id).unwrap();
258        let mut descriptor = PluginDescriptor::new(id, Version::new(1, 0, 0), "test HTTP plugin");
259        descriptor
260            .operations
261            .extend(
262                operation_ids
263                    .iter()
264                    .map(|operation_id| OperationDescriptor {
265                        operation_id: (*operation_id).to_owned(),
266                        method: "GET".to_owned(),
267                        path: format!("/{operation_id}"),
268                        public: true,
269                        idempotent: false,
270                    }),
271            );
272        let mut builder = GraphBuilder::default();
273        builder.add_plugin(descriptor);
274        builder.build().unwrap()
275    }
276
277    #[tokio::test]
278    async fn plugin_routers_are_merged_from_ordered_contributions() {
279        let mut contributions = ContributionCollection::default();
280        contributions.push(Arc::new(
281            HttpModule::new(
282                PluginId::new("first").unwrap(),
283                Router::new().route("/first", get(|| async { "first" })),
284            )
285            .with_operations(["firstOperation"]),
286        ));
287        contributions.push(Arc::new(
288            HttpModule::new(
289                PluginId::new("second").unwrap(),
290                Router::new().route("/second", get(|| async { "second" })),
291            )
292            .with_operations(["secondOperation"]),
293        ));
294        let router = merge_plugin_http_modules(Router::new(), &contributions.freeze());
295
296        for path in ["/first", "/second"] {
297            let response = router
298                .clone()
299                .oneshot(http::Request::get(path).body(Body::empty()).unwrap())
300                .await
301                .unwrap();
302            assert!(response.status().is_success(), "{path}");
303        }
304    }
305
306    #[test]
307    fn operation_inventory_must_match_the_plugin_descriptor() {
308        let graph = graph_with_operations("feedback", &["createFeedback", "getFeedback"]);
309        let mut contributions = ContributionCollection::default();
310        contributions.push(Arc::new(
311            HttpModule::new(PluginId::new("feedback").unwrap(), Router::new())
312                .with_operations(["createFeedback"]),
313        ));
314        let error = validate_plugin_http_modules(&graph, &contributions.freeze()).unwrap_err();
315        assert!(matches!(
316            error,
317            HttpCompositionError::OperationMismatch { .. }
318        ));
319    }
320
321    #[test]
322    fn exact_operation_inventory_is_accepted() {
323        let graph = graph_with_operations("feedback", &["createFeedback", "getFeedback"]);
324        let mut contributions = ContributionCollection::default();
325        contributions.push(Arc::new(
326            HttpModule::new(PluginId::new("feedback").unwrap(), Router::new())
327                .with_operations(["createFeedback", "getFeedback"]),
328        ));
329        assert!(validate_plugin_http_modules(&graph, &contributions.freeze()).is_ok());
330    }
331
332    #[test]
333    fn upload_capable_modules_raise_only_the_global_ceiling() {
334        let mut contributions = ContributionCollection::default();
335        contributions.push(Arc::new(
336            HttpModule::new(PluginId::new("uploads").unwrap(), Router::new())
337                .with_max_request_body_bytes(8 * 1024 * 1024),
338        ));
339        let frozen = contributions.freeze();
340        assert_eq!(
341            required_request_body_bytes(1024 * 1024, &frozen),
342            8 * 1024 * 1024
343        );
344        assert_eq!(
345            required_request_body_bytes(16 * 1024 * 1024, &frozen),
346            16 * 1024 * 1024
347        );
348    }
349
350    #[test]
351    fn plugin_header_requirements_merge_and_deduplicate_exact_names() {
352        let mut first = HttpHeaderPolicy::empty();
353        first.allow_request_header_name("x-example-token").unwrap();
354        first
355            .mark_request_header_name_sensitive("x-example-token")
356            .unwrap();
357        let mut second = HttpHeaderPolicy::empty();
358        second.allow_request_header_name("X-Example-Token").unwrap();
359        second
360            .expose_response_header_name("x-example-result")
361            .unwrap();
362        let mut contributions = ContributionCollection::default();
363        contributions.push(Arc::new(
364            HttpModule::new(PluginId::new("first").unwrap(), Router::new())
365                .with_header_policy(first),
366        ));
367        contributions.push(Arc::new(
368            HttpModule::new(PluginId::new("second").unwrap(), Router::new())
369                .with_header_policy(second),
370        ));
371
372        let policy =
373            required_header_policy(&HttpHeaderPolicy::default(), &contributions.freeze()).unwrap();
374        let allowed = policy
375            .allowed_request_headers()
376            .into_iter()
377            .map(|name| name.as_str().to_owned())
378            .collect::<Vec<_>>();
379        assert_eq!(
380            allowed
381                .iter()
382                .filter(|name| name.as_str() == "x-example-token")
383                .count(),
384            1
385        );
386        assert!(
387            policy
388                .exposed_response_headers()
389                .iter()
390                .any(|name| name == "x-example-result")
391        );
392    }
393
394    #[tokio::test]
395    async fn installed_plugin_header_is_allowed_by_preflight() {
396        let mut policy = HttpHeaderPolicy::empty();
397        policy
398            .allow_request_header_name("x-minco-feedback-token")
399            .unwrap();
400        let mut contributions = ContributionCollection::default();
401        contributions.push(Arc::new(
402            HttpModule::new(PluginId::new("feedback").unwrap(), Router::new())
403                .with_header_policy(policy),
404        ));
405        let frozen = contributions.freeze();
406        let graph = graph_with_operations("feedback", &[]);
407        let router = compose_plugin_http(
408            Router::new(),
409            &HttpRuntimeConfig::default(),
410            &graph,
411            &frozen,
412        )
413        .unwrap();
414        let response = router
415            .oneshot(
416                http::Request::builder()
417                    .method(http::Method::OPTIONS)
418                    .uri("/")
419                    .header(http::header::ORIGIN, "http://127.0.0.1:3000")
420                    .header(http::header::ACCESS_CONTROL_REQUEST_METHOD, "GET")
421                    .header(
422                        http::header::ACCESS_CONTROL_REQUEST_HEADERS,
423                        "x-minco-feedback-token",
424                    )
425                    .body(Body::empty())
426                    .unwrap(),
427            )
428            .await
429            .unwrap();
430        let allowed = response
431            .headers()
432            .get(http::header::ACCESS_CONTROL_ALLOW_HEADERS)
433            .and_then(|value| value.to_str().ok())
434            .unwrap_or_default();
435        assert!(allowed.contains("x-minco-feedback-token"), "{allowed}");
436    }
437}