Http service

The HttpService offers an easy way to do Ajax requests. It is very simple but nonetheless powerful, and is designed with extensibility and reactivity in mind. To use it, simply import it from @banquette/http:

import { Injector } from '@banquette/dependency-injection';
import { HttpService } from '@banquette/http';

const http = Injector.Get(HttpService);

Create a request

Requests are done by creating an HttpRequest object that is then given to the send() method of the service.

There are three ways to create an HttpRequest :

  • using shortcut methods: if your request is close enough to the default configuration
  • using the request builder: offers a fluent interface giving you full control over the request configuration
  • using the factory: give you full control too but using a configuration object

No matter the option you chose, the same object will be created at the end. The only difference is the control you will have over the configuration.

All parameters not explicitly defined will default to the following values:

{
    url             : **mandatory**           // The only mandatory parameter, the URL to call
    method          : HttpMethod.GET,         // The HTTP method to use
    payloadType     : PayloadTypeFormData,    // The format of the body (details in the Encoders section)
    responseType    : ResponseTypeAutoDetect, // The type of response expected form the server (details in the Decoders section)
    headers         : {},                     // The HTTP headers to send
    payload         : null,                   // The body of the request
    extras          : {},                     // Extra data that are not sent to the server, for anything you may need
    timeout         : 10000,                  // Maximum time (in ms) to wait before canceling the request
    retry           : 5,                      // Maximum number of tries in case of a network error.
    retryDelay      : 'auto',                 // Time to wait before trying again in case of error.
    priority        : 0,                      // The higher the priority the sooner the request will be executed.
    withCredentials : false,                  // If true, cookies and auth headers are included in the request.
    mimeType        : null                    // Mime type of the body (if sending binary data)
}

Now let's review the different ways you can create this object properly.


Shortcut methods

Shortcut methods are the fastest way to create a request. They expose only essential parameters and use the default values for the rest. You'll find one for each supported HTTP method: get, post, put, patch and delete.

Shortcut methods call send() for you, so you have only one method to call.

Here are some examples:

Doing a GET request

// Prototype
get<T>(url: string, headers?: Record<string, string>): HttpResponse<T>;

// Usage examples
const response = http.get('/books');
const response = http.get('/books', {'x-token': 'abc'});

 




Doing a POST request

// Prototype
post<T>(url: string, body?: any, headers?: Record<string, string>): HttpResponse<T>;

// Usage example
const response = http.post('/books', [
    {title: 'Book 1', description: 'A great story.'},
    {title: 'Book 2', description: 'Poetic!'}
]);

 






Doing a PUT request

// Prototype
put<T>(url: string, body?: any, headers?: Record<string, string>): HttpResponse<T>;

// Usage example
const response = http.put('/books/12', {
    title: 'New title',
    description: 'A great story.'
});

 






Doing a PATCH request

// Prototype
patch<T>(url: string, body?: any, headers?: Record<string, string>): HttpResponse<T>;

// Usage example
const response = http.patch('/books/12', {
    title: 'New title'
});

 





Doing a DELETE request

// Prototype
delete<T>(url: string, headers?: Record<string, string>): HttpResponse<T>;

// Usage example
const response = http.delete('/book/12');

 



Very basic stuff.

Each of these methods synchronously return an HttpResponse<T> object (containing a Promise). The fact that the response is returned synchronously is very useful and is discussed in detail in the Handling the response section.

Shortcuts methods are a great choice when you don't need much control on the request configuration, or the default parameters are ok for you.


Request builder

The request builder is the most verbose solution but offers full control with very little room for errors. You can create a HttpRequestBuilder by calling build() and get the resulting request object by calling getRequest() on it:

const request = http.build().url('...').getRequest();

The request can then be sent by calling send() on the HttpService:

const request = http.build().url('...').getRequest();
const response = http.send(request);

Like above, an HttpResponse<T> is returned synchronously (see Handling the response).

The request builder offers a fluent interface to leverage autocompletion, you will find the following methods, each of them returning the builder so you can chain the calls:

// Set the HTP method to GET.
get()

// Set the HTP method to POST. 
post()

// Set the HTP method to PUT.
put()

// Set the HTP method to PATCH.
patch()

// Set the HTP method to DELETE.
delete()

// Set the url to call.
url(url: string)

// Set the whole headers object, removing all previously set headers.
headers(headers: Record< string, string>)

// Set a single header value.
header( name: string, value: string)

//  Set the body of the request and optionally its type.
payload(payload: any, type?: symbol)

// Set the expected format of the response.
responseType(type: symbol)

