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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/forge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ 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. 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.

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.
Expand Down
6 changes: 4 additions & 2 deletions packages/forge/plugin/forge.wit
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -128,7 +128,7 @@ interface forge {
}

variant scalar {
unknown, null, boolean, %string, number(number), date-time, date, bytes, binary,
unknown, null, boolean, %string, number(number), date-time, date, bytes, binary, uuid,
}

variant enum-value {
Expand All @@ -154,6 +154,8 @@ interface forge {

record array-info {
elem: type-id,
/// The `minItems`, or none if it is absent; zero is kept.
min-items: option<u64>,
/// The `maxItems`, or none if the array has no limit.
max-items: option<u64>,
}
Expand Down
10 changes: 7 additions & 3 deletions packages/forge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -294,7 +294,7 @@ export type Type = ScalarType | EnumType | ArrayType | ObjectType | UnionType |
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 };

Expand Down Expand Up @@ -322,6 +322,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;
}
Expand Down Expand Up @@ -966,13 +968,15 @@ function makeType(forge: ForgeImpl, id: host.TypeId): Type {
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',
get elem() {
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),
};
}
Expand Down
29 changes: 29 additions & 0 deletions packages/forge/test/fixtures/metadata-host.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// 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: { 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() {
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];
}
53 changes: 53 additions & 0 deletions packages/forge/test/plugin.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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, 'uuid');
return [];
},
}).generate();
});

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' },
{ kind: 'scalar', scalar: 'uuid' },
{ kind: 'scalar', scalar: 'date-time' },
{ kind: 'scalar', scalar: 'bytes' },
{ kind: 'scalar', scalar: 'number', number: 'int32' },
],
);
return [];
},
}).generate();
});
20 changes: 19 additions & 1 deletion packages/forge/test/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' && type.scalar === 'uuid') {
const uuid: 'uuid' = type.scalar;
// @ts-expect-error Scalar types are read-only.
type.scalar = 'string';
return uuid;
}
return undefined;
}

const f: File = file('a.ts');
const m: Module = module('m.ts');
Expand Down Expand Up @@ -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 };
2 changes: 1 addition & 1 deletion src/bin/forge/ts/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1533,6 +1533,7 @@ impl<'d> Builder<'d> {
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,
Expand Down Expand Up @@ -1567,6 +1568,7 @@ impl<'d> Builder<'d> {
};
self.push(TypeData::Array(ArrayData {
elem,
min_items: schema.min_items,
max_items: schema.max_items,
}))
}
Expand Down
5 changes: 5 additions & 0 deletions src/link/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,8 @@ pub(super) struct SchemaObject {
pub(super) unevaluated_properties: Schema,
pub(super) required: Span<Str>,
pub(super) items: Option<Schema>,
/// The `minItems`, or `None` without it.
pub(super) min_items: Option<u64>,
/// The `maxItems`, or `None` without it.
pub(super) max_items: Option<u64>,
/// Whether the schema has `prefixItems`: it is a tuple, which is not
Expand Down Expand Up @@ -428,6 +430,7 @@ pub(super) enum SchemaType {
pub(super) enum Format {
DateTime,
Date,
Uuid,
Byte,
Binary,
Int8,
Expand Down Expand Up @@ -1602,6 +1605,7 @@ keys! {
UnevaluatedProperties = "unevaluatedProperties",
Required = "required",
Items = "items",
MinItems = "minItems",
MaxItems = "maxItems",
PrefixItems = "prefixItems",
AllOf = "allOf",
Expand Down Expand Up @@ -1654,6 +1658,7 @@ impl ReadObject for SchemaObject {
schema.required = map.next_value_seed(SeqOfStrsSeed(doc))?;
}
SchemaKey::Items => schema.items = map.next_value_seed(seed::<Nullable<Schema>>(doc))?,
SchemaKey::MinItems => schema.min_items = Some(map.next_value::<Count>()?.0),
SchemaKey::MaxItems => schema.max_items = Some(map.next_value::<Count>()?.0),
SchemaKey::PrefixItems => schema.prefix_items = present(&mut map)?,
SchemaKey::AllOf => schema.all_of = map.next_value_seed(seed::<Seq<Schema>>(doc))?,
Expand Down
8 changes: 6 additions & 2 deletions src/plugin/wasm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -189,7 +189,7 @@ interface forge {
}

variant scalar {
unknown, null, boolean, %string, number(number), date-time, date, bytes, binary,
unknown, null, boolean, %string, number(number), date-time, date, bytes, binary, uuid,
}

variant enum-value {
Expand All @@ -215,6 +215,8 @@ interface forge {

record array-info {
elem: type-id,
/// The `minItems`, or none if it is absent; zero is kept.
min-items: option<u64>,
/// The `maxItems`, or none if the array has no limit.
max-items: option<u64>,
}
Expand Down Expand Up @@ -549,6 +551,7 @@ impl wit::Host for State {
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,
Expand Down Expand Up @@ -581,6 +584,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 {
Expand Down
15 changes: 14 additions & 1 deletion src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -251,6 +253,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<u64> {
self.data.min_items
}

/// Returns the most elements the array can have, or `None` if it has
/// no limit.
///
Expand Down Expand Up @@ -575,6 +586,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<u64>,
/// The `maxItems`, if the array has a limit.
pub(crate) max_items: Option<u64>,
}
Expand Down
53 changes: 53 additions & 0 deletions tests/parse/types/arrays.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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": {}}"#));
Expand Down
Loading
Loading