Skip to content
This repository has been archived by the owner on Jul 30, 2018. It is now read-only.

Updating request library to mimic fetch API #261

Merged
merged 13 commits into from
Feb 10, 2017
74 changes: 74 additions & 0 deletions src/QueuingEvented.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import WeakMap from '@dojo/shim/WeakMap';
import Evented from './Evented';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add some documentation to this class about what it does.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added!

import { EventObject, Handle } from './interfaces';

interface QueuingEventedData {
queue: { [eventType: string]: EventObject[] };
subscribed: { [eventType: string]: boolean };
}

const queingEventedSubscribers = new WeakMap<QueuingEvented, QueuingEventedData>();

/**
* An implementation of the Evented class that queues up events when no listeners are
* listening. When a listener is subscribed, the queue will be published to the listener.
* When the queue is full, the oldest events will be discarded to make room for the newest ones.
*
* @property maxEvents The number of events to queue before old events are discarded. If zero (default), an unlimited number of events is queued.
*/
export default class QueuingEvented extends Evented {
maxEvents: number = 0;

constructor() {
super();

queingEventedSubscribers.set(this, {
queue: {},
subscribed: {}
});
}

emit<T extends EventObject>(data: T): void {
const queueData = queingEventedSubscribers.get(this);

if (!queueData.subscribed[ data.type ]) {
let eventQueue = queueData.queue[ data.type ];

if (eventQueue === undefined) {
eventQueue = [];
queueData.queue[ data.type ] = eventQueue;
}

eventQueue.push(data);

if (this.maxEvents > 0 && eventQueue.length > this.maxEvents) {
eventQueue.shift();
}

return;
}

super.emit(data);
}

on(type: string, listener: (event: EventObject) => void): Handle {
const result = super.on(type, listener);

const queueData = queingEventedSubscribers.get(this);

queueData.subscribed[ type ] = true;

const eventQueue = queueData.queue[ type ];

if (eventQueue !== undefined) {
// dispatch queued events
eventQueue.forEach((eventObject) => {
this.emit(eventObject);
});

queueData.queue[ type ] = [];
}

return result;
}
}
3 changes: 2 additions & 1 deletion src/has.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ add('object-assign', typeof global.Object.assign === 'function');

