Skip to content

Commit

Permalink
Merge remote-tracking branches 'origin/rm_node', 'origin/test' and 'o…
Browse files Browse the repository at this point in the history
…rigin/misc' into HEAD

#99
#100
#101
  • Loading branch information
notEthan committed Jul 12, 2020
3 parents a32a569 + 05439d1 + 1cc04a2 commit e887086
Show file tree
Hide file tree
Showing 10 changed files with 195 additions and 82 deletions.
4 changes: 2 additions & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ sudo: false
language: ruby
cache: bundler
rvm:
- 2.1.10
- 2.6.3
- 2.3
- 2.7
script: bundle exec rake test
46 changes: 33 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,13 @@ There's plenty more JSI has to offer, but this should give you a pretty good ide

## Terminology and Concepts

- `JSI::Base` is the base class for each JSI class representing a JSON Schema.
- a "JSI class" is a subclass of `JSI::Base` representing a JSON schema.
- a "JSI schema module" is a module representing a schema, included on a JSI class.
- `JSI::Base` is the base class for each JSI class representing instances of JSON Schemas.
- a "JSI schema module" is a module which represents one schema. Instances of that schema are extended with its JSI schema module.
- a "JSI schema class" is a subclass of `JSI::Base` representing one or more JSON schemas. Instances of such a class are described by all of the represented schemas. A JSI schema class includes the JSI schema module of each represented schema.
- "instance" is a term that is significantly overloaded in this space, so documentation will attempt to be clear what kind of instance is meant:
- a schema instance refers broadly to a data structure that is described by a JSON schema.
- a JSI instance (or just "a JSI") is a ruby object instantiating a JSI class. it has a method `#jsi_instance` which contains the underlying data.
- a schema refers to a JSON schema. `JSI::Schema` is a module which extends schemas. A schema is usually a `JSI::Base` instance, and that schema JSI's schema is a metaschema (see the sections on Metaschemas below).
- a JSI instance (or just "a JSI") is a ruby object instantiating a JSI schema class (subclass of `JSI::Base`). This wraps the content of the schema instance (see `JSI::Base#jsi_instance`), and ties it to the schemas which describe the instance (`JSI::Base#jsi_schemas`).
- a schema refers to a JSON schema. in JSI a schema is typically a JSI instance which is described by a metaschema (see the sections on Metaschemas below). a JSI schema is extended by the `JSI::Schema` module.

## JSI and Object Oriented Programming

Expand Down Expand Up @@ -160,11 +160,19 @@ bill.phone_numbers

Note the use of `super` - you can call to accessors defined by JSI and make your accessors act as wrappers. You can alternatively use `[]` and `[]=` with the same effect.

You can also add methods to a subschema using the same method `#jsi_schema_module` which we used to define the `Contact` module above.
Working with subschemas is just about as easy as with root schemas.

You can subscript or use property accessors on a JSI schema module to refer to the schema modules of its subschemas, e.g.:

```ruby
phone_schema = Contact.schema.properties['phone'].items
phone_schema.jsi_schema_module.module_eval do
Contact.properties['phone'].items
# => (JSI Schema Module: #/properties/phone/items)
```

Opening a subschema module with module_eval, you can add methods to instances of the subschema.

```ruby
Contact.properties['phone'].items.module_eval do
def number_with_dashes
number.split(//).join('-')
end
Expand All @@ -173,24 +181,36 @@ bill.phone.first.number_with_dashes
# => "5-5-5"
```

If you want to name the module, this works:
A recommended convention for naming subschemas is to define them in the namespace of the module of their
parent schema. The module can then be opened to add methods to the subschema's module.

```ruby
ContactPhone = Contact.schema.properties['phone'].items.jsi_schema_module
module Contact
Phone = properties['phone'].items
module Phone
def number_with_dashes
number.split(//).join('-')
end
end
end
```

However, that is only a convention, and a flat namespace works fine too.

```ruby
ContactPhone = Contact.properties['phone'].items
module ContactPhone
def number_with_dashes
number.split(//).join('-')
end
end
```

Either syntax is slightly cumbersome and a better syntax is in the works.

## Metaschemas

A metaschema is a schema which describes schemas. Likewise, a schema is an instance of a metaschema.

In JSI, a schema is generally a JSI::Base instance whose schema is a metaschema.
In JSI, a schema is generally a JSI::Base instance whose schemas include a metaschema.

A self-descriptive metaschema - most commonly one of the JSON schema draft metaschemas - is an object whose schema is itself. This is instantiated in JSI as a JSI::MetaschemaNode (not a JSI::Base).

