awesome_xml 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: 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: []