This exercise covers the following topics.
Create, read, update, and delete (CRUD) are the four basic functions of persistent data storage. You can
REST (Representational State Transfer) has become the predominant web service design model and Ruby implements it quite elegantly. For a REST interface, CRUD has a one-to-one mapping to HTTP operations:
Persistance Operation HTTP Operation Create POST Read GET Update PUT Delete DELETE
You will create a web service to store and retrieve sets of numbers upon which statistics can be computed. You will accomplish this in two steps.
First, you will write a data persistence API for the four CRUD operations. For this you will use MongoDB, a NoSQL database. (This exercise assumes you have installed MongoDB and that it is running on the default port: 27017.) Your MongoDB API will be used internally by your application, but not directly by end users, so RSpec unit tests make sense here.
Second, you will wrap the API in a web service. For that you will use Sinatra. It has a syntax that easily maps HTTP requests to CRUD operations, making it ideal for web services. You will use automated Cucumber acceptance tests for the externally exposed web interface.
Add a runtime dependency for the 'mongo' gem to your statowl.gemspec
file.
Create a new file spec/mongo_spec.rb
and insert this into it:
require 'net/http' require_relative '../lib/statowl/mongo_repository' describe 'A repository for numbers' do mongo_client = Mongo::Client.new("mongodb://localhost:27017/statowl_database") collection = mongo_client[:statowl_stored_numbers] repository = Statowl::MongoRepository.new(collection) it 'should store numbers' do label = 'important_numbers' numbers = '1,2,3,4' repository.create(label, numbers) result = repository.read(label) expect(result).to eq(numbers) end end
Notice that your Statowl::MongoRepository
methods are create
and read
to emphasize CRUD operations and their mapping to REST,
which you will implement later.
Most developers use the methods save
and find
instead.
Execute rake spec
and observe a warning that a file containing the new class
Statowl::MongoRepository
does not exist.
This is typical for Test Driven Development.
You wrote your test first.
You "used the code you wish you had."
Create a new file lib/statowl/mongo_repository.rb
and insert the minimum
amount of code needed to make the test pass. (MongoDB must be running.)
require 'mongo' module Statowl class MongoRepository def initialize(collection) end def create(label, numbers) end def read(label) '1,2,3,4' end end end
All green? There isn't much here to refactor.
Add a new test with a different set of numbers that makes the hard-coded solution fail:
... it 'should store some different numbers' do label = 'important_numbers' numbers = '5,6,7,8' repository.create(label, numbers) result = repository.read(label) expect(result).to eq(numbers) end ...
Check that it fails and then make it pass by adding MongoDB operations to
lib/statowl/mongo_repository.rb
.
This implementation uses MongoDB as a repository of key-value pairs.
In this case the value contains the numbers you want to store
and the key is their label.
require 'mongo' module Statowl class MongoRepository def initialize(collection) @collection = collection end def create(label, numbers) @collection.delete_many({ 'key' => label }) @collection.insert_one({ 'key' => label, 'value' => numbers }) end def read(label) documents = @collection.find({ 'key' => label}) documents.first['value'] if documents.any? end end end
Refactor?
You have implemented the first two CRUD features.
Create is implemented by the create
method
and Read is implemented by the read
method.
Add a test to update numbers that are already stored.
... it 'should update numbers' do label = 'important_numbers' old_numbers = '1,2,3,4' new_numbers = '5,6,7,8' repository.create(label, old_numbers) repository.update(label, new_numbers) result = repository.read(label) expect(result).to eq(new_numbers) end ...
Make sure that it fails because the update
method does not exist.
Because you only allow one value per key,
and update operation is essentially the same as a create operation.
Use Ruby's built-in alias
method to map update
to create
in lib/statowl/mongo_repository.rb
:
... alias :update :create ...
Nine lines of test code passes with only a single line of production code - bravo! Refactor?
Add a test for Delete, the final CRUD operation:
... it 'should delete numbers' do label = 'trash' repository.create(label, '1,2,3') repository.delete(label) result = repository.read(label) expect(result).to eq(nil) end ...
Verify that it fails and then make it pass:
... def delete(label) @collection.delete_many({ 'key' => label }) end ...
That was easy! You have just written a functional CRUD API that is fully covered by automated tests. Think about how long it would have taken to implement and test the API by combining Java, MySQL, and Hibernate annotations! Notice the readability of the RSpec console output.
All tests are green. Any final refactoring?
Add require 'statowl/mongo_repository'
to lib/statowl.rb
.
Next you will expose your CRUD API as a REST service using Sinatra.
Web interfaces are designed to be used by external clients,
so Cucumber Acceptance Tests are appropriate.
Create a new file features/mongo_web_service.feature
and insert a new feature:
Feature: A web service to store and retrieve arrays As the writer of a cool web app I want to store arrays in a database And retrieve them via a web service Scenario: Store and retrieve an array Given my URI is "http://localhost:8080/mongo/my_important_numbers" When I "POST" the numbers "1,2,3,4" And I execute a "GET" request Then the JSON should equal """ { "label": "my_important_numbers", "array": "1,2,3,4" } """
Run rake features
and observe that you have some undefined steps.
Copy the code suggested code snippets to a new file features/step_definitions/mongo_rest_steps.rb
and observe that you now have skipped and pending steps.
Implementing the first step is trivial, because you only have to save the given value for future steps:
Given(/^my URI is "(.*?)"$/) do |uri| @uri = uri end
To implement the next step you can use Ruby's Net
module to make the POST:
require 'net/http' require 'uri' ... When(/^I "(.*?)" the numbers "(.*?)"$/) do |method, csv| s = @uri.split('/') label = s[s.size - 1] case method when 'POST' Net::HTTP.post_form(URI(@uri), { "array" => csv }) else raise 'invalid HTTP method!' end end
/mongo/my_important_numbers
.
Why does this step pass?
You are not verifying that the POST was successful.
You can also use the Net
module for the GET request in the next step:
When(/^I execute a "(.*?)" request$/) do |method| case method when 'GET' @actual_response = Net::HTTP.get( URI(@uri) ) else raise "Invalid HTTP method!" end end
This step passes for the same reason as the prior step. Define the final step:
Then(/^the JSON should equal$/) do |expected_JSON| expected_hash = JSON.parse(expected_JSON) actual_hash = JSON.parse(@actual_response) expect(actual_hash.eql?(expected_hash)).to eq(true) end
This fails, because the HTML from Sinatra's 404 "page not found" error cannot be parsed as JSON.
You can read about the test failure, which is highlighted in red about halfway down the console output.
For a better look at the HTML output, however, it is better to point your browser to the route you
attempted to GET.
Execute bundle exec bin/sinatra
and then point your browser to
http://localhost:8080/mongo/my_important_numbers
As you can see, a web browser is handy for testing GET requests, especially with Firebug installed. To test POST, PUT, and DELETE requests you can use the handy Firefox add-on "Poster."
Make the new feature pass by
implementing the POST and GET routes in bin/sinatra
:
... require 'uri' require_relative '../lib/statowl/mongo_repository' ... post '/mongo/:label' do content_type 'application/json' mongo_client = Mongo::Client.new("mongodb://localhost:27017/statowl_database") collection = mongo_client[:statowl_stored_numbers] repository = Statowl::MongoRepository.new(collection) label = URI.decode( params[:label] ) array = URI.decode(request.body.read.split('=')[1] ) repository.create(label, array) { 'label' => label, 'array' => array }.to_json end get '/mongo/:label' do content_type 'application/json' content_type 'application/json' mongo_client = Mongo::Client.new("mongodb://localhost:27017/statowl_database") collection = mongo_client[:statowl_stored_numbers] repository = Statowl::MongoRepository.new(collection) label = URI.decode( params[:label] ) array = repository.read(label) { 'label' => label, 'array' => array }.to_json end ...
Your tests pass, but there is some obvious redundancy in the Sinatra routes. There is an important issue that needs to be addressed right away, however, so save the refactoring for the next cycle.
There is a very subtle trap in your test of the POST route.
Take a look at features/mongo_web_service.feature
.
The POST is only verified indirectly via the subsequent GET.
If the POST fails, then GET returns any values that may already
be stored in the database.
It is conceivable that POST could break today and for days or weeks
your tests could continue to pass because of the old stored data.
To see for yourself, change the port number for the POST route
from 27017 to 27018 in bin/sinatra
and run your tests again.
Without the correct port number Sinatra cannot connect to MondoDB to save the data.
Now run your tests again. Still green?
The fix is to purge the database before each test run or to triangulate so that static data will not pass.
Keep the incorrect port number and add another scenario where the label is the same but the data array is different. One of the two tests now fails. Change the port number back to 27017 to make it pass.
Test Driven Development creates an environment for fast test-code-refactor cycles, because there is less worry about breaking the existing production code. Risks are lower, which facilitates aggressive refactoring. The presumed safety net depends on the quality of the tests, however. Sometimes it is a good idea to do a quick "test of the test," as you did by changing the MongoDB port number, to assure yourself that your assumed safety net really exists.
Your tests are passing so it is time to refactor.
For both GET and POST you connect to the database,
do something with it,
and then close the connection.
In this situation, Ruby programmers frequently use "Extract Surrounding Method" refactoring.
To apply Extract Surrounding Method here, you can create a method that connects to MongoDB,
creates an instance of MongoRepository
, yields that instance to the GET or POST
that invokes it, and then closes the connection:
... def connect_to_database mongo_client = Mongo::Client.new("mongodb://localhost:27017/statowl_database") collection = mongo_client[:statowl_stored_numbers] repository = Statowl::MongoRepository.new(collection) yield repository end post '/mongo/:label' do content_type 'application/json' connect_to_database do |repository| @label = URI.decode( params[:label] ) @array = URI.decode(request.body.read.split('=')[1] ) repository.create(@label, @array) end { 'label' => @label, 'array' => @array }.to_json end get '/mongo/:label' do content_type 'application/json' connect_to_database do |repository| @label = URI.decode( params[:label] ) @array = repository.read(@label) || "" end { 'label' => @label, 'array' => @array }.to_json end ...
The routes are much leaner. Are your test still green?
Write a new feature to delete the data for a given label.
Begin by writing a new Cucumber feature. A GET request for the label "my_important_numbers" returns this if there is no data stored in the database for it:
{"label":"my_important_numbers","array":""}
Here is how to send an HTTP DELETE to port 8080 of the server "localhost":
uri = URI("http://localhost:8080/mongo/my_important_numbers") http = Net::HTTP.new(uri.host, uri.port) request = Net::HTTP::Delete.new(uri.path) result = http.request(request)
Cryptic errors are usually caused by coding mistakes in bin/sinatra
that cause Sinatra's startup to abort.
To check this behaviour, try to start Sinatra manually:
bundle exec bin/sinatra
You now have a functional Ruby statistics library, a command line application, and web services to compute statistics and store arrays of numbers. Moreover, the code is fully covered by automated tests. Your server-side production code is complete, except for some professional touches you will make in the next exercise. All you need now is a client application to make good use of your web services.
Execute the following to repackage and deploy StatOwl as a gem:
rake clobber rake deploy
Try these commands:
statowl help statowl help all echo '1,2,3,4' | statowl all echo '1,2,3,4' | statowl -f xml all echo '1,2,3,4' | statowl -f json all sinatra
While Sinatra is running, try this in your browser:
http://localhost:8080/statistics.html?numbers=1,2,3,4
Completely uninstall the gem so that it does not conflict with further development. Answer "Y" to the prompt:
gem uninstall statowl
In this exercise you used the mongo RubyGem to implement the Create, Read, Update, and Delete operations of a CRUD application. You then wrapped a RESTful web service around CRUD using the HTTP operations POST, GET, and DELETE. (In this case PUT is unnecessary, because it would accomplish the same thing as POST.)
You used RSpec Behavior Driven Development for the MongoDB production code and Cucumber acceptance tests for the web service.
In the next exercise you will learn how to manage large projects.
Copyright © 2005-2022 Amp Books LLC All Rights Reserved