/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ import {HttpHeaders} from '@angular/common/http'; import {BehaviorSubject, from, Observable, Observer, of} from 'rxjs'; import {concatMap, first} from 'rxjs/operators'; import {delayResponse} from './delay-response'; import {getStatusText, isSuccess, STATUS} from './http-status-codes'; import {InMemoryBackendConfig, InMemoryBackendConfigArgs, InMemoryDbService, ParsedRequestUrl, parseUri, PassThruBackend, removeTrailingSlash, RequestCore, RequestInfo, RequestInfoUtilities, ResponseOptions, UriInfo} 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 */ export abstract class BackendService { protected config: InMemoryBackendConfigArgs = new InMemoryBackendConfig(); protected db: {[key: string]: any} = {}; protected dbReadySubject: BehaviorSubject|undefined; private passThruBackend: PassThruBackend|undefined; protected requestInfoUtils = this.getRequestInfoUtils(); constructor(protected inMemDbService: InMemoryDbService, config: InMemoryBackendConfigArgs = {}) { const 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); } protected get dbReady(): Observable { if (!this.dbReadySubject) { // first time the service is called. this.dbReadySubject = new BehaviorSubject(false); this.resetDb(); } return this.dbReadySubject.asObservable().pipe(first((r: boolean) => r)); } /** * 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"). */ protected handleRequest(req: RequestCore): Observable { // handle the request when there is an in-memory database return this.dbReady.pipe(concatMap(() => this.handleRequest_(req))); } protected handleRequest_(req: RequestCore): Observable { const url = req.urlWithParams ? req.urlWithParams : req.url; // Try override parser // If no override parser or it returns nothing, use default parser const parser = this.bind('parseRequestUrl'); const parsed: ParsedRequestUrl = (parser && parser(url, this.requestInfoUtils)) || this.parseRequestUrl(url); const collectionName = parsed.collectionName; const collection = this.db[collectionName]; const reqInfo: RequestInfo = { 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 }; let resOptions: ResponseOptions; if (/commands\/?$/i.test(reqInfo.apiBase)) { return this.commands(reqInfo); } const 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. const interceptorResponse = methodInterceptor(reqInfo); if (interceptorResponse) { return interceptorResponse; } } if (this.db[collectionName]) { // request is for a known collection of the InMemoryDbService return this.createResponse$(() => 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$(() => resOptions); } /** * Add configured delay to response observable unless delay === 0 */ protected addDelay(response: Observable): Observable { const 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 */ protected applyQuery(collection: any[], query: Map): any[] { // extract filtering conditions - {propertyName, RegExps) - from query/search parameters const conditions: {name: string, rx: RegExp}[] = []; const caseSensitive = this.config.caseSensitiveSearch ? undefined : 'i'; query.forEach((value: string[], name: string) => { value.forEach(v => conditions.push({name, rx: new RegExp(decodeURI(v), caseSensitive)})); }); const len = conditions.length; if (!len) { return collection; } // AND the RegExp conditions return collection.filter(row => { let ok = true; let i = len; while (ok && i) { i -= 1; const 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 */ protected bind(methodName: string) { const fn = (this.inMemDbService as any)[methodName]; return fn ? fn.bind(this.inMemDbService) as T : undefined; } protected bodify(data: any) { return this.config.dataEncapsulation ? {data} : data; } protected clone(data: any) { return JSON.parse(JSON.stringify(data)); } protected collectionHandler(reqInfo: RequestInfo): ResponseOptions { // const req = reqInfo.req; let resOptions: ResponseOptions; 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 const 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}'); */ protected commands(reqInfo: RequestInfo): Observable { const command = reqInfo.collectionName.toLowerCase(); const method = reqInfo.method; let resOptions: ResponseOptions = {url: reqInfo.url}; switch (command) { case 'resetdb': resOptions.status = STATUS.NO_CONTENT; return this.resetDb(reqInfo).pipe( concatMap(() => this.createResponse$(() => 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 { const 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$(() => resOptions, false /* no latency delay */); } protected createErrorResponseOptions(url: string, status: number, message: string): ResponseOptions { return { body: {error: `${message}`}, url: url, headers: this.createHeaders({'Content-Type': 'application/json'}), status: status }; } /** * Create standard HTTP headers object from hash map of header strings * @param headers */ protected abstract createHeaders(headers: {[index: string]: string}): HttpHeaders; /** * create the function that passes unhandled requests through to the "real" backend. */ protected abstract createPassThruBackend(): PassThruBackend; /** * return a search map from a location query/search string */ protected abstract createQueryMap(search: string): Map; /** * 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 */ protected createResponse$(resOptionsFactory: () => ResponseOptions, withDelay = true): Observable { const resOptions$ = this.createResponseOptions$(resOptionsFactory); let resp$ = this.createResponse$fromResponseOptions$(resOptions$); return withDelay ? this.addDelay(resp$) : resp$; } /** * Create a Response observable from ResponseOptions observable. */ protected abstract createResponse$fromResponseOptions$(resOptions$: Observable): Observable; /** * Create a cold Observable of ResponseOptions. * @param resOptionsFactory - creates ResponseOptions when observable is subscribed */ protected createResponseOptions$(resOptionsFactory: () => ResponseOptions): Observable { return new Observable((responseObserver: Observer) => { let resOptions: ResponseOptions; try { resOptions = resOptionsFactory(); } catch (error) { const err = error.message || error; resOptions = this.createErrorResponseOptions('', STATUS.INTERNAL_SERVER_ERROR, `${err}`); } const status = resOptions.status; try { resOptions.statusText = status != null ? getStatusText(status) : undefined; } catch (e) { /* ignore failure */ } if (status != null && isSuccess(status)) { responseObserver.next(resOptions); responseObserver.complete(); } else { responseObserver.error(resOptions); } return () => {}; // unsubscribe function }); } protected delete({collection, collectionName, headers, id, url}: RequestInfo): ResponseOptions { if (id == null) { return this.createErrorResponseOptions( url, STATUS.NOT_FOUND, `Missing "${collectionName}" id`); } const 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 */ protected findById(collection: T[], id: any): T|undefined { return collection.find((item: T) => 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 */ protected genId(collection: T[], collectionName: string): any { const genId = this.bind('genId'); if (genId) { const id = genId(collection, collectionName); if (id != null) { 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 */ protected genIdDefault(collection: T[], collectionName: string): any { if (!this.isCollectionIdNumeric(collection, collectionName)) { throw new Error(`Collection '${ collectionName}' id type is non-numeric or unknown. Can only generate numeric ids.`); } let maxId = 0; collection.reduce((prev: any, item: any) => { maxId = Math.max(maxId, typeof item.id === 'number' ? item.id : maxId); }, undefined); return maxId + 1; } protected get({collection, collectionName, headers, id, query, url}: RequestInfo): ResponseOptions { let data = collection; if (id != null && 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 JSON body from the request object */ protected abstract getJsonBody(req: any): any; /** * Get location info from a url, even on server where `document` is not defined */ protected getLocation(url: string): UriInfo { if (!url.startsWith('http')) { // get the document iff running in browser const doc = (typeof document === 'undefined') ? undefined : document; // add host info to url before parsing. Use a fake host when not in browser. const 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. */ protected getPassThruBackend(): PassThruBackend { return this.passThruBackend ? this.passThruBackend : this.passThruBackend = this.createPassThruBackend(); } /** * Get utility methods from this service instance. * Useful within an HTTP method override */ protected getRequestInfoUtils(): RequestInfoUtilities { return { createResponse$: this.createResponse$.bind(this), findById: this.findById.bind(this), isCollectionIdNumeric: this.isCollectionIdNumeric.bind(this), getConfig: () => this.config, getDb: () => this.db, getJsonBody: this.getJsonBody.bind(this), getLocation: this.getLocation.bind(this), getPassThruBackend: this.getPassThruBackend.bind(this), parseRequestUrl: this.parseRequestUrl.bind(this), }; } /** * return canonical HTTP method name (lowercase) from the request object * e.g. (req.method || 'get').toLowerCase(); * @param req - request object from the http call * */ protected abstract getRequestMethod(req: any): string; protected indexOf(collection: any[], id: number) { return collection.findIndex((item: any) => item.id === id); } /** Parse the id as a number. Return original value if not a number. */ protected parseId(collection: any[], collectionName: string, id: string): any { 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; } const 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 * */ protected isCollectionIdNumeric(collection: T[], collectionName: string): boolean { // 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'] */ protected parseRequestUrl(url: string): ParsedRequestUrl { try { const loc = this.getLocation(url); let drop = (this.config.rootPath || '').length; let 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 + '/'; } const path = loc.path.substring(drop); const pathSegments = path.split('/'); let segmentIndex = 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. let apiBase: string; if (this.config.apiBase == null) { apiBase = pathSegments[segmentIndex++]; } else { apiBase = removeTrailingSlash(this.config.apiBase.trim()); if (apiBase) { segmentIndex = apiBase.split('/').length; } else { segmentIndex = 0; // no api base at all; unwise but allowed. } } apiBase += '/'; let collectionName = pathSegments[segmentIndex++]; // ignore anything after a '.' (e.g.,the "json" in "customers.json") collectionName = collectionName && collectionName.split('.')[0]; const id = pathSegments[segmentIndex++]; const query = this.createQueryMap(loc.query); const resourceUrl = urlRoot + apiBase + collectionName + '/'; return {apiBase, collectionName, id, query, resourceUrl}; } catch (err) { const 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. protected post({collection, collectionName, headers, id, req, resourceUrl, url}: RequestInfo): ResponseOptions { const item = this.clone(this.getJsonBody(req)); if (item.id == null) { try { item.id = id || this.genId(collection, collectionName); } catch (err) { const emsg: string = err.message || ''; if (/id type is non-numeric/.test(emsg)) { return this.createErrorResponseOptions(url, STATUS.UNPROCESSABLE_ENTRY, emsg); } else { 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; } const existingIx = this.indexOf(collection, id); const body = this.bodify(item); if (existingIx === -1) { collection.push(item); headers.set('Location', resourceUrl + '/' + id); return {headers, 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, status: STATUS.NO_CONTENT} : // successful; no content {headers, body, status: STATUS.OK}; // successful; return entity } } // Update existing entity // Can create an entity too if put404 is false. protected put({collection, collectionName, headers, id, req, url}: RequestInfo): ResponseOptions { const item = this.clone(this.getJsonBody(req)); if (item.id == null) { 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; } const existingIx = this.indexOf(collection, id); const body = this.bodify(item); if (existingIx > -1) { collection[existingIx] = item; return this.config.put204 ? {headers, status: STATUS.NO_CONTENT} : // successful; no content {headers, 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, body, status: STATUS.CREATED}; } } protected removeById(collection: any[], id: number) { const 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 */ protected resetDb(reqInfo?: RequestInfo): Observable { this.dbReadySubject && this.dbReadySubject.next(false); const db = this.inMemDbService.createDb(reqInfo); const db$ = db instanceof Observable ? db : typeof (db as any).then === 'function' ? from(db as Promise) : of(db); db$.pipe(first()).subscribe((d: {}) => { this.db = d; this.dbReadySubject && this.dbReadySubject.next(true); }); return this.dbReady; } }