Skip to content

Instantly share code, notes, and snippets.

@sp90
Created September 2, 2019 11:53
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sp90/3e15bd9aa97f77d4908c8b1b1b373034 to your computer and use it in GitHub Desktop.
Save sp90/3e15bd9aa97f77d4908c8b1b1b373034 to your computer and use it in GitHub Desktop.
import { of, BehaviorSubject } from 'rxjs';
import { fromFetch } from 'rxjs/fetch';
import { switchMap, catchError } from 'rxjs/operators';
interface CustomMethodInterface {
name: string;
endpoint: string;
method: 'GET' | 'POST' | 'PATCH' | 'DELETE';
}
interface ModelInterface {
idName: string;
endpoint: string;
validator?: Function;
customEndpoints?: {
find?: string;
findOne?: string;
createOne?: string;
findOneAndUpdate?: string;
findOneAndRemove?: string;
};
customMethods?: CustomMethodInterface[];
HTTPinterceptors?: {
request?: Function;
};
}
interface FindSettingsInterface {
sort?: any;
pagination?: any;
customEndpoint?: string;
}
const originalPagination = {
offset: 0,
limit: 10
};
class FindInstance {
pagination = originalPagination;
requestOptions: any = {
asQueryParam: true
};
data = [];
subject = new BehaviorSubject([]);
constructor(private _model: any, private query: any, private sort: any) {
if (_model.customEndpoints && _model.customEndpoints.find) {
this.requestOptions.customEndpoint = _model.customEndpoints.find;
}
}
run() {
let query = this.newQuery(this.sort, this.pagination);
return this.runCall(query);
}
next() {
this.pagination.offset += this.pagination.limit;
let query = this.newQuery(this.sort, this.pagination);
return this.runCall(query);
}
reset() {
this.pagination.offset = originalPagination.offset;
let query = this.newQuery(this.sort, this.pagination);
return this.runCall(query);
}
private async runCall(query) {
try {
const result = await this._model.HTTP('GET', query, this.requestOptions, true);
this.subject.next(result.data);
this.data = result.data;
return this.data;
} catch (err) {
// Error handling
return err;
}
}
private newQuery(sort, pagination) {
let query: any = this.query ? this.query : {};
if (sort) {
query.sort = JSON.stringify(sort);
}
if (pagination) {
query.pagination = JSON.stringify(pagination);
}
return query;
}
}
function MethodCreator(_model, method, endpoint) {
const _self = {
_model: _model,
method: method,
endpoint: endpoint
};
return (data: any) => {
const isGetter = _self.method === 'GET' || _self.method === 'DELETE';
const isSetter = _self.method === 'PATCH' || _self.method === 'POST';
if (isSetter && _self._model.validator) {
let validatorResponse = _self._model.validator(data);
if (validatorResponse instanceof Error) {
throw validatorResponse;
}
}
let requestOptions: any = {};
const endpointId = isGetter ? data : data[_self._model.idName];
requestOptions.setEndpoint = _self.endpoint.includes(':id')
? _self.endpoint.replace(':id', endpointId)
: _self.endpoint;
return _self._model.HTTP(_self.method, data, requestOptions);
};
}
/**
* Atlas - API taming luxurious abstraction service
* The Atlas can be instansiated to configurate the model you wanna manipulate via the API
*/
export class Atlas {
idName: string;
endpoint: string;
customEndpoints: any = {};
validator: Function;
docInstances: any[] = [];
HTTPinterceptors: any = {};
constructor() {}
/**
* Here you instantiate a model that you can manipulate data on going forward
* @param options contains a series of settings to initiate the model correctly
* @property options.idName referes to the property name of the id of the entries to retrieve by
* @property options.endpoint is the endpoint to ask for data on the API
* @property [options.endpoints] its an object with custom endpoints for each of the request types (find, createOne, findOne ...)
* @property [options.validator] is a function to validate api calls before sending them of to the API, you could use any validator or leave it blank to ignore validation
* @property [options.HTTPinterceptors] is a function to manipulate requests before they are sent of its here you can sent http headers and much more
* @property [options.customMethods] an array of objects to define custom methods
* @property [options.customMethods.name] name of the method
* @property [options.customMethods.endpoint] url to hit the endpoint
* @property [options.customMethods.method] one of the common 4 methods, GET, PATCH, POST & DELETE
* @example
* let TodosV1 = new Atlas().model({
* idName: '_id',
* endpoint: 'http://localhost:3000/api/todos/:id',
* validator: (objToValidate) => {
* // Here we're using the mongoose validator
* const doc = new mongoose.Document(
* objToValidate,
* new mongoose.Schema({
* text: {
* type: String,
* required: true
* },
* done: Boolean,
* deadline: Date,
* reminder: Date
* })
* );
*
* const validationRes = doc.validateSync();
*
* // IMPORTANT: Should return an Error object or true depending on if it valid or invalid
* if (validationRes) {
* return new Error(validationRes);
* } else {
* return true;
* }
* },
* HTTPinterceptors: {
* request: (fetchEndpoint, fetchSettings) => {
* const myToken = 'JSON_WEB_TOKEN'; // Get token from anywhere
*
* fetchSettings.headers['Authorization'] = `Bearer ${myToken}`;
*
* // Important should return both endpoint and settings like an object with correct names
* return {
* fetchEndpoint,
* fetchSettings
* }
* }
* }
* });
*/
model(options: ModelInterface): any {
this.idName = options.idName;
this.endpoint = options.endpoint;
this.customEndpoints = options.customEndpoints;
// Set validator function
this.validator =
options && options.validator
? options.validator
: () => {
return true;
};
// Set Http interceptor
if (options && options.HTTPinterceptors && options.HTTPinterceptors.request) {
this.HTTPinterceptors['request'] = options.HTTPinterceptors.request;
}
const methodsMap = [
{
name: 'findOne',
method: 'GET',
endpoint: this.endpoint
},
{
name: 'createOne',
method: 'POST',
endpoint: this.endpoint.replace('/:id', '')
},
{
name: 'findOneAndUpdate',
method: 'PATCH',
endpoint: this.endpoint
},
{
name: 'findOneAndDelete',
method: 'DELETE',
endpoint: this.endpoint
}
];
const customMethods =
options.customMethods && options.customMethods.length ? options.customMethods : [];
const allMethods = methodsMap.concat(customMethods);
allMethods.forEach(methodObj => {
const newMethod = MethodCreator(this, methodObj.method, methodObj.endpoint);
this[methodObj.name] = newMethod;
});
return this;
}
/**
* HTTP is helper method to do API requests
* @param method can be GET, DELETE, POST or PATCH
* @param data for get and deletes this is the id of the item - for creates and updates its the object you want to update or create
* @param options
* @property options.asQueryParam boolean to set data as query params instead of using the body
*/
HTTP(method: string, data: any, options?: any, asPromise: boolean = false) {
if (!method) {
throw 'Method is missing';
}
if (options && options.asQueryParam) {
options.queryParams = data;
}
// Set default endpoint here
let fetchEndpoint = options.setEndpoint ? options.setEndpoint : this.endpoint;
let fetchSettings: any = {
method: method,
mode: 'cors', // no-cors, cors, *same-origin
headers: {
'Content-Type': 'application/vnd.api+json'
}
};
// Automaticly set body for post & patch
if (method === 'POST' || method === 'PATCH') {
fetchSettings['body'] = JSON.stringify({
data: {
//type: 'users',
attributes: data
}
});
}
// Bind query params to the url before sending it to the interceptor
if (options && options.queryParams) {
fetchEndpoint +=
(fetchEndpoint.indexOf('?') === -1 ? '?' : '&') + queryParams(options.queryParams);
delete options.queryParams;
}
// Interceptor is now ran right before doing the actual fetch
// So the user have full modifyability of the request being sent
if (this.HTTPinterceptors && this.HTTPinterceptors.request) {
const afterInterceptor = this.HTTPinterceptors.request(fetchEndpoint, fetchSettings);
if (afterInterceptor && afterInterceptor.fetchEndpoint) {
fetchEndpoint = afterInterceptor.fetchEndpoint;
}
if (afterInterceptor && afterInterceptor.fetchSettings) {
fetchSettings = { ...fetchSettings, ...afterInterceptor.fetchSettings };
}
}
if (asPromise) {
return new Promise(async (resolve, reject) => {
try {
const response = await fetch(fetchEndpoint, fetchSettings);
const resp = await response.json();
response.ok ? resolve(resp) : reject(resp);
} catch (err) {
reject(err);
}
});
} else {
// Use fetch to do what have been configured above
return fromFetch(fetchEndpoint, fetchSettings).pipe(
switchMap(response => {
if (response.ok) {
// OK return data
return response.json();
} else {
// Server is returning a status requiring the client to try something else.
return of({ error: true, message: `Error ${response.status}` });
}
}),
catchError(err => {
// Network or other error, handle appropriately
console.error(err);
return of({ error: true, message: err.message });
})
);
}
function queryParams(params) {
return Object.keys(params)
.map(k => {
if (Array.isArray(params[k])) {
return params[k]
.map(val => `${encodeURIComponent(k)}[]=${encodeURIComponent(val)}`)
.join('&');
}
return `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`;
})
.join('&');
}
}
/**
* Used to instantiate the DocInstance or grab it from memory this is to avoid having a memory leak by making an instance many times
* @param instanceId the id of the document you want to manipulate
* @param getOnInit set to true if you want to populate memory with data when this is instantiated
* @example
* // Get an instance of a document
* const activeTodo = TodosV1.doc(itemId1, true);
*
* // Subscribe
* activeTodo.subject.subscribe(res => {
* console.log('subscribed value: ', res);
* });
*
* // Do a get
* activeTodo.get().then(res => {
* console.log('after get data: ', res);
* });
*
* // Do an update
* activeTodo.set({
* text: 'new text6'
* });
*/
doc(instanceId: string, getOnInit: boolean): any {
const _model = this;
const instanceIndex = _model.docInstances.map(x => x.instanceId).indexOf(instanceId);
if (instanceIndex > -1) {
return _model.docInstances[instanceIndex].instance;
} else {
let newDocInstance = new DocInstance(instanceId, _model, getOnInit);
_model.docInstances.push({
instanceId: instanceId,
instance: newDocInstance
});
return newDocInstance;
}
}
/**
* find is used to search the api, with support for query, sort & pagination
* @param query is key value pairs in an object that defines the query which is transformed into query params to be send with the get request
* @param sort a stringified object that is sent of to the API as param sort - MIGHT BE DEPRECATED OR CHANGED
* @param pagination
* @property pagination.limit max number of items per request
* @property pagination.offset where in the index to start getting items
* @example
* const findTodos = TodosV1.find(
* {
* query: 'to'
* },
* {
* sort: {
* text: -1
* },
* pagination: {
* offset: 0,
* limit: 2
* }
* }
* )
*
* findTodos.subject.subscribe(res => {
* console.log('response', res)
* })
*
* // Initial run
* findTodos.run()
*
* // Next page
* findTodos.next()
*
* // Reset pagination
* findTodos.next()
*/
find(query: any, settings: FindSettingsInterface) {
return new FindInstance(this, query, settings.sort);
}
// /**
// * findOne is used to get one item by id
// * @param itemId the id of the item you want to retrieve
// * @example
// * TodosV1.findOne('ITEM_ID').then(result => {
// * console.log('result after: ', result);
// * });
// */
// findOne(itemId: string, customEndpoint?: string) {
// let requestOptions: any = {};
// if (this.customEndpoints && this.customEndpoints.findOne) {
// requestOptions.customEndpoint = this.customEndpoints.findOne;
// }
// if (customEndpoint) {
// requestOptions.customEndpoint = customEndpoint;
// }
// return this.HTTP('GET', itemId, requestOptions);
// }
// /**
// * createOne is used to create an input here we also call the custom validator if set on the modal instance
// * @param createObj can contain any values and is validated by validator if set
// * @example
// * TodosV1.createOne({
// * text: 'some todo text',
// * done: false
// * }).then(result => {
// * console.log('result after: ', result);
// * });
// */
// createOne(createObj: any, customEndpoint?: string) {
// let validatorResponse = this.validator(createObj);
// if (validatorResponse instanceof Error) {
// throw validatorResponse;
// }
// let requestOptions: any = {};
// if (this.customEndpoints && this.customEndpoints.createOne) {
// requestOptions.customEndpoint = this.customEndpoints.createOne;
// }
// if (customEndpoint) {
// requestOptions.customEndpoint = customEndpoint;
// }
// return this.HTTP('POST', createObj, requestOptions);
// }
// /**
// * findOneAndUpdate is used to update an input here we also call the custom validator if set on the modal instance
// * @param updateObj can contain any values and is validated by validator if set
// * @example
// * TodosV1.findOneAndUpdate({
// * text: 'some other todo text'
// * }).then(result => {
// * console.log('result after: ', result);
// * });
// */
// findOneAndUpdate(updateObj: any, customEndpoint?: string) {
// let validatorResponse = this.validator(updateObj);
// if (validatorResponse instanceof Error) {
// throw validatorResponse;
// }
// let requestOptions: any = {};
// if (this.customEndpoints && this.customEndpoints.findOneAndUpdate) {
// requestOptions.customEndpoint = this.customEndpoints.findOneAndUpdate;
// }
// if (customEndpoint) {
// requestOptions.customEndpoint = customEndpoint;
// }
// return this.HTTP('PATCH', updateObj, requestOptions);
// }
// /**
// * findOneAndRemove is used to find an item and remove it
// * @param itemId the id of the item you want to delete
// * @example
// * TodosV1.findOneAndRemove('ITEM_ID').then(result => {
// * console.log('result after: ', result);
// * });
// */
// findOneAndRemove(itemId: string, customEndpoint?: string) {
// let requestOptions: any = {};
// if (this.customEndpoints && this.customEndpoints.findOneAndRemove) {
// requestOptions.customEndpoint = this.customEndpoints.findOneAndRemove;
// }
// if (customEndpoint) {
// requestOptions.customEndpoint = customEndpoint;
// }
// return this.HTTP('DELETE', itemId, requestOptions);
// }
}
/**
* The DocInstance is an internal class that can be instantiated on a model
*/
class DocInstance {
_model: any;
instanceId: string | number = '';
data = null;
subject = new BehaviorSubject(null);
/**
* The constructor of the doc instance
* @param instanceId is the document id
* @param _model is the parent model, that has instantiated this doc instance
* @param getOnInit this is to retrive the document when you create the document instance
*/
constructor(instanceId: string | number, _model: object, getOnInit: boolean) {
this.instanceId = instanceId;
this._model = _model;
if (getOnInit) {
this.get();
}
}
/**
* Read - Is to retrive the document from API based on the initial model configuration
* @returns successfully - it returns the document recieved from API
* @example
* // Get an instance of a document
* const activeTodo = TodosV1.doc('ITEM_ID');
*
* // Do a get
* activeTodo.get()
* .then((res) => {
* console.log(res)
* })
*/
async get(): Promise<any> {
try {
const result = await this._model.findOne(this.instanceId);
// Push to subject
this.subject.next(result.data);
// Set in memory
this.data = result.data;
return this.data;
} catch (err) {
// Error handling
return err;
}
}
/**
* Update - Is to update the data in the API based on the initial model configuration
* @returns successfully - it returns the updated document sent to API
* @param updateObj
* @example
* // Get an instance of a document
* const activeTodo = TodosV1.doc('ITEM_ID');
*
* // Set new data to the item
* activeTodo.set({
* text: 'new updated text'
* })
* .then((res) => {
* console.log(res)
* })
*/
async set(updateObj: object): Promise<any> {
let finalUpdateObj = updateObj;
// Set id
finalUpdateObj[this._model.idName] = this.instanceId;
try {
const result = await this._model.findOneAndUpdate(finalUpdateObj);
if (result && result.success) {
// Push to subject
this.subject.next(result.data);
// Set in memory
this.data = result.data;
// Return value
return this.data;
} else {
throw result && result.message ? result.message : 'An unexpected error happend';
}
} catch (err) {
// Error handling
return err;
}
}
/**
* Delete - Is to remove the document from API based on the initial model configuration
* @example
* // Get an instance of a document
* const activeTodo = TodosV1.doc('ITEM_ID');
*
* // Delete the todo
* activeTodo.remove()
*/
async remove(): Promise<any> {
try {
const result = await this._model.findOneAndRemove(this.instanceId);
if (result && result.success) {
this.data = null;
} else {
throw result && result.message ? result.message : 'An unexpected error happend';
}
// TODO - remove instance from memory to avoid a memory leak
return {
message: 'Item has been destroyed'
};
} catch (err) {
// Error handling
return err;
}
}
/**
* @returns the data stored in memory, memory gets updated both when using .set() & .get()
*/
getMemory(): object {
return this.data;
}
}
// export const BasicTokenInterceptor = (myToken: string, type: string = 'Bearer') => {
// return {
// request: (fetchEndpoint, fetchSettings) => {
// if (type === 'Bearer') {
// fetchSettings.headers['Authorization'] = `Bearer ${myToken}`;
// }
// // Important should return both endpoint and settings like an object with correct names
// return {
// fetchEndpoint,
// fetchSettings
// };
// }
// };
// };
export const LsTokenInterceptor = (target: string, type: string = 'Bearer') => {
return {
request: (fetchEndpoint, fetchSettings) => {
let targetArr = target.split('.');
let lsKeyName = targetArr[0];
let myToken;
let myLs = JSON.parse(localStorage.getItem(lsKeyName));
if (myLs) {
if (targetArr.length === 1) {
myToken = myLs;
} else if (targetArr.length === 2) {
myToken = myLs[targetArr[1]];
}
if (type === 'Bearer') {
fetchSettings.headers['Authorization'] = `Bearer ${myToken}`;
}
}
// Important should return both endpoint and settings like an object with correct names
return {
fetchEndpoint,
fetchSettings
};
}
};
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment