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
data/lib/gemstack/http/router.rb
DELETED
|
@@ -1,315 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module GemStack
|
|
4
|
-
module HTTP
|
|
5
|
-
# Routes requests to controllers or Rack apps.
|
|
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
|
data/lib/gemstack/http.rb
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
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)
|