From 721d57695626ae430b074a736558dbc508688b38 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 21:56:26 +0200 Subject: [PATCH] feat: extend renderable with csrf and csrf_token --- lib/prism/renderable.rb | 72 +++++++++++++++++++++++++++++++++- lib/prism/request.rb | 38 ++++++++++++++++-- lib/prism/resource_scope.rb | 77 +++++++++++++++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 lib/prism/resource_scope.rb diff --git a/lib/prism/renderable.rb b/lib/prism/renderable.rb index b511fcb..d359b06 100644 --- a/lib/prism/renderable.rb +++ b/lib/prism/renderable.rb @@ -13,7 +13,22 @@ module Prism end def cookies - @request.cookies + @cookie_jar ||= begin + secret = @env['prism.secret'] + if secret + CookieJar.new(@request, @response, secret: secret, previous_secrets: @env['prism.previous_secrets'] || []) + else + @request.cookies + end + end + end + + def csrf + @csrf ||= Csrf.new(session) + end + + def csrf_token + csrf.masked_token end def status(code = nil) @@ -34,6 +49,21 @@ module Prism payload end + # JSON:API-lite error response for a failed-validation record: `{ + # "errors": [...] }` with each entry built by Orm::Errors#to_jsonapi. + # Mirrors `render_errors(post)` -> `format.json` the same way `render` + # covers the HTML branch of a respond_to block. + def render_errors(record, status: 422) + json({ errors: record.errors.to_jsonapi(status: status) }, status) + end + + # JSON:API-lite "not found" error, the 404 counterpart to render_errors. + # No `source.pointer` (unlike a validation error, a missing resource + # does not point at one attribute of it). + def render_not_found(detail, status: 404) + json({ errors: [{ status: status.to_s, title: 'Resource Not Found', detail: detail }] }, status) + end + def html(markup, code = nil) @response.status = code if code @response['Content-Type'] = 'text/html;charset=utf-8' @@ -41,6 +71,15 @@ module Prism markup end + # ActionController::Live-lite: `stream { |out| out.write(row) }` sends + # the response as HTTP/1.1 chunked Transfer-Encoding instead of + # buffering the whole body first - see Prism::StreamingBody and + # Server#write_response. Nothing inside the block runs here; it only + # runs once the server starts sending the response (see Response#stream). + def stream(&block) + @response.stream(&block) + end + def redirect(location, code = 302) @response.status = code @response['Location'] = location @@ -73,5 +112,36 @@ module Prism def finalize_flash @flash.sweep if @flash end + + # Locale for this request: an explicit ?locale= param wins, then a + # sticky value stashed in the session, then the client's + # Accept-Language header, then Prism::I18n.default_locale. + def locale + @locale ||= detect_locale + end + + def translate(key, **options) + Prism::I18n.t(key, locale: options.delete(:locale) || locale, **options) + end + alias_method :t, :translate + + def localize(value, **options) + Prism::I18n.l(value, locale: options.delete(:locale) || locale, **options) + end + alias_method :l, :localize + + private + + def detect_locale + requested = params['locale'] || session[:locale] + return requested.to_sym if requested && available_locale?(requested) + + negotiated = Prism::I18n.best_locale_from_accept(@request.headers['Accept-Language']) + negotiated || Prism::I18n.default_locale + end + + def available_locale?(candidate) + Prism::I18n.available_locales.map(&:to_s).include?(candidate.to_s) + end end end diff --git a/lib/prism/request.rb b/lib/prism/request.rb index 6f4083c..a9997be 100644 --- a/lib/prism/request.rb +++ b/lib/prism/request.rb @@ -6,8 +6,10 @@ module Prism @env = env end + OverridableMethods = %w[PUT PATCH DELETE].freeze + def request_method - @env['REQUEST_METHOD'] + @request_method ||= compute_request_method end def path @@ -22,10 +24,20 @@ module Prism @env['CONTENT_TYPE'] end + def user_agent + @env['HTTP_USER_AGENT'] + end + + def remote_addr + @env['REMOTE_ADDR'] + end + def body @body ||= begin input = @env['rack.input'] - input ? input.read : '' + content = input ? input.read : '' + input.rewind if input.respond_to?(:rewind) + content end end @@ -67,11 +79,27 @@ module Prism end end + # Negotiated response format: an explicit `.json` path/query extension + # wins, otherwise it falls back to the Accept header, defaulting to + # :html when nothing else matches (mirrors Rails' request.format). + def format + explicit = path_params['format'] || query_params['format'] + return explicit.to_s.to_sym unless explicit.nil? || explicit.to_s.empty? + Mime.best_from_accept(headers['Accept']) || :html + end + private + def compute_request_method + actual = @env['REQUEST_METHOD'] + return actual unless actual == 'POST' + override = (@env['HTTP_X_HTTP_METHOD_OVERRIDE'] || body_params['_method']).to_s.upcase + OverridableMethods.include?(override) ? override : actual + end + def parse_query(string) return Params.new if string.empty? - Params.from(URI.decode_www_form(string).to_h) + Params.nested_from(URI.decode_www_form(string)) rescue ArgumentError Params.new end @@ -82,7 +110,9 @@ module Prism parsed = JSON.parse(body) parsed.is_a?(Hash) ? Params.from(parsed) : Params.from('_json' => parsed) elsif content_type&.include?('application/x-www-form-urlencoded') - Params.from(URI.decode_www_form(body).to_h) + Params.nested_from(URI.decode_www_form(body)) + elsif Multipart.multipart?(content_type) + Multipart.parse(body, Multipart.boundary_from(content_type)) else Params.new end diff --git a/lib/prism/resource_scope.rb b/lib/prism/resource_scope.rb new file mode 100644 index 0000000..947b331 --- /dev/null +++ b/lib/prism/resource_scope.rb @@ -0,0 +1,77 @@ +module Prism + # DSL context yielded (via instance_eval) inside `resources ... do ... end` + # and `resource ... do ... end` blocks. Provides member/collection routes + # and nesting further resources under the current one. + class ResourceScope + def initialize(app, name, singular, param) + @app = app + @name = name + @singular = singular + @param = param + end + + # Extra actions scoped to one member: /name/:id/action -> name#action, + # named "action_singular" (e.g. `publish_post`). + def member(&block) + require_member_param! + @app.group("/#{@name}/:#{@param}") do + MemberCollectionScope.new(@app, @singular, @name, :member).instance_eval(&block) + end + end + + # Extra actions scoped to the whole collection: /name/action -> name#action, + # named "action_name" (e.g. `search_posts`). + def collection(&block) + @app.group("/#{@name}") do + MemberCollectionScope.new(@app, @singular, @name, :collection).instance_eval(&block) + end + end + + # Nested resources, scoped under this resource: for a plural parent + # that's /name/:singular_id/nested_name/... (Rails' classic nested + # routing); for a singular parent (no id at all) it's just + # /name/nested_name/.... + def resources(nested_name, **options, &block) + @app.group(nested_prefix) { @app.resources(nested_name, **options, &block) } + end + + def resource(nested_name, **options, &block) + @app.group(nested_prefix) { @app.resource(nested_name, **options, &block) } + end + + private + + def nested_prefix + @param ? "/#{@name}/:#{@singular}_id" : "/#{@name}" + end + + def require_member_param! + return if @param + raise ArgumentError, 'member/collection routes need an id segment; not available on a singular resource' + end + end + + # DSL context for the block passed to ResourceScope#member/#collection. + class MemberCollectionScope + def initialize(app, singular, plural, kind) + @app = app + @singular = singular + @plural = plural + @kind = kind + end + + %w[get post put patch delete].each do |verb| + define_method(verb) do |action, to: nil, as: nil| + controller_action = to || "#{@plural}##{action}" + name = as || default_name(action) + @app.public_send(verb, "/#{action}", to: controller_action, as: name) + end + end + + private + + def default_name(action) + @kind == :member ? "#{action}_#{@singular}" : "#{action}_#{@plural}" + end + end +end