tlaw 0.0.2 → 0.1.0.pre

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