1 | import { Inject, OpaqueToken, Optional } from '@angular/core';
|
2 | import { BaseResponseOptions, Connection, Headers, ReadyState, Request, RequestMethod,
|
3 | Response, ResponseOptions, URLSearchParams } from '@angular/http';
|
4 | import { Observable } from 'rxjs/Observable';
|
5 | import { Observer } from 'rxjs/Observer';
|
6 | import 'rxjs/add/operator/delay';
|
7 |
|
8 | import { STATUS, STATUS_CODE_INFO } from './http-status-codes';
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 | export const SEED_DATA = new OpaqueToken('seedData');
|
15 |
|
16 |
|
17 |
|
18 |
|
19 |
|
20 | export interface InMemoryDbService {
|
21 | |
22 |
|
23 |
|
24 |
|
25 |
|
26 |
|
27 |
|
28 |
|
29 | createDb(): {};
|
30 | }
|
31 |
|
32 |
|
33 |
|
34 |
|
35 | export interface InMemoryBackendConfigArgs {
|
36 | |
37 |
|
38 |
|
39 | defaultResponseOptions?: ResponseOptions;
|
40 | |
41 |
|
42 |
|
43 | delay?: number;
|
44 | |
45 |
|
46 |
|
47 | delete404?: boolean;
|
48 | |
49 |
|
50 |
|
51 | host?: string;
|
52 | |
53 |
|
54 |
|
55 | rootPath?: string;
|
56 | }
|
57 |
|
58 |
|
59 |
|
60 |
|
61 |
|
62 |
|
63 | export 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 |
|
75 |
|
76 |
|
77 | export 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 |
|
88 | export const isSuccess = (status: number): boolean => (status >= 200 && status < 300);
|
89 |
|
90 |
|
91 |
|
92 |
|
93 |
|
94 |
|
95 |
|
96 |
|
97 |
|
98 |
|
99 |
|
100 |
|
101 |
|
102 |
|
103 |
|
104 |
|
105 |
|
106 |
|
107 |
|
108 |
|
109 |
|
110 |
|
111 |
|
112 |
|
113 |
|
114 |
|
115 |
|
116 |
|
117 |
|
118 |
|
119 |
|
120 | export 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 () => { };
|
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 }: 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, 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
|
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 |