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
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module HTTP
|
|
7
|
+
# Turns an exception into the standard JSON error envelope (DECISIONS D-015):
|
|
8
|
+
#
|
|
9
|
+
# {"error": {"code": "not_found", "message": "Not Found", "request_id": "..."},
|
|
10
|
+
# "errors": {"name": ["is required"]}}
|
|
11
|
+
#
|
|
12
|
+
# Any exception responding to #status and #code is rendered with that
|
|
13
|
+
# status. Everything else is a 500 whose message is hidden unless
|
|
14
|
+
# show_exceptions is on.
|
|
15
|
+
module ErrorRenderer
|
|
16
|
+
BACKTRACE_LINES = 30
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def render(exception, request_id: nil, show_exceptions: false)
|
|
21
|
+
original = exception
|
|
22
|
+
exception = ErrorMapping.translate(exception)
|
|
23
|
+
status, code, message = describe(exception)
|
|
24
|
+
body = { error: { code: code, message: message, request_id: request_id }.compact }
|
|
25
|
+
details = exception.respond_to?(:details) ? exception.details : nil
|
|
26
|
+
body[:errors] = details if details && !details.empty?
|
|
27
|
+
body[:exception] = debug_info(original) if show_exceptions && status >= 500
|
|
28
|
+
|
|
29
|
+
headers = { "content-type" => "application/json; charset=utf-8", "cache-control" => "no-store" }
|
|
30
|
+
headers.merge!(exception.headers) if exception.respond_to?(:headers) && exception.headers
|
|
31
|
+
[status, headers, [::JSON.generate(body)]]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def describe(exception)
|
|
35
|
+
if exception.respond_to?(:status) && exception.respond_to?(:code) && exception.status.is_a?(Integer)
|
|
36
|
+
status = exception.status
|
|
37
|
+
exposed = exception.respond_to?(:expose_message?) ? exception.expose_message? : status < 500
|
|
38
|
+
message = exposed ? exception.message : Rack::Utils::HTTP_STATUS_CODES.fetch(status, "Error")
|
|
39
|
+
[status, exception.code.to_s, message]
|
|
40
|
+
else
|
|
41
|
+
[500, "internal_error", "Internal Server Error"]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# 404 => "not_found"; unknown statuses => "error"
|
|
46
|
+
def code_for(status)
|
|
47
|
+
Rack::Utils::SYMBOL_TO_STATUS_CODE.key(status)&.name || "error"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def debug_info(exception)
|
|
51
|
+
{
|
|
52
|
+
class: exception.class.name,
|
|
53
|
+
message: exception.message,
|
|
54
|
+
backtrace: Array(exception.backtrace).first(BACKTRACE_LINES)
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def json_error(status, code, message, request_id: nil, headers: {})
|
|
59
|
+
render(GemStack::Error.new(message, status: status, code: code, headers: headers), request_id: request_id)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "date"
|
|
6
|
+
|
|
7
|
+
module GemStack
|
|
8
|
+
module HTTP
|
|
9
|
+
# JSON encoding/decoding behind a two-method interface (dump / load), so the
|
|
10
|
+
# implementation can be swapped with `config.http.json = :oj | codec`.
|
|
11
|
+
module JSONCodec
|
|
12
|
+
# How non-JSON-native Ruby objects are encoded. Objects must opt in by
|
|
13
|
+
# defining #as_json (or #to_h); anything else raises instead of silently
|
|
14
|
+
# leaking #inspect/#to_s output into API responses.
|
|
15
|
+
def self.coerce(object)
|
|
16
|
+
case object
|
|
17
|
+
when Symbol then object.name
|
|
18
|
+
when Time, DateTime then object.iso8601(3)
|
|
19
|
+
when Date then object.iso8601
|
|
20
|
+
when Set then object.to_a
|
|
21
|
+
else
|
|
22
|
+
if object.respond_to?(:as_json) then object.as_json
|
|
23
|
+
elsif defined?(BigDecimal) && object.is_a?(BigDecimal) then object.to_s("F")
|
|
24
|
+
elsif object.respond_to?(:to_h) then object.to_h
|
|
25
|
+
else
|
|
26
|
+
raise TypeError, "#{object.class} is not JSON serializable; define #as_json or use a serializer"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Default codec, backed by the json gem's JSON::Coder: native types are
|
|
32
|
+
# encoded in C and the coerce block only runs for other objects.
|
|
33
|
+
class Stdlib
|
|
34
|
+
def initialize(max_nesting: 64)
|
|
35
|
+
@max_nesting = max_nesting
|
|
36
|
+
@coder = ::JSON::Coder.new { |object| JSONCodec.coerce(object) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def dump(object) = @coder.dump(object)
|
|
40
|
+
|
|
41
|
+
def load(source)
|
|
42
|
+
::JSON.parse(source, max_nesting: @max_nesting)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class Oj
|
|
47
|
+
def initialize(max_nesting: 64)
|
|
48
|
+
require "oj"
|
|
49
|
+
@max_nesting = max_nesting
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Serializer output is already JSON-native, so try Oj's C strict mode
|
|
53
|
+
# first; only values with Time, BigDecimal, models... take the slower
|
|
54
|
+
# normalising path.
|
|
55
|
+
def dump(object)
|
|
56
|
+
::Oj.dump(object, mode: :strict)
|
|
57
|
+
rescue TypeError, EncodingError
|
|
58
|
+
::Oj.dump(normalize(object), mode: :strict)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def load(source)
|
|
62
|
+
# Oj has no depth limit option in strict mode; enforce it with the
|
|
63
|
+
# stdlib parser's semantics by checking depth after parsing.
|
|
64
|
+
result = ::Oj.load(source, mode: :strict)
|
|
65
|
+
raise ::JSON::NestingError, "nesting too deep" if depth(result) > @max_nesting
|
|
66
|
+
|
|
67
|
+
result
|
|
68
|
+
rescue ::Oj::ParseError, EncodingError => e
|
|
69
|
+
raise ::JSON::ParserError, e.message
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def normalize(object)
|
|
75
|
+
case object
|
|
76
|
+
when Hash then object.to_h { |k, v| [k.is_a?(Symbol) ? k.name : k, normalize(v)] }
|
|
77
|
+
when Array then object.map { |v| normalize(v) }
|
|
78
|
+
when String, Integer, Float, true, false, nil then object
|
|
79
|
+
else normalize(JSONCodec.coerce(object))
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Same definition as the json gem: `{}` and `[]` have depth 1.
|
|
84
|
+
def depth(object)
|
|
85
|
+
case object
|
|
86
|
+
when Hash then 1 + (object.each_value.map { |v| depth(v) }.max || 0)
|
|
87
|
+
when Array then 1 + (object.map { |v| depth(v) }.max || 0)
|
|
88
|
+
else 0
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
BUILT_IN = { json: Stdlib, oj: Oj }.freeze
|
|
94
|
+
|
|
95
|
+
def self.resolve(setting, max_nesting: 64)
|
|
96
|
+
case setting
|
|
97
|
+
when Symbol, String
|
|
98
|
+
BUILT_IN.fetch(setting.to_sym) { raise ConfigurationError, "unknown JSON codec #{setting.inspect}" }
|
|
99
|
+
.new(max_nesting: max_nesting)
|
|
100
|
+
else
|
|
101
|
+
unless setting.respond_to?(:dump) && setting.respond_to?(:load)
|
|
102
|
+
raise ConfigurationError, "a JSON codec must respond to dump and load"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
setting
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self.default
|
|
110
|
+
@default ||= Stdlib.new
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module HTTP
|
|
5
|
+
module Middleware
|
|
6
|
+
# Rejects request bodies larger than config.http.max_body_size.
|
|
7
|
+
# Declared sizes (Content-Length) are rejected up front; bodies of
|
|
8
|
+
# unknown size (chunked) are counted while being read.
|
|
9
|
+
class BodyLimit
|
|
10
|
+
# Wraps rack.input and raises PayloadTooLarge once the limit is passed.
|
|
11
|
+
class LimitedInput
|
|
12
|
+
def initialize(input, limit)
|
|
13
|
+
@input = input
|
|
14
|
+
@limit = limit
|
|
15
|
+
@read = 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def read(length = nil, buffer = nil)
|
|
19
|
+
data = @input.read(length, buffer)
|
|
20
|
+
count(data)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def gets
|
|
24
|
+
count(@input.gets)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def each
|
|
28
|
+
while (line = gets)
|
|
29
|
+
yield line
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def rewind
|
|
34
|
+
@read = 0
|
|
35
|
+
@input.rewind
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def close = @input.close
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def count(data)
|
|
43
|
+
@read += data.bytesize if data
|
|
44
|
+
raise PayloadTooLarge if @read > @limit
|
|
45
|
+
|
|
46
|
+
data
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def initialize(app, config)
|
|
51
|
+
@app = app
|
|
52
|
+
@limit = config.max_body_size
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def call(env)
|
|
56
|
+
return @app.call(env) unless @limit
|
|
57
|
+
|
|
58
|
+
length = env["CONTENT_LENGTH"]
|
|
59
|
+
if length && !length.empty?
|
|
60
|
+
raise PayloadTooLarge if length.to_i > @limit
|
|
61
|
+
elsif (input = env[Rack::RACK_INPUT])
|
|
62
|
+
env[Rack::RACK_INPUT] = LimitedInput.new(input, @limit)
|
|
63
|
+
end
|
|
64
|
+
@app.call(env)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module HTTP
|
|
7
|
+
module Middleware
|
|
8
|
+
# Response compression with content negotiation (DECISIONS D-033).
|
|
9
|
+
#
|
|
10
|
+
# - Negotiates Accept-Encoding (with q-values): Brotli when the `brotli`
|
|
11
|
+
# gem is available, otherwise gzip.
|
|
12
|
+
# - Compresses only compressible types (JSON, text, JS, XML, SVG) with a
|
|
13
|
+
# known body at least `min_size` bytes long.
|
|
14
|
+
# - Never touches: HEAD, 1xx/204/304, responses that already have a
|
|
15
|
+
# Content-Encoding, `Cache-Control: no-transform`, Server-Sent Events,
|
|
16
|
+
# or streamed bodies of unknown length.
|
|
17
|
+
# - Always sends `Vary: Accept-Encoding` for compressible types, and
|
|
18
|
+
# weakens strong ETags of compressed responses.
|
|
19
|
+
class Compression
|
|
20
|
+
COMPRESSIBLE = %r{
|
|
21
|
+
\A\s*(?:
|
|
22
|
+
text/(?!event-stream) # any text/*, except Server-Sent Events
|
|
23
|
+
| application/(?:json|javascript|xml|[\w.+-]+\+(?:json|xml)) # JSON, JS, XML and +json/+xml types
|
|
24
|
+
| image/svg\+xml
|
|
25
|
+
)
|
|
26
|
+
}xi
|
|
27
|
+
|
|
28
|
+
def self.brotli_available?
|
|
29
|
+
return @brotli_available if defined?(@brotli_available)
|
|
30
|
+
|
|
31
|
+
@brotli_available = begin
|
|
32
|
+
require "brotli"
|
|
33
|
+
true
|
|
34
|
+
rescue LoadError
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(app, config)
|
|
40
|
+
@app = app
|
|
41
|
+
settings = config.compression
|
|
42
|
+
@enabled = settings.enabled
|
|
43
|
+
@min_size = settings.min_size
|
|
44
|
+
@gzip_level = settings.gzip_level
|
|
45
|
+
@brotli_quality = settings.brotli_quality
|
|
46
|
+
@encodings = settings.encodings.map(&:to_s).select do |encoding|
|
|
47
|
+
encoding == "gzip" || (encoding == "br" && self.class.brotli_available?)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def call(env)
|
|
52
|
+
status, headers, body = @app.call(env)
|
|
53
|
+
return [status, headers, body] unless @enabled && eligible?(env, status, headers)
|
|
54
|
+
|
|
55
|
+
vary(headers)
|
|
56
|
+
encoding = negotiate(env["HTTP_ACCEPT_ENCODING"])
|
|
57
|
+
return [status, headers, body] unless encoding && body.respond_to?(:to_ary)
|
|
58
|
+
|
|
59
|
+
content = read(body)
|
|
60
|
+
return [status, headers, [content]] if content.bytesize < @min_size
|
|
61
|
+
|
|
62
|
+
compressed = encode(encoding, content)
|
|
63
|
+
headers["content-encoding"] = encoding
|
|
64
|
+
headers["content-length"] = compressed.bytesize.to_s
|
|
65
|
+
headers["etag"] = "W/#{headers["etag"]}" if headers["etag"]&.start_with?('"')
|
|
66
|
+
[status, headers, [compressed]]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# "gzip;q=0.5, br" → "br". Server preference order breaks ties; q=0 refuses.
|
|
70
|
+
def negotiate(header)
|
|
71
|
+
return nil if header.nil? || header.empty? || @encodings.empty?
|
|
72
|
+
|
|
73
|
+
accepted = header.split(",").to_h do |part|
|
|
74
|
+
name, *params = part.strip.split(";")
|
|
75
|
+
q = params.find { |p| p.strip.start_with?("q=") }&.then { |p| p.strip[2..].to_f } || 1.0
|
|
76
|
+
[name.to_s.strip.downcase, q]
|
|
77
|
+
end
|
|
78
|
+
wildcard = accepted.fetch("*", 0.0)
|
|
79
|
+
ranked = @encodings.map { |encoding| [encoding, accepted.fetch(encoding, wildcard)] }
|
|
80
|
+
best = ranked.select { |_, q| q.positive? }.max_by { |encoding, q| [q, -@encodings.index(encoding)] }
|
|
81
|
+
best&.first
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def eligible?(env, status, headers)
|
|
87
|
+
return false if env[Rack::REQUEST_METHOD] == "HEAD"
|
|
88
|
+
return false if status < 200 || status == 204 || status == 304
|
|
89
|
+
return false if headers["content-encoding"]
|
|
90
|
+
return false if headers["cache-control"]&.include?("no-transform")
|
|
91
|
+
|
|
92
|
+
COMPRESSIBLE.match?(headers["content-type"].to_s)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def vary(headers)
|
|
96
|
+
current = headers["vary"]
|
|
97
|
+
return if current&.split(",")&.any? { |v| %w[accept-encoding *].include?(v.strip.downcase) }
|
|
98
|
+
|
|
99
|
+
headers["vary"] = current ? "#{current}, Accept-Encoding" : "Accept-Encoding"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def read(body)
|
|
103
|
+
content = String.new(encoding: Encoding::BINARY)
|
|
104
|
+
body.each { |part| content << part }
|
|
105
|
+
content
|
|
106
|
+
ensure
|
|
107
|
+
body.close if body.respond_to?(:close)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def encode(encoding, content)
|
|
111
|
+
case encoding
|
|
112
|
+
when "br" then Brotli.deflate(content, quality: @brotli_quality)
|
|
113
|
+
else gzip(content)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def gzip(content)
|
|
118
|
+
io = StringIO.new(String.new(encoding: Encoding::BINARY))
|
|
119
|
+
writer = Zlib::GzipWriter.new(io, @gzip_level)
|
|
120
|
+
writer.write(content)
|
|
121
|
+
writer.finish
|
|
122
|
+
io.string
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module HTTP
|
|
5
|
+
module Middleware
|
|
6
|
+
# Cross-Origin Resource Sharing, driven by config.http.cors. It does
|
|
7
|
+
# nothing until origins are configured — same-origin GemStack apps
|
|
8
|
+
# (the default architecture) never need CORS.
|
|
9
|
+
class Cors
|
|
10
|
+
def initialize(app, config)
|
|
11
|
+
@app = app
|
|
12
|
+
cors = config.cors
|
|
13
|
+
@origins = Array(cors.origins)
|
|
14
|
+
@methods = cors.methods.map(&:upcase).join(", ")
|
|
15
|
+
@headers = cors.headers.join(", ")
|
|
16
|
+
@expose = cors.expose_headers.join(", ")
|
|
17
|
+
@credentials = cors.credentials
|
|
18
|
+
@max_age = cors.max_age.to_s
|
|
19
|
+
return unless @credentials && @origins.include?("*")
|
|
20
|
+
|
|
21
|
+
raise ConfigurationError, "CORS: credentials cannot be combined with the \"*\" origin"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(env)
|
|
25
|
+
origin = env["HTTP_ORIGIN"]
|
|
26
|
+
return @app.call(env) if @origins.empty? || origin.nil?
|
|
27
|
+
|
|
28
|
+
allowed = allowed?(origin)
|
|
29
|
+
return preflight(allowed ? origin : nil) if preflight?(env)
|
|
30
|
+
|
|
31
|
+
status, headers, body = @app.call(env)
|
|
32
|
+
vary(headers)
|
|
33
|
+
apply(headers, origin) if allowed
|
|
34
|
+
[status, headers, body]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def preflight?(env)
|
|
40
|
+
env[Rack::REQUEST_METHOD] == "OPTIONS" && env["HTTP_ACCESS_CONTROL_REQUEST_METHOD"]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def preflight(origin)
|
|
44
|
+
headers = { "vary" => "Origin" }
|
|
45
|
+
if origin
|
|
46
|
+
apply(headers, origin)
|
|
47
|
+
headers["access-control-allow-methods"] = @methods
|
|
48
|
+
headers["access-control-allow-headers"] = @headers
|
|
49
|
+
headers["access-control-max-age"] = @max_age
|
|
50
|
+
end
|
|
51
|
+
[204, headers, []]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def apply(headers, origin)
|
|
55
|
+
headers["access-control-allow-origin"] = @origins.include?("*") ? "*" : origin
|
|
56
|
+
headers["access-control-allow-credentials"] = "true" if @credentials
|
|
57
|
+
headers["access-control-expose-headers"] = @expose unless @expose.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def vary(headers)
|
|
61
|
+
current = headers["vary"]
|
|
62
|
+
headers["vary"] = current ? "#{current}, Origin" : "Origin" unless current&.include?("Origin")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def allowed?(origin)
|
|
66
|
+
@origins.any? do |allowed|
|
|
67
|
+
case allowed
|
|
68
|
+
when "*" then true
|
|
69
|
+
when Regexp then allowed.match?(origin)
|
|
70
|
+
else allowed == origin
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module HTTP
|
|
5
|
+
module Middleware
|
|
6
|
+
# The exception boundary. Everything raised below it becomes a JSON
|
|
7
|
+
# error response; server errors are logged with their backtrace.
|
|
8
|
+
# Internals (class, message, backtrace) reach the client only when
|
|
9
|
+
# config.http.show_exceptions is on (development/test by default).
|
|
10
|
+
class ErrorHandler
|
|
11
|
+
def initialize(app, config)
|
|
12
|
+
@app = app
|
|
13
|
+
@show_exceptions = config.show_exceptions
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(env)
|
|
17
|
+
@app.call(env)
|
|
18
|
+
rescue StandardError, ScriptError => e # ScriptError: syntax errors while reloading in development
|
|
19
|
+
response = ErrorRenderer.render(e, request_id: env[REQUEST_ID], show_exceptions: @show_exceptions)
|
|
20
|
+
return response if response[0] < 500
|
|
21
|
+
|
|
22
|
+
report(e, env)
|
|
23
|
+
# Development: a browser opening the URL gets a readable page (DECISIONS D-055).
|
|
24
|
+
@show_exceptions && ErrorPage.browser?(env) ? ErrorPage.render(e, env, request_id: env[REQUEST_ID]) : response
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def report(exception, env)
|
|
30
|
+
GemStack.logger.error(
|
|
31
|
+
"#{exception.class}: #{exception.message}",
|
|
32
|
+
id: env[REQUEST_ID],
|
|
33
|
+
backtrace: Array(exception.backtrace).first(ErrorRenderer::BACKTRACE_LINES)
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module HTTP
|
|
5
|
+
module Middleware
|
|
6
|
+
# ETags and conditional GET, using Rack's own middleware: Rack::ETag adds
|
|
7
|
+
# a weak ETag (SHA-256 of the body) to buffered 200/201 responses that
|
|
8
|
+
# don't set one, and Rack::ConditionalGet answers matching
|
|
9
|
+
# If-None-Match / If-Modified-Since requests with 304 and no body.
|
|
10
|
+
# Controllers can set ETags themselves (see Controller#stale?).
|
|
11
|
+
#
|
|
12
|
+
# Responses without Cache-Control get Rack's
|
|
13
|
+
# "max-age=0, private, must-revalidate": clients may keep a copy but
|
|
14
|
+
# must revalidate it — the safe default for API data.
|
|
15
|
+
class ETags
|
|
16
|
+
def initialize(app, config)
|
|
17
|
+
@app = config.etags ? Rack::ConditionalGet.new(Rack::ETag.new(app)) : app
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(env) = @app.call(env)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module HTTP
|
|
5
|
+
module Middleware
|
|
6
|
+
# Answers GET/HEAD config.http.health_path with 200 {"status":"ok"}
|
|
7
|
+
# without touching the router (DECISIONS D-016).
|
|
8
|
+
class HealthCheck
|
|
9
|
+
BODY = '{"status":"ok"}'
|
|
10
|
+
|
|
11
|
+
def initialize(app, config)
|
|
12
|
+
@app = app
|
|
13
|
+
@path = config.health_path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(env)
|
|
17
|
+
return @app.call(env) unless @path && env[Rack::PATH_INFO] == @path
|
|
18
|
+
|
|
19
|
+
method = env[Rack::REQUEST_METHOD]
|
|
20
|
+
return @app.call(env) unless %w[GET HEAD].include?(method)
|
|
21
|
+
|
|
22
|
+
headers = { "content-type" => "application/json", "cache-control" => "no-store" }
|
|
23
|
+
[200, headers, method == "HEAD" ? [] : [BODY]]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module HTTP
|
|
7
|
+
module Middleware
|
|
8
|
+
# Assigns every request an id (env["gemstack.request_id"]), echoed in the
|
|
9
|
+
# X-Request-Id response header and included in logs and error bodies.
|
|
10
|
+
# An incoming X-Request-Id (e.g. from a load balancer) is reused when
|
|
11
|
+
# trusted and well-formed.
|
|
12
|
+
class RequestId
|
|
13
|
+
VALID = /\A[A-Za-z0-9\-_.:]{1,128}\z/
|
|
14
|
+
|
|
15
|
+
def initialize(app, config)
|
|
16
|
+
@app = app
|
|
17
|
+
@trust = config.trust_request_id
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(env)
|
|
21
|
+
incoming = env["HTTP_X_REQUEST_ID"]
|
|
22
|
+
id = @trust && incoming&.match?(VALID) ? incoming : SecureRandom.uuid
|
|
23
|
+
env[REQUEST_ID] = id
|
|
24
|
+
status, headers, body = @app.call(env)
|
|
25
|
+
headers["x-request-id"] = id
|
|
26
|
+
[status, headers, body]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module HTTP
|
|
5
|
+
module Middleware
|
|
6
|
+
# One structured log line per request, written when the response body is
|
|
7
|
+
# closed so streamed responses are timed correctly:
|
|
8
|
+
#
|
|
9
|
+
# INFO GET /api/products status=200 ms=1.84 id=5f0c...
|
|
10
|
+
#
|
|
11
|
+
# Query strings are not logged (they often carry tokens).
|
|
12
|
+
class RequestLogger
|
|
13
|
+
def initialize(app, logger: nil)
|
|
14
|
+
@app = app
|
|
15
|
+
@logger = logger
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(env)
|
|
19
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
20
|
+
status, headers, body = @app.call(env)
|
|
21
|
+
body = Rack::BodyProxy.new(body) { log(env, status, started) }
|
|
22
|
+
[status, headers, body]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def log(env, status, started)
|
|
28
|
+
logger = @logger || GemStack.logger
|
|
29
|
+
return unless logger.info?
|
|
30
|
+
|
|
31
|
+
ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(2)
|
|
32
|
+
severity = status >= 500 ? :error : :info
|
|
33
|
+
logger.public_send(severity, "#{env[Rack::REQUEST_METHOD]} #{env[Rack::PATH_INFO]}",
|
|
34
|
+
status: status, ms: ms, id: env[REQUEST_ID])
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module HTTP
|
|
5
|
+
module Middleware
|
|
6
|
+
# Adds config.http.security_headers to responses that don't already set
|
|
7
|
+
# them, plus Strict-Transport-Security on HTTPS requests when
|
|
8
|
+
# config.http.hsts is set (production by default).
|
|
9
|
+
class SecurityHeaders
|
|
10
|
+
def initialize(app, config)
|
|
11
|
+
@app = app
|
|
12
|
+
@headers = config.security_headers.transform_keys { |key| key.to_s.downcase }.freeze
|
|
13
|
+
@hsts = config.hsts
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(env)
|
|
17
|
+
status, headers, body = @app.call(env)
|
|
18
|
+
@headers.each { |key, value| headers[key] = value unless headers.key?(key) }
|
|
19
|
+
headers["strict-transport-security"] ||= @hsts if @hsts && https?(env)
|
|
20
|
+
[status, headers, body]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def https?(env)
|
|
26
|
+
env[Rack::RACK_URL_SCHEME] == "https" || env["HTTP_X_FORWARDED_PROTO"]&.start_with?("https")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|