tlaw 0.0.2 → 0.1.0.pre

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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +18 -2
  4. data/README.md +10 -7
  5. data/examples/demo_base.rb +2 -2
  6. data/examples/experimental/README.md +3 -0
  7. data/examples/experimental/afterthedeadline.rb +22 -0
  8. data/examples/experimental/airvisual.rb +14 -0
  9. data/examples/experimental/apixu.rb +32 -0
  10. data/examples/experimental/bing_maps.rb +18 -0
  11. data/examples/experimental/currencylayer.rb +25 -0
  12. data/examples/experimental/earthquake.rb +29 -0
  13. data/examples/experimental/freegeoip.rb +16 -0
  14. data/examples/experimental/geonames.rb +98 -0
  15. data/examples/experimental/isfdb.rb +17 -0
  16. data/examples/experimental/musicbrainz.rb +27 -0
  17. data/examples/experimental/nominatim.rb +52 -0
  18. data/examples/experimental/omdb.rb +68 -0
  19. data/examples/experimental/open_exchange_rates.rb +36 -0
  20. data/examples/experimental/open_route.rb +27 -0
  21. data/examples/experimental/open_street_map.rb +16 -0
  22. data/examples/experimental/quandl.rb +50 -0
  23. data/examples/experimental/reddit.rb +25 -0
  24. data/examples/experimental/swapi.rb +27 -0
  25. data/examples/experimental/tmdb.rb +53 -0
  26. data/examples/experimental/world_bank.rb +85 -0
  27. data/examples/experimental/world_bank_climate.rb +77 -0
  28. data/examples/experimental/wunderground.rb +66 -0
  29. data/examples/experimental/wunderground_demo.rb +7 -0
  30. data/examples/forecast_io.rb +16 -16
  31. data/examples/giphy.rb +4 -4
  32. data/examples/giphy_demo.rb +1 -1
  33. data/examples/open_weather_map.rb +64 -60
  34. data/examples/open_weather_map_demo.rb +4 -4
  35. data/examples/tmdb_demo.rb +1 -1
  36. data/examples/urbandictionary_demo.rb +2 -2
  37. data/lib/tlaw.rb +14 -15
  38. data/lib/tlaw/api.rb +108 -26
  39. data/lib/tlaw/api_path.rb +86 -87
  40. data/lib/tlaw/data_table.rb +15 -10
  41. data/lib/tlaw/dsl.rb +126 -224
  42. data/lib/tlaw/dsl/api_builder.rb +47 -0
  43. data/lib/tlaw/dsl/base_builder.rb +108 -0
  44. data/lib/tlaw/dsl/endpoint_builder.rb +26 -0
  45. data/lib/tlaw/dsl/namespace_builder.rb +86 -0
  46. data/lib/tlaw/endpoint.rb +63 -85
  47. data/lib/tlaw/formatting.rb +55 -0
  48. data/lib/tlaw/formatting/describe.rb +86 -0
  49. data/lib/tlaw/formatting/inspect.rb +52 -0
  50. data/lib/tlaw/namespace.rb +141 -98
  51. data/lib/tlaw/param.rb +45 -141
  52. data/lib/tlaw/param/type.rb +36 -49
  53. data/lib/tlaw/response_processors.rb +81 -0
  54. data/lib/tlaw/util.rb +16 -33
  55. data/lib/tlaw/version.rb +6 -3
  56. data/tlaw.gemspec +9 -9
  57. metadata +63 -13
  58. data/lib/tlaw/param_set.rb +0 -111
  59. data/lib/tlaw/response_processor.rb +0 -126
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_builder'
4
+ require_relative 'endpoint_builder'
5
+ require_relative 'namespace_builder'
6
+
7
+ module TLAW
8
+ module DSL
9
+ # @private
10
+ class ApiBuilder < NamespaceBuilder
11
+ # @private
12
+ CLASS_NAMES = {
13
+ :[] => 'Element'
14
+ }.freeze
15
+
16
+ def initialize(api_class, &block)
17
+ @api_class = api_class
18
+ @definition = {}
19
+ # super(symbol: nil, children: api_class.children || [], &block)
20
+ super(symbol: nil, **api_class.definition, &block)
21
+ end
22
+
23
+ def finalize
24
+ @api_class.setup(**definition)
25
+ define_children_methods(@api_class)
26
+ constantize_children(@api_class)
27
+ end
28
+
29
+ def base(url)
30
+ @definition[:base_url] = url
31
+ end
32
+
33
+ private
34
+
35
+ def constantize_children(namespace)
36
+ return unless namespace.name&.match?(/^[A-Z]/) && namespace.respond_to?(:children)
37
+
38
+ namespace.children.each do |child|
39
+ class_name = CLASS_NAMES.fetch(child.symbol, Util.camelize(child.symbol.to_s))
40
+ # namespace.send(:remove_const, class_name) if namespace.const_defined?(class_name)
41
+ namespace.const_set(class_name, child) unless namespace.const_defined?(class_name)
42
+ constantize_children(child)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TLAW
4
+ module DSL
5
+ # @private
6
+ class BaseBuilder
7
+ attr_reader :params, :processors, :shared_definitions
8
+
9
+ def initialize(symbol:, path: nil, context: nil, xml: false, params: {}, **opts, &block)
10
+ path ||= "/#{symbol}" # Not default arg, because we need to process explicitly passed path: nil, too
11
+ @definition = opts.merge(symbol: symbol, path: path)
12
+ @params = params.merge(params_from_path(path))
13
+ @processors = (context&.processors || []).dup
14
+ @parser = parser(xml)
15
+ @shared_definitions = context&.shared_definitions || {}
16
+ instance_eval(&block) if block
17
+ end
18
+
19
+ def definition
20
+ @definition.merge(param_defs: params.map { |name, **opts| Param.new(name: name, **opts) })
21
+ end
22
+
23
+ def docs(link)
24
+ @definition[:docs_link] = link
25
+ self
26
+ end
27
+
28
+ def description(text)
29
+ @definition[:description] = Util.deindent(text)
30
+ self
31
+ end
32
+
33
+ alias desc description
34
+
35
+ def param(name, type = nil, enum: nil, desc: nil, description: desc, **opts)
36
+ opts = opts.merge(
37
+ type: type || enum&.yield_self(&method(:enum_type)),
38
+ description: description&.yield_self(&Util.method(:deindent))
39
+ ).compact
40
+ params.merge!(name => opts) { |_, o, n| o.merge(n) }
41
+ self
42
+ end
43
+
44
+ def shared_def(name, &block)
45
+ @shared_definitions[name] = block
46
+ self
47
+ end
48
+
49
+ def use_def(name)
50
+ shared_definitions
51
+ .fetch(name) { fail ArgumentError, "#{name.inspect} is not a shared definition" }
52
+ .tap { |block| instance_eval(&block) }
53
+ self
54
+ end
55
+
56
+ def finalize
57
+ fail NotImplementedError, "#{self.class} doesn't implement #finalize"
58
+ end
59
+
60
+ G = ResponseProcessors::Generators
61
+
62
+ def post_process(key_pattern = nil, &block)
63
+ @processors << (key_pattern ? G.transform_by_key(key_pattern, &block) : G.mutate(&block))
64
+ end
65
+
66
+ # @private
67
+ class PostProcessProxy
68
+ def initialize(owner, parent_key)
69
+ @owner = owner
70
+ @parent_key = parent_key
71
+ end
72
+
73
+ def post_process(key = nil, &block)
74
+ @owner.processors << G.transform_nested(@parent_key, key, &block)
75
+ end
76
+ end
77
+
78
+ def post_process_items(key_pattern, &block)
79
+ PostProcessProxy.new(self, key_pattern).instance_eval(&block)
80
+ end
81
+
82
+ def post_process_replace(&block)
83
+ @processors << block
84
+ end
85
+
86
+ private
87
+
88
+ def parser(xml)
89
+ xml ? Crack::XML.method(:parse) : JSON.method(:parse)
90
+ end
91
+
92
+ def enum_type(enum)
93
+ case enum
94
+ when Hash
95
+ enum
96
+ when Enumerable # well... in fact respond_to?(:each) probably will do
97
+ enum.map { |v| [v, v] }.to_h
98
+ else
99
+ fail ArgumentError, "Can't construct enum from #{enum.inspect}"
100
+ end
101
+ end
102
+
103
+ def params_from_path(path)
104
+ Addressable::Template.new(path).keys.map { |key| [key.to_sym, keyword: false] }.to_h
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_builder'
4
+
5
+ module TLAW
6
+ module DSL
7
+ # @private
8
+ class EndpointBuilder < BaseBuilder
9
+ def definition
10
+ # TODO: Here we'll be more flexible in future, allowing to avoid flatten/datablize
11
+ all_processors = [
12
+ @parser,
13
+ ResponseProcessors.method(:flatten),
14
+ *processors,
15
+ ResponseProcessors.method(:datablize)
16
+ ]
17
+
18
+ super.merge(processors: all_processors)
19
+ end
20
+
21
+ def finalize
22
+ Endpoint.define(**definition)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_builder'
4
+ require_relative 'endpoint_builder'
5
+
6
+ module TLAW
7
+ module DSL
8
+ # @private
9
+ class NamespaceBuilder < BaseBuilder
10
+ CHILD_CLASSES = {NamespaceBuilder => Namespace, EndpointBuilder => Endpoint}.freeze
11
+
12
+ attr_reader :children
13
+
14
+ ENDPOINT_METHOD = <<~CODE
15
+ def %{call_sequence}
16
+ child(:%{symbol}, Endpoint, %{params}).call
17
+ end
18
+ CODE
19
+
20
+ NAMESPACE_METHOD = <<~CODE
21
+ def %{call_sequence}
22
+ child(:%{symbol}, Namespace, %{params})
23
+ end
24
+ CODE
25
+
26
+ METHODS = {Endpoint => ENDPOINT_METHOD, Namespace => NAMESPACE_METHOD}.freeze
27
+
28
+ def initialize(children: [], **args, &block)
29
+ @children = children.map { |c| [c.symbol, c] }.to_h
30
+ super(**args, &block)
31
+ end
32
+
33
+ def definition
34
+ super.merge(children: children.values)
35
+ end
36
+
37
+ def endpoint(symbol, path = nil, **opts, &block)
38
+ child(EndpointBuilder, symbol, path, **opts, &block)
39
+ end
40
+
41
+ def namespace(symbol, path = nil, **opts, &block)
42
+ child(NamespaceBuilder, symbol, path, **opts, &block)
43
+ end
44
+
45
+ def finalize
46
+ Namespace.define(**definition).tap(&method(:define_children_methods))
47
+ end
48
+
49
+ private
50
+
51
+ def child(builder_class, symbol, path, **opts, &block)
52
+ symbol = symbol.to_sym
53
+ target_class = CHILD_CLASSES.fetch(builder_class)
54
+ existing = children[symbol]
55
+ &.tap { |c|
56
+ c < target_class or fail ArgumentError, "#{symbol} already defined as #{c.ansestors.first}"
57
+ }
58
+ &.definition || {}
59
+
60
+ builder_class.new(
61
+ symbol: symbol,
62
+ path: path,
63
+ context: self,
64
+ **opts,
65
+ **existing,
66
+ &block
67
+ ).finalize.tap { |child| children[symbol] = child }
68
+ end
69
+
70
+ def define_children_methods(namespace)
71
+ children.each_value do |child|
72
+ namespace.module_eval(child_method_code(child))
73
+ end
74
+ end
75
+
76
+ def child_method_code(child)
77
+ params = child.param_defs.map { |par| "#{par.name}: #{par.name}" }.join(', ')
78
+ METHODS.fetch(child.ancestors[1]) % {
79
+ call_sequence: Formatting.call_sequence(child),
80
+ symbol: child.symbol,
81
+ params: params
82
+ }
83
+ end
84
+ end
85
+ end
86
+ end
data/lib/tlaw/endpoint.rb CHANGED
@@ -1,137 +1,115 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'faraday'
2
4
  require 'faraday_middleware'
