Building An API Client With The fetch() API In JavaScript
In my continued effort to modernize this blog, I'm thinking about trying to replace the jQuery library with more modern techniques. I don't personally have anything against jQuery; but, by replacing it, I'll have an opportunity to learn newer - and hawter - JavaScript APIs (at the expense of robust browser support). Case in point, I want to replace the jQuery.ajax()
method with a fetch()
-based API client. I've never used the fetch()
method before; so, this will be an exciting exploration!
When consuming an API, you should always create an API client. If your application is using low-level libraries directly when consuming an API, you're making your life much harder than it has to be; and you're designing your code to be tightly-coupled and harder to maintain; which makes it harder for the next developer who's going to work on it after you.
The awesome thing about building an API client is that it can be very opinionated. Unlike generic, low-level libraries such as jQuery, Axios, and fetch()
, an API client doesn't have to be generalized - it only has to support the application in which it is being designed. This means that we can build assumptions and constraints into the API client code. Which means that the code should end up being smaller and easier to maintain. And, it should make our calling code more consistent and stable.
ASIDE: This benefits of an abstraction layer hold true for Loggers as well. You would never want to use a low-level logger, such as Bunyan, directly - you should always create an application-specific logging library that may-or-may-not use something like Bunyan under the hood.
The fetch()
API itself is fairly small:
fetch( url [, settings ] )
:: Promise
However, from a small API there springs a lot of complexity. The fetch()
API makes no assumptions about anything; which means that any higher-level logic that your application needs must be built explicitly around the fetch()
API. Hence, the need for an opinionated API Client.
On my blog, I really only need an API client that does two things:
- Post form data.
- Return JSON payloads.
Form data seems like such a fundamental component of the web experience. And yet, somewhat ironically, the fetch()
API doesn't make this as easy at it could be. Instead of just accepting a hash of key/value pairs, you have to manually construct a FormData
instance and pass that into the fetch()
API as the body
property.
Again, this is where we gain a lot from having an opinionated API client - if we're going to be posting a lot of form data, we can create a API abstraction that makes that super simple and hides-away all the low-level fetch()
and FormData
requirements.
Earlier, I said that my blog API client only needs to do two things. What I mean by that is that my current blog logic only needs two things. However, as I continue to modernize my blog, I may have areas where I want to swap form data out for JSON (JavaScript Object Notation) data. As such, I want my API client to have a bit of flexibility so that I can grow into it, not be constrained by it.
With that said, I created an API client that maps request setting assumptions to low-level fetch()
implementation details. What I mean is, instead of just accepting a singular body
option, the way fetch()
does, my API client will accept the following:
params
- a hash of values to append to the URL.form
- a hash of values to post asmultipart/form-data
.json
- a hash of values to post asapplication/x-json
.body
- a generic option if the other options don't apply.
Note that while params
can be used with any request, the form
, json
, and body
options are all mutually exclusive.
With this API client, if I want to post JSON to the server, I don't have to worry about serializing the data, setting the method, or providing the correct Content-Type
HTTP header - all I have to do is pass in a json
property and the API client makes all that happen - in an opinoionated way - under the hood:
var response = await apiClient.makeRequest({
url: "/api/echo-json-payload",
json: {
firstName: "Ben",
lastName: "Nadel"
}
});
console.log( response.firstName, " ", response.lastName );
Similarly, if I wanted to post form data, such that it would populate the form
scope in ColdFusion, I wouldn't have to create a FormData
instance or set the method, all I would have to do is pass in a form
property:
var response = await apiClient.makeRequest({
url: "/api/update-user",
params: {
userID: 1
},
form: {
action: "update",
firstName: "Benito"
}
});
console.log( response.success );
The other aspect of API request/response handling that I want my API client to be very opinionated about is error handling. Specifically, I want my API client to enforce a standard error interface for every single error that might come back from the server; or, that might be generated by a network failure (these are two different types of errors in the fetch()
ecosystem). Having a dependable, predictable, consistent error scheme simplifies the logic in the calling context and greatly reduces the number of "guard statements" that may otherwise need to exist.
In my recent article about centralizing error response handling on my ColdFusion blog, I looked at how all server-side errors are managed through a centralized ErrorService.cfc
that maps exceptions onto error responses. This service ensures that all application errors return a Struct that has at least these two properties:
type
- a unique error code for the given error.message
- a user-friendly message to display to the user.
Given this constraint within my ColdFusion application, it only makes sense that my application-specific API client would propagate this constraint to the client-side. As such, I wanted to ensure that any and all errors produced by the .makeRequest()
method resulted in an Object
that also at least these two properties:
type
message
In cases where the underlying error was caused by business logic, these properties would simply contain the like-named properties sent back from the server. In other cases, such as with network failures, these properties would be populated by the API client, which would also include a rootCause
property provided by the fetch()
API.
These error guarantees make error handling an order of magnitude easier.
With all that said, here's my ApiClient
class. It only exposes one public method, makeRequest()
, that accepts a hash of options. Those options are used to configure the underlying fetch()
call. There is no option to tell the API client how to handle the response data - it is driven by the Content-Type
header provided by the HTTP response:
NOTE: I am currently using Parcel JS to transpile this to ES5. Though, of course, it still depends directly on the existence of
fetch()
(and additional modern APIs) which means that this won't work in IE11.
// Regular expression patterns for testing content-type response headers.
var RE_CONTENT_TYPE_JSON = new RegExp( "^application/(x-)?json", "i" );
var RE_CONTENT_TYPE_TEXT = new RegExp( "^text/", "i" );
// Static strings.
var UNEXPECTED_ERROR_MESSAGE = "An unexpected error occurred while processing your request.";
export class ApiClient {
/**
* I initialize the API client.
*/
constructor() {
// Nothing to do at this time. In the future, I could add things like base
// headers and other configuration defaults. But, I don't need any of that stuff
// at this time.
}
// ---
// PUBLIC METHODS.
// ---
/**
* I make the API request with the given configuration options.
*
* GUARANTEE: All errors produced by this method will have consistent structure, even
* if they are low-level networking errors. Every Promise rejection is guaranteed to
* have a "type" and a "message" property.
*/
async makeRequest( config ) {
// CAUTION: We want the entire contents of this method to be inside the try/catch
// so that we can guarantee that all errors occurring during this workflow will
// be caught and transformed into a consistent structure. NOTHING HERE SHOULD
// throw an error - but, bugs happen and people pass-in malformed parameters and
// I want the error-handling guarantees in place.
try {
// Extract options, with defaults, from config.
var contentType = ( config.contentType || null );
var headers = ( config.headers || Object.create( null ) );
var method = ( config.method || null );
var url = ( config.url || "" );
var params = ( config.params || Object.create( null ) );
var form = ( config.form || null );
var json = ( config.json || null );
var body = ( config.body || null );
// The fetch* variables are the values that we'll actually use to generate
// the fetch() call. We're going to assign these based on the configuration
// data that was passed-in.
var fetchHeaders = this.buildHeaders( headers );
var fetchMethod = null;
var fetchUrl = this.mergeParamsIntoUrl( url, params );
var fetchBody = null;
if ( form ) {
// NOTE: For form data posts, we want the browser to build the Content-
// Type for us so that it puts in both the "multipart/form-data" plus the
// correct, auto-generated field delimiter.
delete( fetchHeaders[ "content-type" ] );
// ColdFusion will only parse the form data if the method is POST.
fetchMethod = "post";
fetchBody = this.buildFormData( form );
} else if ( json ) {
fetchHeaders[ "content-type" ] = ( contentType || "application/x-json" );
fetchMethod = ( method || "post" );
fetchBody = JSON.stringify( json );
} else if ( body ) {
fetchHeaders[ "content-type" ] = ( contentType || "application/octet-stream" );
fetchMethod = ( method || "post" );
fetchBody = body;
} else {
fetchMethod = ( method || "get" );
}
var fetchRequeset = new window.Request(
fetchUrl,
{
headers: fetchHeaders,
method: fetchMethod,
body: fetchBody
}
);
var fetchResponse = await window.fetch( fetchRequeset );
var data = await this.unwrapResponseData( fetchResponse );
if ( fetchResponse.ok ) {
return( data );
}
// The request came back with a non-2xx status code; but may still contain an
// error structure that is defined by our business domain.
return( Promise.reject( this.normalizeError( data ) ) );
} catch ( error ) {
// The request failed in a critical way; the content of this error will be
// entirely unpredictable.
return( Promise.reject( this.normalizeTransportError( error ) ) );
}
}
// ---
// PRIVATE METHODS.
// ---
/**
* I build a FormData instance from the given object.
*
* NOTE: At this time, only simple values (ie, no files) are supported.
*/
buildFormData( formFields ) {
var formData = new FormData();
Object.entries( formFields ).forEach(
( [ key, value ] ) => {
formData.append( key, value );
}
);
return( formData );
}
/**
* I transform the collection of HTTP headers into a like collection wherein the names
* of the headers have been lower-cased. This way, if we need to manipulate the
* collection prior to transport, we'll know what key-casing to use.
*/
buildHeaders( headers ) {
var lowercaseHeaders = Object.create( null );
Object.entries( headers ).forEach(
( [ key, value ] ) => {
lowercaseHeaders[ key.toLowerCase() ] = value;
}
);
return( lowercaseHeaders );
}
/**
* I build a query string (less the leading "?") from the given params.
*
* NOTE: At this time, there is no special handling of array-based values.
*/
buildQueryString( params ) {
var queryString = Object.entries( params )
.map(
( [ key, value ] ) => {
if ( value === true ) {
return( encodeURIComponent( key ) );
}
return( encodeURIComponent( key ) + "=" + encodeURIComponent( value ) );
}
)
.join( "&" )
;
return( queryString );
}
/**
* I merged the given params into the given URL. This is done by parsing the URL,
* extracting the URL-based params, merging them with the given params, and then
* rebuilding the URL with the merged params.
*
* NOTE: The given params take precedence in the case of a name-conflict.
*/
mergeParamsIntoUrl( url, params ) {
// Split on fragment segments.
var hashParts = url.split( "#", 2 );
var preHash = hashParts[ 0 ];
var fragment = ( hashParts[ 1 ] || "" );
// Split on search segments.
var urlParts = preHash.split( "?", 2 );
var scriptName = urlParts[ 0 ];
// When merging the url-params and the additional params, the additional params
// take precedence (meaning, they will overwrite url-based params).
var urlParams = this.parseQueryString( urlParts[ 1 ] || "" );
var mergedParams = Object.assign( urlParams, params );
var queryString = this.buildQueryString( mergedParams );
var results = [ scriptName ];
if ( queryString ) {
results.push( "?", queryString );
}
if ( fragment ) {
results.push( "#", fragment );
}
return( results.join( "" ) );
}
/**
* At a minimum, we want every error to have "type" and "message" properties. These
* are the two keys that the calling context will depend on; and, are the minimum keys
* that the server is expected to return when it throws domain errors.
*/
normalizeError( data ) {
var error = {
type: "ServerError",
message: UNEXPECTED_ERROR_MESSAGE
};
// If the error data is an Object (which it should be if the server responded
// with a domain-based error), then it should have "type" and "message"
// properties within it. That said, just because this isn't a transport error, it
// doesn't mean that this error is actually being returned by our application.
if (
( typeof( data?.type ) === "string" ) &&
( typeof( data?.message ) === "string" )
) {
return( Object.assign( error, data ) );
// If the error data has any other shape, it means that an unexpected error
// occurred on the server (or somewhere in transit). Let's pass that raw error
// through as the rootCause, but use the default error structure.
} else {
error.rootCause = data;
return( error );
}
}
/**
* If our request never makes it to the server (or the round-trip is interrupted
* somehow), we still want the error response to have a consistent structure with the
* application errors returned by the server.
*/
normalizeTransportError( transportError ) {
return({
type: "TransportError",
message: UNEXPECTED_ERROR_MESSAGE,
rootCause: transportError
});
}
/**
* I parse the given query string into an object.
*
* NOTE: This method assumes that the leading "?" has already been removed.
*/
parseQueryString( queryString ) {
var params = Object.create( null );
for ( var pair of queryString.split( "&" ) ) {
var parts = pair.split( "=", 2 );
var key = decodeURIComponent( parts[ 0 ] );
// CAUTION: If there is no value in the query string pair, we want to use a
// literal TRUE value since this literal value will be treated differently
// when subsequently serializing the params back into a query string.
var value = ( parts[ 1 ] )
? decodeURIComponent( parts[ 1 ] )
: true
;
params[ key ] = value;
}
return( params );
}
/**
* I unwrap the response payload from the given response based on the reported
* content-type.
*/
async unwrapResponseData( response ) {
var contentType = response.headers.has( "content-type" )
? response.headers.get( "content-type" )
: ""
;
if ( RE_CONTENT_TYPE_JSON.test( contentType ) ) {
return( response.json() );
} else if ( RE_CONTENT_TYPE_TEXT.test( contentType ) ) {
return( response.text() );
} else {
return( response.blob() );
}
}
}
Hopefully the methods are broken down enough to make the logic here reasonably simple to follow. I could have broken the makeRequest()
method apart even more; but, I found that by refactoring it, the logic became harder to follow, not easier.
To test this, I created a simple JavaScript file that just make API requests using different configuration. I'm sharing this here just to illustrate the API client interface in a way that may be easier to consume. All this code does it make API requests and then log both the inputs and the outputs:
import { ApiClient } from "../../linked/js/api-client.js";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
var apiClient = new ApiClient();
/**
* I provide a test harness for the given API client request configuration.
*/
async function testConfig( name, config ) {
try {
var result = await apiClient.makeRequest( config );
console.group( name );
console.log( config );
console.log( result );
console.groupEnd();
} catch ( error ) {
console.group( name );
console.log( config );
console.error( error );
console.groupEnd();
}
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
Promise.resolve().then(
async () => {
await testConfig(
"Test One",
{
url: "./api/echo.cfm?firstName=Ben",
params: {
firstName: "Benito",
lastName: "Nadel"
}
}
);
await testConfig(
"Test Two",
{
url: "./api/echo.cfm?firstName=Ben",
params: {
lastName: "Nadel"
},
form: {
action: "update",
lastName: "Nadelio"
}
}
);
await testConfig(
"Test Three",
{
url: "./api/echo.cfm?firstName=Ben&lastName=Nadel",
params: {
action: "update"
},
json: {
firstName: "Benny Boy"
}
}
);
await testConfig(
"Test Four",
{
url: "./api/echo.cfm",
params: {
statusCode: 404
}
}
);
await testConfig(
"Test Five",
{
method: "put",
url: "./api/echo.cfm",
params: {
responseType: "text/plain"
},
json: {
action: "update",
userID: 4,
firstName: "Benito"
}
}
);
await testConfig(
"Test Six",
{
contentType: "text/plain",
url: "./api/echo.cfm",
body: JSON.stringify( "This is a body post" )
}
);
}
);
This will be the first time that I've built something for my blog that doesn't support IE11. This makes me a little nervous. However, I think it's time pull things into the modern era. The trick will be to load my JavaScript asynchronously at the end of the request so that even if someone with IE11 loads my blog, things should gracefully degrade to a read-only experience.
That said, I'm super excited to be trying out the fetch()
API for the first time. It look a bit of trial-and-error to understand its low-level intricacies; and, I'm sure I'll learn even more once I put this API client in place; but, it was a lot of fun getting to work. Hopefully 2022 will usher in a number of JavaScript learning opportunities for me!
And - oh chickens! - how much easier does async
/await
make life?!
What if I Need to Call Two Different APIs With the Same Client?
Given that I said an API Client should be very opinionated, you might be wondering how you would build an API client that needs to call two different APIs. The answer is: you don't. Instead, you should be building two different clients - each one tailored specifically to the given API. The moment you start to think, "how can I make this more generic", you've already lost. You start down that slippery slope and you end-up just re-building the fetch()
API on to of the fetch()
API. And then all you have is incidental complexity without any value.
Want to use code from this post? Check out the license.
Reader Comments
As a follow-up to this, I want to add the ability to apply retry logic to the request. However, in order to do that, I need to be able to expose the underlying http response information (if available). As such, I think my
Promise
rejection value has to be more sophisticated. Instead of just thetype
andmessage
, I think it may have to be nested. Just shooting from the hip - I have't put any of this to paper yet - something like:I figure if someone wants to apply retry logic, they would likely have to introspect the status code of the response to see if its retryable. For example, a
403 Forbidden
status shouldn't be retried; but, it might be reasonable for a524 Gateway Timeout
to be retried.Anyway, I'll have a follow-up post for this thought, more in-depth.
Been playing around with some more robust error object definitions. I'm thinking it should probably be something more like this:
I think on my initial pass of the
ApiClient
, I was thinking too high-level - like I was trying to create a "domain service" that would high-away the fact that we were even making HTTP calls. But, that's not the goal here - theApiClient
is intended to simplify the HTTP calls (and add opinionated approaches), not hide them.@All,
On a related topic, I just completely replaced jQuery with Umbrella JS:
www.bennadel.com/blog/4184-replacing-jquery-110kb-with-umbrella-js-8kb.htm
Umbrella JS has a very similar, fluent API like jQuery but, is only a fraction of the size. And uses the same
prototype
-based plugin system. Convert from one to the other was fairly painless.Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →