tlaw 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
data/lib/tlaw.rb
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
require 'open-uri'
|
2
|
+
require 'json'
|
3
|
+
require 'addressable'
|
4
|
+
|
5
|
+
# Let no one know! But they in Ruby committee just too long to add
|
6
|
+
# something like this to the language.
|
7
|
+
#
|
8
|
+
# See also https://bugs.ruby-lang.org/issues/12760
|
9
|
+
#
|
10
|
+
# @private
|
11
|
+
class Object
|
12
|
+
def derp
|
13
|
+
yield self
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
# TLAW is a framework for creating API wrappers for get-only APIs (like
|
18
|
+
# weather, geonames and so on) or subsets of APIs (like getting data from
|
19
|
+
# Twitter).
|
20
|
+
#
|
21
|
+
# Short example:
|
22
|
+
#
|
23
|
+
# ```ruby
|
24
|
+
# # Definition:
|
25
|
+
# class OpenWeatherMap < TLAW::API
|
26
|
+
# param :appid, required: true
|
27
|
+
#
|
28
|
+
# namespace :current, '/weather' do
|
29
|
+
# endpoint :city, '?q={city}{,country_code}' do
|
30
|
+
# param :city, required: true
|
31
|
+
# end
|
32
|
+
# end
|
33
|
+
# end
|
34
|
+
#
|
35
|
+
# # Usage:
|
36
|
+
# api = OpenWeatherMap.new(appid: '<yourappid>')
|
37
|
+
# api.current.weather('Kharkiv')
|
38
|
+
# # => {"weather.main"=>"Clear",
|
39
|
+
# # "weather.description"=>"clear sky",
|
40
|
+
# # "main.temp"=>8,
|
41
|
+
# # "main.pressure"=>1016,
|
42
|
+
# # "main.humidity"=>81,
|
43
|
+
# # "dt"=>2016-09-19 08:30:00 +0300,
|
44
|
+
# # ...}
|
45
|
+
#
|
46
|
+
# ```
|
47
|
+
#
|
48
|
+
# Refer to [README](./file/README.md) for reasoning about why you need it and links to
|
49
|
+
# more detailed demos, or start reading YARD docs from {API} and {DSL}
|
50
|
+
# modules.
|
51
|
+
module TLAW
|
52
|
+
end
|
53
|
+
|
54
|
+
require_relative 'tlaw/util'
|
55
|
+
require_relative 'tlaw/data_table'
|
56
|
+
|
57
|
+
require_relative 'tlaw/param'
|
58
|
+
require_relative 'tlaw/param_set'
|
59
|
+
|
60
|
+
require_relative 'tlaw/api_path'
|
61
|
+
require_relative 'tlaw/endpoint'
|
62
|
+
require_relative 'tlaw/namespace'
|
63
|
+
require_relative 'tlaw/api'
|
64
|
+
|
65
|
+
require_relative 'tlaw/response_processor'
|
66
|
+
|
67
|
+
require_relative 'tlaw/dsl'
|
data/lib/tlaw/api.rb
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
module TLAW
|
2
|
+
# API is just a top-level {Namespace}.
|
3
|
+
#
|
4
|
+
# Basically, you start creating your endpoint by descending from API
|
5
|
+
# and defining namespaces and endpoints through a {DSL} like this:
|
6
|
+
#
|
7
|
+
# ```ruby
|
8
|
+
# class MyCoolAPI < TLAW::API
|
9
|
+
# define do
|
10
|
+
# base 'http://api.mycool.com'
|
11
|
+
#
|
12
|
+
# namespace :awesome do
|
13
|
+
# # ...and so on
|
14
|
+
# end
|
15
|
+
# end
|
16
|
+
# end
|
17
|
+
# ```
|
18
|
+
#
|
19
|
+
# And then, you use it:
|
20
|
+
#
|
21
|
+
# ```ruby
|
22
|
+
# api = MyCoolAPI.new
|
23
|
+
# api.awesome.cool(param: 'value')
|
24
|
+
# ```
|
25
|
+
#
|
26
|
+
# See {DSL} for explanation of API definition, {Namespace} for explanation
|
27
|
+
# of possible usages and {Endpoint} for real calls performing.
|
28
|
+
#
|
29
|
+
class API < Namespace
|
30
|
+
# Thrown when there are an error during call. Contains real URL which
|
31
|
+
# was called at the time of an error.
|
32
|
+
class Error < RuntimeError
|
33
|
+
end
|
34
|
+
|
35
|
+
class << self
|
36
|
+
# Runs the {DSL} inside your API wrapper class.
|
37
|
+
def define(&block)
|
38
|
+
DSL::APIWrapper.new(self).define(&block)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Returns detailed description of an API, like this:
|
42
|
+
#
|
43
|
+
# ```ruby
|
44
|
+
# MyCoolAPI.describe
|
45
|
+
# # MyCoolAPI.new()
|
46
|
+
# # This is cool API.
|
47
|
+
# #
|
48
|
+
# # Namespaces:
|
49
|
+
# # .awesome()
|
50
|
+
# # This is awesome.
|
51
|
+
# ```
|
52
|
+
#
|
53
|
+
def describe(*)
|
54
|
+
super.sub(/\A./, '')
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,137 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
module TLAW
|
4
|
+
# Base class for all API pathes: entire API, namespaces and endpoints.
|
5
|
+
# Allows to define params and post-processors on any level.
|
6
|
+
#
|
7
|
+
class APIPath
|
8
|
+
class << self
|
9
|
+
# @private
|
10
|
+
attr_accessor :base_url, :path, :xml, :docs_link
|
11
|
+
|
12
|
+
# @private
|
13
|
+
def symbol
|
14
|
+
# FIXME: the second part is necessary only for describes,
|
15
|
+
# and probably should not be here.
|
16
|
+
@symbol || (name && "#{name}.new")
|
17
|
+
end
|
18
|
+
|
19
|
+
# @private
|
20
|
+
CLASS_NAMES = {
|
21
|
+
:[] => 'Element'
|
22
|
+
}.freeze
|
23
|
+
|
24
|
+
# @private
|
25
|
+
def class_name
|
26
|
+
# TODO:
|
27
|
+
# * validate if it is classifiable
|
28
|
+
# * provide additional option for non-default class name
|
29
|
+
CLASS_NAMES[symbol] || Util.camelize(symbol.to_s)
|
30
|
+
end
|
31
|
+
|
32
|
+
# @private
|
33
|
+
def description=(descr)
|
34
|
+
@description = Util::Description.new(descr)
|
35
|
+
end
|
36
|
+
|
37
|
+
# @private
|
38
|
+
def description
|
39
|
+
return unless @description || @docs_link
|
40
|
+
|
41
|
+
Util::Description.new(
|
42
|
+
[@description, ("Docs: #{@docs_link}" if @docs_link)]
|
43
|
+
.compact.join("\n\n")
|
44
|
+
)
|
45
|
+
end
|
46
|
+
|
47
|
+
# @private
|
48
|
+
def inherit(namespace, **attrs)
|
49
|
+
Class.new(self).tap do |subclass|
|
50
|
+
attrs.each { |a, v| subclass.send("#{a}=", v) }
|
51
|
+
namespace.const_set(subclass.class_name, subclass)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# @private
|
56
|
+
def params_from_path!
|
57
|
+
Addressable::Template.new(path).keys.each do |key|
|
58
|
+
param_set.add key.to_sym, keyword: false
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# @private
|
63
|
+
def setup_parents(parent)
|
64
|
+
param_set.parent = parent.param_set
|
65
|
+
response_processor.parent = parent.response_processor
|
66
|
+
end
|
67
|
+
|
68
|
+
# @private
|
69
|
+
def symbol=(sym)
|
70
|
+
@symbol = sym
|
71
|
+
@path ||= "/#{sym}"
|
72
|
+
end
|
73
|
+
|
74
|
+
# @return [ParamSet]
|
75
|
+
def param_set
|
76
|
+
@param_set ||= ParamSet.new
|
77
|
+
end
|
78
|
+
|
79
|
+
# @private
|
80
|
+
def response_processor
|
81
|
+
@response_processor ||= ResponseProcessor.new
|
82
|
+
end
|
83
|
+
|
84
|
+
# @private
|
85
|
+
def to_method_definition
|
86
|
+
"#{symbol}(#{param_set.to_code})"
|
87
|
+
end
|
88
|
+
|
89
|
+
# Redefined on descendants, it just allows you to do `api.namespace.describe`
|
90
|
+
# or `api.namespace1.namespace2.endpoints[:my_endpoint].describe`
|
91
|
+
# and have reasonable useful description printed.
|
92
|
+
#
|
93
|
+
# @return [Util::Description] It is just description string but with
|
94
|
+
# redefined `#inspect` to be pretty-printed in console.
|
95
|
+
def describe(definition = nil)
|
96
|
+
Util::Description.new(
|
97
|
+
".#{definition || to_method_definition}" +
|
98
|
+
(description ? "\n" + description.indent(' ') + "\n" : '') +
|
99
|
+
(param_set.empty? ? '' : "\n" + param_set.describe.indent(' '))
|
100
|
+
)
|
101
|
+
end
|
102
|
+
|
103
|
+
# @private
|
104
|
+
def describe_short
|
105
|
+
Util::Description.new(
|
106
|
+
".#{to_method_definition}" +
|
107
|
+
(description ? "\n" + description_first_para.indent(' ') : '')
|
108
|
+
)
|
109
|
+
end
|
110
|
+
|
111
|
+
# @private
|
112
|
+
def define_method_on(host)
|
113
|
+
file, line = method(:to_code).source_location
|
114
|
+
# line + 1 is where real definition, theoretically, starts
|
115
|
+
host.module_eval(to_code, file, line + 1)
|
116
|
+
end
|
117
|
+
|
118
|
+
private
|
119
|
+
|
120
|
+
def description_first_para
|
121
|
+
description.split("\n\n").first
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
extend Forwardable
|
126
|
+
|
127
|
+
def initialize(**parent_params)
|
128
|
+
@parent_params = parent_params
|
129
|
+
end
|
130
|
+
|
131
|
+
private
|
132
|
+
|
133
|
+
def object_class
|
134
|
+
self.class
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
module TLAW
|
2
|
+
# Basically, just a 2-d array with column names. Or you can think of
|
3
|
+
# it as an array of hashes. Or loose DataFrame implementation.
|
4
|
+
#
|
5
|
+
# Just like this:
|
6
|
+
#
|
7
|
+
# ```ruby
|
8
|
+
# tbl = DataTable.new([
|
9
|
+
# {id: 1, name: 'Mike', salary: 1000},
|
10
|
+
# {id: 2, name: 'Doris', salary: 900},
|
11
|
+
# {id: 3, name: 'Angie', salary: 1200}
|
12
|
+
# ])
|
13
|
+
# # => #<TLAW::DataTable[id, name, salary] x 3>
|
14
|
+
# tbl.count
|
15
|
+
# # => 3
|
16
|
+
# tbl.keys
|
17
|
+
# # => ["id", "name", "salary"]
|
18
|
+
# tbl[0]
|
19
|
+
# # => {"id"=>1, "name"=>"Mike", "salary"=>1000}
|
20
|
+
# tbl['salary']
|
21
|
+
# # => [1000, 900, 1200]
|
22
|
+
# ```
|
23
|
+
#
|
24
|
+
# Basically, that's it. Every array of hashes in TLAW response will be
|
25
|
+
# converted into corresponding `DataTable`.
|
26
|
+
#
|
27
|
+
class DataTable < Array
|
28
|
+
def self.from_columns(column_names, columns)
|
29
|
+
from_rows(column_names, columns.transpose)
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.from_rows(column_names, rows)
|
33
|
+
new rows.map { |r| column_names.zip(r).to_h }
|
34
|
+
end
|
35
|
+
|
36
|
+
# Creates DataTable from array of hashes.
|
37
|
+
#
|
38
|
+
# Note, that all hash keys are stringified, and all hashes are expanded
|
39
|
+
# to have same set of keys.
|
40
|
+
#
|
41
|
+
# @param hashes [Array<Hash>]
|
42
|
+
def initialize(hashes)
|
43
|
+
hashes = hashes.each_with_index.map { |h, i|
|
44
|
+
h.is_a?(Hash) or
|
45
|
+
fail ArgumentError,
|
46
|
+
"All rows are expected to be hashes, row #{i} is #{h.class}"
|
47
|
+
|
48
|
+
h.map { |k, v| [k.to_s, v] }.to_h
|
49
|
+
}
|
50
|
+
empty = hashes.map(&:keys).flatten.uniq.map { |k| [k, nil] }.to_h
|
51
|
+
hashes = hashes.map { |h| empty.merge(h) }
|
52
|
+
super(hashes)
|
53
|
+
end
|
54
|
+
|
55
|
+
# All column names.
|
56
|
+
#
|
57
|
+
# @return [Array<String>]
|
58
|
+
def keys
|
59
|
+
empty? ? [] : first.keys
|
60
|
+
end
|
61
|
+
|
62
|
+
# Allows access to one column or row.
|
63
|
+
#
|
64
|
+
# @overload [](index)
|
65
|
+
# Returns one row from a DataTable.
|
66
|
+
#
|
67
|
+
# @param index [Integer] Row number
|
68
|
+
# @return [Hash] Row as a hash
|
69
|
+
#
|
70
|
+
# @overload [](column_name)
|
71
|
+
# Returns one column from a DataTable.
|
72
|
+
#
|
73
|
+
# @param column_name [String] Name of column
|
74
|
+
# @return [Array] Column as an array of all values in it
|
75
|
+
#
|
76
|
+
def [](index_or_column)
|
77
|
+
case index_or_column
|
78
|
+
when Integer
|
79
|
+
super
|
80
|
+
when String, Symbol
|
81
|
+
map { |h| h[index_or_column.to_s] }
|
82
|
+
else
|
83
|
+
fail ArgumentError,
|
84
|
+
'Expected integer or string/symbol index' \
|
85
|
+
", got #{index_or_column.class}"
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# Slice of a DataTable with only specified columns left.
|
90
|
+
#
|
91
|
+
# @param names [Array<String>] What columns to leave in a DataTable
|
92
|
+
# @return [DataTable]
|
93
|
+
def columns(*names)
|
94
|
+
names.map!(&:to_s)
|
95
|
+
DataTable.new(map { |h| names.map { |n| [n, h[n]] }.to_h })
|
96
|
+
end
|
97
|
+
|
98
|
+
# Represents DataTable as a `column name => all values in columns`
|
99
|
+
# hash.
|
100
|
+
#
|
101
|
+
# @return [Hash{String => Array}]
|
102
|
+
def to_h
|
103
|
+
keys.map { |k| [k, map { |h| h[k] }] }.to_h
|
104
|
+
end
|
105
|
+
|
106
|
+
# @private
|
107
|
+
def inspect
|
108
|
+
"#<#{self.class.name}[#{keys.join(', ')}] x #{size}>"
|
109
|
+
end
|
110
|
+
|
111
|
+
# @private
|
112
|
+
def pretty_print(pp)
|
113
|
+
pp.text("#<#{self.class.name}[#{keys.join(', ')}] x #{size}>")
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
data/lib/tlaw/dsl.rb
ADDED
@@ -0,0 +1,511 @@
|
|
1
|
+
module TLAW
|
2
|
+
# This module is core of a TLAW API definition. It works like this:
|
3
|
+
#
|
4
|
+
# ```ruby
|
5
|
+
# class MyAPI < TLAW::API
|
6
|
+
# define do # here starts what DSL does
|
7
|
+
# namespace :ns do
|
8
|
+
#
|
9
|
+
# endpoint :es do
|
10
|
+
# param :param1, Integer, default: 1
|
11
|
+
# end
|
12
|
+
# end
|
13
|
+
# end
|
14
|
+
# end
|
15
|
+
# ```
|
16
|
+
#
|
17
|
+
# Methods of current namespace documentation describe everything you
|
18
|
+
# can use inside `define` blocks. Actual structure of things is a bit
|
19
|
+
# more complicated (relate to lib/tlaw/dsl.rb if you wish), but current
|
20
|
+
# documentation structure considered to be most informative.
|
21
|
+
#
|
22
|
+
module DSL
|
23
|
+
# @!method base(url)
|
24
|
+
# Allows to set entire API base URL, all endpoints and namespaces
|
25
|
+
# pathes are calculated relative to it.
|
26
|
+
#
|
27
|
+
# **Works for:** API
|
28
|
+
#
|
29
|
+
# @param url [String]
|
30
|
+
|
31
|
+
# @!method desc(text)
|
32
|
+
# Allows to set description string for your API object. It can
|
33
|
+
# be multiline, and TLAW will automatically un-indent excessive
|
34
|
+
# indentations:
|
35
|
+
#
|
36
|
+
# ```ruby
|
37
|
+
# # ...several levels of indents while you create a definition
|
38
|
+
# desc %Q{
|
39
|
+
# This is some endpoint.
|
40
|
+
# And it works!
|
41
|
+
# }
|
42
|
+
#
|
43
|
+
# # ...but when you are using it...
|
44
|
+
# p my_api.endpoints[:endpoint].describe
|
45
|
+
# # This is some endpoint.
|
46
|
+
# # And it works!
|
47
|
+
# # ....
|
48
|
+
# ```
|
49
|
+
#
|
50
|
+
# **Works for:** API, namespace, endpoint
|
51
|
+
#
|
52
|
+
# @param text [String]
|
53
|
+
|
54
|
+
# @!method docs(link)
|
55
|
+
# Allows to add link to documentation as a separate line to
|
56
|
+
# object description. Just to be semantic :)
|
57
|
+
#
|
58
|
+
# ```ruby
|
59
|
+
# # you do something like
|
60
|
+
# desc "That's my endpoint"
|
61
|
+
#
|
62
|
+
# docs "http://docs.example.com/my/endpoint"
|
63
|
+
#
|
64
|
+
# # ...and then somewhere...
|
65
|
+
# p my_api.endpoints[:endpoint].describe
|
66
|
+
# # That is my endpoint.
|
67
|
+
# #
|
68
|
+
# # Docs: http://docs.example.com/my/endpoint
|
69
|
+
# # ....
|
70
|
+
# ```
|
71
|
+
#
|
72
|
+
# **Works for:** API, namespace, endpoint
|
73
|
+
#
|
74
|
+
# @param link [String]
|
75
|
+
|
76
|
+
# @!method param(name, type = nil, keyword: true, required: false, **opts)
|
77
|
+
# Defines parameter for current API (global), namespace or endpoint.
|
78
|
+
#
|
79
|
+
# Param defnition defines several things:
|
80
|
+
#
|
81
|
+
# * how method definition to call this namespace/endpoint would
|
82
|
+
# look like: whether the parameter is keyword or regular argument,
|
83
|
+
# whether it is required and what is default value otherwise;
|
84
|
+
# * how parameter is processed: converted and validated from passed
|
85
|
+
# value;
|
86
|
+
# * how param is sent to target API: how it will be called in
|
87
|
+
# the query string and formatted on call.
|
88
|
+
#
|
89
|
+
# Note also those things about params:
|
90
|
+
#
|
91
|
+
# * as described in {#namespace} and {#endpoint}, setting path template
|
92
|
+
# will implicitly set params. You can rewrite this on implicit
|
93
|
+
# param call, for ex:
|
94
|
+
#
|
95
|
+
# ```ruby
|
96
|
+
# endpoint :foo, '/foo/{bar}'
|
97
|
+
# # call-sequence would be foo(bar = nil)
|
98
|
+
#
|
99
|
+
# # But you can make it back keyword:
|
100
|
+
# endpoint :foo, '/foo/{bar}' do
|
101
|
+
# param :bar, keyword: true, default: 'test'
|
102
|
+
# end
|
103
|
+
# # call-sequence now is foo(bar: 'test')
|
104
|
+
#
|
105
|
+
# # Or make it strictly required
|
106
|
+
# endpoint :foo, '/foo/{bar}/{baz}' do
|
107
|
+
# param :bar, required: true
|
108
|
+
# param :baz, keyword: true, required: true
|
109
|
+
# end
|
110
|
+
# # call-sequence now is foo(bar, baz:)
|
111
|
+
# ```
|
112
|
+
#
|
113
|
+
# * param of outer namespace are passed to API on call from inner
|
114
|
+
# namespaces and endpoints, for ex:
|
115
|
+
#
|
116
|
+
# ```ruby
|
117
|
+
# namespace :city do
|
118
|
+
# param :city_name
|
119
|
+
#
|
120
|
+
# namespace :population do
|
121
|
+
# endpoint :by_year, '/year/{year}'
|
122
|
+
# end
|
123
|
+
# end
|
124
|
+
#
|
125
|
+
# # real call:
|
126
|
+
# api.city('London').population.by_year(2015)
|
127
|
+
# # Will get http://api.example.com/city/year/2015?city_name=London
|
128
|
+
# ```
|
129
|
+
#
|
130
|
+
# **Works for:** API, namespace, endpoint
|
131
|
+
#
|
132
|
+
# @param name [Symbol] Parameter name
|
133
|
+
# @param type [Class, Symbol] Expected parameter type. Could by
|
134
|
+
# some class (then parameter would be checked for being instance
|
135
|
+
# of this class or it would be `ArgumentError`), or duck type
|
136
|
+
# (method name that parameter value should respond to).
|
137
|
+
# @param keyword [true, false] Whether the param will go as a
|
138
|
+
# keyword param to method definition.
|
139
|
+
# @param required [true, false] Whether this param is required.
|
140
|
+
# It will be considered on method definition.
|
141
|
+
# @param opts [Hash] Options
|
142
|
+
# @option opts [Symbol] :field What the field would be called in
|
143
|
+
# API query string (it would be `name` by default).
|
144
|
+
# @option opts [#to_proc] :format How to format this option before
|
145
|
+
# including into URL. By default, it is just `.to_s`.
|
146
|
+
# @option opts [String] :desc Param description. You could do it
|
147
|
+
# multiline and with indents, like {#desc}.
|
148
|
+
# @option opts :default Default value for this param. Would be
|
149
|
+
# rendered in method definition and then passed to target API
|
150
|
+
# _(TODO: in future, there also would be "invisible" params,
|
151
|
+
# that are just passed to target, always the same, as well as
|
152
|
+
# params that aren't passed at all if user gave default value.)_
|
153
|
+
# @option opts [Hash, Array] :enum Whether parameter only accepts
|
154
|
+
# enumerated values. Two forms are accepted:
|
155
|
+
#
|
156
|
+
# ```ruby
|
157
|
+
# # array form
|
158
|
+
# param :units, enum: %i[us metric britain]
|
159
|
+
# # parameter accepts only :us, :metric, :britain values, and
|
160
|
+
# # passes them to target API as is
|
161
|
+
#
|
162
|
+
# # hash "accepted => passed" form
|
163
|
+
# param :compact, enum: {true => 'gzip', false => nil}
|
164
|
+
# # parameter accepts true or false, on true passes "compact=gzip",
|
165
|
+
# # on false passes nothing.
|
166
|
+
# ```
|
167
|
+
|
168
|
+
# @!method namespace(name, path = nil, &block)
|
169
|
+
# Defines new namespace or updates existing one.
|
170
|
+
#
|
171
|
+
# {Namespace} has two roles:
|
172
|
+
#
|
173
|
+
# * on Ruby API, defines how you access to the final endpoint,
|
174
|
+
# like `api.namespace1.namespace2(some_param).endpoint(...)`
|
175
|
+
# * on calling API, it adds its path to entire URL.
|
176
|
+
#
|
177
|
+
# **NB:** If you call `namespace(:something)` and it was already defined,
|
178
|
+
# current definition will be added to existing one (but it can't
|
179
|
+
# change path of existing one, which is reasonable).
|
180
|
+
#
|
181
|
+
# **Works for:** API, namespace
|
182
|
+
#
|
183
|
+
# @param name [Symbol] Name of the method by which namespace would
|
184
|
+
# be accessible.
|
185
|
+
# @param path [String] Path to add to API inside this namespace.
|
186
|
+
# When not provided, considered to be `/<name>`. When provided,
|
187
|
+
# taken literally (no slashes or other symbols added). Note, that
|
188
|
+
# you can use `/../` in path, redesigning someone else's APIs on
|
189
|
+
# the fly. Also, you can use [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570.txt)
|
190
|
+
# URL templates to mark params going straightly into URI.
|
191
|
+
#
|
192
|
+
# Some examples:
|
193
|
+
#
|
194
|
+
# ```ruby
|
195
|
+
# # assuming API base url is http://api.example.com
|
196
|
+
#
|
197
|
+
# namespace :foo
|
198
|
+
# # method would be foo(), API URL would be http://api.example.com/foo
|
199
|
+
#
|
200
|
+
# namespace :bar, '/foo/bar'
|
201
|
+
# # metod would be bar(), API URL http://api.example.com/foo/bar
|
202
|
+
#
|
203
|
+
# namespace :baz, ''
|
204
|
+
# # method baz(), API URL same as base: useful for gathering into
|
205
|
+
# # quazi-namespace from several unrelated endpoints.
|
206
|
+
#
|
207
|
+
# namespace :quux, '/foo/quux/{id}'
|
208
|
+
# # method quux(id = nil), API URL http://api.example.com/foo/quux/123
|
209
|
+
# # ...where 123 is what you've passed as id
|
210
|
+
# ```
|
211
|
+
# @param block Definition of current namespace params, and
|
212
|
+
# namespaces and endpoints inside current.
|
213
|
+
# Note that by defining params inside this block, you can change
|
214
|
+
# namespace's method call sequence.
|
215
|
+
#
|
216
|
+
# For example:
|
217
|
+
#
|
218
|
+
# ```ruby
|
219
|
+
# namespace :foo
|
220
|
+
# # call-sequence: foo()
|
221
|
+
#
|
222
|
+
# namespace :foo do
|
223
|
+
# param :bar
|
224
|
+
# end
|
225
|
+
# # call-sequence: foo(bar: nil)
|
226
|
+
#
|
227
|
+
# namespace :foo do
|
228
|
+
# param :bar, required: true, keyword: false
|
229
|
+
# param :baz, required: true
|
230
|
+
# end
|
231
|
+
# # call-sequence: foo(bar, baz:)
|
232
|
+
# ```
|
233
|
+
#
|
234
|
+
# ...and so on. See also {#param} for understanding what you
|
235
|
+
# can change here.
|
236
|
+
#
|
237
|
+
|
238
|
+
# @!method endpoint(name, path = nil, **opts, &block)
|
239
|
+
# Defines new endpoint or updates existing one.
|
240
|
+
#
|
241
|
+
# {Endpoint} is the thing doing the real work: providing Ruby API
|
242
|
+
# method to really call target API.
|
243
|
+
#
|
244
|
+
# **NB:** If you call `endpoint(:something)` and it was already defined,
|
245
|
+
# current definition will be added to existing one (but it can't
|
246
|
+
# change path of existing one, which is reasonable).
|
247
|
+
#
|
248
|
+
# **Works for:** API, namespace
|
249
|
+
#
|
250
|
+
# @param name [Symbol] Name of the method by which endpoint would
|
251
|
+
# be accessible.
|
252
|
+
# @param path [String] Path to call API from this endpoint.
|
253
|
+
# When not provided, considered to be `/<name>`. When provided,
|
254
|
+
# taken literally (no slashes or other symbols added). Note, that
|
255
|
+
# you can use `/../` in path, redesigning someone else's APIs on
|
256
|
+
# the fly. Also, you can use [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570.txt)
|
257
|
+
# URL templates to mark params going straightly into URI.
|
258
|
+
#
|
259
|
+
# Look at {#namespace} for examples, idea is the same.
|
260
|
+
#
|
261
|
+
# @param opts [Hash] Some options, currently only `:xml`.
|
262
|
+
# @option opts [true, false] :xml Whether endpoint's response should
|
263
|
+
# be parsed as XML (JSON otherwise & by default). Parsing in this
|
264
|
+
# case is performed with [crack](https://github.com/jnunemaker/crack),
|
265
|
+
# producing the hash, to which all other rules of post-processing
|
266
|
+
# are applied.
|
267
|
+
# @param block Definition of endpoint's params and docs.
|
268
|
+
# Note that by defining params inside this block, you can change
|
269
|
+
# endpoints's method call sequence.
|
270
|
+
#
|
271
|
+
# For example:
|
272
|
+
#
|
273
|
+
# ```ruby
|
274
|
+
# endpoint :foo
|
275
|
+
# # call-sequence: foo()
|
276
|
+
#
|
277
|
+
# endpoint :foo do
|
278
|
+
# param :bar
|
279
|
+
# end
|
280
|
+
# # call-sequence: foo(bar: nil)
|
281
|
+
#
|
282
|
+
# endpoint :foo do
|
283
|
+
# param :bar, required: true, keyword: false
|
284
|
+
# param :baz, required: true
|
285
|
+
# end
|
286
|
+
# # call-sequence: foo(bar, baz:)
|
287
|
+
# ```
|
288
|
+
#
|
289
|
+
# ...and so on. See also {#param} for understanding what you
|
290
|
+
# can change here.
|
291
|
+
|
292
|
+
# @!method post_process(key = nil, &block)
|
293
|
+
# Sets post-processors for response.
|
294
|
+
#
|
295
|
+
# There are also {#post_process_replace} (for replacing entire
|
296
|
+
# response with something else) and {#post_process_items} (for
|
297
|
+
# post-processing each item of sub-array).
|
298
|
+
#
|
299
|
+
# Notes:
|
300
|
+
#
|
301
|
+
# * you can set any number of post-processors of any kind, and they
|
302
|
+
# will be applied in exactly the same order they are set;
|
303
|
+
# * you can set post-processors in parent namespace (or for entire
|
304
|
+
# API), in this case post-processors of _outer_ namespace are
|
305
|
+
# always applied before inner ones. That allow you to define some
|
306
|
+
# generic parsing/rewriting on API level, then more specific
|
307
|
+
# key postprocessors on endpoints;
|
308
|
+
# * hashes are flattened again after _each_ post-processor, so if
|
309
|
+
# for some `key` you'll return `{count: 1, continue: false}`,
|
310
|
+
# response hash will immediately have
|
311
|
+
# `{"key.count" => 1, "key.continue" => false}`.
|
312
|
+
#
|
313
|
+
# @overload post_process(&block)
|
314
|
+
# Sets post-processor for whole response. Note, that in this case
|
315
|
+
# _return_ value of block is ignored, it is expected that your
|
316
|
+
# block will receive response and modify it inplace, like this:
|
317
|
+
#
|
318
|
+
# ```ruby
|
319
|
+
# post_process do |response|
|
320
|
+
# response['coord'] = Geo::Coord.new(response['lat'], response['lng'])
|
321
|
+
# end
|
322
|
+
# ```
|
323
|
+
# If you need to replace entire response with something else,
|
324
|
+
# see {#post_process_replace}
|
325
|
+
#
|
326
|
+
# @overload post_process(key, &block)
|
327
|
+
# Sets post-processor for one response key. Post-processor is
|
328
|
+
# called only if key exists in the response, and value by this
|
329
|
+
# key is replaced with post-processor's response.
|
330
|
+
#
|
331
|
+
# Note, that if `block` returns `nil`, key will be removed completely.
|
332
|
+
#
|
333
|
+
# Usage:
|
334
|
+
#
|
335
|
+
# ```ruby
|
336
|
+
# post_process('date') { |val| Date.parse(val) }
|
337
|
+
# # or, btw, just
|
338
|
+
# post_process('date', &Date.method(:parse))
|
339
|
+
# ```
|
340
|
+
#
|
341
|
+
# @param key [String]
|
342
|
+
|
343
|
+
# @!method post_process_items(key, &block)
|
344
|
+
# Sets post-processors for each items of array, being at `key` (if
|
345
|
+
# the key is present in response, and if its value is array of
|
346
|
+
# hashes).
|
347
|
+
#
|
348
|
+
# Inside `block` you can use {#post_process} method as described
|
349
|
+
# above (but all of its actions will be related only to current
|
350
|
+
# item of array).
|
351
|
+
#
|
352
|
+
# Example:
|
353
|
+
#
|
354
|
+
# Considering API response like:
|
355
|
+
#
|
356
|
+
# ```json
|
357
|
+
# {
|
358
|
+
# "meta": {"count": 100},
|
359
|
+
# "data": [
|
360
|
+
# {"timestamp": "2016-05-01", "value": "10", "dummy": "foo"},
|
361
|
+
# {"timestamp": "2016-05-02", "value": "13", "dummy": "bar"}
|
362
|
+
# ]
|
363
|
+
# }
|
364
|
+
# ```
|
365
|
+
# ...you can define postprocessing like this:
|
366
|
+
#
|
367
|
+
# ```ruby
|
368
|
+
# post_process_items 'data' do
|
369
|
+
# post_process 'timestamp', &Date.method(:parse)
|
370
|
+
# post_process 'value', &:to_i
|
371
|
+
# post_process('dummy'){nil} # will be removed
|
372
|
+
# end
|
373
|
+
# ```
|
374
|
+
#
|
375
|
+
# See also {#post_process} for some generic explanation of post-processing.
|
376
|
+
#
|
377
|
+
# @param key [String]
|
378
|
+
|
379
|
+
# @!method post_process_replace(&block)
|
380
|
+
# Just like {#post_process} for entire response, but _replaces_
|
381
|
+
# it with what block returns.
|
382
|
+
#
|
383
|
+
# Real-life usage: WorldBank API typically returns responses this
|
384
|
+
# way:
|
385
|
+
#
|
386
|
+
# ```json
|
387
|
+
# [
|
388
|
+
# {"count": 100, "page": 1},
|
389
|
+
# {"some_data_variable": [{}, {}, {}]}
|
390
|
+
# ]
|
391
|
+
# ```
|
392
|
+
# ...e.g. metadata and real response as two items in array, not
|
393
|
+
# two keys in hash. We can easily fix this:
|
394
|
+
#
|
395
|
+
# ```ruby
|
396
|
+
# post_process_replace do |response|
|
397
|
+
# {meta: response.first, data: response.last}
|
398
|
+
# end
|
399
|
+
# ```
|
400
|
+
#
|
401
|
+
# See also {#post_process} for some generic explanation of post-processing.
|
402
|
+
|
403
|
+
# @private
|
404
|
+
class BaseWrapper
|
405
|
+
def initialize(object)
|
406
|
+
@object = object
|
407
|
+
end
|
408
|
+
|
409
|
+
def define(&block)
|
410
|
+
instance_eval(&block)
|
411
|
+
end
|
412
|
+
|
413
|
+
def description(text)
|
414
|
+
# first, remove spaces at a beginning of each line
|
415
|
+
# then, remove empty lines before and after docs block
|
416
|
+
@object.description =
|
417
|
+
text
|
418
|
+
.gsub(/^[ \t]+/, '')
|
419
|
+
.gsub(/\A\n|\n\s*\Z/, '')
|
420
|
+
end
|
421
|
+
|
422
|
+
alias_method :desc, :description
|
423
|
+
|
424
|
+
def docs(link)
|
425
|
+
@object.docs_link = link
|
426
|
+
end
|
427
|
+
|
428
|
+
def param(name, type = nil, **opts)
|
429
|
+
@object.param_set.add(name, **opts.merge(type: type))
|
430
|
+
end
|
431
|
+
|
432
|
+
def post_process(key = nil, &block)
|
433
|
+
@object.response_processor.add_post_processor(key, &block)
|
434
|
+
end
|
435
|
+
|
436
|
+
def post_process_replace(&block)
|
437
|
+
@object.response_processor.add_replacer(&block)
|
438
|
+
end
|
439
|
+
|
440
|
+
class PostProcessProxy
|
441
|
+
def initialize(parent_key, parent)
|
442
|
+
@parent_key = parent_key
|
443
|
+
@parent = parent
|
444
|
+
end
|
445
|
+
|
446
|
+
def post_process(key = nil, &block)
|
447
|
+
@parent.add_item_post_processor(@parent_key, key, &block)
|
448
|
+
end
|
449
|
+
end
|
450
|
+
|
451
|
+
def post_process_items(key, &block)
|
452
|
+
PostProcessProxy
|
453
|
+
.new(key, @object.response_processor)
|
454
|
+
.instance_eval(&block)
|
455
|
+
end
|
456
|
+
end
|
457
|
+
|
458
|
+
# @private
|
459
|
+
class EndpointWrapper < BaseWrapper
|
460
|
+
end
|
461
|
+
|
462
|
+
# @private
|
463
|
+
class NamespaceWrapper < BaseWrapper
|
464
|
+
def endpoint(name, path = nil, **opts, &block)
|
465
|
+
update_existing(Endpoint, name, path, **opts, &block) ||
|
466
|
+
add_child(Endpoint, name, path: path || "/#{name}", **opts, &block)
|
467
|
+
end
|
468
|
+
|
469
|
+
def namespace(name, path = nil, &block)
|
470
|
+
update_existing(Namespace, name, path, &block) ||
|
471
|
+
add_child(Namespace, name, path: path || "/#{name}", &block)
|
472
|
+
end
|
473
|
+
|
474
|
+
private
|
475
|
+
|
476
|
+
WRAPPERS = {
|
477
|
+
Endpoint => EndpointWrapper,
|
478
|
+
Namespace => NamespaceWrapper
|
479
|
+
}.freeze
|
480
|
+
|
481
|
+
def update_existing(child_class, name, path, **opts, &block)
|
482
|
+
existing = @object.children[name] or return nil
|
483
|
+
existing < child_class or
|
484
|
+
fail ArgumentError, "#{name} is already defined as #{child_class == Endpoint ? 'namespace' : 'endpoint'}, you can't redefine it as #{child_class}"
|
485
|
+
|
486
|
+
!path && opts.empty? or
|
487
|
+
fail ArgumentError, "#{child_class} is already defined, you can't change its path or options"
|
488
|
+
|
489
|
+
WRAPPERS[child_class].new(existing).define(&block) if block
|
490
|
+
end
|
491
|
+
|
492
|
+
def add_child(child_class, name, **opts, &block)
|
493
|
+
@object.add_child(
|
494
|
+
child_class.inherit(@object, symbol: name, **opts)
|
495
|
+
.tap { |c| c.setup_parents(@object) }
|
496
|
+
.tap(&:params_from_path!)
|
497
|
+
.tap { |c|
|
498
|
+
WRAPPERS[child_class].new(c).define(&block) if block
|
499
|
+
}
|
500
|
+
)
|
501
|
+
end
|
502
|
+
end
|
503
|
+
|
504
|
+
# @private
|
505
|
+
class APIWrapper < NamespaceWrapper
|
506
|
+
def base(url)
|
507
|
+
@object.base_url = url
|
508
|
+
end
|
509
|
+
end
|
510
|
+
end
|
511
|
+
end
|