blest 0.0.2 → 1.0.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.
- checksums.yaml +4 -4
- data/README.md +60 -99
- data/lib/blest.rb +811 -143
- data/spec/blest_spec.rb +220 -0
- metadata +10 -9
data/lib/blest.rb
CHANGED
@@ -1,14 +1,340 @@
|
|
1
1
|
require 'socket'
|
2
2
|
require 'json'
|
3
|
+
require 'concurrent'
|
4
|
+
require 'securerandom'
|
5
|
+
require 'net/http'
|
3
6
|
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
+
|
8
|
+
|
9
|
+
class Router
|
10
|
+
attr_reader :routes
|
11
|
+
|
12
|
+
def initialize(options = nil)
|
13
|
+
@middleware = []
|
14
|
+
@afterware = []
|
15
|
+
@timeout = 5000
|
16
|
+
@introspection = false
|
17
|
+
@routes = {}
|
18
|
+
|
19
|
+
if options
|
20
|
+
@timeout = options['timeout'] || options[:timeout] || 5000
|
21
|
+
@introspection = options['introspection'] || options[:introspection] || false
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def before(&handler)
|
26
|
+
unless handler.is_a?(Proc)
|
27
|
+
raise ArgumentError, 'Before handlers should be procs'
|
28
|
+
end
|
29
|
+
|
30
|
+
arg_count = handler.arity
|
31
|
+
if arg_count <= 2
|
32
|
+
@middleware.push(handler)
|
33
|
+
else
|
34
|
+
raise ArgumentError, 'Before handlers should have at most three arguments'
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def after(&handler)
|
39
|
+
unless handler.is_a?(Proc)
|
40
|
+
raise ArgumentError, 'After handlers should be procs'
|
41
|
+
end
|
42
|
+
|
43
|
+
arg_count = handler.arity
|
44
|
+
if arg_count <= 2
|
45
|
+
@afterware.push(handler)
|
46
|
+
else
|
47
|
+
raise ArgumentError, 'After handlers should have at most two arguments'
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def route(route, &handler)
|
52
|
+
route_error = validate_route(route, false)
|
53
|
+
raise ArgumentError, route_error if route_error
|
54
|
+
raise ArgumentError, 'Route already exists' if @routes.key?(route)
|
55
|
+
raise ArgumentError, 'Handler should be a function' unless handler.respond_to?(:call)
|
56
|
+
|
57
|
+
@routes[route] = {
|
58
|
+
handler: [*@middleware, handler, *@afterware],
|
59
|
+
description: nil,
|
60
|
+
schema: nil,
|
61
|
+
visible: @introspection,
|
62
|
+
validate: false,
|
63
|
+
timeout: @timeout
|
64
|
+
}
|
65
|
+
end
|
66
|
+
|
67
|
+
def describe(route, config)
|
68
|
+
raise ArgumentError, 'Route does not exist' unless @routes.key?(route)
|
69
|
+
raise ArgumentError, 'Configuration should be an object' unless config.is_a?(Hash)
|
70
|
+
|
71
|
+
if config.key?('description')
|
72
|
+
raise ArgumentError, 'Description should be a str' if !config['description'].nil? && !config['description'].is_a?(String)
|
73
|
+
@routes[route]['description'] = config['description']
|
74
|
+
end
|
75
|
+
|
76
|
+
if config.key?('schema')
|
77
|
+
raise ArgumentError, 'Schema should be a dict' if !config['schema'].nil? && !config['schema'].is_a?(Hash)
|
78
|
+
@routes[route]['schema'] = config['schema']
|
79
|
+
end
|
80
|
+
|
81
|
+
if config.key?('visible')
|
82
|
+
raise ArgumentError, 'Visible should be True or False' if !config['visible'].nil? && ![true, false].include?(config['visible'])
|
83
|
+
@routes[route]['visible'] = config['visible']
|
84
|
+
end
|
85
|
+
|
86
|
+
if config.key?('validate')
|
87
|
+
raise ArgumentError, 'Validate should be True or False' if !config['validate'].nil? && ![true, false].include?(config['validate'])
|
88
|
+
@routes[route]['validate'] = config['validate']
|
89
|
+
end
|
90
|
+
|
91
|
+
if config.key?('timeout')
|
92
|
+
raise ArgumentError, 'Timeout should be a positive int' if !config['timeout'].nil? && (!config['timeout'].is_a?(Integer) || config['timeout'] <= 0)
|
93
|
+
@routes[route]['timeout'] = config['timeout']
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def merge(router)
|
98
|
+
raise ArgumentError, 'Router is required' unless router.is_a?(Router)
|
99
|
+
|
100
|
+
new_routes = router.routes.keys
|
101
|
+
existing_routes = @routes.keys
|
102
|
+
|
103
|
+
raise ArgumentError, 'No routes to merge' if new_routes.empty?
|
104
|
+
|
105
|
+
new_routes.each do |route|
|
106
|
+
if existing_routes.include?(route)
|
107
|
+
raise ArgumentError, 'Cannot merge duplicate routes: ' + route
|
108
|
+
else
|
109
|
+
@routes[route] = {
|
110
|
+
**router.routes[route],
|
111
|
+
handler: @middleware + router.routes[route][:handler] + @afterware,
|
112
|
+
timeout: router.routes[route][:timeout] || @timeout
|
113
|
+
}
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def namespace(prefix, router)
|
119
|
+
raise ArgumentError, 'Router is required' unless router.is_a?(Router)
|
120
|
+
|
121
|
+
prefix_error = validate_route(prefix, false)
|
122
|
+
raise ArgumentError, prefix_error if prefix_error
|
123
|
+
|
124
|
+
new_routes = router.routes.keys
|
125
|
+
existing_routes = @routes.keys
|
126
|
+
|
127
|
+
raise ArgumentError, 'No routes to namespace' if new_routes.empty?
|
128
|
+
|
129
|
+
new_routes.each do |route|
|
130
|
+
ns_route = "#{prefix}/#{route}"
|
131
|
+
if existing_routes.include?(ns_route)
|
132
|
+
raise ArgumentError, 'Cannot merge duplicate routes: ' + ns_route
|
133
|
+
else
|
134
|
+
@routes[ns_route] = {
|
135
|
+
**router.routes[route],
|
136
|
+
handler: @middleware + router.routes[route][:handler] + @afterware,
|
137
|
+
timeout: router.routes[route].fetch('timeout', @timeout)
|
138
|
+
}
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
def handle(request, context = {})
|
144
|
+
handle_request(@routes, request, context)
|
145
|
+
end
|
146
|
+
|
147
|
+
end
|
148
|
+
|
149
|
+
|
150
|
+
|
151
|
+
class Blest < Router
|
152
|
+
@options = nil
|
153
|
+
@errorhandler = nil
|
154
|
+
|
155
|
+
def initialize(options = nil)
|
156
|
+
@options = options
|
157
|
+
super(options)
|
158
|
+
end
|
159
|
+
|
160
|
+
def errorhandler(&errorhandler)
|
161
|
+
@errorhandler = errorhandler
|
162
|
+
puts 'The errorhandler method is not currently used'
|
163
|
+
end
|
164
|
+
|
165
|
+
def listen(*args)
|
166
|
+
request_handler = create_request_handler(@routes)
|
167
|
+
server = create_http_server(request_handler, @options)
|
168
|
+
server.call(*args)
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
|
173
|
+
|
174
|
+
class HttpClient
|
175
|
+
attr_reader :queue, :futures
|
176
|
+
attr_accessor :url, :max_batch_size, :buffer_delay, :headers
|
177
|
+
|
178
|
+
def initialize(url, max_batch_size = 25, buffer_delay = 10, http_headers = {})
|
179
|
+
@url = url
|
180
|
+
@max_batch_size = max_batch_size
|
181
|
+
@buffer_delay = buffer_delay
|
182
|
+
@http_headers = http_headers
|
183
|
+
@queue = Queue.new
|
184
|
+
@futures = {}
|
185
|
+
@lock = Mutex.new
|
186
|
+
end
|
187
|
+
|
188
|
+
def request(route, body=nil, headers=nil)
|
189
|
+
uuid = SecureRandom.uuid()
|
190
|
+
future = Concurrent::Promises.resolvable_future
|
191
|
+
@lock.synchronize do
|
192
|
+
@futures[uuid] = future
|
193
|
+
end
|
194
|
+
|
195
|
+
@queue.push({ uuid: uuid, data: [uuid, route, body, headers] })
|
196
|
+
process_timeout()
|
197
|
+
future
|
198
|
+
end
|
199
|
+
|
200
|
+
private
|
201
|
+
|
202
|
+
def process_timeout
|
203
|
+
Thread.new do
|
204
|
+
sleep @buffer_delay / 1000.0
|
205
|
+
process
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
def process
|
210
|
+
until @queue.empty?
|
211
|
+
batch = []
|
212
|
+
batch << @queue.pop until batch.length >= @max_batch_size || @queue.empty?
|
213
|
+
|
214
|
+
unless batch.empty?
|
215
|
+
response = send_batch(batch)
|
216
|
+
process_response(response)
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
def send_batch(batch)
|
222
|
+
uri = URI(@url)
|
223
|
+
path = uri.path
|
224
|
+
path = '/' if uri.path.empty?
|
225
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
226
|
+
http.use_ssl = true if uri.scheme == 'https'
|
227
|
+
|
228
|
+
request = Net::HTTP::Post.new(path, @http_headers.merge({ 'Accept' => 'application/json', 'Content-Type' => 'application/json' }))
|
229
|
+
request.body = JSON.generate(batch.map { |item| item[:data] })
|
230
|
+
|
231
|
+
http.request(request)
|
232
|
+
end
|
233
|
+
|
234
|
+
def process_response(response)
|
235
|
+
if response.is_a?(Net::HTTPSuccess)
|
236
|
+
|
237
|
+
results = JSON.parse(response.body)
|
238
|
+
results.each do |result|
|
239
|
+
uuid = result[0]
|
240
|
+
data = result[2]
|
241
|
+
error = result[3]
|
242
|
+
future = nil
|
243
|
+
|
244
|
+
@lock.synchronize do
|
245
|
+
future = @futures.delete(uuid)
|
246
|
+
end
|
247
|
+
|
248
|
+
if future
|
249
|
+
if error
|
250
|
+
future.reject(error)
|
251
|
+
else
|
252
|
+
future.fulfill(data)
|
253
|
+
end
|
254
|
+
end
|
255
|
+
end
|
256
|
+
|
257
|
+
else
|
258
|
+
@lock.synchronize do
|
259
|
+
future = @futures.delete(uuid)
|
260
|
+
end
|
261
|
+
|
262
|
+
if future
|
263
|
+
error_message = "HTTP Error: #{response.code} - #{response.message}"
|
264
|
+
future.reject(error_message)
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
268
|
+
end
|
269
|
+
|
270
|
+
|
271
|
+
|
272
|
+
def get_value(hash, key)
|
273
|
+
if hash.key?(key.to_sym)
|
274
|
+
hash[key.to_sym]
|
275
|
+
elsif hash.key?(key.to_s)
|
276
|
+
hash[key.to_s]
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
|
281
|
+
|
282
|
+
class HttpServer
|
283
|
+
attr_reader :url, :host, :port, :cors, :headers
|
284
|
+
|
285
|
+
def initialize(request_handler, options = {})
|
286
|
+
unless request_handler.is_a?(Router)
|
287
|
+
raise ArgumentError, "request_handler must be an instance of Router class"
|
288
|
+
end
|
289
|
+
@request_handler = request_handler
|
290
|
+
if options
|
291
|
+
options_error = validate_server_options(options)
|
292
|
+
raise ArgumentError, options_error if options_error
|
293
|
+
else
|
294
|
+
@options = {}
|
295
|
+
end
|
296
|
+
|
297
|
+
@url = options && get_value(options, :url) || '/'
|
298
|
+
@host = options && get_value(options, :host) || 'localhost'
|
299
|
+
@port = options && get_value(options, :port) || 8080
|
300
|
+
@cors = options && get_value(options, :cors) || false
|
301
|
+
cors_default = cors == true ? '*' : cors || ''
|
302
|
+
|
303
|
+
@headers = {
|
304
|
+
'access-control-allow-origin' => options && get_value(options, :accessControlAllowOrigin) || cors_default,
|
305
|
+
'content-security-policy' => options && get_value(options, :contentSecurityPolicy) || "default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests",
|
306
|
+
'cross-origin-opener-policy' => options && get_value(options, :crossOriginOpenerPolicy) || 'same-origin',
|
307
|
+
'cross-origin-resource-policy' => options && get_value(options, :crossOriginResourcePolicy) || 'same-origin',
|
308
|
+
'origin-agent-cluster' => options && get_value(options, :originAgentCluster) || '?1',
|
309
|
+
'referrer-policy' => options && get_value(options, :referrerPolicy) || 'no-referrer',
|
310
|
+
'strict-transport-security' => options && get_value(options, :strictTransportSecurity) || 'max-age=1555200 includeSubDomains',
|
311
|
+
'x-content-type-options' => options && get_value(options, :xContentTypeOptions) || 'nosniff',
|
312
|
+
'x-dns-prefetch-control' => options && get_value(options, :xDnsPrefetchOptions) || 'off',
|
313
|
+
'x-download-options' => options && get_value(options, :xDownloadOptions) || 'noopen',
|
314
|
+
'x-frame-options' => options && get_value(options, :xFrameOptions) || 'SAMEORIGIN',
|
315
|
+
'x-permitted-cross-domain-policies' => options && get_value(options, :xPermittedCrossDomainPolicies) || 'none',
|
316
|
+
'x-xss-protection' => options && get_value(options, :xXssProtection) || '0'
|
317
|
+
}
|
318
|
+
end
|
319
|
+
|
320
|
+
def listen()
|
321
|
+
server = TCPServer.new(@host, @port)
|
322
|
+
puts "Server listening on port #{@port}"
|
323
|
+
|
324
|
+
loop do
|
325
|
+
client = server.accept
|
326
|
+
Thread.new { handle_http_request(client, @request_handler, @http_headers) }
|
327
|
+
end
|
328
|
+
end
|
329
|
+
|
330
|
+
private
|
331
|
+
|
332
|
+
def parse_http_request(request_line)
|
7
333
|
method, path, _ = request_line.split(' ')
|
8
334
|
{ method: method, path: path }
|
9
335
|
end
|
10
336
|
|
11
|
-
def
|
337
|
+
def parse_http_headers(client)
|
12
338
|
headers = {}
|
13
339
|
|
14
340
|
while (line = client.gets.chomp)
|
@@ -21,7 +347,7 @@ def create_http_server(request_handler, options = nil)
|
|
21
347
|
headers
|
22
348
|
end
|
23
349
|
|
24
|
-
def
|
350
|
+
def parse_post_body(client, content_length)
|
25
351
|
body = ''
|
26
352
|
|
27
353
|
while content_length > 0
|
@@ -33,7 +359,7 @@ def create_http_server(request_handler, options = nil)
|
|
33
359
|
body
|
34
360
|
end
|
35
361
|
|
36
|
-
def
|
362
|
+
def build_http_response(status, headers, body)
|
37
363
|
response = "HTTP/1.1 #{status}\r\n"
|
38
364
|
headers.each { |key, value| response += "#{key}: #{value}\r\n" }
|
39
365
|
response += "\r\n"
|
@@ -41,30 +367,24 @@ def create_http_server(request_handler, options = nil)
|
|
41
367
|
response
|
42
368
|
end
|
43
369
|
|
44
|
-
def
|
370
|
+
def handle_http_request(client, request_handler, http_headers)
|
45
371
|
request_line = client.gets
|
46
372
|
return unless request_line
|
47
373
|
|
48
|
-
request =
|
374
|
+
request = parse_http_request(request_line)
|
49
375
|
|
50
|
-
headers =
|
51
|
-
|
52
|
-
cors_headers = {
|
53
|
-
'Access-Control-Allow-Origin' => '*',
|
54
|
-
'Access-Control-Allow-Methods' => 'POST, OPTIONS',
|
55
|
-
'Access-Control-Allow-Headers' => 'Content-Type'
|
56
|
-
}
|
376
|
+
headers = parse_http_headers(client)
|
57
377
|
|
58
378
|
if request[:path] != '/'
|
59
|
-
response =
|
379
|
+
response = build_http_response('404 Not Found', http_headers, '')
|
60
380
|
client.print(response)
|
61
381
|
elsif request[:method] == 'OPTIONS'
|
62
|
-
response =
|
382
|
+
response = build_http_response('204 No Content', http_headers, '')
|
63
383
|
client.print(response)
|
64
384
|
elsif request[:method] == 'POST'
|
65
385
|
|
66
386
|
content_length = headers['Content-Length'].to_i
|
67
|
-
body =
|
387
|
+
body = parse_post_body(client, content_length)
|
68
388
|
|
69
389
|
begin
|
70
390
|
json_data = JSON.parse(body)
|
@@ -72,7 +392,7 @@ def create_http_server(request_handler, options = nil)
|
|
72
392
|
'headers' => headers
|
73
393
|
}
|
74
394
|
|
75
|
-
response_headers =
|
395
|
+
response_headers = http_headers.merge({
|
76
396
|
'Content-Type' => 'application/json'
|
77
397
|
})
|
78
398
|
|
@@ -80,202 +400,550 @@ def create_http_server(request_handler, options = nil)
|
|
80
400
|
|
81
401
|
if error
|
82
402
|
response_json = error.to_json
|
83
|
-
response =
|
403
|
+
response = build_http_response('500 Internal Server Error', response_headers, response_json)
|
84
404
|
client.print response
|
85
405
|
elsif result
|
86
406
|
response_json = result.to_json
|
87
|
-
response =
|
407
|
+
response = build_http_response('200 OK', response_headers, response_json)
|
88
408
|
client.print response
|
89
409
|
else
|
90
|
-
response =
|
410
|
+
response = build_http_response('500 Internal Server Error', response_headers, { 'message' => 'Request handler failed to return a result' }.to_json)
|
91
411
|
client.print response
|
92
412
|
end
|
93
413
|
|
94
414
|
rescue JSON::ParserError
|
95
|
-
response =
|
415
|
+
response = build_http_response('400 Bad Request', http_headers, '')
|
96
416
|
end
|
97
417
|
|
98
418
|
else
|
99
|
-
response =
|
419
|
+
response = build_http_response('405 Method Not Allowed', http_headers, '')
|
100
420
|
client.print(response)
|
101
421
|
end
|
102
422
|
client.close()
|
103
423
|
end
|
104
424
|
|
105
|
-
|
425
|
+
end
|
106
426
|
|
107
|
-
port = options&.fetch(:port, 8080) if options.is_a?(Hash)
|
108
|
-
port ||= 8080
|
109
427
|
|
110
|
-
server = TCPServer.new('localhost', 8080)
|
111
|
-
puts "Server listening on port #{port}"
|
112
428
|
|
113
|
-
loop do
|
114
|
-
client = server.accept
|
115
|
-
Thread.new { handle_request(client, request_handler) }
|
116
|
-
end
|
117
429
|
|
118
|
-
end
|
119
430
|
|
120
|
-
return run
|
121
|
-
end
|
122
431
|
|
123
|
-
|
432
|
+
|
433
|
+
def create_http_server(request_handler, options = nil)
|
124
434
|
if options
|
125
|
-
|
435
|
+
options_error = validate_server_options(options)
|
436
|
+
raise ArgumentError, options_error if options_error
|
126
437
|
end
|
127
438
|
|
128
|
-
|
129
|
-
|
439
|
+
url = options && get_value(options, :url) || '/'
|
440
|
+
port = options && get_value(options, :port) || 8080
|
441
|
+
cors = options && get_value(options, :cors) || false
|
442
|
+
cors_default = cors == true ? '*' : cors || ''
|
443
|
+
|
444
|
+
http_headers = {
|
445
|
+
'access-control-allow-origin' => options && get_value(options, :accessControlAllowOrigin) || cors_default,
|
446
|
+
'content-security-policy' => options && get_value(options, :contentSecurityPolicy) || "default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests",
|
447
|
+
'cross-origin-opener-policy' => options && get_value(options, :crossOriginOpenerPolicy) || 'same-origin',
|
448
|
+
'cross-origin-resource-policy' => options && get_value(options, :crossOriginResourcePolicy) || 'same-origin',
|
449
|
+
'origin-agent-cluster' => options && get_value(options, :originAgentCluster) || '?1',
|
450
|
+
'referrer-policy' => options && get_value(options, :referrerPolicy) || 'no-referrer',
|
451
|
+
'strict-transport-security' => options && get_value(options, :strictTransportSecurity) || 'max-age=1555200 includeSubDomains',
|
452
|
+
'x-content-type-options' => options && get_value(options, :xContentTypeOptions) || 'nosniff',
|
453
|
+
'x-dns-prefetch-control' => options && get_value(options, :xDnsPrefetchOptions) || 'off',
|
454
|
+
'x-download-options' => options && get_value(options, :xDownloadOptions) || 'noopen',
|
455
|
+
'x-frame-options' => options && get_value(options, :xFrameOptions) || 'SAMEORIGIN',
|
456
|
+
'x-permitted-cross-domain-policies' => options && get_value(options, :xPermittedCrossDomainPolicies) || 'none',
|
457
|
+
'x-xss-protection' => options && get_value(options, :xXssProtection) || '0'
|
458
|
+
}
|
459
|
+
|
460
|
+
def parse_http_request(request_line)
|
461
|
+
method, path, _ = request_line.split(' ')
|
462
|
+
{ method: method, path: path }
|
130
463
|
end
|
131
464
|
|
132
|
-
def
|
133
|
-
|
465
|
+
def parse_http_headers(client)
|
466
|
+
headers = {}
|
467
|
+
|
468
|
+
while (line = client.gets.chomp)
|
469
|
+
break if line.empty?
|
470
|
+
|
471
|
+
key, value = line.split(':', 2)
|
472
|
+
headers[key] = value.strip
|
473
|
+
end
|
474
|
+
|
475
|
+
headers
|
134
476
|
end
|
135
477
|
|
136
|
-
def
|
137
|
-
|
478
|
+
def parse_post_body(client, content_length)
|
479
|
+
body = ''
|
480
|
+
|
481
|
+
while content_length > 0
|
482
|
+
chunk = client.readpartial([content_length, 4096].min)
|
483
|
+
body += chunk
|
484
|
+
content_length -= chunk.length
|
485
|
+
end
|
486
|
+
|
487
|
+
body
|
138
488
|
end
|
139
489
|
|
140
|
-
def
|
141
|
-
|
142
|
-
|
490
|
+
def build_http_response(status, headers, body)
|
491
|
+
response = "HTTP/1.1 #{status}\r\n"
|
492
|
+
headers.each { |key, value| response += "#{key}: #{value}\r\n" }
|
493
|
+
response += "\r\n"
|
494
|
+
response += body
|
495
|
+
response
|
496
|
+
end
|
143
497
|
|
144
|
-
|
145
|
-
|
146
|
-
|
498
|
+
def handle_http_request(client, request_handler, http_headers)
|
499
|
+
request_line = client.gets
|
500
|
+
return unless request_line
|
501
|
+
|
502
|
+
request = parse_http_request(request_line)
|
503
|
+
|
504
|
+
headers = parse_http_headers(client)
|
505
|
+
|
506
|
+
if request[:path] != '/'
|
507
|
+
response = build_http_response('404 Not Found', http_headers, '')
|
508
|
+
client.print(response)
|
509
|
+
elsif request[:method] == 'OPTIONS'
|
510
|
+
response = build_http_response('204 No Content', http_headers, '')
|
511
|
+
client.print(response)
|
512
|
+
elsif request[:method] == 'POST'
|
513
|
+
|
514
|
+
content_length = headers['Content-Length'].to_i
|
515
|
+
body = parse_post_body(client, content_length)
|
516
|
+
|
517
|
+
begin
|
518
|
+
json_data = JSON.parse(body)
|
519
|
+
context = {
|
520
|
+
'headers' => headers
|
521
|
+
}
|
522
|
+
|
523
|
+
response_headers = http_headers.merge({
|
524
|
+
'Content-Type' => 'application/json'
|
525
|
+
})
|
147
526
|
|
148
|
-
|
149
|
-
|
527
|
+
result, error = request_handler.(json_data, context)
|
528
|
+
|
529
|
+
if error
|
530
|
+
response_json = error.to_json
|
531
|
+
response = build_http_response('500 Internal Server Error', response_headers, response_json)
|
532
|
+
client.print response
|
533
|
+
elsif result
|
534
|
+
response_json = result.to_json
|
535
|
+
response = build_http_response('200 OK', response_headers, response_json)
|
536
|
+
client.print response
|
150
537
|
else
|
151
|
-
|
538
|
+
response = build_http_response('500 Internal Server Error', response_headers, { 'message' => 'Request handler failed to return a result' }.to_json)
|
539
|
+
client.print response
|
152
540
|
end
|
541
|
+
|
542
|
+
rescue JSON::ParserError
|
543
|
+
response = build_http_response('400 Bad Request', http_headers, '')
|
153
544
|
end
|
545
|
+
|
154
546
|
else
|
155
|
-
|
547
|
+
response = build_http_response('405 Method Not Allowed', http_headers, '')
|
548
|
+
client.print(response)
|
156
549
|
end
|
550
|
+
client.close()
|
551
|
+
end
|
157
552
|
|
158
|
-
|
553
|
+
run = ->() do
|
159
554
|
|
160
|
-
|
161
|
-
|
162
|
-
rescue StandardError => error
|
163
|
-
return [request[:id], request[:route], nil, { message: error.message }]
|
164
|
-
end
|
555
|
+
server = TCPServer.new('localhost', port)
|
556
|
+
puts "Server listening on port #{port}"
|
165
557
|
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
if key.is_a?(String)
|
171
|
-
if obj.key?(key.to_sym)
|
172
|
-
filtered_obj[key.to_sym] = obj[key.to_sym]
|
173
|
-
end
|
174
|
-
elsif key.is_a?(Array)
|
175
|
-
nested_obj = obj[key[0].to_sym]
|
176
|
-
nested_arr = key[1]
|
177
|
-
if nested_obj.is_a?(Array)
|
178
|
-
filtered_arr = []
|
179
|
-
nested_obj.each do |nested_item|
|
180
|
-
filtered_nested_obj = filter_object(nested_item, nested_arr)
|
181
|
-
if filtered_nested_obj.keys.length > 0
|
182
|
-
filtered_arr << filtered_nested_obj
|
183
|
-
end
|
184
|
-
end
|
185
|
-
if filtered_arr.length > 0
|
186
|
-
filtered_obj[key[0].to_sym] = filtered_arr
|
187
|
-
end
|
188
|
-
elsif nested_obj.is_a?(Hash)
|
189
|
-
filtered_nested_obj = filter_object(nested_obj, nested_arr)
|
190
|
-
if filtered_nested_obj.keys.length > 0
|
191
|
-
filtered_obj[key[0].to_sym] = filtered_nested_obj
|
192
|
-
end
|
193
|
-
end
|
194
|
-
end
|
558
|
+
begin
|
559
|
+
loop do
|
560
|
+
client = server.accept
|
561
|
+
Thread.new { handle_http_request(client, request_handler, http_headers) }
|
195
562
|
end
|
196
|
-
|
563
|
+
rescue Interrupt
|
564
|
+
exit 1
|
197
565
|
end
|
198
|
-
|
566
|
+
|
199
567
|
end
|
200
568
|
|
201
|
-
|
569
|
+
return run
|
570
|
+
end
|
202
571
|
|
203
|
-
handler = ->(requests, context = {}) do
|
204
|
-
if !requests || !requests.is_a?(Array)
|
205
|
-
return handle_error(400, 'Request body should be a JSON array')
|
206
|
-
end
|
207
572
|
|
208
|
-
unique_ids = []
|
209
|
-
promises = []
|
210
573
|
|
211
|
-
|
212
|
-
|
213
|
-
|
574
|
+
def validate_server_options(options)
|
575
|
+
if options.nil? || options.empty?
|
576
|
+
return nil
|
577
|
+
elsif !options.is_a?(Hash)
|
578
|
+
return 'Options should be a hash'
|
579
|
+
else
|
580
|
+
if options.key?(:url)
|
581
|
+
if !options[:url].is_a?(String)
|
582
|
+
return '"url" option should be a string'
|
583
|
+
elsif !options[:url].start_with?('/')
|
584
|
+
return '"url" option should begin with a forward slash'
|
214
585
|
end
|
586
|
+
end
|
587
|
+
|
588
|
+
if options.key?(:cors)
|
589
|
+
if !options[:cors].is_a?(String) && !options[:cors].is_a?(TrueClass) && !options[:cors].is_a?(FalseClass)
|
590
|
+
return '"cors" option should be a string or boolean'
|
591
|
+
end
|
592
|
+
end
|
593
|
+
end
|
594
|
+
|
595
|
+
return nil
|
596
|
+
end
|
597
|
+
|
215
598
|
|
216
|
-
id = request[0]
|
217
|
-
route = request[1]
|
218
|
-
parameters = request[2] || nil
|
219
|
-
selector = request[3] || nil
|
220
599
|
|
221
|
-
|
222
|
-
|
600
|
+
def create_request_handler(routes)
|
601
|
+
raise ArgumentError, 'A routes object is required' unless routes.is_a?(Hash)
|
602
|
+
|
603
|
+
my_routes = {}
|
604
|
+
|
605
|
+
routes.each do |key, route|
|
606
|
+
route_error = validate_route(key, false)
|
607
|
+
raise ArgumentError, "#{route_error}: #{key}" if route_error
|
608
|
+
|
609
|
+
if route.is_a?(Array)
|
610
|
+
raise ArgumentError, "Route has no handlers: #{key}" if route.empty?
|
611
|
+
|
612
|
+
route.each do |handler|
|
613
|
+
raise ArgumentError, "All route handlers must be functions: #{key}" unless handler.is_a?(Proc)
|
223
614
|
end
|
224
615
|
|
225
|
-
|
226
|
-
|
616
|
+
my_routes[key] = { handler: route }
|
617
|
+
elsif route.is_a?(Hash)
|
618
|
+
unless route.key?(:handler)
|
619
|
+
raise ArgumentError, "Route has no handlers: #{key}"
|
227
620
|
end
|
228
621
|
|
229
|
-
if
|
230
|
-
|
231
|
-
|
232
|
-
return handle_error(400, 'Request item route should be at least two characters long')
|
233
|
-
elsif route[-1] == '/'
|
234
|
-
return handle_error(400, 'Request item route should not end in a forward slash')
|
235
|
-
elsif !/[a-zA-Z]/.match?(route[0])
|
236
|
-
return handle_error(400, 'Request item route should start with a letter')
|
237
|
-
else
|
238
|
-
return handle_error(
|
239
|
-
400,
|
240
|
-
'Request item route should contain only letters, numbers, dashes, underscores, and forward slashes'
|
241
|
-
)
|
622
|
+
if route[:handler].is_a?(Array)
|
623
|
+
route[:handler].each do |handler|
|
624
|
+
raise ArgumentError, "All route handlers must be functions: #{key}" unless handler.is_a?(Proc)
|
242
625
|
end
|
243
|
-
end
|
244
626
|
|
245
|
-
|
246
|
-
|
627
|
+
my_routes[key] = route
|
628
|
+
elsif route[:handler].is_a?(Proc)
|
629
|
+
my_routes[key] = { **route, handler: [route[:handler]] }
|
630
|
+
else
|
631
|
+
raise ArgumentError, "Route handler is not valid: #{key}"
|
247
632
|
end
|
633
|
+
elsif route.is_a?(Proc)
|
634
|
+
my_routes[key] = { handler: [route] }
|
635
|
+
else
|
636
|
+
raise ArgumentError, "Route is missing handler: #{key}"
|
637
|
+
end
|
638
|
+
end
|
639
|
+
|
640
|
+
handler = lambda do |requests, context = {}|
|
641
|
+
handle_request(my_routes, requests, context)
|
642
|
+
end
|
643
|
+
|
644
|
+
return handler
|
645
|
+
end
|
646
|
+
|
647
|
+
|
648
|
+
|
649
|
+
def validate_route(route, system)
|
650
|
+
route_regex = /^[a-zA-Z][a-zA-Z0-9_\-\/]*[a-zA-Z0-9]$/
|
651
|
+
system_route_regex = /^_[a-zA-Z][a-zA-Z0-9_\-\/]*[a-zA-Z0-9]$/
|
652
|
+
if route.nil? || route.empty?
|
653
|
+
return 'Route is required'
|
654
|
+
elsif system && !(route =~ system_route_regex)
|
655
|
+
route_length = route.length
|
656
|
+
if route_length < 3
|
657
|
+
return 'System route should be at least three characters long'
|
658
|
+
elsif route[0] != '_'
|
659
|
+
return 'System route should start with an underscore'
|
660
|
+
elsif !(route[-1] =~ /^[a-zA-Z0-9]/)
|
661
|
+
return 'System route should end with a letter or a number'
|
662
|
+
else
|
663
|
+
return 'System route should contain only letters, numbers, dashes, underscores, and forward slashes'
|
664
|
+
end
|
665
|
+
elsif !system && !(route =~ route_regex)
|
666
|
+
route_length = route.length
|
667
|
+
if route_length < 2
|
668
|
+
return 'Route should be at least two characters long'
|
669
|
+
elsif !(route[0] =~ /^[a-zA-Z]/)
|
670
|
+
return 'Route should start with a letter'
|
671
|
+
elsif !(route[-1] =~ /^[a-zA-Z0-9]/)
|
672
|
+
return 'Route should end with a letter or a number'
|
673
|
+
else
|
674
|
+
return 'Route should contain only letters, numbers, dashes, underscores, and forward slashes'
|
675
|
+
end
|
676
|
+
elsif route =~ /\/[^a-zA-Z]/
|
677
|
+
return 'Sub-routes should start with a letter'
|
678
|
+
elsif route =~ /[^a-zA-Z0-9]\//
|
679
|
+
return 'Sub-routes should end with a letter or a number'
|
680
|
+
elsif route =~ /\/[a-zA-Z0-9_\-]{0,1}\//
|
681
|
+
return 'Sub-routes should be at least two characters long'
|
682
|
+
elsif route =~ /\/[a-zA-Z0-9_\-]$/
|
683
|
+
return 'Sub-routes should be at least two characters long'
|
684
|
+
elsif route =~ /^[a-zA-Z0-9_\-]\//
|
685
|
+
return 'Sub-routes should be at least two characters long'
|
686
|
+
end
|
687
|
+
|
688
|
+
return nil
|
689
|
+
end
|
690
|
+
|
691
|
+
|
248
692
|
|
249
|
-
|
250
|
-
|
693
|
+
def handle_request(routes, requests, context = {})
|
694
|
+
if requests.nil? || !requests.is_a?(Array)
|
695
|
+
return handle_error(400, 'Request should be an array')
|
696
|
+
end
|
697
|
+
|
698
|
+
batch_id = SecureRandom.uuid()
|
699
|
+
unique_ids = []
|
700
|
+
promises = []
|
701
|
+
|
702
|
+
requests.each do |request|
|
703
|
+
request_length = request.length
|
704
|
+
if !request.is_a?(Array)
|
705
|
+
return handle_error(400, 'Request item should be an array')
|
706
|
+
end
|
707
|
+
|
708
|
+
id = request[0] || nil
|
709
|
+
route = request[1] || nil
|
710
|
+
body = request[2] || nil
|
711
|
+
headers = request[3] || nil
|
712
|
+
|
713
|
+
if id.nil? || !id.is_a?(String)
|
714
|
+
return handle_error(400, 'Request item should have an ID')
|
715
|
+
end
|
716
|
+
|
717
|
+
if route.nil? || !route.is_a?(String)
|
718
|
+
return handle_error(400, 'Request items should have a route')
|
719
|
+
end
|
720
|
+
|
721
|
+
if body && !body.is_a?(Hash)
|
722
|
+
return handle_error(400, 'Request item body should be an object')
|
723
|
+
end
|
724
|
+
|
725
|
+
if headers && !headers.is_a?(Hash)
|
726
|
+
return handle_error(400, 'Request item headers should be an object')
|
727
|
+
end
|
728
|
+
|
729
|
+
if unique_ids.include?(id)
|
730
|
+
return handle_error(400, 'Request items should have unique IDs')
|
731
|
+
end
|
732
|
+
|
733
|
+
unique_ids << id
|
734
|
+
this_route = routes[route]
|
735
|
+
route_handler = nil
|
736
|
+
timeout = nil
|
737
|
+
|
738
|
+
if this_route.is_a?(Hash)
|
739
|
+
route_handler = this_route[:handler] || this_route['handler'] || method(:route_not_found)
|
740
|
+
timeout = this_route[:timeout] || this_route['timeout'] || nil
|
741
|
+
else
|
742
|
+
route_handler = this_route || method(:route_not_found)
|
743
|
+
end
|
744
|
+
|
745
|
+
request_object = {
|
746
|
+
id: id,
|
747
|
+
route: route,
|
748
|
+
body: body || {},
|
749
|
+
headers: headers
|
750
|
+
}
|
751
|
+
|
752
|
+
request_context = {}
|
753
|
+
if context.is_a?(Hash)
|
754
|
+
request_context = request_context.merge(context)
|
755
|
+
end
|
756
|
+
request_context["batch_id"] = batch_id
|
757
|
+
request_context["request_id"] = id
|
758
|
+
request_context["route"] = route
|
759
|
+
request_context["headers"] = headers
|
760
|
+
|
761
|
+
promises << Thread.new { route_reducer(route_handler, request_object, request_context, timeout) }
|
762
|
+
end
|
763
|
+
|
764
|
+
results = promises.map(&:value)
|
765
|
+
return handle_result(results)
|
766
|
+
end
|
767
|
+
|
768
|
+
|
769
|
+
|
770
|
+
def handle_result(result)
|
771
|
+
return result, nil
|
772
|
+
end
|
773
|
+
|
774
|
+
|
775
|
+
|
776
|
+
def handle_error(status, message)
|
777
|
+
return nil, {
|
778
|
+
'status' => status,
|
779
|
+
'message' => message
|
780
|
+
}
|
781
|
+
end
|
782
|
+
|
783
|
+
|
784
|
+
|
785
|
+
class BlestError < StandardError
|
786
|
+
attr_accessor :status
|
787
|
+
attr_accessor :code
|
788
|
+
attr_accessor :data
|
789
|
+
attr_accessor :stack
|
790
|
+
|
791
|
+
def initialize(message = nil)
|
792
|
+
@status = 500
|
793
|
+
@code = nil
|
794
|
+
@data = nil
|
795
|
+
@stack = nil
|
796
|
+
super(message)
|
797
|
+
end
|
798
|
+
end
|
799
|
+
|
800
|
+
|
801
|
+
|
802
|
+
def route_not_found(_, _)
|
803
|
+
error = BlestError.new
|
804
|
+
error.status = 404
|
805
|
+
error.stack = nil
|
806
|
+
raise error, 'Not Found'
|
807
|
+
end
|
808
|
+
|
809
|
+
|
810
|
+
|
811
|
+
def route_reducer(handler, request, context, timeout = nil)
|
812
|
+
safe_context = Marshal.load(Marshal.dump(context))
|
813
|
+
route = request[:route]
|
814
|
+
result = nil
|
815
|
+
error = nil
|
816
|
+
|
817
|
+
target = -> do
|
818
|
+
my_result = nil
|
819
|
+
if handler.is_a?(Array)
|
820
|
+
handler.each do |h|
|
821
|
+
break if error
|
822
|
+
temp_result = nil
|
823
|
+
if h.respond_to?(:call)
|
824
|
+
temp_result = Concurrent::Promises.future do
|
825
|
+
begin
|
826
|
+
h.call(request[:body], safe_context)
|
827
|
+
rescue => e
|
828
|
+
error = e
|
829
|
+
end
|
830
|
+
end.value
|
831
|
+
else
|
832
|
+
puts "Tried to resolve route '#{route}' with handler of type '#{h.class}'"
|
833
|
+
raise StandardError
|
834
|
+
end
|
835
|
+
|
836
|
+
if temp_result && temp_result != nil
|
837
|
+
if my_result && my_result != nil
|
838
|
+
puts result
|
839
|
+
puts temp_result
|
840
|
+
puts "Multiple handlers on the route '#{route}' returned results"
|
841
|
+
raise StandardError
|
842
|
+
else
|
843
|
+
my_result = temp_result
|
844
|
+
end
|
845
|
+
end
|
846
|
+
end
|
847
|
+
else
|
848
|
+
if handler.respond_to?(:call)
|
849
|
+
my_result = Concurrent::Promises.future do
|
850
|
+
begin
|
851
|
+
handler.call(request[:body], safe_context)
|
852
|
+
rescue => e
|
853
|
+
error = e
|
854
|
+
end
|
855
|
+
end.value
|
856
|
+
else
|
857
|
+
puts "Tried to resolve route '#{route}' with handler of type '#{handler.class}'"
|
858
|
+
raise StandardError
|
251
859
|
end
|
860
|
+
end
|
861
|
+
|
862
|
+
if error
|
863
|
+
raise error
|
864
|
+
end
|
865
|
+
|
866
|
+
my_result
|
867
|
+
end
|
252
868
|
|
253
|
-
|
254
|
-
|
869
|
+
begin
|
870
|
+
if timeout && timeout > 0
|
871
|
+
begin
|
872
|
+
result = Timeout.timeout(timeout / 1000.0) { target.call }
|
873
|
+
rescue Timeout::Error
|
874
|
+
puts "The route '#{route}' timed out after #{timeout} milliseconds"
|
875
|
+
return [request[:id], request[:route], nil, { 'message' => 'Internal Server Error', 'status' => 500 }]
|
255
876
|
end
|
877
|
+
else
|
878
|
+
result = target.call
|
879
|
+
end
|
256
880
|
|
257
|
-
|
881
|
+
if result.nil? || !result.is_a?(Hash)
|
882
|
+
puts "The route '#{route}' did not return a result object"
|
883
|
+
return [request[:id], request[:route], nil, { 'message' => 'Internal Server Error', 'status' => 500 }]
|
884
|
+
end
|
258
885
|
|
259
|
-
|
886
|
+
# if request[:selector]
|
887
|
+
# result = filter_object(result, request[:selector])
|
888
|
+
# end
|
260
889
|
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
890
|
+
[request[:id], request[:route], result, nil]
|
891
|
+
rescue => error
|
892
|
+
puts error.backtrace
|
893
|
+
response_error = {
|
894
|
+
'message' => error.message || 'Internal Server Error',
|
895
|
+
'status' => error.respond_to?(:status) ? error.status : 500
|
896
|
+
}
|
267
897
|
|
268
|
-
|
898
|
+
if error.respond_to?(:code) && error.code.is_a?(String)
|
899
|
+
response_error['code'] = error.code
|
269
900
|
end
|
270
901
|
|
271
|
-
|
902
|
+
if error.respond_to?(:data) && error.data.is_a?(Hash)
|
903
|
+
response_error['data'] = error.data
|
904
|
+
end
|
272
905
|
|
273
|
-
|
274
|
-
|
906
|
+
if ENV['ENVIRONMENT'] != 'production' && ENV['APP_ENV'] != 'production' && ENV['RACK_ENV'] != 'production' && ENV['RAILS_ENV'] != 'production' && !error.respond_to?(:stack)
|
907
|
+
response_error['stack'] = error.backtrace
|
275
908
|
end
|
276
909
|
|
277
|
-
|
910
|
+
[request[:id], request[:route], nil, response_error]
|
278
911
|
end
|
912
|
+
end
|
279
913
|
|
280
|
-
|
914
|
+
|
915
|
+
|
916
|
+
def filter_object(obj, arr)
|
917
|
+
if arr.is_a?(Array)
|
918
|
+
filtered_obj = {}
|
919
|
+
arr.each do |key|
|
920
|
+
if key.is_a?(String)
|
921
|
+
if obj.key?(key.to_sym)
|
922
|
+
filtered_obj[key.to_sym] = obj[key.to_sym]
|
923
|
+
end
|
924
|
+
elsif key.is_a?(Array)
|
925
|
+
nested_obj = obj[key[0].to_sym]
|
926
|
+
nested_arr = key[1]
|
927
|
+
if nested_obj.is_a?(Array)
|
928
|
+
filtered_arr = []
|
929
|
+
nested_obj.each do |nested_item|
|
930
|
+
filtered_nested_obj = filter_object(nested_item, nested_arr)
|
931
|
+
if filtered_nested_obj.keys.length > 0
|
932
|
+
filtered_arr << filtered_nested_obj
|
933
|
+
end
|
934
|
+
end
|
935
|
+
if filtered_arr.length > 0
|
936
|
+
filtered_obj[key[0].to_sym] = filtered_arr
|
937
|
+
end
|
938
|
+
elsif nested_obj.is_a?(Hash)
|
939
|
+
filtered_nested_obj = filter_object(nested_obj, nested_arr)
|
940
|
+
if filtered_nested_obj.keys.length > 0
|
941
|
+
filtered_obj[key[0].to_sym] = filtered_nested_obj
|
942
|
+
end
|
943
|
+
end
|
944
|
+
end
|
945
|
+
end
|
946
|
+
return filtered_obj
|
947
|
+
end
|
948
|
+
return obj
|
281
949
|
end
|