gemstack-http 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 +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +26 -0
- data/lib/gemstack/http/app.rb +30 -0
- data/lib/gemstack/http/config.rb +84 -0
- data/lib/gemstack/http/controller.rb +343 -0
- data/lib/gemstack/http/error_page.rb +111 -0
- data/lib/gemstack/http/error_renderer.rb +63 -0
- data/lib/gemstack/http/json_codec.rb +114 -0
- data/lib/gemstack/http/middleware/body_limit.rb +69 -0
- data/lib/gemstack/http/middleware/compression.rb +127 -0
- data/lib/gemstack/http/middleware/cors.rb +77 -0
- data/lib/gemstack/http/middleware/error_handler.rb +39 -0
- data/lib/gemstack/http/middleware/etags.rb +24 -0
- data/lib/gemstack/http/middleware/health_check.rb +28 -0
- data/lib/gemstack/http/middleware/request_id.rb +31 -0
- data/lib/gemstack/http/middleware/request_logger.rb +39 -0
- data/lib/gemstack/http/middleware/security_headers.rb +31 -0
- data/lib/gemstack/http/middleware_stack.rb +96 -0
- data/lib/gemstack/http/page.rb +36 -0
- data/lib/gemstack/http/params.rb +140 -0
- data/lib/gemstack/http/request.rb +64 -0
- data/lib/gemstack/http/router.rb +315 -0
- data/lib/gemstack/http.rb +41 -0
- metadata +125 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 24968edac68aecf39777e4896dde4da2eb3666fddd5adfc7e74b762e111dac3b
|
|
4
|
+
data.tar.gz: 9885773b193aee11dc47546fda5567f5d23ae4c9dd82028ac509f91f70d30ecf
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3c1154c7dea23ad9ad39f65531818077f29870257f2286c495d6cd70753c661ff892c0cee5fe94b92343dbaf7a94ba947624bdeb8904a9144159ee3089d505a7
|
|
7
|
+
data.tar.gz: c70c6bc253e4e6acb467443e21979a5896e98d8a1de839711d66b96e0823a06c6fe0cb56584b91643849f2ce99543f54bed4a7eb7d2c6cb54d4d534acbfd1593
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shoaib Malik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# gemstack-http
|
|
2
|
+
|
|
3
|
+
GemStack HTTP: router, middleware, controllers, params and JSON for Rack.
|
|
4
|
+
|
|
5
|
+
The API layer of GemStack, built on Rack 3.
|
|
6
|
+
|
|
7
|
+
Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
|
|
8
|
+
applications. All GemStack gems are developed together in that repository and released with the same
|
|
9
|
+
version.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Installed with the `gemstack` gem; you rarely need to add it yourself.
|
|
14
|
+
|
|
15
|
+
## Documentation
|
|
16
|
+
|
|
17
|
+
- [Guide](https://github.com/gemstack-rb/gemstack/blob/main/docs/controllers.md)
|
|
18
|
+
- [All guides](https://github.com/gemstack-rb/gemstack/tree/main/docs) ·
|
|
19
|
+
[Architecture](https://github.com/gemstack-rb/gemstack/blob/main/ARCHITECTURE.md)
|
|
20
|
+
|
|
21
|
+
Source, issues and pull requests: [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack)
|
|
22
|
+
(this gem lives in `gems/gemstack-http`).
|
|
23
|
+
|
|
24
|
+
## License
|
|
25
|
+
|
|
26
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module HTTP
|
|
5
|
+
# The Rack application: the compiled middleware stack in front of the
|
|
6
|
+
# router. Built once; the router can be replaced (for reloading) without
|
|
7
|
+
# rebuilding the stack.
|
|
8
|
+
class App
|
|
9
|
+
attr_reader :config, :codec
|
|
10
|
+
attr_accessor :router
|
|
11
|
+
|
|
12
|
+
def initialize(config:, router:)
|
|
13
|
+
@config = config
|
|
14
|
+
@router = router
|
|
15
|
+
@codec = JSONCodec.resolve(config.json, max_nesting: config.json_max_nesting)
|
|
16
|
+
@stack = config.middleware.build(method(:endpoint))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(env) = @stack.call(env)
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def endpoint(env)
|
|
24
|
+
env[JSON_CODEC] = @codec
|
|
25
|
+
env[CONFIG] = @config
|
|
26
|
+
@router.call(env)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module HTTP
|
|
5
|
+
# `config.http` — every setting has a production-ready default.
|
|
6
|
+
class Config < Settings
|
|
7
|
+
# Where the API lives. Routes are declared relative to it, and the dev
|
|
8
|
+
# gateway uses it to send traffic to Ruby. "" mounts the API at the root.
|
|
9
|
+
setting :api_path, default: "/api"
|
|
10
|
+
|
|
11
|
+
# Liveness endpoint answered by HealthCheck. nil disables it.
|
|
12
|
+
setting :health_path, default: -> { "#{api_path}/health" }
|
|
13
|
+
|
|
14
|
+
# Maximum request body size in bytes (413 beyond it).
|
|
15
|
+
setting :max_body_size, default: 10 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
# JSON codec: :json (stdlib, default), :oj, or any object responding to
|
|
18
|
+
# dump(object) -> String and load(String) -> object.
|
|
19
|
+
setting :json, default: :json
|
|
20
|
+
|
|
21
|
+
# Maximum nesting depth accepted when parsing JSON request bodies.
|
|
22
|
+
setting :json_max_nesting, default: 64
|
|
23
|
+
|
|
24
|
+
# Include exception class, message and backtrace in 500 responses.
|
|
25
|
+
# Never enable in production.
|
|
26
|
+
setting :show_exceptions, default: -> { GemStack.env.local? }
|
|
27
|
+
|
|
28
|
+
# Accept X-Request-Id from clients/proxies (validated) instead of
|
|
29
|
+
# always generating one.
|
|
30
|
+
setting :trust_request_id, default: true
|
|
31
|
+
|
|
32
|
+
# Headers added to every response unless the response already sets them.
|
|
33
|
+
setting :security_headers, default: {
|
|
34
|
+
"x-content-type-options" => "nosniff",
|
|
35
|
+
"x-frame-options" => "DENY",
|
|
36
|
+
"referrer-policy" => "strict-origin-when-cross-origin",
|
|
37
|
+
"cross-origin-opener-policy" => "same-origin",
|
|
38
|
+
"content-security-policy" => "default-src 'none'; frame-ancestors 'none'"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Strict-Transport-Security for HTTPS requests. nil disables.
|
|
42
|
+
setting :hsts, default: -> { GemStack.env.production? ? "max-age=63072000; includeSubDomains" : nil }
|
|
43
|
+
|
|
44
|
+
# Cross-origin access. Off by default: same-origin apps need none.
|
|
45
|
+
namespace :cors do
|
|
46
|
+
# Allowed origins: exact strings, Regexps, or "*".
|
|
47
|
+
setting :origins, default: []
|
|
48
|
+
setting :methods, default: %w[GET POST PUT PATCH DELETE OPTIONS]
|
|
49
|
+
setting :headers, default: %w[content-type authorization x-request-id]
|
|
50
|
+
setting :expose_headers, default: %w[x-request-id]
|
|
51
|
+
setting :credentials, default: false
|
|
52
|
+
setting :max_age, default: 600
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Response compression (Middleware::Compression).
|
|
56
|
+
namespace :compression do
|
|
57
|
+
setting :enabled, default: true
|
|
58
|
+
# Bodies smaller than this are sent as they are (compression overhead
|
|
59
|
+
# outweighs the savings on tiny responses).
|
|
60
|
+
setting :min_size, default: 1024
|
|
61
|
+
# Server preference; "br" is used only when the brotli gem is installed.
|
|
62
|
+
setting :encodings, default: %w[br gzip]
|
|
63
|
+
# Measured on varied JSON (docs/performance.md): gzip 4 costs half the
|
|
64
|
+
# CPU of gzip 6 for ~2% larger output; Brotli 4 is smaller than gzip 4 and cheaper.
|
|
65
|
+
setting :brotli_quality, default: 4
|
|
66
|
+
setting :gzip_level, default: 4
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# ETags for GET/HEAD responses and 304 Not Modified (Rack::ETag + Rack::ConditionalGet).
|
|
70
|
+
setting :etags, default: true
|
|
71
|
+
|
|
72
|
+
# Defaults for `paginate` in controllers.
|
|
73
|
+
namespace :pagination do
|
|
74
|
+
setting :per_page, default: 25
|
|
75
|
+
setting :max_per_page, default: 100
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The middleware stack. Edit it with use / insert_before / insert_after /
|
|
79
|
+
# swap / delete. Middleware receive this config and read it when the
|
|
80
|
+
# application is built, so settings may be changed in any order.
|
|
81
|
+
setting :middleware, default: -> { MiddlewareStack.default(self) }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module GemStack
|
|
7
|
+
module HTTP
|
|
8
|
+
# Base class for API controllers.
|
|
9
|
+
#
|
|
10
|
+
# class ProductsController < ApplicationController
|
|
11
|
+
# before :load_product, only: %i[show update]
|
|
12
|
+
# rescue_from Payments::Declined, status: 402
|
|
13
|
+
#
|
|
14
|
+
# def index = render(Product.all)
|
|
15
|
+
# def show = render(@product)
|
|
16
|
+
#
|
|
17
|
+
# def create
|
|
18
|
+
# product = Product.create!(params.require(:product).permit(:name, :price))
|
|
19
|
+
# render product, status: :created
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# private
|
|
23
|
+
#
|
|
24
|
+
# def load_product = @product = Product.find(params[:id])
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# Actions are the public methods defined in subclasses. An action that
|
|
28
|
+
# doesn't render responds 204 No Content. A `before` callback that
|
|
29
|
+
# renders halts the chain and the action is skipped.
|
|
30
|
+
class Controller
|
|
31
|
+
class DoubleRenderError < Error; end
|
|
32
|
+
class ActionNotFound < Error; end
|
|
33
|
+
|
|
34
|
+
Callback = Struct.new(:callback_method, :block, :only, :except) do
|
|
35
|
+
def applies?(action)
|
|
36
|
+
(only.nil? || only.include?(action)) && (except.nil? || !except.include?(action))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
RescueHandler = Struct.new(:classes, :handler, :status, :code)
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
def before_callbacks = @before_callbacks ||= inherited_copy(:before_callbacks)
|
|
44
|
+
def after_callbacks = @after_callbacks ||= inherited_copy(:after_callbacks)
|
|
45
|
+
def rescue_handlers = @rescue_handlers ||= inherited_copy(:rescue_handlers)
|
|
46
|
+
|
|
47
|
+
# before :authenticate, only: %i[create update]
|
|
48
|
+
# before { render({ error: "..." }, status: 401) unless ok? }
|
|
49
|
+
def before(*methods, only: nil, except: nil, &block)
|
|
50
|
+
add_callbacks(before_callbacks, methods, block, only, except)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def after(*methods, only: nil, except: nil, &block)
|
|
54
|
+
add_callbacks(after_callbacks, methods, block, only, except)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def skip_before(*methods)
|
|
58
|
+
names = methods.map(&:to_sym)
|
|
59
|
+
@before_callbacks = before_callbacks.reject { |cb| names.include?(cb.callback_method) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# rescue_from Stripe::CardError, with: :card_declined
|
|
63
|
+
# rescue_from Timeout::Error, status: 504, code: "upstream_timeout"
|
|
64
|
+
# rescue_from(MyError) { |error| render({ message: error.message }, status: 400) }
|
|
65
|
+
# Later declarations take precedence (they are checked first).
|
|
66
|
+
def rescue_from(*classes, with: nil, status: nil, code: nil, &block)
|
|
67
|
+
handler = with || block
|
|
68
|
+
raise ArgumentError, "rescue_from needs with:, a block, or status:" unless handler || status
|
|
69
|
+
|
|
70
|
+
rescue_handlers.unshift(RescueHandler.new(classes, handler, status, code))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Declares the request schema for actions; `input` returns the
|
|
74
|
+
# validated, coerced data inside those actions (DECISIONS D-021).
|
|
75
|
+
#
|
|
76
|
+
# accepts :create, with: Product.input_schema
|
|
77
|
+
# accepts :update, with: Product.input_schema, partial: true
|
|
78
|
+
# accepts(:search) { required :q, :string }
|
|
79
|
+
def accepts(*actions, with: nil, partial: false, &)
|
|
80
|
+
schema = with || Schema.define(&)
|
|
81
|
+
raise ArgumentError, "accepts needs with: SchemaClass or a block" unless schema
|
|
82
|
+
|
|
83
|
+
schema = schema.partial if partial
|
|
84
|
+
actions.each { |action| input_schemas[action.to_s] = schema }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def input_schemas = @input_schemas ||= inherited_copy(:input_schemas, {})
|
|
88
|
+
|
|
89
|
+
# Declares an action's response type for the API contract when the
|
|
90
|
+
# convention (<Resource>Serializer, see docs/typescript.md) doesn't apply:
|
|
91
|
+
# returns :search, [ProductSerializer]
|
|
92
|
+
# returns :stats, StatsSerializer
|
|
93
|
+
# returns :ping, nil # no body
|
|
94
|
+
def returns(*actions, type)
|
|
95
|
+
actions.each { |action| response_types[action.to_s] = type }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def response_types = @response_types ||= inherited_copy(:response_types, {})
|
|
99
|
+
|
|
100
|
+
# Public instance methods added by subclasses of Controller.
|
|
101
|
+
def action_methods
|
|
102
|
+
@action_methods ||= (public_instance_methods(true) - Controller.public_instance_methods(true))
|
|
103
|
+
.to_set(&:to_s).freeze
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def method_added(name)
|
|
107
|
+
super
|
|
108
|
+
@action_methods = nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def dispatch(action, env)
|
|
112
|
+
raise ActionNotFound, "#{name}##{action} is not a public action" unless action_methods.include?(action)
|
|
113
|
+
|
|
114
|
+
new(env).process(action)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def inherited_copy(name, empty = [])
|
|
120
|
+
superclass.respond_to?(name) ? superclass.public_send(name).dup : empty
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def add_callbacks(list, methods, block, only, except)
|
|
124
|
+
only &&= Array(only).map(&:to_s)
|
|
125
|
+
except &&= Array(except).map(&:to_s)
|
|
126
|
+
methods.each { |m| list << Callback.new(m.to_sym, nil, only, except) }
|
|
127
|
+
list << Callback.new(nil, block, only, except) if block
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
attr_reader :env, :request, :action_name
|
|
132
|
+
|
|
133
|
+
def initialize(env)
|
|
134
|
+
@env = env
|
|
135
|
+
@request = Request.new(env)
|
|
136
|
+
@response = nil
|
|
137
|
+
@response_headers = {}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def params
|
|
141
|
+
@params ||= Params.new(request.all_params)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The validated input for the current action, as declared with `accepts`.
|
|
145
|
+
def input
|
|
146
|
+
@input ||= begin
|
|
147
|
+
schema = self.class.input_schemas[action_name] or
|
|
148
|
+
raise Error, "#{self.class.name}##{action_name} has no `accepts` declaration; use params.validate instead"
|
|
149
|
+
schema.call(params)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Headers to add to the response. Set them before or after rendering.
|
|
154
|
+
def headers = @response_headers
|
|
155
|
+
|
|
156
|
+
def logger = GemStack.logger
|
|
157
|
+
|
|
158
|
+
def rendered? = !@response.nil?
|
|
159
|
+
|
|
160
|
+
# Renders value as JSON.
|
|
161
|
+
# render product
|
|
162
|
+
# render products, status: :ok
|
|
163
|
+
# render({ ok: true }, status: 202, headers: { "cache-control" => "no-store" })
|
|
164
|
+
# render product, serializer: Admin::ProductSerializer
|
|
165
|
+
def render(value = nil, status: 200, headers: {}, serializer: nil)
|
|
166
|
+
raise DoubleRenderError, "render/head called twice in #{self.class.name}##{action_name}" if rendered?
|
|
167
|
+
|
|
168
|
+
@response_headers.merge!(headers)
|
|
169
|
+
@response_headers["content-type"] ||= "application/json; charset=utf-8"
|
|
170
|
+
body = serializer ? apply_serializer(serializer, value) : serialize(value)
|
|
171
|
+
@response = [status_code(status), [codec.dump(body)]]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Responds without a body: head :no_content, head 404
|
|
175
|
+
def head(status, headers = {})
|
|
176
|
+
raise DoubleRenderError, "render/head called twice in #{self.class.name}##{action_name}" if rendered?
|
|
177
|
+
|
|
178
|
+
@response_headers.merge!(headers)
|
|
179
|
+
@response = [status_code(status), []]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Turns domain objects into JSON-ready structures. By convention an
|
|
183
|
+
# object of class Product is rendered with ProductSerializer (also for
|
|
184
|
+
# arrays and datasets of them). Values without a serializer go to the
|
|
185
|
+
# JSON codec as they are. Override for custom behaviour.
|
|
186
|
+
def serialize(value)
|
|
187
|
+
return { data: serialize(value.items), meta: value.meta } if value.is_a?(HTTP::Page)
|
|
188
|
+
|
|
189
|
+
Serializer.render(value, serializer_context)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Paginates a dataset (or array) using the `page` and `per_page` query
|
|
193
|
+
# parameters, bounded by config.http.pagination:
|
|
194
|
+
#
|
|
195
|
+
# render paginate(Product.order(:id))
|
|
196
|
+
# render paginate(Product.where(active: true).order(:name), per_page: 50)
|
|
197
|
+
#
|
|
198
|
+
# Invalid values are a 422 with field errors. The dataset should be
|
|
199
|
+
# ordered, or pages may overlap.
|
|
200
|
+
def paginate(scope, per_page: nil)
|
|
201
|
+
settings = pagination_settings
|
|
202
|
+
max = settings.max_per_page
|
|
203
|
+
page, size = PAGE_SCHEMA.call(params).values_at(:page, :per_page)
|
|
204
|
+
size = (size || per_page || settings.per_page).clamp(1, max)
|
|
205
|
+
page ||= 1
|
|
206
|
+
total = scope.count
|
|
207
|
+
offset = (page - 1) * size
|
|
208
|
+
items = scope.is_a?(Array) ? scope[offset, size] || [] : scope.limit(size).offset(offset).all
|
|
209
|
+
HTTP::Page.new(items, page: page, per_page: size, total: total)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
PAGE_SCHEMA = Schema.define do
|
|
213
|
+
optional :page, :integer, gte: 1
|
|
214
|
+
optional :per_page, :integer, gte: 1
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# HTTP caching. Sets ETag / Last-Modified and answers 304 Not Modified
|
|
218
|
+
# (without rendering) when the client already has this version:
|
|
219
|
+
#
|
|
220
|
+
# def show
|
|
221
|
+
# product = Product.find(params[:id])
|
|
222
|
+
# render product if stale?(etag: product, last_modified: product.updated_at)
|
|
223
|
+
# end
|
|
224
|
+
#
|
|
225
|
+
# etag: any value; records use #cache_key when they have one.
|
|
226
|
+
def stale?(etag: nil, last_modified: nil)
|
|
227
|
+
headers["etag"] = %(W/"#{Digest::SHA256.hexdigest(etag_source(etag))[0, 32]}") unless etag.nil?
|
|
228
|
+
headers["last-modified"] = last_modified.httpdate if last_modified
|
|
229
|
+
return true unless fresh?
|
|
230
|
+
|
|
231
|
+
head :not_modified
|
|
232
|
+
false
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def fresh_when(**) = stale?(**)
|
|
236
|
+
|
|
237
|
+
# cache_control max_age: 60 # private, max-age=60
|
|
238
|
+
# cache_control max_age: 300, public: true, stale_while_revalidate: 30
|
|
239
|
+
# cache_control :no_store
|
|
240
|
+
def cache_control(directive = nil, max_age: nil, public: false, stale_while_revalidate: nil)
|
|
241
|
+
headers["cache-control"] =
|
|
242
|
+
if directive == :no_store then "no-store"
|
|
243
|
+
else
|
|
244
|
+
[public ? "public" : "private", ("max-age=#{Integer(max_age)}" if max_age),
|
|
245
|
+
("stale-while-revalidate=#{Integer(stale_while_revalidate)}" if stale_while_revalidate)].compact.join(", ")
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Passed to serializers as `context` (e.g. { current_user: current_user }).
|
|
250
|
+
def serializer_context = {}
|
|
251
|
+
|
|
252
|
+
def process(action)
|
|
253
|
+
@action_name = action
|
|
254
|
+
run_callbacks(self.class.before_callbacks)
|
|
255
|
+
public_send(action) unless rendered?
|
|
256
|
+
run_callbacks(self.class.after_callbacks)
|
|
257
|
+
finish
|
|
258
|
+
rescue StandardError => e
|
|
259
|
+
handle_exception(e)
|
|
260
|
+
finish
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
private
|
|
264
|
+
|
|
265
|
+
def finish
|
|
266
|
+
head(:no_content) unless rendered?
|
|
267
|
+
status, body = @response
|
|
268
|
+
[status, @response_headers, body]
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def run_callbacks(callbacks)
|
|
272
|
+
callbacks.each do |callback|
|
|
273
|
+
next unless callback.applies?(@action_name)
|
|
274
|
+
|
|
275
|
+
callback.callback_method ? send(callback.callback_method) : instance_exec(&callback.block)
|
|
276
|
+
break if rendered? && callbacks.equal?(self.class.before_callbacks)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def handle_exception(error)
|
|
281
|
+
rescuer = self.class.rescue_handlers.find { |h| h.classes.any? { |klass| error.is_a?(klass) } }
|
|
282
|
+
raise error unless rescuer
|
|
283
|
+
|
|
284
|
+
@response = nil
|
|
285
|
+
@response_headers = @response_headers.slice("x-request-id")
|
|
286
|
+
if rescuer.handler
|
|
287
|
+
rescuer.handler.is_a?(Proc) ? instance_exec(error, &rescuer.handler) : call_handler(rescuer.handler, error)
|
|
288
|
+
else
|
|
289
|
+
status = status_code(rescuer.status)
|
|
290
|
+
wrapped = GemStack::Error.new(error.message, status: status,
|
|
291
|
+
code: rescuer.code || ErrorRenderer.code_for(status))
|
|
292
|
+
status, headers, body = ErrorRenderer.render(wrapped, request_id: request.request_id)
|
|
293
|
+
@response_headers.merge!(headers)
|
|
294
|
+
@response = [status, body]
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def pagination_settings = (env[CONFIG] || Config.new).pagination
|
|
299
|
+
|
|
300
|
+
def etag_source(value)
|
|
301
|
+
case value
|
|
302
|
+
when Array then value.map { |v| etag_source(v) }.join("/")
|
|
303
|
+
else value.respond_to?(:cache_key) ? value.cache_key.to_s : value.to_s
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def fresh?
|
|
308
|
+
none_match = request.get_header("HTTP_IF_NONE_MATCH")
|
|
309
|
+
etag = headers["etag"]
|
|
310
|
+
if none_match && etag
|
|
311
|
+
tags = none_match.split(",").map { |t| t.strip.delete_prefix("W/") }
|
|
312
|
+
return tags.include?("*") || tags.include?(etag.delete_prefix("W/"))
|
|
313
|
+
end
|
|
314
|
+
modified_since = request.get_header("HTTP_IF_MODIFIED_SINCE")
|
|
315
|
+
last_modified = headers["last-modified"]
|
|
316
|
+
return false unless modified_since && last_modified
|
|
317
|
+
|
|
318
|
+
Time.httpdate(last_modified) <= Time.httpdate(modified_since)
|
|
319
|
+
rescue ArgumentError
|
|
320
|
+
false
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def apply_serializer(serializer, value)
|
|
324
|
+
return { data: serializer.many(value.items, serializer_context), meta: value.meta } if value.is_a?(HTTP::Page)
|
|
325
|
+
|
|
326
|
+
list = value.is_a?(Array) || (value.respond_to?(:all) && value.respond_to?(:model))
|
|
327
|
+
list ? serializer.many(value, serializer_context) : serializer.serialize(value, serializer_context)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def call_handler(name, error)
|
|
331
|
+
method(name).arity.zero? ? send(name) : send(name, error)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def status_code(status)
|
|
335
|
+
return status if status.is_a?(Integer)
|
|
336
|
+
|
|
337
|
+
Rack::Utils::SYMBOL_TO_STATUS_CODE.fetch(status.to_sym) { raise ArgumentError, "unknown status #{status}" }
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def codec = env[JSON_CODEC] || JSONCodec.default
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module HTTP
|
|
7
|
+
# The development error page: shown instead of the JSON envelope when a
|
|
8
|
+
# *browser* opens an API URL that raises (a 500 with show_exceptions on).
|
|
9
|
+
# fetch() and API clients keep getting JSON. Self-contained HTML, no JS.
|
|
10
|
+
module ErrorPage
|
|
11
|
+
CONTEXT_LINES = 5
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# A top-level navigation from a browser (not fetch/XHR or an API client).
|
|
16
|
+
def browser?(env)
|
|
17
|
+
dest = env["HTTP_SEC_FETCH_DEST"]
|
|
18
|
+
return dest == "document" if dest
|
|
19
|
+
|
|
20
|
+
env["HTTP_ACCEPT"].to_s.include?("text/html")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def render(exception, env, request_id: nil)
|
|
24
|
+
html = page(exception, env, request_id)
|
|
25
|
+
[500, { "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store",
|
|
26
|
+
"content-security-policy" => "default-src 'none'; style-src 'unsafe-inline'" }, [html]]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def page(exception, env, request_id)
|
|
30
|
+
frames = frames(exception)
|
|
31
|
+
first_app = frames.find { |frame| frame[:app] }
|
|
32
|
+
<<~HTML
|
|
33
|
+
<!doctype html>
|
|
34
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
35
|
+
<title>#{h(exception.class.name)} · GemStack</title><style>#{STYLE}</style></head>
|
|
36
|
+
<body><main>
|
|
37
|
+
<p class="tag">#{h(env[Rack::REQUEST_METHOD])} #{h(env[Rack::PATH_INFO])}#{" · request #{h(request_id)}" if request_id}</p>
|
|
38
|
+
<h1>#{h(exception.class.name)}</h1>
|
|
39
|
+
<pre class="message">#{h(exception.message)}</pre>
|
|
40
|
+
#{source(first_app || frames.first)}
|
|
41
|
+
#{cause(exception)}
|
|
42
|
+
<h2>Backtrace</h2>
|
|
43
|
+
<ol class="trace">#{frames.map { |frame| trace_line(frame) }.join}</ol>
|
|
44
|
+
<p class="hint">Shown because <code>config.http.show_exceptions</code> is on (development). API clients get the
|
|
45
|
+
JSON error envelope; production shows neither the message nor the backtrace.</p>
|
|
46
|
+
</main></body></html>
|
|
47
|
+
HTML
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def frames(exception)
|
|
51
|
+
root = GemStack.config.root.to_s
|
|
52
|
+
Array(exception.backtrace).first(60).map do |line|
|
|
53
|
+
file, number, label = line.match(/\A(.+?):(\d+)(?::in [`'](.*)')?/)&.captures
|
|
54
|
+
relative = file&.start_with?("#{root}/") ? file.delete_prefix("#{root}/") : nil
|
|
55
|
+
app = !relative.nil? && !relative.start_with?("vendor/", "tmp/") && !relative.include?("/gems/")
|
|
56
|
+
{ line: line, file: file, number: number&.to_i, label: label,
|
|
57
|
+
display: app ? line.delete_prefix("#{root}/") : shorten(line), app: app }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def source(frame)
|
|
62
|
+
return "" unless frame && frame[:file] && File.file?(frame[:file])
|
|
63
|
+
|
|
64
|
+
lines = File.readlines(frame[:file])
|
|
65
|
+
from = [frame[:number] - CONTEXT_LINES, 1].max
|
|
66
|
+
to = [frame[:number] + CONTEXT_LINES, lines.size].min
|
|
67
|
+
rows = (from..to).map do |n|
|
|
68
|
+
css = n == frame[:number] ? ' class="hit"' : ""
|
|
69
|
+
"<span#{css}><i>#{n}</i>#{h(lines[n - 1].to_s.chomp)}</span>"
|
|
70
|
+
end
|
|
71
|
+
%(<h2>#{h(frame[:display].to_s.sub(/:in .*/, ""))}</h2><pre class="source">#{rows.join}</pre>)
|
|
72
|
+
rescue SystemCallError, ArgumentError
|
|
73
|
+
""
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def cause(exception)
|
|
77
|
+
cause = exception.cause or return ""
|
|
78
|
+
return "" if exception.message.include?(cause.message.to_s.lines.first.to_s.strip) # wrapped, already shown
|
|
79
|
+
|
|
80
|
+
%(<p class="cause">Caused by <b>#{h(cause.class.name)}</b>: #{h(cause.message)}</p>)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def trace_line(frame)
|
|
84
|
+
%(<li#{' class="app"' if frame[:app]}>#{h(frame[:display])}</li>)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# /…/gems/4.0.0/gems/sequel-5.108.0/lib/x.rb → sequel-5.108.0/lib/x.rb
|
|
88
|
+
def shorten(line)
|
|
89
|
+
prefixes = Gem.path.map { |dir| "#{dir}/gems/" } + ["#{RbConfig::CONFIG["rubylibdir"]}/"]
|
|
90
|
+
prefix = prefixes.find { |dir| line.start_with?(dir) }
|
|
91
|
+
prefix ? line.delete_prefix(prefix) : line
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def h(value) = Rack::Utils.escape_html(value.to_s)
|
|
95
|
+
|
|
96
|
+
STYLE = <<~CSS
|
|
97
|
+
:root { color-scheme: light dark; --bg: #fff; --fg: #16181d; --muted: #5b6170; --panel: #f3f4f7; --hit: #fde2e2; --accent: #c53030; }
|
|
98
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #0f1115; --fg: #e6e8ee; --muted: #9aa0ad; --panel: #1b1f2a; --hit: #4a1d22; --accent: #ff7b7b; } }
|
|
99
|
+
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; }
|
|
100
|
+
main { max-width: 64rem; margin: 0 auto; padding: 2rem 1rem 4rem; }
|
|
101
|
+
h1 { color: var(--accent); margin: .2rem 0; font-size: 1.6rem; overflow-wrap: anywhere; }
|
|
102
|
+
h2 { font-size: .95rem; margin: 1.8rem 0 .5rem; color: var(--muted); font-weight: 600; overflow-wrap: anywhere; }
|
|
103
|
+
pre, ol { background: var(--panel); border-radius: 8px; padding: .8rem 1rem; overflow-x: auto; font: 13px/1.55 ui-monospace, Menlo, monospace; }
|
|
104
|
+
.message { white-space: pre-wrap; font-size: 14px; }
|
|
105
|
+
.source span { display: block; } .source i { display: inline-block; width: 3.5em; color: var(--muted); font-style: normal; }
|
|
106
|
+
.source .hit { background: var(--hit); } .trace { padding-left: 3rem; color: var(--muted); } .trace .app { color: var(--fg); font-weight: 600; }
|
|
107
|
+
.tag, .hint, .cause { color: var(--muted); } code { font-family: ui-monospace, Menlo, monospace; }
|
|
108
|
+
CSS
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|