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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +175 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +984 -0
  5. data/exe/tarsier +7 -0
  6. data/lib/tarsier/application.rb +336 -0
  7. data/lib/tarsier/cli/commands/console.rb +87 -0
  8. data/lib/tarsier/cli/commands/generate.rb +85 -0
  9. data/lib/tarsier/cli/commands/help.rb +50 -0
  10. data/lib/tarsier/cli/commands/new.rb +59 -0
  11. data/lib/tarsier/cli/commands/routes.rb +139 -0
  12. data/lib/tarsier/cli/commands/server.rb +123 -0
  13. data/lib/tarsier/cli/commands/version.rb +14 -0
  14. data/lib/tarsier/cli/generators/app.rb +528 -0
  15. data/lib/tarsier/cli/generators/base.rb +93 -0
  16. data/lib/tarsier/cli/generators/controller.rb +91 -0
  17. data/lib/tarsier/cli/generators/middleware.rb +81 -0
  18. data/lib/tarsier/cli/generators/migration.rb +109 -0
  19. data/lib/tarsier/cli/generators/model.rb +109 -0
  20. data/lib/tarsier/cli/generators/resource.rb +27 -0
  21. data/lib/tarsier/cli/loader.rb +18 -0
  22. data/lib/tarsier/cli.rb +46 -0
  23. data/lib/tarsier/controller.rb +282 -0
  24. data/lib/tarsier/database.rb +588 -0
  25. data/lib/tarsier/errors.rb +77 -0
  26. data/lib/tarsier/middleware/base.rb +47 -0
  27. data/lib/tarsier/middleware/compression.rb +113 -0
  28. data/lib/tarsier/middleware/cors.rb +101 -0
  29. data/lib/tarsier/middleware/csrf.rb +88 -0
  30. data/lib/tarsier/middleware/logger.rb +74 -0
  31. data/lib/tarsier/middleware/rate_limit.rb +110 -0
  32. data/lib/tarsier/middleware/stack.rb +143 -0
  33. data/lib/tarsier/middleware/static.rb +124 -0
  34. data/lib/tarsier/model.rb +590 -0
  35. data/lib/tarsier/params.rb +269 -0
  36. data/lib/tarsier/query.rb +495 -0
  37. data/lib/tarsier/request.rb +274 -0
  38. data/lib/tarsier/response.rb +282 -0
  39. data/lib/tarsier/router/compiler.rb +173 -0
  40. data/lib/tarsier/router/node.rb +97 -0
  41. data/lib/tarsier/router/route.rb +119 -0
  42. data/lib/tarsier/router.rb +272 -0
  43. data/lib/tarsier/version.rb +5 -0
  44. data/lib/tarsier/websocket.rb +275 -0
  45. data/lib/tarsier.rb +167 -0
  46. data/sig/tarsier.rbs +485 -0
  47. 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