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 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
@@ -0,0 +1,13 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /pkg/
7
+ /doc/
8
+ /spec/reports/
9
+ /tmp/
10
+
11
+ # rspec failure tracking
12
+ .rspec_status
13
+ .*.sw?
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.4.0
5
+ before_install: gem install bundler -v 1.15.3
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4
+
5
+ # Specify your gem's dependencies in structured_reader.gemspec
6
+ gemspec
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
+ [![Build Status](https://travis-ci.org/mboeh/structured_reader.svg?branch=master)](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
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
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
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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,3 @@
1
+ module StructuredReader
2
+ VERSION = "1.0.0"
3
+ end
@@ -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: []