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.
Files changed (5) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +172 -0
  4. data/lib/blest.rb +281 -0
  5. 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: []