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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +33 -0
- data/LICENSE +21 -0
- data/README.md +225 -0
- data/docs/deployment.md +115 -0
- data/docs/routing.md +160 -0
- data/docs/upgrading.md +124 -0
- data/lib/mk_framework/application.rb +147 -0
- data/lib/mk_framework/controller.rb +39 -0
- data/lib/mk_framework/errors.rb +88 -0
- data/lib/mk_framework/request.rb +102 -0
- data/lib/mk_framework/router.rb +165 -0
- data/lib/mk_framework/sequel.rb +56 -0
- data/lib/mk_framework/testing.rb +36 -0
- data/lib/mk_framework/version.rb +5 -0
- data/lib/mk_framework.rb +13 -0
- metadata +137 -0
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
|