Request params parser #9

Merged
merged 13 commits into from Sep 24, 2012
View
@@ -22,6 +22,9 @@ definitions:
ensure that your real API returns valid responses.
* `Interpol::DocumentationApp` builds a sinatra app that renders
documentation for your API based on the endpoint definitions.
+* `Interpol::Sinatra::RequestParamsParser` validates and parses
+ a sinatra `params` hash based on your endpoint params schema
+ definitions.
You can use any of these tools individually or some combination of all
of them.
@@ -51,6 +54,15 @@ name: user_projects
route: /users/:user_id/projects
method: GET
definitions:
+ - message_type: request
+ versions: ["1.0"]
+ path_params:
+ type: object
+ properties:
+ user_id:
+ type: integer
+ schema: {}
+ examples: []
- message_type: response
versions: ["1.0"]
status_codes: ["2xx", "404"]
@@ -107,6 +119,9 @@ Let's look at this YAML file, point-by-point:
attribute that defaults to all status codes. Valid formats for a status code are 3
characters. Each character must be a digit (0-9) or 'x' (wildcard). The following strings
are all valid: "200", "4xx", "x0x".
+* `path_params` lists the path parameters that are used by a request to
+ this endpoint. You can also list `query_params` in the same manner.
+ These are both used by `Interpol::Sinatra::RequestParamsParser`.
* The `schema` contains a [JSON schema](http://tools.ietf.org/html/draft-zyp-json-schema-03)
description of the contents of the endpoint. This schema definition is used by the
`SchemaValidation` middleware to ensure that your implementation of the endpoint
@@ -136,14 +151,15 @@ Interpol.default_configuration do |config|
# This is useful when you need to extract the version from a
# request header (e.g. Accept) or from the request URI.
#
- # Needed by Interpol::StubApp and Interpol::ResponseSchemaValidator.
+ # Needed by Interpol::StubApp, Interpol::ResponseSchemaValidator
+ # and Interpol::Sinatra::RequestParamsParser.
config.api_version '1.0'
# Determines the stub app response when the requested version is not
- # available. This block will be eval'd in the context of the stub app
+ # 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.
+ # Needed by Interpol::StubApp and Interpol::Sinatra::RequestParamsParser.
config.on_unavailable_request_version do |requested_version, available_versions|
message = JSON.dump(
"message" => "Not Acceptable",
@@ -189,6 +205,17 @@ Interpol.default_configuration do |config|
config.filter_example_data do |example, request_env|
example.data["current_url"] = Rack::Request.new(request_env).url
end
+
+ # Determines what to do when Interpol::Sinatra::RequestParamsParser
+ # detects invalid path or query parameters based on their schema
+ # definitions. This block will be eval'd in the context of your
+ # sinatra application so you can use any helper methods such as
+ # `halt`.
+ #
+ # Used by Interpol::Sinatra::RequestParamsParser.
+ config.on_invalid_sinatra_request_params do |error|
+ halt 400, JSON.dump(:error => error.message)
+ end
end
```
@@ -307,6 +334,56 @@ run doc_app
Note: the documentation app is definitely a work-in-progress and I'm not
a front-end/UI developer. I'd happily accept a pull request improving it!
+### Interpol::Sinatra::RequestParamsParser
+
+This Sinatra middleware does a few things:
+
+* It validates the path and query params according to the schema
+ definitions in your YAML files.
+* It replaces the `params` hash with an object that:
+ * Exposes a method for each defined parameter--so you can use
+ `params.user_id` rather than `params[:user_id]`. Undefined
+ params will raise a `NoMethodError` rather than getting `nil`
+ as you would with the normal params hash.
+ * Exposes a predicate method for each defined parameter -- so
+ you can use `params.user_id?` in a conditional rather than
+ `params.user_id`.
+ * Parses each parameter value into an appropriate object based on
+ the defined schema:
+ * An `integer` param will be exposed as a `Fixnum`.
+ * A `number` param will be exposed as a `Float`.
+ * A `null` param will be exposed as `nil` (rather than the empty
+ string).
+ * A `boolean` param will be exposed as `true` or `false` (rather
+ than the corresponding strings).
+ * A `string` param with a `date` format will be exposed as a `Date`.
+ * A `string` param with a `date-time` format will be exposed as a `Time`.
+ * A `string` param with a `uri` format will be exposed as `URI`.
+ * Anything that cannot be parsed into an object will be exposed as
+ its original `string` value.
+* It exposes the original params hash as `unparsed_params`.
+
+Usage:
+
+``` ruby
+require 'sinatra/base'
+require 'interpol/sinatra/request_params_parser'
+
+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::Sinatra::RequestParamsParser do |config|
+ config.on_invalid_sinatra_request_params do |error|
+ halt 400, JSON.dump(:error => error.message)
+ end
+ end
+
+ get '/users/:user_id' do
+ JSON.dump User.find(params.user_id)
+ end
+end
+```
+
## Contributing
1. Fork it
View
@@ -15,6 +15,10 @@ if defined?(RUBY_ENGINE) && RUBY_ENGINE == 'ruby' # MRI only
cane.abc_max = 13
cane.add_threshold 'coverage/coverage_percent.txt', :==, 100
cane.style_measure = 100
+
+ cane.abc_exclude = %w[
+ Interpol::Configuration#register_default_callbacks
+ ]
end
else
task(:quality) { } # no-op
@@ -85,6 +85,14 @@ def request_version_unavailable(execution_context, *args)
execution_context.instance_exec(*args, &@unavailable_request_version_block)
end
+ def on_invalid_sinatra_request_params(&block)
+ @invalid_sinatra_request_params_block = block
+ end
+
+ def sinatra_request_params_invalid(execution_context, *args)
+ execution_context.instance_exec(*args, &@invalid_sinatra_request_params_block)
+ end
+
def filter_example_data(&block)
filter_example_data_blocks << block
end
@@ -144,6 +152,10 @@ def register_default_callbacks
"Available: #{available}"
halt 406, JSON.dump(:error => message)
end
+
+ on_invalid_sinatra_request_params do |error|
+ halt 400, JSON.dump(:error => error.message)
+ end
end
end
end
@@ -0,0 +1,10 @@
+module Interpol
+ # 1.9 has Object#define_singleton_method but 1.8 does not.
+ # This provides 1.8 compatibility for the places we need it.
+ module DefineSingletonMethod
+ def define_singleton_method(name, &block)
+ (class << self; self; end).send(:define_method, name, &block)
+ end
+ end
+end
+
@@ -94,7 +94,7 @@ class Builder
attr_reader :app
def initialize(config)
- @app = Sinatra.new do
+ @app = ::Sinatra.new do
dir = File.dirname(File.expand_path(__FILE__))
set :views, "#{dir}/documentation_app/views"
set :public_folder, "#{dir}/documentation_app/public"
@@ -0,0 +1,37 @@
+require 'interpol/define_singleton_method' unless Object.method_defined?(:define_singleton_method)
+
+module Interpol
+ # Transforms an arbitrarily deeply nested hash into a dot-syntax
+ # object. Useful as an alternative to a hash since it is "strongly typed"
+ # in the sense that fat-fingered property names result in a NoMethodError,
+ # rather than getting a nil as you would with a hash.
+ class DynamicStruct
+ attr_reader :attribute_names, :to_hash
+
+ def initialize(hash)
+ @to_hash = hash
+ @attribute_names = hash.keys.map(&:to_sym)
+
+ hash.each do |key, value|
+ value = method_value_for(value)
+ define_singleton_method(key) { value }
+ define_singleton_method("#{key}?") { !!value }
+ end
+ end
+
+ private
+
+ def method_value_for(hash_value)
+ return self.class.new(hash_value) if hash_value.is_a?(Hash)
+
+ if hash_value.is_a?(Array) && hash_value.all? { |v| v.is_a?(Hash) }
+ return hash_value.map { |v| self.class.new(v) }
+ end
+
+ hash_value
+ end
+
+ include DefineSingletonMethod unless method_defined?(:define_singleton_method)
+ end
+end
+
@@ -0,0 +1,9 @@
+# 1.9 has Enumerable#each_with_object, but 1.8 does not.
+# This provides 1.8 compat for the places where we use each_with_object.
+module Enumerable
+ def each_with_object(object)
+ each { |item| yield item, object }
+ object
+ end
+end
+
View
@@ -1,5 +1,6 @@
require 'json-schema'
require 'interpol/errors'
+require 'forwardable'
module JSON
# The JSON-schema namespace
@@ -118,7 +119,7 @@ def extract_definitions_from(endpoint_hash)
fetch_from(definition, 'versions').each do |version|
message_type = definition.fetch('message_type', DEFAULT_MESSAGE_TYPE)
key = [message_type, version]
- endpoint_definition = EndpointDefinition.new(name, version, message_type, definition)
+ endpoint_definition = EndpointDefinition.new(self, version, message_type, definition)
definitions[key] << endpoint_definition
all_definitions << endpoint_definition
end
@@ -139,21 +140,33 @@ def validate_name!
# Provides the means to validate data against that version of the schema.
class EndpointDefinition
include HashFetcher
- attr_reader :endpoint_name, :message_type, :version, :schema,
+ attr_reader :endpoint, :message_type, :version, :schema,
:path_params, :query_params, :examples
+ extend Forwardable
+ def_delegators :endpoint, :route
- def initialize(endpoint_name, version, message_type, definition)
- @endpoint_name = endpoint_name
+ DEFAULT_PARAM_HASH = { 'type' => 'object', 'properties' => {} }
+
+ def initialize(endpoint, version, message_type, definition)
+ @endpoint = endpoint
@message_type = message_type
@status_codes = StatusCodeMatcher.new(definition['status_codes'])
@version = version
@schema = fetch_from(definition, 'schema')
- @path_params = definition.fetch('path_params', {})
- @query_params = definition.fetch('query_params', {})
- @examples = fetch_from(definition, 'examples').map { |e| EndpointExample.new(e, self) }
+ @path_params = definition.fetch('path_params', DEFAULT_PARAM_HASH.dup)
+ @query_params = definition.fetch('query_params', DEFAULT_PARAM_HASH.dup)
+ @examples = extract_examples_from(definition)
make_schema_strict!(@schema)
end
+ def request?
+ message_type == "request"
+ end
+
+ def endpoint_name
+ @endpoint.name
+ end
+
def validate_data!(data)
errors = ::JSON::Validator.fully_validate_schema(schema)
raise ValidationError.new(errors, nil, description) if errors.any?
@@ -177,6 +190,14 @@ def example_status_code
@example_status_code ||= @status_codes.example_status_code
end
+ def parse_request_params(request_params)
+ request_params_parser.parse(request_params)
+ end
+
+ def request_params_parser
+ @request_params_parser ||= RequestParamsParser.new(self)
+ end
+
private
def make_schema_strict!(raw_schema, modify_object=true)
@@ -191,6 +212,12 @@ def make_schema_strict!(raw_schema, modify_object=true)
raw_schema['additionalProperties'] ||= false
raw_schema['required'] = !raw_schema.delete('optional')
end
+
+ def extract_examples_from(definition)
+ fetch_from(definition, 'examples').map do |ex|
+ EndpointExample.new(ex, self)
+ end
+ end
end
# Holds the acceptable status codes for an enpoint entry
Oops, something went wrong.