diff --git a/aio/content/examples/.DS_Store b/aio/content/examples/.DS_Store new file mode 100644 index 0000000000..55dd692a1c Binary files /dev/null and b/aio/content/examples/.DS_Store differ diff --git a/aio/content/examples/http/e2e-spec.ts b/aio/content/examples/http/e2e-spec.ts deleted file mode 100644 index a85c7489f8..0000000000 --- a/aio/content/examples/http/e2e-spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -'use strict'; // necessary for es6 output in node - -import { browser, element, by } from 'protractor'; - -describe('Server Communication', function () { - - beforeAll(function () { - browser.get(''); - }); - - describe('Tour of Heroes (Observable)', function () { - - let initialHeroCount = 4; - let newHeroName = 'Mr. IQ'; - let heroCountAfterAdd = 5; - - let heroListComp = element(by.tagName('hero-list')); - let addButton = heroListComp.element(by.tagName('button')); - let heroTags = heroListComp.all(by.tagName('li')); - let heroNameInput = heroListComp.element(by.tagName('input')); - - it('should exist', function() { - expect(heroListComp).toBeDefined(' must exist'); - }); - - it('should display ' + initialHeroCount + ' heroes after init', function () { - expect(heroTags.count()).toBe(initialHeroCount); - }); - - it('should not add hero with empty name', function () { - expect(addButton).toBeDefined('"Add Hero" button must be defined'); - addButton.click().then(function() { - expect(heroTags.count()).toBe(initialHeroCount, 'No new hero should be added'); - }); - }); - - it('should add a new hero to the list', function () { - expect(heroNameInput).toBeDefined(' for hero name must exist'); - expect(addButton).toBeDefined('"Add Hero" button must be defined'); - heroNameInput.sendKeys(newHeroName); - addButton.click().then(function() { - expect(heroTags.count()).toBe(heroCountAfterAdd, 'A new hero should be added'); - let newHeroInList = heroTags.get(heroCountAfterAdd - 1).getText(); - expect(newHeroInList).toBe(newHeroName, 'The hero should be added to the end of the list'); - }); - }); - }); - - describe('Wikipedia Demo', function () { - - it('should initialize the demo with empty result list', function () { - let myWikiComp = element(by.tagName('my-wiki')); - expect(myWikiComp).toBeDefined(' must exist'); - let resultList = myWikiComp.all(by.tagName('li')); - expect(resultList.count()).toBe(0, 'result list must be empty'); - }); - - describe('Fetches after each keystroke', function () { - it('should fetch results after "B"', function(done: any) { - testForRefreshedResult('B', done); - }); - - it('should fetch results after "Ba"', function(done: any) { - testForRefreshedResult('a', done); - }); - - it('should fetch results after "Bas"', function(done: any) { - testForRefreshedResult('s', done); - }); - - it('should fetch results after "Basic"', function(done: any) { - testForRefreshedResult('ic', done); - }); - }); - - function testForRefreshedResult(keyPressed: string, done: () => void) { - testForResult('my-wiki', keyPressed, false, done); - } - }); - - describe('Smarter Wikipedia Demo', function () { - - it('should initialize the demo with empty result list', function () { - let myWikiSmartComp = element(by.tagName('my-wiki-smart')); - expect(myWikiSmartComp).toBeDefined(' must exist'); - let resultList = myWikiSmartComp.all(by.tagName('li')); - expect(resultList.count()).toBe(0, 'result list must be empty'); - }); - - it('should fetch results after "Java"', function(done: any) { - testForNewResult('Java', done); - }); - - it('should fetch results after "JavaS"', function(done: any) { - testForStaleResult('S', done); - }); - - it('should fetch results after "JavaSc"', function(done: any) { - testForStaleResult('c', done); - }); - - it('should fetch results after "JavaScript"', function(done: any) { - testForStaleResult('ript', done); - }); - - - function testForNewResult(keyPressed: string, done: () => void) { - testForResult('my-wiki-smart', keyPressed, false, done); - } - - function testForStaleResult(keyPressed: string, done: () => void) { - testForResult('my-wiki-smart', keyPressed, true, done); - } - - }); - - function testForResult(componentTagName: string, keyPressed: string, hasListBeforeSearch: boolean, done: () => void) { - let searchWait = 1000; // Wait for wikipedia but not so long that tests timeout - let wikiComponent = element(by.tagName(componentTagName)); - expect(wikiComponent).toBeDefined('<' + componentTagName + '> must exist'); - let searchBox = wikiComponent.element(by.tagName('input')); - expect(searchBox).toBeDefined(' for search must exist'); - - searchBox.sendKeys(keyPressed).then(function () { - let resultList = wikiComponent.all(by.tagName('li')); - - if (hasListBeforeSearch) { - expect(resultList.count()).toBeGreaterThan(0, 'result list should not be empty before search'); - } - - setTimeout(function() { - expect(resultList.count()).toBeGreaterThan(0, 'result list should not be empty after search'); - done(); - }, searchWait); - }); - } - -}); diff --git a/aio/content/examples/http/e2e/app.e2e-spec.ts b/aio/content/examples/http/e2e/app.e2e-spec.ts new file mode 100644 index 0000000000..9baf2cdc65 --- /dev/null +++ b/aio/content/examples/http/e2e/app.e2e-spec.ts @@ -0,0 +1,139 @@ +import { browser, element, by, ElementFinder } from 'protractor'; +import { resolve } from 'path'; + +const page = { + configClearButton: element.all(by.css('app-config > div button')).get(2), + configErrorButton: element.all(by.css('app-config > div button')).get(3), + configErrorMessage: element(by.css('app-config p')), + configGetButton: element.all(by.css('app-config > div button')).get(0), + configGetResponseButton: element.all(by.css('app-config > div button')).get(1), + configSpan: element(by.css('app-config span')), + downloadButton: element.all(by.css('app-downloader button')).get(0), + downloadClearButton: element.all(by.css('app-downloader button')).get(1), + downloadMessage: element(by.css('app-downloader p')), + heroesListAddButton: element.all(by.css('app-heroes > div button')).get(0), + heroesListInput: element(by.css('app-heroes > div input')), + heroesListSearchButton: element.all(by.css('app-heroes > div button')).get(1), + heroesListItems: element.all(by.css('app-heroes ul li')), + logClearButton: element(by.css('app-messages button')), + logList: element(by.css('app-messages ol')), + logListItems: element.all(by.css('app-messages ol li')), + searchInput: element(by.css('app-package-search input#name')), + searchListItems: element.all(by.css('app-package-search li')), + uploadInput: element(by.css('app-uploader input')), + uploadMessage: element(by.css('app-uploader p')) +}; + +let checkLogForMessage = (message: string) => { + expect(page.logList.getText()).toContain(message); +}; + +describe('Http Tests', function() { + beforeEach(() => { + browser.get(''); + }); + + describe('Heroes', () => { + it('retrieves the list of heroes at startup', () => { + expect(page.heroesListItems.count()).toBe(4); + expect(page.heroesListItems.get(0).getText()).toContain('Mr. Nice'); + checkLogForMessage('GET "api/heroes"'); + }); + + it('makes a POST to add a new hero', () => { + page.heroesListInput.sendKeys('Magneta'); + page.heroesListAddButton.click(); + expect(page.heroesListItems.count()).toBe(5); + checkLogForMessage('POST "api/heroes"'); + }); + + it('makes a GET to search for a hero', () => { + page.heroesListInput.sendKeys('Celeritas'); + page.heroesListSearchButton.click(); + checkLogForMessage('GET "api/heroes?name=Celeritas"'); + }); + }); + + describe('Messages', () => { + it('can clear the logs', () => { + expect(page.logListItems.count()).toBe(1); + page.logClearButton.click(); + expect(page.logListItems.count()).toBe(0); + }); + }); + + describe('Configuration', () => { + it('can fetch the configuration JSON file', () => { + page.configGetButton.click(); + checkLogForMessage('GET "assets/config.json"'); + expect(page.configSpan.getText()).toContain('Heroes API URL is "api/heroes"'); + expect(page.configSpan.getText()).toContain('Textfile URL is "assets/textfile.txt"'); + }); + + it('can fetch the configuration JSON file with headers', () => { + page.configGetResponseButton.click(); + checkLogForMessage('GET "assets/config.json"'); + expect(page.configSpan.getText()).toContain('Response headers:'); + expect(page.configSpan.getText()).toContain('content-type: application/json; charset=UTF-8'); + }); + + it('can clear the configuration log', () => { + page.configGetResponseButton.click(); + expect(page.configSpan.getText()).toContain('Response headers:'); + page.configClearButton.click(); + expect(page.configSpan.isPresent()).toBeFalsy(); + }); + + it('throws an error for a non valid url', () => { + page.configErrorButton.click(); + checkLogForMessage('GET "not/a/real/url"'); + expect(page.configErrorMessage.getText()).toContain('"Something bad happened; please try again later."'); + }); + }); + + describe('Download', () => { + it('can download a txt file and show it', () => { + page.downloadButton.click(); + checkLogForMessage('DownloaderService downloaded "assets/textfile.txt"'); + checkLogForMessage('GET "assets/textfile.txt"'); + expect(page.downloadMessage.getText()).toContain('Contents: "This is the downloaded text file "'); + }); + + it('can clear the log of the download', () => { + page.downloadButton.click(); + expect(page.downloadMessage.getText()).toContain('Contents: "This is the downloaded text file "'); + page.downloadClearButton.click(); + expect(page.downloadMessage.isPresent()).toBeFalsy(); + }); + }); + + describe('Upload', () => { + it('can upload a file', () => { + const filename = 'app.po.ts'; + const url = resolve(__dirname, filename); + page.uploadInput.sendKeys(url); + checkLogForMessage('POST "/upload/file" succeeded in'); + expect(page.uploadMessage.getText()).toContain( + `File "${filename}" was completely uploaded!`); + }); + }); + + describe('PackageSearch', () => { + it('can search for npm package and find in cache', () => { + const packageName = 'angular'; + page.searchInput.sendKeys(packageName); + checkLogForMessage( + 'Caching response from "https://npmsearch.com/query?q=angular"'); + expect(page.searchListItems.count()).toBeGreaterThan(1, 'angular items'); + + page.searchInput.clear(); + page.searchInput.sendKeys(' '); + expect(page.searchListItems.count()).toBe(0, 'search empty'); + + page.searchInput.clear(); + page.searchInput.sendKeys(packageName); + checkLogForMessage( + 'Found cached response for "https://npmsearch.com/query?q=angular"'); + }); + }); +}); diff --git a/aio/content/examples/http/example-config.json b/aio/content/examples/http/example-config.json index e69de29bb2..317e9458f3 100644 --- a/aio/content/examples/http/example-config.json +++ b/aio/content/examples/http/example-config.json @@ -0,0 +1,3 @@ +{ + "projectType": "testing" +} diff --git a/aio/content/examples/http/specs.stackblitz.json b/aio/content/examples/http/specs.stackblitz.json new file mode 100644 index 0000000000..1442c98f3a --- /dev/null +++ b/aio/content/examples/http/specs.stackblitz.json @@ -0,0 +1,18 @@ +{ + "description": "Http Guide Testing", + "files":[ + "src/app/heroes/heroes.service.ts", + "src/app/heroes/heroes.service.spec.ts", + + "src/app/http-error-handler.service.ts", + "src/app/message.service.ts", + "src/testing/*.ts", + + "src/styles.css", + "src/test.css", + "src/main-specs.ts", + "src/index-specs.html" + ], + "main": "src/index-specs.html", + "tags": ["http", "testing"] +} diff --git a/aio/content/examples/http/src/app/app.component.html b/aio/content/examples/http/src/app/app.component.html new file mode 100644 index 0000000000..e5d5dec909 --- /dev/null +++ b/aio/content/examples/http/src/app/app.component.html @@ -0,0 +1,24 @@ +

HTTP Sample

+
+ + + + + + + + + + + + + + +
+ + + + + + + diff --git a/aio/content/examples/http/src/app/app.component.ts b/aio/content/examples/http/src/app/app.component.ts index 780d044cab..655b550276 100644 --- a/aio/content/examples/http/src/app/app.component.ts +++ b/aio/content/examples/http/src/app/app.component.ts @@ -1,13 +1,19 @@ -// #docregion -import { Component } from '@angular/core'; +import { Component } from '@angular/core'; @Component({ - selector: 'my-app', - template: ` - - - - - ` + selector: 'app-root', + templateUrl: './app.component.html' }) -export class AppComponent { } +export class AppComponent { + showHeroes = true; + showConfig = true; + showDownloader = true; + showUploader = true; + showSearch = true; + + toggleHeroes() { this.showHeroes = !this.showHeroes; } + toggleConfig() { this.showConfig = !this.showConfig; } + toggleDownloader() { this.showDownloader = !this.showDownloader; } + toggleUploader() { this.showUploader = !this.showUploader; } + toggleSearch() { this.showSearch = !this.showSearch; } + } diff --git a/aio/content/examples/http/src/app/app.module.1.ts b/aio/content/examples/http/src/app/app.module.1.ts deleted file mode 100644 index fb7012aa02..0000000000 --- a/aio/content/examples/http/src/app/app.module.1.ts +++ /dev/null @@ -1,23 +0,0 @@ -// #docregion -import { NgModule } from '@angular/core'; -import { BrowserModule } from '@angular/platform-browser'; -import { FormsModule } from '@angular/forms'; -import { HttpModule, JsonpModule } from '@angular/http'; - -import { AppComponent } from './app.component'; - -@NgModule({ - imports: [ - BrowserModule, - FormsModule, - HttpModule, - JsonpModule - ], - declarations: [ AppComponent ], - bootstrap: [ AppComponent ] -}) -export class AppModule { -} - - - diff --git a/aio/content/examples/http/src/app/app.module.ts b/aio/content/examples/http/src/app/app.module.ts index fd0c720c3c..0929ec3a9b 100644 --- a/aio/content/examples/http/src/app/app.module.ts +++ b/aio/content/examples/http/src/app/app.module.ts @@ -1,46 +1,89 @@ // #docplaster -// #docregion -import { NgModule } from '@angular/core'; -import { BrowserModule } from '@angular/platform-browser'; -import { FormsModule } from '@angular/forms'; -import { HttpModule, JsonpModule } from '@angular/http'; +// #docregion sketch +import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +// #enddocregion sketch +import { FormsModule } from '@angular/forms'; +// #docregion sketch +import { HttpClientModule } from '@angular/common/http'; +// #enddocregion sketch +import { HttpClientXsrfModule } from '@angular/common/http'; +import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api'; +import { InMemoryDataService } from './in-memory-data.service'; -import { InMemoryWebApiModule } from 'angular-in-memory-web-api'; -import { HeroData } from './hero-data'; -import { requestOptionsProvider } from './default-request-options.service'; +import { RequestCache, RequestCacheWithMap } from './request-cache.service'; -import { AppComponent } from './app.component'; +import { AppComponent } from './app.component'; +import { AuthService } from './auth.service'; +import { ConfigComponent } from './config/config.component'; +import { DownloaderComponent } from './downloader/downloader.component'; +import { HeroesComponent } from './heroes/heroes.component'; +import { HttpErrorHandler } from './http-error-handler.service'; +import { MessageService } from './message.service'; +import { MessagesComponent } from './messages/messages.component'; +import { PackageSearchComponent } from './package-search/package-search.component'; +import { UploaderComponent } from './uploader/uploader.component'; -import { HeroListComponent } from './toh/hero-list.component'; -import { HeroListPromiseComponent } from './toh/hero-list.component.promise'; - -import { WikiComponent } from './wiki/wiki.component'; -import { WikiSmartComponent } from './wiki/wiki-smart.component'; +import { httpInterceptorProviders } from './http-interceptors/index'; +// #docregion sketch @NgModule({ +// #docregion xsrf imports: [ +// #enddocregion xsrf BrowserModule, +// #enddocregion sketch FormsModule, - HttpModule, - JsonpModule, - // #docregion in-mem-web-api - InMemoryWebApiModule.forRoot(HeroData) - // #enddocregion in-mem-web-api +// #docregion sketch + // import HttpClientModule after BrowserModule. +// #docregion xsrf + HttpClientModule, +// #enddocregion sketch + HttpClientXsrfModule.withOptions({ + cookieName: 'My-Xsrf-Cookie', + headerName: 'My-Xsrf-Header', + }), +// #enddocregion xsrf + + // The HttpClientInMemoryWebApiModule module intercepts HTTP requests + // and returns simulated server responses. + // Remove it when a real server is ready to receive requests. + HttpClientInMemoryWebApiModule.forRoot( + InMemoryDataService, { + dataEncapsulation: false, + passThruUnknownUrl: true, + put204: false // return entity after PUT/update + } + ) +// #docregion sketch, xsrf ], +// #enddocregion xsrf declarations: [ AppComponent, - HeroListComponent, - HeroListPromiseComponent, - WikiComponent, - WikiSmartComponent +// #enddocregion sketch + ConfigComponent, + DownloaderComponent, + HeroesComponent, + MessagesComponent, + UploaderComponent, + PackageSearchComponent, +// #docregion sketch ], -// #docregion provide-default-request-options - providers: [ requestOptionsProvider ], -// #enddocregion provide-default-request-options +// #enddocregion sketch +// #docregion interceptor-providers + providers: [ + // #enddocregion interceptor-providers + AuthService, + HttpErrorHandler, + MessageService, + { provide: RequestCache, useClass: RequestCacheWithMap }, + // #docregion interceptor-providers + httpInterceptorProviders + ], +// #enddocregion interceptor-providers +// #docregion sketch bootstrap: [ AppComponent ] }) export class AppModule {} - - - +// #enddocregion sketch diff --git a/aio/content/examples/http/src/app/auth.service.ts b/aio/content/examples/http/src/app/auth.service.ts new file mode 100644 index 0000000000..67a4eb4dc5 --- /dev/null +++ b/aio/content/examples/http/src/app/auth.service.ts @@ -0,0 +1,9 @@ +import { Injectable } from '@angular/core'; + +/** Mock client-side authentication/authorization service */ +@Injectable() +export class AuthService { + getAuthorizationToken() { + return 'some-auth-token'; + } +} diff --git a/aio/content/examples/http/src/app/config/config.component.html b/aio/content/examples/http/src/app/config/config.component.html new file mode 100644 index 0000000000..e17e9bc109 --- /dev/null +++ b/aio/content/examples/http/src/app/config/config.component.html @@ -0,0 +1,18 @@ +

