feat: extend application with module_stack and name_prefix_stack

This commit is contained in:
dualfroz committed 2026-09-06 16:20:32 +02:00
1 parent feaef3ad38
commit b5ea16fa2f
1 file changed
+175 -15
+175 -15
View File
@@ -23,6 +23,14 @@ module Prism
@prefix_stack ||= [] @prefix_stack ||= []
end end
def module_stack
@module_stack ||= []
end
def name_prefix_stack
@name_prefix_stack ||= []
end
def settings def settings
@settings ||= { views: 'views' } @settings ||= { views: 'views' }
end end
@@ -36,9 +44,64 @@ module Prism
reset_stack! reset_stack!
end end
def enable_sessions(secret:, **options) # `previous_secrets:` mirrors enable_attribute_encryption's rotation
# contract: cookies signed under a retired secret keep decoding, new
# ones always sign under `secret`, so rotating SESSION_SECRET no
# longer force-logs-out every visitor.
def enable_sessions(secret:, previous_secrets: [], **options)
set(:session_secret, secret) set(:session_secret, secret)
use(Middleware::Session, secret: secret, **options) set(:session_previous_secrets, previous_secrets)
use(Middleware::Session, secret: secret, previous_secrets: previous_secrets, **options)
end
# One shared secret backing every `encrypts` column across the app
# (Orm::Encryption derives an independent per-attribute key from it) -
# meant to be called once at boot, same spot as enable_sessions.
def enable_attribute_encryption(secret:, previous_secrets: [])
Orm::Encryption.primary_key = secret
Orm::Encryption.previous_keys = previous_secrets
end
# Backs every model's `generates_token_for` (Orm::TokenFor) with a
# shared secret, same spot/shape as enable_sessions and
# enable_attribute_encryption above - a model can call
# generates_token_for before this runs (it only declares the purpose/
# expiry), but actually minting or verifying a token needs this
# called once at boot.
def enable_token_signing(secret:, previous_secrets: [])
Orm::TokenFor.primary_secret = secret
Orm::TokenFor.previous_secrets = previous_secrets
end
# Turns on Bullet-lite N+1 detection (Orm::NPlusOneDetector) for every
# request this application dispatches - meant for development/test
# boot, same spot as enable_sessions/enable_mailer. `raise_on_violation:
# true` turns a detected N+1 into a raised NPlusOneViolation instead of
# a logged warning, so a test suite (or a CI-only boot flag) can fail
# hard on a perf regression instead of relying on someone reading logs.
def enable_n_plus_one_detection(threshold: 2, io: $stderr, raise_on_violation: false)
Orm::NPlusOneDetector.enable!(threshold: threshold, io: io, raise_on_violation: raise_on_violation)
end
# Wires Prism::Mailer::Base#url_for so mailer actions/templates can
# build absolute links (there is no incoming request to pull a router
# or host from inside a mailer, unlike Controller#url_for): points
# Mailer at this app's router and stashes host/protocol/port as its
# app-wide default_url_options.
def enable_mailer(host:, protocol: 'http', port: nil)
Mailer.router = router
Mailer.default_url_options = { host: host, protocol: protocol, port: port }.compact
end
# Precompiles every file under `source` into `output` (Assets::Compiler,
# fingerprinted + manifest.json), points asset_path/friends at the
# resulting manifest, and mounts Middleware::Assets to serve `output`
# back out at "/assets" with far-future caching. Meant to be called
# once at boot, same spot as enable_sessions/enable_mailer.
def enable_assets(source:, output:, prefix: '/assets')
manifest = Assets::Compiler.new(source: source, output: output).compile!
Assets.manifest = manifest
use(Middleware::Assets, root: output, prefix: prefix)
end end
def helpers(&block) def helpers(&block)
@@ -46,8 +109,9 @@ module Prism
end end
%w[get post put patch delete options head].each do |verb| %w[get post put patch delete options head].each do |verb|
define_method(verb) do |pattern, to: nil, as: nil, &block| define_method(verb) do |pattern, to: nil, as: nil, constraints: {}, format: false, &block|
router.add(verb, join_prefix(pattern), name: as, to: to, &block) router.add(verb, join_prefix(pattern), name: join_name(as), to: join_controller_action(to),
constraints: constraints, format: format, &block)
end end
end end
@@ -55,11 +119,101 @@ module Prism
get('/', to: to, as: :root) get('/', to: to, as: :root)
end end
def resources(name, only: nil, except: nil, param: :id) # Groups routes under a URL prefix (default "/name") AND a
# controller module ("name/" prepended to every "controller#action"
# target) AND a route-helper-name prefix ("name_" prepended to every
# `as:`), like Rails' `namespace`. `resources :posts` inside
# `namespace :admin` dispatches to `Admin::PostsController` and
# exposes `admin_posts_path`.
# `module: false` (Rails' escape hatch) keeps the URL prefix and the
# route-name prefix but leaves the controller lookup untouched, for
# when the URL should look namespaced without forcing every
# controller under it into a matching Ruby module/directory.
def namespace(name, path: nil, module: true, &block)
use_module = binding.local_variable_get(:module)
prefix_stack.push(path || "/#{name}")
module_stack.push(name.to_s) if use_module
name_prefix_stack.push(name.to_s)
yield
ensure
prefix_stack.pop
module_stack.pop if use_module
name_prefix_stack.pop
end
# Standard 7-action RESTful resource collection. A block enables
# nested routing: `member`/`collection` for extra actions scoped to
# this resource, and further `resources`/`resource` calls nested
# under `/name/:id_param_id`.
def resources(name, only: nil, except: nil, param: :id, constraints: {}, &block)
wanted = only || RestActions wanted = only || RestActions
wanted = wanted - Array(except) if except wanted = wanted - Array(except) if except
singular = Inflector.singularize(name.to_s) singular = Inflector.singularize(name.to_s)
(RestActions & wanted).each { |action| add_resource_route(name, action, singular, param) } # Custom collection/member/nested routes go first so a literal
# collection path (e.g. "/articles/archive") is tried before the
# generic "/articles/:id" show route it would otherwise be
# shadowed by (our router dispatches to the first match, like the
# built-in "new" action already relies on beating "show").
ResourceScope.new(self, name, singular, param).instance_eval(&block) if block
(RestActions & wanted).each { |action| add_resource_route(name, action, singular, param, constraints) }
end
# Singular resource: no id segment in the path, since it always
# refers to "the current one" (`/profile`, not `/profiles/:id`).
# Carries `format: true` on every route, same as the plural
# `resources` (`add_resource_route`) - without it a singular resource
# such as `resource :session` could never be reached at `/session.json`
# the way `/posts.json` already can, only through Accept-header
# negotiation, which left the two resource DSLs inconsistent.
def resource(name, only: nil, except: nil, &block)
wanted = only || SingularRestActions
wanted = wanted - Array(except) if except
base = "/#{name}"
controller = name.to_s
(SingularRestActions & wanted).each do |action|
case action
when :new then get("#{base}/new", to: "#{controller}#new", as: "new_#{name}", format: true)
when :create then post(base, to: "#{controller}#create", format: true)
when :show then get(base, to: "#{controller}#show", as: name.to_sym, format: true)
when :edit then get("#{base}/edit", to: "#{controller}#edit", as: "edit_#{name}", format: true)
when :update
patch(base, to: "#{controller}#update", format: true)
put(base, to: "#{controller}#update", format: true)
when :destroy then delete(base, to: "#{controller}#destroy", format: true)
end
end
ResourceScope.new(self, name, name.to_s, nil).instance_eval(&block) if block
end
# Mounts a separate Rack app (anything responding to #call(env)) under
# a URL prefix, like Rails' `mount SomeEngine, at: '/some_engine'`.
# SCRIPT_NAME/PATH_INFO are rewritten the way Rack::URLMap does, so
# the mounted app can build its own relative links from PATH_INFO
# without knowing where it was mounted.
def mount(path_prefix, app)
router.mount(join_prefix(path_prefix), app)
end
# Registers a WebSocket endpoint at `path`, handled by `handler`
# (a class exposing #on_open(connection)/#on_message(connection,
# message)/#on_close(connection), instantiated fresh per connection
# by Server). Looked up by Prism::Server directly via
# `find_websocket_handler` before the request ever reaches the
# regular Rack `call`/middleware stack, since an Upgrade: websocket
# request never gets a normal HTTP response. `max_message_size:`
# (bytes) overrides Connection::DEFAULT_MAX_MESSAGE_SIZE for this
# endpoint only, e.g. a lower cap for a plain chat feed or a higher
# one for an endpoint that legitimately pushes large payloads.
def websocket(path, handler, max_message_size: nil)
router.add_websocket(join_prefix(path), handler, max_message_size: max_message_size)
end
def find_websocket_handler(path)
router.find_websocket(path)
end
def find_websocket_options(path)
router.find_websocket_options(path)
end end
def group(prefix) def group(prefix)
@@ -92,20 +246,26 @@ module Prism
private private
RestActions = %i[index new create show edit update destroy].freeze RestActions = %i[index new create show edit update destroy].freeze
SingularRestActions = %i[new create show edit update destroy].freeze
def add_resource_route(name, action, singular, param) def add_resource_route(name, action, singular, param, constraints = {})
base = "/#{name}" base = "/#{name}"
controller = name.to_s controller = name.to_s
route_constraints = constraints.transform_keys(&:to_sym).select { |key, _| key == param.to_sym }
case action case action
when :index then router.add('GET', join_prefix(base), name: name, to: "#{controller}#index") when :index then get(base, to: "#{controller}#index", as: name, format: true)
when :new then router.add('GET', join_prefix("#{base}/new"), name: "new_#{singular}", to: "#{controller}#new") when :new then get("#{base}/new", to: "#{controller}#new", as: "new_#{singular}", format: true)
when :create then router.add('POST', join_prefix(base), to: "#{controller}#create") when :create then post(base, to: "#{controller}#create", format: true)
when :show then router.add('GET', join_prefix("#{base}/:#{param}"), name: singular, to: "#{controller}#show") when :show
when :edit then router.add('GET', join_prefix("#{base}/:#{param}/edit"), name: "edit_#{singular}", to: "#{controller}#edit") get("#{base}/:#{param}", to: "#{controller}#show", as: singular, constraints: route_constraints, format: true)
when :edit
get("#{base}/:#{param}/edit", to: "#{controller}#edit", as: "edit_#{singular}", constraints: route_constraints, format: true)
when :update when :update
router.add('PATCH', join_prefix("#{base}/:#{param}"), to: "#{controller}#update") patch("#{base}/:#{param}", to: "#{controller}#update", constraints: route_constraints, format: true)
router.add('PUT', join_prefix("#{base}/:#{param}"), to: "#{controller}#update") put("#{base}/:#{param}", to: "#{controller}#update", constraints: route_constraints, format: true)
when :destroy then router.add('DELETE', join_prefix("#{base}/:#{param}"), to: "#{controller}#destroy") when :destroy
delete("#{base}/:#{param}", to: "#{controller}#destroy", as: singular, constraints: route_constraints,
format: true)
end end
end end