blest 0.1.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE +1 -1
- data/README.md +10 -41
- data/lib/blest.rb +48 -387
- data/spec/blest_spec.rb +15 -14
- metadata +9 -9
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: dda8d494a672ecd29209b9971076decfaec476253157103dc75a6d74cfc2f327
|
4
|
+
data.tar.gz: dee130556eb32a00cdbb14ae3f5c160133241763061d6558592d1712963cbfc4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4407b468d713463014fa4449efb4e2be40ac59d93fbca2302d1ddc13aa036907b0ba6b4067cd92e1e90a64e37dcca3cd29b0aec605722fd7a5edf4458058245f
|
7
|
+
data.tar.gz: f1c050cdaa44297e90c968ba99be88f3fb440743a959e9ab32286d04ce42a4dc886059bb598c1c29ab0d38d41d7bccc384a86c9d114d5f6398f68da275afb8a3
|
data/LICENSE
CHANGED
data/README.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# BLEST Ruby
|
2
2
|
|
3
|
-
The Ruby reference implementation of BLEST (Batch-able, Lightweight, Encrypted State Transfer), an improved communication protocol for web APIs which leverages JSON, supports request batching
|
3
|
+
The Ruby reference implementation of BLEST (Batch-able, Lightweight, Encrypted State Transfer), an improved communication protocol for web APIs which leverages JSON, supports request batching by default, and provides a modern alternative to REST.
|
4
4
|
|
5
5
|
To learn more about BLEST, please visit the website: https://blest.jhunt.dev
|
6
6
|
|
@@ -10,9 +10,8 @@ For a front-end implementation in React, please visit https://github.com/jhuntde
|
|
10
10
|
|
11
11
|
- Built on JSON - Reduce parsing time and overhead
|
12
12
|
- Request Batching - Save bandwidth and reduce load times
|
13
|
-
- Compact Payloads - Save more bandwidth
|
14
|
-
-
|
15
|
-
- Single Endpoint - Reduce complexity and improve data privacy
|
13
|
+
- Compact Payloads - Save even more bandwidth
|
14
|
+
- Single Endpoint - Reduce complexity and facilitate introspection
|
16
15
|
- Fully Encrypted - Improve data privacy
|
17
16
|
|
18
17
|
## Installation
|
@@ -25,36 +24,6 @@ gem install blest
|
|
25
24
|
|
26
25
|
## Usage
|
27
26
|
|
28
|
-
This core class of this library has an interface somewhat similar to Sinatra. It also provides a `Router` class with a `handle` method for use in an existing Ruby API and an `HttpClient` class with a `request` method for making BLEST HTTP requests.
|
29
|
-
|
30
|
-
```ruby
|
31
|
-
require 'blest'
|
32
|
-
|
33
|
-
app = Blest.new(timeout: 1000, port: 8080, host: 'localhost', cors: 'http://localhost:3000')
|
34
|
-
|
35
|
-
# Create some middleware (optional)
|
36
|
-
app.before do |params, context|
|
37
|
-
if params['name'].present?
|
38
|
-
context['user'] = {
|
39
|
-
name: params['name']
|
40
|
-
}
|
41
|
-
nil
|
42
|
-
else
|
43
|
-
raise RuntimeError, "Unauthorized"
|
44
|
-
end
|
45
|
-
end
|
46
|
-
|
47
|
-
# Create a route controller
|
48
|
-
app.route('greet') do |params, context|
|
49
|
-
{
|
50
|
-
greeting: "Hi, #{context['user']['name']}!"
|
51
|
-
}
|
52
|
-
end
|
53
|
-
|
54
|
-
# Start the server
|
55
|
-
app.listen
|
56
|
-
```
|
57
|
-
|
58
27
|
### Router
|
59
28
|
|
60
29
|
The following example uses Sinatra.
|
@@ -68,10 +37,10 @@ require 'blest'
|
|
68
37
|
router = Router.new(timeout: 1000)
|
69
38
|
|
70
39
|
# Create some middleware (optional)
|
71
|
-
router.before do |
|
72
|
-
if
|
40
|
+
router.before do |body, context|
|
41
|
+
if context.dig('headers', 'auth') == 'myToken'?
|
73
42
|
context['user'] = {
|
74
|
-
|
43
|
+
# user info for example
|
75
44
|
}
|
76
45
|
nil
|
77
46
|
else
|
@@ -80,9 +49,9 @@ router.before do |params, context|
|
|
80
49
|
end
|
81
50
|
|
82
51
|
# Create a route controller
|
83
|
-
router.route('greet') do |
|
52
|
+
router.route('greet') do |body, context|
|
84
53
|
{
|
85
|
-
greeting: "Hi, #{
|
54
|
+
greeting: "Hi, #{body['name']}!"
|
86
55
|
}
|
87
56
|
end
|
88
57
|
|
@@ -106,13 +75,13 @@ end
|
|
106
75
|
require 'blest'
|
107
76
|
|
108
77
|
# Create a client
|
109
|
-
client = HttpClient.new('http://localhost:8080', max_batch_size = 25, buffer_delay = 10,
|
78
|
+
client = HttpClient.new('http://localhost:8080', max_batch_size = 25, buffer_delay = 10, http_headers = {
|
110
79
|
'Authorization': 'Bearer token'
|
111
80
|
})
|
112
81
|
|
113
82
|
# Send a request
|
114
83
|
begin
|
115
|
-
result = client.request('greet', { 'name': 'Steve' },
|
84
|
+
result = client.request('greet', { 'name': 'Steve' }, { 'auth': 'myToken' }).value
|
116
85
|
# Do something with the result
|
117
86
|
rescue => error
|
118
87
|
# Do something in case of error
|
data/lib/blest.rb
CHANGED
@@ -1,6 +1,4 @@
|
|
1
|
-
require 'socket'
|
2
1
|
require 'json'
|
3
|
-
require 'date'
|
4
2
|
require 'concurrent'
|
5
3
|
require 'securerandom'
|
6
4
|
require 'net/http'
|
@@ -50,7 +48,7 @@ class Router
|
|
50
48
|
end
|
51
49
|
|
52
50
|
def route(route, &handler)
|
53
|
-
route_error = validate_route(route)
|
51
|
+
route_error = validate_route(route, false)
|
54
52
|
raise ArgumentError, route_error if route_error
|
55
53
|
raise ArgumentError, 'Route already exists' if @routes.key?(route)
|
56
54
|
raise ArgumentError, 'Handler should be a function' unless handler.respond_to?(:call)
|
@@ -58,8 +56,7 @@ class Router
|
|
58
56
|
@routes[route] = {
|
59
57
|
handler: [*@middleware, handler, *@afterware],
|
60
58
|
description: nil,
|
61
|
-
|
62
|
-
result: nil,
|
59
|
+
schema: nil,
|
63
60
|
visible: @introspection,
|
64
61
|
validate: false,
|
65
62
|
timeout: @timeout
|
@@ -75,14 +72,9 @@ class Router
|
|
75
72
|
@routes[route]['description'] = config['description']
|
76
73
|
end
|
77
74
|
|
78
|
-
if config.key?('
|
79
|
-
raise ArgumentError, '
|
80
|
-
@routes[route]['
|
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']
|
75
|
+
if config.key?('schema')
|
76
|
+
raise ArgumentError, 'Schema should be a dict' if !config['schema'].nil? && !config['schema'].is_a?(Hash)
|
77
|
+
@routes[route]['schema'] = config['schema']
|
86
78
|
end
|
87
79
|
|
88
80
|
if config.key?('visible')
|
@@ -125,7 +117,7 @@ class Router
|
|
125
117
|
def namespace(prefix, router)
|
126
118
|
raise ArgumentError, 'Router is required' unless router.is_a?(Router)
|
127
119
|
|
128
|
-
prefix_error = validate_route(prefix)
|
120
|
+
prefix_error = validate_route(prefix, false)
|
129
121
|
raise ArgumentError, prefix_error if prefix_error
|
130
122
|
|
131
123
|
new_routes = router.routes.keys
|
@@ -155,51 +147,28 @@ end
|
|
155
147
|
|
156
148
|
|
157
149
|
|
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
150
|
class HttpClient
|
182
151
|
attr_reader :queue, :futures
|
183
152
|
attr_accessor :url, :max_batch_size, :buffer_delay, :headers
|
184
153
|
|
185
|
-
def initialize(url, max_batch_size = 25, buffer_delay = 10,
|
154
|
+
def initialize(url, max_batch_size = 25, buffer_delay = 10, http_headers = {})
|
186
155
|
@url = url
|
187
156
|
@max_batch_size = max_batch_size
|
188
157
|
@buffer_delay = buffer_delay
|
189
|
-
@
|
158
|
+
@http_headers = http_headers
|
190
159
|
@queue = Queue.new
|
191
160
|
@futures = {}
|
192
161
|
@lock = Mutex.new
|
193
162
|
end
|
194
163
|
|
195
|
-
def request(route,
|
196
|
-
uuid = SecureRandom.uuid
|
164
|
+
def request(route, body=nil, headers=nil)
|
165
|
+
uuid = SecureRandom.uuid()
|
197
166
|
future = Concurrent::Promises.resolvable_future
|
198
167
|
@lock.synchronize do
|
199
168
|
@futures[uuid] = future
|
200
169
|
end
|
201
170
|
|
202
|
-
@queue.push({ uuid: uuid, data: [uuid, route,
|
171
|
+
@queue.push({ uuid: uuid, data: [uuid, route, body, headers] })
|
203
172
|
process_timeout()
|
204
173
|
future
|
205
174
|
end
|
@@ -232,7 +201,7 @@ class HttpClient
|
|
232
201
|
http = Net::HTTP.new(uri.host, uri.port)
|
233
202
|
http.use_ssl = true if uri.scheme == 'https'
|
234
203
|
|
235
|
-
request = Net::HTTP::Post.new(path, @
|
204
|
+
request = Net::HTTP::Post.new(path, @http_headers.merge({ 'Accept' => 'application/json', 'Content-Type' => 'application/json' }))
|
236
205
|
request.body = JSON.generate(batch.map { |item| item[:data] })
|
237
206
|
|
238
207
|
http.request(request)
|
@@ -286,331 +255,13 @@ end
|
|
286
255
|
|
287
256
|
|
288
257
|
|
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)
|
340
|
-
method, path, _ = request_line.split(' ')
|
341
|
-
{ method: method, path: path }
|
342
|
-
end
|
343
|
-
|
344
|
-
def parse_http_headers(client)
|
345
|
-
headers = {}
|
346
|
-
|
347
|
-
while (line = client.gets.chomp)
|
348
|
-
break if line.empty?
|
349
|
-
|
350
|
-
key, value = line.split(':', 2)
|
351
|
-
headers[key] = value.strip
|
352
|
-
end
|
353
|
-
|
354
|
-
headers
|
355
|
-
end
|
356
|
-
|
357
|
-
def parse_post_body(client, content_length)
|
358
|
-
body = ''
|
359
|
-
|
360
|
-
while content_length > 0
|
361
|
-
chunk = client.readpartial([content_length, 4096].min)
|
362
|
-
body += chunk
|
363
|
-
content_length -= chunk.length
|
364
|
-
end
|
365
|
-
|
366
|
-
body
|
367
|
-
end
|
368
|
-
|
369
|
-
def build_http_response(status, headers, body)
|
370
|
-
response = "HTTP/1.1 #{status}\r\n"
|
371
|
-
headers.each { |key, value| response += "#{key}: #{value}\r\n" }
|
372
|
-
response += "\r\n"
|
373
|
-
response += body
|
374
|
-
response
|
375
|
-
end
|
376
|
-
|
377
|
-
def handle_http_request(client, request_handler, http_headers)
|
378
|
-
request_line = client.gets
|
379
|
-
return unless request_line
|
380
|
-
|
381
|
-
request = parse_http_request(request_line)
|
382
|
-
|
383
|
-
headers = parse_http_headers(client)
|
384
|
-
|
385
|
-
if request[:path] != '/'
|
386
|
-
response = build_http_response('404 Not Found', http_headers, '')
|
387
|
-
client.print(response)
|
388
|
-
elsif request[:method] == 'OPTIONS'
|
389
|
-
response = build_http_response('204 No Content', http_headers, '')
|
390
|
-
client.print(response)
|
391
|
-
elsif request[:method] == 'POST'
|
392
|
-
|
393
|
-
content_length = headers['Content-Length'].to_i
|
394
|
-
body = parse_post_body(client, content_length)
|
395
|
-
|
396
|
-
begin
|
397
|
-
json_data = JSON.parse(body)
|
398
|
-
context = {
|
399
|
-
'headers' => headers
|
400
|
-
}
|
401
|
-
|
402
|
-
response_headers = http_headers.merge({
|
403
|
-
'Content-Type' => 'application/json'
|
404
|
-
})
|
405
|
-
|
406
|
-
result, error = request_handler.(json_data, context)
|
407
|
-
|
408
|
-
if error
|
409
|
-
response_json = error.to_json
|
410
|
-
response = build_http_response('500 Internal Server Error', response_headers, response_json)
|
411
|
-
client.print response
|
412
|
-
elsif result
|
413
|
-
response_json = result.to_json
|
414
|
-
response = build_http_response('200 OK', response_headers, response_json)
|
415
|
-
client.print response
|
416
|
-
else
|
417
|
-
response = build_http_response('500 Internal Server Error', response_headers, { 'message' => 'Request handler failed to return a result' }.to_json)
|
418
|
-
client.print response
|
419
|
-
end
|
420
|
-
|
421
|
-
rescue JSON::ParserError
|
422
|
-
response = build_http_response('400 Bad Request', http_headers, '')
|
423
|
-
end
|
424
|
-
|
425
|
-
else
|
426
|
-
response = build_http_response('405 Method Not Allowed', http_headers, '')
|
427
|
-
client.print(response)
|
428
|
-
end
|
429
|
-
client.close()
|
430
|
-
end
|
431
|
-
|
432
|
-
end
|
433
|
-
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
def create_http_server(request_handler, options = nil)
|
441
|
-
if options
|
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 }
|
470
|
-
end
|
471
|
-
|
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
|
483
|
-
end
|
484
|
-
|
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
|
495
|
-
end
|
496
|
-
|
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
|
503
|
-
end
|
504
|
-
|
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
|
-
}
|
529
|
-
|
530
|
-
response_headers = http_headers.merge({
|
531
|
-
'Content-Type' => 'application/json'
|
532
|
-
})
|
533
|
-
|
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
|
544
|
-
else
|
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
|
547
|
-
end
|
548
|
-
|
549
|
-
rescue JSON::ParserError
|
550
|
-
response = build_http_response('400 Bad Request', http_headers, '')
|
551
|
-
end
|
552
|
-
|
553
|
-
else
|
554
|
-
response = build_http_response('405 Method Not Allowed', http_headers, '')
|
555
|
-
client.print(response)
|
556
|
-
end
|
557
|
-
client.close()
|
558
|
-
end
|
559
|
-
|
560
|
-
run = ->() do
|
561
|
-
|
562
|
-
server = TCPServer.new('localhost', port)
|
563
|
-
puts "Server listening on port #{port}"
|
564
|
-
|
565
|
-
begin
|
566
|
-
loop do
|
567
|
-
client = server.accept
|
568
|
-
Thread.new { handle_http_request(client, request_handler, http_headers) }
|
569
|
-
end
|
570
|
-
rescue Interrupt
|
571
|
-
exit 1
|
572
|
-
end
|
573
|
-
|
574
|
-
end
|
575
|
-
|
576
|
-
return run
|
577
|
-
end
|
578
|
-
|
579
|
-
|
580
|
-
|
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'
|
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
|
-
|
605
|
-
|
606
|
-
|
607
258
|
def create_request_handler(routes)
|
608
259
|
raise ArgumentError, 'A routes object is required' unless routes.is_a?(Hash)
|
609
260
|
|
610
261
|
my_routes = {}
|
611
262
|
|
612
263
|
routes.each do |key, route|
|
613
|
-
route_error = validate_route(key)
|
264
|
+
route_error = validate_route(key, false)
|
614
265
|
raise ArgumentError, "#{route_error}: #{key}" if route_error
|
615
266
|
|
616
267
|
if route.is_a?(Array)
|
@@ -653,16 +304,26 @@ end
|
|
653
304
|
|
654
305
|
|
655
306
|
|
656
|
-
def validate_route(route)
|
307
|
+
def validate_route(route, system)
|
657
308
|
route_regex = /^[a-zA-Z][a-zA-Z0-9_\-\/]*[a-zA-Z0-9]$/
|
309
|
+
system_route_regex = /^_[a-zA-Z][a-zA-Z0-9_\-\/]*[a-zA-Z0-9]$/
|
658
310
|
if route.nil? || route.empty?
|
659
311
|
return 'Route is required'
|
660
|
-
elsif !(route =~
|
312
|
+
elsif system && !(route =~ system_route_regex)
|
313
|
+
route_length = route.length
|
314
|
+
if route_length < 3
|
315
|
+
return 'System route should be at least three characters long'
|
316
|
+
elsif route[0] != '_'
|
317
|
+
return 'System route should start with an underscore'
|
318
|
+
elsif !(route[-1] =~ /^[a-zA-Z0-9]/)
|
319
|
+
return 'System route should end with a letter or a number'
|
320
|
+
else
|
321
|
+
return 'System route should contain only letters, numbers, dashes, underscores, and forward slashes'
|
322
|
+
end
|
323
|
+
elsif !system && !(route =~ route_regex)
|
661
324
|
route_length = route.length
|
662
325
|
if route_length < 2
|
663
326
|
return 'Route should be at least two characters long'
|
664
|
-
elsif route[-1] == '/'
|
665
|
-
return 'Route should not end in a forward slash'
|
666
327
|
elsif !(route[0] =~ /^[a-zA-Z]/)
|
667
328
|
return 'Route should start with a letter'
|
668
329
|
elsif !(route[-1] =~ /^[a-zA-Z0-9]/)
|
@@ -692,6 +353,7 @@ def handle_request(routes, requests, context = {})
|
|
692
353
|
return handle_error(400, 'Request should be an array')
|
693
354
|
end
|
694
355
|
|
356
|
+
batch_id = SecureRandom.uuid()
|
695
357
|
unique_ids = []
|
696
358
|
promises = []
|
697
359
|
|
@@ -703,8 +365,8 @@ def handle_request(routes, requests, context = {})
|
|
703
365
|
|
704
366
|
id = request[0] || nil
|
705
367
|
route = request[1] || nil
|
706
|
-
|
707
|
-
|
368
|
+
body = request[2] || nil
|
369
|
+
headers = request[3] || nil
|
708
370
|
|
709
371
|
if id.nil? || !id.is_a?(String)
|
710
372
|
return handle_error(400, 'Request item should have an ID')
|
@@ -714,12 +376,12 @@ def handle_request(routes, requests, context = {})
|
|
714
376
|
return handle_error(400, 'Request items should have a route')
|
715
377
|
end
|
716
378
|
|
717
|
-
if
|
718
|
-
return handle_error(400, 'Request item
|
379
|
+
if body && !body.is_a?(Hash)
|
380
|
+
return handle_error(400, 'Request item body should be an object')
|
719
381
|
end
|
720
382
|
|
721
|
-
if
|
722
|
-
return handle_error(400, 'Request item
|
383
|
+
if headers && !headers.is_a?(Hash)
|
384
|
+
return handle_error(400, 'Request item headers should be an object')
|
723
385
|
end
|
724
386
|
|
725
387
|
if unique_ids.include?(id)
|
@@ -741,21 +403,20 @@ def handle_request(routes, requests, context = {})
|
|
741
403
|
request_object = {
|
742
404
|
id: id,
|
743
405
|
route: route,
|
744
|
-
|
745
|
-
|
406
|
+
body: body || {},
|
407
|
+
headers: headers
|
746
408
|
}
|
747
409
|
|
748
|
-
|
749
|
-
'requestId' => id,
|
750
|
-
'routeName' => route,
|
751
|
-
'selector' => selector,
|
752
|
-
'requestTime' => DateTime.now.to_time.to_i
|
753
|
-
}
|
410
|
+
request_context = {}
|
754
411
|
if context.is_a?(Hash)
|
755
|
-
|
412
|
+
request_context = request_context.merge(context)
|
756
413
|
end
|
414
|
+
request_context["batch_id"] = batch_id
|
415
|
+
request_context["request_id"] = id
|
416
|
+
request_context["route"] = route
|
417
|
+
request_context["headers"] = headers
|
757
418
|
|
758
|
-
promises << Thread.new { route_reducer(route_handler, request_object,
|
419
|
+
promises << Thread.new { route_reducer(route_handler, request_object, request_context, timeout) }
|
759
420
|
end
|
760
421
|
|
761
422
|
results = promises.map(&:value)
|
@@ -820,7 +481,7 @@ def route_reducer(handler, request, context, timeout = nil)
|
|
820
481
|
if h.respond_to?(:call)
|
821
482
|
temp_result = Concurrent::Promises.future do
|
822
483
|
begin
|
823
|
-
h.call(request[:
|
484
|
+
h.call(request[:body], safe_context)
|
824
485
|
rescue => e
|
825
486
|
error = e
|
826
487
|
end
|
@@ -845,7 +506,7 @@ def route_reducer(handler, request, context, timeout = nil)
|
|
845
506
|
if handler.respond_to?(:call)
|
846
507
|
my_result = Concurrent::Promises.future do
|
847
508
|
begin
|
848
|
-
handler.call(request[:
|
509
|
+
handler.call(request[:body], safe_context)
|
849
510
|
rescue => e
|
850
511
|
error = e
|
851
512
|
end
|
@@ -880,9 +541,9 @@ def route_reducer(handler, request, context, timeout = nil)
|
|
880
541
|
return [request[:id], request[:route], nil, { 'message' => 'Internal Server Error', 'status' => 500 }]
|
881
542
|
end
|
882
543
|
|
883
|
-
if request[:selector]
|
884
|
-
|
885
|
-
end
|
544
|
+
# if request[:selector]
|
545
|
+
# result = filter_object(result, request[:selector])
|
546
|
+
# end
|
886
547
|
|
887
548
|
[request[:id], request[:route], result, nil]
|
888
549
|
rescue => error
|
data/spec/blest_spec.rb
CHANGED
@@ -36,12 +36,13 @@ RSpec.describe Router do
|
|
36
36
|
error6 = nil
|
37
37
|
|
38
38
|
before(:all) do
|
39
|
-
router.route('basicRoute') do |
|
40
|
-
{ 'route'=> 'basicRoute', '
|
39
|
+
router.route('basicRoute') do |body, context|
|
40
|
+
{ 'route'=> 'basicRoute', 'body' => body, 'context' => context }
|
41
41
|
end
|
42
42
|
|
43
|
-
router.before do |
|
44
|
-
context['test'] = { 'value' =>
|
43
|
+
router.before do |body, context|
|
44
|
+
context['test'] = { 'value' => body['testValue'] }
|
45
|
+
context['requestTime'] = Time.now
|
45
46
|
nil
|
46
47
|
end
|
47
48
|
|
@@ -52,20 +53,20 @@ RSpec.describe Router do
|
|
52
53
|
nil
|
53
54
|
end
|
54
55
|
|
55
|
-
router2.route('mergedRoute') do |
|
56
|
-
{ 'route' => 'mergedRoute', '
|
56
|
+
router2.route('mergedRoute') do |body, context|
|
57
|
+
{ 'route' => 'mergedRoute', 'body' => body, 'context' => context }
|
57
58
|
end
|
58
59
|
|
59
|
-
router2.route('timeoutRoute') do |
|
60
|
+
router2.route('timeoutRoute') do |body|
|
60
61
|
sleep(0.2)
|
61
|
-
{ 'testValue' =>
|
62
|
+
{ 'testValue' => body['testValue'] }
|
62
63
|
end
|
63
64
|
|
64
65
|
router.merge(router2)
|
65
66
|
|
66
|
-
router3.route('errorRoute') do |
|
67
|
-
error = BlestError.new(
|
68
|
-
error.code = "ERROR_#{(
|
67
|
+
router3.route('errorRoute') do |body|
|
68
|
+
error = BlestError.new(body['testValue'])
|
69
|
+
error.code = "ERROR_#{(body['testValue'].to_f * 10).round}"
|
69
70
|
raise error
|
70
71
|
end
|
71
72
|
|
@@ -140,9 +141,9 @@ RSpec.describe Router do
|
|
140
141
|
expect(result5[0][1]).to eq('timeoutRoute')
|
141
142
|
end
|
142
143
|
|
143
|
-
it 'should accept
|
144
|
-
expect(result1[0][2]['
|
145
|
-
expect(result2[0][2]['
|
144
|
+
it 'should accept body' do
|
145
|
+
expect(result1[0][2]['body']['testValue']).to eq(testValue1)
|
146
|
+
expect(result2[0][2]['body']['testValue']).to eq(testValue2)
|
146
147
|
end
|
147
148
|
|
148
149
|
it 'should respect context' do
|
metadata
CHANGED
@@ -1,21 +1,21 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: blest
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1
|
4
|
+
version: 1.0.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- JHunt
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2024-10-31 00:00:00.000000000 Z
|
12
12
|
dependencies: []
|
13
13
|
description: The Ruby reference implementation of BLEST (Batch-able, Lightweight,
|
14
14
|
Encrypted State Transfer), an improved communication protocol for web APIs which
|
15
|
-
leverages JSON, supports request batching
|
16
|
-
|
15
|
+
leverages JSON, supports request batching by default, and provides a modern alternative
|
16
|
+
to REST.
|
17
17
|
email:
|
18
|
-
-
|
18
|
+
- hello@jhunt.dev
|
19
19
|
executables: []
|
20
20
|
extensions: []
|
21
21
|
extra_rdoc_files: []
|
@@ -28,7 +28,7 @@ homepage: https://blest.jhunt.dev
|
|
28
28
|
licenses:
|
29
29
|
- MIT
|
30
30
|
metadata: {}
|
31
|
-
post_install_message:
|
31
|
+
post_install_message:
|
32
32
|
rdoc_options: []
|
33
33
|
require_paths:
|
34
34
|
- lib
|
@@ -43,8 +43,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
43
43
|
- !ruby/object:Gem::Version
|
44
44
|
version: '0'
|
45
45
|
requirements: []
|
46
|
-
rubygems_version: 3.
|
47
|
-
signing_key:
|
46
|
+
rubygems_version: 3.5.16
|
47
|
+
signing_key:
|
48
48
|
specification_version: 4
|
49
49
|
summary: The Ruby reference implementation of BLEST
|
50
50
|
test_files: []
|