blest 0.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 +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: []
|