feat: extend responder with Prism and Responder

This commit is contained in:
dualfroz committed 2026-09-06 21:59:20 +02:00
1 parent 721d576956
commit 22c5d77da0
4 files changed
+135 -10

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
module Prism
# Collects per-format render blocks for Controller#respond_to, mirroring
# Rails' `respond_to { |format| format.html { ... }; format.json { ... } }`.
class Responder
class UnknownFormat < StandardError; end
def initialize
@blocks = {}
end
def method_missing(name, &block)
return super unless block
@blocks[name.to_sym] = block
end
def respond_to_missing?(_name, _include_private = false)
true
end
# Registers a fallback block for any format not already handled.
# `any` (no args) applies to every format that gets a respond() call
# later; `any(:json, :xml)` restricts the fallback to those formats.
def any(*formats, &block)
if formats.empty?
@fallback = block
else
formats.each { |format| @blocks[format.to_sym] ||= block }
end
end
def handles?(format)
@blocks.key?(format.to_sym) || !@fallback.nil?
end
def respond(format)
handler = @blocks[format.to_sym] || @fallback
raise UnknownFormat, "no handler registered for format #{format.inspect}" unless handler
handler.call
end
end
end
+22 -1
View File
@@ -23,8 +23,29 @@ module Prism
@headers[name] = value
end
# A StreamingBody (or anything else that already knows how to hand
# itself to a Rack-style server one chunk at a time via #each) is kept
# as-is rather than wrapped in Array(value) - wrapping it would produce
# a one-element Array whose element is the streaming object itself,
# which Server#write_response's ordinary `body.join` path cannot join
# into a String, and which would defeat the entire point of streaming
# (nothing would be sent until the whole thing was collected anyway).
def body=(value)
@body = Array(value)
@body = value.respond_to?(:each) ? value : Array(value)
end
# Sets the body to a StreamingBody wrapping `block`. The block itself
# does not run yet - only once whatever consumes the finished response
# (Server#write_response, or a test calling body.each directly) starts
# pulling chunks, exactly like an ordinary Rack body Array is not
# "used up" until something calls each/join on it either.
def stream(&block)
self.body = StreamingBody.new(&block)
self
end
def streaming?
@body.is_a?(StreamingBody)
end
def set_cookie(name, value, options = {})
+67 -9
View File
@@ -2,9 +2,16 @@ module Prism
class PathMatcher
attr_reader :keys, :pattern
def initialize(pattern)
# constraints: { param_name => Regexp } narrows a dynamic segment's match.
# format: true appends an optional trailing ".xml"/".json"/... capture
# (Rails' implicit `(.:format)`) and reserves the segment class [^/.]+
# so dynamic segments no longer swallow the extension. Left off by
# default so plain routes keep matching literal dots exactly as before.
def initialize(pattern, constraints: {}, format: false)
@pattern = pattern
@keys = []
@constraints = constraints.transform_keys(&:to_s)
@format = format && !pattern.include?('*')
@regex = compile(pattern)
end
@@ -37,8 +44,9 @@ module Prism
source = +'\A'
pattern.scan(/:\w+|\*|[^:*]+/).each do |token|
if token.start_with?(':')
@keys << token[1..]
source << '([^/]+)'
key = token[1..]
@keys << key
source << segment_source(key)
elsif token == '*'
@keys << 'splat'
source << '(.*?)'
@@ -46,15 +54,26 @@ module Prism
source << Regexp.escape(token)
end
end
if @format
@keys << 'format'
source << '(?:\.([^/.]+))?'
end
source << '\z'
Regexp.new(source)
end
def segment_source(key)
constraint = @constraints[key]
return "(#{constraint.source})" if constraint
@format ? '([^/.]+)' : '([^/]+)'
end
def extract(data)
params = {}
splats = []
@keys.each_with_index do |key, index|
value = data.captures[index]
next if value.nil?
if key == 'splat'
splats << value
else
@@ -67,11 +86,12 @@ module Prism
end
class Route
attr_reader :method, :block, :name, :controller_name, :action_name
attr_reader :method, :block, :name, :controller_name, :action_name, :pattern
def initialize(method, pattern, block: nil, name: nil, to: nil)
def initialize(method, pattern, block: nil, name: nil, to: nil, constraints: {}, format: false)
@method = method
@matcher = PathMatcher.new(pattern)
@pattern = pattern
@matcher = PathMatcher.new(pattern, constraints: constraints, format: format)
@block = block
@name = name&.to_sym
@controller_name, @action_name = to&.split('#', 2)
@@ -92,15 +112,19 @@ module Prism
end
class Router
attr_reader :routes
attr_reader :routes, :mounts, :websockets
def initialize
@routes = []
@named_routes = {}
@mounts = []
@websockets = {}
@websocket_options = {}
end
def add(method, pattern, name: nil, to: nil, &block)
route = Route.new(method.to_s.upcase, pattern, block: block, name: name, to: to)
def add(method, pattern, name: nil, to: nil, constraints: {}, format: false, &block)
route = Route.new(method.to_s.upcase, pattern, block: block, name: name, to: to,
constraints: constraints, format: format)
@routes << route
@named_routes[name.to_sym] = route if name
route
@@ -117,5 +141,39 @@ module Prism
route = @named_routes.fetch(name.to_sym) { raise ArgumentError, "no named route #{name.inspect}" }
route.path(params)
end
# Delegates every request under `prefix` to a separate Rack app (an
# engine, in Rails terms), like Rack::URLMap - checked only after no
# regular route matches, so a mounted app never shadows the host
# application's own routes. Longest prefix wins when mounts overlap.
def mount(prefix, app)
prefix = prefix.chomp('/')
@mounts << [prefix, app]
@mounts.sort_by! { |mounted_prefix, _| -mounted_prefix.length }
end
def find_mount(path)
@mounts.find { |prefix, _| path == prefix || path.start_with?("#{prefix}/") }
end
# Registers a WebSocket endpoint (an exact path, unlike `mount` - a
# single Upgrade: websocket handshake target like ActionCable's one
# "/cable" mount, not a prefix for a whole sub-application).
def add_websocket(path, handler, max_message_size: nil)
@websockets[path] = handler
@websocket_options[path] = { max_message_size: max_message_size }
end
def find_websocket(path)
@websockets[path]
end
# Per-path options registered alongside the handler (currently just
# `max_message_size:`) - kept separate from `find_websocket` rather than
# folded into its return value so that method keeps returning the bare
# handler class callers already match on.
def find_websocket_options(path)
@websocket_options[path] || {}
end
end
end
+5
View File
@@ -0,0 +1,5 @@
require_relative 'security/message_verifier'
require_relative 'security/message_encryptor'
require_relative 'security/strong_parameters'
require_relative 'security/csrf'
require_relative 'security/cookie_jar'