-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat!: oneOf now has more strict validation, it actually checks that …
…exactly one schema matches
- Loading branch information
Showing
8 changed files
with
269 additions
and
89 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
42 changes: 42 additions & 0 deletions
42
src/language/typescript/common/bundled/__tests__/utils.bundle.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.