tarsier 0.1.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 +7 -0
- data/CHANGELOG.md +175 -0
- data/LICENSE.txt +21 -0
- data/README.md +984 -0
- data/exe/tarsier +7 -0
- data/lib/tarsier/application.rb +336 -0
- data/lib/tarsier/cli/commands/console.rb +87 -0
- data/lib/tarsier/cli/commands/generate.rb +85 -0
- data/lib/tarsier/cli/commands/help.rb +50 -0
- data/lib/tarsier/cli/commands/new.rb +59 -0
- data/lib/tarsier/cli/commands/routes.rb +139 -0
- data/lib/tarsier/cli/commands/server.rb +123 -0
- data/lib/tarsier/cli/commands/version.rb +14 -0
- data/lib/tarsier/cli/generators/app.rb +528 -0
- data/lib/tarsier/cli/generators/base.rb +93 -0
- data/lib/tarsier/cli/generators/controller.rb +91 -0
- data/lib/tarsier/cli/generators/middleware.rb +81 -0
- data/lib/tarsier/cli/generators/migration.rb +109 -0
- data/lib/tarsier/cli/generators/model.rb +109 -0
- data/lib/tarsier/cli/generators/resource.rb +27 -0
- data/lib/tarsier/cli/loader.rb +18 -0
- data/lib/tarsier/cli.rb +46 -0
- data/lib/tarsier/controller.rb +282 -0
- data/lib/tarsier/database.rb +588 -0
- data/lib/tarsier/errors.rb +77 -0
- data/lib/tarsier/middleware/base.rb +47 -0
- data/lib/tarsier/middleware/compression.rb +113 -0
- data/lib/tarsier/middleware/cors.rb +101 -0
- data/lib/tarsier/middleware/csrf.rb +88 -0
- data/lib/tarsier/middleware/logger.rb +74 -0
- data/lib/tarsier/middleware/rate_limit.rb +110 -0
- data/lib/tarsier/middleware/stack.rb +143 -0
- data/lib/tarsier/middleware/static.rb +124 -0
- data/lib/tarsier/model.rb +590 -0
- data/lib/tarsier/params.rb +269 -0
- data/lib/tarsier/query.rb +495 -0
- data/lib/tarsier/request.rb +274 -0
- data/lib/tarsier/response.rb +282 -0
- data/lib/tarsier/router/compiler.rb +173 -0
- data/lib/tarsier/router/node.rb +97 -0
- data/lib/tarsier/router/route.rb +119 -0
- data/lib/tarsier/router.rb +272 -0
- data/lib/tarsier/version.rb +5 -0
- data/lib/tarsier/websocket.rb +275 -0
- data/lib/tarsier.rb +167 -0
- data/sig/tarsier.rbs +485 -0
- metadata +230 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
module Tarsier
|
|
6
|
+
module Middleware
|
|
7
|
+
# Response compression middleware
|
|
8
|
+
# Supports gzip and deflate encoding
|
|
9
|
+
class Compression < Base
|
|
10
|
+
DEFAULT_OPTIONS = {
|
|
11
|
+
min_size: 1024,
|
|
12
|
+
level: Zlib::DEFAULT_COMPRESSION,
|
|
13
|
+
content_types: %w[
|
|
14
|
+
text/html
|
|
15
|
+
text/plain
|
|
16
|
+
text/css
|
|
17
|
+
text/javascript
|
|
18
|
+
application/javascript
|
|
19
|
+
application/json
|
|
20
|
+
application/xml
|
|
21
|
+
image/svg+xml
|
|
22
|
+
]
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
ENCODINGS = {
|
|
26
|
+
"gzip" => :gzip,
|
|
27
|
+
"deflate" => :deflate
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
def initialize(app, **options)
|
|
31
|
+
super
|
|
32
|
+
@options = DEFAULT_OPTIONS.merge(options)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call(request, response)
|
|
36
|
+
@app.call(request, response)
|
|
37
|
+
|
|
38
|
+
return response unless should_compress?(request, response)
|
|
39
|
+
|
|
40
|
+
encoding = preferred_encoding(request)
|
|
41
|
+
return response unless encoding
|
|
42
|
+
|
|
43
|
+
compress_response(response, encoding)
|
|
44
|
+
response
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def should_compress?(request, response)
|
|
50
|
+
return false if response.streaming?
|
|
51
|
+
return false unless response.body.is_a?(String)
|
|
52
|
+
return false if response.body.bytesize < @options[:min_size]
|
|
53
|
+
return false unless compressible_content_type?(response)
|
|
54
|
+
return false if already_compressed?(response)
|
|
55
|
+
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def compressible_content_type?(response)
|
|
60
|
+
content_type = response.get_header("Content-Type")
|
|
61
|
+
return false unless content_type
|
|
62
|
+
|
|
63
|
+
@options[:content_types].any? { |type| content_type.include?(type) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def already_compressed?(response)
|
|
67
|
+
response.get_header("Content-Encoding")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def preferred_encoding(request)
|
|
71
|
+
accept = request.header("Accept-Encoding")
|
|
72
|
+
return nil unless accept
|
|
73
|
+
|
|
74
|
+
ENCODINGS.each do |name, method|
|
|
75
|
+
return method if accept.include?(name)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def compress_response(response, encoding)
|
|
82
|
+
original = response.body
|
|
83
|
+
compressed = case encoding
|
|
84
|
+
when :gzip then gzip_compress(original)
|
|
85
|
+
when :deflate then deflate_compress(original)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Only use compressed version if it's actually smaller
|
|
89
|
+
if compressed.bytesize < original.bytesize
|
|
90
|
+
response.body = compressed
|
|
91
|
+
response.set_header("Content-Encoding", encoding.to_s)
|
|
92
|
+
response.set_header("Content-Length", compressed.bytesize.to_s)
|
|
93
|
+
response.set_header("Vary", "Accept-Encoding")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def gzip_compress(data)
|
|
98
|
+
io = StringIO.new
|
|
99
|
+
io.set_encoding("BINARY")
|
|
100
|
+
|
|
101
|
+
gz = Zlib::GzipWriter.new(io, @options[:level])
|
|
102
|
+
gz.write(data)
|
|
103
|
+
gz.close
|
|
104
|
+
|
|
105
|
+
io.string
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def deflate_compress(data)
|
|
109
|
+
Zlib::Deflate.deflate(data, @options[:level])
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarsier
|
|
4
|
+
module Middleware
|
|
5
|
+
# CORS (Cross-Origin Resource Sharing) middleware
|
|
6
|
+
# Handles preflight requests and sets appropriate headers
|
|
7
|
+
class CORS < Base
|
|
8
|
+
DEFAULT_OPTIONS = {
|
|
9
|
+
origins: "*",
|
|
10
|
+
methods: %w[GET POST PUT PATCH DELETE OPTIONS],
|
|
11
|
+
headers: %w[Content-Type Authorization Accept X-Requested-With],
|
|
12
|
+
expose_headers: [],
|
|
13
|
+
max_age: 86_400,
|
|
14
|
+
credentials: false
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(app, **options)
|
|
18
|
+
super
|
|
19
|
+
@options = DEFAULT_OPTIONS.merge(options)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(request, response)
|
|
23
|
+
origin = request.header("Origin")
|
|
24
|
+
|
|
25
|
+
# Handle preflight OPTIONS request
|
|
26
|
+
if request.options? && origin
|
|
27
|
+
handle_preflight(request, response, origin)
|
|
28
|
+
return response
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Add CORS headers to actual request
|
|
32
|
+
add_cors_headers(response, origin) if origin
|
|
33
|
+
|
|
34
|
+
@app.call(request, response)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def handle_preflight(request, response, origin)
|
|
40
|
+
return unless allowed_origin?(origin)
|
|
41
|
+
|
|
42
|
+
response.status = 204
|
|
43
|
+
add_cors_headers(response, origin)
|
|
44
|
+
|
|
45
|
+
requested_method = request.header("Access-Control-Request-Method")
|
|
46
|
+
requested_headers = request.header("Access-Control-Request-Headers")
|
|
47
|
+
|
|
48
|
+
if requested_method && allowed_method?(requested_method)
|
|
49
|
+
response.set_header("Access-Control-Allow-Methods", @options[:methods].join(", "))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if requested_headers
|
|
53
|
+
response.set_header("Access-Control-Allow-Headers", allowed_headers(requested_headers))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
response.set_header("Access-Control-Max-Age", @options[:max_age].to_s)
|
|
57
|
+
response.body = ""
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def add_cors_headers(response, origin)
|
|
61
|
+
return unless allowed_origin?(origin)
|
|
62
|
+
|
|
63
|
+
response.set_header("Access-Control-Allow-Origin", cors_origin(origin))
|
|
64
|
+
|
|
65
|
+
if @options[:credentials]
|
|
66
|
+
response.set_header("Access-Control-Allow-Credentials", "true")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if @options[:expose_headers].any?
|
|
70
|
+
response.set_header("Access-Control-Expose-Headers", @options[:expose_headers].join(", "))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
response.set_header("Vary", "Origin")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def allowed_origin?(origin)
|
|
77
|
+
return true if @options[:origins] == "*"
|
|
78
|
+
return true if @options[:origins].include?(origin)
|
|
79
|
+
|
|
80
|
+
Array(@options[:origins]).any? do |pattern|
|
|
81
|
+
pattern.is_a?(Regexp) ? pattern.match?(origin) : pattern == origin
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def cors_origin(origin)
|
|
86
|
+
@options[:origins] == "*" && !@options[:credentials] ? "*" : origin
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def allowed_method?(method)
|
|
90
|
+
@options[:methods].map(&:upcase).include?(method.upcase)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def allowed_headers(requested)
|
|
94
|
+
requested_list = requested.split(",").map(&:strip)
|
|
95
|
+
allowed = @options[:headers].map(&:downcase)
|
|
96
|
+
|
|
97
|
+
requested_list.select { |h| allowed.include?(h.downcase) }.join(", ")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Tarsier
|
|
6
|
+
module Middleware
|
|
7
|
+
# CSRF (Cross-Site Request Forgery) protection middleware
|
|
8
|
+
# Validates tokens for state-changing requests
|
|
9
|
+
class CSRF < Base
|
|
10
|
+
SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
|
|
11
|
+
TOKEN_LENGTH = 32
|
|
12
|
+
HEADER_NAME = "X-CSRF-Token"
|
|
13
|
+
PARAM_NAME = "_csrf_token"
|
|
14
|
+
SESSION_KEY = :csrf_token
|
|
15
|
+
|
|
16
|
+
def initialize(app, **options)
|
|
17
|
+
super
|
|
18
|
+
@skip_paths = options[:skip] || []
|
|
19
|
+
@token_length = options[:token_length] || TOKEN_LENGTH
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(request, response)
|
|
23
|
+
return @app.call(request, response) if skip_csrf?(request)
|
|
24
|
+
|
|
25
|
+
session = request.env["tarsier.session"] ||= {}
|
|
26
|
+
|
|
27
|
+
if safe_request?(request)
|
|
28
|
+
ensure_token(session)
|
|
29
|
+
@app.call(request, response)
|
|
30
|
+
else
|
|
31
|
+
if valid_token?(request, session)
|
|
32
|
+
rotate_token(session) if @options[:rotate]
|
|
33
|
+
@app.call(request, response)
|
|
34
|
+
else
|
|
35
|
+
handle_invalid_token(response)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def skip_csrf?(request)
|
|
43
|
+
@skip_paths.any? { |path| request.path.start_with?(path) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def safe_request?(request)
|
|
47
|
+
SAFE_METHODS.include?(request.method.to_s)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ensure_token(session)
|
|
51
|
+
session[SESSION_KEY] ||= generate_token
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def generate_token
|
|
55
|
+
SecureRandom.hex(@token_length)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def valid_token?(request, session)
|
|
59
|
+
expected = session[SESSION_KEY]
|
|
60
|
+
return false unless expected
|
|
61
|
+
|
|
62
|
+
provided = request.header(HEADER_NAME) ||
|
|
63
|
+
request.params[PARAM_NAME]
|
|
64
|
+
|
|
65
|
+
return false unless provided
|
|
66
|
+
|
|
67
|
+
secure_compare(expected, provided)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def rotate_token(session)
|
|
71
|
+
session[SESSION_KEY] = generate_token
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def handle_invalid_token(response)
|
|
75
|
+
response.status = 403
|
|
76
|
+
response.content_type = "application/json"
|
|
77
|
+
response.body = '{"error":"Invalid CSRF token"}'
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Constant-time string comparison to prevent timing attacks
|
|
81
|
+
def secure_compare(a, b)
|
|
82
|
+
return false unless a.bytesize == b.bytesize
|
|
83
|
+
|
|
84
|
+
a.bytes.zip(b.bytes).reduce(0) { |acc, (x, y)| acc | (x ^ y) }.zero?
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarsier
|
|
4
|
+
module Middleware
|
|
5
|
+
# Request logging middleware
|
|
6
|
+
# Logs request details and timing information
|
|
7
|
+
class Logger < Base
|
|
8
|
+
COLORS = {
|
|
9
|
+
green: "\e[32m",
|
|
10
|
+
yellow: "\e[33m",
|
|
11
|
+
red: "\e[31m",
|
|
12
|
+
cyan: "\e[36m",
|
|
13
|
+
reset: "\e[0m"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(app, logger: nil, **options)
|
|
17
|
+
super(app, **options)
|
|
18
|
+
@logger = logger || default_logger
|
|
19
|
+
@colorize = options.fetch(:colorize, $stdout.tty?)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(request, response)
|
|
23
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
24
|
+
|
|
25
|
+
@app.call(request, response)
|
|
26
|
+
|
|
27
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
28
|
+
log_request(request, response, duration)
|
|
29
|
+
|
|
30
|
+
response
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def log_request(request, response, duration)
|
|
36
|
+
status = response.status
|
|
37
|
+
method = request.method
|
|
38
|
+
path = request.path
|
|
39
|
+
duration_ms = (duration * 1000).round(2)
|
|
40
|
+
|
|
41
|
+
message = format_log(method, path, status, duration_ms)
|
|
42
|
+
@logger.info(message)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def format_log(method, path, status, duration_ms)
|
|
46
|
+
if @colorize
|
|
47
|
+
status_color = status_color(status)
|
|
48
|
+
"#{colorize(method.to_s.ljust(7), :cyan)} #{path} " \
|
|
49
|
+
"#{colorize(status.to_s, status_color)} #{duration_ms}ms"
|
|
50
|
+
else
|
|
51
|
+
"#{method.to_s.ljust(7)} #{path} #{status} #{duration_ms}ms"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def status_color(status)
|
|
56
|
+
case status
|
|
57
|
+
when 200..299 then :green
|
|
58
|
+
when 300..399 then :cyan
|
|
59
|
+
when 400..499 then :yellow
|
|
60
|
+
else :red
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def colorize(text, color)
|
|
65
|
+
"#{COLORS[color]}#{text}#{COLORS[:reset]}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def default_logger
|
|
69
|
+
require "logger"
|
|
70
|
+
::Logger.new($stdout, formatter: proc { |_, _, _, msg| "#{msg}\n" })
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarsier
|
|
4
|
+
module Middleware
|
|
5
|
+
# Rate limiting middleware with sliding window algorithm
|
|
6
|
+
# Supports per-IP and per-user rate limiting
|
|
7
|
+
class RateLimit < Base
|
|
8
|
+
DEFAULT_OPTIONS = {
|
|
9
|
+
limit: 100,
|
|
10
|
+
window: 60,
|
|
11
|
+
key_prefix: "tarsier:rate_limit:",
|
|
12
|
+
headers: true
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(app, store: nil, **options)
|
|
16
|
+
super(app, **options)
|
|
17
|
+
@options = DEFAULT_OPTIONS.merge(options)
|
|
18
|
+
@store = store || MemoryStore.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(request, response)
|
|
22
|
+
key = rate_limit_key(request)
|
|
23
|
+
current_time = Time.now.to_i
|
|
24
|
+
window_start = current_time - @options[:window]
|
|
25
|
+
|
|
26
|
+
# Clean old entries and count requests
|
|
27
|
+
count = @store.count_and_clean(key, window_start)
|
|
28
|
+
|
|
29
|
+
if count >= @options[:limit]
|
|
30
|
+
handle_rate_limited(response, count, current_time)
|
|
31
|
+
return response
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Record this request
|
|
35
|
+
@store.record(key, current_time)
|
|
36
|
+
|
|
37
|
+
# Add rate limit headers
|
|
38
|
+
add_headers(response, count + 1, current_time) if @options[:headers]
|
|
39
|
+
|
|
40
|
+
@app.call(request, response)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def rate_limit_key(request)
|
|
46
|
+
identifier = if @options[:key_generator]
|
|
47
|
+
@options[:key_generator].call(request)
|
|
48
|
+
else
|
|
49
|
+
request.ip
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
"#{@options[:key_prefix]}#{identifier}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def handle_rate_limited(response, count, current_time)
|
|
56
|
+
response.status = 429
|
|
57
|
+
response.content_type = "application/json"
|
|
58
|
+
response.body = '{"error":"Rate limit exceeded"}'
|
|
59
|
+
|
|
60
|
+
add_headers(response, count, current_time)
|
|
61
|
+
response.set_header("Retry-After", @options[:window].to_s)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def add_headers(response, count, current_time)
|
|
65
|
+
remaining = [@options[:limit] - count, 0].max
|
|
66
|
+
reset_time = current_time + @options[:window]
|
|
67
|
+
|
|
68
|
+
response.set_header("X-RateLimit-Limit", @options[:limit].to_s)
|
|
69
|
+
response.set_header("X-RateLimit-Remaining", remaining.to_s)
|
|
70
|
+
response.set_header("X-RateLimit-Reset", reset_time.to_s)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Simple in-memory store for rate limiting
|
|
74
|
+
# Replace with Redis store for production multi-process deployments
|
|
75
|
+
class MemoryStore
|
|
76
|
+
def initialize
|
|
77
|
+
@data = {}
|
|
78
|
+
@mutex = Mutex.new
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def count_and_clean(key, window_start)
|
|
82
|
+
@mutex.synchronize do
|
|
83
|
+
@data[key] ||= []
|
|
84
|
+
@data[key].reject! { |t| t < window_start }
|
|
85
|
+
@data[key].size
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def record(key, timestamp)
|
|
90
|
+
@mutex.synchronize do
|
|
91
|
+
@data[key] ||= []
|
|
92
|
+
@data[key] << timestamp
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def clear(key)
|
|
97
|
+
@mutex.synchronize do
|
|
98
|
+
@data.delete(key)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def clear_all
|
|
103
|
+
@mutex.synchronize do
|
|
104
|
+
@data.clear
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarsier
|
|
4
|
+
module Middleware
|
|
5
|
+
# Fiber-aware middleware stack with zero allocations for unused middleware
|
|
6
|
+
# Supports per-route middleware and conditional execution
|
|
7
|
+
class Stack
|
|
8
|
+
attr_reader :middlewares
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@middlewares = []
|
|
12
|
+
@compiled = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Add middleware to the stack
|
|
16
|
+
# @param middleware [Class] middleware class
|
|
17
|
+
# @param args [Array] middleware arguments
|
|
18
|
+
# @param options [Hash] middleware options
|
|
19
|
+
# @return [self]
|
|
20
|
+
def use(middleware, *args, **options)
|
|
21
|
+
@middlewares << { klass: middleware, args: args, options: options }
|
|
22
|
+
@compiled = nil
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Insert middleware before another
|
|
27
|
+
# @param existing [Class] existing middleware class
|
|
28
|
+
# @param middleware [Class] middleware to insert
|
|
29
|
+
# @param args [Array] middleware arguments
|
|
30
|
+
# @param options [Hash] middleware options
|
|
31
|
+
# @return [self]
|
|
32
|
+
def insert_before(existing, middleware, *args, **options)
|
|
33
|
+
index = find_index(existing)
|
|
34
|
+
raise MiddlewareError, "Middleware not found: #{existing}" unless index
|
|
35
|
+
|
|
36
|
+
@middlewares.insert(index, { klass: middleware, args: args, options: options })
|
|
37
|
+
@compiled = nil
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Insert middleware after another
|
|
42
|
+
# @param existing [Class] existing middleware class
|
|
43
|
+
# @param middleware [Class] middleware to insert
|
|
44
|
+
# @param args [Array] middleware arguments
|
|
45
|
+
# @param options [Hash] middleware options
|
|
46
|
+
# @return [self]
|
|
47
|
+
def insert_after(existing, middleware, *args, **options)
|
|
48
|
+
index = find_index(existing)
|
|
49
|
+
raise MiddlewareError, "Middleware not found: #{existing}" unless index
|
|
50
|
+
|
|
51
|
+
@middlewares.insert(index + 1, { klass: middleware, args: args, options: options })
|
|
52
|
+
@compiled = nil
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Remove middleware from the stack
|
|
57
|
+
# @param middleware [Class] middleware class to remove
|
|
58
|
+
# @return [self]
|
|
59
|
+
def delete(middleware)
|
|
60
|
+
@middlewares.reject! { |m| m[:klass] == middleware }
|
|
61
|
+
@compiled = nil
|
|
62
|
+
self
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Swap one middleware for another
|
|
66
|
+
# @param existing [Class] existing middleware class
|
|
67
|
+
# @param middleware [Class] replacement middleware
|
|
68
|
+
# @param args [Array] middleware arguments
|
|
69
|
+
# @param options [Hash] middleware options
|
|
70
|
+
# @return [self]
|
|
71
|
+
def swap(existing, middleware, *args, **options)
|
|
72
|
+
index = find_index(existing)
|
|
73
|
+
raise MiddlewareError, "Middleware not found: #{existing}" unless index
|
|
74
|
+
|
|
75
|
+
@middlewares[index] = { klass: middleware, args: args, options: options }
|
|
76
|
+
@compiled = nil
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Build the middleware chain
|
|
81
|
+
# @param app [Object] the final application
|
|
82
|
+
# @return [Object] the compiled middleware chain
|
|
83
|
+
def build(app)
|
|
84
|
+
@compiled ||= compile(app)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Call the middleware stack
|
|
88
|
+
# @param request [Request] the request object
|
|
89
|
+
# @param response [Response] the response object
|
|
90
|
+
# @param app [Object] the final application
|
|
91
|
+
# @return [Response]
|
|
92
|
+
def call(request, response, app)
|
|
93
|
+
chain = build(app)
|
|
94
|
+
chain.call(request, response)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Check if middleware is in the stack
|
|
98
|
+
# @param middleware [Class] middleware class
|
|
99
|
+
# @return [Boolean]
|
|
100
|
+
def include?(middleware)
|
|
101
|
+
@middlewares.any? { |m| m[:klass] == middleware }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Get middleware count
|
|
105
|
+
# @return [Integer]
|
|
106
|
+
def size
|
|
107
|
+
@middlewares.size
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Clear all middleware
|
|
111
|
+
# @return [self]
|
|
112
|
+
def clear
|
|
113
|
+
@middlewares.clear
|
|
114
|
+
@compiled = nil
|
|
115
|
+
self
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def find_index(middleware)
|
|
121
|
+
@middlewares.index { |m| m[:klass] == middleware }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def compile(app)
|
|
125
|
+
@middlewares.reverse.reduce(app) do |next_app, middleware|
|
|
126
|
+
build_middleware(middleware, next_app)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def build_middleware(config, app)
|
|
131
|
+
klass = config[:klass]
|
|
132
|
+
args = config[:args]
|
|
133
|
+
options = config[:options]
|
|
134
|
+
|
|
135
|
+
if options.empty?
|
|
136
|
+
klass.new(app, *args)
|
|
137
|
+
else
|
|
138
|
+
klass.new(app, *args, **options)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|