mk_framework 0.2.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.
data/docs/upgrading.md ADDED
@@ -0,0 +1,124 @@
1
+ # Upgrading from the prototype
2
+
3
+ 0.2.0 changes the Ruby API while retaining the original POST mutation URLs by
4
+ default. Upgrade application code before using the new gem. The six samples have
5
+ already been migrated.
6
+
7
+ ## Application boot and class names
8
+
9
+ Place models, controllers, handlers, and the app in an application module. In each
10
+ file, declare that module explicitly; `require` does not inherit its caller's
11
+ lexical namespace. Configure `root: __dir__` and `namespace: YourApp` in the app,
12
+ and call `YourApp::App.boot!` after its class definition.
13
+
14
+ Models must be loaded before boot. MK loads action files relative to the configured
15
+ root and resolves action classes there. `boot!` validates and freezes the app;
16
+ calling `app` before boot is an error. Configure middleware and plugins beforehand.
17
+ Restart the process to pick up source changes.
18
+
19
+ The samples now expose `SampleApp1::App` through `SampleApp6::App` instead of global
20
+ `TodoApp`, `BlogApp`, `KanbanApp`, and `WeatherApp` classes. Their model datasets are
21
+ explicit, so multiple sample apps can coexist without sharing constants or data.
22
+
23
+ ## Automatic action persistence and raw handler data
24
+
25
+ Previously, handlers interpreted class-name suffixes and saved/deleted the object
26
+ returned by a controller. Remove handler `success`/`error` registration blocks.
27
+ Controllers return the prepared record; framework dispatch persists it according
28
+ to the registered action and converts it to raw attributes before the handler:
29
+
30
+ ```ruby
31
+ # Controller
32
+ route do |r|
33
+ Post.new(r.input.permit(title: String))
34
+ end
35
+
36
+ # Handler
37
+ handler do |r|
38
+ r.response.status = 201
39
+ {post: fields(model, :id, :title)}
40
+ end
41
+ ```
42
+
43
+ Require `mk_framework/sequel` for this lifecycle. Sequel is optional and must be
44
+ listed in your application's Gemfile. Create/update results receive `save` then
45
+ `values`; delete results receive `destroy` then `values`; show/index results are
46
+ converted without writes. Remove explicit `persist`, `save`, and `destroy` calls
47
+ from standard controllers to avoid duplicate writes. For explicit multi-record
48
+ transactions, return raw data after completing the writes. Custom actions do not
49
+ automatically persist records.
50
+
51
+ Handlers receive raw hashes/arrays, including materialized nested results. Replace
52
+ model attribute/association methods with hash access and allowlist filtering.
53
+ Select associations in controllers. A handler does not query or write under any
54
+ action name. Validation uses 422 for
55
+ both create and update; expected constraint conflicts use 409. Unexpected failures
56
+ are sanitized 500s. Deliberate `MK::Error` messages are public.
57
+
58
+ The old `route` declaration in a handler remains an alias for `handler`, but it
59
+ does not move persistence into handlers. Handlers return Hash/Array responses rather
60
+ than calling `to_json`. For an empty success, use `r.halt(204)` in the handler.
61
+
62
+ ## Resource declarations
63
+
64
+ Replace `register_nested_resource` with a resource tree:
65
+
66
+ ```ruby
67
+ resource_routes do
68
+ resources :posts do
69
+ resources :comments
70
+ end
71
+ # Optional compatibility URLs for the old shallow member endpoints:
72
+ resources :comments, only: %i[show update delete]
73
+ end
74
+ ```
75
+
76
+ This exposes fully nested CRUD plus the explicitly requested shallow members.
77
+ For exclusively shallow members, instead use `resources :comments, shallow: true`
78
+ inside the parent. There are no implicit parentless comment collections.
79
+
80
+ Use `r.path_params` for ancestor and member IDs. Existing `r.params['id']` remains
81
+ supported, but `r.input` deliberately contains only query/body input. `r.params`
82
+ gives URL IDs precedence. Scope all nested member lookups through their parent.
83
+
84
+ PATCH, PUT, and DELETE now work. POST update/delete aliases remain on by default;
85
+ turn them off using `configure legacy_post_routes: false` when clients migrate.
86
+
87
+ ## Database migration
88
+
89
+ Server boot no longer creates tables. On a new database, run the sample's
90
+ `bundle exec rake db:migrate` before loading its app. Tests use their own in-memory
91
+ databases and run migrations there.
92
+
93
+ Do not run the initial migration blindly against a populated prototype database:
94
+ the existing tables will cause it to fail rather than be silently adopted or
95
+ replaced. Back up that database, compare its schema with `db/migrations/001_initial.rb`,
96
+ and write an application-specific upgrade migration or import into a freshly
97
+ migrated database. Backfill null timestamps and missing defaults before adding
98
+ the new constraints. Mark the initial migration applied only after confirming
99
+ schema equivalence. No existing database is automatically altered by this upgrade.
100
+
101
+ ## Responses and clients
102
+
103
+ Lists are bounded to 25 records by default; use `limit` and `offset`. Maximum limit
104
+ is 100 and maximum offset is 10,000. Nested comments included in parent show
105
+ responses use the same bounds. Adapt clients that previously expected every row.
106
+
107
+ The weather API uses `OPENWEATHERMAP_API_KEY`, not a file in the user's home. Its
108
+ response field is now `forecast`, containing eight three-hour periods, replacing
109
+ the inaccurate `hourly_forecast` field. Times include a timezone. Upstream failures
110
+ are sanitized 502 responses; an unknown location is 404; a missing key is 503.
111
+
112
+ Missing-resource responses consistently use an `error` field. Unexpected error
113
+ responses also include a request ID. Do not depend on internal exception messages.
114
+
115
+ ## Tests and dependencies
116
+
117
+ Use `bundle exec rake` at the repository root to test the framework and every
118
+ sample in isolated processes. Child failures fail the aggregate task. Sample tests
119
+ force `RACK_ENV=test`; they do not open development databases or `DATABASE_URL`.
120
+
121
+ Install the updated bundles with Ruby 3.2 or later and the locked Bundler version.
122
+ The lockfiles include updates to Roda, Rack, Sequel, SQLite3, Puma, and test tools.
123
+ Puma moved from 6.x to 8.x; review your own server configuration when upgrading.
124
+ See `docs/deployment.md` for deployment and release checks.
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MK
4
+ class Application < Roda
5
+ plugin :all_verbs
6
+ plugin :json
7
+ plugin :halt
8
+ plugin :head
9
+ plugin :json_parser,
10
+ parser: ->(body) { value = JSON.parse(body); raise BadRequest unless value.is_a?(Hash); value },
11
+ error_handler: ->(_request) { raise BadRequest, 'Expected a valid JSON object' }
12
+ plugin RequestPlugin
13
+ plugin :error_handler do |error|
14
+ public_error = error.is_a?(MK::Error)
15
+ response.status = public_error ? error.status : 500
16
+ details = ErrorDetails.details(error, request, self.class.settings)
17
+ begin
18
+ logger.error(JSON.generate(details)) unless public_error
19
+ rescue StandardError
20
+ # A failed log sink must not break the error response.
21
+ end
22
+ body = {error: public_error ? error.message : 'Server error', request_id: request.env['mk.request_id']}
23
+ body[:details] = ErrorDetails.filter(error.details, self.class.settings[:filter_parameters]) if public_error && error.details
24
+ body[:debug] = details if self.class.settings[:environment] == 'development'
25
+ body
26
+ end
27
+
28
+ class << self
29
+ attr_reader :settings, :router
30
+
31
+ def inherited(subclass)
32
+ super
33
+ defaults = @settings || {
34
+ environment: ENV.fetch('RACK_ENV', 'production'), root: nil, namespace: nil,
35
+ routes_path: 'routes', legacy_post_routes: true, max_body_bytes: 1_048_576,
36
+ page_size: 25, max_page_size: 100, max_offset: 10_000,
37
+ filter_parameters: ErrorDetails::FILTER_KEYS, logger: Logger.new($stdout)
38
+ }
39
+ subclass.instance_variable_set(:@settings, defaults.dup)
40
+ subclass.instance_variable_set(:@route_definitions, @route_definitions)
41
+ subclass.instance_variable_set(:@request_hooks, Array(@request_hooks).dup)
42
+ subclass.instance_variable_set(:@booted, false)
43
+ end
44
+
45
+ def configure(**options)
46
+ raise ConfigurationError, 'Configure the application before boot!' if @booted
47
+ unknown = options.keys - @settings.keys
48
+ raise ConfigurationError, "Unknown settings: #{unknown.join(', ')}" unless unknown.empty?
49
+
50
+ @settings.merge!(options)
51
+ end
52
+
53
+ def setup_logger(destination = $stdout)
54
+ configure(logger: Logger.new(destination))
55
+ logger
56
+ end
57
+
58
+ def logger = settings.fetch(:logger)
59
+
60
+ def resource_routes(&block)
61
+ raise ConfigurationError, 'Define resources before boot!' if @booted
62
+
63
+ @route_definitions = block
64
+ end
65
+
66
+ # Runs for generated and ordinary Roda routes, including mounted apps.
67
+ def before_request(&block)
68
+ raise ConfigurationError, 'Define hooks before boot!' if @booted
69
+
70
+ @request_hooks << block
71
+ end
72
+
73
+ def request_hooks = @request_hooks
74
+
75
+ def boot!
76
+ return self if @booted
77
+ raise ConfigurationError, 'Set an absolute application root with configure(root: __dir__, namespace: MyApp)' unless settings[:root] && File.absolute_path(settings[:root]) == settings[:root]
78
+ raise ConfigurationError, 'Set namespace to the module containing your controllers and handlers' unless settings[:namespace].is_a?(Module)
79
+ %i[max_body_bytes page_size max_page_size max_offset].each do |key|
80
+ raise ConfigurationError, "#{key} must be a positive integer" unless settings[key].is_a?(Integer) && settings[key].positive?
81
+ end
82
+ raise ConfigurationError, 'page_size exceeds max_page_size' if settings[:page_size] > settings[:max_page_size]
83
+
84
+ routes_path = File.expand_path(settings[:routes_path], settings[:root])
85
+ Dir.glob(File.join(routes_path, '**', '*.rb')).sort.each { |file| require file }
86
+ definitions = Routes.new(namespace: settings[:namespace], legacy: settings[:legacy_post_routes])
87
+ if @route_definitions
88
+ definitions.instance_eval(&@route_definitions)
89
+ else
90
+ Dir.glob(File.join(routes_path, '*')).sort.select { |path| File.directory?(path) }.each do |path|
91
+ actions = Dir.glob(File.join(path, 'controllers', '*.rb')).map { |file| File.basename(file, '.rb').to_sym }
92
+ actions.select! { |action| Routes::ACTIONS.key?(action) }
93
+ definitions.resources(File.basename(path), only: actions)
94
+ end
95
+ end
96
+ @router = Router.new(definitions.endpoints)
97
+ @settings[:filter_parameters] = (ErrorDetails::FILTER_KEYS + Array(settings[:filter_parameters])).map { |key| key.to_s.freeze }.uniq.freeze
98
+ @settings.freeze
99
+ @request_hooks.freeze
100
+ custom_route = route_block
101
+ use RequestBoundary, settings
102
+ route do |r|
103
+ self.class.request_hooks.each { |hook| instance_exec(r, &hook) }
104
+ if custom_route
105
+ result = instance_exec(r, &custom_route)
106
+ r.halt(response.status || 200, result) unless result.nil?
107
+ end
108
+ r.root { {message: 'Welcome to MK Framework'} }
109
+ self.class.router.call(r, self)
110
+ end
111
+ @booted = true
112
+ freeze
113
+ end
114
+
115
+ def app
116
+ raise ConfigurationError, 'Call boot! after defining your application' unless @booted
117
+
118
+ super
119
+ end
120
+
121
+ def route_table
122
+ router.endpoints.map { |entry| "#{entry[:verb].ljust(6)} /#{entry[:path].map { |part| part.is_a?(Symbol) ? ":#{part}" : part }.join('/')} -> #{entry[:controller]} / #{entry[:handler]}" }
123
+ end
124
+ end
125
+
126
+ def logger = self.class.logger
127
+
128
+ def dispatch(endpoint, request)
129
+ value = endpoint[:controller].new.execute(request)
130
+ raise NotFound, "#{endpoint[:label]} not found" if value.nil?
131
+
132
+ value = prepare_result(value, action: endpoint[:action])
133
+ result = endpoint[:handler].new(value).execute(request)
134
+ unless result.is_a?(Hash) || result.is_a?(Array)
135
+ raise ConfigurationError, 'Handlers must return a Hash or Array, or halt with an explicit response'
136
+ end
137
+ result
138
+ end
139
+
140
+ private
141
+
142
+ # The optional Sequel integration supplies the record lifecycle here.
143
+ def prepare_result(value, action:)
144
+ value
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MK
4
+ class Controller
5
+ def self.route(&block)
6
+ define_method(:route_block) { block }
7
+ end
8
+
9
+ def execute(request)
10
+ instance_exec(request, &route_block)
11
+ end
12
+ end
13
+
14
+ class Handler
15
+ attr_reader :model
16
+
17
+ def self.handler(&block)
18
+ define_method(:handler_block) { block }
19
+ end
20
+
21
+ # Old applications used `route` for response blocks.
22
+ class << self
23
+ alias route handler
24
+ end
25
+
26
+ def initialize(value)
27
+ @model = value
28
+ end
29
+
30
+ def execute(request)
31
+ instance_exec(request, &handler_block)
32
+ end
33
+
34
+ # Select fields explicitly instead of exposing future database columns.
35
+ def fields(record, *names)
36
+ names.to_h { |name| [name, record[name]] }
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MK
4
+ class ConfigurationError < StandardError; end
5
+
6
+ class Error < StandardError
7
+ STATUS = 500
8
+ MESSAGE = 'Server error'
9
+ attr_reader :details
10
+
11
+ def initialize(message = self.class::MESSAGE, details: nil)
12
+ super(message)
13
+ @details = details
14
+ end
15
+
16
+ def status = self.class::STATUS
17
+ end
18
+
19
+ class BadRequest < Error
20
+ STATUS = 400
21
+ MESSAGE = 'Bad request'
22
+ end
23
+
24
+ class Unauthorized < Error
25
+ STATUS = 401
26
+ MESSAGE = 'Unauthorized'
27
+ end
28
+
29
+ class Forbidden < Error
30
+ STATUS = 403
31
+ MESSAGE = 'Forbidden'
32
+ end
33
+
34
+ class NotFound < Error
35
+ STATUS = 404
36
+ MESSAGE = 'Not Found'
37
+ end
38
+
39
+ class Conflict < Error
40
+ STATUS = 409
41
+ MESSAGE = 'Conflict'
42
+ end
43
+
44
+ class ValidationError < Error
45
+ STATUS = 422
46
+ MESSAGE = 'Validation failed'
47
+ end
48
+
49
+ class BadGateway < Error
50
+ STATUS = 502
51
+ MESSAGE = 'Upstream service unavailable'
52
+ end
53
+
54
+ module ErrorDetails
55
+ FILTER_KEYS = %w[password secret token authorization cookie api_key apikey credential].freeze
56
+
57
+ def self.filter(value, keys = FILTER_KEYS, depth = 0)
58
+ return '[TRUNCATED]' if depth > 10
59
+
60
+ case value
61
+ when Hash
62
+ value.to_h do |key, item|
63
+ sensitive = keys.any? { |pattern| key.to_s.downcase.include?(pattern.to_s.downcase) }
64
+ [key, sensitive ? '[FILTERED]' : filter(item, keys, depth + 1)]
65
+ end
66
+ when Array
67
+ value.map { |item| filter(item, keys, depth + 1) }
68
+ else
69
+ value
70
+ end
71
+ end
72
+
73
+ # Never parse input while reporting an error: parsing may be what failed.
74
+ # SQL exception messages can contain secrets; omit them in production.
75
+ def self.details(error, request, settings)
76
+ details = {
77
+ request_id: request.env['mk.request_id'],
78
+ method: request.request_method,
79
+ path: request.path,
80
+ error_class: error.class.name,
81
+ params: filter(request.env.fetch('mk.input_params', {}), settings[:filter_parameters]),
82
+ backtrace: Array(error.backtrace).first(30)
83
+ }
84
+ details[:message] = error.message if settings[:environment] == 'development'
85
+ details
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MK
4
+ # Enforce the limit even for chunked bodies with no Content-Length header.
5
+ class RequestBoundary
6
+ def initialize(app, settings)
7
+ @app, @settings = app, settings
8
+ end
9
+
10
+ def call(env)
11
+ env['mk.request_id'] = SecureRandom.uuid
12
+ if (input = env['rack.input'])
13
+ body = +''
14
+ while body.bytesize <= @settings[:max_body_bytes]
15
+ chunk = input.read(@settings[:max_body_bytes] + 1 - body.bytesize)
16
+ break if chunk.nil? || chunk.empty?
17
+
18
+ body << chunk
19
+ end
20
+ if body.bytesize > @settings[:max_body_bytes]
21
+ json = JSON.generate(error: 'Request body too large', request_id: env['mk.request_id'])
22
+ return [413, {'content-type' => 'application/json', 'content-length' => json.bytesize.to_s,
23
+ 'x-request-id' => env['mk.request_id']}, env['REQUEST_METHOD'] == 'HEAD' ? [] : [json]]
24
+ end
25
+ env['rack.input'] = StringIO.new(body)
26
+ end
27
+ status, headers, body = @app.call(env)
28
+ [status, headers.merge('x-request-id' => env['mk.request_id']), body]
29
+ end
30
+ end
31
+
32
+ class Input
33
+ def initialize(params)
34
+ @params = params
35
+ end
36
+
37
+ def require(name, type: String)
38
+ raise BadRequest, "Missing parameter: #{name}" unless @params.key?(name.to_s)
39
+
40
+ cast(name, @params.fetch(name.to_s), type)
41
+ end
42
+
43
+ # Only explicitly listed fields reach a model. Unknown fields are ignored.
44
+ def permit(**fields)
45
+ fields.each_with_object({}) do |(name, type), values|
46
+ values[name] = cast(name, @params[name.to_s], type) if @params.key?(name.to_s)
47
+ end
48
+ end
49
+
50
+ def integer(name, default:, min: 0, max: nil)
51
+ value = @params.fetch(name.to_s, default)
52
+ valid = value.is_a?(Integer) || (value.is_a?(String) && value.match?(/\A[0-9]+\z/))
53
+ raise BadRequest, "Invalid parameter: #{name}" unless valid
54
+
55
+ value = Integer(value)
56
+ raise BadRequest, "Invalid parameter: #{name}" if value < min || (max && value > max)
57
+
58
+ value
59
+ end
60
+
61
+ private
62
+
63
+ def cast(name, value, type)
64
+ if type == :boolean
65
+ return true if [true, 'true', '1'].include?(value)
66
+ return false if [false, 'false', '0'].include?(value)
67
+ elsif Array(type).any? { |klass| klass === value }
68
+ return value
69
+ end
70
+ raise BadRequest, "Invalid parameter: #{name}"
71
+ end
72
+ end
73
+
74
+ module RequestPlugin
75
+ module RequestMethods
76
+ EMPTY_PARAMS = {}.freeze
77
+ def path_params = env.fetch('mk.path_params', EMPTY_PARAMS)
78
+
79
+ # Compatibility for existing controllers; route identifiers always win.
80
+ def params
81
+ input_params = super
82
+ env['mk.input_params'] = input_params
83
+ input_params.merge(path_params.transform_keys(&:to_s))
84
+ rescue Rack::QueryParser::ParameterTypeError, Rack::QueryParser::InvalidParameterError, Rack::QueryParser::ParamsTooDeepError
85
+ raise BadRequest, 'Invalid request parameters'
86
+ end
87
+
88
+ def input
89
+ params
90
+ Input.new(env.fetch('mk.input_params'))
91
+ end
92
+
93
+ def page
94
+ settings = roda_class.settings
95
+ {
96
+ limit: input.integer(:limit, default: settings[:page_size], min: 1, max: settings[:max_page_size]),
97
+ offset: input.integer(:offset, default: 0, max: settings[:max_offset])
98
+ }
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MK
4
+ class Routes
5
+ ACTIONS = {index: ['GET', false], create: ['POST', false], show: ['GET', true],
6
+ update: ['PATCH', true], delete: ['DELETE', true]}.freeze
7
+ attr_reader :endpoints
8
+
9
+ def initialize(namespace:, legacy:, endpoints: [], prefix: [], scope_prefix: [])
10
+ @namespace, @legacy, @endpoints = namespace, legacy, endpoints
11
+ @prefix, @scope_prefix = prefix, scope_prefix
12
+ end
13
+
14
+ def self.camelize(name) = name.to_s.split('_').map(&:capitalize).join
15
+
16
+ def self.singularize(name)
17
+ irregular = {'people' => 'person', 'children' => 'child', 'men' => 'man', 'women' => 'woman'}
18
+ irregular.fetch(name) do
19
+ name.end_with?('ies') ? "#{name[0...-3]}y" : name.sub(/(?<!s)s\z/, '')
20
+ end
21
+ end
22
+
23
+ def scope(path, &block)
24
+ parts = path.to_s.split('/').reject(&:empty?)
25
+ self.class.new(namespace: @namespace, legacy: @legacy, endpoints: @endpoints,
26
+ prefix: @prefix + parts, scope_prefix: @scope_prefix + parts).instance_eval(&block)
27
+ end
28
+
29
+ def namespace(name, &block)
30
+ mod = @namespace.const_get(Routes.camelize(name), false)
31
+ Routes.new(namespace: mod, legacy: @legacy, endpoints: @endpoints,
32
+ prefix: @prefix + [name.to_s], scope_prefix: @scope_prefix + [name.to_s]).instance_eval(&block)
33
+ end
34
+
35
+ def resources(name, only: ACTIONS.keys, singular: nil, param: :id, parent_key: nil,
36
+ shallow: false, namespace: @namespace, actions: {}, legacy: @legacy, &block)
37
+ name = name.to_s
38
+ raise ConfigurationError, "Invalid resource path: #{name}" unless name.match?(/\A[a-zA-Z0-9_-]+\z/)
39
+
40
+ singular ||= Routes.singularize(name)
41
+ collection_path = @prefix + [name]
42
+ member_path = (shallow ? @scope_prefix + [name] : collection_path) + [param.to_sym]
43
+ child_path = member_path[0...-1] + [(parent_key || "#{singular}_id").to_sym]
44
+ resource = ResourceRoutes.new(namespace: namespace, legacy: legacy, endpoints: @endpoints,
45
+ prefix: child_path, scope_prefix: @scope_prefix)
46
+ resource.collection_path, resource.member_path = collection_path, member_path
47
+ resource.resource_name, resource.label = name, Routes.camelize(singular)
48
+ only.each do |action|
49
+ verb, member = ACTIONS.fetch(action.to_sym) { raise ConfigurationError, "Unknown action: #{action}" }
50
+ pair = actions[action.to_sym]
51
+ resource.add(verb, member ? member_path : collection_path, action,
52
+ controller: pair&.fetch(0), handler: pair&.fetch(1))
53
+ if action.to_sym == :update
54
+ resource.add('PUT', member_path, action, controller: pair&.fetch(0), handler: pair&.fetch(1))
55
+ end
56
+ if legacy && action.to_sym == :update
57
+ resource.add('POST', member_path, action, controller: pair&.fetch(0), handler: pair&.fetch(1))
58
+ elsif legacy && action.to_sym == :delete
59
+ resource.add('POST', member_path + ['delete'], action, controller: pair&.fetch(0), handler: pair&.fetch(1))
60
+ end
61
+ end
62
+ resource.instance_eval(&block) if block
63
+ end
64
+ end
65
+
66
+ class ResourceRoutes < Routes
67
+ attr_accessor :collection_path, :member_path, :resource_name, :label
68
+
69
+ def member(action, via:, controller: nil, handler: nil)
70
+ add(via, member_path + [action.to_s], action, controller: controller, handler: handler)
71
+ end
72
+
73
+ def collection(action, via:, controller: nil, handler: nil)
74
+ add(via, collection_path + [action.to_s], action, controller: controller, handler: handler)
75
+ end
76
+
77
+ def add(verb, path, action, controller:, handler:)
78
+ prefix = "#{Routes.camelize(resource_name)}#{Routes.camelize(action)}"
79
+ controller ||= @namespace.const_get("#{prefix}Controller", false)
80
+ handler ||= @namespace.const_get("#{prefix}Handler", false)
81
+ unless controller <= Controller && controller.method_defined?(:route_block)
82
+ raise ConfigurationError, "#{controller} must define a controller route block"
83
+ end
84
+ unless handler <= Handler && handler.method_defined?(:handler_block)
85
+ raise ConfigurationError, "#{handler} must define a handler block"
86
+ end
87
+ names = path.grep(Symbol)
88
+ raise ConfigurationError, "Duplicate path parameters in #{path.inspect}; set parent_key" unless names.uniq == names
89
+
90
+ path = path.map { |part| part.is_a?(String) ? part.dup.freeze : part }.freeze
91
+ @endpoints << {verb: verb.to_s.upcase.freeze, path: path, action: action.to_sym,
92
+ controller: controller, handler: handler, label: label.freeze, params: names.freeze}.freeze
93
+ rescue NameError => error
94
+ raise ConfigurationError, "Missing action for #{resource_name}.#{action}: #{error.message}"
95
+ end
96
+ end
97
+
98
+ # A compiled trie. Literal branches take precedence over parameter captures.
99
+ # Matching still uses Roda, including its exact-path and halt semantics.
100
+ class Router
101
+ Node = Struct.new(:literals, :dynamic, :methods, keyword_init: true)
102
+ attr_reader :endpoints
103
+
104
+ def initialize(endpoints)
105
+ @endpoints = endpoints.freeze
106
+ @root = new_node
107
+ endpoints.each do |endpoint|
108
+ node = endpoint[:path].inject(@root) do |branch, segment|
109
+ segment.is_a?(Symbol) ? (branch.dynamic ||= new_node) : (branch.literals[segment] ||= new_node)
110
+ end
111
+ verb = endpoint[:verb]
112
+ raise ConfigurationError, "Duplicate route: #{verb} /#{endpoint[:path].join('/')}" if node.methods.key?(verb)
113
+
114
+ node.methods[verb] = endpoint
115
+ end
116
+ freeze_node(@root)
117
+ freeze
118
+ end
119
+
120
+ def call(request, application)
121
+ walk(@root, request, application, [])
122
+ end
123
+
124
+ private
125
+
126
+ def new_node = Node.new(literals: {}, methods: {})
127
+
128
+ def freeze_node(node)
129
+ node.literals.each_value { |child| freeze_node(child) }
130
+ freeze_node(node.dynamic) if node.dynamic
131
+ node.literals.freeze
132
+ node.methods.freeze
133
+ node.freeze
134
+ end
135
+
136
+ def walk(node, request, application, captures)
137
+ request.is do
138
+ request.halt(404, {error: 'Not Found'}) if node.methods.empty?
139
+
140
+ verb = request.head? ? 'GET' : request.request_method
141
+ endpoint = node.methods[verb]
142
+ unless endpoint
143
+ allowed = node.methods.keys
144
+ allowed += ['HEAD'] if allowed.include?('GET')
145
+ request.response['allow'] = allowed.sort.join(', ')
146
+ request.halt(405, {error: 'Method not allowed'})
147
+ end
148
+ request.env['mk.path_params'] = endpoint[:params].zip(captures).to_h.freeze
149
+ request.params # Validate the body before controller side effects.
150
+ application.dispatch(endpoint, request)
151
+ end
152
+
153
+ request.on String do |segment|
154
+ if (child = node.literals[segment])
155
+ walk(child, request, application, captures)
156
+ elsif node.dynamic
157
+ walk(node.dynamic, request, application, captures + [segment.freeze])
158
+ else
159
+ request.halt(404, {error: 'Not Found'})
160
+ end
161
+ end
162
+ request.halt(404, {error: 'Not Found'})
163
+ end
164
+ end
165
+ end