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,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
|