Repository: oxidecomputer/typify
Pull request #773 (2025-02-15 19:38:44)

take a consistent stance wrt additionalProperties interpretation
See https://github.com/oxidecomputer/progenitor/issues/1064

See the README update for details.


Patch:
diff --git a/README.md b/README.md
index 6201e6d..7c9d516 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,13 @@ if you can articulate the output you'd ideally like to see.
 
 ## JSON Schema → Rust types
 
+JSON Schema is a constraint language designed for validation. As a result, it
+is not well-suited--and is often seemingly hostile--to translation into
+constructive type systems. It allows for expressions of arbitrary complexity
+with an infinity of ways to articulate a given set of constraints. As such,
+typify does its best to discern an appropriate interpretation, but it is far
+from perfect!
+
 Typify translates JSON Schema types in a few different ways depending on some
 basic properties of the schema:
 
@@ -93,6 +100,29 @@ flattened members, this is one of the weaker areas of code generation.
 
 Issues describing example schemas and desired output are welcome and helpful.
 
+### AdditionalProperties
+
+The `additionalProperties` constraint lets a schema define what *non-specified*
+properties are permitted. A value of `false` means that no other properties are
+permitted (this is expressed in Rust with the `#[serde(deny_unknown_fields)]`
+annotation). The absence of `additionalProperties` or a value of `true` are
+equivalent constructions that mean that any other property is permitted.
+
+Without other properties, an object that permits additional properties will be
+represented as a map type. In conjunction with other properties, typify employs
+a heuristic interpretation. Absent or with a value of `true`, additional
+properties are ignored. If, however, `additionalProperties` has another value
+(i.e. a schema), the generated type will have a map field annotated with
+`#[serde(flatten)]`.
+
+Note that this is true of **any** schema value for `additionalProperties` that
+is not a boolean. This includes values that would be equivalent with regard to
+validation such as the schema `{}` or `{ "not": false }` or any of the other
+infinity of equivalent schemas. One can therefore construct a `struct` with
+named properties **and** a flattened map of additional properties by using a
+value for `additionalProperties` that is equivalent to `true` or absent with
+regard to validation, by using some e.g. `{}`.
+
 ## Rust -> Schema -> Rust
 
 Schemas derived from Rust types may include an extension that provides
