Basic "bump" API using Rails
This post will show how to create a basic "bump" API, it does have several shortcomings but will demonstrate the essential components required to get started. The code can be found on github.
How does bump work?
The basic operation of the bump protocol breaks down into 2 parts an app running on a mobile device capable of sensing a "bump" using its sensors and a server that receives these bump events via some mechanism like an API. In the event of a bump the device sends basic information like device id and coordinates to a server. The server in turn uses an algorithm to match the bump to other recent bump events in the vicinity of the received bump and proceeds to pair up the devices. This post will focus on the server part of the protocol.
Environment
System requirements include:
Rvm, Ruby & Rails
It is not required but a good idea to setup rvm to help you manage different ruby versions. Another good practice is to create gemsets per project, it has the benefit of keeping dependencies isolated.
rvm use 2.2.1@rails_bump_protocol --create
gem install rails
Postgres & PostGIS
I chose Postgres because of PostGIS which is a spatial database extender for Postgres. This will form the backbone of the solution which is to detect bumps in close proximity. Most of the instructions I got out of the ubuntu wiki page but the gist of it is:
sudo apt-get install postgresql postgresql-contrib postgresql-client postgis postgresql-9.3-postgis-2.1
sudo su postgres
createuser -s rails_bump
psql
\password rails_bump
\q
Rails app
Generate a new rails app in your project directory using
rails new rails-bump-protocol
- I'm not sure about the rails app naming convention, after quick google search I could find examples with both "-" and "_" to separate terms.
Next step is to add the required gems to the project. RGeo is an Active Record extension that provides spatial data types and spacial queries. Puma is used to ensure that the app can handle concurrent requests.
Change the Gemfile:
gem 'pg' # replace 'sqlite3'
gem 'rgeo'
gem 'activerecord-postgis-adapter'
gem 'rgeo-geojson'
gem 'puma'
The database.yml file needs to be amended to switch to the postgis
adapter.
default: &default
adapter: postgis
pool: 5
timeout: 5000
schema_search_path: public
username: rails_bump
password: <password>
host: localhost
port: 5432
development:
<<: *default
database: rails_bump_protocol_dev
test:
<<: *default
database: rails_bump_protocol_test
Finally install the gems and migrate the database.
bundle install
rake db:setup
rake db:gis:setup
BumpEvent Resource
The solution is backed by two model objects, BumpEvent
which represents a bump received from a device and a BumpEventMatch
which is a link between 2 bump events.
Since I'm a complete rails noob, I'm going to avoid getting the naming convention wrong and just generate the resource.
rails g resource BumpEvent device_id:string lonlat:st_point --no-fixture --no-helper --no-assets
rails g model BumpEventMatch bump_event:references
The generated migration files will need some specifics applied. First up the CreateBumpEvents
migration. The :lonlat
column needs to be marked as :geographic
to indicate that it will contain longitude and latitude data. We also need to add a spatial index to :lonlat
since most queries would be around the location.
class CreateBumpEvents < ActiveRecord::Migration
def change
create_table :bump_events do |t|
t.string :device_id
t.st_point :lonlat, :geographic => true
t.timestamps null: false
end
add_index :bump_events, :lonlat, using: :gist
end
end
Next up is the CreateBumpEventMatches
migration, it needs the foreign key settings in addition to the unique index on :bump_event_id
and :matched_event_id
class CreateBumpEventMatches < ActiveRecord::Migration
def change
create_table :bump_event_matches do |t|
t.references :bump_event, index: true, foreign_key: true
t.references :matched_event, index: true
t.timestamps null: false
end
add_foreign_key :bump_event_matches, :bump_events, column: :matched_event_id
add_index :bump_event_matches, [:bump_event_id, :matched_event_id], unique: true
end
end
After doing some googling, I came across a great tutorial on self-referential associations and extracted just the parts I needed from it. As the diagram indicates, a BumpEvent
has many BumpEvent
through BumpEventMatches
. The other parts to note are the named scopes which makes for code that reads better.
class BumpEvent < ActiveRecord::Base
has_many :bump_event_matches
has_many :matched_events, through: :bump_event_matches,
dependent: :destroy
scope :recent, ->() { where('bump_events.created_at >= :time_ago',:time_ago => Time.now - 10.seconds) }
scope :nearby, ->(point) { where('ST_Distance(lonlat, :point) < :distance',:point => point.as_text, :distance => 10) }
scope :not_matched_to, ->(bump_event_id) {
where.not(id: BumpEventMatch.select(:matched_event_id).where(bump_event: bump_event_id))
}
scope :bump_matches, ->(bump_event) {
recent.nearby(bump_event.lonlat).not_matched_to(bump_event.id).where.not(id: bump_event.id)
}
def link_to_nearby_bumps()
matched_events << BumpEvent.bump_matches(self)
end
end
class BumpEventMatch < ActiveRecord::Base
belongs_to :bump_event
belongs_to :matched_event, class_name: "BumpEvent"
end
Controller & Routes
The controller only needs to support a subset of the RESTFul actions, POST
to create a BumpEvent
and GET
to retrieve it. It's a good idea to separate API controllers from the rest of the application controllers, hence I namespaced and versioned the resource.
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :bump_events, only: [:create, :show]
end
end
end
The generated bump_events_controller.rb
needs to be moved into controllers/api/v1 to match the namespace defined in the routes file. We also need to tell rails that API is an acronym to facilitate defining the module accordingly instead of Api like convention would dictate.
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym 'API'
end
The basic implementation of the controller is rudimentary and would not scale well. The create
method saves the BumpEvent
after decoding the JSON representation of the latitude/longitude which follows GeojSON format. Then follows the goofy bit, loop 10 times, sleeping for a second between loops and then linking any BumpEvents
that the system might have received in the last second.
module API
module V1
class BumpEventsController < ApplicationController
def show
event = BumpEvent.find(params[:id])
render json: event, status: 200
end
def create
bump_event = BumpEvent.new
bump_event.device_id = bump_event_params[:device_id]
bump_event.lonlat = RGeo::GeoJSON.decode(bump_event_params[:lonlat], json_parser: :json)
if bump_event.save
(1..10).each do
sleep 1
bump_event.link_to_nearby_bumps
end
render json: bump_event.to_json(include: {matched_events: {only: [:id, :device_id]}}),
status: 201, location: api_v1_bump_event_url(bump_event[:id])
end
end
private
def bump_event_params
params.require(:bump_event).permit(:device_id, lonlat: [:type, coordinates: []])
end
end
end
end
Tweaks
Considering the fact that API's are usually stateless, the CSRF protection behavior needs to change to :null_session
. Any other way would make the API calls fail due to an invalid CSRF token.
class ApplicationController < ActionController::Base
protect_from_forgery with: :null_session
end
Due to the primitive implementation of the Controller the rails app needs to be configured to allow concurrency since we would need at least two concurrent requests to facilitate a match.
module RailsBumpProtocol
class Application < Rails::Application
...
config.allow_concurrency = true
end
end
The last tweak is to change the default JSON generator for spatial types to the GeoJSON generator provided by RGeo. Add a new file config/initializers/rgeo.rb
RGeo::ActiveRecord::GeometryMixin.set_json_generator(:geojson)
Basic test
At this point you should be able to test the API, but first the DB needs to be migrated
rake db:migrate
rails s Puma
Open up two separate terminals and issue the below curl command in quick succession from both of them
curl -H "Content-Type: application/json" -X POST -d '{"device_id":"'$RANDOM'","lonlat":{"type":"Point", "coordinates":[-122.3989885,37.7905576]}}' http://localhost:3000/api/v1/bump_events
You should receive a 201 response code and a body similar to:
{
"id": 64,
"device_id": "21985",
"lonlat": {
"type": "Point",
"coordinates": [
-122.3989885,
37.7905576
]
},
"created_at": "2015-12-31T04:30:53.518Z",
"updated_at": "2015-12-31T04:30:53.518Z",
"matched_events": [
{
"id": 63,
"device_id": "6737"
}
]
}
Conclusion
Although this solution has a few weaknesses, it does provide the basic components of a working prototype. To get past the 10s sleep one might consider a polling solution from the device or maybe some other mechanism for pushing the matched events back to each device.
An alternative approach may be to return immediately and let each bump event take care for linking both ways during creation. Each device can then request all the matches some time after the initial bump, thereby moving the delay to the client device.
The source code is available on github.