Last active
December 21, 2015 06:49
-
-
Save ashmoran/6267199 to your computer and use it in GitHub Desktop.
FishermanRegistrarServerResource: an example Webmachine resource for my Realm application Harvest
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" => "[email protected]", | |
"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: "[email protected]", | |
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" => "[email protected]", | |
"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: "[email protected]", | |
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" => "[email protected]", | |
"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: "[email protected]", | |
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" => "[email protected]", | |
"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: "[email protected]", | |
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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