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 @@

Well tested and maintained

-

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.

diff --git a/test/utopia/.performance/pages/api/controller.rb b/test/utopia/.performance/pages/api/controller.rb index e8fb742e..6caf5f3e 100644 --- a/test/utopia/.performance/pages/api/controller.rb +++ b/test/utopia/.performance/pages/api/controller.rb @@ -4,7 +4,7 @@ # Copyright, 2016-2023, by Samuel Williams. prepend Respond, Actions -respond.with_json +responds.with_json on 'fetch' do succeed! [1, 2, 3] diff --git a/test/utopia/controller/.respond/api/controller.rb b/test/utopia/controller/.respond/api/controller.rb index 07c5ce46..02696256 100644 --- a/test/utopia/controller/.respond/api/controller.rb +++ b/test/utopia/controller/.respond/api/controller.rb @@ -4,7 +4,7 @@ # Copyright, 2016-2023, by Samuel Williams. prepend Respond, Actions -respond.with_json +responds.with_json class VersionedResponse def to_json(options = {}) diff --git a/test/utopia/controller/.respond/html/controller.rb b/test/utopia/controller/.respond/html/controller.rb index 4bb85e8d..8343fc63 100644 --- a/test/utopia/controller/.respond/html/controller.rb +++ b/test/utopia/controller/.respond/html/controller.rb @@ -6,7 +6,7 @@ prepend Respond, Actions # Respond with json: -respond.with_json +responds.with_json # This method should return HTML, even thought this controller responds with JSON. on 'hello-world' do diff --git a/test/utopia/controller/.respond/rewrite/controller.rb b/test/utopia/controller/.respond/rewrite/controller.rb index 0bb37a5c..46ead6d9 100644 --- a/test/utopia/controller/.respond/rewrite/controller.rb +++ b/test/utopia/controller/.respond/rewrite/controller.rb @@ -5,7 +5,7 @@ prepend Respond, Rewrite, Actions -respond.with_json +responds.with_json rewrite.extract_prefix id: Integer do |request| fail! :not_found, message: "Could not find record" if @id == 1 diff --git a/test/utopia/controller/.websocket/server/controller.rb b/test/utopia/controller/.websocket/server/controller.rb index 87f59124..fddbfc6d 100644 --- a/test/utopia/controller/.websocket/server/controller.rb +++ b/test/utopia/controller/.websocket/server/controller.rb @@ -5,7 +5,7 @@ prepend Respond, Actions -respond.with_passthrough +responds.with_passthrough on 'events' do |request| upgrade = Async::WebSocket::Adapters::HTTP.open(request) do |connection| diff --git a/test/utopia/controller/respond.rb b/test/utopia/controller/respond.rb index 6d2d2c56..e444f937 100644 --- a/test/utopia/controller/respond.rb +++ b/test/utopia/controller/respond.rb @@ -46,6 +46,8 @@ def self.uri_path end end + TestController.freeze + let(:controller) {TestController.new} def mock_request(path, headers = {}) @@ -86,6 +88,24 @@ def mock_request(path, headers = {}) expect(response.read).to be == '{"user_id":10}' end + it "preserves the requested order for equally preferred responses" do + request, path = mock_request("/fetch", {"accept" => "text/plain, application/json"}) + relative_path = path - controller.class.uri_path + + response = controller.process!(request, relative_path) + + expect(response.headers["content-type"]).to be == "text/plain" + end + + it "treats an empty accept header as accepting any response" do + request, path = mock_request("/fetch", {"accept" => ""}) + relative_path = path - controller.class.uri_path + + response = controller.process!(request, relative_path) + + expect(response.headers["content-type"]).to be == "application/json" + end + it "preserves readable response bodies" do body = Protocol::HTTP::Body::Readable.new controller.instance_variable_set(:@stream, body) @@ -117,6 +137,14 @@ def mock_request(path, headers = {}) expect(response.read).to be == "Explicit" end + it "falls back to the passthrough handler" do + responder = Utopia::Controller::Responder.new + responder.with_passthrough + responder.freeze + request = Utopia::Request["GET", "/", {"accept" => "application/xml"}] + + expect(responder.call(controller, request, "Hello World")).to be == [nil, "Hello World"] + end end describe Utopia::Controller do diff --git a/test/utopia/localization.rb b/test/utopia/localization.rb index b6df428f..cd43e545 100755 --- a/test/utopia/localization.rb +++ b/test/utopia/localization.rb @@ -76,6 +76,24 @@ expect(last_response.headers["content-location"].to_s).to be == "/ja/localized.txt" end + it "matches less specific accepted languages" do + application = Utopia::Application.build(Protocol::HTTP::Middleware.for do |request| + Utopia::Response.text(request.localization.preferred_locales.compact.join(",")) + end) do + use Utopia::Localization, locales: ["en-NZ", "en-US"], default_locale: "en-NZ" + end + + response = application.call(Protocol::HTTP::Request["GET", "/", {"accept-language" => "en"}]) + + expect(response.read).to be == "en-NZ" + end + + it "ignores malformed accepted languages" do + client.get "/localized.txt", {"accept-language" => "not a language"} + + expect(last_response.read).to be == "localized.en.txt" + end + it "resolves localized content templates" do client.get "/page", {"accept-language" => "ja,en"} diff --git a/utopia.gemspec b/utopia.gemspec index 2fcfb842..297b27c2 100644 --- a/utopia.gemspec +++ b/utopia.gemspec @@ -28,14 +28,14 @@ Gem::Specification.new do |spec| spec.add_dependency "bake", "~> 0.20" spec.add_dependency "concurrent-ruby", "~> 1.2" spec.add_dependency "console", "~> 1.24" - spec.add_dependency "http-accept", "~> 2.1" spec.add_dependency "irb" spec.add_dependency "mail", "~> 2.6" spec.add_dependency "mime-types", "~> 3.0" spec.add_dependency "msgpack" spec.add_dependency "net-smtp" spec.add_dependency "protocol-content", "~> 0.1" - spec.add_dependency "protocol-http", "~> 0.68" + spec.add_dependency "protocol-http", "~> 0.70" + spec.add_dependency "protocol-media", "~> 0.3" spec.add_dependency "protocol-url", "~> 0.10" spec.add_dependency "samovar", "~> 2.1" spec.add_dependency "traces", "~> 0.10"