3
5
  require 'addressable/template'
4
6
  require 'crack'
5
7
 
6
8
  module TLAW
7
- # This class does all the hard work: actually calling some HTTP API
8
- # and processing responses.
9
- #
10
- # Each real API endpoint is this class descendant, defining its own
11
- # params and response processors. On each call small instance of this
12
- # class is created, {#call}-ed and dies as you don't need it anymore.
9
+ # Represents API endpoint.
13
10
  #
14
- # Typically, you will neither create nor use endpoint descendants or
15
- # instances directly:
11
+ # You will neither create nor use endpoint descendants or instances directly:
16
12
  #
17
13
  # * endpoint class definition is performed through {DSL} helpers,
18
- # * and then, containing namespace obtains `.<current_endpoint_name>()`
19
- # method, which is (almost) everything you need to know.
14
+ # * and then, containing namespace obtains `.<endpoint_name>()` method, which is (almost)
15
+ # everything you need to know.
20
16
  #
21
17
  class Endpoint < APIPath
22
18
  class << self
19
+ # @private
20
+ attr_reader :processors
21
+
23
22
  # Inspects endpoint class prettily.
24
23
  #
25
24
  # Example:
26
25
  #
27
26
  # ```ruby
28
- # some_api.some_namespace.endpoints[:my_endpoint]
27
+ # some_api.some_namespace.endpoint(:my_endpoint)
29
28
  # # => <SomeApi::SomeNamespace::MyEndpoint call-sequence: my_endpoint(param1, param2: nil), docs: .describe>
