diff --git a/ark/jsonschema/CHANGELOG.md b/ark/jsonschema/CHANGELOG.md new file mode 100644 index 0000000000..4f4fb7479e --- /dev/null +++ b/ark/jsonschema/CHANGELOG.md @@ -0,0 +1,13 @@ +# @arktype/jsonschema + +## 1.0.0 + +### Initial Release + +Released the initial implementation of the package. + +Known limitations: + +- No `dependencies` support +- No `if`/`else`/`then` support +- `multipleOf` only supports integers diff --git a/ark/jsonschema/README.md b/ark/jsonschema/README.md new file mode 100644 index 0000000000..a89bc13962 --- /dev/null +++ b/ark/jsonschema/README.md @@ -0,0 +1,51 @@ +# @arktype/jsonschema + +## What is it? + +@arktype/jsonschema is a package that allows converting from a JSON Schema schema, to an ArkType type. For example: + +```js +import { parseJsonSchema } from "@ark/jsonschema" + +const t = parseJsonSchema({ type: "string", minLength: 5, maxLength: 10 }) +``` + +is equivalent to: + +```js +import { type } from "arktype" + +const t = type("5<=string<=10") +``` + +This enables easy adoption of ArkType for people who currently have JSON Schema based runtime validation in their codebase. + +Where possible, the library also has TypeScript type inference so that the runtime validation remains typesafe. Extending on the above example, this means that the return type of the below `parseString` function would be correctly inferred as `string`: + +```ts +const assertIsString = (data: unknown) + return t.assert(data) +``` + +## Extra Type Safety + +If you wish to ensure that your JSON Schema schemas are valid, you can do this too! Simply import the relevant `Schema` type from `@ark/jsonschema` like so: + +```ts +import type { JsonSchema } from "arktype" + +const integerSchema: JsonSchema.Numeric = { + type: "integer", + multipleOf: "3" // errors stating that 'multipleOf' must be a number +} +``` + +Note that for string schemas exclusively, you must import the schema type from `@ark/jsonschema` instead of `arktype`. This is because `@ark/jsonschema` doesn't yet support the `format` keyword whilst `arktype` does. + +```ts +import type { StringSchema } from "@ark/jsonschema" +const stringSchema: StringSchema = { + type: "string", + minLength: "3" // errors stating that 'minLength' must be a number +} +``` diff --git a/ark/jsonschema/__tests__/array.test.ts b/ark/jsonschema/__tests__/array.test.ts new file mode 100644 index 0000000000..07c16ff134 --- /dev/null +++ b/ark/jsonschema/__tests__/array.test.ts @@ -0,0 +1,141 @@ +import { attest, contextualize } from "@ark/attest" +import { parseJsonSchema } from "@ark/jsonschema" + +// TODO: Add compound tests for arrays (e.g. maxItems AND minItems ) +// TODO: Add explicit test for negative length constraint failing (since explicitly mentioned in spec) + +contextualize(() => { + it("type array", () => { + const t = parseJsonSchema({ type: "array" }) + attest(t.json).snap({ proto: "Array" }) + }) + + it("items & prefixItems", () => { + const tItems = parseJsonSchema({ type: "array", items: { type: "string" } }) + attest(tItems.json).snap({ proto: "Array", sequence: "string" }) + attest(tItems.allows(["foo"])).equals(true) + attest(tItems.allows(["foo", "bar"])).equals(true) + attest(tItems.allows(["foo", 3, "bar"])).equals(false) + + const tItemsArr = parseJsonSchema({ + type: "array", + items: [{ type: "string" }, { type: "number" }] + }) + attest(tItemsArr.json).snap({ + proto: "Array", + sequence: { prefix: ["string", "number"] }, + exactLength: 2 + }) + attest(tItemsArr.allows(["foo", 1])).equals(true) + attest(tItemsArr.allows([1, "foo"])).equals(false) + attest(tItemsArr.allows(["foo", 1, true])).equals(false) + + const tPrefixItems = parseJsonSchema({ + type: "array", + prefixItems: [{ type: "string" }, { type: "number" }] + }) + attest(tPrefixItems.json).snap({ + proto: "Array", + sequence: { prefix: ["string", "number"] }, + exactLength: 2 + }) + }) + + it("additionalItems", () => { + const tItemsVariadic = parseJsonSchema({ + type: "array", + items: [{ type: "string" }, { type: "number" }], + additionalItems: { type: "boolean" } + }) + attest(tItemsVariadic.json).snap({ + minLength: 2, + proto: "Array", + sequence: { + prefix: ["string", "number"], + variadic: [{ unit: false }, { unit: true }] + } + }) + attest(tItemsVariadic.allows(["foo", 1])).equals(true) + attest(tItemsVariadic.allows([1, "foo", true])).equals(false) + attest(tItemsVariadic.allows([false, "foo", 1])).equals(false) + attest(tItemsVariadic.allows(["foo", 1, true])).equals(true) + }) + + it("contains", () => { + const tContains = parseJsonSchema({ + type: "array", + contains: { type: "number" } + }) + const predicateRef = + tContains.internal.firstReferenceOfKindOrThrow( + "predicate" + ).serializedPredicate + attest(tContains.json).snap({ + proto: "Array", + predicate: [predicateRef] + }) + attest(tContains.allows([])).equals(false) + attest(tContains.allows([1, 2, 3])).equals(true) + attest(tContains.allows(["foo", "bar", "baz"])).equals(false) + }) + + it("maxItems", () => { + const tMaxItems = parseJsonSchema({ + type: "array", + maxItems: 5 + }) + attest(tMaxItems.json).snap({ + proto: "Array", + maxLength: 5 + }) + + attest(() => parseJsonSchema({ type: "array", maxItems: -1 })).throws( + "maxItems must be an integer >= 0" + ) + }) + + it("minItems", () => { + const tMinItems = parseJsonSchema({ + type: "array", + minItems: 5 + }) + attest(tMinItems.json).snap({ + proto: "Array", + minLength: 5 + }) + + attest(() => parseJsonSchema({ type: "array", minItems: -1 })).throws( + "minItems must be an integer >= 0" + ) + }) + + it("uniqueItems", () => { + const tUniqueItems = parseJsonSchema({ + type: "array", + uniqueItems: true + }) + const predicateRef = + tUniqueItems.internal.firstReferenceOfKindOrThrow( + "predicate" + ).serializedPredicate + attest(tUniqueItems.json).snap({ + proto: "Array", + predicate: [predicateRef] + }) + attest(tUniqueItems.allows([1, 2, 3])).equals(true) + attest(tUniqueItems.allows([1, 1, 2])).equals(false) + attest( + tUniqueItems.allows([ + { foo: { bar: ["baz", { qux: "quux" }] } }, + { foo: { bar: ["baz", { qux: "quux" }] } } + ]) + ).equals(false) + attest( + // JSON Schema specifies that arrays must be same order to be classified as equal + tUniqueItems.allows([ + { foo: { bar: ["baz", { qux: "quux" }] } }, + { foo: { bar: [{ qux: "quux" }, "baz"] } } + ]) + ).equals(true) + }) +}) diff --git a/ark/jsonschema/__tests__/number.test.ts b/ark/jsonschema/__tests__/number.test.ts new file mode 100644 index 0000000000..8d3b64a2f7 --- /dev/null +++ b/ark/jsonschema/__tests__/number.test.ts @@ -0,0 +1,89 @@ +import { attest, contextualize } from "@ark/attest" +import { parseJsonSchema } from "@ark/jsonschema" + +// TODO: Compound tests for number (e.g. 'minimum' AND 'maximum') + +contextualize(() => { + it("type number", () => { + const jsonSchema = { type: "number" } as const + const expectedArkTypeSchema = { domain: "number" } as const + + const parsedNumberValidator = parseJsonSchema(jsonSchema) + attest(parsedNumberValidator.json).snap(expectedArkTypeSchema) + }) + + it("type integer", () => { + const t = parseJsonSchema({ type: "integer" }) + attest(t.json).snap({ domain: "number", divisor: 1 }) + }) + + it("maximum & exclusiveMaximum", () => { + const tMax = parseJsonSchema({ + type: "number", + maximum: 5 + }) + attest(tMax.json).snap({ + domain: "number", + max: 5 + }) + + const tExclMax = parseJsonSchema({ + type: "number", + exclusiveMaximum: 5 + }) + attest(tExclMax.json).snap({ + domain: "number", + max: { rule: 5, exclusive: true } + }) + + attest(() => + parseJsonSchema({ + type: "number", + maximum: 5, + exclusiveMaximum: 5 + }) + ).throws( + "ParseError: Provided number JSON Schema cannot have 'maximum' and 'exclusiveMaximum" + ) + }) + + it("minimum & exclusiveMinimum", () => { + const tMin = parseJsonSchema({ type: "number", minimum: 5 }) + attest(tMin.json).snap({ domain: "number", min: 5 }) + + const tExclMin = parseJsonSchema({ + type: "number", + exclusiveMinimum: 5 + }) + attest(tExclMin.json).snap({ + domain: "number", + min: { rule: 5, exclusive: true } + }) + + attest(() => + parseJsonSchema({ + type: "number", + minimum: 5, + exclusiveMinimum: 5 + }) + ).throws( + "ParseError: Provided number JSON Schema cannot have 'minimum' and 'exclusiveMinimum" + ) + }) + + it("multipleOf", () => { + const t = parseJsonSchema({ type: "number", multipleOf: 5 }) + attest(t.json).snap({ domain: "number", divisor: 5 }) + + const tInt = parseJsonSchema({ + type: "integer", + multipleOf: 5 + }) + attest(tInt.json).snap({ domain: "number", divisor: 5 }) + + // JSON Schema allows decimal multipleOf, but ArkType doesn't. + attest(() => parseJsonSchema({ type: "number", multipleOf: 5.5 })).throws( + "AggregateError: multipleOf must be an integer" + ) + }) +}) diff --git a/ark/jsonschema/__tests__/object.test.ts b/ark/jsonschema/__tests__/object.test.ts new file mode 100644 index 0000000000..ff20b7f171 --- /dev/null +++ b/ark/jsonschema/__tests__/object.test.ts @@ -0,0 +1,103 @@ +import { attest, contextualize } from "@ark/attest" +import { parseJsonSchema } from "@ark/jsonschema" + +// TODO: Add compound tests for objects (e.g. 'maxProperties' AND 'minProperties') + +contextualize(() => { + it("type object", () => { + const t = parseJsonSchema({ type: "object" }) + attest(t.json).snap({ domain: "object" }) + }) + + it("maxProperties", () => { + const tMaxProperties = parseJsonSchema({ + type: "object", + maxProperties: 1 + }) + attest(tMaxProperties.json).snap({ domain: "object" }) + attest(tMaxProperties.allows({})).equals(true) + attest(tMaxProperties.allows({ foo: 1 })).equals(true) + attest(tMaxProperties.allows({ foo: 1, bar: 2 })).equals(false) + attest(tMaxProperties.allows({ foo: 1, bar: 2, baz: 3 })).equals(false) + }) + + it("minProperties", () => { + const tMinProperties = parseJsonSchema({ + type: "object", + minProperties: 2 + }) + attest(tMinProperties.json).snap({ domain: "object" }) + attest(tMinProperties.allows({})).equals(false) + attest(tMinProperties.allows({ foo: 1 })).equals(false) + attest(tMinProperties.allows({ foo: 1, bar: 2 })).equals(true) + attest(tMinProperties.allows({ foo: 1, bar: 2, baz: 3 })).equals(true) + }) + + it("properties & required", () => { + const tRequired = parseJsonSchema({ + type: "object", + properties: { + foo: { type: "string" }, + bar: { type: "number" } + }, + required: ["foo"] + }) + attest(tRequired.json).snap({ + domain: "object", + required: [{ key: "foo", value: "string" }], + optional: [{ key: "bar", value: "number" }] + }) + + attest(() => parseJsonSchema({ type: "object", required: ["foo"] })).throws( + "'required' array is present but 'properties' object is missing" + ) + attest(() => + parseJsonSchema({ + type: "object", + properties: { foo: { type: "string" } }, + required: ["bar"] + }) + ).throws( + "Key 'bar' in 'required' array is not present in 'properties' object" + ) + attest(() => + parseJsonSchema({ + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo", "foo"] + }) + ).throws("Duplicate keys in 'required' array") + }) + + it("additionalProperties", () => { + const tAdditionalProperties = parseJsonSchema({ + type: "object", + additionalProperties: { type: "number" } + }) + attest(tAdditionalProperties.json).snap({ + domain: "object", + additional: "number" + }) + attest(tAdditionalProperties.allows({})).equals(true) + attest(tAdditionalProperties.allows({ foo: 1 })).equals(true) + attest(tAdditionalProperties.allows({ foo: 1, bar: 2 })).equals(true) + attest(tAdditionalProperties.allows({ foo: 1, bar: "2" })).equals(false) + }) + + it("patternProperties", () => { + const tPatternProperties = parseJsonSchema({ + type: "object", + patternProperties: { + "^[a-z]+$": { type: "string" } + } + }) + attest(tPatternProperties.json).snap({ + domain: "object", + pattern: [{ key: "^[a-z]+$", value: "string" }] + }) + attest(tPatternProperties.allows({})).equals(true) + attest(tPatternProperties.allows({ foo: "bar" })).equals(true) + attest(tPatternProperties.allows({ foo: 1 })).equals(false) + attest(tPatternProperties.allows({ "123": "bar" })).equals(false) + }) +}) diff --git a/ark/jsonschema/__tests__/string.test.ts b/ark/jsonschema/__tests__/string.test.ts new file mode 100644 index 0000000000..00e00ac9d2 --- /dev/null +++ b/ark/jsonschema/__tests__/string.test.ts @@ -0,0 +1,58 @@ +import { attest, contextualize } from "@ark/attest" +import { parseJsonSchema } from "@ark/jsonschema" + +// TODO: Add compound tests for strings (e.g. maxLength AND pattern) +// TODO: Add explicit test for negative length constraint failing (since explicitly mentioned in spec) + +contextualize(() => { + it("type string", () => { + const t = parseJsonSchema({ type: "string" }) + attest(t.json).snap({ domain: "string" }) + }) + + it("maxLength", () => { + const tMaxLength = parseJsonSchema({ + type: "string", + maxLength: 5 + }) + attest(tMaxLength.json).snap({ + domain: "string", + maxLength: 5 + }) + }) + + it("minLength", () => { + const tMinLength = parseJsonSchema({ + type: "string", + minLength: 5 + }) + attest(tMinLength.json).snap({ + domain: "string", + minLength: 5 + }) + }) + + it("pattern", () => { + const tPatternString = parseJsonSchema({ + type: "string", + pattern: "es" + }) + attest(tPatternString.json).snap({ + domain: "string", + pattern: ["es"] + }) + // JSON Schema explicitly specifies that regexes MUST NOT be implicitly anchored + // https://json-schema.org/draft-07/draft-handrews-json-schema-validation-01#rfc.section.4.3 + attest(tPatternString.allows("expression")).equals(true) + + const tPatternRegExp = parseJsonSchema({ + type: "string", + pattern: /es/ + }) + attest(tPatternRegExp.json).snap({ + domain: "string", + pattern: ["es"] // strips the outer slashes + }) + attest(tPatternRegExp.allows("expression")).equals(true) + }) +}) diff --git a/ark/jsonschema/any.ts b/ark/jsonschema/any.ts new file mode 100644 index 0000000000..5e45be6771 --- /dev/null +++ b/ark/jsonschema/any.ts @@ -0,0 +1,17 @@ +import { throwParseError } from "@ark/util" +import { type JsonSchema, type Type, type } from "arktype" + +export const parseJsonSchemaAnyKeywords = ( + jsonSchema: JsonSchema +): Type | undefined => { + if ("const" in jsonSchema) { + if ("enum" in jsonSchema) { + throwParseError( + "Provided JSON Schema cannot have both 'const' and 'enum' keywords." + ) + } + return type.unit(jsonSchema.const) + } + + if ("enum" in jsonSchema) return type.enumerated(jsonSchema.enum) +} diff --git a/ark/jsonschema/array.ts b/ark/jsonschema/array.ts new file mode 100644 index 0000000000..775fe8b642 --- /dev/null +++ b/ark/jsonschema/array.ts @@ -0,0 +1,115 @@ +import { + rootSchema, + type Intersection, + type Predicate, + type TraversalContext +} from "@ark/schema" +import { printable } from "@ark/util" +import type { Out, Type } from "arktype" + +import { parseJsonSchema } from "./json.ts" +import { JsonSchemaScope, type ArraySchema } from "./scope.ts" + +const deepNormalize = (data: unknown): unknown => + typeof data === "object" ? + data === null ? null + : Array.isArray(data) ? data.map(item => deepNormalize(item)) + : Object.fromEntries( + Object.entries(data) + .map(([k, v]) => [k, deepNormalize(v)] as const) + .sort((l, r) => (l[0] > r[0] ? 1 : -1)) + ) + : data + +const arrayItemsAreUnique = ( + array: readonly unknown[], + ctx: TraversalContext +) => { + const seen: Record<string, true> = {} + const duplicates: unknown[] = [] + for (const item of array) { + const stringified = JSON.stringify(deepNormalize(item)) + if (stringified in seen) duplicates.push(item) + else seen[stringified] = true + } + return duplicates.length === 0 ? + true + : ctx.reject({ + expected: "unique array items", + actual: `duplicated at elements ${printable(duplicates)}` + }) +} + +const arrayContainsItemMatchingSchema = ( + array: readonly unknown[], + schema: Type, + ctx: TraversalContext +) => + array.some(item => schema.allows(item)) === true ? + true + : ctx.reject({ + expected: `an array containing at least one item matching 'contains' schema of ${schema.description}`, + actual: printable(array) + }) + +export const validateJsonSchemaArray: Type< + (In: ArraySchema) => Out<Type<unknown[], {}>>, + any +> = JsonSchemaScope.ArraySchema.pipe((jsonSchema, ctx) => { + const arktypeArraySchema: Intersection.Schema<Array<unknown>> = { + proto: "Array" + } + + if ("prefixItems" in jsonSchema) { + if ("items" in jsonSchema) { + ctx.reject({ + expected: "a valid array JSON Schema", + actual: + "an array JSON Schema with mutually exclusive keys 'prefixItems' and 'items' specified" + }) + } else jsonSchema.items = jsonSchema.prefixItems + } + + if ("items" in jsonSchema) { + if (Array.isArray(jsonSchema.items)) { + arktypeArraySchema.sequence = { + prefix: jsonSchema.items.map(item => parseJsonSchema(item).internal) + } + + if ("additionalItems" in jsonSchema) { + if (jsonSchema.additionalItems === false) + arktypeArraySchema.exactLength = jsonSchema.items.length + else { + arktypeArraySchema.sequence = { + ...arktypeArraySchema.sequence, + variadic: parseJsonSchema(jsonSchema.additionalItems).internal + } + } + } + } else { + arktypeArraySchema.sequence = { + variadic: parseJsonSchema(jsonSchema.items).json + } + } + } + + if ("maxItems" in jsonSchema) + arktypeArraySchema.maxLength = jsonSchema.maxItems + if ("minItems" in jsonSchema) + arktypeArraySchema.minLength = jsonSchema.minItems + + const predicates: Predicate.Schema[] = [] + if ("uniqueItems" in jsonSchema && jsonSchema.uniqueItems === true) + predicates.push((arr: unknown[], ctx) => arrayItemsAreUnique(arr, ctx)) + + if ("contains" in jsonSchema) { + const parsedContainsJsonSchema = parseJsonSchema(jsonSchema.contains) + predicates.push((arr: unknown[], ctx) => + arrayContainsItemMatchingSchema(arr, parsedContainsJsonSchema, ctx) + ) + } + + arktypeArraySchema.predicate = predicates + + return rootSchema(arktypeArraySchema) as never +}) diff --git a/ark/jsonschema/composition.ts b/ark/jsonschema/composition.ts new file mode 100644 index 0000000000..7ca32134d8 --- /dev/null +++ b/ark/jsonschema/composition.ts @@ -0,0 +1,66 @@ +import { printable } from "@ark/util" +import { type, type JsonSchema, type Type } from "arktype" +import { parseJsonSchema } from "./json.ts" + +const validateAllOfJsonSchemas = (jsonSchemas: readonly JsonSchema[]): Type => + jsonSchemas + .map(jsonSchema => parseJsonSchema(jsonSchema)) + .reduce((acc, validator) => acc.and(validator)) + +const validateAnyOfJsonSchemas = (jsonSchemas: readonly JsonSchema[]): Type => + jsonSchemas + .map(jsonSchema => parseJsonSchema(jsonSchema)) + .reduce((acc, validator) => acc.or(validator)) + +const validateNotJsonSchema = (jsonSchema: JsonSchema) => { + const inner = parseJsonSchema(jsonSchema) + return type("unknown").narrow((data, ctx) => + inner.allows(data) ? + ctx.reject({ + expected: `a value that's not ${inner.description}`, + actual: printable(data) + }) + : true + ) as Type +} + +const validateOneOfJsonSchemas = (jsonSchemas: readonly JsonSchema[]) => { + const oneOfValidators = jsonSchemas.map(nestedSchema => + parseJsonSchema(nestedSchema) + ) + const oneOfValidatorsDescriptions = oneOfValidators.map( + validator => `○ ${validator.description}` + ) + return ( + ( + type("unknown").narrow((data, ctx) => { + let matchedValidator: Type | undefined = undefined + + for (const validator of oneOfValidators) { + if (validator.allows(data)) { + if (matchedValidator === undefined) { + matchedValidator = validator + continue + } + return ctx.reject({ + expected: `exactly one of:\n${oneOfValidatorsDescriptions.join("\n")}`, + actual: printable(data) + }) + } + } + return matchedValidator !== undefined + }) as Type + ) + // TODO: Theoretically this shouldn't be necessary due to above `ctx.rejects` in narrow??? + .describe(`one of:\n${oneOfValidatorsDescriptions.join("\n")}\n`) + ) +} + +export const parseJsonSchemaCompositionKeywords = ( + jsonSchema: JsonSchema +): Type | undefined => { + if ("allOf" in jsonSchema) return validateAllOfJsonSchemas(jsonSchema.allOf) + if ("anyOf" in jsonSchema) return validateAnyOfJsonSchemas(jsonSchema.anyOf) + if ("not" in jsonSchema) return validateNotJsonSchema(jsonSchema.not) + if ("oneOf" in jsonSchema) return validateOneOfJsonSchemas(jsonSchema.oneOf) +} diff --git a/ark/jsonschema/index.ts b/ark/jsonschema/index.ts new file mode 100644 index 0000000000..7363a05a1c --- /dev/null +++ b/ark/jsonschema/index.ts @@ -0,0 +1,2 @@ +export { parseJsonSchema } from "./json.ts" +export * from "./scope.ts" diff --git a/ark/jsonschema/json.ts b/ark/jsonschema/json.ts new file mode 100644 index 0000000000..7b15a88f1c --- /dev/null +++ b/ark/jsonschema/json.ts @@ -0,0 +1,91 @@ +import type { JsonSchemaOrBoolean } from "@ark/schema" +import { printable, throwParseError } from "@ark/util" +import { type, type JsonSchema } from "arktype" +import { parseJsonSchemaAnyKeywords } from "./any.ts" +import { validateJsonSchemaArray } from "./array.ts" +import { parseJsonSchemaCompositionKeywords } from "./composition.ts" +import { validateJsonSchemaNumber } from "./number.ts" +import { validateJsonSchemaObject } from "./object.ts" +import { JsonSchemaScope } from "./scope.ts" +import { validateJsonSchemaString } from "./string.ts" + +export const innerParseJsonSchema = JsonSchemaScope.Schema.pipe( + (jsonSchema: JsonSchemaOrBoolean): type.Any => { + if (typeof jsonSchema === "boolean") { + if (jsonSchema) return JsonSchemaScope.Json + else return type("never") // No runtime value ever passes validation for JSON schema of 'false' + } + + if (Array.isArray(jsonSchema)) { + return ( + parseJsonSchemaCompositionKeywords({ anyOf: jsonSchema }) ?? + throwParseError( + "Failed to convert root array of JSON Schemas to an anyOf schema" + ) + ) + } + + const constAndOrEnumValidator = parseJsonSchemaAnyKeywords( + jsonSchema as JsonSchema + ) + const compositionValidator = parseJsonSchemaCompositionKeywords( + jsonSchema as JsonSchema + ) + + const preTypeValidator: type.Any | undefined = + constAndOrEnumValidator ? + compositionValidator ? compositionValidator.and(constAndOrEnumValidator) + : constAndOrEnumValidator + : compositionValidator + + if ("type" in jsonSchema) { + let typeValidator: type.Any + + if (Array.isArray(jsonSchema.type)) { + typeValidator = + parseJsonSchemaCompositionKeywords({ + anyOf: jsonSchema.type.map(t => ({ type: t })) + }) ?? + throwParseError( + "Failed to convert array of JSON Schemas types to an anyOf schema" + ) + } else { + const jsonSchemaType = jsonSchema.type as JsonSchema.TypeName + switch (jsonSchemaType) { + case "array": + typeValidator = validateJsonSchemaArray.assert(jsonSchema) + break + case "boolean": + case "null": + typeValidator = type(jsonSchemaType) + break + case "integer": + case "number": + typeValidator = validateJsonSchemaNumber.assert(jsonSchema) + break + case "object": + typeValidator = validateJsonSchemaObject.assert(jsonSchema) + break + case "string": + typeValidator = validateJsonSchemaString.assert(jsonSchema) + break + default: + throwParseError( + `Provided 'type' value must be a supported JSON Schema type (was '${jsonSchemaType}')` + ) + } + } + if (preTypeValidator === undefined) return typeValidator + return typeValidator.and(preTypeValidator) + } + if (preTypeValidator === undefined) { + throwParseError( + `Provided JSON Schema must have one of 'type', 'enum', 'const', 'allOf', 'anyOf' but was ${printable(jsonSchema)}.` + ) + } + return preTypeValidator + } +) + +export const parseJsonSchema = (jsonSchema: JsonSchemaOrBoolean): type.Any => + innerParseJsonSchema.assert(jsonSchema) as never diff --git a/ark/jsonschema/number.ts b/ark/jsonschema/number.ts new file mode 100644 index 0000000000..623dd11cb4 --- /dev/null +++ b/ark/jsonschema/number.ts @@ -0,0 +1,47 @@ +import { rootSchema, type Intersection } from "@ark/schema" +import { throwParseError } from "@ark/util" +import type { JsonSchema, Out, Type } from "arktype" +import { JsonSchemaScope } from "./scope.ts" + +export const validateJsonSchemaNumber: Type< + (In: JsonSchema.Numeric) => Out<Type<number, any>>, + any +> = JsonSchemaScope.NumberSchema.pipe((jsonSchema): Type<number> => { + const arktypeNumberSchema: Intersection.Schema<number> = { + domain: "number" + } + + if ("maximum" in jsonSchema) { + if ("exclusiveMaximum" in jsonSchema) { + throwParseError( + "Provided number JSON Schema cannot have 'maximum' and 'exclusiveMaximum" + ) + } + arktypeNumberSchema.max = jsonSchema.maximum + } else if ("exclusiveMaximum" in jsonSchema) { + arktypeNumberSchema.max = { + rule: jsonSchema.exclusiveMaximum, + exclusive: true + } + } + + if ("minimum" in jsonSchema) { + if ("exclusiveMinimum" in jsonSchema) { + throwParseError( + "Provided number JSON Schema cannot have 'minimum' and 'exclusiveMinimum" + ) + } + arktypeNumberSchema.min = jsonSchema.minimum + } else if ("exclusiveMinimum" in jsonSchema) { + arktypeNumberSchema.min = { + rule: jsonSchema.exclusiveMinimum, + exclusive: true + } + } + + if ("multipleOf" in jsonSchema) + arktypeNumberSchema.divisor = jsonSchema.multipleOf + else if (jsonSchema.type === "integer") arktypeNumberSchema.divisor = 1 + + return rootSchema(arktypeNumberSchema) as never +}) diff --git a/ark/jsonschema/object.ts b/ark/jsonschema/object.ts new file mode 100644 index 0000000000..9ea50bbabe --- /dev/null +++ b/ark/jsonschema/object.ts @@ -0,0 +1,266 @@ +import { + ArkErrors, + rootSchema, + type Intersection, + type Predicate, + type TraversalContext +} from "@ark/schema" +import { conflatenateAll, getDuplicatesOf, printable } from "@ark/util" +import type { JsonSchema, Out, Type } from "arktype" + +import { parseJsonSchema } from "./json.ts" +import { JsonSchemaScope } from "./scope.ts" + +const parseMinMaxProperties = ( + jsonSchema: JsonSchema.Object, + ctx: TraversalContext +) => { + const predicates: Predicate.Schema[] = [] + if ("maxProperties" in jsonSchema) { + const maxProperties = jsonSchema.maxProperties + + if ((jsonSchema.required?.length ?? 0) > maxProperties) { + ctx.reject({ + expected: `an object JSON Schema with at most ${jsonSchema.maxProperties} required properties`, + actual: `an object JSON Schema with ${jsonSchema.required!.length} required properties` + }) + } + predicates.push((data: object, ctx) => { + const keys = Object.keys(data) + return keys.length <= maxProperties ? + true + : ctx.reject({ + expected: `an object with at most ${maxProperties} propert${maxProperties === 1 ? "y" : "ies"}`, + actual: `an object with ${keys.length.toString()} propert${maxProperties === 1 ? "y" : "ies"}` + }) + }) + } + if ("minProperties" in jsonSchema) { + const minProperties = jsonSchema.minProperties + predicates.push((data: object, ctx) => { + const keys = Object.keys(data) + return keys.length >= minProperties ? + true + : ctx.reject({ + expected: `an object with at least ${minProperties} propert${minProperties === 1 ? "y" : "ies"}`, + actual: `an object with ${keys.length.toString()} propert${minProperties === 1 ? "y" : "ies"}` + }) + }) + } + return predicates +} + +const parsePatternProperties = ( + jsonSchema: JsonSchema.Object, + ctx: TraversalContext +) => { + if (!("patternProperties" in jsonSchema)) return + + const patternProperties = Object.entries(jsonSchema.patternProperties).map( + ([key, value]) => [new RegExp(key), parseJsonSchema(value)] as const + ) + + // Ensure that the schema for any property is compatible with any corresponding patternProperties + patternProperties.forEach(([pattern, parsedPatternPropertySchema]) => { + Object.entries(jsonSchema.properties ?? {}).forEach( + ([property, schemaForProperty]) => { + if (!pattern.test(property)) return + + const parsedPropertySchema = parseJsonSchema(schemaForProperty) + + if (!parsedPropertySchema.overlaps(parsedPatternPropertySchema)) { + ctx.reject({ + path: [property], + expected: `a JSON Schema that overlaps with the schema for patternProperty ${pattern} (${parsedPatternPropertySchema.description})`, + actual: parsedPropertySchema.description + }) + } + } + ) + }) + + // NB: We don't validate compatability of schemas for overlapping patternProperties + // since getting the intersection of regexes is inherently non-trivial. + return (data: object, ctx: TraversalContext) => { + Object.entries(data).forEach(([dataKey, dataValue]) => { + patternProperties.forEach(([pattern, parsedJsonSchema]) => { + if (pattern.test(dataKey) && !parsedJsonSchema.allows(dataValue)) { + ctx.reject({ + path: [dataKey], + expected: `${parsedJsonSchema.description}, as property ${dataKey} matches patternProperty ${pattern}`, + actual: printable(dataValue) + }) + } + }) + }) + return ctx.hasError() + } +} + +const parsePropertyNames = ( + jsonSchema: JsonSchema.Object, + ctx: TraversalContext +) => { + if (!("propertyNames" in jsonSchema)) return + + const propertyNamesValidator = parseJsonSchema(jsonSchema.propertyNames) + + if ( + "domain" in propertyNamesValidator.json && + propertyNamesValidator.json.domain !== "string" + ) { + ctx.reject({ + path: ["propertyNames"], + expected: "a schema for validating a string", + actual: `a schema for validating a ${printable(propertyNamesValidator.json.domain)}` + }) + } + + return (data: object, ctx: TraversalContext) => { + Object.keys(data).forEach(key => { + if (!propertyNamesValidator.allows(key)) { + ctx.reject({ + path: [key], + expected: `a key adhering to the propertyNames schema of ${propertyNamesValidator.description}`, + actual: key + }) + } + }) + return ctx.hasError() + } +} + +const parseRequiredAndOptionalKeys = ( + jsonSchema: JsonSchema.Object, + ctx: TraversalContext +) => { + const optionalKeys: string[] = [] + const requiredKeys: string[] = [] + if ("properties" in jsonSchema) { + if ("required" in jsonSchema) { + const duplicateRequiredKeys = getDuplicatesOf(jsonSchema.required) + if (duplicateRequiredKeys.length !== 0) { + ctx.reject({ + path: ["required"], + expected: "an array of unique strings", + actual: `an array with the following duplicates: ${printable(duplicateRequiredKeys)}` + }) + } + + for (const key of jsonSchema.required) { + if (key in jsonSchema.properties) requiredKeys.push(key) + else { + ctx.reject({ + path: ["required"], + expected: `a key from the 'properties' object (one of ${printable(Object.keys(jsonSchema.properties))})`, + actual: key + }) + } + } + for (const key in jsonSchema.properties) + if (!jsonSchema.required.includes(key)) optionalKeys.push(key) + } else { + // If 'required' is not present, all keys are optional + optionalKeys.push(...Object.keys(jsonSchema.properties)) + } + } else if ("required" in jsonSchema) { + ctx.reject({ + expected: "a valid object JSON Schema", + actual: + "an object JSON Schema with 'required' array but no 'properties' object" + }) + } + + return { + optionalKeys: optionalKeys.map(key => ({ + key, + value: parseJsonSchema(jsonSchema.properties![key]).internal + })), + requiredKeys: requiredKeys.map(key => ({ + key, + value: parseJsonSchema(jsonSchema.properties![key]).internal + })) + } +} + +const parseAdditionalProperties = (jsonSchema: JsonSchema.Object) => { + if (!("additionalProperties" in jsonSchema)) return + + const properties = + jsonSchema.properties ? Object.keys(jsonSchema.properties) : [] + const patternProperties = Object.keys(jsonSchema.patternProperties ?? {}) + + const additionalPropertiesSchema = jsonSchema.additionalProperties + if (additionalPropertiesSchema === true) return + + return (data: object, ctx: TraversalContext) => { + Object.keys(data).forEach(key => { + if ( + properties.includes(key) || + patternProperties.find(pattern => new RegExp(pattern).test(key)) + ) + // Not an additional property, so don't validate here + return + + if (additionalPropertiesSchema === false) { + ctx.reject({ + expected: + "an object with no additional keys, since provided additionalProperties JSON Schema doesn't allow it", + actual: `an additional key (${key})` + }) + return + } + + const additionalPropertyValidator = parseJsonSchema( + additionalPropertiesSchema + ) + + const value = data[key as keyof typeof data] + if (!additionalPropertyValidator.allows(value)) { + ctx.reject({ + path: [key], + expected: `${additionalPropertyValidator.description}, since ${key} is an additional property.`, + actual: printable(value) + }) + } + }) + return ctx.hasError() + } +} + +export const validateJsonSchemaObject: Type< + (In: JsonSchema.Object) => Out<Type<object, any>>, + any +> = JsonSchemaScope.ObjectSchema.pipe((jsonSchema, ctx): Type<object> => { + const arktypeObjectSchema: Intersection.Schema<object> = { + domain: "object" + } + + const { requiredKeys, optionalKeys } = parseRequiredAndOptionalKeys( + jsonSchema, + ctx + ) + arktypeObjectSchema.required = requiredKeys + arktypeObjectSchema.optional = optionalKeys + + const predicates = conflatenateAll<Predicate.Schema>( + ...parseMinMaxProperties(jsonSchema, ctx), + parsePropertyNames(jsonSchema, ctx), + parsePatternProperties(jsonSchema, ctx), + parseAdditionalProperties(jsonSchema) + ) + + const typeWithoutPredicates = rootSchema(arktypeObjectSchema) + if (predicates.length === 0) return typeWithoutPredicates as never + + return rootSchema({ domain: "object", predicate: predicates }).narrow( + (obj: object, innerCtx) => { + const validationResult = typeWithoutPredicates(obj) + if (validationResult instanceof ArkErrors) { + innerCtx.errors.merge(validationResult) + return false + } + return true + } + ) as never +}) diff --git a/ark/jsonschema/package.json b/ark/jsonschema/package.json new file mode 100644 index 0000000000..7ffc255029 --- /dev/null +++ b/ark/jsonschema/package.json @@ -0,0 +1,41 @@ +{ + "name": "@ark/jsonschema", + "version": "1.0.0", + "license": "MIT", + "author": { + "name": "TizzySaurus", + "email": "tizzysaurus@gmail.com", + "url": "https://github.com/tizzysaurus" + }, + "repository": { + "type": "git", + "url": "https://github.com/arktypeio/arktype.git" + }, + "type": "module", + "main": "./out/index.js", + "types": "./out/index.d.ts", + "exports": { + ".": { + "ark-ts": "./index.ts", + "default": "./out/index.js" + }, + "./internal/*.ts": { + "ark-ts": "./*.ts", + "default": "./out/*.js" + } + }, + "files": [ + "out" + ], + "scripts": { + "build": "ts ../repo/build.ts", + "bench": "ts ./__tests__/comparison.bench.ts", + "test": "ts ../repo/testPackage.ts", + "tnt": "ts ../repo/testPackage.ts --skipTypes" + }, + "dependencies": { + "arktype": "workspace:*", + "@ark/schema": "workspace:*", + "@ark/util": "workspace:*" + } +} diff --git a/ark/jsonschema/scope.ts b/ark/jsonschema/scope.ts new file mode 100644 index 0000000000..2567ba30a9 --- /dev/null +++ b/ark/jsonschema/scope.ts @@ -0,0 +1,105 @@ +import type { JsonSchemaOrBoolean } from "@ark/schema" +import { type JsonSchema, scope, type Scope } from "arktype" + +type AnyKeywords = Partial<JsonSchema.Const & JsonSchema.Enum> + +type TypeWithNoKeywords = { type: "boolean" | "null" } +type TypeWithKeywords = + | JsonSchema.Array + | JsonSchema.Numeric + | JsonSchema.Object + | StringSchema +// NB: For sake of simplicitly, at runtime it's assumed that +// whatever we're parsing is valid JSON since it will be 99% of the time. +// This decision may be changed later, e.g. when a built-in JSON type exists in AT. +type Json = unknown + +type ArraySchema = JsonSchema.Array + +type NumberSchema = JsonSchema.Numeric + +type ObjectSchema = JsonSchema.Object + +// NB: @ark/jsonschema doesn't support the "format" keyword, and the "pattern" could be string|RegExp rather than only string, so we need a separate type +export type StringSchema = Omit<JsonSchema.String, "format" | "pattern"> & { + pattern?: string | RegExp +} + +type JsonSchemaScope = Scope<{ + AnyKeywords: AnyKeywords + CompositionKeywords: JsonSchema.Composition + TypeWithNoKeywords: TypeWithNoKeywords + TypeWithKeywords: TypeWithKeywords + Json: Json + Schema: JsonSchemaOrBoolean + ArraySchema: ArraySchema + NumberSchema: NumberSchema + ObjectSchema: ObjectSchema + StringSchema: StringSchema +}> + +const $: JsonSchemaScope = scope({ + AnyKeywords: { + "const?": "unknown", + "enum?": "unknown[]" + }, + CompositionKeywords: { + "allOf?": "Schema[]", + "anyOf?": "Schema[]", + "oneOf?": "Schema[]", + "not?": "Schema" + }, + TypeWithNoKeywords: { type: "'boolean'|'null'" }, + TypeWithKeywords: "ArraySchema|NumberSchema|ObjectSchema|StringSchema", + // NB: For sake of simplicitly, at runtime it's assumed that + // whatever we're parsing is valid JSON since it will be 99% of the time. + // This decision may be changed later, e.g. when a built-in JSON type exists in AT. + Json: "unknown", + "#BaseSchema": + // NB: `true` means "accept an valid JSON"; `false` means "reject everything". + "boolean|TypeWithNoKeywords|TypeWithKeywords|AnyKeywords|CompositionKeywords", + Schema: "BaseSchema|BaseSchema[]", + ArraySchema: { + "additionalItems?": "Schema", + "contains?": "Schema", + // JSON Schema states that if 'items' is not present, then treat as an empty schema (i.e. accept any valid JSON) + "items?": "Schema|Schema[]", + "maxItems?": "number.integer>=0", + "minItems?": "number.integer>=0", + // NB: Technically `prefixItems` and `items` are mutually exclusive, + // which is reflected at runtime but it's not worth the performance cost to validate this statically. + "prefixItems?": "Schema[]", + type: "'array'", + "uniqueItems?": "boolean" + }, + NumberSchema: { + // NB: Technically 'exclusiveMaximum' and 'exclusiveMinimum' are mutually exclusive with 'maximum' and 'minimum', respectively, + // which is reflected at runtime but it's not worth the performance cost to validate this statically. + "exclusiveMaximum?": "number", + "exclusiveMinimum?": "number", + "maximum?": "number", + "minimum?": "number", + // NB: JSON Schema allows decimal multipleOf, but ArkType only supports integer. + "multipleOf?": "number.integer", + type: "'number'|'integer'" + }, + ObjectSchema: { + "additionalProperties?": "Schema", + "maxProperties?": "number.integer>=0", + "minProperties?": "number.integer>=0", + "patternProperties?": { "[string]": "Schema" }, + // NB: Technically 'properties' is required when 'required' is present, + // which is reflected at runtime but it's not worth the performance cost to validate this statically. + "properties?": { "[string]": "Schema" }, + "propertyNames?": "Schema", + "required?": "string[]", + type: "'object'" + }, + StringSchema: { + "maxLength?": "number.integer>=0", + "minLength?": "number.integer>=0", + "pattern?": "RegExp | string", + type: "'string'" + } +}) as unknown as JsonSchemaScope +export const JsonSchemaScope = $.export() diff --git a/ark/jsonschema/string.ts b/ark/jsonschema/string.ts new file mode 100644 index 0000000000..8aa835f41c --- /dev/null +++ b/ark/jsonschema/string.ts @@ -0,0 +1,26 @@ +import { rootSchema, type Intersection } from "@ark/schema" +import type { Out, Type } from "arktype" +import { JsonSchemaScope, type StringSchema } from "./scope.ts" + +export const validateJsonSchemaString: Type< + (In: StringSchema) => Out<Type<string, any>>, + any +> = JsonSchemaScope.StringSchema.pipe((jsonSchema): Type<string> => { + const arktypeStringSchema: Intersection.Schema<string> = { + domain: "string" + } + + if ("maxLength" in jsonSchema) + arktypeStringSchema.maxLength = jsonSchema.maxLength + if ("minLength" in jsonSchema) + arktypeStringSchema.minLength = jsonSchema.minLength + if ("pattern" in jsonSchema) { + if (jsonSchema.pattern instanceof RegExp) { + arktypeStringSchema.pattern = [ + // Strip leading and trailing slashes from RegExp + jsonSchema.pattern.toString().slice(1, -1) + ] + } else arktypeStringSchema.pattern = [jsonSchema.pattern] + } + return rootSchema(arktypeStringSchema) as never +}) diff --git a/ark/jsonschema/tsconfig.build.json b/ark/jsonschema/tsconfig.build.json new file mode 120000 index 0000000000..f74ef64d4c --- /dev/null +++ b/ark/jsonschema/tsconfig.build.json @@ -0,0 +1 @@ +../repo/tsconfig.esm.json \ No newline at end of file diff --git a/ark/schema/shared/jsonSchema.ts b/ark/schema/shared/jsonSchema.ts index 4709223423..b3eee3ed3d 100644 --- a/ark/schema/shared/jsonSchema.ts +++ b/ark/schema/shared/jsonSchema.ts @@ -1,13 +1,16 @@ import { printable, throwInternalError, + type array, type JsonArray, type JsonObject, type listable } from "@ark/util" import type { ConstraintKind } from "./implement.ts" -export type JsonSchema = JsonSchema.Union | JsonSchema.Branch +export type JsonSchema = JsonSchema.NonBooleanBranch +export type ListableJsonSchema = listable<JsonSchema> +export type JsonSchemaOrBoolean = listable<JsonSchema.Branch> export declare namespace JsonSchema { export type TypeName = @@ -31,30 +34,61 @@ export declare namespace JsonSchema { examples?: readonly t[] } - export type Branch = Constrainable | Const | String | Numeric | Object | Array + type Composition = Union | OneOf | Intersection | Not + + type NonBooleanBranch = + | Constrainable + | Const + | Composition + | Enum + | String + | Numeric + | Object + | Array + + export type Branch = boolean | JsonSchema export interface Constrainable extends Meta { type?: listable<TypeName> } + export interface Intersection extends Meta { + allOf: readonly JsonSchema[] + } + + export interface Not { + not: JsonSchema + } + + export interface OneOf extends Meta { + oneOf: readonly JsonSchema[] + } + export interface Union extends Meta { - anyOf: readonly Branch[] + anyOf: readonly JsonSchema[] } export interface Const extends Meta { const: unknown } + export interface Enum extends Meta { + enum: array + } + export interface String extends Meta<string> { type: "string" minLength?: number maxLength?: number - pattern?: string + pattern?: string | RegExp format?: string } + // NB: Technically 'exclusiveMaximum' and 'exclusiveMinimum' are mutually exclusive with 'maximum' and 'minimum', respectively, + // which is reflected at runtime but it's not worth the performance cost to validate this statically. export interface Numeric extends Meta<number> { type: "number" | "integer" + // NB: JSON Schema allows decimal multipleOf, but ArkType only supports integer. multipleOf?: number minimum?: number exclusiveMinimum?: number @@ -62,20 +96,28 @@ export declare namespace JsonSchema { exclusiveMaximum?: number } + // NB: Technically 'properties' is required when 'required' is present, + // which is reflected at runtime but it's not worth the performance cost to validate this statically. export interface Object extends Meta<JsonObject> { type: "object" properties?: Record<string, JsonSchema> required?: string[] patternProperties?: Record<string, JsonSchema> - additionalProperties?: false | JsonSchema + additionalProperties?: JsonSchemaOrBoolean + maxProperties?: number + minProperties?: number + propertyNames?: String } export interface Array extends Meta<JsonArray> { type: "array" + additionalItems?: JsonSchemaOrBoolean + contains?: JsonSchemaOrBoolean + uniqueItems?: boolean minItems?: number maxItems?: number - items?: JsonSchema | false - prefixItems?: readonly JsonSchema[] + items?: JsonSchemaOrBoolean + prefixItems?: readonly Branch[] } export type LengthBoundable = String | Array diff --git a/ark/type/__tests__/imports.test.ts b/ark/type/__tests__/imports.test.ts index fb049c06c7..87aacdc783 100644 --- a/ark/type/__tests__/imports.test.ts +++ b/ark/type/__tests__/imports.test.ts @@ -76,8 +76,8 @@ contextualize(() => { // have to snapshot the module since TypeScript treats it as bivariant attest(types).type.toString.snap(`Module<{ - public: true | 3 | uuid | "no" hasCrept: true + public: true | 3 | uuid | "no" }>`) }) } diff --git a/ark/type/index.ts b/ark/type/index.ts index 5a170f90ab..9f30614b17 100644 --- a/ark/type/index.ts +++ b/ark/type/index.ts @@ -6,7 +6,7 @@ export { type JsonSchema } from "@ark/schema" export { Hkt, inferred } from "@ark/util" -export type { distill } from "./attributes.ts" +export type { distill, number, Out, string } from "./attributes.ts" export * from "./config.ts" export { Generic } from "./generic.ts" export { @@ -21,3 +21,4 @@ export { export { Module, type BoundModule, type Submodule } from "./module.ts" export { module, scope, type Scope } from "./scope.ts" export { Type } from "./type.ts" + diff --git a/ark/util/arrays.ts b/ark/util/arrays.ts index e14c6bb8fb..c6785753b9 100644 --- a/ark/util/arrays.ts +++ b/ark/util/arrays.ts @@ -3,6 +3,34 @@ import type { anyOrNever, conform } from "./generics.ts" import type { isDisjoint } from "./intersections.ts" import type { parseNonNegativeInteger } from "./numbers.ts" +type DuplicateData<val = unknown> = { element: val; indices: number[] } + +/** + * Extracts duplicated elements and their indices from an array, returning them. + * + * @param arr The array to extract duplicate elements from. + */ export const getDuplicatesOf = <const arr extends array>( + arr: arr, + opts?: ComparisonOptions<arr[number]> +): DuplicateData<arr[number]>[] => { + const isEqual = opts?.isEqual ?? ((l, r) => l === r) + + const seenElements: Set<arr[number]> = new Set() + const duplicates: DuplicateData<arr[number]>[] = [] + + arr.forEach((element, indx) => { + if (seenElements.has(element)) { + const duplicatesEntryIndx = duplicates.findIndex((l, r) => + isEqual(l.element, r) + ) + if (duplicatesEntryIndx === -1) + duplicates.push({ element, indices: [indx] }) + else duplicates[duplicatesEntryIndx].indices.push(indx) + } else seenElements.add(element) + }) + return duplicates +} + export type pathToString< segments extends string[], delimiter extends string = "/" diff --git a/ark/util/package.json b/ark/util/package.json index 550e0ed022..fec368e0a3 100644 --- a/ark/util/package.json +++ b/ark/util/package.json @@ -1,6 +1,6 @@ { "name": "@ark/util", - "version": "0.25.0", + "version": "0.26.0", "license": "MIT", "author": { "name": "David Blass", diff --git a/ark/util/registry.ts b/ark/util/registry.ts index edeb954b62..9e203df908 100644 --- a/ark/util/registry.ts +++ b/ark/util/registry.ts @@ -7,7 +7,7 @@ import { FileConstructor, objectKindOf } from "./objectKinds.ts" // recent node versions (https://nodejs.org/api/esm.html#json-modules). // For now, we assert this matches the package.json version via a unit test. -export const arkUtilVersion = "0.25.0" +export const arkUtilVersion = "0.26.0" export const initialRegistryContents = { version: arkUtilVersion, diff --git a/package.json b/package.json index 06a8293f60..ffdd0f27c2 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@ark/attest-ts-min": "catalog:", "@ark/attest-ts-next": "catalog:", "@ark/fs": "workspace:*", + "@ark/jsonschema": "workspace:*", "@ark/repo": "workspace:*", "@ark/util": "workspace:*", "@eslint/js": "9.14.0",