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