Skip to content

Commit e7e10c2

Browse files
Use protocol media for response negotiation.
Assisted-By: devx/b3414b50-d642-461b-95a8-8f773c4d087b
1 parent 8000336 commit e7e10c2

9 files changed

Lines changed: 113 additions & 76 deletions

File tree

lib/utopia/controller/respond.rb

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,24 @@ def self.prepended(base)
2020

2121
# Defines response handlers on controller classes.
2222
module ClassMethods
23-
# Return this controller's responder.
24-
# @returns [Responder] The responder.
23+
# Return this controller's responder builder.
24+
# @returns [Responder::Builder] The responder builder.
2525
def responds
26-
@responder ||= Responder.new
26+
@responder_builder ||= Responder::Builder.new
2727
end
2828

29-
alias respond responds
29+
# Compile the response declarations before freezing the controller class.
30+
# @returns [self] This controller class.
31+
def freeze
32+
return self if frozen?
33+
34+
if @responder_builder
35+
@responder = @responder_builder.build
36+
@responder_builder = nil
37+
end
38+
39+
return super
40+
end
3041

3142
# Serialize a semantic value according to the request's accepted media types.
3243
# @parameter context [Controller::Base] The controller context.

lib/utopia/controller/responder.rb

Lines changed: 82 additions & 67 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,66 +79,97 @@ 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+
# Incrementally constructs an immutable responder.
83+
class Builder
84+
# Initialize an empty responder builder.
85+
def initialize
86+
@handlers = Protocol::Media::Map::Builder.new
87+
@passthrough = nil
88+
end
89+
90+
# Add a serializer for the specified content type.
91+
# @parameter content_type [String | Protocol::Media::Type] The produced media type.
92+
# @yields The response handler body.
93+
# @returns [self] This builder.
94+
def handle(content_type, &block)
95+
@handlers[content_type] = Handler.new(content_type, block).freeze
96+
return self
97+
end
98+
99+
# Register the default JSON handler.
100+
# @returns [self] This builder.
101+
def with_json
102+
@handlers[Handlers::JSON::APPLICATION_JSON] = Handlers::JSON
103+
return self
104+
end
105+
106+
# Register the wildcard passthrough handler.
107+
# @returns [self] This builder.
108+
def with_passthrough
109+
@passthrough = Handlers::Passthrough
110+
return self
111+
end
112+
113+
# Add a serializer for the specified content type.
114+
# @parameter content_type [String | Protocol::Media::Type] The produced media type.
115+
# @yields The response handler body.
116+
# @returns [self] This builder.
117+
def with(content_type, &block)
118+
return handle(content_type, &block)
119+
end
120+
121+
# Compile the declarations into an immutable responder.
122+
# @returns [Responder] The responder.
123+
def build
124+
return Responder.new(@handlers.build, @passthrough).freeze
125+
end
101126
end
102127

103-
attr :handlers
128+
# Construct an immutable responder using a builder.
129+
# @yields {|builder| ...} The mutable builder.
130+
# @returns [Responder] The responder.
131+
def self.build
132+
builder = Builder.new
133+
yield builder
134+
return builder.build
135+
end
104136

105-
# Freeze this object and its internal state.
106-
# @returns [self] This object.
107-
def freeze
108-
@handlers.freeze
109-
110-
super
137+
# Initialize a responder with an immutable handler map.
138+
# @parameter handlers [Protocol::Media::Map] The response handlers.
139+
# @parameter passthrough [Object | Nil] The fallback response handler.
140+
def initialize(handlers, passthrough = nil)
141+
@handlers = handlers
142+
@passthrough = passthrough
111143
end
112144

