GET /cfml

A Guide to Writing API Wrappers

Matthew Clemente

Also Called:

APIs are awesome!

Everything has an API

Everything has an API

  • Payments
  • Email
  • Search
  • AI
  • Natural Language Processing
  • Physical Mail
  • Maps
  • APIs
  • E-Commerce
  • IoT
  • SMS/Voice
  • Accounting
  • Books
  • Calendars
  • Cloud Storage
  • Validation
  • Dictionaries
  • Weather
  • Ticketing
  • Music
  • Recipes
  • Image Recognition
  • Beer
  • Star Wars

Don’t Reinvent the Wheel

How do we get started?

Step 1: Don't Write Anything!

How did I get here?

Every API is different

All APIs are the same

and also

REST

REpresentational State Transfer

REST is CRUD over HTTP. Some exceptions to this rule are allowed as long as they don’t become the rule.

Method + Host + Path + Query Params + Body + Headers
Status Code + Status Text + Headers + Data
api-wrapper-template

A CommandBox tool for scaffolding CFML API Clients

  • Quickly create an API wrapper project

  • Scaffold boilerplate HTTP request handling

  • Structured to mirror API documentation

  • Handle various response formats

  • Provide additional information about request/response

box install api-wrapper-template

Don’t Reinvent the Wheel

Getting Started

  • Read the documentation
    • Authentication / Authorization

    • Endpoint / Test Endpoint

    • Rate limits

    • Response Format

    • Other limitations (size of request, etc.)

  • Review other client libraries

The Cat API

The Cat API Wrapper

