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.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +60 -99
  3. data/lib/blest.rb +811 -143
  4. data/spec/blest_spec.rb +220 -0
  5. 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
- def create_http_server(request_handler, options = nil)
5
-
6
- def parse_request(request_line)
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 parse_headers(client)
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 parse_body(client, content_length)
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 build_response(status, headers, body)
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 handle_request(client, request_handler)
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 = parse_request(request_line)
374
+ request = parse_http_request(request_line)
49
375
 
50
- headers = parse_headers(client)
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 = build_response('404 Not Found', cors_headers, '')
379
+ response = build_http_response('404 Not Found', http_headers, '')
60
380
  client.print(response)
61
381
  elsif request[:method] == 'OPTIONS'
62
- response = build_response('204 No Content', cors_headers, '')
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 = parse_body(client, content_length)
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 = cors_headers.merge({
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 = build_response('500 Internal Server Error', response_headers, response_json)
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 = build_response('200 OK', response_headers, response_json)
407
+ response = build_http_response('200 OK', response_headers, response_json)
88
408
  client.print response
89
409
  else
90
- response = build_response('500 Internal Server Error', response_headers, { 'message' => 'Request handler failed to return a result' }.to_json)
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 = build_response('400 Bad Request', cors_headers, '')
415
+ response = build_http_response('400 Bad Request', http_headers, '')
96
416
  end
97
417
 
98
418
  else
99
- response = build_response('405 Method Not Allowed', cors_headers, '')
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
- run = ->() do
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
- def create_request_handler(routes, options = nil)
432
+
433
+ def create_http_server(request_handler, options = nil)
124
434
  if options
125
- puts 'The "options" argument is not yet used, but may be used in the future'
435
+ options_error = validate_server_options(options)
436
+ raise ArgumentError, options_error if options_error
126
437
  end
127
438
 
128
- def handle_result(result)
129
- return [result, nil]
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 handle_error(code, message)
133
- return [nil, { 'code' => code, 'message' => message }]
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 route_not_found(_, _)
137
- raise 'Route not found'
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 route_reducer(handler, request, context = nil)
141
- safe_context = context ? context.clone : {}
142
- result = nil
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
- if handler.is_a?(Array)
145
- handler.each_with_index do |func, i|
146
- temp_result = func.call(request[:parameters], safe_context)
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
- if i == handler.length - 1
149
- result = temp_result
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
- raise 'Middleware should not return anything but may mutate context' if temp_result
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
- result = handler.call(request[:parameters], safe_context)
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
- raise 'The result, if any, should be a JSON object' if result && !(result.is_a?(Hash))
553
+ run = ->() do
159
554
 
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
555
+ server = TCPServer.new('localhost', port)
556
+ puts "Server listening on port #{port}"
165
557
 
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
558
+ begin
559
+ loop do
560
+ client = server.accept
561
+ Thread.new { handle_http_request(client, request_handler, http_headers) }
195
562
  end
196
- return filtered_obj
563
+ rescue Interrupt
564
+ exit 1
197
565
  end
198
- return obj
566
+
199
567
  end
200
568
 
201
- route_regex = /^[a-zA-Z][a-zA-Z0-9_\-\/]*[a-zA-Z0-9_\-]$/
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
- requests.each do |request|
212
- if !request.is_a?(Array)
213
- return handle_error(400, 'Request item should be an array')
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
- if !id || !id.is_a?(String)
222
- return handle_error(400, 'Request item should have an ID')
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
- if !route || !route.is_a?(String)
226
- return handle_error(400, 'Request item should have a route')
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 !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
- )
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
- if parameters && !parameters.is_a?(Hash)
246
- return handle_error(400, 'Request item parameters should be a JSON object')
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
- if selector && !selector.is_a?(Array)
250
- return handle_error(400, 'Request item selector should be a JSON array')
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
- if unique_ids.include?(id)
254
- return handle_error(400, 'Request items should have unique IDs')
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
- unique_ids << id
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
- route_handler = routes[route] || routes[route.to_sym] || method(:route_not_found)
886
+ # if request[:selector]
887
+ # result = filter_object(result, request[:selector])
888
+ # end
260
889
 
261
- request_object = {
262
- id: id,
263
- route: route,
264
- parameters: parameters,
265
- selector: selector,
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
- promises << route_reducer(route_handler, request_object, context)
898
+ if error.respond_to?(:code) && error.code.is_a?(String)
899
+ response_error['code'] = error.code
269
900
  end
270
901
 
271
- results = []
902
+ if error.respond_to?(:data) && error.data.is_a?(Hash)
903
+ response_error['data'] = error.data
904
+ end
272
905
 
273
- promises.each do |result|
274
- results << result
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
- return handle_result(results)
910
+ [request[:id], request[:route], nil, response_error]
278
911
  end
912
+ end
279
913
 
280
- return handler
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