awesome_xml 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: 6e0fdacdca25fa723e63830f68a8b2c250c4b1df
4
+ data.tar.gz: f49a9d5503da9076d8608e98168267ebf572a304
5
+ SHA512:
6
+ metadata.gz: 1acd280ece6c36b3aa553e5b12901a8df5bf566363a7c4b0854bf285d23c0b817b74800c57255b885c4742b68b473fa3c53afa6ca78d815f9aef57ee734ac162
7
+ data.tar.gz: bf932106ae5e58ff57922688405c50074fd6e7640b3fa1f8ccc1066ad9abd2cc1e0fac1b17aa93affdabd483763ade2b0dd3248d76ace5d7308d8b7b05379bc7
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017 fromAtoB
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,498 @@
1
+ # AwesomeXML
2
+
3
+ AwesomeXML is an XML mapping library that lets your Ruby classes parse arbitrary data from XML documents into a hash.
4
+ The hash can be structured completely freely. The parsing itself is based on [Nokogiri](https://github.com/sparklemotion/nokogiri).
5
+ The concept is very similar to that of [happymapper](https://github.com/dam5s/happymapper).
6
+
7
+ ## Include it
8
+
9
+ Include `AwesomeXML` in any class you want to have all the capabilities this gem provides to you.
10
+
11
+ ```ruby
12
+ class MyDocument
13
+ include AwesomeXML
14
+ end
15
+ ```
16
+
17
+ ## Feed it
18
+
19
+ Your class will now have a `.parse` class method which takes in a single argument containing a string
20
+ representing an XML document. It returns an instance of your class. Like this:
21
+
22
+ ```ruby
23
+ my_document = MyDocument.parse('<document><title>This is a document.</title></document>')
24
+ => #<MyDocument:0x007fc57d239520 @xml=#<Nokogiri::XML::Document:0x3fe2be91ca54 name="document" children=[#<Nokogiri::XML::Element:0x3fe2be91c70c name="document" children=[#<Nokogiri::XML::Element:0x3fe2be91c52c name="title" children=[#<Nokogiri::XML::Text:0x3fe2be91c34c "This is a document.">]>]>]>, @parent_node=nil>
25
+ ```
26
+
27
+ ## Create your first awesome node
28
+
29
+ Let's say you have this XML document and you want to parse the content of the `<title></title>` tag.
30
+
31
+ ```xml
32
+ <document>
33
+ <title>This is a document.</title>
34
+ </document>
35
+ ```
36
+
37
+ The `AwesomeXML` module defines several class methods on your class that that help you with that.
38
+ The most basic one is the `.node` method.
39
+ Its arguments are
40
+ - a symbol, which will be the name of your node.
41
+ - the type which the parser will assume the parsed value has
42
+ - an options hash (optional)
43
+
44
+ The type can either be a native type given in the form of a symbol (currently supported are `:text`,
45
+ `:integer`, `:float`, `:duration` and `:date_time`), or a custom class. You can also pass in a string containing
46
+ a class name in case the class constant is not yet defined at the time you run the `.node` method.
47
+ More about that later.
48
+
49
+ Let's try it!
50
+
51
+ ```ruby
52
+ class MyDocument
53
+ include AwesomeXML
54
+
55
+ set_context 'document'
56
+ node :title, :text
57
+ end
58
+ ```
59
+
60
+ Notice we needed to set a context node `'document'` so the `title` node could be found. `.set_context` takes an XPath
61
+ and sets the current node for the whole class. There's a few other ways you can achievement the same thing as above.
62
+ For example by passing in an explicit XPath.
63
+
64
+ ```ruby
65
+ class MyDocument
66
+ include AwesomeXML
67
+
68
+ node :title, :text, xpath: 'document/title'
69
+ end
70
+ ```
71
+
72
+ If you don't pass an XPath (like in the very first example), the default is assumed, which is `"./#{name_of_you_node}"`.
73
+ Or, if you don't want to set the context node for the whole class, you can use `.with_context`, which takes a block:
74
+
75
+ ```ruby
76
+ class MyDocument
77
+ include AwesomeXML
78
+
79
+ with_context 'document' do
80
+ node :title, :text
81
+ end
82
+ end
83
+ ```
84
+
85
+ All of these make a few things possible. Firstly, after calling `MyDocument.parse(xml_string)`, you can access
86
+ an attribute reader method with the name of your node (`title`). It contains the value parsed from your XML document.
87
+
88
+ ```ruby
89
+ my_document.title
90
+ => "This is a document."
91
+ ```
92
+
93
+ Secondly, it changes the result of the `#to_hash` method of your class. More about that later.
94
+
95
+ ## Attributes, elements and `self`
96
+
97
+ Let's say your XML document has important data hidden in the attributes of tags:
98
+
99
+ ```xml
100
+ <document title='This is a document.'/>
101
+ ```
102
+
103
+ One way to do it is to pass the option `attribute: true` to your node:
104
+
105
+ ```ruby
106
+ class MyDocument
107
+ include AwesomeXML
108
+
109
+ set_context 'document'
110
+ node :title, :text, attribute: true
111
+ end
112
+ ```
113
+
114
+ This is the same as passing an explicit XPath `"./@#{name_of_you_node}"`.
115
+ Instead of just `true`, you can pass in a symbol (or string) to the `:attribute` option that will then be used to
116
+ build the XPath to your node, instead of using the node name. Use this whenever you want your nodes
117
+ to be named differently than in the XML document.
118
+
119
+ This is also true for the other two types of nodes: elements and `self`. By default, `AwesomeXML` will look for
120
+ elements, so passing the option `element: true` will do nothing. But you can use the option like `:attribute`, in
121
+ that you can pass something else than `true` to tell the parser to look for an element with a different name.
122
+
123
+ The last type of node is `self`. Pass in `self: true` if you want to access the content of the current context
124
+ node itself. This is equivalent to passing in `xpath: '.'`. Changing the option value will do nothing.
125
+
126
+ ## Method nodes
127
+
128
+ If you want, you can define your node in a method. Like this:
129
+
130
+ ```ruby
131
+ class MyDocument
132
+ include AwesomeXML
133
+
134
+ set_context 'document'
135
+ node :title, :text
136
+ method_node :reversed_title
137
+
138
+ def reversed_title
139
+ title.reverse
140
+ end
141
+ end
142
+ ```
143
+
144
+ You might say, ok, that's useless. I don't need to have the node define my `#reversed_title` method for me,
145
+ I'm doing that myself already! And you would be correct. There's one side effect, though, related to
146
+ the following awesome method that is provided to you:
147
+
148
+ ## `#to_hash`
149
+
150
+ Including `AwesomeXML` will define the method `#to_hash` on your class. It traverses all the nodes
151
+ you defined in your class (including the ones declared with `.method_node`) and returns values in a hash
152
+ that follows the structure you defined. Let's take the example from the section above. Then, `#to_hash`
153
+ would do the following:
154
+
155
+ ```ruby
156
+ my_document.to_hash
157
+ => {:title=>"This is a document.", :reversed_title=>".tnemucod a si sihT"}
158
+ ```
159
+
160
+ Let's step it up a little.
161
+
162
+ ## Child nodes
163
+
164
+ Let's say you have a slightly more complicated XML document. Baby steps.
165
+
166
+ ```xml
167
+ <document>
168
+ <title>This is a document.</title>
169
+ <item ref='123'>
170
+ <owner>John Doe</owner>
171
+ </item>
172
+ </document>
173
+ ```
174
+
175
+ If you want your parsed hash to look like this:
176
+ ```ruby
177
+ { title: "This is a document.", item: { reference: 123, owner: 'John Doe' } }
178
+ ```
179
+ You can do that by creating a node of the type of another class that also includes `AwesomeXML`.
180
+
181
+ ```ruby
182
+ class MyDocument
183
+ include AwesomeXML
184
+
185
+ set_context 'document'
186
+ node :title, :text
187
+ node :item, 'Item'
188
+
189
+ class Item
190
+ include AwesomeXML
191
+
192
+ node :reference, :integer, attribute: :ref
193
+ node :owner, :text
194
+ end
195
+ end
196
+ ```
197
+
198
+ Easy! You might have noticed that the context node for the `Item` class is automatically set. No need
199
+ to call `.set_context` except you want to override the default, of course.
200
+
201
+ If you want, you can also pass in the class itself instead of a string with the class name.
202
+ Just make sure that it is defined before you use it in your `.node` method! Like this:
203
+
204
+ ```ruby
205
+ class MyDocument
206
+ include AwesomeXML
207
+
208
+ class Item
209
+ include AwesomeXML
210
+
211
+ node :reference, :integer, attribute: :ref
212
+ node :owner, :text
213
+ end
214
+
215
+ set_context 'document'
216
+ node :title, :text
217
+ node :item, Item
218
+ end
219
+ ```
220
+
221
+ ## Array nodes
222
+
223
+ What if you have more than one `<item/>`? Say your XML document looks like this:
224
+
225
+ ```xml
226
+ <document>
227
+ <item ref='123'/>
228
+ <item ref='456'/>
229
+ <item ref='789'/>
230
+ </document>
231
+ ```
232
+
233
+ And you want your parsed hash to look like this:
234
+
235
+ ```ruby
236
+ { refs: [123, 456, 789] }
237
+ ```
238
+
239
+ Fret no more, just use the option `array: true`:
240
+
241
+ ```ruby
242
+ class MyDocument
243
+ include AwesomeXML
244
+
245
+ set_context 'document/item'
246
+ node :refs, :integer, attribute: true, array: true
247
+ end
248
+ ```
249
+
250
+ Pretty self-explanatory, right? `AwesomeXML` even singularizes your node name automatically!
251
+
252
+ Okay, you say, that's a very simple array, indeed. What if I want an array of hashes? Like so:
253
+ ```ruby
254
+ { items: [{ ref: 123 }, { ref: 456 }, { ref: 789 }] }
255
+ ```
256
+
257
+ Just combine the two things we last learned:
258
+
259
+ ```ruby
260
+ class MyDocument
261
+ include AwesomeXML
262
+
263
+ set_context 'document'
264
+ node :items, 'Item', array: true
265
+
266
+ class Item
267
+ include AwesomeXML
268
+
269
+ node :ref, :integer, attribute: true
270
+ end
271
+ end
272
+ ```
273
+
274
+ Awesome, right? You've got a few more notches you can kick it up, though.
275
+
276
+ ## Passing blocks
277
+
278
+ That's right, you can pass blocks. It's actually very simple. All `*_node` methods (except `.method_node`
279
+ and `.constant_node`) define instance methods that yield their result to the block you specify. This lets you
280
+ do pretty much anything you want. Let's say you don't like the way the items are numbered in your XML document:
281
+
282
+ ```xml
283
+ <document>
284
+ <item index='1'/>
285
+ <item index='2'/>
286
+ <item index='3'/>
287
+ </document>
288
+ ```
289
+
290
+ Yuck. Let's fix that:
291
+
292
+ ```ruby
293
+ class MyDocument
294
+ include AwesomeXML
295
+
296
+ set_context 'document'
297
+ node(:items, :integer, array: true, xpath: './item/@index') do |values|
298
+ values.map { |value| value - 1 }
299
+ end
300
+ end
301
+
302
+ my_document.to_hash
303
+ => {:items=>[0, 1, 2]}
304
+
305
+ ```
306
+
307
+ That's better. Note that array nodes yield the whole array to the block and not an `Enumerator`.
308
+
309
+ There's another twist to this block passing, though. AwesomeXML also yields the instance of your class
310
+ to the block so you can actually access other nodes inside the block! Let's see it in action.
311
+
312
+ Your XML data:
313
+ ```xml
314
+ <document>
315
+ <items multiplicator='100'>
316
+ <item value='1'/>
317
+ <item value='2'/>
318
+ <item value='3'/>
319
+ </items>
320
+ </document>
321
+ ```
322
+
323
+ Your `AwesomeXML` class:
324
+
325
+ ```ruby
326
+ class MyDocument
327
+ include AwesomeXML
328
+
329
+ set_context 'document/items'
330
+ node :multiplicator, :integer, attribute: true
331
+ node(:item_values, :integer, array: :true, xpath: './item/@value') do |values, instance|
332
+ values.map { |value| value * instance.multiplicator }
333
+ end
334
+ end
335
+
336
+ my_document.to_hash
337
+ => {:multiplicator=>100, :item_values=>[100, 200, 300]}
338
+ ```
339
+
340
+ ## Overwriting attribute readers
341
+
342
+ You can achieve the same effect as passing blocks by redefining the attribute accessors that `AwesomeXML`
343
+ usually defines for you. Arguably, this is the more elegant method, although you might prefer the block
344
+ syntax's brevity for more simple operations.
345
+
346
+ Let's see how the example from above would look in this style:
347
+
348
+ ```ruby
349
+ class MyDocument
350
+ include AwesomeXML
351
+
352
+ set_context 'document/items'
353
+ node :multiplicator, :integer, attribute: true
354
+ node :item_values, :integer, array: :true, xpath: './item/@value'
355
+
356
+ def item_values
357
+ @item_values.map { |value| value * multiplicator }
358
+ end
359
+ end
360
+ ```
361
+
362
+ ## `#parent_node`
363
+
364
+ This method is available on all class instances including the `AwesomeXML` module. It returns the
365
+ instance of the class it was initialized from. Let's see how that can be useful. Let's again use
366
+ the XML document from the above two examples.
367
+
368
+ ```xml
369
+ <document>
370
+ <items multiplicator='100'>
371
+ <item value='1'/>
372
+ <item value='2'/>
373
+ <item value='3'/>
374
+ </items>
375
+ </document>
376
+ ```
377
+
378
+ This time, you want each `<item/>` to be represented by its own hash. Like this:
379
+ ```ruby
380
+ my_document.to_hash
381
+ => {:items=>[{:value=>100}, {:value=>200}, {:value=>300}]}
382
+ ```
383
+
384
+ There's (at least) two ways to do this. You can either define the `multiplicator` node on your child class:
385
+
386
+ ```ruby
387
+ class MyDocument
388
+ include AwesomeXML
389
+
390
+ set_context 'document/items'
391
+ node :items, 'Item', array: true
392
+
393
+ class Item
394
+ include AwesomeXML
395
+
396
+ node :multiplicator, :integer, xpath: '../@multiplicator', private: true
397
+ node :value, :integer, attribute: true
398
+
399
+ def value
400
+ @value * multiplicator
401
+ end
402
+ end
403
+ end
404
+ ```
405
+
406
+ Or, alternatively, you can use `#parent_node`:
407
+
408
+ ```ruby
409
+ class MyDocument
410
+ include AwesomeXML
411
+
412
+ set_context 'document/items'
413
+ node :multiplicator, :integer, attribute: true, private: true
414
+ node :items, 'Item', array: true
415
+
416
+ class Item
417
+ include AwesomeXML
418
+
419
+ node :value, :integer, attribute: true
420
+
421
+ def value
422
+ @value * parent_node.multiplicator
423
+ end
424
+ end
425
+ end
426
+ ```
427
+
428
+ Both are perfectly acceptable. The latter is slightly more efficient because the `multiplicator` node
429
+ will only be parsed once instead of once per `item`. You may have noticed that we used a new option:
430
+ `:private`. I'll explain it in the next section.
431
+
432
+ ## More options
433
+
434
+ ### `:private`
435
+
436
+ The `:private` option removes your node from the ones being evaluated in `#to_hash`. This is
437
+ helpful if you want to parse something that is not meant to end up in the parsed schema. Let's revisit the example
438
+ from above.
439
+
440
+ ```xml
441
+ <document>
442
+ <items multiplicator='100'>
443
+ <item value='1'/>
444
+ <item value='2'/>
445
+ <item value='3'/>
446
+ </items>
447
+ </document>
448
+ ```
449
+
450
+ Now let's try and remove the `multiplicator` from your parsed hash. Like so:
451
+
452
+ ```ruby
453
+ class MyDocument
454
+ include AwesomeXML
455
+
456
+ set_context 'document/items'
457
+ node :multiplicator, :integer, attribute: true, private: true
458
+ node :item_values, :integer, array: :true, xpath: './item/@value'
459
+
460
+ def item_values
461
+ @item_values.map { |value| value * multiplicator }
462
+ end
463
+ end
464
+ ```
465
+
466
+ ```ruby
467
+ my_document.to_hash
468
+ => {:item_values=>[100, 200, 300]}
469
+ ```
470
+
471
+ Awesome.
472
+
473
+ ### `:default` and `:default_empty`
474
+
475
+ Using these options, you can control what happens in case the tag or attribute you wanted to parse is empty
476
+ or doesn't even exist. For the former, use `:default_empty`, for the latter, use `:default`.
477
+
478
+ ## More node types
479
+
480
+ Let's talk about duration nodes. As you may remember, `:duration` is of the native types for `.node`.
481
+ They return `ActiveSupport::Duration` objects, which interact freely with each other and with `Time` and
482
+ `DateTime` objects.
483
+ The special thing about them is that they take a *mandatory* `:format` option. There, you can specify the
484
+ format in which the duration you want to parse is available. The format is given in the form of a duration
485
+ format string with an easy syntax. Basically, you emulate the format of the given duration string and
486
+ replace the numbers with instructions how to treat them. The syntax is `"{#{unit}#{parse_length}}"`.
487
+ The `unit` can be one of `D`, `H`, `M`, or `S` (or their lowercase variants), representing days, hours, minutes, and seconds.
488
+ The `parse_length` tells the parser how many digits to look for, and can be any integer.
489
+
490
+ For example, let's say you want to parse a duration string that looks like `'1234'`, where the first two
491
+ digits stand for minutes and the last two for seconds. To parse this correctly, use the format string
492
+ `'{M2}{S2}'`. Easy enough.
493
+
494
+ What, though, if the number of digits vary? Maybe your duration string sometimes looks like `'12m34'`,
495
+ but when the numbers are single digit, it looks like `'2m1'`. In this case, just don't specify a
496
+ `parse_length`. Everything up to the following character (or the end of the duration string) will be
497
+ treated as going into the parsed value. The format string that would parse you the correct duration
498
+ would be `'{M}m{S}'`.
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
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A collection of class methods that will be available on classes that include `AwesomeXML`.
4
+ module AwesomeXML
5
+ module ClassMethods
6
+ attr_reader :context, :nodes, :public_nodes
7
+ private :nodes, :public_nodes
8
+
9
+ # Takes in a string representing an XML document. Initializes an instance of the class
10
+ # the module was included in and calls `#parse` on it. See there for more info.
11
+ def parse(xml)
12
+ new(xml).parse
13
+ end
14
+
15
+ # Takes in a string representing an XPath and assigns it to the class variable `@@context`.
16
+ # This sets the current context node for all nodes defined below it in the class this
17
+ # module is included in.
18
+ def set_context(xpath)
19
+ @context ||= xpath
20
+ end
21
+
22
+ # Works just like `set_context`, but sets the current context node only for nodes defined
23
+ # inside the block passed to this method.
24
+ def with_context(xpath, &block)
25
+ @local_context = xpath
26
+ yield
27
+ @local_context = nil
28
+ end
29
+
30
+ # Defines a method on your class returning a constant.
31
+ def constant_node(name, value, options = {})
32
+ attr_reader name.to_sym
33
+ define_method("parse_#{name}".to_sym) do
34
+ instance_variable_set("@#{name}", value)
35
+ end
36
+ register(name, options[:private])
37
+ end
38
+
39
+ # Does not actually define a method, but registers the node name
40
+ # in the `@nodes` attribute.
41
+ def method_node(name)
42
+ define_method("parse_#{name}".to_sym) {}
43
+ register(name, false)
44
+ end
45
+
46
+ # Defines a method on your class returning a parsed value
47
+ def node(name, type, options = {}, &block)
48
+ attr_reader name.to_sym
49
+ options[:local_context] = @local_context
50
+ xpath = NodeXPath.new(name, options).xpath
51
+ define_method("parse_#{name}".to_sym) do
52
+ evaluate_args = [xpath, AwesomeXML::Type.for(type, self.class.name), options]
53
+ instance_variable_set(
54
+ "@#{name}",
55
+ evaluate_nodes(*evaluate_args, &block)
56
+ )
57
+ end
58
+ register(name, options[:private])
59
+ end
60
+
61
+ # Returns an array of symbols containing all method names defined by node builder methods
62
+ # in your class.
63
+ def nodes
64
+ @nodes ||= []
65
+ end
66
+
67
+ # Returns an array of symbols containing all method names defined by node builder methods
68
+ # in your class. Does not list nodes built with option `:private`.
69
+ def public_nodes
70
+ @public_nodes ||= []
71
+ end
72
+
73
+ private
74
+
75
+ def register(node_name, privateness)
76
+ @nodes ||= []
77
+ @nodes << node_name.to_sym
78
+ @public_nodes ||= []
79
+ @public_nodes << node_name.to_sym unless privateness
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A class that lets you parse a string according to the rules of a
4
+ # specified duration format chunk.
5
+ module AwesomeXML
6
+ class Duration
7
+ class ChunkParser
8
+ attr_reader :duration_string_chunk, :format_chunk, :duration
9
+ private :duration_string_chunk, :format_chunk
10
+
11
+ # Parses a string given as `duration_string_chunk` according to the rules of the passed in
12
+ # `format_chunk`. The latter being either a `AwesomeXML::Duration::Format::StaticChunk`
13
+ # or a `AwesomeXML::Duration::Format::DynamicChunk`. Saves the resulting duration
14
+ # in the attribute `duration`.
15
+ def initialize(duration_string_chunk, format_chunk)
16
+ @duration_string_chunk = duration_string_chunk
17
+ @format_chunk = format_chunk
18
+ parse
19
+ end
20
+
21
+ private
22
+
23
+ def parse
24
+ if format_chunk.dynamic?
25
+ @duration = number.public_send(format_chunk.unit)
26
+ else
27
+ fail format_mismatch unless duration_string_chunk == format_chunk.to_s
28
+ @duration = 0.seconds
29
+ end
30
+ end
31
+
32
+ def number
33
+ fail format_mismatch unless valid_number?
34
+ duration_string_chunk.to_i
35
+ end
36
+
37
+ def valid_number?
38
+ duration_string_chunk =~ /^[0-9]*$/
39
+ end
40
+
41
+ def format_mismatch
42
+ FormatMismatch.new(duration_string_chunk, format_chunk.to_s)
43
+ end
44
+
45
+ class FormatMismatch < StandardError
46
+ def initialize(timestamp, format_string)
47
+ super("Duration string '#{timestamp}' does not conform to given format '#{format_string}'.")
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A class that holds an array of characters and represents a dynamic
4
+ # component of a format string. Has a `unit`, a `parse_length` or a `delimiter`.
5
+ # See `AwesomeXML::Duration::Format` for more info.
6
+ module AwesomeXML
7
+ class Duration
8
+ class Format
9
+ class DynamicChunk
10
+ UNITS = { 'D' => :days, 'H' => :hours, 'M' => :minutes, 'S' => :seconds}
11
+
12
+ attr_accessor :format_chars, :delimiter
13
+
14
+ def initialize
15
+ @format_chars = []
16
+ end
17
+
18
+ # Returns the defining characters joint into a string.
19
+ def to_s
20
+ [format_chars, delimiter].join
21
+ end
22
+
23
+ # Counterpart of the same method of `AwesomeXML::Duration::Format::DynamicChunk`.
24
+ # Used to differentiate between instances of these two classes.
25
+ def dynamic?
26
+ true
27
+ end
28
+
29
+ # Takes the first character of `format_chars` and interprets as a duration unit.
30
+ def unit
31
+ fail InvalidDurationUnit.new(parsed_unit) unless valid_unit?
32
+ @unit ||= UNITS[parsed_unit]
33
+ end
34
+
35
+ # Takes the characters following the first character of `format_chars` and interprets
36
+ # them as an integer representing the number of characters to parse when given to the
37
+ # `AweseomXML::Duration::ChunkParser` together with a piece of duration string.
38
+ # When the `format_chars` only contain a single character, this will be 0.
39
+ def parse_length
40
+ fail InvalidParseLength.new(parsed_parse_length) unless valid_parse_length?
41
+ @parse_length ||= parsed_parse_length.to_i
42
+ end
43
+
44
+ private
45
+
46
+ def valid_unit?
47
+ %w(D H M S).include?(parsed_unit)
48
+ end
49
+
50
+ def parsed_unit
51
+ format_chars[0].upcase
52
+ end
53
+
54
+ def valid_parse_length?
55
+ parsed_parse_length =~ /^[0-9]*$/ || parsed_parse_length.nil?
56
+ end
57
+
58
+ def parsed_parse_length
59
+ @parsed_parse_length ||= format_chars.drop(1).join
60
+ end
61
+
62
+ class InvalidDurationUnit < StandardError
63
+ def initialize(parsed_unit)
64
+ super("Parsed unknown duration unit: '#{parsed_unit}'. Please choose from [D, H, M, S].")
65
+ end
66
+ end
67
+
68
+ class InvalidParseLength < StandardError
69
+ def initialize(parsed_parse_length)
70
+ super("Couldn't parse '#{parsed_parse_length}' into an integer.")
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A class that holds an array of characters and represents a static
4
+ # component of a format string. See `AwesomeXML::Duration::Format` for more info.
5
+ module AwesomeXML
6
+ class Duration
7
+ class Format
8
+ class StaticChunk
9
+ attr_accessor :format_chars
10
+
11
+ def initialize
12
+ @format_chars = []
13
+ end
14
+
15
+ # Returns the defining characters joint into a string.
16
+ def to_s
17
+ format_chars.join
18
+ end
19
+
20
+ # Returns the number of the defining characters.
21
+ def parse_length
22
+ format_chars.length
23
+ end
24
+
25
+ # Counterpart of the same method of `AwesomeXML::Duration::Format::DynamicChunk`.
26
+ # Used to differentiate between instances of these two classes.
27
+ def dynamic?
28
+ false
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A representation of a user defined duration format.
4
+ module AwesomeXML
5
+ class Duration
6
+ class Format
7
+ attr_reader :format_string, :chunks
8
+ private :format_string
9
+
10
+ # Returns an `AwesomeXML::Duration::Format` instance representing a user defined
11
+ # duration format specified by the passed in `format_string`. Splits the format
12
+ # string into chunks that are each one of either `AwesomeXML::Duration::Format::StaticChunk`
13
+ # or `AwesomeXML::Duration::Format::DynamicChunk` and saves them in the attribute `chunks`.
14
+ # The latter class mentioned is used for a section of the format string inside curly
15
+ # brackets. For more information about the syntax of the format string, read the README.
16
+ def initialize(format_string)
17
+ @format_string = format_string
18
+ @chunks = []
19
+ compile
20
+ end
21
+
22
+ private
23
+
24
+ def compile
25
+ format_string.chars.each(&method(:process))
26
+ @chunks = chunks&.compact
27
+ end
28
+
29
+ def process(character)
30
+ case character
31
+ when '{'
32
+ chunks.append(DynamicChunk.new)
33
+ when '}'
34
+ chunks.append(nil)
35
+ else
36
+ if chunks.last.nil?
37
+ if chunks[-2].is_a?(DynamicChunk)
38
+ chunks[-2].delimiter = character
39
+ end
40
+ chunks.tap(&:pop).append(StaticChunk.new)
41
+ end
42
+
43
+ chunks.last.format_chars.append(character)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This module is shared by all native type classes of `AwesomeXML`. It defines the
4
+ # interface for types that the `AwesomeXML::NodeEvaluator` expects.
5
+ module AwesomeXML
6
+ module NativeType
7
+ attr_reader :string, :options
8
+ private :string, :options
9
+
10
+ # Native type instances are initialized with a `Nokogiri::XML` object and an options hash.
11
+ def initialize(node, options = {})
12
+ @string = node&.text
13
+ @options = options
14
+ end
15
+
16
+ # This method returns the parsed value of the given node (obtained by calling `#text` on it) according
17
+ # to the implementation of the private method `#parse_value` defined in every native type class.
18
+ def evaluate
19
+ @value ||= with_defaults { parse_value }
20
+ end
21
+
22
+ private
23
+
24
+ def with_defaults(&block)
25
+ return options[:default] if string.nil?
26
+ return options[:default_empty] if options.has_key?(:default_empty) && string.empty?
27
+ return default_empty if string.empty?
28
+ yield
29
+ end
30
+
31
+ def default_empty
32
+ nil
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This class is responsible for parsing a specific value from an XML document given all the
4
+ # necessary information.
5
+ module AwesomeXML
6
+ class NodeEvaluator
7
+ attr_reader :xml, :xpath, :type_class, :options
8
+ private :xml, :xpath, :type_class, :options
9
+
10
+ # Initialize an instance of this class with a Nokogiri::XML object, a string representing
11
+ # an XPath to the value(s) you want to parse, a type class (see `AwesomeXML::Type` for more
12
+ # info), and an options hash.
13
+ def initialize(xml, xpath, type_class, options)
14
+ @xml = xml
15
+ @xpath = xpath
16
+ @type_class = type_class
17
+ @options = options
18
+ end
19
+
20
+ # Parses one or several nodes, depending on the `options[:array]` setting, according to the
21
+ # type passed in in the form of a class that handles the conversion.
22
+ def call
23
+ if options[:array]
24
+ all_nodes.map { |node| type_class.new(node, options).evaluate }
25
+ else
26
+ type_class.new(first_node, options).evaluate
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def all_nodes
33
+ xml_in_context&.xpath(xpath)
34
+ end
35
+
36
+ def first_node
37
+ xml_in_context&.at_xpath(xpath)
38
+ end
39
+
40
+ def xml_in_context
41
+ options[:local_context] ? xml&.xpath(options[:local_context]) : xml
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This class's responsibility is to build an XPath from specified options.
4
+ module AwesomeXML
5
+ class NodeXPath
6
+ attr_reader :node_name, :specific_xpath, :element_option, :attribute_option, :self_option, :array
7
+ private :node_name, :specific_xpath, :element_option, :attribute_option, :self_option, :array
8
+
9
+ # Initialize this class by providing the name of the `AwesomeXML` node and an options hash.
10
+ # For more information on how the options work, please refer to the README.
11
+ def initialize(node_name, options)
12
+ @node_name = node_name
13
+ @specific_xpath = options[:xpath]
14
+ @element_option = options[:element]
15
+ @attribute_option = options[:attribute]
16
+ @self_option = options[:self]
17
+ @look_for = options[:look_for]
18
+ @array = options[:array]
19
+ end
20
+
21
+ # Returns a String representing an XPath built from the options passed in at initialization time.
22
+ def xpath
23
+ specific_xpath || xpath_by_tag_type
24
+ end
25
+
26
+ private
27
+
28
+ def xpath_by_tag_type
29
+ if attribute_option
30
+ "./@#{tag_name(attribute_option)}"
31
+ elsif self_option
32
+ "."
33
+ else
34
+ "./#{tag_name(element_option)}"
35
+ end
36
+ end
37
+
38
+ def node_name_singular
39
+ array ? node_name.to_s.singularize.to_sym : node_name
40
+ end
41
+
42
+ def tag_name(option)
43
+ (option if option != true) || node_name_singular
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This class converts types passed in as arguments to `.node` method calls to a
4
+ # type class. Either native or user-defined.
5
+ module AwesomeXML
6
+ module Type
7
+ NATIVE_TYPE_CLASSES = {
8
+ text: AwesomeXML::Text,
9
+ integer: AwesomeXML::Integer,
10
+ float: AwesomeXML::Float,
11
+ duration: AwesomeXML::Duration,
12
+ date_time: AwesomeXML::DateTime
13
+ }.freeze
14
+
15
+ # Takes a type (Symbol, String or Class) passed in from a `.node` method call and the
16
+ # name of the class it was called in. The latter is needed to correctly assign the namespace
17
+ # if the type is given in String form. Returns a class, either one of the native `AwesomeXML`
18
+ # types or a user-defined class. Raises an exception if `type` is given as a Symbol, but
19
+ # does not represent one of the native types.
20
+ def self.for(type, class_name)
21
+ case type
22
+ when Symbol
23
+ NATIVE_TYPE_CLASSES[type] || fail(UnknownNodeType.new(type))
24
+ when String
25
+ [class_name, type].join('::').constantize
26
+ when Class
27
+ type
28
+ end
29
+ end
30
+
31
+ # An excception of this type is raised if `type` is given as a Symbol to `.for`, but does not
32
+ # represent one of the native `AwesomeXML` types.
33
+ class UnknownNodeType < StandardError
34
+ def initialize(type)
35
+ super("Cannot create node with unknown node type '#{type}'.")
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A class that knows how to parse a point in time given a timestamp and a format string.
4
+ module AwesomeXML
5
+ class DateTime
6
+ include AwesomeXML::NativeType
7
+
8
+ private
9
+
10
+ def parse_value
11
+ fail NoFormatProvided if options[:format].nil?
12
+ ::DateTime.strptime(string, options[:format])
13
+ end
14
+
15
+ class NoFormatProvided < StandardError
16
+ def initialize
17
+ super('Please provide a format option to date_time nodes.')
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A class that knows how to parse a duration given a duration string and a format string.
4
+ module AwesomeXML
5
+ class Duration
6
+ include AwesomeXML::NativeType
7
+
8
+ private
9
+
10
+ def parse_value
11
+ fail NoFormatProvided if options[:format].nil?
12
+ string_chunks.zip(format_chunks).map do |string_chunk, format_chunk|
13
+ AwesomeXML::Duration::ChunkParser.new(string_chunk, format_chunk).duration
14
+ end.reduce(:+) || 0.seconds
15
+ end
16
+
17
+ def string_chunks
18
+ @string_chunks ||= chunk_string
19
+ end
20
+
21
+ def chunk_string
22
+ result = []
23
+ format_chunks.reduce(string.chars) do |chopped_string, format_chunk|
24
+ if format_chunk.parse_length.zero?
25
+ parse_length = chopped_string.find_index(format_chunk.delimiter) || chopped_string.length
26
+ else
27
+ parse_length = format_chunk.parse_length
28
+ end
29
+ result.append(chopped_string.first(parse_length).join)
30
+ chopped_string.drop(parse_length)
31
+ end
32
+ result
33
+ end
34
+
35
+ def format_chunks
36
+ @format_chunks ||= AwesomeXML::Duration::Format.new(options[:format]).chunks
37
+ end
38
+
39
+ def split_at_character(string, character)
40
+ return string unless string.chars.include?(character)
41
+ split_after(string, string.chars.find_index(character) - 1)
42
+ end
43
+
44
+ def split_after(string, after_position)
45
+ [string.chars.first(after_position), string.chars.drop(after_position)].map(&:join)
46
+ end
47
+
48
+ class NoFormatProvided < StandardError
49
+ def initialize
50
+ super('Please provide a format option to duration nodes.')
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A class that knows how to parse a Float from a String.
4
+ module AwesomeXML
5
+ class Float
6
+ include AwesomeXML::NativeType
7
+
8
+ private
9
+
10
+ def parse_value
11
+ string.to_f
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A class that knows how to parse an Integer from a String.
4
+ module AwesomeXML
5
+ class Integer
6
+ include AwesomeXML::NativeType
7
+
8
+ private
9
+
10
+ def parse_value
11
+ string.to_i
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A class that knows how to parse a String from a String. Shockingly, it doesn't do much.
4
+ module AwesomeXML
5
+ class Text
6
+ include AwesomeXML::NativeType
7
+
8
+ private
9
+
10
+ def parse_value
11
+ string
12
+ end
13
+
14
+ def default_empty
15
+ ''
16
+ end
17
+ end
18
+ end
metadata ADDED
@@ -0,0 +1,147 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: awesome_xml
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Felix Lublasser
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-06-01 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.12'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.12'
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
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.41'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.41'
69
+ - !ruby/object:Gem::Dependency
70
+ name: activesupport
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '4.2'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '4.2'
83
+ - !ruby/object:Gem::Dependency
84
+ name: nokogiri
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.3'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.3'
97
+ description: Have XML data that you want to bend to your willand conform to your schema?
98
+ This gem is for you.
99
+ email:
100
+ - felix.lublasser@fromatob.com
101
+ executables: []
102
+ extensions: []
103
+ extra_rdoc_files: []
104
+ files:
105
+ - Gemfile
106
+ - LICENSE
107
+ - README.md
108
+ - Rakefile
109
+ - lib/awesome_xml/class_methods.rb
110
+ - lib/awesome_xml/duration/chunk_parser.rb
111
+ - lib/awesome_xml/duration/format.rb
112
+ - lib/awesome_xml/duration/format/dynamic_chunk.rb
113
+ - lib/awesome_xml/duration/format/static_chunk.rb
114
+ - lib/awesome_xml/native_type.rb
115
+ - lib/awesome_xml/node_evaluator.rb
116
+ - lib/awesome_xml/node_xpath.rb
117
+ - lib/awesome_xml/type.rb
118
+ - lib/awesome_xml/types/date_time.rb
119
+ - lib/awesome_xml/types/duration.rb
120
+ - lib/awesome_xml/types/float.rb
121
+ - lib/awesome_xml/types/integer.rb
122
+ - lib/awesome_xml/types/text.rb
123
+ homepage: https://github.com/fromAtoB/awesome_xml
124
+ licenses:
125
+ - MIT
126
+ metadata: {}
127
+ post_install_message:
128
+ rdoc_options: []
129
+ require_paths:
130
+ - lib
131
+ required_ruby_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: '0'
136
+ required_rubygems_version: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ version: '0'
141
+ requirements: []
142
+ rubyforge_project:
143
+ rubygems_version: 2.5.2
144
+ signing_key:
145
+ specification_version: 4
146
+ summary: Parse data from XML documents into arbitrary ruby hashes.
147
+ test_files: []