use crate::middleware::{
HttpConfigurationError, HttpHeaderPolicy, HttpRuntimeConfig, apply_standard_middleware,
};
use axum::Router;
use minco_core::{ApplicationGraph, FrozenContributions, PluginContext, PluginId};
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
};
use thiserror::Error;
#[derive(Clone)]
pub struct HttpModule {
pub plugin_id: PluginId,
pub router: Router,
pub operation_ids: BTreeSet<String>,
pub max_request_body_bytes: Option<usize>,
pub header_policy: HttpHeaderPolicy,
}
impl HttpModule {
pub const fn new(plugin_id: PluginId, router: Router) -> Self {
Self {
plugin_id,
router,
operation_ids: BTreeSet::new(),
max_request_body_bytes: None,
header_policy: HttpHeaderPolicy::empty(),
}
}
#[must_use]
pub fn with_operations<I, S>(mut self, operation_ids: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.operation_ids = operation_ids.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub const fn with_max_request_body_bytes(mut self, maximum: usize) -> Self {
self.max_request_body_bytes = Some(maximum);
self
}
#[must_use]
pub fn with_header_policy(mut self, policy: HttpHeaderPolicy) -> Self {
self.header_policy = policy;
self
}
pub fn contribute(self, context: &mut PluginContext<'_>) {
context.contributions().push(Arc::new(self));
}
}
impl std::fmt::Debug for HttpModule {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("HttpModule")
.field("plugin_id", &self.plugin_id)
.field("operation_ids", &self.operation_ids)
.field("max_request_body_bytes", &self.max_request_body_bytes)
.field("header_policy", &self.header_policy)
.finish_non_exhaustive()
}
}
pub fn validate_plugin_http_modules(
graph: &ApplicationGraph,
contributions: &FrozenContributions,
) -> Result<(), HttpCompositionError> {
let expected = graph
.plugins
.iter()
.map(|plugin| {
(
plugin.id.clone(),
plugin
.operations
.iter()
.map(|operation| operation.operation_id.clone())
.collect::<BTreeSet<_>>(),
)
})
.collect::<BTreeMap<_, _>>();
let mut actual = BTreeMap::<PluginId, BTreeSet<String>>::new();
let mut owners = BTreeMap::<String, PluginId>::new();
for module in contributions.get::<HttpModule>() {
if !expected.contains_key(&module.plugin_id) {
return Err(HttpCompositionError::UnknownPlugin(
module.plugin_id.clone(),
));
}
let plugin_operations = actual.entry(module.plugin_id.clone()).or_default();
for operation_id in &module.operation_ids {
if !plugin_operations.insert(operation_id.clone()) {
return Err(HttpCompositionError::DuplicateModuleOperation {
plugin: module.plugin_id.clone(),
operation_id: operation_id.clone(),
});
}
if let Some(first) = owners.insert(operation_id.clone(), module.plugin_id.clone()) {
return Err(HttpCompositionError::OperationOwnedByMultiplePlugins {
operation_id: operation_id.clone(),
first,
second: module.plugin_id.clone(),
});
}
}
}
for (plugin, expected_operations) in expected {
let actual_operations = actual.remove(&plugin).unwrap_or_default();
if expected_operations != actual_operations {
let missing = expected_operations
.difference(&actual_operations)
.cloned()
.collect();
let undeclared = actual_operations
.difference(&expected_operations)
.cloned()
.collect();
return Err(HttpCompositionError::OperationMismatch {
plugin,
missing,
undeclared,
});
}
}
Ok(())
}
pub fn merge_plugin_http_modules(
mut router: Router,
contributions: &FrozenContributions,
) -> Router {
for module in contributions.get::<HttpModule>() {
router = router.merge(module.router.clone());
}
router
}
#[must_use]
pub fn required_request_body_bytes(baseline: usize, contributions: &FrozenContributions) -> usize {
contributions
.get::<HttpModule>()
.into_iter()
.filter_map(|module| module.max_request_body_bytes)
.fold(baseline, usize::max)
}
pub fn required_header_policy(
baseline: &HttpHeaderPolicy,
contributions: &FrozenContributions,
) -> Result<HttpHeaderPolicy, HttpConfigurationError> {
let mut policy = baseline.clone();
for module in contributions.get::<HttpModule>() {
policy.merge(&module.header_policy)?;
}
Ok(policy)
}
pub fn compose_plugin_http(
router: Router,
configuration: &HttpRuntimeConfig,
graph: &ApplicationGraph,
contributions: &FrozenContributions,
) -> Result<Router, HttpCompositionError> {
validate_plugin_http_modules(graph, contributions)?;
let mut effective = configuration.clone();
effective.max_request_body_bytes =
required_request_body_bytes(configuration.max_request_body_bytes, contributions);
effective.header_policy = required_header_policy(&configuration.header_policy, contributions)?;
apply_standard_middleware(merge_plugin_http_modules(router, contributions), &effective)
.map_err(HttpCompositionError::InvalidConfiguration)
}
#[derive(Debug, Error)]
pub enum HttpCompositionError {
#[error("HTTP module references plugin that is not in the application graph: {0}")]
UnknownPlugin(PluginId),
#[error("plugin {plugin} contributes operation {operation_id} more than once")]
DuplicateModuleOperation {
plugin: PluginId,
operation_id: String,
},
#[error("operation {operation_id} is contributed by both plugin {first} and plugin {second}")]
OperationOwnedByMultiplePlugins {
operation_id: String,
first: PluginId,
second: PluginId,
},
#[error(
"HTTP operations for plugin {plugin} do not match its descriptor; missing={missing:?}, undeclared={undeclared:?}"
)]
OperationMismatch {
plugin: PluginId,
missing: BTreeSet<String>,
undeclared: BTreeSet<String>,
},
#[error("invalid HTTP middleware configuration: {0}")]
InvalidConfiguration(#[from] HttpConfigurationError),
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{body::Body, routing::get};
use minco_core::{ContributionCollection, GraphBuilder, OperationDescriptor, PluginDescriptor};
use semver::Version;
use tower::ServiceExt;
fn graph_with_operations(plugin_id: &str, operation_ids: &[&str]) -> ApplicationGraph {
let id = PluginId::new(plugin_id).unwrap();
let mut descriptor = PluginDescriptor::new(id, Version::new(1, 0, 0), "test HTTP plugin");
descriptor
.operations
.extend(
operation_ids
.iter()
.map(|operation_id| OperationDescriptor {
operation_id: (*operation_id).to_owned(),
method: "GET".to_owned(),
path: format!("/{operation_id}"),
public: true,
idempotent: false,
}),
);
let mut builder = GraphBuilder::default();
builder.add_plugin(descriptor);
builder.build().unwrap()
}
#[tokio::test]
async fn plugin_routers_are_merged_from_ordered_contributions() {
let mut contributions = ContributionCollection::default();
contributions.push(Arc::new(
HttpModule::new(
PluginId::new("first").unwrap(),
Router::new().route("/first", get(|| async { "first" })),
)
.with_operations(["firstOperation"]),
));
contributions.push(Arc::new(
HttpModule::new(
PluginId::new("second").unwrap(),
Router::new().route("/second", get(|| async { "second" })),
)
.with_operations(["secondOperation"]),
));
let router = merge_plugin_http_modules(Router::new(), &contributions.freeze());
for path in ["/first", "/second"] {
let response = router
.clone()
.oneshot(http::Request::get(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert!(response.status().is_success(), "{path}");
}
}
#[test]
fn operation_inventory_must_match_the_plugin_descriptor() {
let graph = graph_with_operations("feedback", &["createFeedback", "getFeedback"]);
let mut contributions = ContributionCollection::default();
contributions.push(Arc::new(
HttpModule::new(PluginId::new("feedback").unwrap(), Router::new())
.with_operations(["createFeedback"]),
));
let error = validate_plugin_http_modules(&graph, &contributions.freeze()).unwrap_err();
assert!(matches!(
error,
HttpCompositionError::OperationMismatch { .. }
));
}
#[test]
fn exact_operation_inventory_is_accepted() {
let graph = graph_with_operations("feedback", &["createFeedback", "getFeedback"]);
let mut contributions = ContributionCollection::default();
contributions.push(Arc::new(
HttpModule::new(PluginId::new("feedback").unwrap(), Router::new())
.with_operations(["createFeedback", "getFeedback"]),
));
assert!(validate_plugin_http_modules(&graph, &contributions.freeze()).is_ok());
}
#[test]
fn upload_capable_modules_raise_only_the_global_ceiling() {
let mut contributions = ContributionCollection::default();
contributions.push(Arc::new(
HttpModule::new(PluginId::new("uploads").unwrap(), Router::new())
.with_max_request_body_bytes(8 * 1024 * 1024),
));
let frozen = contributions.freeze();
assert_eq!(
required_request_body_bytes(1024 * 1024, &frozen),
8 * 1024 * 1024
);
assert_eq!(
required_request_body_bytes(16 * 1024 * 1024, &frozen),
16 * 1024 * 1024
);
}
#[test]
fn plugin_header_requirements_merge_and_deduplicate_exact_names() {
let mut first = HttpHeaderPolicy::empty();
first.allow_request_header_name("x-example-token").unwrap();
first
.mark_request_header_name_sensitive("x-example-token")
.unwrap();
let mut second = HttpHeaderPolicy::empty();
second.allow_request_header_name("X-Example-Token").unwrap();
second
.expose_response_header_name("x-example-result")
.unwrap();
let mut contributions = ContributionCollection::default();
contributions.push(Arc::new(
HttpModule::new(PluginId::new("first").unwrap(), Router::new())
.with_header_policy(first),
));
contributions.push(Arc::new(
HttpModule::new(PluginId::new("second").unwrap(), Router::new())
.with_header_policy(second),
));
let policy =
required_header_policy(&HttpHeaderPolicy::default(), &contributions.freeze()).unwrap();
let allowed = policy
.allowed_request_headers()
.into_iter()
.map(|name| name.as_str().to_owned())
.collect::<Vec<_>>();
assert_eq!(
allowed
.iter()
.filter(|name| name.as_str() == "x-example-token")
.count(),
1
);
assert!(
policy
.exposed_response_headers()
.iter()
.any(|name| name == "x-example-result")
);
}
#[tokio::test]
async fn installed_plugin_header_is_allowed_by_preflight() {
let mut policy = HttpHeaderPolicy::empty();
policy
.allow_request_header_name("x-minco-feedback-token")
.unwrap();
let mut contributions = ContributionCollection::default();
contributions.push(Arc::new(
HttpModule::new(PluginId::new("feedback").unwrap(), Router::new())
.with_header_policy(policy),
));
let frozen = contributions.freeze();
let graph = graph_with_operations("feedback", &[]);
let router = compose_plugin_http(
Router::new(),
&HttpRuntimeConfig::default(),
&graph,
&frozen,
)
.unwrap();
let response = router
.oneshot(
http::Request::builder()
.method(http::Method::OPTIONS)
.uri("/")
.header(http::header::ORIGIN, "http://127.0.0.1:3000")
.header(http::header::ACCESS_CONTROL_REQUEST_METHOD, "GET")
.header(
http::header::ACCESS_CONTROL_REQUEST_HEADERS,
"x-minco-feedback-token",
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let allowed = response
.headers()
.get(http::header::ACCESS_CONTROL_ALLOW_HEADERS)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
assert!(allowed.contains("x-minco-feedback-token"), "{allowed}");
}
}