structured_reader 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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: []