diff --git a/Cargo.lock b/Cargo.lock index 81f17bd739..87ac0d6c4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7127,6 +7127,32 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "schemars" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92" +dependencies = [ + "dyn-clone", + "indexmap 2.6.0", + "schemars_derive", + "semver", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.87", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -7254,6 +7280,17 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "serde_ignored" version = "0.1.10" @@ -7657,6 +7694,7 @@ dependencies = [ "reqwest 0.12.9", "rpassword", "runtime-tests", + "schemars", "semver", "serde", "serde_json", @@ -8206,6 +8244,7 @@ dependencies = [ "anyhow", "glob", "indexmap 2.6.0", + "schemars", "semver", "serde", "serde_json", @@ -8339,6 +8378,7 @@ version = "3.2.0-pre0" dependencies = [ "anyhow", "base64 0.22.1", + "schemars", "semver", "serde", "wasm-pkg-common", diff --git a/Cargo.toml b/Cargo.toml index f110d9d73b..d5e9521ea3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ path-absolutize = "3" regex = { workspace = true } reqwest = { workspace = true } rpassword = "7" +schemars = { version = "0.8.21", features = ["indexmap2", "semver"] } semver = "1" serde = { version = "1", features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/manifest/Cargo.toml b/crates/manifest/Cargo.toml index 50fd9f37d3..217abd2692 100644 --- a/crates/manifest/Cargo.toml +++ b/crates/manifest/Cargo.toml @@ -7,6 +7,7 @@ edition = { workspace = true } [dependencies] anyhow = { workspace = true } indexmap = { version = "2", features = ["serde"] } +schemars = { version = "0.8.21", features = ["indexmap2", "semver"] } semver = { version = "1.0", features = ["serde"] } serde = { workspace = true } spin-serde = { path = "../serde" } diff --git a/crates/manifest/src/compat.rs b/crates/manifest/src/compat.rs index 638fbf3d01..b16dd0bf63 100644 --- a/crates/manifest/src/compat.rs +++ b/crates/manifest/src/compat.rs @@ -60,6 +60,7 @@ pub fn v1_to_v2_app(manifest: v1::AppManifestV1) -> Result, /// `package = "example:component"` + #[schemars(with = "String")] package: PackageRef, /// `version = "1.2.3"` version: String, @@ -64,7 +67,7 @@ impl Display for ComponentSource { } /// WASI files mount -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields, untagged)] pub enum WasiFilesMount { /// `"images/*.png"` @@ -79,7 +82,7 @@ pub enum WasiFilesMount { } /// Component build configuration -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ComponentBuildConfig { /// `command = "cargo build"` @@ -104,7 +107,7 @@ impl ComponentBuildConfig { } /// Component build command or commands -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(untagged)] pub enum Commands { /// `command = "cargo build"` diff --git a/crates/manifest/src/schema/v2.rs b/crates/manifest/src/schema/v2.rs index 61d0ac0f47..7833ac8489 100644 --- a/crates/manifest/src/schema/v2.rs +++ b/crates/manifest/src/schema/v2.rs @@ -1,4 +1,5 @@ use anyhow::Context; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use spin_serde::{DependencyName, DependencyPackageName, FixedVersion, LowerSnakeId}; pub use spin_serde::{KebabId, SnakeId}; @@ -9,10 +10,11 @@ pub use super::common::{ComponentBuildConfig, ComponentSource, Variable, WasiFil pub(crate) type Map = indexmap::IndexMap; /// App manifest -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct AppManifest { /// `spin_manifest_version = 2` + #[schemars(with = "usize", range = (min = 2, max = 2))] pub spin_manifest_version: FixedVersion<2>, /// `[application]` pub application: AppDetails, @@ -21,6 +23,7 @@ pub struct AppManifest { pub variables: Map, /// `[[trigger.]]` #[serde(rename = "trigger")] + #[schemars(with = "schema::TriggerSchema")] pub triggers: Map>, /// `[component.]` #[serde(rename = "component")] @@ -42,7 +45,7 @@ impl AppManifest { } /// App details -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct AppDetails { /// `name = "my-app"` @@ -58,9 +61,11 @@ pub struct AppDetails { pub authors: Vec, /// `[application.triggers.]` #[serde(rename = "trigger", default, skip_serializing_if = "Map::is_empty")] + #[schemars(schema_with = "map_of_toml_tables_schema")] pub trigger_global_configs: Map, /// Settings for custom tools or plugins. Spin ignores this field. #[serde(default, skip_serializing_if = "Map::is_empty")] + #[schemars(schema_with = "map_of_toml_tables_schema")] pub tool: Map, } @@ -82,12 +87,123 @@ pub struct Trigger { } /// One or many `ComponentSpec`(s) -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(transparent)] -pub struct OneOrManyComponentSpecs(#[serde(with = "one_or_many")] pub Vec); +pub struct OneOrManyComponentSpecs( + #[serde(with = "one_or_many")] + #[schemars(schema_with = "one_or_many_schema::")] + pub Vec, +); + +fn one_or_many_schema( + gen: &mut schemars::gen::SchemaGenerator, +) -> schemars::schema::Schema { + schemars::schema::Schema::Object(schemars::schema::SchemaObject { + subschemas: Some(Box::new(schemars::schema::SubschemaValidation { + one_of: Some(vec![ + gen.subschema_for::(), + gen.subschema_for::>(), + ]), + ..Default::default() + })), + ..Default::default() + }) +} + +fn toml_table_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + schemars::schema::Schema::Object(schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::SingleOrVec::Single(Box::new( + schemars::schema::InstanceType::Object, + ))), + ..Default::default() + }) +} + +fn map_of_toml_tables_schema( + _gen: &mut schemars::gen::SchemaGenerator, +) -> schemars::schema::Schema { + schemars::schema::Schema::Object(schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::SingleOrVec::Single(Box::new( + schemars::schema::InstanceType::Object, + ))), + ..Default::default() + }) +} + +/// TODO: there may be a way to do this without dead code using `schemars::extend` or +/// something like that? +#[allow(dead_code)] +mod schema { + use super::{toml_table_schema, ComponentSpec, Map, OneOrManyComponentSpecs}; + use schemars::JsonSchema; + + #[derive(JsonSchema)] + pub struct TriggerSchema { + /// HTTP triggers + #[schemars(default)] + http: Vec, + /// Redis triggers + #[schemars(default)] + redis: Vec, + } + + #[derive(JsonSchema)] + #[schemars(deny_unknown_fields)] + struct HttpTriggerSchema { + /// `id = "trigger-id"` + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + /// `component = ...` + #[serde(default, skip_serializing_if = "Option::is_none")] + pub component: Option, + /// `components = { ... }` + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub components: Map, + /// `route = "/user/:name/..."` + route: HttpRouteSchema, + /// `executor = { type = "wagi" } + #[schemars(default, schema_with = "toml_table_schema")] + executor: Option, + } + + #[derive(JsonSchema)] + #[schemars(untagged)] + enum HttpRouteSchema { + /// `route = "/user/:name/..."` + Route(String), + /// `route = { private = true }` + Private(HttpPrivateEndpoint), + } + + #[derive(JsonSchema)] + #[schemars(deny_unknown_fields)] + struct HttpPrivateEndpoint { + /// Whether the private endpoint is private. This must be true. + pub private: bool, + } + + #[derive(JsonSchema)] + #[schemars(deny_unknown_fields)] + struct RedisTriggerSchema { + /// `id = "trigger-id"` + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + /// `component = ...` + #[serde(default, skip_serializing_if = "Option::is_none")] + pub component: Option, + /// `components = { ... }` + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub components: Map, + /// `channel = "my-messages"` + channel: String, + /// `address = "redis://redis.example.com:6379"` + #[serde(default, skip_serializing_if = "Option::is_none")] + address: Option, + } +} /// Component reference or inline definition -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields, untagged, try_from = "toml::Value")] pub enum ComponentSpec { /// `"component-id"` @@ -111,7 +227,7 @@ impl TryFrom for ComponentSpec { } /// Component dependency -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(untagged, deny_unknown_fields)] pub enum ComponentDependency { /// `... = ">= 0.1.0"` @@ -147,7 +263,7 @@ pub enum ComponentDependency { } /// Component definition -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Component { /// `source = ...` @@ -169,6 +285,7 @@ pub struct Component { pub exclude_files: Vec, /// `allowed_http_hosts = ["example.com"]` #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[deprecated] pub allowed_http_hosts: Vec, /// `allowed_outbound_hosts = ["redis://myredishost.com:6379"]` #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -179,6 +296,7 @@ pub struct Component { with = "kebab_or_snake_case", skip_serializing_if = "Vec::is_empty" )] + #[schemars(with = "Vec")] pub key_value_stores: Vec, /// `sqlite_databases = ["default", "my-database"]` #[serde( @@ -186,6 +304,7 @@ pub struct Component { with = "kebab_or_snake_case", skip_serializing_if = "Vec::is_empty" )] + #[schemars(with = "Vec")] pub sqlite_databases: Vec, /// `ai_models = ["llama2-chat"]` #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -195,6 +314,7 @@ pub struct Component { pub build: Option, /// Settings for custom tools or plugins. Spin ignores this field. #[serde(default, skip_serializing_if = "Map::is_empty")] + #[schemars(schema_with = "map_of_toml_tables_schema")] pub tool: Map, /// If true, allow dependencies to inherit configuration. #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -205,7 +325,7 @@ pub struct Component { } /// Component dependencies -#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] #[serde(transparent)] pub struct ComponentDependencies { /// `dependencies = { "foo:bar" = ">= 0.1.0" }` @@ -377,6 +497,7 @@ impl Component { /// Combine `allowed_outbound_hosts` with the deprecated `allowed_http_hosts` into /// one array all normalized to the syntax of `allowed_outbound_hosts`. pub fn normalized_allowed_outbound_hosts(&self) -> anyhow::Result> { + #[allow(deprecated)] let normalized = crate::compat::convert_allowed_http_to_allowed_hosts(&self.allowed_http_hosts, false)?; if !normalized.is_empty() { @@ -531,6 +652,7 @@ mod tests { } fn get_test_component_with_labels(labels: Vec) -> Component { + #[allow(deprecated)] Component { source: ComponentSource::Local("dummy".to_string()), description: "".to_string(), diff --git a/crates/serde/Cargo.toml b/crates/serde/Cargo.toml index 7c6d342ba5..11007aad11 100644 --- a/crates/serde/Cargo.toml +++ b/crates/serde/Cargo.toml @@ -7,6 +7,7 @@ edition = { workspace = true } [dependencies] anyhow = { workspace = true } base64 = "0.22.1" +schemars = { version = "0.8.21", features = ["indexmap2", "semver"] } semver = { version = "1.0", features = ["serde"] } serde = { workspace = true } wasm-pkg-common = { workspace = true } diff --git a/crates/serde/src/id.rs b/crates/serde/src/id.rs index 0e1cee45be..4e8d12e70e 100644 --- a/crates/serde/src/id.rs +++ b/crates/serde/src/id.rs @@ -1,10 +1,13 @@ //! ID (de)serialization +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// An ID is a non-empty string containing one or more component model /// `word`s separated by a delimiter char. -#[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)] +#[derive( + Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, JsonSchema, +)] #[serde(into = "String", try_from = "String")] pub struct Id(String); diff --git a/crates/serde/src/version.rs b/crates/serde/src/version.rs index 9516c38b5e..f05573ffb1 100644 --- a/crates/serde/src/version.rs +++ b/crates/serde/src/version.rs @@ -1,8 +1,10 @@ +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// FixedVersion represents a version integer field with a const value. -#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] #[serde(into = "usize", try_from = "usize")] +#[schemars(with = "usize", range = (min = V, max = V))] pub struct FixedVersion; impl From> for usize { @@ -24,8 +26,9 @@ impl TryFrom for FixedVersion { /// FixedVersion represents a version integer field with a const value, /// but accepts lower versions during deserialisation. -#[derive(Clone, Debug, Default, Deserialize)] +#[derive(Clone, Debug, Default, Deserialize, JsonSchema)] #[serde(into = "usize", try_from = "usize")] +#[schemars(with = "usize", range = (min = 1, max = V))] pub struct FixedVersionBackwardCompatible; impl From> for usize { diff --git a/src/bin/spin.rs b/src/bin/spin.rs index e3ba655040..ce9db88b30 100644 --- a/src/bin/spin.rs +++ b/src/bin/spin.rs @@ -136,6 +136,8 @@ enum SpinApp { #[clap(alias = "w")] Watch(WatchCommand), Doctor(DoctorCommand), + #[clap(hide = true)] + Schema(SchemaCommand), } #[derive(Subcommand)] @@ -165,6 +167,7 @@ impl SpinApp { Self::External(cmd) => execute_external_subcommand(cmd, app).await, Self::Watch(cmd) => cmd.run().await, Self::Doctor(cmd) => cmd.run().await, + Self::Schema(cmd) => cmd.run().await, } } } @@ -223,3 +226,14 @@ fn installed_plugin_help_entries() -> Vec { fn hide_plugin_in_help(plugin: &spin_plugins::manifest::PluginManifest) -> bool { plugin.name().starts_with("trigger-") } + +#[derive(Parser, Debug)] +struct SchemaCommand; + +impl SchemaCommand { + async fn run(&self) -> anyhow::Result<()> { + let schema = schemars::schema_for!(spin_manifest::schema::v2::AppManifest); + println!("{}", serde_json::to_string_pretty(&schema)?); + Ok(()) + } +}