Expand Down
14 changes: 12 additions & 2 deletions lib/jsi/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,11 @@ def name

# NOINSTANCE is a magic value passed to #initialize when instantiating a JSI
# from a document and JSON Pointer.
NOINSTANCE = Object.new.tap { |o| [:inspect, :to_s].each(&(-> (s, m) { o.define_singleton_method(m) { s } }.curry.([JSI::Base.name, 'NOINSTANCE'].join('::')))) }
#
# @private
NOINSTANCE = Object.new
[:inspect, :to_s].each(&(-> (s, m) { NOINSTANCE.define_singleton_method(m) { s } }.curry.("#{JSI::Base}::NOINSTANCE")))
NOINSTANCE.freeze

# initializes this JSI from the given instance - instance is most commonly
# a parsed JSON document consisting of Hash, Array, or sometimes a basic
Expand Down Expand Up @@ -306,6 +310,11 @@ def deref(&block)
return self
end

# @return [Set<Module>] the set of JSI schema modules corresponding to the schemas that describe this JSI
def jsi_schema_modules
jsi_schemas.map(&:jsi_schema_module).to_set
end

# yields the content of the underlying instance. the block must result in
# a modified copy of that (not destructively modifying the yielded content)
# which will be used to instantiate a new instance of this JSI class with
Expand All @@ -324,7 +333,7 @@ def jsi_modified_copy(&block)
modified_jsi_root_node = @jsi_root_node.jsi_modified_copy do |root|
@jsi_ptr.modified_document_copy(root, &block)
end
self.class.new(Base::NOINSTANCE, jsi_document: modified_jsi_root_node.jsi_document, jsi_ptr: @jsi_ptr, jsi_root_node: modified_jsi_root_node)
@jsi_ptr.evaluate(modified_jsi_root_node)
end
end

Expand Down Expand Up @@ -372,6 +381,7 @@ def pretty_print(q)
q.text '>'
end

# @private
# @return [Array<String>]
def jsi_object_group_text
class_name = self.class.name unless self.class.in_schema_classes
Expand Down
91 changes: 43 additions & 48 deletions lib/jsi/json/pointer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module JSI
module JSON
# a JSON Pointer, as described by RFC 6901 https://tools.ietf.org/html/rfc6901
# a representation to work with JSON Pointer, as described by RFC 6901 https://tools.ietf.org/html/rfc6901
class Pointer
class Error < StandardError
end
Expand All @@ -11,69 +11,61 @@ class PointerSyntaxError < Error
class ReferenceError < Error
end

# instantiates a Pointer from any given reference tokens.
# instantiates a Pointer from the given reference tokens.
#
# >> JSI::JSON::Pointer[]
# => #<JSI::JSON::Pointer reference_tokens: []>
# >> JSI::JSON::Pointer['a', 'b']
# => #<JSI::JSON::Pointer reference_tokens: ["a", "b"]>
# >> JSI::JSON::Pointer['a']['b']
# => #<JSI::JSON::Pointer reference_tokens: ["a", "b"]>
# JSI::JSON::Pointer[]
#
# note in the last example that you can conveniently chain the class .[] method
# with the instance #[] method.
# instantes a root pointer.
#
# @param *reference_tokens any number of reference tokens
# JSI::JSON::Pointer['a', 'b']
# JSI::JSON::Pointer['a']['b']
#
# are both ways to instantiate a pointer with reference tokens ['a', 'b']. the latter example chains the
# class .[] method with the instance #[] method.
#
# @param reference_tokens any number of reference tokens
# @return [JSI::JSON::Pointer]
def self.[](*reference_tokens)
new(reference_tokens)
end

# parse a URI-escaped fragment and instantiate as a JSI::JSON::Pointer
#
# ptr = JSI::JSON::Pointer.from_fragment('/foo/bar')
# => #<JSI::JSON::Pointer fragment: /foo/bar>
# ptr.reference_tokens
# => ["foo", "bar"]
# JSI::JSON::Pointer.from_fragment('/foo/bar')
# => JSI::JSON::Pointer["foo", "bar"]
#
# with URI escaping:
#
# ptr = JSI::JSON::Pointer.from_fragment('/foo%20bar')
# => #<JSI::JSON::Pointer fragment: /foo%20bar>
# ptr.reference_tokens
# => ["foo bar"]
# JSI::JSON::Pointer.from_fragment('/foo%20bar')
# => JSI::JSON::Pointer["foo bar"]
#
# @param fragment [String] a fragment containing a pointer (starting with #)
# @param fragment [String] a fragment containing a pointer
# @return [JSI::JSON::Pointer]
# @raise [JSI::JSON::Pointer::PointerSyntaxError] when the fragment does not contain a pointer with valid pointer syntax
# @raise [JSI::JSON::Pointer::PointerSyntaxError] when the fragment does not contain a pointer with
# valid pointer syntax
def self.from_fragment(fragment)
from_pointer(Addressable::URI.unescape(fragment), type: 'fragment')
from_pointer(Addressable::URI.unescape(fragment))
end

