require 'forwardable' require 'interpol/errors' require 'json' require 'json-schema' require 'set' 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 # Monkey patch json-schema to only allow the defined formats. # We've been accidentally using invalid formats like "timestamp", # so this will help ensure we only use valid ones. class Validator VALID_FORMATS = %w[ date-time date time utc-millisec regex color style phone uri email ip-address ipv6 host-name ] def open(uri) return super unless uri.start_with?('file://') && uri.end_with?('draft-03.json') return StringIO.new(Validator.overriden_draft_03) if Validator.overriden_draft_03 schema = JSON.parse(super.read) schema.fetch("properties").fetch("format")["enum"] = VALID_FORMATS override = JSON.dump(schema) Validator.overriden_draft_03 = override StringIO.new(override) end class << self attr_accessor :overriden_draft_03 end end end module Interpol # Provides a helper method for fetching an item from a hash. # Deals with differences on different ruby interpretters. 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, :configuration def initialize(endpoint_hash, configuration = Interpol.default_configuration) @name = fetch_from(endpoint_hash, 'name') @route = fetch_from(endpoint_hash, 'route') @method = fetch_from(endpoint_hash, 'method').downcase.to_sym @configuration = configuration @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, :configuration 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 = Marshal.load(Marshal.dump 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, validate_schema = true) if validate_schema errors = ::JSON::Validator.fully_validate_schema(schema) raise ValidationError.new(errors, schema, description) if errors.any? end 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) case raw_schema when Hash then make_schema_hash_strict!(raw_schema, modify_object) when Array then make_schema_array_strict!(raw_schema, modify_object) end end PROPERTY_PARENTS = %w[ properties patternProperties ] def make_schema_hash_strict!(raw_schema, make_this_schema_strict=true) conditionally_make_nullable(raw_schema) if make_this_schema_strict raw_schema.each do |key, value| make_schema_strict!(value, !PROPERTY_PARENTS.include?(key)) end return unless make_this_schema_strict if raw_schema.has_key?('properties') raw_schema['additionalProperties'] ||= false end raw_schema['required'] = !raw_schema.delete('optional') end def make_schema_array_strict!(raw_schema, make_nested_schemas_strict=true) raw_schema.each do |entry| make_schema_strict!(entry, make_nested_schemas_strict) end end def conditionally_make_nullable(raw_schema) return unless should_be_nullable?(raw_schema) types = Array(raw_schema['type']) return if types.none? || types.include?('null') # Mark as non-nullable so it doesn't try to recurse down into it # and make it nullable at that level (since we are taking care of it at this level) sub_schema = raw_schema.merge('nullable' => false) raw_schema.replace('type' => ['null', sub_schema], 'optional' => sub_schema['optional']) end def should_be_nullable?(raw_schema) raw_schema.fetch('nullable') do configuration.scalars_nullable_by_default? && scalar?(raw_schema) end end NON_SCALAR_TYPES = %w[ object array ] def scalar?(raw_schema) types = Array(raw_schema['type']).to_set NON_SCALAR_TYPES.none? do |non_scalar| types.include?(non_scalar) end 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