import {RestResourceError} from '../lib/error'; import {Session} from '../lib/session/session'; import {PageInfo, RestRequestReturn} from '../lib/clients/admin/types'; import {DataType} from '../lib/clients/types'; import {RestClient} from '../lib/clients/admin/rest/client'; import {ApiVersion} from '../lib/types'; import {ConfigInterface} from '../lib/base-types'; import {Headers} from '../runtime/http'; import {IdSet, Body, ResourcePath, ParamSet, ResourceNames} from './types'; interface BaseFindArgs { session: Session; params?: ParamSet; urlIds: IdSet; requireIds?: boolean; } interface BaseConstructorArgs { session: Session; fromData?: Body | null; } interface SaveArgs { update?: boolean; } interface RequestArgs extends BaseFindArgs { http_method: string; operation: string; body?: Body | null; entity?: Base | null; } interface GetPathArgs { http_method: string; operation: string; urlIds: IdSet; entity?: Base | null; } interface SetClassPropertiesArgs { Client: typeof RestClient; config: ConfigInterface; } export interface FindAllResponse { data: T[]; headers: Headers; pageInfo?: PageInfo; } export class Base { // For instance attributes [key: string]: any; public static Client: typeof RestClient; public static config: ConfigInterface; public static apiVersion: string; protected static resourceNames: ResourceNames[] = []; protected static primaryKey = 'id'; protected static customPrefix: string | null = null; protected static readOnlyAttributes: string[] = []; protected static hasOne: Record = {}; protected static hasMany: Record = {}; protected static paths: ResourcePath[] = []; public static setClassProperties({Client, config}: SetClassPropertiesArgs) { this.Client = Client; this.config = config; } protected static async baseFind({ session, urlIds, params, requireIds = false, }: BaseFindArgs): Promise> { if (requireIds) { const hasIds = Object.entries(urlIds).some(([_key, value]) => value); if (!hasIds) { throw new RestResourceError( 'No IDs given for request, cannot find path', ); } } const response = await this.request({ http_method: 'get', operation: 'get', session, urlIds, params, }); return { data: this.createInstancesFromResponse(session, response.body as Body), headers: response.headers, pageInfo: response.pageInfo, }; } protected static async request({ session, http_method, operation, urlIds, params, body, entity, }: RequestArgs): Promise> { const client = new this.Client({ session, apiVersion: this.apiVersion as ApiVersion, }); const path = this.getPath({http_method, operation, urlIds, entity}); const cleanParams: Record = {}; if (params) { for (const key in params) { if (params[key] !== null) { cleanParams[key] = params[key]; } } } switch (http_method) { case 'get': return client.get({path, query: cleanParams}); case 'post': return client.post({ path, query: cleanParams, data: body!, type: DataType.JSON, }); case 'put': return client.put({ path, query: cleanParams, data: body!, type: DataType.JSON, }); case 'delete': return client.delete({path, query: cleanParams}); default: throw new Error(`Unrecognized HTTP method "${http_method}"`); } } protected static getJsonBodyName(): string { return this.name.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase(); } protected static getPath({ http_method, operation, urlIds, entity, }: GetPathArgs): string { let match: string | null = null; let specificity = -1; const potentialPaths: ResourcePath[] = []; this.paths.forEach((path: ResourcePath) => { if ( http_method !== path.http_method || operation !== path.operation || path.ids.length <= specificity ) { return; } potentialPaths.push(path); let pathUrlIds: IdSet = {...urlIds}; path.ids.forEach((id) => { if (!pathUrlIds[id] && entity && entity[id]) { pathUrlIds[id] = entity[id]; } }); pathUrlIds = Object.entries(pathUrlIds).reduce( (acc: IdSet, [key, value]: [string, string | number | null]) => { if (value) { acc[key] = value; } return acc; }, {}, ); // If we weren't given all of the path's required ids, we can't use it const diff = path.ids.reduce( (acc: string[], id: string) => (pathUrlIds[id] ? acc : acc.concat(id)), [], ); if (diff.length > 0) { return; } specificity = path.ids.length; match = path.path.replace( /(<([^>]+)>)/g, (_m1, _m2, id) => `${pathUrlIds[id]}`, ); }); if (!match) { const pathOptions = potentialPaths.map((path) => path.path); throw new RestResourceError( `Could not find a path for request. If you are trying to make a request to one of the following paths, ensure all relevant IDs are set. :\n - ${pathOptions.join( '\n - ', )}`, ); } if (this.customPrefix) { return `${this.customPrefix}/${match}`; } else { return match; } } protected static createInstancesFromResponse( session: Session, data: Body, ): T[] { let instances: T[] = []; this.resourceNames.forEach((resourceName) => { const singular = resourceName.singular; const plural = resourceName.plural; if (data[plural] || Array.isArray(data[singular])) { instances = instances.concat( (data[plural] || data[singular]).reduce( (acc: T[], entry: Body) => acc.concat(this.createInstance(session, entry)), [], ), ); } else if (data[singular]) { instances.push(this.createInstance(session, data[singular])); } }); return instances; } protected static createInstance( session: Session, data: Body, prevInstance?: T, ): T { const instance: T = prevInstance ? prevInstance : new (this as any)({session}); if (data) { instance.setData(data); } return instance; } #session: Session; get session(): Session { return this.#session; } constructor({session, fromData}: BaseConstructorArgs) { this.#session = session; if (fromData) { this.setData(fromData); } } public async save({update = false}: SaveArgs = {}): Promise { const {primaryKey, resourceNames} = this.resource(); const method = this[primaryKey] ? 'put' : 'post'; const data = this.serialize(true); const response = await this.resource().request({ http_method: method, operation: method, session: this.session, urlIds: {}, body: {[this.resource().getJsonBodyName()]: data}, entity: this, }); const flattenResourceNames: string[] = resourceNames.reduce( (acc, obj) => { return acc.concat(Object.values(obj)); }, [], ); const matchResourceName = Object.keys(response.body as Body).filter( (key: string) => flattenResourceNames.includes(key), ); const body: Body | undefined = (response.body as Body)[ matchResourceName[0] ]; if (update && body) { this.setData(body); } } public async saveAndUpdate(): Promise { await this.save({update: true}); } public async delete(): Promise { await this.resource().request({ http_method: 'delete', operation: 'delete', session: this.session, urlIds: {}, entity: this, }); } public serialize(saving = false): Body { const {hasMany, hasOne, readOnlyAttributes} = this.resource(); return Object.entries(this).reduce((acc: Body, [attribute, value]) => { if ( ['#session'].includes(attribute) || (saving && readOnlyAttributes.includes(attribute)) ) { return acc; } if (attribute in hasMany && value) { acc[attribute] = value.reduce((attrAcc: Body, entry: Base) => { return attrAcc.concat(this.serializeSubAttribute(entry, saving)); }, []); } else if (attribute in hasOne && value) { acc[attribute] = this.serializeSubAttribute(value, saving); } else { acc[attribute] = value; } return acc; }, {}); } public toJSON(): Body { return this.serialize(); } public request(args: RequestArgs) { return this.resource().request(args); } protected setData(data: Body): void { const {hasMany, hasOne} = this.resource(); Object.entries(data).forEach(([attribute, val]) => { if (attribute in hasMany) { const HasManyResource: typeof Base = hasMany[attribute]; this[attribute] = []; val.forEach((entry: Body) => { const obj = new HasManyResource({session: this.session}); if (entry) { obj.setData(entry); } this[attribute].push(obj); }); } else if (attribute in hasOne) { const HasOneResource: typeof Base = hasOne[attribute]; const obj = new HasOneResource({session: this.session}); if (val) { obj.setData(val); } this[attribute] = obj; } else { this[attribute] = val; } }); } protected resource(): typeof Base { return this.constructor as unknown as typeof Base; } private serializeSubAttribute(attribute: Base, saving: boolean): Body { return attribute.serialize ? attribute.serialize(saving) : this.resource() .createInstance(this.session, attribute) .serialize(saving); } }