30
29
  # ```
30
+ #
31
+ # @return [String]
31
32
  def inspect
32
- "#<#{name || '(unnamed endpoint class)'}:" \
33
- " call-sequence: #{symbol}(#{param_set.to_code}); docs: .describe>"
34
- end
33
+ return super unless is_defined?
35
34
 
36
- # @private
37
- def to_code
38
- "def #{to_method_definition}\n" \
39
- " child(:#{symbol}, Endpoint).call({#{param_set.to_hash_code}})\n" \
40
- 'end'
35
+ Formatting::Inspect.endpoint_class(self)
41
36
  end
42
37
 
43
- # @private
44
- def construct_template
45
- tpl = if query_string_params.empty?
46
- base_url
47
- else
48
- joiner = base_url.include?('?') ? '&' : '?'
49
- "#{base_url}{#{joiner}#{query_string_params.join(',')}}"
50
- end
51
- Addressable::Template.new(tpl)
52
- end
38
+ # @return [Formatting::Description]
39
+ def describe
40
+ return '' unless is_defined?
53
41
 
54
- # @private
55
- def parse(body)
56
- if xml
57
- Crack::XML.parse(body)
58
- else
59
- JSON.parse(body)
60
- end.derp { |response| response_processor.process(response) }
42
+ Formatting::Describe.endpoint_class(self)
61
43
  end
