diff --git a/lib/prism/responder.rb b/lib/prism/responder.rb new file mode 100644 index 0000000..b52e687 --- /dev/null +++ b/lib/prism/responder.rb @@ -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 diff --git a/lib/prism/response.rb b/lib/prism/response.rb index da9c20c..f7ffc8d 100644 --- a/lib/prism/response.rb +++ b/lib/prism/response.rb @@ -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 = {}) diff --git a/lib/prism/router.rb b/lib/prism/router.rb index b7cadc9..7c8baf0 100644 --- a/lib/prism/router.rb +++ b/lib/prism/router.rb @@ -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 diff --git a/lib/prism/security.rb b/lib/prism/security.rb new file mode 100644 index 0000000..5a09a78 --- /dev/null +++ b/lib/prism/security.rb @@ -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'