api-transformer 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +14 -0
- data/.overcommit.yml +27 -0
- data/.rubocop.yml +9 -0
- data/.rubocop_todo.yml +15 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +358 -0
- data/Rakefile +9 -0
- data/api-transformer.gemspec +31 -0
- data/examples/ip_server.rb +19 -0
- data/lib/api/transformer.rb +1 -0
- data/lib/api_transformer/backend_request.rb +79 -0
- data/lib/api_transformer/backend_request_sender.rb +56 -0
- data/lib/api_transformer/backend_response.rb +62 -0
- data/lib/api_transformer/endpoint.rb +120 -0
- data/lib/api_transformer/errors.rb +4 -0
- data/lib/api_transformer/frontend_response.rb +52 -0
- data/lib/api_transformer/frontend_response_builder.rb +138 -0
- data/lib/api_transformer/params.rb +8 -0
- data/lib/api_transformer/rack/cookie_params.rb +38 -0
- data/lib/api_transformer/routes.rb +71 -0
- data/lib/api_transformer/version.rb +4 -0
- data/lib/api_transformer.rb +147 -0
- data/spec/server_spec.rb +746 -0
- data/spec/spec_helper.rb +3 -0
- metadata +199 -0
@@ -0,0 +1,62 @@
|
|
1
|
+
require "json"
|
2
|
+
require "hashie"
|
3
|
+
|
4
|
+
module ApiTransformer
|
5
|
+
# Container for backend response data
|
6
|
+
class BackendResponse
|
7
|
+
# Subclasses Hash so that it can interchangeably use string or symbol access
|
8
|
+
class Data < Hash
|
9
|
+
include Hashie::Extensions::IndifferentAccess
|
10
|
+
end
|
11
|
+
|
12
|
+
attr_reader :body
|
13
|
+
|
14
|
+
def initialize(http)
|
15
|
+
@http = http
|
16
|
+
@body = ""
|
17
|
+
|
18
|
+
@http.stream { |chunk| @body += chunk }
|
19
|
+
end
|
20
|
+
|
21
|
+
def success?
|
22
|
+
status >= 200 && status < 300
|
23
|
+
end
|
24
|
+
|
25
|
+
def status
|
26
|
+
@http.response_header.http_status
|
27
|
+
end
|
28
|
+
|
29
|
+
def json
|
30
|
+
@data ||= Data.try_convert(JSON.parse(@body))
|
31
|
+
rescue JSON::ParserError
|
32
|
+
{}
|
33
|
+
end
|
34
|
+
|
35
|
+
def [](key)
|
36
|
+
json[key]
|
37
|
+
end
|
38
|
+
|
39
|
+
def cookies
|
40
|
+
pairs = @http.response_header["SET_COOKIE"].split("\s*;\s*")
|
41
|
+
data = pairs.reduce({}) do |hash, pair|
|
42
|
+
key, value = pair.split("=")
|
43
|
+
hash.merge(key => value)
|
44
|
+
end
|
45
|
+
|
46
|
+
Data.try_convert(data)
|
47
|
+
end
|
48
|
+
|
49
|
+
def stream(&block)
|
50
|
+
if block
|
51
|
+
block.call(@body)
|
52
|
+
|
53
|
+
@http.stream(&block)
|
54
|
+
EM::Synchrony::Iterator.new([block]).each do |_, iter|
|
55
|
+
@http.callback { iter.next }
|
56
|
+
end
|
57
|
+
else
|
58
|
+
fail "a block is required when streaming"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
module ApiTransformer
|
2
|
+
# Processes endpoint blocks
|
3
|
+
class Endpoint
|
4
|
+
def initialize(base_url, env, route)
|
5
|
+
@base_url = base_url
|
6
|
+
@env = env
|
7
|
+
@route = route
|
8
|
+
|
9
|
+
@backend_request_senders = []
|
10
|
+
@backend_responses = {}
|
11
|
+
end
|
12
|
+
|
13
|
+
def run
|
14
|
+
fail "Endpoints must define a response block" unless @builder
|
15
|
+
|
16
|
+
send_requests
|
17
|
+
send_response
|
18
|
+
end
|
19
|
+
|
20
|
+
def complete
|
21
|
+
if @error
|
22
|
+
complete_error
|
23
|
+
else
|
24
|
+
complete_success
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def request(name, options = {}, &block)
|
29
|
+
return unless !options.key?(:when) || options[:when]
|
30
|
+
|
31
|
+
@backend_request_senders << BackendRequestSender.new(
|
32
|
+
name,
|
33
|
+
options.merge(base_url: @base_url),
|
34
|
+
block,
|
35
|
+
@env["client-headers"],
|
36
|
+
@route[:helper_blocks]
|
37
|
+
)
|
38
|
+
end
|
39
|
+
|
40
|
+
def response(options = {}, &block)
|
41
|
+
@builder = FrontendResponseBuilder.new(
|
42
|
+
@env,
|
43
|
+
options,
|
44
|
+
block,
|
45
|
+
@route
|
46
|
+
)
|
47
|
+
end
|
48
|
+
|
49
|
+
def stream(value)
|
50
|
+
@stream = value
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def send_requests
|
56
|
+
@multi = EventMachine::MultiRequest.new
|
57
|
+
iterator = EM::Synchrony::Iterator.new(@backend_request_senders)
|
58
|
+
|
59
|
+
iterator.each do |sender, iter|
|
60
|
+
send_request(sender, iter)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def send_request(sender, iter)
|
65
|
+
http = sender.send(@backend_responses)
|
66
|
+
@backend_responses[sender.request_name] = BackendResponse.new(http)
|
67
|
+
@multi.add sender.request_name, http
|
68
|
+
http.headers { iter.next }
|
69
|
+
rescue ApiTransformer::RequestError => e
|
70
|
+
@error = e
|
71
|
+
iter.next
|
72
|
+
end
|
73
|
+
|
74
|
+
def send_response
|
75
|
+
if @stream
|
76
|
+
status_and_headers
|
77
|
+
else
|
78
|
+
if @backend_request_senders.any? && !@error
|
79
|
+
EM::Synchrony::Iterator.new([@multi]).each do |multi, iter|
|
80
|
+
multi.callback { iter.next }
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
status_and_headers
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def status_and_headers
|
89
|
+
if @error
|
90
|
+
[400, {}]
|
91
|
+
else
|
92
|
+
@builder.status_and_headers(@backend_responses)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def complete_success
|
97
|
+
EM.synchrony do
|
98
|
+
@builder.do_streaming
|
99
|
+
|
100
|
+
if @backend_request_senders.any?
|
101
|
+
@multi.callback do
|
102
|
+
close
|
103
|
+
end
|
104
|
+
else
|
105
|
+
close
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def complete_error
|
111
|
+
@builder.stream_write @error
|
112
|
+
close
|
113
|
+
end
|
114
|
+
|
115
|
+
def close
|
116
|
+
@builder.send_body
|
117
|
+
@builder.stream_close
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require "json"
|
2
|
+
|
3
|
+
module ApiTransformer
|
4
|
+
# Container for frontend response data
|
5
|
+
class FrontendResponse
|
6
|
+
attr_accessor :status
|
7
|
+
attr_writer :body, :content_type
|
8
|
+
|
9
|
+
def set(key, value)
|
10
|
+
@hash ||= {}
|
11
|
+
@hash[key] = value
|
12
|
+
end
|
13
|
+
|
14
|
+
def set_cookie(key, value)
|
15
|
+
@cookies ||= {}
|
16
|
+
@cookies[key] = value
|
17
|
+
end
|
18
|
+
|
19
|
+
def body
|
20
|
+
@hash && @hash.to_json || @body
|
21
|
+
end
|
22
|
+
|
23
|
+
def headers
|
24
|
+
cookie_header.merge(content_type_header)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def cookie_header
|
30
|
+
if @cookies
|
31
|
+
pairs = @cookies.map { |key, value| "#{key}=#{value}" }
|
32
|
+
{ "Set-Cookie" => pairs.join("; ") }
|
33
|
+
else
|
34
|
+
{}
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def content_type_header
|
39
|
+
{ "Content-Type" => content_type }
|
40
|
+
end
|
41
|
+
|
42
|
+
def content_type
|
43
|
+
if @content_type
|
44
|
+
@content_type
|
45
|
+
elsif @hash
|
46
|
+
"application/json"
|
47
|
+
else
|
48
|
+
"text/plain"
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
module ApiTransformer
|
2
|
+
# Processes the response block
|
3
|
+
class FrontendResponseBuilder
|
4
|
+
attr_reader :success
|
5
|
+
|
6
|
+
def initialize(env, options, block, route)
|
7
|
+
@env = env
|
8
|
+
@options = options
|
9
|
+
@block = block
|
10
|
+
@route = route
|
11
|
+
|
12
|
+
@frontend_response = FrontendResponse.new
|
13
|
+
end
|
14
|
+
|
15
|
+
def status_and_headers(backend_responses)
|
16
|
+
@route[:helper_blocks].each { |block| instance_eval(&block) }
|
17
|
+
instance_exec(backend_responses, &@block)
|
18
|
+
add_failure_handlers(backend_responses)
|
19
|
+
handle_success_or_failure(backend_responses)
|
20
|
+
|
21
|
+
[@frontend_response.status, @frontend_response.headers]
|
22
|
+
end
|
23
|
+
|
24
|
+
def body(value)
|
25
|
+
@frontend_response.body = value
|
26
|
+
end
|
27
|
+
|
28
|
+
def streaming?
|
29
|
+
options[:streaming]
|
30
|
+
end
|
31
|
+
|
32
|
+
def success(value = nil, &block)
|
33
|
+
@frontend_response.status = value
|
34
|
+
@success = block
|
35
|
+
end
|
36
|
+
|
37
|
+
def failure(run = true, &block)
|
38
|
+
run && !@failure && @failure = block
|
39
|
+
end
|
40
|
+
|
41
|
+
def status(value)
|
42
|
+
@frontend_response.status = value
|
43
|
+
end
|
44
|
+
|
45
|
+
def content_type(value)
|
46
|
+
@frontend_response.content_type = value
|
47
|
+
end
|
48
|
+
|
49
|
+
def cookie(key, value)
|
50
|
+
@frontend_response.set_cookie(key, value)
|
51
|
+
end
|
52
|
+
|
53
|
+
def attribute(key, value)
|
54
|
+
@frontend_response.set key, value
|
55
|
+
end
|
56
|
+
|
57
|
+
def object(key, klass, object_data)
|
58
|
+
@frontend_response.set key, object_hash(klass, object_data)
|
59
|
+
end
|
60
|
+
|
61
|
+
def array(key, klass, array_data)
|
62
|
+
value = array_data.map { |data| object_hash(klass, data) }
|
63
|
+
@frontend_response.set key, value
|
64
|
+
end
|
65
|
+
|
66
|
+
def send_body
|
67
|
+
stream_write @frontend_response.body
|
68
|
+
end
|
69
|
+
|
70
|
+
def stream(&block)
|
71
|
+
if block
|
72
|
+
@streaming_block = block
|
73
|
+
else
|
74
|
+
fail "a block is required when streaming"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def do_streaming
|
79
|
+
@streaming_block.call if @streaming_block
|
80
|
+
end
|
81
|
+
|
82
|
+
def stream_write(data)
|
83
|
+
@env.stream_send data
|
84
|
+
end
|
85
|
+
|
86
|
+
def stream_close
|
87
|
+
@env.stream_close
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
def add_failure_handlers(backend_responses)
|
93
|
+
@route[:failure_handlers].each do |block|
|
94
|
+
backend_responses.values.each do |backend_response|
|
95
|
+
instance_exec(backend_response, &block)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def handle_success_or_failure(backend_responses)
|
101
|
+
if @failure
|
102
|
+
handle_failure
|
103
|
+
elsif backend_responses.all? { |_, resp| resp.success? }
|
104
|
+
handle_success
|
105
|
+
else
|
106
|
+
unhandled_failure(backend_responses)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def handle_failure
|
111
|
+
set_failure_defaults
|
112
|
+
instance_eval(&@failure) if @failure
|
113
|
+
end
|
114
|
+
|
115
|
+
def handle_success
|
116
|
+
set_success_defaults
|
117
|
+
instance_eval(&@success) if @success
|
118
|
+
end
|
119
|
+
|
120
|
+
def unhandled_failure(backend_responses)
|
121
|
+
first_failure = backend_responses.values.first { |resp| !resp.success }
|
122
|
+
status first_failure.status
|
123
|
+
@frontend_response.body = first_failure.body
|
124
|
+
end
|
125
|
+
|
126
|
+
def object_hash(klass, data)
|
127
|
+
klass.new(data).to_hash
|
128
|
+
end
|
129
|
+
|
130
|
+
def set_success_defaults
|
131
|
+
@frontend_response.status ||= 200
|
132
|
+
end
|
133
|
+
|
134
|
+
def set_failure_defaults
|
135
|
+
@frontend_response.status ||= 400
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module ApiTransformer
|
2
|
+
module Rack
|
3
|
+
# Parses out cookies and make them available as request parameters
|
4
|
+
class CookieParams
|
5
|
+
# Cookie string parser
|
6
|
+
module Parser
|
7
|
+
def cookie_params(env)
|
8
|
+
cookie_string = env["HTTP_COOKIE"]
|
9
|
+
|
10
|
+
if cookie_string
|
11
|
+
cookie_pairs = cookie_string.split(/\s*;\s*/)
|
12
|
+
|
13
|
+
cookie_pairs.reduce({}) do |hash, pair|
|
14
|
+
key, value = pair.split("=")
|
15
|
+
hash.merge(key => value)
|
16
|
+
end
|
17
|
+
else
|
18
|
+
{}
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
include Goliath::Rack::Validator
|
24
|
+
include Parser
|
25
|
+
|
26
|
+
def initialize(app)
|
27
|
+
@app = app
|
28
|
+
end
|
29
|
+
|
30
|
+
def call(env)
|
31
|
+
Goliath::Rack::Validator.safely(env) do
|
32
|
+
env["params"].merge!(cookie_params(env))
|
33
|
+
@app.call(env)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
require "ostruct"
|
2
|
+
|
3
|
+
module ApiTransformer
|
4
|
+
# Collection of routes
|
5
|
+
class Routes
|
6
|
+
# A route
|
7
|
+
class Route < OpenStruct; end
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@routes = {}
|
11
|
+
end
|
12
|
+
|
13
|
+
def add(args)
|
14
|
+
pattern, path_params = parse_path_definition(args[:path])
|
15
|
+
|
16
|
+
route = Route.new(
|
17
|
+
path_params: path_params,
|
18
|
+
options: args[:options],
|
19
|
+
block: args[:block],
|
20
|
+
failure_handlers: args[:failure_handlers],
|
21
|
+
helper_blocks: args[:helper_blocks]
|
22
|
+
)
|
23
|
+
|
24
|
+
store_route(args[:method], pattern, route)
|
25
|
+
end
|
26
|
+
|
27
|
+
def find(raw_method, path)
|
28
|
+
method = raw_method.downcase.to_sym
|
29
|
+
return unless @routes[method]
|
30
|
+
|
31
|
+
pattern, route = @routes[method].find do |pattern, _|
|
32
|
+
path.match(pattern)
|
33
|
+
end
|
34
|
+
|
35
|
+
[route, path_params(path, route, pattern)]
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def parse_path_definition(path)
|
41
|
+
parts = path.split(/\//)
|
42
|
+
|
43
|
+
pattern_string = parts
|
44
|
+
.map { |part| part.match(/^\:/) ? "(.*?)" : part }
|
45
|
+
.join("\/")
|
46
|
+
.tap { |inner| "^#{inner}$" }
|
47
|
+
|
48
|
+
pattern = Regexp.new("^#{pattern_string}$")
|
49
|
+
|
50
|
+
params = parts
|
51
|
+
.select { |part| part.match(/^\:/) }
|
52
|
+
.map { |part| part.gsub(/^\:/, "").to_sym }
|
53
|
+
|
54
|
+
[pattern, params]
|
55
|
+
end
|
56
|
+
|
57
|
+
def path_params(path, route, pattern)
|
58
|
+
if pattern
|
59
|
+
matches = path.match(pattern)[1..-1]
|
60
|
+
Hash[route[:path_params].zip(matches)]
|
61
|
+
else
|
62
|
+
{}
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def store_route(method, pattern, route)
|
67
|
+
@routes[method] ||= {}
|
68
|
+
@routes[method][pattern] = route
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
require "goliath"
|
2
|
+
|
3
|
+
require_relative "./api_transformer/endpoint"
|
4
|
+
require_relative "./api_transformer/errors"
|
5
|
+
require_relative "./api_transformer/backend_request"
|
6
|
+
require_relative "./api_transformer/backend_request_sender"
|
7
|
+
require_relative "./api_transformer/backend_response"
|
8
|
+
require_relative "./api_transformer/frontend_response"
|
9
|
+
require_relative "./api_transformer/frontend_response_builder"
|
10
|
+
require_relative "./api_transformer/params"
|
11
|
+
require_relative "./api_transformer/routes"
|
12
|
+
require_relative "./api_transformer/rack/cookie_params"
|
13
|
+
|
14
|
+
module ApiTransformer
|
15
|
+
# Inherit from this class to implement an API transformation server:
|
16
|
+
#
|
17
|
+
# class ExampleServer < ApiTransformer::Server
|
18
|
+
# base_url "http://ip.jsontest.com/"
|
19
|
+
#
|
20
|
+
# get "/ip" do |params|
|
21
|
+
# request :ip do
|
22
|
+
# path "/"
|
23
|
+
# method :get
|
24
|
+
# end
|
25
|
+
#
|
26
|
+
# response do |data|
|
27
|
+
# success do
|
28
|
+
# status 200
|
29
|
+
# attribute :your_ip, data[:ip][:ip]
|
30
|
+
# end
|
31
|
+
# end
|
32
|
+
# end
|
33
|
+
# end
|
34
|
+
#
|
35
|
+
class Server < Goliath::API
|
36
|
+
use Goliath::Rack::Params
|
37
|
+
use ApiTransformer::Rack::CookieParams
|
38
|
+
|
39
|
+
def on_headers(env, headers)
|
40
|
+
env["client-headers"] = headers
|
41
|
+
end
|
42
|
+
|
43
|
+
def response(env)
|
44
|
+
path = env[Goliath::Request::REQUEST_PATH]
|
45
|
+
route, path_params = @@routes.find(env["REQUEST_METHOD"], path)
|
46
|
+
|
47
|
+
if route
|
48
|
+
run_route(route, path_params, env)
|
49
|
+
else
|
50
|
+
[404, {}, "nope"]
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def run_route(route, path_params, env)
|
55
|
+
indifferent_params = Params.try_convert(params.merge(path_params))
|
56
|
+
endpoint = Endpoint.new(@@base_url, env, route)
|
57
|
+
headers = env["client-headers"]
|
58
|
+
|
59
|
+
route[:helper_blocks].each { |block| endpoint.instance_eval(&block) }
|
60
|
+
endpoint.instance_exec(indifferent_params, headers, &route[:block])
|
61
|
+
|
62
|
+
status, headers = endpoint.run
|
63
|
+
|
64
|
+
EM.next_tick do
|
65
|
+
endpoint.complete
|
66
|
+
end
|
67
|
+
|
68
|
+
streaming_response(status, headers)
|
69
|
+
end
|
70
|
+
|
71
|
+
class << self
|
72
|
+
def inherited(klass)
|
73
|
+
klass.use Goliath::Rack::Params
|
74
|
+
end
|
75
|
+
|
76
|
+
def base_url(url)
|
77
|
+
@@base_url = url
|
78
|
+
end
|
79
|
+
|
80
|
+
def namespace(path)
|
81
|
+
prior = [@namespace, failure_handlers.dup, helper_blocks.dup]
|
82
|
+
@namespace = @namespace.to_s + path
|
83
|
+
|
84
|
+
yield
|
85
|
+
|
86
|
+
@namespace, @failure_handlers, @helper_blocks = prior
|
87
|
+
end
|
88
|
+
|
89
|
+
def unhandled_failures(&block)
|
90
|
+
failure_handlers.unshift(block)
|
91
|
+
end
|
92
|
+
|
93
|
+
def helpers(&block)
|
94
|
+
helper_blocks.unshift(block)
|
95
|
+
end
|
96
|
+
|
97
|
+
def get(path = "", options = {}, &block)
|
98
|
+
add_route(:get, path, options, block)
|
99
|
+
end
|
100
|
+
|
101
|
+
def post(path = "", options = {}, &block)
|
102
|
+
add_route(:post, path, options, block)
|
103
|
+
end
|
104
|
+
|
105
|
+
def put(path = "", options = {}, &block)
|
106
|
+
add_route(:put, path, options, block)
|
107
|
+
end
|
108
|
+
|
109
|
+
def delete(path = "", options = {}, &block)
|
110
|
+
add_route(:delete, path, options, block)
|
111
|
+
end
|
112
|
+
|
113
|
+
def reset_routes
|
114
|
+
@@routes = Routes.new
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
def add_route(method, path, options, block)
|
120
|
+
full_path = "#{@namespace}#{path}"
|
121
|
+
|
122
|
+
@@routes ||= Routes.new
|
123
|
+
@@routes.add(method: method, path: full_path, options: options,
|
124
|
+
block: block, failure_handlers: failure_handlers,
|
125
|
+
helper_blocks: helper_blocks)
|
126
|
+
end
|
127
|
+
|
128
|
+
def failure_handlers
|
129
|
+
@failure_handlers ||= []
|
130
|
+
end
|
131
|
+
|
132
|
+
def helper_blocks
|
133
|
+
@helper_blocks ||= []
|
134
|
+
end
|
135
|
+
|
136
|
+
def server
|
137
|
+
superclass
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
private
|
142
|
+
|
143
|
+
def server
|
144
|
+
self.class
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|