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.
- checksums.yaml +7 -0
- data/.codeclimate.yml +11 -0
- data/.yardopts +2 -0
- data/LICENSE.txt +22 -0
- data/README.md +438 -0
- data/examples/demo_base.rb +10 -0
- data/examples/forecast_io.rb +113 -0
- data/examples/forecast_io_demo.rb +72 -0
- data/examples/open_weather_map.rb +266 -0
- data/examples/open_weather_map_demo.rb +219 -0
- data/examples/tmdb_demo.rb +133 -0
- data/examples/urbandictionary_demo.rb +105 -0
- data/lib/tlaw.rb +67 -0
- data/lib/tlaw/api.rb +58 -0
- data/lib/tlaw/api_path.rb +137 -0
- data/lib/tlaw/data_table.rb +116 -0
- data/lib/tlaw/dsl.rb +511 -0
- data/lib/tlaw/endpoint.rb +132 -0
- data/lib/tlaw/namespace.rb +159 -0
- data/lib/tlaw/param.rb +155 -0
- data/lib/tlaw/param/type.rb +113 -0
- data/lib/tlaw/param_set.rb +111 -0
- data/lib/tlaw/response_processor.rb +124 -0
- data/lib/tlaw/util.rb +45 -0
- data/lib/tlaw/version.rb +7 -0
- data/tlaw.gemspec +53 -0
- metadata +265 -0
@@ -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
|
data/lib/tlaw/param.rb
ADDED
@@ -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
|