diff --git a/library/src/actions/index.ts b/library/src/actions/index.ts index 0c37dd5c..e38b4600 100644 --- a/library/src/actions/index.ts +++ b/library/src/actions/index.ts @@ -36,6 +36,7 @@ export * from './isoTime/index.ts'; export * from './isoTimeSecond/index.ts'; export * from './isoTimestamp/index.ts'; export * from './isoWeek/index.ts'; +export * from './json/index.ts'; export * from './length/index.ts'; export * from './mac/index.ts'; export * from './mac48/index.ts'; diff --git a/library/src/actions/json/index.ts b/library/src/actions/json/index.ts new file mode 100644 index 00000000..de786e35 --- /dev/null +++ b/library/src/actions/json/index.ts @@ -0,0 +1 @@ +export * from './json.ts'; diff --git a/library/src/actions/json/json.test-d.ts b/library/src/actions/json/json.test-d.ts new file mode 100644 index 00000000..8885229e --- /dev/null +++ b/library/src/actions/json/json.test-d.ts @@ -0,0 +1,41 @@ +import { describe, expectTypeOf, test } from 'vitest'; +import type { InferInput, InferIssue, InferOutput } from '../../types/index.ts'; +import { json, type JsonAction, type JsonIssue } from './json.ts'; + +describe('json', () => { + describe('should return action object', () => { + test('with undefined message', () => { + type Action = JsonAction; + expectTypeOf(json()).toEqualTypeOf(); + expectTypeOf(json(undefined)).toEqualTypeOf(); + }); + + test('with string message', () => { + expectTypeOf(json('message')).toEqualTypeOf< + JsonAction + >(); + }); + + test('with function message', () => { + expectTypeOf(json string>(() => 'message')).toEqualTypeOf< + JsonAction string> + >(); + }); + }); + + describe('should infer correct types', () => { + type Action = JsonAction; + + test('of input', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + test('of output', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + test('of issue', () => { + expectTypeOf>().toEqualTypeOf>(); + }); + }); +}); diff --git a/library/src/actions/json/json.test.ts b/library/src/actions/json/json.test.ts new file mode 100644 index 00000000..a1b742d3 --- /dev/null +++ b/library/src/actions/json/json.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from 'vitest'; +import { HEX_COLOR_REGEX } from '../../regex.ts'; +import type { StringIssue } from '../../schemas/index.ts'; +import { expectActionIssue, expectNoActionIssue } from '../../vitest/index.ts'; +import { + json, + type JsonAction, + type JsonIssue, +} from './json.ts'; + +describe('json', () => { + describe('should return action object', () => { + const baseAction: Omit, 'message'> = { + kind: 'validation', + type: 'json', + reference: json, + expects: null, + requirement: expect.any(Function), + async: false, + '~run': expect.any(Function), + }; + + test('with undefined message', () => { + const action: JsonAction = { + ...baseAction, + message: undefined, + }; + expect(json()).toStrictEqual(action); + expect(json(undefined)).toStrictEqual(action); + }); + + test('with string message', () => { + expect(json('message')).toStrictEqual({ + ...baseAction, + message: 'message', + } satisfies JsonAction); + }); + + test('with function message', () => { + const message = () => 'message'; + expect(json(message)).toStrictEqual({ + ...baseAction, + message, + } satisfies JsonAction); + }); + }); + + describe('should return dataset without issues', () => { + const action = json(); + + test('for untyped inputs', () => { + const issues: [StringIssue] = [ + { + kind: 'schema', + type: 'string', + input: null, + expected: 'string', + received: 'null', + message: 'message', + }, + ]; + expect( + action['~run']({ typed: false, value: null, issues }, {}) + ).toStrictEqual({ + typed: false, + value: null, + issues, + }); + }); + + test('for literals', () => { + expectNoActionIssue(action, [ + '{}', + '[]', + 'null', + '123', + '"example"', + '12.3', + '"escaped \\"quote"', + ]); + }); + + test('for complex values', () => { + expectNoActionIssue(action, [ + '{"name":"John Doe","age":30,"color":null,"children":["Alice","Bob"]}', + '[123, "John Doe", null, {"fruit":"apple"}]', + ]); + }); + }); + + describe('should return dataset with issues', () => { + const action = json('message'); + const baseIssue: Omit, 'input' | 'received'> = { + kind: 'validation', + type: 'json', + expected: null, + message: 'message', + requirement: expect.any(Function), + }; + + test('for empty strings', () => { + expectActionIssue(action, baseIssue, ['', ' ', '\n']); + }); + + test('for malformed strings', () => { + expectActionIssue(action, baseIssue, [ + '{"key:"value"}', + '{key":"value"}', + "'key'", + '"unescaped "quote""', + '{]', + '[}', + ]); + }); + + test('for malformed arrays', () => { + expectActionIssue(action, baseIssue, [ + '[1, 2, , 3]', + '[1, 2, "key":"value"]', + ]); + }); + + test('for malformed objects', () => { + expectActionIssue(action, baseIssue, [ + '{"key":"value",,"key2": "value2"}', + '{"key":"value","key2"}', + '{{}}', + ]); + }); + + test('for trailing commas', () => { + expectActionIssue(action, baseIssue, [ + '{"key":"value"},', + '{"key":"value",}', + '[1, 2, 3,]', + ]); + }); + }); +}); diff --git a/library/src/actions/json/json.ts b/library/src/actions/json/json.ts new file mode 100644 index 00000000..ff877a4d --- /dev/null +++ b/library/src/actions/json/json.ts @@ -0,0 +1,107 @@ +import type { + BaseIssue, + BaseValidation, + ErrorMessage, +} from '../../types/index.ts'; +import { _addIssue } from '../../utils/index.ts'; + +/** + * JSON issue type. + */ +export interface JsonIssue extends BaseIssue { + /** + * The issue kind. + */ + readonly kind: 'validation'; + /** + * The issue type. + */ + readonly type: 'json'; + /** + * The expected property. + */ + readonly expected: null; + /** + * The received property. + */ + readonly received: `"${string}"`; + /** + * The JSON validation requirement. + */ + readonly requirement: (input: string) => boolean; +} + +/** + * JSON action type. + */ +export interface JsonAction< + TInput extends string, + TMessage extends ErrorMessage> | undefined, +> extends BaseValidation> { + /** + * The action type. + */ + readonly type: 'json'; + /** + * The action reference. + */ + readonly reference: typeof json; + /** + * The expected property. + */ + readonly expects: null; + /** + * The JSON validation requirement. + */ + readonly requirement: (input: string) => boolean; + /** + * The error message. + */ + readonly message: TMessage; +} + +/** + * Creates a [JSON](https://en.wikipedia.org/wiki/JSON) validation action. + * + * @returns A JSON action. + */ +export function json(): JsonAction; + +/** + * Creates a [JSON](https://en.wikipedia.org/wiki/JSON) validation action. + * + * @param message The error message. + * + * @returns A JSON action. + */ +export function json< + TInput extends string, + const TMessage extends ErrorMessage> | undefined, +>(message: TMessage): JsonAction; + +export function json( + message?: ErrorMessage> +): JsonAction> | undefined> { + return { + kind: 'validation', + type: 'json', + reference: json, + async: false, + expects: null, + requirement(input) { + try { + JSON.parse(input); + return true; + } catch { + return false; + } + }, + message, + '~run'(dataset, config) { + if (dataset.typed && !this.requirement(dataset.value)) { + _addIssue(this, 'JSON', dataset, config); + } + return dataset; + }, + }; +}