Skip to content

Commit

Permalink
feat!: oneOf now has more strict validation, it actually checks that …
Browse files Browse the repository at this point in the history
…exactly one schema matches
  • Loading branch information
kokovtsev committed Jan 10, 2024
1 parent 82158bc commit cce2557
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 89 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,33 @@ describe('SchemaObject', () => {
},
],
},
{
anyOf: [
{
type: 'object',
properties: {
walk: { type: 'string' },
},
required: ['walk'],
},
{
type: 'object',
properties: {
swim: { type: 'string' },
},
required: ['swim'],
},
],
}
],
});
const serialized = pipe(schema, reportIfFailed, either.chain(serializeSchemaObject(ref)));
pipe(
serialized,
either.fold(fail, result => {
expect(result.type).toEqual('{ id: string } & ({ value: string } | { error: string })');
expect(result.type).toEqual('{ id: string } & ({ value: string } | { error: string }) & ({ walk: string } | { swim: string })');
expect(result.io).toEqual(
'intersection([type({ id: string }),union([type({ value: string }),type({ error: string })])])',
'intersection([type({ id: string }),oneOf([type({ value: string }),type({ error: string })]),union([type({ walk: string }),type({ swim: string })])])',
);
}),
);
Expand Down
13 changes: 12 additions & 1 deletion src/language/typescript/3.0/serializers/schema-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getSerializedIntersectionType,
getSerializedNullableType,
getSerializedObjectType,
getSerializedOneOfType,
getSerializedOptionPropertyType,
getSerializedRecursiveType,
getSerializedRefType,
Expand Down Expand Up @@ -32,6 +33,7 @@ import {
OneOfSchemaObjectCodec,
SchemaObject,
PrimitiveSchemaObject,
AnyOfSchemaObjectCodec,
} from '../../../../schema/3.0/schema-object';
import { ReferenceObject, ReferenceObjectCodec } from '../../../../schema/3.0/reference-object';
import { traverseNEAEither } from '../../../../utils/either';
Expand All @@ -53,10 +55,19 @@ const serializeSchemaObjectWithRecursion = (from: Ref, shouldTrackRecursion: boo
schemaObject: SchemaObject,
): Either<Error, SerializedType> => {
const isNullable = pipe(schemaObject.nullable, option.exists(identity));
if (AnyOfSchemaObjectCodec.is(schemaObject)) {
return pipe(
serializeChildren(from, schemaObject.anyOf),
either.map(getSerializedUnionType),
either.map(getSerializedRecursiveType(from, shouldTrackRecursion)),
either.map(getSerializedNullableType(isNullable)),
);
}

if (OneOfSchemaObjectCodec.is(schemaObject)) {
return pipe(
serializeChildren(from, schemaObject.oneOf),
either.map(getSerializedUnionType),
either.chain(children => getSerializedOneOfType(from, children)),
either.map(getSerializedRecursiveType(from, shouldTrackRecursion)),
either.map(getSerializedNullableType(isNullable)),
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { oneOf } from '../utils.bundle';
import { string, Type, type } from 'io-ts';
import { optionFromNullable } from 'io-ts-types/lib/optionFromNullable';
import { none, some } from 'fp-ts/lib/Option';

describe('utils.bundle', () => {
describe('oneOf', () => {
function expectDecodeResult(o: Type<any>, input: unknown, valid: boolean) {
expect(o.is(input)).toBe(valid);
expect(o.validate(input, [])).toMatchObject({ _tag: valid ? 'Right' : 'Left' });
}

it('should ensure that the value matches exactly one schema', () => {
const o = oneOf([type({ left: string }), type({ right: string })]);

expectDecodeResult(o, { left: 'test' }, true);
expectDecodeResult(o, { right: 'test' }, true);
expectDecodeResult(o, { right: 'test', extra: 'allowed' }, true);

expectDecodeResult(o, {}, false);
expectDecodeResult(o, { middle: '???' }, false);
expectDecodeResult(o, { left: 1000 }, false);
expectDecodeResult(o, { left: 'test', right: 'test' }, false);
});

it('should encode correctly, even if multiple schemas are matched', () => {
const o = oneOf([type({ left: optionFromNullable(string) }), type({ right: optionFromNullable(string) })]);

expect(o.encode({ left: some('123') })).toEqual({ left: '123' });

const value = { left: some('test'), extra: 'allowed' };
expect(o.encode(value)).toEqual({ left: 'test', extra: 'allowed' });

expect(o.encode({ right: none })).toEqual({ right: null });

// Note: `encode` does not check `is` before processing, so it will attempt to encode the value even
// if it matches multiple schemas. Only one schema will be used for encoding, so the result below is
// correct even though undesirable
expect(o.encode({ left: some('123'), right: none })).toEqual({ left: '123', right: none });
});
});
});
122 changes: 122 additions & 0 deletions src/language/typescript/common/bundled/utils.bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { either, left, right, isLeft, Either } from 'fp-ts/lib/Either';
import {
Type,
type,
TypeOf,
failure,
success,
string as tstring,
literal,
Validate,
Context,
getValidationError,
Mixed,
UnionC,
union,
UnionType,
OutputOf,
Errors,
failures,
} from 'io-ts';

export const DateFromISODateStringIO = new Type<Date, string, unknown>(
'DateFromISODateString',
(u): u is Date => u instanceof Date,
(u, c) =>
either.chain(tstring.validate(u, c), dateString => {
const [year, calendarMonth, day] = dateString.split('-');
const d = new Date(+year, +calendarMonth - 1, +day);
return isNaN(d.getTime()) ? failure(u, c) : success(d);
}),
a =>
`${a
.getFullYear()
.toString()
.padStart(4, '0')}-${(a.getMonth() + 1).toString().padStart(2, '0')}-${a
.getDate()
.toString()
.padStart(2, '0')}`,
);

export type Base64 = TypeOf<typeof Base64IO>;

export const Base64IO = type({
string: tstring,
format: literal('base64'),
});

export const Base64FromStringIO = new Type<Base64, string, unknown>(
'Base64FromString',
(u): u is Base64 => Base64IO.is(u),
(u, c) => either.chain(tstring.validate(u, c), string => success({ string, format: 'base64' })),
a => a.string,
);

export type Binary = TypeOf<typeof BinaryIO>;

export const BinaryIO = type({
string: tstring,
format: literal('binary'),
});

export const BinaryFromStringIO = new Type<Binary, string, unknown>(
'BinaryFromString',
(u): u is Binary => BinaryIO.is(u),
(u, c) => either.chain(tstring.validate(u, c), string => success({ string, format: 'binary' })),
a => a.string,
);

const validateBlob: Validate<unknown, Blob> = (u: unknown, c: Context) =>
u instanceof Blob ? right(u) : left([getValidationError(u, c)]);

export const BlobToBlobIO = new Type<Blob, Blob, unknown>(
'Base64FromString',
(u): u is Blob => u instanceof Blob,
validateBlob,
a => a,
);

const blobMediaRegexp = /^(video|audio|image|application)/;
const textMediaRegexp = /^text/;
export const getResponseTypeFromMediaType = (mediaType: string) => {
if (mediaType === 'application/json') {
return 'json';
}
if (blobMediaRegexp.test(mediaType)) {
return 'blob';
}
if (textMediaRegexp.test(mediaType)) {
return 'text';
}
return 'json';
};

export const oneOf = <CS extends [Mixed, Mixed, ...Mixed[]]>(codecs: CS, name?: string): UnionC<CS> => {
const u = union(codecs, name);
return new UnionType<CS, TypeOf<CS[number]>, OutputOf<CS[number]>, unknown>(
u.name,
(input): input is TypeOf<CS[number]> =>
codecs.reduce((matches, codec) => matches + (codec.is(input) ? 1 : 0), 0) === 1,
(input, context) => {
const errors: Errors = [];
let match: [number, Either<Errors, any>] | undefined;
for (let i = 0; i < codecs.length; i++) {
const result = codecs[i].validate(input, context);
if (isLeft(result)) {
errors.push(...result.left);
} else if (match) {
return failure(
input,
context,
`Input matches multiple schemas in oneOf "${u.name}": ${match[0]} and ${i}`,
);
} else {
match = [i, result];
}
}
return match ? match[1] : failures(errors);
},
u.encode,
codecs,
);
};
90 changes: 4 additions & 86 deletions src/language/typescript/common/bundled/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,96 +2,14 @@ import { fromString } from '../../../../utils/ref';
import { pipe } from 'fp-ts/lib/pipeable';
import { either } from 'fp-ts';
import { fromRef } from '../../../../utils/fs';
import * as fs from 'fs';
import * as path from 'path';

export const utilsRef = fromString('#/utils/utils');

const utils = `
import { either, left, right } from 'fp-ts/lib/Either';
import {
Type,
type,
TypeOf,
failure,
success,
string as tstring,
literal,
Validate,
Context,
getValidationError,
} from 'io-ts';
export const DateFromISODateStringIO = new Type<Date, string, unknown>(
'DateFromISODateString',
(u): u is Date => u instanceof Date,
(u, c) =>
either.chain(tstring.validate(u, c), dateString => {
const [year, calendarMonth, day] = dateString.split('-');
const d = new Date(+year, +calendarMonth - 1, +day);
return isNaN(d.getTime()) ? failure(u, c) : success(d);
}),
a =>
\`\${a.getFullYear().toString().padStart(4, '0')}-\${(a.getMonth() + 1).toString().padStart(2, '0')}-\${a
.getDate()
.toString()
.padStart(2, '0')}\`,
);
export type Base64 = TypeOf<typeof Base64IO>;
export const Base64IO = type({
string: tstring,
format: literal('base64'),
});
export const Base64FromStringIO = new Type<Base64, string, unknown>(
'Base64FromString',
(u): u is Base64 => Base64IO.is(u),
(u, c) => either.chain(tstring.validate(u, c), string => success({ string, format: 'base64' })),
a => a.string,
);
export type Binary = TypeOf<typeof BinaryIO>;
export const BinaryIO = type({
string: tstring,
format: literal('binary'),
});
export const BinaryFromStringIO = new Type<Binary, string, unknown>(
'BinaryFromString',
(u): u is Binary => BinaryIO.is(u),
(u, c) => either.chain(tstring.validate(u, c), string => success({ string, format: 'binary' })),
a => a.string,
);
const validateBlob: Validate<unknown, Blob> = (u: unknown, c: Context) =>
u instanceof Blob ? right(u) : left([getValidationError(u, c)]);
export const BlobToBlobIO = new Type<Blob, Blob, unknown>(
'Base64FromString',
(u): u is Blob => u instanceof Blob,
validateBlob,
a => a,
);
const blobMediaRegexp = /^(video|audio|image|application)/;
const textMediaRegexp = /^text/;
export const getResponseTypeFromMediaType = (mediaType: string) => {
if (mediaType === 'application/json') {
return 'json';
}
if (blobMediaRegexp.test(mediaType)) {
return 'blob';
}
if (textMediaRegexp.test(mediaType)) {
return 'text';
}
return 'json';
};
`;
const utilsSourceCode = fs.readFileSync(path.resolve(__dirname, 'utils.bundle.ts'), 'utf8');

export const utilsFile = pipe(
utilsRef,
either.map(ref => fromRef(ref, '.ts', utils)),
either.map(ref => fromRef(ref, '.ts', utilsSourceCode)),
);
18 changes: 18 additions & 0 deletions src/language/typescript/common/data/serialized-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,24 @@ export const getSerializedUnionType = (serialized: NonEmptyArray<SerializedType>
}
};

export const getSerializedOneOfType = (from: Ref, serialized: NonEmptyArray<SerializedType>) =>
combineEither(
utilsRef,
(utilsRef): SerializedType => {
if (serialized.length === 1) {
return head(serialized);
} else {
const intercalated = intercalateSerializedTypes(serializedType(' | ', ',', [], []), serialized);
return serializedType(
`(${intercalated.type})`,
`oneOf([${intercalated.io}])`,
[...intercalated.dependencies, serializedDependency('oneOf', getRelativePath(from, utilsRef))],
intercalated.refs,
);
}
},
);

export const getSerializedIntersectionType = (serialized: NonEmptyArray<SerializedType>): SerializedType => {
if (serialized.length === 1) {
return head(serialized);
Expand Down
Loading

0 comments on commit cce2557

Please sign in to comment.