// Set the maximum number of time the request can be replayed in case of a network error.
retry(count: number|null)

// Time to wait (in ms) between each try.
// If set to 'auto', an exponential backoff retry strategy is used.
retryDelay(delay: number|'auto'|null)

// The higher priority requests are executed first.
priority(priority: number)

// Set the maximum time the request can take.
timeout(timeout: number)

// If true, cookies and auth headers are included in the request.
withCredentials(value: boolean)

// Set the mime type of the payload.
mimeType(mimeType: string|null)

// Set an extra value that will not be used by the service but will still be accessible in the request
extra( name: string, value: string)

// Replace the current extra object by the one in parameter. 
// No copy is made so the actual reference you give will be stored in the request.
extras(extras: Record<string,any>)

Factory

Even if it's impractical, you could create the HttpRequest object manually by callings its constructor. But all arguments must be defined in the correct order, which is a pain.

The factory allows you to pass an object only containing the attributes you want to define:

import { HttpRequestFactory } from '@banquette/http';

const request = HttpRequestFactory.Create({
    url: '/books',
    method: HttpMethod.POST,
    payload: {'title': 'The Sapphire Flame'}
});

Sending your request

If you created your request using the request builder or the factory, you need to send it be calling the send() method:

const response = http.send(request);

All the shortcut methods already call send for you, because they create the request themselves.

Calling send will put your request in queue, it will be sent as soon as possible, depending on the configuration and the number of requests already in execution.

NOTE

More details on the queue are available in the Requests queue section.

Handling the response

The send() method (or any of the shortcut methods) returns an HttpResponse<T> object. The object is returned synchronously, no matter if the request is queued or immediately executed.

Here is the full definition of the HttpResponse class:

class HttpResponse<T> {
    /**
     * Final URL of the response, may be different from 
     * the request url in case of redirects.
     */
    url: string | null;

    /**
     * Holds the status of the request.
     * It may be more convenient to use the flags instead.
     */
    readonly status: HttpResponseStatus = HttpResponseStatus.Pending;

    /**
     * The HTTP status code as returned by the server.
     */
    httpStatusCode: number;

    /**
     * The HTTP status text as returned by the server.
     */
    httpStatusText: string;

    /**
     * HTTP headers returned by the server.
     * The name of each header has been normalized (slugified).
     *
     * For example: "Content-Type" will be "content-type".
     */
    httpHeaders: Record<string, string>;

    /**
     * True if status === HttpResponseStatus.Pending.
     */
    isPending: boolean;

    /**
     * True if status === HttpResponseStatus.Success.
     */
    isSuccess: boolean;

    /**
     * True if status === HttpResponseStatus.Error.
     */
    isError: boolean;

    /**
     * True if status === HttpResponseStatus.Canceled.
     */
    isCanceled: boolean;

    /**
     * Holds the error details in case the status becomes HttpResponseStatus.Error.
     */
    error: RequestException | NetworkException | null;

    /**
     * The result of the request.
     * Will be undefined until the status is HttpResponseStatus.Success.
     */
    result: T;

    /**
     * The promise from the Http service.
     * Will resolve (or reject) depending on the status of the request.
     */
    promise: ObservablePromise<HttpResponse<T>> | null;

    /**
     * The request associated with the response.
     */
    readonly request: HttpRequest;
}

The main advantage of returning this object synchronously is that you can immediately use it in your template.

If you use a framework like React, VueJS or Angular, you can use the object directly in your view to make it react to the changes automatically.

If you need the promise behavior, a promise is exposed in the response object:

http.get<BookEntity[]>('/books').promise.then((result: BookEntity[]) => {
    // ...
});

// Or using async/await
async function getBooks(): BookEntity[] {
    return await http.get<BookEntity[]>('/books');
}

TIP

Maybe you noticed that promise is not actually a Promise but an ObservablePromise.

To make it short, an ObservablePromise is a promise with progression support. A full chapter is available on this subject.

If you don't want to read it now, in a nutshell it allows you this:

http.post('/upload', {file}).progress((event: RequestProgressEvent) => {
    // Here you get detailed information on the progress of the request.
}).then((result: any) => {
    // ...
});

The promise always resolves and rejects with the HttpRespone object as value, no matter the result.

Error management

When an error occurs, the promise is rejected with the HttpRespose as value. The response then contains an error attribute holding the details of what happened.

