Skip to main content

spin_compose/
middleware.rs

1//! Composition of HTTP middleware pipelines.
2//!
3//! This module is deliberately free of any manifest, lockfile, trigger or
4//! executor types: it operates purely on component bytes plus the capability
5//! configuration each middleware is allowed to inherit, so that any embedding
6//! can reuse it regardless of how it discovers and loads middleware.
7
8use anyhow::{Context, bail};
9use semver::Version;
10use wac_graph::{CompositionGraph, NodeId, PackageId, types::Package};
11
12use crate::InheritConfiguration;
13
14/// The interface an HTTP middleware component both imports (to forward a
15/// request to the next component in the pipeline) and exports (to receive a
16/// request from the previous one).
17///
18/// Any semver-compatible `0.3.x` version of this interface is accepted, and
19/// a component's import and export may use different `0.3.x` versions.
20pub const MIDDLEWARE_HANDLER_INTERFACE: &str = "wasi:http/handler";
21
22/// A middleware component to be composed into a pipeline.
23pub struct Middleware {
24    /// The middleware component (or module, which will be componentized).
25    pub source: Vec<u8>,
26    /// The host capabilities this middleware may inherit from the application.
27    pub inherit: InheritConfiguration,
28}
29
30/// Composes `middlewares` into a pipeline in front of the `primary` component.
31///
32/// `middlewares` is ordered from **outermost** (first to receive a request) to
33/// **innermost** (last before the primary component). Each middleware has the
34/// deny adapter applied according to its [`InheritConfiguration`] before being
35/// wired into the pipeline.
36///
37/// Each component's [`MIDDLEWARE_HANDLER_INTERFACE`] import and export are
38/// located by semver compatibility with `0.3`, so components built against
39/// different `0.3.x` versions of `wasi:http` can be mixed. `wac-graph` merges
40/// the semver-compatible `wasi:http/types` imports into a single import at the
41/// highest version, so all components share one set of HTTP resources.
42///
43/// The composed component exports [`MIDDLEWARE_HANDLER_INTERFACE`] at the
44/// highest `0.3.x` version exported by any component in the pipeline, backed
45/// by the outermost middleware. If `middlewares` is empty, `primary` is
46/// returned unchanged.
47pub fn compose_middleware_pipeline(
48    primary: Vec<u8>,
49    middlewares: Vec<Middleware>,
50) -> anyhow::Result<Vec<u8>> {
51    if middlewares.is_empty() {
52        return Ok(primary);
53    }
54
55    let mut graph = CompositionGraph::new();
56    let mut stages = Vec::with_capacity(middlewares.len());
57
58    // Register middleware packages (outermost → innermost order).
59    for (index, middleware) in middlewares.into_iter().enumerate() {
60        let description = format!("middleware #{}", index + 1);
61        let bytes = spin_componentize::componentize_if_necessary(&middleware.source)
62            .with_context(|| format!("failed to componentize {description}"))?;
63        let bytes = spin_capabilities::apply_deny_adapter(&bytes, middleware.inherit)?;
64        let name = format!("middleware{index}");
65        let package = Package::from_bytes(&name, None, bytes, graph.types_mut())
66            .with_context(|| format!("parsing {description}"))?;
67        let world = &graph.types()[package.ty()];
68        let import = find_handler(world.imports.keys(), "import", &description)?;
69        let export = find_handler(world.exports.keys(), "export", &description)?;
70        stages.push(Stage {
71            package: graph.register_package(package)?,
72            description,
73            import,
74            export,
75        });
76    }
77
78    // Register the primary component (innermost in the chain).
79    let package = Package::from_bytes("primary", None, primary, graph.types_mut())
80        .context("parsing primary component")?;
81    let export = find_handler(
82        graph.types()[package.ty()].exports.keys(),
83        "export",
84        "primary component",
85    )?;
86    let primary = (graph.register_package(package)?, export);
87
88    let composed_export = stages
89        .iter()
90        .map(|stage| &stage.export)
91        .chain([&primary.1])
92        .max_by_key(|handler| &handler.version)
93        .expect("pipeline always contains the primary component")
94        .name
95        .clone();
96
97    // Wire the pipeline: outermost middleware → … → primary.
98    let outermost_export = chain(&mut graph, primary, &stages)?;
99
100    // Export the outermost handler as the composed component's export.
101    graph.export(outermost_export, composed_export)?;
102
103    Ok(graph.encode(Default::default())?)
104}
105
106/// A `wasi:http/handler@0.3.x` import or export name found on a component.
107struct Handler {
108    name: String,
109    version: Version,
110}
111
112/// A registered middleware component and its handler import/export names.
113struct Stage {
114    package: PackageId,
115    description: String,
116    import: Handler,
117    export: Handler,
118}
119
120/// Finds the single `wasi:http/handler` name on the `0.3` semver track among
121/// `names`, which are a component's imports or exports (`kind`).
122fn find_handler<'a>(
123    names: impl IntoIterator<Item = &'a String>,
124    kind: &str,
125    description: &str,
126) -> anyhow::Result<Handler> {
127    let mut handlers = names.into_iter().filter_map(|name| {
128        let version = name
129            .strip_prefix(MIDDLEWARE_HANDLER_INTERFACE)?
130            .strip_prefix('@')?;
131        let version = Version::parse(version).ok()?;
132        (version.major == 0 && version.minor == 3 && version.pre.is_empty()).then(|| Handler {
133            name: name.clone(),
134            version,
135        })
136    });
137    let Some(handler) = handlers.next() else {
138        bail!("{description} does not {kind} `{MIDDLEWARE_HANDLER_INTERFACE}@0.3.x`");
139    };
140    if let Some(other) = handlers.next() {
141        bail!(
142            "{description} has ambiguous `{MIDDLEWARE_HANDLER_INTERFACE}@0.3.x` {kind}s: `{}` and `{}`",
143            handler.name,
144            other.name
145        );
146    }
147    Ok(handler)
148}
149
150/// Chains `middlewares` (ordered **outermost** to **innermost**) in front of
151/// the `primary` package, wiring each component's handler export into the
152/// handler import of the component before it.
153///
154/// Returns the [`NodeId`] of the alias for the outermost component's export,
155/// ready to be passed to [`CompositionGraph::export`].
156fn chain(
157    graph: &mut CompositionGraph,
158    (primary, primary_export): (PackageId, Handler),
159    middlewares: &[Stage],
160) -> anyhow::Result<NodeId> {
161    // The innermost component's unsatisfied imports (if any) become implicit
162    // imports of the composed component.
163    let instance = graph.instantiate(primary);
164    let mut upstream_handle = graph.alias_instance_export(instance, &primary_export.name)?;
165
166    for stage in middlewares.iter().rev() {
167        let instance = graph.instantiate(stage.package);
168        graph
169            .set_instantiation_argument(instance, &stage.import.name, upstream_handle)
170            .with_context(|| {
171                format!(
172                    "failed to wire `{}` of {}",
173                    stage.import.name, stage.description
174                )
175            })?;
176        upstream_handle = graph.alias_instance_export(instance, &stage.export.name)?;
177    }
178
179    Ok(upstream_handle)
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use wac_graph::types::Types;
186
187    fn http_wit(version: &str) -> String {
188        format!(
189            r#"
190            package wasi:http@{version};
191
192            interface types {{
193                resource fields {{ constructor(); }}
194                resource request {{ constructor(headers: fields); }}
195                resource response {{ constructor(headers: fields); }}
196                variant error-code {{ internal-error(option<string>) }}
197            }}
198
199            interface handler {{
200                use types.{{request, response, error-code}};
201                handle: async func(request: request) -> result<response, error-code>;
202            }}
203            "#
204        )
205    }
206
207    /// Builds a dummy component whose world contains `items` (e.g.
208    /// `export wasi:http/handler@0.3.2;`).
209    fn component(items: &[&str]) -> Vec<u8> {
210        let mut resolve = wit_parser::Resolve::default();
211        for version in ["0.3.0", "0.3.2"] {
212            resolve.push_str("http.wit", &http_wit(version)).unwrap();
213        }
214        let wit = format!(
215            "package test:middleware;\nworld test {{\n{}\n}}",
216            items.join("\n")
217        );
218        let package = resolve.push_str("test.wit", &wit).unwrap();
219        let world = resolve.select_world(&[package], Some("test")).unwrap();
220
221        let mut module = wit_component::dummy_module(
222            &resolve,
223            world,
224            wit_parser::ManglingAndAbi::Legacy(wit_parser::LiftLowerAbi::Sync),
225        );
226        wit_component::embed_component_metadata(
227            &mut module,
228            &resolve,
229            world,
230            wit_component::StringEncoding::UTF8,
231            false,
232        )
233        .unwrap();
234        wit_component::ComponentEncoder::default()
235            .validate(true)
236            .module(&module)
237            .unwrap()
238            .encode()
239            .unwrap()
240    }
241
242    fn service(version: &str) -> Vec<u8> {
243        component(&[&format!("export wasi:http/handler@{version};")])
244    }
245
246    fn middleware(import_version: &str, export_version: &str) -> Middleware {
247        Middleware {
248            source: component(&[
249                &format!("import wasi:http/handler@{import_version};"),
250                &format!("export wasi:http/handler@{export_version};"),
251            ]),
252            inherit: InheritConfiguration::All,
253        }
254    }
255
256    /// Returns the sorted import and export names of a composed component.
257    fn imports_and_exports(bytes: Vec<u8>) -> (Vec<String>, Vec<String>) {
258        let mut types = Types::default();
259        let package = Package::from_bytes("composed", None, bytes, &mut types).unwrap();
260        let world = &types[package.ty()];
261        let mut imports: Vec<_> = world.imports.keys().cloned().collect();
262        let mut exports: Vec<_> = world.exports.keys().cloned().collect();
263        imports.sort();
264        exports.sort();
265        (imports, exports)
266    }
267
268    fn assert_composes(
269        primary: Vec<u8>,
270        middlewares: Vec<Middleware>,
271        expected_types_import: &str,
272        expected_handler_export: &str,
273    ) {
274        let composed = compose_middleware_pipeline(primary, middlewares).unwrap();
275        let (imports, exports) = imports_and_exports(composed);
276        assert_eq!(imports, [expected_types_import]);
277        assert_eq!(exports, [expected_handler_export]);
278    }
279
280    #[test]
281    fn all_components_at_0_3_0() {
282        assert_composes(
283            service("0.3.0"),
284            vec![middleware("0.3.0", "0.3.0"), middleware("0.3.0", "0.3.0")],
285            "wasi:http/types@0.3.0",
286            "wasi:http/handler@0.3.0",
287        );
288    }
289
290    #[test]
291    fn all_components_at_0_3_2() {
292        assert_composes(
293            service("0.3.2"),
294            vec![middleware("0.3.2", "0.3.2"), middleware("0.3.2", "0.3.2")],
295            "wasi:http/types@0.3.2",
296            "wasi:http/handler@0.3.2",
297        );
298    }
299
300    #[test]
301    fn newer_primary_with_older_middleware() {
302        assert_composes(
303            service("0.3.2"),
304            vec![middleware("0.3.0", "0.3.0")],
305            "wasi:http/types@0.3.2",
306            "wasi:http/handler@0.3.2",
307        );
308    }
309
310    #[test]
311    fn older_primary_with_newer_middleware() {
312        assert_composes(
313            service("0.3.0"),
314            vec![middleware("0.3.2", "0.3.2")],
315            "wasi:http/types@0.3.2",
316            "wasi:http/handler@0.3.2",
317        );
318    }
319
320    #[test]
321    fn middleware_with_mixed_import_and_export_versions() {
322        assert_composes(
323            service("0.3.2"),
324            vec![middleware("0.3.0", "0.3.2")],
325            "wasi:http/types@0.3.2",
326            "wasi:http/handler@0.3.2",
327        );
328        assert_composes(
329            service("0.3.0"),
330            vec![
331                middleware("0.3.0", "0.3.0"),
332                middleware("0.3.0", "0.3.2"),
333                middleware("0.3.2", "0.3.0"),
334            ],
335            "wasi:http/types@0.3.2",
336            "wasi:http/handler@0.3.2",
337        );
338    }
339
340    #[test]
341    fn primary_without_0_3_handler_export_fails() {
342        let primary = component(&["export run: func();"]);
343        let err = compose_middleware_pipeline(primary, vec![middleware("0.3.0", "0.3.0")])
344            .unwrap_err()
345            .to_string();
346        assert_eq!(
347            err,
348            "primary component does not export `wasi:http/handler@0.3.x`"
349        );
350    }
351
352    #[test]
353    fn middleware_without_0_3_handler_import_fails() {
354        let middleware = Middleware {
355            source: service("0.3.0"),
356            inherit: InheritConfiguration::All,
357        };
358        let err = compose_middleware_pipeline(service("0.3.0"), vec![middleware])
359            .unwrap_err()
360            .to_string();
361        assert_eq!(
362            err,
363            "middleware #1 does not import `wasi:http/handler@0.3.x`"
364        );
365    }
366
367    #[test]
368    fn ambiguous_handler_exports_fail() {
369        let primary = component(&[
370            "export wasi:http/handler@0.3.0;",
371            "export wasi:http/handler@0.3.2;",
372        ]);
373        let err = compose_middleware_pipeline(primary, vec![middleware("0.3.0", "0.3.0")])
374            .unwrap_err()
375            .to_string();
376        assert_eq!(
377            err,
378            "primary component has ambiguous `wasi:http/handler@0.3.x` exports: \
379             `wasi:http/handler@0.3.0` and `wasi:http/handler@0.3.2`"
380        );
381    }
382
383    #[test]
384    fn no_middleware_returns_primary_unchanged() {
385        let primary = service("0.3.2");
386        let composed = compose_middleware_pipeline(primary.clone(), vec![]).unwrap();
387        assert_eq!(composed, primary);
388    }
389}