blest 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +172 -0
- data/lib/blest.rb +281 -0
- metadata +49 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: e0a8f112cb397ac2725175231766a0ca838e9ce898180cfc8be6e59e7fa498b8
|
4
|
+
data.tar.gz: b9f91cdfb82e5058d889fd6abddc8f14248c567567a3cb0acbd673d04b99f84c
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 7fc3d8ae042c5b9dc96cb9fda9edeada1e34c26d1d08e890e72359c74f675e7c874b3c71ec78e4c056a8aaa886081be3374d87a46e22f86fd8689019149a9c8f
|
7
|
+
data.tar.gz: a862d3210e033fdf7b9d71b6b65b710a169955b49804df64aacfe0aacc73486f28e42a5dbf37994e66fa7a66ade5a39bbb2ecb5984941f5cf2c7284cc87308a8
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2023 JHunt
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,172 @@
|
|
1
|
+
# BLEST Ruby
|
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 and selective returns, and provides a modern alternative to REST.
|
4
|
+
|
5
|
+
To learn more about BLEST, please refer to the white paper: https://jhunt.dev/BLEST%20White%20Paper.pdf
|
6
|
+
|
7
|
+
For a front-end implementation in React, please visit https://github.com/jhuntdev/blest-react
|
8
|
+
|
9
|
+
## Features
|
10
|
+
|
11
|
+
- Built on JSON - Reduce parsing time and overhead
|
12
|
+
- Request Batching - Save bandwidth and reduce load times
|
13
|
+
- Compact Payloads - Save more bandwidth
|
14
|
+
- Selective Returns - Save even more bandwidth
|
15
|
+
- Single Endpoint - Reduce complexity and improve data privacy
|
16
|
+
- Fully Encrypted - Improve data privacy
|
17
|
+
|
18
|
+
<!-- ## Installation
|
19
|
+
|
20
|
+
Install BLEST Python from PyPI.
|
21
|
+
|
22
|
+
```bash
|
23
|
+
python3 -m pip install blest
|
24
|
+
``` -->
|
25
|
+
|
26
|
+
## Usage
|
27
|
+
|
28
|
+
Use the `create_request_handler` function to create a request handler suitable for use in an existing Python application. Use the `create_http_server` function to create a standalone HTTP server for your request handler.
|
29
|
+
|
30
|
+
<!-- Use the `create_http_client` function to create a BLEST HTTP client. -->
|
31
|
+
|
32
|
+
### create_request_handler
|
33
|
+
|
34
|
+
```ruby
|
35
|
+
require 'socket'
|
36
|
+
require 'json'
|
37
|
+
require './blest.rb'
|
38
|
+
|
39
|
+
server = TCPServer.new('localhost', 8080)
|
40
|
+
|
41
|
+
# Create some middleware (optional)
|
42
|
+
auth_middleware = ->(params, context) {
|
43
|
+
if params[:name].present?
|
44
|
+
context[:user] = {
|
45
|
+
name: params[:name]
|
46
|
+
}
|
47
|
+
nil
|
48
|
+
else
|
49
|
+
raise RuntimeError, "Unauthorized"
|
50
|
+
end
|
51
|
+
}
|
52
|
+
|
53
|
+
# Create a route controller
|
54
|
+
greet_controller = ->(params, context) {
|
55
|
+
{
|
56
|
+
greeting: "Hi, #{context[:user][:name]}!"
|
57
|
+
}
|
58
|
+
}
|
59
|
+
|
60
|
+
# Create a router
|
61
|
+
router = {
|
62
|
+
greet: [auth_middleware, greet_controller]
|
63
|
+
}
|
64
|
+
|
65
|
+
# Create a request handler
|
66
|
+
handler = create_request_handler(router)
|
67
|
+
|
68
|
+
puts "Server listening on port 8080"
|
69
|
+
|
70
|
+
loop do
|
71
|
+
|
72
|
+
client = server.accept
|
73
|
+
|
74
|
+
request = client.gets
|
75
|
+
if request.nil?
|
76
|
+
client.close
|
77
|
+
else
|
78
|
+
|
79
|
+
method, path, _ = request.split(' ')
|
80
|
+
|
81
|
+
if method != 'POST'
|
82
|
+
client.puts "HTTP/1.1 405 Method Not Allowed"
|
83
|
+
client.puts "\r\n"
|
84
|
+
elsif path != '/'
|
85
|
+
client.puts "HTTP/1.1 404 Not Found"
|
86
|
+
client.puts "\r\n"
|
87
|
+
else
|
88
|
+
content_length = 0
|
89
|
+
while line = client.gets
|
90
|
+
break if line == "\r\n"
|
91
|
+
content_length = line.split(': ')[1].to_i if line.start_with?('Content-Length')
|
92
|
+
end
|
93
|
+
|
94
|
+
body = client.read(content_length)
|
95
|
+
data = JSON.parse(body)
|
96
|
+
|
97
|
+
context = {
|
98
|
+
headers: request.headers
|
99
|
+
}
|
100
|
+
|
101
|
+
# Use the request handler]
|
102
|
+
result, error = handler.(data, context)
|
103
|
+
|
104
|
+
if error
|
105
|
+
response = error.to_json
|
106
|
+
client.puts "HTTP/1.1 500 Internal Server Error"
|
107
|
+
client.puts "Content-Type: application/json"
|
108
|
+
client.puts "Content-Length: #{response.bytesize}"
|
109
|
+
client.puts "\r\n"
|
110
|
+
client.puts response
|
111
|
+
elsif result
|
112
|
+
response = result.to_json
|
113
|
+
client.puts "HTTP/1.1 200 OK"
|
114
|
+
client.puts "Content-Type: application/json"
|
115
|
+
client.puts "Content-Length: #{response.bytesize}"
|
116
|
+
client.puts "\r\n"
|
117
|
+
client.puts response
|
118
|
+
else
|
119
|
+
client.puts "HTTP/1.1 204 No Content"
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
client.close
|
124
|
+
|
125
|
+
end
|
126
|
+
end
|
127
|
+
```
|
128
|
+
|
129
|
+
### create_http_server
|
130
|
+
|
131
|
+
```ruby
|
132
|
+
require 'socket'
|
133
|
+
require 'json'
|
134
|
+
require './blest.rb'
|
135
|
+
|
136
|
+
# Create some middleware (optional)
|
137
|
+
auth_middleware = ->(params, context) {
|
138
|
+
if params[:name].present?
|
139
|
+
context[:user] = {
|
140
|
+
name: params[:name]
|
141
|
+
}
|
142
|
+
nil
|
143
|
+
else
|
144
|
+
raise RuntimeError, "Unauthorized"
|
145
|
+
end
|
146
|
+
}
|
147
|
+
|
148
|
+
# Create a route controller
|
149
|
+
greet_controller = ->(params, context) {
|
150
|
+
{
|
151
|
+
greeting: "Hi, #{context[:user][:name]}!"
|
152
|
+
}
|
153
|
+
}
|
154
|
+
|
155
|
+
# Create a router
|
156
|
+
router = {
|
157
|
+
greet: [auth_middleware, greet_controller]
|
158
|
+
}
|
159
|
+
|
160
|
+
# Create a request handler
|
161
|
+
handler = create_request_handler(router)
|
162
|
+
|
163
|
+
# Create the server
|
164
|
+
server = create_http_server(handler, { port: 8080 })
|
165
|
+
|
166
|
+
# Run the server
|
167
|
+
server.()
|
168
|
+
```
|
169
|
+
|
170
|
+
## License
|
171
|
+
|
172
|
+
This project is licensed under the [MIT License](LICENSE).
|
data/lib/blest.rb
ADDED
@@ -0,0 +1,281 @@
|
|
1
|
+
require 'socket'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
def create_http_server(request_handler, options = nil)
|
5
|
+
|
6
|
+
def parse_request(request_line)
|
7
|
+
method, path, _ = request_line.split(' ')
|
8
|
+
{ method: method, path: path }
|
9
|
+
end
|
10
|
+
|
11
|
+
def parse_headers(client)
|
12
|
+
headers = {}
|
13
|
+
|
14
|
+
while (line = client.gets.chomp)
|
15
|
+
break if line.empty?
|
16
|
+
|
17
|
+
key, value = line.split(':', 2)
|
18
|
+
headers[key] = value.strip
|
19
|
+
end
|
20
|
+
|
21
|
+
headers
|
22
|
+
end
|
23
|
+
|
24
|
+
def parse_body(client, content_length)
|
25
|
+
body = ''
|
26
|
+
|
27
|
+
while content_length > 0
|
28
|
+
chunk = client.readpartial([content_length, 4096].min)
|
29
|
+
body += chunk
|
30
|
+
content_length -= chunk.length
|
31
|
+
end
|
32
|
+
|
33
|
+
body
|
34
|
+
end
|
35
|
+
|
36
|
+
def build_response(status, headers, body)
|
37
|
+
response = "HTTP/1.1 #{status}\r\n"
|
38
|
+
headers.each { |key, value| response += "#{key}: #{value}\r\n" }
|
39
|
+
response += "\r\n"
|
40
|
+
response += body
|
41
|
+
response
|
42
|
+
end
|
43
|
+
|
44
|
+
def handle_request(client, request_handler)
|
45
|
+
request_line = client.gets
|
46
|
+
return unless request_line
|
47
|
+
|
48
|
+
request = parse_request(request_line)
|
49
|
+
|
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
|
+
}
|
57
|
+
|
58
|
+
if request[:path] != '/'
|
59
|
+
response = build_response('404 Not Found', cors_headers, '')
|
60
|
+
client.print(response)
|
61
|
+
elsif request[:method] == 'OPTIONS'
|
62
|
+
response = build_response('204 No Content', cors_headers, '')
|
63
|
+
client.print(response)
|
64
|
+
elsif request[:method] == 'POST'
|
65
|
+
|
66
|
+
content_length = headers['Content-Length'].to_i
|
67
|
+
body = parse_body(client, content_length)
|
68
|
+
|
69
|
+
begin
|
70
|
+
json_data = JSON.parse(body)
|
71
|
+
context = {
|
72
|
+
'headers' => headers
|
73
|
+
}
|
74
|
+
|
75
|
+
response_headers = cors_headers.merge({
|
76
|
+
'Content-Type' => 'application/json'
|
77
|
+
})
|
78
|
+
|
79
|
+
result, error = request_handler.(json_data, context)
|
80
|
+
|
81
|
+
if error
|
82
|
+
response_json = error.to_json
|
83
|
+
response = build_response('500 Internal Server Error', response_headers, response_json)
|
84
|
+
client.print response
|
85
|
+
elsif result
|
86
|
+
response_json = result.to_json
|
87
|
+
response = build_response('200 OK', response_headers, response_json)
|
88
|
+
client.print response
|
89
|
+
else
|
90
|
+
response = build_response('500 Internal Server Error', response_headers, { 'message' => 'Request handler failed to return a result' }.to_json)
|
91
|
+
client.print response
|
92
|
+
end
|
93
|
+
|
94
|
+
rescue JSON::ParserError
|
95
|
+
response = build_response('400 Bad Request', cors_headers, '')
|
96
|
+
end
|
97
|
+
|
98
|
+
else
|
99
|
+
response = build_response('405 Method Not Allowed', cors_headers, '')
|
100
|
+
client.print(response)
|
101
|
+
end
|
102
|
+
client.close()
|
103
|
+
end
|
104
|
+
|
105
|
+
run = ->() do
|
106
|
+
|
107
|
+
port = options&.fetch(:port, 8080) if options.is_a?(Hash)
|
108
|
+
port ||= 8080
|
109
|
+
|
110
|
+
server = TCPServer.new('localhost', 8080)
|
111
|
+
puts "Server listening on port #{port}"
|
112
|
+
|
113
|
+
loop do
|
114
|
+
client = server.accept
|
115
|
+
Thread.new { handle_request(client, request_handler) }
|
116
|
+
end
|
117
|
+
|
118
|
+
end
|
119
|
+
|
120
|
+
return run
|
121
|
+
end
|
122
|
+
|
123
|
+
def create_request_handler(routes, options = nil)
|
124
|
+
if options
|
125
|
+
puts 'The "options" argument is not yet used, but may be used in the future'
|
126
|
+
end
|
127
|
+
|
128
|
+
def handle_result(result)
|
129
|
+
return [result, nil]
|
130
|
+
end
|
131
|
+
|
132
|
+
def handle_error(code, message)
|
133
|
+
return [nil, { 'code' => code, 'message' => message }]
|
134
|
+
end
|
135
|
+
|
136
|
+
def route_not_found(_, _)
|
137
|
+
raise 'Route not found'
|
138
|
+
end
|
139
|
+
|
140
|
+
def route_reducer(handler, request, context = nil)
|
141
|
+
safe_context = context ? context.clone : {}
|
142
|
+
result = nil
|
143
|
+
|
144
|
+
if handler.is_a?(Array)
|
145
|
+
handler.each_with_index do |func, i|
|
146
|
+
temp_result = func.call(request[:parameters], safe_context)
|
147
|
+
|
148
|
+
if i == handler.length - 1
|
149
|
+
result = temp_result
|
150
|
+
else
|
151
|
+
raise 'Middleware should not return anything but may mutate context' if temp_result
|
152
|
+
end
|
153
|
+
end
|
154
|
+
else
|
155
|
+
result = handler.call(request[:parameters], safe_context)
|
156
|
+
end
|
157
|
+
|
158
|
+
raise 'The result, if any, should be a JSON object' if result && !(result.is_a?(Hash))
|
159
|
+
|
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
|
165
|
+
|
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
|
195
|
+
end
|
196
|
+
return filtered_obj
|
197
|
+
end
|
198
|
+
return obj
|
199
|
+
end
|
200
|
+
|
201
|
+
route_regex = /^[a-zA-Z][a-zA-Z0-9_\-\/]*[a-zA-Z0-9_\-]$/
|
202
|
+
|
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
|
+
|
208
|
+
unique_ids = []
|
209
|
+
promises = []
|
210
|
+
|
211
|
+
requests.each do |request|
|
212
|
+
if !request.is_a?(Array)
|
213
|
+
return handle_error(400, 'Request item should be an array')
|
214
|
+
end
|
215
|
+
|
216
|
+
id = request[0]
|
217
|
+
route = request[1]
|
218
|
+
parameters = request[2] || nil
|
219
|
+
selector = request[3] || nil
|
220
|
+
|
221
|
+
if !id || !id.is_a?(String)
|
222
|
+
return handle_error(400, 'Request item should have an ID')
|
223
|
+
end
|
224
|
+
|
225
|
+
if !route || !route.is_a?(String)
|
226
|
+
return handle_error(400, 'Request item should have a route')
|
227
|
+
end
|
228
|
+
|
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
|
+
)
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
if parameters && !parameters.is_a?(Hash)
|
246
|
+
return handle_error(400, 'Request item parameters should be a JSON object')
|
247
|
+
end
|
248
|
+
|
249
|
+
if selector && !selector.is_a?(Array)
|
250
|
+
return handle_error(400, 'Request item selector should be a JSON array')
|
251
|
+
end
|
252
|
+
|
253
|
+
if unique_ids.include?(id)
|
254
|
+
return handle_error(400, 'Request items should have unique IDs')
|
255
|
+
end
|
256
|
+
|
257
|
+
unique_ids << id
|
258
|
+
|
259
|
+
route_handler = routes[route] || routes[route.to_sym] || method(:route_not_found)
|
260
|
+
|
261
|
+
request_object = {
|
262
|
+
id: id,
|
263
|
+
route: route,
|
264
|
+
parameters: parameters,
|
265
|
+
selector: selector,
|
266
|
+
}
|
267
|
+
|
268
|
+
promises << route_reducer(route_handler, request_object, context)
|
269
|
+
end
|
270
|
+
|
271
|
+
results = []
|
272
|
+
|
273
|
+
promises.each do |result|
|
274
|
+
results << result
|
275
|
+
end
|
276
|
+
|
277
|
+
return handle_result(results)
|
278
|
+
end
|
279
|
+
|
280
|
+
return handler
|
281
|
+
end
|
metadata
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: blest
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- JHunt
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2023-07-10 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: The Ruby reference implementation of BLEST (Batch-able, Lightweight,
|
14
|
+
Encrypted State Transfer), an improved communication protocol for web APIs which
|
15
|
+
leverages JSON, supports request batching and selective returns, and provides a
|
16
|
+
modern alternative to REST.
|
17
|
+
email:
|
18
|
+
- blest@jhunt.dev
|
19
|
+
executables: []
|
20
|
+
extensions: []
|
21
|
+
extra_rdoc_files: []
|
22
|
+
files:
|
23
|
+
- LICENSE
|
24
|
+
- README.md
|
25
|
+
- lib/blest.rb
|
26
|
+
homepage: https://blest.jhunt.dev
|
27
|
+
licenses:
|
28
|
+
- MIT
|
29
|
+
metadata: {}
|
30
|
+
post_install_message:
|
31
|
+
rdoc_options: []
|
32
|
+
require_paths:
|
33
|
+
- lib
|
34
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
35
|
+
requirements:
|
36
|
+
- - ">="
|
37
|
+
- !ruby/object:Gem::Version
|
38
|
+
version: '0'
|
39
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
40
|
+
requirements:
|
41
|
+
- - ">="
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: '0'
|
44
|
+
requirements: []
|
45
|
+
rubygems_version: 3.0.3
|
46
|
+
signing_key:
|
47
|
+
specification_version: 4
|
48
|
+
summary: The Ruby reference implementation of BLEST
|
49
|
+
test_files: []
|