diff --git a/lib/utopia/controller/respond.rb b/lib/utopia/controller/respond.rb index 17e32a21..3ee21b3f 100644 --- a/lib/utopia/controller/respond.rb +++ b/lib/utopia/controller/respond.rb @@ -26,8 +26,6 @@ def responds @responder ||= Responder.new end - alias respond responds - # Serialize a semantic value according to the request's accepted media types. # @parameter context [Controller::Base] The controller context. # @parameter request [Utopia::Request] The request. diff --git a/lib/utopia/controller/responder.rb b/lib/utopia/controller/responder.rb index b62581df..929225b4 100644 --- a/lib/utopia/controller/responder.rb +++ b/lib/utopia/controller/responder.rb @@ -5,25 +5,23 @@ require_relative "middleware" +require "protocol/http/header/accept" +require "protocol/media/map" +require "protocol/media/type" +require "protocol/media/range" + module Utopia module Controller # @namespace module Handlers # Serializes controller values as JSON responses. module JSON - APPLICATION_JSON = HTTP::Accept::ContentType.new("application", "json").freeze - - # Delegate content-type splitting to the JSON media type. - # @parameter arguments [Array] The arguments. - # @returns [Array] The resulting values. - def self.split(*arguments) - APPLICATION_JSON.split(*arguments) - end + APPLICATION_JSON = Protocol::Media::Type.new("application", "json").freeze # Serialize an object as JSON. # @parameter context [Object] The context. # @parameter request [Utopia::Request] The request. - # @parameter media_range [HTTP::Accept::MediaTypes::MediaRange] The negotiated media range. + # @parameter media_range [Protocol::HTTP::Header::Accept::MediaRange] The negotiated media range. # @parameter object [Object] The object. # @parameter options [Hash] The options. # @returns [String] The serialized JSON body. @@ -36,7 +34,7 @@ def self.call(context, request, media_range, object, **options) end # The media type produced by this handler. - # @returns [HTTP::Accept::ContentType] The JSON media type. + # @returns [Protocol::Media::Type] The JSON media type. def self.content_type APPLICATION_JSON end @@ -44,19 +42,12 @@ def self.content_type # Passes response values through without transformation. module Passthrough - WILDCARD = HTTP::Accept::MediaTypes::MediaRange.new("*", "*").freeze - - # Delegate content-type splitting to the wildcard media range. - # @parameter arguments [Array] The arguments. - # @returns [Array] The resulting values. - def self.split(*arguments) - WILDCARD.split(*arguments) - end + WILDCARD = Protocol::Media::Range.new("*", "*").freeze # Pass an object through without transformation. # @parameter context [Object] The context. # @parameter request [Utopia::Request] The request. - # @parameter media_range [HTTP::Accept::MediaTypes::MediaRange] The negotiated media range. + # @parameter media_range [Protocol::HTTP::Header::Accept::MediaRange] The negotiated media range. # @parameter object [Object] The object. # @parameter options [Hash] The options. # @returns [Object] The original body. @@ -76,17 +67,10 @@ def self.content_type class Responder # A content-type handler and its response block. Handler = Struct.new(:content_type, :block) do - # Delegate content-type splitting to this handler's content type. - # @parameter arguments [Array] The arguments. - # @returns [Array] The resulting values. - def split(*arguments) - self.content_type.split(*arguments) - end - # Invoke this handler's block in the controller context. # @parameter context [Object] The context. # @parameter request [Utopia::Request] The request. - # @parameter media_range [HTTP::Accept::MediaTypes::MediaRange] The negotiated media range. + # @parameter media_range [Protocol::HTTP::Header::Accept::MediaRange] The negotiated media range. # @parameter arguments [Array] The arguments. # @parameter options [Hash] The options. # @returns [Object] The handler block's result. @@ -95,65 +79,85 @@ def call(context, request, media_range, *arguments, **options) end end - # Initialize an empty content-type handler map. - def initialize - @handlers = HTTP::Accept::MediaTypes::Map.new + # Initialize a responder with a handler map. + # @parameter handlers [Protocol::Media::Map] The response handlers. + # @parameter passthrough [Object | Nil] The fallback response handler. + def initialize(handlers = Protocol::Media::Map.new, passthrough = nil) + @handlers = handlers + @passthrough = passthrough end attr :handlers - # Freeze this object and its internal state. - # @returns [self] This object. + # Freeze this responder and compile its handler map. + # @returns [self] This responder. def freeze - @handlers.freeze - - super - end - - # Negotiate the request's accepted media types and invoke the best handler. - # @parameter context [Object] The controller context. - # @parameter request [Utopia::Request] The request. - # @parameter arguments [Array] The arguments. - # @parameter options [Hash] The options. - # @returns [Array(Object, Object) | Nil] The selected content type and body, or `nil` if none matches. - def call(context, request, *arguments, **options) - # Parse the list of browser preferred content types and return ordered by priority: - media_types = HTTP::Accept::MediaTypes.browser_preferred_media_types( - HTTP::Accept::MediaTypes::HTTP_ACCEPT => Array(request.headers["accept"]).join(",") - ) + return self if frozen? - handler, media_range = @handlers.for(media_types) - - if handler - return handler.content_type, handler.call(context, request, media_range, *arguments, **options) - end + @handlers.freeze - return nil + return super end # Add a serializer for the specified content type. + # @parameter content_type [String | Protocol::Media::Type] The produced media type. + # @yields The response handler body. + # @returns [self] This responder. def handle(content_type, &block) - @handlers << Handler.new(content_type, block) + @handlers[content_type] = Handler.new(content_type, block).freeze + return self end # Register the default JSON handler. - # @returns [HTTP::Accept::MediaTypes::Map] The updated handler map. + # @returns [self] This responder. def with_json - @handlers << Handlers::JSON + @handlers[Handlers::JSON::APPLICATION_JSON] = Handlers::JSON + return self end # Register the wildcard passthrough handler. - # @returns [HTTP::Accept::MediaTypes::Map] The updated handler map. + # @returns [self] This responder. def with_passthrough - @handlers << Handlers::Passthrough + @passthrough = Handlers::Passthrough + return self end - # Invoke the responder with the given object. - # @parameter content_type [String] The content type. + # Add a serializer for the specified content type. + # @parameter content_type [String | Protocol::Media::Type] The produced media type. # @yields The response handler body. - # @returns [HTTP::Accept::MediaTypes::Map] The updated handler map. + # @returns [self] This responder. def with(content_type, &block) - handle(content_type, &block) + return handle(content_type, &block) + end + + # Negotiate the request's accepted media types and invoke the best handler. + # @parameter context [Object] The controller context. + # @parameter request [Utopia::Request] The request. + # @parameter arguments [Array] The arguments. + # @parameter options [Hash] The options. + # @returns [Array(Object, Object) | Nil] The selected content type and body, or `nil` if none matches. + def call(context, request, *arguments, **options) + accept = request.headers["accept"] + + # An absent or empty Accept header accepts any media type: + if accept.nil? || accept.empty? + media_ranges = [Handlers::Passthrough::WILDCARD] + else + media_ranges = accept.preferred_media_ranges + end + + if match = @handlers.for(media_ranges) + handler, media_range = match + elsif @passthrough + handler = @passthrough + media_range = media_ranges.first + end + + if handler + return handler.content_type, handler.call(context, request, media_range, *arguments, **options) + end + + return nil end end end diff --git a/lib/utopia/http.rb b/lib/utopia/http.rb index d63f31c6..f4fb7a93 100644 --- a/lib/utopia/http.rb +++ b/lib/utopia/http.rb @@ -3,15 +3,11 @@ # Released under the MIT License. # Copyright, 2010-2026, by Samuel Williams. -require "http/accept" require "protocol/http/status" module Utopia # HTTP protocol implementation. module HTTP - # Pull in {::HTTP::Accept} for parsing. - Accept = ::HTTP::Accept - # A list of commonly used HTTP status codes. # For help choosing the right status code, see http://racksburg.com/choosing-an-http-status-code/ STATUS_CODES = { diff --git a/lib/utopia/localization.rb b/lib/utopia/localization.rb index 3bcc20dd..2f6e90fc 100644 --- a/lib/utopia/localization.rb +++ b/lib/utopia/localization.rb @@ -4,6 +4,7 @@ # Copyright, 2009-2025, by Samuel Williams. require_relative "localization/preferences" +require_relative "localization/locales" require_relative "localization/resolver" require_relative "localization/middleware" diff --git a/lib/utopia/localization/locales.rb b/lib/utopia/localization/locales.rb new file mode 100644 index 00000000..46435897 --- /dev/null +++ b/lib/utopia/localization/locales.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Utopia + module Localization + # Matches configured locales against language ranges. + class Locales + # Expand a locale into progressively less specific language ranges. + # @parameter locale [String] The locale to expand. + # @parameter patterns [Hash] The destination language-range mapping. + def self.expand(locale, patterns) + parts = locale.split("-") + + while parts.any? + pattern = parts.join("-") + patterns[pattern] ||= locale + parts.pop + end + end + + # Initialize the configured locales. + # @parameter names [Array(String)] The locale names, in preference order. + def initialize(names) + @names = names + @patterns = {} + + @names.each do |name| + self.class.expand(name, @patterns) + end + + freeze + end + + # Freeze this object and its internal state. + # @returns [self] This object. + def freeze + return self if frozen? + + @names.freeze + @patterns.freeze + + return super + end + + attr :names + attr :patterns + + # Select configured locales matching the given language ranges. + # @parameter languages [Enumerable] Preferred language ranges. + # @returns [Array(String)] Matching locale names in language preference order. + def match(languages) + languages.filter_map do |language| + @patterns[language.name] + end + end + end + end +end diff --git a/lib/utopia/localization/middleware.rb b/lib/utopia/localization/middleware.rb index 1024bc0b..579401eb 100644 --- a/lib/utopia/localization/middleware.rb +++ b/lib/utopia/localization/middleware.rb @@ -4,11 +4,13 @@ # Copyright, 2025-2026, by Samuel Williams. require_relative "preferences" +require_relative "locales" require_relative "../middleware" require_relative "../request" require_relative "../response" require "set" +require "protocol/http/header/accept_language" module Utopia module Localization @@ -22,7 +24,7 @@ class Middleware < Protocol::HTTP::Middleware def initialize(app, locales:, default_locale: nil, default_locales: nil, hosts: {}, ignore: []) super(app) - @all_locales = HTTP::Accept::Languages::Locales.new(locales) + @all_locales = Locales.new(locales) # Locales here are represented as an array of strings, e.g. ['en', 'ja', 'cn', 'de'] and are used in order if no locale is specified by the user. unless @default_locales = default_locales @@ -125,17 +127,17 @@ def extract_path_locale(request) # @parameter request [Utopia::Request] The application request. # @returns [Array(String)] Supported locales accepted by the browser, in preference order. def browser_preferred_locales(request) - accept_languages = request.headers["accept-language"]&.to_s + accept_languages = request.headers["accept-language"] # No user prefered languages: return [] unless accept_languages # Extract the ordered list of languages: - languages = HTTP::Accept::Languages.parse(accept_languages) + languages = accept_languages.preferred_languages # Returns available languages based on the order languages: - return @all_locales & languages - rescue HTTP::Accept::ParseError + return @all_locales.match(languages) + rescue Protocol::HTTP::Header::AcceptLanguage::ParseError # If we fail to parse the browser Accept-Language header, we ignore it (silently). return [] end diff --git a/readme.md b/readme.md index 911e964c..2df75f81 100644 --- a/readme.md +++ b/readme.md @@ -57,7 +57,6 @@ Please see the [project releases](https://socketry.github.io/utopia/releases/ind - [Utopia::Gallery](https://github.com/ioquatix/utopia-gallery) — A fast photo gallery based on [libvips](https://github.com/jcupitt/libvips). - [Utopia::Project](https://github.com/socketry/utopia-project) — A Ruby project documentation tool. - [Utopia::Analytics](https://github.com/ioquatix/utopia-analytics) — Simple integration with Google Analytics. - - [HTTP::Accept](https://github.com/ioquatix/http-accept) — RFC compliant header parser. - [Samovar](https://github.com/ioquatix/samovar) — Command line parser used by Utopia. - [Mapping](https://github.com/ioquatix/mapping) — Provide structured conversions for web interfaces. diff --git a/releases.md b/releases.md index e166e495..f1f0e85b 100644 --- a/releases.md +++ b/releases.md @@ -3,6 +3,7 @@ ## Unreleasd - **Security** Fix handling of redirects that start with `//` to prevent open redirect vulnerabilities. + - Use `protocol-media` and `protocol-http` for response and language negotiation, removing the `http-accept` dependency. ## v2.31.0 diff --git a/setup/site/pages/welcome/index.xnode b/setup/site/pages/welcome/index.xnode index 91bbc44a..3e8b08c0 100644 --- a/setup/site/pages/welcome/index.xnode +++ b/setup/site/pages/welcome/index.xnode @@ -17,7 +17,7 @@
Utopia comprises a core gem and several supporting libraries, the main ones being trenni for templates and parsing, and http-accept for HTTP header processing. Together, these gems have over 90% test coverage.
+Utopia comprises a core gem and several supporting libraries, including XRB for templates and markup parsing and Protocol HTTP for HTTP protocol handling.