Skip to content

Commit

Permalink
feat: add json string validation action
Browse files Browse the repository at this point in the history
  • Loading branch information
hendrikheil committed Dec 4, 2024
1 parent 65f0e59 commit 850c22f
Show file tree
Hide file tree
Showing 5 changed files with 289 additions and 0 deletions.
1 change: 1 addition & 0 deletions library/src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions library/src/actions/json/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './json.ts';
41 changes: 41 additions & 0 deletions library/src/actions/json/json.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<string, undefined>;
expectTypeOf(json<string>()).toEqualTypeOf<Action>();
expectTypeOf(json<string, undefined>(undefined)).toEqualTypeOf<Action>();
});

test('with string message', () => {
expectTypeOf(json<string, 'message'>('message')).toEqualTypeOf<
JsonAction<string, 'message'>
>();
});

test('with function message', () => {
expectTypeOf(json<string, () => string>(() => 'message')).toEqualTypeOf<
JsonAction<string, () => string>
>();
});
});

describe('should infer correct types', () => {
type Action = JsonAction<string, undefined>;

test('of input', () => {
expectTypeOf<InferInput<Action>>().toEqualTypeOf<string>();
});

test('of output', () => {
expectTypeOf<InferOutput<Action>>().toEqualTypeOf<string>();
});

test('of issue', () => {
expectTypeOf<InferIssue<Action>>().toEqualTypeOf<JsonIssue<string>>();
});
});
});
139 changes: 139 additions & 0 deletions library/src/actions/json/json.test.ts
Original file line number Diff line number Diff line change
@@ -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<JsonAction<string, never>, '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<string, undefined> = {
...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<string, string>);
});

test('with function message', () => {
const message = () => 'message';
expect(json(message)).toStrictEqual({
...baseAction,
message,
} satisfies JsonAction<string, typeof message>);
});
});

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<JsonIssue<string>, '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,]',
]);
});
});
});
107 changes: 107 additions & 0 deletions library/src/actions/json/json.ts
Original file line number Diff line number Diff line change
@@ -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<TInput extends string> extends BaseIssue<TInput> {
/**
* 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<JsonIssue<TInput>> | undefined,
> extends BaseValidation<TInput, TInput, JsonIssue<TInput>> {
/**
* 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<TInput extends string>(): JsonAction<TInput, undefined>;

/**
* 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<JsonIssue<TInput>> | undefined,
>(message: TMessage): JsonAction<TInput, TMessage>;

export function json(
message?: ErrorMessage<JsonIssue<string>>
): JsonAction<string, ErrorMessage<JsonIssue<string>> | 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;
},
};
}

0 comments on commit 850c22f

Please sign in to comment.