The error attribute always contains an exception, that can be of two types:

  • NetworkException: when the request fails at the network level (if the timeout is reached, the request is canceled or something is wrong the connection).
  • RequestException: when there is an issue with the request itself (if it doesn't respond with a 2xx HTTP status code or if there is a problem while processing the payload or the response).

In case of a RequestException, the response from the server may be available in the result property.

Here is an example of error handling that leverages the catchOf and catchNotOf methods of ObservablePromise:

http.post('/login', {username: 'paul', password: 'oblique'}).then((result: any) => {
    // ...
}).catchOf(AuthenticationException, (reason: AuthenticationException) => {
    // Handle invalid username/password for example
}).catchNotOf(RequestCanceledException, (reason: any) => {
    // Called if "reason" have any value except an object of type RequestCanceledException
});

You could also just use catch() and test for the type of error yourself:

http.post('/login', {username: 'paul', password: 'oblique'}).then((result: any) => {
    // ...
}).catch((reason: any) => {
    if (reason instanceof AuthenticationException) {
        // Handle invalid username/password for example
    } else if (!(reason instanceof RequestCanceledException)) {
        // ...
    }
});

Events

Several events are emitted throught the EventDispatcher during the lifecycle of a request.

The HttpService doesn't execute requests directly, they are added to a queue and executed when the time is right (more of that here). Because of that, some events can trigger multiple times for a single request, which is described in the table below.

All events are found in the Events object that you can import from @banquette/http.

Symbol Description Multiple? Event arg
RequestQueued Emitted when a request has been added to the queue of the HTTP service. Yes RequestEvent
BeforeRequest Emitted right before an HTTP request is executed. Yes RequestEvent
BeforeResponse Emitted after a request has been executed successfully (on a network level). Having a response only mean the communication with the server worked, but the response could still hold an error. No ResponseEvent
RequestSuccess Emitted after a request has been successfully executed. No RequestEvent
RequestFailure Emitted after a request failed to execute. The request may have failed for a network issue (no internet) or because the timeout is reached, on because it has been canceled, this kind of thing. No RequestEvent

Here is an example of a simple listener that logs all requests:

import { Events, RequestEvent } from '@banquette/http';

eventDispatcher.subscribe(Events.BeforeRequest, (event: RequestEvent) => {
    if (event.request.tryCount === 0) {
        console.log(`[${event.request.method}] ${event.request.url}`);
        // Example output:
        // // [GET] https://example.com
    }
});



 





Because BeforeRequest can be called multiple times in case of a temporary network failure, we check the number of times the request have been executed to only log the first time using tryCount.

Events are not limited to getting information on the requests, you can modify them. That's the idea behind encoders and decoders, that we'll see in the next chapter.

Encoding the body

The HttpService is built with extensibility in mind, so there is no logic relative to the encoding the body of a request in the service itself. Instead, events are used to intercept the requests and modify their body.

There are several built in encoders that are listed below:

Symbol Description Default? Priority
PayloadTypeJson Encode the request payload into a JSON string. No 0
PayloadTypeFormData Encode the request payload into a FormData object. Yes 0
PayloadTypeFile Encode the input into a Blob object, containing the binary of a file. No 0
PayloadTypeRaw An encoder doing nothing except for stopping the propagation, thus ensuring no other encoder runs after it. No 16

Like always, encoders are identified using symbols that you can import from @banquette/http.

When doing a request, you can define the type of encoder you want to use by using the request builder or factory. Here is an example using the factory:

import { PayloadTypeJson } from '@banquette/http';

HttpRequestFactory.Create({
    url: '/book/12',
    method: HttpMethod.POST,
    payload: {title: 'End of Arrakis'},
    payloadType: PayloadTypeJson // Encoder defined as Json
});






 

The default encoder is PayloadTypeFormData that will create a FormData object with your payload. It should be ok for most of your payloads.


Sending files

You can very easily send files using the default PayloadTypeFormData encoder, or mix contents. Below is an example of sending a file by setting a reference to an <input type="file" /> directly in the payload, with the name of the file next to it:

// <input type="file" id="file-input" />

const response = http.post('/upload', {
    file: document.getElementById('#file-input'),
    name: 'Mon super fichier!'
});

The encoder will automatically check if the file input is multiple or not and will send an array if necessary.


Sending a form

You also can send a form by giving a reference to the <form> element in the payload when using the default PayloadTypeFormData encoder:

// <form id="my-form">...</form>

const response = http.post('/book/12', document.getElementById('#my-form'));

Decoding the response

The decoding of the server's response follow the same principles as the encoding. There are three built in decoders:

Symbol Description Priority
ResponseTypeJson Decode the JSON response into an object. 0
ResponseTypeAutoDetect Allow other encoders to test if the response "looks like" the format they're responsible of. N/A
ResponseTypeRaw A decoder doing nothing except for stopping the propagation, thus ensuring no other decoder runs after it. 16

By default, the decoder is ResponseTypeAutoDetect, which doesn't do anything on its own but simply allows other decoders to decode the response if they find out that the format matches what they search. The first decoder to match then stops the chain.

Your own encoder / decoder

Creating your own encoder / decoder (or any other interceptor for that matter) is very easy. You just have to listen to the event you're interested of using the EventDispacher.

Encoder

To create an encoder, you'll want to listen for the BeforeRequest event. Let's create a basic json encoder together to illustrate how it works:

import { Injector } from "@banquette/dependency-injection";
import { EventDispatcherService } from "@banquette/event";
import { EncoderTag, HttpEvents, RequestEvent } from "@banquette/http";

export const PayloadTypeJson = Symbol('json');

function onBeforeRequest(event: RequestEvent) {
    if (event.request.payloadType !== PayloadTypeJson) {
        return ;
    }
    event.stopPropagation();
    event.request.payload = JSON.stringify(event.request.payload);
}
Injector.Get(EventDispatcherService).subscribe<RequestEvent>(
    HttpEvents.BeforeRequest,
    onBeforeRequest,
    0, 
    [],
    [EncoderTag]
);

It may look complicated but really it isn't. Let's take it line by line:

1) We import what we need

