Schematic
Schematic is a library that provides:
- A layered serde-driven configuration system with support for merge strategies, validation rules, environment variables, and more!
- A schema modeling system that can be used to generate TypeScript types, JSON schemas, and more!
Both of these features can be used independently or together.
cargo add schematic
Example references
The following projects are using Schematic and can be used as a reference:
- moon - A build system for web based monorepos.
- proto - A multi-language version manager with WASM plugin support.
- ryot - Track various aspects of your life.
Configuration
Requires the
config
Cargo feature, which is enabled by default.
The primary feature of Schematic is a layered serde-driven configuration solution, and is powered
through the Config
and ConfigEnum
traits, and their
associated derive macro. These macros help to generate and automate the following (when applicable):
- Generates a partial implementation, with all field values wrapped in
Option
. - Provides default value and environment variable handling.
- Implements merging and validation logic.
- Models a schema (when
schema
Cargo feature enabled). - And other minor features, like context & metadata.
The struct or enum that derives Config
represents the final state, after all
partial layers have been merged, and default and environment variable values have
been applied. This means that all fields (settings) should not be wrapped in Option
, unless the
setting is truly optional (think nullable in the config file).
#![allow(unused)] fn main() { #[derive(Config)] struct ExampleConfig { pub number: usize, pub string: String, pub boolean: bool, pub array: Vec<String>, pub optional: Option<String>, } }
This pattern provides the optimal developer experience, as you can reference the settings as-is, without having to unwrap them, or use
match
orif-let
statements!
Usage
Define a struct or enum and derive the Config
trait. Fields within the struct
(known as settings) can be annotated with the #[setting]
attribute to provide
additional functionality.
#![allow(unused)] fn main() { use schematic::Config; #[derive(Config)] struct AppConfig { #[setting(default = 3000, env = "PORT")] pub port: usize, #[setting(default = true)] pub secure: bool, #[setting(default = vec!["localhost".into()])] pub allowed_hosts: Vec<String>, } }
Loading sources
When all of your structs and enums have been defined, you can then load, parse, merge, and validate a configuration from one or many sources. A source is either a file path, secure URL, or inline code string.
Begin by importing the
ConfigLoader
struct and
initializing it with the Config
type you want to load.
#![allow(unused)] fn main() { use schematic::ConfigLoader; let loader = ConfigLoader::<AppConfig>::new(); }
From here, you can feed it sources to load. For file paths, use the
ConfigLoader::file()
or
ConfigLoader::file_optional()
methods. For URLs, use the
ConfigLoader::url()
method (requires the url
Cargo feature, which is on by default). For inline code, use the
ConfigLoader::code()
method, which requires an explicit format.
#![allow(unused)] fn main() { use schematic::Format; loader.code("secure: false", Format::Yaml)?; loader.file("path/to/config.yml")?; loader.url("https://ordomain.com/to/config.yaml")?; }
The format for files and URLs are derived from the trailing extension.
And lastly call the
ConfigLoader::load()
method to generate the final configuration. This methods returns a result, which includes the final
configuration, as well as all of the partial layers that were loaded.
#![allow(unused)] fn main() { let result = loader.load()?; result.config; // AppConfig result.layers; // Vec<Layer<PartialAppConfig>> }
Automatic schemas
When the schema
Cargo feature is enabled, the
Schematic
trait will be
automatically implemented for all types that implement
Config
and
ConfigEnum
. You do not and
should not derive both of these together.
#![allow(unused)] fn main() { // Correct #[derive(Config)] struct AppConfig {} // Incorrect #[derive(Config, Schematic)] struct AppConfig {} }
Supported source formats
Schematic is powered entirely by serde, and supports the following formats:
- JSON - Uses
serde_json
and requires thejson
Cargo feature. - Pkl (experimental) - Uses
rpkl
and requires thepkl
Cargo feature. - TOML - Uses
toml
and requires thetoml
Cargo feature. - YAML - Uses
serde_yaml
and requires theyaml
Cargo feature.
Cargo features
The following Cargo features are available:
config
(default) - Enables configuration support (all the above stuff).env
(default) - Enables environment variables for settings.extends
(default) - Enables configs to extend other configs.json
- Enables JSON.pkl
- Enables Pkl.toml
- Enables TOML.tracing
- Wrap generated code in tracing instrumentations.url
- Enables loading, extending, and parsing configs from URLs.validate
(default) - Enables setting value validation.yaml
- Enables YAML.
Settings
Settings are the individual fields of a Config
struct or variants of a
Config
enum, and can be annotated with the optional #[setting]
attribute.
Attribute fields
The following fields are supported for the #[setting]
field/variant attribute:
default
- Sets the default value.env
(struct only) - Sets the environment variable to receive a value from.extend
(struct only) - Enables a configuration to extend other configs.merge
- Defines a function to use for merging values.nested
- Marks the field as using a nestedConfig
.parse_env
(struct only) - Parses the environment variable value using a function.required
- Marks the field as required. This is useful forOption
types that do not supportDefault
, but require a value.validate
- Defines a function to use for validating values.
And the following for serde compatibility:
alias
flatten
rename
skip
skip_deserializing
skip_serializing
Serde support
A handful of serde attribute fields are currently supported (above) and will apply a #[serde]
attribute to the partial implementation.
#![allow(unused)] fn main() { #[derive(Config)] struct Example { #[setting(rename = "type")] pub type_of: SomeEnum, } }
These values can also be applied using
#[serde]
, which is useful if you want to apply them to the main struct as well, and not just the partial struct.
Partials
A powerful feature of Schematic is what we call partial configurations. These are a mirror of the
derived Config
struct or Config
enum, with all
settings wrapped in Option
, the item name prefixed with Partial
, and have common serde and
derive attributes automatically applied.
For example, the ExampleConfig
from the first chapter would generate the
following partial struct:
#![allow(unused)] fn main() { #[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] #[serde(default, deny_unknown_fields, rename_all = "camelCase")] pub struct PartialExampleConfig { #[serde(skip_serializing_if = "Option::is_none")] pub number: Option<usize>, #[serde(skip_serializing_if = "Option::is_none")] pub string: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub boolean: Option<bool>, #[serde(skip_serializing_if = "Option::is_none")] pub array: Option<Vec<String>>, #[serde(skip_serializing_if = "Option::is_none")] pub optional: Option<String>, } }
So what are partials used for exactly? Partials are used for the entire parsing, layering, extending, and merging process, and ultimately become the final configuration.
When deserializing a source with serde, we utilize the partial config as the target type, because not all fields are guaranteed to be present. This is especially true when merging multiple sources together, as each source may only contain a subset of the final config. Each source represents a layer to be merged.
Partials are also beneficial when serializing, as only settings with values will be written to the source, instead of everything! A common complaint of serde’s strictness.
As stated above, partials also handle the following:
- Defining default values for settings.
- Inheriting environment variable values.
- Merging partials with strategy functions.
- Validating current values with validate functions.
- Declaring extendable sources.
Nesting
Config
structs can easily be nested within other Config
s using
the #[setting(nested)]
attribute. Children will be deeply merged and validated alongside the
parent.
#![allow(unused)] fn main() { #[derive(Config)] struct ChildConfig { // ... } #[derive(Config)] struct ParentConfig { #[setting(nested)] pub nested: ChildConfig, #[setting(nested)] pub optional_nested: Option<ChildConfig>, } #[derive(Config)] enum ParentEnum { #[setting(nested)] Variant(ChildConfig), } }
The #[setting(nested)]
attribute is required, as the macro will substitute Config
with
its partial implementation.
Nested values can also be wrapped in collections, like
Vec
andHashMap
. However, these are tricky to support and may not work in all situations!
Bare structs
For structs that do not implement the Config
trait, you can use them as-is without the
#[setting(nested)]
attribute. When using bare structs, be aware that all of the functionality
provided by our Config
trait is not available, like merging and validation.
#![allow(unused)] fn main() { struct BareConfig { // ... } #[derive(Config)] pub struct ParentConfig { pub nested: BareConfig, } }
Context
Context is an important mechanism that allows for different default values, merge strategies, and validation rules to be used, for the same configuration struct, depending on context!
To begin, a context is a struct with a default implementation.
#![allow(unused)] fn main() { #[derive(Default)] struct ExampleContext { pub some_value: bool, pub another_value: usize, } }
Context must then be associated with a
Config
derived struct through the
context
attribute field.
#![allow(unused)] fn main() { #[derive(Config)] #[config(context = ExampleContext)] struct ExampleConfig { // ... } }
And then passed to the
ConfigLoader::load_with_context()
method.
#![allow(unused)] fn main() { let context = ExampleContext { some_value: true, another_value: 10, }; let result = ConfigLoader::<ExampleConfig>::new() .url(url_to_config)? .load_with_context(&context)?; }
Refer to the default values, merge strategies, and validation rules sections for more information on how to use context.
Structs & enums
The Config
trait can be derived for structs and enums.
#![allow(unused)] fn main() { #[derive(Config)] struct AppConfig { pub base: String, pub port: usize, pub secure: bool, pub allowed_hosts: Vec<String>, } #[derive(Config)] enum Host { Local, Remote(HostConfig), } }
Enum caveats
Config
can only be derived for enums with tuple or unit variants, but not struct/named
variants. Why not struct variants? Because with this pattern, the enum acts like a union type. This
also allows for Config
functionality, like partials, merging, and validation, to be
applied to the contents of each variant.
If you’d like to support unit-only enums, you can use the
ConfigEnum
trait instead.
Attribute fields
The following fields are supported for the #[config]
container attribute:
allow_unknown_fields
- Removes the serdedeny_unknown_fields
from the partial struct. Defaults tofalse
.context
- Sets the struct to be used as the context. Defaults toNone
.env_prefix
- Sets the prefix to use for environment variable mapping. Defaults toNone
.serde
- A nested attribute that sets tagging related fields for the partial. Defaults toNone
.
#![allow(unused)] fn main() { #[derive(Config)] #[config(allow_unknown_fields, env_prefix = "EXAMPLE_")] struct ExampleConfig { // ... } }
And the following for serde compatibility:
rename
rename_all
- Defaults tocamelCase
.
Serde support
By default the Config
macro will apply the following #[serde]
to the
partial struct. The default
and deny_unknown_fields
ensure proper parsing and
layer merging.
#![allow(unused)] fn main() { #[serde(default, deny_unknown_fields, rename_all = "camelCase")] }
However, the deny_unknown_fields
and rename_all
fields can be customized, and we also support
the rename
field, both via the top-level #[config]
attribute.
#![allow(unused)] fn main() { #[derive(Config)] #[config(allow_unknown_fields, rename = "ExampleConfig", rename_all = "snake_case")] struct Example { // ... } }
These values can also be applied using
#[serde]
, which is useful if you want to apply them to the main struct as well, and not just the partial struct.
Default values
In Schematic, there are 2 forms of default values:
- The first is applied through the partial configuration, is defined with the
#[setting]
attribute, and is the first layer to be merged. - The second is on the final configuration itself, and uses the
Default
trait to generate the final value if none was provided. This acts more like a fallback.
To define a default value, use the #[setting(default)]
attribute. The default
attribute field is
used for declaring primitive values, like numbers, strings, and booleans, but can also be used for
array and tuple literals, as well as function (mainly for from()
) and macros calls.
#![allow(unused)] fn main() { #[derive(Config)] struct AppConfig { #[setting(default = "/")] pub base: String, #[setting(default = 3000)] pub port: usize, #[setting(default = true)] pub secure: bool, #[setting(default = vec!["localhost".into()])] pub allowed_hosts: Vec<String>, } }
For enums, the default
field takes no value, and simply marks which variant to use as the default.
#![allow(unused)] fn main() { #[derive(Config)] enum Host { #[setting(default)] Local, Remote(HostConfig), } }
Handler function
If you need more control or need to calculate a complex value, you can pass a reference to a
function to call. This function receives the context as the first argument, and can
return an optional value. If None
is returned, the Default
value will be used instead.
#![allow(unused)] fn main() { fn find_unused_port(ctx: &Context) -> DefaultValueResult<usize> { let port = do_find()?; Ok(Some(port)) } #[derive(Config)] struct AppConfig { #[setting(default = find_unused_port)] pub port: usize, } }
Context handling
If you’re not using context, you can use ()
as the context type, or rely on
generic inferrence.
#![allow(unused)] fn main() { fn using_unit_type(_: &()) -> DefaultValueResult<usize> { // ... } fn using_generics<C>(_: &C) -> DefaultValueResult<usize> { // ... } }
Environment variables
Requires the
env
Cargo feature, which is enabled by default.
Not supported for enums.
Settings can also inherit values from environment variables via the #[setting(env)]
attribute
field. When using this, variables take the highest precedence, and are merged as the last layer.
#![allow(unused)] fn main() { #[derive(Config)] struct AppConfig { #[setting(default = 3000, env = "PORT")] pub port: usize, } }
Container prefixes
If you’d prefer to not define env
for every setting, you can instead define a prefix on the
containing struct using the #[setting(env_prefix)]
attribute field. This will define an
environment variable for all direct fields in the struct, in the format of “env prefix + field
name” in UPPER_SNAKE_CASE.
For example, the environment variable below for port
is now APP_PORT
.
#![allow(unused)] fn main() { #[derive(Config)] #[config(env_prefix = "APP_")] struct AppConfig { #[setting(default = 3000)] pub port: usize, } }
Nested prefixes
Since env_prefix
only applies to direct fields and not for nested/children structs, you’ll need to
define env_prefix
for each struct, and manually set the prefixes. Schematic does not concatenate
the prefixes between parent and child.
#![allow(unused)] fn main() { #[derive(Config)] #[config(env_prefix = "APP_SERVER_")] struct AppServerConfig { // ... } #[derive(Config)] #[config(env_prefix = "APP_")] struct AppConfig { #[setting(nested)] pub server: AppServerConfig, } }
Parsing values
We also support parsing environment variables into the required type. For example, the variable may be a comma separated list of values, or a JSON string.
The #[setting(parse_env)]
attribute field can be used, which requires a path to a function to
handle the parsing, and receives the variable value as a single argument.
#![allow(unused)] fn main() { #[derive(Config)] struct AppConfig { #[setting(env = "ALLOWED_HOSTS", parse_env = schematic::env::split_comma)] pub allowed_hosts: Vec<String>, } }
We provide a handful of built-in parsing functions in the
env
module.
Parse handler function
You can also define your own function for parsing values out of environment variables.
When defining a custom parse_env
function, the variable value is passed as the 1st argument. A
None
value can be returned, which will fallback to the previous or default value.
#![allow(unused)] fn main() { pub fn custom_parse(var: String) -> ParseEnvResult<ReturnValue> { do_parse() .map(|v| Some(v)) .map_err(|e| HandlerError::new(e.to_string())) } #[derive(Config)] struct ExampleConfig { #[setting(env = "FIELD", parse_env = custom_parse)] pub field: String, } }
Extendable sources
Requires the
extends
Cargo feature, which is enabled by default.
Not supported for enums.
Configs can extend other configs, generating an accurate layer chain, via the #[setting(extend)]
attribute field. Extended configs can either be a file path (relative from the current config) or a
secure URL.
When defining extend
, we currently support 3 types of patterns. We also suggest making the setting
optional, so that extending is not required by consumers!
Single source
The first pattern is with a single string, which only allows a single file or URL to be extended.
#![allow(unused)] fn main() { #[derive(Config)] struct AppConfig { #[setting(extend, validate = schematic::validate::extends_string)] pub extends: Option<String>, } }
Example:
extends: "./another/file.yml"
Multiple sources
The second pattern is with a list of strings, allowing multiple files or URLs to be extended. Each item in the list is merged from top to bottom (lowest precedence to highest).
#![allow(unused)] fn main() { #[derive(Config)] struct AppConfig { #[setting(extend, validate = schematic::validate::extends_list)] pub extends: Option<Vec<String>>, } }
Example:
extends:
- "./another/file.yml"
- "https://domain.com/some/other/file.yml"
Either pattern
And lastly, supporting both a string or a list, using our built-in enum.
#![allow(unused)] fn main() { #[derive(Config)] struct AppConfig { #[setting(extend, validate = schematic::validate::extends_from)] pub extends: Option<schematic::ExtendsFrom>, } }
Merge strategies
A common requirement for configuration is to merge multiple sources/layers into a final result. By
default Schematic will replace the previous setting value with the next value if the next value is
Some
, but sometimes you want far more control, like shallow or deep merging collections.
This can be achieved with the #[setting(merge)]
attribute field, which requires a reference to a
function to call.
#![allow(unused)] fn main() { #[derive(Config)] struct AppConfig { #[setting(merge = schematic::merge::append_vec)] pub allowed_hosts: Vec<String>, } #[derive(Config)] enum Projects { #[setting(merge = schematic::merge::append_vec)] List(Vec<String>), // ... } }
We provide a handful of built-in merge functions in the
merge
module.
Merge handler function
You can also define your own function for merging values.
When defining a custom merge
function, the previous value, next value, and
context are passed as arguments, and the function must return an optional merged
result. If None
is returned, neither value will be used.
Here’s an example of the merge function above.
#![allow(unused)] fn main() { fn append_vec<T>(mut prev: Vec<T>, next: Vec<T>, context: &Context) -> MergeResult<Vec<T>>> { prev.extend(next); Ok(Some(prev)) } #[derive(Config)] struct ExampleConfig { #[setting(merge = append_vec)] pub field: Vec<String>, } }
Context handling
If you’re not using context, you can use ()
as the context type, or rely on
generic inferrence.
#![allow(unused)] fn main() { fn using_unit_type<T>(prev: T, next: T, _: &()) -> MergeResult<T> { // ... } fn using_generics<T, C>(prev: T, next: T, _: &C) -> MergeResult<T> { // ... } }
Validation rules
Requires the
validate
Cargo feature, which is enabled by default.
What kind of configuration crate would this be without built-in validation? As such, we support it as a first-class feature, with built-in validation rules provided by the garde crate.
In Schematic, validation does not happen as part of the serde parsing process, and instead happens for each partial configuration to be merged. However, with that said, prefer serde parsing over validation rules for structural adherence (learn more).
Validation can be applied on a per-setting basis with the #[setting(validate)]
attribute field,
which requires a reference to a function to call.
#![allow(unused)] fn main() { #[derive(Config)] struct AppConfig { #[setting(validate = schematic::validate::alphanumeric)] pub secret_key: String, #[setting(validate = schematic::validate::regex("^\.env"))] pub env_file: String, } }
Or on a per-variant basis when using an enum.
#![allow(unused)] fn main() { #[derive(Config)] enum Projects { #[setting(validate = schematic::validate::min_length(1))] List(Vec<String>), // ... } }
We provide a handful of built-in validation functions in the
validate
module. Furthermore, some functions are factories which can be called to produce a validator.
Validate handler function
You can also define your own function for validating values, also known as a validator.
When defining a custom validate function, the value to check is passed as the first argument, the current/parent partial as the second, the context as the third, and whether this is the final validation pass.
#![allow(unused)] fn main() { fn validate_string( value: &str, partial: &PartialAppConfig, context: &Context finalize: bool ) -> ValidateResult { if !do_check(value) { return Err(ValidateError::new("Some failure message")); } Ok(()) } }
If validation fails, you must return a
ValidateError
with a
failure message.
Factories
For composition and reusability concerns, we also support factory functions that can be called to
create a unique validator. This can be seen above with schematic::validate::regex
. To create your
own factory, declare a normal function, with any number of arguments, that returns a
Validator
.
Using the regex
factory as an example, it would look something like this.
#![allow(unused)] fn main() { use schematic::Validator; fn regex<T, P, C>(pattern: &str) -> Validator<T, P, C> { let pattern = regex::Regex::new(pattern).unwrap(); Box::new(move |value, _, _| { if !pattern.is_match(value) { return Err(ValidateError::new("Some failure message")); } Ok(()) }) } }
Path targeting
If validating an item in a list or collection, you can specifiy the nested path when failing. This is extremely useful when building error messages.
#![allow(unused)] fn main() { use schematic::PathSegment; ValidateError::with_segments( "Some failure message", // [i].key [PathSegment::Index(i), PathSegment::Key(key.to_string())] ) }
Context and partial handling
If you’re not using context, or want to create a validator for any kind of partial, we suggest generic inferrence.
#![allow(unused)] fn main() { fn using_generics<P, C>(value: &str, partial: &P, context: &C, finalize: bool) -> ValidateResult { // ... } }
Cargo features
The following Cargo features can be enabled for more functionality:
validate_email
- Enables email validation with theschematic::validate::email
function.validate_url
- Enables URL validation with theschematic::validate::url
andurl_secure
functions.
Unit-only enums
Configurations typically use enums to support multiple values within a specific
setting. To simplify this process, and to provide streamlined interoperability
with Config
, we offer a
ConfigEnum
trait and macro
that can be derived for enums with unit-only variants.
#![allow(unused)] fn main() { #[derive(ConfigEnum)] enum LogLevel { Info, Error, Debug, Off } }
When paired with Config
, it’ll look like:
#![allow(unused)] fn main() { #[derive(Config)] struct AppConfig { pub log_level: LogLevel } }
This enum will generate the following implementations:
- Provides a static
T::variants()
method, that returns a list of all variants. Perfect for iteration. - Implements
FromStr
andTryFrom
for parsing from a string. - Implements
Display
for formatting into a string.
Attribute fields
The following fields are supported for the #[config]
container attribute:
before_parse
- Transform the variant string value before parsing. Supportslowercase
orUPPERCASE
.
#![allow(unused)] fn main() { #[derive(ConfigEnum)] #[config(before_parse = "UPPERCASE")] enum ExampleEnum { // ... } }
And the following for serde compatibility:
rename
rename_all
- Defaults tokebab-case
.
Variants
The following fields are supported for the #[variant]
variant attribute:
fallback
- Marks the variant as the fallback.value
- Overrides (explicitly sets) the string value used for parsing and formatting. This is similar to serde’srename
.
And the following for serde compatibility:
alias
rename
Deriving common traits
All enums (not just unit-only enums) typically support the same derived traits, like Clone
, Eq
,
etc. To reduce boilerplate, we offer a
derive_enum!
macro that will
apply these traits for you.
#![allow(unused)] fn main() { derive_enum!( #[derive(ConfigEnum)] enum LogLevel { Info, Error, Debug, Off } ); }
This macro will inject the following attributes:
#![allow(unused)] fn main() { #[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "kebab-case")] }
Default variant
To define a default variant, use the Default
trait and the optional #[default]
variant
attribute. We provide no special functionality or syntax for handling defaults.
#![allow(unused)] fn main() { #[derive(ConfigEnum, Default)] enum LogLevel { Info, Error, Debug, #[default] Off } }
Fallback variant
Although ConfigEnum
only
supports unit variants, we do support a catch-all variant known as the “fallback variant”, which can
be defined with #[variant(fallback)]
. Fallback variants are primarily used when parsing from a
string, and will be used if no other variant matches.
#![allow(unused)] fn main() { #[derive(ConfigEnum)] enum Value { Foo, Bar, Baz #[variant(fallback)] Other(String) } }
However, this pattern does have a few caveats:
- Only 1 fallback variant can be defined.
- The fallback variant must be a tuple variant with a single field.
- The field type can be anything and we’ll attempt to convert it with
try_into()
. - The fallback inner value is not casing formatted based on serde’s
rename_all
.
#![allow(unused)] fn main() { let qux = Value::from_str("qux")?; // Value::Other("qux") }
Experimental
Pkl configuration format (>= v0.17)
Thanks to the rpkl
crate, we have experimental support for the
Pkl configuration language. Pkl is a dynamic and programmable
configuration format built and maintained by Apple.
port = 3000
secure = true
allowedHosts = List(".localhost")
Pkl support can be enabled with the
pkl
Cargo feature.
Caveats
Unlike our other static formats, Pkl requires the following to work correctly:
- The
pkl
binary must exist onPATH
. This requires every user to install Pkl onto their machine. - Pkl parses local file system paths only.
- Passing source code directly to
ConfigLoader
is NOT supported. - Reading configuration from URLs is NOT supported, but can be worked around by implementing a
custom file-based
Cacher
.
- Passing source code directly to
Known issues
- The
rpkl
crate is relatively new and may be buggy or have missing/incomplete functionality. - When parsing fails and a code snippet is rendered in the terminal using
miette
, the line/column offset may not be accurate.
Schemas
Requires the
schema
Cargo feature, which is not enabled by default.
The other feature of Schematic is the ability to model schemas for Rust types using the
Schematic
trait and associated macro. Schemas are useful for:
- Generating code, documentation, and other formats.
- Ensuring data integrity across systems.
- Standardizing interoperability and enforcing contracts.
Usage
Define a struct, enum, or type and derive the Schematic
trait. Our macro will attempt
to convert all fields, variants, values, and generics into a schema representation using
SchemaType
.
#![allow(unused)] fn main() { use schematic::Schematic; #[derive(Schematic)] enum UserStatus { Active, Inactive, } #[derive(Schematic)] struct User { pub name: String; pub age: usize; pub status: UserStatus; } }
Once a type has a schema associated with it, it can be fed into the generator.
Custom implementation
Our derive macro will always implement schemas using the default state of Schema
,
SchemaType
, and associated types. If you want these types to use
custom settings, you can implement the Schematic
trait and
Schematic::build_schema()
method manually.
The
Schematic::schema_name()
method is optional, but is encouraged for non-primitive types. It will associate references between
types, and avoid circular references.
#![allow(unused)] fn main() { use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::*}; #[derive(Schematic)] enum UserStatus { Active, Inactive, } struct User { pub name: String; pub age: usize; pub status: UserStatus; } impl Schematic for User { fn schema_name() -> Option<String> { Some("User".into()) } fn build_schema(mut schema: SchemaBuilder) -> Schema { schema.structure(StructType::new([ ("name".into(), schema.nest().string(StringType { min_length: Some(1), ..StringType::default() })), ("age".into(), schema.nest().integer(IntegerType::new_kind(IntegerKind::Usize))), ("status".into(), schema.infer::<UserStatus>()), ])) } } }
Learn more about our supported types.
Cargo features
The following Cargo features are available:
Renderers
Learn more about renderers.
renderer_json_schema
- Enables JSON schema generation.renderer_template
- Enables config template generation.renderer_typescript
- Enables TypeScript types generation.
External types
Learn more about external types.
type_chrono
- Implements schematic for thechrono
crate.type_indexmap
- Implements schematic for theindexmap
crate.type_regex
- Implements schematic for theregex
crate.type_relative_path
- Implements schematic for therelative-path
crate.type_rust_decimal
- Implements schematic for therust_decimal
crate.type_semver
- Implements schematic for thesemver
crate.type_url
- Implements schematic for theurl
crate.
Types
Schema types are the building blocks when modeling your schema. They are used to define the explicit shape of your types, data, or configuration. This type information is then passed to a generator, which can then generate and render the schema types in a variety of formats.
Defining names
Schemas can be named, which is useful for referencing them in other types when generating code. By
default the Schematic
derive macro will use the name of the type, but when
implementing the trait manually, you can use the
Schematic::schema_name()
method.
#![allow(unused)] fn main() { impl Schematic for T { fn schema_name() -> Option<String> { Some("CustomName".into()) } } }
This method is optional, but is encouraged for non-primitive types. It will associate references between types, and avoid circular references.
Inferring schemas
When building a schema, you’ll almost always need to reference schemas from other types that
implement Schematic
. To do so, you can use the
SchemaBuilder.infer::<T>()
method, which will create a nested builder, and build an isolated schema based on its
implementation.
#![allow(unused)] fn main() { struct OtherType {} impl Schematic for OtherType { // ... } impl Schematic for T { fn build_schema(mut schema: SchemaBuilder) -> Schema { let builtin_type = schema.infer::<String>(); let custom_type = schema.infer::<OtherType>(); // ... } } }
Creating nested schemas
When building a schema, you may have situations where you need to build nested schemas, for example,
within struct fields. You cannot use the type-based methods on SchemaBuilder
, as they mutate the
current builder. Instead you must created another builder, which can be achieved with the
SchemaBuilder.nest()
method.
#![allow(unused)] fn main() { impl Schematic for T { fn build_schema(mut schema: SchemaBuilder) -> Schema { // Mutates self schema.string_default(); // Creates a new builder and mutates it schema.nest().string_default(); // ... } } }
Arrays
The ArrayType
can be used to represent a variable list of homogeneous values of a given
type, as defined by items_type
. For example, a list of strings:
#![allow(unused)] fn main() { use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::ArrayType}; impl Schematic for T { fn build_schema(mut schema: SchemaBuilder) -> Schema { schema.array(ArrayType { items_type: Box::new(schema.infer::<String>()), ..ArrayType::default() }) } } }
If you’re only defining the items_type
field, you can use the shorthand
ArrayType::new()
method.
#![allow(unused)] fn main() { schema.array(ArrayType::new(schema.infer::<String>())); }
Automatically implemented for
Vec
,BTreeSet
,HashSet
,[T; N]
, and&[T]
.
Settings
The following fields can be passed to ArrayType
, which are then fed into the
generator.
Contains
The contains
field can be enabled to indicate that the array must contain at least one item of the
type defined by items_type
, instead of all items.
#![allow(unused)] fn main() { ArrayType { // ... contains: Some(true), } }
Length
The min_length
and max_length
fields can be used to restrict the length of the array. Both
fields accept a non-zero number, and can be used together or individually.
#![allow(unused)] fn main() { ArrayType { // ... min_length: Some(1), max_length: Some(10), } }
Uniqueness
The unique
field can be used to indicate that all items in the array must be unique. Note that
Schematic does not verify uniqueness.
#![allow(unused)] fn main() { ArrayType { // ... unique: Some(true), } }
Booleans
The BooleanType
can be used to represent a boolean true
or false
value. Values that
evaluate to true or false, such as 1 and 0, are not accepted by the schema.
#![allow(unused)] fn main() { use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::BooleanType}; impl Schematic for T { fn build_schema(mut schema: SchemaBuilder) -> Schema { schema.boolean_default() } } }
Automatically implemented for
bool
.
Default value
To customize the default value for use within generators, pass the desired
value to the BooleanType
constructor.
#![allow(unused)] fn main() { schema.boolean(BooleanType::new(true)); }
Enums
The EnumType
can be used to represent a list of literal values.
#![allow(unused)] fn main() { use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::{EnumType, LiteralValue}}; impl Schematic for T { fn build_schema(mut schema: SchemaBuilder) -> Schema { schema.enumerable(EnumType { values: vec![ LiteralValue::String("debug".into()), LiteralValue::String("error".into()), LiteralValue::String("warning".into()), ], ..EnumType::default() }) } } }
If you’re only defining the values
field, you can use the shorthand
EnumType::new()
method.
#![allow(unused)] fn main() { schema.enumerable(EnumType::new([ LiteralValue::String("debug".into()), LiteralValue::String("error".into()), LiteralValue::String("warning".into()), ])); }
Detailed variants
If you’d like to provide more detailed information for each variant (value), like descriptions and
visibility, you can define the variants
field and pass a map of
SchemaField
s.
#![allow(unused)] fn main() { schema.enumerable(EnumType { values: vec![ LiteralValue::String("debug".into()), LiteralValue::String("error".into()), LiteralValue::String("warning".into()), ], variants: Some(IndexMap::from_iter([ ( "Debug".into(), SchemaField { comment: Some("Shows debug messages and above".into()), schema: Schema::new(SchemaType::literal(LiteralValue::String("debug".into()))), ..SchemaField::default() } ), ( "Error".into(), SchemaField { comment: Some("Shows only error messages".into()), schema: Schema::new(SchemaType::literal(LiteralValue::String("error".into()))), ..SchemaField::default() } ), ( "Warning".into(), SchemaField { comment: Some("Shows warning and error messages".into()), schema: Schema::new(SchemaType::literal(LiteralValue::String("warning".into()))), ..SchemaField::default() } ), ])), ..EnumType::default() }) }
This comes in handy when working with specific generators, like TypeScript.
Floats
The FloatType
can be used to represent a float or double.
#![allow(unused)] fn main() { use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::{FloatType, FloatKind}}; impl Schematic for T { fn build_schema(mut schema: SchemaBuilder) -> Schema { schema.float(FloatType { kind: FloatKind::F32, ..FloatType::default() }) } } }
If you’re only defining the kind
field, you can use the shorthand
FloatType::new_kind()
method.
#![allow(unused)] fn main() { schema.float(FloatType::new_kind(FloatKind::F32)); }
Automatically implemented for
f32
andf64
.
Default value
To customize the default value for use within generators, pass the desired
value to the FloatType
constructor.
#![allow(unused)] fn main() { schema.float(FloatType::new_32(32.0)); // Or schema.float(FloatType::new_64(64.0)); }
Settings
The following fields can be passed to FloatType
, which are then fed into the
generator.
Enumerable
The enum_values
field can be used to specify a list of literal values that are allowed for the
field.
#![allow(unused)] fn main() { FloatType { // ... enum_values: Some(vec![0.0, 0.25, 0.5, 0.75, 1.0]), } }
Formats
The format
field can be used to associate semantic meaning to the float, and how the float will be
used and displayed.
#![allow(unused)] fn main() { FloatType { // ... format: Some("currency".into()), } }
This is primarily used by JSON Schema.
Min/max
The min
and max
fields can be used to specify the minimum and maximum inclusive values allowed.
Both fields accept a non-zero number, and can be used together or individually.
#![allow(unused)] fn main() { FloatType { // ... min: Some(0.0), // >0 max: Some(1.0), // <1 } }
These fields are not exclusive and do not include the lower and upper bound values. To include them,
use min_exclusive
and max_exclusive
instead.
#![allow(unused)] fn main() { FloatType { // ... min_exclusive: Some(0.0), // >=0 max_exclusive: Some(1.0), // <=1 } }
Multiple of
The multiple_of
field can be used to specify a value that the float must be a multiple of.
#![allow(unused)] fn main() { FloatType { // ... multiple_of: Some(0.25), // 0.0, 0.25, 0.50, etc } }
Integers
The IntegerType
can be used to represent an integer (number).
#![allow(unused)] fn main() { use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::{IntegerType, IntegerKind}}; impl Schematic for T { fn build_schema(mut schema: SchemaBuilder) -> Schema { schema.integer(IntegerType { kind: IntegerKind::U32, ..IntegerType::default() }) } } }
If you’re only defining the kind
field, you can use the shorthand
IntegerType::new_kind()
method.
#![allow(unused)] fn main() { schema.integer(IntegerType::new_kind(IntegerKind::U32)); }
Automatically implemented for
usize
-u128
andisize
-i128
.
Default value
To customize the default value for use within generators, pass the desired
value to the IntegerType
constructor.
#![allow(unused)] fn main() { schema.integer(IntegerType::new(IntegerKind::I32, 100)); // Or schema.integer(IntegerType::new_unsigned(IntegerKind::U32, 100)); }
Settings
The following fields can be passed to IntegerType
, which are then fed into the
generator.
Enumerable
The enum_values
field can be used to specify a list of literal values that are allowed for the
field.
#![allow(unused)] fn main() { IntegerType { // ... enum_values: Some(vec![0, 25, 50, 75, 100]), } }
Formats
The format
field can be used to associate semantic meaning to the integer, and how the integer
will be used and displayed.
#![allow(unused)] fn main() { IntegerType { // ... format: Some("age".into()), } }
This is primarily used by JSON Schema.
Min/max
The min
and max
fields can be used to specify the minimum and maximum inclusive values allowed.
Both fields accept a non-zero number, and can be used together or individually.
#![allow(unused)] fn main() { IntegerType { // ... min: Some(0), // >0 max: Some(100), // <100 } }
These fields are not exclusive and do not include the lower and upper bound values. To include them,
use min_exclusive
and max_exclusive
instead.
#![allow(unused)] fn main() { IntegerType { // ... min_exclusive: Some(0), // >=0 max_exclusive: Some(100), // <=100 } }
Multiple of
The multiple_of
field can be used to specify a value that the integer must be a multiple of.
#![allow(unused)] fn main() { IntegerType { // ... multiple_of: Some(25), // 0, 25, 50, etc } }
Literals
The LiteralType
can
be used to represent a literal primitive value, such as a string or number.
#![allow(unused)] fn main() { use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::{LiteralType, LiteralValue}}; impl Schematic for T { fn build_schema(mut schema: SchemaBuilder) -> Schema { schema.literal(LiteralType::new(LiteralValue::String("enabled".into()))) // Or schema.literal_value(LiteralValue::String("enabled".into())) } } }
The
LiteralValue
type is used by other schema types for their default or enumerable values.
Nulls
The SchemaType::Null
variant can be used to represent a literal null
value. This works
best when paired with unions or fields that need to be nullable.
#![allow(unused)] fn main() { use schematic::{Schematic, Schema, SchemaBuilder, SchemaType}; impl Schematic for T { fn build_schema(mut schema: SchemaBuilder) -> Schema { schema.set_type_and_build(SchemaType::Null) } } }
Automatically implemented for
()
andOption<T>
.
Marking as nullable
If you want a concrete schema to also accept null (an Option
al value), you can use the
SchemaBuilder::nullable()
method. Under the hood, this will create a union of the defined type, and the null type.
#![allow(unused)] fn main() { // string | null schema.nullable(schema.infer::<String>()); }
Objects
The ObjectType
can be used to represent a key-value object of homogenous types. This is
also known as a map, record, keyed object, or indexed object.
#![allow(unused)] fn main() { use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::ObjectType}; impl Schematic for T { fn build_schema(mut schema: SchemaBuilder) -> Schema { schema.object(ObjectType { key_type: Box::new(schema.infer::<String>()), value_type: Box::new(schema.infer::<String>()), ..ObjectType::default() }) } } }
If you’re only defining the key_type
and value_type
fields, you can use the shorthand
ObjectType::new()
method.
#![allow(unused)] fn main() { schema.object(ObjectType::new(schema.infer::<String>(), schema.infer::<String>())); }
Automatically implemented for
BTreeMap
andHashMap
.
Settings
The following fields can be passed to ObjectType
, which are then fed into the
generator.
Length
The min_length
and max_length
fields can be used to restrict the length (key-value pairs) of the
object. Both fields accept a non-zero number, and can be used together or individually.
#![allow(unused)] fn main() { ObjectType { // ... min_length: Some(1), max_length: Some(10), } }
Required keys
The required
field can be used to specify a list of keys that are required for the object, and
must exist when the object is validated.
#![allow(unused)] fn main() { ObjectType { // ... required: Some(vec!["foo".into(), "bar".into()]), } }
This is primarily used by JSON Schema.
Strings
The StringType
can be used to represent a sequence of bytes, you know, a string.
#![allow(unused)] fn main() { use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::{StringType, IntegerKind}}; impl Schematic for T { fn build_schema(mut schema: SchemaBuilder) -> Schema { schema.string_default() } } }
Automatically implemented for
char
,str
,String
,Path
,PathBuf
,Ipv4Addr
,Ipv6Addr
,SystemTime
, andDuration
.
Default value
To customize the default value for use within generators, pass the desired
value to the StringType
constructor.
#![allow(unused)] fn main() { schema.string(StringType::new("abc")); }
Settings
The following fields can be passed to StringType
, which are then fed into the
generator.
Enumerable
The enum_values
field can be used to specify a list of literal values that are allowed for the
field.
#![allow(unused)] fn main() { StringType { // ... enum_values: Some(vec!["a".into(), "b".into(), "c".into()]), } }
Formats
The format
field can be used to associate semantic meaning to the string, and how the string will
be used and displayed.
#![allow(unused)] fn main() { StringType { // ... format: Some("url".into()), } }
This is primarily used by JSON Schema.
Length
The min_length
and max_length
fields can be used to restrict the length of the string. Both
fields accept a non-zero number, and can be used together or individually.
#![allow(unused)] fn main() { StringType { // ... min_length: Some(1), max_length: Some(10), } }
Patterns
The pattern
field can be used to define a regex pattern that the string must abide by.
#![allow(unused)] fn main() { StringType { // ... format: Some("version".into()), pattern: Some("\d+\.\d+\.\d+".into()), } }
This is primarily used by JSON Schema.
Structs
The StructType
can be used to represent a struct with explicitly named fields and typed
values. This is also known as a “shape” or “model”.
#![allow(unused)] fn main() { use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::StructType}; impl Schematic for T { fn build_schema(mut schema: SchemaBuilder) -> Schema { schema.structure(StructType { fields: HashMap::from_iter([ ( "name".into(), Box::new(SchemaField { comment: Some("Name of the user".into()), schema: schema.infer::<String>(), ..SchemaField::default() }) ), ( "age".into(), Box::new(SchemaField { comment: Some("Age of the user".into()), schema: schema.nest().integer(IntegerType::new_kind(IntegerKind::U16)), ..SchemaField::default() }) ), ( "active".into(), Box::new(SchemaField { comment: Some("Is the user active".into()), schema: schema.infer::<bool>(), ..SchemaField::default() }) ), ]), ..StructType::default() }) } } }
If you’re only defining fields
, you can use the shorthand
StructType::new()
method. When using this approach, the Box
s are automatically inserted for you.
#![allow(unused)] fn main() { schema.structure(StructType::new([ ( "name".into(), SchemaField { comment: Some("Name of the user".into()), schema: schema.infer::<String>(), ..SchemaField::default() } ), // ... ])); }
Settings
The following fields can be passed to StructType
, which are then fed into the
generator.
Required fields
The required
field can be used to specify a list of fields that are required for the struct.
#![allow(unused)] fn main() { StructType { // ... required: Some(vec!["name".into()]), } }
This is primarily used by JSON Schema.
Tuples
The TupleType
can be used to represent a fixed list of heterogeneous values of a given
type, as defined by items_types
.
#![allow(unused)] fn main() { use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::{TupleType, IntegerKind}}; impl Schematic for T { fn build_schema(mut schema: SchemaBuilder) -> Schema { schema.tuple(TupleType { items_types: vec![ Box::new(schema.infer::<String>()), Box::new(schema.infer::<bool>()), Box::new(schema.nest().integer(IntegerType::new_kind(IntegerKind::U32))), ], ..TupleType::default() }) } } }
If you’re only defining the items_types
field, you can use the shorthand
TupleType::new()
method. When using this approach, the Box
s are automatically inserted for you.
#![allow(unused)] fn main() { schema.tuple(TupleType::new([ schema.infer::<String>(), schema.infer::<bool>(), schema.nest().integer(IntegerType::new_kind(IntegerKind::U32)), ])); }
Automatically implemented for tuples of 0-12 length.
Unions
The UnionType
paired with
SchemaType::Union
can be used to represent a list of heterogeneous schema types (variants), in which a value must
match one or more of the types.
#![allow(unused)] fn main() { use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::{UnionType, UnionOperator}}; impl Schematic for T { fn build_schema(mut schema: SchemaBuilder) -> Schema { schema.union(UnionType { operator: UnionOperator::AnyOf, variants_types: vec![ Box::new(schema.infer::<String>()), Box::new(schema.infer::<bool>()), Box::new(schema.nest().integer(IntegerType::new_kind(IntegerKind::U32))), ], ..UnionType::default() }) } } }
If you’re only defining the variants_types
field, you can use the shorthand
UnionType::new_any()
(any of) or
UnionType::new_one()
(one of) methods. When using this approach, the Box
s are automatically inserted for you.
#![allow(unused)] fn main() { // Any of schema.union(UnionType::new_any([ schema.infer::<String>(), schema.infer::<bool>(), schema.nest().integer(IntegerType::new_kind(IntegerKind::U32)), ])); // One of schema.union(UnionType::new_one([ // ... ])); }
Operators
Unions support 2 kinds of operators, any of and one of, both of which can be defined with the
operator
field.
- Any of requires the value to match any of the variants.
- One of requires the value to match only one of the variants.
#![allow(unused)] fn main() { UnionType { // ... operator: UnionOperator::OneOf, } }
Unknown
The SchemaType::Unknown
variant can be used to represent an unknown type. This is
sometimes known as an “any” or “mixed” type.
#![allow(unused)] fn main() { use schematic::{Schematic, Schema, SchemaBuilder, SchemaType}; impl Schematic for T { fn build_schema(schema: SchemaBuilder) -> Schema { schema.build() } } }
The SchemaType::Unknown
variant is also the default variant, and the default
implementation for
Schematic::build_schema()
,
so the above can simply be written as:
#![allow(unused)] fn main() { impl Schematic for T {} }
External types
Schematic provides schema implementations for third-party crates through a concept known as external types. This functionality is opt-in through Cargo features.
chrono
Requires the
type_chrono
Cargo feature.
Implements schemas for Date
, DateTime
, Duration
, Days
, Months
, IsoWeek
, NaiveWeek
,
NaiveDate
, NaiveDateTime
, and NaiveTime
from the chrono
crate.
indexmap
Requires the
type_indexmap
Cargo feature.
Implements a schema for IndexMap
and IndexSet
from the
indexmap crate.
regex
Requires the
type_regex
Cargo feature.
Implements a schema for Regex
from the regex crate.
relative-path
Requires the
type_relative_path
Cargo feature.
Implements schemas for RelativePath
and RelativePathBuf
from the
relative-path crate.
rpkl
Requires the
type_serde_rpkl
Cargo feature.
Implements schemas for Value
from the rpkl crate.
rust_decimal
Requires the
type_rust_decimal
Cargo feature.
Implements a schema for Decimal
from the rust_decimal
crate.
semver
Requires the
type_semver
Cargo feature.
Implements schemas for Version
and VersionReq
from the semver
crate.
serde_json
Requires the
type_serde_json
Cargo feature.
Implements schemas for Value
, Number
, and Map
from the
serde_json crate.
serde_yaml
Requires the
type_serde_yaml
Cargo feature.
Implements schemas for Value
, Number
, and Mapping
from the
serde_yaml crate.
toml
Requires the
type_serde_toml
Cargo feature.
Implements schemas for Value
and Map
from the toml crate.
url
Requires the
type_url
Cargo feature.
Implements a schema for Url
from the url crate.
Code generation
The primary benefit of a schema modeling system, is that you can consume this type information to generate code into multiple output formats. This is a common pattern in many languages, and is a great way to reduce boilerplate.
In the context of Rust, why use multiple disparate crates, each with their own unique
implementations and #[derive]
macros, just to generate some output. With Schematic, you can ditch
all of these and use a single standardized approach.
Usage
To make use of the generator, import and instantiate our
SchemaGenerator
.
This is typically done within a one-off main
function that can be ran from Cargo.
#![allow(unused)] fn main() { use schematic::schema::SchemaGenerator; let mut generator = SchemaGenerator::default(); }
Adding types
From here, for every type that implements
Schematic
and you want to
include in the generated output, call
SchemaGenerator::add()
.
If you only have a SchemaType
,
you can use the
SchemaGenerator::add_schema()
method instead.
#![allow(unused)] fn main() { use schematic::schema::SchemaGenerator; let mut generator = SchemaGenerator::default(); generator.add::<FirstConfig>(); generator.add::<SecondConfig>(); generator.add::<ThirdConfig>(); }
We’ll recursively add referenced and nested schemas for types that are added. No need to explicitly add all required types!
Generating output
From here, call
SchemaGenerator::generate()
to render the schemes with a chosen renderer to an output file of your choice. This
method can be called multiple times, each with a different output file or renderer.
#![allow(unused)] fn main() { use schematic::schema::SchemaGenerator; let mut generator = SchemaGenerator::default(); generator.add::<FirstConfig>(); generator.add::<SecondConfig>(); generator.add::<ThirdConfig>(); generator.generate(PathBuf::from("output/file"), CustomRenderer::default())?; generator.generate(PathBuf::from("output/another/file"), AnotherRenderer::default())?; }
Renderers
The following built-in renderers are available, but custom renderers can be created as well by
implementing the
SchemaRenderer
trait.
Config templates (experimental)
Requires the
template
and desired format Cargo feature.
With our format renderers, you can generate a config template in a specific format. This template will include all fields, default values, comments, metadata, and is useful for situations like scaffolding files during installation.
To utilize, instantiate a generator, add types to render, and generate the output file.
#![allow(unused)] fn main() { use schematic::schema::SchemaGenerator; let mut generator = SchemaGenerator::default(); generator.add::<CustomType>(); generator.generate(output_dir.join("config.json"), renderer)?; }
Supported formats
JSON
The
JsonTemplateRenderer
will render JSON templates without comments. Any commented related options will be force disabled.
#![allow(unused)] fn main() { use schematic::schema::{JsonTemplateRenderer, TemplateOptions}; JsonTemplateRenderer::default(); JsonTemplateRenderer::new(TemplateOptions::default()); }
JSONC
The
JsoncTemplateRenderer
will render JSON templates with comments. We suggest using the .jsonc
file extension, but not
required.
#![allow(unused)] fn main() { use schematic::schema::{JsoncTemplateRenderer, TemplateOptions}; JsoncTemplateRenderer::default(); JsoncTemplateRenderer::new(TemplateOptions::default()); }
Pkl
The
PklTemplateRenderer
will render Pkl templates.
#![allow(unused)] fn main() { use schematic::schema::{PklTemplateRenderer, TemplateOptions}; PklTemplateRenderer::default(); PklTemplateRenderer::new(TemplateOptions::default()); }
TOML
The
TomlTemplateRenderer
will render TOML templates.
#![allow(unused)] fn main() { use schematic::schema::{TomlTemplateRenderer, TemplateOptions}; TomlTemplateRenderer::default(); TomlTemplateRenderer::new(TemplateOptions::default()); }
YAML
The
YamlTemplateRenderer
will render YAML templates.
#![allow(unused)] fn main() { use schematic::schema::{YamlTemplateRenderer, TemplateOptions}; YamlTemplateRenderer::default(); YamlTemplateRenderer::new(TemplateOptions::default()); }
Root document
A template represents a single document, typically for a struct. In Schematic, the last type to be
added to SchemaGenerator
will be the root document, while all other types will be ignored. For
example:
#![allow(unused)] fn main() { // These are only used for type information generator.add::<FirstConfig>(); generator.add::<SecondConfig>(); generator.add::<ThirdConfig>(); // This is the root document generator.add::<LastType>(); generator.generate(output_dir.join("config.json"), renderer)?; }
Caveats
By default arrays and objects do not support default values, and will
render []
and {}
respectively. This can be customized with the
expand_fields
option.
Furthermore, enums and unions only support default values when
explicitly marked as such. For example, with #[default]
.
And lastly, when we’re unsure of what to render for a value, we’ll render null
. This isn’t a valid
value for TOML, and may not be what you expect.
Example output
Given the following type:
#![allow(unused)] fn main() { #[derive(Config)] struct ServerConfig { /// The base URL to serve from. #[setting(default = "/")] pub base_url: String, /// The default port to listen on. #[setting(default = 8080, env = "PORT")] pub port: usize, } }
Would render the following formats:
JSONC | Pkl |
|
|
TOML | YAML |
|
|
Applying the desired casing for field names should be done with serde
rename_all
on the container.
Options
Custom options can be passed to the renderer using
TemplateOptions
.
#![allow(unused)] fn main() { use schematic::schema::TemplateOptions; JsoncTemplateRenderer::new(TemplateOptions { // ... ..TemplateOptions::default() }); }
The
format
option is required!
Indentation
The indentation of the generated template can be customized using the indent_char
option. By
default this is 2 spaces (
).
#![allow(unused)] fn main() { TemplateOptions { // ... indent_char: "\t".into(), } }
The spacing between fields can also be toggled with the newline_between_fields
option. By default
this is enabled, which adds a newline between each field.
#![allow(unused)] fn main() { TemplateOptions { // ... newline_between_fields: false, } }
Comments
All Rust doc comments (///
) are rendered as comments above each field in the template. This can be
disabled with the comments
option.
#![allow(unused)] fn main() { TemplateOptions { // ... comments: false, } }
Header and footer
The header
and footer
options can be customized to add additional content to the top and bottom
of the rendered template respectively.
#![allow(unused)] fn main() { TemplateOptions { // ... header: "$schema: \"https://example.com/schema.json\"\n\n".into(), footer: "\n\n# Learn more: https://example.com".into(), } }
Field display
By default all non-skipped fields in the root document (struct) are rendered in the template. If
you’d like to hide certain fields from being rendered, you can use the hide_fields
option. This
option accepts a list of field names and also supports dot-notation for nested fields.
#![allow(unused)] fn main() { TemplateOptions { // ... hide_fields: vec!["key".into(), "nested.key".into()], } }
Additionally, if you’d like to render a field but have it commented out by default, use the
comment_fields
option instead. This also supports dot-notation for nested fields.
#![allow(unused)] fn main() { TemplateOptions { // ... comment_fields: vec!["key".into(), "nested.key".into()], } }
Field names use the serde cased name, not the Rust struct field name.
Field expansion
For arrays and objects, we render an empty value ([]
or {}
) by
default because there’s no actual data associated with the schema. However, if you’d like to render
a single example item for a field, you can use the expand_fields
option.
#![allow(unused)] fn main() { TemplateOptions { // ... expand_fields: vec!["key".into(), "nested.key".into()], } }
Here’s an example of how this works:
Not expanded | Expanded |
|
|
JSON schemas
Requires the
json_schema
Cargo feature.
With our
JsonSchemaRenderer
,
you can generate a JSON Schema document for all types that implement
Schematic
. Internally
this renderer uses the schemars
crate to generate the JSON
document.
To utilize, instantiate a generator, add types to render, and generate the output file.
#![allow(unused)] fn main() { use schematic::schema::{SchemaGenerator, JsonSchemaRenderer}; let mut generator = SchemaGenerator::default(); generator.add::<CustomType>(); generator.generate(output_dir.join("schema.json"), JsonSchemaRenderer::default())?; }
For a reference implementation, check out moonrepo/moon.
Root document
Unlike other renderers, a JSON schema represents a single document, with referenced types being
organized into definitions. In Schematic, the last type to be added to SchemaGenerator
will be
the root document, while all other types will become definitions. For example:
#![allow(unused)] fn main() { // These are definitions generator.add::<FirstConfig>(); generator.add::<SecondConfig>(); generator.add::<ThirdConfig>(); // This is the root document generator.add::<LastType>(); generator.generate(output_dir.join("schema.json"), JsonSchemaRenderer::default())?; }
When rendered, will look something like the following:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "LastType",
"type": "object",
"properties": {
// Fields in LastType...
},
"definitions": {
// Other types...
}
}
Options
Custom options can be passed to the renderer using
JsonSchemaOptions
.
#![allow(unused)] fn main() { use schematic::schema::JsonSchemaOptions; JsonSchemaRenderer::new(JsonSchemaOptions { // ... ..JsonSchemaOptions::default() }); }
This type also contains all fields from the upstream
SchemaSettings
type from theschemars
crate. Refer to their documentation for more information.
Markdown descriptions
By default, the description
field in the JSON schema specification is supposed to be a plain text
string, but some tools support markdown through another field called markdownDescription
.
To support this pattern, enable the markdown_description
option, which will inject the
markdownDescription
field if markdown was detected in the description
field.
#![allow(unused)] fn main() { JsonSchemaOptions { // ... markdown_description: true, } }
This is a non-standard extension to the JSON schema specification.
Required fields
When a struct is rendered, automatically mark all non-Option
struct fields as required, and
include them in the JSON schema
required
field.
This is enabled by default.
#![allow(unused)] fn main() { JsonSchemaOptions { // ... mark_struct_fields_required: false, } }
Field titles
The JSON schema specification supports a
title
annotation for
each field, which is a human-readable string. By default this is the name of the Rust struct, enum,
or type field.
But depending on the tool that consumes the schema, this may not be the best representation. As an
alternative, the set_field_name_as_title
option can be enabled to use the field name itself as the
title
.
#![allow(unused)] fn main() { JsonSchemaOptions { // ... set_field_name_as_title: true, } }
TypeScript types
Requires the
typescript
Cargo feature.
With our
TypeScriptRenderer
,
you can generate TypeScript types for all types that implement
Schematic
. To utilize,
instantiate a generator, add types to render, and generate the output file.
#![allow(unused)] fn main() { use schematic::schema::{SchemaGenerator, TypeScriptRenderer}; let mut generator = SchemaGenerator::default(); generator.add::<CustomType>(); generator.generate(output_dir.join("types.ts"), TypeScriptRenderer::default())?; }
For a reference implementation, check out moonrepo/moon.
Options
Custom options can be passed to the renderer using
TypeScriptOptions
.
#![allow(unused)] fn main() { use schematic::schema::TypeScriptOptions; TypeScriptRenderer::new(TypeScriptOptions { // ... ..TypeScriptOptions::default() }); }
Indentation
The indentation of the generated TypeScript code can be customized using the indent_char
option.
By default this is a tab (\t
).
#![allow(unused)] fn main() { TypeScriptOptions { // ... indent_char: " ".into(), } }
Enum types
Enum types can be rendered in a format of your choice using the enum_format
option
and the
EnumFormat
enum. By default enums are rendered as TypeScript string unions, but can be rendered as TypeScript
enums instead.
#![allow(unused)] fn main() { TypeScriptOptions { // ... enum_format: EnumFormat::Enum, } }
// Default
export type LogLevel = "debug" | "info" | "error";
// As enum
export enum LogLevel {
Debug,
Info,
Error,
}
Furthermore, the const_enum
option can be enabled to render const enum
types instead of enum
types. This does not apply when EnumFormat::Union
is used.
#![allow(unused)] fn main() { TypeScriptOptions { // ... const_enum: true, } }
// Enabled
export const enum LogLevel {}
// Disabled
export enum LogLevel {}
Object types
Struct types can be rendered as either TypeScript interfaces or type aliases using
the object_format
option and the
ObjectFormat
enum. By default structs are rendered as TypeScript interfaces.
#![allow(unused)] fn main() { TypeScriptOptions { // ... object_format: ObjectFormat::Type, } }
// Default
export interface User {
name: string;
}
// As alias
export type User = {
name: string;
};
Properties format
Properties within a struct can be rendered as either optional or required in TypeScript, depending
on usage. The default format for all properties can be customized with the property_format
option
and the
PropertyFormat
enum. By default all properties are required.
#![allow(unused)] fn main() { TypeScriptOptions { // ... property_format: PropertyFormat::Required, } }
// Default / required
export interface User {
name: string;
}
// Optional
export interface User {
name?: string;
}
// Optional with undefined union
export interface User {
name?: string | undefined;
}
Type references
In the context of this renderer, a type reference is simply a reference to another type by its name, and is used by other types of another name. For example, the fields of a struct type may reference another type by name.
export type UserStatus = "active" | "inactive";
export interface User {
status: UserStatus;
}
Depending on your use case, this may not be desirable. If so, you can enable the
disable_references
option, which disables references entirely, and inlines all type information.
So the example above would become:
#![allow(unused)] fn main() { TypeScriptOptions { // ... disable_references: true, } }
export type UserStatus = "active" | "inactive";
export interface User {
status: "active" | "inactive";
}
Additionally, the exclude_references
option can be used to exclude a type reference by name
entirely from the output, as demonstrated below.
#![allow(unused)] fn main() { TypeScriptOptions { // ... exclude_references: vec!["UserStatus".into()], } }
export interface User {
status: "active" | "inactive";
}
Importing external types
For better interoperability, you can import external types from other TypeScript modules using the
external_types
option, which is a map of file paths (relative from the output location) to a list
of types to import from that file. This is useful if:
- You have existing types that aren’t generated and want to reference.
- You want to reference types from other generated files, and don’t want to duplicate them.
#![allow(unused)] fn main() { TypeScriptOptions { // ... external_types: HashMap::from_iter([ ("./states".into(), vec!["UserStatus".into()]), ]), } }
import type { UserStatus } from "./states";
export interface User {
status: UserStatus;
}