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,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "json"
5
+
6
+ module Tarsier
7
+ # Immutable request object with lazy parsing
8
+ # Provides efficient access to request data without unnecessary allocations
9
+ class Request
10
+ attr_reader :env, :route_params
11
+
12
+ # Standard HTTP methods
13
+ HTTP_METHODS = %w[GET POST PUT PATCH DELETE HEAD OPTIONS TRACE CONNECT].freeze
14
+
15
+ # @param env [Hash] Rack environment hash
16
+ # @param route_params [Hash] parameters extracted from route matching
17
+ def initialize(env, route_params = {})
18
+ @env = env.freeze
19
+ @route_params = route_params.transform_keys(&:to_sym).freeze
20
+ @parsed_body = nil
21
+ @query_params = nil
22
+ @headers = nil
23
+ @cookies = nil
24
+ end
25
+
26
+ # HTTP method
27
+ # @return [Symbol]
28
+ def method
29
+ @method ||= @env["REQUEST_METHOD"].to_sym
30
+ end
31
+
32
+ # Request path
33
+ # @return [String]
34
+ def path
35
+ @path ||= @env["PATH_INFO"] || "/"
36
+ end
37
+
38
+ # Full URL
39
+ # @return [String]
40
+ def url
41
+ @url ||= "#{scheme}://#{host}#{path}#{query_string.empty? ? '' : "?#{query_string}"}"
42
+ end
43
+
44
+ # URL scheme (http/https)
45
+ # @return [String]
46
+ def scheme
47
+ @scheme ||= @env["rack.url_scheme"] || (@env["HTTPS"] == "on" ? "https" : "http")
48
+ end
49
+
50
+ # Host name
51
+ # @return [String]
52
+ def host
53
+ @host ||= @env["HTTP_HOST"] || @env["SERVER_NAME"]
54
+ end
55
+
56
+ # Server port
57
+ # @return [Integer]
58
+ def port
59
+ @port ||= (@env["SERVER_PORT"] || 80).to_i
60
+ end
61
+
62
+ # Query string
63
+ # @return [String]
64
+ def query_string
65
+ @query_string ||= @env["QUERY_STRING"] || ""
66
+ end
67
+
68
+ # Query parameters (lazy parsed)
69
+ # @return [Hash]
70
+ def query_params
71
+ @query_params ||= parse_query_string.freeze
72
+ end
73
+
74
+ # Combined parameters (route + query + body)
75
+ # @return [Params]
76
+ def params
77
+ @params ||= Params.new(route_params.merge(query_params).merge(body_params))
78
+ end
79
+
80
+ # Request body (raw)
81
+ # @return [String]
82
+ def body
83
+ @body ||= begin
84
+ input = @env["rack.input"]
85
+ return "" unless input
86
+ input.rewind if input.respond_to?(:rewind)
87
+ input.read.to_s
88
+ end
89
+ end
90
+
91
+ # Parsed body parameters (JSON, form data, etc.)
92
+ # @return [Hash]
93
+ def body_params
94
+ @body_params ||= parse_body.freeze
95
+ end
96
+
97
+ # Request headers (lazy loaded)
98
+ # @return [Hash]
99
+ def headers
100
+ @headers ||= extract_headers.freeze
101
+ end
102
+
103
+ # Get a specific header
104
+ # @param name [String] header name (case-insensitive)
105
+ # @return [String, nil]
106
+ def header(name)
107
+ headers[normalize_header_name(name)]
108
+ end
109
+
110
+ # Content type
111
+ # @return [String, nil]
112
+ def content_type
113
+ @content_type ||= @env["CONTENT_TYPE"]
114
+ end
115
+
116
+ # Content length
117
+ # @return [Integer, nil]
118
+ def content_length
119
+ @content_length ||= @env["CONTENT_LENGTH"]&.to_i
120
+ end
121
+
122
+ # Accept header
123
+ # @return [String, nil]
124
+ def accept
125
+ @accept ||= @env["HTTP_ACCEPT"]
126
+ end
127
+
128
+ # Accepted content types (parsed)
129
+ # @return [Array<String>]
130
+ def accepted_types
131
+ @accepted_types ||= parse_accept_header
132
+ end
133
+
134
+ # Check if request accepts a content type
135
+ # @param type [String] content type to check
136
+ # @return [Boolean]
137
+ def accepts?(type)
138
+ accepted_types.any? { |t| t == "*/*" || t == type || t.start_with?(type.split("/").first + "/*") }
139
+ end
140
+
141
+ # Cookies (lazy parsed)
142
+ # @return [Hash]
143
+ def cookies
144
+ @cookies ||= parse_cookies.freeze
145
+ end
146
+
147
+ # User agent
148
+ # @return [String, nil]
149
+ def user_agent
150
+ @user_agent ||= @env["HTTP_USER_AGENT"]
151
+ end
152
+
153
+ # Client IP address
154
+ # @return [String]
155
+ def ip
156
+ @ip ||= @env["HTTP_X_FORWARDED_FOR"]&.split(",")&.first&.strip ||
157
+ @env["HTTP_X_REAL_IP"] ||
158
+ @env["REMOTE_ADDR"] ||
159
+ "127.0.0.1"
160
+ end
161
+
162
+ # Check if request is secure (HTTPS)
163
+ # @return [Boolean]
164
+ def secure?
165
+ scheme == "https"
166
+ end
167
+
168
+ # Check if request is XHR/AJAX
169
+ # @return [Boolean]
170
+ def xhr?
171
+ @env["HTTP_X_REQUESTED_WITH"]&.downcase == "xmlhttprequest"
172
+ end
173
+
174
+ # Check if request is JSON
175
+ # @return [Boolean]
176
+ def json?
177
+ content_type&.include?("application/json")
178
+ end
179
+
180
+ # Check if request is form data
181
+ # @return [Boolean]
182
+ def form?
183
+ content_type&.include?("application/x-www-form-urlencoded") ||
184
+ content_type&.include?("multipart/form-data")
185
+ end
186
+
187
+ # HTTP method helpers
188
+ HTTP_METHODS.each do |m|
189
+ define_method("#{m.downcase}?") { method == m.to_sym }
190
+ end
191
+
192
+ # Check if request is safe (GET, HEAD, OPTIONS)
193
+ # @return [Boolean]
194
+ def safe?
195
+ %i[GET HEAD OPTIONS].include?(method)
196
+ end
197
+
198
+ # Check if request is idempotent
199
+ # @return [Boolean]
200
+ def idempotent?
201
+ %i[GET HEAD PUT DELETE OPTIONS].include?(method)
202
+ end
203
+
204
+ private
205
+
206
+ def parse_query_string
207
+ return {} if query_string.empty?
208
+
209
+ query_string.split("&").each_with_object({}) do |pair, hash|
210
+ key, value = pair.split("=", 2).map { |s| URI.decode_www_form_component(s.to_s) }
211
+ hash[key.to_sym] = value
212
+ end
213
+ end
214
+
215
+ def parse_body
216
+ return {} if body.empty?
217
+
218
+ case content_type
219
+ when /json/i
220
+ JSON.parse(body, symbolize_names: true)
221
+ when /x-www-form-urlencoded/i
222
+ parse_form_data(body)
223
+ else
224
+ {}
225
+ end
226
+ rescue JSON::ParserError
227
+ {}
228
+ end
229
+
230
+ def parse_form_data(data)
231
+ data.split("&").each_with_object({}) do |pair, hash|
232
+ key, value = pair.split("=", 2).map { |s| URI.decode_www_form_component(s.to_s) }
233
+ hash[key.to_sym] = value
234
+ end
235
+ end
236
+
237
+ def extract_headers
238
+ @env.each_with_object({}) do |(key, value), headers|
239
+ next unless key.start_with?("HTTP_")
240
+ header_name = key[5..].split("_").map(&:capitalize).join("-")
241
+ headers[header_name] = value
242
+ end
243
+ end
244
+
245
+ def normalize_header_name(name)
246
+ name.to_s.split(/[-_]/).map(&:capitalize).join("-")
247
+ end
248
+
249
+ def parse_accept_header
250
+ return ["*/*"] unless accept
251
+
252
+ accept.split(",").map do |type|
253
+ type.split(";").first.strip
254
+ end.sort_by do |type|
255
+ # Sort by specificity
256
+ case type
257
+ when "*/*" then 2
258
+ when %r{.+/\*} then 1
259
+ else 0
260
+ end
261
+ end
262
+ end
263
+
264
+ def parse_cookies
265
+ cookie_header = @env["HTTP_COOKIE"]
266
+ return {} unless cookie_header
267
+
268
+ cookie_header.split(";").each_with_object({}) do |cookie, hash|
269
+ key, value = cookie.strip.split("=", 2)
270
+ hash[key.to_sym] = value
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Tarsier
7
+ # Response object supporting streaming and async rendering
8
+ # Rack-compatible but optimized for direct use
9
+ class Response
10
+ attr_reader :status, :headers
11
+ attr_accessor :body
12
+
13
+ # Common HTTP status codes
14
+ STATUS_CODES = {
15
+ 100 => "Continue",
16
+ 200 => "OK",
17
+ 201 => "Created",
18
+ 204 => "No Content",
19
+ 301 => "Moved Permanently",
20
+ 302 => "Found",
21
+ 304 => "Not Modified",
22
+ 400 => "Bad Request",
23
+ 401 => "Unauthorized",
24
+ 403 => "Forbidden",
25
+ 404 => "Not Found",
26
+ 405 => "Method Not Allowed",
27
+ 422 => "Unprocessable Entity",
28
+ 429 => "Too Many Requests",
29
+ 500 => "Internal Server Error",
30
+ 502 => "Bad Gateway",
31
+ 503 => "Service Unavailable"
32
+ }.freeze
33
+
34
+ # @param status [Integer] HTTP status code
35
+ # @param headers [Hash] response headers
36
+ # @param body [String, Array, nil] response body
37
+ def initialize(status: 200, headers: {}, body: nil)
38
+ @status = status
39
+ @headers = default_headers.merge(headers)
40
+ @body = body
41
+ @streaming = false
42
+ @sent = false
43
+ end
44
+
45
+ # Set response status
46
+ # @param code [Integer] HTTP status code
47
+ # @return [self]
48
+ def status=(code)
49
+ raise ResponseAlreadySentError if @sent
50
+ @status = code
51
+ end
52
+
53
+ # Set a header
54
+ # @param name [String] header name
55
+ # @param value [String] header value
56
+ # @return [self]
57
+ def set_header(name, value)
58
+ raise ResponseAlreadySentError if @sent
59
+ @headers[name] = value
60
+ self
61
+ end
62
+
63
+ alias []= set_header
64
+
65
+ # Get a header
66
+ # @param name [String] header name
67
+ # @return [String, nil]
68
+ def get_header(name)
69
+ @headers[name]
70
+ end
71
+
72
+ alias [] get_header
73
+
74
+ # Delete a header
75
+ # @param name [String] header name
76
+ # @return [String, nil]
77
+ def delete_header(name)
78
+ raise ResponseAlreadySentError if @sent
79
+ @headers.delete(name)
80
+ end
81
+
82
+ # Set content type
83
+ # @param type [String] content type
84
+ # @param charset [String] character set
85
+ # @return [self]
86
+ def content_type=(type, charset: "utf-8")
87
+ set_header("Content-Type", charset ? "#{type}; charset=#{charset}" : type)
88
+ end
89
+
90
+ # Set content length
91
+ # @param length [Integer] content length
92
+ # @return [self]
93
+ def content_length=(length)
94
+ set_header("Content-Length", length.to_s)
95
+ end
96
+
97
+ # Write to response body
98
+ # @param data [String] data to write
99
+ # @return [self]
100
+ def write(data)
101
+ raise ResponseAlreadySentError if @sent && !@streaming
102
+
103
+ @body ||= +""
104
+ @body << data.to_s
105
+ self
106
+ end
107
+
108
+ alias << write
109
+
110
+ # Render JSON response
111
+ # @param data [Object] data to serialize
112
+ # @param status [Integer] HTTP status code
113
+ # @return [self]
114
+ def json(data, status: 200)
115
+ @status = status
116
+ self.content_type = "application/json"
117
+ @body = JSON.generate(data)
118
+ self
119
+ end
120
+
121
+ # Render HTML response
122
+ # @param content [String] HTML content
123
+ # @param status [Integer] HTTP status code
124
+ # @return [self]
125
+ def html(content, status: 200)
126
+ @status = status
127
+ self.content_type = "text/html"
128
+ @body = content
129
+ self
130
+ end
131
+
132
+ # Render plain text response
133
+ # @param content [String] text content
134
+ # @param status [Integer] HTTP status code
135
+ # @return [self]
136
+ def text(content, status: 200)
137
+ @status = status
138
+ self.content_type = "text/plain"
139
+ @body = content
140
+ self
141
+ end
142
+
143
+ # Redirect to another URL
144
+ # @param url [String] redirect URL
145
+ # @param status [Integer] HTTP status code (301 or 302)
146
+ # @return [self]
147
+ def redirect(url, status: 302)
148
+ @status = status
149
+ set_header("Location", url)
150
+ @body = ""
151
+ self
152
+ end
153
+
154
+ # Set a cookie
155
+ # @param name [String] cookie name
156
+ # @param value [String] cookie value
157
+ # @param options [Hash] cookie options
158
+ # @return [self]
159
+ def set_cookie(name, value, **options)
160
+ cookie = "#{name}=#{value}"
161
+ cookie << "; Path=#{options[:path]}" if options[:path]
162
+ cookie << "; Domain=#{options[:domain]}" if options[:domain]
163
+ cookie << "; Expires=#{options[:expires].httpdate}" if options[:expires]
164
+ cookie << "; Max-Age=#{options[:max_age]}" if options[:max_age]
165
+ cookie << "; Secure" if options[:secure]
166
+ cookie << "; HttpOnly" if options[:http_only]
167
+ cookie << "; SameSite=#{options[:same_site]}" if options[:same_site]
168
+
169
+ existing = @headers["Set-Cookie"]
170
+ if existing
171
+ @headers["Set-Cookie"] = [existing, cookie].flatten
172
+ else
173
+ @headers["Set-Cookie"] = cookie
174
+ end
175
+ self
176
+ end
177
+
178
+ # Delete a cookie
179
+ # @param name [String] cookie name
180
+ # @param options [Hash] cookie options
181
+ # @return [self]
182
+ def delete_cookie(name, **options)
183
+ set_cookie(name, "", **options.merge(max_age: 0, expires: Time.at(0)))
184
+ end
185
+
186
+ # Stream response body
187
+ # @yield [StreamWriter] stream writer for chunked output
188
+ # @return [self]
189
+ def stream(&block)
190
+ raise ResponseAlreadySentError if @sent
191
+
192
+ @streaming = true
193
+ set_header("Transfer-Encoding", "chunked")
194
+ delete_header("Content-Length")
195
+
196
+ @body = StreamBody.new(&block)
197
+ self
198
+ end
199
+
200
+ # Check if response is streaming
201
+ # @return [Boolean]
202
+ def streaming?
203
+ @streaming
204
+ end
205
+
206
+ # Check if response has been sent
207
+ # @return [Boolean]
208
+ def sent?
209
+ @sent
210
+ end
211
+
212
+ # Mark response as sent
213
+ def mark_sent!
214
+ @sent = true
215
+ end
216
+
217
+ # Convert to Rack response format
218
+ # @return [Array] Rack response tuple [status, headers, body]
219
+ def to_rack
220
+ body_content = normalize_body
221
+ @headers["Content-Length"] ||= calculate_content_length(body_content) unless @streaming
222
+
223
+ [@status, @headers, body_content]
224
+ end
225
+
226
+ # Finish and return Rack response
227
+ # @return [Array]
228
+ def finish
229
+ mark_sent!
230
+ to_rack
231
+ end
232
+
233
+ private
234
+
235
+ def default_headers
236
+ {
237
+ "Content-Type" => "text/html; charset=utf-8",
238
+ "X-Content-Type-Options" => "nosniff",
239
+ "X-Frame-Options" => "SAMEORIGIN"
240
+ }
241
+ end
242
+
243
+ def normalize_body
244
+ case @body
245
+ when nil then []
246
+ when String then [@body]
247
+ when Array then @body
248
+ else @body
249
+ end
250
+ end
251
+
252
+ def calculate_content_length(body_content)
253
+ return nil unless body_content.respond_to?(:sum)
254
+ body_content.sum { |chunk| chunk.bytesize }.to_s
255
+ end
256
+ end
257
+
258
+ # Streaming body wrapper for chunked responses
259
+ class StreamBody
260
+ def initialize(&block)
261
+ @block = block
262
+ end
263
+
264
+ def each(&output_block)
265
+ writer = StreamWriter.new(&output_block)
266
+ @block.call(writer)
267
+ end
268
+ end
269
+
270
+ # Writer for streaming responses
271
+ class StreamWriter
272
+ def initialize(&block)
273
+ @output = block
274
+ end
275
+
276
+ def write(data)
277
+ @output.call(data.to_s)
278
+ end
279
+
280
+ alias << write
281
+ end
282
+ end