Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Loading…

Add RequestBodyValidator. #11

Closed
wants to merge 1 commit into from

2 participants

@myronmarston

Can you review this, @proby?

@proby proby commented on the diff
README.md
@@ -178,6 +180,13 @@ Interpol.default_configuration do |config|
halt 406, message
end
+ # Determines the response when the requested version is not available.
+ #
+ # Used by Interpol::RequestBodyValidator.
+ config.on_unavailable_request_version do |env, requested_version, available_versions|
@proby
proby added a note

Maybe mention this is for all rack apps and how this and the sinatra version interact with each other? Which one gets run first? Can they both be set? Things like that.

@myronmarston Owner

It basically just that tools that are sinatra-focused use the sinatra one and tools that are rack-focused use this one.

The listing of tools that use the config option is given--did you notice that? Is there a better way to explain that?

@proby
proby added a note

I did notice that but I misread it and convinced myself the two config options overlapped in their tool usage. Ignore me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@proby

merge it

@myronmarston

Merged in 4f28f0c

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Oct 16, 2012
  1. @myronmarston
This page is out of date. Refresh to see the latest.
View
59 README.md
@@ -25,6 +25,8 @@ definitions:
* `Interpol::Sinatra::RequestParamsParser` validates and parses
a sinatra `params` hash based on your endpoint params schema
definitions.
+* `Interpol::RequestBodyValidator` is a rack middleware that validates
+ and parses request bodies based on your schema definitions.
You can use any of these tools individually or some combination of all
of them.
@@ -167,7 +169,7 @@ Interpol.default_configuration do |config|
# available. This block will be eval'd in the context of a
# sinatra application, so you can use sinatra helpers like `halt` here.
#
- # Needed by Interpol::StubApp and Interpol::Sinatra::RequestParamsParser.
+ # Used by Interpol::StubApp and Interpol::Sinatra::RequestParamsParser.
config.on_unavailable_sinatra_request_version do |requested_version, available_versions|
message = JSON.dump(
"message" => "Not Acceptable",
@@ -178,6 +180,13 @@ Interpol.default_configuration do |config|
halt 406, message
end
+ # Determines the response when the requested version is not available.
+ #
+ # Used by Interpol::RequestBodyValidator.
+ config.on_unavailable_request_version do |env, requested_version, available_versions|
@proby
proby added a note

Maybe mention this is for all rack apps and how this and the sinatra version interact with each other? Which one gets run first? Can they both be set? Things like that.

@myronmarston Owner

It basically just that tools that are sinatra-focused use the sinatra one and tools that are rack-focused use this one.

The listing of tools that use the config option is given--did you notice that? Is there a better way to explain that?

@proby
proby added a note

I did notice that but I misread it and convinced myself the two config options overlapped in their tool usage. Ignore me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+ [406, { 'Content-Type' => 'text/plain' }, ['Wrong Version!']]
+ end
+
# Determines which responses will be validated against the endpoint
# definition when you use Interpol::ResponseSchemaValidator. The
# validation is meant to run against the "happy path" response.
@@ -190,6 +199,14 @@ Interpol.default_configuration do |config|
headers['Content-Type'] == my_custom_mime_type
end
+ # Determines which request bodies to validate.
+ #
+ # Used by Interpol::RequestBodyValidator.
+ config.validate_request_if do |env|
+ env.fetch('CONTENT_TYPE').to_s.include?('json') &&
+ %w[ POST PUT ].include?(env.fetch('REQUEST_METHOD'))
+ end
+
# Determines how Interpol::ResponseSchemaValidator handles
# invalid data. By default it will raise an error, but you can
# make it print a warning instead.
@@ -224,6 +241,14 @@ Interpol.default_configuration do |config|
config.on_invalid_sinatra_request_params do |error|
halt 400, JSON.dump(:error => error.message)
end
+
+ # Determines how to respond when the request body is invalid
+ # based on your schema definition.
+ #
+ # Used by Interpol::RequestBodyValidator.
+ config.on_invalid_request_body do |env, error|
+ [400, { 'Content-Type' => 'text/plain' }, [error.message]]
+ end
end
```
@@ -392,6 +417,38 @@ class MySinatraApp < Sinatra::Base
end
```
+### Interpol::RequestBodyValidator
+
+This rack middleware validates request body (e.g. for POST or PUT
+requests) based on your endpoint request schema definitions.
+It also makes the parsed request body available as
+`interpol.parsed_body` in the rack env hash.
+
+``` ruby
+require 'sinatra/base'
+require 'interpol/request_body_validator'
+
+class MySinatraApp < Sinatra::Base
+ # The block is only necessary if you want to override the
+ # default config or have not set a default config.
+ use Interpol::RequestBodyValidator do |config|
+ config.on_invalid_request_body do |error|
+ [400, { 'Content-Type' => 'text/plain' }, [error.message]]
+ end
+ end
+
+ helpers do
+ def parsed_body
+ env.fetch('interpol.parsed_body')
+ end
+ end
+
+ put '/users/:user_id' do
+ User.create_or_replace(parsed_body.user_id, parsed_body.attributes)
+ end
+end
+```
+
## Contributing
1. Fork it
View
49 lib/interpol/configuration.rb
@@ -97,6 +97,14 @@ def validate_if(&block)
validate_response_if(&block)
end
+ def validate_request?(env)
+ @validate_request_if_block.call(env)
+ end
+
+ def validate_request_if(&block)
+ @validate_request_if_block = block
+ end
+
def on_unavailable_sinatra_request_version(&block)
@unavailable_sinatra_request_version_block = block
end
@@ -105,6 +113,14 @@ def sinatra_request_version_unavailable(execution_context, *args)
execution_context.instance_exec(*args, &@unavailable_sinatra_request_version_block)
end
+ def on_unavailable_request_version(&block)
+ @unavailable_request_version_block = block
+ end
+
+ def request_version_unavailable(*args)
+ @unavailable_request_version_block.call(*args)
+ end
+
def on_invalid_sinatra_request_params(&block)
@invalid_sinatra_request_params_block = block
end
@@ -113,6 +129,14 @@ def sinatra_request_params_invalid(execution_context, *args)
execution_context.instance_exec(*args, &@invalid_sinatra_request_params_block)
end
+ def on_invalid_request_body(&block)
+ @invalid_request_body_block = block
+ end
+
+ def request_body_invalid(*args)
+ @invalid_request_body_block.call(*args)
+ end
+
def filter_example_data(&block)
filter_example_data_blocks << block
end
@@ -156,6 +180,13 @@ def endpoint_merge_keys
}.join("\n\n")
end
+ def rack_json_response(status, hash)
+ json = JSON.dump(hash)
+
+ [status, { 'Content-Type' => 'application/json',
+ 'Content-Length' => json.bytesize }, [json]]
+ end
+
def register_default_callbacks
request_version do
raise ConfigurationError, "request_version has not been configured"
@@ -170,13 +201,31 @@ def register_default_callbacks
status >= 200 && status <= 299 && status != 204 # No Content
end
+ validate_request_if do |env|
+ env.fetch('CONTENT_TYPE').to_s.include?('json') &&
+ %w[ POST PUT ].include?(env.fetch('REQUEST_METHOD'))
+ end
+
+ on_unavailable_request_version do |env, requested, available|
+ message = "The requested API version is invalid. " +
+ "Requested: #{requested}. " +
+ "Available: #{available}"
+
+ rack_json_response(406, :error => message)
+ end
+
on_unavailable_sinatra_request_version do |requested, available|
message = "The requested API version is invalid. " +
"Requested: #{requested}. " +
"Available: #{available}"
+
halt 406, JSON.dump(:error => message)
end
+ on_invalid_request_body do |env, error|
+ rack_json_response(400, :error => error.message)
+ end
+
on_invalid_sinatra_request_params do |error|
halt 400, JSON.dump(:error => error.message)
end
View
82 lib/interpol/request_body_validator.rb
@@ -0,0 +1,82 @@
+require 'interpol'
+require 'interpol/dynamic_struct'
+
+module Interpol
+ # Validates and parses a request body according to the endpoint
+ # schema definitions.
+ class RequestBodyValidator
+ def initialize(app, &block)
+ @config = Configuration.default.customized_duplicate(&block)
+ @app = app
+ end
+
+ def call(env)
+ if @config.validate_request?(env)
+ handler = Handler.new(env, @config)
+
+ handler.validate do |error_response|
+ return error_response
+ end
+
+ env['interpol.parsed_body'] = handler.parse
+ end
+
+ @app.call(env)
+ end
+
+ # Handles request body validation for a single request.
+ class Handler
+ attr_reader :env, :config
+
+ def initialize(env, config)
+ @env = env
+ @config = config
+ end
+
+ def parse
+ DynamicStruct.new(parsed_body)
+ end
+
+ def validate(&block)
+ endpoint_definition(&block).validate_data!(parsed_body)
+ rescue Interpol::ValidationError => e
+ yield @config.request_body_invalid(env, e)
+ end
+
+ private
+
+ def request_method
+ env.fetch('REQUEST_METHOD')
+ end
+
+ def path
+ env.fetch('PATH_INFO')
+ end
+
+ def parsed_body
+ @parsed_body ||= JSON.parse(unparsed_body)
+ end
+
+ def unparsed_body
+ @unparsed_body ||= begin
+ input = env.fetch('rack.input')
+ input.read.tap { input.rewind }
+ end
+ end
+
+ def endpoint_definition(&block)
+ config.endpoints.find_definition(request_method, path, 'request', nil) do |endpoint|
+ available = endpoint.available_versions
+
+ @config.request_version_for(env, endpoint).tap do |requested|
+ unless available.include?(requested)
+ yield @config.request_version_unavailable(env, requested, available)
+ end
+ end
+ end
+ end
+
+ end
+ end
+end
+
View
190 spec/unit/interpol/request_body_validator_spec.rb
@@ -0,0 +1,190 @@
+require 'fast_spec_helper'
+require 'rack/test'
+require 'interpol/request_body_validator'
+
+module Interpol
+ describe RequestBodyValidator do
+ include Rack::Test::Methods
+
+ let_without_indentation(:endpoint_definition_yml) do <<-EOF
+ ---
+ name: parsed_body
+ route: /parsed_body
+ method: POST
+ definitions:
+ - versions: ["1.0"]
+ message_type: request
+ schema:
+ type: object
+ properties:
+ id:
+ type: integer
+ name:
+ type: string
+ examples: []
+ EOF
+ end
+
+ before do
+ Interpol.default_configuration do |config|
+ config.endpoints = [Endpoint.new(YAML.load endpoint_definition_yml)]
+ config.request_version '1.0'
+ end
+ end
+
+ def override_config(&block)
+ @override_config = block
+ end
+
+ let(:app) do
+ _override_config = @override_config || Proc.new { }
+
+ Rack::Builder.new do
+ use(Interpol::RequestBodyValidator, &_override_config)
+ use Rack::ContentLength
+
+ map('/parsed_body') do
+ run lambda { |env|
+ body = if env['HTTP_READ_BODY']
+ env.fetch('rack.input').read
+ else
+ parsed = env.fetch('interpol.parsed_body')
+ "id: #{parsed.id}; name: #{parsed.name}"
+ end
+
+ [ 200, { 'Content-Type' => 'text/plain' }, [body]]
+ }
+ end
+ end
+ end
+
+ let(:valid_json_body) { JSON.dump("id" => 3, "name" => "foo") }
+ let(:invalid_json_body) { JSON.dump("id" => "not a number", "name" => "foo") }
+
+ it 'responds with a 400 by default when it fails validation' do
+ header 'Content-Type', 'application/json'
+ post '/parsed_body', invalid_json_body
+ last_response.status.should eq(400)
+ last_response.body.should include("validating", "parsed_body")
+ end
+
+ it 'uses the configured on_invalid_request_body hook' do
+ Interpol.default_configuration do |config|
+ config.on_invalid_request_body do |env, error|
+ [412, { 'Content-Type' => 'text/plain' }, ["abc"]]
+ end
+ end
+
+ header 'Content-Type', 'application/json'
+ post '/parsed_body', invalid_json_body
+ last_response.status.should eq(412)
+ last_response.body.should eq("abc")
+ end
+
+ it 'makes the parsed body object available as `interpol.parsed_body`' do
+ header 'Content-Type', 'application/json'
+ post '/parsed_body', valid_json_body
+ last_response.body.should eq("id: 3; name: foo")
+ end
+
+ it 'allows the default config to be overriden' do
+ Interpol.default_configuration do |config|
+ config.request_version '2000.0'
+ end
+
+ override_config do |config|
+ config.request_version '1.0'
+ end
+
+ header 'Content-Type', 'application/json'
+ post '/parsed_body', valid_json_body
+ last_response.body.should eq("id: 3; name: foo")
+ end
+
+ it 'rewinds the input stream after reading it' do
+ header 'Read-Body', 'true'
+ header 'Content-Type', 'application/json'
+ post '/parsed_body', valid_json_body
+ last_response.body.should eq(valid_json_body)
+ end
+
+ it 'does not attempt to validate a GET or DELETE request by default' do
+ header 'Read-Body', 'true'
+ header 'Content-Type', 'application/json'
+ get '/parsed_body', invalid_json_body
+ last_response.status.should eq(200)
+
+ delete '/parsed_body', invalid_json_body
+ last_response.status.should eq(200)
+ end
+
+ it 'does not attempt to validate non-JSON by default' do
+ header 'Read-Body', 'true'
+ header 'Content-Type', 'text/plain'
+ post '/parsed_body', "some content"
+ last_response.body.should eq("some content")
+ end
+
+ it 'allows users to override the validate_request_if config' do
+ override_config do |config|
+ config.validate_request_if do |env|
+ true
+ end
+ end
+
+ header 'Content-Type', 'text/plain'
+ post '/parsed_body', valid_json_body
+ last_response.body.should eq("id: 3; name: foo")
+ end
+
+ it 'responds with a 406 by default when no matching version can be found' do
+ wrong_version_yaml = endpoint_definition_yml.gsub('1.0', '2.0')
+
+ override_config do |config|
+ config.endpoints = [Endpoint.new(YAML.load wrong_version_yaml)]
+ end
+
+ header 'Content-Type', 'application/json'
+ post '/parsed_body', valid_json_body
+ last_response.status.should eq(406)
+ end
+
+ it 'uses the on_unavailable_request_version hook to respond in ' +
+ 'cases where no version can be found' do
+ wrong_version_yaml = endpoint_definition_yml.gsub('1.0', '2.0')
+
+ override_config do |config|
+ config.endpoints = [Endpoint.new(YAML.load wrong_version_yaml)]
+ config.on_unavailable_request_version do |env, requested, available|
+ [315, { 'Content-Type' => 'text/plain' },
+ ["Method: #{env.fetch('REQUEST_METHOD')}; ",
+ "Requested: #{requested.inspect}; ",
+ "Available: #{available.inspect}"]]
+ end
+ end
+
+ header 'Content-Type', 'application/json'
+ post '/parsed_body', valid_json_body
+ last_response.status.should eq(315)
+ last_response.body.should eq('Method: POST; Requested: "1.0"; Available: ["2.0"]')
+ end
+
+ it 'uses the request_version_for callback to select the version' do
+ wrong_version_yaml = endpoint_definition_yml.gsub('1.0', '2.0')
+
+ override_config do |config|
+ config.endpoints = [Endpoint.new(YAML.load wrong_version_yaml)]
+ config.request_version do |env, endpoint|
+ env.fetch('REQUEST_METHOD').should eq('POST')
+ endpoint.should be_a(Interpol::Endpoint)
+ '2.0'
+ end
+ end
+
+ header 'Content-Type', 'application/json'
+ post '/parsed_body', valid_json_body
+ last_response.status.should eq(200)
+ end
+ end
+end
+
Something went wrong with that request. Please try again.