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,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