stir_fry 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.
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module StirFry
6
+ module Middleware
7
+ # Logger is our custom logger for a better format using a shared logger
8
+ class Logger
9
+ # Create the new middleware for logging
10
+ def initialize(app, logger = ::Logger.new($stdout))
11
+ @app = app
12
+ @logger = logger
13
+ end
14
+
15
+ # Middleware call that logs the request and it's response.
16
+ def call(env)
17
+ began_at = Time.now.to_f
18
+ content = @app.call(env)
19
+ log(env, Time.now.to_f - began_at)
20
+ content
21
+ end
22
+
23
+ private
24
+
25
+ def log(env, elapsed)
26
+ req = env.fetch(App::REQUEST_ENV_KEY, Rack::Request.new(env))
27
+ resp = env.fetch(App::RESPONSE_ENV_KEY, Rack::Response.new(env))
28
+ log_method(resp).call(log_line(req, resp, elapsed))
29
+ end
30
+
31
+ def log_method(resp)
32
+ if resp.status.between?(100, 299)
33
+ @logger.method(:info)
34
+ elsif resp.status.between?(300, 499)
35
+ @logger.method(:warn)
36
+ else
37
+ @logger.method(:error)
38
+ end
39
+ end
40
+
41
+ def log_line(req, resp, elapsed)
42
+ {
43
+ method: req.request_method,
44
+ path: req.path_info,
45
+ query: req.query_string,
46
+ status: resp.status,
47
+ user: client_log(req),
48
+ request: req_log(req),
49
+ response: resp_log(resp, elapsed)
50
+ }.compact
51
+ end
52
+
53
+ def client_log(req)
54
+ {
55
+ ip: req.ip,
56
+ remote_user: req.get_header("REMOTE_USER")
57
+ }.compact
58
+ end
59
+
60
+ def req_log(req)
61
+ {
62
+ accept_encoding: req.get_header("HTTP_ACCEPT"),
63
+ scheme: req.scheme,
64
+ server_authority: req.server_authority,
65
+ referer: req.referer,
66
+ script_name: req.script_name,
67
+ media_type: req.media_type,
68
+ proto: req.get_header("SERVER_PROTOCOL")
69
+ }.compact
70
+ end
71
+
72
+ def resp_log(resp, elapsed)
73
+ line = {
74
+ content_type: resp.headers["Content-Type"],
75
+ content_length: resp.headers["Content-Length"],
76
+ elapsed: elapsed % 60
77
+ }
78
+
79
+ if resp.respond_to?(:request_error)
80
+ line[:error] = resp.request_error&.message&.to_s
81
+ line[:error_stacktrace] = resp.request_error&.backtrace
82
+ end
83
+
84
+ line.compact
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StirFry
4
+ # Module containing middleware that makes this framework nicer to use.
5
+ module Middleware
6
+ autoload :Logger, "stir_fry/middleware/logger"
7
+ end
8
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StirFry
4
+ module Pages
5
+ # ErrorPage is a custom component and main handler for server errors. It will
6
+ # respond with a specific mime type depending on what was requested.
7
+ class ErrorPage < Component
8
+ # error is the error raised during the request cycle.
9
+ attr_reader :error
10
+
11
+ # Create a new error page that will display the message and status.
12
+ def initialize(error: "", **args)
13
+ super
14
+ @error = error
15
+ end
16
+
17
+ # format a message from the error
18
+ def message = error.message == error.class.name ? "" : error.message
19
+
20
+ # status will derive a response status from the error raised.
21
+ def status
22
+ @status ||= case error
23
+ when NotFound then :not_found
24
+ when BadRequest then :bad_request
25
+ when Unauthorized then :unauthorized
26
+ when PreconditionFailed then :precondition_failed
27
+ else :internal_server_error
28
+ end
29
+ end
30
+
31
+ # Overrides Component#call to customize the output based on mime.
32
+ def call
33
+ if request.json? then response.json(render_json, status)
34
+ elsif request.html? then response.html(render, status)
35
+ else response.text(message, status)
36
+ end
37
+ end
38
+
39
+ def title # :nodoc:
40
+ Rack::Utils::HTTP_STATUS_CODES[response.status].to_s
41
+ end
42
+
43
+ private
44
+
45
+ def render_json
46
+ {
47
+ error: title.downcase.tr(" ", "_"),
48
+ message: message
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ __END__
56
+ <StirFry.Pages.Layout>
57
+ <section>
58
+ <header><h1>{title}</h1></header>
59
+ <section class="error-page">
60
+ <p class="error-page__code">{response.status}</p>
61
+ <p class="error-page__path">{ "[#{request.request_method.upcase}] #{request.host_with_port}#{request.path_info}" }</p>
62
+ <p class="error-page__message">{message}</p>
63
+ <ul class="error-page__backtrace">
64
+ {error.backtrace.map {|t| <li>{t.to_s}</li> }.join }
65
+ </ul>
66
+ </section>
67
+ </section>
68
+ </StirFry.Pages.Layout>
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StirFry
4
+ module Pages
5
+ # Layout is the layout of the frameworks pages that it serves.
6
+ class Layout < Component; end
7
+ end
8
+ end
9
+
10
+ __END__
11
+ <!DOCTYPE html>
12
+ <html>
13
+ <head><link rel="stylesheet" href="/stir_fry/stir_fry.css" /></head>
14
+ <body>{ yield }</body>
15
+ </html>
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StirFry
4
+ module Pages
5
+ # NotFoundPage is a simple page to display 404 errors appropriate for the
6
+ # request mime type
7
+ class NotFoundPage < Component
8
+ # Overrides Component#call to customize the output based on mime.
9
+ def call
10
+ if request.json? then response.json({ error: "not_found", message: "Not Found" }, :not_found)
11
+ elsif request.html? then response.html(render, :not_found)
12
+ else response.text("Not Found", :not_found)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ __END__
20
+ <StirFry.Pages.Layout>
21
+ <section>
22
+ <header><h1>Not Found</h1></header>
23
+ <section class="error-page">
24
+ <p class="error-page__code">404</p>
25
+ <p class="error-page__message">Request path did not match any registered routes.</p>
26
+ <ul class="error-page__backtrace">
27
+ {application.all_routes.map { |route| <li>{route}</li> }.join }
28
+ </ul>
29
+ </section>
30
+ </section>
31
+ </StirFry.Pages.Layout>
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StirFry
4
+ module Pages
5
+ # Displays routes that are defined by the app. This is only enabled in development
6
+ # and the route will not be define otherwise.
7
+ class RoutesPage < Component; end
8
+ end
9
+ end
10
+
11
+ __END__
12
+ <StirFry.Pages.Layout>
13
+ <section>
14
+ <header><h1>Routes</h1></header>
15
+ <section>
16
+ <ul class="error-page__backtrace">
17
+ {application.all_routes.map { |route| <li>{route}</li> }.join }
18
+ </ul>
19
+ </section>
20
+ </section>
21
+ </StirFry.Pages.Layout>
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StirFry
4
+ module Pages # :nodoc:
5
+ autoload :Layout, "stir_fry/pages/layout"
6
+ autoload :ErrorPage, "stir_fry/pages/error_page"
7
+ autoload :RoutesPage, "stir_fry/pages/routes_page"
8
+ autoload :NotFoundPage, "stir_fry/pages/not_found_page"
9
+ end
10
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "json"
5
+
6
+ module StirFry
7
+ # Request expands Rack::Request to include url args
8
+ class Request < Rack::Request
9
+ # args are the parameters that were parsed from the url path such as resource ids.
10
+ attr_accessor :args
11
+
12
+ # The unescaped path from the request.
13
+ def path = @path ||= Rack::Utils.unescape_path(path_info)
14
+ # Returns true if the request accepts json
15
+ def json? = accept?("application/json")
16
+ # Returns true if the request accepts html
17
+ def html? = accept?("text/html")
18
+ # Returns true if the request accepts text
19
+ def text? = accept?("text/plain")
20
+ # Returns true if the request accepts xml
21
+ def xml? = accept?("application/xml")
22
+ # Returns true if the request accepts csv
23
+ def csv? = accept?("text/csv")
24
+ # Returns true if the request accepts header matches a mime type string
25
+ def accept?(mime) = accept_types.include?(mime)
26
+
27
+ # params wraps rack:request params and captures common errors. This is copied
28
+ # from Sinatra's handling of params.
29
+ def params
30
+ super
31
+ rescue Rack::Utils::ParameterTypeError, Rack::Utils::InvalidParameterError => e
32
+ raise BadRequest, "Invalid query parameters: #{Rack::Utils.escape_html(e.message)}"
33
+ rescue EOFError => e
34
+ raise BadRequest, "Invalid multipart/form-data: #{Rack::Utils.escape_html(e.message)}"
35
+ end
36
+
37
+ private
38
+
39
+ def accept_types
40
+ @accept_types ||= get_header("HTTP_ACCEPT").to_s.split(",").filter_map { |part| part.split(";", 2).first&.strip }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,351 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "json"
5
+
6
+ module StirFry
7
+ # Response is a wrapper around Rack::Response that makes it easier to generate
8
+ # rich responses easily.
9
+ class Response
10
+ # the corresponding reqest to this reponse.
11
+ attr_reader :req
12
+ # the inner Rack::Response that we are building.
13
+ attr_reader :resp
14
+ # the rack request env.
15
+ attr_reader :env
16
+ # if there was an error during the request it will be set here.
17
+ attr_accessor :request_error
18
+
19
+ MULTIPART_BOUNDARY = "AaB03x" # :nodoc:
20
+ MULTIPART_FORM_DATA_REPLACEMENT_TABLE = { # :nodoc:
21
+ '"' => "%22",
22
+ "\r" => "%0D",
23
+ "\n" => "%0A"
24
+ }.freeze
25
+
26
+ # create a new Reponse
27
+ def initialize(env)
28
+ @env = env
29
+ @req = StirFry::Request.new(env)
30
+ @resp = Rack::Response.new
31
+ content_type(Rack::MediaType.type(env["HTTP_ACCEPT"]))
32
+ end
33
+
34
+ # whether or not the status is set to 1xx
35
+ def informational? = status.between?(100, 199)
36
+ # whether or not the status is set to 2xx
37
+ def success? = status.between?(200, 299)
38
+ # whether or not the status is set to 3xx
39
+ def redirect? = status.between?(300, 399)
40
+ # whether or not the status is set to 4xx
41
+ def client_error? = status.between?(400, 499)
42
+ # whether or not the status is set to 5xx
43
+ def server_error? = status.between?(500, 599)
44
+ # whether or not the status is set to 404
45
+ def not_found? = status == 404
46
+ # whether or not the status is set to 400
47
+ def bad_request? = status == 400
48
+
49
+ # sugar for `respond(body, :ok)`
50
+ def ok(body) = respond(body, :ok)
51
+ # sugar for `respond(body, :not_found)`
52
+ def not_found(body) = respond(body, :not_found)
53
+ # sugar for `respond(body, :bad_request)`
54
+ def bad_request(body) = respond(body, :bad_request)
55
+ # sugar for `respond(body, :unauthorized)`
56
+ def unauthorized(body) = respond(body, :unauthorized)
57
+ # sugar for `respond(body, :internal_error)`
58
+ def internal_error(body) = respond(body, :internal_server_error)
59
+
60
+ # immediately return okay status without running any following middleware.
61
+ def ok!(msg = "") = raise OKAY, msg
62
+ # immediately return not found status without running any following middleware.
63
+ def not_found!(msg = "") = raise NotFound, msg
64
+ # immediately return bad request status without running any following middleware.
65
+ def bad_request!(msg = "") = raise BadRequest, msg
66
+ # immediately return unauthorized status without running any following middleware.
67
+ def unauthorized!(msg = "") = raise Unauthorized, msg
68
+ # immediately return internal error status without running any following middleware.
69
+ def internal_error!(msg = "") = raise InternalError, msg
70
+
71
+ # respond with text content
72
+ def text(body, stat = :ok) = answer(stat, :text, body)
73
+ # respond with json content
74
+ def json(body, stat = :ok) = answer(stat, :json, JSON.dump(body))
75
+ # respond with html content
76
+ def html(body, stat = :ok) = answer(stat, :html, body)
77
+ # respond with a rendered component. sugar for `html(view.render)`
78
+ def render(klass, stat = :ok) = html(klass.render, stat)
79
+ # start a streaming body that doesn't immeadiately close the connection.
80
+ def stream(&block) = resp.body = block.to_proc
81
+
82
+ # respond will choose what kind of content to respond with depending on what
83
+ # kind of data you give it.
84
+ #
85
+ # `respond({message: "hi"}, :ok)` => application/json JSON response
86
+ # `respond(AppView, :ok)` => text/html HTML Component response
87
+ # `respond("OKAY", :ok)` => text/plain response
88
+ def respond(obj, stat = :ok)
89
+ case obj
90
+ when Hash then json(obj, stat)
91
+ when StirFry::Component then render(obj, stat)
92
+ when String then text(obj, stat)
93
+ else status(stat)
94
+ end
95
+ end
96
+
97
+ # redirect the request to a uri
98
+ def redirect(uri)
99
+ http_version = env["SERVER_PROTOCOL"]
100
+ if (http_version == "HTTP/1.1") && (env["REQUEST_METHOD"] != "GET")
101
+ status(303)
102
+ else
103
+ status(302)
104
+ end
105
+ headers["Location"] = uri.to_s
106
+ end
107
+
108
+ # Build and format a link with the current host and port.
109
+ def uri(addr = nil, absolute: true)
110
+ port_required = !req.forwarded_authority.nil? || (req.port != (req.ssl? ? 443 : 80))
111
+ uri = [host = String.new]
112
+ if absolute
113
+ host.concat("http#{"s" if req.ssl?}://", port_required ? req.host_with_port : req.host)
114
+ end
115
+ uri << (addr || req.path_info).to_s
116
+ File.join uri
117
+ end
118
+
119
+ # Sugar for redirect(request.referer)
120
+ def redirect_back = redirect(req.referer)
121
+ # Readonly body content. To change the content you should use one of the response methods
122
+ # text, html, render, json, stream or other methods available to set the body
123
+ # in a valid way.
124
+ def body = resp.body
125
+ # Response headers.
126
+ def headers = resp.headers
127
+
128
+ # Set the response status, and return the current status.
129
+ def status(value = nil)
130
+ resp.status = Rack::Utils.status_code(value) if value
131
+ resp.status
132
+ end
133
+
134
+ # Set the content type header on the response
135
+ def content_type(kind = nil)
136
+ headers["Content-Type"] = mime_type(kind) if kind
137
+ headers["Content-Type"]
138
+ end
139
+
140
+ # Set the content length header on the response
141
+ def content_length(len = nil)
142
+ headers["Content-Length"] = len.to_s if len
143
+ headers["Content-Length"]
144
+ end
145
+
146
+ # Specify response freshness policy for HTTP caches (Cache-Control header).
147
+ # Any number of non-value directives (:public, :private, :no_cache,
148
+ # :no_store, :must_revalidate, :proxy_revalidate) may be passed along with
149
+ # a Hash of value directives (:max_age, :s_maxage).
150
+ #
151
+ # cache_control :public, :must_revalidate, :max_age => 60
152
+ # => Cache-Control: public, must-revalidate, max-age=60
153
+ #
154
+ def cache_control(*values)
155
+ hash = extract_cache_control_hash(values)
156
+ values.map! { |value| value.to_s.tr("_", "-") }
157
+ hash.each { |key, value| values << cache_control_pair(key, value) }
158
+
159
+ headers["Cache-Control"] = values.join(", ") if values.any?
160
+ end
161
+
162
+ # Set the response entity tag (HTTP 'ETag' header) and halt if conditional
163
+ # GET matches. The +value+ argument is an identifier that uniquely
164
+ # identifies the current version of the resource. The weak argument will allow
165
+ # a weak value but defaults to strong.
166
+ #
167
+ # When the current request includes an 'If-None-Match' header with a
168
+ # matching etag, execution is immediately halted. If the request method is
169
+ # GET or HEAD, a '304 Not Modified' response is sent.
170
+ def etag(value, weak: false, new_resource: req.post?)
171
+ headers["ETag"] = etag_header_value(value, weak)
172
+ return unless success? || status == 304
173
+
174
+ raise(request_safe? ? NotModified : PreconditionFailed) if etag_matches?(env["HTTP_IF_NONE_MATCH"], new_resource)
175
+ raise PreconditionFailed if env["HTTP_IF_MATCH"] && !etag_matches?(env["HTTP_IF_MATCH"], new_resource)
176
+ end
177
+
178
+ # Set the Expires header and Cache-Control/max-age directive. Amount
179
+ # can be an integer number of seconds in the future or a Time object
180
+ # indicating when the response should be considered "stale". The remaining
181
+ # "values" arguments are passed to the #cache_control helper:
182
+ #
183
+ # expires 500, :public, :must_revalidate
184
+ # => Cache-Control: public, must-revalidate, max-age=500
185
+ # => Expires: Mon, 08 Jun 2009 08:50:17 GMT
186
+ #
187
+ def expires(amount, *values)
188
+ values << {} unless values.last.is_a?(Hash)
189
+ time, max_age = expires_time_and_max_age(amount)
190
+
191
+ values.last.merge!(max_age: max_age) { |_key, v1, v2| v1 || v2 }
192
+ cache_control(*values)
193
+
194
+ headers["Expires"] = time.httpdate
195
+ end
196
+
197
+ # Set the last modified time of the resource (HTTP 'Last-Modified' header)
198
+ # and halt if conditional GET matches. The +time+ argument is a Time,
199
+ # DateTime, or other object that responds to +to_time+.
200
+ #
201
+ # When the current request includes an 'If-Modified-Since' header that is
202
+ # equal or later than the time specified, execution is immediately halted
203
+ # with a '304 Not Modified' response.
204
+ def last_modified(time)
205
+ return unless time
206
+
207
+ time = time_for(time)
208
+ headers["Last-Modified"] = time.httpdate
209
+ return if env["HTTP_IF_NONE_MATCH"]
210
+
211
+ check_if_modified_since(time)
212
+ check_if_unmodified_since(time)
213
+ end
214
+
215
+ # Responde directly with a file.
216
+ def send_file(req, filename, attachment: false)
217
+ if attachment
218
+ headers["Content-Disposition"] =
219
+ format('attachment; filename="%s"',
220
+ File.basename(filename).gsub(/["\r\n]/, MULTIPART_FORM_DATA_REPLACEMENT_TABLE))
221
+ end
222
+ serve_file(req, filename)
223
+ end
224
+
225
+ def finish # :nodoc:
226
+ clear_body_headers if informational? || [204, 304].include?(status)
227
+ resp.body = [] if [204, 304].include?(status)
228
+ content_length(resp.body.sum(&:bytesize)) if resp.body.is_a?(Array) && !content_length
229
+ resp.to_a
230
+ end
231
+
232
+ private
233
+
234
+ def answer(stat, contenttype, value)
235
+ status(stat)
236
+ content_type(contenttype)
237
+ resp.body = value.is_a?(String) ? [value.to_str] : value
238
+ end
239
+
240
+ def request_safe?
241
+ req.get? || req.head? || req.options? || req.trace?
242
+ end
243
+
244
+ def mime_type(type)
245
+ return type if type.nil?
246
+ return type.to_s if type.to_s.include?("/")
247
+
248
+ type = ".#{type}" unless type.to_s[0] == "."
249
+ Rack::Mime.mime_type(type, nil)
250
+ end
251
+
252
+ def etag_matches?(list, new_resource = req.post?)
253
+ return !new_resource if list == "*"
254
+
255
+ list.to_s.split(",").map(&:strip).include?(headers["ETag"])
256
+ end
257
+
258
+ def etag_header_value(value, weak) = format('%<weak>s"%<value>s"', weak: weak ? "W/" : "", value: value)
259
+
260
+ def extract_cache_control_hash(values)
261
+ return {} unless values.last.is_a?(Hash)
262
+
263
+ hash = values.pop
264
+ hash.reject! { |_k, v| v == false }
265
+ hash.reject! { |k, v| values << k if v == true }
266
+ hash
267
+ end
268
+
269
+ def cache_control_pair(key, value)
270
+ key = key.to_s.tr("_", "-")
271
+ value = value.to_i if %w[max-age s-maxage].include? key
272
+ "#{key}=#{value}"
273
+ end
274
+
275
+ def expires_time_and_max_age(amount)
276
+ return [Time.now + amount.to_i, amount] if amount.is_a? Integer
277
+
278
+ time = time_for(amount)
279
+ [time, time - Time.now]
280
+ end
281
+
282
+ def check_if_modified_since(time)
283
+ return unless (status == 200) && env["HTTP_IF_MODIFIED_SINCE"]
284
+
285
+ since = Time.httpdate(env["HTTP_IF_MODIFIED_SINCE"]).to_i
286
+ raise NotModified if since >= time.to_i
287
+ end
288
+
289
+ def check_if_unmodified_since(time)
290
+ return unless (success? || (status == 412)) && env["HTTP_IF_UNMODIFIED_SINCE"]
291
+
292
+ since = Time.httpdate(env["HTTP_IF_UNMODIFIED_SINCE"]).to_i
293
+ raise PreconditionFailed if since < time.to_i
294
+ end
295
+
296
+ def time_for(value)
297
+ if value.is_a? Numeric
298
+ Time.at value
299
+ elsif value.respond_to? :to_s
300
+ Time.parse value.to_s
301
+ else
302
+ value.to_time
303
+ end
304
+ end
305
+
306
+ def serve_file(req, filename)
307
+ content_type(File.extname(filename))
308
+ last_modified(File.mtime(filename))
309
+ size = ::File.size?(filename) || ::File.read(filename).bytesize
310
+ ranges = Rack::Utils.get_byte_ranges(req.get_header("HTTP_RANGE"), size)
311
+ if ranges.nil? then send_full_file_content(filename, size)
312
+ elsif ranges.empty? then bad_file_send_range(size)
313
+ else send_partial_file(filename, ranges, size)
314
+ end
315
+ rescue Errno::ENOENT
316
+ raise NotFound
317
+ end
318
+
319
+ def send_full_file_content(filename, size)
320
+ content_length(size)
321
+ resp.body = if req.head?
322
+ []
323
+ else
324
+ Rack::Files::Iterator.new(filename, [0..(size - 1)], mime_type: content_type,
325
+ size: size)
326
+ end
327
+ end
328
+
329
+ def bad_file_send_range(size)
330
+ headers["content-range"] = "bytes */#{size}"
331
+ answer(416, :text, "Byte range unsatisfiable")
332
+ end
333
+
334
+ def send_partial_file(filename, ranges, size)
335
+ if ranges.size == 1
336
+ headers["content-range"] = "bytes #{ranges[0].begin}-#{ranges[0].end}/#{size}"
337
+ else
338
+ content_type("multipart/byteranges; boundary=#{MULTIPART_BOUNDARY}")
339
+ end
340
+ status(206)
341
+ body = Rack::Files::BaseIterator.new(filename, ranges, mime_type: content_type, size: size)
342
+ content_length(body.bytesize)
343
+ resp.body = req.head? ? [] : body
344
+ end
345
+
346
+ def clear_body_headers
347
+ headers.delete "content-length"
348
+ headers.delete "content-type"
349
+ end
350
+ end
351
+ end