spin_manifest/schema/
v2.rs

1use anyhow::{anyhow, Context};
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use spin_serde::{DependencyName, DependencyPackageName, FixedVersion, LowerSnakeId};
5pub use spin_serde::{KebabId, SnakeId};
6use std::path::PathBuf;
7
8pub use super::common::{ComponentBuildConfig, ComponentSource, Variable, WasiFilesMount};
9use super::json_schema;
10
11pub(crate) type Map<K, V> = indexmap::IndexMap<K, V>;
12
13/// App manifest
14#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
15#[serde(deny_unknown_fields)]
16pub struct AppManifest {
17    /// `spin_manifest_version = 2`
18    #[schemars(with = "usize", range = (min = 2, max = 2))]
19    pub spin_manifest_version: FixedVersion<2>,
20    /// `[application]`
21    pub application: AppDetails,
22    /// Application configuration variables. These can be set via environment variables, or
23    /// from sources such as Hashicorp Vault or Azure KeyVault by using a runtime config file.
24    /// They are not available directly to components: use a component variable to ingest them.
25    ///
26    /// Learn more: https://spinframework.dev/variables, https://spinframework.dev/dynamic-configuration#application-variables-runtime-configuration
27    #[serde(default, skip_serializing_if = "Map::is_empty")]
28    pub variables: Map<LowerSnakeId, Variable>,
29    /// The triggers to which the application responds. Most triggers can appear
30    /// multiple times with different parameters: for example, the `http` trigger may
31    /// appear multiple times with different routes, or the `redis` trigger with
32    /// different channels.
33    ///
34    /// Example: `[[trigger.http]]`
35    #[serde(rename = "trigger")]
36    #[schemars(with = "json_schema::TriggerSchema")]
37    pub triggers: Map<String, Vec<Trigger>>,
38    /// `[component.<id>]`
39    #[serde(rename = "component")]
40    #[serde(default, skip_serializing_if = "Map::is_empty")]
41    pub components: Map<KebabId, Component>,
42}
43
44impl AppManifest {
45    /// This method ensures that the dependencies of each component are valid.
46    pub fn validate_dependencies(&self) -> anyhow::Result<()> {
47        for (component_id, component) in &self.components {
48            component
49                .dependencies
50                .validate()
51                .with_context(|| format!("component {component_id:?} has invalid dependencies"))?;
52        }
53        Ok(())
54    }
55}
56
57/// App details
58#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
59#[serde(deny_unknown_fields)]
60pub struct AppDetails {
61    /// The name of the application.
62    ///
63    /// Example: `name = "my-app"`
64    pub name: String,
65    /// The application version. This should be a valid semver version.
66    ///
67    /// Example: `version = "1.0.0"`
68    #[serde(default, skip_serializing_if = "String::is_empty")]
69    pub version: String,
70    /// A human-readable description of the application.
71    ///
72    /// Example: `description = "App description"`
73    #[serde(default, skip_serializing_if = "String::is_empty")]
74    pub description: String,
75    /// The author(s) of the application.
76    ///
77    /// `authors = ["author@example.com"]`
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub authors: Vec<String>,
80    /// The Spin environments with which the application must be compatible.
81    ///
82    /// Example: `targets = ["spin-up:3.3", "spinkube:0.4"]`
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub targets: Vec<TargetEnvironmentRef>,
85    /// Application-level settings for the trigger types used in the application.
86    /// The possible values are trigger type-specific.
87    ///
88    /// Example:
89    ///
90    /// ```ignore
91    /// [application.triggers.redis]
92    /// address = "redis://notifications.example.com:6379"
93    /// ```
94    ///
95    /// Learn more (Redis example): https://spinframework.dev/redis-trigger#setting-a-default-server
96    #[serde(rename = "trigger", default, skip_serializing_if = "Map::is_empty")]
97    #[schemars(schema_with = "json_schema::map_of_toml_tables")]
98    pub trigger_global_configs: Map<String, toml::Table>,
99    /// Settings for custom tools or plugins. Spin ignores this field.
100    #[serde(default, skip_serializing_if = "Map::is_empty")]
101    #[schemars(schema_with = "json_schema::map_of_toml_tables")]
102    pub tool: Map<String, toml::Table>,
103}
104
105/// Trigger configuration. A trigger maps an event of the trigger's type (e.g.
106/// an HTTP request on route `/shop`, a Redis message on channel `orders`) to
107/// a Spin component.
108///
109/// The trigger manifest contains additional fields which depend on the trigger
110/// type. For the `http` type, these additional fields are `route` (required) and
111/// `executor` (optional). For the `redis` type, the additional fields are
112/// `channel` (required) and `address` (optional). For other types, see the trigger
113/// documentation.
114///
115/// Learn more: https://spinframework.dev/http-trigger, https://spinframework.dev/redis-trigger
116#[derive(Clone, Debug, Serialize, Deserialize)]
117pub struct Trigger {
118    /// Optional identifier for the trigger.
119    ///
120    /// Example: `id = "trigger-id"`
121    #[serde(default, skip_serializing_if = "String::is_empty")]
122    pub id: String,
123    /// The component that Spin should run when the trigger occurs. For HTTP triggers,
124    /// this is the HTTP request handler for the trigger route. This is typically
125    /// the ID of an entry in the `[component]` table, although you can also write
126    /// the component out as the value of this field.
127    ///
128    /// Example: `component = "shop-handler"`
129    ///
130    /// Learn more: https://spinframework.dev/triggers#triggers-and-components
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub component: Option<ComponentSpec>,
133    /// Reserved for future use.
134    ///
135    /// `components = { ... }`
136    #[serde(default, skip_serializing_if = "Map::is_empty")]
137    pub components: Map<String, OneOrManyComponentSpecs>,
138    /// Opaque trigger-type-specific config
139    #[serde(flatten)]
140    pub config: toml::Table,
141}
142
143/// One or many `ComponentSpec`(s)
144#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
145#[serde(transparent)]
146pub struct OneOrManyComponentSpecs(
147    #[serde(with = "one_or_many")]
148    #[schemars(schema_with = "json_schema::one_or_many::<ComponentSpec>")]
149    pub Vec<ComponentSpec>,
150);
151
152/// Component reference or inline definition
153#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
154#[serde(deny_unknown_fields, untagged, try_from = "toml::Value")]
155pub enum ComponentSpec {
156    /// `"component-id"`
157    Reference(KebabId),
158    /// `{ ... }`
159    Inline(Box<Component>),
160}
161
162impl TryFrom<toml::Value> for ComponentSpec {
163    type Error = toml::de::Error;
164
165    fn try_from(value: toml::Value) -> Result<Self, Self::Error> {
166        if value.is_str() {
167            Ok(ComponentSpec::Reference(KebabId::deserialize(value)?))
168        } else {
169            Ok(ComponentSpec::Inline(Box::new(Component::deserialize(
170                value,
171            )?)))
172        }
173    }
174}
175
176/// Specifies how to satisfy an import dependency of the component. This may be one of:
177///
178/// - A semantic versioning constraint for the package version to use. Spin fetches the latest matching version of the package whose name matches the dependency name from the default registry.
179///
180/// Example: `"my:dep/import" = ">= 0.1.0"`
181///
182/// - A package from a registry.
183///
184/// Example: `"my:dep/import" = { version = "0.1.0", registry = "registry.io", ...}`
185///
186/// - A package from a filesystem path.
187///
188/// Example: `"my:dependency" = { path = "path/to/component.wasm", export = "my-export" }`
189///
190/// - A component in the application. The referenced component binary is composed: additional
191///   configuration such as files, networking, storage, etc. are ignored. This is intended
192///   primarily as a convenience for including dependencies in the manifest so that they
193///   can be built using `spin build`.
194///
195/// Example: `"my:dependency" = { component = "my-dependency", export = "my-export" }`
196///
197/// - A package from an HTTP URL.
198///
199/// Example: `"my:import" = { url = "https://example.com/component.wasm", sha256 = "sha256:..." }`
200///
201/// Learn more: https://spinframework.dev/v3/writing-apps#using-component-dependencies
202#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
203#[serde(untagged, deny_unknown_fields)]
204pub enum ComponentDependency {
205    /// `... = ">= 0.1.0"`
206    #[schemars(description = "")] // schema docs are on the parent
207    Version(String),
208    /// `... = { version = "0.1.0", registry = "registry.io", ...}`
209    #[schemars(description = "")] // schema docs are on the parent
210    Package {
211        /// A semantic versioning constraint for the package version to use. Required. Spin
212        /// fetches the latest matching version from the specified registry, or from
213        /// the default registry if no registry is specified.
214        ///
215        /// Example: `"my:dep/import" = { version = ">= 0.1.0" }`
216        ///
217        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry
218        version: String,
219        /// The registry that hosts the package. If omitted, this defaults to your
220        /// system default registry.
221        ///
222        /// Example: `"my:dep/import" = { registry = "registry.io", version = " 0.1.0" }`
223        ///
224        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry
225        registry: Option<String>,
226        /// The name of the package to use. If omitted, this defaults to the package name of the
227        /// imported interface.
228        ///
229        /// Example: `"my:dep/import" = { package = "your:implementation", version = " 0.1.0" }`
230        ///
231        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry
232        package: Option<String>,
233        /// The name of the export in the package. If omitted, this defaults to the name of the import.
234        ///
235        /// Example: `"my:dep/import" = { export = "your:impl/export", version = " 0.1.0" }`
236        ///
237        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-registry
238        export: Option<String>,
239    },
240    /// `... = { path = "path/to/component.wasm", export = "my-export" }`
241    #[schemars(description = "")] // schema docs are on the parent
242    Local {
243        /// The path to the Wasm file that implements the dependency.
244        ///
245        /// Example: `"my:dep/import" = { path = "path/to/component.wasm" }`
246        ///
247        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-local-component
248        path: PathBuf,
249        /// The name of the export in the package. If omitted, this defaults to the name of the import.
250        ///
251        /// Example: `"my:dep/import" = { export = "your:impl/export", path = "path/to/component.wasm" }`
252        ///
253        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-local-component
254        export: Option<String>,
255    },
256    /// `... = { url = "https://example.com/component.wasm", sha256 = "..." }`
257    #[schemars(description = "")] // schema docs are on the parent
258    HTTP {
259        /// The URL to the Wasm component that implements the dependency.
260        ///
261        /// Example: `"my:dep/import" = { url = "https://example.com/component.wasm", sha256 = "sha256:..." }`
262        ///
263        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-url
264        url: String,
265        /// The SHA256 digest of the Wasm file. This is required for integrity checking. Must begin with `sha256:`.
266        ///
267        /// Example: `"my:dep/import" = { sha256 = "sha256:...", ... }`
268        ///
269        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-url
270        digest: String,
271        /// The name of the export in the package. If omitted, this defaults to the name of the import.
272        ///
273        /// Example: `"my:dep/import" = { export = "your:impl/export", ... }`
274        ///
275        /// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-url
276        export: Option<String>,
277    },
278    /// `... = { component = "my-dependency" }`
279    #[schemars(description = "")] // schema docs are on the parent
280    AppComponent {
281        /// The ID of the component which implements the dependency.
282        ///
283        /// Example: `"my:dep/import" = { component = "my-dependency" }`
284        ///
285        /// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies
286        component: KebabId,
287        /// The name of the export in the package. If omitted, this defaults to the name of the import.
288        ///
289        /// Example: `"my:dep/import" = { export = "your:impl/export", component = "my-dependency" }`
290        ///
291        /// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies
292        export: Option<String>,
293    },
294}
295
296/// A Spin component.
297#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
298#[serde(deny_unknown_fields)]
299pub struct Component {
300    /// The file, package, or URL containing the component Wasm binary.
301    ///
302    /// Example: `source = "bin/cart.wasm"`
303    ///
304    /// Learn more: https://spinframework.dev/writing-apps#the-component-source
305    pub source: ComponentSource,
306    /// A human-readable description of the component.
307    ///
308    /// Example: `description = "Shopping cart"`
309    #[serde(default, skip_serializing_if = "String::is_empty")]
310    pub description: String,
311    /// Configuration variables available to the component. Names must be
312    /// in `lower_snake_case`. Values are strings, and may refer
313    /// to application variables using `{{ ... }}` syntax.
314    ///
315    /// `variables = { users_endpoint = "https://{{ api_host }}/users"}`
316    ///
317    /// Learn more: https://spinframework.dev/variables#adding-variables-to-your-applications
318    #[serde(default, skip_serializing_if = "Map::is_empty")]
319    pub variables: Map<LowerSnakeId, String>,
320    /// Environment variables to be set for the Wasm module.
321    ///
322    /// `environment = { DB_URL = "mysql://spin:spin@localhost/dev" }`
323    #[serde(default, skip_serializing_if = "Map::is_empty")]
324    pub environment: Map<String, String>,
325    /// The files the component is allowed to read. Each list entry is either:
326    ///
327    /// - a glob pattern (e.g. "assets/**/*.jpg"); or
328    ///
329    /// - a source-destination pair indicating where a host directory should be mapped in the guest (e.g. { source = "assets", destination = "/" })
330    ///
331    /// Learn more: https://spinframework.dev/writing-apps#including-files-with-components
332    #[serde(default, skip_serializing_if = "Vec::is_empty")]
333    pub files: Vec<WasiFilesMount>,
334    /// Any files or glob patterns that should not be available to the
335    /// Wasm module at runtime, even though they match a `files`` entry.
336    ///
337    /// Example: `exclude_files = ["secrets/*"]`
338    ///
339    /// Learn more: https://spinframework.dev/writing-apps#including-files-with-components
340    #[serde(default, skip_serializing_if = "Vec::is_empty")]
341    pub exclude_files: Vec<String>,
342    /// Deprecated. Use `allowed_outbound_hosts` instead.
343    ///
344    /// Example: `allowed_http_hosts = ["example.com"]`
345    #[serde(default, skip_serializing_if = "Vec::is_empty")]
346    #[deprecated]
347    pub allowed_http_hosts: Vec<String>,
348    /// The network destinations which the component is allowed to access.
349    /// Each entry is in the form "(scheme)://(host)[:port]". Each element
350    /// allows * as a wildcard e.g. "https://\*" (HTTPS on the default port
351    /// to any destination) or "\*://localhost:\*" (any protocol to any port on
352    /// localhost). The host part allows segment wildcards for subdomains
353    /// e.g. "https://\*.example.com". Application variables are allowed using
354    /// `{{ my_var }}`` syntax.
355    ///
356    /// Example: `allowed_outbound_hosts = ["redis://myredishost.com:6379"]`
357    ///
358    /// Learn more: https://spinframework.dev/http-outbound#granting-http-permissions-to-components
359    #[serde(default, skip_serializing_if = "Vec::is_empty")]
360    #[schemars(with = "Vec<json_schema::AllowedOutboundHost>")]
361    pub allowed_outbound_hosts: Vec<String>,
362    /// The key-value stores which the component is allowed to access. Stores are identified
363    /// by label e.g. "default" or "customer". Stores other than "default" must be mapped
364    /// to a backing store in the runtime config.
365    ///
366    /// Example: `key_value_stores = ["default", "my-store"]`
367    ///
368    /// Learn more: https://spinframework.dev/kv-store-api-guide#custom-key-value-stores
369    #[serde(
370        default,
371        with = "kebab_or_snake_case",
372        skip_serializing_if = "Vec::is_empty"
373    )]
374    #[schemars(with = "Vec<json_schema::KeyValueStore>")]
375    pub key_value_stores: Vec<String>,
376    /// The SQLite databases which the component is allowed to access. Databases are identified
377    /// by label e.g. "default" or "analytics". Databases other than "default" must be mapped
378    /// to a backing store in the runtime config. Use "spin up --sqlite" to run database setup scripts.
379    ///
380    /// Example: `sqlite_databases = ["default", "my-database"]`
381    ///
382    /// Learn more: https://spinframework.dev/sqlite-api-guide#preparing-an-sqlite-database
383    #[serde(
384        default,
385        with = "kebab_or_snake_case",
386        skip_serializing_if = "Vec::is_empty"
387    )]
388    #[schemars(with = "Vec<json_schema::SqliteDatabase>")]
389    pub sqlite_databases: Vec<String>,
390    /// The AI models which the component is allowed to access. For local execution, you must
391    /// download all models; for hosted execution, you should check which models are available
392    /// in your target environment.
393    ///
394    /// Example: `ai_models = ["llama2-chat"]`
395    ///
396    /// Learn more: https://spinframework.dev/serverless-ai-api-guide#using-serverless-ai-from-applications
397    #[serde(default, skip_serializing_if = "Vec::is_empty")]
398    #[schemars(with = "Vec<json_schema::AIModel>")]
399    pub ai_models: Vec<String>,
400    /// The component build configuration.
401    ///
402    /// Learn more: https://spinframework.dev/build
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub build: Option<ComponentBuildConfig>,
405    /// Settings for custom tools or plugins. Spin ignores this field.
406    #[serde(default, skip_serializing_if = "Map::is_empty")]
407    #[schemars(schema_with = "json_schema::map_of_toml_tables")]
408    pub tool: Map<String, toml::Table>,
409    /// If true, dependencies can invoke Spin APIs with the same permissions as the main
410    /// component. If false, dependencies have no permissions (e.g. network,
411    /// key-value stores, SQLite databases).
412    ///
413    /// Learn more: https://spinframework.dev/writing-apps#dependency-permissions
414    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
415    pub dependencies_inherit_configuration: bool,
416    /// Specifies how to satisfy Wasm Component Model imports of this component.
417    ///
418    /// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies
419    #[serde(default, skip_serializing_if = "ComponentDependencies::is_empty")]
420    pub dependencies: ComponentDependencies,
421}
422
423/// Component dependencies
424#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
425#[serde(transparent)]
426pub struct ComponentDependencies {
427    /// `dependencies = { "foo:bar" = ">= 0.1.0" }`
428    pub inner: Map<DependencyName, ComponentDependency>,
429}
430
431impl ComponentDependencies {
432    /// This method validates the correct specification of dependencies in a
433    /// component section of the manifest. See the documentation on the methods
434    /// called for more information on the specific checks.
435    fn validate(&self) -> anyhow::Result<()> {
436        self.ensure_plain_names_have_package()?;
437        self.ensure_package_names_no_export()?;
438        self.ensure_disjoint()?;
439        Ok(())
440    }
441
442    /// This method ensures that all dependency names in plain form (e.g.
443    /// "foo-bar") do not map to a `ComponentDependency::Version`, or a
444    /// `ComponentDependency::Package` where the `package` is `None`.
445    fn ensure_plain_names_have_package(&self) -> anyhow::Result<()> {
446        for (dependency_name, dependency) in self.inner.iter() {
447            let DependencyName::Plain(plain) = dependency_name else {
448                continue;
449            };
450            match dependency {
451                ComponentDependency::Package { package, .. } if package.is_none() => {}
452                ComponentDependency::Version(_) => {}
453                _ => continue,
454            }
455            anyhow::bail!("dependency {plain:?} must specify a package name");
456        }
457        Ok(())
458    }
459
460    /// This method ensures that dependency names in the package form (e.g.
461    /// "foo:bar" or "foo:bar@0.1.0") do not map to specific exported
462    /// interfaces, e.g. `"foo:bar = { ..., export = "my-export" }"` is invalid.
463    fn ensure_package_names_no_export(&self) -> anyhow::Result<()> {
464        for (dependency_name, dependency) in self.inner.iter() {
465            if let DependencyName::Package(name) = dependency_name {
466                if name.interface.is_none() {
467                    let export = match dependency {
468                        ComponentDependency::Package { export, .. } => export,
469                        ComponentDependency::Local { export, .. } => export,
470                        _ => continue,
471                    };
472
473                    anyhow::ensure!(
474                        export.is_none(),
475                        "using an export to satisfy the package dependency {dependency_name:?} is not currently permitted",
476                    );
477                }
478            }
479        }
480        Ok(())
481    }
482
483    /// This method ensures that dependencies names do not conflict with each other. That is to say
484    /// that two dependencies of the same package must have disjoint versions or interfaces.
485    fn ensure_disjoint(&self) -> anyhow::Result<()> {
486        for (idx, this) in self.inner.keys().enumerate() {
487            for other in self.inner.keys().skip(idx + 1) {
488                let DependencyName::Package(other) = other else {
489                    continue;
490                };
491                let DependencyName::Package(this) = this else {
492                    continue;
493                };
494
495                if this.package == other.package {
496                    Self::check_disjoint(this, other)?;
497                }
498            }
499        }
500        Ok(())
501    }
502
503    fn check_disjoint(
504        this: &DependencyPackageName,
505        other: &DependencyPackageName,
506    ) -> anyhow::Result<()> {
507        assert_eq!(this.package, other.package);
508
509        if let (Some(this_ver), Some(other_ver)) = (this.version.clone(), other.version.clone()) {
510            if Self::normalize_compatible_version(this_ver)
511                != Self::normalize_compatible_version(other_ver)
512            {
513                return Ok(());
514            }
515        }
516
517        if let (Some(this_itf), Some(other_itf)) =
518            (this.interface.as_ref(), other.interface.as_ref())
519        {
520            if this_itf != other_itf {
521                return Ok(());
522            }
523        }
524
525        Err(anyhow!("{this:?} dependency conflicts with {other:?}"))
526    }
527
528    /// Normalize version to perform a compatibility check against another version.
529    ///
530    /// See backwards comptabilitiy rules at https://semver.org/
531    fn normalize_compatible_version(mut version: semver::Version) -> semver::Version {
532        version.build = semver::BuildMetadata::EMPTY;
533
534        if version.pre != semver::Prerelease::EMPTY {
535            return version;
536        }
537        if version.major > 0 {
538            version.minor = 0;
539            version.patch = 0;
540            return version;
541        }
542
543        if version.minor > 0 {
544            version.patch = 0;
545            return version;
546        }
547
548        version
549    }
550
551    fn is_empty(&self) -> bool {
552        self.inner.is_empty()
553    }
554}
555
556/// Identifies a deployment target.
557#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
558#[serde(untagged, deny_unknown_fields)]
559pub enum TargetEnvironmentRef {
560    /// Environment definition doc reference e.g. `spin-up:3.2`, `my-host`. This is looked up
561    /// in the default environment catalogue (registry).
562    DefaultRegistry(String),
563    /// An environment definition doc in an OCI registry other than the default
564    Registry {
565        /// Registry or prefix hosting the environment document e.g. `ghcr.io/my/environments`.
566        registry: String,
567        /// Environment definition document name e.g. `my-spin-env:1.2`. For hosted environments
568        /// where you always want `latest`, omit the version tag e.g. `my-host`.
569        id: String,
570    },
571    /// A local environment document file. This is expected to contain a serialised
572    /// EnvironmentDefinition in TOML format.
573    File {
574        /// The file path of the document.
575        path: PathBuf,
576    },
577}
578
579mod kebab_or_snake_case {
580    use serde::{Deserialize, Serialize};
581    pub use spin_serde::{KebabId, SnakeId};
582    pub fn serialize<S>(value: &[String], serializer: S) -> Result<S::Ok, S::Error>
583    where
584        S: serde::ser::Serializer,
585    {
586        if value.iter().all(|s| {
587            KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
588        }) {
589            value.serialize(serializer)
590        } else {
591            Err(serde::ser::Error::custom(
592                "expected kebab-case or snake_case",
593            ))
594        }
595    }
596
597    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
598    where
599        D: serde::Deserializer<'de>,
600    {
601        let value = toml::Value::deserialize(deserializer)?;
602        let list: Vec<String> = Vec::deserialize(value).map_err(serde::de::Error::custom)?;
603        if list.iter().all(|s| {
604            KebabId::try_from(s.clone()).is_ok() || SnakeId::try_from(s.to_owned()).is_ok()
605        }) {
606            Ok(list)
607        } else {
608            Err(serde::de::Error::custom(
609                "expected kebab-case or snake_case",
610            ))
611        }
612    }
613}
614
615impl Component {
616    /// Combine `allowed_outbound_hosts` with the deprecated `allowed_http_hosts` into
617    /// one array all normalized to the syntax of `allowed_outbound_hosts`.
618    pub fn normalized_allowed_outbound_hosts(&self) -> anyhow::Result<Vec<String>> {
619        #[allow(deprecated)]
620        let normalized =
621            crate::compat::convert_allowed_http_to_allowed_hosts(&self.allowed_http_hosts, false)?;
622        if !normalized.is_empty() {
623            terminal::warn!(
624                "Use of the deprecated field `allowed_http_hosts` - to fix, \
625            replace `allowed_http_hosts` with `allowed_outbound_hosts = {normalized:?}`",
626            )
627        }
628
629        Ok(self
630            .allowed_outbound_hosts
631            .iter()
632            .cloned()
633            .chain(normalized)
634            .collect())
635    }
636}
637
638mod one_or_many {
639    use serde::{Deserialize, Deserializer, Serialize, Serializer};
640
641    pub fn serialize<T, S>(vec: &Vec<T>, serializer: S) -> Result<S::Ok, S::Error>
642    where
643        T: Serialize,
644        S: Serializer,
645    {
646        if vec.len() == 1 {
647            vec[0].serialize(serializer)
648        } else {
649            vec.serialize(serializer)
650        }
651    }
652
653    pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Vec<T>, D::Error>
654    where
655        T: Deserialize<'de>,
656        D: Deserializer<'de>,
657    {
658        let value = toml::Value::deserialize(deserializer)?;
659        if let Ok(val) = T::deserialize(value.clone()) {
660            Ok(vec![val])
661        } else {
662            Vec::deserialize(value).map_err(serde::de::Error::custom)
663        }
664    }
665}
666
667#[cfg(test)]
668mod tests {
669    use toml::toml;
670
671    use super::*;
672
673    #[derive(Deserialize)]
674    #[allow(dead_code)]
675    struct FakeGlobalTriggerConfig {
676        global_option: bool,
677    }
678
679    #[derive(Deserialize)]
680    #[allow(dead_code)]
681    struct FakeTriggerConfig {
682        option: Option<bool>,
683    }
684
685    #[test]
686    fn deserializing_trigger_configs() {
687        let manifest = AppManifest::deserialize(toml! {
688            spin_manifest_version = 2
689            [application]
690            name = "trigger-configs"
691            [application.trigger.fake]
692            global_option = true
693            [[trigger.fake]]
694            component = { source = "inline.wasm" }
695            option = true
696        })
697        .unwrap();
698
699        FakeGlobalTriggerConfig::deserialize(
700            manifest.application.trigger_global_configs["fake"].clone(),
701        )
702        .unwrap();
703
704        FakeTriggerConfig::deserialize(manifest.triggers["fake"][0].config.clone()).unwrap();
705    }
706
707    #[derive(Deserialize)]
708    #[allow(dead_code)]
709    struct FakeGlobalToolConfig {
710        lint_level: String,
711    }
712
713    #[derive(Deserialize)]
714    #[allow(dead_code)]
715    struct FakeComponentToolConfig {
716        command: String,
717    }
718
719    #[test]
720    fn deserialising_custom_tool_settings() {
721        let manifest = AppManifest::deserialize(toml! {
722            spin_manifest_version = 2
723            [application]
724            name = "trigger-configs"
725            [application.tool.lint]
726            lint_level = "savage"
727            [[trigger.fake]]
728            something = "something else"
729            [component.fake]
730            source = "dummy"
731            [component.fake.tool.clean]
732            command = "cargo clean"
733        })
734        .unwrap();
735
736        FakeGlobalToolConfig::deserialize(manifest.application.tool["lint"].clone()).unwrap();
737        let fake_id: KebabId = "fake".to_owned().try_into().unwrap();
738        FakeComponentToolConfig::deserialize(manifest.components[&fake_id].tool["clean"].clone())
739            .unwrap();
740    }
741
742    #[test]
743    fn deserializing_labels() {
744        AppManifest::deserialize(toml! {
745            spin_manifest_version = 2
746            [application]
747            name = "trigger-configs"
748            [[trigger.fake]]
749            something = "something else"
750            [component.fake]
751            source = "dummy"
752            key_value_stores = ["default", "snake_case", "kebab-case"]
753            sqlite_databases = ["default", "snake_case", "kebab-case"]
754        })
755        .unwrap();
756    }
757
758    #[test]
759    fn deserializing_labels_fails_for_non_kebab_or_snake() {
760        assert!(AppManifest::deserialize(toml! {
761            spin_manifest_version = 2
762            [application]
763            name = "trigger-configs"
764            [[trigger.fake]]
765            something = "something else"
766            [component.fake]
767            source = "dummy"
768            key_value_stores = ["b@dlabel"]
769        })
770        .is_err());
771    }
772
773    fn get_test_component_with_labels(labels: Vec<String>) -> Component {
774        #[allow(deprecated)]
775        Component {
776            source: ComponentSource::Local("dummy".to_string()),
777            description: "".to_string(),
778            variables: Map::new(),
779            environment: Map::new(),
780            files: vec![],
781            exclude_files: vec![],
782            allowed_http_hosts: vec![],
783            allowed_outbound_hosts: vec![],
784            key_value_stores: labels.clone(),
785            sqlite_databases: labels,
786            ai_models: vec![],
787            build: None,
788            tool: Map::new(),
789            dependencies_inherit_configuration: false,
790            dependencies: Default::default(),
791        }
792    }
793
794    #[test]
795    fn serialize_labels() {
796        let stores = vec![
797            "default".to_string(),
798            "snake_case".to_string(),
799            "kebab-case".to_string(),
800        ];
801        let component = get_test_component_with_labels(stores.clone());
802        let serialized = toml::to_string(&component).unwrap();
803        let deserialized = toml::from_str::<Component>(&serialized).unwrap();
804        assert_eq!(deserialized.key_value_stores, stores);
805    }
806
807    #[test]
808    fn serialize_labels_fails_for_non_kebab_or_snake() {
809        let component = get_test_component_with_labels(vec!["camelCase".to_string()]);
810        assert!(toml::to_string(&component).is_err());
811    }
812
813    #[test]
814    fn test_valid_snake_ids() {
815        for valid in ["default", "mixed_CASE_words", "letters1_then2_numbers345"] {
816            if let Err(err) = SnakeId::try_from(valid.to_string()) {
817                panic!("{valid:?} should be value: {err:?}");
818            }
819        }
820    }
821
822    #[test]
823    fn test_invalid_snake_ids() {
824        for invalid in [
825            "",
826            "kebab-case",
827            "_leading_underscore",
828            "trailing_underscore_",
829            "double__underscore",
830            "1initial_number",
831            "unicode_snowpeople☃☃☃",
832            "mIxEd_case",
833            "MiXeD_case",
834        ] {
835            if SnakeId::try_from(invalid.to_string()).is_ok() {
836                panic!("{invalid:?} should not be a valid SnakeId");
837            }
838        }
839    }
840
841    #[test]
842    fn test_check_disjoint() {
843        for (a, b) in [
844            ("foo:bar@0.1.0", "foo:bar@0.2.0"),
845            ("foo:bar/baz@0.1.0", "foo:bar/baz@0.2.0"),
846            ("foo:bar/baz@0.1.0", "foo:bar/bub@0.1.0"),
847            ("foo:bar@0.1.0", "foo:bar/bub@0.2.0"),
848            ("foo:bar@1.0.0", "foo:bar@2.0.0"),
849            ("foo:bar@0.1.0", "foo:bar@1.0.0"),
850            ("foo:bar/baz", "foo:bar/bub"),
851            ("foo:bar/baz@0.1.0-alpha", "foo:bar/baz@0.1.0-beta"),
852        ] {
853            let a: DependencyPackageName = a.parse().expect(a);
854            let b: DependencyPackageName = b.parse().expect(b);
855            ComponentDependencies::check_disjoint(&a, &b).unwrap();
856        }
857
858        for (a, b) in [
859            ("foo:bar@0.1.0", "foo:bar@0.1.1"),
860            ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
861            ("foo:bar/baz@0.1.0", "foo:bar@0.1.0"),
862            ("foo:bar", "foo:bar@0.1.0"),
863            ("foo:bar@0.1.0-pre", "foo:bar@0.1.0-pre"),
864        ] {
865            let a: DependencyPackageName = a.parse().expect(a);
866            let b: DependencyPackageName = b.parse().expect(b);
867            assert!(
868                ComponentDependencies::check_disjoint(&a, &b).is_err(),
869                "{a} should conflict with {b}",
870            );
871        }
872    }
873
874    #[test]
875    fn test_validate_dependencies() {
876        // Specifying a dependency name as a plain-name without a package is an error
877        assert!(ComponentDependencies::deserialize(toml! {
878            "plain-name" = "0.1.0"
879        })
880        .unwrap()
881        .validate()
882        .is_err());
883
884        // Specifying a dependency name as a plain-name without a package is an error
885        assert!(ComponentDependencies::deserialize(toml! {
886            "plain-name" = { version = "0.1.0" }
887        })
888        .unwrap()
889        .validate()
890        .is_err());
891
892        // Specifying an export to satisfy a package dependency name is an error
893        assert!(ComponentDependencies::deserialize(toml! {
894            "foo:baz@0.1.0" = { path = "foo.wasm", export = "foo"}
895        })
896        .unwrap()
897        .validate()
898        .is_err());
899
900        // Two compatible versions of the same package is an error
901        assert!(ComponentDependencies::deserialize(toml! {
902            "foo:baz@0.1.0" = "0.1.0"
903            "foo:bar@0.2.1" = "0.2.1"
904            "foo:bar@0.2.2" = "0.2.2"
905        })
906        .unwrap()
907        .validate()
908        .is_err());
909
910        // Two disjoint versions of the same package is ok
911        assert!(ComponentDependencies::deserialize(toml! {
912            "foo:bar@0.1.0" = "0.1.0"
913            "foo:bar@0.2.0" = "0.2.0"
914            "foo:baz@0.2.0" = "0.1.0"
915        })
916        .unwrap()
917        .validate()
918        .is_ok());
919
920        // Unversioned and versioned dependencies of the same package is an error
921        assert!(ComponentDependencies::deserialize(toml! {
922            "foo:bar@0.1.0" = "0.1.0"
923            "foo:bar" = ">= 0.2.0"
924        })
925        .unwrap()
926        .validate()
927        .is_err());
928
929        // Two interfaces of two disjoint versions of a package is ok
930        assert!(ComponentDependencies::deserialize(toml! {
931            "foo:bar/baz@0.1.0" = "0.1.0"
932            "foo:bar/baz@0.2.0" = "0.2.0"
933        })
934        .unwrap()
935        .validate()
936        .is_ok());
937
938        // A versioned interface and a different versioned package is ok
939        assert!(ComponentDependencies::deserialize(toml! {
940            "foo:bar/baz@0.1.0" = "0.1.0"
941            "foo:bar@0.2.0" = "0.2.0"
942        })
943        .unwrap()
944        .validate()
945        .is_ok());
946
947        // A versioned interface and package of the same version is an error
948        assert!(ComponentDependencies::deserialize(toml! {
949            "foo:bar/baz@0.1.0" = "0.1.0"
950            "foo:bar@0.1.0" = "0.1.0"
951        })
952        .unwrap()
953        .validate()
954        .is_err());
955
956        // A versioned interface and unversioned package is an error
957        assert!(ComponentDependencies::deserialize(toml! {
958            "foo:bar/baz@0.1.0" = "0.1.0"
959            "foo:bar" = "0.1.0"
960        })
961        .unwrap()
962        .validate()
963        .is_err());
964
965        // An unversioned interface and versioned package is an error
966        assert!(ComponentDependencies::deserialize(toml! {
967            "foo:bar/baz" = "0.1.0"
968            "foo:bar@0.1.0" = "0.1.0"
969        })
970        .unwrap()
971        .validate()
972        .is_err());
973
974        // An unversioned interface and unversioned package is an error
975        assert!(ComponentDependencies::deserialize(toml! {
976            "foo:bar/baz" = "0.1.0"
977            "foo:bar" = "0.1.0"
978        })
979        .unwrap()
980        .validate()
981        .is_err());
982    }
983}