145+
attr :handlers
146+
113147
# Negotiate the request's accepted media types and invoke the best handler.
114148
# @parameter context [Object] The controller context.
115149
# @parameter request [Utopia::Request] The request.
116150
# @parameter arguments [Array] The arguments.
117151
# @parameter options [Hash] The options.
118152
# @returns [Array(Object, Object) | Nil] The selected content type and body, or `nil` if none matches.
119153
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-
)
154+
if accept = request.headers["accept"]
155+
media_types = accept.media_ranges.sort
156+
else
157+
media_types = [Handlers::Passthrough::WILDCARD]
158+
end
124159

125-
handler, media_range = @handlers.for(media_types)
160+
if match = @handlers.for(media_types)
161+
handler, media_range = match
162+
elsif @passthrough
163+
handler = @passthrough
164+
media_range = media_types.first
165+
end
126166

127167
if handler
128168
return handler.content_type, handler.call(context, request, media_range, *arguments, **options)
129169
end
130170

131171
return nil
132172
end
133-
134-
# Add a serializer for the specified content type.
135-
def handle(content_type, &block)
136-
@handlers << Handler.new(content_type, block)
137-
end
138-
139-
# Register the default JSON handler.
140-
# @returns [HTTP::Accept::MediaTypes::Map] The updated handler map.
141-
def with_json
142-
@handlers << Handlers::JSON
143-
end
144-
145-
# Register the wildcard passthrough handler.
146-
# @returns [HTTP::Accept::MediaTypes::Map] The updated handler map.
147-
def with_passthrough
148-
@handlers << Handlers::Passthrough
149-
end
150-
151-
# Invoke the responder with the given object.
152-
# @parameter content_type [String] The content type.
153-
# @yields The response handler body.
154-
# @returns [HTTP::Accept::MediaTypes::Map] The updated handler map.
155-
def with(content_type, &block)
156-
handle(content_type, &block)
157-
end
158173
end
159174
end
160175
end

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]

test/utopia/controller/.respond/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
class VersionedResponse
1010
def to_json(options = {})

test/utopia/controller/.respond/html/controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
prepend Respond, Actions
77

88
# Respond with json:
9-
respond.with_json
9+
responds.with_json
1010

1111
# This method should return HTML, even thought this controller responds with JSON.
1212
on 'hello-world' do

test/utopia/controller/.respond/rewrite/controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
prepend Respond, Rewrite, Actions
77

8-
respond.with_json
8+
responds.with_json
99

1010
rewrite.extract_prefix id: Integer do |request|
1111
fail! :not_found, message: "Could not find record" if @id == 1

test/utopia/controller/.websocket/server/controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
prepend Respond, Actions
77

8-
respond.with_passthrough
8+
responds.with_passthrough
99

1010
on 'events' do |request|
1111
upgrade = Async::WebSocket::Adapters::HTTP.open(request) do |connection|

test/utopia/controller/respond.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ def self.uri_path
4646
end
4747
end
4848

49+
TestController.freeze
50+
4951
let(:controller) {TestController.new}
5052

5153
def mock_request(path, headers = {})
@@ -117,6 +119,14 @@ def mock_request(path, headers = {})
117119
expect(response.read).to be == "Explicit"
118120
end
119121

122+
it "falls back to the passthrough handler" do
123+
responder = Utopia::Controller::Responder.build do |builder|
124+
builder.with_passthrough
125+
end
126+
request = Utopia::Request["GET", "/", {"accept" => "application/xml"}]
127+
128+
expect(responder.call(controller, request, "Hello World")).to be == [nil, "Hello World"]
129+
end
120130
end
121131

122132
describe Utopia::Controller do

utopia.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Gem::Specification.new do |spec|
3636
spec.add_dependency "net-smtp"
3737
spec.add_dependency "protocol-content", "~> 0.1"
3838
spec.add_dependency "protocol-http", "~> 0.68"
39+
spec.add_dependency "protocol-media", "~> 0.2"
3940
spec.add_dependency "protocol-url", "~> 0.10"
4041
spec.add_dependency "samovar", "~> 2.1"
4142
spec.add_dependency "traces", "~> 0.10"

0 commit comments

Comments
 (0)