Skip to content

Instantly share code, notes, and snippets.

@bennadel
Created January 9, 2022 14:18
Show Gist options
  • Save bennadel/8a1b43225b3f8caeedafebaeef20e82e to your computer and use it in GitHub Desktop.
Save bennadel/8a1b43225b3f8caeedafebaeef20e82e to your computer and use it in GitHub Desktop.
Building An API Client With The fetch() API In JavaScript
// 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() );
}
}
}
var response = await apiClient.makeRequest({
url: "/api/echo-json-payload",
json: {
firstName: "Ben",
lastName: "Nadel"
}
});
console.log( response.firstName, " ", response.lastName );
var response = await apiClient.makeRequest({
url: "/api/update-user",
params: {
userID: 1
},
form: {
action: "update",
firstName: "Benito"
}
});
console.log( response.success );
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" )
}
);
}
);
@jemersonfd
Copy link

Thank you for this code and the blog post.
One suggestion (if you find it necessary): include your authorship, specially in the api-client.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment