api-transformer 0.1.0

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,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,4 @@
1
+ module ApiTransformer
2
+ # Error with the frontend request coming in
3
+ class RequestError < StandardError; end
4
+ 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,8 @@
1
+ require "hashie"
2
+
3
+ module ApiTransformer
4
+ # Request parameters
5
+ class Params < Hash
6
+ include Hashie::Extensions::IndifferentAccess
7
+ end
8
+ 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,4 @@
1
+ # Top-level namespace
2
+ module ApiTransformer
3
+ VERSION = "0.1.0"
4
+ 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