diff --git a/README.md b/README.md index 19460e7c..0aad3983 100644 --- a/README.md +++ b/README.md @@ -42,14 +42,10 @@ yarn add schemata-ts npm install schemata-ts ``` -## Codec +## Codec and Arbitrary A codec is a typeclass that contains the methods of `Decoder`, `Encoder`, `JsonSerializer`, `JsonDeserializer`, and `Guard`. Decoder and encoder are lossless when composed together. This means that for all domain types for which an encoder encodes to, a decoder will return a valid `E.Right` value. -Likewise, `JsonSerializer` and `JsonDeserializer` are lossless when composed together. Certain data types in Javascript like `NaN`, `undefined`, `Infinity`, and others are not part of the JSON specification, and `JSON.stringify` will turn these values into something different. This means that if you stringify these types and attempt to parse, you will get a different object than you originally started with. Additionally, JSON cannot stringify `bigint`, and cannot contain circular references. Under these circumstances `JSON.stringify` may throw an error. - -`JsonSerializer` and `JsonDeserializer` are typeclasses included in a Codec which are lossless when composed together. So anything that successfully stringifies using `JsonSerializer` will successfully parse with `JsonDeserializer` and be equivalent objects. This is useful to avoid bugs when using JSON strings for storing data. Additionally, `JsonDeserializer` will decode the Json string into a domain type for immediate use in your program. - ### User Document Example This is a live example found in `src/Codec.ts` type-checked and tested with [docs-ts](https://github.com/gcanti/docs-ts). @@ -140,6 +136,94 @@ assert.deepStrictEqual( ) ``` +## Json Serializer and Deserializer + +Like encoder and decoder, `JsonSerializer` and `JsonDeserializer` are lossless when composed together. Certain data types in Javascript like `NaN`, `undefined`, `Infinity`, and others are not part of the JSON specification, and `JSON.stringify` will turn these values into something different (or omit them). This means that if you stringify these types and attempt to parse, you will get a different object than you originally started with. Additionally, JSON cannot stringify `bigint`, and cannot contain circular references. Under these circumstances `JSON.stringify` will throw an error. + +Anything that successfully stringifies using `JsonSerializer` will successfully parse with `JsonDeserializer` and will be equivalent objects. This is useful to avoid bugs when using JSON strings for storing data. Additionally, `JsonDeserializer` will decode the Json string into a domain type for immediate use in your program. + +## Deriving JSON Schema + +Schemata-ts comes with its own implementation of [JSON-Schema](https://json-schema.org/) and is a validation standard that can be used to validate artifacts in many other languages and frameworks. Schemata-ts's implementation is compatible with JSON Schema Draft 4, Draft 6, Draft 7, Draft 2019-09, and has partial support for 2020-12. _Note_: string `format` (like regex, contentType, or mediaType) is only available starting with Draft 6, and tuples are not compatible with Draft 2020-12. + +### Customer JSON Schema Example + +This is a live example generating a JSON Schema in `src/base/JsonSchemaBase.ts` + +```ts +import * as JS from 'schemata-ts/base/JsonSchemaBase' +import * as S from 'schemata-ts/schemata' +import { getJsonSchema } from 'schemata-ts/JsonSchema' + +const schema = S.Struct({ + id: S.Natural, + jwt: S.Jwt, + tag: S.Literal('Customer'), +}) + +const jsonSchema = getJsonSchema(schema) + +assert.deepStrictEqual(JS.stripIdentity(jsonSchema), { + type: 'object', + required: ['id', 'jwt', 'tag'], + properties: { + id: { type: 'integer', minimum: 0, maximum: 9007199254740991 }, + jwt: { + type: 'string', + description: 'Jwt', + pattern: + '^(([A-Za-z0-9_\\x2d]*)\\.([A-Za-z0-9_\\x2d]*)(\\.([A-Za-z0-9_\\x2d]*)){0,1})$', + }, + tag: { type: 'string', const: 'Customer' }, + }, +}) +``` + +A note on `JS.stripIdentity` in the above example: internally, JSON Schema is represented as a union of Typescript classes. This is handy when inspecting the schema because the name of the schema is shown next to its properties. Because this prevents equality comparison, schemata-ts exposes a method `stripIdentity` to remove the object's class identity. _Caution_: this method stringifies and parses the schema and may throw if the schema itself contains circularity. + +## Pattern Builder + +Schemata-ts comes with powerful regex combinators that are used to construct regex from comprehensible atoms. The `Pattern` schema allows extension of a string schema to a subset of strings defined by a pattern. Decoders and Guards guarantee that a string conforms to the specified pattern, Arbitrary generates strings that follow the pattern, and Json Schema generates string schemata that lists the pattern as a property. + +### US Phone Number Example + +This is a live example validating US Phone numbers found in `src/PatternBuilder.ts` + +```ts +import * as PB from 'schemata-ts/PatternBuilder' +import { pipe } from 'fp-ts/function' + +const digit = PB.characterClass(false, ['0', '9']) + +const areaCode = pipe( + pipe( + PB.char('('), + PB.then(PB.times(3)(digit)), + PB.then(PB.char(')')), + PB.then(PB.maybe(PB.char(' '))), + ), + PB.or(PB.times(3)(digit)), + PB.subgroup, +) + +const prefix = PB.times(3)(digit) + +const lineNumber = PB.times(4)(digit) + +export const usPhoneNumber = pipe( + areaCode, + PB.then(pipe(PB.char('-'), PB.maybe)), + PB.then(prefix), + PB.then(PB.char('-')), + PB.then(lineNumber), +) + +assert.equal(PB.regexFromPattern(usPhoneNumber).test('(123) 456-7890'), true) +assert.equal(PB.regexFromPattern(usPhoneNumber).test('(123)456-7890'), true) +assert.equal(PB.regexFromPattern(usPhoneNumber).test('123-456-7890'), true) +assert.equal(PB.regexFromPattern(usPhoneNumber).test('1234567890'), false) +``` + ## Documentation - [schemata-ts](https://jacob-alford.github.io/schemata-ts/docs/modules) @@ -203,7 +287,7 @@ Additionally, there are more unlisted base schemable schemata also exported from schemata-ts is planned to support the following features in various 1.x and 2.x versions in the near future: - ~~JsonSerializer and JsonDeserializer: encoding / decoding from Json strings~~ Added in 1.1.0 -- Json Schema: generating JSON-Schema from schemata ([#137](https://github.com/jacob-alford/schemata-ts/issues/137)) +- ~~Json Schema: generating JSON-Schema from schemata ([#137](https://github.com/jacob-alford/schemata-ts/issues/137))~~ Added in 1.2.0 - Optic Schema: generating optics from schemata ([#134](https://github.com/jacob-alford/schemata-ts/issues/134)) - Mapped Structs: conversions between struct types, i.e. `snake-case` keys to `camelCase` keys - More generic schemata: (SetFromArray, ~~NonEmptyArray~~ Added in 1.1.0) diff --git a/docs/index.md b/docs/index.md index fd196c0a..72e43595 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,13 +17,27 @@ nav_order: 1 A schema is an expression of a type structure that can be used to generate typeclass instances from a single declaration. Typeclass instances can perform a variety of tasks, for instance `Decoder` can take a pesky `unknown` value and give you an Either in return where the success case abides by the `schema` that generated it. The example below constructs a `User` schema. -## Codec +## Installation -A codec is a typeclass that contains the methods of `Decoder`, `Encoder`, `JsonSerializer`, `JsonDeserializer`, and `Guard`. Decoder and encoder are lossless when composed together. This means that for all domain types for which an encoder encodes to, a decoder will return a valid `E.Right` value. +Uses `fp-ts`, and `io-ts` as peer dependencies. Read more about peer dependencies at [nodejs.org](https://nodejs.org/en/blog/npm/peer-dependencies/). + +Also contains `fast-check` as a soft peer dependency. Soft peer dependency implies that usage of the `Arbitrary` module requires fast-check as a peer-dependency. + +### Yarn + +```bash +yarn add schemata-ts +``` -Likewise, `JsonSerializer` and `JsonDeserializer` are lossless when composed together. Certain data types in Javascript like `NaN`, `undefined`, `Infinity`, and others are not part of the JSON specification, and `JSON.stringify` will turn these values into something different. This means that if you stringify these types and attempt to parse, you will get a different object than you originally started with. Additionally, JSON cannot stringify `bigint`, and cannot contain circular references. Under these circumstances `JSON.stringify` may throw an error. +### NPM -`JsonSerializer` and `JsonDeserializer` are typeclasses included in a Codec which are lossless when composed together. So anything that successfully stringifies using `JsonSerializer` will successfully parse with `JsonDeserializer` and be equivalent objects. This is useful to avoid bugs when using JSON strings for storing data. Additionally, `JsonDeserializer` will decode the Json string into a domain type for immediate use in your program. +```bash +npm install schemata-ts +``` + +## Codec and Arbitrary + +A codec is a typeclass that contains the methods of `Decoder`, `Encoder`, `JsonSerializer`, `JsonDeserializer`, and `Guard`. Decoder and encoder are lossless when composed together. This means that for all domain types for which an encoder encodes to, a decoder will return a valid `E.Right` value. ### User Document Example @@ -114,3 +128,91 @@ assert.deepStrictEqual( E.right(testUsers), ) ``` + +## Json Serializer and Deserializer + +Like encoder and decoder, `JsonSerializer` and `JsonDeserializer` are lossless when composed together. Certain data types in Javascript like `NaN`, `undefined`, `Infinity`, and others are not part of the JSON specification, and `JSON.stringify` will turn these values into something different (or omit them). This means that if you stringify these types and attempt to parse, you will get a different object than you originally started with. Additionally, JSON cannot stringify `bigint`, and cannot contain circular references. Under these circumstances `JSON.stringify` will throw an error. + +Anything that successfully stringifies using `JsonSerializer` will successfully parse with `JsonDeserializer` and will be equivalent objects. This is useful to avoid bugs when using JSON strings for storing data. Additionally, `JsonDeserializer` will decode the Json string into a domain type for immediate use in your program. + +## Deriving JSON Schema + +Schemata-ts comes with its own implementation of [JSON-Schema](https://json-schema.org/) and is a validation standard that can be used to validate artifacts in many other languages and frameworks. Schemata-ts's implementation is compatible with JSON Schema Draft 4, Draft 6, Draft 7, Draft 2019-09, and has partial support for 2020-12. _Note_: string `format` (like regex, contentType, or mediaType) is only available starting with Draft 6, and tuples are not compatible with Draft 2020-12. + +### Customer JSON Schema Example + +This is a live example generating a JSON Schema in `src/base/JsonSchemaBase.ts` + +```ts +import * as JS from 'schemata-ts/base/JsonSchemaBase' +import * as S from 'schemata-ts/schemata' +import { getJsonSchema } from 'schemata-ts/JsonSchema' + +const schema = S.Struct({ + id: S.Natural, + jwt: S.Jwt, + tag: S.Literal('Customer'), +}) + +const jsonSchema = getJsonSchema(schema) + +assert.deepStrictEqual(JS.stripIdentity(jsonSchema), { + type: 'object', + required: ['id', 'jwt', 'tag'], + properties: { + id: { type: 'integer', minimum: 0, maximum: 9007199254740991 }, + jwt: { + type: 'string', + description: 'Jwt', + pattern: + '^(([A-Za-z0-9_\\x2d]*)\\.([A-Za-z0-9_\\x2d]*)(\\.([A-Za-z0-9_\\x2d]*)){0,1})$', + }, + tag: { type: 'string', const: 'Customer' }, + }, +}) +``` + +A note on `JS.stripIdentity` in the above example: internally, JSON Schema is represented as a union of Typescript classes. This is handy when inspecting the schema because the name of the schema is shown next to its properties. Because this prevents equality comparison, schemata-ts exposes a method `stripIdentity` to remove the object's class identity. _Caution_: this method stringifies and parses the schema and may throw if the schema itself contains circularity. + +## Pattern Builder + +Schemata-ts comes with powerful regex combinators that are used to construct regex from comprehensible atoms. The `Pattern` schema allows extension of a string schema to a subset of strings defined by a pattern. Decoders and Guards guarantee that a string conforms to the specified pattern, Arbitrary generates strings that follow the pattern, and Json Schema generates string schemata that lists the pattern as a property. + +### US Phone Number Example + +This is a live example validating US Phone numbers found in `src/PatternBuilder.ts` + +```ts +import * as PB from 'schemata-ts/PatternBuilder' +import { pipe } from 'fp-ts/function' + +const digit = PB.characterClass(false, ['0', '9']) + +const areaCode = pipe( + pipe( + PB.char('('), + PB.then(PB.times(3)(digit)), + PB.then(PB.char(')')), + PB.then(PB.maybe(PB.char(' '))), + ), + PB.or(PB.times(3)(digit)), + PB.subgroup, +) + +const prefix = PB.times(3)(digit) + +const lineNumber = PB.times(4)(digit) + +export const usPhoneNumber = pipe( + areaCode, + PB.then(pipe(PB.char('-'), PB.maybe)), + PB.then(prefix), + PB.then(PB.char('-')), + PB.then(lineNumber), +) + +assert.equal(PB.regexFromPattern(usPhoneNumber).test('(123) 456-7890'), true) +assert.equal(PB.regexFromPattern(usPhoneNumber).test('(123)456-7890'), true) +assert.equal(PB.regexFromPattern(usPhoneNumber).test('123-456-7890'), true) +assert.equal(PB.regexFromPattern(usPhoneNumber).test('1234567890'), false) +``` \ No newline at end of file diff --git a/package.json b/package.json index 95b3c60f..21fede19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "schemata-ts", - "version": "1.1.0", + "version": "1.2.0", "description": "A collection of Schemata inspired by io-ts-types and validators.js", "homepage": "https://jacob-alford.github.io/schemata-ts/", "repository": { @@ -94,6 +94,7 @@ "glob": "^8.0.3", "husky": "^8.0.0", "jest": "^26.6.3", + "json-schema-library": "^7.4.4", "lint-staged": "^13.0.3", "markdown-magic": "^2.0.0", "prettier": "^2.2.1", diff --git a/scripts/generate-schemables.ts b/scripts/generate-schemables.ts index 8087d39b..60698533 100644 --- a/scripts/generate-schemables.ts +++ b/scripts/generate-schemables.ts @@ -152,6 +152,7 @@ export type SchemableTypeclasses = | SchemableTypeclass<'Encoder', 'Enc', 'SchemableExt2', '1.0.0'> | SchemableTypeclass<'Arbitrary', 'Arb', 'SchemableExt1', '1.0.0'> | SchemableTypeclass<'Printer', 'P', 'SchemableExt2', '1.1.0'> + | SchemableTypeclass<'JsonSchema', 'JS', 'SchemableExt2', '1.2.0'> // #region Typeclass modules @@ -359,6 +360,7 @@ const schemableTypeclasses: ReadonlyArray = [ ['Encoder', 'Enc', 'SchemableExt2', '1.0.0', 'encoder'], ['Arbitrary', 'Arb', 'SchemableExt1', '1.0.0', 'arbitrary'], ['Printer', 'P', 'SchemableExt2', '1.1.0', 'printer'], + ['JsonSchema', 'JS', 'SchemableExt2', '1.2.0', 'json-schema'], ] const format: Build = C => C.exec('yarn format') diff --git a/src/Arbitrary.ts b/src/Arbitrary.ts index bd250a14..b27ee71e 100644 --- a/src/Arbitrary.ts +++ b/src/Arbitrary.ts @@ -7,6 +7,7 @@ */ import * as Arb from './base/ArbitraryBase' import { SchemableExt1 } from './SchemableExt' +import * as WithAnnotate from './schemables/WithAnnotate/instances/arbitrary' import * as WithBrand from './schemables/WithBrand/instances/arbitrary' import * as WithCheckDigit from './schemables/WithCheckDigit/instances/arbitrary' import * as WithDate from './schemables/WithDate/instances/arbitrary' @@ -36,6 +37,7 @@ export type { */ export const Schemable: SchemableExt1 = { ...Arb.Schemable, + ...WithAnnotate.Arbitrary, ...WithBrand.Arbitrary, ...WithCheckDigit.Arbitrary, ...WithDate.Arbitrary, diff --git a/src/Decoder.ts b/src/Decoder.ts index c6df4f4d..e2b2ae0e 100644 --- a/src/Decoder.ts +++ b/src/Decoder.ts @@ -7,6 +7,7 @@ */ import * as D from './base/DecoderBase' import { SchemableExt2C } from './SchemableExt' +import * as WithAnnotate from './schemables/WithAnnotate/instances/decoder' import * as WithBrand from './schemables/WithBrand/instances/decoder' import * as WithCheckDigit from './schemables/WithCheckDigit/instances/decoder' import * as WithDate from './schemables/WithDate/instances/decoder' @@ -36,6 +37,7 @@ export type { */ export const Schemable: SchemableExt2C = { ...D.Schemable, + ...WithAnnotate.Decoder, ...WithBrand.Decoder, ...WithCheckDigit.Decoder, ...WithDate.Decoder, diff --git a/src/Encoder.ts b/src/Encoder.ts index dfd5a60d..ae2956ec 100644 --- a/src/Encoder.ts +++ b/src/Encoder.ts @@ -7,6 +7,7 @@ */ import * as Enc from './base/EncoderBase' import { SchemableExt2 } from './SchemableExt' +import * as WithAnnotate from './schemables/WithAnnotate/instances/encoder' import * as WithBrand from './schemables/WithBrand/instances/encoder' import * as WithCheckDigit from './schemables/WithCheckDigit/instances/encoder' import * as WithDate from './schemables/WithDate/instances/encoder' @@ -36,6 +37,7 @@ export type { */ export const Schemable: SchemableExt2 = { ...Enc.Schemable, + ...WithAnnotate.Encoder, ...WithBrand.Encoder, ...WithCheckDigit.Encoder, ...WithDate.Encoder, diff --git a/src/Eq.ts b/src/Eq.ts index 9e67241c..3aea1118 100644 --- a/src/Eq.ts +++ b/src/Eq.ts @@ -7,6 +7,7 @@ */ import * as Eq from './base/EqBase' import { SchemableExt1 } from './SchemableExt' +import * as WithAnnotate from './schemables/WithAnnotate/instances/eq' import * as WithBrand from './schemables/WithBrand/instances/eq' import * as WithCheckDigit from './schemables/WithCheckDigit/instances/eq' import * as WithDate from './schemables/WithDate/instances/eq' @@ -36,6 +37,7 @@ export type { */ export const Schemable: SchemableExt1 = { ...Eq.Schemable, + ...WithAnnotate.Eq, ...WithBrand.Eq, ...WithCheckDigit.Eq, ...WithDate.Eq, diff --git a/src/Guard.ts b/src/Guard.ts index 11a7477b..b3a4e576 100644 --- a/src/Guard.ts +++ b/src/Guard.ts @@ -7,6 +7,7 @@ */ import * as G from './base/GuardBase' import { SchemableExt1 } from './SchemableExt' +import * as WithAnnotate from './schemables/WithAnnotate/instances/guard' import * as WithBrand from './schemables/WithBrand/instances/guard' import * as WithCheckDigit from './schemables/WithCheckDigit/instances/guard' import * as WithDate from './schemables/WithDate/instances/guard' @@ -36,6 +37,7 @@ export type { */ export const Schemable: SchemableExt1 = { ...G.Schemable, + ...WithAnnotate.Guard, ...WithBrand.Guard, ...WithCheckDigit.Guard, ...WithDate.Guard, diff --git a/src/JsonSchema.ts b/src/JsonSchema.ts new file mode 100644 index 00000000..d48cce7f --- /dev/null +++ b/src/JsonSchema.ts @@ -0,0 +1,61 @@ +/** + * SchemableExt instances for JsonSchema + * + * **Warning: DO NOT EDIT, this module is autogenerated** + * + * @since 1.2.0 + */ +import * as JS from './base/JsonSchemaBase' +import { SchemableExt2 } from './SchemableExt' +import * as WithAnnotate from './schemables/WithAnnotate/instances/json-schema' +import * as WithBrand from './schemables/WithBrand/instances/json-schema' +import * as WithCheckDigit from './schemables/WithCheckDigit/instances/json-schema' +import * as WithDate from './schemables/WithDate/instances/json-schema' +import * as WithFloat from './schemables/WithFloat/instances/json-schema' +import * as WithInt from './schemables/WithInt/instances/json-schema' +import * as WithInvariant from './schemables/WithInvariant/instances/json-schema' +import * as WithJson from './schemables/WithJson/instances/json-schema' +import * as WithMap from './schemables/WithMap/instances/json-schema' +import * as WithOption from './schemables/WithOption/instances/json-schema' +import * as WithOptional from './schemables/WithOptional/instances/json-schema' +import * as WithPadding from './schemables/WithPadding/instances/json-schema' +import * as WithPattern from './schemables/WithPattern/instances/json-schema' +import * as WithRefine from './schemables/WithRefine/instances/json-schema' +import * as WithUnknownContainers from './schemables/WithUnknownContainers/instances/json-schema' +import { interpret } from './SchemaExt' +export type { + /** + * @since 1.2.0 + * @category Model + */ + JsonSchema, +} from './base/JsonSchemaBase' + +/** + * @since 1.2.0 + * @category Instances + */ +export const Schemable: SchemableExt2 = { + ...JS.Schemable, + ...WithAnnotate.JsonSchema, + ...WithBrand.JsonSchema, + ...WithCheckDigit.JsonSchema, + ...WithDate.JsonSchema, + ...WithFloat.JsonSchema, + ...WithInt.JsonSchema, + ...WithInvariant.JsonSchema, + ...WithJson.JsonSchema, + ...WithMap.JsonSchema, + ...WithOption.JsonSchema, + ...WithOptional.JsonSchema, + ...WithPadding.JsonSchema, + ...WithPattern.JsonSchema, + ...WithRefine.JsonSchema, + ...WithUnknownContainers.JsonSchema, +} + +/** + * @since 1.2.0 + * @category Interpreters + */ +export const getJsonSchema = interpret(Schemable) diff --git a/src/PatternBuilder.ts b/src/PatternBuilder.ts index ed65efc9..0b7af83a 100644 --- a/src/PatternBuilder.ts +++ b/src/PatternBuilder.ts @@ -13,7 +13,12 @@ * const digit = PB.characterClass(false, ['0', '9']) * * const areaCode = pipe( - * pipe(PB.char('('), PB.then(PB.times(3)(digit)), PB.then(PB.char(')'))), + * pipe( + * PB.char('('), + * PB.then(PB.times(3)(digit)), + * PB.then(PB.char(')')), + * PB.then(PB.maybe(PB.char(' '))), + * ), * PB.or(PB.times(3)(digit)), * PB.subgroup, * ) @@ -24,11 +29,16 @@ * * export const usPhoneNumber = pipe( * areaCode, - * PB.then(PB.char('-')), + * PB.then(pipe(PB.char('-'), PB.maybe)), * PB.then(prefix), * PB.then(PB.char('-')), * PB.then(lineNumber), * ) + * + * assert.equal(PB.regexFromPattern(usPhoneNumber).test('(123) 456-7890'), true) + * assert.equal(PB.regexFromPattern(usPhoneNumber).test('(123)456-7890'), true) + * assert.equal(PB.regexFromPattern(usPhoneNumber).test('123-456-7890'), true) + * assert.equal(PB.regexFromPattern(usPhoneNumber).test('1234567890'), false) */ import { pipe } from 'fp-ts/function' import * as O from 'fp-ts/Option' diff --git a/src/Printer.ts b/src/Printer.ts index be957088..8edb8367 100644 --- a/src/Printer.ts +++ b/src/Printer.ts @@ -7,6 +7,7 @@ */ import * as P from './base/PrinterBase' import { SchemableExt2 } from './SchemableExt' +import * as WithAnnotate from './schemables/WithAnnotate/instances/printer' import * as WithBrand from './schemables/WithBrand/instances/printer' import * as WithCheckDigit from './schemables/WithCheckDigit/instances/printer' import * as WithDate from './schemables/WithDate/instances/printer' @@ -36,6 +37,7 @@ export type { */ export const Schemable: SchemableExt2 = { ...P.Schemable, + ...WithAnnotate.Printer, ...WithBrand.Printer, ...WithCheckDigit.Printer, ...WithDate.Printer, diff --git a/src/SchemableExt.ts b/src/SchemableExt.ts index 688bd45a..923309ec 100644 --- a/src/SchemableExt.ts +++ b/src/SchemableExt.ts @@ -9,6 +9,12 @@ import { URIS, URIS2 } from 'fp-ts/HKT' import { Schemable1, Schemable2C } from 'io-ts/Schemable' import { Schemable2, SchemableHKT2 } from './base/SchemableBase' +import { + WithAnnotate1, + WithAnnotate2, + WithAnnotate2C, + WithAnnotateHKT2, +} from './schemables/WithAnnotate/definition' import { WithBrand1, WithBrand2, @@ -100,6 +106,7 @@ import { */ export interface SchemableExt extends SchemableHKT2, + WithAnnotateHKT2, WithBrandHKT2, WithCheckDigitHKT2, WithDateHKT2, @@ -121,6 +128,7 @@ export interface SchemableExt */ export interface SchemableExt1 extends Schemable1, + WithAnnotate1, WithBrand1, WithCheckDigit1, WithDate1, @@ -142,6 +150,7 @@ export interface SchemableExt1 */ export interface SchemableExt2 extends Schemable2, + WithAnnotate2, WithBrand2, WithCheckDigit2, WithDate2, @@ -163,6 +172,7 @@ export interface SchemableExt2 */ export interface SchemableExt2C extends Schemable2C, + WithAnnotate2C, WithBrand2C, WithCheckDigit2C, WithDate2C, diff --git a/src/TaskDecoder.ts b/src/TaskDecoder.ts index 8b11ab96..e779ec4b 100644 --- a/src/TaskDecoder.ts +++ b/src/TaskDecoder.ts @@ -7,6 +7,7 @@ */ import * as TD from './base/TaskDecoderBase' import { SchemableExt2C } from './SchemableExt' +import * as WithAnnotate from './schemables/WithAnnotate/instances/task-decoder' import * as WithBrand from './schemables/WithBrand/instances/task-decoder' import * as WithCheckDigit from './schemables/WithCheckDigit/instances/task-decoder' import * as WithDate from './schemables/WithDate/instances/task-decoder' @@ -36,6 +37,7 @@ export type { */ export const Schemable: SchemableExt2C = { ...TD.Schemable, + ...WithAnnotate.TaskDecoder, ...WithBrand.TaskDecoder, ...WithCheckDigit.TaskDecoder, ...WithDate.TaskDecoder, diff --git a/src/Type.ts b/src/Type.ts index 14aaab18..31ef8237 100644 --- a/src/Type.ts +++ b/src/Type.ts @@ -7,6 +7,7 @@ */ import * as t from './base/TypeBase' import { SchemableExt1 } from './SchemableExt' +import * as WithAnnotate from './schemables/WithAnnotate/instances/type' import * as WithBrand from './schemables/WithBrand/instances/type' import * as WithCheckDigit from './schemables/WithCheckDigit/instances/type' import * as WithDate from './schemables/WithDate/instances/type' @@ -36,6 +37,7 @@ export type { */ export const Schemable: SchemableExt1 = { ...t.Schemable, + ...WithAnnotate.Type, ...WithBrand.Type, ...WithCheckDigit.Type, ...WithDate.Type, diff --git a/src/base/JsonSchemaBase.ts b/src/base/JsonSchemaBase.ts new file mode 100644 index 00000000..a0dca8b5 --- /dev/null +++ b/src/base/JsonSchemaBase.ts @@ -0,0 +1,538 @@ +/** + * Models for JsonSchema as subsets of JSON Schema Draft 4, Draft 6, and Draft 7. + * + * @since 1.2.0 + * @example + * import * as JS from 'schemata-ts/base/JsonSchemaBase' + * import * as S from 'schemata-ts/schemata' + * import { getJsonSchema } from 'schemata-ts/JsonSchema' + * + * const schema = S.Struct({ + * id: S.Natural, + * jwt: S.Jwt, + * tag: S.Literal('Customer'), + * }) + * + * const jsonSchema = getJsonSchema(schema) + * + * assert.deepStrictEqual(JS.stripIdentity(jsonSchema), { + * type: 'object', + * required: ['id', 'jwt', 'tag'], + * properties: { + * id: { type: 'integer', minimum: 0, maximum: 9007199254740991 }, + * jwt: { + * type: 'string', + * description: 'Jwt', + * pattern: + * '^(([A-Za-z0-9_\\x2d]*)\\.([A-Za-z0-9_\\x2d]*)(\\.([A-Za-z0-9_\\x2d]*)){0,1})$', + * }, + * tag: { type: 'string', const: 'Customer' }, + * }, + * }) + * + * @see https://json-schema.org/draft/2020-12/json-schema-validation.html + */ +import { Const, make } from 'fp-ts/Const' +import { identity, pipe } from 'fp-ts/function' +import * as RNEA from 'fp-ts/ReadonlyNonEmptyArray' +import * as RR from 'fp-ts/ReadonlyRecord' +import * as Str from 'fp-ts/string' +import { memoize } from 'io-ts/Schemable' + +import { Int } from '../schemables/WithInt/definition' +import { Schemable2 } from './SchemableBase' + +// ------------------------------------------------------------------------------------- +// Model +// ------------------------------------------------------------------------------------- + +/** + * @since 1.2.0 + * @category Model + */ +export type JsonSchema = + | JsonEmpty + | JsonString + | JsonNumber + | JsonBoolean + | JsonNull + | JsonInteger + | JsonConst + | JsonLiteral + | JsonExclude + | JsonStruct + | JsonRecord + | JsonArray + | JsonUnion + | JsonIntersection + +/** + * @since 1.2.0 + * @category Model + */ +export type JsonSchemaWithDescription = JsonSchema & Description + +interface Description { + readonly title?: string + readonly description?: string +} + +/** Matches anything */ +class JsonEmpty {} + +/** Matches a subset of strings */ +class JsonString { + readonly type = 'string' + constructor( + readonly minLength?: number, + readonly maxLength?: number, + readonly pattern?: string, + readonly contentEncoding?: string, + readonly contentMediaType?: string, + readonly contentSchema?: JsonSchema, + readonly format?: string, + ) {} +} + +/** Matches a subset of floats */ +class JsonNumber { + readonly type = 'number' + constructor(readonly minimum?: number, readonly maximum?: number) {} +} + +/** Matches a subset of integers */ +class JsonInteger implements Omit { + readonly type = 'integer' + constructor(readonly minimum?: number, readonly maximum?: number) {} +} + +/** Matches true or false */ +class JsonBoolean { + readonly type = 'boolean' +} + +/** Matches a constant value */ +interface JsonConst { + readonly const: unknown +} + +/** Matches a boolean, number, string, or null constant value */ +type JsonLiteral = (JsonBoolean | JsonNumber | JsonString | JsonNull) & JsonConst + +/** Matches a set of properties with a given set of required properties. */ +class JsonStruct { + readonly type = 'object' + constructor( + readonly properties: Readonly>, + readonly required: ReadonlyArray, + ) {} +} + +/** Matches an object with uniform key values */ +class JsonRecord { + readonly type = 'object' + constructor(readonly additionalProperties: JsonSchema) {} +} + +/** Matches a subset of arrays with uniform index values (or specific index values) */ +class JsonArray { + readonly type = 'array' + constructor( + readonly items: JsonSchema | ReadonlyArray, + readonly minItems?: number, + readonly maxItems?: number, + ) {} +} + +/** Matches null exactly */ +class JsonNull { + readonly type = 'null' + readonly const = null +} + +/** Negates a schema */ +class JsonExclude { + constructor(readonly not: JsonSchema) {} +} + +/** Matches any of the supplied schemas */ +class JsonUnion { + constructor(readonly oneOf: ReadonlyArray) {} +} + +/** Matches all of the supplied schemas */ +class JsonIntersection { + constructor(readonly allOf: RNEA.ReadonlyNonEmptyArray) {} +} + +// ------------------------------------------------------------------------------------- +// guards +// ------------------------------------------------------------------------------------- + +/** @internal */ +const hasType = ( + type: string, + u: JsonSchema, +): u is JsonSchema & { readonly type: string } => 'type' in u && u.type === type + +/** @internal */ +const hasKey = ( + key: K, + u: JsonSchema, +): u is JsonSchema & { readonly [key in K]: unknown } => key in u + +/** + * @since 1.2.0 + * @category Guards + */ +export const isJsonEmpty = (u: JsonSchema): u is JsonEmpty => + u instanceof JsonEmpty || Object.keys(u).length === 0 + +/** + * @since 1.2.0 + * @category Guards + */ +export const isJsonString = (u: JsonSchema): u is JsonString => + u instanceof JsonString || hasType('string', u) + +/** + * @since 1.2.0 + * @category Guards + */ +export const isJsonNumber = (u: JsonSchema): u is JsonNumber => + u instanceof JsonNumber || hasType('number', u) + +/** + * @since 1.2.0 + * @category Guards + */ +export const isJsonNull = (u: JsonSchema): u is JsonNull => + u instanceof JsonNull || hasType('null', u) + +/** + * @since 1.2.0 + * @category Guards + */ +export const isJsonInteger = (u: JsonSchema): u is JsonInteger => + u instanceof JsonInteger || hasType('integer', u) + +/** + * @since 1.2.0 + * @category Guards + */ +export const isJsonBoolean = (u: JsonSchema): u is JsonBoolean => + u instanceof JsonBoolean || hasType('boolean', u) + +/** + * @since 1.2.0 + * @category Guards + */ +export const isJsonConst = (u: JsonSchema): u is JsonConst => 'const' in u + +/** + * @since 1.2.0 + * @category Guards + */ +export const isJsonPrimitive = (u: JsonSchema): u is JsonLiteral | JsonNull => + isJsonNull(u) || isJsonBoolean(u) || isJsonNumber(u) || isJsonString(u) + +/** + * @since 1.2.0 + * @category Guards + */ +export const isJsonLiteral = (u: JsonSchema): u is JsonLiteral => + isJsonConst(u) && isJsonPrimitive(u) + +/** + * @since 1.2.0 + * @category Guards + */ +export const isJsonStruct = (u: JsonSchema): u is JsonStruct => + u instanceof JsonStruct || + (hasType('object', u) && hasKey('properties', u) && hasKey('required', u)) + +/** + * @since 1.2.0 + * @category Guards + */ +export const isJsonRecord = (u: JsonSchema): u is JsonRecord => + u instanceof JsonRecord || (hasType('object', u) && hasKey('additionalProperties', u)) + +/** + * @since 1.2.0 + * @category Guards + */ +export const isJsonArray = (u: JsonSchema): u is JsonArray => + u instanceof JsonArray || hasType('array', u) + +/** + * @since 1.2.0 + * @category Guards + */ +export const isJsonExclude = (u: JsonSchema): u is JsonExclude => + u instanceof JsonExclude || hasKey('not', u) + +/** + * @since 1.2.0 + * @category Guards + */ +export const isJsonUnion = (u: JsonSchema): u is JsonUnion => + u instanceof JsonUnion || hasKey('oneOf', u) + +/** + * @since 1.2.0 + * @category Guards + */ +export const isJsonIntersection = (u: JsonSchema): u is JsonIntersection => + u instanceof JsonIntersection || hasKey('allOf', u) + +// ------------------------------------------------------------------------------------- +// constructors +// ------------------------------------------------------------------------------------- + +/** + * @since 1.2.0 + * @category Constructors + */ +export const emptySchema = make(new JsonEmpty()) + +/** + * @since 1.2.0 + * @category Constructors + */ +export const makeStringSchema = ( + params: { + minLength?: number + maxLength?: number + pattern?: string + contentEncoding?: string + contentMediaType?: string + contentSchema?: JsonSchema + format?: string + } = {}, +): Const => + make( + new JsonString( + params.minLength, + params.maxLength, + params.pattern, + params.contentEncoding, + params.contentMediaType, + params.contentSchema, + params.format, + ), + ) + +/** + * @since 1.2.0 + * @category Constructors + */ +export const makeNumberSchema = ( + params: { + minimum?: number + maximum?: number + } = {}, +): Const => make(new JsonNumber(params.minimum, params.maximum)) + +/** + * @since 1.2.0 + * @category Constructors + */ +export const makeIntegerSchema = ( + params: { + minimum?: number + maximum?: number + } = {}, +): Const => make(new JsonInteger(params.minimum, params.maximum)) + +/** + * @since 1.2.0 + * @category Constructors + */ +export const booleanSchema: Const = make(new JsonBoolean()) + +/** + * This is internal because it's not technically accurate to say: `forall value. const: + * value` is a valid json schema. However, internally, the only usage is with + * OptionFromExclude which is likely to stick with valid JSON types + * + * @internal + */ +export const makeConstSchema = (value: A): Const => + make({ const: value }) + +/** + * @since 1.2.0 + * @category Constructors + */ +export const makeLiteralSchema = ( + value: A, +): Const => + value === null ? make(new JsonNull()) : make({ type: typeof value, const: value }) + +/** + * @since 1.2.0 + * @category Constructors + */ +export const makeStructSchema = ( + properties: { + [K in keyof A]: Const + }, + required: ReadonlyArray = [], +): Const => make(new JsonStruct(properties, required)) + +/** + * @since 1.2.0 + * @category Constructors + */ +export const makeRecordSchema = ( + additionalProperties: Const, +): Const> => make(new JsonRecord(additionalProperties)) + +/** + * @since 1.2.0 + * @category Constructors + */ +export const makeArraySchema = + (params: { minItems?: number; maxItems?: number } = {}) => + (items: Const): Const> => + make(new JsonArray(items, params.minItems, params.maxItems)) + +/** + * @since 1.2.0 + * @category Constructors + */ +export const makeTupleSchema = >( + ...items: { + [K in keyof A]: Const + } +): Const => make(new JsonArray(items, items.length, items.length)) + +/** + * @since 1.2.0 + * @category Constructors + */ +export const nullSchema = make(new JsonNull()) + +/** + * @since 1.2.0 + * @category Constructors + */ +export const makeUnionSchema = >>( + ...members: U +): Const ? A : never> => + make(members.length > 1 ? new JsonUnion(members) : members[0]) + +/** + * @since 1.2.0 + * @category Constructors + */ +export const makeIntersectionSchema = + (right: Const) => + (left: Const): Const => + make(new JsonIntersection([left, right])) + +/** + * @since 1.2.0 + * @category Constructors + */ +export const makeExclusionSchema = ( + exclude: Z, + schema: Const, +): Const> => + make(new JsonIntersection([new JsonExclude(makeConstSchema(exclude)), schema])) + +/** + * @since 1.2.0 + * @category Combintators + */ +export const annotate: (params?: { + title?: string + description?: string +}) => (schema: JsonSchema) => Const = + ({ title, description } = {}) => + schema => + title === undefined && description === undefined + ? make(schema) + : make({ + ...schema, + ...(title === undefined ? {} : { title }), + ...(description === undefined ? {} : { description }), + }) + +// ------------------------------------------------------------------------------------- +// Destructors +// ------------------------------------------------------------------------------------- + +/** + * Removes the internal class identities from a `JsonSchema` + * + * @since 1.2.0 + * @category Destructors + */ +export const stripIdentity: (schema: Const) => JsonSchema = schema => + JSON.parse(JSON.stringify(schema)) + +// ------------------------------------------------------------------------------------- +// instances +// ------------------------------------------------------------------------------------- + +/** + * @since 1.2.0 + * @category Instances + */ +export const URI = 'JsonSchema' + +/** + * @since 1.2.0 + * @category Instances + */ +export type URI = typeof URI + +declare module 'fp-ts/lib/HKT' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface URItoKind2 { + readonly JsonSchema: Const + } +} + +/** + * @since 1.2.0 + * @category Instances + */ +export const Schemable: Schemable2 = { + URI, + literal: (...values) => + pipe(values, RNEA.map(makeLiteralSchema), schemata => + make(schemata.length === 1 ? RNEA.head(schemata) : new JsonUnion(schemata)), + ), + string: makeStringSchema(), + number: makeNumberSchema(), + boolean: booleanSchema, + nullable: schema => make(new JsonUnion([schema, nullSchema])), + // @ts-expect-error -- typelevel difference + struct: s => makeStructSchema(s, Object.keys(s)), + // @ts-expect-error -- typelevel difference + partial: makeStructSchema, + record: makeRecordSchema, + array: makeArraySchema(), + // @ts-expect-error -- typelevel difference + tuple: >( + ...items: { [K in keyof A]: Const } + ): Const => makeTupleSchema(...items), + intersect: right => left => make(new JsonIntersection([left, right])), + sum: () => members => + make( + makeUnionSchema( + ...pipe( + members as Readonly>>, + RR.collect(Str.Ord)((_, a) => a), + ), + ), + ), + lazy: (_, f) => { + const get = memoize(f) + return make(get()) + }, + readonly: identity, +} diff --git a/src/base/PrinterBase.ts b/src/base/PrinterBase.ts index 1816ca47..3bb1460e 100644 --- a/src/base/PrinterBase.ts +++ b/src/base/PrinterBase.ts @@ -218,28 +218,24 @@ export const boolean: Printer = { * @category Primitives */ export const UnknownArray: Printer, Array> = { - domainToJson: input => - pipe( - input, - RA.traverseWithIndex(printerValidation)((i, v) => - pipe( - toJson(v), - E.mapLeft(err => new PE.ErrorAtIndex(i, err)), - ), + domainToJson: flow( + RA.traverseWithIndex(printerValidation)((i, v) => + pipe( + toJson(v), + E.mapLeft(err => new PE.ErrorAtIndex(i, err)), ), - E.map(safeJsonArray), ), - codomainToJson: input => - pipe( - input, - RA.traverseWithIndex(printerValidation)((i, v) => - pipe( - toJson(v), - E.mapLeft(err => new PE.ErrorAtIndex(i, err)), - ), + E.map(safeJsonArray), + ), + codomainToJson: flow( + RA.traverseWithIndex(printerValidation)((i, v) => + pipe( + toJson(v), + E.mapLeft(err => new PE.ErrorAtIndex(i, err)), ), - E.map(safeJsonArray), ), + E.map(safeJsonArray), + ), } /** @@ -247,28 +243,24 @@ export const UnknownArray: Printer, Array> = { * @category Primitives */ export const UnknownRecord: Printer, Record> = { - domainToJson: input => - pipe( - input, - RR.traverseWithIndex(printerValidation)((key, v) => - pipe( - toJson(v), - E.mapLeft(err => new PE.ErrorAtKey(key, err)), - ), + domainToJson: flow( + RR.traverseWithIndex(printerValidation)((key, v) => + pipe( + toJson(v), + E.mapLeft(err => new PE.ErrorAtKey(key, err)), ), - E.map(safeJsonRecord), ), - codomainToJson: input => - pipe( - input, - RR.traverseWithIndex(printerValidation)((key, v) => - pipe( - toJson(v), - E.mapLeft(err => new PE.ErrorAtKey(key, err)), - ), + E.map(safeJsonRecord), + ), + codomainToJson: flow( + RR.traverseWithIndex(printerValidation)((key, v) => + pipe( + toJson(v), + E.mapLeft(err => new PE.ErrorAtKey(key, err)), ), - E.map(safeJsonRecord), ), + E.map(safeJsonRecord), + ), } // ------------------------------------------------------------------------------------- @@ -318,11 +310,9 @@ export const struct =

