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!: oneOf now has more strict validation, it actually checks that exactly one schema matches #181

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
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
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