diff --git a/README.md b/README.md index bd02213..88a7fa6 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ npm install @gorhom/codable ## Usage ```ts -import { BaseCodable, types, decode } from '@gorhom/codable'; +import { BaseCodable, types, decode, encode } from '@gorhom/codable'; import dayjs from 'dayjs'; class Post extends BaseCodable { @@ -86,12 +86,16 @@ const jsonPayload = { }; const user: User = decode(User, jsonPayload); + +// now encode it back 🙈 + +const userJson = encode(user) ``` ## TODO - [x] Add [Swift Decodable](https://developer.apple.com/documentation/swift/decodable) functionality. -- [ ] Add [Swift Encodable](https://developer.apple.com/documentation/swift/encodable) functionality. +- [x] Add [Swift Encodable](https://developer.apple.com/documentation/swift/encodable) functionality. - [ ] Write API docs. ## Built With diff --git a/src/codable.ts b/src/codable.ts index 0e48e05..d0be451 100644 --- a/src/codable.ts +++ b/src/codable.ts @@ -1,7 +1,7 @@ import { ICodingPropertyType, IDictionary, IBaseCodable } from './internal'; +// @ts-ignore export abstract class BaseCodable implements IBaseCodable { public static CodingProperties: IDictionary; constructor(payload: object) {} - public toJSON = () => ''; } diff --git a/src/internal.ts b/src/internal.ts index b9af6ae..dba8ae5 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -19,6 +19,7 @@ export * from './models'; export * from './utils/types'; export * from './utils/typecheck'; export * from './utils/decoder'; +export * from './utils/encoder'; export * from './utils/errors'; export * from './utils/isEmpty'; export * from './utils/get'; diff --git a/src/models/array.ts b/src/models/array.ts index be7921e..b8319cf 100644 --- a/src/models/array.ts +++ b/src/models/array.ts @@ -4,6 +4,7 @@ import { IModel, IType, decodeCodable, + encode as encodeCodable, errors, } from '../internal'; @@ -33,21 +34,34 @@ export const array = (type: IType): IModel => { value[0] ); }; - const decode = (key: string, value: any[]) => { if (validate(key, value)) { const { subtype } = type; if (subtype !== undefined && isCodable(subtype)) { - return value.map(item => decodeCodable(subtype, item, false, key)); + return value.map(item => + decodeCodable({ + type: subtype, + json: item, + isRoot: false, + key, + }) + ); } return value; } else { return undefined; } }; - + const encode = (key: string, value: any[]) => { + const { subtype } = type; + if (subtype !== undefined && isCodable(subtype)) { + return value.map(item => encodeCodable(item)); + } + return value; + }; return { validate, decode, + encode, }; }; diff --git a/src/models/boolean.ts b/src/models/boolean.ts index 91ff61f..4b1b776 100644 --- a/src/models/boolean.ts +++ b/src/models/boolean.ts @@ -12,9 +12,10 @@ export const boolean = (type: IType): IModel => { return true; }; const decode = (key: string, value: any) => value; - + const encode = (key: string, value: any) => value; return { validate, decode, + encode, }; }; diff --git a/src/models/date.ts b/src/models/date.ts index 1aa854f..ff227af 100644 --- a/src/models/date.ts +++ b/src/models/date.ts @@ -19,7 +19,6 @@ export const date = (type: IType): IModel => { } return true; }; - const decode = (key: string, value: any) => { try { return type.parser!(value); @@ -27,9 +26,10 @@ export const date = (type: IType): IModel => { throw errors.failToParse(key, value, error.message || error); } }; - + const encode = (key: string, value: any) => value; return { validate, decode, + encode, }; }; diff --git a/src/models/number.ts b/src/models/number.ts index b423d2e..e3eda06 100644 --- a/src/models/number.ts +++ b/src/models/number.ts @@ -12,9 +12,10 @@ export const number = (type: IType): IModel => { return true; }; const decode = (key: string, value: any) => value; - + const encode = (key: string, value: any) => value; return { validate, decode, + encode, }; }; diff --git a/src/models/optional.ts b/src/models/optional.ts index 84fa2ff..456cd94 100644 --- a/src/models/optional.ts +++ b/src/models/optional.ts @@ -40,16 +40,20 @@ export const optional = (type: IType): IModel => { value ); }; - const decode = (key: string, value: object) => value === undefined ? undefined : validate(key, value) - ? decodeValue(key, type.subtype!, value) + ? decodeValue({ + key, + type: type.subtype!, + value, + }) : undefined; - + const encode = (key: string, value: any) => value; return { validate, decode, + encode, }; }; diff --git a/src/models/string.ts b/src/models/string.ts index 1a1dcec..3e783d0 100644 --- a/src/models/string.ts +++ b/src/models/string.ts @@ -12,9 +12,10 @@ export const string = (type: IType): IModel => { return true; }; const decode = (key: string, value: any) => value; - + const encode = (key: string, value: any) => `${value}`; return { validate, decode, + encode, }; }; diff --git a/src/utils/decoder.ts b/src/utils/decoder.ts index 2fca094..7138a94 100644 --- a/src/utils/decoder.ts +++ b/src/utils/decoder.ts @@ -14,26 +14,34 @@ import { IModel, INewable, IBaseCodable, ICodable } from './types'; export const decode = ( type: INewable, json: any -): T & IBaseCodable => decodeCodable(type, json, true); +): T & IBaseCodable => + decodeCodable({ + type, + json, + isRoot: true, + }); -export const decodeCodable = ( - type: INewable, - json: any, - isRoot: boolean, - key?: string -): T & IBaseCodable => { +export const decodeCodable = ({ + type, + json, + isRoot, + key, +}: { + type: INewable; + json: any; + isRoot: boolean; + key?: string; +}): T & IBaseCodable => { if (isRoot === false && isEmpty(json) === true) { throw errors.missingValue(key || '', typeof type); } - let result = new type(json); - // @ts-ignore if (json !== undefined && type.CodingProperties !== undefined) { // @ts-ignore Object.assign(result, decodePayload(json, type.CodingProperties)); } - + // @ts-ignore return result; }; @@ -43,32 +51,47 @@ export const decodePayload = ( ) => { return Object.keys(codingProperties) .map(key => ({ - [key]: decodeProperty(key, codingProperties[key], payload), + [key]: decodeProperty({ + key, + codingProperty: codingProperties[key], + payload, + }), })) .reduce((properties, property) => ({ ...properties, ...property }), {}); }; -export const decodeProperty = ( - key: string, - codingProperty: ICodingPropertyType, - payload?: object -) => { +export const decodeProperty = ({ + key, + codingProperty, + payload, +}: { + key: string; + codingProperty: ICodingPropertyType; + payload?: object; +}) => { const jsonKey = get(codingProperty, 'key', key); const value = get(payload, jsonKey, undefined); const type: IType | ICodable = get(codingProperty, 'type', codingProperty); - - return decodeValue(jsonKey, type, value); + return decodeValue({ key: jsonKey, type, value }); }; -export const decodeValue = ( - key: string, - type: IType | ICodable, - value?: object -) => { +export const decodeValue = ({ + key, + type, + value, +}: { + key: string; + type: IType | ICodable; + value?: object; +}) => { if (isCodable(type)) { - return decodeCodable(type, value, false, key); + return decodeCodable({ + type, + json: value, + isRoot: false, + key, + }); } - const model: IModel = models[type.name](type); return model.validate(key, value) ? model.decode(key, value) : undefined; }; diff --git a/src/utils/encoder.ts b/src/utils/encoder.ts new file mode 100644 index 0000000..9c2793c --- /dev/null +++ b/src/utils/encoder.ts @@ -0,0 +1,83 @@ +import { + models, + IType, + ICodingPropertyType, + isCodable, + BaseCodable, + get, + errors, +} from '../internal'; +import { IModel, ICodable } from './types'; + +export const encode = (codable: T): any => { + if (!(codable instanceof BaseCodable)) { + throw errors.invalidCodable(); + } + + // @ts-ignore + if (!codable.__proto__.constructor.CodingProperties) { + throw errors.missingCodingProperties(); + } + + // @ts-ignore + const codingProperties = codable.__proto__.constructor.CodingProperties; + + // @ts-ignore + return Object.keys(codingProperties) + .map(key => + // @ts-ignore + encodeProperty({ + key, + codingProperty: codingProperties[key], + codable, + }) + ) + .reduce((result, item) => { + // @ts-ignore + result[item.key] = item.value; + return result; + }, {}); +}; + +const encodeProperty = ({ + key, + codingProperty, + codable, +}: { + key: string; + codingProperty: ICodingPropertyType; + codable: BaseCodable; +}) => { + const jsonKey = get(codingProperty, 'key', key); + const type: IType | ICodable = get(codingProperty, 'type', codingProperty); + + return encodeValue({ + key: jsonKey, + type, + // @ts-ignore + value: codable[key], + }); +}; + +const encodeValue = ({ + key, + type, + value, +}: { + key: string; + type: IType | ICodable; + value?: object; +}) => { + if (isCodable(type)) { + return { + key, + // @ts-ignore + value: encode(value), + }; + } + const model: IModel = models[type.name](type); + return { + key, + value: model.encode(key, value), + }; +}; diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 5c2f5cf..07950e8 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -11,4 +11,6 @@ export const errors = { `Missing date parser. key: '${key}', type: ${type}`, failToParse: (key: string, value: string, errorMessage: string) => `Fail to parse date with custom parser. key: '${key}', value: '${value}', parser error: '${errorMessage}'`, + invalidCodable: () => `Invalid codable type.`, + missingCodingProperties: () => `Missing 'CodingProperties' static variable.`, }; diff --git a/src/utils/types.d.ts b/src/utils/types.d.ts index 7fa81e2..f8facf4 100644 --- a/src/utils/types.d.ts +++ b/src/utils/types.d.ts @@ -20,6 +20,7 @@ export interface IType { export interface IModel { validate: (key: string, value: any) => boolean; decode: (key: string, value: any) => any; + encode: (key: string, value: any) => any; } export type IModelDictionary = { @@ -33,7 +34,7 @@ export interface IDictionary { } export interface IBaseCodable { - toJSON: () => string; + CodingProperties: IDictionary; } interface IBaseCodableStatic { diff --git a/test/codable.test.ts b/test/codable.test.ts index af4f585..ae95c54 100644 --- a/test/codable.test.ts +++ b/test/codable.test.ts @@ -1,4 +1,4 @@ -import { types, BaseCodable, decode } from '../src/internal'; +import { types, BaseCodable, decode, encode } from '../src/internal'; import { fixturePayload } from './fixtures'; describe('Decoder', () => { @@ -695,3 +695,357 @@ describe('Decoder', () => { }); }); }); + +describe('Encoder', () => { + it('throws error when try to encode a non-codable class', () => { + class Post { + tags!: string[]; + constructor() { + this.tags = []; + } + } + const post = new Post(); + // @ts-ignore + expect(() => encode(post)).toThrowError(/Invalid codable type\./); + }); + + it('throws error when try to encode a codable without setting "CodingProperties"', () => { + class Post extends BaseCodable { + tags!: string[]; + constructor(payload: any) { + super(payload); + this.tags = []; + } + } + const post = new Post({}); + // @ts-ignore + expect(() => encode(post)).toThrowError( + /Missing 'CodingProperties' static variable\./ + ); + }); + + describe('Encode String', () => { + it('encode property with string type', () => { + class Post extends BaseCodable { + title!: string; + } + + Post.CodingProperties = { + title: types.string, + }; + + const post = decode(Post, fixturePayload); + const encodedPost = encode(post); + expect(encodedPost.title).toBe(fixturePayload.title); + }); + + it('encode property with string type and custom key', () => { + class Post extends BaseCodable { + postTitle!: string; + } + + Post.CodingProperties = { + postTitle: { + type: types.string, + key: 'title', + }, + }; + + const post = decode(Post, fixturePayload); + const encodedPost = encode(post); + expect(encodedPost.title).toBe(fixturePayload.title); + }); + + it('encode property with optional string type', () => { + class Post extends BaseCodable { + title?: string; + } + + Post.CodingProperties = { + title: { + type: types.optional(types.string), + key: 'non-exist-key', + }, + }; + + const post = decode(Post, fixturePayload); + const encodedPost = encode(post); + expect(encodedPost['non-exist-key']).toBe(undefined); + }); + }); + + describe('Encode Number', () => { + it('encode property with number type', () => { + class Post extends BaseCodable { + id!: string; + } + + Post.CodingProperties = { + id: types.number, + }; + + const post = decode(Post, fixturePayload); + const encodedPost = encode(post); + expect(encodedPost.id).toBe(fixturePayload.id); + }); + + it('encode property with number type and custom key', () => { + class Post extends BaseCodable { + postId!: string; + } + + Post.CodingProperties = { + postId: { + key: 'id', + type: types.number, + }, + }; + + const post = decode(Post, fixturePayload); + const encodedPost = encode(post); + expect(encodedPost.id).toBe(fixturePayload.id); + }); + + it('encode property with optional number type', () => { + class Post extends BaseCodable { + id?: number; + } + + Post.CodingProperties = { + id: { + type: types.optional(types.number), + key: 'non-exist-key', + }, + }; + + const post = decode(Post, fixturePayload); + const encodedPost = encode(post); + expect(encodedPost['non-exist-key']).toBe(undefined); + }); + }); + + describe('Encode Boolean', () => { + it('encode property with boolean type', () => { + class Post extends BaseCodable { + active!: boolean; + } + + Post.CodingProperties = { + active: types.boolean, + }; + + const post = decode(Post, fixturePayload); + const encodedPost = encode(post); + expect(encodedPost.active).toBe(fixturePayload.active); + }); + + it('encode property with boolean type and custom key', () => { + class Post extends BaseCodable { + isActive!: boolean; + } + + Post.CodingProperties = { + isActive: { + type: types.boolean, + key: 'active', + }, + }; + + const post = decode(Post, fixturePayload); + const encodedPost = encode(post); + expect(encodedPost.active).toBe(fixturePayload.active); + }); + + it('encode property with optional boolean type', () => { + class Post extends BaseCodable { + active?: boolean; + } + + Post.CodingProperties = { + active: { + type: types.optional(types.boolean), + key: 'non-exist-key', + }, + }; + + const post = decode(Post, fixturePayload); + const encodedPost = encode(post); + expect(encodedPost['non-exist-key']).toBe(undefined); + }); + }); + + describe('Encode Codable', () => { + it('encode property with Codable type', () => { + class User extends BaseCodable { + id!: number; + username!: string; + } + + User.CodingProperties = { + id: types.number, + username: types.string, + }; + + class Post extends BaseCodable { + title!: string; + user!: User; + } + + Post.CodingProperties = { + title: types.string, + user: User, + }; + + const post = decode(Post, fixturePayload); + const encodedPost = encode(post); + expect(encodedPost.user['id']).toBe(fixturePayload.user.id); + }); + + it('encode property with Codable type and custom key', () => { + class User extends BaseCodable { + id!: number; + username!: string; + } + + User.CodingProperties = { + id: types.number, + username: types.string, + }; + + class Post extends BaseCodable { + title!: string; + owner!: User; + } + + Post.CodingProperties = { + title: types.string, + owner: { + key: 'user', + type: User, + }, + }; + + const post = decode(Post, fixturePayload); + const encodedPost = encode(post); + expect(encodedPost.user['id']).toBe(fixturePayload.user.id); + }); + + it('encode property with optional Codable type', () => { + class User extends BaseCodable { + id!: number; + username!: string; + } + + User.CodingProperties = { + id: types.number, + username: types.string, + }; + + class Post extends BaseCodable { + title!: string; + user?: User; + } + + Post.CodingProperties = { + title: types.string, + user: { + type: types.optional(User), + key: 'non-exist-key', + }, + }; + + const post = decode(Post, fixturePayload); + const encodedPost = encode(post); + expect(encodedPost['non-exist-key']).toBe(undefined); + }); + }); + + describe('Encode Array', () => { + it('encode property with array of string type', () => { + class Post extends BaseCodable { + tags!: string[]; + } + + Post.CodingProperties = { + tags: types.array(types.string), + }; + + const post = decode(Post, fixturePayload); + const encodedPost = encode(post); + expect(encodedPost.tags[0]).toBe(fixturePayload.tags[0]); + }); + + it('encode property with array of number type', () => { + class Post extends BaseCodable { + categories!: number[]; + } + + Post.CodingProperties = { + categories: types.array(types.number), + }; + + const post = decode(Post, fixturePayload); + const encodedPost = encode(post); + expect(encodedPost.categories[0]).toBe(fixturePayload.categories[0]); + }); + + it('encode property with array of Codable type', () => { + class User extends BaseCodable { + private _id!: number; + private _username!: string; + + get id() { + return this._id; + } + + get username() { + return this._username; + } + } + + User.CodingProperties = { + _id: { + type: types.number, + key: 'id', + }, + _username: { + type: types.string, + key: 'username', + }, + }; + + class Comment extends BaseCodable { + id!: number; + body!: string; + user!: User; + } + + Comment.CodingProperties = { + id: types.number, + body: types.string, + user: User, + }; + + class Post extends BaseCodable { + comments!: Comment[]; + } + + Post.CodingProperties = { + comments: types.array(Comment), + }; + + const post = decode(Post, fixturePayload); + const encodedPost = encode(post); + + expect(encodedPost.comments.length).toBe(fixturePayload.comments.length); + expect(encodedPost.comments[0].id).toBe(fixturePayload.comments[0].id); + + expect(encodedPost.comments[0].user.id).toBe( + fixturePayload.comments[0].user.id + ); + expect(encodedPost.comments[0].user.username).toBe( + fixturePayload.comments[0].user.username + ); + }); + }); +});