Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Fetching contributors…

Cannot retrieve contributors at this time

332 lines (271 sloc) 9.523 kb
require 'json-schema'
require 'interpol/errors'
require 'forwardable'
module JSON
# The JSON-schema namespace
class Schema
# Monkey patch json-schema to reject unrecognized types.
# It allows them because the spec says they should be allowed,
# but we don't want to allow them.
# For more info, see:
# - https://github.com/hoxworth/json-schema/pull/37
# - https://github.com/hoxworth/json-schema/pull/38
class TypeAttribute
(class << self; self; end).class_eval do
alias original_data_valid_for_type? data_valid_for_type?
def data_valid_for_type?(data, type)
return false unless TYPE_CLASS_MAPPINGS.has_key?(type)
original_data_valid_for_type?(data, type)
end
end
end
end
end
module Interpol
module HashFetcher
# Unfortunately, on JRuby 1.9, the error raised from Hash#fetch when
# the key is not found does not include the key itself :(. So we work
# around it here.
def fetch_from(hash, key)
hash.fetch(key) do
raise ArgumentError.new("key not found: #{key.inspect}")
end
end
end
# Represents an endpoint. Instances of this class are constructed
# based on the endpoint definitions in the YAML files.
class Endpoint
include HashFetcher
attr_reader :name, :route, :method, :custom_metadata
def initialize(endpoint_hash)
@name = fetch_from(endpoint_hash, 'name')
@route = fetch_from(endpoint_hash, 'route')
@method = fetch_from(endpoint_hash, 'method').downcase.to_sym
@custom_metadata = endpoint_hash.fetch('meta') { {} }
@definitions_hash, @all_definitions = extract_definitions_from(endpoint_hash)
validate_name!
end
def find_definition!(version, message_type)
defs = find_definitions(version, message_type) do
message = "No definition found for #{name} endpoint for version #{version}"
message << " and message_type #{message_type}"
raise NoEndpointDefinitionFoundError.new(message)
end
return defs.first if defs.size == 1
raise MultipleEndpointDefinitionsFoundError, "#{defs.size} endpoint definitions " +
"were found for #{name} / #{version} / #{message_type}"
end
def find_definitions(version, message_type, &block)
@definitions_hash.fetch([message_type, version], &block)
end
def available_request_versions
available_versions_matching &:request?
end
def available_response_versions
available_versions_matching &:response?
end
def definitions
# sort all requests before all responses
# sort higher version numbers before lower version numbers
@sorted_definitions ||= @all_definitions.sort do |x, y|
if x.message_type == y.message_type
y.version <=> x.version
else
x.message_type <=> y.message_type
end
end
end
def route_matches?(path)
path =~ route_regex
end
def inspect
"#<#{self.class.name} #{method} #{route} (#{name})>"
end
alias to_s inspect
private
def available_versions_matching
@all_definitions.each_with_object(Set.new) do |definition, set|
set << definition.version if yield definition
end.to_a
end
def route_regex
@route_regex ||= begin
regex_string = route.split('/').map do |path_part|
if path_part.start_with?(':')
'[^\/]+' # it's a parameter; match anything
else
Regexp.escape(path_part)
end
end.join('\/')
/\A#{regex_string}\z/
end
end
DEFAULT_MESSAGE_TYPE = 'response'
def extract_definitions_from(endpoint_hash)
definitions = Hash.new { |h, k| h[k] = [] }
all_definitions = []
fetch_from(endpoint_hash, 'definitions').each do |definition|
fetch_from(definition, 'versions').each do |version|
message_type = definition.fetch('message_type', DEFAULT_MESSAGE_TYPE)
key = [message_type, version]
endpoint_definition = EndpointDefinition.new(self, version, message_type, definition)
definitions[key] << endpoint_definition
all_definitions << endpoint_definition
end
end
return definitions, all_definitions
end
def validate_name!
unless name =~ /\A[\w\-]+\z/
raise ArgumentError, "Invalid endpoint name (#{name.inspect}). "+
"Only letters, numbers, underscores and dashes are allowed."
end
end
end
# Wraps a single versioned definition for an endpoint.
# Provides the means to validate data against that version of the schema.
class EndpointDefinition
include HashFetcher
attr_reader :endpoint, :message_type, :version, :schema,
:path_params, :query_params, :examples, :custom_metadata
extend Forwardable
def_delegators :endpoint, :route
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', DEFAULT_PARAM_HASH.dup)
@query_params = definition.fetch('query_params', DEFAULT_PARAM_HASH.dup)
@examples = extract_examples_from(definition)
@custom_metadata = definition.fetch('meta') { {} }
make_schema_strict!(@schema)
end
def request?
message_type == "request"
end
def response?
message_type == "response"
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?
errors = ::JSON::Validator.fully_validate(schema, data)
raise ValidationError.new(errors, data, description) if errors.any?
end
def description
subdescription = "#{message_type} v. #{version}"
subdescription << " for status: #{status_codes}" if message_type == 'response'
"#{endpoint_name} (#{subdescription})"
end
def status_codes
@status_codes.code_strings.join(',')
end
def matches_status_code?(status_code)
status_code.nil? || @status_codes.matches?(status_code)
end
def example_status_code
@example_status_code ||= @status_codes.example_status_code
end
private
def make_schema_strict!(raw_schema, modify_object=true)
return unless Hash === raw_schema
raw_schema.each do |key, value|
make_schema_strict!(value, key != 'properties')
end
return unless modify_object
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
# Acceptable status code are either exact status codes (200, 404, etc)
# or partial status codes (2xx, 3xx, 4xx, etc). Currently, partial status
# codes can only be a digit followed by two lower-case x's.
class StatusCodeMatcher
attr_reader :code_strings
def initialize(codes)
codes = ["xxx"] if Array(codes).empty?
@code_strings = codes
validate!
end
def matches?(status_code)
code_regexes.any? { |re| re =~ status_code.to_s }
end
def example_status_code
example_status_code = "200"
code_strings.first.chars.each_with_index do |char, index|
example_status_code[index] = char if char != 'x'
end
example_status_code
end
private
def code_regexes
@code_regexes ||= code_strings.map do |string|
/\A#{string.gsub('x', '\d')}\z/
end
end
def validate!
code_strings.each do |code|
# ensure code is 3 characters and all chars are a number or 'x'
# http://rubular.com/r/4sl68Bb4XO
unless code =~ /\A[\dx]{3}\Z/
raise StatusCodeMatcherArgumentError, "#{code} is not a valid format"
end
end
end
end
# Wraps an example for a particular endpoint entry.
class EndpointExample
attr_reader :data, :definition
def initialize(data, definition)
@data, @definition = data, definition
end
def validate!
definition.validate_data!(data)
end
def apply_filters(filter_blocks, request_env)
deep_dup.tap do |example|
filter_blocks.each do |filter|
filter.call(example, request_env)
end
end
end
protected
attr_writer :data
private
def deep_dup
dup.tap { |d| d.data = dup_object(d.data) }
end
DUPPERS = { Hash => :dup_hash, Array => :dup_array }
def dup_hash(hash)
duplicate = hash.dup
duplicate.each_pair do |k,v|
duplicate[k] = dup_object(v)
end
duplicate
end
def dup_array(array)
duplicate = array.dup
duplicate.each_with_index do |o, index|
duplicate[index] = dup_object(o)
end
duplicate
end
def dup_object(o)
dupper = DUPPERS[o.class]
return o unless dupper
send(dupper, o)
end
end
end
Jump to Line
Something went wrong with that request. Please try again.