Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## Unreleased

#### 🚀 Updates

- Added `#[serde(flatten)]` support to schema generation.
- Added `SchemaField.flatten` field.
- Updated `JsonSchemaRenderer` to render flattened fields as `additionalProperties`.
- Updated `TypeScriptRenderer` to render flattened fields as index signatures & intersection
types.
- Updated `TemplateRenderer`s to skip rendering flattened fields.
- Updated `#[serde(untagged)]` enums to render better error messages based on the variant types.

## 0.19.2

#### ⚙️ Internal
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ members = ["crates/*"]

[workspace.dependencies]
chrono = "0.4.42"
convert_case = "0.10.0"
indexmap = "2.13.0"
miette = "7.6.0"
regex = "1.12.2"
Expand Down
2 changes: 1 addition & 1 deletion crates/macros/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ all-features = true
proc-macro = true

[dependencies]
convert_case = "0.10.0"
convert_case = { workspace = true }
darling = "0.23.0"
proc-macro2 = "1.0.105"
quote = "1.0.43"
Expand Down
8 changes: 8 additions & 0 deletions crates/macros/src/common/field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ impl Field<'_> {
self.args.extend
}

#[cfg(feature = "schema")]
pub fn is_flatten(&self) -> bool {
self.serde_args.flatten || self.args.flatten
}

pub fn is_nested(&self) -> bool {
self.args.nested
}
Expand Down Expand Up @@ -260,6 +265,7 @@ impl Field<'_> {

let aliases = map_vec_field_quote("aliases", self.get_aliases());
let hidden = map_bool_field_quote("hidden", self.is_skipped());
let flatten = map_bool_field_quote("flatten", self.is_flatten());
let nullable = map_bool_field_quote("nullable", self.is_nullable());
let optional = map_bool_field_quote("optional", self.is_optional());
let comment = map_option_field_quote("comment", extract_comment(&self.attrs));
Expand Down Expand Up @@ -305,6 +311,7 @@ impl Field<'_> {
&& comment.is_none()
&& deprecated.is_none()
&& env_var.is_none()
&& flatten.is_none()
&& hidden.is_none()
&& nullable.is_none()
&& optional.is_none()
Expand All @@ -320,6 +327,7 @@ impl Field<'_> {
#comment
#deprecated
#env_var
#flatten
#hidden
#nullable
#optional
Expand Down
3 changes: 2 additions & 1 deletion crates/schematic/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ regex = { workspace = true, optional = true }
semver = { workspace = true, optional = true }

# schema
convert_case = { workspace = true, optional = true }
indexmap = { workspace = true, optional = true, features = ["serde"] }

# json
Expand Down Expand Up @@ -71,7 +72,7 @@ config = [
"dep:starbase_styles",
"schematic_macros/config",
]
schema = ["dep:indexmap", "schematic_macros/schema"]
schema = ["dep:convert_case", "dep:indexmap", "schematic_macros/schema"]
schema_serde = ["schema", "schematic_types/serde"]
tracing = ["schematic_macros/tracing"]

