tlaw 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,132 @@
1
+ require 'faraday'
2
+ require 'addressable/template'
3
+ require 'crack'
4
+
5
+ module TLAW
6
+ # This class does all the hard work: actually calling some HTTP API
7
+ # and processing responses.
8
+ #
9
+ # Each real API endpoint is this class descendant, defining its own
10
+ # params and response processors. On each call small instance of this
11
+ # class is created, {#call}-ed and dies as you don't need it anymore.
12
+ #
13
+ # Typically, you will neither create nor use endpoint descendants or
14
+ # instances directly:
15
+ #
16
+ # * endpoint class definition is performed through {DSL} helpers,
17
+ # * and then, containing namespace obtains `.<current_endpoint_name>()`
18
+ # method, which is (almost) everything you need to know.
19
+ #
20
+ class Endpoint < APIPath
21
+ class << self
22
+ # Inspects endpoint class prettily.
23
+ #
24
+ # Example:
25
+ #
26
+ # ```ruby
27
+ # some_api.some_namespace.endpoints[:my_endpoint]
28
+ # # => <SomeApi::SomeNamespace::MyEndpoint call-sequence: my_endpoint(param1, param2: nil), docs: .describe>
29
+ # ```
30
+ def inspect
31
+ "#<#{name || '(unnamed endpoint class)'}:" \
32
+ " call-sequence: #{symbol}(#{param_set.to_code}); docs: .describe>"
33
+ end
34
+
35
+ # @private
36
+ def to_code
37
+ "def #{to_method_definition}\n" \
38
+ " child(:#{symbol}, Endpoint).call({#{param_set.to_hash_code}})\n" \
39
+ 'end'
40
+ end
41
+
42
+ # @private
43
+ def construct_template
44
+ tpl = if query_string_params.empty?
45
+ base_url
46
+ else
47
+ joiner = base_url.include?('?') ? '&' : '?'
48
+ "#{base_url}{#{joiner}#{query_string_params.join(',')}}"
49
+ end
50
+ Addressable::Template.new(tpl)
51
+ end
52
+
53
+ # @private
54
+ def parse(body)
55
+ if xml
56
+ Crack::XML.parse(body)
57
+ else
58
+ JSON.parse(body)
59
+ end.derp { |response| response_processor.process(response) }
60
+ end
61
+
62
+ private
63
+
64
+ def query_string_params
65
+ param_set.all_params.values.map(&:field).map(&:to_s) -
66
+ Addressable::Template.new(base_url).keys
67
+ end
68
+ end
69
+
70
+ attr_reader :url_template
71
+
72
+ # Creates endpoint class (or descendant) instance. Typically, you
73
+ # never use it directly.
74
+ #
75
+ # Params defined in parent namespace are passed here.
76
+ #
77
+ def initialize(**parent_params)
78
+ super
79
+
80
+ @client = Faraday.new
81
+ @url_template = self.class.construct_template
82
+ end
83
+
84
+ # Does the real call to the API, with all params passed to this method
85
+ # and to parent namespace.
86
+ #
87
+ # Typically, you don't use it directly, that's what called when you
88
+ # do `some_namespace.endpoint_name(**params)`.
89
+ #
90
+ # @return [Hash,Array] Parsed, flattened and post-processed response
91
+ # body.
92
+ def call(**params)
93
+ url = construct_url(**full_params(params))
94
+
95
+ @client.get(url)
96
+ .tap { |response| guard_errors!(response) }
97
+ .derp { |response| self.class.parse(response.body) }
98
+ rescue API::Error
99
+ raise # Not catching in the next block
100
+ rescue => e
101
+ raise unless url
102
+ raise API::Error, "#{e.class} at #{url}: #{e.message}"
103
+ end
104
+
105
+ def_delegators :object_class, :inspect, :describe
106
+
107
+ private
108
+
109
+ def full_params(**params)
110
+ @parent_params.merge(params.reject { |_, v| v.nil? })
111
+ end
112
+
113
+ def guard_errors!(response)
114
+ return response if (200...400).cover?(response.status)
115
+
116
+ body = JSON.parse(response.body) rescue nil
117
+ message = body && (body['message'] || body['error'])
118
+
119
+ fail API::Error,
120
+ "HTTP #{response.status} at #{response.env[:url]}" +
121
+ (message ? ': ' + message : '')
122
+ end
123
+
124
+ def construct_url(**params)
125
+ url_params = self.class.param_set.process(**params)
126
+ @url_template
127
+ .expand(url_params).normalize.to_s
128
+ .split('?', 2).derp { |url, param| [url.gsub('%2F', '/'), param] }
129
+ .compact.join('?')
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,159 @@
1
+ module TLAW
2
+ # Namespace is basically a container for {Endpoint}s. It allows to
3
+ # nest Ruby calls (like `api.namespace1.namespace2.real_call(params)`),
4
+ # optionally providing some parameters while nesting, like
5
+ # `worldbank.countries('uk').population(2016)`.
6
+ #
7
+ # By default, namespaces nesting also means URL nesting (e.g.
8
+ # `base_url/namespace1/namespace2/endpoint`), but that could be altered
9
+ # on namespace definition, see {DSL} module for details.
10
+ #
11
+ # Typically, as with {Endpoint}, you never create namespace instances
12
+ # or subclasses by yourself: you use {DSL} for their definition and
13
+ # then call `.<namespace_name>` method on parent namespace (or API instance):
14
+ #
15
+ # ```ruby
16
+ # class SampleAPI < TLAW::API
17
+ # # namespace definition:
18
+ # namespace :my_ns do
19
+ # endpoint :weather
20
+ # end
21
+ # end
22
+ #
23
+ # # usage:
24
+ # api = SampleAPI.new
25
+ #
26
+ # api.namespaces[:my_ns] # => class SampleAPI::MyNS, subclass of namespace
27
+ # api.my_ns # => short-living instance of SampleAPI::MyNS
28
+ # api.my_ns.weather # => real call to API
29
+ # ```
30
+ #
31
+ class Namespace < APIPath
32
+ class << self
33
+ # @private
34
+ def base_url=(url)
35
+ @base_url = url
36
+
37
+ children.values.each do |c|
38
+ c.base_url = base_url + c.path if c.path && !c.base_url
39
+ end
40
+ end
41
+
42
+ # Lists all current namespace's nested namespaces as a hash.
43
+ #
44
+ # @return [Hash{Symbol => Namespace}]
45
+ def namespaces
46
+ children.select { |_k, v| v < Namespace }
47
+ end
48
+
49
+ # Lists all current namespace's endpoints as a hash.
50
+ #
51
+ # @return [Hash{Symbol => Endpoint}]
52
+ def endpoints
53
+ children.select { |_k, v| v < Endpoint }
54
+ end
55
+
56
+ # @private
57
+ def to_code
58
+ "def #{to_method_definition}\n" \
59
+ " child(:#{symbol}, Namespace, {#{param_set.to_hash_code}})\n" \
60
+ 'end'
61
+ end
62
+
63
+ def inspect
64
+ "#<#{name || '(unnamed namespace class)'}: " \
65
+ "call-sequence: #{symbol}(#{param_set.to_code});" +
66
+ inspect_docs
67
+ end
68
+
69
+ # @private
70
+ def inspect_docs
71
+ inspect_namespaces + inspect_endpoints + ' docs: .describe>'
72
+ end
73
+
74
+ # @private
75
+ def add_child(child)
76
+ children[child.symbol] = child
77
+
78
+ child.base_url = base_url + child.path if !child.base_url && base_url
79
+
80
+ child.define_method_on(self)
81
+ end
82
+
83
+ # @private
84
+ def children
85
+ @children ||= {}
86
+ end
87
+
88
+ # Detailed namespace documentation.
89
+ #
90
+ # See {APIPath.describe} for explanations.
91
+ #
92
+ # @return [Util::Description]
93
+ def describe(definition = nil)
94
+ super + describe_children
95
+ end
96
+
97
+ private
98
+
99
+ def inspect_namespaces
100
+ return '' if namespaces.empty?
101
+ " namespaces: #{namespaces.keys.join(', ')};"
102
+ end
103
+
104
+ def inspect_endpoints
105
+ return '' if endpoints.empty?
106
+ " endpoints: #{endpoints.keys.join(', ')};"
107
+ end
108
+
109
+ def describe_children
110
+ describe_namespaces + describe_endpoints
111
+ end
112
+
113
+ def describe_namespaces
114
+ return '' if namespaces.empty?
115
+
116
+ "\n\n Namespaces:\n\n" + children_description(namespaces)
117
+ end
118
+
119
+ def describe_endpoints
120
+ return '' if endpoints.empty?
121
+
122
+ "\n\n Endpoints:\n\n" + children_description(endpoints)
123
+ end
124
+
125
+ def children_description(children)
126
+ children.values.map(&:describe_short)
127
+ .map { |cd| cd.indent(' ') }.join("\n\n")
128
+ end
129
+ end
130
+
131
+ def_delegators :object_class,
132
+ :symbol,
133
+ :children, :namespaces, :endpoints,
134
+ :param_set, :describe_short
135
+
136
+ def inspect
137
+ "#<#{symbol}(#{param_set.to_hash_code(@parent_params)})" +
138
+ self.class.inspect_docs
139
+ end
140
+
141
+ def describe
142
+ self.class
143
+ .describe("#{symbol}(#{param_set.to_hash_code(@parent_params)})")
144
+ end
145
+
146
+ private
147
+
148
+ def child(symbol, expected_class, **params)
149
+ children[symbol]
150
+ .tap { |child_class|
151
+ child_class && child_class < expected_class or
152
+ fail ArgumentError,
153
+ "Unregistered #{expected_class.name.downcase}: #{symbol}"
154
+ }.derp { |child_class|
155
+ child_class.new(@parent_params.merge(params))
156
+ }
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,155 @@
1
+ module TLAW
2
+ # Base parameter class for working with parameters validation and
3
+ # converting. You'll never instantiate it directly, just see {DSL#param}
4
+ # for parameters definition.
5
+ #
6
+ class Param
7
+ # This error is thrown when some value could not be converted to what
8
+ # this parameter inspects. For example:
9
+ #
10
+ # ```ruby
11
+ # # definition:
12
+ # param :timestamp, :to_time, format: :to_i
13
+ # # this means: parameter, when passed, will first be converted with
14
+ # # method #to_time, and then resulting time will be made into
15
+ # # unix timestamp with #to_i before passing to API
16
+ #
17
+ # # usage:
18
+ # my_endpoint(timestamp: Time.now) # ok
19
+ # my_endpoint(timestamp: Date.today) # ok
20
+ # my_endpoint(timestamp: '2016-06-01') # Nonconvertible! ...unless you've included ActiveSupport :)
21
+ # ```
22
+ #
23
+ Nonconvertible = Class.new(ArgumentError)
24
+
25
+ def self.make(name, **options)
26
+ # NB: Sic. :keyword is nil (not provided) should still
27
+ # make a keyword argument.
28
+ if options[:keyword] != false
29
+ KeywordParam.new(name, **options)
30
+ else
31
+ ArgumentParam.new(name, **options)
32
+ end
33
+ end
34
+
35
+ attr_reader :name, :type, :options
36
+
37
+ def initialize(name, **options)
38
+ @name = name
39
+ @options = options
40
+ @type = Type.parse(options)
41
+ @options[:desc] ||= @options[:description]
42
+ @options[:desc].gsub!(/\n( *)/, "\n ") if @options[:desc]
43
+ @formatter = make_formatter
44
+ end
45
+
46
+ def required?
47
+ options[:required]
48
+ end
49
+
50
+ def default
51
+ options[:default]
52
+ end
53
+
54
+ def merge(**new_options)
55
+ Param.make(name, @options.merge(new_options))
56
+ end
57
+
58
+ def field
59
+ options[:field] || name
60
+ end
61
+
62
+ def convert(value)
63
+ type.convert(value)
64
+ end
65
+
66
+ def format(value)
67
+ to_url_part(formatter.call(value))
68
+ end
69
+
70
+ def convert_and_format(value)
71
+ format(convert(value))
72
+ end
73
+
74
+ alias_method :to_h, :options
75
+
76
+ def description
77
+ options[:desc]
78
+ end
79
+
80
+ def describe
81
+ [
82
+ '@param', name,
83
+ ("[#{doc_type}]" if doc_type),
84
+ description,
85
+ if @options[:enum]
86
+ "\n Possible values: #{type.values.map(&:inspect).join(', ')}"
87
+ end,
88
+ ("(default = #{default.inspect})" if default)
89
+ ].compact.join(' ')
90
+ .derp(&Util::Description.method(:new))
91
+ end
92
+
93
+ private
94
+
95
+ attr_reader :formatter
96
+
97
+ def doc_type
98
+ type.to_doc_type
99
+ end
100
+
101
+ def to_url_part(value)
102
+ case value
103
+ when Array
104
+ value.join(',')
105
+ else
106
+ value.to_s
107
+ end
108
+ end
109
+
110
+ def make_formatter
111
+ options[:format].derp { |f|
112
+ return ->(v) { v } unless f
113
+ return f.to_proc if f.respond_to?(:to_proc)
114
+ fail ArgumentError, "#{self}: unsupporter formatter #{f}"
115
+ }
116
+ end
117
+
118
+ def default_to_code
119
+ # FIXME: this `inspect` will fail with, say, Time
120
+ default.inspect
121
+ end
122
+ end
123
+
124
+ # @private
125
+ class ArgumentParam < Param
126
+ def keyword?
127
+ false
128
+ end
129
+
130
+ def to_code
131
+ if required?
132
+ name.to_s
133
+ else
134
+ "#{name}=#{default_to_code}"
135
+ end
136
+ end
137
+ end
138
+
139
+ # @private
140
+ class KeywordParam < Param
141
+ def keyword?
142
+ true
143
+ end
144
+
145
+ def to_code
146
+ if required?
147
+ "#{name}:"
148
+ else
149
+ "#{name}: #{default_to_code}"
150
+ end
151
+ end
152
+ end
153
+ end
154
+
155
+ require_relative 'param/type'
@@ -0,0 +1,113 @@
1
+ module TLAW
2
+ class Param
3
+ # @private
4
+ class Type
5
+ attr_reader :type
6
+
7
+ def self.parse(options)
8
+ type = options[:type]
9
+
10
+ case type
11
+ when nil
12
+ options[:enum] ? EnumType.new(options[:enum]) : Type.new(nil)
13
+ when Class
14
+ ClassType.new(type)
15
+ when Symbol
16
+ DuckType.new(type)
17
+ when Hash
18
+ EnumType.new(type)
19
+ else
20
+ fail ArgumenError, "Undefined type #{type}"
21
+ end
22
+ end
23
+
24
+ def initialize(type)
25
+ @type = type
26
+ end
27
+
28
+ def to_doc_type
29
+ nil
30
+ end
31
+
32
+ def convert(value)
33
+ validate(value) && _convert(value)
34
+ end
35
+
36
+ def validate(_value)
37
+ true
38
+ end
39
+
40
+ def _convert(value)
41
+ value
42
+ end
43
+
44
+ def nonconvertible!(value, reason)
45
+ fail Nonconvertible,
46
+ "#{self} can't convert #{value.inspect}: #{reason}"
47
+ end
48
+ end
49
+
50
+ # @private
51
+ class ClassType < Type
52
+ def validate(value)
53
+ value.is_a?(type) or
54
+ nonconvertible!(value, "not an instance of #{type}")
55
+ end
56
+
57
+ def _convert(value)
58
+ value
59
+ end
60
+
61
+ def to_doc_type
62
+ type.name
63
+ end
64
+ end
65
+
66
+ # @private
67
+ class DuckType < Type
68
+ def _convert(value)
69
+ value.send(type)
70
+ end
71
+
72
+ def validate(value)
73
+ value.respond_to?(type) or
74
+ nonconvertible!(value, "not responding to #{type}")
75
+ end
76
+
77
+ def to_doc_type
78
+ "##{type}"
79
+ end
80
+ end
81
+
82
+ # @private
83
+ class EnumType < Type
84
+ def initialize(enum)
85
+ @type =
86
+ case enum
87
+ when Hash
88
+ enum
89
+ when ->(e) { e.respond_to?(:map) }
90
+ enum.map { |n| [n, n] }.to_h
91
+ else
92
+ fail ArgumentError, "Unparseable enum: #{enum.inspect}"
93
+ end
94
+ end
95
+
96
+ def values
97
+ type.keys
98
+ end
99
+
100
+ def validate(value)
101
+ type.key?(value) or
102
+ nonconvertible!(
103
+ value,
104
+ "is not one of #{type.keys.map(&:inspect).join(', ')}"
105
+ )
106
+ end
107
+
108
+ def _convert(value)
109
+ type[value]
110
+ end
111
+ end
112
+ end
113
+ end