apiWrapper scaffold --wizard
apiName* Name of the API this library will wrap. [i.e. Stripe]
apiEndpointUrl* Base endpoint URL for API calls. [i.e. https://api.stripe.com/v1]
apiAuthentication Type of authentication used [None, Basic, Apikey, Other]
apiDocUrl URL of the API documentation homepage
name Name for the wrapper [i.e. StripeCFC] 
description A short description of the wrapper.
author Name of the author of the wrapper.
quickStart Do you want to quickStart the project?

The Cat API Wrapper

apiwrapper scaffold apiName="Cat API" apiEndpointUrl="http://thecatapi.com/api" apiAuthentication="none" apiDocUrl="https://thecatapi.com/docs.html" name="CFCat" description="Some people are cat people. Some people are dog people. This wrapper will give the former random cats on demand." && cd catWrapper && server start openbrowser=false
apiName Cat API
apiEndpointUrl http://thecatapi.com/api
apiAuthentication none
apiDocUrl https://thecatapi.com/docs.html
name CFCat
description Some people are cat people. Some people are dog people. This wrapper will give the former random cats on demand. 

The Cat API Wrapper

Read the documentation!

Making the Request

Building Your Requests

Making the Request

  /**
  * http://thecatapi.com/docs.html#images-get
  * @hint List cats
  */
  public struct function listCats( string category = '' ) {
    var params = { 'format' : 'html' };
    
    if ( category.len() ) params[ 'category' ] = category;

    return apiCall( 'GET', '/images/get', params );
  }

Naming Your Methods

  • Be Consistent
  • Follow basic CRUD conventions
    • createResource()
    • getResource()
    • listResources()
    • updateResource()
    • deleteResource()
  • Use language of documentation
  • For non-CRUD operations, look to other clients

(Make sure there's a method to your madness)

The Result of our Request

cat = new cat.cat();
catImage = cat.listCats().data;
writeOutput( catImage );

API Responses > Data

cat = new cat.cat();
catImage = cat.listCats();
writeDump( catImage );

Make Debugging Easy

RequestBin / Hookbin / Mockbin

Being able to send your API wrappers requests

to a different endpoint is helpful when debugging.

Make Debugging Easy

cat = new cat.cat( includeRaw = true );
catImage = cat.listCats();
writeDump( catImage );

Authentication

Authentication typically involves providing credentials via Headers

No Authentication This is easy! You don't need to do anything! Yay!
Basic Authentication Base64 encoded username:password in Authorization header
API Key Authentication Unique identifiers passed via headers ( or, less frequently, query params or in the body)
Open Authorization (OAuth) Access Token used, following authentication and permission

Authentication

How do we handle it in our CFML API clients?

No Authentication This is easy! You don't need to do anything! Yay!
Basic Authentication cfhttp username and password attributes
API Key Authentication cfhttpparam( type = "header" ... )

Handled via getBaseHttpHeaders() in the api-wrapper-template
Open Authorization (OAuth) https://github.com/coldfumonkeh/oauth2

Environment Variables

Keeping secrets in environment variables

provides practical and security benefits

// Get access to the system class.
system = createObject( "java", "java.lang.System" );
value = system.getenv( 'key' );

// Calling getenv() without an argument returns a map / struct of all of the
// keys available in the current environment (to the ColdFusion user).
environment = system.getenv();

Environment Variables

Handled automatically in api-wrapper-template

    var secrets = {
      'applicationId': 'AYLIEN_APPLICATION_ID',
      'applicationKey': 'AYLIEN_APPLICATION_KEY'
    };

Aylien Text Analysis API

Aylien Text Analysis API

Aylien Text Analysis API

apiwrapper scaffold apiName="Aylien" apiEndpointUrl="https://api.aylien.com/api/v1" apiAuthentication="apikey" apiDocUrl="http://docs.aylien.com/textapi/#getting-started" name="ayliencfc" description="Access Aylien's Natural Language Processing (NLP) API to extract meaning and insight from textual content." && cd aylienWrapper && server start openbrowser=false
apiName Aylien
apiEndpointUrl https://api.aylien.com/api/v1
apiAuthentication apikey
apiDocUrl http://docs.aylien.com/textapi/#getting-started
name ayliencfc
description Access Aylien's Natural Language Processing (NLP) API to extract meaning and insight from textual content.

Handling API Keys

public any function init(
    string applicationId = '',
    string applicationKey = '',
    string baseUrl = "https://api.aylien.com/api/v1",
    boolean includeRaw = false ) {

Handling API Keys

  private struct function getBaseHttpHeaders() {
    return {
      'Accept' : 'application/json',
      'Content-Type' : 'application/json',
      'X-AYLIEN-TextAPI-Application-Key' : variables.applicationKey,
      'X-AYLIEN-TextAPI-Application-ID' : variables.applicationId,
      'User-Agent' : 'ayliencfc/#variables._ayliencfc_version# (ColdFusion)'
    };
  }

Making a Request

  /**
  * https://docs.aylien.com/textapi/endpoints/#entity-extraction
  * @hint Extracts different types of notable entities from a document
  */
  public struct function entities( string text = '', string url = '', string language ) {
    var params = {
      'language' : language ?: 'auto'
    };

    if ( text.len() )
      params[ 'text' ] = text;

    if ( url.len() )
      params[ 'url' ] = url;

    return apiCall( 'POST', '/entities', params );
  }

URL to Analyze

Aylien Entity Analysis

aylien = new aylien.aylien();
entities = aylien.entities( url = 'http://bit.ly/intb2018' ).data;
writeDump( entities );

Aylien Response Headers

aylien = new aylien.aylien();
entities = aylien.entities( url = 'http://bit.ly/intb2018' );
writeDump( entities );

Shouldn't We Validate?

No...

Don’t Reinvent the Wheel

Leave Validation to the API

aylien = new aylien.aylien();
entities = aylien.entities( url = 'I am not a URL!' );
writeDump( entities );

Your Users are Developers

Make their lives easier by identifying pain points.

entities = aylien.entities( 'http://bit.ly/intb2018' ).data;

Developer-focused Refactor #1

Developer-focused Refactor #2

Lob API for Printables

Lob API for Printables

apiwrapper scaffold apiName="Lob" apiEndpointUrl="https://api.lob.com/v1" apiAuthentication="basic" apiDocUrl="https://lob.com/docs#intro" name="lobcfc" description="Wrap the Lob API to verify addresses and send physical mail programmatically." && cd lobWrapper && server start openbrowser=false
apiName Lob
apiEndpointUrl https://api.lob.com/v1
apiAuthentication basic
apiDocUrl https://lob.com/docs#intro
name lobcfc
description Wrap the Lob API to verify addresses and send physical mail programmatically.

Lob API for Printables

Basic Auth & Test Endpoints

  public any function init(
    string apiKey = '',
    string testApiKey = '',
    boolean forceTestMode = false,
    string baseUrl = "https://api.lob.com/v1",
    boolean includeRaw = false ) {
    var secrets = {
      'apiKey': 'LOB_LIVE_API_KEY',
      'testApiKey': 'LOB_TEST_API_KEY'
    };

Basic Auth & Test Endpoints

cfhttp( url = fullPath, method = httpMethod, username = !variables.forceTestMode ? variables.apikey : variables.testApiKey, password = '', result = 'result' ) {

Lob Test Endpoint

lob = new lob.lob( forceTestMode = true );
//etc etc
result = lob.createPostcard( postcard );

Getting Into Arguments

How To Lose Arguments

How To Defuse Arguments

  • Easiest to implement
  • Commonly used by official clients
  • Preferable to listing arguments
  • Puts burden on developer
  • Data not reusable

(The fast and easy approach)

How To Win Arguments

(Helper components with fluent interfaces)

  • More initial work for you
  • Significantly better developer experience
  • Component reuse ensures consistent data

Helper Component Props

/helpers/address.cfc
/**
* lobcfc
* Copyright 2018  Matthew J. Clemente, John Berquist
* Licensed under MIT (https://mit-license.org)
*/
component accessors="true" {

  property name="description" default="";
  property name="name" default="";
  property name="company" default="";
  property name="address_line1" default="";
  property name="address_line2" default="";
  property name="address_city" default="";
  property name="address_state" default="";
  property name="address_zip" default="";
  property name="address_country" default="";
  property name="phone" default="";
  property name="email" default="";
  property name="metadata" default="";

  /**
  * https://lob.com/docs#addresses_object
  * @hint No parameters can be passed to init this component. They must be built manually. When creating and updating addresses, the following fields are required:
    * name or company (both can be provided)
    * address_line1
    * address_city (if in US)
    * address_zip (if in US)
  */
  public any function init() {
    setMetadata( {} );
    return this;
  }

  /**
  * @hint An internal description that identifies this resource.
  */
  public any function description( required string description ) {
    setDescription( description );
    return this;
  }

  /**
  * @hint Either name or company is required
  */
  public any function name( required string name ) {
    setName( name );
    return this;
  }

  /**
  * @hint Either name or company is required
  */
  public any function company( required string company ) {
    setCompany( company );
    return this;
  }

  /**
  * @hint Required
  */
  public any function addressLine1( required string address ) {
    setAddress_line1( address );
    return this;
  }

  /**
  * @hint alias for setting address line 1
  */
  public any function address( required string address ) {
    return addressLine1( address );
  }

  public any function addressLine2( required string address ) {
    setAddress_line2( address );
    return this;
  }

  /**
  * @hint alias for setting address line 2
  */
  public any function address2( required string address ) {
    return addressLine2( address );
  }

  /**
  * @hint Required if address is in US
  */
  public any function city( required string city ) {
    setAddress_city( city );
    return this;
  }

  /**
  * @hint alias for setting city
  */
  public any function addressCity( required string city ) {
    return city( city );
  }

  /**
  * @hint Required if address is in US. Can accept either a 2 letter state short-name code or a valid full state name
  */
  public any function state( required string state ) {
    setAddress_state( state );
    return this;
  }

  /**
  * @hint alias for setting state
  */
  public any function addressState( required string state ) {
    return state( state );
  }

  /**
  * @hint Required if address is in US. Can accept either a ZIP format of 12345 or ZIP+4 format of 12345-1234
  */
  public any function zip( required string zip ) {
    setAddress_zip( zip );
    return this;
  }

  /**
  * @hint alias for setting zip
  */
  public any function addressZip( required string zip ) {
    return zip( zip );
  }

  /**
  * @hint 2 letter country short-name code (ISO 3166). Defaults to US.
  */
  public any function country( required string country ) {
    setAddress_country( country );
    return this;
  }

  /**
  * @hint alias for setting country
  */
  public any function addressCountry( required string country ) {
    return country( country );
  }

  public any function phone( required string phone ) {
    setPhone( phone );
    return this;
  }

  public any function email( required string email ) {
    setEmail( email );
    return this;
  }

  /**
  * @hint Use metadata to store custom information for tagging and labeling back to your internal systems. Must be an object with up to 20 key-value pairs. Keys must at most 40 characters and values must be at most 500 characters. Neither can contain the characters " and \. Nested objects are not supported. See https://lob.com/docs#metadata for more information.
  * @metadata if a struct is provided, it will be serialized. Otherwise the string will be set as provided
  */
  public any function metadata( required any metadata ) {
    if ( isStruct( metadata ) )
      setMetadata( serializeJSON( metadata ) );
    else
      setMetadata( metadata );
    return this;
  }

  /**
  * @hint The zip code needs to be handled via a custom method, to force it to be passed as a string
  */
  public string function build() {

    var body = '';
    var properties = getPropertyValues();
    var count = properties.len();

    properties.each(
      function( property, index ) {

        var value = property.key != 'address_zip' ? serializeJSON( property.value ) : serializeValuesAsString( property.value );
        body &= '"#property.key#": ' & value & '#index NEQ count ? "," : ""#';
      }
    );

    return '{' & body & '}';
  }

  /**
  * @hint helper that forces object value serialization to strings. This is needed in some cases, where CF's loose typing causes problems
  */
  private string function serializeValuesAsString( required any data ) {
    var result = '';
    if ( isStruct( data ) ) {
      var serializedData = data.reduce(
        function( result, key, value ) {

          if ( result.len() ) result &= ',';

          return result & '"#key#": "#value#"';
        }, ''
      );
      result = '{' & serializedData & '}';
    } else if ( isNumeric( data ) ) {
      result = '"#data#"';
    } else if ( isArray( data ) ) {
      var serializedData = data.reduce(
        function( result, item, index ) {
          if ( result.len() ) result &= ',';

          return result & '"#item#"';
        }, ''
      );

      result = '[' & serializedData & ']';
    }

    return result;
  }

  /**
  * @hint converts the array of properties to an array of their keys/values, while filtering those that have not been set
  */
  private array function getPropertyValues() {

    var propertyValues = getProperties().map(
      function( item, index ) {
        return {
          "key" : item.name,
          "value" : getPropertyValue( item.name )
        };
      }
    );

    return propertyValues.filter(
      function( item, index ) {
        if ( isStruct( item.value ) )
          return !item.value.isEmpty();
        else
          return item.value.len();
      }
    );
  }

  private array function getProperties() {

    var metaData = getMetaData( this );
    var properties = [];

    for( var prop in metaData.properties ) {
      properties.append( prop );
    }

    return properties;
  }

  private any function getPropertyValue( string key ){
    var method = this["get#key#"];
    var value = method();
    return value;
  }
}

Chaining Methods

/helpers/address.cfc
lob = new lob.lob();

address = new lob.helpers.address()
  .description( 'Into The Box 2018 - Demo' )
  .company( 'Hyatt Place Houston/The Woodlands' )
  .name( 'Guest: Matthew Clemente (4/25)' )
  .address( '1909 Research Forest Drive' )
  .city( 'The Woodlands' ).state( 'Texas' ).zip( '77380' );

result = lob.createAddress( address );
writeDump( var='#result#', format='html', abort='true' );
  /**
  * https://lob.com/docs#addresses_create
  * @hint Create an address
  * @address this should be an instance of the `helpers.address` component. However, if you want to create and pass in the struct or json yourself, you can.
  */
  public struct function createAddress( required any address ) {
    var body = {};
    if ( isValid( 'component', address ) )
      body = address.build();
    else
      body = address;
    return apiCall( 'POST', '/addresses', {}, body );
  }

Something is Wrong Here

Document Your Wrapper

(Because don't you prefer well documented repositories)

  • Docs shouldn't be an afterthought
  • Add a function, add it to the docs
  • Link to official docs
  • Provide a quick start guide/example
  • Describe the install procedure
  • List the functions you provide
  • Model a repo you admire

Share Your API Wrappers!

Don’t Reinvent the Wheel

GET /cfml - A Guide to Writing API Wrappers

By mjclemente

GET /cfml - A Guide to Writing API Wrappers

Presentation for Into the Box 2018

  • 4,174