Get configuration from JSON file

+
+ + + + + +

Heroes API URL is "{{config.heroesUrl}}"

+

Textfile URL is "{{config.textfile}}"

+
+ Response headers: +
    +
  • {{header}}
  • +
+
+
+
+

{{error | json}}

diff --git a/aio/content/examples/http/src/app/config/config.component.ts b/aio/content/examples/http/src/app/config/config.component.ts new file mode 100644 index 0000000000..f372c81b79 --- /dev/null +++ b/aio/content/examples/http/src/app/config/config.component.ts @@ -0,0 +1,78 @@ +// #docplaster +// #docregion +import { Component } from '@angular/core'; +import { Config, ConfigService } from './config.service'; +import { MessageService } from '../message.service'; + +@Component({ + selector: 'app-config', + templateUrl: './config.component.html', + providers: [ ConfigService ], + styles: ['.error {color: red;}'] +}) +export class ConfigComponent { + error: any; + headers: string[]; + // #docregion v2 + config: Config; + + // #enddocregion v2 + constructor(private configService: ConfigService) {} + + clear() { + this.config = undefined; + this.error = undefined; + this.headers = undefined; + } + + // #docregion v1, v2, v3 + showConfig() { + this.configService.getConfig() + // #enddocregion v1, v2 + .subscribe( + data => this.config = { ...data }, // success path + error => this.error = error // error path + ); + } + // #enddocregion v3 + + showConfig_v1() { + this.configService.getConfig_1() + // #docregion v1, v1_callback + .subscribe(data => this.config = { + heroesUrl: data['heroesUrl'], + textfile: data['textfile'] + }); + // #enddocregion v1_callback + } + // #enddocregion v1 + + showConfig_v2() { + this.configService.getConfig() + // #docregion v2, v2_callback + // clone the data object, using its known Config shape + .subscribe(data => this.config = { ...data }); + // #enddocregion v2_callback + } + // #enddocregion v2 + +// #docregion showConfigResponse + showConfigResponse() { + this.configService.getConfigResponse() + // resp is of type `HttpResponse` + .subscribe(resp => { + // display its headers + const keys = resp.headers.keys(); + this.headers = keys.map(key => + `${key}: ${resp.headers.get(key)}`); + + // access the body directly, which is typed as `Config`. + this.config = { ... resp.body }; + }); + } +// #enddocregion showConfigResponse + makeError() { + this.configService.makeIntentionalError().subscribe(null, error => this.error = error ); + } +} +// #enddocregion diff --git a/aio/content/examples/http/src/app/config/config.service.ts b/aio/content/examples/http/src/app/config/config.service.ts new file mode 100644 index 0000000000..aaa2285d9a --- /dev/null +++ b/aio/content/examples/http/src/app/config/config.service.ts @@ -0,0 +1,100 @@ +// #docplaster +// #docregion , proto +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +// #enddocregion proto +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; + +// #docregion rxjs-imports +import { Observable } from 'rxjs/Observable'; +import { ErrorObservable } from 'rxjs/observable/ErrorObservable'; +import { catchError, retry } from 'rxjs/operators'; +// #enddocregion rxjs-imports + +// #docregion config-interface +export interface Config { + heroesUrl: string; + textfile: string; +} +// #enddocregion config-interface +// #docregion proto + +@Injectable() +export class ConfigService { + // #enddocregion proto + // #docregion getConfig_1 + configUrl = 'assets/config.json'; + + // #enddocregion getConfig_1 + // #docregion proto + constructor(private http: HttpClient) { } + // #enddocregion proto + + // #docregion getConfig, getConfig_1, getConfig_2, getConfig_3 + getConfig() { + // #enddocregion getConfig_1, getConfig_2, getConfig_3 + return this.http.get(this.configUrl) + .pipe( + retry(3), // retry a failed request up to 3 times + catchError(this.handleError) // then handle the error + ); + } + // #enddocregion getConfig + + getConfig_1() { + // #docregion getConfig_1 + return this.http.get(this.configUrl); + } + // #enddocregion getConfig_1 + + getConfig_2() { + // #docregion getConfig_2 + // now returns an Observable of Config + return this.http.get(this.configUrl); + } + // #enddocregion getConfig_2 + + getConfig_3() { + // #docregion getConfig_3 + return this.http.get(this.configUrl) + .pipe( + catchError(this.handleError) + ); + } + // #enddocregion getConfig_3 + + // #docregion getConfigResponse + getConfigResponse(): Observable> { + return this.http.get( + this.configUrl, { observe: 'response' }); + } + // #enddocregion getConfigResponse + + // #docregion handleError + private handleError(error: HttpErrorResponse) { + if (error.error instanceof ErrorEvent) { + // A client-side or network error occurred. Handle it accordingly. + console.error('An error occurred:', error.error.message); + } else { + // The backend returned an unsuccessful response code. + // The response body may contain clues as to what went wrong, + console.error( + `Backend returned code ${error.status}, ` + + `body was: ${error.error}`); + } + // return an ErrorObservable with a user-facing error message + return new ErrorObservable( + 'Something bad happened; please try again later.'); + }; + // #enddocregion handleError + + makeIntentionalError() { + return this.http.get('not/a/real/url') + .pipe( + catchError(this.handleError) + ); + } + +// #docregion proto +} +// #enddocregion proto diff --git a/aio/content/examples/http/src/app/default-request-options.service.ts b/aio/content/examples/http/src/app/default-request-options.service.ts deleted file mode 100644 index 9ec52daa80..0000000000 --- a/aio/content/examples/http/src/app/default-request-options.service.ts +++ /dev/null @@ -1,16 +0,0 @@ -// #docregion -import { Injectable } from '@angular/core'; -import { BaseRequestOptions, RequestOptions } from '@angular/http'; - -@Injectable() -export class DefaultRequestOptions extends BaseRequestOptions { - - constructor() { - super(); - - // Set the default 'Content-Type' header - this.headers.set('Content-Type', 'application/json'); - } -} - -export const requestOptionsProvider = { provide: RequestOptions, useClass: DefaultRequestOptions }; diff --git a/aio/content/examples/http/src/app/downloader/downloader.component.html b/aio/content/examples/http/src/app/downloader/downloader.component.html new file mode 100644 index 0000000000..3eb9516472 --- /dev/null +++ b/aio/content/examples/http/src/app/downloader/downloader.component.html @@ -0,0 +1,4 @@ +

Download the textfile

+ + +

Contents: "{{contents}}"