>>( pipe( properties, witherS(PE.semigroupPrintingError)((key, printer) => - O.some( - pipe( - printer.domainToJson(input[key]), - E.mapLeft((err): PE.PrintError => new PE.ErrorAtKey(key as string, err)), - ), + pipe( + printer.domainToJson(input[key]), + E.bimap((err): PE.PrintError => new PE.ErrorAtKey(key as string, err), O.some), ), ), E.map(safeJsonRecord), @@ -331,11 +321,9 @@ export const struct =

>>( pipe( properties, witherS(PE.semigroupPrintingError)((key, printer) => - O.some( - pipe( - printer.codomainToJson(input[key]), - E.mapLeft(err => new PE.ErrorAtKey(key as string, err)), - ), + pipe( + printer.codomainToJson(input[key]), + E.bimap(err => new PE.ErrorAtKey(key as string, err), O.some), ), ), E.map(safeJsonRecord), @@ -359,7 +347,7 @@ export const partial =

>>( pipe( input[key], O.fromNullable, - O.map(value => + O.traverse(E.Applicative)(value => pipe( printer.domainToJson(value), E.mapLeft(err => new PE.ErrorAtKey(key as string, err)), @@ -376,7 +364,7 @@ export const partial =

>>( pipe( input[key], O.fromNullable, - O.map(value => + O.traverse(E.Applicative)(value => pipe( printer.codomainToJson(value), E.mapLeft(err => new PE.ErrorAtKey(key as string, err)), @@ -402,7 +390,6 @@ export const record = ( E.mapLeft(err => new PE.ErrorAtKey(k, err)), ), ), - E.map(safeJsonRecord), ), codomainToJson: flow( diff --git a/src/internal/util.ts b/src/internal/util.ts index 851f450c..da86851e 100644 --- a/src/internal/util.ts +++ b/src/internal/util.ts @@ -39,7 +39,7 @@ export const typeOf = (x: unknown): string => (x === null ? 'null' : typeof x) export const witherS = (sgErrors: Sg.Semigroup) => , A>( - f: (key: K, value: In[K]) => O.Option>, + f: (key: K, value: In[K]) => E.Either>, ) => (s: In): E.Either => { const errors: E[] = [] @@ -48,13 +48,19 @@ export const witherS = for (const key in s) { /* Ignores inherited properties */ if (!hasOwn(s, key)) continue + /* Perform effect */ const result = f(key, s[key]) + + /* add any errors to accumulation */ + if (E.isLeft(result)) { + errors.push(result.left) + continue + } + /* none => skip */ - if (O.isNone(result)) continue - /* Bail early if effect failed */ - if (E.isLeft(result.value)) errors.push(result.value.left) - /* Otherwise, add result to output */ else out[key] = result.value.right + if (O.isNone(result.right)) continue + else out[key] = result.right.value } return RA.isNonEmpty(errors) ? E.left(pipe(errors, RNEA.concatAll(sgErrors))) diff --git a/src/schemables/WithAnnotate/definition.ts b/src/schemables/WithAnnotate/definition.ts new file mode 100644 index 00000000..60d8eb5f --- /dev/null +++ b/src/schemables/WithAnnotate/definition.ts @@ -0,0 +1,51 @@ +/** + * Schemable for annotating a JSON Schema. Interpretation using interpreters other than + * JsonSchema will not change the derivation. + * + * @since 1.2.0 + */ +import { HKT2, Kind, Kind2, URIS, URIS2 } from 'fp-ts/HKT' + +/** + * @since 1.2.0 + * @category Model + */ +export interface WithAnnotateHKT2 { + readonly annotate: (params?: { + title?: string + description?: string + }) => (schema: HKT2) => HKT2 +} + +/** + * @since 1.2.0 + * @category Model + */ +export interface WithAnnotate1 { + readonly annotate: (params?: { + title?: string + description?: string + }) => (schema: Kind) => Kind +} + +/** + * @since 1.2.0 + * @category Model + */ +export interface WithAnnotate2 { + readonly annotate: (params?: { + title?: string + description?: string + }) => (schema: Kind2) => Kind2 +} + +/** + * @since 1.2.0 + * @category Model + */ +export interface WithAnnotate2C { + readonly annotate: (params?: { + title?: string + description?: string + }) => (schema: Kind2) => Kind2 +} diff --git a/src/schemables/WithAnnotate/instances/arbitrary.ts b/src/schemables/WithAnnotate/instances/arbitrary.ts new file mode 100644 index 00000000..3bec8098 --- /dev/null +++ b/src/schemables/WithAnnotate/instances/arbitrary.ts @@ -0,0 +1,18 @@ +/** + * Schemable for annotating a JSON Schema. Interpretation using interpreters other than + * JsonSchema will not change the derivation. + * + * @since 1.2.0 + */ +import { constant, identity } from 'fp-ts/function' + +import * as Arb from '../../../base/ArbitraryBase' +import { WithAnnotate1 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const Arbitrary: WithAnnotate1 = { + annotate: constant(identity), +} diff --git a/src/schemables/WithAnnotate/instances/decoder.ts b/src/schemables/WithAnnotate/instances/decoder.ts new file mode 100644 index 00000000..a684a8b6 --- /dev/null +++ b/src/schemables/WithAnnotate/instances/decoder.ts @@ -0,0 +1,18 @@ +/** + * Schemable for annotating a JSON Schema. Interpretation using interpreters other than + * JsonSchema will not change the derivation. + * + * @since 1.2.0 + */ +import { constant, identity } from 'fp-ts/function' + +import * as D from '../../../base/DecoderBase' +import { WithAnnotate2C } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const Decoder: WithAnnotate2C = { + annotate: constant(identity), +} diff --git a/src/schemables/WithAnnotate/instances/encoder.ts b/src/schemables/WithAnnotate/instances/encoder.ts new file mode 100644 index 00000000..10ac5a4c --- /dev/null +++ b/src/schemables/WithAnnotate/instances/encoder.ts @@ -0,0 +1,18 @@ +/** + * Schemable for annotating a JSON Schema. Interpretation using interpreters other than + * JsonSchema will not change the derivation. + * + * @since 1.2.0 + */ +import { constant, identity } from 'fp-ts/function' + +import * as Enc from '../../../base/EncoderBase' +import { WithAnnotate2 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const Encoder: WithAnnotate2 = { + annotate: constant(identity), +} diff --git a/src/schemables/WithAnnotate/instances/eq.ts b/src/schemables/WithAnnotate/instances/eq.ts new file mode 100644 index 00000000..72e08f3b --- /dev/null +++ b/src/schemables/WithAnnotate/instances/eq.ts @@ -0,0 +1,18 @@ +/** + * Schemable for annotating a JSON Schema. Interpretation using interpreters other than + * JsonSchema will not change the derivation. + * + * @since 1.2.0 + */ +import * as Eq_ from 'fp-ts/Eq' +import { constant, identity } from 'fp-ts/function' + +import { WithAnnotate1 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const Eq: WithAnnotate1 = { + annotate: constant(identity), +} diff --git a/src/schemables/WithAnnotate/instances/guard.ts b/src/schemables/WithAnnotate/instances/guard.ts new file mode 100644 index 00000000..1a102aa0 --- /dev/null +++ b/src/schemables/WithAnnotate/instances/guard.ts @@ -0,0 +1,18 @@ +/** + * Schemable for annotating a JSON Schema. Interpretation using interpreters other than + * JsonSchema will not change the derivation. + * + * @since 1.2.0 + */ +import { constant, identity } from 'fp-ts/function' + +import * as G from '../../../base/GuardBase' +import { WithAnnotate1 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const Guard: WithAnnotate1 = { + annotate: constant(identity), +} diff --git a/src/schemables/WithAnnotate/instances/json-schema.ts b/src/schemables/WithAnnotate/instances/json-schema.ts new file mode 100644 index 00000000..44557593 --- /dev/null +++ b/src/schemables/WithAnnotate/instances/json-schema.ts @@ -0,0 +1,16 @@ +/** + * Schemable for annotating a JSON Schema. Interpretation using interpreters other than + * JsonSchema will not change the derivation. + * + * @since 1.2.0 + */ +import * as JS from '../../../base/JsonSchemaBase' +import { WithAnnotate2 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const JsonSchema: WithAnnotate2 = { + annotate: JS.annotate, +} diff --git a/src/schemables/WithAnnotate/instances/printer.ts b/src/schemables/WithAnnotate/instances/printer.ts new file mode 100644 index 00000000..92cdcdf0 --- /dev/null +++ b/src/schemables/WithAnnotate/instances/printer.ts @@ -0,0 +1,18 @@ +/** + * Schemable for annotating a JSON Schema. Interpretation using interpreters other than + * JsonSchema will not change the derivation. + * + * @since 1.2.0 + */ +import { constant, identity } from 'fp-ts/function' + +import * as P from '../../../base/PrinterBase' +import { WithAnnotate2 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const Printer: WithAnnotate2 = { + annotate: constant(identity), +} diff --git a/src/schemables/WithAnnotate/instances/schema.ts b/src/schemables/WithAnnotate/instances/schema.ts new file mode 100644 index 00000000..338e8775 --- /dev/null +++ b/src/schemables/WithAnnotate/instances/schema.ts @@ -0,0 +1,16 @@ +/** + * Schemable for annotating a JSON Schema. Interpretation using interpreters other than + * JsonSchema will not change the derivation. + * + * @since 1.2.0 + */ +import * as SC from '../../../SchemaExt' + +/** + * @since 1.2.0 + * @category Combinators + */ +export const Schema = + (params?: { title?: string; description?: string }) => + (schema: SC.SchemaExt): SC.SchemaExt => + SC.make(s => s.annotate(params)(schema(s))) diff --git a/src/schemables/WithAnnotate/instances/task-decoder.ts b/src/schemables/WithAnnotate/instances/task-decoder.ts new file mode 100644 index 00000000..5e133526 --- /dev/null +++ b/src/schemables/WithAnnotate/instances/task-decoder.ts @@ -0,0 +1,18 @@ +/** + * Schemable for annotating a JSON Schema. Interpretation using interpreters other than + * JsonSchema will not change the derivation. + * + * @since 1.2.0 + */ +import { constant, identity } from 'fp-ts/function' + +import * as TD from '../../../base/TaskDecoderBase' +import { WithAnnotate2C } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const TaskDecoder: WithAnnotate2C = { + annotate: constant(identity), +} diff --git a/src/schemables/WithAnnotate/instances/type.ts b/src/schemables/WithAnnotate/instances/type.ts new file mode 100644 index 00000000..e1053f3a --- /dev/null +++ b/src/schemables/WithAnnotate/instances/type.ts @@ -0,0 +1,18 @@ +/** + * Schemable for annotating a JSON Schema. Interpretation using interpreters other than + * JsonSchema will not change the derivation. + * + * @since 1.2.0 + */ +import { constant, identity } from 'fp-ts/function' + +import * as t from '../../../base/TypeBase' +import { WithAnnotate1 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const Type: WithAnnotate1 = { + annotate: constant(identity), +} diff --git a/src/schemables/WithBrand/instances/json-schema.ts b/src/schemables/WithBrand/instances/json-schema.ts new file mode 100644 index 00000000..47dd908d --- /dev/null +++ b/src/schemables/WithBrand/instances/json-schema.ts @@ -0,0 +1,17 @@ +/** + * Schemable for constructing a branded newtype + * + * @since 1.2.0 + */ +import { identity } from 'fp-ts/function' + +import * as JS from '../../../base/JsonSchemaBase' +import { WithBrand2 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const JsonSchema: WithBrand2 = { + brand: () => identity, +} diff --git a/src/schemables/WithCheckDigit/instances/json-schema.ts b/src/schemables/WithCheckDigit/instances/json-schema.ts new file mode 100644 index 00000000..fe836aa3 --- /dev/null +++ b/src/schemables/WithCheckDigit/instances/json-schema.ts @@ -0,0 +1,17 @@ +/** + * Schemable for constructing a string with a check digit (e.g. ISBN or Credit Card) + * + * @since 1.2.0 + */ +import { identity } from 'fp-ts/function' + +import * as JS from '../../../base/JsonSchemaBase' +import { WithCheckDigit2 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const JsonSchema: WithCheckDigit2 = { + checkDigit: () => identity, +} diff --git a/src/schemables/WithDate/instances/json-schema.ts b/src/schemables/WithDate/instances/json-schema.ts new file mode 100644 index 00000000..c99f6070 --- /dev/null +++ b/src/schemables/WithDate/instances/json-schema.ts @@ -0,0 +1,16 @@ +/** + * Represents valid Date objects, and valid date-strings parsable by `Date.parse` + * + * @since 1.2.0 + */ +import * as JS from '../../../base/JsonSchemaBase' +import { WithDate2 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const JsonSchema: WithDate2 = { + date: JS.emptySchema, + dateFromString: JS.makeStringSchema({ format: 'date' }), +} diff --git a/src/schemables/WithFloat/instances/json-schema.ts b/src/schemables/WithFloat/instances/json-schema.ts new file mode 100644 index 00000000..af22598b --- /dev/null +++ b/src/schemables/WithFloat/instances/json-schema.ts @@ -0,0 +1,24 @@ +/** + * Floating point branded newtype. Parameters: min, max are inclusive. + * + * Represents floating point numbers: + * + * ```math + * { f | f ∈ ℝ, f >= -Number.MAX_VALUE, f <= Number.MAX_VALUE } + * ``` + * + * @since 1.2.0 + */ +import * as JS from '../../../base/JsonSchemaBase' +import { WithFloat2 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const JsonSchema: WithFloat2 = { + float(params = {}) { + const { min = -Number.MAX_VALUE, max = Number.MAX_VALUE } = params + return JS.makeNumberSchema({ minimum: min, maximum: max }) + }, +} diff --git a/src/schemables/WithInt/instances/json-schema.ts b/src/schemables/WithInt/instances/json-schema.ts new file mode 100644 index 00000000..762a0fa6 --- /dev/null +++ b/src/schemables/WithInt/instances/json-schema.ts @@ -0,0 +1,24 @@ +/** + * Integer branded newtype. Parameters: min, max are inclusive. + * + * Represents integers: + * + * ```math + * { z | z ∈ ℤ, z >= -2 ** 53 + 1, z <= 2 ** 53 - 1 } + * ``` + * + * @since 1.2.0 + */ +import * as JS from '../../../base/JsonSchemaBase' +import { WithInt2 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const JsonSchema: WithInt2 = { + int: (params = {}) => { + const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = params + return JS.makeIntegerSchema({ minimum: min, maximum: max }) + }, +} diff --git a/src/schemables/WithInvariant/instances/json-schema.ts b/src/schemables/WithInvariant/instances/json-schema.ts new file mode 100644 index 00000000..8f125fa7 --- /dev/null +++ b/src/schemables/WithInvariant/instances/json-schema.ts @@ -0,0 +1,17 @@ +/** + * Invariant mapping for schemable + * + * @since 1.2.0 + */ +import { identity } from 'fp-ts/function' + +import * as JS from '../../../base/JsonSchemaBase' +import { WithInvariant2 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const JsonSchema: WithInvariant2 = { + imap: () => () => identity, +} diff --git a/src/schemables/WithJson/instances/json-schema.ts b/src/schemables/WithJson/instances/json-schema.ts new file mode 100644 index 00000000..58ffcbd1 --- /dev/null +++ b/src/schemables/WithJson/instances/json-schema.ts @@ -0,0 +1,16 @@ +/** + * A basal schemable for Json and JsonString + * + * @since 1.2.0 + */ +import * as JS from '../../../base/JsonSchemaBase' +import { WithJson2 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const JsonSchema: WithJson2 = { + json: JS.emptySchema, + jsonString: JS.makeStringSchema({ contentMediaType: 'application/json' }), +} diff --git a/src/schemables/WithMap/instances/json-schema.ts b/src/schemables/WithMap/instances/json-schema.ts new file mode 100644 index 00000000..a5690ae0 --- /dev/null +++ b/src/schemables/WithMap/instances/json-schema.ts @@ -0,0 +1,15 @@ +/** + * Represents a ReadonlyMap converted from an expected array of entries. + * + * @since 1.2.0 + */ +import * as JS from '../../../base/JsonSchemaBase' +import { WithMap2 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const JsonSchema: WithMap2 = { + mapFromEntries: (_, jsK, jsA) => JS.makeArraySchema()(JS.Schemable.tuple(jsK, jsA)), +} diff --git a/src/schemables/WithOption/instances/json-schema.ts b/src/schemables/WithOption/instances/json-schema.ts new file mode 100644 index 00000000..a7850752 --- /dev/null +++ b/src/schemables/WithOption/instances/json-schema.ts @@ -0,0 +1,22 @@ +/** + * Represents an exclusion of a supplied value where the exclusion is mapped to `None`. + * Requires an inner schemable, and an Eq instance which defaults to strict equality. + * + * @since 1.2.0 + */ +import * as JS from '../../../base/JsonSchemaBase' +import { WithOption2 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const JsonSchema: WithOption2 = { + optionFromExclude: (exclude, jsA) => + /* + * TODO @jacob-alford: Exclude can technically _not_ be a literal value, and that + * might cause problems if the excluded value is not a valid schema value, such as undefined. + */ + // @ts-expect-error -- typelevel difference + JS.makeExclusionSchema(exclude, jsA), +} diff --git a/src/schemables/WithOptional/instances/json-schema.ts b/src/schemables/WithOptional/instances/json-schema.ts new file mode 100644 index 00000000..f2216792 --- /dev/null +++ b/src/schemables/WithOptional/instances/json-schema.ts @@ -0,0 +1,16 @@ +/** + * Schemable for widening a type to include undefined. Similar to nullable but for undefined. + * + * @since 1.2.0 + */ +import * as JS from '../../../base/JsonSchemaBase' +import { WithOptional2 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const JsonSchema: WithOptional2 = { + // Undefined is not a valid JSON Value + optional: () => JS.emptySchema, +} diff --git a/src/schemables/WithPadding/instances/json-schema.ts b/src/schemables/WithPadding/instances/json-schema.ts new file mode 100644 index 00000000..d553fd87 --- /dev/null +++ b/src/schemables/WithPadding/instances/json-schema.ts @@ -0,0 +1,49 @@ +/** + * Adds a character to the right or left of a string until it reaches a certain length. + * + * @since 1.2.0 + */ +import { pipe } from 'fp-ts/function' + +import * as JS from '../../../base/JsonSchemaBase' +import { WithPadding2 } from '../definition' +import { match } from '../utils' + +/** + * @since 1.2.0 + * @category Instances + */ +export const JsonSchema: WithPadding2 = { + padLeft: length => stringSchema => + pipe( + length, + match({ + MaxLength: ({ maxLength }) => + typeof maxLength === 'number' + ? JS.makeIntersectionSchema(stringSchema)(JS.makeStringSchema({ maxLength })) + : JS.makeIntersectionSchema(stringSchema)(JS.makeStringSchema()), + ExactLength: ({ exactLength }) => + typeof exactLength === 'number' + ? JS.makeIntersectionSchema(stringSchema)( + JS.makeStringSchema({ minLength: exactLength, maxLength: exactLength }), + ) + : JS.makeIntersectionSchema(stringSchema)(JS.makeStringSchema()), + }), + ), + padRight: length => stringSchema => + pipe( + length, + match({ + MaxLength: ({ maxLength }) => + typeof maxLength === 'number' + ? JS.makeIntersectionSchema(stringSchema)(JS.makeStringSchema({ maxLength })) + : JS.makeIntersectionSchema(stringSchema)(JS.makeStringSchema()), + ExactLength: ({ exactLength }) => + typeof exactLength === 'number' + ? JS.makeIntersectionSchema(stringSchema)( + JS.makeStringSchema({ minLength: exactLength, maxLength: exactLength }), + ) + : JS.makeIntersectionSchema(stringSchema)(JS.makeStringSchema()), + }), + ), +} diff --git a/src/schemables/WithPattern/instances/json-schema.ts b/src/schemables/WithPattern/instances/json-schema.ts new file mode 100644 index 00000000..c272ca51 --- /dev/null +++ b/src/schemables/WithPattern/instances/json-schema.ts @@ -0,0 +1,24 @@ +/** + * Schemable construction based on Regex combinators + * + * @since 1.2.0 + */ +import { pipe } from 'fp-ts/function' + +import * as JS from '../../../base/JsonSchemaBase' +import * as PB from '../../../PatternBuilder' +import { WithPattern2 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const JsonSchema: WithPattern2 = { + pattern: (pattern, description, caseInsensitive) => + pipe( + JS.makeStringSchema({ + pattern: PB.regexFromPattern(pattern, caseInsensitive).source, + }), + JS.annotate({ description }), + ), +} diff --git a/src/schemables/WithRefine/instances/json-schema.ts b/src/schemables/WithRefine/instances/json-schema.ts new file mode 100644 index 00000000..f547de7a --- /dev/null +++ b/src/schemables/WithRefine/instances/json-schema.ts @@ -0,0 +1,13 @@ +/** @since 1.2.0 */ +import { identity } from 'fp-ts/function' + +import * as JS from '../../../base/JsonSchemaBase' +import { WithRefine2 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const JsonSchema: WithRefine2 = { + refine: () => identity, +} diff --git a/src/schemables/WithUnknownContainers/instances/json-schema.ts b/src/schemables/WithUnknownContainers/instances/json-schema.ts new file mode 100644 index 00000000..0e17d5e4 --- /dev/null +++ b/src/schemables/WithUnknownContainers/instances/json-schema.ts @@ -0,0 +1,12 @@ +/** @since 1.2.0 */ +import * as JS from '../../../base/JsonSchemaBase' +import { WithUnknownContainers2 } from '../definition' + +/** + * @since 1.2.0 + * @category Instances + */ +export const JsonSchema: WithUnknownContainers2 = { + UnknownArray: JS.makeArraySchema()(JS.emptySchema), + UnknownRecord: JS.makeRecordSchema(JS.emptySchema), +} diff --git a/src/schemables/WithUnknownContainers/instances/printer.ts b/src/schemables/WithUnknownContainers/instances/printer.ts index 66eb1f4f..e5fd4200 100644 --- a/src/schemables/WithUnknownContainers/instances/printer.ts +++ b/src/schemables/WithUnknownContainers/instances/printer.ts @@ -1,4 +1,4 @@ -/** @since 1.0.0 */ +/** @since 1.1.0 */ import * as P from '../../../base/PrinterBase' import { WithUnknownContainers2 } from '../definition' diff --git a/src/schemata.ts b/src/schemata.ts index 061775e9..a90eb05c 100644 --- a/src/schemata.ts +++ b/src/schemata.ts @@ -23,6 +23,14 @@ export * from './base/SchemaBase' /** Schemables */ +export { + /** + * Schemable for annotating a JSON Schema. Interpretation using interpreters other than + * JsonSchema will not change the derivation. + * + * @since 1.2.0 + */ Schema as Annotate, +} from './schemables/WithAnnotate/instances/schema' export { /** * Schemable for constructing a branded newtype @@ -177,7 +185,6 @@ export { * * Notable features: * - * - Requires `T` separator between date and time * - Requires padded months, days, hours, minutes, and seconds * - Can be configured to require a time, time and timezone offset (e.g. `Z` or `±05:00`) * or neither (default is require both). diff --git a/src/schemata/date/DateFromIsoString.ts b/src/schemata/date/DateFromIsoString.ts index fdd2326d..6e5470d7 100644 --- a/src/schemata/date/DateFromIsoString.ts +++ b/src/schemata/date/DateFromIsoString.ts @@ -294,7 +294,6 @@ export type DateFromIsoStringS = ( * * Notable features: * - * - Requires `T` separator between date and time * - Requires padded months, days, hours, minutes, and seconds * - Can be configured to require a time, time and timezone offset (e.g. `Z` or `±05:00`) or * neither (default is require both). diff --git a/tests/JsonSchema.test.ts b/tests/JsonSchema.test.ts new file mode 100644 index 00000000..637d0f73 --- /dev/null +++ b/tests/JsonSchema.test.ts @@ -0,0 +1,509 @@ +import * as fc from 'fast-check' +import * as Str from 'fp-ts/string' +import { Draft04, Draft06, Draft07 } from 'json-schema-library' + +import { getArbitrary } from '../src/Arbitrary' +import * as base from '../src/base/JsonSchemaBase' +import { getJsonSchema } from '../src/JsonSchema' +import * as S from '../src/schemata' + +describe('JsonSchema', () => { + describe('guards', () => { + test('isJsonEmpty', () => { + expect(base.isJsonEmpty(base.emptySchema)).toBe(true) + expect(base.isJsonEmpty({})).toBe(true) + }) + test('isJsonString', () => { + expect(base.isJsonString(base.makeStringSchema())).toBe(true) + expect(base.isJsonString({ type: 'string' })).toBe(true) + }) + test('isJsonNumber', () => { + expect(base.isJsonNumber(base.makeNumberSchema())).toBe(true) + expect(base.isJsonNumber({ type: 'number' })).toBe(true) + }) + test('isJsonNull', () => { + expect(base.isJsonNull(base.nullSchema)).toBe(true) + expect(base.isJsonNull({ type: 'null', const: null })).toBe(true) + }) + test('isJsonInteger', () => { + expect(base.isJsonInteger(base.makeIntegerSchema())).toBe(true) + expect(base.isJsonInteger({ type: 'integer' })).toBe(true) + }) + test('isJsonBoolean', () => { + expect(base.isJsonBoolean(base.booleanSchema)).toBe(true) + expect(base.isJsonBoolean({ type: 'boolean' })).toBe(true) + }) + test('isJsonLiteral', () => { + expect(base.isJsonLiteral(base.makeLiteralSchema('string'))).toBe(true) + expect(base.isJsonLiteral({ type: 'string', const: 'string' })).toBe(true) + }) + test('isJsonArray', () => { + expect(base.isJsonArray(base.makeArraySchema()(base.emptySchema))).toBe(true) + expect(base.isJsonArray({ type: 'array', items: {} })).toBe(true) + }) + test('isJsonStruct', () => { + expect( + base.isJsonStruct(base.makeStructSchema({ a: base.makeStringSchema() })), + ).toBe(true) + expect( + base.isJsonStruct({ + type: 'object', + properties: { a: { type: 'string' } }, + required: [], + }), + ).toBe(true) + }) + test('isJsonRecord', () => { + expect(base.isJsonRecord(base.makeRecordSchema(base.makeStringSchema()))).toBe(true) + expect( + base.isJsonRecord({ type: 'object', additionalProperties: { type: 'string' } }), + ).toBe(true) + }) + test('isJsonExclude', () => { + expect( + base.isJsonExclude({ + not: { + const: 5, + }, + }), + ).toBe(true) + }) + test('isJsonUnion', () => { + expect( + base.isJsonUnion( + base.makeUnionSchema(base.makeStringSchema(), base.makeNumberSchema()), + ), + ).toBe(true) + expect( + base.isJsonUnion({ + oneOf: [{ type: 'string' }, { type: 'number' }], + }), + ).toBe(true) + }) + test('isJsonIntersection', () => { + expect( + base.isJsonIntersection( + base.makeIntersectionSchema(base.makeStringSchema())(base.makeNumberSchema()), + ), + ).toBe(true) + expect( + base.isJsonIntersection({ + allOf: [{ type: 'string' }, { type: 'number' }], + }), + ).toBe(true) + }) + }) + + describe('derivation', () => { + const testSchema = S.Readonly( + S.Struct({ + literal: S.Literal('string', 5, true, null), + nully: S.Nullable(S.Int({ min: 0, max: 1 })), + partial: S.Partial({ + optNull: S.OptionFromNullable(S.Float({ min: 0, max: 1 })), + optUndefined: S.OptionFromUndefined(S.Float({ min: 0, max: 1 })), + }), + rec: S.Record(S.CreditCard), + arr: S.Array(S.Json.jsonString), + + tup: S.Tuple(S.JsonFromString, S.Number), + sum: S.Sum('type')({ + a: S.Struct({ type: S.Literal('a'), a: S.Boolean }), + b: S.Struct({ type: S.Literal('b'), b: S.Lazy('Sum[b].b', () => S.Natural) }), + }), + intersection: S.Intersection(S.Struct({ a: S.NonPositiveInt }))( + S.Struct({ b: S.NonNegativeFloat, c: S.NonPositiveFloat }), + ), + date: S.Date.dateFromString, + isoDate: S.DateFromIsoString(), + map: S.Map(Str.Ord, S.String, S.Float()), + int: S.Int(), + option: S.Option(69420, S.Number), + singularSum: S.Sum('type')({ + a: S.Struct({ + type: S.Literal('a'), + a: S.NonEmptyArray( + S.Padding.padRight( + { by: 'ExactLength', exactLength: 1 }, + '*', + )( + S.Padding.padRight( + { by: 'MaxLength', maxLength: 2 }, + '=', + )( + S.Padding.padLeft( + { by: 'MaxLength', maxLength: 3 }, + '=', + )( + S.Padding.padLeft( + { by: 'ExactLength', exactLength: 4 }, + '=', + )( + S.Padding.padRight( + { by: 'ExactLength', exactLength: () => 0 }, + '&', + )( + S.Padding.padRight( + { by: 'MaxLength', maxLength: () => 0 }, + '=', + )( + S.Padding.padLeft( + { by: 'MaxLength', maxLength: () => 0 }, + '=', + )( + S.Padding.padLeft( + { by: 'ExactLength', exactLength: () => 0 }, + '=', + )(S.String), + ), + ), + ), + ), + ), + ), + ), + ), + }), + }), + }), + ) + const testValue = base.stripIdentity(getJsonSchema(testSchema)) as any + test('struct', () => { + expect(testValue.type).toBe('object') + expect(testValue.required).toStrictEqual([ + 'literal', + 'nully', + 'partial', + 'rec', + 'arr', + 'tup', + 'sum', + 'intersection', + 'date', + 'isoDate', + 'map', + 'int', + 'option', + 'singularSum', + ]) + }) + test('literal', () => { + expect(testValue.properties.literal).toStrictEqual({ + oneOf: [ + { type: 'string', const: 'string' } as any, + { type: 'number', const: 5 }, + { type: 'boolean', const: true }, + { type: 'null', const: null }, + ], + }) + }) + test('nullable', () => { + expect(testValue.properties.nully).toStrictEqual({ + oneOf: [ + { type: 'integer', minimum: 0, maximum: 1 }, + { type: 'null', const: null }, + ], + }) + }) + test('partial', () => { + expect(testValue.properties.partial).toStrictEqual({ + type: 'object', + properties: { + optNull: { + oneOf: [ + { type: 'number', minimum: 0, maximum: 1 }, + { type: 'null', const: null }, + ], + }, + optUndefined: {}, + }, + required: [], + }) + }) + test('record', () => { + expect(testValue.properties.rec).toStrictEqual({ + type: 'object', + additionalProperties: { + description: 'CreditCard', + type: 'string', + pattern: + '^(4(\\d{12}|\\d{15})|(5[1-5]\\d{4}|(222)[1-9]\\d{2}|(22)[3-9]\\d{3}|(2)[3-6]\\d{4}|(27)[01]\\d{3}|(2720)\\d{2})\\d{10}|3[47]\\d{13}|(3(0([0-5]\\d{5}|(95)\\d{4})|[89]\\d{6})\\d{8,11}|(36)\\d{6}\\d{6,11})|((6011)(0[5-9]\\d{2}|[2-4]\\d{3}|(74)\\d{2}|(7)[7-9]\\d{2}|(8)[6-9]\\d{2}|(9)\\d{3})|(64)[4-9]\\d{5}|(650)[0-5]\\d{4}|(65060)[1-9]\\d{2}|(65061)[1-9]\\d{2}|(6506)[2-9]\\d{3}|(650)[7-9]\\d{4}|(65)[1-9]\\d{5})\\d{8,11}|((352)[89]\\d{4}|(35)[3-8]\\d{5})\\d{8,11}|(((60)|(65)|(81)|(82))\\d{14}|(508)\\d{14})|(62)(2((12)[6-9]\\d{2}|1[3-9]\\d{3}|[2-8]\\d|(9)[01]\\d{3}|(92)[0-5]\\d{2})|[4-6]\\d{5}|(8)[2-8]\\d{4})\\d{8,11})$', + }, + }) + }) + test('array', () => { + expect(testValue.properties.arr).toStrictEqual({ + type: 'array', + items: { + type: 'string', + contentMediaType: 'application/json', + }, + }) + }) + test('tuple', () => { + expect(testValue.properties.tup).toStrictEqual({ + type: 'array', + items: [ + { + type: 'string', + contentMediaType: 'application/json', + }, + { + type: 'number', + }, + ], + minItems: 2, + maxItems: 2, + }) + }) + test('sum', () => { + expect(testValue.properties.sum).toStrictEqual({ + oneOf: [ + { + type: 'object', + properties: { + type: { + type: 'string', + const: 'a', + }, + a: { + type: 'boolean', + }, + }, + required: ['type', 'a'], + }, + { + type: 'object', + properties: { + type: { + type: 'string', + const: 'b', + }, + b: { + type: 'integer', + maximum: 9007199254740991, + minimum: 0, + }, + }, + required: ['type', 'b'], + }, + ], + }) + }) + test('date', () => { + expect(testValue.properties.date).toStrictEqual({ + type: 'string', + format: 'date', + }) + }) + test('isoDate', () => { + expect(testValue.properties.isoDate).toStrictEqual({ + type: 'string', + description: 'IsoDateString', + pattern: + '^(((\\d{4}|[+\\x2d]\\d{6})-((0[1-9])|(1[0-2]))-((0[1-9])|(1\\d|[2]\\d|3[0-1]))|((\\d{4}|[+\\x2d]\\d{6})-((0[1-9])|(1[0-2])))|\\d{4}|[+\\x2d]\\d{6})(T| )(((0\\d)|(1\\d|2[0-3])):((0\\d)|(1\\d|[2-4]\\d|5\\d)):((0\\d)|(1\\d|[2-4]\\d|5\\d))\\.(\\d+?)|((0\\d)|(1\\d|2[0-3])):((0\\d)|(1\\d|[2-4]\\d|5\\d)):((0\\d)|(1\\d|[2-4]\\d|5\\d))|((0\\d)|(1\\d|2[0-3])):((0\\d)|(1\\d|[2-4]\\d|5\\d)))(Z|[+\\x2d]((0\\d)|(1\\d|2[0-3])):((0\\d)|(1\\d|[2-4]\\d|5\\d))))$', + }) + }) + test('intersection', () => { + expect(testValue.properties.intersection).toStrictEqual({ + allOf: [ + { + type: 'object', + properties: { + a: { + type: 'integer', + maximum: 0, + minimum: -9007199254740991, + }, + }, + required: ['a'], + }, + { + type: 'object', + properties: { + b: { + type: 'number', + maximum: 1.7976931348623157e308, + minimum: 0, + }, + c: { + type: 'number', + maximum: 0, + minimum: -1.7976931348623157e308, + }, + }, + required: ['b', 'c'], + }, + ], + }) + }) + test('map', () => { + expect(testValue.properties.map).toStrictEqual({ + type: 'array', + items: { + type: 'array', + items: [ + { + type: 'string', + }, + { + type: 'number', + maximum: 1.7976931348623157e308, + minimum: -1.7976931348623157e308, + }, + ], + minItems: 2, + maxItems: 2, + }, + }) + }) + test('int', () => { + expect(testValue.properties.int).toStrictEqual({ + type: 'integer', + maximum: 9007199254740991, + minimum: -9007199254740991, + }) + }) + test('option', () => { + expect(testValue.properties.option).toStrictEqual({ + allOf: [ + { + not: { + const: 69420, + }, + }, + { + type: 'number', + }, + ], + }) + }) + test('singularSum', () => { + expect(testValue.properties.singularSum).toStrictEqual({ + type: 'object', + properties: { + type: { + type: 'string', + const: 'a', + }, + a: { + type: 'array', + items: { + allOf: [ + { + type: 'string', + maxLength: 1, + minLength: 1, + }, + { + allOf: [ + { + type: 'string', + maxLength: 2, + }, + { + allOf: [ + { + type: 'string', + maxLength: 3, + }, + { + allOf: [ + { + type: 'string', + maxLength: 4, + minLength: 4, + }, + { + allOf: [ + { + type: 'string', + }, + { + allOf: [ + { + type: 'string', + }, + { + allOf: [ + { + type: 'string', + }, + { + allOf: [ + { + type: 'string', + }, + { + type: 'string', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + }, + required: ['type', 'a'], + }) + }) + }) + + describe('externally valid', () => { + const testSchema = S.Readonly( + S.Struct({ + literal: S.Literal('string', 5, true, null), + nully: S.Nullable(S.Int({ min: 0, max: 1 })), + rec: S.Record(S.CreditCard), + arr: S.Array(S.Json.jsonString), + + tup: S.Tuple(S.Number, S.Number), + sum: S.Sum('type')({ + a: S.Struct({ type: S.Literal('a'), a: S.Boolean }), + b: S.Struct({ type: S.Literal('b'), b: S.Lazy('Sum[b].b', () => S.Natural) }), + }), + intersection: S.Intersection(S.Struct({ a: S.NonPositiveInt }))( + S.Struct({ b: S.NonNegativeFloat, c: S.NonPositiveFloat }), + ), + int: S.Int(), + }), + ) + const jsonSchema = getJsonSchema(testSchema) + const arbitrary = getArbitrary(testSchema).arbitrary(fc) + it('validates for version 4', () => { + const v4 = new Draft04(jsonSchema) + fc.assert( + fc.property(arbitrary, value => { + expect(v4.isValid(value)).toBe(true) + }), + ) + }) + it('validates for version 6', () => { + const v6 = new Draft06(jsonSchema) + fc.assert( + fc.property(arbitrary, value => { + expect(v6.isValid(value)).toBe(true) + }), + ) + }) + it('validates for version 7', () => { + const v7 = new Draft07(jsonSchema) + fc.assert( + fc.property(arbitrary, value => { + expect(v7.isValid(value)).toBe(true) + }), + ) + }) + }) +}) diff --git a/tests/internal.util.test.ts b/tests/internal.util.test.ts index 8aae57c1..42fb5248 100644 --- a/tests/internal.util.test.ts +++ b/tests/internal.util.test.ts @@ -19,7 +19,7 @@ describe('traverseESO', () => { obj, witherS(RA.getMonoid())((_, value) => { tester(value) - return O.some(E.right(value)) + return E.right(O.some(value)) }), ) expect(tester).toHaveBeenCalledTimes(1) @@ -37,7 +37,7 @@ describe('traverseESO', () => { obj, witherS(RA.getMonoid())((_, value) => { tester(value) - return O.some(E.right(value)) + return E.right(O.some(value)) }), ) expect(tester).toHaveBeenCalledTimes(1) @@ -52,7 +52,7 @@ describe('traverseESO', () => { const result = pipe( obj, witherS(RA.getMonoid())((_, value) => { - return value === 2 ? O.none : O.some(E.right(value)) + return value === 2 ? E.right(O.none) : E.right(O.some(value)) }), ) expect(result).toStrictEqual(E.right({ a: 1, c: 3 })) @@ -68,7 +68,7 @@ describe('traverseESO', () => { obj, witherS(RA.getMonoid())((_, value) => { tester(value) - return value >= 2 ? O.some(E.left(['fail'])) : O.some(E.right(value)) + return value >= 2 ? E.left(['fail']) : E.right(O.some(value)) }), ) expect(result).toStrictEqual(E.left(['fail', 'fail'])) diff --git a/tests/schemables/WithAnnotate.test.ts b/tests/schemables/WithAnnotate.test.ts new file mode 100644 index 00000000..7192d596 --- /dev/null +++ b/tests/schemables/WithAnnotate.test.ts @@ -0,0 +1,23 @@ +import { makeIntegerSchema } from '../../src/base/JsonSchemaBase' +import { getJsonSchema } from '../../src/JsonSchema' +import * as S from '../../src/schemata' + +describe('annotation', () => { + it('annotates', () => { + const schema = S.Annotate({ title: 'root' })(S.BigIntFromString()) + const jsonSchema = getJsonSchema(schema) + expect(jsonSchema).toEqual({ + type: 'string', + pattern: '^((0b)[0-1]+?|(0o)[0-7]+?|-?\\d+?|(0x)[0-9A-Fa-f]+?)$', + title: 'root', + description: 'BigIntFromString', + }) + }) + it('doesnt trample class names', () => { + const schema = S.Annotate()(S.Natural) + const jsonSchema = getJsonSchema(schema) + expect(jsonSchema).toStrictEqual( + makeIntegerSchema({ minimum: 0, maximum: 9007199254740991 }), + ) + }) +}) diff --git a/yarn.lock b/yarn.lock index b8103f7d..50879208 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1558,6 +1558,19 @@ tiny-glob "^0.2.9" tslib "^2.4.0" +"@sagold/json-pointer@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@sagold/json-pointer/-/json-pointer-5.0.0.tgz#49a3f072cd95c89d82f67cc3d88b8bb86060c9e6" + integrity sha512-+kIj/GyAlBfZPPcxXlpMdcDP/HyQBbmVCrQ9VZQ8wEqphIHCg0oLojBCLTqpICie/tIc7nwXh3joLVJ4PTD+NQ== + +"@sagold/json-query@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@sagold/json-query/-/json-query-6.0.0.tgz#a250a98d19cc20d8c06e8e9eeaf9d1de833f1ed4" + integrity sha512-fk9BimvNrzlhXiy+dvlwyA97W2N6GmPWCJo/2kwKEtU9oc93cVKamca9NcnjKx1hhjECjPfu30NQ8Tg2JGv/pA== + dependencies: + "@sagold/json-pointer" "^5.0.0" + ebnf "^1.9.0" + "@sinonjs/commons@^1.7.0": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" @@ -3070,6 +3083,11 @@ eastasianwidth@^0.2.0: resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== +ebnf@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/ebnf/-/ebnf-1.9.0.tgz#9c2dd6052f3ed43a69c1f0b07b15bd03cefda764" + integrity sha512-LKK899+j758AgPq00ms+y90mo+2P86fMKUWD28sH0zLKUj7aL6iIH2wy4jejAMM9I2BawJ+2kp6C3mMXj+Ii5g== + electron-to-chromium@^1.4.202: version "1.4.246" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.246.tgz#802132d1bbd3ff32ce82fcd6a6ed6ab59b4366dc" @@ -5020,6 +5038,17 @@ json-parse-even-better-errors@^2.3.0: resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== +json-schema-library@^7.4.4: + version "7.4.4" + resolved "https://registry.yarnpkg.com/json-schema-library/-/json-schema-library-7.4.4.tgz#8c09aa0a4e20cb5cc46c0b148ad9384ca1f861c0" + integrity sha512-pn1uP4UChkqFvJ9KAZnPgZZbn8QDMmROwhR0EnjS4yW97aCy7jiu4bwEPsAZbqLE+ioSosaayHgC8HzsX2OZkQ== + dependencies: + "@sagold/json-pointer" "^5.0.0" + "@sagold/json-query" "^6.0.0" + deepmerge "^4.2.2" + fast-deep-equal "^3.1.3" + valid-url "^1.0.9" + json-schema-traverse@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" @@ -7593,6 +7622,11 @@ v8-to-istanbul@^7.0.0: convert-source-map "^1.6.0" source-map "^0.7.3" +valid-url@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/valid-url/-/valid-url-1.0.9.tgz#1c14479b40f1397a75782f115e4086447433a200" + integrity sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA== + validate-npm-package-license@^3.0.1: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"