Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add json string validation action #957

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>>();
});
});
});
138 changes: 138 additions & 0 deletions library/src/actions/json/json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { describe, expect, test } from 'vitest';
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;
},
};
}