Expand Down
8 changes: 7 additions & 1 deletion crates/schematic/src/schema/renderers/json_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -438,13 +438,19 @@ impl SchemaRenderer<JsonSchema> for JsonSchemaRenderer {
) -> RenderResult<JsonSchema> {
let mut properties = BTreeMap::new();
let mut required = BTreeSet::from_iter(structure.required.clone().unwrap_or_default());
let mut additional_properties = JsonSchema::Bool(false);
let exclude_aliases = self.options.exclude_aliases;

for (name, field) in &structure.fields {
if field.hidden {
continue;
}

if field.flatten {
additional_properties = self.render_schema_without_reference(&field.schema)?;
continue;
}

if !field.optional && self.options.mark_struct_fields_required {
required.insert(name.to_owned());
}
Expand All @@ -465,7 +471,7 @@ impl SchemaRenderer<JsonSchema> for JsonSchemaRenderer {
metadata: Some(Box::new(self.create_metadata_from_schema(schema))),
instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Object))),
object: Some(Box::new(ObjectValidation {
additional_properties: Some(Box::new(JsonSchema::Bool(false))),
additional_properties: Some(Box::new(additional_properties)),
required,
properties,
..Default::default()
Expand Down
4 changes: 4 additions & 0 deletions crates/schematic/src/schema/renderers/jsonc_template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ impl SchemaRenderer<String> for JsoncTemplateRenderer {
self.ctx.depth += 1;

for (index, (name, field)) in structure.fields.iter().enumerate() {
if field.flatten {
continue;
}

self.ctx.push_stack(name);

if !self.ctx.is_hidden(field) {
Expand Down
4 changes: 4 additions & 0 deletions crates/schematic/src/schema/renderers/pkl_template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ impl SchemaRenderer<String> for PklTemplateRenderer {
self.ctx.depth += 1;

for (name, field) in &structure.fields {
if field.flatten {
continue;
}

self.ctx.push_stack(name);

if !self.ctx.is_hidden(field) {
Expand Down
4 changes: 4 additions & 0 deletions crates/schematic/src/schema/renderers/toml_template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ impl SchemaRenderer<String> for TomlTemplateRenderer {
let mut out = vec![];

for (name, field) in &structure.fields {
if field.flatten {
continue;
}

self.ctx.push_stack(name);

if !self.ctx.is_hidden(field) {
Expand Down
84 changes: 71 additions & 13 deletions crates/schematic/src/schema/renderers/typescript.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::schema::{RenderResult, SchemaRenderer};
use convert_case::{Case, Casing};
use indexmap::IndexMap;
use schematic_types::*;
use std::collections::{BTreeMap, HashMap, HashSet};
Expand Down Expand Up @@ -154,16 +155,12 @@ impl TypeScriptRenderer {
Ok(self.wrap_in_comment(schema.description.as_ref(), tags, output))
}

fn export_object_type(
&mut self,
name: &str,
structure: &StructType,
schema: &Schema,
) -> RenderResult {
let value = self.render_struct(structure, schema)?;
fn export_object_type(&mut self, name: &str, schema: &Schema, value: String) -> RenderResult {
let mut tags = vec![];

let output = if matches!(self.options.object_format, ObjectFormat::Interface) {
let output = if !value.contains(" & ")
&& matches!(self.options.object_format, ObjectFormat::Interface)
{
format!("export interface {name} {value}")
} else {
self.export_type_alias(name, value)?
Expand All @@ -180,6 +177,58 @@ impl TypeScriptRenderer {
Ok(self.wrap_in_comment(schema.description.as_ref(), tags, output))
}

fn export_object_types(
&mut self,
name: &str,
structure: &StructType,
schema: &Schema,
) -> RenderResult<Vec<String>> {
let mut outputs = vec![];
let mut extends = vec![];

// Extract flattened fields first as we'll need to use intersections
// to support them correctly in TypeScript
for (field_name, field) in &structure.fields {
if field.flatten {
let name = format!(
"{name}{}",
field_name.from_case(Case::Snake).to_case(Case::Pascal)
);
let value = self.render_schema(&field.schema)?;

outputs.push(self.export_object_type(
&name,
&field.schema,
format!("{{ [key: string]: {value} }}"),
)?);
extends.push(name);
}
}

let value = self.render_struct(structure, schema)?;

// If nothing to extend then we can render this as either an
// interface or a type alias, depending on the option
if extends.is_empty() {
outputs.push(self.export_object_type(name, schema, value)?);
}
// Otherwise we need to render the struct as a "base" type
// and then create a parent type alias thats an intersection
// of all the extended types
else {
let base_name = format!("{name}Base");

outputs.push(self.export_object_type(&base_name, schema, value)?);

extends.push(base_name);
extends.reverse();

outputs.push(self.export_object_type(name, schema, extends.join(" & "))?)
}

Ok(outputs)
}

fn render_enum_as_string_union(&mut self, enu: &EnumType, schema: &Schema) -> RenderResult {
// Map using variants instead of values (when available),
// so that the fallback variant is included
Expand Down Expand Up @@ -471,6 +520,11 @@ impl SchemaRenderer<String> for TypeScriptRenderer {
};

for (name, field) in &structure.fields {
// Handle in `export_object_types`
if field.flatten {
continue;
}

if !exclude_aliases {
for alias in &field.aliases {
create_row(alias, field)?;
Expand Down Expand Up @@ -538,14 +592,18 @@ impl SchemaRenderer<String> for TypeScriptRenderer {
continue;
}

outputs.push(match &schema.ty {
SchemaType::Enum(inner) => self.export_enum_type(name, inner, schema)?,
SchemaType::Struct(inner) => self.export_object_type(name, inner, schema)?,
match &schema.ty {
SchemaType::Enum(inner) => {
outputs.push(self.export_enum_type(name, inner, schema)?)
}
SchemaType::Struct(inner) => {
outputs.extend(self.export_object_types(name, inner, schema)?);
}
_ => {
let out = self.render_schema_without_reference(schema)?;
self.export_type_alias(name, out)?
outputs.push(self.export_type_alias(name, out)?);
}
});
};
}

Ok(outputs.join("\n\n"))
Expand Down
4 changes: 4 additions & 0 deletions crates/schematic/src/schema/renderers/yaml_template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ impl SchemaRenderer<String> for YamlTemplateRenderer {
let mut out = vec![];

for (name, field) in &structure.fields {
if field.flatten {
continue;
}

self.ctx.push_stack(name);

if self.ctx.is_hidden(field) {
Expand Down
3 changes: 3 additions & 0 deletions crates/schematic/tests/generator_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ struct GenConfig {
/// **Nested** field.
#[setting(nested)]
nested: AnotherConfig,
/// Flattened field...
#[setting(flatten)]
flattened: HashMap<String, serde_json::Value>,

// Types
date: chrono::NaiveDate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,22 @@ expression: "fs::read_to_string(file).unwrap()"
]
}
},
"additionalProperties": false,
"additionalProperties": {
"type": "object",
"additionalProperties": {
"type": [
"boolean",
"object",
"array",
"number",
"string",
"integer"
]
},
"propertyNames": {
"type": "string"
}
},
"definitions": {
"AnotherConfig": {
"title": "AnotherConfig",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,22 @@ expression: "fs::read_to_string(file).unwrap()"
]
}
},
"additionalProperties": false,
"additionalProperties": {
"type": "object",
"additionalProperties": {
"type": [
"boolean",
"object",
"array",
"number",
"string",
"integer"
]
},
"propertyNames": {
"type": "string"
}
},
"definitions": {
"AnotherConfig": {
"title": "AnotherConfig",
Expand Down
Loading