Skip to content

Commit c60b590

Browse files
Use protocol media for response negotiation. (#58)
1 parent 8000336 commit c60b590

17 files changed

Lines changed: 190 additions & 83 deletions

File tree

lib/utopia/controller/respond.rb

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ def responds
2626
@responder ||= Responder.new
2727
end
2828

29-
alias respond responds
30-
3129
# Serialize a semantic value according to the request's accepted media types.
3230
# @parameter context [Controller::Base] The controller context.
3331
# @parameter request [Utopia::Request] The request.

lib/utopia/controller/responder.rb

Lines changed: 67 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,23 @@
55

66
require_relative "middleware"
77

8+
require "protocol/http/header/accept"
9+
require "protocol/media/map"
10+
require "protocol/media/type"
11+
require "protocol/media/range"
12+
813
module Utopia
914
module Controller
1015
# @namespace
1116
module Handlers
1217
# Serializes controller values as JSON responses.
1318
module JSON
14-
APPLICATION_JSON = HTTP::Accept::ContentType.new("application", "json").freeze
15-
16-
# Delegate content-type splitting to the JSON media type.
17-
# @parameter arguments [Array] The arguments.
18-
# @returns [Array] The resulting values.
19-
def self.split(*arguments)
20-
APPLICATION_JSON.split(*arguments)
21-
end
19+
APPLICATION_JSON = Protocol::Media::Type.new("application", "json").freeze
2220

2321
# Serialize an object as JSON.
2422
# @parameter context [Object] The context.
2523
# @parameter request [Utopia::Request] The request.
26-
# @parameter media_range [HTTP::Accept::MediaTypes::MediaRange] The negotiated media range.
24+
# @parameter media_range [Protocol::HTTP::Header::Accept::MediaRange] The negotiated media range.
2725
# @parameter object [Object] The object.
2826
# @parameter options [Hash] The options.
2927
# @returns [String] The serialized JSON body.
@@ -36,27 +34,20 @@ def self.call(context, request, media_range, object, **options)
3634
end
3735

3836
# The media type produced by this handler.
39-
# @returns [HTTP::Accept::ContentType] The JSON media type.
37+
# @returns [Protocol::Media::Type] The JSON media type.
4038
def self.content_type
4139
APPLICATION_JSON
4240
end
4341
end
4442

4543
# Passes response values through without transformation.
4644
module Passthrough
47-
WILDCARD = HTTP::Accept::MediaTypes::MediaRange.new("*", "*").freeze
48-
49-
# Delegate content-type splitting to the wildcard media range.
50-
# @parameter arguments [Array] The arguments.
51-
# @returns [Array] The resulting values.
52-
def self.split(*arguments)
53-
WILDCARD.split(*arguments)
54-
end
45+
WILDCARD = Protocol::Media::Range.new("*", "*").freeze
5546

5647
# Pass an object through without transformation.
5748
# @parameter context [Object] The context.
5849
# @parameter request [Utopia::Request] The request.
59-
# @parameter media_range [HTTP::Accept::MediaTypes::MediaRange] The negotiated media range.
50+
# @parameter media_range [Protocol::HTTP::Header::Accept::MediaRange] The negotiated media range.
6051
# @parameter object [Object] The object.
6152
# @parameter options [Hash] The options.
6253
# @returns [Object] The original body.
@@ -76,17 +67,10 @@ def self.content_type
7667
class Responder
7768
# A content-type handler and its response block.
7869
Handler = Struct.new(:content_type, :block) do
79-
# Delegate content-type splitting to this handler's content type.
80-
# @parameter arguments [Array] The arguments.
81-
# @returns [Array] The resulting values.
82-
def split(*arguments)
83-
self.content_type.split(*arguments)
84-
end
85-
8670
# Invoke this handler's block in the controller context.
8771
# @parameter context [Object] The context.
8872
# @parameter request [Utopia::Request] The request.
89-
# @parameter media_range [HTTP::Accept::MediaTypes::MediaRange] The negotiated media range.
73+
# @parameter media_range [Protocol::HTTP::Header::Accept::MediaRange] The negotiated media range.
9074
# @parameter arguments [Array] The arguments.
9175
# @parameter options [Hash] The options.
9276
# @returns [Object] The handler block's result.
@@ -95,65 +79,85 @@ def call(context, request, media_range, *arguments, **options)
9579
end
9680
end
9781

98-
# Initialize an empty content-type handler map.
99-
def initialize
100-
@handlers = HTTP::Accept::MediaTypes::Map.new
82+
# Initialize a responder with a handler map.
83+
# @parameter handlers [Protocol::Media::Map] The response handlers.
84+
# @parameter passthrough [Object | Nil] The fallback response handler.
85+
def initialize(handlers = Protocol::Media::Map.new, passthrough = nil)
86+
@handlers = handlers
87+
@passthrough = passthrough
10188
end
10289

10390
attr :handlers
10491

105-
# Freeze this object and its internal state.
106-
# @returns [self] This object.
92+
# Freeze this responder and compile its handler map.
93+
# @returns [self] This responder.
10794
def freeze
108-
@handlers.freeze
109-
110-
super
111-
end
112-
113-
# Negotiate the request's accepted media types and invoke the best handler.
114-
# @parameter context [Object] The controller context.
115-
# @parameter request [Utopia::Request] The request.
116-
# @parameter arguments [Array] The arguments.
117-
# @parameter options [Hash] The options.
118-
# @returns [Array(Object, Object) | Nil] The selected content type and body, or `nil` if none matches.
119-
def call(context, request, *arguments, **options)
120-
# Parse the list of browser preferred content types and return ordered by priority:
121-
media_types = HTTP::Accept::MediaTypes.browser_preferred_media_types(
122-
HTTP::Accept::MediaTypes::HTTP_ACCEPT => Array(request.headers["accept"]).join(",")
123-
)
95+
return self if frozen?
12496

125-
handler, media_range = @handlers.for(media_types)
126-
127-
if handler
128-
return handler.content_type, handler.call(context, request, media_range, *arguments, **options)
129-
end
97+
@handlers.freeze
13098

131-
return nil
99+
return super
132100
end
133101

134102
# Add a serializer for the specified content type.
103+
# @parameter content_type [String | Protocol::Media::Type] The produced media type.
104+
# @yields The response handler body.
105+
# @returns [self] This responder.
135106
def handle(content_type, &block)
136-
@handlers << Handler.new(content_type, block)
107+
@handlers[content_type] = Handler.new(content_type, block).freeze
108+
return self
137109
end
138110

139111
# Register the default JSON handler.
140-
# @returns [HTTP::Accept::MediaTypes::Map] The updated handler map.
112+
# @returns [self] This responder.
141113
def with_json
142-
@handlers << Handlers::JSON
114+
@handlers[Handlers::JSON::APPLICATION_JSON] = Handlers::JSON
115+
return self
143116
end
144117

145118
# Register the wildcard passthrough handler.
146-
# @returns [HTTP::Accept::MediaTypes::Map] The updated handler map.
119+
# @returns [self] This responder.
147120
def with_passthrough
148-
@handlers << Handlers::Passthrough
121+
@passthrough = Handlers::Passthrough
122+
return self
149123
end
150124

151-
# Invoke the responder with the given object.
152-
# @parameter content_type [String] The content type.
125+
# Add a serializer for the specified content type.
126+
# @parameter content_type [String | Protocol::Media::Type] The produced media type.
153127
# @yields The response handler body.
154-
# @returns [HTTP::Accept::MediaTypes::Map] The updated handler map.
128+
# @returns [self] This responder.
155129
def with(content_type, &block)
156-
handle(content_type, &block)
130+
return handle(content_type, &block)
131+
end
132+
133+
# Negotiate the request's accepted media types and invoke the best handler.
134+
# @parameter context [Object] The controller context.
135+
# @parameter request [Utopia::Request] The request.
136+
# @parameter arguments [Array] The arguments.
137+
# @parameter options [Hash] The options.
138+
# @returns [Array(Object, Object) | Nil] The selected content type and body, or `nil` if none matches.
139+
def call(context, request, *arguments, **options)
140+
accept = request.headers["accept"]
141+
142+
# An absent or empty Accept header accepts any media type:
143+
if accept.nil? || accept.empty?
144+
media_ranges = [Handlers::Passthrough::WILDCARD]
145+
else
146+
media_ranges = accept.preferred_media_ranges
147+
end
148+
149+
if match = @handlers.for(media_ranges)
150+
handler, media_range = match
151+
elsif @passthrough
152+
handler = @passthrough
153+
media_range = media_ranges.first
154+
end
155+
156+
if handler
157+
return handler.content_type, handler.call(context, request, media_range, *arguments, **options)
158+
end
159+
160+
return nil
157161
end
158162
end
159163
end

lib/utopia/http.rb

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,11 @@
33
# Released under the MIT License.
44
# Copyright, 2010-2026, by Samuel Williams.
55

6-
require "http/accept"
76
require "protocol/http/status"
87

98
module Utopia
109
# HTTP protocol implementation.
1110
module HTTP
12-
# Pull in {::HTTP::Accept} for parsing.
13-
Accept = ::HTTP::Accept
14-
1511
# A list of commonly used HTTP status codes.
1612
# For help choosing the right status code, see http://racksburg.com/choosing-an-http-status-code/
1713
STATUS_CODES = {

lib/utopia/localization.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# Copyright, 2009-2025, by Samuel Williams.
55

66
require_relative "localization/preferences"
7+
require_relative "localization/locales"
78
require_relative "localization/resolver"
89
require_relative "localization/middleware"
910

lib/utopia/localization/locales.rb

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
module Utopia
7+
module Localization
8+
# Matches configured locales against language ranges.
9+
class Locales
10+
# Expand a locale into progressively less specific language ranges.
11+
# @parameter locale [String] The locale to expand.
12+
# @parameter patterns [Hash] The destination language-range mapping.
13+
def self.expand(locale, patterns)
14+
parts = locale.split("-")
15+
16+
while parts.any?
17+
pattern = parts.join("-")
18+
patterns[pattern] ||= locale
19+
parts.pop
20+
end
21+
end
22+
23+
# Initialize the configured locales.
24+
# @parameter names [Array(String)] The locale names, in preference order.
25+
def initialize(names)
26+
@names = names
27+
@patterns = {}
28+
29+
@names.each do |name|
30+
self.class.expand(name, @patterns)
31+
end
32+
33+
freeze
34+
end
35+
36+
# Freeze this object and its internal state.
37+
# @returns [self] This object.
38+
def freeze
39+
return self if frozen?
40+
41+
@names.freeze
42+
@patterns.freeze
43+
44+
return super
45+
end
46+
47+
attr :names
48+
attr :patterns
49+
50+
# Select configured locales matching the given language ranges.
51+
# @parameter languages [Enumerable] Preferred language ranges.
52+
# @returns [Array(String)] Matching locale names in language preference order.
53+
def match(languages)
54+
languages.filter_map do |language|
55+
@patterns[language.name]
56+
end
57+
end
58+
end
59+
end
60+
end

lib/utopia/localization/middleware.rb

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
# Copyright, 2025-2026, by Samuel Williams.
55

66
require_relative "preferences"
7+
require_relative "locales"
78
require_relative "../middleware"
89
require_relative "../request"
910
require_relative "../response"
1011

1112
require "set"
13+
require "protocol/http/header/accept_language"
1214

1315
module Utopia
1416
module Localization
@@ -22,7 +24,7 @@ class Middleware < Protocol::HTTP::Middleware
2224
def initialize(app, locales:, default_locale: nil, default_locales: nil, hosts: {}, ignore: [])
2325
super(app)
2426

25-
@all_locales = HTTP::Accept::Languages::Locales.new(locales)
27+
@all_locales = Locales.new(locales)
2628

2729
# 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.
2830
unless @default_locales = default_locales
@@ -125,17 +127,17 @@ def extract_path_locale(request)
125127
# @parameter request [Utopia::Request] The application request.
126128
# @returns [Array(String)] Supported locales accepted by the browser, in preference order.
127129
def browser_preferred_locales(request)
128-
accept_languages = request.headers["accept-language"]&.to_s
130+
accept_languages = request.headers["accept-language"]
129131

130132
# No user prefered languages:
131133
return [] unless accept_languages
132134

133135
# Extract the ordered list of languages:
134-
languages = HTTP::Accept::Languages.parse(accept_languages)
136+
languages = accept_languages.preferred_languages
135137

136138
# Returns available languages based on the order languages:
137-
return @all_locales & languages
138-
rescue HTTP::Accept::ParseError
139+
return @all_locales.match(languages)
140+
rescue Protocol::HTTP::Header::AcceptLanguage::ParseError
139141
# If we fail to parse the browser Accept-Language header, we ignore it (silently).
140142
return []
141143
end

readme.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ Please see the [project releases](https://socketry.github.io/utopia/releases/ind
5757
- [Utopia::Gallery](https://github.com/ioquatix/utopia-gallery) — A fast photo gallery based on [libvips](https://github.com/jcupitt/libvips).
5858
- [Utopia::Project](https://github.com/socketry/utopia-project) — A Ruby project documentation tool.
5959
- [Utopia::Analytics](https://github.com/ioquatix/utopia-analytics) — Simple integration with Google Analytics.
60-
- [HTTP::Accept](https://github.com/ioquatix/http-accept) — RFC compliant header parser.
6160
- [Samovar](https://github.com/ioquatix/samovar) — Command line parser used by Utopia.
6261
- [Mapping](https://github.com/ioquatix/mapping) — Provide structured conversions for web interfaces.
6362

releases.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleasd
44

55
- **Security** Fix handling of redirects that start with `//` to prevent open redirect vulnerabilities.
6+
- Use `protocol-media` and `protocol-http` for response and language negotiation, removing the `http-accept` dependency.
67

78
## v2.31.0
89

setup/site/pages/welcome/index.xnode

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
<div>
1818
<i class="fa fa-code"></i>
1919
<h2>Well tested and maintained</h2>
20-
<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>
20+
<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>
2121
</div>
2222

2323
<div>

test/utopia/.performance/pages/api/controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# Copyright, 2016-2023, by Samuel Williams.
55

66
prepend Respond, Actions
7-
respond.with_json
7+
responds.with_json
88

99
on 'fetch' do
1010
succeed! [1, 2, 3]

0 commit comments

Comments
 (0)