From 1165a71bc7a7261d8468aa134863767c6acdb900 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Fri, 16 Oct 2026 21:08:23 +0100 Subject: [PATCH 1/2] fix: preserve array minimums and scalar format metadata --- packages/forge/README.md | 11 +++ packages/forge/plugin/forge.wit | 12 ++- packages/forge/src/index.ts | 26 ++++-- packages/forge/test/fixtures/metadata-host.js | 31 +++++++ packages/forge/test/plugin.test.js | 56 +++++++++++ packages/forge/test/types.ts | 20 +++- src/bin/forge/ts/codec.rs | 6 +- src/bin/forge/ts/types.rs | 6 +- src/lib.rs | 4 +- src/link.rs | 48 ++++++---- src/link/openapi.rs | 39 ++++++-- src/plugin/wasm.rs | 60 +++++++----- src/types.rs | 61 +++++++++++- tests/parse/common.rs | 6 +- tests/parse/requests.rs | 2 +- tests/parse/types/arrays.rs | 53 +++++++++++ tests/parse/types/scalars.rs | 93 ++++++++++++++++++- tests/wasm.rs | 21 ++++- tests/wasm/dump.rs | 16 +++- tests/wasm/ts/src/lib.rs | 89 +++++++++++++----- tests/wasm/ts/wit/forge.wit | 12 ++- 21 files changed, 565 insertions(+), 107 deletions(-) create mode 100644 packages/forge/test/fixtures/metadata-host.js create mode 100644 packages/forge/test/plugin.test.js diff --git a/packages/forge/README.md b/packages/forge/README.md index 239bcad10..a8d165139 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -78,6 +78,17 @@ by `kind`, so a `never` check in a `switch` turns a kind you missed into a compile error. The same group, method or model is always the same object, so you can compare them with `===` and keep them in a `Map`. +Array types expose `minItems` and `maxItems`; an absent bound is +`undefined`, and an explicit zero is kept. Scalar types expose the original +`format`, including custom formats such as `uuid`, separately from their +classification (`scalar: 'string'` for a UUID). These are metadata for +plugins to interpret; Forge does not validate request values. + +The plugin interface is `forge:plugin@0.3.0`. Rebuild plugins compiled +against `0.2.0` with the updated package before running them with this +version of Forge. In Rust, `Type::Scalar` now contains a `ScalarType`: use +`kind()` for its `Scalar` classification and `format()` for the annotation. + A plugin fails by throwing; forge stops and prints the message. Its files go through the same checks as forge's own: a path must be relative, use `/`, have no `.` or `..` parts, and no two files may share one. diff --git a/packages/forge/plugin/forge.wit b/packages/forge/plugin/forge.wit index fc75f5614..c82d07ea8 100644 --- a/packages/forge/plugin/forge.wit +++ b/packages/forge/plugin/forge.wit @@ -1,5 +1,5 @@ -package forge:plugin@0.2.0; +package forge:plugin@0.3.0; /// Read-only access to the forge a plugin runs on, which the host keeps. /// Each id is an index the host gave out. An id the host never gave out @@ -131,6 +131,12 @@ interface forge { unknown, null, boolean, %string, number(number), date-time, date, bytes, binary, } + record scalar-info { + kind: scalar, + /// The original `format`, including an empty or unrecognized one. + format: option, + } + variant enum-value { %string(string), number(f64), @@ -154,6 +160,8 @@ interface forge { record array-info { elem: type-id, + /// The `minItems`, or none if it is absent; zero is kept. + min-items: option, /// The `maxItems`, or none if the array has no limit. max-items: option, } @@ -178,7 +186,7 @@ interface forge { } variant type-info { - scalar(scalar), + scalar(scalar-info), %enum(list), array(array-info), object(object-info), diff --git a/packages/forge/src/index.ts b/packages/forge/src/index.ts index 0fd8da742..934de2293 100644 --- a/packages/forge/src/index.ts +++ b/packages/forge/src/index.ts @@ -32,8 +32,8 @@ // classes behind them stay private, so the bindings never show in // index.d.ts. -import * as host from 'forge:plugin/forge@0.2.0'; -import type * as hostPlugin from 'forge:plugin/plugin@0.2.0'; +import * as host from 'forge:plugin/forge@0.3.0'; +import type * as hostPlugin from 'forge:plugin/plugin@0.3.0'; // --------------------------------------------------------------------------- // The API. @@ -291,12 +291,20 @@ export interface Field { export type Type = ScalarType | EnumType | ArrayType | ObjectType | UnionType | IntersectionType | ModelType; /** A primitive, or any value (`unknown`). */ -export type ScalarType = +export type ScalarType = { + /** + * The original `format`, including an empty or unrecognized one, or + * undefined if absent. The scalar classification is unchanged: a UUID + * is `string`, for example. Forge does not validate values. + */ + readonly format: string | undefined; +} & ( | { readonly kind: 'scalar'; readonly scalar: 'unknown' | 'null' | 'boolean' | 'string' | 'date-time' | 'date' | 'bytes' | 'binary'; } - | { readonly kind: 'scalar'; readonly scalar: 'number'; readonly number: NumberFormat }; + | { readonly kind: 'scalar'; readonly scalar: 'number'; readonly number: NumberFormat } +); /** The size of a number, from its `format`. `int` and `double` have none. */ export type NumberFormat = @@ -322,6 +330,8 @@ export interface EnumType { export interface ArrayType { readonly kind: 'array'; readonly elem: Type; + /** The fewest elements, `minItems`, or undefined if absent. An explicit zero is kept. */ + readonly minItems: number | undefined; /** The most elements the array can have, `maxItems`, or undefined if it has no limit. */ readonly maxItems: number | undefined; } @@ -959,13 +969,14 @@ function makeType(forge: ForgeImpl, id: host.TypeId): Type { const type = (ty: host.TypeId) => forge.type(ty); switch (t.tag) { case 'scalar': - return t.val.tag === 'number' - ? { kind: 'scalar', scalar: 'number', number: t.val.val } - : { kind: 'scalar', scalar: t.val.tag }; + return t.val.kind.tag === 'number' + ? { kind: 'scalar', scalar: 'number', number: t.val.kind.val, format: opt(t.val.format) } + : { kind: 'scalar', scalar: t.val.kind.tag, format: opt(t.val.format) }; case 'enum': return { kind: 'enum', values: t.val.map((value) => value.val) }; case 'array': { const { elem } = t.val; + const minItems = opt(t.val.minItems); const maxItems = opt(t.val.maxItems); return { kind: 'array', @@ -973,6 +984,7 @@ function makeType(forge: ForgeImpl, id: host.TypeId): Type { return type(elem); }, // A u64 comes as a bigint; no array's limit needs more than a number. + minItems: minItems === undefined ? undefined : Number(minItems), maxItems: maxItems === undefined ? undefined : Number(maxItems), }; } diff --git a/packages/forge/test/fixtures/metadata-host.js b/packages/forge/test/fixtures/metadata-host.js new file mode 100644 index 000000000..b9fb5ba52 --- /dev/null +++ b/packages/forge/test/fixtures/metadata-host.js @@ -0,0 +1,31 @@ +// The WIT records returned by a host, including QuickJS's null options and +// the bigint values used for u64 bounds. The Rust wasm tests exercise the +// real host; this fixture checks the JavaScript projection of its records. +const types = [ + { tag: 'array', val: { elem: 3, minItems: null, maxItems: null } }, + { tag: 'array', val: { elem: 4, minItems: 0n, maxItems: 50n } }, + { tag: 'array', val: { elem: 4, minItems: 1n, maxItems: 50n } }, + { tag: 'scalar', val: { kind: { tag: 'string' }, format: null } }, + { tag: 'scalar', val: { kind: { tag: 'string' }, format: 'uuid' } }, + { tag: 'scalar', val: { kind: { tag: 'string' }, format: '' } }, + { tag: 'scalar', val: { kind: { tag: 'string' }, format: 'custom-ID' } }, + { tag: 'scalar', val: { kind: { tag: 'date-time' }, format: 'date-time' } }, + { tag: 'scalar', val: { kind: { tag: 'bytes' }, format: undefined } }, + { tag: 'scalar', val: { kind: { tag: 'number', val: 'int32' }, format: 'int32' } }, +]; + +export function info() { + return { version: '1', description: '', baseUrl: '' }; +} + +export function models() { + return types.map((_, id) => id); +} + +export function getModel(id) { + return { schemaName: `Type${id}`, name: [`type${id}`], description: '', deprecated: false, group: undefined, ty: id }; +} + +export function getType(id) { + return types[id]; +} diff --git a/packages/forge/test/plugin.test.js b/packages/forge/test/plugin.test.js new file mode 100644 index 000000000..6be7ee400 --- /dev/null +++ b/packages/forge/test/plugin.test.js @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { test } from 'node:test'; + +// Supply the imported host interface while using the built plugin library +// unchanged, just as a component receives that interface from Forge. +const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + return nextResolve( + specifier === 'forge:plugin/forge@0.3.0' + ? new URL('./fixtures/metadata-host.js', import.meta.url).href + : specifier, + context, + ); + }, +}); +const { definePlugin } = await import('../plugin/index.js'); +hooks.deregister(); + +test('array bounds distinguish absent, zero and one through the plugin API', () => { + definePlugin({ + generate(forge) { + const [absent, zero, one] = forge.models.map((model) => model.type); + assert.equal(absent.kind, 'array'); + assert.equal(absent.minItems, undefined); + assert.equal(absent.maxItems, undefined); + assert.equal(zero.minItems, 0); + assert.equal(zero.maxItems, 50); + assert.equal(one.minItems, 1); + assert.equal(one.maxItems, 50); + assert.equal(one.elem.scalar, 'string'); + assert.equal(one.elem.format, 'uuid'); + return []; + }, + }).generate(); +}); + +test('scalar formats preserve their spelling and the existing classifications', () => { + definePlugin({ + generate(forge) { + assert.deepEqual( + forge.models.slice(3).map((model) => model.type), + [ + { kind: 'scalar', scalar: 'string', format: undefined }, + { kind: 'scalar', scalar: 'string', format: 'uuid' }, + { kind: 'scalar', scalar: 'string', format: '' }, + { kind: 'scalar', scalar: 'string', format: 'custom-ID' }, + { kind: 'scalar', scalar: 'date-time', format: 'date-time' }, + { kind: 'scalar', scalar: 'bytes', format: undefined }, + { kind: 'scalar', scalar: 'number', number: 'int32', format: 'int32' }, + ], + ); + return []; + }, + }).generate(); +}); diff --git a/packages/forge/test/types.ts b/packages/forge/test/types.ts index ff2a57d17..bfd4b939a 100644 --- a/packages/forge/test/types.ts +++ b/packages/forge/test/types.ts @@ -3,6 +3,24 @@ // the types must reject, and fails the test if they accept it. import { type File, type Ident, type Module, type Part, file, frag, ident, join, module } from '../plugin/ts.js'; +import type { Type } from '../plugin/index.js'; + +function metadata(type: Type): number | string | undefined { + if (type.kind === 'array') { + const min: number | undefined = type.minItems; + const max: number | undefined = type.maxItems; + // @ts-expect-error Metadata is read-only. + type.minItems = 0; + return min ?? max; + } + if (type.kind === 'scalar') { + const format: string | undefined = type.format; + // @ts-expect-error Metadata is read-only. + type.format = 'uuid'; + return format; + } + return undefined; +} const f: File = file('a.ts'); const m: Module = module('m.ts'); @@ -66,4 +84,4 @@ f.module = m; // @ts-expect-error Nor can what it printed be reached. f.body; -export { printed, parts, notModule, notIdent, typeOnly, dir, fn, bound, path, content }; +export { metadata, printed, parts, notModule, notIdent, typeOnly, dir, fn, bound, path, content }; diff --git a/src/bin/forge/ts/codec.rs b/src/bin/forge/ts/codec.rs index 43a8fe71b..f689d47e5 100644 --- a/src/bin/forge/ts/codec.rs +++ b/src/bin/forge/ts/codec.rs @@ -52,7 +52,7 @@ pub(super) fn model_codec(f: &mut File, m: Model<'_>) { /// since it is built after the models it uses. pub(super) fn codec(t: Type<'_>, direct: bool) -> Fragment { match t { - Type::Scalar(scalar) => match scalar_codec(scalar) { + Type::Scalar(scalar) => match scalar_codec(scalar.kind()) { Some(id) => frag!(id), None => frag!(), }, @@ -189,7 +189,7 @@ fn discriminated(t: Type<'_>) -> Option> { /// at, whose codecs are needed only for what else is reached. pub(super) fn needs_codec<'f>(t: Type<'f>, seen: &mut HashSet>) -> bool { match t { - Type::Scalar(scalar) => scalar_codec(scalar).is_some(), + Type::Scalar(scalar) => scalar_codec(scalar.kind()).is_some(), Type::Model(m) => seen.insert(m) && needs_codec(m.ty(), seen), Type::Array(array) => needs_codec(array.elem(), seen), Type::Object(object) => { @@ -247,7 +247,7 @@ fn target_codec<'f>(m: Model<'f>, visiting: &mut HashSet>) -> Ident { /// intersection that needs a codec. fn single_codec<'f>(t: Type<'f>, visiting: &mut HashSet>) -> Option { let members: Vec> = match t { - Type::Scalar(scalar) => return scalar_codec(scalar), + Type::Scalar(scalar) => return scalar_codec(scalar.kind()), Type::Model(m) => { return needs_codec(t, &mut HashSet::new()).then(|| target_codec(m, visiting)); } diff --git a/src/bin/forge/ts/types.rs b/src/bin/forge/ts/types.rs index 05970d82e..0cdf63520 100644 --- a/src/bin/forge/ts/types.rs +++ b/src/bin/forge/ts/types.rs @@ -21,7 +21,7 @@ pub(super) enum Prec { /// ``` pub(super) fn typ(t: Type<'_>, prec: Prec) -> Fragment { match t { - Type::Scalar(scalar) => frag!(scalar_type(scalar)), + Type::Scalar(scalar) => frag!(scalar_type(scalar.kind())), Type::Model(m) => frag!(Ident::model(m).ty()), Type::Array(array) => frag!(typ(array.elem(), Prec::Array), "[]"), Type::Object(object) => object_type(object), @@ -138,7 +138,7 @@ pub(super) fn fields(object: Object<'_>) -> Vec> { /// Returns the type of the values of a map, which is unknown when the map /// does not say. pub(super) fn elem(object: Object<'_>) -> Type<'_> { - object.extra().unwrap_or(Type::Scalar(Scalar::Unknown)) + object.extra().unwrap_or(Type::Scalar(Scalar::Unknown.into())) } /// Reports whether `t` is an object type with a property the SDK keeps. An @@ -171,7 +171,7 @@ pub(super) fn all_optional(t: Type<'_>) -> bool { fn walk<'f>(t: Type<'f>, seen: &mut HashSet>) -> bool { match t { Type::Object(object) => !object.fields().any(|f| kept(f) && f.is_required()), - Type::Scalar(Scalar::Unknown) => true, + Type::Scalar(scalar) if scalar.kind() == Scalar::Unknown => true, Type::Model(m) => seen.insert(m) && walk(m.ty(), seen), Type::Intersection(intersection) => intersection.members().all(|m| walk(m, seen)), Type::Union(union) => union.members().any(|m| walk(m, seen)), diff --git a/src/lib.rs b/src/lib.rs index 55e1611d5..b39bd7ae0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,7 +15,9 @@ pub use name::Name; #[cfg(feature = "wasm")] pub use plugin::wasm; pub use plugin::{Err, File, Plugin, run, ts, write}; -pub use types::{Array, Discriminator, Enum, EnumValue, Field, Intersection, Number, Object, Scalar, Type, Union}; +pub use types::{ + Array, Discriminator, Enum, EnumValue, Field, Intersection, Number, Object, Scalar, ScalarType, Type, Union, +}; use arena::{List, Span, Str, Strings}; use method::{BodyData, ResponseData}; diff --git a/src/link.rs b/src/link.rs index 0255688c2..cabcce604 100644 --- a/src/link.rs +++ b/src/link.rs @@ -22,7 +22,7 @@ use crate::arena::{List, Span, Str, Strings}; use crate::json::Json; use crate::method::{BodyData, ContentData, ResponseData, is_json}; use crate::types::{ - ArrayData, DiscriminatorData, EnumData, FieldData, NameId, ObjectData, TypeData, TypeId, UnionData, + ArrayData, DiscriminatorData, EnumData, FieldData, NameId, ObjectData, ScalarData, TypeData, TypeId, UnionData, }; use crate::{ Availability, EnumValue, Forge, GroupData, GroupId, HttpMethod, Location, MethodData, MethodId, ModelData, ModelId, @@ -403,8 +403,8 @@ struct Builder<'d> { /// Each method by group and name. method_index: HashMap<(GroupId, Name), MethodId>, types: Vec, - /// The type of each scalar, which every use shares. - scalars: Vec<(Scalar, TypeId)>, + /// The type of each scalar and format, which every matching use shares. + scalars: HashMap<(Scalar, Option<&'d str>), TypeId>, models: Vec, /// The type of each model, indexed by `ModelId`, which every use /// shares. @@ -461,7 +461,7 @@ impl<'d> Builder<'d> { methods: Vec::new(), method_index: HashMap::new(), types: Vec::new(), - scalars: Vec::new(), + scalars: HashMap::new(), models: Vec::new(), model_types: Vec::new(), model_index: HashMap::new(), @@ -1529,8 +1529,10 @@ impl<'d> Builder<'d> { /// Returns the type of `schema` as the one type `ty`. fn single(&mut self, schema: &'d SchemaObject, ty: SchemaType) -> TypeId { + let format = schema.format.map(|s| self.doc.str(s)); + let recognized = Format::from_str(format.unwrap_or_default()); let scalar = match ty { - SchemaType::String => match schema.format { + SchemaType::String => match recognized { Format::DateTime => Scalar::DateTime, Format::Date => Scalar::Date, Format::Byte => Scalar::Bytes, @@ -1538,7 +1540,7 @@ impl<'d> Builder<'d> { _ if schema.content_encoding == ContentEncoding::Base64 => Scalar::Bytes, _ => Scalar::String, }, - SchemaType::Integer | SchemaType::Number => Scalar::Number(match schema.format { + SchemaType::Integer | SchemaType::Number => Scalar::Number(match recognized { Format::Int8 => Number::Int8, Format::Int16 => Number::Int16, Format::Int32 => Number::Int32, @@ -1557,7 +1559,7 @@ impl<'d> Builder<'d> { SchemaType::Array => return self.array(schema), SchemaType::Object => return self.object(schema), }; - self.scalar(scalar) + self.formatted_scalar(scalar, format) } fn array(&mut self, schema: &'d SchemaObject) -> TypeId { @@ -1567,6 +1569,7 @@ impl<'d> Builder<'d> { }; self.push(TypeData::Array(ArrayData { elem, + min_items: schema.min_items, max_items: schema.max_items, })) } @@ -1797,7 +1800,7 @@ impl<'d> Builder<'d> { /// already there, to `values` if it is an enum, or to `null`. fn add_member(&self, member: TypeId, out: &mut Vec, values: &mut Vec, null: &mut bool) { match &self.types[member.index()] { - TypeData::Scalar(Scalar::Null) => *null = true, + TypeData::Scalar(ScalarData { kind: Scalar::Null, .. }) => *null = true, TypeData::Enum(enum_values) => { for &value in self.values.get(*enum_values) { let new = value.get(&self.strings); @@ -2112,22 +2115,35 @@ impl<'d> Builder<'d> { /// Returns `ty`, or the union of it and null if `nullable`. fn nullable(&mut self, ty: TypeId, nullable: bool) -> TypeId { let data = &self.types[ty.index()]; - if !nullable || matches!(data, TypeData::Scalar(Scalar::Unknown | Scalar::Null)) { + if !nullable + || matches!( + data, + TypeData::Scalar(ScalarData { + kind: Scalar::Unknown | Scalar::Null, + .. + }) + ) + { return ty; } let null = self.scalar(Scalar::Null); self.union_of(&[ty, null], None, true) } - /// Returns the type of `scalar`, which every use shares. + /// Returns the type of `scalar` without a format. fn scalar(&mut self, scalar: Scalar) -> TypeId { - for &(s, id) in &self.scalars { - if s == scalar { - return id; - } + self.formatted_scalar(scalar, None) + } + + /// Shares scalars only when both classification and format match. + fn formatted_scalar(&mut self, kind: Scalar, format: Option<&'d str>) -> TypeId { + let key = (kind, format); + if let Some(&id) = self.scalars.get(&key) { + return id; } - let id = self.push(TypeData::Scalar(scalar)); - self.scalars.push((scalar, id)); + let format = format.map(|s| self.strings.push(s)); + let id = self.push(TypeData::Scalar(ScalarData { kind, format })); + self.scalars.insert(key, id); id } diff --git a/src/link/openapi.rs b/src/link/openapi.rs index 51f1b3767..2b698e357 100644 --- a/src/link/openapi.rs +++ b/src/link/openapi.rs @@ -352,7 +352,7 @@ pub(super) struct SchemaObject { /// The types the schema allows: one for a string, any number for an /// array, and none without `type`. pub(super) types: Span, - pub(super) format: Format, + pub(super) format: Option, pub(super) content_encoding: ContentEncoding, pub(super) nullable: bool, /// The values of `enum`, or none without it. An empty `enum`, which @@ -368,6 +368,8 @@ pub(super) struct SchemaObject { pub(super) unevaluated_properties: Schema, pub(super) required: Span, pub(super) items: Option, + /// The `minItems`, or `None` without it. + pub(super) min_items: Option, /// The `maxItems`, or `None` without it. pub(super) max_items: Option, /// Whether the schema has `prefixItems`: it is a tuple, which is not @@ -421,10 +423,9 @@ pub(super) enum SchemaType { Null, } -/// A `format` in a Schema Object that decides a type. Every other format, -/// and no format, is `Other`. -#[derive(Deserialize, Default, Clone, Copy, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] +/// A recognized `format` that decides a type. The original text is kept +/// separately, including formats that do not change the classification. +#[derive(Clone, Copy, PartialEq, Eq)] pub(super) enum Format { DateTime, Date, @@ -440,11 +441,31 @@ pub(super) enum Format { Uint64, Float, Double, - #[default] - #[serde(other)] Other, } +impl Format { + pub(super) fn from_str(format: &str) -> Self { + match format { + "date-time" => Self::DateTime, + "date" => Self::Date, + "byte" => Self::Byte, + "binary" => Self::Binary, + "int8" => Self::Int8, + "int16" => Self::Int16, + "int32" => Self::Int32, + "int64" => Self::Int64, + "uint8" => Self::Uint8, + "uint16" => Self::Uint16, + "uint32" => Self::Uint32, + "uint64" => Self::Uint64, + "float" => Self::Float, + "double" => Self::Double, + _ => Self::Other, + } + } +} + /// A `contentEncoding` in a Schema Object. Every encoding but base64, and /// no encoding, is `Other`. #[derive(Deserialize, Default, Clone, Copy, PartialEq, Eq)] @@ -1602,6 +1623,7 @@ keys! { UnevaluatedProperties = "unevaluatedProperties", Required = "required", Items = "items", + MinItems = "minItems", MaxItems = "maxItems", PrefixItems = "prefixItems", AllOf = "allOf", @@ -1634,7 +1656,7 @@ impl ReadObject for SchemaObject { match key { SchemaKey::Ref => schema.reference = map.next_value_seed(seed::(doc))?, SchemaKey::Type => schema.types = map.next_value_seed(seed::(doc))?, - SchemaKey::Format => schema.format = map.next_value()?, + SchemaKey::Format => schema.format = Some(map.next_value_seed(seed::(doc))?), SchemaKey::ContentEncoding => schema.content_encoding = map.next_value()?, SchemaKey::Nullable => schema.nullable = map.next_value()?, SchemaKey::Enum => schema.values = map.next_value_seed(seed::>(doc))?, @@ -1654,6 +1676,7 @@ impl ReadObject for SchemaObject { schema.required = map.next_value_seed(SeqOfStrsSeed(doc))?; } SchemaKey::Items => schema.items = map.next_value_seed(seed::>(doc))?, + SchemaKey::MinItems => schema.min_items = Some(map.next_value::()?.0), SchemaKey::MaxItems => schema.max_items = Some(map.next_value::()?.0), SchemaKey::PrefixItems => schema.prefix_items = present(&mut map)?, SchemaKey::AllOf => schema.all_of = map.next_value_seed(seed::>(doc))?, diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs index 9223b019c..bae70cbca 100644 --- a/src/plugin/wasm.rs +++ b/src/plugin/wasm.rs @@ -60,7 +60,7 @@ macro_rules! wit { wit!( r#" -package forge:plugin@0.2.0; +package forge:plugin@0.3.0; /// Read-only access to the forge a plugin runs on, which the host keeps. /// Each id is an index the host gave out. An id the host never gave out @@ -192,6 +192,12 @@ interface forge { unknown, null, boolean, %string, number(number), date-time, date, bytes, binary, } + record scalar-info { + kind: scalar, + /// The original `format`, including an empty or unrecognized one. + format: option, + } + variant enum-value { %string(string), number(f64), @@ -215,6 +221,8 @@ interface forge { record array-info { elem: type-id, + /// The `minItems`, or none if it is absent; zero is kept. + min-items: option, /// The `maxItems`, or none if the array has no limit. max-items: option, } @@ -239,7 +247,7 @@ interface forge { } variant type-info { - scalar(scalar), + scalar(scalar-info), %enum(list), array(array-info), object(object-info), @@ -544,28 +552,31 @@ impl wit::Host for State { let forge = self.forge()?; let s = |s: Str| forge.strings.get(s).to_owned(); Ok(match get(&forge.types, id, "type")? { - TypeData::Scalar(scalar) => wit::TypeInfo::Scalar(match *scalar { - Scalar::Unknown => wit::Scalar::Unknown, - Scalar::Null => wit::Scalar::Null, - Scalar::Boolean => wit::Scalar::Boolean, - Scalar::String => wit::Scalar::String, - Scalar::Number(number) => wit::Scalar::Number(match number { - Number::Int => wit::Number::Int, - Number::Int8 => wit::Number::Int8, - Number::Int16 => wit::Number::Int16, - Number::Int32 => wit::Number::Int32, - Number::Int64 => wit::Number::Int64, - Number::Uint8 => wit::Number::Uint8, - Number::Uint16 => wit::Number::Uint16, - Number::Uint32 => wit::Number::Uint32, - Number::Uint64 => wit::Number::Uint64, - Number::Float => wit::Number::Float, - Number::Double => wit::Number::Double, - }), - Scalar::DateTime => wit::Scalar::DateTime, - Scalar::Date => wit::Scalar::Date, - Scalar::Bytes => wit::Scalar::Bytes, - Scalar::Binary => wit::Scalar::Binary, + TypeData::Scalar(scalar) => wit::TypeInfo::Scalar(wit::ScalarInfo { + kind: match scalar.kind { + Scalar::Unknown => wit::Scalar::Unknown, + Scalar::Null => wit::Scalar::Null, + Scalar::Boolean => wit::Scalar::Boolean, + Scalar::String => wit::Scalar::String, + Scalar::Number(number) => wit::Scalar::Number(match number { + Number::Int => wit::Number::Int, + Number::Int8 => wit::Number::Int8, + Number::Int16 => wit::Number::Int16, + Number::Int32 => wit::Number::Int32, + Number::Int64 => wit::Number::Int64, + Number::Uint8 => wit::Number::Uint8, + Number::Uint16 => wit::Number::Uint16, + Number::Uint32 => wit::Number::Uint32, + Number::Uint64 => wit::Number::Uint64, + Number::Float => wit::Number::Float, + Number::Double => wit::Number::Double, + }), + Scalar::DateTime => wit::Scalar::DateTime, + Scalar::Date => wit::Scalar::Date, + Scalar::Bytes => wit::Scalar::Bytes, + Scalar::Binary => wit::Scalar::Binary, + }, + format: scalar.format.map(s), }), TypeData::Enum(values) => wit::TypeInfo::Enum( forge @@ -581,6 +592,7 @@ impl wit::Host for State { ), TypeData::Array(array) => wit::TypeInfo::Array(wit::ArrayInfo { elem: array.elem.0, + min_items: array.min_items, max_items: array.max_items, }), TypeData::Object(object) => wit::TypeInfo::Object(wit::ObjectInfo { diff --git a/src/types.rs b/src/types.rs index 18cadcb00..f9ccb9b90 100644 --- a/src/types.rs +++ b/src/types.rs @@ -16,8 +16,8 @@ use crate::{Forge, Json, Model, ModelId, Name}; /// `const: null`, or `null` among the values of an `enum`. #[derive(Clone, Copy, Debug)] pub enum Type<'f> { - /// A type with no parts. - Scalar(Scalar), + /// A primitive, with its original `format` annotation. + Scalar(ScalarType<'f>), /// One of the values of an `enum`, which are all different, in the /// order it lists them. There is at least one, and none is null: an /// `enum` that lists `null` is a union of the other values and @@ -73,7 +73,7 @@ pub enum Type<'f> { Model(Model<'f>), } -/// A type with no parts: a primitive, or any value. +/// A primitive's classification, apart from its [`ScalarType::format`]. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Scalar { /// Any value: from a schema that does not constrain its type, such as @@ -104,6 +104,37 @@ pub enum Scalar { Binary, } +/// A primitive and the `format` written on its schema. +/// +/// The classification keeps its existing meaning: a UUID is still +/// [`Scalar::String`], and a date-time is still [`Scalar::DateTime`]. The +/// annotation lets plugins apply their own format handling, including +/// formats Forge does not recognize. Forge does not validate values. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ScalarType<'f> { + kind: Scalar, + format: Option<&'f str>, +} + +impl<'f> ScalarType<'f> { + /// Returns the primitive's classification. + pub fn kind(self) -> Scalar { + self.kind + } + + /// Returns the original `format`, including an empty or unrecognized + /// one, or `None` if the schema has no `format`. + pub fn format(self) -> Option<&'f str> { + self.format + } +} + +impl From for ScalarType<'_> { + fn from(kind: Scalar) -> Self { + Self { kind, format: None } + } +} + /// The size of a [`Scalar::Number`], from the `format` of an `integer` or /// `number` schema, as defined by the /// [Format Registry](https://spec.openapis.org/registry/format/). @@ -209,7 +240,10 @@ impl<'f> Type<'f> { /// Returns the type with ID `id` in `forge`. pub(crate) fn new(forge: &'f Forge, id: TypeId) -> Type<'f> { match &forge.types[id.index()] { - TypeData::Scalar(scalar) => Type::Scalar(*scalar), + TypeData::Scalar(data) => Type::Scalar(ScalarType { + kind: data.kind, + format: data.format.map(|s| forge.strings.get(s)), + }), TypeData::Enum(values) => Type::Enum(Enum { forge, values: forge.values.get(*values), @@ -251,6 +285,15 @@ impl<'f> Array<'f> { Type::new(self.forge, self.data.elem) } + /// Returns the fewest elements the array can have: its `minItems`, or + /// `None` if it is absent. An explicit zero is `Some(0)`. + /// + /// Reading and reference handling follow [`Array::max_items`]. Forge + /// preserves the bounds without checking whether they are consistent. + pub fn min_items(self) -> Option { + self.data.min_items + } + /// Returns the most elements the array can have, or `None` if it has /// no limit. /// @@ -555,7 +598,7 @@ impl NameId { /// What a [`Forge`] stores for a type. The types inside it are stored /// separately, by ID, and its lists in the `Forge`'s arenas. pub(crate) enum TypeData { - Scalar(Scalar), + Scalar(ScalarData), Enum(Span), Array(ArrayData), Object(ObjectData), @@ -564,6 +607,12 @@ pub(crate) enum TypeData { Model(ModelId), } +#[derive(Clone, Copy)] +pub(crate) struct ScalarData { + pub(crate) kind: Scalar, + pub(crate) format: Option, +} + /// What a [`Forge`] stores for an [`EnumValue`]. #[derive(Clone, Copy)] pub(crate) enum EnumData { @@ -575,6 +624,8 @@ pub(crate) enum EnumData { #[derive(Clone, Copy)] pub(crate) struct ArrayData { pub(crate) elem: TypeId, + /// The `minItems`, if the schema specifies it. + pub(crate) min_items: Option, /// The `maxItems`, if the array has a limit. pub(crate) max_items: Option, } diff --git a/tests/parse/common.rs b/tests/parse/common.rs index ed8854cd2..59a77f406 100644 --- a/tests/parse/common.rs +++ b/tests/parse/common.rs @@ -129,7 +129,7 @@ pub fn scalar(ty: Type<'_>) -> Scalar { let Type::Scalar(scalar) = ty else { panic!("want a scalar, got {ty:?}"); }; - scalar + scalar.kind() } /// Returns the values of the enum `ty` is. @@ -194,7 +194,7 @@ pub fn non_null(ty: Type<'_>) -> Type<'_> { let others: Vec = members .iter() .copied() - .filter(|m| !matches!(m, Type::Scalar(Scalar::Null))) + .filter(|m| !matches!(m, Type::Scalar(s) if s.kind() == Scalar::Null)) .collect(); let [other] = others[..] else { panic!("want a union of one type and null, got {ty:?}"); @@ -208,7 +208,7 @@ pub fn non_null(ty: Type<'_>) -> Type<'_> { /// name of the variant. pub fn kind(ty: Type<'_>) -> String { match ty { - Type::Scalar(scalar) => format!("{scalar:?}"), + Type::Scalar(scalar) => format!("{:?}", scalar.kind()), Type::Enum(_) => "Enum".to_string(), Type::Array(_) => "Array".to_string(), Type::Object(_) => "Object".to_string(), diff --git a/tests/parse/requests.rs b/tests/parse/requests.rs index 31ad98776..ba88bfa1f 100644 --- a/tests/parse/requests.rs +++ b/tests/parse/requests.rs @@ -32,7 +32,7 @@ fn path_param_is_a_required_request_field() { let zone_id = field(params, "zone_id"); assert!(zone_id.is_required()); assert!( - matches!(zone_id.ty(), Type::Scalar(Scalar::String)), + matches!(zone_id.ty(), Type::Scalar(s) if s.kind() == Scalar::String), "got {:?}", zone_id.ty() ); diff --git a/tests/parse/types/arrays.rs b/tests/parse/types/arrays.rs index ccc0b4ae0..262c601e1 100644 --- a/tests/parse/types/arrays.rs +++ b/tests/parse/types/arrays.rs @@ -20,6 +20,59 @@ fn array_of_items() { assert_eq!(scalar(elem(response_type(&forge, "a.get"))), Scalar::String); } +#[test] +fn min_items_preserves_absence_zero_and_positive_bounds() { + for (bound, want) in [ + ("", None), + (", \"minItems\": 0", Some(0)), + (", \"minItems\": 1", Some(1)), + ] { + let forge = parse(&responding_with(&format!( + r#"{{"type": "array", "items": {{"type": "string"}}, "maxItems": 50{bound}}}"# + ))); + let array = array(response_type(&forge, "a.get")); + assert_eq!(array.min_items(), want); + assert_eq!(array.max_items(), Some(50)); + } +} + +#[test] +fn arrays_with_different_minimums_keep_their_own_bounds() { + let forge = parse(&responding_with_schemas( + r##"{"type": "object", "properties": { + "zero": {"type": "array", "items": {}, "minItems": 0}, + "one": {"$ref": "#/components/schemas/One"}, + "any": {"items": {}} + }}"##, + r#"{"One": {"type": "array", "items": {}, "minItems": 1, "maxItems": 50}}"#, + )); + let object = object(response_type(&forge, "a.get")); + assert_eq!(array(field(object, "zero").ty()).min_items(), Some(0)); + let one = array(model_of(field(object, "one").ty()).ty()); + assert_eq!(one.min_items(), Some(1)); + assert_eq!(one.max_items(), Some(50)); + assert_eq!(array(field(object, "any").ty()).min_items(), None); +} + +#[test] +fn min_items_follows_the_same_number_rules_as_max_items() { + let forge = parse(&responding_with(r#"{"type": "array", "minItems": 1.0}"#)); + assert_eq!(array(response_type(&forge, "a.get")).min_items(), Some(1)); + + for value in ["-1", "2.5", r#""3""#, "null", "true"] { + let err = parse_err(&responding_with(&format!( + r#"{{"type": "array", "minItems": {value}}}"# + ))); + assert!(err.contains("expected a non-negative integer"), "{value}: {err}"); + } +} + +#[test] +fn min_items_on_a_non_array_is_ignored() { + let forge = parse(&responding_with(r#"{"type": "string", "minItems": 1}"#)); + assert_eq!(scalar(response_type(&forge, "a.get")), Scalar::String); +} + #[test] fn empty_items_are_unknown() { let forge = parse(&responding_with(r#"{"type": "array", "items": {}}"#)); diff --git a/tests/parse/types/scalars.rs b/tests/parse/types/scalars.rs index d7fe0fccd..f0b1f066f 100644 --- a/tests/parse/types/scalars.rs +++ b/tests/parse/types/scalars.rs @@ -1,6 +1,95 @@ -use forge::{Number, Scalar}; +use forge::{Number, Scalar, Type}; -use crate::common::{field, object, parse, responding_with, response_type, scalar}; +use crate::common::{ + field, model_of, object, parse, responding_with, responding_with_schemas, response_type, scalar, union, +}; + +fn format(ty: Type<'_>) -> Option<&str> { + let Type::Scalar(scalar) = ty else { + panic!("want a scalar, got {ty:?}"); + }; + scalar.format() +} + +#[test] +fn original_scalar_formats_are_kept_without_changing_the_classification() { + for (schema, kind, want) in [ + (r#"{"type":"string"}"#, Scalar::String, None), + (r#"{"type":"string","format":""}"#, Scalar::String, Some("")), + (r#"{"type":"string","format":"uuid"}"#, Scalar::String, Some("uuid")), + ( + r#"{"type":"string","format":"custom-ID"}"#, + Scalar::String, + Some("custom-ID"), + ), + ( + r#"{"type":"string","format":"date-time"}"#, + Scalar::DateTime, + Some("date-time"), + ), + (r#"{"type":"string","format":"date"}"#, Scalar::Date, Some("date")), + (r#"{"type":"string","format":"byte"}"#, Scalar::Bytes, Some("byte")), + (r#"{"type":"string","format":"binary"}"#, Scalar::Binary, Some("binary")), + (r#"{"type":"string","contentEncoding":"base64"}"#, Scalar::Bytes, None), + ( + r#"{"type":"string","format":"uuid","contentEncoding":"base64"}"#, + Scalar::Bytes, + Some("uuid"), + ), + ( + r#"{"type":"integer","format":"int32"}"#, + Scalar::Number(Number::Int32), + Some("int32"), + ), + ( + r#"{"type":"number","format":"decimal"}"#, + Scalar::Number(Number::Double), + Some("decimal"), + ), + ] { + let forge = parse(&responding_with(schema)); + let ty = response_type(&forge, "a.get"); + assert_eq!(scalar(ty), kind, "{schema}"); + assert_eq!(format(ty), want, "{schema}"); + } +} + +#[test] +fn strings_with_different_formats_do_not_share_annotations() { + let forge = parse(&responding_with_schemas( + r##"{"type":"object","properties":{ + "id":{"$ref":"#/components/schemas/ID"}, + "uri":{"type":"string","format":"uri"}, + "plain":{"type":"string"}, + "empty":{"type":"string","format":""}, + "id_again":{"type":"string","format":"uuid"} + }}"##, + r#"{"ID":{"type":"string","format":"uuid"}}"#, + )); + let object = object(response_type(&forge, "a.get")); + assert_eq!(format(model_of(field(object, "id").ty()).ty()), Some("uuid")); + for (name, want) in [ + ("uri", Some("uri")), + ("plain", None), + ("empty", Some("")), + ("id_again", Some("uuid")), + ] { + assert_eq!(format(field(object, name).ty()), want, "{name}"); + } +} + +#[test] +fn union_deduplication_keeps_distinct_formats() { + let forge = parse(&responding_with( + r#"{"anyOf":[ + {"type":"string","format":"uuid"}, + {"type":"string","format":"uri"}, + {"type":"string","format":"uuid"} + ]}"#, + )); + let formats: Vec<_> = union(response_type(&forge, "a.get")).members().map(format).collect(); + assert_eq!(formats, [Some("uuid"), Some("uri")]); +} #[test] fn primitive_types() { diff --git a/tests/wasm.rs b/tests/wasm.rs index 6efe095ea..596d39539 100644 --- a/tests/wasm.rs +++ b/tests/wasm.rs @@ -179,13 +179,24 @@ const FEATURES: &str = r##"{ "Pet": {"type": "object", "properties": { "id": {"type": "string", "readOnly": true}, "password": {"type": "string", "x-sensitive": true}, - "tags": {"type": "array", "items": {"type": "string"}, "maxItems": 3}, + "tags": {"type": "array", "items": {"type": "string"}, "minItems": 0, "maxItems": 3}, + "dataset_scope": {"oneOf": [ + {"type": "object", "properties": {"type": {"const": "default"}}, "required": ["type"], "additionalProperties": false}, + {"type": "object", "properties": { + "type": {"const": "datasets"}, + "dataset_ids": {"$ref": "#/components/schemas/DatasetIDs"} + }, "required": ["type", "dataset_ids"], "additionalProperties": false} + ]}, + "empty_format": {"type": "string", "format": ""}, + "custom_format": {"type": "string", "format": "custom-ID"}, "kind": {"type": "string", "enum": ["cat", "dog"], "default": "cat"}, "meta": {"type": "object", "additionalProperties": true, "default": {"z": 1, "a": [true, null]}}, "owner": {"$ref": "#/components/schemas/Owner", "description": "Who has the pet."}, "nickname": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "age": {"oneOf": [{"type": "string"}, {"type": "integer"}]} }, "required": ["id"]}, + "DatasetIDs": {"type": "array", "minItems": 1, "maxItems": 50, "items": {"$ref": "#/components/schemas/ID"}}, + "ID": {"type": "string", "format": "uuid"}, "Owner": {"type": "object", "description": "A person.", "properties": { "pets": {"type": "array", "items": {"$ref": "#/components/schemas/Pet"}} }} @@ -213,7 +224,13 @@ fn reads_all_of_the_forge() { "read only true", "sensitive true", "default Some(", - "max items Some(3)", + "min items Some(0) max items Some(3)", + "min items Some(1) max items Some(50)", + "min items None max items None", + "String format Some(\"uuid\")", + "String format Some(\"custom-ID\")", + "String format Some(\"\")", + "String format None", "Binary", "description \"The photo.\"", "union exclusive false", diff --git a/tests/wasm/dump.rs b/tests/wasm/dump.rs index 1d615b28e..95464376b 100644 --- a/tests/wasm/dump.rs +++ b/tests/wasm/dump.rs @@ -161,10 +161,22 @@ fn content(o: &mut String, content: Content<'_>) { /// Writes `ty`, and the location in `method` of each field it has. fn ty(o: &mut String, depth: usize, method: Option>, ty: Type<'_>) { match ty { - Type::Scalar(scalar) => line(o, depth, format_args!("{scalar:?}")), + Type::Scalar(scalar) => line( + o, + depth, + format_args!("{:?} format {:?}", scalar.kind(), scalar.format()), + ), Type::Enum(values) => line(o, depth, format_args!("enum {:?}", values.values().collect::>())), Type::Array(array) => { - line(o, depth, format_args!("array max items {:?}", array.max_items())); + line( + o, + depth, + format_args!( + "array min items {:?} max items {:?}", + array.min_items(), + array.max_items() + ), + ); self::ty(o, depth + 1, method, array.elem()); } Type::Object(object) => self::object(o, depth, method, object), diff --git a/tests/wasm/ts/src/lib.rs b/tests/wasm/ts/src/lib.rs index 42ff7a416..bc21c6f77 100644 --- a/tests/wasm/ts/src/lib.rs +++ b/tests/wasm/ts/src/lib.rs @@ -278,28 +278,31 @@ impl Forge { return data; } let data = match host::get_type(id) { - host::TypeInfo::Scalar(scalar) => TypeData::Scalar(match scalar { - host::Scalar::Unknown => Scalar::Unknown, - host::Scalar::Null => Scalar::Null, - host::Scalar::Boolean => Scalar::Boolean, - host::Scalar::String => Scalar::String, - host::Scalar::Number(number) => Scalar::Number(match number { - host::Number::Int => Number::Int, - host::Number::Int8 => Number::Int8, - host::Number::Int16 => Number::Int16, - host::Number::Int32 => Number::Int32, - host::Number::Int64 => Number::Int64, - host::Number::Uint8 => Number::Uint8, - host::Number::Uint16 => Number::Uint16, - host::Number::Uint32 => Number::Uint32, - host::Number::Uint64 => Number::Uint64, - host::Number::Float => Number::Float, - host::Number::Double => Number::Double, - }), - host::Scalar::DateTime => Scalar::DateTime, - host::Scalar::Date => Scalar::Date, - host::Scalar::Bytes => Scalar::Bytes, - host::Scalar::Binary => Scalar::Binary, + host::TypeInfo::Scalar(scalar) => TypeData::Scalar(ScalarData { + kind: match scalar.kind { + host::Scalar::Unknown => Scalar::Unknown, + host::Scalar::Null => Scalar::Null, + host::Scalar::Boolean => Scalar::Boolean, + host::Scalar::String => Scalar::String, + host::Scalar::Number(number) => Scalar::Number(match number { + host::Number::Int => Number::Int, + host::Number::Int8 => Number::Int8, + host::Number::Int16 => Number::Int16, + host::Number::Int32 => Number::Int32, + host::Number::Int64 => Number::Int64, + host::Number::Uint8 => Number::Uint8, + host::Number::Uint16 => Number::Uint16, + host::Number::Uint32 => Number::Uint32, + host::Number::Uint64 => Number::Uint64, + host::Number::Float => Number::Float, + host::Number::Double => Number::Double, + }), + host::Scalar::DateTime => Scalar::DateTime, + host::Scalar::Date => Scalar::Date, + host::Scalar::Bytes => Scalar::Bytes, + host::Scalar::Binary => Scalar::Binary, + }, + format: scalar.format, }), host::TypeInfo::Enum(values) => TypeData::Enum( values @@ -313,6 +316,7 @@ impl Forge { ), host::TypeInfo::Array(array) => TypeData::Array(ArrayData { elem: array.elem, + min_items: array.min_items, max_items: array.max_items, }), host::TypeInfo::Object(object) => TypeData::Object(ObjectData { @@ -428,7 +432,7 @@ struct ModelData { } enum TypeData { - Scalar(Scalar), + Scalar(ScalarData), Enum(Vec), Array(ArrayData), Object(ObjectData), @@ -437,6 +441,11 @@ enum TypeData { Model(u32), } +struct ScalarData { + kind: Scalar, + format: Option, +} + enum EnumData { String(String), Number(f64), @@ -445,6 +454,7 @@ enum EnumData { struct ArrayData { elem: u32, + min_items: Option, max_items: Option, } @@ -806,7 +816,7 @@ impl fmt::Debug for Model<'_> { #[derive(Clone, Copy, Debug)] pub enum Type<'f> { - Scalar(Scalar), + Scalar(ScalarType<'f>), Enum(Enum<'f>), Array(Array<'f>), Object(Object<'f>), @@ -815,6 +825,28 @@ pub enum Type<'f> { Model(Model<'f>), } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ScalarType<'f> { + kind: Scalar, + format: Option<&'f str>, +} + +impl<'f> ScalarType<'f> { + pub fn kind(self) -> Scalar { + self.kind + } + + pub fn format(self) -> Option<&'f str> { + self.format + } +} + +impl From for ScalarType<'_> { + fn from(kind: Scalar) -> Self { + Self { kind, format: None } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Scalar { Unknown, @@ -905,7 +937,10 @@ pub struct Discriminator<'f> { impl<'f> Type<'f> { fn new(forge: &'f Forge, id: u32) -> Type<'f> { match forge.type_data(id) { - TypeData::Scalar(scalar) => Type::Scalar(*scalar), + TypeData::Scalar(scalar) => Type::Scalar(ScalarType { + kind: scalar.kind, + format: scalar.format.as_deref(), + }), TypeData::Enum(values) => Type::Enum(Enum { values }), TypeData::Array(data) => Type::Array(Array { forge, data }), TypeData::Object(data) => Type::Object(Object { forge, id, data }), @@ -931,6 +966,10 @@ impl<'f> Array<'f> { Type::new(self.forge, self.data.elem) } + pub fn min_items(self) -> Option { + self.data.min_items + } + pub fn max_items(self) -> Option { self.data.max_items } diff --git a/tests/wasm/ts/wit/forge.wit b/tests/wasm/ts/wit/forge.wit index fc75f5614..c82d07ea8 100644 --- a/tests/wasm/ts/wit/forge.wit +++ b/tests/wasm/ts/wit/forge.wit @@ -1,5 +1,5 @@ -package forge:plugin@0.2.0; +package forge:plugin@0.3.0; /// Read-only access to the forge a plugin runs on, which the host keeps. /// Each id is an index the host gave out. An id the host never gave out @@ -131,6 +131,12 @@ interface forge { unknown, null, boolean, %string, number(number), date-time, date, bytes, binary, } + record scalar-info { + kind: scalar, + /// The original `format`, including an empty or unrecognized one. + format: option, + } + variant enum-value { %string(string), number(f64), @@ -154,6 +160,8 @@ interface forge { record array-info { elem: type-id, + /// The `minItems`, or none if it is absent; zero is kept. + min-items: option, /// The `maxItems`, or none if the array has no limit. max-items: option, } @@ -178,7 +186,7 @@ interface forge { } variant type-info { - scalar(scalar), + scalar(scalar-info), %enum(list), array(array-info), object(object-info), From 1460f1e50c8424f445c7da24462102cb0162c8f8 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 19 Oct 2026 14:28:23 +0100 Subject: [PATCH 2/2] fix: represent UUIDs as an explicit scalar variant --- packages/forge/README.md | 11 +- packages/forge/plugin/forge.wit | 10 +- packages/forge/src/index.ts | 20 +-- packages/forge/test/fixtures/metadata-host.js | 12 +- packages/forge/test/plugin.test.js | 17 +- packages/forge/test/types.ts | 10 +- src/bin/forge/ts/codec.rs | 6 +- src/bin/forge/ts/types.rs | 8 +- src/lib.rs | 4 +- src/link.rs | 48 ++---- src/link/openapi.rs | 36 ++--- src/plugin/wasm.rs | 58 +++---- src/types.rs | 54 +------ tests/parse/common.rs | 6 +- tests/parse/requests.rs | 2 +- tests/parse/types/scalars.rs | 148 +++++++----------- tests/wasm.rs | 5 +- tests/wasm/dump.rs | 6 +- tests/wasm/ts/src/lib.rs | 85 ++++------ tests/wasm/ts/wit/forge.wit | 10 +- 20 files changed, 191 insertions(+), 365 deletions(-) diff --git a/packages/forge/README.md b/packages/forge/README.md index a8d165139..39bc2163f 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -79,15 +79,14 @@ compile error. The same group, method or model is always the same object, so you can compare them with `===` and keep them in a `Map`. Array types expose `minItems` and `maxItems`; an absent bound is -`undefined`, and an explicit zero is kept. Scalar types expose the original -`format`, including custom formats such as `uuid`, separately from their -classification (`scalar: 'string'` for a UUID). These are metadata for -plugins to interpret; Forge does not validate request values. +`undefined`, and an explicit zero is kept. A string with OpenAPI format +`uuid` is exposed as `scalar: 'uuid'` (`Scalar::Uuid` in Rust), so plugins +can handle it explicitly. Other unrecognized string formats remain +`scalar: 'string'`. Forge does not validate request values. The plugin interface is `forge:plugin@0.3.0`. Rebuild plugins compiled against `0.2.0` with the updated package before running them with this -version of Forge. In Rust, `Type::Scalar` now contains a `ScalarType`: use -`kind()` for its `Scalar` classification and `format()` for the annotation. +version of Forge. A plugin fails by throwing; forge stops and prints the message. Its files go through the same checks as forge's own: a path must be relative, use `/`, diff --git a/packages/forge/plugin/forge.wit b/packages/forge/plugin/forge.wit index c82d07ea8..af93aa10e 100644 --- a/packages/forge/plugin/forge.wit +++ b/packages/forge/plugin/forge.wit @@ -128,13 +128,7 @@ interface forge { } variant scalar { - unknown, null, boolean, %string, number(number), date-time, date, bytes, binary, - } - - record scalar-info { - kind: scalar, - /// The original `format`, including an empty or unrecognized one. - format: option, + unknown, null, boolean, %string, number(number), date-time, date, bytes, binary, uuid, } variant enum-value { @@ -186,7 +180,7 @@ interface forge { } variant type-info { - scalar(scalar-info), + scalar(scalar), %enum(list), array(array-info), object(object-info), diff --git a/packages/forge/src/index.ts b/packages/forge/src/index.ts index 934de2293..9b374eab9 100644 --- a/packages/forge/src/index.ts +++ b/packages/forge/src/index.ts @@ -291,20 +291,12 @@ export interface Field { export type Type = ScalarType | EnumType | ArrayType | ObjectType | UnionType | IntersectionType | ModelType; /** A primitive, or any value (`unknown`). */ -export type ScalarType = { - /** - * The original `format`, including an empty or unrecognized one, or - * undefined if absent. The scalar classification is unchanged: a UUID - * is `string`, for example. Forge does not validate values. - */ - readonly format: string | undefined; -} & ( +export type ScalarType = | { readonly kind: 'scalar'; - readonly scalar: 'unknown' | 'null' | 'boolean' | 'string' | 'date-time' | 'date' | 'bytes' | 'binary'; + readonly scalar: 'unknown' | 'null' | 'boolean' | 'string' | 'date-time' | 'date' | 'bytes' | 'binary' | 'uuid'; } - | { readonly kind: 'scalar'; readonly scalar: 'number'; readonly number: NumberFormat } -); + | { readonly kind: 'scalar'; readonly scalar: 'number'; readonly number: NumberFormat }; /** The size of a number, from its `format`. `int` and `double` have none. */ export type NumberFormat = @@ -969,9 +961,9 @@ function makeType(forge: ForgeImpl, id: host.TypeId): Type { const type = (ty: host.TypeId) => forge.type(ty); switch (t.tag) { case 'scalar': - return t.val.kind.tag === 'number' - ? { kind: 'scalar', scalar: 'number', number: t.val.kind.val, format: opt(t.val.format) } - : { kind: 'scalar', scalar: t.val.kind.tag, format: opt(t.val.format) }; + return t.val.tag === 'number' + ? { kind: 'scalar', scalar: 'number', number: t.val.val } + : { kind: 'scalar', scalar: t.val.tag }; case 'enum': return { kind: 'enum', values: t.val.map((value) => value.val) }; case 'array': { diff --git a/packages/forge/test/fixtures/metadata-host.js b/packages/forge/test/fixtures/metadata-host.js index b9fb5ba52..f9a9b546e 100644 --- a/packages/forge/test/fixtures/metadata-host.js +++ b/packages/forge/test/fixtures/metadata-host.js @@ -5,13 +5,11 @@ const types = [ { tag: 'array', val: { elem: 3, minItems: null, maxItems: null } }, { tag: 'array', val: { elem: 4, minItems: 0n, maxItems: 50n } }, { tag: 'array', val: { elem: 4, minItems: 1n, maxItems: 50n } }, - { tag: 'scalar', val: { kind: { tag: 'string' }, format: null } }, - { tag: 'scalar', val: { kind: { tag: 'string' }, format: 'uuid' } }, - { tag: 'scalar', val: { kind: { tag: 'string' }, format: '' } }, - { tag: 'scalar', val: { kind: { tag: 'string' }, format: 'custom-ID' } }, - { tag: 'scalar', val: { kind: { tag: 'date-time' }, format: 'date-time' } }, - { tag: 'scalar', val: { kind: { tag: 'bytes' }, format: undefined } }, - { tag: 'scalar', val: { kind: { tag: 'number', val: 'int32' }, format: 'int32' } }, + { tag: 'scalar', val: { tag: 'string' } }, + { tag: 'scalar', val: { tag: 'uuid' } }, + { tag: 'scalar', val: { tag: 'date-time' } }, + { tag: 'scalar', val: { tag: 'bytes' } }, + { tag: 'scalar', val: { tag: 'number', val: 'int32' } }, ]; export function info() { diff --git a/packages/forge/test/plugin.test.js b/packages/forge/test/plugin.test.js index 6be7ee400..be76a75f2 100644 --- a/packages/forge/test/plugin.test.js +++ b/packages/forge/test/plugin.test.js @@ -28,26 +28,23 @@ test('array bounds distinguish absent, zero and one through the plugin API', () assert.equal(zero.maxItems, 50); assert.equal(one.minItems, 1); assert.equal(one.maxItems, 50); - assert.equal(one.elem.scalar, 'string'); - assert.equal(one.elem.format, 'uuid'); + assert.equal(one.elem.scalar, 'uuid'); return []; }, }).generate(); }); -test('scalar formats preserve their spelling and the existing classifications', () => { +test('UUIDs are a distinct scalar through the plugin API', () => { definePlugin({ generate(forge) { assert.deepEqual( forge.models.slice(3).map((model) => model.type), [ - { kind: 'scalar', scalar: 'string', format: undefined }, - { kind: 'scalar', scalar: 'string', format: 'uuid' }, - { kind: 'scalar', scalar: 'string', format: '' }, - { kind: 'scalar', scalar: 'string', format: 'custom-ID' }, - { kind: 'scalar', scalar: 'date-time', format: 'date-time' }, - { kind: 'scalar', scalar: 'bytes', format: undefined }, - { kind: 'scalar', scalar: 'number', number: 'int32', format: 'int32' }, + { kind: 'scalar', scalar: 'string' }, + { kind: 'scalar', scalar: 'uuid' }, + { kind: 'scalar', scalar: 'date-time' }, + { kind: 'scalar', scalar: 'bytes' }, + { kind: 'scalar', scalar: 'number', number: 'int32' }, ], ); return []; diff --git a/packages/forge/test/types.ts b/packages/forge/test/types.ts index bfd4b939a..a5e7aa4fc 100644 --- a/packages/forge/test/types.ts +++ b/packages/forge/test/types.ts @@ -13,11 +13,11 @@ function metadata(type: Type): number | string | undefined { type.minItems = 0; return min ?? max; } - if (type.kind === 'scalar') { - const format: string | undefined = type.format; - // @ts-expect-error Metadata is read-only. - type.format = 'uuid'; - return format; + if (type.kind === 'scalar' && type.scalar === 'uuid') { + const uuid: 'uuid' = type.scalar; + // @ts-expect-error Scalar types are read-only. + type.scalar = 'string'; + return uuid; } return undefined; } diff --git a/src/bin/forge/ts/codec.rs b/src/bin/forge/ts/codec.rs index f689d47e5..43a8fe71b 100644 --- a/src/bin/forge/ts/codec.rs +++ b/src/bin/forge/ts/codec.rs @@ -52,7 +52,7 @@ pub(super) fn model_codec(f: &mut File, m: Model<'_>) { /// since it is built after the models it uses. pub(super) fn codec(t: Type<'_>, direct: bool) -> Fragment { match t { - Type::Scalar(scalar) => match scalar_codec(scalar.kind()) { + Type::Scalar(scalar) => match scalar_codec(scalar) { Some(id) => frag!(id), None => frag!(), }, @@ -189,7 +189,7 @@ fn discriminated(t: Type<'_>) -> Option> { /// at, whose codecs are needed only for what else is reached. pub(super) fn needs_codec<'f>(t: Type<'f>, seen: &mut HashSet>) -> bool { match t { - Type::Scalar(scalar) => scalar_codec(scalar.kind()).is_some(), + Type::Scalar(scalar) => scalar_codec(scalar).is_some(), Type::Model(m) => seen.insert(m) && needs_codec(m.ty(), seen), Type::Array(array) => needs_codec(array.elem(), seen), Type::Object(object) => { @@ -247,7 +247,7 @@ fn target_codec<'f>(m: Model<'f>, visiting: &mut HashSet>) -> Ident { /// intersection that needs a codec. fn single_codec<'f>(t: Type<'f>, visiting: &mut HashSet>) -> Option { let members: Vec> = match t { - Type::Scalar(scalar) => return scalar_codec(scalar.kind()), + Type::Scalar(scalar) => return scalar_codec(scalar), Type::Model(m) => { return needs_codec(t, &mut HashSet::new()).then(|| target_codec(m, visiting)); } diff --git a/src/bin/forge/ts/types.rs b/src/bin/forge/ts/types.rs index 0cdf63520..728595025 100644 --- a/src/bin/forge/ts/types.rs +++ b/src/bin/forge/ts/types.rs @@ -21,7 +21,7 @@ pub(super) enum Prec { /// ``` pub(super) fn typ(t: Type<'_>, prec: Prec) -> Fragment { match t { - Type::Scalar(scalar) => frag!(scalar_type(scalar.kind())), + Type::Scalar(scalar) => frag!(scalar_type(scalar)), Type::Model(m) => frag!(Ident::model(m).ty()), Type::Array(array) => frag!(typ(array.elem(), Prec::Array), "[]"), Type::Object(object) => object_type(object), @@ -57,7 +57,7 @@ fn scalar_type(scalar: Scalar) -> &'static str { Scalar::Unknown => "unknown", Scalar::Null => "null", Scalar::Boolean => "boolean", - Scalar::String => "string", + Scalar::String | Scalar::Uuid => "string", Scalar::Number(Number::Int64) => "bigint", Scalar::Number(_) => "number", Scalar::DateTime => "Temporal.Instant", @@ -138,7 +138,7 @@ pub(super) fn fields(object: Object<'_>) -> Vec> { /// Returns the type of the values of a map, which is unknown when the map /// does not say. pub(super) fn elem(object: Object<'_>) -> Type<'_> { - object.extra().unwrap_or(Type::Scalar(Scalar::Unknown.into())) + object.extra().unwrap_or(Type::Scalar(Scalar::Unknown)) } /// Reports whether `t` is an object type with a property the SDK keeps. An @@ -171,7 +171,7 @@ pub(super) fn all_optional(t: Type<'_>) -> bool { fn walk<'f>(t: Type<'f>, seen: &mut HashSet>) -> bool { match t { Type::Object(object) => !object.fields().any(|f| kept(f) && f.is_required()), - Type::Scalar(scalar) if scalar.kind() == Scalar::Unknown => true, + Type::Scalar(Scalar::Unknown) => true, Type::Model(m) => seen.insert(m) && walk(m.ty(), seen), Type::Intersection(intersection) => intersection.members().all(|m| walk(m, seen)), Type::Union(union) => union.members().any(|m| walk(m, seen)), diff --git a/src/lib.rs b/src/lib.rs index b39bd7ae0..55e1611d5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,9 +15,7 @@ pub use name::Name; #[cfg(feature = "wasm")] pub use plugin::wasm; pub use plugin::{Err, File, Plugin, run, ts, write}; -pub use types::{ - Array, Discriminator, Enum, EnumValue, Field, Intersection, Number, Object, Scalar, ScalarType, Type, Union, -}; +pub use types::{Array, Discriminator, Enum, EnumValue, Field, Intersection, Number, Object, Scalar, Type, Union}; use arena::{List, Span, Str, Strings}; use method::{BodyData, ResponseData}; diff --git a/src/link.rs b/src/link.rs index cabcce604..e77314894 100644 --- a/src/link.rs +++ b/src/link.rs @@ -22,7 +22,7 @@ use crate::arena::{List, Span, Str, Strings}; use crate::json::Json; use crate::method::{BodyData, ContentData, ResponseData, is_json}; use crate::types::{ - ArrayData, DiscriminatorData, EnumData, FieldData, NameId, ObjectData, ScalarData, TypeData, TypeId, UnionData, + ArrayData, DiscriminatorData, EnumData, FieldData, NameId, ObjectData, TypeData, TypeId, UnionData, }; use crate::{ Availability, EnumValue, Forge, GroupData, GroupId, HttpMethod, Location, MethodData, MethodId, ModelData, ModelId, @@ -403,8 +403,8 @@ struct Builder<'d> { /// Each method by group and name. method_index: HashMap<(GroupId, Name), MethodId>, types: Vec, - /// The type of each scalar and format, which every matching use shares. - scalars: HashMap<(Scalar, Option<&'d str>), TypeId>, + /// The type of each scalar, which every use shares. + scalars: Vec<(Scalar, TypeId)>, models: Vec, /// The type of each model, indexed by `ModelId`, which every use /// shares. @@ -461,7 +461,7 @@ impl<'d> Builder<'d> { methods: Vec::new(), method_index: HashMap::new(), types: Vec::new(), - scalars: HashMap::new(), + scalars: Vec::new(), models: Vec::new(), model_types: Vec::new(), model_index: HashMap::new(), @@ -1529,18 +1529,17 @@ impl<'d> Builder<'d> { /// Returns the type of `schema` as the one type `ty`. fn single(&mut self, schema: &'d SchemaObject, ty: SchemaType) -> TypeId { - let format = schema.format.map(|s| self.doc.str(s)); - let recognized = Format::from_str(format.unwrap_or_default()); let scalar = match ty { - SchemaType::String => match recognized { + SchemaType::String => match schema.format { Format::DateTime => Scalar::DateTime, Format::Date => Scalar::Date, + Format::Uuid => Scalar::Uuid, Format::Byte => Scalar::Bytes, Format::Binary => Scalar::Binary, _ if schema.content_encoding == ContentEncoding::Base64 => Scalar::Bytes, _ => Scalar::String, }, - SchemaType::Integer | SchemaType::Number => Scalar::Number(match recognized { + SchemaType::Integer | SchemaType::Number => Scalar::Number(match schema.format { Format::Int8 => Number::Int8, Format::Int16 => Number::Int16, Format::Int32 => Number::Int32, @@ -1559,7 +1558,7 @@ impl<'d> Builder<'d> { SchemaType::Array => return self.array(schema), SchemaType::Object => return self.object(schema), }; - self.formatted_scalar(scalar, format) + self.scalar(scalar) } fn array(&mut self, schema: &'d SchemaObject) -> TypeId { @@ -1800,7 +1799,7 @@ impl<'d> Builder<'d> { /// already there, to `values` if it is an enum, or to `null`. fn add_member(&self, member: TypeId, out: &mut Vec, values: &mut Vec, null: &mut bool) { match &self.types[member.index()] { - TypeData::Scalar(ScalarData { kind: Scalar::Null, .. }) => *null = true, + TypeData::Scalar(Scalar::Null) => *null = true, TypeData::Enum(enum_values) => { for &value in self.values.get(*enum_values) { let new = value.get(&self.strings); @@ -2115,35 +2114,22 @@ impl<'d> Builder<'d> { /// Returns `ty`, or the union of it and null if `nullable`. fn nullable(&mut self, ty: TypeId, nullable: bool) -> TypeId { let data = &self.types[ty.index()]; - if !nullable - || matches!( - data, - TypeData::Scalar(ScalarData { - kind: Scalar::Unknown | Scalar::Null, - .. - }) - ) - { + if !nullable || matches!(data, TypeData::Scalar(Scalar::Unknown | Scalar::Null)) { return ty; } let null = self.scalar(Scalar::Null); self.union_of(&[ty, null], None, true) } - /// Returns the type of `scalar` without a format. + /// Returns the type of `scalar`, which every use shares. fn scalar(&mut self, scalar: Scalar) -> TypeId { - self.formatted_scalar(scalar, None) - } - - /// Shares scalars only when both classification and format match. - fn formatted_scalar(&mut self, kind: Scalar, format: Option<&'d str>) -> TypeId { - let key = (kind, format); - if let Some(&id) = self.scalars.get(&key) { - return id; + for &(s, id) in &self.scalars { + if s == scalar { + return id; + } } - let format = format.map(|s| self.strings.push(s)); - let id = self.push(TypeData::Scalar(ScalarData { kind, format })); - self.scalars.insert(key, id); + let id = self.push(TypeData::Scalar(scalar)); + self.scalars.push((scalar, id)); id } diff --git a/src/link/openapi.rs b/src/link/openapi.rs index 2b698e357..ba7fa4906 100644 --- a/src/link/openapi.rs +++ b/src/link/openapi.rs @@ -352,7 +352,7 @@ pub(super) struct SchemaObject { /// The types the schema allows: one for a string, any number for an /// array, and none without `type`. pub(super) types: Span, - pub(super) format: Option, + pub(super) format: Format, pub(super) content_encoding: ContentEncoding, pub(super) nullable: bool, /// The values of `enum`, or none without it. An empty `enum`, which @@ -423,12 +423,14 @@ pub(super) enum SchemaType { Null, } -/// A recognized `format` that decides a type. The original text is kept -/// separately, including formats that do not change the classification. -#[derive(Clone, Copy, PartialEq, Eq)] +/// A `format` in a Schema Object that decides a type. Every other format, +/// and no format, is `Other`. +#[derive(Deserialize, Default, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] pub(super) enum Format { DateTime, Date, + Uuid, Byte, Binary, Int8, @@ -441,31 +443,11 @@ pub(super) enum Format { Uint64, Float, Double, + #[default] + #[serde(other)] Other, } -impl Format { - pub(super) fn from_str(format: &str) -> Self { - match format { - "date-time" => Self::DateTime, - "date" => Self::Date, - "byte" => Self::Byte, - "binary" => Self::Binary, - "int8" => Self::Int8, - "int16" => Self::Int16, - "int32" => Self::Int32, - "int64" => Self::Int64, - "uint8" => Self::Uint8, - "uint16" => Self::Uint16, - "uint32" => Self::Uint32, - "uint64" => Self::Uint64, - "float" => Self::Float, - "double" => Self::Double, - _ => Self::Other, - } - } -} - /// A `contentEncoding` in a Schema Object. Every encoding but base64, and /// no encoding, is `Other`. #[derive(Deserialize, Default, Clone, Copy, PartialEq, Eq)] @@ -1656,7 +1638,7 @@ impl ReadObject for SchemaObject { match key { SchemaKey::Ref => schema.reference = map.next_value_seed(seed::(doc))?, SchemaKey::Type => schema.types = map.next_value_seed(seed::(doc))?, - SchemaKey::Format => schema.format = Some(map.next_value_seed(seed::(doc))?), + SchemaKey::Format => schema.format = map.next_value()?, SchemaKey::ContentEncoding => schema.content_encoding = map.next_value()?, SchemaKey::Nullable => schema.nullable = map.next_value()?, SchemaKey::Enum => schema.values = map.next_value_seed(seed::>(doc))?, diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs index bae70cbca..97220a570 100644 --- a/src/plugin/wasm.rs +++ b/src/plugin/wasm.rs @@ -189,13 +189,7 @@ interface forge { } variant scalar { - unknown, null, boolean, %string, number(number), date-time, date, bytes, binary, - } - - record scalar-info { - kind: scalar, - /// The original `format`, including an empty or unrecognized one. - format: option, + unknown, null, boolean, %string, number(number), date-time, date, bytes, binary, uuid, } variant enum-value { @@ -247,7 +241,7 @@ interface forge { } variant type-info { - scalar(scalar-info), + scalar(scalar), %enum(list), array(array-info), object(object-info), @@ -552,31 +546,29 @@ impl wit::Host for State { let forge = self.forge()?; let s = |s: Str| forge.strings.get(s).to_owned(); Ok(match get(&forge.types, id, "type")? { - TypeData::Scalar(scalar) => wit::TypeInfo::Scalar(wit::ScalarInfo { - kind: match scalar.kind { - Scalar::Unknown => wit::Scalar::Unknown, - Scalar::Null => wit::Scalar::Null, - Scalar::Boolean => wit::Scalar::Boolean, - Scalar::String => wit::Scalar::String, - Scalar::Number(number) => wit::Scalar::Number(match number { - Number::Int => wit::Number::Int, - Number::Int8 => wit::Number::Int8, - Number::Int16 => wit::Number::Int16, - Number::Int32 => wit::Number::Int32, - Number::Int64 => wit::Number::Int64, - Number::Uint8 => wit::Number::Uint8, - Number::Uint16 => wit::Number::Uint16, - Number::Uint32 => wit::Number::Uint32, - Number::Uint64 => wit::Number::Uint64, - Number::Float => wit::Number::Float, - Number::Double => wit::Number::Double, - }), - Scalar::DateTime => wit::Scalar::DateTime, - Scalar::Date => wit::Scalar::Date, - Scalar::Bytes => wit::Scalar::Bytes, - Scalar::Binary => wit::Scalar::Binary, - }, - format: scalar.format.map(s), + TypeData::Scalar(scalar) => wit::TypeInfo::Scalar(match *scalar { + Scalar::Unknown => wit::Scalar::Unknown, + Scalar::Null => wit::Scalar::Null, + Scalar::Boolean => wit::Scalar::Boolean, + Scalar::String => wit::Scalar::String, + Scalar::Uuid => wit::Scalar::Uuid, + Scalar::Number(number) => wit::Scalar::Number(match number { + Number::Int => wit::Number::Int, + Number::Int8 => wit::Number::Int8, + Number::Int16 => wit::Number::Int16, + Number::Int32 => wit::Number::Int32, + Number::Int64 => wit::Number::Int64, + Number::Uint8 => wit::Number::Uint8, + Number::Uint16 => wit::Number::Uint16, + Number::Uint32 => wit::Number::Uint32, + Number::Uint64 => wit::Number::Uint64, + Number::Float => wit::Number::Float, + Number::Double => wit::Number::Double, + }), + Scalar::DateTime => wit::Scalar::DateTime, + Scalar::Date => wit::Scalar::Date, + Scalar::Bytes => wit::Scalar::Bytes, + Scalar::Binary => wit::Scalar::Binary, }), TypeData::Enum(values) => wit::TypeInfo::Enum( forge diff --git a/src/types.rs b/src/types.rs index f9ccb9b90..34aab173f 100644 --- a/src/types.rs +++ b/src/types.rs @@ -16,8 +16,8 @@ use crate::{Forge, Json, Model, ModelId, Name}; /// `const: null`, or `null` among the values of an `enum`. #[derive(Clone, Copy, Debug)] pub enum Type<'f> { - /// A primitive, with its original `format` annotation. - Scalar(ScalarType<'f>), + /// A type with no parts. + Scalar(Scalar), /// One of the values of an `enum`, which are all different, in the /// order it lists them. There is at least one, and none is null: an /// `enum` that lists `null` is a union of the other values and @@ -73,7 +73,7 @@ pub enum Type<'f> { Model(Model<'f>), } -/// A primitive's classification, apart from its [`ScalarType::format`]. +/// A type with no parts: a primitive, or any value. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Scalar { /// Any value: from a schema that does not constrain its type, such as @@ -82,8 +82,10 @@ pub enum Scalar { Unknown, Null, Boolean, - /// A string, with no format or one not listed here, such as `uuid`. + /// A string, with no format or one not listed here, such as `uri`. String, + /// A string with format `uuid`. + Uuid, /// An integer or number, of the size its format gives. Number(Number), /// A string with format `date-time`. @@ -104,37 +106,6 @@ pub enum Scalar { Binary, } -/// A primitive and the `format` written on its schema. -/// -/// The classification keeps its existing meaning: a UUID is still -/// [`Scalar::String`], and a date-time is still [`Scalar::DateTime`]. The -/// annotation lets plugins apply their own format handling, including -/// formats Forge does not recognize. Forge does not validate values. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct ScalarType<'f> { - kind: Scalar, - format: Option<&'f str>, -} - -impl<'f> ScalarType<'f> { - /// Returns the primitive's classification. - pub fn kind(self) -> Scalar { - self.kind - } - - /// Returns the original `format`, including an empty or unrecognized - /// one, or `None` if the schema has no `format`. - pub fn format(self) -> Option<&'f str> { - self.format - } -} - -impl From for ScalarType<'_> { - fn from(kind: Scalar) -> Self { - Self { kind, format: None } - } -} - /// The size of a [`Scalar::Number`], from the `format` of an `integer` or /// `number` schema, as defined by the /// [Format Registry](https://spec.openapis.org/registry/format/). @@ -240,10 +211,7 @@ impl<'f> Type<'f> { /// Returns the type with ID `id` in `forge`. pub(crate) fn new(forge: &'f Forge, id: TypeId) -> Type<'f> { match &forge.types[id.index()] { - TypeData::Scalar(data) => Type::Scalar(ScalarType { - kind: data.kind, - format: data.format.map(|s| forge.strings.get(s)), - }), + TypeData::Scalar(scalar) => Type::Scalar(*scalar), TypeData::Enum(values) => Type::Enum(Enum { forge, values: forge.values.get(*values), @@ -598,7 +566,7 @@ impl NameId { /// What a [`Forge`] stores for a type. The types inside it are stored /// separately, by ID, and its lists in the `Forge`'s arenas. pub(crate) enum TypeData { - Scalar(ScalarData), + Scalar(Scalar), Enum(Span), Array(ArrayData), Object(ObjectData), @@ -607,12 +575,6 @@ pub(crate) enum TypeData { Model(ModelId), } -#[derive(Clone, Copy)] -pub(crate) struct ScalarData { - pub(crate) kind: Scalar, - pub(crate) format: Option, -} - /// What a [`Forge`] stores for an [`EnumValue`]. #[derive(Clone, Copy)] pub(crate) enum EnumData { diff --git a/tests/parse/common.rs b/tests/parse/common.rs index 59a77f406..ed8854cd2 100644 --- a/tests/parse/common.rs +++ b/tests/parse/common.rs @@ -129,7 +129,7 @@ pub fn scalar(ty: Type<'_>) -> Scalar { let Type::Scalar(scalar) = ty else { panic!("want a scalar, got {ty:?}"); }; - scalar.kind() + scalar } /// Returns the values of the enum `ty` is. @@ -194,7 +194,7 @@ pub fn non_null(ty: Type<'_>) -> Type<'_> { let others: Vec = members .iter() .copied() - .filter(|m| !matches!(m, Type::Scalar(s) if s.kind() == Scalar::Null)) + .filter(|m| !matches!(m, Type::Scalar(Scalar::Null))) .collect(); let [other] = others[..] else { panic!("want a union of one type and null, got {ty:?}"); @@ -208,7 +208,7 @@ pub fn non_null(ty: Type<'_>) -> Type<'_> { /// name of the variant. pub fn kind(ty: Type<'_>) -> String { match ty { - Type::Scalar(scalar) => format!("{:?}", scalar.kind()), + Type::Scalar(scalar) => format!("{scalar:?}"), Type::Enum(_) => "Enum".to_string(), Type::Array(_) => "Array".to_string(), Type::Object(_) => "Object".to_string(), diff --git a/tests/parse/requests.rs b/tests/parse/requests.rs index ba88bfa1f..31ad98776 100644 --- a/tests/parse/requests.rs +++ b/tests/parse/requests.rs @@ -32,7 +32,7 @@ fn path_param_is_a_required_request_field() { let zone_id = field(params, "zone_id"); assert!(zone_id.is_required()); assert!( - matches!(zone_id.ty(), Type::Scalar(s) if s.kind() == Scalar::String), + matches!(zone_id.ty(), Type::Scalar(Scalar::String)), "got {:?}", zone_id.ty() ); diff --git a/tests/parse/types/scalars.rs b/tests/parse/types/scalars.rs index f0b1f066f..810e9954a 100644 --- a/tests/parse/types/scalars.rs +++ b/tests/parse/types/scalars.rs @@ -1,96 +1,9 @@ -use forge::{Number, Scalar, Type}; +use forge::{Number, Scalar}; use crate::common::{ field, model_of, object, parse, responding_with, responding_with_schemas, response_type, scalar, union, }; -fn format(ty: Type<'_>) -> Option<&str> { - let Type::Scalar(scalar) = ty else { - panic!("want a scalar, got {ty:?}"); - }; - scalar.format() -} - -#[test] -fn original_scalar_formats_are_kept_without_changing_the_classification() { - for (schema, kind, want) in [ - (r#"{"type":"string"}"#, Scalar::String, None), - (r#"{"type":"string","format":""}"#, Scalar::String, Some("")), - (r#"{"type":"string","format":"uuid"}"#, Scalar::String, Some("uuid")), - ( - r#"{"type":"string","format":"custom-ID"}"#, - Scalar::String, - Some("custom-ID"), - ), - ( - r#"{"type":"string","format":"date-time"}"#, - Scalar::DateTime, - Some("date-time"), - ), - (r#"{"type":"string","format":"date"}"#, Scalar::Date, Some("date")), - (r#"{"type":"string","format":"byte"}"#, Scalar::Bytes, Some("byte")), - (r#"{"type":"string","format":"binary"}"#, Scalar::Binary, Some("binary")), - (r#"{"type":"string","contentEncoding":"base64"}"#, Scalar::Bytes, None), - ( - r#"{"type":"string","format":"uuid","contentEncoding":"base64"}"#, - Scalar::Bytes, - Some("uuid"), - ), - ( - r#"{"type":"integer","format":"int32"}"#, - Scalar::Number(Number::Int32), - Some("int32"), - ), - ( - r#"{"type":"number","format":"decimal"}"#, - Scalar::Number(Number::Double), - Some("decimal"), - ), - ] { - let forge = parse(&responding_with(schema)); - let ty = response_type(&forge, "a.get"); - assert_eq!(scalar(ty), kind, "{schema}"); - assert_eq!(format(ty), want, "{schema}"); - } -} - -#[test] -fn strings_with_different_formats_do_not_share_annotations() { - let forge = parse(&responding_with_schemas( - r##"{"type":"object","properties":{ - "id":{"$ref":"#/components/schemas/ID"}, - "uri":{"type":"string","format":"uri"}, - "plain":{"type":"string"}, - "empty":{"type":"string","format":""}, - "id_again":{"type":"string","format":"uuid"} - }}"##, - r#"{"ID":{"type":"string","format":"uuid"}}"#, - )); - let object = object(response_type(&forge, "a.get")); - assert_eq!(format(model_of(field(object, "id").ty()).ty()), Some("uuid")); - for (name, want) in [ - ("uri", Some("uri")), - ("plain", None), - ("empty", Some("")), - ("id_again", Some("uuid")), - ] { - assert_eq!(format(field(object, name).ty()), want, "{name}"); - } -} - -#[test] -fn union_deduplication_keeps_distinct_formats() { - let forge = parse(&responding_with( - r#"{"anyOf":[ - {"type":"string","format":"uuid"}, - {"type":"string","format":"uri"}, - {"type":"string","format":"uuid"} - ]}"#, - )); - let formats: Vec<_> = union(response_type(&forge, "a.get")).members().map(format).collect(); - assert_eq!(formats, [Some("uuid"), Some("uri")]); -} - #[test] fn primitive_types() { for (schema, want) in [ @@ -156,7 +69,7 @@ fn string_formats() { (r#"{"type": "string", "format": "date"}"#, Scalar::Date), (r#"{"type": "string", "format": "byte"}"#, Scalar::Bytes), (r#"{"type": "string", "format": "binary"}"#, Scalar::Binary), - (r#"{"type": "string", "format": "uuid"}"#, Scalar::String), + (r#"{"type": "string", "format": "uuid"}"#, Scalar::Uuid), (r#"{"type": "string", "format": "uri"}"#, Scalar::String), ] { let forge = parse(&responding_with(schema)); @@ -165,6 +78,63 @@ fn string_formats() { } } +#[test] +fn uuid_references_and_plain_strings_keep_their_own_scalar_types() { + let forge = parse(&responding_with_schemas( + r##"{"type":"object","properties":{ + "id":{"$ref":"#/components/schemas/ID"}, + "plain":{"type":"string"}, + "empty":{"type":"string","format":""}, + "custom":{"type":"string","format":"custom-ID"}, + "id_again":{"type":"string","format":"uuid"} + }}"##, + r#"{"ID":{"type":"string","format":"uuid"}}"#, + )); + let object = object(response_type(&forge, "a.get")); + assert_eq!(scalar(model_of(field(object, "id").ty()).ty()), Scalar::Uuid); + assert_eq!(scalar(field(object, "id_again").ty()), Scalar::Uuid); + for name in ["plain", "empty", "custom"] { + assert_eq!(scalar(field(object, name).ty()), Scalar::String, "{name}"); + } +} + +#[test] +fn union_deduplication_keeps_uuids_distinct_from_plain_strings() { + let forge = parse(&responding_with( + r#"{"anyOf":[ + {"type":"string","format":"uuid"}, + {"type":"string"}, + {"type":"string","format":"uuid"}, + {"type":"string","format":"uri"} + ]}"#, + )); + let members: Vec<_> = union(response_type(&forge, "a.get")).members().map(scalar).collect(); + assert_eq!(members, [Scalar::Uuid, Scalar::String]); +} + +#[test] +fn uuid_format_only_classifies_strings_and_survives_nullability() { + for schema in [ + r#"{"type":"string","format":"uuid","nullable":true}"#, + r#"{"type":["string","null"],"format":"uuid"}"#, + ] { + let forge = parse(&responding_with(schema)); + let members: Vec<_> = union(response_type(&forge, "a.get")).members().map(scalar).collect(); + assert_eq!(members, [Scalar::Uuid, Scalar::Null], "{schema}"); + } + for (schema, want) in [ + (r#"{"type":"integer","format":"uuid"}"#, Scalar::Number(Number::Int)), + (r#"{"type":"boolean","format":"uuid"}"#, Scalar::Boolean), + ( + r#"{"type":"string","format":"uuid","contentEncoding":"base64"}"#, + Scalar::Uuid, + ), + ] { + let forge = parse(&responding_with(schema)); + assert_eq!(scalar(response_type(&forge, "a.get")), want, "{schema}"); + } +} + #[test] fn base64_content_encoding_is_bytes() { let forge = parse( diff --git a/tests/wasm.rs b/tests/wasm.rs index 596d39539..7278ead1d 100644 --- a/tests/wasm.rs +++ b/tests/wasm.rs @@ -227,10 +227,7 @@ fn reads_all_of_the_forge() { "min items Some(0) max items Some(3)", "min items Some(1) max items Some(50)", "min items None max items None", - "String format Some(\"uuid\")", - "String format Some(\"custom-ID\")", - "String format Some(\"\")", - "String format None", + "Uuid", "Binary", "description \"The photo.\"", "union exclusive false", diff --git a/tests/wasm/dump.rs b/tests/wasm/dump.rs index 95464376b..86c788b1d 100644 --- a/tests/wasm/dump.rs +++ b/tests/wasm/dump.rs @@ -161,11 +161,7 @@ fn content(o: &mut String, content: Content<'_>) { /// Writes `ty`, and the location in `method` of each field it has. fn ty(o: &mut String, depth: usize, method: Option>, ty: Type<'_>) { match ty { - Type::Scalar(scalar) => line( - o, - depth, - format_args!("{:?} format {:?}", scalar.kind(), scalar.format()), - ), + Type::Scalar(scalar) => line(o, depth, format_args!("{scalar:?}")), Type::Enum(values) => line(o, depth, format_args!("enum {:?}", values.values().collect::>())), Type::Array(array) => { line( diff --git a/tests/wasm/ts/src/lib.rs b/tests/wasm/ts/src/lib.rs index bc21c6f77..b289aa574 100644 --- a/tests/wasm/ts/src/lib.rs +++ b/tests/wasm/ts/src/lib.rs @@ -278,31 +278,29 @@ impl Forge { return data; } let data = match host::get_type(id) { - host::TypeInfo::Scalar(scalar) => TypeData::Scalar(ScalarData { - kind: match scalar.kind { - host::Scalar::Unknown => Scalar::Unknown, - host::Scalar::Null => Scalar::Null, - host::Scalar::Boolean => Scalar::Boolean, - host::Scalar::String => Scalar::String, - host::Scalar::Number(number) => Scalar::Number(match number { - host::Number::Int => Number::Int, - host::Number::Int8 => Number::Int8, - host::Number::Int16 => Number::Int16, - host::Number::Int32 => Number::Int32, - host::Number::Int64 => Number::Int64, - host::Number::Uint8 => Number::Uint8, - host::Number::Uint16 => Number::Uint16, - host::Number::Uint32 => Number::Uint32, - host::Number::Uint64 => Number::Uint64, - host::Number::Float => Number::Float, - host::Number::Double => Number::Double, - }), - host::Scalar::DateTime => Scalar::DateTime, - host::Scalar::Date => Scalar::Date, - host::Scalar::Bytes => Scalar::Bytes, - host::Scalar::Binary => Scalar::Binary, - }, - format: scalar.format, + host::TypeInfo::Scalar(scalar) => TypeData::Scalar(match scalar { + host::Scalar::Unknown => Scalar::Unknown, + host::Scalar::Null => Scalar::Null, + host::Scalar::Boolean => Scalar::Boolean, + host::Scalar::String => Scalar::String, + host::Scalar::Uuid => Scalar::Uuid, + host::Scalar::Number(number) => Scalar::Number(match number { + host::Number::Int => Number::Int, + host::Number::Int8 => Number::Int8, + host::Number::Int16 => Number::Int16, + host::Number::Int32 => Number::Int32, + host::Number::Int64 => Number::Int64, + host::Number::Uint8 => Number::Uint8, + host::Number::Uint16 => Number::Uint16, + host::Number::Uint32 => Number::Uint32, + host::Number::Uint64 => Number::Uint64, + host::Number::Float => Number::Float, + host::Number::Double => Number::Double, + }), + host::Scalar::DateTime => Scalar::DateTime, + host::Scalar::Date => Scalar::Date, + host::Scalar::Bytes => Scalar::Bytes, + host::Scalar::Binary => Scalar::Binary, }), host::TypeInfo::Enum(values) => TypeData::Enum( values @@ -432,7 +430,7 @@ struct ModelData { } enum TypeData { - Scalar(ScalarData), + Scalar(Scalar), Enum(Vec), Array(ArrayData), Object(ObjectData), @@ -441,11 +439,6 @@ enum TypeData { Model(u32), } -struct ScalarData { - kind: Scalar, - format: Option, -} - enum EnumData { String(String), Number(f64), @@ -816,7 +809,7 @@ impl fmt::Debug for Model<'_> { #[derive(Clone, Copy, Debug)] pub enum Type<'f> { - Scalar(ScalarType<'f>), + Scalar(Scalar), Enum(Enum<'f>), Array(Array<'f>), Object(Object<'f>), @@ -825,34 +818,13 @@ pub enum Type<'f> { Model(Model<'f>), } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct ScalarType<'f> { - kind: Scalar, - format: Option<&'f str>, -} - -impl<'f> ScalarType<'f> { - pub fn kind(self) -> Scalar { - self.kind - } - - pub fn format(self) -> Option<&'f str> { - self.format - } -} - -impl From for ScalarType<'_> { - fn from(kind: Scalar) -> Self { - Self { kind, format: None } - } -} - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Scalar { Unknown, Null, Boolean, String, + Uuid, Number(Number), DateTime, Date, @@ -937,10 +909,7 @@ pub struct Discriminator<'f> { impl<'f> Type<'f> { fn new(forge: &'f Forge, id: u32) -> Type<'f> { match forge.type_data(id) { - TypeData::Scalar(scalar) => Type::Scalar(ScalarType { - kind: scalar.kind, - format: scalar.format.as_deref(), - }), + TypeData::Scalar(scalar) => Type::Scalar(*scalar), TypeData::Enum(values) => Type::Enum(Enum { values }), TypeData::Array(data) => Type::Array(Array { forge, data }), TypeData::Object(data) => Type::Object(Object { forge, id, data }), diff --git a/tests/wasm/ts/wit/forge.wit b/tests/wasm/ts/wit/forge.wit index c82d07ea8..af93aa10e 100644 --- a/tests/wasm/ts/wit/forge.wit +++ b/tests/wasm/ts/wit/forge.wit @@ -128,13 +128,7 @@ interface forge { } variant scalar { - unknown, null, boolean, %string, number(number), date-time, date, bytes, binary, - } - - record scalar-info { - kind: scalar, - /// The original `format`, including an empty or unrecognized one. - format: option, + unknown, null, boolean, %string, number(number), date-time, date, bytes, binary, uuid, } variant enum-value { @@ -186,7 +180,7 @@ interface forge { } variant type-info { - scalar(scalar-info), + scalar(scalar), %enum(list), array(array-info), object(object-info),