# parse a pointer string and instantiate as a JSI::JSON::Pointer
#
# ptr1 = JSI::JSON::Pointer.from_pointer('/foo')
# => #<JSI::JSON::Pointer pointer: /foo>
# ptr1.reference_tokens
# => ["foo"]
# JSI::JSON::Pointer.from_pointer('/foo')
# => JSI::JSON::Pointer["foo"]
#
# ptr2 = JSI::JSON::Pointer.from_pointer('/foo~0bar/baz~1qux')
# => #<JSI::JSON::Pointer pointer: /foo~0bar/baz~1qux>
# ptr2.reference_tokens
# => ["foo~bar", "baz/qux"]
# JSI::JSON::Pointer.from_pointer('/foo~0bar/baz~1qux')
# => JSI::JSON::Pointer["foo~bar", "baz/qux"]
#
# @param pointer_string [String] a pointer string
# @param type (for internal use) indicates the original representation of the pointer
# @return [JSI::JSON::Pointer]
# @raise [JSI::JSON::Pointer::PointerSyntaxError] when the pointer_string does not have valid pointer syntax
def self.from_pointer(pointer_string, type: 'pointer')
def self.from_pointer(pointer_string)
tokens = pointer_string.split('/', -1).map! do |piece|
piece.gsub('~1', '/').gsub('~0', '~')
end
if tokens[0] == ''
new(tokens[1..-1], type: type)
new(tokens[1..-1])
elsif tokens.empty?
new(tokens, type: type)
new(tokens)
else
raise(PointerSyntaxError, "Invalid pointer syntax in #{pointer_string.inspect}: pointer must begin with /")
end
Expand All @@ -82,13 +74,11 @@ def self.from_pointer(pointer_string, type: 'pointer')
# initializes a JSI::JSON::Pointer from the given reference_tokens.
#
# @param reference_tokens [Array<Object>]
# @param type [String, Symbol] one of 'pointer' or 'fragment'
def initialize(reference_tokens, type: nil)
def initialize(reference_tokens)
unless reference_tokens.respond_to?(:to_ary)
raise(TypeError, "reference_tokens must be an array. got: #{reference_tokens.inspect}")
end
@reference_tokens = reference_tokens.to_ary.map(&:freeze).freeze
@type = type.is_a?(Symbol) ? type.to_s : type
end

attr_reader :reference_tokens
Expand All @@ -104,6 +94,10 @@ def evaluate(document)
if value.respond_to?(:to_ary)
if token.is_a?(String) && token =~ /\A\d|[1-9]\d+\z/
token = token.to_i
elsif token == '-'
# per rfc6901, - refers "to the (nonexistent) member after the last array element" and is
# expected to raise an error condition.
raise(ReferenceError, "Invalid resolution for #{to_s}: #{token.inspect} refers to a nonexistent element in array #{value.inspect}")
end
unless token.is_a?(Integer)
raise(ReferenceError, "Invalid resolution for #{to_s}: #{token.inspect} is not an integer and cannot be resolved in array #{value.inspect}")
Expand Down Expand Up @@ -134,12 +128,13 @@ def fragment
Addressable::URI.escape(pointer)
end

# @return [Addressable::URI] a URI consisting only of a pointer fragment
# @return [Addressable::URI] a URI consisting of a fragment containing this pointer's fragment string
# representation
def uri
Addressable::URI.new(fragment: fragment)
end

# @return [Boolean] whether this pointer points to the root (has an empty array of reference_tokens)
# @return [Boolean] whether this is a root pointer, indicated by an empty array of reference_tokens
def root?
reference_tokens.empty?
end
Expand All @@ -150,7 +145,7 @@ def parent
if root?
raise(ReferenceError, "cannot access parent of root pointer: #{pretty_inspect.chomp}")
else
Pointer.new(reference_tokens[0...-1], type: @type)
Pointer.new(reference_tokens[0...-1])
end
end

