import { Observable, BehaviorSubject, of, from } from 'rxjs'; import { concatMap, first } from 'rxjs/operators'; import { getStatusText, isSuccess, STATUS } from './http-status-codes'; import { delayResponse } from './delay-response'; import { InMemoryBackendConfig, parseUri, removeTrailingSlash } from './interfaces'; /** * Base class for in-memory web api back-ends * Simulate the behavior of a RESTy web api * backed by the simple in-memory data store provided by the injected `InMemoryDbService` service. * Conforms mostly to behavior described here: * http://www.restapitutorial.com/lessons/httpmethods.html */ var BackendService = /** @class */ (function () { function BackendService(inMemDbService, config) { if (config === void 0) { config = {}; } this.inMemDbService = inMemDbService; this.config = new InMemoryBackendConfig(); this.requestInfoUtils = this.getRequestInfoUtils(); var loc = this.getLocation('/'); this.config.host = loc.host; // default to app web server host this.config.rootPath = loc.path; // default to path when app is served (e.g.'/') Object.assign(this.config, config); } Object.defineProperty(BackendService.prototype, "dbReady", { //// protected ///// get: function () { if (!this.dbReadySubject) { // first time the service is called. this.dbReadySubject = new BehaviorSubject(false); this.resetDb(); } return this.dbReadySubject.asObservable().pipe(first(function (r) { return r; })); }, enumerable: true, configurable: true }); /** * Process Request and return an Observable of Http Response object * in the manner of a RESTy web api. * * Expect URI pattern in the form :base/:collectionName/:id? * Examples: * // for store with a 'customers' collection * GET api/customers // all customers * GET api/customers/42 // the character with id=42 * GET api/customers?name=^j // 'j' is a regex; returns customers whose name starts with 'j' or 'J' * GET api/customers.json/42 // ignores the ".json" * * Also accepts direct commands to the service in which the last segment of the apiBase is the word "commands" * Examples: * POST commands/resetDb, * GET/POST commands/config - get or (re)set the config * * HTTP overrides: * If the injected inMemDbService defines an HTTP method (lowercase) * The request is forwarded to that method as in * `inMemDbService.get(requestInfo)` * which must return either an Observable of the response type * for this http library or null|undefined (which means "keep processing"). */ BackendService.prototype.handleRequest = function (req) { var _this = this; // handle the request when there is an in-memory database return this.dbReady.pipe(concatMap(function () { return _this.handleRequest_(req); })); }; BackendService.prototype.handleRequest_ = function (req) { var _this = this; var url = req.urlWithParams ? req.urlWithParams : req.url; // Try override parser // If no override parser or it returns nothing, use default parser var parser = this.bind('parseRequestUrl'); var parsed = (parser && parser(url, this.requestInfoUtils)) || this.parseRequestUrl(url); var collectionName = parsed.collectionName; var collection = this.db[collectionName]; var reqInfo = { req: req, apiBase: parsed.apiBase, collection: collection, collectionName: collectionName, headers: this.createHeaders({ 'Content-Type': 'application/json' }), id: this.parseId(collection, collectionName, parsed.id), method: this.getRequestMethod(req), query: parsed.query, resourceUrl: parsed.resourceUrl, url: url, utils: this.requestInfoUtils }; var resOptions; if (/commands\/?$/i.test(reqInfo.apiBase)) { return this.commands(reqInfo); } var methodInterceptor = this.bind(reqInfo.method); if (methodInterceptor) { // InMemoryDbService intercepts this HTTP method. // if interceptor produced a response, return it. // else InMemoryDbService chose not to intercept; continue processing. var interceptorResponse = methodInterceptor(reqInfo); if (interceptorResponse) { return interceptorResponse; } ; } if (this.db[collectionName]) { // request is for a known collection of the InMemoryDbService return this.createResponse$(function () { return _this.collectionHandler(reqInfo); }); } if (this.config.passThruUnknownUrl) { // unknown collection; pass request thru to a "real" backend. return this.getPassThruBackend().handle(req); } // 404 - can't handle this request resOptions = this.createErrorResponseOptions(url, STATUS.NOT_FOUND, "Collection '" + collectionName + "' not found"); return this.createResponse$(function () { return resOptions; }); }; /** * Add configured delay to response observable unless delay === 0 */ BackendService.prototype.addDelay = function (response) { var d = this.config.delay; return d === 0 ? response : delayResponse(response, d || 500); }; /** * Apply query/search parameters as a filter over the collection * This impl only supports RegExp queries on string properties of the collection * ANDs the conditions together */ BackendService.prototype.applyQuery = function (collection, query) { // extract filtering conditions - {propertyName, RegExps) - from query/search parameters var conditions = []; var caseSensitive = this.config.caseSensitiveSearch ? undefined : 'i'; query.forEach(function (value, name) { value.forEach(function (v) { return conditions.push({ name: name, rx: new RegExp(decodeURI(v), caseSensitive) }); }); }); var len = conditions.length; if (!len) { return collection; } // AND the RegExp conditions return collection.filter(function (row) { var ok = true; var i = len; while (ok && i) { i -= 1; var cond = conditions[i]; ok = cond.rx.test(row[cond.name]); } return ok; }); }; /** * Get a method from the `InMemoryDbService` (if it exists), bound to that service */ BackendService.prototype.bind = function (methodName) { var fn = this.inMemDbService[methodName]; return fn ? fn.bind(this.inMemDbService) : undefined; }; BackendService.prototype.bodify = function (data) { return this.config.dataEncapsulation ? { data: data } : data; }; BackendService.prototype.clone = function (data) { return JSON.parse(JSON.stringify(data)); }; BackendService.prototype.collectionHandler = function (reqInfo) { // const req = reqInfo.req; var resOptions; switch (reqInfo.method) { case 'get': resOptions = this.get(reqInfo); break; case 'post': resOptions = this.post(reqInfo); break; case 'put': resOptions = this.put(reqInfo); break; case 'delete': resOptions = this.delete(reqInfo); break; default: resOptions = this.createErrorResponseOptions(reqInfo.url, STATUS.METHOD_NOT_ALLOWED, 'Method not allowed'); break; } // If `inMemDbService.responseInterceptor` exists, let it morph the response options var interceptor = this.bind('responseInterceptor'); return interceptor ? interceptor(resOptions, reqInfo) : resOptions; }; /** * Commands reconfigure the in-memory web api service or extract information from it. * Commands ignore the latency delay and respond ASAP. * * When the last segment of the `apiBase` path is "commands", * the `collectionName` is the command. * * Example URLs: * commands/resetdb (POST) // Reset the "database" to its original state * commands/config (GET) // Return this service's config object * commands/config (POST) // Update the config (e.g. the delay) * * Usage: * http.post('commands/resetdb', undefined); * http.get('commands/config'); * http.post('commands/config', '{"delay":1000}'); */ BackendService.prototype.commands = function (reqInfo) { var _this = this; var command = reqInfo.collectionName.toLowerCase(); var method = reqInfo.method; var resOptions = { url: reqInfo.url }; switch (command) { case 'resetdb': resOptions.status = STATUS.NO_CONTENT; return this.resetDb(reqInfo).pipe(concatMap(function () { return _this.createResponse$(function () { return resOptions; }, false /* no latency delay */); })); case 'config': if (method === 'get') { resOptions.status = STATUS.OK; resOptions.body = this.clone(this.config); // any other HTTP method is assumed to be a config update } else { var body = this.getJsonBody(reqInfo.req); Object.assign(this.config, body); this.passThruBackend = undefined; // re-create when needed resOptions.status = STATUS.NO_CONTENT; } break; default: resOptions = this.createErrorResponseOptions(reqInfo.url, STATUS.INTERNAL_SERVER_ERROR, "Unknown command \"" + command + "\""); } return this.createResponse$(function () { return resOptions; }, false /* no latency delay */); }; BackendService.prototype.createErrorResponseOptions = function (url, status, message) { return { body: { error: "" + message }, url: url, headers: this.createHeaders({ 'Content-Type': 'application/json' }), status: status }; }; /** * Create a cold response Observable from a factory for ResponseOptions * @param resOptionsFactory - creates ResponseOptions when observable is subscribed * @param withDelay - if true (default), add simulated latency delay from configuration */ BackendService.prototype.createResponse$ = function (resOptionsFactory, withDelay) { if (withDelay === void 0) { withDelay = true; } var resOptions$ = this.createResponseOptions$(resOptionsFactory); var resp$ = this.createResponse$fromResponseOptions$(resOptions$); return withDelay ? this.addDelay(resp$) : resp$; }; /** * Create a cold Observable of ResponseOptions. * @param resOptionsFactory - creates ResponseOptions when observable is subscribed */ BackendService.prototype.createResponseOptions$ = function (resOptionsFactory) { var _this = this; return new Observable(function (responseObserver) { var resOptions; try { resOptions = resOptionsFactory(); } catch (error) { var err = error.message || error; resOptions = _this.createErrorResponseOptions('', STATUS.INTERNAL_SERVER_ERROR, "" + err); } var status = resOptions.status; try { resOptions.statusText = getStatusText(status); } catch (e) { /* ignore failure */ } if (isSuccess(status)) { responseObserver.next(resOptions); responseObserver.complete(); } else { responseObserver.error(resOptions); } return function () { }; // unsubscribe function }); }; BackendService.prototype.delete = function (_a) { var collection = _a.collection, collectionName = _a.collectionName, headers = _a.headers, id = _a.id, url = _a.url; // tslint:disable-next-line:triple-equals if (id == undefined) { return this.createErrorResponseOptions(url, STATUS.NOT_FOUND, "Missing \"" + collectionName + "\" id"); } var exists = this.removeById(collection, id); return { headers: headers, status: (exists || !this.config.delete404) ? STATUS.NO_CONTENT : STATUS.NOT_FOUND }; }; /** * Find first instance of item in collection by `item.id` * @param collection * @param id */ BackendService.prototype.findById = function (collection, id) { return collection.find(function (item) { return item.id === id; }); }; /** * Generate the next available id for item in this collection * Use method from `inMemDbService` if it exists and returns a value, * else delegates to `genIdDefault`. * @param collection - collection of items with `id` key property */ BackendService.prototype.genId = function (collection, collectionName) { var genId = this.bind('genId'); if (genId) { var id = genId(collection, collectionName); // tslint:disable-next-line:triple-equals if (id != undefined) { return id; } } return this.genIdDefault(collection, collectionName); }; /** * Default generator of the next available id for item in this collection * This default implementation works only for numeric ids. * @param collection - collection of items with `id` key property * @param collectionName - name of the collection */ BackendService.prototype.genIdDefault = function (collection, collectionName) { if (!this.isCollectionIdNumeric(collection, collectionName)) { throw new Error("Collection '" + collectionName + "' id type is non-numeric or unknown. Can only generate numeric ids."); } var maxId = 0; collection.reduce(function (prev, item) { maxId = Math.max(maxId, typeof item.id === 'number' ? item.id : maxId); }, undefined); return maxId + 1; }; BackendService.prototype.get = function (_a) { var collection = _a.collection, collectionName = _a.collectionName, headers = _a.headers, id = _a.id, query = _a.query, url = _a.url; var data = collection; // tslint:disable-next-line:triple-equals if (id != undefined && id !== '') { data = this.findById(collection, id); } else if (query) { data = this.applyQuery(collection, query); } if (!data) { return this.createErrorResponseOptions(url, STATUS.NOT_FOUND, "'" + collectionName + "' with id='" + id + "' not found"); } return { body: this.bodify(this.clone(data)), headers: headers, status: STATUS.OK }; }; /** * Get location info from a url, even on server where `document` is not defined */ BackendService.prototype.getLocation = function (url) { if (!url.startsWith('http')) { // get the document iff running in browser var doc = (typeof document === 'undefined') ? undefined : document; // add host info to url before parsing. Use a fake host when not in browser. var base = doc ? doc.location.protocol + '//' + doc.location.host : 'http://fake'; url = url.startsWith('/') ? base + url : base + '/' + url; } return parseUri(url); }; ; /** * get or create the function that passes unhandled requests * through to the "real" backend. */ BackendService.prototype.getPassThruBackend = function () { return this.passThruBackend ? this.passThruBackend : this.passThruBackend = this.createPassThruBackend(); }; /** * Get utility methods from this service instance. * Useful within an HTTP method override */ BackendService.prototype.getRequestInfoUtils = function () { var _this = this; return { createResponse$: this.createResponse$.bind(this), findById: this.findById.bind(this), isCollectionIdNumeric: this.isCollectionIdNumeric.bind(this), getConfig: function () { return _this.config; }, getDb: function () { return _this.db; }, getJsonBody: this.getJsonBody.bind(this), getLocation: this.getLocation.bind(this), getPassThruBackend: this.getPassThruBackend.bind(this), parseRequestUrl: this.parseRequestUrl.bind(this), }; }; BackendService.prototype.indexOf = function (collection, id) { return collection.findIndex(function (item) { return item.id === id; }); }; /** Parse the id as a number. Return original value if not a number. */ BackendService.prototype.parseId = function (collection, collectionName, id) { if (!this.isCollectionIdNumeric(collection, collectionName)) { // Can't confirm that `id` is a numeric type; don't parse as a number // or else `'42'` -> `42` and _get by id_ fails. return id; } var idNum = parseFloat(id); return isNaN(idNum) ? id : idNum; }; /** * return true if can determine that the collection's `item.id` is a number * This implementation can't tell if the collection is empty so it assumes NO * */ BackendService.prototype.isCollectionIdNumeric = function (collection, collectionName) { // collectionName not used now but override might maintain collection type information // so that it could know the type of the `id` even when the collection is empty. return !!(collection && collection[0]) && typeof collection[0].id === 'number'; }; /** * Parses the request URL into a `ParsedRequestUrl` object. * Parsing depends upon certain values of `config`: `apiBase`, `host`, and `urlRoot`. * * Configuring the `apiBase` yields the most interesting changes to `parseRequestUrl` behavior: * When apiBase=undefined and url='http://localhost/api/collection/42' * {base: 'api/', collectionName: 'collection', id: '42', ...} * When apiBase='some/api/root/' and url='http://localhost/some/api/root/collection' * {base: 'some/api/root/', collectionName: 'collection', id: undefined, ...} * When apiBase='/' and url='http://localhost/collection' * {base: '/', collectionName: 'collection', id: undefined, ...} * * The actual api base segment values are ignored. Only the number of segments matters. * The following api base strings are considered identical: 'a/b' ~ 'some/api/' ~ `two/segments' * * To replace this default method, assign your alternative to your InMemDbService['parseRequestUrl'] */ BackendService.prototype.parseRequestUrl = function (url) { try { var loc = this.getLocation(url); var drop = this.config.rootPath.length; var urlRoot = ''; if (loc.host !== this.config.host) { // url for a server on a different host! // assume it's collection is actually here too. drop = 1; // the leading slash urlRoot = loc.protocol + '//' + loc.host + '/'; } var path = loc.path.substring(drop); var pathSegments = path.split('/'); var segmentIx = 0; // apiBase: the front part of the path devoted to getting to the api route // Assumes first path segment if no config.apiBase // else ignores as many path segments as are in config.apiBase // Does NOT care what the api base chars actually are. var apiBase = void 0; // tslint:disable-next-line:triple-equals if (this.config.apiBase == undefined) { apiBase = pathSegments[segmentIx++]; } else { apiBase = removeTrailingSlash(this.config.apiBase.trim()); if (apiBase) { segmentIx = apiBase.split('/').length; } else { segmentIx = 0; // no api base at all; unwise but allowed. } } apiBase += '/'; var collectionName = pathSegments[segmentIx++]; // ignore anything after a '.' (e.g.,the "json" in "customers.json") collectionName = collectionName && collectionName.split('.')[0]; var id = pathSegments[segmentIx++]; var query = this.createQueryMap(loc.query); var resourceUrl = urlRoot + apiBase + collectionName + '/'; return { apiBase: apiBase, collectionName: collectionName, id: id, query: query, resourceUrl: resourceUrl }; } catch (err) { var msg = "unable to parse url '" + url + "'; original error: " + err.message; throw new Error(msg); } }; // Create entity // Can update an existing entity too if post409 is false. BackendService.prototype.post = function (_a) { var collection = _a.collection, collectionName = _a.collectionName, headers = _a.headers, id = _a.id, req = _a.req, resourceUrl = _a.resourceUrl, url = _a.url; var item = this.clone(this.getJsonBody(req)); // tslint:disable-next-line:triple-equals if (item.id == undefined) { try { item.id = id || this.genId(collection, collectionName); } catch (err) { var emsg = err.message || ''; if (/id type is non-numeric/.test(emsg)) { return this.createErrorResponseOptions(url, STATUS.UNPROCESSABLE_ENTRY, emsg); } else { console.error(err); return this.createErrorResponseOptions(url, STATUS.INTERNAL_SERVER_ERROR, "Failed to generate new id for '" + collectionName + "'"); } } } if (id && id !== item.id) { return this.createErrorResponseOptions(url, STATUS.BAD_REQUEST, "Request id does not match item.id"); } else { id = item.id; } var existingIx = this.indexOf(collection, id); var body = this.bodify(item); if (existingIx === -1) { collection.push(item); headers.set('Location', resourceUrl + '/' + id); return { headers: headers, body: body, status: STATUS.CREATED }; } else if (this.config.post409) { return this.createErrorResponseOptions(url, STATUS.CONFLICT, "'" + collectionName + "' item with id='" + id + " exists and may not be updated with POST; use PUT instead."); } else { collection[existingIx] = item; return this.config.post204 ? { headers: headers, status: STATUS.NO_CONTENT } : // successful; no content { headers: headers, body: body, status: STATUS.OK }; // successful; return entity } }; // Update existing entity // Can create an entity too if put404 is false. BackendService.prototype.put = function (_a) { var collection = _a.collection, collectionName = _a.collectionName, headers = _a.headers, id = _a.id, req = _a.req, url = _a.url; var item = this.clone(this.getJsonBody(req)); // tslint:disable-next-line:triple-equals if (item.id == undefined) { return this.createErrorResponseOptions(url, STATUS.NOT_FOUND, "Missing '" + collectionName + "' id"); } if (id && id !== item.id) { return this.createErrorResponseOptions(url, STATUS.BAD_REQUEST, "Request for '" + collectionName + "' id does not match item.id"); } else { id = item.id; } var existingIx = this.indexOf(collection, id); var body = this.bodify(item); if (existingIx > -1) { collection[existingIx] = item; return this.config.put204 ? { headers: headers, status: STATUS.NO_CONTENT } : // successful; no content { headers: headers, body: body, status: STATUS.OK }; // successful; return entity } else if (this.config.put404) { // item to update not found; use POST to create new item for this id. return this.createErrorResponseOptions(url, STATUS.NOT_FOUND, "'" + collectionName + "' item with id='" + id + " not found and may not be created with PUT; use POST instead."); } else { // create new item for id not found collection.push(item); return { headers: headers, body: body, status: STATUS.CREATED }; } }; BackendService.prototype.removeById = function (collection, id) { var ix = this.indexOf(collection, id); if (ix > -1) { collection.splice(ix, 1); return true; } return false; }; /** * Tell your in-mem "database" to reset. * returns Observable of the database because resetting it could be async */ BackendService.prototype.resetDb = function (reqInfo) { var _this = this; this.dbReadySubject.next(false); var db = this.inMemDbService.createDb(reqInfo); var db$ = db instanceof Observable ? db : typeof db.then === 'function' ? from(db) : of(db); db$.pipe(first()).subscribe(function (d) { _this.db = d; _this.dbReadySubject.next(true); }); return this.dbReady; }; return BackendService; }()); export { BackendService }; //# sourceMappingURL=backend.service.js.map