import { Injector } from "@banquette/dependency-injection";
import { EventDispatcherService } from "@banquette/event";
import { EncoderTag, HttpEvents, RequestEvent } from "@banquette/http";

2) We create and export the Symbol that will identify our encoder

export const PayloadTypeJson = Symbol('json');

This is the symbol that will set as payloadType when making and Http request.

3) We define the method the will be called when the encoder triggers

function onBeforeRequest(event: RequestEvent) {
    //...
}

The method takes a RequestEvent as parameter, which contains a reference to the request being made through the request property.

4) We check if the current request needs our encoder

Encoders are simply listeners of the EventDispatcher. Calling them encoders is more a convention than anything. The EventDispatcher calls every listener watching for the BeforeRequest event for each request. So we have to check if the current request needs our encoder or not, and do nothing if not:

if (event.request.payloadType !== PayloadTypeCustom) {
    return ;
}

5) IF our encoder is needed, we stop the propagation

As stated above, the EventDispatcher will call all encoders for each request, until all listeners have been called OR one of them stop the propagation.

That's why the first thing we do is:

event.stopPropagation();

To prevent other encoders to be called.

6) We do the actual encoding of the payload

Now we can modify the payload. Here we simply call JSON.stringify to keep the example simple:

event.request.payload = JSON.stringify(event.request.payload);

7) Registering the listener

We finally have to register our callback in the EventDispatcher so it can be called:

Injector.Get(EventDispatcherService).subscribe<RequestEvent>(
    HttpEvents.BeforeRequest,
    onBeforeRequest,
    0, 
    [],
    [EncoderTag]
);

We first get the EventDispatcherService from the Injector:

Injector.Get(EventDispatcherService)

Then call subscribe() on it with the following arguments:

  • HttpEvents.BeforeRequest: the event we want to subscribe to. Here we want to be notified before the request is sent.
  • onBeforeRequest: the name of our callback
  • 0: the priority of our encoder. The higher the level the sooner it will be called. You can find the prority of built-in encoders/decoders in the previous sections.
  • [EncoderTag]: this one is a bit more complex. We mark our listener with the EncoderTag tag to group it with other listeners of the same type so when propagation is stopped, only encoders are stopped. There is a full chapter explaining the mechanism and why it is important in the EventDispatcher documentation.

TIP

It's good practice to register the encoder into the EventDispatcher in the same file as where it is defined. This way the listener will only be registered when it is used.

If any request want to use our encoder, an import has to be made to get the Symbol. So by registering the listener in the same file we ensure that it will only be registered when needed.

All services in Banquette follow this rule so you never have to worry about a service not being declared to the Injector, or having all services always registered no matter what you use.


Decoder

Creating a decoder is exactly the same principle. The only differences are:

  • you'll want to listen for the BeforeResponse event,
  • the callback will take a ResponseEvent as parameter,
  • you'll want to modify the response (that you'll find in event.response.response),
  • you'll have to mark your event with the DecoderTag.

Here is an example of a json decoder, please refer to the encoder for details as it's very similar:

import { Injector } from '@banquette/dependency-injection';
import { EventDispatcherService } from "@banquette/event";
import { DecoderTag, HttpEvents, ResponseEvent } from "@banquette/http";

