diff --git a/index.ts b/index.ts index 19e0c32..1afb143 100644 --- a/index.ts +++ b/index.ts @@ -6,8 +6,6 @@ import { HttpBackend } from './src/common-http/src/backend'; import { Type } from 'injection-js/facade/type'; import { HTTP_INTERCEPTORS } from './src/common-http/src/interceptor'; -require('zone.js'); - export const HTTP_CLIENT_PROVIDERS: Provider[] = [ ...SERVER_HTTP_PROVIDERS, HttpClient, diff --git a/package.json b/package.json index 6ce6757..c0c8f36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "node-angular-http-client", - "version": "1.1.7", + "version": "1.1.8", "description": "The Angular 4.3 HttpClient for node.js", "keywords": [ "Angular", @@ -12,24 +12,27 @@ "HTTP", "fetch" ], - "main": "dist/index.js", - "types": "dist/index.d.ts", + "main": "index.js", + "types": "index.d.ts", "author": "Ionut Costica ", "license": "MIT", "repository": "https://github.com/souldreamer/http-client.git", "dependencies": { "injection-js": "^2.1.0", "rxjs": "^5.5.2", - "xhr2-cookies": "^1.1.0", + "xhr2-cookies": "^1.1.0" + }, + "peerDependencies": { "zone.js": "^0.8.18" }, "devDependencies": { "@types/node": "^8.0.51", "ts-loader": "^3.1.1", "typescript": "^2.6.1", - "webpack": "^3.8.1" + "webpack": "^3.8.1", + "rimraf": "^2.6.0" }, "scripts": { - "prepare": "tsc" + "prepare": "rimraf dist && tsc && cp package.json dist" } } diff --git a/src/common-http-testing/index.ts b/src/common-http-testing/index.ts new file mode 100644 index 0000000..f93e7c3 --- /dev/null +++ b/src/common-http-testing/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google Inc. 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 + */ + +export * from './public_api'; diff --git a/src/common-http-testing/public_api.ts b/src/common-http-testing/public_api.ts new file mode 100644 index 0000000..eb3e631 --- /dev/null +++ b/src/common-http-testing/public_api.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright Google Inc. 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 + */ + +export {HttpTestingController, RequestMatch} from './src/api'; +export {TESTING_HTTP_PROVIDERS} from './src/module'; +export {TestRequest} from './src/request'; diff --git a/src/common-http-testing/src/api.ts b/src/common-http-testing/src/api.ts new file mode 100644 index 0000000..4b7be58 --- /dev/null +++ b/src/common-http-testing/src/api.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright Google Inc. 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 {HttpRequest} from '../../common-http'; +import {TestRequest} from './request'; + +/** + * Defines a matcher for requests based on URL, method, or both. + * + * @stable + */ +export interface RequestMatch { + method?: string; + url?: string; +} + +/** + * Controller to be injected into tests, that allows for mocking and flushing + * of requests. + * + * @stable + */ +export abstract class HttpTestingController { + /** + * Search for requests that match the given parameter, without any expectations. + */ + abstract match(match: string|RequestMatch|((req: HttpRequest) => boolean)): TestRequest[]; + + /** + * Expect that a single request has been made which matches the given URL, and return its + * mock. + * + * If no such request has been made, or more than one such request has been made, fail with an + * error message including the given request description, if any. + */ + abstract expectOne(url: string, description?: string): TestRequest; + + /** + * Expect that a single request has been made which matches the given parameters, and return + * its mock. + * + * If no such request has been made, or more than one such request has been made, fail with an + * error message including the given request description, if any. + */ + abstract expectOne(params: RequestMatch, description?: string): TestRequest; + + /** + * Expect that a single request has been made which matches the given predicate function, and + * return its mock. + * + * If no such request has been made, or more than one such request has been made, fail with an + * error message including the given request description, if any. + */ + abstract expectOne(matchFn: ((req: HttpRequest) => boolean), description?: string): + TestRequest; + + /** + * Expect that a single request has been made which matches the given condition, and return + * its mock. + * + * If no such request has been made, or more than one such request has been made, fail with an + * error message including the given request description, if any. + */ + abstract expectOne( + match: string|RequestMatch|((req: HttpRequest) => boolean), + description?: string): TestRequest; + + /** + * Expect that no requests have been made which match the given URL. + * + * If a matching request has been made, fail with an error message including the given request + * description, if any. + */ + abstract expectNone(url: string, description?: string): void; + + /** + * Expect that no requests have been made which match the given parameters. + * + * If a matching request has been made, fail with an error message including the given request + * description, if any. + */ + abstract expectNone(params: RequestMatch, description?: string): void; + + /** + * Expect that no requests have been made which match the given predicate function. + * + * If a matching request has been made, fail with an error message including the given request + * description, if any. + */ + abstract expectNone(matchFn: ((req: HttpRequest) => boolean), description?: string): void; + + /** + * Expect that no requests have been made which match the given condition. + * + * If a matching request has been made, fail with an error message including the given request + * description, if any. + */ + abstract expectNone( + match: string|RequestMatch|((req: HttpRequest) => boolean), description?: string): void; + + /** + * Verify that no unmatched requests are outstanding. + * + * If any requests are outstanding, fail with an error message indicating which requests were not + * handled. + * + * If `ignoreCancelled` is not set (the default), `verify()` will also fail if cancelled requests + * were not explicitly matched. + */ + abstract verify(opts?: {ignoreCancelled?: boolean}): void; +} diff --git a/src/common-http-testing/src/backend.ts b/src/common-http-testing/src/backend.ts new file mode 100644 index 0000000..328aab2 --- /dev/null +++ b/src/common-http-testing/src/backend.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright Google Inc. 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 {HttpBackend, HttpEvent, HttpEventType, HttpRequest} from '../../common-http'; +import {Injectable} from 'injection-js'; +import {Observable} from 'rxjs/Observable'; +import {Observer} from 'rxjs/Observer'; +import {startWith} from 'rxjs/operator/startWith'; + +import {HttpTestingController, RequestMatch} from './api'; +import {TestRequest} from './request'; + + +/** + * A testing backend for `HttpClient` which both acts as an `HttpBackend` + * and as the `HttpTestingController`. + * + * `HttpClientTestingBackend` works by keeping a list of all open requests. + * As requests come in, they're added to the list. Users can assert that specific + * requests were made and then flush them. In the end, a verify() method asserts + * that no unexpected requests were made. + * + * @stable + */ +@Injectable() +export class HttpClientTestingBackend implements HttpBackend, HttpTestingController { + /** + * List of pending requests which have not yet been expected. + */ + private open: TestRequest[] = []; + + /** + * Handle an incoming request by queueing it in the list of open requests. + */ + handle(req: HttpRequest): Observable> { + console.log("HttpClient: ", req.url); + return new Observable((observer: Observer) => { + const testReq = new TestRequest(req, observer); + this.open.push(testReq); + observer.next({ type: HttpEventType.Sent } as HttpEvent); + return () => { testReq._cancelled = true; }; + }); + } + + /** + * Helper function to search for requests in the list of open requests. + */ + private _match(match: string|RequestMatch|((req: HttpRequest) => boolean)): TestRequest[] { + if (typeof match === 'string') { + return this.open.filter(testReq => testReq.request.urlWithParams === match); + } else if (typeof match === 'function') { + return this.open.filter(testReq => match(testReq.request)); + } else { + return this.open.filter( + testReq => (!match.method || testReq.request.method === match.method.toUpperCase()) && + (!match.url || testReq.request.urlWithParams === match.url)); + } + } + + /** + * Search for requests in the list of open requests, and return all that match + * without asserting anything about the number of matches. + */ + match(match: string|RequestMatch|((req: HttpRequest) => boolean)): TestRequest[] { + const results = this._match(match); + results.forEach(result => { + const index = this.open.indexOf(result); + if (index !== -1) { + this.open.splice(index, 1); + } + }); + return results; + } + + /** + * Expect that a single outstanding request matches the given matcher, and return + * it. + * + * Requests returned through this API will no longer be in the list of open requests, + * and thus will not match twice. + */ + expectOne(match: string|RequestMatch|((req: HttpRequest) => boolean), description?: string): + TestRequest { + description = description || this.descriptionFromMatcher(match); + const matches = this.match(match); + if (matches.length > 1) { + throw new Error( + `Expected one matching request for criteria "${description}", found ${matches.length} requests.`); + } + if (matches.length === 0) { + throw new Error(`Expected one matching request for criteria "${description}", found none.`); + } + return matches[0]; + } + + /** + * Expect that no outstanding requests match the given matcher, and throw an error + * if any do. + */ + expectNone(match: string|RequestMatch|((req: HttpRequest) => boolean), description?: string): + void { + description = description || this.descriptionFromMatcher(match); + const matches = this.match(match); + if (matches.length > 0) { + throw new Error( + `Expected zero matching requests for criteria "${description}", found ${matches.length}.`); + } + } + + /** + * Validate that there are no outstanding requests. + */ + verify(opts: {ignoreCancelled?: boolean} = {}): void { + let open = this.open; + // It's possible that some requests may be cancelled, and this is expected. + // The user can ask to ignore open requests which have been cancelled. + if (opts.ignoreCancelled) { + open = open.filter(testReq => !testReq.cancelled); + } + if (open.length > 0) { + // Show the methods and URLs of open requests in the error, for convenience. + const requests = open.map(testReq => { + const url = testReq.request.urlWithParams.split('?')[0]; + const method = testReq.request.method; + return `${method} ${url}`; + }) + .join(', '); + throw new Error(`Expected no open requests, found ${open.length}: ${requests}`); + } + } + + private descriptionFromMatcher(matcher: string|RequestMatch| + ((req: HttpRequest) => boolean)): string { + if (typeof matcher === 'string') { + return `Match URL: ${matcher}`; + } else if (typeof matcher === 'object') { + const method = matcher.method || '(any)'; + const url = matcher.url || '(any)'; + return `Match method: ${method}, URL: ${url}`; + } else { + return `Match by function: ${matcher.name}`; + } + } +} diff --git a/src/common-http-testing/src/module.ts b/src/common-http-testing/src/module.ts new file mode 100644 index 0000000..fceceda --- /dev/null +++ b/src/common-http-testing/src/module.ts @@ -0,0 +1,20 @@ +import { HTTP_CLIENT_PROVIDERS } from './../../../index'; +import { ReflectiveInjector } from 'injection-js'; +/** + * @license + * Copyright Google Inc. 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 {HttpBackend} from '../../common-http'; + +import {HttpTestingController} from './api'; +import {HttpClientTestingBackend} from './backend'; + +export const TESTING_HTTP_PROVIDERS = [ + HttpClientTestingBackend, + {provide: HttpBackend, useExisting: HttpClientTestingBackend}, + {provide: HttpTestingController, useExisting: HttpClientTestingBackend}, +]; diff --git a/src/common-http-testing/src/request.ts b/src/common-http-testing/src/request.ts new file mode 100644 index 0000000..a0a56a6 --- /dev/null +++ b/src/common-http-testing/src/request.ts @@ -0,0 +1,211 @@ +/** + * @license + * Copyright Google Inc. 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 {HttpErrorResponse, HttpEvent, HttpEventType, HttpHeaders, HttpRequest, HttpResponse} from '../../common-http'; +import {Observer} from 'rxjs/Observer'; + +/** + * A mock requests that was received and is ready to be answered. + * + * This interface allows access to the underlying `HttpRequest`, and allows + * responding with `HttpEvent`s or `HttpErrorResponse`s. + * + * @stable + */ +export class TestRequest { + /** + * Whether the request was cancelled after it was sent. + */ + get cancelled(): boolean { return this._cancelled; } + + /** + * @internal set by `HttpClientTestingBackend` + */ + _cancelled = false; + + constructor(public request: HttpRequest, private observer: Observer>) {} + + /** + * Resolve the request by returning a body plus additional HTTP information (such as response + * headers) if provided. + * + * Both successful and unsuccessful responses can be delivered via `flush()`. + */ + flush(body: ArrayBuffer|Blob|string|number|Object|(string|number|Object|null)[]|null, opts: { + headers?: HttpHeaders | {[name: string]: string | string[]}, + status?: number, + statusText?: string, + } = {}): void { + if (this.cancelled) { + throw new Error(`Cannot flush a cancelled request.`); + } + const url = this.request.urlWithParams; + const headers = + (opts.headers instanceof HttpHeaders) ? opts.headers : new HttpHeaders(opts.headers); + body = _maybeConvertBody(this.request.responseType, body); + let statusText: string|undefined = opts.statusText; + let status: number = opts.status !== undefined ? opts.status : 200; + if (opts.status === undefined) { + if (body === null) { + status = 204; + statusText = statusText || 'No Content'; + } else { + statusText = statusText || 'OK'; + } + } + if (statusText === undefined) { + throw new Error('statusText is required when setting a custom status.'); + } + if (status >= 200 && status < 300) { + this.observer.next(new HttpResponse({body, headers, status, statusText, url})); + this.observer.complete(); + } else { + this.observer.error(new HttpErrorResponse({error: body, headers, status, statusText, url})); + } + } + + /** + * Resolve the request by returning an `ErrorEvent` (e.g. simulating a network failure). + */ + error(error: ErrorEvent, opts: { + headers?: HttpHeaders | {[name: string]: string | string[]}, + status?: number, + statusText?: string, + } = {}): void { + if (this.cancelled) { + throw new Error(`Cannot return an error for a cancelled request.`); + } + if (opts.status && opts.status >= 200 && opts.status < 300) { + throw new Error(`error() called with a successful status.`); + } + const headers = + (opts.headers instanceof HttpHeaders) ? opts.headers : new HttpHeaders(opts.headers); + this.observer.error(new HttpErrorResponse({ + error, + headers, + status: opts.status || 0, + statusText: opts.statusText || '', + url: this.request.urlWithParams, + })); + } + + /** + * Deliver an arbitrary `HttpEvent` (such as a progress event) on the response stream for this + * request. + */ + event(event: HttpEvent): void { + if (this.cancelled) { + throw new Error(`Cannot send events to a cancelled request.`); + } + this.observer.next(event); + } +} + + +/** + * Helper function to convert a response body to an ArrayBuffer. + */ +function _toArrayBufferBody( + body: ArrayBuffer | Blob | string | number | Object | + (string | number | Object | null)[]): ArrayBuffer { + if (typeof ArrayBuffer === 'undefined') { + throw new Error('ArrayBuffer responses are not supported on this platform.'); + } + if (body instanceof ArrayBuffer) { + return body; + } + throw new Error('Automatic conversion to ArrayBuffer is not supported for response type.'); +} + +/** + * Helper function to convert a response body to a Blob. + */ +function _toBlob( + body: ArrayBuffer | Blob | string | number | Object | + (string | number | Object | null)[]): Blob { + if (typeof Blob === 'undefined') { + throw new Error('Blob responses are not supported on this platform.'); + } + if (body instanceof Blob) { + return body; + } + if (ArrayBuffer && body instanceof ArrayBuffer) { + return new Blob([body]); + } + throw new Error('Automatic conversion to Blob is not supported for response type.'); +} + +/** + * Helper function to convert a response body to JSON data. + */ +function _toJsonBody( + body: ArrayBuffer | Blob | string | number | Object | (string | number | Object | null)[], + format: string = 'JSON'): Object|string|number|(Object | string | number)[] { + if (typeof ArrayBuffer !== 'undefined' && body instanceof ArrayBuffer) { + throw new Error(`Automatic conversion to ${format} is not supported for ArrayBuffers.`); + } + if (typeof Blob !== 'undefined' && body instanceof Blob) { + throw new Error(`Automatic conversion to ${format} is not supported for Blobs.`); + } + if (typeof body === 'string' || typeof body === 'number' || typeof body === 'object' || + Array.isArray(body)) { + return body; + } + throw new Error(`Automatic conversion to ${format} is not supported for response type.`); +} + +/** + * Helper function to convert a response body to a string. + */ +function _toTextBody( + body: ArrayBuffer | Blob | string | number | Object | + (string | number | Object | null)[]): string { + if (typeof body === 'string') { + return body; + } + if (typeof ArrayBuffer !== 'undefined' && body instanceof ArrayBuffer) { + throw new Error('Automatic conversion to text is not supported for ArrayBuffers.'); + } + if (typeof Blob !== 'undefined' && body instanceof Blob) { + throw new Error('Automatic conversion to text is not supported for Blobs.'); + } + return JSON.stringify(_toJsonBody(body, 'text')); +} + +/** + * Convert a response body to the requested type. + */ +function _maybeConvertBody( + responseType: string, body: ArrayBuffer | Blob | string | number | Object | + (string | number | Object | null)[] | null): ArrayBuffer|Blob|string|number|Object| + (string | number | Object | null)[]|null { + switch (responseType) { + case 'arraybuffer': + if (body === null) { + return null; + } + return _toArrayBufferBody(body); + case 'blob': + if (body === null) { + return null; + } + return _toBlob(body); + case 'json': + if (body === null) { + return 'null'; + } + return _toJsonBody(body); + case 'text': + if (body === null) { + return null; + } + return _toTextBody(body); + default: + throw new Error(`Unsupported responseType: ${responseType}`); + } +} diff --git a/src/http.ts b/src/http.ts index 39f5b12..056a430 100644 --- a/src/http.ts +++ b/src/http.ts @@ -192,6 +192,6 @@ export const SERVER_HTTP_PROVIDERS: Provider[] = [ { provide: HttpHandler, useFactory: zoneWrappedInterceptingHandler, - deps: [HttpBackend, [new Optional(), HTTP_INTERCEPTORS]] + deps: [HttpBackend] } ]; diff --git a/testing.ts b/testing.ts new file mode 100644 index 0000000..3c4e204 --- /dev/null +++ b/testing.ts @@ -0,0 +1 @@ +export * from './src/common-http-testing'; diff --git a/tsconfig.json b/tsconfig.json index 884e708..7f4eaa1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,7 @@ }, "files": [ "index.ts", + "testing.ts", "node_modules/zone.js/dist/zone.js.d.ts" ] }