gemstack-http 0.2.5 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +2 -26
- data/README.md +6 -18
- data/lib/gemstack-http.rb +5 -0
- metadata +18 -70
- data/lib/gemstack/http/app.rb +0 -30
- data/lib/gemstack/http/config.rb +0 -84
- data/lib/gemstack/http/controller.rb +0 -343
- data/lib/gemstack/http/error_page.rb +0 -111
- data/lib/gemstack/http/error_renderer.rb +0 -63
- data/lib/gemstack/http/json_codec.rb +0 -114
- data/lib/gemstack/http/middleware/body_limit.rb +0 -69
- data/lib/gemstack/http/middleware/compression.rb +0 -127
- data/lib/gemstack/http/middleware/cors.rb +0 -77
- data/lib/gemstack/http/middleware/error_handler.rb +0 -39
- data/lib/gemstack/http/middleware/etags.rb +0 -24
- data/lib/gemstack/http/middleware/health_check.rb +0 -28
- data/lib/gemstack/http/middleware/request_id.rb +0 -31
- data/lib/gemstack/http/middleware/request_logger.rb +0 -39
- data/lib/gemstack/http/middleware/security_headers.rb +0 -31
- data/lib/gemstack/http/middleware_stack.rb +0 -96
- data/lib/gemstack/http/page.rb +0 -36
- data/lib/gemstack/http/params.rb +0 -140
- data/lib/gemstack/http/request.rb +0 -64
- data/lib/gemstack/http/router.rb +0 -315
- data/lib/gemstack/http.rb +0 -41
|
@@ -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
|
data/lib/gemstack/http/page.rb
DELETED
|
@@ -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
|
-
# (docs/pagination.md):
|
|
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
|
data/lib/gemstack/http/params.rb
DELETED
|
@@ -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
|