@nymphjs/client
Version:
Nymph.js - Client
459 lines • 15.9 kB
JavaScript
import Entity from './Entity.js';
import EntityWeakCache from './EntityWeakCache.js';
import HttpRequester, { ClientError } from './HttpRequester.js';
import { entitiesToReferences, entityConstructorsToClassNames, } from './utils.js';
let requester;
export default class Nymph {
/**
* And optional PubSub client instance.
*/
pubsub = undefined;
/**
* A simple map of names to Entity classes.
*/
entityClasses = {};
/**
* The entity class for this instance of Nymph.
*/
Entity;
requestCallbacks = [];
responseCallbacks = [];
restUrl = '';
weakCache = false;
/**
* Headers that will be sent with every request.
*
* These are used by Tilmeld for authentication.
*/
headers = {};
/**
* The entity cache.
*/
cache = new EntityWeakCache();
/**
* Return `null` or empty array instead of error when entity/ies not found.
*/
returnNullOnNotFound = false;
constructor(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.
*/
addEntityClass(entityClass) {
const nymph = this;
class NymphEntity extends entityClass {
static nymph = nymph;
constructor(...args) {
super(...args);
}
}
this.entityClasses[entityClass.class] = NymphEntity;
return NymphEntity;
}
getEntityClass(className) {
let key = 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);
}
async newUID(name) {
const data = await requester.POST({
url: this.restUrl,
headers: { ...this.headers },
dataType: 'text',
data: { action: 'uid', data: name },
});
return Number(data);
}
async setUID(name, value) {
return await requester.PUT({
url: this.restUrl,
headers: { ...this.headers },
dataType: 'json',
data: { action: 'uid', data: { name, value } },
});
}
async getUID(name) {
const data = await requester.GET({
url: this.restUrl,
headers: { ...this.headers },
dataType: 'text',
data: { action: 'uid', data: name },
});
return Number(data);
}
async deleteUID(name) {
return await requester.DELETE({
url: this.restUrl,
headers: { ...this.headers },
dataType: 'text',
data: { action: 'uid', data: name },
});
}
async saveEntity(entity) {
let method = entity.guid == null ? 'POST' : 'PUT';
return await this.requestWithMethod(entity, method, entity, false);
}
async saveEntities(entities) {
if (!entities.length) {
return Promise.resolve(false);
}
let method = 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);
}
async patchEntity(entity) {
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);
}
async patchEntities(entities) {
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);
}
async requestWithMethod(entity, method, data, plural) {
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);
}
else if (!Array.isArray(entity) && typeof response.guid !== 'undefined') {
return entity.$init(response);
}
throw new Error('Server error');
}
async getEntity(options, ...selectors) {
let data = null;
try {
// @ts-ignore: Implementation signatures of overloads are not externally visible.
data = (await this.getEntityData(options, ...selectors));
}
catch (e) {
if (this.returnNullOnNotFound &&
e instanceof ClientError &&
e.status === 404) {
data = null;
}
else {
throw e;
}
}
if (options.return && options.return === 'count') {
return Number(data ?? 0);
}
if (data != null) {
if (options.return && options.return === 'guid') {
return data;
}
else {
return this.initEntity(data);
}
}
return null;
}
async getEntityData(options, ...selectors) {
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;
}
async getEntities(options, ...selectors) {
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) {
if (this.returnNullOnNotFound &&
e instanceof ClientError &&
e.status === 404) {
data = null;
}
else {
throw e;
}
}
if (options.return && options.return === 'count') {
return Number(data ?? 0);
}
if (data == null) {
return [];
}
if (options.return && options.return === 'guid') {
return data;
}
return data.map((e) => this.initEntity(e));
}
initEntity(entityJSON) {
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;
}
}
return entity.$init(entityJSON);
}
getEntityFromCache(EntityClass, guid) {
if (!this.weakCache) {
return null;
}
return this.cache.get(EntityClass, guid);
}
setEntityToCache(EntityClass, entity) {
if (!this.weakCache) {
return;
}
return this.cache.set(EntityClass, entity);
}
initEntitiesFromData(item) {
if (Array.isArray(item)) {
// Recurse into lower arrays.
return item.map((entry) => this.initEntitiesFromData(entry));
}
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.class)) {
return this.initEntity(item);
}
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;
}
async deleteEntity(entity, _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.class,
}))
: {
guid: entity.guid,
class: entity.constructor.class,
},
},
});
}
async deleteEntities(entities) {
return await this.deleteEntity(entities, true);
}
async serverCall(entity, method, params, stateless = false) {
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),
};
}
async serverCallStatic(className, method, params) {
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);
}
async serverCallStaticIterator(className, method, params) {
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 = {
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;
}
on(event, callback) {
const prop = (event + 'Callbacks');
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);
}
off(event, callback) {
const prop = (event + 'Callbacks');
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) {
super(message);
this.name = 'ClassNotAvailableError';
}
}
export class InvalidRequestError extends Error {
constructor(message) {
super(message);
this.name = 'InvalidRequestError';
}
}
//# sourceMappingURL=Nymph.js.map