tlaw 0.0.1

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.
@@ -0,0 +1,67 @@
1
+ require 'open-uri'
2
+ require 'json'
3
+ require 'addressable'
4
+
5
+ # Let no one know! But they in Ruby committee just too long to add
6
+ # something like this to the language.
7
+ #
8
+ # See also https://bugs.ruby-lang.org/issues/12760
9
+ #
10
+ # @private
11
+ class Object
12
+ def derp
13
+ yield self
14
+ end
15
+ end
16
+
17
+ # TLAW is a framework for creating API wrappers for get-only APIs (like
18
+ # weather, geonames and so on) or subsets of APIs (like getting data from
19
+ # Twitter).
20
+ #
21
+ # Short example:
22
+ #
23
+ # ```ruby
24
+ # # Definition:
25
+ # class OpenWeatherMap < TLAW::API
26
+ # param :appid, required: true
27
+ #
28
+ # namespace :current, '/weather' do
29
+ # endpoint :city, '?q={city}{,country_code}' do
30
+ # param :city, required: true
31
+ # end
32
+ # end
33
+ # end
34
+ #
35
+ # # Usage:
36
+ # api = OpenWeatherMap.new(appid: '<yourappid>')
37
+ # api.current.weather('Kharkiv')
38
+ # # => {"weather.main"=>"Clear",
39
+ # # "weather.description"=>"clear sky",
40
+ # # "main.temp"=>8,
41
+ # # "main.pressure"=>1016,
42
+ # # "main.humidity"=>81,
43
+ # # "dt"=>2016-09-19 08:30:00 +0300,
44
+ # # ...}
45
+ #
46
+ # ```
47
+ #
48
+ # Refer to [README](./file/README.md) for reasoning about why you need it and links to
49
+ # more detailed demos, or start reading YARD docs from {API} and {DSL}
50
+ # modules.
51
+ module TLAW
52
+ end
53
+
54
+ require_relative 'tlaw/util'
55
+ require_relative 'tlaw/data_table'
56
+
57
+ require_relative 'tlaw/param'
58
+ require_relative 'tlaw/param_set'
59
+
60
+ require_relative 'tlaw/api_path'
61
+ require_relative 'tlaw/endpoint'
62
+ require_relative 'tlaw/namespace'
63
+ require_relative 'tlaw/api'
64
+
65
+ require_relative 'tlaw/response_processor'
66
+
67
+ require_relative 'tlaw/dsl'
@@ -0,0 +1,58 @@
1
+ module TLAW
2
+ # API is just a top-level {Namespace}.
3
+ #
4
+ # Basically, you start creating your endpoint by descending from API
5
+ # and defining namespaces and endpoints through a {DSL} like this:
6
+ #
7
+ # ```ruby
8
+ # class MyCoolAPI < TLAW::API
9
+ # define do
10
+ # base 'http://api.mycool.com'
11
+ #
12
+ # namespace :awesome do
13
+ # # ...and so on
14
+ # end
15
+ # end
16
+ # end
17
+ # ```
18
+ #
19
+ # And then, you use it:
20
+ #
21
+ # ```ruby
22
+ # api = MyCoolAPI.new
23
+ # api.awesome.cool(param: 'value')
24
+ # ```
25
+ #
26
+ # See {DSL} for explanation of API definition, {Namespace} for explanation
27
+ # of possible usages and {Endpoint} for real calls performing.
28
+ #
29
+ class API < Namespace
30
+ # Thrown when there are an error during call. Contains real URL which
31
+ # was called at the time of an error.
32
+ class Error < RuntimeError
33
+ end
34
+
35
+ class << self
36
+ # Runs the {DSL} inside your API wrapper class.
37
+ def define(&block)
38
+ DSL::APIWrapper.new(self).define(&block)
39
+ end
40
+
41
+ # Returns detailed description of an API, like this:
42
+ #
43
+ # ```ruby
44
+ # MyCoolAPI.describe
45
+ # # MyCoolAPI.new()
46
+ # # This is cool API.
47
+ # #
48
+ # # Namespaces:
49
+ # # .awesome()
50
+ # # This is awesome.
51
+ # ```
52
+ #
53
+ def describe(*)
54
+ super.sub(/\A./, '')
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,137 @@
1
+ require 'forwardable'
2
+
3
+ module TLAW
4
+ # Base class for all API pathes: entire API, namespaces and endpoints.
5
+ # Allows to define params and post-processors on any level.
6
+ #
7
+ class APIPath
8
+ class << self
9
+ # @private
10
+ attr_accessor :base_url, :path, :xml, :docs_link
11
+
12
+ # @private
13
+ def symbol
14
+ # FIXME: the second part is necessary only for describes,
15
+ # and probably should not be here.
16
+ @symbol || (name && "#{name}.new")
17
+ end
18
+
19
+ # @private
20
+ CLASS_NAMES = {
21
+ :[] => 'Element'
22
+ }.freeze
23
+
24
+ # @private
25
+ def class_name
26
+ # TODO:
27
+ # * validate if it is classifiable
28
+ # * provide additional option for non-default class name
29
+ CLASS_NAMES[symbol] || Util.camelize(symbol.to_s)
30
+ end
31
+
32
+ # @private
33
+ def description=(descr)
34
+ @description = Util::Description.new(descr)
35
+ end
36
+
37
+ # @private
38
+ def description
39
+ return unless @description || @docs_link
40
+
41
+ Util::Description.new(
42
+ [@description, ("Docs: #{@docs_link}" if @docs_link)]
43
+ .compact.join("\n\n")
44
+ )
45
+ end
46
+
47
+ # @private
48
+ def inherit(namespace, **attrs)
49
+ Class.new(self).tap do |subclass|
50
+ attrs.each { |a, v| subclass.send("#{a}=", v) }
51
+ namespace.const_set(subclass.class_name, subclass)
52
+ end
53
+ end
54
+
55
+ # @private
56
+ def params_from_path!
57
+ Addressable::Template.new(path).keys.each do |key|
58
+ param_set.add key.to_sym, keyword: false
59
+ end
60
+ end
61
+
62
+ # @private
63
+ def setup_parents(parent)
64
+ param_set.parent = parent.param_set
65
+ response_processor.parent = parent.response_processor
66
+ end
67
+
68
+ # @private
69
+ def symbol=(sym)
70
+ @symbol = sym
71
+ @path ||= "/#{sym}"
72
+ end
73
+
74
+ # @return [ParamSet]
75
+ def param_set
76
+ @param_set ||= ParamSet.new
77
+ end
78
+
79
+ # @private
80
+ def response_processor
81
+ @response_processor ||= ResponseProcessor.new
82
+ end
83
+
84
+ # @private
85
+ def to_method_definition
86
+ "#{symbol}(#{param_set.to_code})"
87
+ end
88
+
89
+ # Redefined on descendants, it just allows you to do `api.namespace.describe`
90
+ # or `api.namespace1.namespace2.endpoints[:my_endpoint].describe`
91
+ # and have reasonable useful description printed.
92
+ #
93
+ # @return [Util::Description] It is just description string but with
94
+ # redefined `#inspect` to be pretty-printed in console.
95
+ def describe(definition = nil)
96
+ Util::Description.new(
97
+ ".#{definition || to_method_definition}" +
98
+ (description ? "\n" + description.indent(' ') + "\n" : '') +
99
+ (param_set.empty? ? '' : "\n" + param_set.describe.indent(' '))
100
+ )
101
+ end
102
+
103
+ # @private
104
+ def describe_short
105
+ Util::Description.new(
106
+ ".#{to_method_definition}" +
107
+ (description ? "\n" + description_first_para.indent(' ') : '')
108
+ )
109
+ end
110
+
111
+ # @private
112
+ def define_method_on(host)
113
+ file, line = method(:to_code).source_location
114
+ # line + 1 is where real definition, theoretically, starts
115
+ host.module_eval(to_code, file, line + 1)
116
+ end
117
+
118
+ private
119
+
120
+ def description_first_para
121
+ description.split("\n\n").first
122
+ end
123
+ end
124
+
125
+ extend Forwardable
126
+
127
+ def initialize(**parent_params)
128
+ @parent_params = parent_params
129
+ end
130
+
131
+ private
132
+
133
+ def object_class
134
+ self.class
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,116 @@
1
+ module TLAW
2
+ # Basically, just a 2-d array with column names. Or you can think of
3
+ # it as an array of hashes. Or loose DataFrame implementation.
4
+ #
5
+ # Just like this:
6
+ #
7
+ # ```ruby
8
+ # tbl = DataTable.new([
9
+ # {id: 1, name: 'Mike', salary: 1000},
10
+ # {id: 2, name: 'Doris', salary: 900},
11
+ # {id: 3, name: 'Angie', salary: 1200}
12
+ # ])
13
+ # # => #<TLAW::DataTable[id, name, salary] x 3>
14
+ # tbl.count
15
+ # # => 3
16
+ # tbl.keys
17
+ # # => ["id", "name", "salary"]
18
+ # tbl[0]
19
+ # # => {"id"=>1, "name"=>"Mike", "salary"=>1000}
20
+ # tbl['salary']
21
+ # # => [1000, 900, 1200]
22
+ # ```
23
+ #
24
+ # Basically, that's it. Every array of hashes in TLAW response will be
25
+ # converted into corresponding `DataTable`.
26
+ #
27
+ class DataTable < Array
28
+ def self.from_columns(column_names, columns)
29
+ from_rows(column_names, columns.transpose)
30
+ end
31
+
32
+ def self.from_rows(column_names, rows)
33
+ new rows.map { |r| column_names.zip(r).to_h }
34
+ end
35
+
36
+ # Creates DataTable from array of hashes.
37
+ #
38
+ # Note, that all hash keys are stringified, and all hashes are expanded
39
+ # to have same set of keys.
40
+ #
41
+ # @param hashes [Array<Hash>]
42
+ def initialize(hashes)
43
+ hashes = hashes.each_with_index.map { |h, i|
44
+ h.is_a?(Hash) or
45
+ fail ArgumentError,
46
+ "All rows are expected to be hashes, row #{i} is #{h.class}"
47
+
48
+ h.map { |k, v| [k.to_s, v] }.to_h
49
+ }
50
+ empty = hashes.map(&:keys).flatten.uniq.map { |k| [k, nil] }.to_h
51
+ hashes = hashes.map { |h| empty.merge(h) }
52
+ super(hashes)
53
+ end
54
+
55
+ # All column names.
56
+ #
57
+ # @return [Array<String>]
58
+ def keys
59
+ empty? ? [] : first.keys
60
+ end
61
+
62
+ # Allows access to one column or row.
63
+ #
64
+ # @overload [](index)
65
+ # Returns one row from a DataTable.
66
+ #
67
+ # @param index [Integer] Row number
68
+ # @return [Hash] Row as a hash
69
+ #
70
+ # @overload [](column_name)
71
+ # Returns one column from a DataTable.
72
+ #
73
+ # @param column_name [String] Name of column
74
+ # @return [Array] Column as an array of all values in it
75
+ #
76
+ def [](index_or_column)
77
+ case index_or_column
78
+ when Integer
79
+ super
80
+ when String, Symbol
81
+ map { |h| h[index_or_column.to_s] }
82
+ else
83
+ fail ArgumentError,
84
+ 'Expected integer or string/symbol index' \
85
+ ", got #{index_or_column.class}"
86
+ end
87
+ end
88
+
89
+ # Slice of a DataTable with only specified columns left.
90
+ #
91
+ # @param names [Array<String>] What columns to leave in a DataTable
92
+ # @return [DataTable]
93
+ def columns(*names)
94
+ names.map!(&:to_s)
95
+ DataTable.new(map { |h| names.map { |n| [n, h[n]] }.to_h })
96
+ end
97
+
98
+ # Represents DataTable as a `column name => all values in columns`
99
+ # hash.
100
+ #
101
+ # @return [Hash{String => Array}]
102
+ def to_h
103
+ keys.map { |k| [k, map { |h| h[k] }] }.to_h
104
+ end
105
+
106
+ # @private
107
+ def inspect
108
+ "#<#{self.class.name}[#{keys.join(', ')}] x #{size}>"
109
+ end
110
+
111
+ # @private
112
+ def pretty_print(pp)
113
+ pp.text("#<#{self.class.name}[#{keys.join(', ')}] x #{size}>")
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,511 @@
1
+ module TLAW
2
+ # This module is core of a TLAW API definition. It works like this:
3
+ #
4
+ # ```ruby
5
+ # class MyAPI < TLAW::API
6
+ # define do # here starts what DSL does
7
+ # namespace :ns do
8
+ #
9
+ # endpoint :es do
10
+ # param :param1, Integer, default: 1
11
+ # end
12
+ # end
13
+ # end
14
+ # end
15
+ # ```
16
+ #
17
+ # Methods of current namespace documentation describe everything you
18
+ # can use inside `define` blocks. Actual structure of things is a bit
19
+ # more complicated (relate to lib/tlaw/dsl.rb if you wish), but current
20
+ # documentation structure considered to be most informative.
21
+ #
22
+ module DSL
23
+ # @!method base(url)
24
+ # Allows to set entire API base URL, all endpoints and namespaces
25
+ # pathes are calculated relative to it.
26
+ #
27
+ # **Works for:** API
28
+ #
29
+ # @param url [String]
30
+
31
+ # @!method desc(text)
32
+ # Allows to set description string for your API object. It can
33
+ # be multiline, and TLAW will automatically un-indent excessive
34
+ # indentations:
35
+ #
36
+ # ```ruby
37
+ # # ...several levels of indents while you create a definition
38
+ # desc %Q{
39
+ # This is some endpoint.
40
+ # And it works!
41
+ # }
42
+ #
43
+ # # ...but when you are using it...
44
+ # p my_api.endpoints[:endpoint].describe
45
+ # # This is some endpoint.
46
+ # # And it works!
47
+ # # ....
48
+ # ```
49
+ #
50
+ # **Works for:** API, namespace, endpoint
51
+ #
52
+ # @param text [String]
53
+
54
+ # @!method docs(link)
55
+ # Allows to add link to documentation as a separate line to
56
+ # object description. Just to be semantic :)
57
+ #
58
+ # ```ruby
59
+ # # you do something like
60
+ # desc "That's my endpoint"
61
+ #
62
+ # docs "http://docs.example.com/my/endpoint"
63
+ #
64
+ # # ...and then somewhere...
65
+ # p my_api.endpoints[:endpoint].describe
66
+ # # That is my endpoint.
67
+ # #
68
+ # # Docs: http://docs.example.com/my/endpoint
69
+ # # ....
70
+ # ```
71
+ #
72
+ # **Works for:** API, namespace, endpoint
73
+ #
74
+ # @param link [String]
75
+
76
+ # @!method param(name, type = nil, keyword: true, required: false, **opts)
77
+ # Defines parameter for current API (global), namespace or endpoint.
78
+ #
79
+ # Param defnition defines several things:
80
+ #
81
+ # * how method definition to call this namespace/endpoint would
82
+ # look like: whether the parameter is keyword or regular argument,
83
+ # whether it is required and what is default value otherwise;
84
+ # * how parameter is processed: converted and validated from passed
85
+ # value;
86
+ # * how param is sent to target API: how it will be called in
87
+ # the query string and formatted on call.
88
+ #
89
+ # Note also those things about params:
90
+ #
91
+ # * as described in {#namespace} and {#endpoint}, setting path template
92
+ # will implicitly set params. You can rewrite this on implicit
93
+ # param call, for ex:
94
+ #
95
+ # ```ruby
96
+ # endpoint :foo, '/foo/{bar}'
97
+ # # call-sequence would be foo(bar = nil)
98
+ #
99
+ # # But you can make it back keyword:
100
+ # endpoint :foo, '/foo/{bar}' do
101
+ # param :bar, keyword: true, default: 'test'
102
+ # end
103
+ # # call-sequence now is foo(bar: 'test')
104
+ #
105
+ # # Or make it strictly required
106
+ # endpoint :foo, '/foo/{bar}/{baz}' do
107
+ # param :bar, required: true
108
+ # param :baz, keyword: true, required: true
109
+ # end
110
+ # # call-sequence now is foo(bar, baz:)
111
+ # ```
112
+ #
113
+ # * param of outer namespace are passed to API on call from inner
114
+ # namespaces and endpoints, for ex:
115
+ #
116
+ # ```ruby
117
+ # namespace :city do
118
+ # param :city_name
119
+ #
120
+ # namespace :population do
121
+ # endpoint :by_year, '/year/{year}'
122
+ # end
123
+ # end
124
+ #
125
+ # # real call:
126
+ # api.city('London').population.by_year(2015)
127
+ # # Will get http://api.example.com/city/year/2015?city_name=London
128
+ # ```
129
+ #
130
+ # **Works for:** API, namespace, endpoint
131
+ #
132
+ # @param name [Symbol] Parameter name
133
+ # @param type [Class, Symbol] Expected parameter type. Could by
134
+ # some class (then parameter would be checked for being instance
135
+ # of this class or it would be `ArgumentError`), or duck type
136
+ # (method name that parameter value should respond to).
137
+ # @param keyword [true, false] Whether the param will go as a
138
+ # keyword param to method definition.
139
+ # @param required [true, false] Whether this param is required.
140
+ # It will be considered on method definition.
141
+ # @param opts [Hash] Options
142
+ # @option opts [Symbol] :field What the field would be called in
143
+ # API query string (it would be `name` by default).
144
+ # @option opts [#to_proc] :format How to format this option before
145
+ # including into URL. By default, it is just `.to_s`.
146
+ # @option opts [String] :desc Param description. You could do it
147
+ # multiline and with indents, like {#desc}.
148
+ # @option opts :default Default value for this param. Would be
149
+ # rendered in method definition and then passed to target API
150
+ # _(TODO: in future, there also would be "invisible" params,
151
+ # that are just passed to target, always the same, as well as
152
+ # params that aren't passed at all if user gave default value.)_
153
+ # @option opts [Hash, Array] :enum Whether parameter only accepts
154
+ # enumerated values. Two forms are accepted:
155
+ #
156
+ # ```ruby
157
+ # # array form
158
+ # param :units, enum: %i[us metric britain]
159
+ # # parameter accepts only :us, :metric, :britain values, and
160
+ # # passes them to target API as is
161
+ #
162
+ # # hash "accepted => passed" form
163
+ # param :compact, enum: {true => 'gzip', false => nil}
164
+ # # parameter accepts true or false, on true passes "compact=gzip",
165
+ # # on false passes nothing.
166
+ # ```
167
+
168
+ # @!method namespace(name, path = nil, &block)
169
+ # Defines new namespace or updates existing one.
170
+ #
171
+ # {Namespace} has two roles:
172
+ #
173
+ # * on Ruby API, defines how you access to the final endpoint,
174
+ # like `api.namespace1.namespace2(some_param).endpoint(...)`
175
+ # * on calling API, it adds its path to entire URL.
176
+ #
177
+ # **NB:** If you call `namespace(:something)` and it was already defined,
178
+ # current definition will be added to existing one (but it can't
179
+ # change path of existing one, which is reasonable).
180
+ #
181
+ # **Works for:** API, namespace
182
+ #
183
+ # @param name [Symbol] Name of the method by which namespace would
184
+ # be accessible.
185
+ # @param path [String] Path to add to API inside this namespace.
186
+ # When not provided, considered to be `/<name>`. When provided,
187
+ # taken literally (no slashes or other symbols added). Note, that
188
+ # you can use `/../` in path, redesigning someone else's APIs on
189
+ # the fly. Also, you can use [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570.txt)
190
+ # URL templates to mark params going straightly into URI.
191
+ #
192
+ # Some examples:
193
+ #
194
+ # ```ruby
195
+ # # assuming API base url is http://api.example.com
196
+ #
197
+ # namespace :foo
198
+ # # method would be foo(), API URL would be http://api.example.com/foo
199
+ #
200
+ # namespace :bar, '/foo/bar'
201
+ # # metod would be bar(), API URL http://api.example.com/foo/bar
202
+ #
203
+ # namespace :baz, ''
204
+ # # method baz(), API URL same as base: useful for gathering into
205
+ # # quazi-namespace from several unrelated endpoints.
206
+ #
207
+ # namespace :quux, '/foo/quux/{id}'
208
+ # # method quux(id = nil), API URL http://api.example.com/foo/quux/123
209
+ # # ...where 123 is what you've passed as id
210
+ # ```
211
+ # @param block Definition of current namespace params, and
212
+ # namespaces and endpoints inside current.
213
+ # Note that by defining params inside this block, you can change
214
+ # namespace's method call sequence.
215
+ #
216
+ # For example:
217
+ #
218
+ # ```ruby
219
+ # namespace :foo
220
+ # # call-sequence: foo()
221
+ #
222
+ # namespace :foo do
223
+ # param :bar
224
+ # end
225
+ # # call-sequence: foo(bar: nil)
226
+ #
227
+ # namespace :foo do
228
+ # param :bar, required: true, keyword: false
229
+ # param :baz, required: true
230
+ # end
231
+ # # call-sequence: foo(bar, baz:)
232
+ # ```
233
+ #
234
+ # ...and so on. See also {#param} for understanding what you
235
+ # can change here.
236
+ #
237
+
238
+ # @!method endpoint(name, path = nil, **opts, &block)
239
+ # Defines new endpoint or updates existing one.
240
+ #
241
+ # {Endpoint} is the thing doing the real work: providing Ruby API
242
+ # method to really call target API.
243
+ #
244
+ # **NB:** If you call `endpoint(:something)` and it was already defined,
245
+ # current definition will be added to existing one (but it can't
246
+ # change path of existing one, which is reasonable).
247
+ #
248
+ # **Works for:** API, namespace
249
+ #
250
+ # @param name [Symbol] Name of the method by which endpoint would
251
+ # be accessible.
252
+ # @param path [String] Path to call API from this endpoint.
253
+ # When not provided, considered to be `/<name>`. When provided,
254
+ # taken literally (no slashes or other symbols added). Note, that
255
+ # you can use `/../` in path, redesigning someone else's APIs on
256
+ # the fly. Also, you can use [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570.txt)
257
+ # URL templates to mark params going straightly into URI.
258
+ #
259
+ # Look at {#namespace} for examples, idea is the same.
260
+ #
261
+ # @param opts [Hash] Some options, currently only `:xml`.
262
+ # @option opts [true, false] :xml Whether endpoint's response should
263
+ # be parsed as XML (JSON otherwise & by default). Parsing in this
264
+ # case is performed with [crack](https://github.com/jnunemaker/crack),
265
+ # producing the hash, to which all other rules of post-processing
266
+ # are applied.
267
+ # @param block Definition of endpoint's params and docs.
268
+ # Note that by defining params inside this block, you can change
269
+ # endpoints's method call sequence.
270
+ #
271
+ # For example:
272
+ #
273
+ # ```ruby
274
+ # endpoint :foo
275
+ # # call-sequence: foo()
276
+ #
277
+ # endpoint :foo do
278
+ # param :bar
279
+ # end
280
+ # # call-sequence: foo(bar: nil)
281
+ #
282
+ # endpoint :foo do
283
+ # param :bar, required: true, keyword: false
284
+ # param :baz, required: true
285
+ # end
286
+ # # call-sequence: foo(bar, baz:)
287
+ # ```
288
+ #
289
+ # ...and so on. See also {#param} for understanding what you
290
+ # can change here.
291
+
292
+ # @!method post_process(key = nil, &block)
293
+ # Sets post-processors for response.
294
+ #
295
+ # There are also {#post_process_replace} (for replacing entire
296
+ # response with something else) and {#post_process_items} (for
297
+ # post-processing each item of sub-array).
298
+ #
299
+ # Notes:
300
+ #
301
+ # * you can set any number of post-processors of any kind, and they
302
+ # will be applied in exactly the same order they are set;
303
+ # * you can set post-processors in parent namespace (or for entire
304
+ # API), in this case post-processors of _outer_ namespace are
305
+ # always applied before inner ones. That allow you to define some
306
+ # generic parsing/rewriting on API level, then more specific
307
+ # key postprocessors on endpoints;
308
+ # * hashes are flattened again after _each_ post-processor, so if
309
+ # for some `key` you'll return `{count: 1, continue: false}`,
310
+ # response hash will immediately have
311
+ # `{"key.count" => 1, "key.continue" => false}`.
312
+ #
313
+ # @overload post_process(&block)
314
+ # Sets post-processor for whole response. Note, that in this case
315
+ # _return_ value of block is ignored, it is expected that your
316
+ # block will receive response and modify it inplace, like this:
317
+ #
318
+ # ```ruby
319
+ # post_process do |response|
320
+ # response['coord'] = Geo::Coord.new(response['lat'], response['lng'])
321
+ # end
322
+ # ```
323
+ # If you need to replace entire response with something else,
324
+ # see {#post_process_replace}
325
+ #
326
+ # @overload post_process(key, &block)
327
+ # Sets post-processor for one response key. Post-processor is
328
+ # called only if key exists in the response, and value by this
329
+ # key is replaced with post-processor's response.
330
+ #
331
+ # Note, that if `block` returns `nil`, key will be removed completely.
332
+ #
333
+ # Usage:
334
+ #
335
+ # ```ruby
336
+ # post_process('date') { |val| Date.parse(val) }
337
+ # # or, btw, just
338
+ # post_process('date', &Date.method(:parse))
339
+ # ```
340
+ #
341
+ # @param key [String]
342
+
343
+ # @!method post_process_items(key, &block)
344
+ # Sets post-processors for each items of array, being at `key` (if
345
+ # the key is present in response, and if its value is array of
346
+ # hashes).
347
+ #
348
+ # Inside `block` you can use {#post_process} method as described
349
+ # above (but all of its actions will be related only to current
350
+ # item of array).
351
+ #
352
+ # Example:
353
+ #
354
+ # Considering API response like:
355
+ #
356
+ # ```json
357
+ # {
358
+ # "meta": {"count": 100},
359
+ # "data": [
360
+ # {"timestamp": "2016-05-01", "value": "10", "dummy": "foo"},
361
+ # {"timestamp": "2016-05-02", "value": "13", "dummy": "bar"}
362
+ # ]
363
+ # }
364
+ # ```
365
+ # ...you can define postprocessing like this:
366
+ #
367
+ # ```ruby
368
+ # post_process_items 'data' do
369
+ # post_process 'timestamp', &Date.method(:parse)
370
+ # post_process 'value', &:to_i
371
+ # post_process('dummy'){nil} # will be removed
372
+ # end
373
+ # ```
374
+ #
375
+ # See also {#post_process} for some generic explanation of post-processing.
376
+ #
377
+ # @param key [String]
378
+
379
+ # @!method post_process_replace(&block)
380
+ # Just like {#post_process} for entire response, but _replaces_
381
+ # it with what block returns.
382
+ #
383
+ # Real-life usage: WorldBank API typically returns responses this
384
+ # way:
385
+ #
386
+ # ```json
387
+ # [
388
+ # {"count": 100, "page": 1},
389
+ # {"some_data_variable": [{}, {}, {}]}
390
+ # ]
391
+ # ```
392
+ # ...e.g. metadata and real response as two items in array, not
393
+ # two keys in hash. We can easily fix this:
394
+ #
395
+ # ```ruby
396
+ # post_process_replace do |response|
397
+ # {meta: response.first, data: response.last}
398
+ # end
399
+ # ```
400
+ #
401
+ # See also {#post_process} for some generic explanation of post-processing.
402
+
403
+ # @private
404
+ class BaseWrapper
405
+ def initialize(object)
406
+ @object = object
407
+ end
408
+
409
+ def define(&block)
410
+ instance_eval(&block)
411
+ end
412
+
413
+ def description(text)
414
+ # first, remove spaces at a beginning of each line
415
+ # then, remove empty lines before and after docs block
416
+ @object.description =
417
+ text
418
+ .gsub(/^[ \t]+/, '')
419
+ .gsub(/\A\n|\n\s*\Z/, '')
420
+ end
421
+
422
+ alias_method :desc, :description
423
+
424
+ def docs(link)
425
+ @object.docs_link = link
426
+ end
427
+
428
+ def param(name, type = nil, **opts)
429
+ @object.param_set.add(name, **opts.merge(type: type))
430
+ end
431
+
432
+ def post_process(key = nil, &block)
433
+ @object.response_processor.add_post_processor(key, &block)
434
+ end
435
+
436
+ def post_process_replace(&block)
437
+ @object.response_processor.add_replacer(&block)
438
+ end
439
+
440
+ class PostProcessProxy
441
+ def initialize(parent_key, parent)
442
+ @parent_key = parent_key
443
+ @parent = parent
444
+ end
445
+
446
+ def post_process(key = nil, &block)
447
+ @parent.add_item_post_processor(@parent_key, key, &block)
448
+ end
449
+ end
450
+
451
+ def post_process_items(key, &block)
452
+ PostProcessProxy
453
+ .new(key, @object.response_processor)
454
+ .instance_eval(&block)
455
+ end
456
+ end
457
+
458
+ # @private
459
+ class EndpointWrapper < BaseWrapper
460
+ end
461
+
462
+ # @private
463
+ class NamespaceWrapper < BaseWrapper
464
+ def endpoint(name, path = nil, **opts, &block)
465
+ update_existing(Endpoint, name, path, **opts, &block) ||
466
+ add_child(Endpoint, name, path: path || "/#{name}", **opts, &block)
467
+ end
468
+
469
+ def namespace(name, path = nil, &block)
470
+ update_existing(Namespace, name, path, &block) ||
471
+ add_child(Namespace, name, path: path || "/#{name}", &block)
472
+ end
473
+
474
+ private
475
+
476
+ WRAPPERS = {
477
+ Endpoint => EndpointWrapper,
478
+ Namespace => NamespaceWrapper
479
+ }.freeze
480
+
481
+ def update_existing(child_class, name, path, **opts, &block)
482
+ existing = @object.children[name] or return nil
483
+ existing < child_class or
484
+ fail ArgumentError, "#{name} is already defined as #{child_class == Endpoint ? 'namespace' : 'endpoint'}, you can't redefine it as #{child_class}"
485
+
486
+ !path && opts.empty? or
487
+ fail ArgumentError, "#{child_class} is already defined, you can't change its path or options"
488
+
489
+ WRAPPERS[child_class].new(existing).define(&block) if block
490
+ end
491
+
492
+ def add_child(child_class, name, **opts, &block)
493
+ @object.add_child(
494
+ child_class.inherit(@object, symbol: name, **opts)
495
+ .tap { |c| c.setup_parents(@object) }
496
+ .tap(&:params_from_path!)
497
+ .tap { |c|
498
+ WRAPPERS[child_class].new(c).define(&block) if block
499
+ }
500
+ )
501
+ end
502
+ end
503
+
504
+ # @private
505
+ class APIWrapper < NamespaceWrapper
506
+ def base(url)
507
+ @object.base_url = url
508
+ end
509
+ end
510
+ end
511
+ end