Expand All @@ -166,7 +161,7 @@ def ptr_relative_to(ancestor_ptr)
unless ancestor_ptr.contains?(self)
raise(ReferenceError, "ancestor_ptr #{ancestor_ptr.inspect} is not ancestor of #{inspect}")
end
Pointer.new(reference_tokens[ancestor_ptr.reference_tokens.size..-1], type: @type)
Pointer.new(reference_tokens[ancestor_ptr.reference_tokens.size..-1])
end

# @param ptr [JSI::JSON::Pointer]
Expand All @@ -175,7 +170,7 @@ def +(ptr)
unless ptr.is_a?(JSI::JSON::Pointer)
raise(TypeError, "ptr must be a JSI::JSON::Pointer; got: #{ptr.inspect}")
end
Pointer.new(reference_tokens + ptr.reference_tokens, type: @type)
Pointer.new(reference_tokens + ptr.reference_tokens)
end

# @param n [Integer]
Expand All @@ -185,15 +180,15 @@ def take(n)
unless (0..reference_tokens.size).include?(n)
raise(ArgumentError, "n not in range (0..#{reference_tokens.size}): #{n.inspect}")
end
Pointer.new(reference_tokens.take(n), type: @type)
Pointer.new(reference_tokens.take(n))
end

# appends the given token to this Pointer's reference tokens and returns the result
#
# @param token [Object]
# @return [JSI::JSON::Pointer] pointer to a child node of this pointer with the given token
def [](token)
Pointer.new(reference_tokens + [token], type: @type)
Pointer.new(reference_tokens + [token])
end

# given this Pointer points to a schema in the given document, returns a set of pointers
Expand Down Expand Up @@ -389,11 +384,11 @@ def deref(document, &block)
# HAX for how google does refs and ids
if document['schemas'].respond_to?(:to_hash)
if document['schemas'][ref]
return Pointer.new(['schemas', ref], type: 'hax').tap(&block)
return Pointer.new(['schemas', ref]).tap(&block)
end
document['schemas'].each do |k, schema|
if schema['id'] == ref
return Pointer.new(['schemas', k], type: 'hax').tap(&block)
return Pointer.new(['schemas', k]).tap(&block)
end
end
end
Expand All @@ -402,14 +397,14 @@ def deref(document, &block)
return self
end

# @return [String] string representation of this Pointer
# @return [String] a string representation of this Pointer
def inspect
"#{self.class.name}[#{reference_tokens.map(&:inspect).join(", ")}]"
end

alias_method :to_s, :inspect

# pointers are equal if the reference_tokens are equal, regardless of @type
# pointers are equal if the reference_tokens are equal
def jsi_fingerprint
{class: JSI::JSON::Pointer, reference_tokens: reference_tokens}
end
Expand Down
1 change: 1 addition & 0 deletions lib/jsi/metaschema_node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ def pretty_print(q)
q.text '>'
end

# @private
# @return [Array<String>]
def jsi_object_group_text
if jsi_schemas.any?
Expand Down
15 changes: 11 additions & 4 deletions lib/jsi/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ def from_object(schema_object)
alias_method :new, :from_object
end

# the underlying JSON data used to instantiate this JSI::Schema.
# this is an alias for PathedNode#jsi_node_content, named for clarity in the context of working with
# a schema.
def schema_content
jsi_node_content
end

# @return [String, nil] an absolute id for the schema, with a json pointer fragment. nil if
# no parent of this schema defines an id.
def schema_id
Expand Down Expand Up @@ -195,11 +202,11 @@ def subschemas_for_index(index)
def described_object_property_names
jsi_memoize(:described_object_property_names) do
Set.new.tap do |property_names|
if jsi_node_content.respond_to?(:to_hash) && jsi_node_content['properties'].respond_to?(:to_hash)
property_names.merge(jsi_node_content['properties'].keys)
if schema_content.respond_to?(:to_hash) && schema_content['properties'].respond_to?(:to_hash)
property_names.merge(schema_content['properties'].keys)
end
if jsi_node_content.respond_to?(:to_hash) && jsi_node_content['required'].respond_to?(:to_ary)
property_names.merge(jsi_node_content['required'].to_ary)
if schema_content.respond_to?(:to_hash) && schema_content['required'].respond_to?(:to_ary)
property_names.merge(schema_content['required'].to_ary)
end
end
end
Expand Down
Loading

0 comments on commit e887086

Please sign in to comment.