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 callback0
: 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 theEncoderTag
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:
- to run again requests for certain types of failures,
- to have control over the amount of requests running in parallel,
- 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's a network error (with the exception of the timeout error which is fatal):
- 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.