feat: extend application with join_controller and join_controller_action

This commit is contained in:
dualfroz committed 2026-09-06 16:26:01 +02:00
1 parent b5ea16fa2f
commit 34d1c9bf4e
2 files changed
+116 -2

No files matched your search

+88 -2
View File
@@ -273,6 +273,22 @@ module Prism
return pattern if prefix_stack.empty? return pattern if prefix_stack.empty?
prefix_stack.join + pattern prefix_stack.join + pattern
end end
def join_controller(controller)
return controller if module_stack.empty?
"#{module_stack.join('/')}/#{controller}"
end
def join_controller_action(to)
return to unless to
controller, action = to.split('#', 2)
"#{join_controller(controller)}##{action}"
end
def join_name(name)
return name if name.nil? || name_prefix_stack.empty?
"#{name_prefix_stack.join('_')}_#{name}".to_sym
end
end end
attr_reader :request, :response, :env attr_reader :request, :response, :env
@@ -285,15 +301,43 @@ module Prism
head = @request.request_method == 'HEAD' head = @request.request_method == 'HEAD'
lookup_method = head ? 'GET' : @request.request_method lookup_method = head ? 'GET' : @request.request_method
Orm::NPlusOneDetector.reset! if Orm::NPlusOneDetector.enabled?
Instrumentation.instrument('process_action.prism', method: @request.request_method,
path: @request.path) do |payload|
@instrumentation_payload = payload
catch(:halt) do catch(:halt) do
begin
run_before_filters run_before_filters
matched = run_routes(lookup_method) matched = run_routes(lookup_method) || dispatch_mount
handle_not_found unless matched handle_not_found unless matched
rescue StandardError => e
handle_uncaught_exception(e)
end
end end
catch(:halt) do catch(:halt) do
run_after_filters run_after_filters
end end
finalize_flash finalize_flash
# Deliberately inside the instrumented span's rescue reach (unlike a
# plain StandardError, an NPlusOneViolation is raised AFTER the
# action already produced a response) - without this rescue, a
# violation would blow straight out of #dispatch, past every
# middleware and Server#serve's `@app.call(env)` (server.rb has no
# StandardError rescue around that call, only EPIPE/ECONNRESET/
# IOError), killing the request thread with the socket closed and
# NOT ONE byte written back to the client - silent connection death
# instead of the same 500 every other uncaught bug in this app gets.
# Routing it through handle_uncaught_exception keeps raise_on_
# violation usable as a real regression gate (curl/CI sees a normal
# 500, not a broken pipe) instead of only being provable in a unit
# test that calls NPlusOneDetector.report! directly.
begin
Orm::NPlusOneDetector.report!(method: @request.request_method, path: @request.path) if Orm::NPlusOneDetector.enabled?
rescue Orm::NPlusOneViolation => e
handle_uncaught_exception(e)
end
payload[:status] = @response.status
end
status, headers, body = @response.finish status, headers, body = @response.finish
body = [] if head body = [] if head
@@ -335,24 +379,66 @@ module Prism
end end
def dispatch_to_controller(route) def dispatch_to_controller(route)
controller_class = Object.const_get("#{Inflector.camelize(route.controller_name)}Controller") controller_class = resolve_controller_class(route.controller_name)
controller = controller_class.new(@env, route.action_name, @request.path_params) controller = controller_class.new(@env, route.action_name, @request.path_params)
@instrumentation_payload&.merge!(controller: route.controller_name, action: route.action_name)
status, headers, body = controller.process status, headers, body = controller.process
@response.status = status @response.status = status
headers.each { |name, value| @response[name] = value } headers.each { |name, value| @response[name] = value }
@response.body = body @response.body = body
end end
def dispatch_mount
mounted = self.class.router.find_mount(@request.path)
return false unless mounted
prefix, app = mounted
path_info = @request.path.delete_prefix(prefix)
path_info = '/' if path_info.empty?
mount_env = @env.merge(
'SCRIPT_NAME' => "#{@env['SCRIPT_NAME']}#{prefix}",
'PATH_INFO' => path_info
)
status, headers, body = app.call(mount_env)
@response.status = status
headers.each { |name, value| @response[name] = value }
@response.body = body
true
end
def finalize_body(result) def finalize_body(result)
return if @response.streaming?
return unless @response.body.empty? return unless @response.body.empty?
@response.body = [result.to_s] if result.is_a?(String) @response.body = [result.to_s] if result.is_a?(String)
end end
# "admin/posts" -> Admin::PostsController (each "/"-separated segment
# becomes its own camelized module/class, not one flattened name), so
# `namespace :admin` dispatches to real nested Ruby modules.
def resolve_controller_class(controller_name)
segments = controller_name.split('/').map { |segment| Inflector.camelize(segment) }
Object.const_get("#{segments.join('::')}Controller")
end
def handle_not_found def handle_not_found
@response.status = 404 @response.status = 404
@response.body = ["Not Found: #{@request.request_method} #{@request.path}"] @response.body = ["Not Found: #{@request.request_method} #{@request.path}"]
end end
# Last-resort safety net: a controller's own `rescue_from` handlers
# (see Controller#handle_exception) already had first crack at this and
# re-raised, so nothing in the app knows how to turn it into a response.
# Records it on the instrumentation payload (a LogSubscriber can print
# it) and renders a generic 500 - never the exception message or
# backtrace, which could leak internals to the client.
def handle_uncaught_exception(exception)
@instrumentation_payload[:exception] = [exception.class.name, exception.message]
@instrumentation_payload[:exception_object] = exception
@response.status = 500
@response['Content-Type'] = 'text/plain'
@response.body = ['Internal Server Error']
end
def load_template(name) def load_template(name)
directory = self.class.settings[:views] || 'views' directory = self.class.settings[:views] || 'views'
File.read(File.join(directory, "#{name}.erb")) File.read(File.join(directory, "#{name}.erb"))
+28
View File
@@ -0,0 +1,28 @@
require_relative 'assets/manifest'
require_relative 'assets/compiler'
module Prism
# Sprockets/Propshaft-lite static asset pipeline: Compiler fingerprints
# files on disk into a Manifest, Application#enable_assets makes that
# manifest available app-wide so Assets::Helpers (asset_path and friends)
# can look logical paths up in it, and Middleware::Assets serves the
# fingerprinted files back out with a far-future Cache-Control header
# (safe because the filename changes whenever the content does).
module Assets
class << self
attr_writer :manifest
def manifest
@manifest
end
def enabled?
!@manifest.nil?
end
def reset!
@manifest = nil
end
end
end
end