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,96 @@
|
|
|
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
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
|
@@ -0,0 +1,140 @@
|
|
|
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
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module HTTP
|
|
5
|
+
# Routes requests to controllers or Rack apps (DECISIONS D-004, D-005).
|
|
6
|
+
#
|
|
7
|
+
# router = Router.new(prefix: "/api")
|
|
8
|
+
# router.draw do
|
|
9
|
+
# get "/status", to: "status#show"
|
|
10
|
+
# resources :products do
|
|
11
|
+
# member { post "/publish", action: :publish }
|
|
12
|
+
# resources :reviews, only: %i[index create]
|
|
13
|
+
# end
|
|
14
|
+
# namespace :admin do
|
|
15
|
+
# resources :orders, only: %i[index]
|
|
16
|
+
# end
|
|
17
|
+
# mount SomeRackApp, at: "/webhooks"
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# Matching: fully static paths are an O(1) hash lookup; paths with
|
|
21
|
+
# :params or a trailing *glob are matched through a per-verb segment trie.
|
|
22
|
+
class Router
|
|
23
|
+
VERBS = %w[GET POST PUT PATCH DELETE OPTIONS HEAD].freeze
|
|
24
|
+
|
|
25
|
+
Route = Struct.new(:verb, :path, :controller, :action, :app, :name, :param_names, keyword_init: true) do
|
|
26
|
+
def target = app ? app.inspect : "#{controller}##{action}"
|
|
27
|
+
def static? = param_names.empty?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class Node
|
|
31
|
+
attr_accessor :route, :param, :glob
|
|
32
|
+
|
|
33
|
+
def children = @children ||= {}
|
|
34
|
+
def child(segment) = @children&.[](segment)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
attr_reader :prefix, :routes
|
|
38
|
+
|
|
39
|
+
def initialize(prefix: "", resolver: nil)
|
|
40
|
+
@prefix = normalize(prefix)
|
|
41
|
+
@prefix = "" if @prefix == "/"
|
|
42
|
+
@resolver = resolver || method(:resolve_controller)
|
|
43
|
+
@routes = []
|
|
44
|
+
@static = {}
|
|
45
|
+
@trees = {}
|
|
46
|
+
@endpoints = {}
|
|
47
|
+
@mutex = Mutex.new
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def draw(&)
|
|
51
|
+
Mapper.new(self).instance_exec(&)
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Low-level registration; the DSL (Mapper) is built on top of it.
|
|
56
|
+
def add(verb, path, controller: nil, action: nil, app: nil, name: nil)
|
|
57
|
+
verb = verb.to_s.upcase
|
|
58
|
+
raise ArgumentError, "unknown HTTP verb #{verb}" unless VERBS.include?(verb)
|
|
59
|
+
raise ArgumentError, "route needs a controller#action or an app" unless app || (controller && action)
|
|
60
|
+
|
|
61
|
+
full = join(@prefix, path)
|
|
62
|
+
segments = split(full)
|
|
63
|
+
params = segments.filter_map { |s| s[1..] if s.start_with?(":", "*") }
|
|
64
|
+
route = Route.new(verb: verb, path: full, controller: controller&.to_s, action: action&.to_s, app: app,
|
|
65
|
+
name: name, param_names: params.freeze)
|
|
66
|
+
insert(route, segments)
|
|
67
|
+
@routes << route
|
|
68
|
+
route
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns [route, params] or nil.
|
|
72
|
+
def recognize(verb, path)
|
|
73
|
+
path = normalize(path)
|
|
74
|
+
if (route = @static[verb]&.[](path))
|
|
75
|
+
return [route, {}]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
tree = @trees[verb] or return
|
|
79
|
+
segments = split(path).map { |s| Rack::Utils.unescape_path(s) }
|
|
80
|
+
values = []
|
|
81
|
+
route = walk(tree, segments, 0, values)
|
|
82
|
+
route && [route, route.param_names.zip(values).to_h]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def allowed_verbs(path)
|
|
86
|
+
VERBS.select { |verb| recognize(verb, path) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Rack interface.
|
|
90
|
+
def call(env)
|
|
91
|
+
verb = env[Rack::REQUEST_METHOD]
|
|
92
|
+
path = env[Rack::PATH_INFO]
|
|
93
|
+
route, params = recognize(verb, path)
|
|
94
|
+
route, params = recognize("GET", path) if route.nil? && verb == "HEAD"
|
|
95
|
+
return no_route(verb, path) unless route
|
|
96
|
+
|
|
97
|
+
env[PATH_PARAMS] = params
|
|
98
|
+
env[ROUTE] = route
|
|
99
|
+
status, headers, body = dispatch(route, env)
|
|
100
|
+
if verb == "HEAD" && route.verb != "HEAD"
|
|
101
|
+
body.close if body.respond_to?(:close)
|
|
102
|
+
body = []
|
|
103
|
+
end
|
|
104
|
+
[status, headers, body]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def dispatch(route, env)
|
|
110
|
+
return route.app.call(env) if route.app
|
|
111
|
+
|
|
112
|
+
controller = @endpoints[route.controller] || @mutex.synchronize do
|
|
113
|
+
@endpoints[route.controller] ||= @resolver.call(route.controller)
|
|
114
|
+
end
|
|
115
|
+
controller.dispatch(route.action, env)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def resolve_controller(name)
|
|
119
|
+
const = "#{Inflector.camelize(name)}Controller"
|
|
120
|
+
Object.const_get(const)
|
|
121
|
+
rescue NameError => e
|
|
122
|
+
raise unless e.name.to_s == const.split("::").last || e.message.include?(const)
|
|
123
|
+
|
|
124
|
+
raise ConfigurationError, "route points to #{const}, which is not defined"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def no_route(verb, path)
|
|
128
|
+
allowed = allowed_verbs(path)
|
|
129
|
+
raise NotFound.new("No route matches #{verb} #{path}", code: "route_not_found") if allowed.empty?
|
|
130
|
+
|
|
131
|
+
allowed << "HEAD" if allowed.include?("GET") && !allowed.include?("HEAD")
|
|
132
|
+
raise MethodNotAllowed.new("#{verb} is not allowed for #{path}", headers: { "allow" => allowed.join(", ") })
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def insert(route, segments)
|
|
136
|
+
return insert_static(route) if route.static?
|
|
137
|
+
|
|
138
|
+
glob = segments.last.start_with?("*") ? segments.pop : nil
|
|
139
|
+
if segments.any? { |segment| segment.start_with?("*") }
|
|
140
|
+
raise ArgumentError, "glob must be the last segment in #{route.path}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
node = segments.inject(@trees[route.verb] ||= Node.new) do |current, segment|
|
|
144
|
+
segment.start_with?(":") ? (current.param ||= Node.new) : (current.children[segment] ||= Node.new)
|
|
145
|
+
end
|
|
146
|
+
slot = glob ? :glob : :route
|
|
147
|
+
duplicate!(route) if node.public_send(slot)
|
|
148
|
+
node.public_send(:"#{slot}=", route)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def insert_static(route)
|
|
152
|
+
static = (@static[route.verb] ||= {})
|
|
153
|
+
duplicate!(route) if static.key?(route.path)
|
|
154
|
+
static[route.path] = route
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def duplicate!(route)
|
|
158
|
+
raise ArgumentError, "duplicate route #{route.verb} #{route.path}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Depth-first: static segments beat params, params beat globs.
|
|
162
|
+
# A glob matches one or more remaining segments.
|
|
163
|
+
def walk(node, segments, index, values)
|
|
164
|
+
return node.route if index == segments.size
|
|
165
|
+
|
|
166
|
+
segment = segments[index]
|
|
167
|
+
if (child = node.child(segment)) && (found = walk(child, segments, index + 1, values))
|
|
168
|
+
return found
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
if node.param
|
|
172
|
+
values.push(segment)
|
|
173
|
+
found = walk(node.param, segments, index + 1, values)
|
|
174
|
+
return found if found
|
|
175
|
+
|
|
176
|
+
values.pop
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
finish_glob(node, segments, index, values) if node.glob
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def finish_glob(node, segments, index, values)
|
|
183
|
+
values.push(segments[index..].join("/"))
|
|
184
|
+
node.glob
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def join(prefix, path)
|
|
188
|
+
path = normalize(path)
|
|
189
|
+
return prefix.empty? ? "/" : prefix if path == "/"
|
|
190
|
+
|
|
191
|
+
prefix + path
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def normalize(path)
|
|
195
|
+
path = path.to_s
|
|
196
|
+
path = "/#{path}" unless path.start_with?("/")
|
|
197
|
+
path = path.squeeze("/")
|
|
198
|
+
path.length > 1 ? path.chomp("/") : path
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def split(path) = path.split("/").reject(&:empty?)
|
|
202
|
+
|
|
203
|
+
# The routing DSL. Kept separate from Router so the DSL's method names
|
|
204
|
+
# (get, delete, resources, ...) never collide with the router's own API.
|
|
205
|
+
class Mapper
|
|
206
|
+
RESOURCE_ACTIONS = {
|
|
207
|
+
index: [["GET", ""]],
|
|
208
|
+
create: [["POST", ""]],
|
|
209
|
+
show: [["GET", "/:id"]],
|
|
210
|
+
update: [["PATCH", "/:id"], ["PUT", "/:id"]],
|
|
211
|
+
destroy: [["DELETE", "/:id"]]
|
|
212
|
+
}.freeze
|
|
213
|
+
|
|
214
|
+
def initialize(router, path: "", module_name: nil, controller: nil)
|
|
215
|
+
@router = router
|
|
216
|
+
@path = path
|
|
217
|
+
@module = module_name
|
|
218
|
+
@controller = controller
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
VERBS.each do |verb|
|
|
222
|
+
define_method(verb.downcase) do |path, to: nil, action: nil, controller: nil, as: nil|
|
|
223
|
+
map(verb, path, to: to, action: action, controller: controller, as: as)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def scope(path = "", module: nil, &)
|
|
228
|
+
Mapper.new(@router, path: @path + clean(path), module_name: join_module(binding.local_variable_get(:module)),
|
|
229
|
+
controller: @controller).instance_exec(&)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def namespace(name, &)
|
|
233
|
+
scope("/#{name}", module: name.to_s, &)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# A conventional REST resource: index, create, show, update, destroy.
|
|
237
|
+
def resources(name, only: nil, except: nil, path: nil, controller: nil, &block)
|
|
238
|
+
actions = RESOURCE_ACTIONS.keys
|
|
239
|
+
actions &= Array(only).map(&:to_sym) if only
|
|
240
|
+
actions -= Array(except).map(&:to_sym) if except
|
|
241
|
+
base = @path + (path ? clean(path) : "/#{Inflector.dasherize(name)}")
|
|
242
|
+
controller_name = qualify(controller || name.to_s)
|
|
243
|
+
|
|
244
|
+
actions.each do |action|
|
|
245
|
+
RESOURCE_ACTIONS.fetch(action).each do |verb, suffix|
|
|
246
|
+
@router.add(verb, base + suffix, controller: controller_name, action: action,
|
|
247
|
+
name: route_name(name, action))
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
return unless block
|
|
251
|
+
|
|
252
|
+
ResourceMapper.new(@router, base: base, controller: controller_name, module_name: @module,
|
|
253
|
+
param: "#{Inflector.singularize(name.to_s)}_id").instance_exec(&block)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Mounts any Rack app under a path. The app receives the full path.
|
|
257
|
+
def mount(app, at:)
|
|
258
|
+
base = @path + clean(at)
|
|
259
|
+
VERBS.each do |verb|
|
|
260
|
+
@router.add(verb, base, app: app)
|
|
261
|
+
@router.add(verb, "#{base}/*path", app: app)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
private
|
|
266
|
+
|
|
267
|
+
def map(verb, path, to:, action:, controller:, as:)
|
|
268
|
+
return @router.add(verb, @path + clean(path), app: to, name: as) if to.respond_to?(:call)
|
|
269
|
+
|
|
270
|
+
if to
|
|
271
|
+
controller, action = to.to_s.split("#", 2)
|
|
272
|
+
raise ArgumentError, "to: must look like \"controller#action\", got #{to.inspect}" unless action
|
|
273
|
+
end
|
|
274
|
+
controller = controller ? qualify(controller.to_s) : @controller
|
|
275
|
+
raise ArgumentError, "route #{verb} #{path} needs to: \"controller#action\"" unless controller && action
|
|
276
|
+
|
|
277
|
+
@router.add(verb, @path + clean(path), controller: controller, action: action, name: as)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def qualify(controller) = @module && !controller.include?("/") ? "#{@module}/#{controller}" : controller
|
|
281
|
+
def join_module(mod) = [@module, mod].compact.join("/").then { |m| m.empty? ? nil : m }
|
|
282
|
+
def clean(path) = path.to_s.empty? || path == "/" ? "" : "/#{path.to_s.delete_prefix("/").chomp("/")}"
|
|
283
|
+
|
|
284
|
+
def route_name(name, action)
|
|
285
|
+
singular = Inflector.singularize(name.to_s)
|
|
286
|
+
{ index: name.to_s, show: singular }[action]&.then { |n| [@module&.tr("/", "_"), n].compact.join("_") }
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# DSL inside a `resources` block: member/collection routes and nesting.
|
|
291
|
+
class ResourceMapper < Mapper
|
|
292
|
+
def initialize(router, base:, controller:, module_name:, param:)
|
|
293
|
+
super(router, path: base, module_name: module_name, controller: controller)
|
|
294
|
+
@base = base
|
|
295
|
+
@param = param
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Routes on a single record: /products/:id/<path>
|
|
299
|
+
def member(&)
|
|
300
|
+
Mapper.new(@router, path: "#{@base}/:id", module_name: @module, controller: @controller).instance_exec(&)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Routes on the collection: /products/<path>
|
|
304
|
+
def collection(&)
|
|
305
|
+
Mapper.new(@router, path: @base, module_name: @module, controller: @controller).instance_exec(&)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Nested resources: /products/:product_id/reviews
|
|
309
|
+
def resources(name, **, &)
|
|
310
|
+
Mapper.new(@router, path: "#{@base}/:#{@param}", module_name: @module).resources(name, **, &)
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack"
|
|
4
|
+
require "gemstack/core"
|
|
5
|
+
require "gemstack/schema"
|
|
6
|
+
|
|
7
|
+
module GemStack
|
|
8
|
+
# The API layer: a Rack application composed of a middleware stack and a
|
|
9
|
+
# router that dispatches to controllers.
|
|
10
|
+
module HTTP
|
|
11
|
+
# Rack env keys owned by GemStack.
|
|
12
|
+
REQUEST_ID = "gemstack.request_id"
|
|
13
|
+
PATH_PARAMS = "gemstack.path_params"
|
|
14
|
+
JSON_CODEC = "gemstack.json"
|
|
15
|
+
ROUTE = "gemstack.route"
|
|
16
|
+
CONFIG = "gemstack.http_config"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
require_relative "http/json_codec"
|
|
21
|
+
require_relative "http/error_renderer"
|
|
22
|
+
require_relative "http/error_page"
|
|
23
|
+
require_relative "http/middleware_stack"
|
|
24
|
+
require_relative "http/middleware/request_id"
|
|
25
|
+
require_relative "http/middleware/request_logger"
|
|
26
|
+
require_relative "http/middleware/compression"
|
|
27
|
+
require_relative "http/middleware/error_handler"
|
|
28
|
+
require_relative "http/middleware/security_headers"
|
|
29
|
+
require_relative "http/middleware/cors"
|
|
30
|
+
require_relative "http/middleware/body_limit"
|
|
31
|
+
require_relative "http/middleware/health_check"
|
|
32
|
+
require_relative "http/middleware/etags"
|
|
33
|
+
require_relative "http/config"
|
|
34
|
+
require_relative "http/request"
|
|
35
|
+
require_relative "http/params"
|
|
36
|
+
require_relative "http/router"
|
|
37
|
+
require_relative "http/page"
|
|
38
|
+
require_relative "http/controller"
|
|
39
|
+
require_relative "http/app"
|
|
40
|
+
|
|
41
|
+
GemStack::Config.namespace(:http, GemStack::HTTP::Config)
|