feat(http): add basic http service

This implementation only works in JavaScript, while the Observable transpilation
story gets worked out. Right now, the service just makes a simple request,
and returns an Observable of Response.

Additional functionality will be captured in separate issues.

Fixes #2028
This commit is contained in:
Jeff Cross
2015-04-28 23:07:55 -07:00
parent 363b9ba415
commit 21568106b1
35 changed files with 1054 additions and 2 deletions

View File

@ -107,5 +107,5 @@ export class EventEmitter extends Observable {
throw(error) { this._subject.onError(error); }
return (value) { this._subject.onCompleted(); }
return (value?) { this._subject.onCompleted(); }
}

View File

@ -238,6 +238,7 @@ export class ListWrapper {
l.sort();
}
}
static toString<T>(l: List<T>): string { return l.toString(); }
}
export function isListLikeIterable(obj): boolean {

View File

@ -0,0 +1,9 @@
library angular2.src.http.backends.browser_xhr;
/// import 'dart:html' show HttpRequest;
/// import 'package:angular2/di.dart';
/// @Injectable()
/// class BrowserXHR {
/// factory BrowserXHR() => new HttpRequest();
/// }

View File

@ -0,0 +1,9 @@
declare var window;
import {Injectable} from 'angular2/di';
// Make sure not to evaluate this in a non-browser environment!
@Injectable()
export class BrowserXHR {
constructor() { return <any>(new window.XMLHttpRequest()); }
}

View File

@ -0,0 +1,106 @@
import {Injectable} from 'angular2/di';
import {Request} from 'angular2/src/http/static_request';
import {Response} from 'angular2/src/http/static_response';
import {ReadyStates} from 'angular2/src/http/enums';
import * as Rx from 'rx';
/**
* Connection represents a request and response for an underlying transport, like XHR or mock.
* The mock implementation contains helper methods to respond to connections within tests.
* API subject to change and expand.
**/
export class Connection {
/**
* Observer to call on download progress, if provided in config.
**/
downloadObserver: Rx.Observer<Response>;
/**
* TODO
* Name `readyState` should change to be more generic, and states could be made to be more
* descriptive than XHR states.
**/
readyState: ReadyStates;
request: Request;
response: Rx.Subject<Response>;
constructor(req: Request) {
// State
if (Rx.hasOwnProperty('default')) {
this.response = new ((<any>Rx).default.Rx.Subject)();
} else {
this.response = new Rx.Subject<Response>();
}
this.readyState = ReadyStates.OPEN;
this.request = req;
this.dispose = this.dispose.bind(this);
}
dispose() {
if (this.readyState !== ReadyStates.DONE) {
this.readyState = ReadyStates.CANCELLED;
}
}
/**
* Called after a connection has been established.
**/
mockRespond(res: Response) {
if (this.readyState >= ReadyStates.DONE) {
throw new Error('Connection has already been resolved');
}
this.readyState = ReadyStates.DONE;
this.response.onNext(res);
this.response.onCompleted();
}
mockDownload(res: Response) {
this.downloadObserver.onNext(res);
if (res.bytesLoaded === res.totalBytes) {
this.downloadObserver.onCompleted();
}
}
mockError(err?) {
// Matches XHR semantics
this.readyState = ReadyStates.DONE;
this.response.onError(err);
this.response.onCompleted();
}
}
@Injectable()
export class MockBackend {
connections: Rx.Subject<Connection>;
connectionsArray: Array<Connection>;
pendingConnections: Rx.Observable<Connection>;
constructor() {
this.connectionsArray = [];
if (Rx.hasOwnProperty('default')) {
this.connections = new (<any>Rx).default.Rx.Subject();
} else {
this.connections = new Rx.Subject<Connection>();
}
this.connections.subscribe(connection => this.connectionsArray.push(connection));
this.pendingConnections = this.connections.filter((c) => c.readyState < ReadyStates.DONE);
}
verifyNoPendingRequests() {
let pending = 0;
this.pendingConnections.subscribe((c) => pending++);
if (pending > 0) throw new Error(`${pending} pending connections to be resolved`);
}
resolveAllConnections() { this.connections.subscribe((c) => c.readyState = 4); }
createConnection(req: Request) {
if (!req || !(req instanceof Request)) {
throw new Error(`createConnection requires an instance of Request, got ${req}`);
}
let connection = new Connection(req);
this.connections.onNext(connection);
return connection;
}
}

View File

@ -0,0 +1,40 @@
import {ConnectionBackend, Connection} from '../interfaces';
import {ReadyStates, RequestMethods} from '../enums';
import {Request} from '../static_request';
import {Response} from '../static_response';
import {Inject} from 'angular2/di';
import {Injectable} from 'angular2/di';
import {BrowserXHR} from './browser_xhr';
import * as Rx from 'rx';
export class XHRConnection implements Connection {
request: Request;
response: Rx.Subject<Response>;
readyState: ReadyStates;
private _xhr;
constructor(req: Request, NativeConstruct: any) {
this.request = req;
if (Rx.hasOwnProperty('default')) {
this.response = new (<any>Rx).default.Rx.Subject();
} else {
this.response = new Rx.Subject<Response>();
}
this._xhr = new NativeConstruct();
this._xhr.open(RequestMethods[req.method], req.url);
this._xhr.addEventListener(
'load',
() => {this.response.onNext(new Response(this._xhr.response || this._xhr.responseText))});
// TODO(jeffbcross): make this more dynamic based on body type
this._xhr.send(this.request.text());
}
dispose(): void { this._xhr.abort(); }
}
@Injectable()
export class XHRBackend implements ConnectionBackend {
constructor(private _NativeConstruct: BrowserXHR) {}
createConnection(request: Request): XHRConnection {
return new XHRConnection(request, this._NativeConstruct);
}
}

View File

@ -0,0 +1,21 @@
import {CONST_EXPR, CONST} from 'angular2/src/facade/lang';
import {Headers} from './headers';
import {URLSearchParams} from './url_search_params';
import {RequestModesOpts, RequestMethods, RequestCacheOpts, RequestCredentialsOpts} from './enums';
import {RequestOptions} from './interfaces';
import {Injectable} from 'angular2/di';
@Injectable()
export class BaseRequestOptions implements RequestOptions {
method: RequestMethods;
headers: Headers;
body: URLSearchParams | FormData | string;
mode: RequestModesOpts;
credentials: RequestCredentialsOpts;
cache: RequestCacheOpts;
constructor() {
this.method = RequestMethods.GET;
this.mode = RequestModesOpts.Cors;
}
}

View File

@ -0,0 +1,23 @@
import {Headers} from './headers';
import {ResponseTypes} from './enums';
import {ResponseOptions} from './interfaces';
export class BaseResponseOptions implements ResponseOptions {
status: number;
headers: Headers | Object;
statusText: string;
type: ResponseTypes;
url: string;
constructor({status = 200, statusText = 'Ok', type = ResponseTypes.Default,
headers = new Headers(), url = ''}: ResponseOptions = {}) {
this.status = status;
this.statusText = statusText;
this.type = type;
this.headers = headers;
this.url = url;
}
}
;
export var baseResponseOptions = Object.freeze(new BaseResponseOptions());

View File

@ -0,0 +1,12 @@
export enum RequestModesOpts { Cors, NoCors, SameOrigin };
export enum RequestCacheOpts { Default, NoStore, Reload, NoCache, ForceCache, OnlyIfCached };
export enum RequestCredentialsOpts { Omit, SameOrigin, Include };
export enum RequestMethods { GET, POST, PUT, DELETE, OPTIONS, HEAD };
export enum ReadyStates { UNSENT, OPEN, HEADERS_RECEIVED, LOADING, DONE, CANCELLED };
export enum ResponseTypes { Basic, Cors, Default, Error, Opaque }

View File

@ -0,0 +1,79 @@
import {
isPresent,
isBlank,
isJsObject,
isType,
StringWrapper,
BaseException
} from 'angular2/src/facade/lang';
import {
isListLikeIterable,
List,
Map,
MapWrapper,
ListWrapper
} from 'angular2/src/facade/collection';
// (@jeffbcross): This is implemented mostly to spec, except that the entries method has been
// removed because it doesn't exist in dart, and it doesn't seem worth adding it to the facade.
export class Headers {
_headersMap: Map<string, List<string>>;
constructor(headers?: Headers | Object) {
if (isBlank(headers)) {
this._headersMap = MapWrapper.create();
return;
}
if (isPresent((<Headers>headers)._headersMap)) {
this._headersMap = (<Headers>headers)._headersMap;
} else if (isJsObject(headers)) {
this._headersMap = MapWrapper.createFromStringMap(headers);
MapWrapper.forEach(this._headersMap, (v, k) => {
if (!isListLikeIterable(v)) {
var list = ListWrapper.create();
ListWrapper.push(list, v);
MapWrapper.set(this._headersMap, k, list);
}
});
}
}
append(name: string, value: string): void {
var list = MapWrapper.get(this._headersMap, name) || ListWrapper.create();
ListWrapper.push(list, value);
MapWrapper.set(this._headersMap, name, list);
}
delete (name: string): void { MapWrapper.delete(this._headersMap, name); }
forEach(fn: Function) { return MapWrapper.forEach(this._headersMap, fn); }
get(header: string): string {
return ListWrapper.first(MapWrapper.get(this._headersMap, header));
}
has(header: string) { return MapWrapper.contains(this._headersMap, header); }
keys() { return MapWrapper.keys(this._headersMap); }
// TODO: this implementation seems wrong. create list then check if it's iterable?
set(header: string, value: string | List<string>): void {
var list = ListWrapper.create();
if (!isListLikeIterable(value)) {
ListWrapper.push(list, value);
} else {
ListWrapper.push(list, ListWrapper.toString((<List<string>>value)));
}
MapWrapper.set(this._headersMap, header, list);
}
values() { return MapWrapper.values(this._headersMap); }
getAll(header: string): Array<string> {
return MapWrapper.get(this._headersMap, header) || ListWrapper.create();
}
entries() { throw new BaseException('"entries" method is not implemented on Headers class'); }
}

View File

@ -0,0 +1,68 @@
/// <reference path="../../typings/rx/rx.all.d.ts" />
import {Injectable} from 'angular2/src/di/decorators';
import {RequestOptions, Connection} from './interfaces';
import {Request} from './static_request';
import {Response} from './static_response';
import {XHRBackend} from './backends/xhr_backend';
import {BaseRequestOptions} from './base_request_options';
import * as Rx from 'rx';
/**
* A function to perform http requests over XMLHttpRequest.
*
* #Example
*
* ```
* @Component({
* appInjector: [httpBindings]
* })
* @View({
* directives: [NgFor],
* template: `
* <ul>
* <li *ng-for="#person of people">
* hello, {{person.name}}
* </li>
* </ul>
* `
* })
* class MyComponent {
* constructor(http:Http) {
* http('people.json').subscribe(res => this.people = res.json());
* }
* }
* ```
*
*
* This function is bound to a single underlying connection mechanism, such as XHR, which could be
* mocked with dependency injection by replacing the `Backend` binding. For other transports, like
* JSONP or Node, a separate http function would be created, such as httpJSONP.
*
* @exportedAs angular2/http
*
**/
// Abstract
@Injectable()
export class Http {
}
var Observable;
if (Rx.hasOwnProperty('default')) {
Observable = (<any>Rx).default.Rx.Observable;
} else {
Observable = Rx.Observable;
}
export function HttpFactory(backend: XHRBackend, defaultOptions: BaseRequestOptions) {
return function(url: string, options?: RequestOptions) {
return <Rx.Observable<Response>>(Observable.create(observer => {
var connection: Connection = backend.createConnection(new Request(url, options));
var internalSubscription = connection.response.subscribe(observer);
return () => {
internalSubscription.dispose();
connection.dispose();
}
}))
}
}

View File

@ -0,0 +1,63 @@
/// <reference path="../../typings/rx/rx.all.d.ts" />
import {
ReadyStates,
RequestModesOpts,
RequestMethods,
RequestCacheOpts,
RequestCredentialsOpts,
ResponseTypes
} from './enums';
import {Headers} from './headers';
import {URLSearchParams} from './url_search_params';
export interface RequestOptions {
method?: RequestMethods;
headers?: Headers;
body?: URLSearchParams | FormData | string;
mode?: RequestModesOpts;
credentials?: RequestCredentialsOpts;
cache?: RequestCacheOpts;
}
export interface Request {
method: RequestMethods;
mode: RequestModesOpts;
credentials: RequestCredentialsOpts;
}
export interface ResponseOptions {
status?: number;
statusText?: string;
headers?: Headers | Object;
type?: ResponseTypes;
url?: string;
}
export interface Response {
headers: Headers;
ok: boolean;
status: number;
statusText: string;
type: ResponseTypes;
url: string;
totalBytes: number;
bytesLoaded: number;
blob(): Blob;
arrayBuffer(): ArrayBuffer;
text(): string;
json(): Object;
}
export interface ConnectionBackend { createConnection(observer: any, config: Request): Connection; }
export interface Connection {
readyState: ReadyStates;
request: Request;
response: Rx.Subject<Response>;
dispose(): void;
}
// Prefixed as IHttp because used in conjunction with Http class, but interface is callable
// constructor(@Inject(Http) http:IHttp)
export interface IHttp { (url: string, options?: RequestOptions): Rx.Observable<Response> }

View File

@ -0,0 +1,39 @@
import {RequestMethods, RequestModesOpts, RequestCredentialsOpts} from './enums';
import {URLSearchParams} from './url_search_params';
import {RequestOptions, Request as IRequest} from './interfaces';
import {Headers} from './headers';
import {BaseException, RegExpWrapper} from 'angular2/src/facade/lang';
// TODO(jeffbcross): implement body accessors
export class Request implements IRequest {
method: RequestMethods;
mode: RequestModesOpts;
credentials: RequestCredentialsOpts;
headers: Headers;
/*
* Non-Standard Properties
*/
// This property deviates from the standard. Body can be set in constructor, but is only
// accessible
// via json(), text(), arrayBuffer(), and blob() accessors, which also change the request's state
// to "used".
private body: URLSearchParams | FormData | Blob | string;
constructor(public url: string, {body, method = RequestMethods.GET, mode = RequestModesOpts.Cors,
credentials = RequestCredentialsOpts.Omit,
headers = new Headers()}: RequestOptions = {}) {
this.body = body;
// Defaults to 'GET', consistent with browser
this.method = method;
// Defaults to 'cors', consistent with browser
// TODO(jeffbcross): implement behavior
this.mode = mode;
// Defaults to 'omit', consistent with browser
// TODO(jeffbcross): implement behavior
this.credentials = credentials;
// Defaults to empty headers object, consistent with browser
this.headers = headers;
}
text(): String { return this.body ? this.body.toString() : ''; }
}

View File

@ -0,0 +1,47 @@
import {Response as IResponse, ResponseOptions} from './interfaces';
import {ResponseTypes} from './enums';
import {baseResponseOptions} from './base_response_options';
import {BaseException, isJsObject, isString, global} from 'angular2/src/facade/lang';
import {Headers} from './headers';
// TODO: make this injectable so baseResponseOptions can be overridden
export class Response implements IResponse {
type: ResponseTypes;
ok: boolean;
url: string;
status: number;
statusText: string;
bytesLoaded: number;
totalBytes: number;
headers: Headers;
constructor(private body?: string | Object | ArrayBuffer | JSON | FormData | Blob,
{status, statusText, headers, type, url}: ResponseOptions = baseResponseOptions) {
if (isJsObject(headers)) {
headers = new Headers(headers);
}
this.body = body;
this.status = status;
this.statusText = statusText;
this.headers = <Headers>headers;
this.type = type;
this.url = url;
}
blob(): Blob {
throw new BaseException('"blob()" method not implemented on Response superclass');
}
json(): JSON {
if (isJsObject(this.body)) {
return <JSON>this.body;
} else if (isString(this.body)) {
return global.JSON.parse(<string>this.body);
}
}
text(): string { return this.body.toString(); }
arrayBuffer(): ArrayBuffer {
throw new BaseException('"arrayBuffer()" method not implemented on Response superclass');
}
}

View File

@ -0,0 +1,45 @@
import {isPresent, isBlank, StringWrapper} from 'angular2/src/facade/lang';
import {Map, MapWrapper, List, ListWrapper} from 'angular2/src/facade/collection';
function paramParser(rawParams: string): Map<string, List<string>> {
var map: Map<string, List<string>> = MapWrapper.create();
var params: List<string> = StringWrapper.split(rawParams, '&');
ListWrapper.forEach(params, (param: string) => {
var split: List<string> = StringWrapper.split(param, '=');
var key = ListWrapper.get(split, 0);
var val = ListWrapper.get(split, 1);
var list = MapWrapper.get(map, key) || ListWrapper.create();
ListWrapper.push(list, val);
MapWrapper.set(map, key, list);
});
return map;
}
export class URLSearchParams {
paramsMap: Map<string, List<string>>;
constructor(public rawParams: string) { this.paramsMap = paramParser(rawParams); }
has(param: string): boolean { return MapWrapper.contains(this.paramsMap, param); }
get(param: string): string { return ListWrapper.first(MapWrapper.get(this.paramsMap, param)); }
getAll(param: string): List<string> {
return MapWrapper.get(this.paramsMap, param) || ListWrapper.create();
}
append(param: string, val: string): void {
var list = MapWrapper.get(this.paramsMap, param) || ListWrapper.create();
ListWrapper.push(list, val);
MapWrapper.set(this.paramsMap, param, list);
}
toString(): string {
var paramsList = ListWrapper.create();
MapWrapper.forEach(this.paramsMap, (values, k) => {
ListWrapper.forEach(values, v => { ListWrapper.push(paramsList, k + '=' + v); });
});
return ListWrapper.join(paramsList, '&');
}
delete (param): void { MapWrapper.delete(this.paramsMap, param); }
}