diff --git a/typify-impl/src/structs.rs b/typify-impl/src/structs.rs
index 9bc9348..4e28525 100644
--- a/typify-impl/src/structs.rs
+++ b/typify-impl/src/structs.rs
@@ -83,30 +83,12 @@ impl TypeSpace {
             Some(a) if a.as_ref() == &Schema::Bool(false) => true,
 
             // We have a permissive schema so all additional properties are
-            // allowed (None is equivalent to the permissive schema).
-            Some(a)
-                if matches!(
-                    a.as_ref(),
-                    Schema::Bool(true)
-                        | Schema::Object(SchemaObject {
-                            metadata: _,
-                            instance_type: None,
-                            format: None,
-                            enum_values: None,
-                            const_value: None,
-                            subschemas: None,
-                            number: None,
-                            string: None,
-                            array: None,
-                            object: None,
-                            reference: None,
-                            extensions: _,
-                        })
-                ) =>
-            {
-                false
-            }
-
+            // allowed (None is equivalent to the permissive schema). This is
+            // so common that it would be distracting to represent them with a
+            // flattened struct so instead we just ignore them. One can cause
+            // a flattened struct to be generated by using an equivalently
+            // permissive schema such as {}.
+            Some(a) if a.as_ref() == &Schema::Bool(true) => false,
             None => false,
 
             // Only particular additional properties are allowed. Note that


Test patch:
diff --git a/typify/tests/schemas/more_types.json b/typify/tests/schemas/more_types.json
new file mode 100644
index 0000000..d9e5057
--- /dev/null
+++ b/typify/tests/schemas/more_types.json
@@ -0,0 +1,62 @@
+{
+  "$comment": "dumping ground for various types",
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "definitions": {
+    "ObjectWithNoExtra": {
+      "properties": {
+        "foo": {
+          "type": "string"
+        }
+      },
+      "required": [
+        "foo"
+      ],
+      "additionalProperties": false
+    },
+    "ObjectWithOkExtra": {
+      "properties": {
+        "foo": {
+          "type": "string"
+        }
+      },
+      "required": [
+        "foo"
+      ]
+    },
+    "ObjectWithYesExtra": {
+      "properties": {
+        "foo": {
+          "type": "string"
+        }
+      },
+      "required": [
+        "foo"
+      ],
+      "additionalProperties": true
+    },
+    "ObjectWithWhichExtra": {
+      "properties": {
+        "foo": {
+          "type": "string"
+        }
+      },
+      "required": [
+        "foo"
+      ],
+      "additionalProperties": {}
+    },
+    "ObjectWithStringExtra": {
+      "properties": {
+        "foo": {
+          "type": "string"
+        }
+      },
+      "required": [
+        "foo"
+      ],
+      "additionalProperties": {
+        "type": "string"
+      }
+    }
+  }
+}
diff --git a/typify/tests/schemas/more_types.rs b/typify/tests/schemas/more_types.rs
new file mode 100644
index 0000000..eecfb73
--- /dev/null
+++ b/typify/tests/schemas/more_types.rs
@@ -0,0 +1,423 @@
+#![deny(warnings)]
+#[doc = r" Error types."]
+pub mod error {
+    #[doc = r" Error from a `TryFrom` or `FromStr` implementation."]
+    pub struct ConversionError(::std::borrow::Cow<'static, str>);
+    impl ::std::error::Error for ConversionError {}
+    impl ::std::fmt::Display for ConversionError {
+        fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> {
+            ::std::fmt::Display::fmt(&self.0, f)
+        }
+    }
+    impl ::std::fmt::Debug for ConversionError {
+        fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> {
+            ::std::fmt::Debug::fmt(&self.0, f)
+        }
+    }
+    impl From<&'static str> for ConversionError {
+        fn from(value: &'static str) -> Self {
+            Self(value.into())
+        }
+    }
+    impl From<String> for ConversionError {
+        fn from(value: String) -> Self {
+            Self(value.into())
+        }
+    }
+}
+#[doc = "`ObjectWithNoExtra`"]
+#[doc = r""]
+#[doc = r" <details><summary>JSON schema</summary>"]
+#[doc = r""]
+#[doc = r" ```json"]
+#[doc = "{"]
+#[doc = "  \"required\": ["]
+#[doc = "    \"foo\""]
+#[doc = "  ],"]
+#[doc = "  \"properties\": {"]
+#[doc = "    \"foo\": {"]
+#[doc = "      \"type\": \"string\""]
+#[doc = "    }"]
+#[doc = "  },"]
+#[doc = "  \"additionalProperties\": false"]
+#[doc = "}"]
+#[doc = r" ```"]
+#[doc = r" </details>"]
+#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug)]
+#[serde(deny_unknown_fields)]
+pub struct ObjectWithNoExtra {
+    pub foo: ::std::string::String,
+}
+impl ::std::convert::From<&ObjectWithNoExtra> for ObjectWithNoExtra {
+    fn from(value: &ObjectWithNoExtra) -> Self {
+        value.clone()
+    }
+}
+impl ObjectWithNoExtra {
+    pub fn builder() -> builder::ObjectWithNoExtra {
+        Default::default()
+    }
+}
+#[doc = "`ObjectWithOkExtra`"]
+#[doc = r""]
+#[doc = r" <details><summary>JSON schema</summary>"]
+#[doc = r""]
+#[doc = r" ```json"]
+#[doc = "{"]
+#[doc = "  \"required\": ["]
+#[doc = "    \"foo\""]
+#[doc = "  ],"]
+#[doc = "  \"properties\": {"]
+#[doc = "    \"foo\": {"]
+#[doc = "      \"type\": \"string\""]
+#[doc = "    }"]
+#[doc = "  }"]
+#[doc = "}"]
+#[doc = r" ```"]
+#[doc = r" </details>"]
+#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug)]
+pub struct ObjectWithOkExtra {
+    pub foo: ::std::string::String,
+}
+impl ::std::convert::From<&ObjectWithOkExtra> for ObjectWithOkExtra {
+    fn from(value: &ObjectWithOkExtra) -> Self {
+        value.clone()
+    }
+}
+impl ObjectWithOkExtra {
+    pub fn builder() -> builder::ObjectWithOkExtra {
+        Default::default()
+    }
+}
+#[doc = "`ObjectWithStringExtra`"]
+#[doc = r""]
+#[doc = r" <details><summary>JSON schema</summary>"]
+#[doc = r""]
+#[doc = r" ```json"]
+#[doc = "{"]
+#[doc = "  \"required\": ["]
+#[doc = "    \"foo\""]
+#[doc = "  ],"]
+#[doc = "  \"properties\": {"]
+#[doc = "    \"foo\": {"]
+#[doc = "      \"type\": \"string\""]
+#[doc = "    }"]
+#[doc = "  },"]
+#[doc = "  \"additionalProperties\": {"]
+#[doc = "    \"type\": \"string\""]
+#[doc = "  }"]
+#[doc = "}"]
+#[doc = r" ```"]
+#[doc = r" </details>"]
+#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug)]
+pub struct ObjectWithStringExtra {
+    pub foo: ::std::string::String,
+    #[serde(flatten)]
+    pub extra: ::std::collections::HashMap<::std::string::String, ::std::string::String>,
+}
+impl ::std::convert::From<&ObjectWithStringExtra> for ObjectWithStringExtra {
+    fn from(value: &ObjectWithStringExtra) -> Self {
+        value.clone()
+    }
+}
+impl ObjectWithStringExtra {
+    pub fn builder() -> builder::ObjectWithStringExtra {
+        Default::default()
+    }
+}
+#[doc = "`ObjectWithWhichExtra`"]
+#[doc = r""]
+#[doc = r" <details><summary>JSON schema</summary>"]
+#[doc = r""]
+#[doc = r" ```json"]
+#[doc = "{"]
+#[doc = "  \"required\": ["]
+#[doc = "    \"foo\""]
+#[doc = "  ],"]
+#[doc = "  \"properties\": {"]
+#[doc = "    \"foo\": {"]
+#[doc = "      \"type\": \"string\""]
+#[doc = "    }"]
+#[doc = "  },"]
+#[doc = "  \"additionalProperties\": {}"]
+#[doc = "}"]
+#[doc = r" ```"]
+#[doc = r" </details>"]
+#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug)]
+pub struct ObjectWithWhichExtra {
+    pub foo: ::std::string::String,
+    #[serde(flatten)]
+    pub extra: ::serde_json::Map<::std::string::String, ::serde_json::Value>,
+}
+impl ::std::convert::From<&ObjectWithWhichExtra> for ObjectWithWhichExtra {
+    fn from(value: &ObjectWithWhichExtra) -> Self {
+        value.clone()
+    }
+}
+impl ObjectWithWhichExtra {
+    pub fn builder() -> builder::ObjectWithWhichExtra {
+        Default::default()
+    }
+}
+#[doc = "`ObjectWithYesExtra`"]
+#[doc = r""]
+#[doc = r" <details><summary>JSON schema</summary>"]
+#[doc = r""]
+#[doc = r" ```json"]
+#[doc = "{"]
+#[doc = "  \"required\": ["]
+#[doc = "    \"foo\""]
+#[doc = "  ],"]
+#[doc = "  \"properties\": {"]
+#[doc = "    \"foo\": {"]
+#[doc = "      \"type\": \"string\""]
+#[doc = "    }"]
+#[doc = "  },"]
+#[doc = "  \"additionalProperties\": true"]
+#[doc = "}"]
+#[doc = r" ```"]
+#[doc = r" </details>"]
+#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug)]
+pub struct ObjectWithYesExtra {
+    pub foo: ::std::string::String,
+}
+impl ::std::convert::From<&ObjectWithYesExtra> for ObjectWithYesExtra {
+    fn from(value: &ObjectWithYesExtra) -> Self {
+        value.clone()
+    }
+}
+impl ObjectWithYesExtra {
+    pub fn builder() -> builder::ObjectWithYesExtra {
+        Default::default()
+    }
+}
+#[doc = r" Types for composing complex structures."]
+pub mod builder {
+    #[derive(Clone, Debug)]
+    pub struct ObjectWithNoExtra {
+        foo: ::std::result::Result<::std::string::String, ::std::string::String>,
+    }
+    impl ::std::default::Default for ObjectWithNoExtra {
+        fn default() -> Self {
+            Self {
+                foo: Err("no value supplied for foo".to_string()),
+            }
+        }
+    }
+    impl ObjectWithNoExtra {
+        pub fn foo<T>(mut self, value: T) -> Self
+        where
+            T: ::std::convert::TryInto<::std::string::String>,
+            T::Error: ::std::fmt::Display,
+        {
+            self.foo = value
+                .try_into()
+                .map_err(|e| format!("error converting supplied value for foo: {}", e));
+            self
+        }
+    }
+    impl ::std::convert::TryFrom<ObjectWithNoExtra> for super::ObjectWithNoExtra {
+        type Error = super::error::ConversionError;
+        fn try_from(
+            value: ObjectWithNoExtra,
+        ) -> ::std::result::Result<Self, super::error::ConversionError> {
+            Ok(Self { foo: value.foo? })
+        }
+    }
+    impl ::std::convert::From<super::ObjectWithNoExtra> for ObjectWithNoExtra {
+        fn from(value: super::ObjectWithNoExtra) -> Self {
+            Self { foo: Ok(value.foo) }
+        }
+    }
+    #[derive(Clone, Debug)]
+    pub struct ObjectWithOkExtra {
+        foo: ::std::result::Result<::std::string::String, ::std::string::String>,
+    }
+    impl ::std::default::Default for ObjectWithOkExtra {
+        fn default() -> Self {
+            Self {
+                foo: Err("no value supplied for foo".to_string()),
+            }
+        }
+    }
+    impl ObjectWithOkExtra {
+        pub fn foo<T>(mut self, value: T) -> Self
+        where
+            T: ::std::convert::TryInto<::std::string::String>,
+            T::Error: ::std::fmt::Display,
+        {
+            self.foo = value
+                .try_into()
+                .map_err(|e| format!("error converting supplied value for foo: {}", e));
+            self
+        }
+    }
+    impl ::std::convert::TryFrom<ObjectWithOkExtra> for super::ObjectWithOkExtra {
+        type Error = super::error::ConversionError;
+        fn try_from(
+            value: ObjectWithOkExtra,
+        ) -> ::std::result::Result<Self, super::error::ConversionError> {
+            Ok(Self { foo: value.foo? })
+        }
+    }
+    impl ::std::convert::From<super::ObjectWithOkExtra> for ObjectWithOkExtra {
+        fn from(value: super::ObjectWithOkExtra) -> Self {
+            Self { foo: Ok(value.foo) }
+        }
+    }
+    #[derive(Clone, Debug)]
+    pub struct ObjectWithStringExtra {
+        foo: ::std::result::Result<::std::string::String, ::std::string::String>,
+        extra: ::std::result::Result<
+            ::std::collections::HashMap<::std::string::String, ::std::string::String>,
+            ::std::string::String,
+        >,
+    }
+    impl ::std::default::Default for ObjectWithStringExtra {
+        fn default() -> Self {
+            Self {
+                foo: Err("no value supplied for foo".to_string()),
+                extra: Err("no value supplied for extra".to_string()),
+            }
+        }
+    }
+    impl ObjectWithStringExtra {
+        pub fn foo<T>(mut self, value: T) -> Self
+        where
+            T: ::std::convert::TryInto<::std::string::String>,
+            T::Error: ::std::fmt::Display,
+        {
+            self.foo = value
+                .try_into()
+                .map_err(|e| format!("error converting supplied value for foo: {}", e));
+            self
+        }
+        pub fn extra<T>(mut self, value: T) -> Self
+        where
+            T: ::std::convert::TryInto<
+                ::std::collections::HashMap<::std::string::String, ::std::string::String>,
+            >,
+            T::Error: ::std::fmt::Display,
+        {
+            self.extra = value
+                .try_into()
+                .map_err(|e| format!("error converting supplied value for extra: {}", e));
+            self
+        }
+    }
+    impl ::std::convert::TryFrom<ObjectWithStringExtra> for super::ObjectWithStringExtra {
+        type Error = super::error::ConversionError;
+        fn try_from(
+            value: ObjectWithStringExtra,
+        ) -> ::std::result::Result<Self, super::error::ConversionError> {
+            Ok(Self {
+                foo: value.foo?,
+                extra: value.extra?,
+            })
+        }
+    }
+    impl ::std::convert::From<super::ObjectWithStringExtra> for ObjectWithStringExtra {
+        fn from(value: super::ObjectWithStringExtra) -> Self {
+            Self {
+                foo: Ok(value.foo),
+                extra: Ok(value.extra),
+            }
+        }
+    }
+    #[derive(Clone, Debug)]
+    pub struct ObjectWithWhichExtra {
+        foo: ::std::result::Result<::std::string::String, ::std::string::String>,
+        extra: ::std::result::Result<
+            ::serde_json::Map<::std::string::String, ::serde_json::Value>,
+            ::std::string::String,
+        >,
+    }
+    impl ::std::default::Default for ObjectWithWhichExtra {
+        fn default() -> Self {
+            Self {
+                foo: Err("no value supplied for foo".to_string()),
+                extra: Err("no value supplied for extra".to_string()),
+            }
+        }
+    }
+    impl ObjectWithWhichExtra {
+        pub fn foo<T>(mut self, value: T) -> Self
+        where
+            T: ::std::convert::TryInto<::std::string::String>,
+            T::Error: ::std::fmt::Display,
+        {
+            self.foo = value
+                .try_into()
+                .map_err(|e| format!("error converting supplied value for foo: {}", e));
+            self
+        }
+        pub fn extra<T>(mut self, value: T) -> Self
+        where
+            T: ::std::convert::TryInto<
+                ::serde_json::Map<::std::string::String, ::serde_json::Value>,
+            >,
+            T::Error: ::std::fmt::Display,
+        {
+            self.extra = value
+                .try_into()
+                .map_err(|e| format!("error converting supplied value for extra: {}", e));
+            self
+        }
+    }
+    impl ::std::convert::TryFrom<ObjectWithWhichExtra> for super::ObjectWithWhichExtra {
+        type Error = super::error::ConversionError;
+        fn try_from(
+            value: ObjectWithWhichExtra,
+        ) -> ::std::result::Result<Self, super::error::ConversionError> {
+            Ok(Self {
+                foo: value.foo?,
+                extra: value.extra?,
+            })
+        }
+    }
+    impl ::std::convert::From<super::ObjectWithWhichExtra> for ObjectWithWhichExtra {
+        fn from(value: super::ObjectWithWhichExtra) -> Self {
+            Self {
+                foo: Ok(value.foo),
+                extra: Ok(value.extra),
+            }
+        }
+    }
+    #[derive(Clone, Debug)]
+    pub struct ObjectWithYesExtra {
+        foo: ::std::result::Result<::std::string::String, ::std::string::String>,
+    }
+    impl ::std::default::Default for ObjectWithYesExtra {
+        fn default() -> Self {
+            Self {
+                foo: Err("no value supplied for foo".to_string()),
+            }
+        }
+    }
+    impl ObjectWithYesExtra {
+        pub fn foo<T>(mut self, value: T) -> Self
+        where
+            T: ::std::convert::TryInto<::std::string::String>,
+            T::Error: ::std::fmt::Display,
+        {
+            self.foo = value
+                .try_into()
+                .map_err(|e| format!("error converting supplied value for foo: {}", e));
+            self
+        }
+    }
+    impl ::std::convert::TryFrom<ObjectWithYesExtra> for super::ObjectWithYesExtra {
+        type Error = super::error::ConversionError;
+        fn try_from(
+            value: ObjectWithYesExtra,
+        ) -> ::std::result::Result<Self, super::error::ConversionError> {
+            Ok(Self { foo: value.foo? })
+        }
+    }
+    impl ::std::convert::From<super::ObjectWithYesExtra> for ObjectWithYesExtra {
+        fn from(value: super::ObjectWithYesExtra) -> Self {
+            Self { foo: Ok(value.foo) }
+        }
+    }
+}
+fn main() {}