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