api_valve 0.0.1.alpha

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e6b393476b3a17c2f66a154ed51be1d1ffe1bd8425ba4bcd3758336da16a169a
4
+ data.tar.gz: b320775933274fd57cd9fc1792f0f0c9b881d27b3ceb87df16328adadcf1d6cf
5
+ SHA512:
6
+ metadata.gz: 2c687fb655c0eed88ff7e197c7ea02346e92a113fc1c01940195b3df15d962f2c3224ebe6de122cb42db9c07d911f055510604013f5547d8a0637f5113407cdf
7
+ data.tar.gz: 18409ae39eabc1d45aaf34e7cd8e8328d339a0c1123c823408dedc414096eaa0c4dc4aa3272706672b107308bef08e1f52a37d3d707f3fc9ac1cc1f0d6bdb038
data/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # ApiValve
2
+
3
+ Lightweight API reverse proxy written in ruby. Based on rack.
4
+
5
+ ## Installation
6
+
7
+ Just add the gem
8
+
9
+ ```ruby
10
+ gem 'api_valve'
11
+ ```
12
+
13
+ See the [examples](https://github.com/mkon/api_valve/tree/master/examples) section on how to create & configure your own
14
+ proxy using this gem.
@@ -0,0 +1,11 @@
1
+ module ApiValve
2
+ module Benchmarking
3
+ def benchmark
4
+ result = nil
5
+ time = Benchmark.realtime do
6
+ result = yield
7
+ end
8
+ [result, time]
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ module ApiValve
2
+ module Error
3
+ class Base < RuntimeError
4
+ class_attribute :http_status
5
+ self.http_status = :server_error
6
+ end
7
+
8
+ Rack::Utils::SYMBOL_TO_STATUS_CODE.each do |sym, code|
9
+ next unless code >= 400
10
+ const_set sym.to_s.camelize, Class.new(Base) { self.http_status = sym }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,44 @@
1
+ module ApiValve
2
+ class ErrorResponder
3
+ def initialize(error)
4
+ @error = error
5
+ end
6
+
7
+ def call
8
+ [
9
+ status,
10
+ {'Content-Type' => 'application/json'},
11
+ [MultiJson.dump({errors: [json_error]}, mode: :compat)]
12
+ ]
13
+ end
14
+
15
+ private
16
+
17
+ def status
18
+ Rack::Utils::SYMBOL_TO_STATUS_CODE[@error.http_status] || 500
19
+ end
20
+
21
+ def json_error
22
+ {
23
+ status: status,
24
+ code: json_code,
25
+ detail: json_detail,
26
+ meta: json_meta
27
+ }.compact
28
+ end
29
+
30
+ def json_code
31
+ @error.try(:code) || Rack::Utils::HTTP_STATUS_CODES[status]
32
+ end
33
+
34
+ def json_detail
35
+ @error.message != @error.class.to_s ? @error.message : nil
36
+ end
37
+
38
+ def json_meta
39
+ (@error.try(:to_hash).presence || {}).merge(
40
+ backtrace: ApiValve.expose_backtraces ? json_backtrace : nil
41
+ ).compact.presence
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,75 @@
1
+ module ApiValve
2
+ # Wraps original request
3
+ # Responsible for altering the request before it is forwarded
4
+ class Forwarder::Request
5
+ attr_reader :original_request, :options
6
+
7
+ WHITELISTED_HEADERS = %w(
8
+ Accept
9
+ Content-Type
10
+ Forwarded
11
+ User-Agent
12
+ X-Forwarded-For
13
+ X-Forwarded-Host
14
+ X-Forwarded-Port
15
+ X-Forwarded-Proto
16
+ ).freeze
17
+ NOT_PREFIXED_HEADERS = %w(
18
+ Content-Length
19
+ Content-Type
20
+ ).freeze
21
+
22
+ def initialize(original_request, options = {})
23
+ @original_request = original_request
24
+ @options = default_options.merge options
25
+ end
26
+
27
+ def method
28
+ @method ||= original_request.request_method.downcase.to_sym
29
+ end
30
+
31
+ def path
32
+ path = options['endpoint'] || ''
33
+ if pattern = options['path']
34
+ path += pattern % options.dig('match_data').named_captures.symbolize_keys
35
+ else
36
+ path += original_request.path_info
37
+ end
38
+ path.gsub(%r{^/}, '')
39
+ end
40
+
41
+ def headers
42
+ whitelisted_headers.each_with_object({}) do |key, h|
43
+ h[key] = header(key)
44
+ end.merge('X-Request-Id' => Thread.current[:request_id]).compact
45
+ end
46
+
47
+ def header(name)
48
+ name = "HTTP_#{name}" unless NOT_PREFIXED_HEADERS.include? name
49
+ name = name.upcase.tr('-', '_')
50
+ original_request.get_header(name)
51
+ end
52
+
53
+ def body
54
+ return unless %i(put post patch).include? method
55
+ original_request.body.read
56
+ end
57
+
58
+ def url_params
59
+ return unless original_request.query_string.present?
60
+ @url_params ||= Rack::Utils.parse_nested_query(original_request.query_string)
61
+ end
62
+
63
+ private
64
+
65
+ def whitelisted_headers
66
+ @options[:whitelisted_headers]
67
+ end
68
+
69
+ def default_options
70
+ {
71
+ whitelisted_headers: WHITELISTED_HEADERS
72
+ }
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,36 @@
1
+ module ApiValve
2
+ # Wraps faraday response
3
+ # Responsible for altering the response before it is returned
4
+ class Forwarder::Response
5
+ attr_reader :original_response
6
+
7
+ WHITELISTED_HEADERS = %w(
8
+ Content-Type
9
+ Cache-Control
10
+ ).freeze
11
+
12
+ def initialize(original_response)
13
+ @original_response = original_response
14
+ end
15
+
16
+ def rack_response
17
+ [status, headers, [body]]
18
+ end
19
+
20
+ private
21
+
22
+ def status
23
+ original_response.status
24
+ end
25
+
26
+ def headers
27
+ WHITELISTED_HEADERS.each_with_object({}) do |k, h|
28
+ h[k] = original_response.headers[k]
29
+ end.compact
30
+ end
31
+
32
+ def body
33
+ original_response.body.to_s
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,75 @@
1
+ module ApiValve
2
+ class Forwarder
3
+ autoload :Request, 'api_valve/forwarder/request'
4
+ autoload :Response, 'api_valve/forwarder/response'
5
+
6
+ include Benchmarking
7
+
8
+ DEFAULT_OPTIONS = {
9
+ response_klass: Response,
10
+ request_klass: Request
11
+ }.freeze
12
+
13
+ attr_accessor :response_klass, :request_klass
14
+ attr_reader :endpoint
15
+
16
+ def initialize(options = {})
17
+ DEFAULT_OPTIONS.merge(options).each do |k, v|
18
+ public_send("#{k}=", v)
19
+ end
20
+ end
21
+
22
+ def call(original_request, request_options = {})
23
+ request = request_klass.new(original_request, request_options)
24
+ response_klass.new(run_request(request)).rack_response
25
+ end
26
+
27
+ # Enforce trailing slash
28
+ def endpoint=(endpoint)
29
+ @endpoint = File.join(endpoint, '')
30
+ end
31
+
32
+ private
33
+
34
+ def run_request(request)
35
+ log_request request
36
+ response, elapsed_time = benchmark do
37
+ faraday.run_request(request.method, request.path, request.body, request.headers) do |req|
38
+ req.params.update(request.url_params) if request.url_params
39
+ end
40
+ end
41
+ log_response response, elapsed_time
42
+ response
43
+ end
44
+
45
+ def log_request(request)
46
+ ApiValve.logger.info do
47
+ format(
48
+ '-> %<method>s %<endpoint>s%<path>s',
49
+ method: request.method.upcase,
50
+ endpoint: endpoint,
51
+ path: request.path
52
+ )
53
+ end
54
+ end
55
+
56
+ def log_response(response, elapsed_time)
57
+ ApiValve.logger.info do
58
+ format(
59
+ '<- %<status>s in %<ms>dms',
60
+ status: response.status,
61
+ ms: elapsed_time * 1000
62
+ )
63
+ end
64
+ end
65
+
66
+ def faraday
67
+ @faraday ||= Faraday.new(
68
+ url: endpoint,
69
+ ssl: {verify: false}
70
+ ) do |config|
71
+ config.adapter Faraday.default_adapter
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,15 @@
1
+ module ApiValve
2
+ module Middleware
3
+ class ErrorHandling
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ @app.call(env)
10
+ rescue Exception => e # rubocop:disable Lint/RescueException
11
+ ErrorResponder.new(e).call
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,68 @@
1
+ require 'byebug'
2
+
3
+ module ApiValve
4
+ class Proxy
5
+ include ActiveSupport::Rescuable
6
+
7
+ FORWARDER_OPTIONS = %w(endpoint).freeze
8
+
9
+ class << self
10
+ def from_yaml(file_path)
11
+ from_hash YAML.load_file(file_path)
12
+ end
13
+
14
+ def from_hash(config)
15
+ config = config.with_indifferent_access
16
+ forwarder = Forwarder.new(config.slice(*FORWARDER_OPTIONS))
17
+ new(forwarder).tap { |proxy| proxy.build_routes_from_config config }
18
+ end
19
+ end
20
+
21
+ attr_reader :forwarder, :router
22
+
23
+ def initialize(forwarder)
24
+ @forwarder = forwarder
25
+ @router = Router.new
26
+ end
27
+
28
+ def call(*args)
29
+ @router.call(*args)
30
+ rescue ApiValve::Error::Base => e
31
+ ErrorResponder.new(e).call
32
+ end
33
+
34
+ delegate :add_route, to: :router
35
+
36
+ def build_routes_from_config(config)
37
+ config['routes']&.each do |route_config|
38
+ method, path_regexp, req_conf = *route_config.values_at('method', 'path', 'request')
39
+ if route_config['raise']
40
+ deny method, path_regexp, with: route_config['raise']
41
+ else
42
+ forward method, path_regexp, req_conf
43
+ end
44
+ end
45
+ forward_all
46
+ end
47
+
48
+ def forward(methods, path_regexp = nil, config = {})
49
+ Array.wrap(methods).each do |method|
50
+ router.public_send(method, path_regexp, proc { |request, match_data|
51
+ forwarder.call request, {'match_data' => match_data}.merge(config || {})
52
+ })
53
+ end
54
+ end
55
+
56
+ def forward_all
57
+ router.any do |request, match_data|
58
+ forwarder.call request, 'match_data' => match_data
59
+ end
60
+ end
61
+
62
+ def deny(methods, path_regexp = nil, with: 'Error::Forbidden')
63
+ Array.wrap(methods).each do |method|
64
+ router.public_send(method, path_regexp, ->(*_args) { raise ApiValve.const_get(with) })
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,74 @@
1
+ module ApiValve
2
+ class Router
3
+ METHODS = %i(get post put patch delete head).freeze
4
+
5
+ Route = Struct.new(:regexp, :block) do
6
+ delegate :call, to: :block
7
+
8
+ def match(path_info)
9
+ return {} if regexp.nil? # return empty 'match data' on catch all
10
+ regexp.match(path_info)
11
+ end
12
+ end
13
+
14
+ def initialize
15
+ reset_routes
16
+ end
17
+
18
+ def call(env)
19
+ match Rack::Request.new(env)
20
+ end
21
+
22
+ def delete(path = nil, callee = nil)
23
+ add_route :delete, path, callee || Proc.new
24
+ end
25
+
26
+ def get(path = nil, callee = nil)
27
+ add_route :get, path, callee || Proc.new
28
+ end
29
+
30
+ def head(path = nil, callee = nil)
31
+ add_route :head, path, callee || Proc.new
32
+ end
33
+
34
+ def patch(path = nil, callee = nil)
35
+ add_route :patch, path, callee || Proc.new
36
+ end
37
+
38
+ def post(path = nil, callee = nil)
39
+ add_route :post, path, callee || Proc.new
40
+ end
41
+
42
+ def put(path = nil, callee = nil)
43
+ add_route :put, path, callee || Proc.new
44
+ end
45
+
46
+ def any(path = nil, callee = nil)
47
+ add_route METHODS, path, callee || Proc.new
48
+ end
49
+
50
+ def reset_routes
51
+ @routes = Hash[METHODS.map { |v| [v, []] }].freeze
52
+ end
53
+
54
+ private
55
+
56
+ def add_route(methods, regexp, callee)
57
+ Array.wrap(methods).each do |method|
58
+ @routes[method] << Route.new(regexp, callee)
59
+ end
60
+ end
61
+
62
+ def match(request)
63
+ # For security reasons do not allow URLs that could break out of the proxy namespace on the
64
+ # server. Preferably an nxing/apache rewrite will kill these URLs before they hit us
65
+ raise 'URL not supported' if request.path_info.include?('/../')
66
+ @routes && @routes[request.request_method.downcase.to_sym].each do |route|
67
+ if match_data = route.match(request.path_info)
68
+ return route.call request, match_data
69
+ end
70
+ end
71
+ raise Error::NotFound, 'Endpoint not found'
72
+ end
73
+ end
74
+ end
data/lib/api_valve.rb ADDED
@@ -0,0 +1,38 @@
1
+ require 'active_support/configurable'
2
+ require 'active_support/core_ext/class'
3
+ require 'active_support/core_ext/hash'
4
+ require 'active_support/core_ext/module'
5
+ require 'active_support/rescuable'
6
+ require 'benchmark'
7
+ require 'faraday'
8
+ require 'multi_json'
9
+ require 'logger'
10
+
11
+ module ApiValve
12
+ autoload :Benchmarking, 'api_valve/benchmarking'
13
+ autoload :Error, 'api_valve/error'
14
+ autoload :ErrorResponder, 'api_valve/error_responder'
15
+ autoload :Forwarder, 'api_valve/forwarder'
16
+ autoload :Proxy, 'api_valve/proxy'
17
+ autoload :Router, 'api_valve/router'
18
+
19
+ include ActiveSupport::Configurable
20
+
21
+ module Middleware
22
+ autoload :ErrorHandling, 'api_valve/middleware/error_handling'
23
+ end
24
+
25
+ config_accessor :logger do
26
+ Logger.new(STDOUT)
27
+ end
28
+
29
+ config_accessor :expose_backtraces do
30
+ false
31
+ end
32
+
33
+ # :nocov:
34
+ def self.configure
35
+ yield config
36
+ end
37
+ # :nocov:
38
+ end
metadata ADDED
@@ -0,0 +1,215 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: api_valve
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1.alpha
5
+ platform: ruby
6
+ authors:
7
+ - mkon
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-05-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 5.0.2
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '6'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 5.0.2
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '6'
33
+ - !ruby/object:Gem::Dependency
34
+ name: faraday
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.14'
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.14'
47
+ - !ruby/object:Gem::Dependency
48
+ name: multi_json
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: rack
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2'
68
+ type: :runtime
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '2'
75
+ - !ruby/object:Gem::Dependency
76
+ name: rack-test
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ - !ruby/object:Gem::Dependency
90
+ name: rspec
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3'
96
+ type: :development
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '3'
103
+ - !ruby/object:Gem::Dependency
104
+ name: rubocop
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - '='
108
+ - !ruby/object:Gem::Version
109
+ version: 0.54.0
110
+ type: :development
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - '='
115
+ - !ruby/object:Gem::Version
116
+ version: 0.54.0
117
+ - !ruby/object:Gem::Dependency
118
+ name: rubocop-rspec
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - '='
122
+ - !ruby/object:Gem::Version
123
+ version: 1.22.2
124
+ type: :development
125
+ prerelease: false
126
+ version_requirements: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - '='
129
+ - !ruby/object:Gem::Version
130
+ version: 1.22.2
131
+ - !ruby/object:Gem::Dependency
132
+ name: simplecov
133
+ requirement: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ type: :development
139
+ prerelease: false
140
+ version_requirements: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '0'
145
+ - !ruby/object:Gem::Dependency
146
+ name: timecop
147
+ requirement: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '0'
152
+ type: :development
153
+ prerelease: false
154
+ version_requirements: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - "~>"
157
+ - !ruby/object:Gem::Version
158
+ version: '0'
159
+ - !ruby/object:Gem::Dependency
160
+ name: webmock
161
+ requirement: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - "~>"
164
+ - !ruby/object:Gem::Version
165
+ version: '2'
166
+ type: :development
167
+ prerelease: false
168
+ version_requirements: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - "~>"
171
+ - !ruby/object:Gem::Version
172
+ version: '2'
173
+ description:
174
+ email:
175
+ - konstantin@munteanu.de
176
+ executables: []
177
+ extensions: []
178
+ extra_rdoc_files: []
179
+ files:
180
+ - README.md
181
+ - lib/api_valve.rb
182
+ - lib/api_valve/benchmarking.rb
183
+ - lib/api_valve/error.rb
184
+ - lib/api_valve/error_responder.rb
185
+ - lib/api_valve/forwarder.rb
186
+ - lib/api_valve/forwarder/request.rb
187
+ - lib/api_valve/forwarder/response.rb
188
+ - lib/api_valve/middleware/error_handling.rb
189
+ - lib/api_valve/proxy.rb
190
+ - lib/api_valve/router.rb
191
+ homepage: https://github.com/mkon/api_valve
192
+ licenses:
193
+ - MIT
194
+ metadata: {}
195
+ post_install_message:
196
+ rdoc_options: []
197
+ require_paths:
198
+ - lib
199
+ required_ruby_version: !ruby/object:Gem::Requirement
200
+ requirements:
201
+ - - ">="
202
+ - !ruby/object:Gem::Version
203
+ version: '0'
204
+ required_rubygems_version: !ruby/object:Gem::Requirement
205
+ requirements:
206
+ - - ">"
207
+ - !ruby/object:Gem::Version
208
+ version: 1.3.1
209
+ requirements: []
210
+ rubyforge_project:
211
+ rubygems_version: 2.7.6
212
+ signing_key:
213
+ specification_version: 4
214
+ summary: Lightweight ruby/rack API reverse proxy or gateway
215
+ test_files: []