Skip to content

Instantly share code, notes, and snippets.

@ashmoran
Last active December 21, 2015 06:49
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ashmoran/6267199 to your computer and use it in GitHub Desktop.
Save ashmoran/6267199 to your computer and use it in GitHub Desktop.
FishermanRegistrarServerResource: an example Webmachine resource for my Realm application Harvest
require 'json'
module Harvest
module HTTP
module Server
module Resources
class FishermanRegistrarServerResource < Resource
def trace?
true
end
def content_types_provided
[
['application/hal+json', :to_json],
['application/json', :to_json]
]
end
def allowed_methods
%W[ GET POST ]
end
def malformed_request?
# This is probably proof we need to split the GET and POST requests
# into separate resources
return false if request.get?
# Send to_s because we might have a LazyRequestBody
JSON.parse(request.body.to_s)
false
rescue JSON::ParserError
render_json_error_response(
error: "malformed_request",
message: "Request body contained malformed JSON"
)
true
end
# Note: we could consider using post_is_create? and creating a
# fisherman resource here, but I'll leave that for the future
def process_post
service.sign_up_fisherman(
JSON.parse(request.body.to_s).symbolize_keys
).on(
fishing_application_succeeded: ->(result) {
response.headers['Content-Type'] = "application/json"
response.body = result.to_json
true
},
fishing_application_conflicts: ->(result) {
render_json_error_response(
error: "command_failed_validation", message: result.fetch(:message)
)
409
},
fishing_application_invalid: ->(result) {
render_json_error_response(
error: "command_failed_validation", message: result.fetch(:message)
)
422
}
)
end
def to_json
Representations::FishermanRegistrar.new(
base_uri,
fisherman_read_model: harvest_app.read_models[:registered_fishermen],
fishing_ground_read_model: harvest_app.read_models[:fishing_grounds_available_to_join],
fishing_ground_businesses_read_model: harvest_app.read_models[:fishing_ground_businesses],
).to_json
end
def handle_exception(error)
case error
when Realm::Messaging::MessagePropertyError
render_json_error_response(
error: "invalid_command_format", message: error.message
)
# Unprocessable Entity, not in Webmachine as this status code is from WebDAV,
# but it's gaining traction to indicate a semantic error rather than a syntactic
# error (400).
# Maybe it would make more sense to use 400 for unconstructable commands though,
# and 422 only for when domain validation fails.
422
when Realm::Messaging::UnhandledMessageError
# Currently we can get here via either a missing command handler on the
# message bus or a missing response handler in the resource
render_json_error_response(
error: "unhandled_message",
message: %'The server has not been configured to handle "#{error.message_type_name}"'
)
500
else
super
end
end
private
def service
harvest_app.application_services.fetch(:poseidon)
end
def render_json_error_response(error: required('error'), message: required('message'))
response.headers['Content-Type'] = "application/json"
response.body = {
"error" => error,
"message" => message
}.to_json
end
end
end
end
end
end
require 'spec_helper'
require 'harvest/domain'
require 'harvest/http/representations'
require 'harvest/http/server/resources'
module Harvest
module HTTP
module Server
module Resources
describe FishermanRegistrarServerResource, type: :resource do
let(:resource_route) { [ ] }
let(:request_method) { 'POST' }
let(:request_path) { '/' }
let(:poseidon) {
double("ApplicationService", sign_up_fisherman: command_response)
}
before(:each) do
harvest_app.application_services[:poseidon] = poseidon
end
before(:each) do
dispatch_request
end
context "malformed request JSON" do
let(:request_body) { "{ this is not JSON" }
its(:code) { should be == 400 }
let(:command_response) { :_never_reached_ }
specify "content type" do
expect(response).to have_content_type("application/json")
end
specify "body" do
expect(JSON.parse(response.body)).to be == {
"error" => "malformed_request",
"message" => "Request body contained malformed JSON"
}
end
end
context "well-formed but invalid request" do
let(:request_body) { '{ "invalid": "command" }' }
# This is a bit nasty - as we're no longer creating the commands in the
# resource, we have to know how to construct a MessagePropertyError in
# the specs. A better solution will emerge later, I hope...
let(:error) {
Realm::Messaging::MessagePropertyError.new(:some_message_type, [:foo], [:bar])
}
let(:command_response) {
Realm::Messaging::FakeMessageResponse.new(raise_error: error)
}
specify "command sent" do
expect(poseidon).to have_received(:sign_up_fisherman).with(
invalid: "command"
)
end
its(:code) { should be == 422 }
specify "content type" do
expect(response).to have_content_type("application/json")
end
describe "body" do
subject(:parsed_body) { JSON.parse(response.body) }
specify "error" do
expect(parsed_body["error"]).to be == "invalid_command_format"
end
specify "message" do
expect(parsed_body["message"]).to match(/Attributes did not match MessageType/)
end
end
end
context "unhandled command" do
let(:fake_message) {
double(Realm::Messaging::Message, message_type_name: :fake_message_type)
}
let(:request_body) {
{
"username" => "valid_username",
"email_address" => "valid.email@example.com",
"password" => "valid password"
}.to_json
}
let(:command_response) {
Realm::Messaging::FakeMessageResponse.new(
raise_error: Realm::Messaging::UnhandledMessageError.new(fake_message)
)
}
specify "command sent" do
expect(poseidon).to have_received(:sign_up_fisherman).with(
username: "valid_username",
email_address: "valid.email@example.com",
password: "valid password"
)
end
# It's a shame 501 can't be used here, but that implies we can't handle
# POST for any resource, rather than just the one with the unimplemented
# command handler
its(:code) { should be == 500 }
specify "content type" do
expect(response).to have_content_type("application/json")
end
describe "body" do
subject(:parsed_body) { JSON.parse(response.body) }
specify "error" do
expect(parsed_body["error"]).to be == "unhandled_message"
end
specify "message" do
expect(parsed_body["message"]).to match(
/The server has not been configured to handle "fake_message_type"/
)
end
end
end
context "domain-disallowed request (invalid according to domain validation)" do
let(:request_body) {
{
"username" => "invalid username!",
"email_address" => "valid.email@example.com",
"password" => "valid password"
}.to_json
}
let(:command_response) {
Realm::Messaging::FakeMessageResponse.new(
resolve_with: {
message_type_name: :fishing_application_invalid,
args: { message: "Invalid username" }
}
)
}
specify "command sent" do
expect(poseidon).to have_received(:sign_up_fisherman).with(
username: "invalid username!",
email_address: "valid.email@example.com",
password: "valid password"
)
end
its(:code) { should be == 422 }
specify "content type" do
expect(response).to have_content_type("application/json")
end
describe "body" do
subject(:parsed_body) { JSON.parse(response.body) }
specify "error" do
expect(parsed_body["error"]).to be == "command_failed_validation"
end
specify "message" do
expect(parsed_body["message"]).to match(/Invalid username/)
end
end
end
context "conflicting command (eg duplicate username)" do
let(:request_body) {
{
"username" => "duplicate_username",
"email_address" => "valid.email@example.com",
"password" => "valid password"
}.to_json
}
let(:command_response) {
Realm::Messaging::FakeMessageResponse.new(
resolve_with: {
message_type_name: :fishing_application_conflicts,
args: { message: "Username taken" }
}
)
}
specify "command sent" do
expect(poseidon).to have_received(:sign_up_fisherman).with(
username: "duplicate_username",
email_address: "valid.email@example.com",
password: "valid password"
)
end
# This is a bit of a hack as conflicts are intended per-resource,
# maybe we should use post_is_create and see if we can treat it as PUT?
# (We'd still have the issue that the conlict is cross-resource though.)
its(:code) { should be == 409 }
specify "content type" do
expect(response).to have_content_type("application/json")
end
describe "body" do
subject(:parsed_body) { JSON.parse(response.body) }
specify "error" do
expect(parsed_body["error"]).to be == "command_failed_validation"
end
specify "message" do
expect(parsed_body["message"]).to match(/Username taken/)
end
end
end
context "successful create" do
let(:request_body) {
{
"username" => "username",
"email_address" => "valid.email@example.com",
"password" => "valid password"
}.to_json
}
let(:command_response) {
Realm::Messaging::FakeMessageResponse.new(
resolve_with: {
message_type_name: :fishing_application_succeeded,
args: { uuid: "some_uuid" }
}
)
}
specify "command sent" do
expect(poseidon).to have_received(:sign_up_fisherman).with(
username: "username",
email_address: "valid.email@example.com",
password: "valid password"
)
end
# In future we may create a new resource, and then return a 201
its(:code) { should be == 200 }
specify "content type" do
expect(response).to have_content_type("application/json")
end
describe "body" do
subject(:parsed_body) { JSON.parse(response.body) }
specify "uuid" do
expect(parsed_body["uuid"]).to be == "some_uuid"
end
end
end
end
end
end
end
end
require 'json'
require 'webmachine'
require 'harvest/app'
require 'harvest/http/server/resource_creator'
shared_context "resource context", type: :resource do
let(:command_bus) { double(Realm::Messaging::Bus::MessageBus, send: nil) }
let(:harvest_app) {
double(Harvest::App,
command_bus: command_bus,
application_services: Hash.new
)
}
let(:base_uri) { "" }
let(:resource_creator) {
Harvest::HTTP::Server::ResourceCreator.new(
harvest_app: harvest_app,
base_uri: base_uri,
cache_path: :unused
)
}
let(:dispatcher) {
Webmachine::Dispatcher.new(resource_creator).tap do |dispatcher|
dispatcher.add_route(resource_route, described_class)
end
}
let(:request) {
Webmachine::Request.new(
request_method, URI::HTTP.build(path: request_path), Webmachine::Headers.new, request_body
)
}
subject(:response) { Webmachine::TestResponse.build }
def dispatch_request
dispatcher.dispatch(request, response)
end
end
require 'forwardable'
require 'term/ansicolor'
require 'ap'
RSpec::Matchers.define :have_status_code do |expected_code|
match do |response|
response.code == expected_code
end
# TODO: only want to log the error on unexpected 500s etc
failure_message_for_should do |response|
"Expected response #{response} to have status #{expected_code}\n" +
"Got: #{response.code}\n" +
(response.error || "")
end
end
RSpec::Matchers.define :have_content_type do |expected_content_type|
match do |response|
@actual_content_type = response.headers['Content-Type']
@actual_content_type == expected_content_type
end
failure_message_for_should do |response|
%'Expected response #{response} to have content type "#{expected_content_type}" but ' +
if @actual_content_type
%'got "#{@actual_content_type}"'
else
"the Content-Type header was not set"
end
end
end
module Webmachine
# A decorator for resources to help in testing
class TestResponse
include Term::ANSIColor
extend Forwardable
def_delegators :@response,
:headers, :code, :code=, :body, :body=, :redirect, :trace, :error, :error=,
:do_redirect, :set_cookie,
:is_redirect?, :redirect_to
class << self
def build
new(Webmachine::Response.new)
end
end
def initialize(response)
@response = response
@inspector = AwesomePrint::Inspector.new(
multiline: false, color: { symbol: :purpleish }
)
end
def trace_lines
trace.map { |trace_hash|
format_trace_line(trace_hash)
}
end
# Handling all cases the same here because I'm no longer sure
# if we want to do error formatting in this method
def format_trace_line(line)
case line[:type]
when :response
case line[:code].to_i
when 500
format_standard_line(line)
else
format_standard_line(line)
end
else
format_standard_line(line)
end
end
def format_standard_line(line)
line.inject([ ]) { |line_parts, (key, value)|
line_parts.concat([ "#{cyan(key.to_s)}: #{@inspector.awesome(value)}" ])
}.join(", ")
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment