syntropy 0.29.0 → 0.31.0
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/.github/workflows/test.yml +2 -2
- data/CHANGELOG.md +22 -0
- data/README.md +0 -2
- data/bin/syntropy +8 -86
- data/cmd/_banner.rb +16 -0
- data/cmd/help.rb +12 -0
- data/cmd/serve.rb +95 -0
- data/cmd/test.rb +40 -0
- data/examples/{counter.rb → basic/counter.rb} +1 -1
- data/examples/{templates.rb → basic/templates.rb} +1 -1
- data/examples/mcp-oauth/.ruby-version +1 -0
- data/examples/mcp-oauth/Gemfile +8 -0
- data/examples/mcp-oauth/README.md +128 -0
- data/examples/mcp-oauth/app/.well-known/oauth-authorization-server.rb +18 -0
- data/examples/mcp-oauth/app/.well-known/oauth-protected-resource.rb +10 -0
- data/examples/mcp-oauth/app/_lib/auth_store.rb +23 -0
- data/examples/mcp-oauth/app/index.md +1 -0
- data/examples/mcp-oauth/app/mcp.rb +38 -0
- data/examples/mcp-oauth/app/oauth/authorize.rb +26 -0
- data/examples/mcp-oauth/app/oauth/consent.rb +86 -0
- data/examples/mcp-oauth/app/oauth/register.rb +15 -0
- data/examples/mcp-oauth/app/oauth/token.rb +79 -0
- data/examples/mcp-oauth/app/signin.rb +85 -0
- data/examples/mcp-oauth/test/helper.rb +9 -0
- data/examples/mcp-oauth/test/test_app.rb +27 -0
- data/examples/mcp-oauth/test/test_oauth.rb +628 -0
- data/lib/syntropy/app.rb +23 -12
- data/lib/syntropy/applets/builtin/default_error_handler.rb +3 -3
- data/lib/syntropy/applets/builtin/req.rb +1 -1
- data/lib/syntropy/dev_mode.rb +1 -1
- data/lib/syntropy/errors.rb +19 -12
- data/lib/syntropy/http/client.rb +43 -0
- data/lib/syntropy/http/client_connection.rb +36 -0
- data/lib/syntropy/http/io_extensions.rb +148 -0
- data/lib/syntropy/http/server.rb +174 -0
- data/lib/syntropy/http/server_connection.rb +367 -0
- data/lib/syntropy/http/status.rb +76 -0
- data/lib/syntropy/http.rb +7 -0
- data/lib/syntropy/json_api.rb +2 -5
- data/lib/syntropy/logger.rb +5 -1
- data/lib/syntropy/mime_types.rb +37 -0
- data/lib/syntropy/papercraft_extensions.rb +1 -1
- data/lib/syntropy/request/mock_adapter.rb +60 -0
- data/lib/syntropy/request/request_info.rb +255 -0
- data/lib/syntropy/request/response.rb +206 -0
- data/lib/syntropy/request/validation.rb +146 -0
- data/lib/syntropy/request.rb +99 -0
- data/lib/syntropy/routing_tree.rb +2 -1
- data/lib/syntropy/test.rb +65 -0
- data/lib/syntropy/utils.rb +1 -1
- data/lib/syntropy/version.rb +1 -1
- data/lib/syntropy.rb +4 -27
- data/syntropy.gemspec +2 -4
- data/test/app/.well-known/foo.rb +3 -0
- data/test/app/about/_error.rb +1 -1
- data/test/app/api+.rb +1 -1
- data/test/app_custom/_site.rb +1 -1
- data/test/bm_router_proc.rb +3 -3
- data/test/helper.rb +4 -27
- data/test/test_app.rb +83 -98
- data/test/test_caching.rb +2 -2
- data/test/test_errors.rb +6 -6
- data/test/test_http_client.rb +52 -0
- data/test/test_http_client_connection.rb +43 -0
- data/test/{test_connection.rb → test_http_server_connection.rb} +32 -32
- data/test/test_json_api.rb +14 -12
- data/test/test_mock_adapter.rb +59 -0
- data/test/{test_request_extensions.rb → test_request.rb} +150 -18
- data/test/test_response.rb +112 -0
- data/test/test_routing_tree.rb +15 -3
- data/test/test_server.rb +1 -1
- metadata +57 -35
- data/lib/syntropy/connection.rb +0 -402
- data/lib/syntropy/request_extensions.rb +0 -308
- data/lib/syntropy/server.rb +0 -173
- /data/examples/{bad.rb → basic/bad.rb} +0 -0
- /data/examples/{card.rb → basic/card.rb} +0 -0
- /data/examples/{counter.js → basic/counter.js} +0 -0
- /data/examples/{counter_api.rb → basic/counter_api.rb} +0 -0
- /data/examples/{favicon.ico → basic/favicon.ico} +0 -0
- /data/examples/{index.md → basic/index.md} +0 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'syntropy/errors'
|
|
4
|
+
require 'syntropy/http/io_extensions'
|
|
5
|
+
|
|
6
|
+
module Syntropy
|
|
7
|
+
module HTTP
|
|
8
|
+
# Implements an HTTP/1.1 connection received by the Syntropy server. This
|
|
9
|
+
# implementation rejects incoming HTTP/0.9 or HTTP/1.0 requests. The response
|
|
10
|
+
# body is sent exclusively using chunked transfer encoding. Request bodies are
|
|
11
|
+
# accepted using either fixed length (Content-Length header) or chunked
|
|
12
|
+
# transfer encoding.
|
|
13
|
+
class ServerConnection
|
|
14
|
+
attr_reader :fd, :response_headers, :logger
|
|
15
|
+
|
|
16
|
+
def initialize(machine, fd, env, io_mode: :socket, &app)
|
|
17
|
+
@machine = machine
|
|
18
|
+
@fd = fd
|
|
19
|
+
@env = env
|
|
20
|
+
@logger = env[:logger]
|
|
21
|
+
@io = machine.io(fd, io_mode)
|
|
22
|
+
@app = app
|
|
23
|
+
|
|
24
|
+
@done = nil
|
|
25
|
+
@response_headers = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run
|
|
29
|
+
loop do
|
|
30
|
+
@done = nil
|
|
31
|
+
@response_headers = nil
|
|
32
|
+
persist = serve_request
|
|
33
|
+
break if !persist
|
|
34
|
+
end
|
|
35
|
+
rescue UM::Terminate
|
|
36
|
+
# server is terminated, do nothing
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
@logger&.error(
|
|
39
|
+
message: 'Uncaught error while running connection',
|
|
40
|
+
error: e
|
|
41
|
+
)
|
|
42
|
+
ensure
|
|
43
|
+
@machine.close_async(@fd)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Processes an incoming request by parsing the headers, creating a request
|
|
47
|
+
# object and handing it off to the app handler. Returns true if the
|
|
48
|
+
# connection should be persisted.
|
|
49
|
+
def serve_request
|
|
50
|
+
@closed = nil
|
|
51
|
+
headers = parse_headers
|
|
52
|
+
return false if !headers
|
|
53
|
+
|
|
54
|
+
request = Syntropy::Request.new(headers, self)
|
|
55
|
+
|
|
56
|
+
@app.call(request)
|
|
57
|
+
persist_connection?(headers)
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
handle_error(request, e)
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Handles an error encountered while serving a request by logging the error
|
|
64
|
+
# and optionally sending an error response with the relevant HTTP status
|
|
65
|
+
# code. For I/O errors, no response is sent.
|
|
66
|
+
#
|
|
67
|
+
# @param request [Syntropy::Request] HTTP request
|
|
68
|
+
# @param err [Exception] error
|
|
69
|
+
# @return [void]
|
|
70
|
+
def handle_error(request, err)
|
|
71
|
+
case err
|
|
72
|
+
when SystemCallError
|
|
73
|
+
log_error(err, 'I/O error')
|
|
74
|
+
false
|
|
75
|
+
when ProtocolError
|
|
76
|
+
log_error(err, err.message)
|
|
77
|
+
respond(request, err.message, ':status' => err.http_status)
|
|
78
|
+
else
|
|
79
|
+
log_error(err, 'Internal error')
|
|
80
|
+
return if !request || @done
|
|
81
|
+
|
|
82
|
+
respond(request, 'Internal server error', ':status' => INTERNAL_SERVER_ERROR)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Logs the given err and given message.
|
|
87
|
+
#
|
|
88
|
+
# @param err [Exception] error
|
|
89
|
+
# @param message [String] error message
|
|
90
|
+
# @return [void]
|
|
91
|
+
def log_error(err, message)
|
|
92
|
+
@logger&.error(message: "#{message}, closing connection", error: err)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def get_body(req)
|
|
96
|
+
headers = req.headers
|
|
97
|
+
return nil if headers[':body-done-reading']
|
|
98
|
+
|
|
99
|
+
body = @io.http_read_body(headers)
|
|
100
|
+
headers[':body-done-reading'] = true if body
|
|
101
|
+
body
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def get_body_chunk(req)
|
|
105
|
+
headers = req.headers
|
|
106
|
+
return nil if headers[':body-done-reading']
|
|
107
|
+
|
|
108
|
+
chunk = @io.http_read_body_chunk(headers)
|
|
109
|
+
headers[':body-done-reading'] = true if !chunk
|
|
110
|
+
chunk
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def complete?(req)
|
|
114
|
+
req.headers[':body-done-reading']
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# response API
|
|
118
|
+
|
|
119
|
+
# Sets response headers before sending any response. This method is used to
|
|
120
|
+
# add headers such as Set-Cookie or cache control headers to a response
|
|
121
|
+
# before actually responding, specifically in middleware hooks.
|
|
122
|
+
#
|
|
123
|
+
# @param headers [Hash] response headers
|
|
124
|
+
# @return [void]
|
|
125
|
+
def set_response_headers(headers)
|
|
126
|
+
@response_headers ? @response_headers.merge!(headers) : @response_headers = headers
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def set_cookie(*cookies)
|
|
130
|
+
existing_cookies = @response_headers && @response_headers['Set-Cookie']
|
|
131
|
+
if existing_cookies
|
|
132
|
+
@response_headers['Set-Cookie'] = existing_cookies + cookies
|
|
133
|
+
else
|
|
134
|
+
set_response_headers('Set-Cookie' => cookies)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
SEND_FLAGS = UM::MSG_NOSIGNAL | UM::MSG_WAITALL
|
|
139
|
+
|
|
140
|
+
EMPTY_CHUNK = "0\r\n\r\n"
|
|
141
|
+
EMPTY_CHUNK_LEN = EMPTY_CHUNK.bytesize
|
|
142
|
+
|
|
143
|
+
CHUNKED_ENCODING_POSTLUDE = "\r\n#{EMPTY_CHUNK}"
|
|
144
|
+
|
|
145
|
+
# Sends response including headers and body. Waits for the request to complete
|
|
146
|
+
# if not yet completed. The body is sent using chunked transfer encoding.
|
|
147
|
+
# @param request [Syntropy::Request] HTTP request
|
|
148
|
+
# @param body [String] response body
|
|
149
|
+
# @param headers
|
|
150
|
+
def respond(request, body, headers)
|
|
151
|
+
headers = @response_headers.merge(headers) if @response_headers
|
|
152
|
+
|
|
153
|
+
formatted_headers = format_headers(headers, body)
|
|
154
|
+
@response_headers = headers
|
|
155
|
+
request&.tx_incr(formatted_headers.bytesize + (body ? body.bytesize : 0))
|
|
156
|
+
if body
|
|
157
|
+
chunk_prelude = "#{body.bytesize.to_s(16)}\r\n"
|
|
158
|
+
@machine.sendv(@fd, formatted_headers, chunk_prelude, body, CHUNKED_ENCODING_POSTLUDE)
|
|
159
|
+
else
|
|
160
|
+
@machine.send(@fd, formatted_headers, formatted_headers.bytesize, SEND_FLAGS)
|
|
161
|
+
end
|
|
162
|
+
@logger&.info(request: request, response_headers: headers) if request
|
|
163
|
+
@done = true
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Sends response headers. If empty_response is truthy, the response status
|
|
167
|
+
# code will default to 204, otherwise to 200.
|
|
168
|
+
# @param request [Syntropy::Request] HTTP request
|
|
169
|
+
# @param headers [Hash] response headers
|
|
170
|
+
# @param empty_response [boolean] whether a response body will be sent
|
|
171
|
+
# @return [void]
|
|
172
|
+
def send_headers(request, headers, empty_response: false)
|
|
173
|
+
formatted_headers = format_headers(headers, !empty_response)
|
|
174
|
+
request.tx_incr(formatted_headers.bytesize)
|
|
175
|
+
@machine.send(@fd, formatted_headers, formatted_headers.bytesize, SEND_FLAGS)
|
|
176
|
+
@response_headers = headers
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Sends a response body chunk. If no headers were sent, default headers are
|
|
180
|
+
# sent using #send_headers. if the done option is true(thy), an empty chunk
|
|
181
|
+
# will be sent to signal response completion to the client.
|
|
182
|
+
# @param request [Syntropy::Request] HTTP request
|
|
183
|
+
# @param chunk [String] response body chunk
|
|
184
|
+
# @param done [boolean] whether the response is completed
|
|
185
|
+
# @return [void]
|
|
186
|
+
def send_chunk(request, chunk, done: false)
|
|
187
|
+
data = +''
|
|
188
|
+
data << "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n" if chunk
|
|
189
|
+
data << EMPTY_CHUNK if done
|
|
190
|
+
return if data.empty?
|
|
191
|
+
|
|
192
|
+
request.tx_incr(data.bytesize)
|
|
193
|
+
@machine.send(@fd, data, data.bytesize, SEND_FLAGS)
|
|
194
|
+
return if @done || !done
|
|
195
|
+
|
|
196
|
+
@logger&.info(request: request, response_headers: @response_headers)
|
|
197
|
+
@done = true
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Finishes the response to the current request. If no headers were sent,
|
|
201
|
+
# default headers are sent using #send_headers.
|
|
202
|
+
# @return [void]
|
|
203
|
+
def finish(request)
|
|
204
|
+
request.tx_incr(EMPTY_CHUNK_LEN)
|
|
205
|
+
@machine.send(@fd, EMPTY_CHUNK, EMPTY_CHUNK_LEN, SEND_FLAGS)
|
|
206
|
+
return if @done
|
|
207
|
+
|
|
208
|
+
@logger&.info(request, request, response_headers: @response_headers)
|
|
209
|
+
@done = true
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def respond_with_static_file(req, path, env, cache_headers)
|
|
213
|
+
fd = @machine.open(path, UM::O_RDONLY)
|
|
214
|
+
env ||= {}
|
|
215
|
+
if env[:headers]
|
|
216
|
+
env[:headers].merge!(cache_headers)
|
|
217
|
+
else
|
|
218
|
+
env[:headers] = cache_headers
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
maxlen = env[:max_len] || 65_536
|
|
222
|
+
buf = String.new(capacity: maxlen)
|
|
223
|
+
headers_sent = nil
|
|
224
|
+
loop do
|
|
225
|
+
res = @machine.read(fd, buf, maxlen, 0)
|
|
226
|
+
if res < maxlen && !headers_sent
|
|
227
|
+
return respond(req, buf, env[:headers])
|
|
228
|
+
elsif res == 0
|
|
229
|
+
return finish(req)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
if !headers_sent
|
|
233
|
+
send_headers(req, env[:headers])
|
|
234
|
+
headers_sent = true
|
|
235
|
+
end
|
|
236
|
+
done = res < maxlen
|
|
237
|
+
send_chunk(req, buf, done: done)
|
|
238
|
+
return if done
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def close
|
|
243
|
+
return if @closed
|
|
244
|
+
|
|
245
|
+
@closed = true
|
|
246
|
+
@machine.shutdown(@fd, UM::SHUT_WR)
|
|
247
|
+
@machine.close_async(@fd)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def with_stream
|
|
251
|
+
yield @io, @fd
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
private
|
|
255
|
+
|
|
256
|
+
RE_REQUEST_LINE = /^([a-z]+)\s+([^\s]+)\s+http\/([019\.]{1,3})/i
|
|
257
|
+
RE_HEADER_LINE = /^([a-z0-9-]+):\s+(.+)/i
|
|
258
|
+
MAX_REQUEST_LINE_LEN = 1 << 14 # 16KB
|
|
259
|
+
MAX_HEADER_LINE_LEN = 1 << 10 # 1KB
|
|
260
|
+
MAX_CHUNK_SIZE_LEN = 16
|
|
261
|
+
|
|
262
|
+
def persist_connection?(headers)
|
|
263
|
+
connection = headers['connection']&.downcase
|
|
264
|
+
return connection != 'close'
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def parse_headers
|
|
268
|
+
headers = get_request_line(MAX_REQUEST_LINE_LEN)
|
|
269
|
+
return nil if !headers
|
|
270
|
+
|
|
271
|
+
loop do
|
|
272
|
+
line = @io.read_line(MAX_HEADER_LINE_LEN)
|
|
273
|
+
break if line.nil? || line.empty?
|
|
274
|
+
|
|
275
|
+
m = line.match(RE_HEADER_LINE)
|
|
276
|
+
raise ProtocolError, "Invalid header: #{line[0..2047].inspect}" if !m
|
|
277
|
+
|
|
278
|
+
headers[m[1].downcase] = m[2]
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
headers
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def get_request_line(buf)
|
|
285
|
+
line = @io.read_line(MAX_REQUEST_LINE_LEN)
|
|
286
|
+
return nil if !line
|
|
287
|
+
|
|
288
|
+
m = line.match(RE_REQUEST_LINE)
|
|
289
|
+
raise ProtocolError, 'Invalid request line' if !m
|
|
290
|
+
|
|
291
|
+
http_version = m[3]
|
|
292
|
+
raise UnsupportedHTTPVersionError, 'HTTP version not supported' if http_version != '1.1'
|
|
293
|
+
|
|
294
|
+
{
|
|
295
|
+
':method' => m[1].downcase,
|
|
296
|
+
':path' => m[2],
|
|
297
|
+
':protocol' => 'http/1.1'
|
|
298
|
+
}
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def read_chunk(headers, buffer)
|
|
302
|
+
chunk_size_str = @io.read_line(MAX_CHUNK_SIZE_LEN)
|
|
303
|
+
return nil if !chunk_size_str
|
|
304
|
+
|
|
305
|
+
chunk_size = chunk_size_str.to_i(16)
|
|
306
|
+
if chunk_size == 0
|
|
307
|
+
headers[':body-done-reading'] = true
|
|
308
|
+
@io.read_line(0)
|
|
309
|
+
return nil
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
chunk = @io.read(chunk_size)
|
|
313
|
+
@io.read_line(0)
|
|
314
|
+
|
|
315
|
+
buffer ? (buffer << chunk) : chunk
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
INTERNAL_HEADER_REGEXP = /^:/
|
|
319
|
+
|
|
320
|
+
# Formats response headers into an array. If empty_response is true(thy),
|
|
321
|
+
# the response status code will default to 204, otherwise to 200.
|
|
322
|
+
# @param headers [Hash] response headers
|
|
323
|
+
# @param body [boolean] whether a response body will be sent
|
|
324
|
+
# @return [String] formatted response headers
|
|
325
|
+
def format_headers(headers, body)
|
|
326
|
+
status = headers[':status'] || (body ? OK : NO_CONTENT)
|
|
327
|
+
lines = format_status_line(body, status)
|
|
328
|
+
lines << @env[:server_headers] if @env[:server_headers]
|
|
329
|
+
headers.each do |k, v|
|
|
330
|
+
next if k =~ INTERNAL_HEADER_REGEXP
|
|
331
|
+
|
|
332
|
+
collect_header_lines(lines, k, v)
|
|
333
|
+
end
|
|
334
|
+
lines << "\r\n"
|
|
335
|
+
lines
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def format_status_line(body, status)
|
|
339
|
+
if !body
|
|
340
|
+
empty_status_line(status)
|
|
341
|
+
else
|
|
342
|
+
with_body_status_line(status, body)
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def empty_status_line(status)
|
|
347
|
+
if status == 204
|
|
348
|
+
+"HTTP/1.1 #{status}\r\n"
|
|
349
|
+
else
|
|
350
|
+
+"HTTP/1.1 #{status}\r\nContent-Length: 0\r\n"
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def with_body_status_line(status, body)
|
|
355
|
+
+"HTTP/1.1 #{status}\r\nTransfer-Encoding: chunked\r\n"
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def collect_header_lines(lines, key, value)
|
|
359
|
+
if value.is_a?(Array)
|
|
360
|
+
value.inject(lines) { |_, item| lines << "#{key}: #{item}\r\n" }
|
|
361
|
+
else
|
|
362
|
+
lines << "#{key}: #{value}\r\n"
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syntropy
|
|
4
|
+
module HTTP
|
|
5
|
+
# translated from https://golang.org/pkg/net/http/#pkg-constants
|
|
6
|
+
# https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
|
|
7
|
+
|
|
8
|
+
CONTINUE = 100 # RFC 7231, 6.2.1
|
|
9
|
+
SWITCHING_PROTOCOLS = 101 # RFC 7231, 6.2.2
|
|
10
|
+
PROCESSING = 102 # RFC 2518, 10.1
|
|
11
|
+
EARLY_HINTS = 103 # RFC 8297
|
|
12
|
+
|
|
13
|
+
OK = 200 # RFC 7231, 6.3.1
|
|
14
|
+
CREATED = 201 # RFC 7231, 6.3.2
|
|
15
|
+
ACCEPTED = 202 # RFC 7231, 6.3.3
|
|
16
|
+
NON_AUTHORITATIVE_INFO = 203 # RFC 7231, 6.3.4
|
|
17
|
+
NO_CONTENT = 204 # RFC 7231, 6.3.5
|
|
18
|
+
RESET_CONTENT = 205 # RFC 7231, 6.3.6
|
|
19
|
+
PARTIAL_CONTENT = 206 # RFC 7233, 4.1
|
|
20
|
+
MULTI_STATUS = 207 # RFC 4918, 11.1
|
|
21
|
+
ALREADY_REPORTED = 208 # RFC 5842, 7.1
|
|
22
|
+
IM_USED = 226 # RFC 3229, 10.4.1
|
|
23
|
+
|
|
24
|
+
MULTIPLE_CHOICES = 300 # RFC 7231, 6.4.1
|
|
25
|
+
MOVED_PERMANENTLY = 301 # RFC 7231, 6.4.2
|
|
26
|
+
FOUND = 302 # RFC 7231, 6.4.3
|
|
27
|
+
SEE_OTHER = 303 # RFC 7231, 6.4.4
|
|
28
|
+
NOT_MODIFIED = 304 # RFC 7232, 4.1
|
|
29
|
+
USE_PROXY = 305 # RFC 7231, 6.4.5
|
|
30
|
+
|
|
31
|
+
TEMPORARY_REDIRECT = 307 # RFC 7231, 6.4.7
|
|
32
|
+
PERMANENT_REDIRECT = 308 # RFC 7538, 3
|
|
33
|
+
|
|
34
|
+
BAD_REQUEST = 400 # RFC 7231, 6.5.1
|
|
35
|
+
UNAUTHORIZED = 401 # RFC 7235, 3.1
|
|
36
|
+
PAYMENT_REQUIRED = 402 # RFC 7231, 6.5.2
|
|
37
|
+
FORBIDDEN = 403 # RFC 7231, 6.5.3
|
|
38
|
+
NOT_FOUND = 404 # RFC 7231, 6.5.4
|
|
39
|
+
METHOD_NOT_ALLOWED = 405 # RFC 7231, 6.5.5
|
|
40
|
+
NOT_ACCEPTABLE = 406 # RFC 7231, 6.5.6
|
|
41
|
+
PROXY_AUTH_REQUIRED = 407 # RFC 7235, 3.2
|
|
42
|
+
REQUEST_TIMEOUT = 408 # RFC 7231, 6.5.7
|
|
43
|
+
CONFLICT = 409 # RFC 7231, 6.5.8
|
|
44
|
+
GONE = 410 # RFC 7231, 6.5.9
|
|
45
|
+
LENGTH_REQUIRED = 411 # RFC 7231, 6.5.10
|
|
46
|
+
PRECONDITION_FAILED = 412 # RFC 7232, 4.2
|
|
47
|
+
REQUEST_ENTITY_TOO_LARGE = 413 # RFC 7231, 6.5.11
|
|
48
|
+
REQUEST_URI_TOO_LONG = 414 # RFC 7231, 6.5.12
|
|
49
|
+
UNSUPPORTED_MEDIA_TYPE = 415 # RFC 7231, 6.5.13
|
|
50
|
+
REQUESTED_RANGE_NOT_SATISFIABLE = 416 # RFC 7233, 4.4
|
|
51
|
+
EXPECTATION_FAILED = 417 # RFC 7231, 6.5.14
|
|
52
|
+
TEAPOT = 418 # RFC 7168, 2.3.3
|
|
53
|
+
MISDIRECTED_REQUEST = 421 # RFC 7540, 9.1.2
|
|
54
|
+
UNPROCESSABLE_ENTITY = 422 # RFC 4918, 11.2
|
|
55
|
+
LOCKED = 423 # RFC 4918, 11.3
|
|
56
|
+
FAILED_DEPENDENCY = 424 # RFC 4918, 11.4
|
|
57
|
+
TOO_EARLY = 425 # RFC 8470, 5.2.
|
|
58
|
+
UPGRADE_REQUIRED = 426 # RFC 7231, 6.5.15
|
|
59
|
+
PRECONDITION_REQUIRED = 428 # RFC 6585, 3
|
|
60
|
+
TOO_MANY_REQUESTS = 429 # RFC 6585, 4
|
|
61
|
+
REQUEST_HEADER_FIELDS_TOO_LARGE = 431 # RFC 6585, 5
|
|
62
|
+
UNAVAILABLE_FOR_LEGAL_REASONS = 451 # RFC 7725, 3
|
|
63
|
+
|
|
64
|
+
INTERNAL_SERVER_ERROR = 500 # RFC 7231, 6.6.1
|
|
65
|
+
NOT_IMPLEMENTED = 501 # RFC 7231, 6.6.2
|
|
66
|
+
BAD_GATEWAY = 502 # RFC 7231, 6.6.3
|
|
67
|
+
SERVICE_UNAVAILABLE = 503 # RFC 7231, 6.6.4
|
|
68
|
+
GATEWAY_TIMEOUT = 504 # RFC 7231, 6.6.5
|
|
69
|
+
HTTP_VERSION_NOT_SUPPORTED = 505 # RFC 7231, 6.6.6
|
|
70
|
+
VARIANT_ALSO_NEGOTIATES = 506 # RFC 2295, 8.1
|
|
71
|
+
INSUFFICIENT_STORAGE = 507 # RFC 4918, 11.5
|
|
72
|
+
LOOP_DETECTED = 508 # RFC 5842, 7.2
|
|
73
|
+
NOT_EXTENDED = 510 # RFC 2774, 7
|
|
74
|
+
NETWORK_AUTHENTICATION_REQUIRED = 511 # RFC 6585, 6
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/syntropy/json_api.rb
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'qeweney'
|
|
4
3
|
require 'syntropy/errors'
|
|
5
4
|
require 'json'
|
|
6
5
|
|
|
@@ -31,7 +30,7 @@ module Syntropy
|
|
|
31
30
|
else
|
|
32
31
|
raise Syntropy::Error.method_not_allowed
|
|
33
32
|
end
|
|
34
|
-
[{ status: 'OK', response: response },
|
|
33
|
+
[{ status: 'OK', response: response }, HTTP::OK]
|
|
35
34
|
rescue => e
|
|
36
35
|
if !e.is_a?(Syntropy::Error)
|
|
37
36
|
p e
|
|
@@ -55,10 +54,8 @@ module Syntropy
|
|
|
55
54
|
raise err
|
|
56
55
|
end
|
|
57
56
|
|
|
58
|
-
INTERNAL_SERVER_ERROR = Qeweney::Status::INTERNAL_SERVER_ERROR
|
|
59
|
-
|
|
60
57
|
def __error_response__(err)
|
|
61
|
-
http_status = err.respond_to?(:http_status) ? err.http_status : INTERNAL_SERVER_ERROR
|
|
58
|
+
http_status = err.respond_to?(:http_status) ? err.http_status : HTTP::INTERNAL_SERVER_ERROR
|
|
62
59
|
error_name = err.class.name.split('::').last
|
|
63
60
|
[{ status: error_name, message: err.message }, http_status]
|
|
64
61
|
end
|
data/lib/syntropy/logger.rb
CHANGED
|
@@ -67,7 +67,7 @@ module Syntropy
|
|
|
67
67
|
request = o[:request]
|
|
68
68
|
request_headers = request.headers
|
|
69
69
|
response_headers = o[:response_headers]
|
|
70
|
-
elapsed =
|
|
70
|
+
elapsed = monotonic_clock - request.start_stamp
|
|
71
71
|
{
|
|
72
72
|
level: level.to_s,
|
|
73
73
|
ts: (t = Time.now; t.to_i),
|
|
@@ -82,6 +82,10 @@ module Syntropy
|
|
|
82
82
|
}
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
def monotonic_clock
|
|
86
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
87
|
+
end
|
|
88
|
+
|
|
85
89
|
def make_hash_entry(level, hash)
|
|
86
90
|
{
|
|
87
91
|
level: level.to_s,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syntropy
|
|
4
|
+
# File extension to MIME type mapping
|
|
5
|
+
module MimeTypes
|
|
6
|
+
TYPES = {
|
|
7
|
+
'html' => 'text/html',
|
|
8
|
+
'css' => 'text/css',
|
|
9
|
+
'js' => 'application/javascript',
|
|
10
|
+
'txt' => 'text/plain',
|
|
11
|
+
'text' => 'text/plain',
|
|
12
|
+
'gif' => 'image/gif',
|
|
13
|
+
'jpg' => 'image/jpeg',
|
|
14
|
+
'jpeg' => 'image/jpeg',
|
|
15
|
+
'png' => 'image/png',
|
|
16
|
+
'ico' => 'image/x-icon',
|
|
17
|
+
'svg' => 'image/svg+xml',
|
|
18
|
+
'pdf' => 'application/pdf',
|
|
19
|
+
'json' => 'application/json',
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
EXT_REGEXP = /\.?([^\.]+)$/.freeze
|
|
23
|
+
|
|
24
|
+
def self.[](ref)
|
|
25
|
+
case ref
|
|
26
|
+
when Symbol
|
|
27
|
+
TYPES[ref.to_s]
|
|
28
|
+
when EXT_REGEXP
|
|
29
|
+
TYPES[Regexp.last_match(1)]
|
|
30
|
+
when ''
|
|
31
|
+
nil
|
|
32
|
+
else
|
|
33
|
+
raise "Invalid argument #{ref.inspect}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syntropy
|
|
4
|
+
class MockAdapter
|
|
5
|
+
attr_reader :response_body, :response_headers, :calls
|
|
6
|
+
|
|
7
|
+
def get_body_chunk(_req)
|
|
8
|
+
@request_body_chunks.shift
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def get_body(_req)
|
|
12
|
+
body = @request_body_chunks.join('')
|
|
13
|
+
@request_body_chunks.clear
|
|
14
|
+
body
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def complete?(_req)
|
|
18
|
+
@request_body_chunks.empty?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(request_body)
|
|
22
|
+
case request_body
|
|
23
|
+
when Array
|
|
24
|
+
@request_body_chunks = request_body
|
|
25
|
+
when nil
|
|
26
|
+
@request_body_chunks = []
|
|
27
|
+
else
|
|
28
|
+
@request_body_chunks = [request_body]
|
|
29
|
+
end
|
|
30
|
+
@calls = []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def set_response_headers(headers)
|
|
34
|
+
@response_headers = headers
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def respond(req, body, headers)
|
|
38
|
+
headers = @response_headers.merge(headers) if @response_headers
|
|
39
|
+
@calls << [:respond, req, body, headers]
|
|
40
|
+
@response_body = body
|
|
41
|
+
@response_headers = headers
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def status
|
|
45
|
+
raise 'No response' if !response_headers
|
|
46
|
+
|
|
47
|
+
response_headers[':status'] || HTTP::OK
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def method_missing(sym, *args)
|
|
51
|
+
calls << [sym, *args]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.mock(headers = {}, request_body = nil)
|
|
55
|
+
headers[':method'] ||= ''
|
|
56
|
+
headers[':path'] ||= ''
|
|
57
|
+
Request.new(headers, new(request_body))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|