Jon Dean
Lead Software Engineer
github.com/jonathandean/magmaconf-2013-inter-app-api
Presentation is at jonathandean.github.io/magmaconf-2013-inter-app-api
Run locally by cloning and running grunt serve. See reveal.js for instructions.
Apps are at magma-payments-service/ and magma-client-app/
This is not your production code. Don't stop refactoring! Be sure to write tests.
(And I didn't test it all, so good luck!)
Application Programming Interface
Simply, a clearly defined (and hopefully documented) way for different pieces of software to communicate with one another
The public methods in a Class define an API for how other code interacts with it
class VeryImportantService
def self.do_my_bidding
set_up_some_stuff
do_some_work
build_the_output
end
private
def set_up_some_stuff
# ...
def do_some_work
# ...
def build_the_output
# ...
end
The API for VeryImportantService is the do_my_bidding method
The private methods are NOT part of the API but part of the internal implementation
Generally a Server application that exposes some data and/or functionality to be consumed by another Client application
The API (typically) is an HTTP endpoint and the internal implementation is up to you
REST:
REpresentational State Transfer
Ruby on Rails encourages RESTful URLs as a best practice and should already feel familiar
All of the information needed is part of the request. There's no need to store the state of anything between requests.
Just like websites over HTTP, responses can generally be cached by the client to improve performance.
Clients and Servers have a uniform interface that simplifies architecture and design. Additionally, well-design RESTful APIs look familiar and similar to one another.
Uses familar HTTP verbs GET, POST, PUT, and DELETE
The Word Wide Web itself is RESTful
Most RESTful APIs use JSON or XML for formatting the data, but you can use whatever you want. (The Web uses HTML)
A few of the many reasons:
Multiple (3 so far) Rails applications that need to share functionality
Legacy applications suck, so we made some gems.
(Because who cares about Django, right?)
Need to update and deploy all applications when the gem changes
Internal improvements require a change in all applications using it
PaymentClass.charge_someone
changes to
OtherPaymentThing.charge
With a service application you are typically sharing data/objects instead with basic instructions (create it, update it, delete it, etc.)
POST /customers/:customer_id/transactions transactions#create
Cannot easily add other languages to your systems. (Our legacy app can't take advantage of new code!)
You tell me.
Simple application that is the starting point for handling payments in our systems
config/routes.rb
MagmaPaymentsService::Application.routes.draw do
resources :customers, only: [:create, :update] do
resources :transactions, only: [:create, :index, :show]
end
end
rake routes
GET /customers/:customer_id/transactions transactions#index POST /customers/:customer_id/transactions transactions#create GET /customers/:customer_id/transactions/:id transactions#show POST /customers customers#create PUT /customers/:id customers#update
app/controllers/customers_controller.rb
class CustomersController < ApplicationController
def create
# Need to send a hash of :id, :first_name, :last_name, :email
result = Braintree::Customer.create({
:id => params[:id],
:first_name => params[:last_name],
:last_name => params[:last_name],
:email => params[:email]
})
# Build a hash we can send as JSON in the response
resp = { success: result.success?, message: (result.message rescue '') }
# Render JSON as the response
respond_to do |format|
format.json { render json: resp }
end
end
end
config/initializers/braintree.rb
Braintree::Configuration.environment = ENV['BRAINTREE_ENV'].to_sym Braintree::Configuration.merchant_id = ENV['BRAINTREE_MERCHANT_ID'] Braintree::Configuration.public_key = ENV['BRAINTREE_PUBLIC_KEY'] Braintree::Configuration.private_key = ENV['BRAINTREE_PRIVATE_KEY']
Then go to https://www.braintreepayments.com/get-started to sign up and set some environment variables like
export BRAINTREE_ENV=sandbox export BRAINTREE_MERCHANT_ID=[get your own!] export BRAINTREE_PUBLIC_KEY=[get your own!] export BRAINTREE_PRIVATE_KEY=[get your own!]
rails server
curl --data "id=1&first_name=Jon&last_name=Dean&email=jon@example.com" \
http://localhost:3000/customers
outputs
{"success":true,"message":""}
curl --data "id=1&first_name=Jon&last_name=Dean&email=jon@example.com" \
http://localhost:3000/customers
outputs
{"success":false,"message":"Customer ID has already been taken."}
If you ever get something like WARNING: Can't verify CSRF token authenticity then remove protect_from_forgery from ApplicationController. We aren't submitting forms from our application to itself and so it will complain.
httparty is the curl of Ruby
We want to create a customer in Braintree as soon as the User is created
app/models/user.rb
class User < ActiveRecord::Base
attr_accessible :name, :email
after_create :create_payments_customer
def create_payments_customer
params = {
id: self.id,
first_name: self.name.split(' ').first,
last_name: self.name.split(' ').last,
email: self.email
}
response = HTTParty.post('http://localhost:3000/customers.json', { body: params })
answer = response.parsed_response
puts "response success: #{answer['success']}"
puts "response message: #{answer['message']}"
end
end
rails console
1.9.3p194 :004 > user = User.create(name: "Jon Dean", email: "jon@example.com") response success: true response message:
Each environment will have a different URL
config/environments/development.rb
MagmaClientApp::Application.configure do ... config.payments_base_uri = 'http://localhost:3000' end
app/services/payments_service.rb
class PaymentsService include HTTParty base_uri MagmaClientApp::Application.config.payments_base_uri end
app/models/user.rb
response = PaymentsService.post('/customers.json', { body: params })
app/services/payments_service.rb
class PaymentsService
include HTTParty
base_uri MagmaClientApp::Application.config.payments_base_uri
def self.create_customer(user)
params = {
id: user.id,
first_name: user.name.split(' ').first,
last_name: user.name.split(' ').last,
email: user.email
}
response = self.post('/customers.json', { body: params })
response.parsed_response
end
end
app/models/user.rb
def create_payments_customer
answer = PaymentsService.create_customer(self)
puts "response success: #{answer['success']}"
puts "response message: #{answer['message']}"
end
The beauty is that none of this Ruby-specific. Clients just need to know HTTP and JSON, so they can be written in any language.
If we just made a Ruby gem, the Django app would be out of luck.
We can't have just anyone creating customers!
The Payments Service App needs a way to know that the client is accepted.
A shared secret token!
> rake secret 0224651fc98de3a615243ebf75188ff430bdb2c1c983ab87614b3db2f4c7a167455354d6c0d2e7e788651bbead373bf0a9a166b12c63d47b48f060cdf759e16e
config/application.rb
module MagmaPaymentsService
class Application < Rails::Application
# NOTE: config.secret_token is used for cookie session data
config.api_secret = '0224651fc98de3a615243ebf75188ff430bdb2c1c983ab87614b3db2f4c7a167455354d6c0d2e7e788651bbead373bf0a9a166b12c63d47b48f060cdf759e16e'
end
end
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_filter :authenticate
private
def authenticate
authenticate_or_request_with_http_token do |token, options|
token == MagmaPaymentsService::Application.config.api_secret
end
end
end
Restart the Payments Service app and now you should get this if you try the API call again:
Started POST "/customers.json" for 127.0.0.1 at 2013-06-01 15:48:19 -0400
Processing by CustomersController#create as JSON
Parameters: {"id"=>"38", "first_name"=>"Jon", "last_name"=>"Dean", "email"=>"jon@example.com"}
Rendered text template (0.0ms)
Filter chain halted as :authenticate rendered or redirected
Completed 401 Unauthorized in 8ms (Views: 7.3ms | ActiveRecord: 0.0ms)
config/application.rb
module MagmaClientApp
class Application < Rails::Application
# NOTE: for both apps you are better off settings these as ENV vars
config.payments_api_secret = '0224651fc98de3a615243ebf75188ff430bdb2c1c983ab87614b3db2f4c7a167455354d6c0d2e7e788651bbead373bf0a9a166b12c63d47b48f060cdf759e16e'
end
end
app/services/payments_service.rb
class PaymentsService
include HTTParty
base_uri MagmaClientApp::Application.config.payments_base_uri
def self.create_customer(user)
params = { ... }
options = { body: params, headers: { "Authorization" => authorization_credentials }}
response = self.post('/customers.json', options)
response.parsed_response
end
private
def self.authorization_credentials
token = MagmaClientApp::Application.config.payments_api_secret
ActionController::HttpAuthentication::Token.encode_credentials(token)
end
end
Sometimes (hopefully not often) the external API of your service will have to change.
Many of these changes don't have to force an immediate upgrade of all clients.
We realized that our client apps just store one field for name for users and each of them is splitting the name for the benefit of Braintree. So we want to move this logic into the service.
So our API will now accept just name for creating a customer instead of first_name and last_name.
Do this up front. Don't wait until you realize you need it.
What if the new version is always mandatory?
You still want versioning.
Because it's better to serve a message that the service refused the request rather than trying to run your code and things breaking unexpectedly. In both cases it's broken, but only one of them will be easy to handle.
(Without versioning you may not even realize things are going wrong!)
Common approach: Modules and URL namespacing
config/routes.rb
MagmaPaymentsService::Application.routes.draw do
namespace :v1 do
resources :customers, only: [:create, :update] do
resources :transactions, only: [:create, :index, :show]
end
do
end
rake routes
GET /v1/customers/:customer_id/transactions v1/transactions#index POST /v1/customers/:customer_id/transactions v1/transactions#create GET /v1/customers/:customer_id/transactions/:id v1/transactions#show POST /v1/customers v1/customers#create PUT /v1/customers/:id v1/customers#update
app/controllers/customers_controller.rb -> app/controllers/v1/customers_controller.rb app/controllers/transactions_controller.rb -> app/controllers/v1/transactions_controller.rb
app/controllers/v1/customers_controller.rb
module V1
class CustomersController < ApplicationController
...
end
end
app/services/payments_service.rb
class PaymentsService
include HTTParty
base_uri MagmaClientApp::Application.config.payments_base_uri
VERSION = 'v1'
def self.create_customer(user)
params = { ... }
options = { body: params, headers: { "Authorization" => authorization_credentials }}
response = self.post("/#{VERSION}/customers.json", options)
response.parsed_response
end
private
def self.authorization_credentials
token = MagmaClientApp::Application.config.payments_api_secret
ActionController::HttpAuthentication::Token.encode_credentials(token)
end
end
app/controllers/v2/customers_controller.rb
module V2
class CustomersController < ApplicationController
def create
# Need to send a hash of :id, :name, :email
result = Braintree::Customer.create({
:id => params[:id],
:first_name => params[:name].split(' ').first,
:last_name => params[:name].split(' ').last,
:email => params[:email]
})
resp = { success: result.success?, message: (result.message rescue '') }
respond_to do |format|
format.json { render json: resp }
end
end
end
end
Now clients can either send a first_name and last_name to /v1/customers or just a name to /v2/customers. They have time to upgrade!
Another common approach is to use an Accept header that specifies the version.
Read about it on RailsCasts later... there's other good info there as well :)
Some stuff may never change in your service. In this case, the non-version part of our routes and the TransactionsController stayed the same
You can cleverly avoid duplication in routes.rb
MagmaPaymentsService::Application.routes.draw do
(1..2).each do |version|
namespace :"v#{version}" do
resources :customers, only: [:create, :update] do
resources :transactions, only: [:create, :index, :show]
end
end
end
end
app/controllers/base/customers_controller.rb
module Base
class CustomersController < ApplicationController
# Stuff that rarely or never will change
end
end
app/controllers/v2/customers_controller.rb
module V2
class CustomersController < Base::CustomersController
# Stuff that overrides or is new functionality from Base
end
end
You make a change in V5. Now you have to do one of the following:
You may realize this adds more complication than anything, so decide if it's worth it. If you're building an internal service, it is very likely the previous versions won't live long anyway because you control the clients.
If you do it, be sure to test the behavior of this refactoring as well and keep tests for all active versions.
Decide and document from the beginning how depcrecation and removal of versions will work
Remember, the point is to not break clients! Don't make them implement version handling starting with V2
config/routes.rb
MagmaPaymentsService::Application.routes.draw do
api :version => 1 do
resources :customers, only: [:create, :update] do
resources :transactions, only: [:create, :index, :show]
end
end
end
app/controllers/customers_controller.rb
class CustomersController < RocketPants::Base
version '1'
def create
result = Braintree::Customer.create( ... )
expose({ success: result.success?, message: (result.message rescue '') })
end
end
app/clients/payments_client.rb
class PaymentsClient < RocketPants::Client
version '1'
base_uri MagmaClientApp::Application.config.payments_base_uri
class Result < APISmith::Smash
property :success
property :message
end
def create_customer(user)
post 'customers', payload: { name: user.name, email: user.email }, transformer: Result
end
end
Now just call this is the after_create
app/models/user.rb
PaymentsClient.new.create_customer(self)
Register and Handle errors
In controller you can do
error! :not_found
or raise the exception class
raise RocketPants::NotFound
and it will do the 404 Not Found HTTP response for you!
Create an API service application