import { array, object, number, string, date, InferType } from "yup"; import { Model } from "objection"; // Shared // Shared API data schema (objects shared between client and server) export const sharedSchema = object({ // none of these are required since not set until save (but they are also not nullable) id: number() .integer() .notRequired(), createdAt: date().notRequired(), updatedAt: date().notRequired() }); export type SharedData = InferType; // Shared Product export const productSchema = sharedSchema.clone().shape({ name: string().notRequired() // note: no user reference to avoid circular reference }); export type ProductData = InferType; // helper for converting from either client or server into shared format // removes leaked data while preserving collections :-)! const shareProduct = (p: ProductData) => { return productSchema.noUnknown().cast(p); }; // Shared User export const userSchema = sharedSchema.clone().shape({ email: string().required(), displayName: string() .nullable() .notRequired(), photoUrl: string() .nullable() .notRequired(), products: array() .of(productSchema) .notRequired() // a collection! }); export type UserData = InferType; // rename "SharedUser" const shareUser = (u: UserData) => { return userSchema.noUnknown().cast(u); }; // Shared Helpers // Since the server can't inherit from a SharedUser class, // we define shared helper functions for monkey patching export interface UserHelpers { isEmailValid: () => boolean; nameForDisplay: () => string; } export const getUserHelpers = (u: UserData): UserHelpers => { return { isEmailValid: (): boolean => { return (u.email || "").indexOf("@") > -1; }, nameForDisplay: (): string => { return u.displayName || u.email.split("@")[0]; } }; }; // Client Product export class ClientProduct implements ProductData { id: number | undefined; // can't use "id?: number" (field is required, but may be undefined) createdAt: Date | undefined; updatedAt: Date | undefined; name: string | undefined; constructor(input: ProductData) { Object.assign(this, productSchema.noUnknown().cast(input)); } } // Client User export class ClientUser implements UserData, UserHelpers { id: number | undefined; createdAt: Date | undefined; updatedAt: Date | undefined; email!: string; displayName?: string; photoUrl?: string; products?: ClientProduct[]; constructor(input: UserData) { Object.assign(this, userSchema.noUnknown().cast(input)); } helpers = getUserHelpers(this); isEmailValid = this.helpers.isEmailValid; nameForDisplay = this.helpers.nameForDisplay; } // Server User export class ServerModel extends Model implements SharedData { id: number | undefined; createdAt: Date | undefined; updatedAt: Date | undefined; } // Server User - has additional data export const userServerSchema = userSchema.clone().shape({ passwordHash: string().notRequired() }); type UserServerData = InferType; export class ServerUser extends ServerModel implements UserServerData { static tableName = "users"; static fromShared(u: UserData): ServerUser { const user = new ServerUser(); Object.assign(user, userSchema.noUnknown().cast(u)); return user; } email: string = ""; displayName?: string; photoUrl?: string; products?: ServerProduct[]; passwordHash?: string; // no constructor only the parameterless one provided by Objection isEmailValid = getUserHelpers(this).isEmailValid; _nameForDisplay = getUserHelpers(this).nameForDisplay; get nameForDisplay(): string { return this._nameForDisplay(); } } export class ServerProduct extends ServerModel implements ProductData { static tableName = "products"; name: string | undefined; } // Tests --- // Convert client -> shared -> JSON -> shared -> server const cu = new ClientUser({ email: "test@test.com" }); if (!cu.isEmailValid()) { throw new Error("Email not valid or helper didn't work"); } const userData = shareUser(cu); const serializedUserData = JSON.stringify(userData); const deserializedUserData = JSON.parse(serializedUserData); const su = ServerUser.fromShared(deserializedUserData); if (!su.email) { throw "No Server User email"; } // Go the other way, and add collection (pretend these were loaded from database) const su1 = new ServerUser(); const sp = new ServerProduct(); su1.id = 1; su1.email = "test1@test.com"; su1.products = [sp]; sp.id = 1; sp.name = "Product 1"; const sud = JSON.stringify(shareUser(su1)); const dud = JSON.parse(sud); const cu1 = new ClientUser(dud); // can use constructor going this way if (cu1.email !== "test1@test.com") { throw new Error("Email missing"); } if (!cu1.products) { throw new Error("Products missing"); } if (cu1.products?.[0].name !== "Product 1") { throw new Error("Product missing"); }