UNPKG

15.1 kBPlain TextView Raw
1import { Inject, OpaqueToken, Optional } from '@angular/core';
2import { BaseResponseOptions, Connection, Headers, ReadyState, Request, RequestMethod,
3 Response, ResponseOptions, URLSearchParams } from '@angular/http';
4import { Observable } from 'rxjs/Observable';
5import { Observer } from 'rxjs/Observer';
6import 'rxjs/add/operator/delay';
7
8import { STATUS, STATUS_CODE_INFO } from './http-status-codes';
9
10/**
11* Seed data for in-memory database
12* Must implement InMemoryDbService.
13*/
14export const SEED_DATA = new OpaqueToken('seedData');
15
16/**
17* Interface for a class that creates an in-memory database
18* Safe for consuming service to morph arrays and objects.
19*/
20export interface InMemoryDbService {
21 /**
22 * Creates "database" object hash whose keys are collection names
23 * and whose values are arrays of the collection objects.
24 *
25 * It must be safe to call again and should return new arrays with new objects.
26 * This condition allows InMemoryBackendService to morph the arrays and objects
27 * without touching the original source data.
28 */
29 createDb(): {};
30}
31
32/**
33* Interface for InMemoryBackend configuration options
34*/
35export interface InMemoryBackendConfigArgs {
36 /**
37 * default response options
38 */
39 defaultResponseOptions?: ResponseOptions;
40 /**
41 * delay (in ms) to simulate latency
42 */
43 delay?: number;
44 /**
45 * false (default) if ok when object-to-delete not found; else 404
46 */
47 delete404?: boolean;
48 /**
49 * host for this service
50 */
51 host?: string;
52 /**
53 * root path before any API call
54 */
55 rootPath?: string;
56}
57
58/**
59* InMemoryBackendService configuration options
60* Usage:
61* provide(InMemoryBackendConfig, {useValue: {delay:600}}),
62*/
63export class InMemoryBackendConfig implements InMemoryBackendConfigArgs {
64 constructor(config: InMemoryBackendConfigArgs = {}) {
65 Object.assign(this, {
66 defaultResponseOptions: new BaseResponseOptions(),
67 delay: 500,
68 delete404: false
69 }, config);
70 }
71}
72
73/**
74* Interface for object w/ info about the current request url
75* extracted from an Http Request
76*/
77export interface ReqInfo {
78 req: Request;
79 base: string;
80 collection: any[];
81 collectionName: string;
82 headers: Headers;
83 id: any;
84 query: URLSearchParams;
85 resourceUrl: string;
86}
87
88export const isSuccess = (status: number): boolean => (status >= 200 && status < 300);
89
90/**
91 * Simulate the behavior of a RESTy web api
92 * backed by the simple in-memory data store provided by the injected InMemoryDataService service.
93 * Conforms mostly to behavior described here:
94 * http://www.restapitutorial.com/lessons/httpmethods.html
95 *
96 * ### Usage
97 *
98 * Create InMemoryDataService class the implements InMemoryDataService.
99 * Register both this service and the seed data as in:
100 * ```
101 * // other imports
102 * import { HTTPPROVIDERS, XHRBackend } from 'angular2/http';
103 * import { InMemoryBackendConfig, InMemoryBackendService, SEEDDATA } from '../in-memory-backend/in-memory-backend.service';
104 * import { InMemoryStoryService } from '../api/in-memory-story.service';
105 *
106 * @Component({
107 * selector: ...,
108 * templateUrl: ...,
109 * providers: [
110 * HTTPPROVIDERS,
111 * provide(XHRBackend, { useClass: InMemoryBackendService }),
112 * provide(SEEDDATA, { useClass: InMemoryStoryService }),
113 * provide(InMemoryBackendConfig, { useValue: { delay: 600 } }),
114 * ]
115 * })
116 * export class AppComponent { ... }
117 * ```
118 */
119
120export class InMemoryBackendService {
121
122 protected config: InMemoryBackendConfigArgs = new InMemoryBackendConfig();
123 protected db: {};
124
125 constructor(
126 @Inject(SEED_DATA) private seedData: InMemoryDbService,
127 @Inject(InMemoryBackendConfig) @Optional() config: InMemoryBackendConfigArgs) {
128 this.resetDb();
129
130 let loc = this.getLocation('./');
131 this.config.host = loc.host;
132 this.config.rootPath = loc.pathname;
133 Object.assign(this.config, config);
134 }
135
136 createConnection(req: Request): Connection {
137 let res = this.handleRequest(req);
138
139 let response = new Observable<Response>((responseObserver: Observer<Response>) => {
140 if (isSuccess(res.status)) {
141 responseObserver.next(res);
142 responseObserver.complete();
143 } else {
144 responseObserver.error(res);
145 }
146 return () => { }; // unsubscribe function
147 });
148
149 response = response.delay(this.config.delay || 500);
150 return {
151 readyState: ReadyState.Done,
152 request: req,
153 response
154 };
155 }
156
157 //// protected /////
158
159 /**
160 * Process Request and return an Http Response object
161 * in the manner of a RESTy web api.
162 *
163 * Expect URI pattern in the form :base/:collectionName/:id?
164 * Examples:
165 * // for store with a 'characters' collection
166 * GET api/characters // all characters
167 * GET api/characters/42 // the character with id=42
168 * GET api/characters?name=^j // 'j' is a regex; returns characters whose name contains 'j' or 'J'
169 * GET api/characters.json/42 // ignores the ".json"
170 *
171 * POST commands/resetDb // resets the "database"
172 */
173 protected handleRequest(req: Request) {
174 let {base, collectionName, id, resourceUrl, query} = this.parseUrl(req.url);
175 let collection = this.db[collectionName];
176 let reqInfo: ReqInfo = {
177 req: req,
178 base: base,
179 collection: collection,
180 collectionName: collectionName,
181 headers: new Headers({ 'Content-Type': 'application/json' }),
182 id: this.parseId(collection, id),
183 query: query,
184 resourceUrl: resourceUrl
185 };
186
187 let options: ResponseOptions;
188
189 try {
190 if ('commands' === reqInfo.base.toLowerCase()) {
191 options = this.commands(reqInfo);
192
193 } else if (reqInfo.collection) {
194 switch (req.method) {
195 case RequestMethod.Get:
196 options = this.get(reqInfo);
197 break;
198 case RequestMethod.Post:
199 options = this.post(reqInfo);
200 break;
201 case RequestMethod.Put:
202 options = this.put(reqInfo);
203 break;
204 case RequestMethod.Delete:
205 options = this.delete(reqInfo);
206 break;
207 default:
208 options = this.createErrorResponse(STATUS.METHOD_NOT_ALLOWED, 'Method not allowed');
209 break;
210 }
211
212 } else {
213 options = this.createErrorResponse(STATUS.NOT_FOUND, `Collection '${collectionName}' not found`);
214 }
215
216 } catch (error) {
217 let err = error.message || error;
218 options = this.createErrorResponse(STATUS.INTERNAL_SERVER_ERROR, `${err}`);
219 }
220
221 options = this.setStatusText(options);
222 if (this.config.defaultResponseOptions) {
223 options = this.config.defaultResponseOptions.merge(options);
224 }
225
226 return new Response(options);
227 }
228
229 /**
230 * Apply query/search parameters as a filter over the collection
231 * This impl only supports RegExp queries on string properties of the collection
232 * ANDs the conditions together
233 */
234 protected applyQuery(collection: any[], query: URLSearchParams) {
235 // extract filtering conditions - {propertyName, RegExps) - from query/search parameters
236 let conditions: {name: string, rx: RegExp}[] = [];
237 query.paramsMap.forEach((value: string[], name: string) => {
238 value.forEach(v => conditions.push({name, rx: new RegExp(decodeURI(v), 'i')}));
239 });
240
241 let len = conditions.length;
242 if (!len) { return collection; }
243
244 // AND the RegExp conditions
245 return collection.filter(row => {
246 let ok = true;
247 let i = len;
248 while (ok && i) {
249 i -= 1;
250 let cond = conditions[i];
251 ok = cond.rx.test(row[cond.name]);
252 }
253 return ok;
254 });
255 }
256
257 protected clone(data: any) {
258 return JSON.parse(JSON.stringify(data));
259 }
260
261 /**
262 * When the `base`="commands", the `collectionName` is the command
263 * Example URLs:
264 * commands/resetdb // Reset the "database" to its original state
265 * commands/config (GET) // Return this service's config object
266 * commands/config (!GET) // Update the config (e.g. delay)
267 *
268 * Usage:
269 * http.post('commands/resetdb', null);
270 * http.get('commands/config');
271 * http.post('commands/config', '{"delay":1000}');
272 */
273 protected commands(reqInfo: ReqInfo) {
274 let command = reqInfo.collectionName.toLowerCase();
275 let method = reqInfo.req.method;
276 let options: ResponseOptions;
277
278 switch (command) {
279 case 'resetdb':
280 this.resetDb();
281 options = new ResponseOptions({ status: STATUS.OK });
282 break;
283 case 'config':
284 if (method === RequestMethod.Get) {
285 options = new ResponseOptions({
286 body: this.clone(this.config),
287 status: STATUS.OK
288 });
289 } else {
290 // Be nice ... any other method is a config update
291 let body = JSON.parse(<string>reqInfo.req.text() || '{}');
292 Object.assign(this.config, body);
293 options = new ResponseOptions({ status: STATUS.NO_CONTENT });
294 }
295 break;
296 default:
297 options = this.createErrorResponse(
298 STATUS.INTERNAL_SERVER_ERROR, `Unknown command "${command}"`);
299 }
300 return options;
301 }
302
303 protected createErrorResponse(status: number, message: string) {
304 return new ResponseOptions({
305 body: { 'error': `${message}` },
306 headers: new Headers({ 'Content-Type': 'application/json' }),
307 status: status
308 });
309 }
310
311 protected delete({id, collection, collectionName, headers /*, req */}: ReqInfo) {
312 if (!id) {
313 return this.createErrorResponse(STATUS.NOT_FOUND, `Missing "${collectionName}" id`);
314 }
315 let exists = this.removeById(collection, id);
316 return new ResponseOptions({
317 headers: headers,
318 status: (exists || !this.config.delete404) ? STATUS.NO_CONTENT : STATUS.NOT_FOUND
319 });
320 }
321
322 protected findById(collection: any[], id: number | string) {
323 return collection.find((item: any) => item.id === id);
324 }
325
326 protected genId(collection: any): any {
327 // assumes numeric ids
328 let maxId = 0;
329 collection.reduce((prev: any, item: any) => {
330 maxId = Math.max(maxId, typeof item.id === 'number' ? item.id : maxId);
331 }, null);
332 return maxId + 1;
333 }
334
335 protected get({id, query, collection, collectionName, headers}: ReqInfo) {
336 let data = collection;
337
338 if (id) {
339 data = this.findById(collection, id);
340 } else if (query) {
341 data = this.applyQuery(collection, query);
342 }
343
344 if (!data) {
345 return this.createErrorResponse(STATUS.NOT_FOUND,
346 `'${collectionName}' with id='${id}' not found`);
347 }
348 return new ResponseOptions({
349 body: { data: this.clone(data) },
350 headers: headers,
351 status: STATUS.OK
352 });
353 }
354
355 protected getLocation(href: string) {
356 let l = document.createElement('a');
357 l.href = href;
358 return l;
359 };
360
361 protected indexOf(collection: any[], id: number) {
362 return collection.findIndex((item: any) => item.id === id);
363 }
364
365 // tries to parse id as number if collection item.id is a number.
366 // returns the original param id otherwise.
367 protected parseId(collection: {id: any}[], id: string): any {
368 if (!id) { return null; }
369 let isNumberId = collection[0] && typeof collection[0].id === 'number';
370 if (isNumberId) {
371 let idNum = parseFloat(id);
372 return isNaN(idNum) ? id : idNum;
373 }
374 return id;
375 }
376
377 protected parseUrl(url: string) {
378 try {
379 let loc = this.getLocation(url);
380 let drop = this.config.rootPath.length;
381 let urlRoot = '';
382 if (loc.host !== this.config.host) {
383 // url for a server on a different host!
384 // assume it's collection is actually here too.
385 drop = 1; // the leading slash
386 urlRoot = loc.protocol + '//' + loc.host + '/';
387 }
388 let path = loc.pathname.substring(drop);
389 let [base, collectionName, id] = path.split('/');
390 let resourceUrl = urlRoot + base + '/' + collectionName + '/';
391 [collectionName] = collectionName.split('.'); // ignore anything after the '.', e.g., '.json'
392 let query = loc.search && new URLSearchParams(loc.search.substr(1));
393 return { base, id, collectionName, resourceUrl, query };
394 } catch (err) {
395 let msg = `unable to parse url '${url}'; original error: ${err.message}`;
396 throw new Error(msg);
397 }
398 }
399
400 protected post({collection, /* collectionName, */ headers, id, req, resourceUrl}: ReqInfo) {
401 let item = JSON.parse(<string>req.text());
402 if (!item.id) {
403 item.id = id || this.genId(collection);
404 }
405 // ignore the request id, if any. Alternatively,
406 // could reject request if id differs from item.id
407 id = item.id;
408 let existingIx = this.indexOf(collection, id);
409 if (existingIx > -1) {
410 collection[existingIx] = item;
411 return new ResponseOptions({
412 headers: headers,
413 status: STATUS.NO_CONTENT
414 });
415 } else {
416 collection.push(item);
417 headers.set('Location', resourceUrl + '/' + id);
418 return new ResponseOptions({
419 headers: headers,
420 body: { data: this.clone(item) },
421 status: STATUS.CREATED
422 });
423 }
424 }
425
426 protected put({id, collection, collectionName, headers, req}: ReqInfo) {
427 let item = JSON.parse(<string>req.text());
428 if (!id) {
429 return this.createErrorResponse(STATUS.NOT_FOUND, `Missing '${collectionName}' id`);
430 }
431 if (id !== item.id) {
432 return this.createErrorResponse(STATUS.BAD_REQUEST,
433 `"${collectionName}" id does not match item.id`);
434 }
435 let existingIx = this.indexOf(collection, id);
436 if (existingIx > -1) {
437 collection[existingIx] = item;
438 return new ResponseOptions({
439 headers: headers,
440 status: STATUS.NO_CONTENT // successful; no content
441 });
442 } else {
443 collection.push(item);
444 return new ResponseOptions({
445 body: { data: this.clone(item) },
446 headers: headers,
447 status: STATUS.CREATED
448 });
449 }
450 }
451
452 protected removeById(collection: any[], id: number) {
453 let ix = this.indexOf(collection, id);
454 if (ix > -1) {
455 collection.splice(ix, 1);
456 return true;
457 }
458 return false;
459 }
460
461 /**
462 * Reset the "database" to its original state
463 */
464 protected resetDb() {
465 this.db = this.seedData.createDb();
466 }
467
468 protected setStatusText(options: ResponseOptions) {
469 try {
470 let statusCode = STATUS_CODE_INFO[options.status];
471 options['statusText'] = statusCode ? statusCode.text : 'Unknown Status';
472 return options;
473 } catch (err) {
474 return new ResponseOptions({
475 status: STATUS.INTERNAL_SERVER_ERROR,
476 statusText: 'Invalid Server Operation'
477 });
478 }
479 }
480}
481
\No newline at end of file