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.
@@ -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