export const ResponseTypeJson = Symbol('json');

function onAfterRequest(event: ResponseEvent) {
    if (event.request.responseType !== ResponseTypeJson) {
        return ;
    }
    event.stopPropagation();
    event.response.response = JSON.parse(event.response.response);
}

Injector.Get(EventDispatcherService).subscribe<ResponseEvent>(
    HttpEvents.BeforeResponse,
    onAfterRequest,
    0,
    [],
    [DecoderTag]
);

WARNING

If you create a decoder, please beware of the ResponseTypeAutoDetect symbol.

If the request's responseType is set to ResponseTypeAutoDetect you have to determine yourself if the response can be decoded using your encoder (by analyzing the content of the response, the headers or anything you want).

Requests queue

When send() is called (or a shortcut method like get, post, etc.), the request is not executed immediately but is put into queue for later execution.

There are three main reasons for that:

  1. to run again requests for certain types of failures,
  2. to have control over the amount of requests running in parallel,
  3. to postprone the execution of certain requests.

The way it works is pretty straight forward:

  • the request is added to the queue at a position that depends on its priority,
  • the queue is then processed from top to bottom until there are no free slots left,
  • the request stays in the queue until its turn,
  • when the request is executed, it is removed from the queue,
  • then if it fails:
    • if it's a network error (with the exception of the timeout error which is fatal):
      • if there are tries left, the request is put back into the queue (back to step 1) after a delay that depends on the configuration
      • if the max number of tries is reached, the request is marked as failed and the promise rejected
    • if the request succeeded on a network level but the server didn't respond with a 2xx http code, the response is processed and the request is marked as failed and the promise rejected
  • if it succeeded: the response is processed and the promise is resolved,
  • then the queue checked to maybe execute other requests.

They are 3 parameters that you can use to control how the queue behave: maxSimultaneousRequests, requestRetryDelay and requestRetryCount. The full configuration is detailed below.

Configuration

The configuration is available through the ConfigurationService service. If you are not already familiar with it please read the dedicated chapter describing it in details.

Here is the full list of options:

Option name Description Default value
adapter
(AdapterInterface)
The adapter to use to make XHR requests. XhrAdapter
maxSimultaneousRequests
(number)
Maximum number of requests that can run simultaneously. 3
requestTimeout
(number)
Default maximum time to wait for a request to finish before it is considered a failure. Ccan be overridden for each request. 10000
requestRetryCount
(number)
Default number of time a request will be retried until it is considered a failure. Request are only replayed when there is a network error (except for timeout errors). Can be overridden for each request. 5
requestRetryDelay
(number l 'auto')
Default time (in milliseconds) to wait before retrying when a request has failed. If set to 'auto', an exponential backoff retry strategy is used. 'auto'

TIP

As a reminder, to override the default configuration, do the following:

Injector.Get(ConfigurationService).modify<HttpConfigurationInterface>(HttpConfigurationSymbol, {
    // Override what you want 
    maxSimultaneousRequests: 5,
    requestRetryCount: 5
});

Please check out the ConfigurationService documentation for more details.

You own adapter

The HttpService doesn't handle the actual HTTP communication directly, it uses an adapter for this.

By default the only adapter available is the XhrAdapter which uses the native XMLHttpRequest object to do the query.

But if you need, you can override the default adapter to replace it by your own. To do this, you first have to create a class that implements the following interface:

interface AdapterInterface {
    /**
     * Execute a request.
     */
    execute(request: AdapterRequest): ObservablePromise<AdapterResponse>;

    /**
     * Cancel the request.
     */
    cancel(): void;
}

AdapterRequest request is the same as HttpRequest with the only exception that the payload is guarenteed to be a String (or null).

So when you receive the AdapterRequest, everything is already setup. You just have to make the actual HTTP call and send the result when it's done.

Once your adapter is created, you have to set it in the configuration of the HttpService, like so:

import { ConfigurationService } from '@banquette/config';
import { HttpConfigurationInterface, HttpConfigurationSymbol } from '@banquette/http';
import { CustomAdapter } from 'path/to/your/custom.adapter';

Injector.Get(ConfigurationService).modify<HttpConfigurationInterface>(HttpConfigurationSymbol, {
    adapter: CustomAdapter
});

If you don't understand the code above, please refer to the ConfigurationService documentation.

WARNING

You have to set a @Service() or @Module() decorator on the class, so it is registered in the container.

TIP

The default adapter is a module, so a new instance is created for each request. So you don't have to worry about storing some data specific to the request.