62
44
 
63
- private
45
+ protected
64
46
 
65
- def query_string_params
66
- param_set.all_params.values.map(&:field).map(&:to_s) -
67
- Addressable::Template.new(base_url).keys
47
+ def setup(processors: [], **args)
48
+ super(**args)
49
+ self.processors = processors.dup
68
50
  end
51
+
52
+ attr_writer :processors
69
53
  end
70
54
 
71
- attr_reader :url_template
55
+ # @private
56
+ attr_reader :url, :request_params
72
57
 
73
- # Creates endpoint class (or descendant) instance. Typically, you
74
- # never use it directly.
58
+ # Creates endpoint class (or descendant) instance. Typically, you never use it directly.
75
59
  #
76
60
  # Params defined in parent namespace are passed here.
77
61
  #
78
- def initialize(**parent_params)
62
+ def initialize(parent, **params)
79
63
  super
80
64
 
81
- @client = Faraday.new do |faraday|
82
- faraday.use FaradayMiddleware::FollowRedirects
83
- faraday.adapter Faraday.default_adapter
84
- end
85
- @url_template = self.class.construct_template
65
+ template = Addressable::Template.new(url_template)
66
+
67
+ # .normalize fixes ../ in path
68
+ @url = template.expand(**prepared_params).normalize.to_s.yield_self(&method(:fix_slash))
69
+ url_keys = template.keys.map(&:to_sym)
70
+ @request_params = prepared_params.reject { |k,| url_keys.include?(k) }
86
71
  end
