gemstack-http 0.2.4 → 0.3.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.
@@ -1,39 +0,0 @@
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
@@ -1,31 +0,0 @@
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
@@ -1,96 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GemStack
4
- module HTTP
5
- # An ordered, editable list of Rack middleware, compiled once into a
6
- # nested Rack app when the application is built.
7
- #
8
- # config.http.middleware.use Rack::Attack
9
- # config.http.middleware.insert_before GemStack::HTTP::Middleware::ErrorHandler, MyTiming
10
- # config.http.middleware.swap GemStack::HTTP::Middleware::RequestLogger, MyLogger
11
- # config.http.middleware.delete GemStack::HTTP::Middleware::SecurityHeaders
12
- #
13
- # Targets are middleware classes or integer positions. Any Rack
14
- # middleware (`new(app, *args)`, `#call(env)`) works.
15
- class MiddlewareStack
16
- include Enumerable
17
-
18
- Entry = Struct.new(:klass, :args, :kwargs, :block) do
19
- def build(app) = klass.new(app, *args, **kwargs, &block)
20
- def name = klass.respond_to?(:name) && klass.name ? klass.name : klass.inspect
21
- end
22
-
23
- def self.default(config)
24
- new.tap do |stack|
25
- stack.use Middleware::RequestId, config
26
- stack.use Middleware::RequestLogger
27
- stack.use Middleware::Compression, config
28
- stack.use Middleware::ErrorHandler, config
29
- stack.use Middleware::SecurityHeaders, config
30
- stack.use Middleware::Cors, config
31
- stack.use Middleware::BodyLimit, config
32
- stack.use Middleware::HealthCheck, config
33
- stack.use Middleware::ETags, config
34
- end
35
- end
36
-
37
- def initialize
38
- @entries = []
39
- end
40
-
41
- def use(klass, *args, **kwargs, &block)
42
- @entries << Entry.new(klass, args, kwargs, block)
43
- self
44
- end
45
-
46
- def unshift(klass, *args, **kwargs, &block)
47
- @entries.unshift(Entry.new(klass, args, kwargs, block))
48
- self
49
- end
50
-
51
- def insert_before(target, klass, *args, **kwargs, &block)
52
- @entries.insert(index!(target), Entry.new(klass, args, kwargs, block))
53
- self
54
- end
55
-
56
- def insert_after(target, klass, *args, **kwargs, &block)
57
- @entries.insert(index!(target) + 1, Entry.new(klass, args, kwargs, block))
58
- self
59
- end
60
-
61
- def swap(target, klass, *args, **kwargs, &block)
62
- @entries[index!(target)] = Entry.new(klass, args, kwargs, block)
63
- self
64
- end
65
-
66
- def delete(target)
67
- @entries.delete_at(index!(target))
68
- self
69
- end
70
-
71
- def include?(klass) = @entries.any? { |entry| entry.klass == klass }
72
- def each(&) = @entries.each(&)
73
- def size = @entries.size
74
- def names = @entries.map(&:name)
75
-
76
- # Wraps endpoint so that the first middleware in the list runs first.
77
- def build(endpoint)
78
- @entries.reverse.inject(endpoint) { |app, entry| entry.build(app) }
79
- end
80
-
81
- def initialize_copy(source)
82
- super
83
- @entries = source.to_a.dup
84
- end
85
-
86
- private
87
-
88
- def index!(target)
89
- index = target.is_a?(Integer) ? target : @entries.index { |entry| entry.klass == target }
90
- raise ArgumentError, "no middleware #{target.inspect} in the stack" unless index && @entries[index]
91
-
92
- index
93
- end
94
- end
95
- end
96
- end
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GemStack
4
- module HTTP
5
- # One page of a collection, rendered as the pagination envelope
6
- # (DECISIONS D-034):
7
- #
8
- # { "data": [ ... ], "meta": { "page": 2, "per_page": 25, "total": 180, "total_pages": 8 } }
9
- #
10
- # Built by Controller#paginate. `GemStack::Page[ProductSerializer]` is the
11
- # matching type for `returns`, so the TypeScript client gets `Paginated<Product>`.
12
- # Always namespaced, so an app's own Page model never collides with it.
13
- class Page
14
- # Type marker for the contract: Page[ProductSerializer].
15
- Type = Struct.new(:item)
16
-
17
- def self.[](item_type) = Type.new(item_type)
18
-
19
- attr_reader :items, :page, :per_page, :total
20
-
21
- def initialize(items, page:, per_page:, total:)
22
- @items = items
23
- @page = page
24
- @per_page = per_page
25
- @total = total
26
- end
27
-
28
- def total_pages = total.zero? ? 0 : (total.to_f / per_page).ceil
29
-
30
- def meta = { page: page, per_page: per_page, total: total, total_pages: total_pages }
31
- end
32
- end
33
-
34
- # Public name, used in controllers: `returns :index, GemStack::Page[ProductSerializer]`.
35
- Page = HTTP::Page
36
- end
@@ -1,140 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GemStack
4
- module HTTP
5
- # Request parameters with indifferent (string/symbol) key access and
6
- # explicit allow-listing for anything that reaches the application:
7
- #
8
- # params[:id] # path, query or body
9
- # params.require(:product) # 400 if missing/blank
10
- # params.permit(:name, :price, tags: [], dimensions: [:width, :height])
11
- # # => { name: "Lamp", price: "9.99", tags: ["home"], dimensions: { width: 3, height: 5 } }
12
- #
13
- # #permit only returns scalars (and arrays/hashes that are explicitly
14
- # declared), so unexpected nested structures never reach models.
15
- class Params
16
- include Enumerable
17
-
18
- class ParameterMissing < BadRequest
19
- def initialize(key)
20
- super("Missing parameter: #{key}", code: "parameter_missing", details: { key.to_s => ["is required"] })
21
- end
22
- end
23
-
24
- SCALARS = [String, Integer, Float, TrueClass, FalseClass, NilClass].freeze
25
-
26
- def initialize(hash = {})
27
- @hash = hash.transform_keys(&:to_s)
28
- end
29
-
30
- def [](key) = wrap(@hash[key.to_s])
31
- def key?(key) = @hash.key?(key.to_s)
32
- alias has_key? key?
33
- alias include? key?
34
- def keys = @hash.keys
35
- def empty? = @hash.empty?
36
- def size = @hash.size
37
- def each(&) = @hash.each { |key, value| yield key, wrap(value) }
38
-
39
- def fetch(key, *default, &)
40
- wrap(@hash.fetch(key.to_s, *default, &))
41
- end
42
-
43
- def dig(key, *rest)
44
- value = self[key]
45
- rest.empty? || value.nil? ? value : value.dig(*rest)
46
- end
47
-
48
- # Returns the value for key, raising ParameterMissing (400) when it is
49
- # absent, nil, or an empty string/collection.
50
- def require(key)
51
- value = @hash[key.to_s]
52
- raise ParameterMissing, key if blank?(value)
53
-
54
- wrap(value)
55
- end
56
-
57
- def slice(*keys) = Params.new(@hash.slice(*keys.map(&:to_s)))
58
- def except(*keys) = Params.new(@hash.except(*keys.map(&:to_s)))
59
-
60
- # Allow-list. Returns a plain Hash with symbol keys.
61
- def permit(*filters)
62
- filters.each_with_object({}) do |filter, result|
63
- case filter
64
- when Symbol, String
65
- key = filter.to_s
66
- value = @hash[key]
67
- result[filter.to_sym] = value if @hash.key?(key) && scalar?(value)
68
- when Hash
69
- filter.each { |key, nested| permit_nested(result, key, nested) }
70
- else
71
- raise ArgumentError, "invalid permit filter #{filter.inspect}"
72
- end
73
- end
74
- end
75
-
76
- # Validates and coerces against a schema (see GemStack::Schema), returning
77
- # a symbol-keyed Hash of declared fields or raising a 422 ValidationError:
78
- #
79
- # attrs = params.validate do
80
- # required :name, :string
81
- # required :price, :decimal, gt: 0
82
- # end
83
- # attrs = params.validate(ProductInput)
84
- def validate(schema = nil, &)
85
- schema ||= Schema.define(&)
86
- schema.call(@hash)
87
- end
88
-
89
- # Unfiltered, deep copy with string keys. Prefer #permit for input
90
- # that will be persisted.
91
- def to_h = deep_dup(@hash)
92
- alias to_unsafe_h to_h
93
-
94
- def ==(other)
95
- to_h == (other.is_a?(Params) ? other.to_h : other)
96
- end
97
-
98
- def inspect = "#<#{self.class.name} #{@hash.inspect}>"
99
-
100
- private
101
-
102
- def permit_nested(result, key, nested)
103
- value = @hash[key.to_s]
104
- return unless @hash.key?(key.to_s)
105
-
106
- if nested == [] # array of scalars
107
- result[key.to_sym] = value.select { |v| scalar?(v) } if value.is_a?(Array)
108
- elsif value.is_a?(Hash)
109
- result[key.to_sym] = Params.new(value).permit(*nested)
110
- elsif value.is_a?(Array) && value.all?(Hash) # array of objects
111
- result[key.to_sym] = value.map { |item| Params.new(item).permit(*nested) }
112
- end
113
- end
114
-
115
- def wrap(value)
116
- case value
117
- when Hash then Params.new(value)
118
- when Array then value.map { |v| wrap(v) }
119
- else value
120
- end
121
- end
122
-
123
- def scalar?(value) = SCALARS.any? { |type| value.is_a?(type) } || uploaded_file?(value)
124
-
125
- def uploaded_file?(value) = defined?(Rack::Multipart::UploadedFile) && value.is_a?(Rack::Multipart::UploadedFile)
126
-
127
- def blank?(value)
128
- value.nil? || (value.respond_to?(:empty?) && value.empty?) || (value.is_a?(String) && value.strip.empty?)
129
- end
130
-
131
- def deep_dup(value)
132
- case value
133
- when Hash then value.transform_values { |v| deep_dup(v) }
134
- when Array then value.map { |v| deep_dup(v) }
135
- else value
136
- end
137
- end
138
- end
139
- end
140
- end
@@ -1,64 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GemStack
4
- module HTTP
5
- # Rack::Request plus GemStack conveniences. Bodies are parsed lazily:
6
- # nothing is read from the socket until params or #json is used.
7
- class Request < Rack::Request
8
- JSON_TYPE = %r{\Aapplication/(?:[\w.+-]+\+)?json\b}i
9
-
10
- def request_id = get_header(REQUEST_ID)
11
- def path_params = get_header(PATH_PARAMS) || {}
12
- def route = get_header(ROUTE)
13
-
14
- def json?
15
- JSON_TYPE.match?(content_type.to_s)
16
- end
17
-
18
- # The parsed JSON body (any JSON value), or nil for an empty body.
19
- # Raises BadRequest for malformed JSON or excessive nesting.
20
- def json
21
- return @json if defined?(@json)
22
-
23
- raw = body&.read.to_s
24
- body&.rewind if body.respond_to?(:rewind)
25
- @json = raw.strip.empty? ? nil : codec.load(raw)
26
- rescue ::JSON::NestingError
27
- raise BadRequest.new("JSON body is nested too deeply", code: "invalid_json")
28
- rescue ::JSON::ParserError, EncodingError
29
- raise BadRequest.new("Request body is not valid JSON", code: "invalid_json")
30
- end
31
-
32
- # Parameters from the body: a JSON object, or form/multipart fields.
33
- # Non-object JSON bodies (arrays, scalars) are available via #json.
34
- def body_params
35
- @body_params ||=
36
- if json?
37
- value = json
38
- value.is_a?(Hash) ? value : {}
39
- elsif form_data? || parseable_data?
40
- self.POST
41
- else
42
- {}
43
- end
44
- end
45
-
46
- # Query, then body, then path parameters (later sources win).
47
- def all_params
48
- query = self.GET
49
- body = body_params
50
- path = path_params
51
- return query if body.empty? && path.empty?
52
-
53
- query.merge(body).merge(path)
54
- rescue Rack::QueryParser::ParameterTypeError, Rack::QueryParser::InvalidParameterError,
55
- Rack::Multipart::MultipartPartLimitError => e
56
- raise BadRequest.new(e.message, code: "invalid_parameters")
57
- end
58
-
59
- private
60
-
61
- def codec = get_header(JSON_CODEC) || JSONCodec.default
62
- end
63
- end
64
- end