UNPKG

@nymphjs/client

Version:

Nymph.js - Client

687 lines (636 loc) 20.3 kB
import Entity, { type EntityInstanceType } from './Entity.js'; import type { EntityConstructor, EntityInterface, EntityJson, ServerCallResponse, ServerCallStaticResponse, } from './Entity.types.js'; import EntityWeakCache from './EntityWeakCache.js'; import type { AbortableAsyncIterator } from './HttpRequester.js'; import HttpRequester, { ClientError } from './HttpRequester.js'; import type { EventType, NymphOptions, Options, RequestCallback, ResponseCallback, Selector, } from './Nymph.types.js'; import type PubSub from './PubSub.js'; import { entitiesToReferences, entityConstructorsToClassNames, } from './utils.js'; let requester: HttpRequester; export default class Nymph { /** * And optional PubSub client instance. */ public pubsub: PubSub | undefined = undefined; /** * A simple map of names to Entity classes. */ private entityClasses: { [k: string]: EntityConstructor } = {}; /** * The entity class for this instance of Nymph. */ public Entity: typeof Entity; private requestCallbacks: RequestCallback[] = []; private responseCallbacks: ResponseCallback[] = []; private restUrl: string = ''; private weakCache = false; /** * Headers that will be sent with every request. * * These are used by Tilmeld for authentication. */ public headers: { [k: string]: string } = {}; /** * The entity cache. */ public cache = new EntityWeakCache(); /** * Return `null` or empty array instead of error when entity/ies not found. */ public returnNullOnNotFound = false; public constructor(NymphOptions: NymphOptions) { this.restUrl = NymphOptions.restUrl; // @ts-ignore TS doesn't know about WeakRef. this.weakCache = !!NymphOptions.weakCache && typeof WeakRef !== 'undefined'; if ( 'returnNullOnNotFound' in NymphOptions && NymphOptions.returnNullOnNotFound != null ) { this.returnNullOnNotFound = NymphOptions.returnNullOnNotFound; } this.Entity = this.addEntityClass(Entity); requester = new HttpRequester( 'fetch' in NymphOptions ? NymphOptions.fetch : undefined, ); if ('renewTokens' in NymphOptions && !NymphOptions.renewTokens) { this.headers['X-Tilmeld-Token-Renewal'] = 'off'; } requester.on('request', (_requester, url, options) => { for (let i = 0; i < this.requestCallbacks.length; i++) { this.requestCallbacks[i] && this.requestCallbacks[i](url, options); } }); requester.on('response', (_requester, response, text) => { for (let i = 0; i < this.responseCallbacks.length; i++) { this.responseCallbacks[i] && this.responseCallbacks[i](response, text); } }); } /** * Add your class to this instance. * * This will create a class that extends your class within this instance of * Nymph and return it. You can then use this class's constructor and methods, * which will use this instance of Nymph. * * Because this creates a subclass, don't use the class returned from * `getEntityClass` to check with `instanceof`. Instead, use the base class * that you passed into this method. */ public addEntityClass<T extends EntityConstructor>(entityClass: T): T { const nymph = this; class NymphEntity extends entityClass { static nymph: Nymph = nymph; constructor(...args: any[]) { super(...args); } } this.entityClasses[entityClass.class] = NymphEntity; return NymphEntity; } public getEntityClass<T extends EntityConstructor>(className: T): T; public getEntityClass(className: string): EntityConstructor; public getEntityClass<T extends EntityConstructor = EntityConstructor>( className: T | string, ): T | EntityConstructor { let key: string | null = null; if (typeof className === 'string') { key = className; } else { key = className.class; } if (key in this.entityClasses) { return this.entityClasses[key]; } throw new ClassNotAvailableError('Tried to use class: ' + key); } public async newUID(name: string) { const data = await requester.POST({ url: this.restUrl, headers: { ...this.headers }, dataType: 'text', data: { action: 'uid', data: name }, }); return Number(data); } public async setUID(name: string, value: number) { return await requester.PUT({ url: this.restUrl, headers: { ...this.headers }, dataType: 'json', data: { action: 'uid', data: { name, value } }, }); } public async getUID(name: string) { const data = await requester.GET({ url: this.restUrl, headers: { ...this.headers }, dataType: 'text', data: { action: 'uid', data: name }, }); return Number(data); } public async deleteUID(name: string) { return await requester.DELETE({ url: this.restUrl, headers: { ...this.headers }, dataType: 'text', data: { action: 'uid', data: name }, }); } public async saveEntity(entity: EntityInterface) { let method: 'POST' | 'PUT' = entity.guid == null ? 'POST' : 'PUT'; return await this.requestWithMethod(entity, method, entity, false); } public async saveEntities(entities: EntityInterface[]) { if (!entities.length) { return Promise.resolve(false); } let method: 'POST' | 'PUT' = entities[0].guid == null ? 'POST' : 'PUT'; entities.forEach((cur) => { if ( (method === 'POST' && cur.guid != null) || (method === 'PUT' && cur.guid == null) ) { throw new InvalidRequestError( 'Due to REST restriction, you can only create new entities or ' + 'update existing entities, not both at the same time.', ); } }); return await this.requestWithMethod(entities, method, entities, true); } public async patchEntity(entity: EntityInterface) { if (entity.guid == null) { throw new InvalidRequestError( "You can't patch an entity that hasn't yet been saved.", ); } let patch = entity.$getPatch(); return await this.requestWithMethod(entity, 'PATCH', patch, false); } public async patchEntities(entities: EntityInterface[]) { if (!entities.length) { return Promise.resolve(false); } entities.forEach((cur) => { if (cur.guid == null) { throw new InvalidRequestError( 'Due to REST restriction, you can only create new entities or ' + 'update existing entities, not both at the same time.', ); } }); let patch = entities.map((e) => e.$getPatch()); return await this.requestWithMethod(entities, 'PATCH', patch, true); } private async requestWithMethod<T extends EntityInterface>( entity: T, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', data: { [k: string]: any }, plural: false, ): Promise<T>; private async requestWithMethod<T extends EntityInterface>( entity: T[], method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', data: { [k: string]: any }, plural: true, ): Promise<T[]>; private async requestWithMethod<T extends EntityInterface>( entity: T | T[], method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', data: { [k: string]: any }, plural: boolean, ): Promise<T | T[]> { const response = await requester[method]({ url: this.restUrl, headers: { ...this.headers }, dataType: 'json', data: { action: plural ? 'entities' : 'entity', data, }, }); if (plural && Array.isArray(entity) && entity.length === response.length) { return entity.map((e, i) => response[i] && typeof response[i].guid !== 'undefined' && (e.guid == null || e.guid === response[i].guid) ? e.$init(response[i]) : e, ) as T[]; } else if (!Array.isArray(entity) && typeof response.guid !== 'undefined') { return entity.$init(response) as T; } throw new Error('Server error'); } public async getEntity<T extends EntityConstructor = EntityConstructor>( options: Options<T> & { return: 'count' }, ...selectors: Selector[] ): Promise<number>; public async getEntity<T extends EntityConstructor = EntityConstructor>( options: Options<T> & { return: 'guid' }, ...selectors: Selector[] ): Promise<string | null>; public async getEntity<T extends EntityConstructor = EntityConstructor>( options: Options<T>, ...selectors: Selector[] ): Promise<EntityInstanceType<T> | null>; public async getEntity<T extends EntityConstructor = EntityConstructor>( options: Options<T> & { return: 'count' }, guid: string, ): Promise<number>; public async getEntity<T extends EntityConstructor = EntityConstructor>( options: Options<T> & { return: 'guid' }, guid: string, ): Promise<string | null>; public async getEntity<T extends EntityConstructor = EntityConstructor>( options: Options<T>, guid: string, ): Promise<EntityInstanceType<T> | null>; public async getEntity<T extends EntityConstructor = EntityConstructor>( options: Options<T>, ...selectors: Selector[] | string[] ): Promise<EntityInstanceType<T> | string | number | null> { let data: any = null; try { // @ts-ignore: Implementation signatures of overloads are not externally visible. data = (await this.getEntityData(options, ...selectors)) as | EntityJson<T> | string | number | null; } catch (e: any) { if ( this.returnNullOnNotFound && e instanceof ClientError && e.status === 404 ) { data = null; } else { throw e; } } if (options.return && options.return === 'count') { return Number(data ?? 0) as number; } if (data != null) { if (options.return && options.return === 'guid') { return data as string; } else { return this.initEntity(data as EntityJson<T>); } } return null; } public async getEntityData<T extends EntityConstructor = EntityConstructor>( options: Options<T> & { return: 'count' }, ...selectors: Selector[] ): Promise<number>; public async getEntityData<T extends EntityConstructor = EntityConstructor>( options: Options<T> & { return: 'guid' }, ...selectors: Selector[] ): Promise<string | null>; public async getEntityData<T extends EntityConstructor = EntityConstructor>( options: Options<T>, ...selectors: Selector[] ): Promise<EntityJson<T> | null>; public async getEntityData<T extends EntityConstructor = EntityConstructor>( options: Options<T> & { return: 'count' }, guid: string, ): Promise<number>; public async getEntityData<T extends EntityConstructor = EntityConstructor>( options: Options<T> & { return: 'guid' }, guid: string, ): Promise<string | null>; public async getEntityData<T extends EntityConstructor = EntityConstructor>( options: Options<T>, guid: string, ): Promise<EntityJson<T> | null>; public async getEntityData<T extends EntityConstructor = EntityConstructor>( options: Options<T>, ...selectors: Selector[] | string[] ): Promise<EntityJson<T> | string | number | null> { if (options.class instanceof Entity) { throw new InvalidRequestError( "You can't make REST requests with the base Entity class.", ); } // Set up options and selectors. if (typeof selectors[0] === 'string') { selectors = [{ type: '&', guid: selectors[0] }]; } const data = await requester.GET({ url: this.restUrl, headers: { ...this.headers }, dataType: 'json', data: { action: 'entity', data: [ { ...options, class: options.class.class }, ...entityConstructorsToClassNames(selectors), ], }, }); if (options.return === 'count' || typeof data.guid !== 'undefined') { return data; } return null; } public async getEntities<T extends EntityConstructor = EntityConstructor>( options: Options<T> & { return: 'count' }, ...selectors: Selector[] ): Promise<number>; public async getEntities<T extends EntityConstructor = EntityConstructor>( options: Options<T> & { return: 'guid' }, ...selectors: Selector[] ): Promise<string[]>; public async getEntities<T extends EntityConstructor = EntityConstructor>( options: Options<T>, ...selectors: Selector[] ): Promise<EntityInstanceType<T>[]>; public async getEntities<T extends EntityConstructor = EntityConstructor>( options: Options<T>, ...selectors: Selector[] ): Promise<EntityInstanceType<T>[] | string[] | number> { let data = null; try { data = await requester.GET({ url: this.restUrl, headers: { ...this.headers }, dataType: 'json', data: { action: 'entities', data: [ { ...options, class: options.class.class }, ...entityConstructorsToClassNames(selectors), ], }, }); } catch (e: any) { if ( this.returnNullOnNotFound && e instanceof ClientError && e.status === 404 ) { data = null; } else { throw e; } } if (options.return && options.return === 'count') { return Number(data ?? 0) as number; } if (data == null) { return []; } if (options.return && options.return === 'guid') { return data; } return data.map((e: EntityJson<T>) => this.initEntity(e)); } public initEntity<T extends EntityConstructor = EntityConstructor>( entityJSON: EntityJson<T>, ): EntityInstanceType<T> { const EntityClass = this.getEntityClass(entityJSON.class); if (!EntityClass) { throw new ClassNotAvailableError( entityJSON.class + ' class cannot be found.', ); } let entity = EntityClass.factorySync(); if (this.weakCache) { // Try to get it from cache. const entityFromCache = this.cache.get( EntityClass, entityJSON.guid || '', ); if (entityFromCache != null) { entity = entityFromCache as EntityInstanceType<T>; } } return entity.$init(entityJSON) as EntityInstanceType<T>; } public getEntityFromCache<T extends EntityConstructor = EntityConstructor>( EntityClass: EntityConstructor, guid: string, ): EntityInstanceType<T> | null { if (!this.weakCache) { return null; } return this.cache.get(EntityClass, guid) as EntityInstanceType<T> | null; } public setEntityToCache( EntityClass: EntityConstructor, entity: EntityInterface, ) { if (!this.weakCache) { return; } return this.cache.set(EntityClass, entity); } public initEntitiesFromData<T extends any>(item: T): T { if (Array.isArray(item)) { // Recurse into lower arrays. return item.map((entry) => this.initEntitiesFromData(entry)) as T; } else if (item instanceof Object && !(item instanceof Entity)) { if ( item.hasOwnProperty('class') && item.hasOwnProperty('guid') && item.hasOwnProperty('cdate') && item.hasOwnProperty('mdate') && item.hasOwnProperty('tags') && item.hasOwnProperty('data') && this.getEntityClass((item as any as EntityJson).class) ) { return this.initEntity(item as any as EntityJson) as T; } else { for (let [key, value] of Object.entries(item)) { // @ts-ignore: Key comes from the object. It's fine. item[key] = this.initEntitiesFromData(value); } } } // Not an entity or array, just return it. return item; } public async deleteEntity( entity: EntityInterface | EntityInterface[], _plural = false, ) { return await requester.DELETE({ url: this.restUrl, headers: { ...this.headers }, dataType: 'json', data: { action: _plural ? 'entities' : 'entity', data: _plural && Array.isArray(entity) ? entity.map((e) => ({ guid: e.guid, class: (e.constructor as EntityConstructor).class, })) : { guid: (entity as EntityInterface).guid, class: (entity.constructor as EntityConstructor).class, }, }, }); } public async deleteEntities(entities: EntityInterface[]) { return await this.deleteEntity(entities, true); } public async serverCall( entity: EntityInterface, method: string, params: any[], stateless = false, ): Promise<ServerCallResponse> { const data = await requester.POST({ url: this.restUrl, headers: { ...this.headers }, dataType: 'json', data: { action: 'method', data: { entity, stateless, method, params: entitiesToReferences(entityConstructorsToClassNames(params)), }, }, }); return { ...data, return: this.initEntitiesFromData(data.return), }; } public async serverCallStatic( className: string, method: string, params: any[], ): Promise<ServerCallStaticResponse> { const data = await requester.POST({ url: this.restUrl, headers: { ...this.headers }, dataType: 'json', data: { action: 'method', data: { class: className, static: true, method: method, params: entitiesToReferences(entityConstructorsToClassNames(params)), }, }, }); return this.initEntitiesFromData(data); } public async serverCallStaticIterator( className: string, method: string, params: any[], ): Promise<AbortableAsyncIterator<ServerCallStaticResponse>> { const iterable = await requester.POST_ITERATOR({ url: this.restUrl, headers: { ...this.headers }, dataType: 'json', data: { action: 'method', data: { class: className, static: true, method: method, iterator: true, params: entitiesToReferences(entityConstructorsToClassNames(params)), }, }, }); const that = this; const iterator: AbortableAsyncIterator = { abortController: iterable.abortController, async *[Symbol.asyncIterator]() { for await (let response of iterable) { if (response instanceof Error) { yield response; } else { yield that.initEntitiesFromData(response); } } }, }; return iterator; } public on<T extends EventType>( event: T, callback: T extends 'request' ? RequestCallback : T extends 'response' ? ResponseCallback : never, ) { const prop = (event + 'Callbacks') as T extends 'request' ? 'requestCallbacks' : T extends 'request' ? 'responseCallbacks' : never; if (!(prop in this)) { throw new Error('Invalid event type.'); } // @ts-ignore: The callback should always be the right type here. this[prop].push(callback); return () => this.off(event, callback); } public off<T extends EventType>( event: T, callback: T extends 'request' ? RequestCallback : T extends 'response' ? ResponseCallback : never, ) { const prop = (event + 'Callbacks') as T extends 'request' ? 'requestCallbacks' : T extends 'request' ? 'responseCallbacks' : never; if (!(prop in this)) { return false; } // @ts-ignore: The callback should always be the right type here. const i = this[prop].indexOf(callback); if (i > -1) { // @ts-ignore: The callback should always be the right type here. this[prop].splice(i, 1); } return true; } } export class ClassNotAvailableError extends Error { constructor(message: string) { super(message); this.name = 'ClassNotAvailableError'; } } export class InvalidRequestError extends Error { constructor(message: string) { super(message); this.name = 'InvalidRequestError'; } }