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,315 +0,0 @@
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
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)