Final Ode to OpenEdge ABL Part 2: Ruby Helps You REST Easy
In part 1 of this series, we learned how to get Ruby to talk to an OpenEdge database by using an adapter for the DataMapper ORM framework.
In this post, I would like to demonstrate both the power and beauty of Ruby by
rapidly prototyping a RESTful Web service (using JSON representation responses)
for sports2000
customers using our new OpenEdge database adapter.
REST is a pretty big topic and if you are unfamiliar with it you should probably invest some effort into learning about it. The simplified version is that it is a way to describe resources and actions involving said resources. The HTTP protocol that powers the Web was basically built specifically to implement REST principles. Therefore, if your resources can respond properly to all the HTTP methods than your service is probably pretty RESTful.
In researching this article I tried to find some existing examples of REST in use in the OpenEdge community to compare to. All I found were some murmurs about a REST adapter that Progress Corp. was supposedly going to provide for AppServers / WebSpeed as part of OpenEdge 11 which apparently hasn’t materialized (update: Phillip Molly Malone, a PSC employee has stated that it is coming in OE 11.2.0 in 2013), and a “Web 2.0 RIA” product sold by BravePoint which doesn’t use REST at all but uses some proprietary “RPC Engine” to communicate between client (JavaScript) and server (WebSpeed).
Knowing that we have no existing prior art in the OpenEdge community to compare to, let’s break new ground and do it ourselves. We are going to start by building a barebones REST API for a single resource - customers. We are going to support all the basic CRUD actions, which in HTTP terms are POST for create, GET for read, PUT/PATCH for update, and DELETE for… delete. For this example we are going to use Sinatra.
Setup
I am going to assume that you are following along from part 1 and have already installed JRuby using rvm (if not, go back and do so). Let’s proceed by creating a fresh gemset to namespace our gems for this demo:
rvm use --create jruby-1.7.0@openedge-sinatra
Let’s re-use the same git repo from part 1 that has our DataMapper models defined (if you still have the code just copy it to a new directory):
git clone git://gist.github.com/3073736.git openedge-sinatra
cd openedge-sinatra
If this is a fresh clone be sure to change the database parameters on line
4 of models.rb
to match yours, and potentially change the version of the
jdbc-openedge
gem in the Gemfile
to use the version of OpenEdge
that you’re on (if you’re not on 11.1).
Next, we are going to install the sinatra
gem. Open the Gemfile
and add
this line at the bottom:
gem "sinatra", "~> 1.3.2"
Now we are ready to install the gems using bundler by typing
bundle install
Sinatra
Sinatra is essentially a very simple Domain-specific language (DSL) for specifying how to respond to HTTP requests. It isn’t a Web server in itself, so it will delegate to WEBrick (a Web server that comes built-in to Ruby’s stdlib) if you don’t have one. WEBrick is fine for development, but should never be ran in a production environment as it is not optimized for that. Obviously we won’t be worrying about that here, but keep it in mind if you continue using Ruby.
Let’s create a simple server using Sinatra to respond to the root url (/
)
with hello world
. Create a file called server.rb
and put this content in
it:
require 'sinatra'
get '/' do
'hello world'
end
At this point we can start our Web server by running our Ruby code:
bundle exec ruby server.rb
Now browse to http://localhost:4567 in your browser. You should see
the text hello world
in the body of your browser. If you’re an ABL
programmer, I hope you’re shocked by how simple that is.
Without waxing poetically too much, I’d like to point out some things about Ruby here that might look a bit like magic.
First, lines 3-5 probably don’t look much like code. That’s because Sinatra
is taking advantage of some features of Ruby to essentially make its own
DSL. The first is that get
is just a Ruby method that Sinatra has
defined but moved into the global object scope so that it looks like a
Ruby language keyword. get
takes two parameters - the first is a string
that matches a path, in this case the root path /
, while the second
parameter is a block, which is the part between the do ... end
(in this
case it’s just 'hello world'
. Blocks can also be passed using curly
braces ({ }
); standard practice is to use braces for one-line blocks and
do ... end
for multi-line blocks.
In Ruby, blocks are very important. They are a lexical closure, or a
chunk of code that is bound to the lexical scope they are defined in (i.e.
they can see variables defined outside of the block). They are powerful
because they let you pass around a block of code as an object. The way that
we are using them in our get
method is to evaluate the first argument -
the route, in this case the '/'
- and if it matches to execute the code in
the second argument, i.e. the block.
Secondly, you might note that the first argument to the get
method - '/'
-
doesn’t look much like an argument because it doesn’t have parentheses around
it. That’s because in Ruby, parentheses are optional (well, as long as its
not ambiguous to the interpreter that you are passing method arguments,
anyway).
Finally, in Ruby the last statement of a method/block is the return value;
you don’t need an explicit return
statement. You can use one, but
it’s not idiomatic Ruby and looks ugly; it’s typically only used to
short-circuit evaluation near the beginning of a method due to a problem with
some state that should prevent execution from continuing. Therefore, you can
see that the return value of our do ... end
block is simply and
unconditionally the string 'hello world'
.
Taking all of the above into consideration, it would also be valid to write our method as
Sinatra::Base.get('/'){ return 'hello world' }
However, notice the difference in readability. Ruby encourages the Sinatra method of creating mini-DSLs over making everything look like generic, terse code, for good reason.
Hooking into our models
Now that we have a running Web server, let’s make it do something useful.
Let’s load our DataMapper code from part 1 and add a route to display all
customers. Edit your server.rb
to look like this:
require 'sinatra'
require './models'
get '/customers' do
Customer.all.to_json
end
Restart the server and visit http://localhost:4567/customers in your browser, and voilà - you should see a big JSON array that contains every customer in our database! If you’re using Chrome I recommend the JSONView extension for improved readability.
The URI that we just created is referred to as a collection URI as it returns a collection of resources rather than a single element. Let’s go ahead and add support for individual elements, and implement all the HTTP methods that correspond to the CRUD actions - GET (read), POST (create), PUT/PATCH (update), DELETE.
GET (read)
We already implemented this HTTP type for our collection URI. The only added
complexity we need for a single element is to accept the primary key of the
element that the user is requesting. Sinatra makes this very easy by providing
support for this in its route matcher. Add this to your sinatra.rb
file:
get '/customer/:cust_num' do |cust_num|
@customer = Customer.get(cust_num)
if @customer
@customer.to_json
else
not_found 'unknown customer'
end
end
A few more things to note here about Ruby. First, that similar to methods,
blocks can take parameters (|cust_num|
). The block value for cust_num
will be anything after /customer/
in the request URL, according to our route
matcher. If there was another :param
in our routing string then we could
have our block accept multiple arguments.
Second, note the conditional on line 3; Ruby conditionals evaluate anything
other than false
and nil
as true (this is known as “truthy” evaluation).
Therefore, it is very common
to just use the actual object in a conditional rather than adding an explicit
check to see if it is not nil, because an initialized object will have a
non-nil value anyway. You can explicitly check for nil using
@customer.nil?
, but it is almost always unnecessary and is a definite code
smell.
Finally, note that again we are taking advantage of Ruby’s DSL-supporting
features of the return value of a method being the last statement it evaluates
and the lack of parentheses around the call to the not_found
method.
To test our method, let’s use a Unix utility called curl to fetch just the first customer (alternatively, you could visit the address in your browser):
curl http://localhost:4567/customer/1
You should see this JSON get output on your console:
{"cust_num":1,"name":"Lift Tours","country":"USA","address":"276 North Drive","address2":"","city":"Burlington","state":"MA","postal_code":"01730","contact":"Gloria Shepley","phone":"(617) 450-0086","sales_rep":"HXM","credit_limit":"0.667E5","balance":"0.90364E3","terms":"Net30","discount":35,"comments":"This customer is on credit hold.","fax":"","email_address":""}
POST (create)
Next, let’s support creating a new customer using the HTTP POST method. Add
this code to server.rb
:
post '/customer' do
next_id = Customer.last.cust_num + 1
Customer.create(params.merge(:cust_num => next_id))
end
This one is pretty easy as DataMapper’s create
method accepts a hash of
attributes, which is what we’re passing in (Sinatra stores our parameters in a
hash called params
). The only tricky part is getting the next customer
number to use for insertion, as we don’t have a sequence. Also note that we
are using Hash#merge
to override any user-provided value for cust_num
;
this may actually not be a valid consideration if you want API users to be able
to provide a value for this (personally I don’t see why you would need this).
To test this method we can again use curl, specifying the HTTP POST header
with the -X
option and parameters with -d
:
curl -X POST -d "name=foo&country=Mexico" http://localhost:4567/customer
You should see a response with the JSON data of our new customer appear in the console. It should look something like this:
{"cust_num":2107,"name":"foo","country":"Mexico","address":null,"address2":null,"city":null,"state":null,"postal_code":null,"contact":null,"phone":null,"sales_rep":null,"credit_limit":null,"balance":null,"terms":null,"discount":null,"comments":null,"fax":null,"email_address":null}
PUT and PATCH (update)
To update an existing customer, we will support the HTTP methods PUT and PATCH. The difference is that PUT is for completely replacing the entire customer object, while PATCH is for retaining the existing customer but only replacing some of its attributes (i.e. a merge). PATCH is relatively recent, having only been proposed in 2010.
put '/customer/:cust_num' do |cust_num|
@customer = Customer.get(cust_num)
if @customer
@customer.destroy && Customer.create(request.params.merge({'cust_num' => cust_num}))
@customer.to_json
else
not_found 'unknown customer'
end
end
patch '/customer/:cust_num' do |cust_num|
@customer = Customer.get(cust_num)
if @customer
@customer.attributes = request.params.reject{|k,v| k == 'cust_num'}
@customer.save
@customer.to_json
else
not_found 'unknown customer'
end
end
There’s not a whole lot going on here, besides the &&
which is just for
chaining method calls together, continuing as long as every call returns true
(you’ve probably seen it in bash scripts or many other
languages). Also, we again use Hash#merge
in the put
method to force
cust_num
to be the value passed in from the URL, and not a value that the
user provides. For the same reason, we use Hash#reject
in the patch
method to pull out any user-provided value for cust_num
.
Let’s test PUT with curl:
curl -X PUT -d "name=bar" http://localhost:4567/customer/2107
You should see a result like this:
{"cust_num":2107,"name":"bar","country":"USA","address":"","address2":"","city":"","state":"","postal_code":"","contact":"","phone":"","sales_rep":"","credit_limit":"0.15E4","balance":"0.0","terms":"Net30","discount":0,"comments":"","fax":"","email_address":""}
Notice that the customer’s name did change to bar
as expected, however the
other attributes reverted to new object defaults (note country changed to
USA
). This is because we are creating an entirely new object.
Now let’s test PATCH:
curl -X PATCH -d "country=Mexico" http://localhost:4567/customer/2107
You should see output like this:
{"cust_num":2107,"name":"bar","country":"Mexico","address":"","address2":"","city":"","state":"","postal_code":"","contact":"","phone":"","sales_rep":"","credit_limit":"0.15E4","balance":"0.0","terms":"Net30","discount":0,"comments":"","fax":"","email_address":""}
Note that country changed to Mexico
as expected, however the name remained
bar
because we are modifying the existing object and not creating a new one.
To test our little security feature of disallowing the cust_num
field to
change, we can try passing it in like this:
curl -X PATCH -d "cust_num=9999" http://localhost:4567/customer/2107
The output from curl verifies that it works:
{"cust_num":2107,"name":"bar","country":"Mexico","address":"","address2":"","city":"","state":"","postal_code":"","contact":"","phone":"","sales_rep":"","credit_limit":"0.15E4","balance":"0.0","terms":"Net30","discount":0,"comments":"","fax":"","email_address":""}
DELETE
Finally, to delete a customer, we simply define a method like this:
delete '/customer/:cust_num' do |cust_num|
@customer = Customer.get(cust_num)
if @customer
@customer.destroy
else
not_found 'unknown customer'
end
end
Once again we test with curl, this time adding the -I
option, which displays
the HTTP headers of the response. Alternatively, we could change our server
code to return some type of {status: "success"}
JSON in the response body
but then it would probably make sense to change the rest of our methods too,
so we will take the simple way out and just pay attention to the HTTP response
code:
curl -IX DELETE http://localhost:4567/customer/2107
You should see a 200 OK
HTTP header on the first line of the response, which
means that the request was successful. It should look something like this:
HTTP/1.1 200 OK
X-Frame-Options: sameorigin
X-Xss-Protection: 1; mode=block
Content-Type: text/html;charset=utf-8
Content-Length: 0
Server: WEBrick/1.3.1 (Ruby/1.9.2/2011-12-27)
Date: Fri, 13 Jul 2012 03:09:01 GMT
Connection: Keep-Alive
To verify that the customer really is deleted, you can either repeat the same
command we just did (side note: the ability to repeat this action without
fear of causing side-effects is called idempotence), or just a simple GET
request and you should see a response header of 404 Not Found
with a body of
unknown customer
.
Putting it all together
If you’ve been following along, your server.rb
should look like this:
require 'sinatra'
require './models'
get '/customers' do
Customer.all.to_json
end
get '/customer/:cust_num' do |cust_num|
@customer = Customer.get(cust_num)
if @customer
@customer.to_json
else
not_found 'unknown customer'
end
end
post '/customer' do
next_id = Customer.last.cust_num + 1
@customer = Customer.create(params.merge(:cust_num => next_id))
@customer.to_json
end
put '/customer/:cust_num' do |cust_num|
@customer = Customer.get(cust_num)
if @customer
@customer.destroy && Customer.create(request.params.merge({'cust_num' => cust_num}))
@customer.to_json
else
not_found 'unknown customer'
end
end
patch '/customer/:cust_num' do |cust_num|
@customer = Customer.get(cust_num)
if @customer
@customer.attributes = request.params.reject{|k,v| k == 'cust_num'}
@customer.save
@customer.to_json
else
not_found 'unknown customer'
end
end
delete '/customer/:cust_num' do |cust_num|
@customer = Customer.get(cust_num)
if @customer
@customer.destroy
else
not_found 'unknown customer'
end
end
That’s a RESTful JSON API for sports2000
customers in 50 lines of code! Do
you think you can do that in ABL?
Tests
I agonized over whether to write this app from a test-driven development perspective, which would necessitate writing tests before writing the app code. I decided in the interest of absolute simplicity I would focus on the actual server code, but it would be an excellent learning experience to do this on your own. Sinatra has some good examples of how to write tests for different test frameworks (I personally enjoy RSpec).
Writing tests is definitely a best practice in the Ruby community, and in my opinion practically a necessity for a language such as Ruby that can bend like silly putty at runtime.
Ruby on Rails
I was also tempted to make this post be about creating a full CRUD app in Rails, complete with HTML forms for creating/updating customers, but it would have required too much hand-waving to be of any use for people new to Ruby (my audience being ABL programmers). Sticking with Sinatra keeps you closer to plain Ruby and keeps the code short and understandable; Rails has a lot of conventions and requires knowledge of that black magic to do anything useful.
The advantages with using Rails would have been that I could have
had models and relationships for every single table in sports2000
, and had
complete HTML forms for CRUD actions be generated easily by Rails’s
generators.
Although it wasn’t worth the added complexity for this tutorial, I do think that Rails excels at the typical CRUD app (which is what most applications of ABL are) and would be worth exploring if you found this post enlightening. For learning Rails, the best resource I can recommend is Michael Hartl’s Rails tutorial. However, I would first recommend getting a better foundation in Ruby, for which I recommend the book The Ruby Programming Language both as an overview and as a reference.
Personally, I only learned Rails to pay the bills… Ruby the language is what really captivated me (although at this point the honeymoon is kind of over).
Next post(s) in series
There doesn’t seem to be much interest in this so far, so I may just cut it short with the next post with a short summation and some honest advice for Progress Software Corporation. However, I also had an idea of showing how to do a simple database migration using DataMapper’s ability to connect to multiple databases at the same time (called “contexts” in DataMapper lingo). If you’ve been following along and that interests you let me know in the comments.
Update: Part 3 has now been posted.