diff --git a/aio/content/examples/http/src/app/downloader/downloader.component.ts b/aio/content/examples/http/src/app/downloader/downloader.component.ts new file mode 100644 index 0000000000..21216d1c63 --- /dev/null +++ b/aio/content/examples/http/src/app/downloader/downloader.component.ts @@ -0,0 +1,23 @@ +import { Component } from '@angular/core'; +import { DownloaderService } from './downloader.service'; + +@Component({ + selector: 'app-downloader', + templateUrl: './downloader.component.html', + providers: [ DownloaderService ] +}) +export class DownloaderComponent { + contents: string; + constructor(private downloaderService: DownloaderService) {} + + clear() { + this.contents = undefined; + } + + // #docregion download + download() { + this.downloaderService.getTextFile('assets/textfile.txt') + .subscribe(results => this.contents = results); + } + // #enddocregion download +} diff --git a/aio/content/examples/http/src/app/downloader/downloader.service.ts b/aio/content/examples/http/src/app/downloader/downloader.service.ts new file mode 100644 index 0000000000..e10a4f2b35 --- /dev/null +++ b/aio/content/examples/http/src/app/downloader/downloader.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { tap } from 'rxjs/operators'; + +import { MessageService } from '../message.service'; + +@Injectable() +export class DownloaderService { + constructor( + private http: HttpClient, + private messageService: MessageService) { } + + // #docregion getTextFile + getTextFile(filename: string) { + // The Observable returned by get() is of type Observable + // because a text response was specified. + // There's no need to pass a type parameter to get(). + return this.http.get(filename, {responseType: 'text'}) + .pipe( + tap( // Log the result or error + data => this.log(filename, data), + error => this.logError(filename, error) + ) + ); + } + // #enddocregion getTextFile + + private log(filename: string, data: string) { + const message = `DownloaderService downloaded "${filename}" and got "${data}".`; + this.messageService.add(message); + } + + private logError(filename: string, error: any) { + const message = `DownloaderService failed to download "${filename}"; got error "${error.message}".`; + console.error(message); + this.messageService.add(message); + } +} diff --git a/aio/content/examples/http/src/app/hero-data.ts b/aio/content/examples/http/src/app/hero-data.ts deleted file mode 100644 index 4db6aca115..0000000000 --- a/aio/content/examples/http/src/app/hero-data.ts +++ /dev/null @@ -1,13 +0,0 @@ -// #docregion -import { InMemoryDbService } from 'angular-in-memory-web-api'; -export class HeroData implements InMemoryDbService { - createDb() { - let heroes = [ - { id: 1, name: 'Windstorm' }, - { id: 2, name: 'Bombasto' }, - { id: 3, name: 'Magneta' }, - { id: 4, name: 'Tornado' } - ]; - return {heroes}; - } -} diff --git a/aio/content/examples/http/src/app/heroes.json b/aio/content/examples/http/src/app/heroes.json deleted file mode 100644 index dfb589066b..0000000000 --- a/aio/content/examples/http/src/app/heroes.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "data": [ - { "id": 1, "name": "Windstorm" }, - { "id": 2, "name": "Bombasto" }, - { "id": 3, "name": "Magneta" }, - { "id": 4, "name": "Tornado" } - ] -} diff --git a/aio/content/examples/http/src/app/heroes/hero.ts b/aio/content/examples/http/src/app/heroes/hero.ts new file mode 100644 index 0000000000..a61b497759 --- /dev/null +++ b/aio/content/examples/http/src/app/heroes/hero.ts @@ -0,0 +1,4 @@ +export interface Hero { + id: number; + name: string; +} diff --git a/aio/content/examples/http/src/app/heroes/heroes.component.css b/aio/content/examples/http/src/app/heroes/heroes.component.css new file mode 100644 index 0000000000..89a07c17bf --- /dev/null +++ b/aio/content/examples/http/src/app/heroes/heroes.component.css @@ -0,0 +1,89 @@ +/* HeroesComponent's private CSS styles */ +.heroes { + margin: 0 0 2em 0; + list-style-type: none; + padding: 0; + width: 15em; +} +.heroes li { + position: relative; + cursor: pointer; + background-color: #EEE; + margin: .5em; + padding: .3em 0; + height: 1.6em; + border-radius: 4px; + width: 19em; +} + +.heroes li:hover { + color: #607D8B; + background-color: #DDD; + left: .1em; +} + +.heroes a { + color: #888; + text-decoration: none; + position: relative; + display: block; + width: 250px; +} + +.heroes a:hover { + color:#607D8B; +} + +.heroes .badge { + display: inline-block; + font-size: small; + color: white; + padding: 0.8em 0.7em 0 0.7em; + background-color: #607D8B; + line-height: 1em; + position: relative; + left: -1px; + top: -4px; + height: 1.8em; + min-width: 16px; + text-align: right; + margin-right: .8em; + border-radius: 4px 0 0 4px; +} + +.button { + background-color: #eee; + border: none; + padding: 5px 10px; + border-radius: 4px; + cursor: pointer; + cursor: hand; + font-family: Arial; +} + +button:hover { + background-color: #cfd8dc; +} + +button.delete { + position: relative; + left: 24em; + top: -32px; + background-color: gray !important; + color: white; + display: inherit; + padding: 5px 8px; + width: 2em; +} + +input { + font-size: 100%; + margin-bottom: 2px; + width: 11em; +} + +.heroes input { + position: relative; + top: -3px; + width: 12em; +} diff --git a/aio/content/examples/http/src/app/heroes/heroes.component.html b/aio/content/examples/http/src/app/heroes/heroes.component.html new file mode 100644 index 0000000000..4bb3597a71 --- /dev/null +++ b/aio/content/examples/http/src/app/heroes/heroes.component.html @@ -0,0 +1,32 @@ +

Heroes

+ +
+ + + + +
+ + + + + diff --git a/aio/content/examples/http/src/app/heroes/heroes.component.ts b/aio/content/examples/http/src/app/heroes/heroes.component.ts new file mode 100644 index 0000000000..d44f80e9ff --- /dev/null +++ b/aio/content/examples/http/src/app/heroes/heroes.component.ts @@ -0,0 +1,76 @@ +import { Component, OnInit } from '@angular/core'; + +import { Hero } from './hero'; +import { HeroesService } from './heroes.service'; + +@Component({ + selector: 'app-heroes', + templateUrl: './heroes.component.html', + providers: [ HeroesService ], + styleUrls: ['./heroes.component.css'] +}) +export class HeroesComponent implements OnInit { + heroes: Hero[]; + editHero: Hero; // the hero currently being edited + + constructor(private heroesService: HeroesService) { } + + ngOnInit() { + this.getHeroes(); + } + + getHeroes(): void { + this.heroesService.getHeroes() + .subscribe(heroes => this.heroes = heroes); + } + + add(name: string): void { + this.editHero = undefined; + name = name.trim(); + if (!name) { return; } + + // The server will generate the id for this new hero + const newHero: Hero = { name } as Hero; + // #docregion add-hero-subscribe + this.heroesService.addHero(newHero) + .subscribe(hero => this.heroes.push(hero)); + // #enddocregion add-hero-subscribe + } + + delete(hero: Hero): void { + this.heroes = this.heroes.filter(h => h !== hero); + // #docregion delete-hero-subscribe + this.heroesService.deleteHero(hero.id).subscribe(); + // #enddocregion delete-hero-subscribe + /* + // #docregion delete-hero-no-subscribe + // oops ... subscribe() is missing so nothing happens + this.heroesService.deleteHero(hero.id); + // #enddocregion delete-hero-no-subscribe + */ + } + + edit(hero) { + this.editHero = hero; + } + + search(searchTerm: string) { + this.editHero = undefined; + if (searchTerm) { + this.heroesService.searchHeroes(searchTerm) + .subscribe(heroes => this.heroes = heroes); + } + } + + update() { + if (this.editHero) { + this.heroesService.updateHero(this.editHero) + .subscribe(hero => { + // replace the hero in the heroes list with update from server + const ix = hero ? this.heroes.findIndex(h => h.id === hero.id) : -1; + if (ix > -1) { this.heroes[ix] = hero; } + }); + this.editHero = undefined; + } + } +} diff --git a/aio/content/examples/http/src/app/heroes/heroes.service.spec.ts b/aio/content/examples/http/src/app/heroes/heroes.service.spec.ts new file mode 100644 index 0000000000..bfc204b4ad --- /dev/null +++ b/aio/content/examples/http/src/app/heroes/heroes.service.spec.ts @@ -0,0 +1,156 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +// Other imports +import { TestBed } from '@angular/core/testing'; +import { HttpClient, HttpResponse } from '@angular/common/http'; + +import { Hero } from './hero'; +import { HeroesService } from './heroes.service'; +import { HttpErrorHandler } from '../http-error-handler.service'; +import { MessageService } from '../message.service'; + +describe('HeroesService', () => { + let httpClient: HttpClient; + let httpTestingController: HttpTestingController; + let heroService: HeroesService; + + beforeEach(() => { + TestBed.configureTestingModule({ + // Import the HttpClient mocking services + imports: [ HttpClientTestingModule ], + // Provide the service-under-test and its dependencies + providers: [ + HeroesService, + HttpErrorHandler, + MessageService + ] + }); + + // Inject the http, test controller, and service-under-test + // as they will be referenced by each test. + httpClient = TestBed.get(HttpClient); + httpTestingController = TestBed.get(HttpTestingController); + heroService = TestBed.get(HeroesService); + }); + + afterEach(() => { + // After every test, assert that there are no more pending requests. + httpTestingController.verify(); + }); + + /// HeroService method tests begin /// + + describe('#getHeroes', () => { + let expectedHeroes: Hero[]; + + beforeEach(() => { + heroService = TestBed.get(HeroesService); + expectedHeroes = [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + ] as Hero[]; + }); + + it('should return expected heroes (called once)', () => { + + heroService.getHeroes().subscribe( + heroes => expect(heroes).toEqual(expectedHeroes, 'should return expected heroes'), + fail + ); + + // HeroService should have made one request to GET heroes from expected URL + const req = httpTestingController.expectOne(heroService.heroesUrl); + expect(req.request.method).toEqual('GET'); + + // Respond with the mock heroes + req.flush(expectedHeroes); + }); + + it('should be OK returning no heroes', () => { + + heroService.getHeroes().subscribe( + heroes => expect(heroes.length).toEqual(0, 'should have empty heroes array'), + fail + ); + + const req = httpTestingController.expectOne(heroService.heroesUrl); + req.flush([]); // Respond with no heroes + }); + + // This service reports the error but finds a way to let the app keep going. + it('should turn 404 into an empty heroes result', () => { + + heroService.getHeroes().subscribe( + heroes => expect(heroes.length).toEqual(0, 'should return empty heroes array'), + fail + ); + + const req = httpTestingController.expectOne(heroService.heroesUrl); + + // respond with a 404 and the error message in the body + const msg = 'deliberate 404 error'; + req.flush(msg, {status: 404, statusText: 'Not Found'}); + }); + + it('should return expected heroes (called multiple times)', () => { + + heroService.getHeroes().subscribe(); + heroService.getHeroes().subscribe(); + heroService.getHeroes().subscribe( + heroes => expect(heroes).toEqual(expectedHeroes, 'should return expected heroes'), + fail + ); + + const requests = httpTestingController.match(heroService.heroesUrl); + expect(requests.length).toEqual(3, 'calls to getHeroes()'); + + // Respond to each request with different mock hero results + requests[0].flush([]); + requests[1].flush([{id: 1, name: 'bob'}]); + requests[2].flush(expectedHeroes); + }); + }); + + describe('#updateHero', () => { + // Expecting the query form of URL so should not 404 when id not found + const makeUrl = (id: number) => `${heroService.heroesUrl}/?id=${id}`; + + it('should update a hero and return it', () => { + + const updateHero: Hero = { id: 1, name: 'A' }; + + heroService.updateHero(updateHero).subscribe( + data => expect(data).toEqual(updateHero, 'should return the hero'), + fail + ); + + // HeroService should have made one request to PUT hero + const req = httpTestingController.expectOne(heroService.heroesUrl); + expect(req.request.method).toEqual('PUT'); + expect(req.request.body).toEqual(updateHero); + + // Expect server to return the hero after PUT + const expectedResponse = new HttpResponse( + { status: 200, statusText: 'OK', body: updateHero }); + req.event(expectedResponse); + }); + + // This service reports the error but finds a way to let the app keep going. + it('should turn 404 error into return of the update hero', () => { + const updateHero: Hero = { id: 1, name: 'A' }; + + heroService.updateHero(updateHero).subscribe( + data => expect(data).toEqual(updateHero, 'should return the update hero'), + fail + ); + + const req = httpTestingController.expectOne(heroService.heroesUrl); + + // respond with a 404 and the error message in the body + const msg = 'deliberate 404 error'; + req.flush(msg, {status: 404, statusText: 'Not Found'}); + }); + }); + + // TODO: test other HeroService methods +}); diff --git a/aio/content/examples/http/src/app/heroes/heroes.service.ts b/aio/content/examples/http/src/app/heroes/heroes.service.ts new file mode 100644 index 0000000000..8e989ef09c --- /dev/null +++ b/aio/content/examples/http/src/app/heroes/heroes.service.ts @@ -0,0 +1,99 @@ +// #docplaster +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +// #docregion http-options +import { HttpHeaders } from '@angular/common/http'; + +// #enddocregion http-options + +import { Observable } from 'rxjs/Observable'; +import { of } from 'rxjs/observable/of'; +import { catchError } from 'rxjs/operators'; + +import { Hero } from './hero'; +import { HttpErrorHandler, HandleError } from '../http-error-handler.service'; + +// #docregion http-options +const httpOptions = { + headers: new HttpHeaders({ + 'Content-Type': 'application/json', + 'Authorization': 'my-auth-token' + }) +}; +// #enddocregion http-options + +@Injectable() +export class HeroesService { + heroesUrl = 'api/heroes'; // URL to web api + private handleError: HandleError; + + constructor( + private http: HttpClient, + httpErrorHandler: HttpErrorHandler) { + this.handleError = httpErrorHandler.createHandleError('HeroesService'); + } + + /** GET heroes from the server */ + getHeroes (): Observable { + return this.http.get(this.heroesUrl) + .pipe( + catchError(this.handleError('getHeroes', [])) + ); + } + + // #docregion searchHeroes + /* GET heroes whose name contains search term */ + searchHeroes(term: string): Observable { + term = term.trim(); + + // Add safe, URL encoded search parameter if there is a search term + const options = term ? + { params: new HttpParams().set('name', term) } : {}; + + return this.http.get(this.heroesUrl, options) + .pipe( + catchError(this.handleError('searchHeroes', [])) + ); + } + // #enddocregion searchHeroes + + //////// Save methods ////////// + + // #docregion addHero + /** POST: add a new hero to the database */ + addHero (hero: Hero): Observable { + return this.http.post(this.heroesUrl, hero, httpOptions) + .pipe( + catchError(this.handleError('addHero', hero)) + ); + } + // #enddocregion addHero + + // #docregion deleteHero + /** DELETE: delete the hero from the server */ + deleteHero (id: number): Observable<{}> { + const url = `${this.heroesUrl}/${id}`; // DELETE api/heroes/42 + return this.http.delete(url, httpOptions) + .pipe( + catchError(this.handleError('deleteHero')) + ); + } + // #enddocregion deleteHero + + // #docregion updateHero + /** PUT: update the hero on the server. Returns the updated hero upon success. */ + updateHero (hero: Hero): Observable { + // #enddocregion updateHero + // #docregion update-headers + httpOptions.headers = + httpOptions.headers.set('Authorization', 'my-new-auth-token'); + // #enddocregion update-headers + + // #docregion updateHero + return this.http.put(this.heroesUrl, hero, httpOptions) + .pipe( + catchError(this.handleError('updateHero', hero)) + ); + } + // #enddocregion updateHero +} diff --git a/aio/content/examples/http/src/app/http-error-handler.service.ts b/aio/content/examples/http/src/app/http-error-handler.service.ts new file mode 100644 index 0000000000..72a849b265 --- /dev/null +++ b/aio/content/examples/http/src/app/http-error-handler.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; + +import { Observable } from 'rxjs/Observable'; +import { of } from 'rxjs/observable/of'; + +import { MessageService } from './message.service'; + +/** Type of the handleError function returned by HttpErrorHandler.createHandleError */ +export type HandleError = + (operation?: string, result?: T) => (error: HttpErrorResponse) => Observable; + +/** Handles HttpClient errors */ +@Injectable() +export class HttpErrorHandler { + constructor(private messageService: MessageService) { } + + /** Create curried handleError function that already knows the service name */ + createHandleError = (serviceName = '') => + (operation = 'operation', result = {} as T) => this.handleError(serviceName, operation, result); + + /** + * Returns a function that handles Http operation failures. + * This error handler lets the app continue to run as if no error occurred. + * @param serviceName = name of the data service that attempted the operation + * @param operation - name of the operation that failed + * @param result - optional value to return as the observable result + */ + handleError (serviceName = '', operation = 'operation', result = {} as T) { + + return (error: HttpErrorResponse): Observable => { + // TODO: send the error to remote logging infrastructure + console.error(error); // log to console instead + + const message = (error.error instanceof ErrorEvent) ? + error.error.message : + `server returned code ${error.status} with body "${error.error}"`; + + // TODO: better job of transforming error for user consumption + this.messageService.add(`${serviceName}: ${operation} failed: ${message}`); + + // Let the app keep running by returning a safe result. + return of( result ); + }; + + } +} diff --git a/aio/content/examples/http/src/app/http-interceptors/auth-interceptor.ts b/aio/content/examples/http/src/app/http-interceptors/auth-interceptor.ts new file mode 100644 index 0000000000..db472a25a9 --- /dev/null +++ b/aio/content/examples/http/src/app/http-interceptors/auth-interceptor.ts @@ -0,0 +1,42 @@ +// #docplaster +import { Injectable } from '@angular/core'; +import { + HttpEvent, HttpInterceptor, HttpHandler, HttpRequest +} from '@angular/common/http'; + +import { Observable } from 'rxjs/Observable'; + +// #docregion +import { AuthService } from '../auth.service'; + +@Injectable() +export class AuthInterceptor implements HttpInterceptor { + + constructor(private auth: AuthService) {} + + intercept(req: HttpRequest, next: HttpHandler) { + // Get the auth token from the service. + const authToken = this.auth.getAuthorizationToken(); + + // #enddocregion + /* + * The verbose way: + // #docregion + // Clone the request and replace the original headers with + // cloned headers, updated with the authorization. + const authReq = req.clone({ + headers: req.headers.set('Authorization', authToken) + }); + // #enddocregion + */ + // #docregion set-header-shortcut + // Clone the request and set the new header in one step. + const authReq = req.clone({ setHeaders: { Authorization: authToken } }); + // #enddocregion set-header-shortcut + // #docregion + + // send cloned request with header to the next handler. + return next.handle(authReq); + } +} +// #enddocregion diff --git a/aio/content/examples/http/src/app/http-interceptors/caching-interceptor.ts b/aio/content/examples/http/src/app/http-interceptors/caching-interceptor.ts new file mode 100644 index 0000000000..2079a85442 --- /dev/null +++ b/aio/content/examples/http/src/app/http-interceptors/caching-interceptor.ts @@ -0,0 +1,86 @@ +// #docplaster +import { Injectable } from '@angular/core'; +import { + HttpEvent, HttpHeaders, HttpRequest, HttpResponse, + HttpInterceptor, HttpHandler +} from '@angular/common/http'; + +import { Observable } from 'rxjs/Observable'; +import { of } from 'rxjs/observable/of'; +import { startWith, tap } from 'rxjs/operators'; + +import { RequestCache } from '../request-cache.service'; +import { searchUrl } from '../package-search/package-search.service'; + + +/** + * If request is cachable (e.g., package search) and + * response is in cache return the cached response as observable. + * If has 'x-refresh' header that is true, + * then also re-run the package search, using response from next(), + * returning an observable that emits the cached response first. + * + * If not in cache or not cachable, + * pass request through to next() + */ +// #docregion v1 +@Injectable() +export class CachingInterceptor implements HttpInterceptor { + constructor(private cache: RequestCache) {} + + intercept(req: HttpRequest, next: HttpHandler) { + // continue if not cachable. + if (!isCachable(req)) { return next.handle(req); } + + const cachedResponse = this.cache.get(req); + // #enddocregion v1 + // #docregion intercept-refresh + // cache-then-refresh + if (req.headers.get('x-refresh')) { + const results$ = sendRequest(req, next, this.cache); + return cachedResponse ? + results$.pipe( startWith(cachedResponse) ) : + results$; + } + // cache-or-fetch + // #docregion v1 + return cachedResponse ? + of(cachedResponse) : sendRequest(req, next, this.cache); + // #enddocregion intercept-refresh + } +} +// #enddocregion v1 + + +/** Is this request cachable? */ +function isCachable(req: HttpRequest) { + // Only GET requests are cachable + return req.method === 'GET' && + // Only npm package search is cachable in this app + -1 < req.url.indexOf(searchUrl); +} + +// #docregion send-request +/** + * Get server response observable by sending request to `next()`. + * Will add the response to the cache on the way out. + */ +function sendRequest( + req: HttpRequest, + next: HttpHandler, + cache: RequestCache): Observable> { + + // No headers allowed in npm search request + const noHeaderReq = req.clone({ headers: new HttpHeaders() }); + + return next.handle(noHeaderReq).pipe( + tap(event => { + // There may be other events besides the response. + if (event instanceof HttpResponse) { + cache.put(req, event); // Update the cache. + } + }) + ); +} +// #enddocregion send-request + diff --git a/aio/content/examples/http/src/app/http-interceptors/ensure-https-interceptor.ts b/aio/content/examples/http/src/app/http-interceptors/ensure-https-interceptor.ts new file mode 100644 index 0000000000..70b0bdb085 --- /dev/null +++ b/aio/content/examples/http/src/app/http-interceptors/ensure-https-interceptor.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { + HttpEvent, HttpInterceptor, HttpHandler, HttpRequest +} from '@angular/common/http'; + +import { Observable } from 'rxjs/Observable'; + +@Injectable() +export class EnsureHttpsInterceptor implements HttpInterceptor { + intercept(req: HttpRequest, next: HttpHandler): Observable> { + // #docregion excerpt + // clone request and replace 'http://' with 'https://' at the same time + const secureReq = req.clone({ + url: req.url.replace('http://', 'https://') + }); + // send the cloned, "secure" request to the next handler. + return next.handle(secureReq); + // #enddocregion excerpt + } +} diff --git a/aio/content/examples/http/src/app/http-interceptors/index.ts b/aio/content/examples/http/src/app/http-interceptors/index.ts new file mode 100644 index 0000000000..a44f18f5f9 --- /dev/null +++ b/aio/content/examples/http/src/app/http-interceptors/index.ts @@ -0,0 +1,34 @@ +// #docplaster +// #docregion interceptor-providers +/* "Barrel" of Http Interceptors */ +import { HTTP_INTERCEPTORS } from '@angular/common/http'; + +// #enddocregion interceptor-providers +import { AuthInterceptor } from './auth-interceptor'; +import { CachingInterceptor } from './caching-interceptor'; +import { EnsureHttpsInterceptor } from './ensure-https-interceptor'; +import { LoggingInterceptor } from './logging-interceptor'; +// #docregion interceptor-providers +import { NoopInterceptor } from './noop-interceptor'; +// #enddocregion interceptor-providers +import { TrimNameInterceptor } from './trim-name-interceptor'; +import { UploadInterceptor } from './upload-interceptor'; + +// #docregion interceptor-providers + +/** Http interceptor providers in outside-in order */ +export const httpInterceptorProviders = [ + // #docregion noop-provider + { provide: HTTP_INTERCEPTORS, useClass: NoopInterceptor, multi: true }, + // #enddocregion noop-provider, interceptor-providers + + { provide: HTTP_INTERCEPTORS, useClass: EnsureHttpsInterceptor, multi: true }, + { provide: HTTP_INTERCEPTORS, useClass: TrimNameInterceptor, multi: true }, + { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, + { provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true }, + { provide: HTTP_INTERCEPTORS, useClass: UploadInterceptor, multi: true }, + { provide: HTTP_INTERCEPTORS, useClass: CachingInterceptor, multi: true }, + + // #docregion interceptor-providers +]; +// #enddocregion interceptor-providers diff --git a/aio/content/examples/http/src/app/http-interceptors/logging-interceptor.ts b/aio/content/examples/http/src/app/http-interceptors/logging-interceptor.ts new file mode 100644 index 0000000000..63cb7cc92c --- /dev/null +++ b/aio/content/examples/http/src/app/http-interceptors/logging-interceptor.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@angular/core'; +import { + HttpEvent, HttpInterceptor, HttpHandler, + HttpRequest, HttpResponse +} from '@angular/common/http'; + +import { Observable } from 'rxjs/Observable'; +// #docregion excerpt +import { finalize, tap } from 'rxjs/operators'; +import { MessageService } from '../message.service'; + +@Injectable() +export class LoggingInterceptor implements HttpInterceptor { + constructor(private messenger: MessageService) {} + + intercept(req: HttpRequest, next: HttpHandler) { + const started = Date.now(); + let ok: string; + + // extend server response observable with logging + return next.handle(req) + .pipe( + tap( + // Succeeds when there is a response; ignore other events + event => ok = event instanceof HttpResponse ? 'succeeded' : '', + // Operation failed; error is an HttpErrorResponse + error => ok = 'failed' + ), + // Log when response observable either completes or errors + finalize(() => { + const elapsed = Date.now() - started; + const msg = `${req.method} "${req.urlWithParams}" + ${ok} in ${elapsed} ms.`; + this.messenger.add(msg); + }) + ); + } +} +// #enddocregion excerpt diff --git a/aio/content/examples/http/src/app/http-interceptors/noop-interceptor.ts b/aio/content/examples/http/src/app/http-interceptors/noop-interceptor.ts new file mode 100644 index 0000000000..2bd0244cfc --- /dev/null +++ b/aio/content/examples/http/src/app/http-interceptors/noop-interceptor.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import { + HttpEvent, HttpInterceptor, HttpHandler, HttpRequest +} from '@angular/common/http'; + +import { Observable } from 'rxjs/Observable'; + +/** Pass untouched request through to the next request handler. */ +@Injectable() +export class NoopInterceptor implements HttpInterceptor { + + intercept(req: HttpRequest, next: HttpHandler): + Observable> { + return next.handle(req); + } +} diff --git a/aio/content/examples/http/src/app/http-interceptors/trim-name-interceptor.ts b/aio/content/examples/http/src/app/http-interceptors/trim-name-interceptor.ts new file mode 100644 index 0000000000..87705da2f9 --- /dev/null +++ b/aio/content/examples/http/src/app/http-interceptors/trim-name-interceptor.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { + HttpEvent, HttpInterceptor, HttpHandler, HttpRequest +} from '@angular/common/http'; + +import { Observable } from 'rxjs/Observable'; + +@Injectable() +export class TrimNameInterceptor implements HttpInterceptor { + intercept(req: HttpRequest, next: HttpHandler): Observable> { + const body = req.body; + if (!body || !body.name ) { + return next.handle(req); + } + // #docregion excerpt + // copy the body and trim whitespace from the name property + const newBody = { ...body, name: body.name.trim() }; + // clone request and set its body + const newReq = req.clone({ body: newBody }); + // send the cloned request to the next handler. + return next.handle(newReq); + // #enddocregion excerpt + } +} diff --git a/aio/content/examples/http/src/app/http-interceptors/upload-interceptor.ts b/aio/content/examples/http/src/app/http-interceptors/upload-interceptor.ts new file mode 100644 index 0000000000..1ad1891898 --- /dev/null +++ b/aio/content/examples/http/src/app/http-interceptors/upload-interceptor.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@angular/core'; +import { + HttpEvent, HttpInterceptor, HttpHandler, + HttpRequest, HttpResponse, + HttpEventType, HttpProgressEvent +} from '@angular/common/http'; + +import { Observable } from 'rxjs/Observable'; +import { of } from 'rxjs/observable/of'; + +/** Simulate server replying to file upload request */ +@Injectable() +export class UploadInterceptor implements HttpInterceptor { + intercept(req: HttpRequest, next: HttpHandler): Observable> { + if (req.url.indexOf('/upload/file') === -1) { + return next.handle(req); + } + const delay = 300; // Todo: inject delay? + return createUploadEvents(delay); + } +} + +/** Create simulation of upload event stream */ +function createUploadEvents(delay: number) { + // Simulate XHR behavior which would provide this information in a ProgressEvent + const chunks = 5; + const total = 12345678; + const chunkSize = Math.ceil(total / chunks); + + return new Observable>(observer => { + // notify the event stream that the request was sent. + observer.next({type: HttpEventType.Sent}); + + uploadLoop(0); + + function uploadLoop(loaded: number) { + // N.B.: Cannot use setInterval or rxjs delay (which uses setInterval) + // because e2e test won't complete. A zone thing? + // Use setTimeout and tail recursion instead. + setTimeout(() => { + loaded += chunkSize; + + if (loaded >= total) { + const doneResponse = new HttpResponse({ + status: 201, // OK but no body; + }); + observer.next(doneResponse); + observer.complete(); + return; + } + + const progressEvent: HttpProgressEvent = { + type: HttpEventType.UploadProgress, + loaded, + total + }; + observer.next(progressEvent); + uploadLoop(loaded); + }, delay); + } + }); +} diff --git a/aio/content/examples/http/src/app/in-memory-data.service.ts b/aio/content/examples/http/src/app/in-memory-data.service.ts new file mode 100644 index 0000000000..606e77f8f5 --- /dev/null +++ b/aio/content/examples/http/src/app/in-memory-data.service.ts @@ -0,0 +1,13 @@ +import { InMemoryDbService } from 'angular-in-memory-web-api'; + +export class InMemoryDataService implements InMemoryDbService { + createDb() { + const heroes = [ + { id: 11, name: 'Mr. Nice' }, + { id: 12, name: 'Narco' }, + { id: 13, name: 'Bombasto' }, + { id: 14, name: 'Celeritas' }, + ]; + return {heroes}; + } +} diff --git a/aio/content/examples/http/src/app/message.service.ts b/aio/content/examples/http/src/app/message.service.ts new file mode 100644 index 0000000000..1c56a49e70 --- /dev/null +++ b/aio/content/examples/http/src/app/message.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core'; + +@Injectable() +export class MessageService { + messages: string[] = []; + + add(message: string) { + this.messages.push(message); + } + + clear() { + this.messages = []; + } +} diff --git a/aio/content/examples/http/src/app/messages/messages.component.html b/aio/content/examples/http/src/app/messages/messages.component.html new file mode 100644 index 0000000000..d17b895692 --- /dev/null +++ b/aio/content/examples/http/src/app/messages/messages.component.html @@ -0,0 +1,8 @@ +
+

Messages

+ +
+
    +
  1. {{message}}
  2. +
+
diff --git a/aio/content/examples/http/src/app/messages/messages.component.ts b/aio/content/examples/http/src/app/messages/messages.component.ts new file mode 100644 index 0000000000..d54a121665 --- /dev/null +++ b/aio/content/examples/http/src/app/messages/messages.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; +import { MessageService } from '../message.service'; + +@Component({ + selector: 'app-messages', + templateUrl: './messages.component.html' +}) +export class MessagesComponent { + constructor(public messageService: MessageService) {} +} diff --git a/aio/content/examples/http/src/app/package-search/package-search.component.html b/aio/content/examples/http/src/app/package-search/package-search.component.html new file mode 100644 index 0000000000..b1bd56d561 --- /dev/null +++ b/aio/content/examples/http/src/app/package-search/package-search.component.html @@ -0,0 +1,17 @@ + +

Search Npm Packages

+

Searches when typing stops. Caches for 30 seconds.

+ + + + + + + +
    +
  • + {{package.name}} v.{{package.version}} - + {{package.description}} +
  • +
+ diff --git a/aio/content/examples/http/src/app/package-search/package-search.component.ts b/aio/content/examples/http/src/app/package-search/package-search.component.ts new file mode 100644 index 0000000000..8a42bd9b44 --- /dev/null +++ b/aio/content/examples/http/src/app/package-search/package-search.component.ts @@ -0,0 +1,39 @@ +import { Component, OnInit } from '@angular/core'; + +import { Observable } from 'rxjs/Observable'; +import { Subject } from 'rxjs/Subject'; +import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; + +import { NpmPackageInfo, PackageSearchService } from './package-search.service'; + +@Component({ + selector: 'app-package-search', + templateUrl: './package-search.component.html', + providers: [ PackageSearchService ] +}) +export class PackageSearchComponent implements OnInit { + // #docregion debounce + withRefresh = false; + packages$: Observable; + private searchText$ = new Subject(); + + search(packageName: string) { + this.searchText$.next(packageName); + } + + ngOnInit() { + this.packages$ = this.searchText$.pipe( + debounceTime(500), + distinctUntilChanged(), + switchMap(packageName => + this.searchService.search(packageName, this.withRefresh)) + ); + } + + constructor(private searchService: PackageSearchService) { } + + // #enddocregion debounce + + toggleRefresh() { this.withRefresh = ! this.withRefresh; } + +} diff --git a/aio/content/examples/http/src/app/package-search/package-search.service.ts b/aio/content/examples/http/src/app/package-search/package-search.service.ts new file mode 100644 index 0000000000..19d37f8259 --- /dev/null +++ b/aio/content/examples/http/src/app/package-search/package-search.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; + +import { Observable } from 'rxjs/Observable'; +import { of } from 'rxjs/observable/of'; +import { catchError, map } from 'rxjs/operators'; + +import { HttpErrorHandler, HandleError } from '../http-error-handler.service'; + +export interface NpmPackageInfo { + name: string; + version: string; + description: string; +} + +export const searchUrl = 'https://npmsearch.com/query'; + +const httpOptions = { + headers: new HttpHeaders({ + 'x-refresh': 'true' + }) +}; + +function createHttpOptions(packageName: string, refresh = false) { + // npm package name search api + // e.g., http://npmsearch.com/query?q=dom' + const params = new HttpParams({ fromObject: { q: packageName } }); + const headerMap = refresh ? {'x-refresh': 'true'} : {}; + const headers = new HttpHeaders(headerMap) ; + return { headers, params }; +} + +@Injectable() +export class PackageSearchService { + private handleError: HandleError; + + constructor( + private http: HttpClient, + httpErrorHandler: HttpErrorHandler) { + this.handleError = httpErrorHandler.createHandleError('HeroesService'); + } + + search (packageName: string, refresh = false): Observable { + // clear if no pkg name + if (!packageName.trim()) { return of([]); } + + const options = createHttpOptions(packageName, refresh); + + // TODO: Add error handling + return this.http.get(searchUrl, options).pipe( + map((data: any) => { + return data.results.map(entry => ({ + name: entry.name[0], + version: entry.version[0], + description: entry.description[0] + } as NpmPackageInfo ) + ); + }), + catchError(this.handleError('search', [])) + ); + } +} diff --git a/aio/content/examples/http/src/app/request-cache.service.ts b/aio/content/examples/http/src/app/request-cache.service.ts new file mode 100644 index 0000000000..a055a74d75 --- /dev/null +++ b/aio/content/examples/http/src/app/request-cache.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@angular/core'; +import { HttpRequest, HttpResponse } from '@angular/common/http'; + +import { MessageService } from './message.service'; + +export interface RequestCacheEntry { + url: string; + response: HttpResponse; + lastRead: number; +} + +// #docregion request-cache +export abstract class RequestCache { + abstract get(req: HttpRequest): HttpResponse | undefined; + abstract put(req: HttpRequest, response: HttpResponse): void +} +// #enddocregion request-cache + +const maxAge = 30000; // maximum cache age (ms) + +@Injectable() +export class RequestCacheWithMap implements RequestCache { + + cache = new Map(); + + constructor(private messenger: MessageService) { } + + get(req: HttpRequest): HttpResponse | undefined { + const url = req.urlWithParams; + const cached = this.cache.get(url); + + if (!cached) { + return undefined; + } + + const isExpired = cached.lastRead < (Date.now() - maxAge); + const expired = isExpired ? 'expired ' : ''; + this.messenger.add( + `Found ${expired}cached response for "${url}".`); + return isExpired ? undefined : cached.response; + } + + put(req: HttpRequest, response: HttpResponse): void { + const url = req.urlWithParams; + this.messenger.add(`Caching response from "${url}".`); + + const entry = { url, response, lastRead: Date.now() }; + this.cache.set(url, entry); + + // remove expired cache entries + const expired = Date.now() - maxAge; + this.cache.forEach(entry => { + if (entry.lastRead < expired) { + this.cache.delete(entry.url); + } + }); + + this.messenger.add(`Request cache size: ${this.cache.size}.`); + } +} diff --git a/aio/content/examples/http/src/app/toh/hero-list.component.html b/aio/content/examples/http/src/app/toh/hero-list.component.html deleted file mode 100644 index 65ca9cfbb7..0000000000 --- a/aio/content/examples/http/src/app/toh/hero-list.component.html +++ /dev/null @@ -1,11 +0,0 @@ - -

Tour of Heroes ({{mode}})

-

Heroes:

-
    -
  • {{hero.name}}
  • -
- - - - -

{{errorMessage}}

diff --git a/aio/content/examples/http/src/app/toh/hero-list.component.promise.ts b/aio/content/examples/http/src/app/toh/hero-list.component.promise.ts deleted file mode 100644 index 4bbe7eade2..0000000000 --- a/aio/content/examples/http/src/app/toh/hero-list.component.promise.ts +++ /dev/null @@ -1,40 +0,0 @@ -// #docregion -// Promise Version -import { Component, OnInit } from '@angular/core'; -import { Hero } from './hero'; -import { HeroService } from './hero.service.promise'; - -@Component({ - selector: 'hero-list-promise', - templateUrl: './hero-list.component.html', - providers: [ HeroService ], - styles: ['.error {color:red;}'] -}) -// #docregion component -export class HeroListPromiseComponent implements OnInit { - errorMessage: string; - heroes: Hero[]; - mode = 'Promise'; - - constructor (private heroService: HeroService) {} - - ngOnInit() { this.getHeroes(); } - - // #docregion methods - getHeroes() { - this.heroService.getHeroes() - .then( - heroes => this.heroes = heroes, - error => this.errorMessage = error); - } - - addHero (name: string) { - if (!name) { return; } - this.heroService.addHero(name) - .then( - hero => this.heroes.push(hero), - error => this.errorMessage = error); - } - // #enddocregion methods -} -// #enddocregion component diff --git a/aio/content/examples/http/src/app/toh/hero-list.component.ts b/aio/content/examples/http/src/app/toh/hero-list.component.ts deleted file mode 100644 index 8cca504762..0000000000 --- a/aio/content/examples/http/src/app/toh/hero-list.component.ts +++ /dev/null @@ -1,44 +0,0 @@ -// #docregion -// Observable Version -import { Component, OnInit } from '@angular/core'; -import { Hero } from './hero'; -import { HeroService } from './hero.service'; - -@Component({ - selector: 'hero-list', - templateUrl: './hero-list.component.html', - providers: [ HeroService ], - styles: ['.error {color:red;}'] -}) -// #docregion component -export class HeroListComponent implements OnInit { - errorMessage: string; - heroes: Hero[]; - mode = 'Observable'; - - constructor (private heroService: HeroService) {} - - ngOnInit() { this.getHeroes(); } - - // #docregion methods - // #docregion getHeroes - getHeroes() { - this.heroService.getHeroes() - .subscribe( - heroes => this.heroes = heroes, - error => this.errorMessage = error); - } - // #enddocregion getHeroes - - // #docregion addHero - addHero(name: string) { - if (!name) { return; } - this.heroService.create(name) - .subscribe( - hero => this.heroes.push(hero), - error => this.errorMessage = error); - } - // #enddocregion addHero - // #enddocregion methods -} -// #enddocregion component diff --git a/aio/content/examples/http/src/app/toh/hero.service.promise.ts b/aio/content/examples/http/src/app/toh/hero.service.promise.ts deleted file mode 100644 index e38bd4bebf..0000000000 --- a/aio/content/examples/http/src/app/toh/hero.service.promise.ts +++ /dev/null @@ -1,60 +0,0 @@ -// #docplaster -// #docregion -// Promise Version -import { Injectable } from '@angular/core'; -import { Http, Response } from '@angular/http'; -import { Headers, RequestOptions } from '@angular/http'; - -// #docregion rxjs-imports -import 'rxjs/add/operator/toPromise'; -// #enddocregion rxjs-imports - -import { Hero } from './hero'; - -@Injectable() -export class HeroService { - // URL to web api - private heroesUrl = 'app/heroes'; - - constructor (private http: Http) {} - - // #docregion methods - getHeroes (): Promise { - return this.http.get(this.heroesUrl) - .toPromise() - .then(this.extractData) - .catch(this.handleError); - } - - addHero (name: string): Promise { - let headers = new Headers({ 'Content-Type': 'application/json' }); - let options = new RequestOptions({ headers: headers }); - - return this.http.post(this.heroesUrl, { name }, options) - .toPromise() - .then(this.extractData) - .catch(this.handleError); - } - - private extractData(res: Response) { - let body = res.json(); - return body.data || { }; - } - - private handleError (error: Response | any) { - // In a real world app, we might use a remote logging infrastructure - let errMsg: string; - if (error instanceof Response) { - const body = error.json() || ''; - const err = body.error || JSON.stringify(body); - errMsg = `${error.status} - ${error.statusText || ''} ${err}`; - } else { - errMsg = error.message ? error.message : error.toString(); - } - console.error(errMsg); - return Promise.reject(errMsg); - } - -// #enddocregion methods -} -// #enddocregion diff --git a/aio/content/examples/http/src/app/toh/hero.service.ts b/aio/content/examples/http/src/app/toh/hero.service.ts deleted file mode 100644 index 4f0da49021..0000000000 --- a/aio/content/examples/http/src/app/toh/hero.service.ts +++ /dev/null @@ -1,80 +0,0 @@ -// #docplaster -// #docregion -// Observable Version -// #docregion v1 -import { Injectable } from '@angular/core'; -import { Http, Response } from '@angular/http'; -// #enddocregion v1 -// #docregion import-request-options -import { Headers, RequestOptions } from '@angular/http'; -// #enddocregion import-request-options -// #docregion v1 - -// #docregion rxjs-imports -import { Observable } from 'rxjs/Observable'; -import 'rxjs/add/operator/catch'; -import 'rxjs/add/operator/map'; -// #enddocregion rxjs-imports - -import { Hero } from './hero'; - -@Injectable() -export class HeroService { - // #docregion endpoint - private heroesUrl = 'api/heroes'; // URL to web API - // #enddocregion endpoint - - // #docregion ctor - constructor (private http: Http) {} - // #enddocregion ctor - - // #docregion methods, error-handling, http-get - getHeroes(): Observable { - return this.http.get(this.heroesUrl) - .map(this.extractData) - .catch(this.handleError); - } - // #enddocregion error-handling, http-get, v1 - - // #docregion create, create-sig - create(name: string): Observable { - // #enddocregion create-sig - let headers = new Headers({ 'Content-Type': 'application/json' }); - let options = new RequestOptions({ headers: headers }); - - return this.http.post(this.heroesUrl, { name }, options) - .map(this.extractData) - .catch(this.handleError); - } - // #enddocregion create - - // #docregion v1, extract-data - private extractData(res: Response) { - let body = res.json(); - return body.data || { }; - } - // #enddocregion extract-data - // #docregion error-handling - - private handleError (error: Response | any) { - // In a real world app, you might use a remote logging infrastructure - let errMsg: string; - if (error instanceof Response) { - const body = error.json() || ''; - const err = body.error || JSON.stringify(body); - errMsg = `${error.status} - ${error.statusText || ''} ${err}`; - } else { - errMsg = error.message ? error.message : error.toString(); - } - console.error(errMsg); - return Observable.throw(errMsg); - } - // #enddocregion error-handling, methods -} -// #enddocregion - -/* - // #docregion endpoint-json - private heroesUrl = 'app/heroes.json'; // URL to JSON file - // #enddocregion endpoint-json -*/ diff --git a/aio/content/examples/http/src/app/toh/hero.ts b/aio/content/examples/http/src/app/toh/hero.ts deleted file mode 100644 index 09b8d295ce..0000000000 --- a/aio/content/examples/http/src/app/toh/hero.ts +++ /dev/null @@ -1,6 +0,0 @@ -// #docregion -export class Hero { - constructor( - public id: number, - public name: string) { } -} diff --git a/aio/content/examples/http/src/app/uploader/uploader.component.html b/aio/content/examples/http/src/app/uploader/uploader.component.html new file mode 100644 index 0000000000..2f477ef110 --- /dev/null +++ b/aio/content/examples/http/src/app/uploader/uploader.component.html @@ -0,0 +1,12 @@ +

Upload file

+
+
+ +
+ +
+
+

{{message}}

+
diff --git a/aio/content/examples/http/src/app/uploader/uploader.component.ts b/aio/content/examples/http/src/app/uploader/uploader.component.ts new file mode 100644 index 0000000000..f4d2427813 --- /dev/null +++ b/aio/content/examples/http/src/app/uploader/uploader.component.ts @@ -0,0 +1,25 @@ +import { Component } from '@angular/core'; +import { UploaderService } from './uploader.service'; + +@Component({ + selector: 'app-uploader', + templateUrl: './uploader.component.html', + providers: [ UploaderService ] +}) +export class UploaderComponent { + message: string; + + constructor(private uploaderService: UploaderService) {} + + onPicked(input: HTMLInputElement) { + const file = input.files[0]; + if (file) { + this.uploaderService.upload(file).subscribe( + msg => { + input.value = null; + this.message = msg; + } + ); + } + } +} diff --git a/aio/content/examples/http/src/app/uploader/uploader.service.ts b/aio/content/examples/http/src/app/uploader/uploader.service.ts new file mode 100644 index 0000000000..115791a947 --- /dev/null +++ b/aio/content/examples/http/src/app/uploader/uploader.service.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@angular/core'; +import { + HttpClient, HttpEvent, HttpEventType, HttpProgressEvent, + HttpRequest, HttpResponse, HttpErrorResponse +} from '@angular/common/http'; + +import { of } from 'rxjs/observable/of'; +import { catchError, last, map, tap } from 'rxjs/operators'; + +import { MessageService } from '../message.service'; + +@Injectable() +export class UploaderService { + constructor( + private http: HttpClient, + private messenger: MessageService) {} + + // If uploading multiple files, change to: + // upload(files: FileList) { + // const formData = new FormData(); + // files.forEach(f => formData.append(f.name, f)); + // new HttpRequest('POST', '/upload/file', formData, {reportProgress: true}); + // ... + // } + + upload(file: File) { + if (!file) { return; } + + // COULD HAVE WRITTEN: + // return this.http.post('/upload/file', file, { + // reportProgress: true, + // observe: 'events' + // }).pipe( + + // Create the request object that POSTs the file to an upload endpoint. + // The `reportProgress` option tells HttpClient to listen and return + // XHR progress events. + // #docregion upload-request + const req = new HttpRequest('POST', '/upload/file', file, { + reportProgress: true + }); + // #enddocregion upload-request + + // #docregion upload-body + // The `HttpClient.request` API produces a raw event stream + // which includes start (sent), progress, and response events. + return this.http.request(req).pipe( + map(event => this.getEventMessage(event, file)), + tap(message => this.showProgress(message)), + last(), // return last (completed) message to caller + catchError(this.handleError(file)) + ); + // #enddocregion upload-body + } + + // #docregion getEventMessage + /** Return distinct message for sent, upload progress, & response events */ + private getEventMessage(event: HttpEvent, file: File) { + switch (event.type) { + case HttpEventType.Sent: + return `Uploading file "${file.name}" of size ${file.size}.`; + + case HttpEventType.UploadProgress: + // Compute and show the % done: + const percentDone = Math.round(100 * event.loaded / event.total); + return `File "${file.name}" is ${percentDone}% uploaded.`; + + case HttpEventType.Response: + return `File "${file.name}" was completely uploaded!`; + + default: + return `File "${file.name}" surprising upload event: ${event.type}.`; + } + } + // #enddocregion getEventMessage + + /** + * Returns a function that handles Http upload failures. + * @param file - File object for file being uploaded + * + * When no `UploadInterceptor` and no server, + * you'll end up here in the error handler. + */ + private handleError(file: File) { + const userMessage = `${file.name} upload failed.`; + + return (error: HttpErrorResponse) => { + // TODO: send the error to remote logging infrastructure + console.error(error); // log to console instead + + const message = (error.error instanceof Error) ? + error.error.message : + `server returned code ${error.status} with body "${error.error}"`; + + this.messenger.add(`${userMessage} ${message}`); + + // Let app keep running but indicate failure. + return of(userMessage); + }; + } + + private showProgress(message: string) { + this.messenger.add(message); + } +} diff --git a/aio/content/examples/http/src/app/wiki/wiki-smart.component.ts b/aio/content/examples/http/src/app/wiki/wiki-smart.component.ts deleted file mode 100644 index fc453a820e..0000000000 --- a/aio/content/examples/http/src/app/wiki/wiki-smart.component.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* tslint:disable: member-ordering forin */ -// #docplaster -// #docregion -import { Component, OnInit } from '@angular/core'; - -// #docregion rxjs-imports -import { Observable } from 'rxjs/Observable'; -import 'rxjs/add/operator/debounceTime'; -import 'rxjs/add/operator/distinctUntilChanged'; -import 'rxjs/add/operator/switchMap'; - -// #docregion import-subject -import { Subject } from 'rxjs/Subject'; -// #enddocregion import-subject - -import { WikipediaService } from './wikipedia.service'; - -@Component({ - selector: 'my-wiki-smart', - template: ` -

Smarter Wikipedia Demo

-

Search when typing stops

- -
    -
  • {{item}}
  • -
`, - providers: [ WikipediaService ] -}) -export class WikiSmartComponent implements OnInit { - items: Observable; - - constructor (private wikipediaService: WikipediaService) {} - - // #docregion subject - private searchTermStream = new Subject(); - search(term: string) { this.searchTermStream.next(term); } - // #enddocregion subject - - ngOnInit() { - // #docregion observable-operators - this.items = this.searchTermStream - .debounceTime(300) - .distinctUntilChanged() - .switchMap((term: string) => this.wikipediaService.search(term)); - // #enddocregion observable-operators - } -} diff --git a/aio/content/examples/http/src/app/wiki/wiki.component.ts b/aio/content/examples/http/src/app/wiki/wiki.component.ts deleted file mode 100644 index 4230df12a1..0000000000 --- a/aio/content/examples/http/src/app/wiki/wiki.component.ts +++ /dev/null @@ -1,26 +0,0 @@ -// #docregion -import { Component } from '@angular/core'; -import { Observable } from 'rxjs/Observable'; - -import { WikipediaService } from './wikipedia.service'; - -@Component({ - selector: 'my-wiki', - template: ` -

Wikipedia Demo

-

Search after each keystroke

- -
    -
  • {{item}}
  • -
`, - providers: [ WikipediaService ] -}) -export class WikiComponent { - items: Observable; - - constructor (private wikipediaService: WikipediaService) { } - - search (term: string) { - this.items = this.wikipediaService.search(term); - } -} diff --git a/aio/content/examples/http/src/app/wiki/wikipedia.service.1.ts b/aio/content/examples/http/src/app/wiki/wikipedia.service.1.ts deleted file mode 100644 index 5cbcb7d707..0000000000 --- a/aio/content/examples/http/src/app/wiki/wikipedia.service.1.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Create the query string by hand -// #docregion -import { Injectable } from '@angular/core'; -import { Jsonp } from '@angular/http'; - -import 'rxjs/add/operator/map'; - -@Injectable() -export class WikipediaService { - constructor(private jsonp: Jsonp) { } - - // TODO: Add error handling - search(term: string) { - - let wikiUrl = 'http://en.wikipedia.org/w/api.php'; - - // #docregion query-string - let queryString = - `?search=${term}&action=opensearch&format=json&callback=JSONP_CALLBACK`; - - return this.jsonp - .get(wikiUrl + queryString) - .map(response => response.json()[1]); - // #enddocregion query-string - } -} diff --git a/aio/content/examples/http/src/app/wiki/wikipedia.service.ts b/aio/content/examples/http/src/app/wiki/wikipedia.service.ts deleted file mode 100644 index a38167d1c6..0000000000 --- a/aio/content/examples/http/src/app/wiki/wikipedia.service.ts +++ /dev/null @@ -1,30 +0,0 @@ -// #docregion -import { Injectable } from '@angular/core'; -import { Jsonp, URLSearchParams } from '@angular/http'; - -import 'rxjs/add/operator/map'; - -@Injectable() -export class WikipediaService { - constructor(private jsonp: Jsonp) {} - - search (term: string) { - - let wikiUrl = 'http://en.wikipedia.org/w/api.php'; - - // #docregion search-parameters - let params = new URLSearchParams(); - params.set('search', term); // the user's search value - params.set('action', 'opensearch'); - params.set('format', 'json'); - params.set('callback', 'JSONP_CALLBACK'); - // #enddocregion search-parameters - - // #docregion call-jsonp - // TODO: Add error handling - return this.jsonp - .get(wikiUrl, { search: params }) - .map(response => response.json()[1]); - // #enddocregion call-jsonp - } -} diff --git a/aio/content/examples/http/src/assets/config.json b/aio/content/examples/http/src/assets/config.json new file mode 100644 index 0000000000..a6f2505140 --- /dev/null +++ b/aio/content/examples/http/src/assets/config.json @@ -0,0 +1,4 @@ +{ + "heroesUrl": "api/heroes", + "textfile": "assets/textfile.txt" +} diff --git a/aio/content/examples/http/src/assets/textfile.txt b/aio/content/examples/http/src/assets/textfile.txt new file mode 100644 index 0000000000..282575a15a --- /dev/null +++ b/aio/content/examples/http/src/assets/textfile.txt @@ -0,0 +1 @@ +This is the downloaded text file diff --git a/aio/content/examples/http/src/browser-test-shim.js b/aio/content/examples/http/src/browser-test-shim.js new file mode 100644 index 0000000000..6a7ce2698b --- /dev/null +++ b/aio/content/examples/http/src/browser-test-shim.js @@ -0,0 +1,88 @@ +// BROWSER TESTING SHIM +// Keep it in-sync with what karma-test-shim does +// #docregion +/*global jasmine, __karma__, window*/ +(function () { + + Error.stackTraceLimit = 0; // "No stacktrace"" is usually best for app testing. + + // Uncomment to get full stacktrace output. Sometimes helpful, usually not. + // Error.stackTraceLimit = Infinity; // + + jasmine.DEFAULT_TIMEOUT_INTERVAL = 3000; + + var baseURL = document.baseURI; + baseURL = baseURL + baseURL[baseURL.length-1] ? '' : '/'; + + System.config({ + baseURL: baseURL, + // Extend usual application package list with test folder + packages: { 'testing': { main: 'index.js', defaultExtension: 'js' } }, + + // Assume npm: is set in `paths` in systemjs.config + // Map the angular testing umd bundles + map: { + '@angular/core/testing': 'npm:@angular/core/bundles/core-testing.umd.js', + '@angular/common/testing': 'npm:@angular/common/bundles/common-testing.umd.js', + '@angular/common/http/testing': 'npm:@angular/common/bundles/common-http-testing.umd.js', + '@angular/compiler/testing': 'npm:@angular/compiler/bundles/compiler-testing.umd.js', + '@angular/platform-browser/testing': 'npm:@angular/platform-browser/bundles/platform-browser-testing.umd.js', + '@angular/platform-browser-dynamic/testing': 'npm:@angular/platform-browser-dynamic/bundles/platform-browser-dynamic-testing.umd.js', + '@angular/http/testing': 'npm:@angular/http/bundles/http-testing.umd.js', + '@angular/router/testing': 'npm:@angular/router/bundles/router-testing.umd.js', + '@angular/forms/testing': 'npm:@angular/forms/bundles/forms-testing.umd.js', + }, + }); + + System.import('systemjs.config.js') + // .then(importSystemJsExtras) // not in this project + .then(initTestBed) + .then(initTesting); + + /** Optional SystemJS configuration extras. Keep going w/o it */ + function importSystemJsExtras(){ + return System.import('systemjs.config.extras.js') + .catch(function(reason) { + console.log( + 'Note: System.import could not load "systemjs.config.extras.js" where you might have added more configuration. It is an optional file so we will continue without it.' + ); + console.log(reason); + }); + } + + function initTestBed(){ + return Promise.all([ + System.import('@angular/core/testing'), + System.import('@angular/platform-browser-dynamic/testing') + ]) + + .then(function (providers) { + var coreTesting = providers[0]; + var browserTesting = providers[1]; + + coreTesting.TestBed.initTestEnvironment( + browserTesting.BrowserDynamicTestingModule, + browserTesting.platformBrowserDynamicTesting()); + }) + } + + // Import all spec files defined in the html (__spec_files__) + // and start Jasmine testrunner + function initTesting () { + console.log('loading spec files: '+__spec_files__.join(', ')); + return Promise.all( + __spec_files__.map(function(spec) { + return System.import(spec); + }) + ) + // After all imports load, re-execute `window.onload` which + // triggers the Jasmine test-runner start or explain what went wrong + .then(success, console.error.bind(console)); + + function success () { + console.log('Spec files loaded; starting Jasmine testrunner'); + window.onload(); + } + } + + })(); diff --git a/aio/content/examples/http/src/index-specs.html b/aio/content/examples/http/src/index-specs.html new file mode 100644 index 0000000000..26286a5083 --- /dev/null +++ b/aio/content/examples/http/src/index-specs.html @@ -0,0 +1,4 @@ + diff --git a/aio/content/examples/http/src/index.html b/aio/content/examples/http/src/index.html index 78c7b7127e..5e56060366 100644 --- a/aio/content/examples/http/src/index.html +++ b/aio/content/examples/http/src/index.html @@ -1,27 +1,14 @@ - - + - - Angular Http Demo - - - - - - - - - - - - - - - - - - + + + HttpClient Demo + + + + + + + diff --git a/aio/content/examples/http/src/main-specs.ts b/aio/content/examples/http/src/main-specs.ts new file mode 100644 index 0000000000..c54ce8da2e --- /dev/null +++ b/aio/content/examples/http/src/main-specs.ts @@ -0,0 +1,44 @@ +import './testing/global-jasmine'; +import 'jasmine-core/lib/jasmine-core/jasmine-html.js'; +import 'jasmine-core/lib/jasmine-core/boot.js'; + +declare var jasmine; + +import './polyfills'; + +import 'zone.js/dist/async-test'; +import 'zone.js/dist/fake-async-test'; +import 'zone.js/dist/long-stack-trace-zone'; +import 'zone.js/dist/proxy.js'; +import 'zone.js/dist/sync-test'; +import 'zone.js/dist/jasmine-patch'; + +import { getTestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting +} from '@angular/platform-browser-dynamic/testing'; + +// Import spec files individually for Stackblitz +import './app/heroes/heroes.service.spec.ts'; +import './testing/http-client.spec.ts'; + +// +bootstrap(); + +// +function bootstrap () { + if (window['jasmineRef']) { + location.reload(); + return; + } else { + window.onload(undefined); + window['jasmineRef'] = jasmine.getEnv(); + } + + // First, initialize the Angular testing environment. + getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting() + ); +} diff --git a/aio/content/examples/http/src/test.css b/aio/content/examples/http/src/test.css new file mode 100644 index 0000000000..6010a5d9ba --- /dev/null +++ b/aio/content/examples/http/src/test.css @@ -0,0 +1 @@ +@import "~jasmine-core/lib/jasmine-core/jasmine.css" diff --git a/aio/content/examples/http/src/testing/global-jasmine.ts b/aio/content/examples/http/src/testing/global-jasmine.ts new file mode 100644 index 0000000000..560ff97d66 --- /dev/null +++ b/aio/content/examples/http/src/testing/global-jasmine.ts @@ -0,0 +1,3 @@ +import jasmineRequire from 'jasmine-core/lib/jasmine-core/jasmine.js'; + +window['jasmineRequire'] = jasmineRequire; diff --git a/aio/content/examples/http/src/testing/http-client.spec.ts b/aio/content/examples/http/src/testing/http-client.spec.ts new file mode 100644 index 0000000000..2c5b5ffd46 --- /dev/null +++ b/aio/content/examples/http/src/testing/http-client.spec.ts @@ -0,0 +1,192 @@ +// #docplaster +// #docregion imports +// Http testing module and mocking controller +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +// Other imports +import { TestBed } from '@angular/core/testing'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; + +// #enddocregion imports +import { HttpHeaders } from '@angular/common/http'; + +interface Data { + name: string; +} + +const testUrl = '/data'; + +// #docregion setup +describe('HttpClient testing', () => { + let httpClient: HttpClient; + let httpTestingController: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ HttpClientTestingModule ] + }); + + // Inject the http service and test controller for each test + httpClient = TestBed.get(HttpClient); + httpTestingController = TestBed.get(HttpTestingController); + }); + // #enddocregion setup + // #docregion afterEach + afterEach(() => { + // After every test, assert that there are no more pending requests. + httpTestingController.verify(); + }); + // #enddocregion afterEach + // #docregion setup + /// Tests begin /// + // #enddocregion setup + // #docregion get-test + it('can test HttpClient.get', () => { + const testData: Data = {name: 'Test Data'}; + + // Make an HTTP GET request + httpClient.get(testUrl) + .subscribe(data => + // When observable resolves, result should match test data + expect(data).toEqual(testData) + ); + + // The following `expectOne()` will match the request's URL. + // If no requests or multiple requests matched that URL + // `expectOne()` would throw. + const req = httpTestingController.expectOne('/data'); + + // Assert that the request is a GET. + expect(req.request.method).toEqual('GET'); + + // Respond with mock data, causing Observable to resolve. + // Subscribe callback asserts that correct data was returned. + req.flush(testData); + + // Finally, assert that there are no outstanding requests. + httpTestingController.verify(); + }); + // #enddocregion get-test + it('can test HttpClient.get with matching header', () => { + const testData: Data = {name: 'Test Data'}; + + // Make an HTTP GET request with specific header + httpClient.get(testUrl, { + headers: new HttpHeaders({'Authorization': 'my-auth-token'}) + }) + .subscribe(data => + expect(data).toEqual(testData) + ); + + // Find request with a predicate function. + // #docregion predicate + // Expect one request with an authorization header + const req = httpTestingController.expectOne( + req => req.headers.has('Authorization') + ); + // #enddocregion predicate + req.flush(testData); + }); + + it('can test multiple requests', () => { + let testData: Data[] = [ + { name: 'bob' }, { name: 'carol' }, + { name: 'ted' }, { name: 'alice' } + ]; + + // Make three requests in a row + httpClient.get(testUrl) + .subscribe(d => expect(d.length).toEqual(0, 'should have no data')); + + httpClient.get(testUrl) + .subscribe(d => expect(d).toEqual([testData[0]], 'should be one element array')); + + httpClient.get(testUrl) + .subscribe(d => expect(d).toEqual(testData, 'should be expected data')); + + // #docregion multi-request + // get all pending requests that match the given URL + const requests = httpTestingController.match(testUrl); + expect(requests.length).toEqual(3); + + // Respond to each request with different results + requests[0].flush([]); + requests[1].flush([testData[0]]); + requests[2].flush(testData); + // #enddocregion multi-request + }); + + // #docregion 404 + it('can test for 404 error', () => { + const emsg = 'deliberate 404 error'; + + httpClient.get(testUrl).subscribe( + data => fail('should have failed with the 404 error'), + (error: HttpErrorResponse) => { + expect(error.status).toEqual(404, 'status'); + expect(error.error).toEqual(emsg, 'message'); + } + ); + + const req = httpTestingController.expectOne(testUrl); + + // Respond with mock error + req.flush(emsg, { status: 404, statusText: 'Not Found' }); + }); + // #enddocregion 404 + + // #docregion network-error + it('can test for network error', () => { + const emsg = 'simulated network error'; + + httpClient.get(testUrl).subscribe( + data => fail('should have failed with the network error'), + (error: HttpErrorResponse) => { + expect(error.error.message).toEqual(emsg, 'message'); + } + ); + + const req = httpTestingController.expectOne(testUrl); + + // Create mock ErrorEvent, raised when something goes wrong at the network level. + // Connection timeout, DNS error, offline, etc + const errorEvent = new ErrorEvent('so sad', { + message: emsg, + // #enddocregion network-error + // The rest of this is optional and not used. + // Just showing that you could provide this too. + filename: 'HeroService.ts', + lineno: 42, + colno: 21 + // #docregion network-error + }); + + // Respond with mock error + req.error(errorEvent); + }); + // #enddocregion network-error + + it('httpTestingController.verify should fail if HTTP response not simulated', () => { + // Sends request + httpClient.get('some/api').subscribe(); + + // verify() should fail because haven't handled the pending request. + expect(() => httpTestingController.verify()).toThrow(); + + // Now get and flush the request so that afterEach() doesn't fail + const req = httpTestingController.expectOne('some/api'); + req.flush(null); + }); + + // Proves that verify in afterEach() really would catch error + // if test doesn't simulate the HTTP response. + // + // Must disable this test because can't catch an error in an afterEach(). + // Uncomment if you want to confirm that afterEach() does the job. + // it('afterEach() should fail when HTTP response not simulated',() => { + // // Sends request which is never handled by this test + // httpClient.get('some/api').subscribe(); + // }); +// #docregion setup +}); +// #enddocregion setup diff --git a/aio/content/examples/http/stackblitz.json b/aio/content/examples/http/stackblitz.json index db67bfd2e9..6ef625c1a0 100644 --- a/aio/content/examples/http/stackblitz.json +++ b/aio/content/examples/http/stackblitz.json @@ -3,7 +3,9 @@ "files":[ "!**/*.d.ts", "!**/*.js", - "!**/*.[1].*" + + "!src/testing/*.*", + "!src/index-specs.html" ], - "tags": ["http", "jsonp"] + "tags": ["http"] } diff --git a/aio/content/guide/http.md b/aio/content/guide/http.md index 2846e8a849..0c67dee157 100644 --- a/aio/content/guide/http.md +++ b/aio/content/guide/http.md @@ -2,218 +2,397 @@ Most front-end applications communicate with backend services over the HTTP protocol. Modern browsers support two different APIs for making HTTP requests: the `XMLHttpRequest` interface and the `fetch()` API. -With `HttpClient`, `@angular/common/http` provides a simplified API for HTTP functionality for use with Angular applications, building on top of the `XMLHttpRequest` interface exposed by browsers. -Additional benefits of `HttpClient` include testability support, strong typing of request and response objects, request and response interceptor support, and better error handling via apis based on Observables. +The `HttpClient` in `@angular/common/http` offers a simplified client HTTP API for Angular applications +that rests on the `XMLHttpRequest` interface exposed by browsers. +Additional benefits of `HttpClient` include testability features, typed request and response objects, request and response interception, `Observable` apis, and streamlined error handling. -## Setup: installing the module +You can run the that accompanies this guide. -Before you can use the `HttpClient`, you need to install the `HttpClientModule` which provides it. This can be done in your application module, and is only necessary once. +
-```typescript -// app.module.ts: +The sample app does not require a data server. +It relies on the +[Angular _in-memory-web-api_](https://github.com/angular/in-memory-web-api/blob/master/README.md), +which replaces the _HttpClient_ module's `HttpBackend`. +The replacement service simulates the behavior of a REST-like backend. -import {NgModule} from '@angular/core'; -import {BrowserModule} from '@angular/platform-browser'; +Look at the `AppModule` _imports_ to see how it is configured. -// Import HttpClientModule from @angular/common/http -import {HttpClientModule} from '@angular/common/http'; +
-@NgModule({ - imports: [ - BrowserModule, - // Include it under 'imports' in your application module - // after BrowserModule. - HttpClientModule, - ], -}) -export class MyAppModule {} -``` +## Setup -Once you import `HttpClientModule` into your app module, you can inject `HttpClient` -into your components and services. +Before you can use the `HttpClient`, you need to import the Angular `HttpClientModule`. +Most apps do so in the root `AppModule`. -## Making a request for JSON data + + -The most common type of request applications make to a backend is to request JSON data. For example, suppose you have an API endpoint that lists items, `/api/items`, which returns a JSON object of the form: +Having imported `HttpClientModule` into the `AppModule`, you can inject the `HttpClient` +into an application class as shown in the following `ConfigService` example. -```json -{ - "results": [ - "Item 1", - "Item 2", - ] -} -``` + + -The `get()` method on `HttpClient` makes accessing this data straightforward. +## Getting JSON data +Applications often request JSON data from the server. +For example, the app might need a configuration file on the server, `config.json`, +that specifies resource URLs. -```typescript -@Component(...) -export class MyComponent implements OnInit { + + - results: string[]; +The `ConfigService` fetches this file with a `get()` method on `HttpClient`. - // Inject HttpClient into your component or service. - constructor(private http: HttpClient) {} + + - ngOnInit(): void { - // Make the HTTP request: - this.http.get('/api/items').subscribe(data => { - // Read the result field from the JSON response. - this.results = data['results']; - }); - } -} -``` +A component, such as `ConfigComponent`, injects the `ConfigService` and calls +the `getConfig` service method. + + -### Typechecking the response +Because the service method returns an `Observable` of configuration data, +the component **subscribes** to the method's return value. +The subscription callback copies the data fields into the component's `config` object, +which is data-bound in the component template for display. -In the above example, the `data['results']` field access stands out because you use bracket notation to access the results field. If you tried to write `data.results`, TypeScript would correctly complain that the `Object` coming back from HTTP does not have a `results` property. That's because while `HttpClient` parsed the JSON response into an `Object`, it doesn't know what shape that object is. +### Why write a service -You can, however, tell `HttpClient` what type the response will be, which is recommended. -To do so, first you define an interface with the correct shape: +This example is so simple that it is tempting to write the `Http.get()` inside the +component itself and skip the service. -```typescript -interface ItemsResponse { - results: string[]; -} -``` +However, data access rarely stays this simple. +You typically post-process the data, add error handling, and maybe some retry logic to +cope with intermittent connectivity. -Then, when you make the `HttpClient.get` call, pass a type parameter: +The component quickly becomes cluttered with data access minutia. +The component becomes harder to understand, harder to test, and the data access logic can't be re-used or standardized. -```typescript -http.get('/api/items').subscribe(data => { - // data is now an instance of type ItemsResponse, so you can do this: - this.results = data.results; -}); -``` +That's why it is a best practice to separate presentation of data from data access by +encapsulating data access in a separate service and delegating to that service in +the component, even in simple cases like this one. + +### Type-checking the response + +The subscribe callback above requires bracket notation to extract the data values. + + + + +You can't write `data.heroesUrl` because TypeScript correctly complains that the `data` object from the service does not have a `heroesUrl` property. + +The `HttpClient.get()` method parsed the JSON server response into the anonymous `Object` type. It doesn't know what the shape of that object is. + +You can tell `HttpClient` the type of the response to make consuming the output easier and more obvious. + +First, define an interface with the correct shape: + + + + +Then, specify that interface as the `HttpClient.get()` call's type parameter in the service: + + + + +The callback in the updated component method receives a typed data object, which is +easier and safer to consume: + + + ### Reading the full response -The response body doesn't return all the data you may need. Sometimes servers return special headers or status codes to indicate certain conditions, and inspecting those can be necessary. To do this, you can tell `HttpClient` you want the full response instead of just the body with the `observe` option: +The response body doesn't return all the data you may need. Sometimes servers return special headers or status codes to indicate certain conditions that are important to the application workflow. -```typescript -http - .get('/data.json', {observe: 'response'}) - .subscribe(resp => { - // Here, resp is of type HttpResponse. - // You can inspect its headers: - console.log(resp.headers.get('X-Custom-Header')); - // And access the body directly, which is typed as MyJsonData as requested. - console.log(resp.body.someField); - }); -``` +Tell `HttpClient` that you want the full response with the `observe` option: -As you can see, the resulting object has a `body` property of the correct type. + + +Now `HttpClient.get()` returns an `Observable` of typed `HttpResponse` rather than just the JSON data. +The component's `showConfigResponse()` method displays the response headers as well as the configuration: -### Error handling + + -What happens if the request fails on the server, or if a poor network connection prevents it from even reaching the server? `HttpClient` will return an _error_ instead of a successful response. +As you can see, the response object has a `body` property of the correct type. -To handle it, add an error handler to your `.subscribe()` call: +## Error handling -```typescript -http - .get('/api/items') - .subscribe( - // Successful responses call the first callback. - data => {...}, - // Errors will call this callback instead: - err => { - console.log('Something went wrong!'); - } - ); -``` +What happens if the request fails on the server, or if a poor network connection prevents it from even reaching the server? `HttpClient` will return an _error_ object instead of a successful response. -#### Getting error details +You _could_ handle in the component by adding a second callback to the `.subscribe()`: -Detecting that an error occurred is one thing, but it's more useful to know what error actually occurred. The `err` parameter to the callback above is of type `HttpErrorResponse`, and contains useful information on what went wrong. + + -There are two types of errors that can occur. If the backend returns an unsuccessful response code (404, 500, etc.), it gets returned as an error. Also, if something goes wrong client-side, such as an exception gets thrown in an RxJS operator, or if a network error prevents the request from completing successfully, an actual `Error` will be thrown. +It's certainly a good idea to give the user some kind of feedback when data access fails. +But displaying the raw error object returned by `HttpClient` is far from the best way to do it. -In both cases, you can look at the `HttpErrorResponse` to figure out what happened. +{@a error-details} +### Getting error details -```typescript -http - .get('/api/items') - .subscribe( - data => {...}, - (err: HttpErrorResponse) => { - if (err.error instanceof Error) { - // A client-side or network error occurred. Handle it accordingly. - console.log('An error occurred:', err.error.message); - } else { - // The backend returned an unsuccessful response code. - // The response body may contain clues as to what went wrong, - console.log(`Backend returned code ${err.status}, body was: ${err.error}`); - } - } - ); -``` +Detecting that an error occurred is one thing. +Interpreting that error and composing a user-friendly response is a bit more involved. -#### `.retry()` +Two types of errors can occur. The server backend might reject the request, returning an HTTP response with a status code such as 404 or 500. These are error _responses_. -One way to deal with errors is to simply retry the request. This strategy can be useful when the errors are transient and unlikely to repeat. +Or something could go wrong on the client-side such as a network error that prevents the request from completing successfully or an exception thrown in an RxJS operator. These errors produce JavaScript `ErrorEvent` objects. -RxJS has a useful operator called `.retry()`, which automatically resubscribes to an Observable, thus reissuing the request, upon encountering an error. +The `HttpClient` captures both kinds of errors in its `HttpErrorResponse` and you can inspect that response to figure out what really happened. -First, import it: +Error inspection, interpretation, and resolution is something you want to do in the _service_, +not in the _component_. -```js -import 'rxjs/add/operator/retry'; -``` +You might first devise an error handler like this one: -Then, you can use it with HTTP Observables like this: + + -```typescript -http - .get('/api/items') - // Retry this request up to 3 times. - .retry(3) - // Any errors after the 3rd retry will fall through to the app. - .subscribe(...); -``` +Notice that this handler returns an RxJS [`ErrorObservable`](#rxjs) with a user-friendly error message. +Consumers of the service expect service methods to return an `Observable` of some kind, +even a "bad" one. -### Requesting non-JSON data +Now you take the `Observables` returned by the `HttpClient` methods +and _pipe them through_ to the error handler. -Not all APIs return JSON data. Suppose you want to read a text file on the server. You have to tell `HttpClient` that you expect a textual response: + + -```typescript -http - .get('/textfile.txt', {responseType: 'text'}) - // The Observable returned by get() is of type Observable - // because a text response was specified. There's no need to pass - // a type parameter to get(). - .subscribe(data => console.log(data)); -``` +### `retry()` + +Sometimes the error is transient and will go away automatically if you try again. +For example, network interruptions are common in mobile scenarios, and trying again +may produce a successful result. + +The [RxJS library](#rxjs) offers several _retry_ operators that are worth exploring. +The simplest is called `retry()` and it automatically re-subscribes to a failed `Observable` a specified number of times. _Re-subscribing_ to the result of an `HttpClient` method call has the effect of reissuing the HTTP request. + +_Pipe_ it onto the `HttpClient` method result just before the error handler. + + + + +{@a rxjs} +## Observables and operators + +The previous sections of this guide referred to RxJS `Observables` and operators such as `catchError` and `retry`. +You will encounter more RxJS artifacts as you continue below. + +[RxJS](http://reactivex.io/rxjs/) is a library for composing asynchronous and callback-based code +in a _functional, reactive style_. +Many Angular APIs, including `HttpClient`, produce and consume RxJS `Observables`. + +RxJS itself is out-of-scope for this guide. You will find many learning resources on the web. +While you can get by with a minimum of RxJS knowledge, you'll want to grow your RxJS skills over time in order to use `HttpClient` effectively. + +If you're following along with these code snippets, note that you must import the RxJS observable and operator symbols that appear in those snippets. These `ConfigService` imports are typical. + + + + +## Requesting non-JSON data + +Not all APIs return JSON data. In this next example, +a `DownloaderService` method reads a text file from the server +and logs the file contents, before returning those contents to the caller +as an `Observable`. + + + + +`HttpClient.get()` returns a string rather than the default JSON because of the `responseType` option. + +The RxJS `tap` operator (as in "wiretap") lets the code inspect good and error values passing through the observable without disturbing them. + +A `download()` method in the `DownloaderComponent` initiates the request by subscribing to the service method. + + + ## Sending data to the server -In addition to fetching data from the server, `HttpClient` supports mutating requests, that is, sending data to the server in various forms. +In addition to fetching data from the server, `HttpClient` supports mutating requests, that is, sending data to the server with other HTTP methods such as PUT, POST, and DELETE. + +The sample app for this guide includes a simplified version of the "Tour of Heroes" example +that fetches heroes and enables users to add, delete, and update them. + +The following sections excerpt methods of the sample's `HeroesService`. + +### Adding headers + +Many servers require extra headers for save operations. +For example, they may require a "Content-Type" header to explicitly declare +the MIME type of the request body. +Or perhaps the server requires an authorization token. + +The `HeroesService` defines such headers in an `httpOptions` object that will be passed +to every `HttpClient` save method. + + + ### Making a POST request -One common operation is to POST data to a server; for example when submitting a form. The code for -sending a POST request is very similar to the code for GET: +Apps often POST data to a server. They POST when submitting a form. +In the following example, the `HeroService` posts when adding a hero to the database. -```typescript -const body = {name: 'Brad'}; + + + +The `HttpClient.post()` method is similar to `get()` in that it has a type parameter +(you're expecting the server to return the new hero) +and it takes a resource URL. + +It takes two more parameters: + +1. `hero` - the data to POST in the body of the request. +1. `httpOptions` - the method options which, in this case, [specify required headers](#adding-headers). + +Of course it catches errors in much the same manner [described above](#error-details). +It also _taps_ the returned observable in order to log the successful POST. + +The `HeroesComponent` initiates the actual POST operation by subscribing to +the `Observable` returned by this service method. + + + + +When the server responds successfully with the newly added hero, the component adds +that hero to the displayed `heroes` list. + +### Making a DELETE request + +This application deletes a hero with the `HttpClient.delete` method by passing the hero's id +in the request URL. + + + + +The `HeroesComponent` initiates the actual DELETE operation by subscribing to +the `Observable` returned by this service method. + + + -http - .post('/api/developers/add', body) - // See below - subscribe() is still necessary when using post(). - .subscribe(...); -```
-*Note the `subscribe()` method.* All Observables returned from `HttpClient` are _cold_, which is to say that they are _blueprints_ for making requests. Nothing will happen until you call `subscribe()`, and every such call will make a separate request. For example, this code sends a POST request with the same data twice: +You must call _subscribe()_ or nothing happens! -```typescript -const req = http.post('/api/items/add', body); +
+ +The component isn't expecting a result from the delete operation and +subscribes without a callback. +The bare `.subscribe()` _seems_ pointless. + +In fact, it is essential. +Merely calling `HeroService.addHero()` **does not initiate the DELETE request.** + + + + +{@a always-subscribe} +### Always _subscribe_! + +An `HttpClient` method does not begin its HTTP request until you call `subscribe()` on the observable returned by that method. This is true for _all_ `HttpClient` _methods_. + +
+ +The [`AsyncPipe`](api/common/AsyncPipe) subscribes (and unsubscribes) for you automatically. + +
+ +All observables returned from `HttpClient` methods are _cold_ by design. +Execution of the HTTP request is _deferred_, allowing you to extend the +observable with additional operations such as `tap` and `catchError` + before anything actually happens. + +Calling `subscribe(...)` triggers execution of the observable and causes +`HttpClient` to compose and send the HTTP request to the server. + +You can think of these observables as _blueprints_ for actual HTTP requests. + +
+ +In fact, each `subscribe()` initiates a separate, independent execution of the observable. +Subscribing twice results in two HTTP requests. + +```javascript +const req = http.get('/api/heroes'); // 0 requests made - .subscribe() not called. req.subscribe(); // 1 request made. @@ -222,315 +401,503 @@ req.subscribe(); ```
-### Configuring other parts of the request +### Making a PUT request -Besides the URL and a possible request body, there are other aspects of an outgoing request which you may wish to configure. All of these are available via an options object, which you pass to the request. +An app will send a PUT request to completely replace a resource with updated data. +The following `HeroService` example is just like the POST example. -#### Headers + + -One common task is adding an `Authorization` header to outgoing requests. Here's how you do that: - -```typescript -http - .post('/api/items/add', body, { - headers: new HttpHeaders().set('Authorization', 'my-auth-token'), - }) - .subscribe(); -``` - -The `HttpHeaders` class is immutable, so every `set()` returns a new instance and applies the changes. - -#### URL Parameters - -Adding URL parameters works in the same way. To send a request with the `id` parameter set to `3`, you would do: - -```typescript -http - .post('/api/items/add', body, { - params: new HttpParams().set('id', '3'), - }) - .subscribe(); -``` - -In this way, you send the POST request to the URL `/api/items/add?id=3`. +For the reasons [explained above](#always-subscribe), the caller (`HeroesComponent.update()` in this case) must `subscribe()` to the observable returned from the `HttpClient.put()` +in order to initiate the request. ## Advanced usage -The above sections detail how to use the basic HTTP functionality in `@angular/common/http`, but sometimes you need to do more than just make requests and get data back. +The above sections detail how to use the basic HTTP functionality in `@angular/common/http`, but sometimes you need to do more than make simple requests and get data back. + +### Configuring the request + +Other aspects of an outgoing request can be configured via the options object +passed as the last argument to the `HttpClient` method. + +You [saw earlier](#adding-headers) that the `HeroService` sets the default headers by +passing an options object (`httpOptions`) to its save methods. +You can do more. + +#### Update headers + +You can't directly modify the existing headers within the previous options +object because instances of the `HttpHeaders` class are immutable. + +Use the `set()` method instead. +It returns a clone of the current instance with the new changes applied. + +Here's how you might update the authorization header (after the old token expired) +before making the next request. + + + + +#### URL Parameters + +Adding URL search parameters works a similar way. +Here is a `searchHeroes` method that queries for heroes whose names contain the search term. + + + + +If there is a search term, the code constructs an options object with an HTML URL encoded search parameter. If the term were "foo", the GET request URL would be `api/heroes/?name=foo`. + +The `HttpParms` are immutable so you'll have to use the `set()` method to update the options. + +### Debouncing requests + +The sample includes an _npm package search_ feature. + +When the user enters a name in a search-box, the `PackageSearchComponent` sends +a search request for a package with that name to the NPM web api. + +Here's a pertinent excerpt from the template: + + + + +The `(keyup)` event binding sends every keystroke to the component's `search()` method. + +Sending a request for every keystroke could be expensive. +It's better to wait until the user stops typing and then send a request. +That's easy to implement with RxJS operators, as shown in this excerpt. + + + + +The `searchText$` is the sequence of search-box values coming from the user. +It's defined as an RxJS `Subject`, which means it is an `Observable` +that can also produce values for itself by calling `next(value)`, +as happens in the `search()` method. + +Rather than forward every `searchText` value directly to the injected `PackageSearchService`, +the code in `ngOnInit()` _pipes_ search values through three operators: + +1. `debounceTime(500)` - wait for the user to stop typing (1/2 second in this case). +1. `distinctUntilChanged()` - wait until the search text changes. +1. `switchMap()` - send the search request to the service. + +The code sets `packages$` to this re-composed `Observable` of search results. +The template subscribes to `packages$` with the [AsyncPipe](api/common/AsyncPipe) +and displays search results as they arrive. + +A search value reaches the service only if it's a new value and the user has stopped typing. + +
+ +The `withRefresh` option is explained [below](#cache-refresh). + +
+ +#### _switchMap()_ + +The `switchMap()` operator has three important characteristics. + +1. It takes a function argument that returns an `Observable`. +`PackageSearchService.search` returns an `Observable`, as other data service methods do. + +2. If a previous search request is still _in-flight_ (as when the connection is poor), +it cancels that request and sends a new one. + +3. It returns service responses in their original request order, even if the +server returns them out of order. -### Intercepting all requests or responses +
-A major feature of `@angular/common/http` is _interception_, the ability to declare interceptors which sit in between your application and the backend. When your application makes a request, interceptors transform it -before sending it to the server, and the interceptors can transform the response on its way back before your application sees it. This is useful for everything from authentication to logging. +If you think you'll reuse this debouncing logic, +consider moving it to a utility function or into the `PackageSearchService` itself. -#### Writing an interceptor +
-To implement an interceptor, you declare a class that implements `HttpInterceptor`, which -has a single `intercept()` method. Here is a simple interceptor which does nothing but forward the request through without altering it: +### Intercepting requests and responses -```typescript -import {Injectable} from '@angular/core'; -import {HttpEvent, HttpInterceptor, HttpHandler, HttpRequest} from '@angular/common/http'; +_HTTP Interception_ is a major feature of `@angular/common/http`. +With interception, you declare _interceptors_ that inspect and transform HTTP requests from your application to the server. +The same interceptors may also inspect and transform the server's responses on their way back to the application. +Multiple interceptors form a _forward-and-backward_ chain of request/response handlers. -import {Observable} from 'rxjs/Observable'; +Interceptors can perform a variety of _implicit_ tasks, from authentication to logging, in a routine, standard way, for every HTTP request/response. -@Injectable() -export class NoopInterceptor implements HttpInterceptor { - intercept(req: HttpRequest, next: HttpHandler): Observable> { - return next.handle(req); - } +Without interception, developers would have to implement these tasks _explicitly_ +for each `HttpClient` method call. + +#### Write an interceptor + +To implement an interceptor, declare a class that implements the `intercept()` method of the `HttpInterceptor` interface. + + Here is a do-nothing _noop_ interceptor that simply passes the request through without touching it: + + + +The `intercept` method transforms a request into an `Observable` that eventually returns the HTTP response. +In this sense, each interceptor is fully capable of handling the request entirely by itself. + +Most interceptors inspect the request on the way in and forward the (perhaps altered) request to the `handle()` method of the `next` object which implements the [`HttpHandler`](api/common/http/HttpHandler) interface. + +```javascript +export abstract class HttpHandler { + abstract handle(req: HttpRequest): Observable>; } ``` -`intercept` is a method which transforms a request into an Observable that eventually returns the response. In this sense, each interceptor is entirely responsible for handling the request by itself. +Like `intercept()`, the `handle()` method transforms an HTTP request into an `Observable` of [`HttpEvents`](#httpevents) which ultimately include the server's response. The `intercept()` method could inspect that observable and alter it before returning it to the caller. -Most of the time, though, interceptors will make some minor change to the request and forward it to the rest of the chain. That's where the `next` parameter comes in. `next` is an `HttpHandler`, an interface that, similar to `intercept`, transforms a request into an Observable for the response. In an interceptor, `next` always represents the next interceptor in the chain, if any, or the final backend if there are no more interceptors. So most interceptors will end by calling `next` on the request they transformed. +This _no-op_ interceptor simply calls `next.handle()` with the original request and returns the observable without doing a thing. -Our do-nothing handler simply calls `next.handle` on the original request, forwarding it without mutating it at all. +#### The _next_ object -This pattern is similar to those in middleware frameworks such as Express.js. +The `next` object represents the next interceptor in the chain of interceptors. +The final `next` in the chain is the `HttpClient` backend handler that sends the request to the server and receives the server's response. -##### Providing your interceptor -Simply declaring the `NoopInterceptor` above doesn't cause your app to use it. You need to wire it up in your app module by providing it as an interceptor, as follows: +Most interceptors call `next.handle()` so that the request flows through to the next interceptor and, eventually, the backend handler. +An interceptor _could_ skip calling `next.handle()`, short-circuit the chain, and [return its own `Observable`](#caching) with an artificial server response. -```typescript -import {NgModule} from '@angular/core'; -import {HTTP_INTERCEPTORS} from '@angular/common/http'; +This is a common middleware pattern found in frameworks such as Express.js. -import {NoopInterceptor} from 'noop.interceptor.ts'; +#### Provide the interceptor -@NgModule({ - providers: [{ - provide: HTTP_INTERCEPTORS, - useClass: NoopInterceptor, - multi: true, - }], -}) -export class AppModule {} +The `NoopInterceptor` is a service managed by Angular's [dependency injection (DI)](guide/dependency-injection) system. +Like other services, you must provide the interceptor class before the app can use it. + +Because interceptors are (optional) dependencies of the `HttpClient` service, +you must provide them in the same injector (or a parent of the injector) that provides `HttpClient`. +Interceptors provided _after_ DI creates the `HttpClient` are ignored. + +This app provides `HttpClient` in the app's root injector, as a side-effect of importing the `HttpClientModule` in `AppModule`. +You should provide interceptors in `AppModule` as well. + +After importing the `HTTP_INTERCEPTORS` injection token from `@angular/common/http`, +write the `NoopInterceptor` provider like this: + + + + +Note the `multi: true` option. +This required setting tells Angular that `HTTP_INTERCEPTORS` is a token for a _multiprovider_ +that injects an array of values, rather than a single value. + +You _could_ add this provider directly to the providers array of the `AppModule`. +However, it's rather verbose and there's a good chance that +you'll create more interceptors and provide them in the same way. +You must also pay [close attention to the order](#interceptor-order) +in which you provide these interceptors. + +Consider creating a "barrel" file that gathers all the interceptor providers into an `httpInterceptorProviders` array, starting with this first one, the `NoopInterceptor`. + + + + +Then import and add it to the `AppModule` _providers array_ like this: + + + + +As you create new interceptors, add them to the `httpInterceptorProviders` array and +you won't have to revisit the `AppModule`. + +
+ +There are many more interceptors in the complete sample code. + +
+ +#### Interceptor order + +Angular applies interceptors in the order that you provide them. +If you provide interceptors _A_, then _B_, then _C_, requests will flow in _A->B->C_ and +responses will flow out _C->B->A_. + +You cannot change the order or remove interceptors later. +If you need to enable and disable an interceptor dynamically, you'll have to build that capability into the interceptor itself. + +#### _HttpEvents_ + +You may have expected the `intercept()` and `handle()` methods to return observables of `HttpResponse` as most `HttpClient` methods do. + +Instead they return observables of `HttpEvent`. + +That's because interceptors work at a lower level than those `HttpClient` methods. A single HTTP request can generate multiple _events_, including upload and download progress events. The `HttpResponse` class itself is actually an event, whose type is `HttpEventType.HttpResponseEvent`. + +Many interceptors are only concerned with the outgoing request and simply return the event stream from `next.handle()` without modifying it. + +But interceptors that examine and modify the response from `next.handle()` +will see all of these events. +Your interceptor should return _every event untouched_ unless it has a _compelling reason to do otherwise_. + +#### Immutability + +Although interceptors are capable of mutating requests and responses, +the `HttpRequest` and `HttpResponse` instance properties are `readonly`, +rendering them largely immutable. + +They are immutable for a good reason: the app may retry a request several times before it succeeds, which means that the interceptor chain may re-process the same request multiple times. +If an interceptor could modify the original request object, the re-tried operation would start from the modified request rather than the original. Immutability ensures that interceptors see the same request for each try. + +TypeScript will prevent you from setting `HttpRequest` readonly properties. + +```javascript + // Typescript disallows the following assignment because req.url is readonly + req.url = req.url.replace('http://', 'https://'); +``` +To alter the request, clone it first and modify the clone before passing it to `next.handle()`. +You can clone and modify the request in a single step as in this example. + + + + +The `clone()` method's hash argument allows you to mutate specific properties of the request while copying the others. + +##### The request body + +The `readonly` assignment guard can't prevent deep updates and, in particular, +it can't prevent you from modifying a property of a request body object. + +```javascript + req.body.name = req.body.name.trim(); // bad idea! ``` -Note the `multi: true` option. This is required and tells Angular that `HTTP_INTERCEPTORS` is an array of values, rather than a single value. +If you must mutate the request body, copy it first, change the copy, +`clone()` the request, and set the clone's body with the new body, as in the following example. + + -##### Events +##### Clearing the request body -You may have also noticed that the Observable returned by `intercept` and `HttpHandler.handle` is not an `Observable>` but an `Observable>`. That's because interceptors work at a lower level than the `HttpClient` interface. A single request can generate multiple events, including upload and download progress events. The `HttpResponse` class is actually an event itself, with a `type` of `HttpEventType.HttpResponseEvent`. +Sometimes you need to clear the request body rather than replace it. +If you set the cloned request body to `undefined`, Angular assumes you intend to leave the body as is. +That is not what you want. +If you set the cloned request body to `null`, Angular knows you intend to clear the request body. -An interceptor must pass through all events that it does not understand or intend to modify. It must not filter out events it didn't expect to process. Many interceptors are only concerned with the outgoing request, though, and will simply return the event stream from `next` without modifying it. - - -##### Ordering - -When you provide multiple interceptors in an application, Angular applies them in the order that you -provided them. - -##### Immutability - -Interceptors exist to examine and mutate outgoing requests and incoming responses. However, it may be surprising to learn that the `HttpRequest` and `HttpResponse` classes are largely immutable. - -This is for a reason: because the app may retry requests, the interceptor chain may process an individual request multiple times. If requests were mutable, a retried request would be different than the original request. Immutability ensures the interceptors see the same request for each try. - -There is one case where type safety cannot protect you when writing interceptors—the request body. It is invalid to mutate a request body within an interceptor, but this is not checked by the type system. - -If you have a need to mutate the request body, you need to copy the request body, mutate the copy, and then use `clone()` to copy the request and set the new body. - -Since requests are immutable, they cannot be modified directly. To mutate them, use `clone()`: - -```typescript -intercept(req: HttpRequest, next: HttpHandler): Observable> { - // This is a duplicate. It is exactly the same as the original. - const dupReq = req.clone(); - - // Change the URL and replace 'http://' with 'https://' - const secureReq = req.clone({url: req.url.replace('http://', 'https://')}); -} +```javascript + newReq = req.clone({ ... }); // body not mentioned => preserve original body + newReq = req.clone({ body: undefined }); // preserve original body + newReq = req.clone({ body: null }); // clear the body ``` -As you can see, the hash accepted by `clone()` allows you to mutate specific properties of the request while copying the others. +#### Set default headers -#### Setting new headers +Apps often use an interceptor to set default headers on outgoing requests. -A common use of interceptors is to set default headers on outgoing responses. For example, assuming you have an injectable `AuthService` which can provide an authentication token, here is how you would write an interceptor which adds it to all outgoing requests: +The sample app has an `AuthService` that produces an authorization token. +Here is its `AuthInterceptor` that injects that service to get the token and +adds an authorization header with that token to every outgoing request: -```typescript -import {Injectable} from '@angular/core'; -import {HttpEvent, HttpInterceptor, HttpHandler, HttpRequest} from '@angular/common/http'; + + -@Injectable() -export class AuthInterceptor implements HttpInterceptor { - constructor(private auth: AuthService) {} +The practice of cloning a request to set new headers is so common that +there's a `setHeaders` shortcut for it: - intercept(req: HttpRequest, next: HttpHandler): Observable> { - // Get the auth header from the service. - const authHeader = this.auth.getAuthorizationHeader(); - // Clone the request to add the new header. - const authReq = req.clone({headers: req.headers.set('Authorization', authHeader)}); - // Pass on the cloned request instead of the original request. - return next.handle(authReq); - } -} -``` - -The practice of cloning a request to set new headers is so common that there's actually a shortcut for it: - -```typescript -const authReq = req.clone({setHeaders: {Authorization: authHeader}}); -``` + + An interceptor that alters headers can be used for a number of different operations, including: * Authentication/authorization -* Caching behavior; for example, If-Modified-Since +* Caching behavior; for example, `If-Modified-Since` * XSRF protection #### Logging -Because interceptors can process the request and response _together_, they can do things like log or time requests. Consider this interceptor which uses `console.log` to show how long each request takes: +Because interceptors can process the request and response _together_, they can do things like time and log +an entire HTTP operation. -```typescript -import 'rxjs/add/operator/do'; +Consider the following `LoggingInterceptor`, which captures the time of the request, +the time of the response, and logs the outcome with the elapsed time +with the injected `MessageService`. -export class TimingInterceptor implements HttpInterceptor { - constructor(private auth: AuthService) {} + + - intercept(req: HttpRequest, next: HttpHandler): Observable> { - const started = Date.now(); - return next - .handle(req) - .do(event => { - if (event instanceof HttpResponse) { - const elapsed = Date.now() - started; - console.log(`Request for ${req.urlWithParams} took ${elapsed} ms.`); - } - }); - } -} -``` -Notice the RxJS `do()` operator—it adds a side effect to an Observable without affecting the values on the stream. Here, it detects the `HttpResponse` event and logs the time the request took. +The RxJS `tap` operator captures whether the request succeed or failed. +The RxJS `finalize` operator is called when the response observable either errors or completes (which it must), +and reports the outcome to the `MessageService`. + +Neither `tap` nor `finalize` touch the values of the observable stream returned to the caller. #### Caching -You can also use interceptors to implement caching. For this example, assume that you've written an HTTP cache with a simple interface: +Interceptors can handle requests by themselves, without forwarding to `next.handle()`. -```typescript -abstract class HttpCache { - /** - * Returns a cached response, if any, or null if not present. - */ - abstract get(req: HttpRequest): HttpResponse|null; +For example, you might decide to cache certain requests and responses to improve performance. +You can delegate caching to an interceptor without disturbing your existing data services. - /** - * Adds or updates the response in the cache. - */ - abstract put(req: HttpRequest, resp: HttpResponse): void; -} -``` +The `CachingInterceptor` demonstrates this approach. -An interceptor can apply this cache to outgoing requests. + + -```typescript -@Injectable() -export class CachingInterceptor implements HttpInterceptor { - constructor(private cache: HttpCache) {} +The `isCachable()` function determines if the request is cachable. +In this sample, only GET requests to the npm package search api are cachable. - intercept(req: HttpRequest, next: HttpHandler): Observable> { - // Before doing anything, it's important to only cache GET requests. - // Skip this interceptor if the request method isn't GET. - if (req.method !== 'GET') { - return next.handle(req); - } +If the request is not cachable, the interceptor simply forwards the request +to the next handler in the chain. - // First, check the cache to see if this request exists. - const cachedResponse = this.cache.get(req); - if (cachedResponse) { - // A cached response exists. Serve it instead of forwarding - // the request to the next handler. - return Observable.of(cachedResponse); - } +If a cachable request is found in the cache, the interceptor returns an `of()` _observable_ with +the cached response, by-passing the `next` handler (and all other interceptors downstream). - // No cached response exists. Go to the network, and cache - // the response when it arrives. - return next.handle(req).do(event => { - // Remember, there may be other events besides just the response. - if (event instanceof HttpResponse) { - // Update the cache. - this.cache.put(req, event); - } - }); - } -} -``` +If a cachable request is not in cache, the code calls `sendRequest`. -Obviously this example glosses over request matching, cache invalidation, etc., but it's easy to see that interceptors have a lot of power beyond just transforming requests. If desired, they can be used to completely take over the request flow. +{@a send-request} + + -To really demonstrate their flexibility, you can change the above example to return _two_ response events if the request exists in cache—the cached response first, and an updated network response later. +The `sendRequest` function creates a [request clone](#immutability) without headers +because the npm api forbids them. -```typescript -intercept(req: HttpRequest, next: HttpHandler): Observable> { - // Still skip non-GET requests. - if (req.method !== 'GET') { - return next.handle(req); - } +It forwards that request to `next.handle()` which ultimately calls the server and +returns the server's response. - // This will be an Observable of the cached value if there is one, - // or an empty Observable otherwise. It starts out empty. - let maybeCachedResponse: Observable> = Observable.empty(); +Note how `sendRequest` _intercepts the response_ on its way back to the application. +It _pipes_ the response through the `tap()` operator, +whose callback adds the response to the cache. - // Check the cache. - const cachedResponse = this.cache.get(req); - if (cachedResponse) { - maybeCachedResponse = Observable.of(cachedResponse); - } +The original response continues untouched back up through the chain of interceptors +to the application caller. - // Create an Observable (but don't subscribe) that represents making - // the network request and caching the value. - const networkResponse = next.handle(req).do(event => { - // Just like before, check for the HttpResponse event and cache it. - if (event instanceof HttpResponse) { - this.cache.put(req, event); - } - }); +Data services, such as `PackageSearchService`, are unaware that +some of their `HttpClient` requests actually return cached responses. - // Now, combine the two and send the cached response first (if there is - // one), and the network response second. - return Observable.concat(maybeCachedResponse, networkResponse); -} -``` +{@a cache-refresh} +#### Return a multi-valued _Observable_ -Now anyone doing `http.get(url)` will receive _two_ responses if that URL has been cached before. +The `HttpClient.get()` method normally returns an _observable_ +that either emits the data or an error. +Some folks describe it as a "_one and done_" observable. + +But an interceptor can change this to an _observable_ that emits more than once. + +A revised version of the `CachingInterceptor` optionally returns an _observable_ that +immediately emits the cached response, sends the request to the npm web api anyway, +and emits again later with the updated search results. + + + + +The _cache-then-refresh_ option is triggered by the presence of a **custom `x-refresh` header**. + +
+ +A checkbox on the `PackageSearchComponent` toggles a `withRefresh` flag, +which is one of the arguments to `PackageSearchService.search()`. +That `search()` method creates the custom `x-refresh` header +and adds it to the request before calling `HttpClient.get()`. + +
+ +The revised `CachingInterceptor` sets up a server request +whether there's a cached value or not, +using the same `sendRequest()` method described [above](#send-request). +The `results$` observable will make the request when subscribed. + +If there's no cached value, the interceptor returns `results$`. + +If there is a cached value, the code _pipes_ the cached response onto +`results$`, producing a recomposed observable that emits twice, +the cached response first (and immediately), followed later +by the response from the server. +Subscribers see a sequence of _two_ responses. ### Listening to progress events -Sometimes applications need to transfer large amounts of data, and those transfers can take time. It's a good user experience practice to provide feedback on the progress of such transfers; for example, uploading files—and `@angular/common/http` supports this. +Sometimes applications transfer large amounts of data and those transfers can take a long time. +File uploads are a typical example. +Give the users a better experience by providing feedback on the progress of such transfers. -To make a request with progress events enabled, first create an instance of `HttpRequest` with the special `reportProgress` option set: +To make a request with progress events enabled, you can create an instance of `HttpRequest` +with the `reportProgress` option set true to enable tracking of progress events. -```typescript -const req = new HttpRequest('POST', '/upload/file', file, { - reportProgress: true, -}); -``` + + -This option enables tracking of progress events. Remember, every progress event triggers -change detection, so only turn them on if you intend to actually update the UI on each event. +
-Next, make the request through the `request()` method of `HttpClient`. The result will be an Observable of events, just like with interceptors: +Every progress event triggers change detection, so only turn them on if you truly intend to report progress in the UI. -```typescript -http.request(req).subscribe(event => { - // Via this API, you get access to the raw event stream. - // Look for upload progress events. - if (event.type === HttpEventType.UploadProgress) { - // This is an upload progress event. Compute and show the % done: - const percentDone = Math.round(100 * event.loaded / event.total); - console.log(`File is ${percentDone}% uploaded.`); - } else if (event instanceof HttpResponse) { - console.log('File is completely uploaded!'); - } -}); -``` +
+ +Next, pass this request object to the `HttpClient.request()` method, which +returns an `Observable` of `HttpEvents`, the same events processed by interceptors: + + + + +The `getEventMessage` method interprets each type of `HttpEvent` in the event stream. + + + + +
+ +The sample app for this guide doesn't have a server that accepts uploaded files. +The `UploadInterceptor` in `app/http-interceptors/upload-interceptor.ts` +intercepts and short-circuits upload requests +by returning an observable of simulated events. + +
## Security: XSRF Protection @@ -546,107 +913,133 @@ cookie with a salt for added security. In order to prevent collisions in environments where multiple Angular apps share the same domain or subdomain, give each application a unique cookie name.
-*Note that `HttpClient`'s support is only the client half of the XSRF protection scheme.* Your backend service must be configured to set the cookie for your page, and to verify that the header is present on all eligible requests. If not, Angular's default protection will be ineffective. + +*Note that `HttpClient` supports only the client half of the XSRF protection scheme.* +Your backend service must be configured to set the cookie for your page, and to verify that +the header is present on all eligible requests. +If not, Angular's default protection will be ineffective. +
### Configuring custom cookie/header names -If your backend service uses different names for the XSRF token cookie or header, use `HttpClientXsrfModule.withOptions()` to override the defaults. +If your backend service uses different names for the XSRF token cookie or header, +use `HttpClientXsrfModule.withOptions()` to override the defaults. -```typescript -imports: [ - HttpClientModule, - HttpClientXsrfModule.withOptions({ - cookieName: 'My-Xsrf-Cookie', - headerName: 'My-Xsrf-Header', - }), -] -``` + + ## Testing HTTP requests -Like any external dependency, the HTTP backend needs to be mocked as part of good testing practice. `@angular/common/http` provides a testing library `@angular/common/http/testing` that makes setting up such mocking straightforward. +Like any external dependency, the HTTP backend needs to be mocked +so your tests can simulate interaction with a remote server. +The `@angular/common/http/testing` library makes +setting up such mocking straightforward. ### Mocking philosophy -Angular's HTTP testing library is designed for a pattern of testing where the app executes code and makes requests first. After that, tests expect that certain requests have or have not been made, perform assertions against those requests, and finally provide responses by "flushing" each expected request, which may trigger more new requests, etc. At the end, tests can optionally verify that the app has made no unexpected requests. +Angular's HTTP testing library is designed for a pattern of testing wherein +the the app executes code and makes requests first. + +Then a test expects that certain requests have or have not been made, +performs assertions against those requests, +and finally provide responses by "flushing" each expected request. + +At the end, tests may verify that the app has made no unexpected requests. + +
+ +You can run these sample tests +in a live coding environment. + +The tests described in this guide are in `src/testing/http-client.spec.ts`. +There are also tests of an application data service that call `HttpClient` in +`src/app/heroes/heroes.service.spec.ts`. + +
### Setup -To begin testing requests made through `HttpClient`, import `HttpClientTestingModule` and add it to your `TestBed` setup, like so: +To begin testing calls to `HttpClient`, +import the `HttpClientTestingModule` and the mocking controller, `HttpTestingController`, +along with the other symbols your tests require. -```typescript + + -import {HttpClientTestingModule} from '@angular/common/http/testing'; +Then add the `HttpClientTestingModule` to the `TestBed` and continue with +the setup of the _service-under-test_. -beforeEach(() => { - TestBed.configureTestingModule({ - ..., - imports: [ - HttpClientTestingModule, - ], - }) -}); -``` + + -That's it. Now requests made in the course of your tests will hit the testing backend instead of the normal backend. +Now requests made in the course of your tests will hit the testing backend instead of the normal backend. + +This setup also calls `TestBed.get()` to inject the `HttpClient` service and the mocking controller +so they can be referenced during the tests. ### Expecting and answering requests -With the mock installed via the module, you can write a test that expects a GET Request to occur and provides a mock response. The following example does this by injecting both the `HttpClient` into the test and a class called `HttpTestingController` +Now you can write a test that expects a GET Request to occur and provides a mock response. -```typescript -it('expects a GET request', inject([HttpClient, HttpTestingController], (http: HttpClient, httpMock: HttpTestingController) => { - // Make an HTTP GET request, and expect that it return an object - // of the form {name: 'Test Data'}. - http - .get('/data') - .subscribe(data => expect(data['name']).toEqual('Test Data')); - - // At this point, the request is pending, and no response has been - // sent. The next step is to expect that the request happened. - const req = httpMock.expectOne('/data'); - - // If no request with that URL was made, or if multiple requests match, - // expectOne() would throw. However this test makes only one request to - // this URL, so it will match and return a mock request. The mock request - // can be used to deliver a response or make assertions against the - // request. In this case, the test asserts that the request is a GET. - expect(req.request.method).toEqual('GET'); - - // Next, fulfill the request by transmitting a response. - req.flush({name: 'Test Data'}); - - // Finally, assert that there are no outstanding requests. - httpMock.verify(); -})); -``` + + The last step, verifying that no requests remain outstanding, is common enough for you to move it into an `afterEach()` step: -```typescript -afterEach(inject([HttpTestingController], (httpMock: HttpTestingController) => { - httpMock.verify(); -})); -``` + + #### Custom request expectations -If matching by URL isn't sufficient, it's possible to implement your own matching function. For example, you could look for an outgoing request that has an Authorization header: +If matching by URL isn't sufficient, it's possible to implement your own matching function. +For example, you could look for an outgoing request that has an authorization header: -```typescript -const req = httpMock.expectOne((req) => req.headers.has('Authorization')); -``` + + -Just as with the `expectOne()` by URL in the test above, if 0 or 2+ requests match this expectation, it will throw. +As with the previous `expectOne()`, +the test will fail if 0 or 2+ requests satisfy this predicate. #### Handling more than one request -If you need to respond to duplicate requests in your test, use the `match()` API instead of `expectOne()`, which takes the same arguments but returns an array of matching requests. Once returned, these requests are removed from future matching and are your responsibility to verify and flush. +If you need to respond to duplicate requests in your test, use the `match()` API instead of `expectOne()`. +It takes the same arguments but returns an array of matching requests. +Once returned, these requests are removed from future matching and +you are responsible for flushing and verifying them. -```typescript -// Expect that 5 pings have been made and flush them. -const reqs = httpMock.match('/ping'); -expect(reqs.length).toBe(5); -reqs.forEach(req => req.flush()); -``` + + + +### Testing for errors + +You should test the app's defenses against HTTP requests that fail. + +Call `request.error()` with an `ErrorEvent` instead of `request.flush()`, as in this example. + + + diff --git a/aio/tools/examples/run-example-e2e.js b/aio/tools/examples/run-example-e2e.js index 3cacd7573c..d45a8acea2 100644 --- a/aio/tools/examples/run-example-e2e.js +++ b/aio/tools/examples/run-example-e2e.js @@ -17,7 +17,6 @@ const CLI_SPEC_FILENAME = 'e2e/app.e2e-spec.ts'; const EXAMPLE_CONFIG_FILENAME = 'example-config.json'; const IGNORED_EXAMPLES = [ // temporary ignores 'quickstart', - 'http', 'setup', 'webpack', 'upgrade-p'