Repository: oxidecomputer/typify
Pull request #584 (2024-05-10 23:21:34)

Add support for the `x-rust-type` extension
This adds support for the `x-rust-type` extension. When we see this extension in types we can (depending on the configuration settings) try to use the type from the particular crate rather than generating the type. This is particularly useful for types with any of the following properties:

* Structure that's not well represented in JSON Schema such as an IP subnet that has distinct components of an IP address and netmask prefix, but that is typically represented simply as a string with a (complex) `pattern` regex.
* Mechanisms that are missed in a generated type. Consider e.g. a type representing a number of bytes that benefits from constructors and accessors aware of IEC prefixes.
* Common, atomic types along the lines of UUIDs or dates (both of which are represented with `format`s... I'm saying *like* those)


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index c869fea..e14d96b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -184,6 +184,7 @@ dependencies = [
  "newline-converter",
  "rustfmt-wrapper",
  "schemars",
+ "semver",
  "serde_json",
  "tempdir",
  "typify",
@@ -895,9 +896,12 @@ dependencies = [
 
 [[package]]
 name = "semver"
-version = "1.0.20"
+version = "1.0.22"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090"
+checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca"
+dependencies = [
+ "serde",
+]
 
 [[package]]
 name = "serde"
@@ -1199,6 +1203,7 @@ dependencies = [
  "rustfmt-wrapper",
  "schema",
  "schemars",
+ "semver",
  "serde",
  "serde_json",
  "syn",
@@ -1214,6 +1219,7 @@ dependencies = [
  "proc-macro2",
  "quote",
  "schemars",
+ "semver",
  "serde",
  "serde_json",
  "serde_tokenstream",
diff --git a/README.md b/README.md
index 3b14ad1..8b63f2b 100644
--- a/README.md
+++ b/README.md
@@ -31,29 +31,30 @@ appropriate built-in type based on type attributes. For example, a JSON Schema
 might specify a maximum and/or minimum that indicates the appropriate integral
 type to use.
 
-String schemas that include a `format` are represented with the appropriate Rust
-type. For example `{ "type": "string", "format": "uuid" }` is represented as a
-`uuid::Uuid` (which requires the `uuid` crate be included as a dependency).
+String schemas that include a known `format` are represented with the
+appropriate Rust type. For example `{ "type": "string", "format": "uuid" }` is
+represented as a `uuid::Uuid` (which requires the `uuid` crate be included as a
+dependency).
 
 ### Arrays
 
 JSON Schema arrays can turn into one of three Rust types `Vec<T>`, `HashSet<T>`,
 and tuples depending on the schema properties. An array may have a fixed length
 that matches a fixed list of item types; this is well represented by a Rust
-tuples. The distinction between `Vec<T>` and `HashSet<T>` is only if the
+tuple. The distinction between `Vec<T>` and `HashSet<T>` is only if the
 schema's `uniqueItems` field is `false` or `true` respectively.
 
 ### Objects
 
-In general, objects turn in to Rust structs. If, however, the schema defines no
+In general, objects turn into Rust structs. If, however, the schema defines no
 properties, Typify emits a `HashMap<String, T>` if the `additionalProperties`
 schema specifies `T` or a `HashMap<String, serde_json::Value>` otherwise.
 
-Properties that are not in the `required` set are typically represented as an
-`Option<T>` with the `#[serde(default)]` attribute applied. Non-required
-properties with types that already have a default value (such as a `Vec<T>`)
-simply get the `#[serde(default)]` attribute (so you won't see e.g.
-`Option<Vec<T>>`).
+Properties of generated `struct` that are not in the `required` set are
+typically represented as an `Option<T>` with the `#[serde(default)]` attribute
+applied. Non-required properties with types that already have a default value
+(such as a `Vec<T>`) simply get the `#[serde(default)]` attribute (so you won't
+see e.g. `Option<Vec<T>>`).
 
 ### OneOf
 
@@ -76,6 +77,166 @@ flattened members, this is one of the weaker areas of code generation.
 
 Issues describing example schemas and desired output are welcome and helpful.
 
+## Rust -> Schema -> Rust
+
+Schemas derived from Rust types may include an extension that provides
+information about the original type:
+
+```json
+{
+  "type": "object",
+  "properties": { .. },
+  "x-rust-type": {
+    "crate": "crate-o-types",
+    "version": "1.0.0",
+    "path": "crate_o_types::some_mod::SomeType"
+  }
+}
+```
+
+The extension includes the name of the crate, a Cargo-style version
+requirements spec, and the full path (that must start with ident-converted name
+of the crate).
+
+Each of the modes of using typify allow for a list of crates and versions to be
+specified. In this case, if the user specifies "crate-o-types@1.0.1" for
+example, then typify would use its `SomeType` type rather than generating one
+according to the schema.
+
+### Using types from other crates
+
+Each mode of using typify has a method for controlling the use of types with
+`x-rust-type` annotations. The default is to ignore them. The recommended
+method is to specify each crate and version you intend to use. You can
+additionally supply the `*` version for crates (which may result in
+incompatibilities) or you can define a policy to allow the use of all "unknown"
+crates (which may require that addition of dependencies for those crates).
+
+For the CLI:
+```console
+$ cargo typify --unknown-crates allow --crate oxnet@1.0.0 ...
+```
+
+For the builder:
+```rust
+let mut settings = typify::TypeSpaceSettings::default();
+settings.with_unknown_crates(typify::UnknownPolicy::Allow)
+    .with_crate("oxnet", typify::CrateVers::Version("1.0.0".parse().unwrap()));
+```
+
+For the macro:
+```rust
+typify::import_types!(
+  schema = "schema.json",
+  unknown_types = Allow,
+  crates {
+    "oxnet" = "1.0.0"
+  }
+)
+```
+
+### Version requirements
+
+The `version` field within the `x-rust-type` extension follows the Cargo
+version requirements specification. If the extension specifies `0.1.0` of a
+crate and the user states that they're using `0.1.1`, then the type is used;
+conversely, if the extension specifies `0.2.2` and the user is only using
+`0.2.0` the type is not used.
+
+Crate authors may choose to adhere to greater stability than otherwise provided
+by semver. If the extension version is `>=0.1.0, <1.0.0` then the crate author
+is committing to the schema compatibility of the given type on all releases
+until `1.0.0`. It is important that crate authors populate the `version` field
+in a way that upholds type availability. For example, while `*` is a valid
+value, it is only conceivably valid if the type in question were available in
+the first ever version of a crate published and never changed incompatibly in
+any subsequent version.
+
+### Type parameters
+
+The `x-rust-type` extension may also specify type parameters:
+
+```json
+{
+  "$defs": {
+    "Sprocket": {
+      "type": "object",
+      "properties": { .. },
+      "x-rust-type": {
+        "crate": "util",
+        "version": "0.1.0",
+        "path": "util::Sprocket",
+        "parameters": [
+          {
+            "$ref": "#/$defs/Gizmo"
+          }
+        ]
+      }
+    },
+    "Gizmo": {
+      "type": "object",
+      "properties": { .. },
+      "x-rust-type": {
+        "crate": "util",
+        "version": "0.1.0",
+        "path": "util::Gizmo"
+      }
+    }
+  }
+}
+```
+
+With the `util@0.1.0` crate specified during type generation, schemas
+referencing `#/$defs/Sprocket` would use the (non-generated) type
+`util::Sprocket<util::Gizmo>`.
+
+The `parameters` field is an array of schemas. They may be inline schemas or
+referenced schemas.
+
+### Including `x-rust-type` in your library
+
+The schema for the expected value is as follows:
+
+```json
+{
+  "description": "schema for the x-rust-type extension",
+  "type": "object",
+  "properties": {
+    "crate": {
+      "type": "string",
+      "pattern": "^[a-zA-Z0-9_-]+$"
+    },
+    "version": {
+      "description": "semver requirements per a Cargo.toml dependencies entry",
+      "type": "string"
+    },
+    "path": {
+      "type": "string",
+      "pattern": "^[a-zA-Z0-9_]+(::[a-zA-Z0-9+]+)*$"
+    },
+    "parameters": {
+      "type": "array",
+      "items": {
+        "$ref": "#/definitions/Schema"
+      }
+    }
+  },
+  "required": [
+    "crate",
+    "path",
+    "version"
+  ]
+}
+```
+
+The `version` field expresses the stability of your type. For example, if
+`0.1.0` indicates that `0.1.1` users would be fine whereas `0.2.0` users would
+not use the type (instead generating it). You can communicate a future
+commitment beyond what semver implies by using the [Cargo version requirement
+syntax](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#version-requirement-syntax).
+For example `>=0.1.0, <1.0.0` says that the type will remain structurally
+compatible from version `0.1.0` until `1.0.0`.
+
 ## Formatting
 
 You can format generated code using crates such as
@@ -90,7 +251,7 @@ The examples below show different ways to convert a `TypeSpace` to a string
 ### `rustfmt`
 
 Best for generation of code that might be checked in alongside hand-written
-code such as in the case of an `xtask` or stand-alone code generator (list
+code such as in the case of an `xtask` or stand-alone code generator (such as
 `cargo-typify`).
 
 ```rust
diff --git a/cargo-typify/Cargo.toml b/cargo-typify/Cargo.toml
index 2e8fcba..e4a319b 100644
--- a/cargo-typify/Cargo.toml
+++ b/cargo-typify/Cargo.toml
@@ -18,6 +18,7 @@ clap = { version = "4.5.4", features = ["derive"] }
 color-eyre = "0.6"
 env_logger = "0.10"
 rustfmt-wrapper = "0.2.1"
+semver = "1.0.22"
 serde_json = "1.0.116"
 schemars = "0.8.17"
 
diff --git a/cargo-typify/src/lib.rs b/cargo-typify/src/lib.rs
index eb74a0d..328b57b 100644
--- a/cargo-typify/src/lib.rs
+++ b/cargo-typify/src/lib.rs
@@ -1,4 +1,4 @@
-// Copyright 2023 Oxide Computer Company
+// Copyright 2024 Oxide Computer Company
 
 //! cargo command to generate Rust code from a JSON Schema.
 
@@ -8,7 +8,7 @@ use std::path::PathBuf;
 
 use clap::{ArgGroup, Args};
 use color_eyre::eyre::{Context, Result};
-use typify::{TypeSpace, TypeSpaceSettings};
+use typify::{CrateVers, TypeSpace, TypeSpaceSettings, UnknownPolicy};
 
 /// A CLI for the `typify` crate that converts JSON Schema files to Rust code.
 #[derive(Args)]
@@ -25,7 +25,8 @@ pub struct CliArgs {
     #[arg(short, long, default_value = "false", group = "build")]
     pub builder: bool,
 
-    /// Inverse of `--builder`. When set the builder-style interface will not be included.
+    /// Inverse of `--builder`. When set the builder-style interface will not
+    /// be included.
     #[arg(short = 'B', long, default_value = "false", group = "build")]
     pub no_builder: bool,
 
@@ -33,12 +34,25 @@ pub struct CliArgs {
     #[arg(short, long = "additional-derive", value_name = "derive")]
     pub additional_derives: Vec<String>,
 
-    /// The output file to write to. If not specified, the input file name will be used with a
-    /// `.rs` extension.
+    /// The output file to write to. If not specified, the input file name will
+    /// be used with a `.rs` extension.
     ///
     /// If `-` is specified, the output will be written to stdout.
     #[arg(short, long)]
     pub output: Option<PathBuf>,
+
+    /// Specify each crate@version that can be assumed to be in use for types
+    /// found in the schema with the x-rust-type extension.
+    #[arg(long = "crate")]
+    crates: Vec<CrateSpec>,
+
+    /// Specify the policy unknown crates found in schemas with the
+    /// x-rust-type extension.
+    #[arg(
+        long = "unknown-crates",
+        value_parser = ["generate", "allow", "deny"]
+    )]
+    unknown_crates: Option<String>,
 }
 
 impl CliArgs {
@@ -66,6 +80,53 @@ impl CliArgs {
     }
 }
 
+#[derive(Debug, Clone)]
+struct CrateSpec {
+    name: String,
+    version: CrateVers,
+    rename: Option<String>,
+}
+
+impl std::str::FromStr for CrateSpec {
+    type Err = &'static str;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        fn is_crate(s: &str) -> bool {
+            !s.contains(|cc: char| !cc.is_alphabetic() && cc != '-' && cc != '_')
+        }
+
+        fn convert(s: &str) -> Option<CrateSpec> {
+            let (rename, s) = if let Some(ii) = s.find('=') {
+                let rename = &s[..ii];
+                let rest = &s[ii + 1..];
+                if !is_crate(rename) {
+                    return None;
+                }
+                (Some(rename.to_string()), rest)
+            } else {
+                (None, s)
+            };
+
+            let ii = s.find('@')?;
+            let crate_str = &s[..ii];
+            let vers_str = &s[ii + 1..];
+
+            if !is_crate(crate_str) {
+                return None;
+            }
+            let version = CrateVers::parse(vers_str)?;
+
+            Some(CrateSpec {
+                name: crate_str.to_string(),
+                version,
+                rename,
+            })
+        }
+
+        convert(s).ok_or("crate specifier must be of the form 'cratename@version'")
+    }
+}
+
 /// Generate Rust code for the selected JSON Schema.
 pub fn convert(args: &CliArgs) -> Result<String> {
     let content = std::fs::read_to_string(&args.input)
@@ -74,14 +135,33 @@ pub fn convert(args: &CliArgs) -> Result<String> {
     let schema = serde_json::from_str::<schemars::schema::RootSchema>(&content)
         .wrap_err("Failed to parse input file as JSON Schema")?;
 
-    let mut settings = &mut TypeSpaceSettings::default();
-    settings = settings.with_struct_builder(args.use_builder());
+    let mut settings = TypeSpaceSettings::default();
+    settings.with_struct_builder(args.use_builder());
 
     for derive in &args.additional_derives {
-        settings = settings.with_derive(derive.clone());
+        settings.with_derive(derive.clone());
+    }
+
+    for CrateSpec {
+        name,
+        version,
+        rename,
+    } in &args.crates
+    {
+        settings.with_crate(name, version.clone(), rename.as_ref());
+    }
+
+    if let Some(unknown_crates) = &args.unknown_crates {
+        let unknown_crates = match unknown_crates.as_str() {
+            "generate" => UnknownPolicy::Generate,
+            "allow" => UnknownPolicy::Allow,
+            "deny" => UnknownPolicy::Deny,
+            _ => unreachable!(),
+        };
+        settings.with_unknown_crates(unknown_crates);
     }
 
-    let mut type_space = TypeSpace::new(settings);
+    let mut type_space = TypeSpace::new(&settings);
     type_space
         .add_root_schema(schema)
         .wrap_err("Schema conversion failed")?;
@@ -113,6 +193,8 @@ mod tests {
             additional_derives: vec![],
             output: Some(PathBuf::from("-")),
             no_builder: false,
+            crates: vec![],
+            unknown_crates: Default::default(),
         };
 
         assert_eq!(args.output_path(), None);
@@ -126,6 +208,8 @@ mod tests {
             additional_derives: vec![],
             output: Some(PathBuf::from("some_file.rs")),
             no_builder: false,
+            crates: vec![],
+            unknown_crates: Default::default(),
         };
 
         assert_eq!(args.output_path(), Some(PathBuf::from("some_file.rs")));
@@ -139,6 +223,8 @@ mod tests {
             additional_derives: vec![],
             output: None,
             no_builder: false,
+            crates: vec![],
+            unknown_crates: Default::default(),
         };
 
         assert_eq!(args.output_path(), Some(PathBuf::from("input.rs")));
@@ -152,6 +238,8 @@ mod tests {
             additional_derives: vec![],
             output: None,
             no_builder: false,
+            crates: vec![],
+            unknown_crates: Default::default(),
         };
 
         assert!(args.use_builder());
@@ -165,6 +253,8 @@ mod tests {
             additional_derives: vec![],
             output: None,
             no_builder: true,
+            crates: vec![],
+            unknown_crates: Default::default(),
         };
 
         assert!(!args.use_builder());
@@ -178,6 +268,8 @@ mod tests {
             additional_derives: vec![],
             output: None,
             no_builder: false,
+            crates: vec![],
+            unknown_crates: Default::default(),
         };
 
         assert!(args.use_builder());
diff --git a/extension-schema.json b/extension-schema.json
new file mode 100644
index 0000000..73e5822
--- /dev/null
+++ b/extension-schema.json
@@ -0,0 +1,29 @@
+{
+  "description": "schema for the x-rust-type extension",
+  "type": "object",
+  "properties": {
+    "crate": {
+      "type": "string",
+      "pattern": "^[a-zA-Z0-9_-]+$"
+    },
+    "version": {
+      "description": "semver requirements per a Cargo.toml dependencies entry",
+      "type": "string"
+    },
+    "path": {
+      "type": "string",
+      "pattern": "^[a-zA-Z0-9_]+(::[a-zA-Z0-9+]+)*$"
+    },
+    "parameters": {
+      "type": "array",
+      "items": {
+        "$ref": "#/definitions/Schema"
+      }
+    }
+  },
+  "required": [
+    "crate",
+    "path",
+    "version"
+  ]
+}
\ No newline at end of file
diff --git a/typify-impl/Cargo.toml b/typify-impl/Cargo.toml
index 470e1b3..60d04a3 100644
--- a/typify-impl/Cargo.toml
+++ b/typify-impl/Cargo.toml
@@ -14,6 +14,8 @@ proc-macro2 = "1.0.81"
 quote = "1.0.36"
 regress = "0.9.1"
 schemars = "0.8.17"
+semver = "1.0.22"
+serde = "1.0.200"
 serde_json = "1.0.116"
 syn = { version = "2.0.60", features = ["full"] }
 thiserror = "1.0.59"
@@ -26,7 +28,6 @@ expectorate = "1.1.0"
 paste = "1.0.14"
 rustfmt-wrapper = "0.2.1"
 schema = "0.1.0"
-schemars = { version = "0.8.17", features = ["uuid1"] }
-serde = "1.0.200"
+schemars = { version = "0.8.17", features = ["uuid1", "impl_json_schema"] }
 syn = { version = "2.0.60", features = ["full", "extra-traits"] }
 uuid = "1.8.0"
diff --git a/typify-impl/src/convert.rs b/typify-impl/src/convert.rs
index 1ad1666..ac8a7a0 100644
--- a/typify-impl/src/convert.rs
+++ b/typify-impl/src/convert.rs
@@ -49,6 +49,10 @@ impl TypeSpace {
         original_schema: &'a Schema,
         schema: &'a SchemaObject,
     ) -> Result<(TypeEntry, &'a Option<Box<Metadata>>)> {
+        if let Some(type_entry) = self.convert_rust_extension(schema) {
+            return Ok((type_entry, &schema.metadata));
+        }
+
         match schema {
             // If we have a schema that has an instance type array that's
             // exactly two elements and one of them is Null, we have the
diff --git a/typify-impl/src/lib.rs b/typify-impl/src/lib.rs
index 2090489..906f458 100644
--- a/typify-impl/src/lib.rs
+++ b/typify-impl/src/lib.rs
@@ -30,6 +30,7 @@ mod defaults;
 mod enums;
 mod merge;
 mod output;
+mod rust_extension;
 mod structs;
 mod type_entry;
 mod util;
@@ -242,11 +243,66 @@ pub struct TypeSpaceSettings {
     extra_derives: Vec<String>,
     struct_builder: bool,
 
+    unknown_crates: UnknownPolicy,
+    crates: BTreeMap<String, CrateSpec>,
+
     patch: BTreeMap<String, TypeSpacePatch>,
     replace: BTreeMap<String, TypeSpaceReplace>,
     convert: Vec<TypeSpaceConversion>,
 }
 
+#[derive(Debug, Clone)]
+struct CrateSpec {
+    version: CrateVers,
+    rename: Option<String>,
+}
+
+/// Policy to apply to external types described by schema extensions whose
+/// crates are not explicitly specified.
+#[derive(Default, Debug, Clone, Copy, Eq, PartialEq, serde::Deserialize)]
+pub enum UnknownPolicy {
+    /// Generate the type rather according to the schema.
+    #[default]
+    Generate,
+    /// Use the specified type by path (this will result in a compile error if
+    /// one of the crates is not an existing dependency). Note that this
+    /// ignores compatibility requirements specified by the schema extension
+    /// and may result in subtle failures if the crate used is incompatible
+    /// with the version that produced the schema.
+    Allow,
+    /// If an unknown crate is encountered, generate a compiler warning
+    /// indicating the crate that must be specified to proceed along with
+    /// version constraints. This affords users an opportunity to specify the
+    /// specific crate version to use (or the user may explicitly deny use of
+    /// that crate).
+    Deny,
+}
+
+/// Specify the version for a named crate to consider for type use (rather than
+/// generating types) in the presense of a schema extension.
+#[derive(Debug, Clone)]
+pub enum CrateVers {
+    /// An explicit version.
+    Version(semver::Version),
+    /// Any version.
+    Any,
+    /// Never use the given crate.
+    Never,
+}
+
+impl CrateVers {
+    /// Parse from a string
+    pub fn parse(s: &str) -> Option<Self> {
+        if s == "!" {
+            Some(Self::Never)
+        } else if s == "*" {
+            Some(Self::Any)
+        } else {
+            Some(Self::Version(semver::Version::parse(s).ok()?))
+        }
+    }
+}
+
 /// Contains a set of modifications that may be applied to an existing type.
 #[derive(Debug, Default, Clone)]
 pub struct TypeSpacePatch {
@@ -365,6 +421,38 @@ impl TypeSpaceSettings {
         });
         self
     }
+
+    /// Type schemas may contain an extension (`x-rust-type`) that indicates
+    /// the corresponding Rust type within a particular crate. This function
+    /// changes the disposition regarding crates not otherwise specified via
+    /// [`Self::with_crate`]. The default value is `false`.
+    pub fn with_unknown_crates(&mut self, policy: UnknownPolicy) -> &mut Self {
+        self.unknown_crates = policy;
+        self
+    }
+
+    /// Type schemas may contain an extension (`x-rust-type`) that indicates
+    /// the corresponding Rust type within a particular crate. This extension
+    /// indicates the crate, version compatibility, type path, and type
+    /// parameters. This function modifies settings to use (rather than
+    /// generate) types from the given crate and version. The version should
+    /// precisely match the version of the crate that you expect as a
+    /// dependency.
+    pub fn with_crate<S1: ToString>(
+        &mut self,
+        crate_name: S1,
+        version: CrateVers,
+        rename: Option<&String>,
+    ) -> &mut Self {
+        self.crates.insert(
+            crate_name.to_string(),
+            CrateSpec {
+                version,
+                rename: rename.cloned(),
+            },
+        );
+        self
+    }
 }
 
 impl TypeSpacePatch {
@@ -535,6 +623,8 @@ impl TypeSpace {
                 schema.clone(),
             ),
 
+            TypeEntryDetails::Native(native) if native.name_match(&type_name) => type_entry,
+
             // For types that don't have names, this is effectively a type
             // alias which we treat as a newtype.
             _ => {
@@ -554,8 +644,10 @@ impl TypeSpace {
                 )
             }
         };
-        let entry_name = type_entry.name().unwrap().clone();
-        self.name_to_id.insert(entry_name, type_id.clone());
+        // TODO need a type alias?
+        if let Some(entry_name) = type_entry.name() {
+            self.name_to_id.insert(entry_name.clone(), type_id.clone());
+        }
         self.id_to_entry.insert(type_id, type_entry);
         Ok(())
     }
diff --git a/typify-impl/src/rust_extension.rs b/typify-impl/src/rust_extension.rs
new file mode 100644
index 0000000..e7a6cf5
--- /dev/null
+++ b/typify-impl/src/rust_extension.rs
@@ -0,0 +1,104 @@
+// Copyright 2024 Oxide Computer Company
+
+use log::warn;
+use schemars::schema::{Schema, SchemaObject};
+use serde::Deserialize;
+
+use crate::{type_entry::TypeEntry, CrateVers, Name, Result, TypeSpace};
+
+const RUST_TYPE_EXTENSION: &str = "x-rust-type";
+
+/// Definition of the value of the x-rust-type extension. This structure
+/// must not change incompatibly (and probably shouldn't change at all).
+#[derive(Deserialize)]
+struct RustExtension {
+    #[serde(rename = "crate")]
+    crate_name: String,
+    version: String,
+    path: String,
+    #[serde(default)]
+    parameters: Vec<Schema>,
+}
+
+impl TypeSpace {
+    pub(crate) fn convert_rust_extension(&mut self, schema: &SchemaObject) -> Option<TypeEntry> {
+        let x_rust = schema.extensions.get(RUST_TYPE_EXTENSION)?;
+
+        let Ok(RustExtension {
+            crate_name,
+            version,
+            path,
+            parameters,
+        }) = serde_json::from_value(x_rust.clone())
+        else {
+            warn!(
+                "{} contains an invalid value for {}",
+                serde_json::to_string_pretty(&schema).unwrap(),
+                RUST_TYPE_EXTENSION,
+            );
+            return None;
+        };
+
+        let Ok(req) = semver::VersionReq::parse(&version) else {
+            warn!(
+                "{} contains an invalid version",
+                serde_json::to_string_pretty(&schema).unwrap(),
+            );
+            return None;
+        };
+
+        let crate_ident = crate_name.replace('-', "_");
+        let path_sep = path.find("::")?;
+        if crate_ident != path[..path_sep] {
+            warn!(
+                "{} path doesn't start with crate name",
+                serde_json::to_string_pretty(&schema).unwrap(),
+            );
+            return None;
+        }
+
+        let path = {
+            if let Some(crate_spec) = self.settings.crates.get(crate_name.as_str()) {
+                // The version must be non-Never and match the requirements
+                // from the extension.
+                match &crate_spec.version {
+                    CrateVers::Any => (),
+                    CrateVers::Version(version) if req.matches(version) => (),
+                    _ => return None,
+                }
+
+                // Replace the initial path component with the new crate name.
+                if let Some(new_crate) = &crate_spec.rename {
+                    format!("{}{}", new_crate.replace('-', "_"), &path[path_sep..])
+                } else {
+                    path
+                }
+            } else {
+                match self.settings.unknown_crates {
+                    crate::UnknownPolicy::Generate => return None,
+                    crate::UnknownPolicy::Allow => path,
+
+                    // TODO need to bubble up a coherent compiler error via the
+                    // generated code.
+                    crate::UnknownPolicy::Deny => return None,
+                }
+            }
+        };
+
+        // Convert and collect type parameters.
+        let param_ids = parameters
+            .iter()
+            .map(|p_schema| {
+                // TODO could we have some reasonable type name? Do we need to?
+                let (param_id, _) = self.id_for_schema(Name::Unknown, p_schema)?;
+                Ok(param_id)
+            })
+            .collect::<Result<Vec<_>>>()
+            .ok()?;
+
+        Some(TypeEntry::new_native_params(
+            format!("::{path}"),
+            &param_ids,
+        ))
+    }
+}
diff --git a/typify-impl/src/type_entry.rs b/typify-impl/src/type_entry.rs
index cc85120..d45d5c8 100644
--- a/typify-impl/src/type_entry.rs
+++ b/typify-impl/src/type_entry.rs
@@ -20,16 +20,16 @@ pub(crate) struct SchemaWrapper(Schema);
 
 impl Eq for SchemaWrapper {}
 
-impl PartialOrd for SchemaWrapper {
-    fn partial_cmp(&self, _other: &Self) -> Option<std::cmp::Ordering> {
-        Some(std::cmp::Ordering::Equal)
-    }
-}
 impl Ord for SchemaWrapper {
     fn cmp(&self, _other: &Self) -> std::cmp::Ordering {
         std::cmp::Ordering::Equal
     }
 }
+impl PartialOrd for SchemaWrapper {
+    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+        Some(self.cmp(other))
+    }
+}
 
 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
 pub(crate) struct TypeEntryEnum {
@@ -89,6 +89,17 @@ pub(crate) enum TypeEntryNewtypeConstraints {
 pub(crate) struct TypeEntryNative {
     pub type_name: String,
     impls: Vec<TypeSpaceImpl>,
+    // TODO to support const generics, this can be some sort of TypeOrValue,
+    // but note that we may some day need to disambiguate char and &'static str
+    // since schemars represents a char as a string of length 1.
+    pub parameters: Vec<TypeId>,
+}
+impl TypeEntryNative {
+    pub(crate) fn name_match(&self, type_name: &Name) -> bool {
+        let native_name = self.type_name.rsplit("::").next().unwrap();
+        !self.parameters.is_empty()
+            || matches!(type_name, Name::Required(req) if req == native_name)
+    }
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -105,8 +116,8 @@ impl Ord for WrappedValue {
     }
 }
 impl PartialOrd for WrappedValue {
-    fn partial_cmp(&self, _: &Self) -> Option<std::cmp::Ordering> {
-        Some(std::cmp::Ordering::Equal)
+    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+        Some(self.cmp(other))
     }
 }
 
@@ -464,6 +475,17 @@ impl TypeEntry {
             details: TypeEntryDetails::Native(TypeEntryNative {
                 type_name: type_name.to_string(),
                 impls: impls.to_vec(),
+                parameters: Default::default(),
+            }),
+            extra_derives: Default::default(),
+        }
+    }
+    pub(crate) fn new_native_params<S: ToString>(type_name: S, params: &[TypeId]) -> Self {
+        TypeEntry {
+            details: TypeEntryDetails::Native(TypeEntryNative {
+                type_name: type_name.to_string(),
+                impls: Default::default(),
+                parameters: params.to_vec(),
             }),
             extra_derives: Default::default(),
         }
@@ -1644,17 +1666,40 @@ impl TypeEntry {
                 quote! { [#item_ident; #length]}
             }
 
+            TypeEntryDetails::Native(TypeEntryNative {
+                type_name,
+                impls: _,
+                parameters,
+            }) => {
+                let path =
+                    syn::parse_str::<syn::TypePath>(type_name).expect("type path wasn't valid");
+
+                let type_idents = (!parameters.is_empty()).then(|| {
+                    let type_idents = parameters.iter().map(|type_id| {
+                        type_space
+                            .id_to_entry
+                            .get(type_id)
+                            .expect("unresolved type id for tuple")
+                            .type_ident(type_space, type_mod)
+                    });
+                    quote! { < #(#type_idents,)* > }
+                });
+
+                quote! {
+                    #path
+                    #type_idents
+                }
+            }
+
             TypeEntryDetails::Unit => quote! { () },
             TypeEntryDetails::String => quote! { String },
             TypeEntryDetails::Boolean => quote! { bool },
             TypeEntryDetails::JsonValue => quote! { serde_json::Value },
-            TypeEntryDetails::Native(TypeEntryNative {
-                type_name: name, ..
-            })
-            | TypeEntryDetails::Integer(name)
-            | TypeEntryDetails::Float(name) => syn::parse_str::<syn::TypePath>(name)
-                .unwrap()
-                .to_token_stream(),
+            TypeEntryDetails::Integer(name) | TypeEntryDetails::Float(name) => {
+                syn::parse_str::<syn::TypePath>(name)
+                    .unwrap()
+                    .to_token_stream()
+            }
 
             TypeEntryDetails::Reference(_) => panic!("references should be resolved by now"),
         }
diff --git a/typify-macro/Cargo.toml b/typify-macro/Cargo.toml
index b491cbb..c3ab84c 100644
--- a/typify-macro/Cargo.toml
+++ b/typify-macro/Cargo.toml
@@ -14,6 +14,7 @@ proc-macro = true
 proc-macro2 = "1.0.81"
 quote = "1.0.36"
 schemars = "0.8.17"
+semver = { version = "1.0.22", features = ["serde"] }
 serde = "1.0.200"
 serde_json = "1.0.116"
 serde_tokenstream = "0.2.0"
diff --git a/typify-macro/src/lib.rs b/typify-macro/src/lib.rs
index c11d9bb..ad5e1bd 100644
--- a/typify-macro/src/lib.rs
+++ b/typify-macro/src/lib.rs
@@ -1,4 +1,4 @@
-// Copyright 2023 Oxide Computer Company
+// Copyright 2024 Oxide Computer Company
 
 //! typify macro implementation.
 
@@ -12,7 +12,7 @@ use serde::Deserialize;
 use serde_tokenstream::ParseWrapper;
 use syn::LitStr;
 use token_utils::TypeAndImpls;
-use typify_impl::{TypeSpace, TypeSpacePatch, TypeSpaceSettings};
+use typify_impl::{CrateVers, TypeSpace, TypeSpacePatch, TypeSpaceSettings, UnknownPolicy};
 
 mod token_utils;
 
@@ -37,6 +37,20 @@ mod token_utils;
 ///   method for each generated struct that can be used to specify each
 ///   property and construct the struct
 ///
+/// - `unknown_crates`: optional policy regarding the handling of schemas that
+///   contain the `x-rust-type` extension whose crates are not explicitly named
+///   in the `crates` section. The options are `generate` to ignore the
+///   extension and generate a *de novo* type, `allow` to use the named type
+///   (which may require the addition of a new dependency to compile, and which
+///   ignores version compatibility checks), or `deny` to produce a
+///   compile-time error (requiring the user to specify the crate's disposition
+///   in the `crates` section).
+///
+/// - `crates`: optional map from crate name to the version of the crate in
+///   use. Types encountered with the Rust type extension (`x-rust-type`) will
+///   use types from the specified crates rather than generating them (within
+///   the constraints of type compatibility).
+///
 /// - `patch`: optional map from type to an object with the optional members
 ///   `rename` and `derives`. This may be used to renamed generated types or
 ///   to apply additional (non-default) derive macros to them.
@@ -64,6 +78,11 @@ struct MacroSettings {
     #[serde(default)]
     struct_builder: bool,
 
+    #[serde(default)]
+    unknown_crates: UnknownPolicy,
+    #[serde(default)]
+    crates: HashMap<CrateName, MacroCrateSpec>,
+
     #[serde(default)]
     patch: HashMap<ParseWrapper<syn::Ident>, MacroPatch>,
     #[serde(default)]
@@ -73,6 +92,68 @@ struct MacroSettings {
         serde_tokenstream::OrderedMap<schemars::schema::SchemaObject, ParseWrapper<TypeAndImpls>>,
 }
 
+struct MacroCrateSpec {
+    original: Option<String>,
+    version: CrateVers,
+}
+
+impl<'de> Deserialize<'de> for MacroCrateSpec {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let ss = String::deserialize(deserializer)?;
+
+        let (original, vers_str) = if let Some(ii) = ss.find('@') {
+            let original_str = &ss[..ii];
+            let rest = &ss[ii + 1..];
+            if !is_crate(original_str) {
+                return Err(<D::Error as serde::de::Error>::invalid_value(
+                    serde::de::Unexpected::Str(&ss),
+                    &"valid crate name",
+                ));
+            }
+
+            (Some(original_str.to_string()), rest)
+        } else {
+            (None, ss.as_ref())
+        };
+
+        let Some(version) = CrateVers::parse(vers_str) else {
+            return Err(<D::Error as serde::de::Error>::invalid_value(
+                serde::de::Unexpected::Str(&ss),
+                &"valid version",
+            ));
+        };
+
+        Ok(Self { original, version })
+    }
+}
+
+#[derive(Hash, PartialEq, Eq)]
+struct CrateName(String);
+impl<'de> Deserialize<'de> for CrateName {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let ss = String::deserialize(deserializer)?;
+
+        if is_crate(&ss) {
+            Ok(Self(ss))
+        } else {
+            Err(<D::Error as serde::de::Error>::invalid_value(
+                serde::de::Unexpected::Str(&ss),
+                &"valid crate name",
+            ))
+        }
+    }
+}
+
+fn is_crate(s: &str) -> bool {
+    !s.contains(|cc: char| !cc.is_alphanumeric() && cc != '_' && cc != '-')
+}
+
 #[derive(Deserialize)]
 struct MacroPatch {
     #[serde(default)]
@@ -106,6 +187,8 @@ fn do_import_types(item: TokenStream) -> Result<TokenStream, syn::Error> {
             patch,
             struct_builder,
             convert,
+            unknown_crates,
+            crates,
         } = serde_tokenstream::from_tokenstream(&item.into())?;
         let mut settings = TypeSpaceSettings::default();
         derives.into_iter().for_each(|derive| {
@@ -125,6 +208,17 @@ fn do_import_types(item: TokenStream) -> Result<TokenStream, syn::Error> {
             settings.with_conversion(schema, type_name, impls);
         });
 
+        crates.into_iter().for_each(
+            |(CrateName(crate_name), MacroCrateSpec { original, version })| {
+                if let Some(original_crate) = original {
+                    settings.with_crate(original_crate, version, Some(&crate_name));
+                } else {
+                    settings.with_crate(crate_name, version, None);
+                }
+            },
+        );
+        settings.with_unknown_crates(unknown_crates);
+
         (schema.into_inner(), settings)
     };
 
diff --git a/typify/src/lib.rs b/typify/src/lib.rs
index ae39d39..28ef02a 100644
--- a/typify/src/lib.rs
+++ b/typify/src/lib.rs
@@ -1,4 +1,4 @@
-// Copyright 2023 Oxide Computer Company
+// Copyright 2024 Oxide Computer Company
 
 //! # Typify
 //!
@@ -156,6 +156,7 @@
 
 #![deny(missing_docs)]
 
+pub use typify_impl::CrateVers;
 pub use typify_impl::Error;
 pub use typify_impl::Type;
 pub use typify_impl::TypeDetails;
@@ -169,5 +170,6 @@ pub use typify_impl::TypeSpacePatch;
 pub use typify_impl::TypeSpaceSettings;
 pub use typify_impl::TypeStruct;
 pub use typify_impl::TypeStructPropInfo;
+pub use typify_impl::UnknownPolicy;
 #[cfg(feature = "macro")]
 pub use typify_macro::import_types;


Test patch:
diff --git a/cargo-typify/tests/outputs/help.txt b/cargo-typify/tests/outputs/help.txt
index d02307a..7417216 100644
--- a/cargo-typify/tests/outputs/help.txt
+++ b/cargo-typify/tests/outputs/help.txt
@@ -21,6 +21,14 @@ Options:
           
           If `-` is specified, the output will be written to stdout.
 
+      --crate <CRATES>
+          Specify each crate@version that can be assumed to be in use for types found in the schema with the x-rust-type extension
+
+      --unknown-crates <UNKNOWN_CRATES>
+          Specify the policy unknown crates found in schemas with the x-rust-type extension
+          
+          [possible values: generate, allow, deny]
+
   -h, --help
           Print help (see a summary with '-h')
 
diff --git a/typify-impl/tests/test_github.rs b/typify-impl/tests/test_github.rs
index d4ff0e9..dd14820 100644
--- a/typify-impl/tests/test_github.rs
+++ b/typify-impl/tests/test_github.rs
@@ -29,7 +29,7 @@ fn test_github() {
 fn test_vega() {
     env_logger::init();
     let mut settings = TypeSpaceSettings::default();
-    let raw_schema = serde_json::json! {
+    let raw_schema = serde_json::json!(
         {
             "enum": [
               null,
@@ -57,7 +57,7 @@ fn test_vega() {
               900
             ]
           }
-    };
+    );
     let schema = serde_json::from_value(raw_schema).unwrap();
     settings
         .with_conversion(schema, "MyEnum", [TypeSpaceImpl::FromStr].into_iter())
diff --git a/typify/tests/schemas.rs b/typify/tests/schemas.rs
index 3854d6e..751aa84 100644
--- a/typify/tests/schemas.rs
+++ b/typify/tests/schemas.rs
@@ -1,4 +1,4 @@
-// Copyright 2023 Oxide Computer Company
+// Copyright 2024 Oxide Computer Company
 
 use std::{error::Error, fs::File, io::BufReader};
 
@@ -32,11 +32,11 @@ fn validate_schema(path: std::path::PathBuf) -> Result<(), Box<dyn Error>> {
     // Read the JSON contents of the file as an instance of `User`.
     let root_schema: RootSchema = serde_json::from_reader(reader)?;
 
-    let schema_raw = json! {
+    let schema_raw = json!(
         {
             "enum": [ 1, "one" ]
         }
-    };
+    );
     let schema = serde_json::from_value(schema_raw).unwrap();
 
     let mut type_space = TypeSpace::new(
@@ -57,6 +57,13 @@ fn validate_schema(path: std::path::PathBuf) -> Result<(), Box<dyn Error>> {
                 schema,
                 "serde_json::Value",
                 [TypeSpaceImpl::Display].into_iter(),
+            )
+            // Our test use of the x-rust-type extension only refers to things
+            // in std.
+            .with_crate(
+                "std",
+                typify::CrateVers::Version("1.0.0".parse().unwrap()),
+                None,
             ),
     );
     type_space.add_root_schema(root_schema)?;
diff --git a/typify/tests/schemas/x-rust-type.json b/typify/tests/schemas/x-rust-type.json
new file mode 100644
index 0000000..139d379
--- /dev/null
+++ b/typify/tests/schemas/x-rust-type.json
@@ -0,0 +1,42 @@
+{
+  "$schema": "https://json-schema.org/draft-07/schema",
+  "$comment": "this test makes weird use of types from std to avoid requiring other dependencies",
+  "$defs": {
+    "AllTheThings": {
+      "type": "object",
+      "properties": {
+        "path": {
+          "$ref": "#/$defs/PathBuf"
+        },
+        "option_marker": {
+          "$ref": "#/$defs/OptionMarker"
+        }
+      }
+    },
+    "PathBuf": {
+      "type": "string",
+      "x-rust-type": {
+        "crate": "std",
+        "version": "1.0.0",
+        "path": "std::path::PathBuf"
+      }
+    },
+    "OptionMarker": {
+      "$comment": "this is silly, but shows type parameters",
+      "type": "null",
+      "x-rust-type": {
+        "crate": "std",
+        "version": "1.0.0",
+        "path": "std::option::Option",
+        "parameters": [
+          {
+            "$ref": "#/$defs/Marker"
+          }
+        ]
+      }
+    },
+    "Marker": {
+      "not": true
+    }
+  }
+}
\ No newline at end of file
diff --git a/typify/tests/schemas/x-rust-type.rs b/typify/tests/schemas/x-rust-type.rs
new file mode 100644
index 0000000..3d21c09
--- /dev/null
+++ b/typify/tests/schemas/x-rust-type.rs
@@ -0,0 +1,75 @@
+#[allow(unused_imports)]
+use serde::{Deserialize, Serialize};
+#[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 = "AllTheThings"]
+#[doc = r""]
+#[doc = r" <details><summary>JSON schema</summary>"]
+#[doc = r""]
+#[doc = r" ```json"]
+#[doc = "{"]
+#[doc = "  \"type\": \"object\","]
+#[doc = "  \"properties\": {"]
+#[doc = "    \"option_marker\": {"]
+#[doc = "      \"$ref\": \"#/$defs/OptionMarker\""]
+#[doc = "    },"]
+#[doc = "    \"path\": {"]
+#[doc = "      \"$ref\": \"#/$defs/PathBuf\""]
+#[doc = "    }"]
+#[doc = "  }"]
+#[doc = "}"]
+#[doc = r" ```"]
+#[doc = r" </details>"]
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct AllTheThings {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub option_marker: Option<::std::option::Option<Marker>>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub path: Option<::std::path::PathBuf>,
+}
+impl From<&AllTheThings> for AllTheThings {
+    fn from(value: &AllTheThings) -> Self {
+        value.clone()
+    }
+}
+#[doc = "Marker"]
+#[doc = r""]
+#[doc = r" <details><summary>JSON schema</summary>"]
+#[doc = r""]
+#[doc = r" ```json"]
+#[doc = "false"]
+#[doc = r" ```"]
+#[doc = r" </details>"]
+#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
+#[serde(deny_unknown_fields)]
+pub enum Marker {}
+impl From<&Marker> for Marker {
+    fn from(value: &Marker) -> Self {
+        value.clone()
+    }
+}
+fn main() {}