Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions lib/utopia/controller/respond.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
130 changes: 67 additions & 63 deletions lib/utopia/controller/responder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -36,27 +34,20 @@ 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
end

# 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.
Expand All @@ -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.
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Ooh, opportunity to assign from media_types once, from the if-as-expression.


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
Expand Down
4 changes: 0 additions & 4 deletions lib/utopia/http.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
1 change: 1 addition & 0 deletions lib/utopia/localization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
60 changes: 60 additions & 0 deletions lib/utopia/localization/locales.rb
Original file line number Diff line number Diff line change
@@ -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
12 changes: 7 additions & 5 deletions lib/utopia/localization/middleware.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion setup/site/pages/welcome/index.xnode
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<div>
<i class="fa fa-code"></i>
<h2>Well tested and maintained</h2>
<p>Utopia comprises a <a href="https://github.com/ioquatix/utopia">core gem</a> and several supporting libraries, the main ones being <a href="https://github.com/ioquatix/trenni">trenni</a> for templates and parsing, and <a href="https://github.com/ioquatix/http-accept">http-accept</a> for HTTP header processing. Together, these gems have over 90% test coverage.</p>
<p>Utopia comprises a <a href="https://github.com/ioquatix/utopia">core gem</a> and several supporting libraries, including <a href="https://github.com/socketry/xrb">XRB</a> for templates and markup parsing and <a href="https://github.com/socketry/protocol-http">Protocol HTTP</a> for HTTP protocol handling.</p>
</div>

<div>
Expand Down
2 changes: 1 addition & 1 deletion test/utopia/.performance/pages/api/controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion test/utopia/controller/.respond/api/controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {})
Expand Down
2 changes: 1 addition & 1 deletion test/utopia/controller/.respond/html/controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading