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.
@@ -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)