From 4a5076080632fe056a1df79f860453ce519aa10c Mon Sep 17 00:00:00 2001 From: Tom Kirchner Date: Fri, 3 Jan 2020 14:27:16 -0800 Subject: [PATCH] Add "model" macro to simplify model definitions This removes a lot of repetitive attributes and type modifiers through the use of an attribute-style procedural macro applied to our model structs. --- workspaces/Cargo.lock | 59 +++++++ workspaces/deny.toml | 1 + workspaces/models/Cargo.toml | 1 + workspaces/models/README.md | 2 + workspaces/models/model-derive/Cargo.toml | 20 +++ workspaces/models/model-derive/README.md | 44 ++++++ workspaces/models/model-derive/README.tpl | 9 ++ workspaces/models/model-derive/build.rs | 32 ++++ workspaces/models/model-derive/src/lib.rs | 178 ++++++++++++++++++++++ workspaces/models/src/aws-dev/mod.rs | 25 +-- workspaces/models/src/aws-k8s/mod.rs | 31 ++-- workspaces/models/src/lib.rs | 119 +++++---------- 12 files changed, 405 insertions(+), 116 deletions(-) create mode 100644 workspaces/models/model-derive/Cargo.toml create mode 100644 workspaces/models/model-derive/README.md create mode 100644 workspaces/models/model-derive/README.tpl create mode 100644 workspaces/models/model-derive/build.rs create mode 100644 workspaces/models/model-derive/src/lib.rs diff --git a/workspaces/Cargo.lock b/workspaces/Cargo.lock index bf8531f8c0c..7cf776b3c0c 100644 --- a/workspaces/Cargo.lock +++ b/workspaces/Cargo.lock @@ -682,6 +682,38 @@ dependencies = [ "sct 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "darling" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "darling_core 0.10.2 (registry+https://github.com/rust-lang/crates.io-index)", + "darling_macro 0.10.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "darling_core" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "fnv 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)", + "ident_case 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "proc-macro2 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", + "strsim 0.9.3 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "darling_macro" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "darling_core 0.10.2 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "data_store_version" version = "0.1.0" @@ -1180,6 +1212,11 @@ dependencies = [ "tokio-uds 0.2.5 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "idna" version = "0.1.5" @@ -1478,6 +1515,17 @@ dependencies = [ "ws2_32-sys 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "model-derive" +version = "0.1.0" +dependencies = [ + "cargo-readme 3.1.2 (registry+https://github.com/rust-lang/crates.io-index)", + "darling 0.10.2 (registry+https://github.com/rust-lang/crates.io-index)", + "proc-macro2 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "models" version = "0.1.0" @@ -1485,6 +1533,7 @@ dependencies = [ "base64 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)", "cargo-readme 3.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "model-derive 0.1.0", "regex 1.3.1 (registry+https://github.com/rust-lang/crates.io-index)", "serde 1.0.104 (registry+https://github.com/rust-lang/crates.io-index)", "snafu 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -2362,6 +2411,11 @@ name = "strsim" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "strsim" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "structopt" version = "0.3.5" @@ -3249,6 +3303,9 @@ dependencies = [ "checksum crossbeam-utils 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ce446db02cdc3165b94ae73111e570793400d0794e46125cc4056c81cbb039f4" "checksum cstr-argument 0.0.2 (registry+https://github.com/rust-lang/crates.io-index)" = "514570a4b719329df37f93448a70df2baac553020d0eb43a8dfa9c1f5ba7b658" "checksum ct-logs 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "4d3686f5fa27dbc1d76c751300376e167c5a43387f44bb451fd1c24776e49113" +"checksum darling 0.10.2 (registry+https://github.com/rust-lang/crates.io-index)" = "0d706e75d87e35569db781a9b5e2416cff1236a47ed380831f959382ccd5f858" +"checksum darling_core 0.10.2 (registry+https://github.com/rust-lang/crates.io-index)" = "f0c960ae2da4de88a91b2d920c2a7233b400bc33cb28453a2987822d8392519b" +"checksum darling_macro 0.10.2 (registry+https://github.com/rust-lang/crates.io-index)" = "d9b5a2f4ac4969822c62224815d069952656cadc7084fdca9751e6d959189b72" "checksum derive_more 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)" = "7a141330240c921ec6d074a3e188a7c7ef95668bb95e7d44fa0e5778ec2a7afe" "checksum digest 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)" = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" "checksum dirs 2.0.2 (registry+https://github.com/rust-lang/crates.io-index)" = "13aea89a5c93364a98e9b37b2fa237effbb694d5cfe01c5b70941f7eb087d5e3" @@ -3298,6 +3355,7 @@ dependencies = [ "checksum hyper 0.12.35 (registry+https://github.com/rust-lang/crates.io-index)" = "9dbe6ed1438e1f8ad955a4701e9a944938e9519f6888d12d8558b645e247d5f6" "checksum hyper-rustls 0.17.1 (registry+https://github.com/rust-lang/crates.io-index)" = "719d85c7df4a7f309a77d145340a063ea929dcb2e025bae46a80345cffec2952" "checksum hyperlocal 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d063d6d5658623c6ef16f452e11437c0e7e23a6d327470573fe78892dafbc4fb" +"checksum ident_case 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" "checksum idna 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)" = "38f09e0f0b1fb55fdee1f17470ad800da77af5186a1a76c026b679358b7e844e" "checksum idna 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "02e2673c30ee86b5b96a9cb52ad15718aa1f966f5ab9ad54a8b95d5ca33120a9" "checksum indexmap 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "712d7b3ea5827fcb9d4fda14bf4da5f136f0db2ae9c8f4bd4e2d1c6fde4e6db2" @@ -3416,6 +3474,7 @@ dependencies = [ "checksum spin 0.5.2 (registry+https://github.com/rust-lang/crates.io-index)" = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" "checksum string 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "d24114bfcceb867ca7f71a0d3fe45d45619ec47a6fbfa98cb14e14250bfa5d6d" "checksum strsim 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" +"checksum strsim 0.9.3 (registry+https://github.com/rust-lang/crates.io-index)" = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c" "checksum structopt 0.3.5 (registry+https://github.com/rust-lang/crates.io-index)" = "30b3a3e93f5ad553c38b3301c8a0a0cec829a36783f6a0c467fc4bf553a5f5bf" "checksum structopt-derive 0.3.5 (registry+https://github.com/rust-lang/crates.io-index)" = "ea692d40005b3ceba90a9fe7a78fa8d4b82b0ce627eebbffc329aab850f3410e" "checksum syn 0.15.44 (registry+https://github.com/rust-lang/crates.io-index)" = "9ca4b3b69a77cbe1ffc9e198781b7acb0c7365a883670e8f1c1bc66fba79a5c5" diff --git a/workspaces/deny.toml b/workspaces/deny.toml index 20cb0ffde9d..24640699d3e 100644 --- a/workspaces/deny.toml +++ b/workspaces/deny.toml @@ -39,6 +39,7 @@ skip = [ { name = "laika", licenses = [] }, { name = "migration-helpers", licenses = [] }, { name = "migrator", licenses = [] }, + { name = "model-derive", licenses = [] }, { name = "models", licenses = [] }, { name = "moondog", licenses = [] }, { name = "netdog", licenses = [] }, diff --git a/workspaces/models/Cargo.toml b/workspaces/models/Cargo.toml index 7bc5c59cc25..9d0ffc42ad3 100644 --- a/workspaces/models/Cargo.toml +++ b/workspaces/models/Cargo.toml @@ -9,6 +9,7 @@ build = "build.rs" [dependencies] base64 = "0.11" lazy_static = "1.2" +model-derive = { path = "model-derive" } regex = "1.1" serde = { version = "1.0", features = ["derive"] } snafu = "0.6" diff --git a/workspaces/models/README.md b/workspaces/models/README.md index a53edf685a4..d6b3525acea 100644 --- a/workspaces/models/README.md +++ b/workspaces/models/README.md @@ -16,6 +16,8 @@ This `Settings` essentially becomes the schema for the variant's data store. At the field level, standard Rust types can be used, or ["modeled types"](src/modeled_types) that add input validation. +The `#[model]` attribute on Settings and its sub-structs reduces duplication and adds some required metadata; see [its docs](model-derive/) for details. + ### aws-k8s: Kubernetes * [Model](src/aws-k8s/mod.rs) diff --git a/workspaces/models/model-derive/Cargo.toml b/workspaces/models/model-derive/Cargo.toml new file mode 100644 index 00000000000..62cf6f557cc --- /dev/null +++ b/workspaces/models/model-derive/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "model-derive" +version = "0.1.0" +authors = ["Tom Kirchner "] +edition = "2018" +publish = false +build = "build.rs" + +[lib] +path = "src/lib.rs" +proc-macro = true + +[dependencies] +darling = "0.10" +proc-macro2 = "1.0" +quote = "1.0" +syn = { version = "1.0", default-features = false, features = ["full", "parsing", "printing", "proc-macro", "visit-mut"] } + +[build-dependencies] +cargo-readme = "3.1" diff --git a/workspaces/models/model-derive/README.md b/workspaces/models/model-derive/README.md new file mode 100644 index 00000000000..e04def47d1d --- /dev/null +++ b/workspaces/models/model-derive/README.md @@ -0,0 +1,44 @@ +# model-derive + +Current version: 0.1.0 + +## Overview + +This module provides a attribute-style procedural macro, `model`, that makes sure a struct is +ready to be used as an API model. + +The goal is to reduce cognitive overhead when reading models. +We do this by automatically specifying required attributes on structs and fields. + +Several arguments are available to override default behavior; see below. + +## Changes it makes + +### Visibility + +All types must be public, so `pub` is added. +Override this (at a per-struct or per-field level) by specifying your own visibility. + +### Derives + +All structs must serde-`Serializable` and -`Deserializable`, and comparable via `PartialEq`. +`Debug` is added for convenience. +`Default` can also be added by specifying the argument `impl_default = true`. + +### Serde + +Structs have a `#[serde(...)]` attribute added to deny unknown fields and rename fields to kebab-case. +The struct can be renamed (for ser/de purposes) by specifying the argument `rename = "bla"`. + +Fields have a `#[serde(...)]` attribute added to skip `Option` fields that are `None`. +This is because we accept updates in the API that are structured the same way as the model, but we don't want to require users to specify fields they aren't changing. +This can be disabled by specifying the argument `add_option = false`. + +### Option + +Fields are all wrapped in `Option<...>`. +Similar to the `serde` attribute added to fields, this is because we don't want users to have to specify fields they aren't changing, and can be disabled the same way, by specifying `add_option = false`. + +## Colophon + +This text was generated from `README.tpl` using [cargo-readme](https://crates.io/crates/cargo-readme), and includes the rustdoc from `src/lib.rs`. \ No newline at end of file diff --git a/workspaces/models/model-derive/README.tpl b/workspaces/models/model-derive/README.tpl new file mode 100644 index 00000000000..91fb62910c8 --- /dev/null +++ b/workspaces/models/model-derive/README.tpl @@ -0,0 +1,9 @@ +# {{crate}} + +Current version: {{version}} + +{{readme}} + +## Colophon + +This text was generated from `README.tpl` using [cargo-readme](https://crates.io/crates/cargo-readme), and includes the rustdoc from `src/lib.rs`. diff --git a/workspaces/models/model-derive/build.rs b/workspaces/models/model-derive/build.rs new file mode 100644 index 00000000000..9ba3843dc8b --- /dev/null +++ b/workspaces/models/model-derive/build.rs @@ -0,0 +1,32 @@ +// Automatically generate README.md from rustdoc. + +use std::env; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; + +fn main() { + // Check for environment variable "SKIP_README". If it is set, + // skip README generation + if env::var_os("SKIP_README").is_some() { + return; + } + + let mut lib = File::open("src/lib.rs").unwrap(); + let mut template = File::open("README.tpl").unwrap(); + + let content = cargo_readme::generate_readme( + &PathBuf::from("."), // root + &mut lib, // source + Some(&mut template), // template + // The "add x" arguments don't apply when using a template. + true, // add title + false, // add badges + false, // add license + true, // indent headings + ) + .unwrap(); + + let mut readme = File::create("README.md").unwrap(); + readme.write_all(content.as_bytes()).unwrap(); +} diff --git a/workspaces/models/model-derive/src/lib.rs b/workspaces/models/model-derive/src/lib.rs new file mode 100644 index 00000000000..3b6538b8f62 --- /dev/null +++ b/workspaces/models/model-derive/src/lib.rs @@ -0,0 +1,178 @@ +/*! +# Overview + +This module provides a attribute-style procedural macro, `model`, that makes sure a struct is +ready to be used as an API model. + +The goal is to reduce cognitive overhead when reading models. +We do this by automatically specifying required attributes on structs and fields. + +Several arguments are available to override default behavior; see below. + +# Changes it makes + +## Visibility + +All types must be public, so `pub` is added. +Override this (at a per-struct or per-field level) by specifying your own visibility. + +## Derives + +All structs must serde-`Serializable` and -`Deserializable`, and comparable via `PartialEq`. +`Debug` is added for convenience. +`Default` can also be added by specifying the argument `impl_default = true`. + +## Serde + +Structs have a `#[serde(...)]` attribute added to deny unknown fields and rename fields to kebab-case. +The struct can be renamed (for ser/de purposes) by specifying the argument `rename = "bla"`. + +Fields have a `#[serde(...)]` attribute added to skip `Option` fields that are `None`. +This is because we accept updates in the API that are structured the same way as the model, but we don't want to require users to specify fields they aren't changing. +This can be disabled by specifying the argument `add_option = false`. + +## Option + +Fields are all wrapped in `Option<...>`. +Similar to the `serde` attribute added to fields, this is because we don't want users to have to specify fields they aren't changing, and can be disabled the same way, by specifying `add_option = false`. +*/ + +extern crate proc_macro; + +use darling::FromMeta; +use proc_macro::TokenStream; +use quote::ToTokens; +use syn::visit_mut::{self, VisitMut}; +use syn::{ + parse_macro_input, parse_quote, Attribute, AttributeArgs, Field, ItemStruct, Visibility, +}; + +/// Define a `#[model]` attribute that can be placed on structs to be used in an API model. +/// Model requirements are automatically applied to the struct and its fields. +/// (The attribute must be placed on sub-structs; it can't be recursively applied to structs +/// referenced in the given struct.) +#[proc_macro_attribute] +pub fn model(args: TokenStream, input: TokenStream) -> TokenStream { + // Parse args + let attr_args = parse_macro_input!(args as AttributeArgs); + let args = + ParsedArgs::from_list(&attr_args).expect("Unable to parse arguments to `model` macro"); + let mut helper = ModelHelper::from(args); + + // Parse and modify source + let mut ast: ItemStruct = + syn::parse(input).expect("Unable to parse item `model` was placed on - is it a struct?"); + helper.visit_item_struct_mut(&mut ast); + ast.into_token_stream().into() +} + +/// Store any args given by the user inside `#[model(...)]`. +#[derive(Debug, Default, FromMeta)] +#[darling(default)] +struct ParsedArgs { + rename: Option, + impl_default: Option, + add_option: Option, +} + +/// Stores the user's requested options, plus any defaults for unspecified options. +#[derive(Debug)] +struct ModelHelper { + rename: Option, + impl_default: bool, + add_option: bool, +} + +/// Takes the user's requested options and sets default values for anything unspecified. +impl From for ModelHelper { + fn from(args: ParsedArgs) -> Self { + // Add any default values + ModelHelper { + rename: args.rename, + impl_default: args.impl_default.unwrap_or(false), + add_option: args.add_option.unwrap_or(true), + } + } +} + +/// VisitMut helps us modify the node types we want without digging through the huge token trees +/// need to represent them. +impl VisitMut for ModelHelper { + // Visit struct definitions. + fn visit_item_struct_mut(&mut self, node: &mut ItemStruct) { + match node.vis { + // If unset, make pub. + Visibility::Inherited => node.vis = parse_quote!(pub), + // Leave alone anything the user set. + _ => {} + } + + // Add our serde attribute, if the user hasn't set one + if !is_attr_set("serde", &node.attrs) { + // Rename the struct, if the user requested + let attr = if let Some(ref rename_to) = self.rename { + parse_quote!( + #[serde(deny_unknown_fields, rename_all = "kebab-case", rename = #rename_to)] + ) + } else { + parse_quote!( + #[serde(deny_unknown_fields, rename_all = "kebab-case")] + ) + }; + node.attrs.push(attr); + } + + // Add our derives, if the user hasn't set any + if !is_attr_set("derive", &node.attrs) { + // Derive Default, if the user requested + let attr = if self.impl_default { + parse_quote!(#[derive(Debug, Default, PartialEq, Serialize, Deserialize)]) + } else { + parse_quote!(#[derive(Debug, PartialEq, Serialize, Deserialize)]) + }; + node.attrs.push(attr); + } + + // Let the default implementation do its thing, recursively. + visit_mut::visit_item_struct_mut(self, node); + } + + // Visit field definitions in structs. + fn visit_field_mut(&mut self, node: &mut Field) { + match node.vis { + // If unset, make pub. + Visibility::Inherited => node.vis = parse_quote!(pub), + // Leave alone anything the user set. + _ => {} + } + + // Add our serde attribute, if the user hasn't set one + if self.add_option { + if !is_attr_set("serde", &node.attrs) { + node.attrs.push(parse_quote!( + #[serde(skip_serializing_if = "Option::is_none")] + )); + } + + // Wrap each field's type in `Option<...>` + let ty = &node.ty; + node.ty = parse_quote!(Option<#ty>); + } + + // Let the default implementation do its thing, recursively. + visit_mut::visit_field_mut(self, node); + } +} + +/// Checks whether an attribute named `attr_name` (e.g. "serde") is set in the given list of +/// `syn::Attribute`s. +fn is_attr_set(attr_name: &'static str, attrs: &[Attribute]) -> bool { + for attr in attrs { + if let Some(name) = attr.path.get_ident() { + if name == attr_name { + return true; + } + } + } + return false; +} diff --git a/workspaces/models/src/aws-dev/mod.rs b/workspaces/models/src/aws-dev/mod.rs index dc51b8d09e5..edbc4cef520 100644 --- a/workspaces/models/src/aws-dev/mod.rs +++ b/workspaces/models/src/aws-dev/mod.rs @@ -1,3 +1,4 @@ +use model_derive::model; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -6,21 +7,11 @@ use crate::{ContainerImage, NtpSettings, UpdatesSettings}; // Note: we have to use 'rename' here because the top-level Settings structure is the only one // that uses its name in serialization; internal structures use the field name that points to it -#[derive(Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename = "settings", rename_all = "kebab-case")] -pub struct Settings { - #[serde(skip_serializing_if = "Option::is_none")] - pub timezone: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub hostname: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub updates: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub host_containers: Option>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub ntp: Option, +#[model(rename = "settings", impl_default = true)] +struct Settings { + timezone: SingleLineString, + hostname: SingleLineString, + updates: UpdatesSettings, + host_containers: HashMap, + ntp: NtpSettings, } diff --git a/workspaces/models/src/aws-k8s/mod.rs b/workspaces/models/src/aws-k8s/mod.rs index 098c9a4bdc6..76ffaab66d6 100644 --- a/workspaces/models/src/aws-k8s/mod.rs +++ b/workspaces/models/src/aws-k8s/mod.rs @@ -1,3 +1,4 @@ +use model_derive::model; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -5,25 +6,13 @@ use crate::modeled_types::{Identifier, SingleLineString}; use crate::{ContainerImage, KubernetesSettings, NtpSettings, UpdatesSettings}; // Note: we have to use 'rename' here because the top-level Settings structure is the only one -// that uses its name in serialization; internal structures use the field name that poitns to it -#[derive(Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename = "settings", rename_all = "kebab-case")] -pub struct Settings { - #[serde(skip_serializing_if = "Option::is_none")] - pub timezone: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub hostname: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub kubernetes: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub updates: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub host_containers: Option>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub ntp: Option, +// that uses its name in serialization; internal structures use the field name that points to it +#[model(rename = "settings", impl_default = true)] +struct Settings { + timezone: SingleLineString, + hostname: SingleLineString, + kubernetes: KubernetesSettings, + updates: UpdatesSettings, + host_containers: HashMap, + ntp: NtpSettings, } diff --git a/workspaces/models/src/lib.rs b/workspaces/models/src/lib.rs index f98c4ae8d82..991737f412e 100644 --- a/workspaces/models/src/lib.rs +++ b/workspaces/models/src/lib.rs @@ -13,6 +13,8 @@ This `Settings` essentially becomes the schema for the variant's data store. At the field level, standard Rust types can be used, or ["modeled types"](src/modeled_types) that add input validation. +The `#[model]` attribute on Settings and its sub-structs reduces duplication and adds some required metadata; see [its docs](model-derive/) for details. + ## aws-k8s: Kubernetes * [Model](src/aws-k8s/mod.rs) @@ -56,6 +58,7 @@ pub use variant::Settings; // structure based on these, and that's what gets exposed via the API. (Specific variants' models // are in subdirectories and linked into place by build.rs at variant/current.) +use model_derive::model; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::net::Ipv4Addr; @@ -65,86 +68,49 @@ use crate::modeled_types::{ SingleLineString, Url, ValidBase64, }; -// Note: fields are marked with skip_serializing_if=Option::is_none so that settings GETs don't -// show field=null for everything that isn't set in the relevant group of settings. - // Kubernetes related settings. The dynamic settings are retrieved from // IMDS via Sundog's child "Pluto". -#[rustfmt::skip] -#[derive(Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "kebab-case")] -pub struct KubernetesSettings { +#[model] +struct KubernetesSettings { // Settings we require the user to specify, likely via user data. - - #[serde(skip_serializing_if = "Option::is_none")] - pub cluster_name: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub cluster_certificate: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub api_server: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub node_labels: Option>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub node_taints: Option>, + cluster_name: KubernetesClusterName, + cluster_certificate: ValidBase64, + api_server: Url, + node_labels: HashMap, + node_taints: HashMap, // Dynamic settings. - - #[serde(skip_serializing_if = "Option::is_none")] - pub max_pods: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub cluster_dns_ip: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub node_ip: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub pod_infra_container_image: Option, + max_pods: SingleLineString, + cluster_dns_ip: Ipv4Addr, + node_ip: Ipv4Addr, + pod_infra_container_image: SingleLineString, } // Updog settings. Taken from userdata. The 'seed' setting is generated // by the "Bork" settings generator at runtime. -#[derive(Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "kebab-case")] -pub struct UpdatesSettings { - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata_base_url: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub target_base_url: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub seed: Option, +#[model] +struct UpdatesSettings { + metadata_base_url: Url, + target_base_url: Url, + seed: u32, } -#[derive(Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "kebab-case")] -pub struct ContainerImage { - #[serde(skip_serializing_if = "Option::is_none")] - pub source: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub enabled: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub superpowered: Option, +#[model] +struct ContainerImage { + source: Url, + enabled: bool, + superpowered: bool, } // NTP settings -#[derive(Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "kebab-case")] -pub struct NtpSettings { - #[serde(skip_serializing_if = "Option::is_none")] - pub time_servers: Option>, +#[model] +struct NtpSettings { + time_servers: Vec, } ///// Internal services -// Note: Top-level objects that get returned from the API should have a serde "rename" attribute +// Note: Top-level objects that get returned from the API should have a "rename" attribute // matching the struct name, but in kebab-case, e.g. ConfigurationFiles -> "configuration-files". // This lets it match the datastore name. // Objects that live inside those top-level objects, e.g. Service lives in Services, should have @@ -153,28 +119,25 @@ pub struct NtpSettings { pub type Services = HashMap; -#[derive(Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename = "", rename_all = "kebab-case")] -pub struct Service { - pub configuration_files: Vec, - pub restart_commands: Vec, +#[model(add_option = false, rename = "")] +struct Service { + configuration_files: Vec, + restart_commands: Vec, } pub type ConfigurationFiles = HashMap; -#[derive(Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename = "", rename_all = "kebab-case")] -pub struct ConfigurationFile { - pub path: SingleLineString, - pub template_path: SingleLineString, +#[model(add_option = false, rename = "")] +struct ConfigurationFile { + path: SingleLineString, + template_path: SingleLineString, } ///// Metadata -#[derive(Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename = "metadata", rename_all = "kebab-case")] -pub struct Metadata { - pub key: SingleLineString, - pub md: SingleLineString, - pub val: toml::Value, +#[model(add_option = false, rename = "metadata")] +struct Metadata { + key: SingleLineString, + md: SingleLineString, + val: toml::Value, }