structured_reader 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/.rspec +2 -0
- data/.travis.yml +5 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +197 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/examples/example.rb +56 -0
- data/examples/selection.rb +56 -0
- data/examples/union.rb +44 -0
- data/lib/structured_reader/version.rb +3 -0
- data/lib/structured_reader.rb +440 -0
- data/structured_reader.gemspec +27 -0
- metadata +103 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 0621752b200561ae03415cd92ccbd8a9c65db9ff
|
4
|
+
data.tar.gz: c7af4b1c0273c3e44b87efc782eec2235a8bff21
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 522146b8e92418a53a6047b675a4b1306f2d3b14a0fc8054f137051ca931080acbbb8c266de3e8e05271c06bdff8e8e5c9521dbe703ac3c7b8fe248df679a48c
|
7
|
+
data.tar.gz: 6750388513520282561be86f6779f2c74a08a4b7a226b1c473d179b893a4d061256a87f4f00728e7e6a6b777fe621fa3743177383ea5cb6a1d6f6b9c958a4b3c
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2017 Matthew Boeh
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,197 @@
|
|
1
|
+
# StructuredReader
|
2
|
+
|
3
|
+
[](https://travis-ci.org/mboeh/structured_reader)
|
4
|
+
|
5
|
+
This library allows you to create declarative rulesets (or schemas) for reading primitive data structures (hashes + arrays + strings + numbers) or JSON into validated data objects. Free yourself from `json.fetch(:widget).fetch(:box).fetch(:dimensions).fetch(:width)`. Get that good `widget.box.dimensions.width` without risking NoMethodErrors. Have confidence that if you're passed total unexpected nonsense, it won't be smoothed over by a convenient MagicHashyMash.
|
6
|
+
|
7
|
+
## Installation
|
8
|
+
|
9
|
+
Add this line to your application's Gemfile:
|
10
|
+
|
11
|
+
```ruby
|
12
|
+
gem 'structured_reader'
|
13
|
+
```
|
14
|
+
|
15
|
+
And then execute:
|
16
|
+
|
17
|
+
$ bundle
|
18
|
+
|
19
|
+
Or install it yourself as:
|
20
|
+
|
21
|
+
$ gem install structured_reader
|
22
|
+
|
23
|
+
## Usage
|
24
|
+
|
25
|
+
StructuredReader allows you to declare the expected structure of a JSON document (or any other data structure built on Ruby primitives). Readers are created with a simple domain-specific language (DSL) and are immutable. The declaration is evaluated at the time you create the reader, not every time it's used to read, so it's a good idea to assign it to a constant.
|
26
|
+
|
27
|
+
```ruby
|
28
|
+
require 'structured_reader'
|
29
|
+
|
30
|
+
READER = StructuredReader.json do |o|
|
31
|
+
o.collection :widgets do |w|
|
32
|
+
w.string :type, "widgetType"
|
33
|
+
w.number :price
|
34
|
+
w.string :description, nullable: true
|
35
|
+
w.array :tags, of: :string
|
36
|
+
w.object :dimensions, nullable: true do |dims|
|
37
|
+
dims.number :weight
|
38
|
+
dims.number :width
|
39
|
+
dims.number :height
|
40
|
+
end
|
41
|
+
w.time :last_updated_at, "lastUpdated"
|
42
|
+
end
|
43
|
+
o.object :pagination, strict: true do |pg|
|
44
|
+
pg.string :next_url, "nextUrl", nullable: true
|
45
|
+
pg.number :total_items, "totalItems"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
```
|
49
|
+
|
50
|
+
Readers provide a `read` method, which takes either a Hash or a JSON string (which will be parsed and is expected to result in a Hash).
|
51
|
+
|
52
|
+
```ruby
|
53
|
+
document = {
|
54
|
+
widgets: [
|
55
|
+
{
|
56
|
+
widgetType: "squorzit",
|
57
|
+
price: 99.99,
|
58
|
+
description: "who can even say?",
|
59
|
+
tags: ["mysterious", "magical"],
|
60
|
+
dimensions: {
|
61
|
+
weight: 10,
|
62
|
+
width: 5,
|
63
|
+
height: 9001
|
64
|
+
},
|
65
|
+
lastUpdated: "2017-12-24 01:01 AM PST"
|
66
|
+
},
|
67
|
+
{
|
68
|
+
widgetType: "frobulator",
|
69
|
+
price: 0.79,
|
70
|
+
tags: [],
|
71
|
+
comment: "a bonus text",
|
72
|
+
lastUpdated: "2017-12-24 01:05 AM PST"
|
73
|
+
}
|
74
|
+
],
|
75
|
+
pagination: {
|
76
|
+
nextUrl: nil,
|
77
|
+
totalItems: 2
|
78
|
+
}
|
79
|
+
}
|
80
|
+
|
81
|
+
result = READER.read(document)
|
82
|
+
```
|
83
|
+
|
84
|
+
Hashes (objects) are parsed into Ruby Structs. Fields present in the object but not in the declaration are ignored. You can change this behavior by passing `strict: true` to `object`.
|
85
|
+
|
86
|
+
```ruby
|
87
|
+
p result.widgets.length # ==> 2
|
88
|
+
p result.widgets[0].dimensions.height # ==> 9001
|
89
|
+
p result.widgets[0].tags # ==> ["mysterious", "magical"]
|
90
|
+
p result.widgets[1].last_updated_at # ==> #<DateTime: 2017-12-24T01:05:00-08:00 ((2458112j,32700s,0n),-28800s,2299161j)>
|
91
|
+
p result.widgets[1].comment # ! ==> NoMethodError
|
92
|
+
```
|
93
|
+
|
94
|
+
## Reader Types
|
95
|
+
|
96
|
+
* `string`: The classic.
|
97
|
+
* `number`: Like in JSON, this can be an integer or a float.
|
98
|
+
* `object`: Must define at least one field.
|
99
|
+
* `array`: An array containing elements of a single type. Use `one_of` to support arrays of mixed types.
|
100
|
+
* `collection`: Shorthand for an `array` of `objects`.
|
101
|
+
* `one_of`: Takes several other reader types and tests them in order, returning the first one that succeeds.
|
102
|
+
* `literal`: Validates an exact, literal value (e.g. the field must contain "foo" and nothing but "foo"). Can be used with `one_of` to implement discriminated unions; see below.
|
103
|
+
* `null`: Shorthand for `literal nil`.
|
104
|
+
* `time`: Expects a String and parses it using DateTime.parse. I am currently thinking about a clean way to declare subtypes of string along the lines of "parsable with a provided parser".
|
105
|
+
* `raw`: Always validates and returns the unmodified value. May be useful if you have a "payload" or "attributes" structure that you want to pass along to somewhere else.
|
106
|
+
* `custom`: Define your own personal pan parser.
|
107
|
+
|
108
|
+
Reead the specs for more details.
|
109
|
+
|
110
|
+
## Advanced Declarations
|
111
|
+
|
112
|
+
### Discriminated Unions
|
113
|
+
|
114
|
+
It is common to have JSON data structures that use a type field to distinguish between objects with different fields:
|
115
|
+
|
116
|
+
```ruby
|
117
|
+
document = [
|
118
|
+
{
|
119
|
+
type: "square",
|
120
|
+
length: 10,
|
121
|
+
},
|
122
|
+
{
|
123
|
+
type: "rectangle",
|
124
|
+
width: 5,
|
125
|
+
height: 10,
|
126
|
+
},
|
127
|
+
{
|
128
|
+
type: "circle",
|
129
|
+
diameter: 4,
|
130
|
+
},
|
131
|
+
]
|
132
|
+
```
|
133
|
+
|
134
|
+
This can be typed as a _discriminated union_ (or _tagged union_). The `one_of` and `literal` methods can be combined to validate and read this data. (A shorthand method for doing this may be implemented in the future.)
|
135
|
+
|
136
|
+
```ruby
|
137
|
+
reader = StructuredReader.json(root: :array) do |a|
|
138
|
+
a.one_of do |shape|
|
139
|
+
shape.object do |sq|
|
140
|
+
sq.literal :type, value: "square"
|
141
|
+
sq.number :length
|
142
|
+
end
|
143
|
+
shape.object do |rc|
|
144
|
+
rc.literal :type, value: "rectangle"
|
145
|
+
rc.number :width
|
146
|
+
rc.number :height
|
147
|
+
end
|
148
|
+
shape.object do |cr|
|
149
|
+
cr.literal :type, value: "circle"
|
150
|
+
cr.number :diameter
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
```
|
155
|
+
|
156
|
+
Each element of the array is tested against each option, and the one that succeeds is used. If none match, `StructuredReader::WrongTypeError` will be raised. (If you need a fallback, consider the `raw` type.)
|
157
|
+
|
158
|
+
There are some performance implications here. In general, declarations are tested against the data in order. In this case, each element will be tested first to see if it is a square, then to see if it is a rectangle, then to see if it is a circle. It's a good idea when using `one_of` to ensure that the type field (or some other distinguishing field) is declared first, so the tests can fail as fast as possible.
|
159
|
+
|
160
|
+
```ruby
|
161
|
+
result = reader.read(document)
|
162
|
+
|
163
|
+
p result.length # ==> 3
|
164
|
+
p result[0].type # ==> "square"
|
165
|
+
p result[0].length # ==> 10
|
166
|
+
p result[2].diameter # ==> 4
|
167
|
+
p result[0].diameter # ! ==> NoMethodError
|
168
|
+
```
|
169
|
+
|
170
|
+
## Defining Custom Types
|
171
|
+
|
172
|
+
The API for this is somewhat experimental. The best reference at this time is the specs.
|
173
|
+
|
174
|
+
## Caveats/Missing Features
|
175
|
+
|
176
|
+
### Stack depth
|
177
|
+
|
178
|
+
The depth of data structures you can read is limited by stack space, because the reading is done by mutual recursion. If you really have to parse a structure too deep for the stack, you can combine multiple layers of readers by using the `raw` type to pluck subdocuments out in unmodified form and then use a second reader to parse that.
|
179
|
+
|
180
|
+
### Performance
|
181
|
+
|
182
|
+
Unbenchmarked, unoptimized. It should be OK, though.
|
183
|
+
|
184
|
+
## Out Of Scope/Unfeatures
|
185
|
+
|
186
|
+
* Complex validation support. You can do a lot with `custom` fields, but I advise not pushing business logic into the serialization layer.
|
187
|
+
* Exact feature compatibility with JSON Schema. Same reason, really. You can use this library to validate JSON, but that's not its purpose.
|
188
|
+
* Inheritance/subtyping. Too much complexity for not much practical use. I might decide otherwise if I keep running into places it'd be useful.
|
189
|
+
* Tuples/fixed-length arrays. I don't see these in the wild very frequently, and I'm not a fan. `array`/`one_of` should suffice, and `custom` is there if you need it.
|
190
|
+
|
191
|
+
## Contributing
|
192
|
+
|
193
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/mboeh/structured_reader.
|
194
|
+
|
195
|
+
## License
|
196
|
+
|
197
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "structured_reader"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/examples/example.rb
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'bundler/setup'
|
2
|
+
|
3
|
+
require 'structured_reader'
|
4
|
+
|
5
|
+
reader = StructuredReader.json do |o|
|
6
|
+
o.collection :widgets do |w|
|
7
|
+
w.string :type, "widgetType"
|
8
|
+
w.number :price
|
9
|
+
w.string :description, nullable: true
|
10
|
+
w.array :tags, of: :string
|
11
|
+
w.object :dimensions, nullable: true do |dims|
|
12
|
+
dims.number :weight
|
13
|
+
dims.number :width
|
14
|
+
dims.number :height
|
15
|
+
end
|
16
|
+
w.time :last_updated_at, "lastUpdated"
|
17
|
+
end
|
18
|
+
o.object :pagination do |pg|
|
19
|
+
pg.string :next_url, "nextUrl", nullable: true
|
20
|
+
pg.number :total_items, "totalItems"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
document = {
|
25
|
+
widgets: [
|
26
|
+
{
|
27
|
+
widgetType: "squorzit",
|
28
|
+
price: 99.99,
|
29
|
+
description: "who can even say?",
|
30
|
+
tags: ["mysterious", "magical"],
|
31
|
+
dimensions: {
|
32
|
+
weight: 10,
|
33
|
+
width: 5,
|
34
|
+
height: 9001
|
35
|
+
},
|
36
|
+
lastUpdated: "2017-12-24 01:01 AM PST"
|
37
|
+
},
|
38
|
+
{
|
39
|
+
widgetType: "frobulator",
|
40
|
+
price: 0.79,
|
41
|
+
tags: [],
|
42
|
+
lastUpdated: "2017-12-24 01:05 AM PST"
|
43
|
+
}
|
44
|
+
],
|
45
|
+
pagination: {
|
46
|
+
nextUrl: nil,
|
47
|
+
totalItems: 2
|
48
|
+
}
|
49
|
+
}
|
50
|
+
|
51
|
+
result = reader.read(document)
|
52
|
+
|
53
|
+
p result.widgets.length # ==> 2
|
54
|
+
p result.widgets[0].dimensions.height # ==> 9001
|
55
|
+
p result.widgets[0].tags # ==> ["mysterious", "magical"]
|
56
|
+
p result.widgets[1].last_updated_at # ==> #<DateTime: 2017-12-24T01:05:00-08:00 ((2458112j,32700s,0n),-28800s,2299161j)>
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'bundler/setup'
|
2
|
+
|
3
|
+
require 'structured_reader'
|
4
|
+
|
5
|
+
reader = StructuredReader.json do |o|
|
6
|
+
o.collection :widgets do |w|
|
7
|
+
w.string :type, "widgetType"
|
8
|
+
w.number :price
|
9
|
+
w.string :description, nullable: true
|
10
|
+
w.array :tags, of: :string
|
11
|
+
w.object :dimensions, nullable: true do |dims|
|
12
|
+
dims.number :weight
|
13
|
+
dims.number :width
|
14
|
+
dims.number :height
|
15
|
+
end
|
16
|
+
w.time :last_updated_at, "lastUpdated"
|
17
|
+
end
|
18
|
+
o.object :pagination do |pg|
|
19
|
+
pg.string :next_url, "nextUrl", nullable: true
|
20
|
+
pg.number :total_items, "totalItems"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
document = {
|
25
|
+
widgets: [
|
26
|
+
{
|
27
|
+
widgetType: "squorzit",
|
28
|
+
price: 99.99,
|
29
|
+
description: "who can even say?",
|
30
|
+
tags: ["mysterious", "magical"],
|
31
|
+
dimensions: {
|
32
|
+
weight: 10,
|
33
|
+
width: 5,
|
34
|
+
height: 9001
|
35
|
+
},
|
36
|
+
lastUpdated: "2017-12-24 01:01 AM PST"
|
37
|
+
},
|
38
|
+
{
|
39
|
+
widgetType: "frobulator",
|
40
|
+
price: 0.79,
|
41
|
+
tags: [],
|
42
|
+
lastUpdated: "2017-12-24 01:05 AM PST"
|
43
|
+
}
|
44
|
+
],
|
45
|
+
pagination: {
|
46
|
+
nextUrl: nil,
|
47
|
+
totalItems: 2
|
48
|
+
}
|
49
|
+
}
|
50
|
+
|
51
|
+
# This uses filename globbing for now, not proper XPath-style wildcards
|
52
|
+
context = StructuredReader::JSONReader::SelectionContext.new(".widgets\\[*\\].price")
|
53
|
+
|
54
|
+
result = reader.read(document, context)
|
55
|
+
|
56
|
+
p result
|
data/examples/union.rb
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'bundler/setup'
|
2
|
+
|
3
|
+
require 'structured_reader'
|
4
|
+
|
5
|
+
reader = StructuredReader.json(root: :array) do |a|
|
6
|
+
a.one_of do |shape|
|
7
|
+
shape.object do |sq|
|
8
|
+
sq.literal :type, value: "square"
|
9
|
+
sq.number :length
|
10
|
+
end
|
11
|
+
shape.object do |rc|
|
12
|
+
rc.literal :type, value: "rectangle"
|
13
|
+
rc.number :width
|
14
|
+
rc.number :height
|
15
|
+
end
|
16
|
+
shape.object do |cr|
|
17
|
+
cr.literal :type, value: "circle"
|
18
|
+
cr.number :diameter
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
document = [
|
24
|
+
{
|
25
|
+
type: "square",
|
26
|
+
length: 10,
|
27
|
+
},
|
28
|
+
{
|
29
|
+
type: "rectangle",
|
30
|
+
width: 5,
|
31
|
+
height: 10,
|
32
|
+
},
|
33
|
+
{
|
34
|
+
type: "circle",
|
35
|
+
diameter: 4,
|
36
|
+
},
|
37
|
+
]
|
38
|
+
|
39
|
+
result = reader.read(document)
|
40
|
+
|
41
|
+
p result.length # ==> 3
|
42
|
+
p result[0].type # ==> "square"
|
43
|
+
p result[0].length # ==> 10
|
44
|
+
p result[2].diameter # ==> 4
|
@@ -0,0 +1,440 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "structured_reader/version"
|
4
|
+
require "json"
|
5
|
+
require "date"
|
6
|
+
|
7
|
+
module StructuredReader
|
8
|
+
Error = Class.new(StandardError)
|
9
|
+
WrongTypeError = Class.new(Error)
|
10
|
+
DeclarationError = Class.new(Error)
|
11
|
+
|
12
|
+
def self.json(*args, &blk)
|
13
|
+
JSONReader.new(*args, &blk)
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.reader_set(&blk)
|
17
|
+
JSONReader::ReaderSet.new.tap(&blk)
|
18
|
+
end
|
19
|
+
|
20
|
+
class JSONReader
|
21
|
+
|
22
|
+
def initialize(root: :object, reader_set: ReaderSet.new, &blk)
|
23
|
+
@root_reader = reader_set.reader(root, &blk)
|
24
|
+
end
|
25
|
+
|
26
|
+
def read(document, context = Context.new)
|
27
|
+
if document.kind_of?(String)
|
28
|
+
document = JSON.parse document
|
29
|
+
end
|
30
|
+
@root_reader.read(document, context)
|
31
|
+
end
|
32
|
+
|
33
|
+
class ObjectReader
|
34
|
+
|
35
|
+
class ReaderBuilder
|
36
|
+
|
37
|
+
def initialize(base, reader_set:)
|
38
|
+
@base = base
|
39
|
+
@reader_set = reader_set
|
40
|
+
end
|
41
|
+
|
42
|
+
def method_missing(type, name, field_name = name.to_s, *args, **kwargs, &blk)
|
43
|
+
if @reader_set.has_reader?(type)
|
44
|
+
@base.field name, field_name, @reader_set.reader(type, *args, **kwargs, &blk)
|
45
|
+
else
|
46
|
+
super
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def respond_to_missing?(type)
|
51
|
+
@reader_set.has_reader?(type) || super
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
55
|
+
|
56
|
+
def initialize(strict: false, reader_set:)
|
57
|
+
@readers = {}
|
58
|
+
@strict = strict
|
59
|
+
yield ReaderBuilder.new(self, reader_set: reader_set)
|
60
|
+
if @readers.empty?
|
61
|
+
raise DeclarationError, "must define at least one field to read"
|
62
|
+
end
|
63
|
+
@object_klass = Struct.new(*@readers.keys)
|
64
|
+
freeze
|
65
|
+
end
|
66
|
+
|
67
|
+
def read(fragment, context)
|
68
|
+
if fragment.kind_of?(Hash)
|
69
|
+
result = @object_klass.new
|
70
|
+
@readers.each do |key, (field, reader)|
|
71
|
+
value = fragment[field] || fragment[field.to_sym]
|
72
|
+
context.push(".#{field}") do |sub_context|
|
73
|
+
result[key] = reader.read(value, sub_context)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
if @strict && ((excess_keys = fragment.keys.map(&:to_sym) - @readers.keys)).any?
|
77
|
+
return context.flunk(fragment, "found strictly forbidden keys #{excess_keys.inspect}")
|
78
|
+
end
|
79
|
+
result.freeze
|
80
|
+
|
81
|
+
context.accept(result)
|
82
|
+
else
|
83
|
+
return context.flunk(fragment, "expected a Hash")
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def field(key, field_name, reader)
|
88
|
+
@readers[key.to_sym] = [field_name, reader]
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|
92
|
+
|
93
|
+
class ArrayReader
|
94
|
+
|
95
|
+
class ReaderBuilder
|
96
|
+
|
97
|
+
def initialize(base, reader_set:)
|
98
|
+
@base = base
|
99
|
+
@reader_set = reader_set
|
100
|
+
end
|
101
|
+
|
102
|
+
def method_missing(type, *args, **kwargs, &blk)
|
103
|
+
if @reader_set.has_reader?(type)
|
104
|
+
@base.member @reader_set.reader(type, *args, **kwargs, &blk)
|
105
|
+
else
|
106
|
+
super
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def respond_to_missing?(type)
|
111
|
+
@reader_set.has_reader?(type) || super
|
112
|
+
end
|
113
|
+
|
114
|
+
end
|
115
|
+
|
116
|
+
def initialize(of: nil, reader_set:, &blk)
|
117
|
+
if block_given?
|
118
|
+
yield ReaderBuilder.new(self, reader_set: reader_set)
|
119
|
+
elsif of
|
120
|
+
ReaderBuilder.new(self, reader_set: reader_set).send(of)
|
121
|
+
end
|
122
|
+
|
123
|
+
unless @member_reader
|
124
|
+
raise DeclarationError, "array must have a member type"
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def member(reader)
|
129
|
+
@member_reader = reader
|
130
|
+
end
|
131
|
+
|
132
|
+
def read(fragment, context)
|
133
|
+
if fragment.kind_of?(Array)
|
134
|
+
context.accept(fragment.map.with_index do |member, idx|
|
135
|
+
context.push("[#{idx}]") do |sub_context|
|
136
|
+
@member_reader.read(member, sub_context)
|
137
|
+
end
|
138
|
+
end)
|
139
|
+
else
|
140
|
+
context.flunk(fragment, "expected an Array")
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
end
|
145
|
+
|
146
|
+
class CollectionReader < ArrayReader
|
147
|
+
|
148
|
+
def initialize(**args, &blk)
|
149
|
+
super do |a|
|
150
|
+
a.object(&blk)
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
end
|
155
|
+
|
156
|
+
class NumberReader
|
157
|
+
|
158
|
+
def initialize(**_)
|
159
|
+
|
160
|
+
end
|
161
|
+
|
162
|
+
def read(fragment, context)
|
163
|
+
if fragment.kind_of?(Numeric)
|
164
|
+
context.accept fragment
|
165
|
+
else
|
166
|
+
context.flunk(fragment, "expected a Numeric")
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
end
|
171
|
+
|
172
|
+
class StringReader
|
173
|
+
|
174
|
+
def initialize(**_)
|
175
|
+
|
176
|
+
end
|
177
|
+
|
178
|
+
def read(fragment, context)
|
179
|
+
if fragment.kind_of?(String)
|
180
|
+
context.accept maybe_parse(fragment, context)
|
181
|
+
else
|
182
|
+
context.flunk(fragment, "expected a String")
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
def maybe_parse(fragment, _context)
|
187
|
+
fragment
|
188
|
+
end
|
189
|
+
|
190
|
+
end
|
191
|
+
|
192
|
+
class TimeReader < StringReader
|
193
|
+
|
194
|
+
def maybe_parse(fragment, context)
|
195
|
+
begin
|
196
|
+
context.accept DateTime.parse(fragment)
|
197
|
+
rescue ArgumentError
|
198
|
+
context.flunk(fragment, "could not be converted to a DateTime")
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
end
|
203
|
+
|
204
|
+
class LiteralReader
|
205
|
+
|
206
|
+
def initialize(value:, **_)
|
207
|
+
@value = value
|
208
|
+
end
|
209
|
+
|
210
|
+
def read(fragment, context)
|
211
|
+
if fragment == @value
|
212
|
+
context.accept fragment
|
213
|
+
else
|
214
|
+
context.flunk(fragment, "expected #{@value.inspect}")
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
end
|
219
|
+
|
220
|
+
class NullReader < LiteralReader
|
221
|
+
|
222
|
+
def initialize(**_)
|
223
|
+
super value: nil
|
224
|
+
end
|
225
|
+
|
226
|
+
end
|
227
|
+
|
228
|
+
class RawReader
|
229
|
+
|
230
|
+
def initialize(**_)
|
231
|
+
|
232
|
+
end
|
233
|
+
|
234
|
+
def read(fragment, context)
|
235
|
+
context.accept fragment
|
236
|
+
end
|
237
|
+
|
238
|
+
end
|
239
|
+
|
240
|
+
class CustomReader
|
241
|
+
|
242
|
+
def initialize(**_, &blk)
|
243
|
+
@read_action = blk
|
244
|
+
end
|
245
|
+
|
246
|
+
def read(fragment, context)
|
247
|
+
@read_action.call(fragment, context)
|
248
|
+
end
|
249
|
+
|
250
|
+
end
|
251
|
+
|
252
|
+
class OneOfReader
|
253
|
+
|
254
|
+
class ReaderBuilder
|
255
|
+
|
256
|
+
def initialize(base, reader_set:)
|
257
|
+
@base = base
|
258
|
+
@reader_set = reader_set
|
259
|
+
end
|
260
|
+
|
261
|
+
def method_missing(type, *args, **kwargs, &blk)
|
262
|
+
if @reader_set.has_reader?(type)
|
263
|
+
@base.option @reader_set.reader(type, *args, **kwargs, &blk)
|
264
|
+
else
|
265
|
+
super
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
def respond_to_missing?(type)
|
270
|
+
@reader_set.has_reader?(type) || super
|
271
|
+
end
|
272
|
+
|
273
|
+
end
|
274
|
+
|
275
|
+
def initialize(reader_set:, **_)
|
276
|
+
@readers = []
|
277
|
+
yield ReaderBuilder.new(self, reader_set: reader_set)
|
278
|
+
if @readers.empty?
|
279
|
+
raise DeclarationError, "must define at least one option"
|
280
|
+
end
|
281
|
+
freeze
|
282
|
+
end
|
283
|
+
|
284
|
+
def read(fragment, context)
|
285
|
+
@readers.each do |reader|
|
286
|
+
if reader.read(fragment, ValidatorContext.new).empty?
|
287
|
+
return context.accept(reader.read(fragment, context))
|
288
|
+
end
|
289
|
+
end
|
290
|
+
|
291
|
+
context.flunk(fragment, "was not any of the expected options")
|
292
|
+
end
|
293
|
+
|
294
|
+
def option(reader)
|
295
|
+
@readers << reader
|
296
|
+
end
|
297
|
+
|
298
|
+
end
|
299
|
+
|
300
|
+
class BuilderDeriver
|
301
|
+
|
302
|
+
def initialize(klass, &blk)
|
303
|
+
@klass = klass
|
304
|
+
@build_action = blk
|
305
|
+
end
|
306
|
+
|
307
|
+
def new(*args, **kwargs)
|
308
|
+
@klass.new(*args, **kwargs, &@build_action)
|
309
|
+
end
|
310
|
+
|
311
|
+
end
|
312
|
+
|
313
|
+
class Context
|
314
|
+
|
315
|
+
def initialize(where = "")
|
316
|
+
@where = where.dup.freeze
|
317
|
+
end
|
318
|
+
|
319
|
+
def accept(fragment)
|
320
|
+
fragment
|
321
|
+
end
|
322
|
+
|
323
|
+
def flunk(fragment, reason)
|
324
|
+
raise WrongTypeError, "#{reason}, got a #{fragment.class} (at #{@where})"
|
325
|
+
end
|
326
|
+
|
327
|
+
def push(path, &blk)
|
328
|
+
yield self.class.new(@where + path)
|
329
|
+
end
|
330
|
+
|
331
|
+
end
|
332
|
+
|
333
|
+
class ValidatorContext
|
334
|
+
|
335
|
+
def initialize(where = "", errors = [])
|
336
|
+
@where = where.dup.freeze
|
337
|
+
@errors = errors
|
338
|
+
end
|
339
|
+
|
340
|
+
def accept(fragment)
|
341
|
+
@errors
|
342
|
+
end
|
343
|
+
|
344
|
+
def flunk(fragment, reason)
|
345
|
+
@errors << [@where, reason]
|
346
|
+
end
|
347
|
+
|
348
|
+
def push(path)
|
349
|
+
yield self.class.new(@where + path, @errors)
|
350
|
+
end
|
351
|
+
|
352
|
+
end
|
353
|
+
|
354
|
+
class SelectionContext
|
355
|
+
|
356
|
+
def initialize(target, where = "", found = [])
|
357
|
+
@target = target
|
358
|
+
@where = where
|
359
|
+
@found = found
|
360
|
+
end
|
361
|
+
|
362
|
+
def accept(fragment)
|
363
|
+
if File.fnmatch(@target, @where)
|
364
|
+
@found << fragment
|
365
|
+
fragment
|
366
|
+
else
|
367
|
+
if @found.any?
|
368
|
+
@found.first
|
369
|
+
else
|
370
|
+
unless @where.empty?
|
371
|
+
fragment
|
372
|
+
end
|
373
|
+
end
|
374
|
+
end
|
375
|
+
end
|
376
|
+
|
377
|
+
def flunk(fragment, reason)
|
378
|
+
nil
|
379
|
+
end
|
380
|
+
|
381
|
+
def push(path)
|
382
|
+
if @found.empty?
|
383
|
+
yield self.class.new(@target, @where + path, @found)
|
384
|
+
end
|
385
|
+
end
|
386
|
+
|
387
|
+
end
|
388
|
+
|
389
|
+
class ReaderSet
|
390
|
+
READERS = {
|
391
|
+
array: ArrayReader,
|
392
|
+
collection: CollectionReader,
|
393
|
+
string: StringReader,
|
394
|
+
time: TimeReader,
|
395
|
+
object: ObjectReader,
|
396
|
+
number: NumberReader,
|
397
|
+
one_of: OneOfReader,
|
398
|
+
null: NullReader,
|
399
|
+
raw: RawReader,
|
400
|
+
literal: LiteralReader,
|
401
|
+
custom: CustomReader,
|
402
|
+
}
|
403
|
+
|
404
|
+
def initialize
|
405
|
+
@readers = READERS.dup
|
406
|
+
end
|
407
|
+
|
408
|
+
def add_reader(type, reader)
|
409
|
+
@readers[type.to_sym] = reader
|
410
|
+
end
|
411
|
+
|
412
|
+
def custom(type, &blk)
|
413
|
+
add_reader type, BuilderDeriver.new(CustomReader, &blk)
|
414
|
+
end
|
415
|
+
|
416
|
+
def object(type, &blk)
|
417
|
+
add_reader type, BuilderDeriver.new(ObjectReader, &blk)
|
418
|
+
end
|
419
|
+
|
420
|
+
def reader(type, *args, **kwargs, &blk)
|
421
|
+
if kwargs[:nullable]
|
422
|
+
kwargs = kwargs.dup
|
423
|
+
kwargs.delete :nullable
|
424
|
+
OneOfReader.new(reader_set: self) do |o|
|
425
|
+
o.null
|
426
|
+
o.send(type, *args, **kwargs, &blk)
|
427
|
+
end
|
428
|
+
else
|
429
|
+
@readers.fetch(type).new(*args, reader_set: self, **kwargs, &blk)
|
430
|
+
end
|
431
|
+
end
|
432
|
+
|
433
|
+
def has_reader?(type)
|
434
|
+
@readers.has_key?(type)
|
435
|
+
end
|
436
|
+
end
|
437
|
+
|
438
|
+
end
|
439
|
+
|
440
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path("../lib", __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require "structured_reader/version"
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "structured_reader"
|
8
|
+
spec.version = StructuredReader::VERSION
|
9
|
+
spec.authors = ["Matthew Boeh"]
|
10
|
+
spec.email = ["m@mboeh.com"]
|
11
|
+
|
12
|
+
spec.summary = %q{Read primitive and JSON data structures into data objects}
|
13
|
+
spec.description = %q{This library allows you to create declarative rulesets (or schemas) for reading primitive data structures (hashes + arrays + strings + numbers) or JSON into validated data objects.}
|
14
|
+
spec.homepage = "https://github.com/mboeh/structured_reader"
|
15
|
+
spec.license = "MIT"
|
16
|
+
|
17
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
18
|
+
f.match(%r{^(test|spec|features)/})
|
19
|
+
end
|
20
|
+
spec.bindir = "exe"
|
21
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
22
|
+
spec.require_paths = ["lib"]
|
23
|
+
|
24
|
+
spec.add_development_dependency "bundler", "~> 1.15"
|
25
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
26
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
27
|
+
end
|
metadata
ADDED
@@ -0,0 +1,103 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: structured_reader
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Matthew Boeh
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-12-25 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.15'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.15'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rake
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '10.0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '10.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rspec
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '3.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '3.0'
|
55
|
+
description: This library allows you to create declarative rulesets (or schemas) for
|
56
|
+
reading primitive data structures (hashes + arrays + strings + numbers) or JSON
|
57
|
+
into validated data objects.
|
58
|
+
email:
|
59
|
+
- m@mboeh.com
|
60
|
+
executables: []
|
61
|
+
extensions: []
|
62
|
+
extra_rdoc_files: []
|
63
|
+
files:
|
64
|
+
- ".gitignore"
|
65
|
+
- ".rspec"
|
66
|
+
- ".travis.yml"
|
67
|
+
- Gemfile
|
68
|
+
- LICENSE.txt
|
69
|
+
- README.md
|
70
|
+
- Rakefile
|
71
|
+
- bin/console
|
72
|
+
- bin/setup
|
73
|
+
- examples/example.rb
|
74
|
+
- examples/selection.rb
|
75
|
+
- examples/union.rb
|
76
|
+
- lib/structured_reader.rb
|
77
|
+
- lib/structured_reader/version.rb
|
78
|
+
- structured_reader.gemspec
|
79
|
+
homepage: https://github.com/mboeh/structured_reader
|
80
|
+
licenses:
|
81
|
+
- MIT
|
82
|
+
metadata: {}
|
83
|
+
post_install_message:
|
84
|
+
rdoc_options: []
|
85
|
+
require_paths:
|
86
|
+
- lib
|
87
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
88
|
+
requirements:
|
89
|
+
- - ">="
|
90
|
+
- !ruby/object:Gem::Version
|
91
|
+
version: '0'
|
92
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
requirements: []
|
98
|
+
rubyforge_project:
|
99
|
+
rubygems_version: 2.6.12
|
100
|
+
signing_key:
|
101
|
+
specification_version: 4
|
102
|
+
summary: Read primitive and JSON data structures into data objects
|
103
|
+
test_files: []
|