diff --git a/src/language/typescript/3.0/serializers/__tests__/schema-object.spec.ts b/src/language/typescript/3.0/serializers/__tests__/schema-object.spec.ts index 53f5832..c12c044 100644 --- a/src/language/typescript/3.0/serializers/__tests__/schema-object.spec.ts +++ b/src/language/typescript/3.0/serializers/__tests__/schema-object.spec.ts @@ -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 })])])', ); }), ); diff --git a/src/language/typescript/3.0/serializers/schema-object.ts b/src/language/typescript/3.0/serializers/schema-object.ts index af25a2f..d059f95 100644 --- a/src/language/typescript/3.0/serializers/schema-object.ts +++ b/src/language/typescript/3.0/serializers/schema-object.ts @@ -5,6 +5,7 @@ import { getSerializedIntersectionType, getSerializedNullableType, getSerializedObjectType, + getSerializedOneOfType, getSerializedOptionPropertyType, getSerializedRecursiveType, getSerializedRefType, @@ -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'; @@ -53,10 +55,19 @@ const serializeSchemaObjectWithRecursion = (from: Ref, shouldTrackRecursion: boo schemaObject: SchemaObject, ): Either => { 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)), ); diff --git a/src/language/typescript/common/bundled/__tests__/utils.bundle.spec.ts b/src/language/typescript/common/bundled/__tests__/utils.bundle.spec.ts new file mode 100644 index 0000000..9367016 --- /dev/null +++ b/src/language/typescript/common/bundled/__tests__/utils.bundle.spec.ts @@ -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, 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 }); + }); + }); +}); diff --git a/src/language/typescript/common/bundled/utils.bundle.ts b/src/language/typescript/common/bundled/utils.bundle.ts new file mode 100644 index 0000000..b892e89 --- /dev/null +++ b/src/language/typescript/common/bundled/utils.bundle.ts @@ -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( + '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; + +export const Base64IO = type({ + string: tstring, + format: literal('base64'), +}); + +export const Base64FromStringIO = new Type( + '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; + +export const BinaryIO = type({ + string: tstring, + format: literal('binary'), +}); + +export const BinaryFromStringIO = new Type( + '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 = (u: unknown, c: Context) => + u instanceof Blob ? right(u) : left([getValidationError(u, c)]); + +export const BlobToBlobIO = new Type( + '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 = (codecs: CS, name?: string): UnionC => { + const u = union(codecs, name); + return new UnionType, OutputOf, unknown>( + u.name, + (input): input is TypeOf => + codecs.reduce((matches, codec) => matches + (codec.is(input) ? 1 : 0), 0) === 1, + (input, context) => { + const errors: Errors = []; + let match: [number, Either] | 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, + ); +}; diff --git a/src/language/typescript/common/bundled/utils.ts b/src/language/typescript/common/bundled/utils.ts index 0a13e24..0d6fad2 100644 --- a/src/language/typescript/common/bundled/utils.ts +++ b/src/language/typescript/common/bundled/utils.ts @@ -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( - '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; - - export const Base64IO = type({ - string: tstring, - format: literal('base64'), - }); - - export const Base64FromStringIO = new Type( - '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; - - export const BinaryIO = type({ - string: tstring, - format: literal('binary'), - }); - - export const BinaryFromStringIO = new Type( - '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 = (u: unknown, c: Context) => - u instanceof Blob ? right(u) : left([getValidationError(u, c)]); - - export const BlobToBlobIO = new Type( - '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)), ); diff --git a/src/language/typescript/common/data/serialized-type.ts b/src/language/typescript/common/data/serialized-type.ts index 258bc6c..c11f079 100644 --- a/src/language/typescript/common/data/serialized-type.ts +++ b/src/language/typescript/common/data/serialized-type.ts @@ -241,6 +241,24 @@ export const getSerializedUnionType = (serialized: NonEmptyArray } }; +export const getSerializedOneOfType = (from: Ref, serialized: NonEmptyArray) => + 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 => { if (serialized.length === 1) { return head(serialized); diff --git a/src/schema/3.0/schema-object.ts b/src/schema/3.0/schema-object.ts index 3c89664..b1f75fa 100644 --- a/src/schema/3.0/schema-object.ts +++ b/src/schema/3.0/schema-object.ts @@ -104,6 +104,19 @@ export const AllOfSchemaObjectCodec: Codec = recursion('AllOf ]), ); +export interface AnyOfSchemaObject extends BaseSchemaObject { + readonly anyOf: NonEmptyArray; +} + +export const AnyOfSchemaObjectCodec: Codec = recursion('AnyOfSchemaObject', () => + intersection([ + BaseSchemaObjectCodec, + type({ + anyOf: nonEmptyArray(union([ReferenceObjectCodec, SchemaObjectCodec])), + }), + ]), +); + export interface OneOfSchemaObject extends BaseSchemaObject { readonly oneOf: NonEmptyArray; } @@ -123,6 +136,7 @@ export type SchemaObject = | ObjectSchemaObject | ArraySchemaObject | AllOfSchemaObject + | AnyOfSchemaObject | OneOfSchemaObject; export const SchemaObjectCodec: Codec = recursion('SchemaObject', () => @@ -132,6 +146,7 @@ export const SchemaObjectCodec: Codec = recursion('SchemaObject', ObjectSchemaObjectCodec, ArraySchemaObjectCodec, AllOfSchemaObjectCodec, + AnyOfSchemaObjectCodec, OneOfSchemaObjectCodec, ]), ); diff --git a/test/specs/3.0/demo.yml b/test/specs/3.0/demo.yml index 10bc241..385555d 100644 --- a/test/specs/3.0/demo.yml +++ b/test/specs/3.0/demo.yml @@ -130,6 +130,42 @@ components: $ref: '#/components/schemas/TestAllOf' kebab-property: type: string + TestAnyOf: + anyOf: + - type: object + properties: + bark: + type: string + - type: object + properties: + meow: + type: string + TestOneOf: + oneOf: + - type: object + properties: + bark: + type: string + - type: object + properties: + meow: + type: string + TestOneOfDiscriminated: + oneOf: + - type: object + properties: + kind: + enum: [ dog ] + bark: + type: string + required: [ kind ] + - type: object + properties: + kind: + enum: [ cat ] + meow: + type: string + required: [ kind ] 37sds afd,asd.324sfa as2_+=: type: object properties: