blest 0.0.2 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +57 -95
  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