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#[derive(Clone)]
18pub struct HttpModule {
19 pub plugin_id: PluginId,
20 pub router: Router,
21 pub operation_ids: BTreeSet<String>,
27 pub max_request_body_bytes: Option<usize>,
33 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 #[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 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
89pub 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
164pub 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#[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
192pub 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
204pub 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}