add('arraybuffer', typeof global.ArrayBuffer !== 'undefined');
add('formdata', typeof global.FormData !== 'undefined');
add('filereader', typeof global.FileReader !== 'undefined', true);
add('xhr', typeof global.XMLHttpRequest !== 'undefined');
add('xhr2', has('xhr') && 'responseType' in global.XMLHttpRequest.prototype);
add('xhr2-blob', function () {
add('blob', function () {
if (!has('xhr2')) {
return false;
}
Expand Down
230 changes: 36 additions & 194 deletions src/request.ts
Original file line number Diff line number Diff line change
@@ -1,207 +1,49 @@
import Promise from '@dojo/shim/Promise';
import Task from './async/Task';
import { Handle } from '@dojo/interfaces/core';
import has from './has';
import MatchRegistry, { Test } from './MatchRegistry';
import load from './load';
import { ParamList } from './UrlSearchParams';
import ProviderRegistry from './request/ProviderRegistry';
import { RequestOptions, Response, Provider } from './request/interfaces';
import xhr from './request/providers/xhr';

declare var require: any;

export class FilterRegistry extends MatchRegistry<RequestFilter> {
register(test: string | RegExp | RequestFilterTest | null, value: RequestFilter, first?: boolean): Handle {
let entryTest: Test;
const inTest = test;

if (typeof inTest === 'string') {
entryTest = (response, url, options) => {
return inTest === url;
};
}
else if (inTest instanceof RegExp) {
entryTest = (response, url, options) => {
return inTest.test(url);
};
}
else {
entryTest = <RequestFilterTest> inTest;
}
export const providerRegistry = new ProviderRegistry();

return super.register(entryTest, value, first);
const request: {
Copy link
Contributor

@rishson rishson Jan 11, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason why we don't also support OPTIONS and HEAD? At the moment we support a list that is neither the CORS safelist of verbs or the verbs subject to normalization in the fetch spec.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a good reason that I know of 😄 You can still still make a custom request with whatever method you want, but adding OPTIONS and HEAD should be fairly trivial, so we can do that too!

Copy link
Contributor

@rishson rishson Jan 11, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, given we actually normalize, e.g. send POST for a post(), then probably best we support all the verbs that spec recommends normalization for. As you say, nothing to stop them doing a custom request for post or pOsT..

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added 👍

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥
TVM!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original reason was likely based on what was done in Dojo 1, fwiw. :)

(url: string, options?: RequestOptions): Task<Response>;
delete(url: string, options?: RequestOptions): Task<Response>;
get(url: string, options?: RequestOptions): Task<Response>;
head(url: string, options?: RequestOptions): Task<Response>;
options(url: string, options?: RequestOptions): Task<Response>;
post(url: string, options?: RequestOptions): Task<Response>;
put(url: string, options?: RequestOptions): Task<Response>;

setDefaultProvider(provider: Provider): void;
} = <any> function request(url: string, options: RequestOptions = {}): Task<Response> {
try {
return providerRegistry.match(url, options)(url, options);
}
}

let defaultProvider = './request/xhr';
if (has('host-node')) {
defaultProvider = './request/node';
}

export class ProviderRegistry extends MatchRegistry<RequestProvider> {
private _providerPromise: Promise<RequestProvider>;

constructor() {
super();

const deferRequest = (url: string, options?: RequestOptions): ResponsePromise<any> => {
let canceled = false;
let actualResponse: ResponsePromise<any>;
return new Task<Response<any>>((resolve, reject) => {
this._providerPromise.then(function (provider) {
if (canceled) {
return;
}
if (provider) {
actualResponse = provider(url, options);
actualResponse.then(resolve, reject);
}
});
}, function () {
if (!canceled) {
canceled = true;
}
if (actualResponse) {
actualResponse.cancel();
}
});
};

// The first request to hit the default value will kick off the import of the default
// provider. While that import is in-flight, subsequent requests will queue up while
// waiting for the provider to be fulfilled.
this._defaultValue = (url: string, options?: RequestOptions): ResponsePromise<any> => {
this._providerPromise = load(require, defaultProvider).then(([ providerModule ]: [ { default: RequestProvider } ]): RequestProvider => {
this._defaultValue = providerModule.default;
return providerModule.default;
});
this._defaultValue = deferRequest;
return deferRequest(url, options);
};
catch (error) {
return Task.reject<Response>(error);
}
};

register(test: string | RegExp | RequestProviderTest | null, value: RequestProvider, first?: boolean): Handle {
let entryTest: Test;

if (typeof test === 'string') {
entryTest = (url, options) => {
return test === url;
};
}
else if (test instanceof RegExp) {
entryTest = (url, options) => {
return test ? (<RegExp> test).test(url) : null;
};
}
else {
entryTest = <RequestProviderTest> test;
[ 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT' ].forEach(method => {
Object.defineProperty(request, method.toLowerCase(), {
value(url: string, options: RequestOptions = {}): Task<Response> {
options = Object.create(options);
options.method = method;
return request(url, options);
}
});
});

return super.register(entryTest, value, first);
Object.defineProperty(request, 'setDefaultProvider', {
value(provider: Provider) {
providerRegistry.setDefaultProvider(provider);
}
}

/**
* Request filters, which filter or modify responses. The default filter simply passes a response through unchanged.
*/
export const filterRegistry = new FilterRegistry(function (response: Response<any>): Response<any> {
return response;
});

/**
* Request providers, which fulfill requests.
*/
export const providerRegistry = new ProviderRegistry();

export interface RequestError<T> extends Error {
response: Response<T>;
}

export interface RequestFilter {
<T>(response: Response<T>, url: string, options?: RequestOptions): T;
}

export interface RequestFilterTest extends Test {
<T>(response: Response<any>, url: string, options?: RequestOptions): boolean | null;
}

export interface RequestOptions {
auth?: string;
cacheBust?: any;
data?: any;
headers?: { [name: string]: string; };
method?: string;
password?: string;
query?: string | ParamList;
responseType?: string;
timeout?: number;
user?: string;
}

export interface RequestProvider {
<T>(url: string, options?: RequestOptions): ResponsePromise<T>;
}

export interface RequestProviderTest extends Test {
(url: string, options?: RequestOptions): boolean | null;
}

export interface Response<T> {
data: T | null;
nativeResponse?: any;
requestOptions: RequestOptions;
statusCode: number | null | undefined;
statusText?: string | null;
url: string;

getHeader(name: string): null | string;
}

/**
* The task returned by a request, which will resolve to a Response
*/
export interface ResponsePromise<T> extends Task<Response<T>> {}

/**
* Make a request, returning a Promise that will resolve or reject when the request completes.
*/
const request: {
<T>(url: string, options?: RequestOptions): ResponsePromise<T>;
delete<T>(url: string, options?: RequestOptions): ResponsePromise<T>;
get<T>(url: string, options?: RequestOptions): ResponsePromise<T>;
post<T>(url: string, options?: RequestOptions): ResponsePromise<T>;
put<T>(url: string, options?: RequestOptions): ResponsePromise<T>;
} = <any> function request<T>(url: string, options: RequestOptions = {}): ResponsePromise<T> {
const promise = providerRegistry.match(url, options)(url, options)
.then(function (response: Response<T>) {
return Task.resolve(filterRegistry.match(response, url, options)(response, url, options))
.then(function (filterResponse: any) {
response.data = filterResponse.data;
return response;
});
});

return promise;
};

[ 'DELETE', 'GET', 'POST', 'PUT' ].forEach(function (method) {
(<any> request)[method.toLowerCase()] = function <T>(url: string, options: RequestOptions = {}): ResponsePromise<T> {
options = Object.create(options);
options.method = method;
return request(url, options);
};
});
providerRegistry.setDefaultProvider(xhr);

export default request;

/**
* Add a filter that automatically parses incoming JSON responses.
*/
filterRegistry.register(
function (response: Response<any>, url: string, options: RequestOptions): boolean {
return Boolean(typeof response.data && options && options.responseType === 'json');
},
function (response: Response<any>, url: string, options: RequestOptions): Object {
return {
data: JSON.parse(String(response.data))
};
}
);
export * from './request/interfaces';
export { default as Headers } from './request/Headers';
export { default as TimeoutError } from './request/TimeoutError';
export { default as Response, ResponseData } from './request/Response';
Loading