87
72
 
88
- # Does the real call to the API, with all params passed to this method
89
- # and to parent namespace.
73
+ # Does the real call to the API, with all params passed to this method and to parent namespace.
90
74
  #
91
- # Typically, you don't use it directly, that's what called when you
92
- # do `some_namespace.endpoint_name(**params)`.
75
+ # Typically, you don't use it directly, that's what called when you do
76
+ # `some_namespace.endpoint_name(**params)`.
93
77
  #
94
- # @return [Hash,Array] Parsed, flattened and post-processed response
95
- # body.
96
- def call(**params)
97
- url = construct_url(**full_params(params))
98
-
99
- @client.get(url)
100
- .tap { |response| guard_errors!(response) }
101
- .derp { |response| self.class.parse(response.body) }
102
- rescue API::Error
103
- raise # Not catching in the next block
104
- rescue => e
105
- raise unless url
106
- raise API::Error, "#{e.class} at #{url}: #{e.message}"
78
+ # @return [Hash,Array] Parsed, flattened and post-processed response body.
79
+ def call
80
+ # TODO: we have a whole response here, so we can potentially have processors that
81
+ # extract some useful information (pagination, rate-limiting) from _headers_.
82
+ api.request(url, **request_params).body.yield_self(&method(:parse))
107
83
  end
108
84
 
109
- def_delegators :object_class, :inspect, :describe
110
-
111
- private
85
+ # @return [String]
86
+ def inspect
87
+ Formatting::Inspect.endpoint(self)
88
+ end
112
89
 
113
- def full_params(**params)
114
- @parent_params.merge(params.reject { |_, v| v.nil? })
90
+ # @private
91
+ def to_curl
92
+ separator = url.include?('?') ? '&' : '?'
93
+ full_url = url + separator + request_params.map(&'%s=%s'.method(:%)).join('&')
94
+ # FIXME: Probably unreliable (escaping), but Shellwords.escape do the wrong thing.
95
+ %{curl "#{full_url}"}
115
96
  end
116
97
 
117
- def guard_errors!(response)
118
- # TODO: follow redirects
119
- return response if (200...400).cover?(response.status)
98
+ private
120
99
 
121
- body = JSON.parse(response.body) rescue nil
122
- message = body && (body['message'] || body['error'])
100
+ def_delegators :self_class, :url_template, :processors
123
101
 
124
- fail API::Error,
125
- "HTTP #{response.status} at #{response.env[:url]}" +
126
- (message ? ': ' + message : '')
102
+ # Fix params substitution: if it was in path part, we shouldn't have escaped "/"
103
+ # E.g. for template `http://google.com/{foo}/bar`, and foo="some/path", Addressable would
104
+ # produce "http://google.com/some%2fpath/bar", but we want "http://google.com/some/path/bar"
105
+ def fix_slash(url)
106
+ url, query = url.split('?', 2)
107
+ url.gsub!('%2F', '/')
108
+ [url, query].compact.join('?')
127
109
  end
128
110
 
129
- def construct_url(**params)
130
- url_params = self.class.param_set.process(**params)
131
- @url_template
132
- .expand(url_params).normalize.to_s
133
- .split('?', 2).derp { |url, param| [url.gsub('%2F', '/'), param] }
134
- .compact.join('?')
111
+ def parse(body)
112
+ processors.inject(body) { |res, proc| proc.(res) }
135
113
  end
136
114
  end
137
115
  end