# Copyright 2010 Google Inc
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
require 'json'
require 'time'
require 'autoparse/inflection'
require 'addressable/uri'
module AutoParse
class Instance
def self.uri
return @uri ||= nil
end
def self.properties
return @properties ||= {}
end
def self.additional_properties_schema
return EMPTY_SCHEMA
end
def self.property_dependencies
return @property_dependencies ||= {}
end
def self.data
return @schema_data
end
def self.description
return @schema_data['description']
end
def self.validate_string_property(property_value, schema_data)
property_value = property_value.to_str rescue property_value
if !property_value.kind_of?(String)
return false
else
# TODO: implement more than type-checking
return true
end
end
def self.define_string_property(property_name, key, schema_data)
define_method(property_name) do
value = self[key] || schema_data['default']
if value != nil
if schema_data['format'] == 'byte'
Base64.decode64(value)
elsif schema_data['format'] == 'date-time'
Time.parse(value)
elsif schema_data['format'] == 'url'
Addressable::URI.parse(value)
elsif schema_data['format'] =~ /^u?int(32|64)$/
value.to_i
else
value
end
else
nil
end
end
define_method(property_name + '=') do |value|
if schema_data['format'] == 'byte'
self[key] = Base64.encode64(value)
elsif schema_data['format'] == 'date-time'
if value.respond_to?(:to_str)
value = Time.parse(value.to_str)
elsif !value.respond_to?(:xmlschema)
raise TypeError,
"Could not obtain RFC 3339 timestamp from #{value.class}."
end
self[key] = value.xmlschema
elsif schema_data['format'] == 'url'
# This effectively does limited URI validation.
self[key] = Addressable::URI.parse(value).to_str
elsif schema_data['format'] =~ /^u?int(32|64)$/
self[key] = value.to_s
elsif value.respond_to?(:to_str)
self[key] = value.to_str
elsif value.kind_of?(Symbol)
self[key] = value.to_s
else
raise TypeError,
"Expected String or Symbol, got #{value.class}."
end
end
end
def self.define_boolean_property(property_name, key, schema_data)
define_method(property_name) do
value = self[key] || schema_data['default']
case value.to_s.downcase
when 'true', 'yes', 'y', 'on', '1'
true
when 'false', 'no', 'n', 'off', '0'
false
when 'nil', 'null'
nil
else
raise TypeError,
"Expected boolean, got #{value.class}."
end
end
define_method(property_name + '=') do |value|
case value.to_s.downcase
when 'true', 'yes', 'y', 'on', '1'
self[key] = true
when 'false', 'no', 'n', 'off', '0'
self[key] = false
when 'nil', 'null'
self[key] = nil
else
raise TypeError, "Expected boolean, got #{value.class}."
end
end
end
def self.validate_number_property(property_value, schema_data)
return false if !property_value.kind_of?(Numeric)
# TODO: implement more than type-checking
return true
end
def self.define_number_property(property_name, key, schema_data)
define_method(property_name) do
Float(self[key] || schema_data['default'])
end
define_method(property_name + '=') do |value|
if value == nil
self[key] = value
else
self[key] = Float(value)
end
end
end
def self.validate_integer_property(property_value, schema_data)
return false if !property_value.kind_of?(Integer)
if schema_data['minimum'] && schema_data['exclusiveMinimum']
return false if property_value <= schema_data['minimum']
elsif schema_data['minimum']
return false if property_value < schema_data['minimum']
end
if schema_data['maximum'] && schema_data['exclusiveMaximum']
return false if property_value >= schema_data['maximum']
elsif schema_data['maximum']
return false if property_value > schema_data['maximum']
end
return true
end
def self.define_integer_property(property_name, key, schema_data)
define_method(property_name) do
Integer(self[key] || schema_data['default'])
end
define_method(property_name + '=') do |value|
if value == nil
self[key] = value
else
self[key] = Integer(value)
end
end
end
def self.validate_array_property(property_value, schema_data)
if property_value.respond_to?(:to_ary)
property_value = property_value.to_ary
else
return false
end
property_value.each do |item_value|
unless self.validate_property_value(item_value, schema_data['items'])
return false
end
end
return true
end
def self.define_array_property(property_name, key, schema_data)
define_method(property_name) do
# The default value of an empty Array obviates a mutator method.
value = self[key] || []
array = if value != nil && !value.respond_to?(:to_ary)
raise TypeError,
"Expected Array, got #{value.class}."
else
value.to_ary
end
if schema_data['items'] && schema_data['items']['$ref']
schema_name = schema_data['items']['$ref']
# FIXME: Vestigial bits need to be replaced with a more viable
# lookup system.
if AutoParse.schemas[schema_name]
schema_class = AutoParse.schemas[schema_name]
array.map! do |item|
schema_class.new(item)
end
else
raise ArgumentError,
"Could not find schema: #{schema_uri}."
end
end
array
end
end
def self.validate_object_property(property_value, schema_data, schema=nil)
if property_value.kind_of?(Instance)
return property_value.valid?
elsif schema != nil && schema.kind_of?(Class)
return schema.new(property_value).valid?
else
# This is highly ineffecient, but hard to avoid given the schema is
# anonymous.
schema = AutoParse.generate(schema_data)
return schema.new(property_value).valid?
end
end
def self.define_object_property(property_name, key, schema_data)
# TODO finish this up...
if schema_data['$ref']
schema_uri = self.uri + Addressable::URI.parse(schema_data['$ref'])
schema = AutoParse.schemas[schema_uri]
if schema == nil
raise ArgumentError,
"Could not find schema: #{schema_data['$ref']} " +
"Referenced schema must be parsed first."
end
else
# Anonymous schema
schema = AutoParse.generate(schema_data)
end
define_method(property_name) do
schema.new(self[key] || schema_data['default'])
end
end
def self.define_any_property(property_name, key, schema_data)
define_method(property_name) do
self[key] || schema_data['default']
end
define_method(property_name + '=') do |value|
self[key] = value
end
end
##
# @api private
def self.validate_property_value(property_value, schema_data)
if property_value == nil && schema_data['required'] == true
return false
elsif property_value == nil
# Value was omitted, but not required. Still valid.
return true
end
# Verify property values
if schema_data['$ref']
schema_uri = self.uri + Addressable::URI.parse(schema_data['$ref'])
schema = AutoParse.schemas[schema_uri]
if schema == nil
raise ArgumentError,
"Could not find schema: #{schema_data['$ref']} " +
"Referenced schema must be parsed first."
end
schema_data = schema.data
end
case schema_data['type']
when 'string'
return false unless self.validate_string_property(
property_value, schema_data
)
when 'boolean'
return false unless self.validate_boolean_property(
property_value, schema_data
)
when 'number'
return false unless self.validate_number_property(
property_value, schema_data
)
when 'integer'
return false unless self.validate_integer_property(
property_value, schema_data
)
when 'array'
return false unless self.validate_array_property(
property_value, schema_data
)
when 'object'
return false unless self.validate_object_property(
property_value, schema_data
)
else
# Either type 'any' or we don't know what this is,
# default to anything goes. Validation of an 'any' property always
# succeeds.
end
return true
end
def initialize(data)
if self.class.data &&
self.class.data['type'] &&
self.class.data['type'] != 'object'
raise TypeError,
"Only schemas of type 'object' are instantiable."
end
if data.respond_to?(:to_hash)
data = data.to_hash
elsif data.respond_to?(:to_json)
data = JSON.parse(data.to_json)
else
raise TypeError,
'Unable to parse. ' +
'Expected data to respond to either :to_hash or :to_json.'
end
@data = data
end
def [](key)
return @data[key]
end
def []=(key, value)
return @data[key] = value
end
##
# Validates the parsed data against the schema.
def valid?
unvalidated_fields = @data.keys.dup
for property_key, property_schema in self.class.properties
property_value = self[property_key]
if !self.class.validate_property_value(property_value, property_schema)
return false
end
if property_value == nil && property_schema['required'] != true
# Value was omitted, but not required. Still valid. Skip dependency
# checks.
next
end
# Verify property dependencies
property_dependencies = self.class.property_dependencies[property_key]
case property_dependencies
when String, Array
property_dependencies = [property_dependencies].flatten
for dependency_key in property_dependencies
dependency_value = self[dependency_key]
return false if dependency_value == nil
end
when Class
if property_dependencies.ancestors.include?(Instance)
dependency_instance = property_dependencies.new(property_value)
return false unless dependency_instance.valid?
else
raise TypeError,
"Expected schema Class, got #{property_dependencies.class}."
end
end
end
if self.class.additional_properties_schema == nil
# No additional properties allowed
return false unless unvalidated_fields.empty?
elsif self.class.additional_properties_schema != EMPTY_SCHEMA
# Validate all remaining fields against this schema
# Make sure tests don't pass prematurely
return false
end
if self.class.superclass && self.class.superclass != Instance &&
self.class.ancestors.first != Instance
# The spec actually only defined the 'extends' semantics as children
# must also validate aainst the parent.
return false unless self.class.superclass.new(@data).valid?
end
return true
end
def to_hash
return @data
end
def to_json
return JSON.generate(self.to_hash)
end
##
# Returns a String
representation of the schema instance.
#
# @return [String] The instance's state, as a String
.
def inspect
if self.class.respond_to?(:description)
sprintf(
"#<%s:%#0x DESC:'%s'>",
self.class.to_s, self.object_id, self.class.description
)
else
sprintf("#<%s:%#0x>", self.class.to_s, self.object_id)
end
end
end
##
# The empty schema accepts all JSON.
EMPTY_SCHEMA = Instance
end