utopia 2.32.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/bake/utopia/environment.rb +6 -6
- data/bake/utopia/server.rb +4 -1
- data/bake/utopia/site.rb +14 -14
- data/bake/utopia/static.rb +26 -13
- data/bake/utopia.rb +1 -1
- data/context/getting-started.md +7 -5
- data/context/index.yaml +1 -2
- data/context/middleware.md +42 -13
- data/lib/utopia/application.rb +83 -0
- data/lib/utopia/content/builder.rb +25 -0
- data/lib/utopia/content/document.rb +64 -6
- data/lib/utopia/content/link.rb +50 -5
- data/lib/utopia/content/links.rb +44 -7
- data/lib/utopia/content/markup.rb +74 -0
- data/lib/utopia/content/middleware.rb +48 -20
- data/lib/utopia/content/namespace.rb +10 -1
- data/lib/utopia/content/node.rb +66 -4
- data/lib/utopia/content/response.rb +12 -3
- data/lib/utopia/content.rb +3 -0
- data/lib/utopia/controller/actions.md +4 -4
- data/lib/utopia/controller/actions.rb +51 -3
- data/lib/utopia/controller/base.rb +72 -29
- data/lib/utopia/controller/middleware.rb +20 -15
- data/lib/utopia/controller/respond.rb +36 -32
- data/lib/utopia/controller/responder.rb +106 -45
- data/lib/utopia/controller/result.rb +11 -0
- data/lib/utopia/controller/rewrite.rb +39 -0
- data/lib/utopia/controller/variables.rb +15 -3
- data/lib/utopia/controller.rb +2 -0
- data/lib/utopia/exceptions/handler.rb +20 -11
- data/lib/utopia/exceptions/mailer.rb +56 -51
- data/lib/utopia/extensions/array_split.rb +6 -0
- data/lib/utopia/extensions/date_comparisons.rb +6 -0
- data/lib/utopia/http.rb +11 -48
- data/lib/utopia/import_map.rb +19 -11
- data/lib/utopia/localization/locales.rb +60 -0
- data/lib/utopia/localization/middleware.rb +107 -73
- data/lib/utopia/localization/preferences.rb +76 -0
- data/lib/utopia/localization/resolver.rb +47 -0
- data/lib/utopia/localization.rb +6 -0
- data/lib/utopia/middleware.rb +3 -4
- data/lib/utopia/path/matcher.rb +15 -0
- data/lib/utopia/path.rb +146 -10
- data/lib/utopia/redirection/client_redirect.rb +87 -0
- data/lib/utopia/redirection/directory_index.rb +41 -0
- data/lib/utopia/redirection/errors.rb +78 -0
- data/lib/utopia/redirection/moved.rb +52 -0
- data/lib/utopia/redirection/request_failure.rb +26 -0
- data/lib/utopia/redirection/rewrite.rb +42 -0
- data/lib/utopia/redirection.rb +7 -165
- data/lib/utopia/request.rb +202 -0
- data/lib/utopia/response.rb +73 -0
- data/lib/utopia/session/lazy_hash.rb +27 -1
- data/lib/utopia/session/middleware.rb +103 -30
- data/lib/utopia/session/serialization.rb +8 -0
- data/lib/utopia/session.rb +5 -0
- data/lib/utopia/setup.rb +23 -3
- data/lib/utopia/shell.rb +28 -7
- data/lib/utopia/static/local_file.rb +84 -61
- data/lib/utopia/static/middleware.rb +65 -33
- data/lib/utopia/static/mime_types.rb +38 -29
- data/lib/utopia/static.rb +2 -0
- data/lib/utopia/version.rb +3 -2
- data/lib/utopia.rb +1 -1
- data/license.md +1 -1
- data/readme.md +33 -4
- data/releases.md +15 -0
- data/setup/site/bake.rb +1 -1
- data/setup/site/config/application.rb +51 -0
- data/setup/site/config/serve.rb +8 -0
- data/setup/site/falcon.rb +17 -4
- data/setup/site/fixtures/website.rb +27 -11
- data/setup/site/gems.rb +1 -3
- data/setup/site/lib/readme.txt +1 -1
- data/setup/site/pages/welcome/index.xnode +3 -3
- data/setup/site/readme.md +0 -3
- data/setup/site/test/website.rb +2 -2
- data.tar.gz.sig +0 -0
- metadata +60 -34
- metadata.gz.sig +0 -0
- data/lib/utopia/localization/wrapper.rb +0 -52
- data/setup/site/Guardfile +0 -12
- data/setup/site/config.ru +0 -49
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
# Copyright, 2014-2025, by Samuel Williams.
|
|
5
5
|
|
|
6
6
|
require_relative "../http"
|
|
7
|
+
require_relative "../path"
|
|
8
|
+
require_relative "../response"
|
|
9
|
+
require_relative "result"
|
|
10
|
+
|
|
11
|
+
require "protocol/content/default"
|
|
7
12
|
|
|
8
13
|
module Utopia
|
|
9
14
|
module Controller
|
|
10
|
-
CONTENT_TYPE = HTTP::CONTENT_TYPE
|
|
11
|
-
|
|
12
15
|
# The base implementation of a controller class.
|
|
13
16
|
class Base
|
|
14
17
|
URI_PATH = nil
|
|
@@ -30,24 +33,34 @@ module Utopia
|
|
|
30
33
|
self.const_get(:CONTROLLER)
|
|
31
34
|
end
|
|
32
35
|
|
|
36
|
+
# Generate a debug representation of this object.
|
|
37
|
+
# @returns [String] The resulting string.
|
|
33
38
|
def self.inspect
|
|
34
39
|
"#{super}#{self.uri_path}"
|
|
35
40
|
end
|
|
36
41
|
|
|
42
|
+
# Convert this object to a string.
|
|
43
|
+
# @returns [String] The resulting string.
|
|
37
44
|
def self.to_s
|
|
38
45
|
self.inspect
|
|
39
46
|
end
|
|
40
47
|
|
|
48
|
+
# Convert this object to a string.
|
|
49
|
+
# @returns [String] The resulting string.
|
|
41
50
|
def to_s
|
|
42
51
|
"\#<#{self.class}>"
|
|
43
52
|
end
|
|
44
53
|
|
|
54
|
+
# Generate a debug representation of this object.
|
|
55
|
+
# @returns [String] The resulting string.
|
|
45
56
|
def inspect
|
|
46
57
|
details = self.instance_variables.map{|name| " #{name}=#{self.instance_variable_get(name)}"}
|
|
47
58
|
|
|
48
59
|
"\#<#{self.class}#{details.join}>"
|
|
49
60
|
end
|
|
50
61
|
|
|
62
|
+
# Freeze this object and its internal state.
|
|
63
|
+
# @returns [self] This object.
|
|
51
64
|
def self.freeze
|
|
52
65
|
# This ensures that all class variables are frozen.
|
|
53
66
|
self.instance_variables.each do |name|
|
|
@@ -57,17 +70,23 @@ module Utopia
|
|
|
57
70
|
super
|
|
58
71
|
end
|
|
59
72
|
|
|
73
|
+
# Check whether the path refers directly to this controller.
|
|
74
|
+
# @parameter path [Utopia::Path | String] The path.
|
|
75
|
+
# @returns [Boolean] Whether the path is directly contained by this controller's URI path.
|
|
60
76
|
def self.direct?(path)
|
|
61
77
|
path.dirname == uri_path
|
|
62
78
|
end
|
|
63
79
|
|
|
80
|
+
# Catch and return a response thrown while executing the block.
|
|
81
|
+
# @yields The controller operation that may throw a response.
|
|
82
|
+
# @returns [Protocol::HTTP::Response | Nil] The thrown response, or `nil` if the block completes.
|
|
64
83
|
def catch_response
|
|
65
84
|
catch(:response) do
|
|
66
85
|
yield and nil
|
|
67
86
|
end
|
|
68
87
|
end
|
|
69
88
|
|
|
70
|
-
# Return nil if this controller didn't do anything. Request will keep on processing. Return a valid
|
|
89
|
+
# Return nil if this controller didn't do anything. Request will keep on processing. Return a valid response if the controller can do so.
|
|
71
90
|
def process!(request, relative_path)
|
|
72
91
|
return nil
|
|
73
92
|
end
|
|
@@ -79,19 +98,48 @@ module Utopia
|
|
|
79
98
|
end
|
|
80
99
|
end
|
|
81
100
|
|
|
82
|
-
# Call into the next
|
|
83
|
-
def call(
|
|
84
|
-
self.class.controller.
|
|
101
|
+
# Call into the next application.
|
|
102
|
+
def call(request)
|
|
103
|
+
self.class.controller.delegate.call(request)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Parse the request body according to its media type.
|
|
107
|
+
# @parameter request [Utopia::Request] The request containing the body.
|
|
108
|
+
# @parameter parser [Protocol::Content::Parser] The content parser.
|
|
109
|
+
# @yields {|name, value| ...} Form entries, including streaming uploads.
|
|
110
|
+
# @returns [Object | Nil] The parsed body, or nil when there is no body.
|
|
111
|
+
def parse_body(request, parser: Protocol::Content::Parser.default, &block)
|
|
112
|
+
body = request.body
|
|
113
|
+
return unless body
|
|
114
|
+
|
|
115
|
+
input = body.to_io
|
|
116
|
+
error = nil
|
|
117
|
+
|
|
118
|
+
begin
|
|
119
|
+
return parser.parse(request.headers["content-type"], input, &block)
|
|
120
|
+
rescue => error
|
|
121
|
+
raise
|
|
122
|
+
ensure
|
|
123
|
+
input.close_read(error)
|
|
124
|
+
end
|
|
85
125
|
end
|
|
86
126
|
|
|
87
|
-
#
|
|
127
|
+
# Immediately respond with a complete protocol response.
|
|
128
|
+
# @parameter response [Protocol::HTTP::Response] The response.
|
|
129
|
+
# @returns [Object] This method does not return normally.
|
|
88
130
|
def respond!(response)
|
|
89
|
-
throw :response, response
|
|
131
|
+
throw :response, Utopia::Response.wrap(response)
|
|
90
132
|
end
|
|
91
133
|
|
|
92
134
|
# Respond with the response, but only if it's not nil.
|
|
135
|
+
# @parameter response [Protocol::HTTP::Response | Nil] The optional response.
|
|
136
|
+
# @returns [Nil] This method returns `nil` when no response is provided.
|
|
93
137
|
def respond?(response)
|
|
94
|
-
|
|
138
|
+
if response
|
|
139
|
+
return respond!(response)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
return nil
|
|
95
143
|
end
|
|
96
144
|
|
|
97
145
|
# This will cause the controller middleware to pass on the request.
|
|
@@ -102,9 +150,14 @@ module Utopia
|
|
|
102
150
|
# Request relative redirect. Respond with a redirect to the given target.
|
|
103
151
|
def redirect!(target, status = 302)
|
|
104
152
|
status = HTTP::Status.new(status, 300...400)
|
|
105
|
-
location = target.to_s
|
|
106
153
|
|
|
107
|
-
|
|
154
|
+
if target.is_a?(Utopia::Path)
|
|
155
|
+
location = target.to_url_path.encoded
|
|
156
|
+
else
|
|
157
|
+
location = target.to_s
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
respond! Utopia::Response[status.to_i, {HTTP::LOCATION => location}, [status.to_s]]
|
|
108
161
|
end
|
|
109
162
|
|
|
110
163
|
# Controller relative redirect.
|
|
@@ -117,28 +170,18 @@ module Utopia
|
|
|
117
170
|
status = HTTP::Status.new(error, 400...600)
|
|
118
171
|
|
|
119
172
|
message ||= status.to_s
|
|
120
|
-
|
|
173
|
+
throw :response, Result.new(status.to_i, {}, message)
|
|
121
174
|
end
|
|
122
175
|
|
|
123
|
-
# Succeed the request
|
|
124
|
-
|
|
176
|
+
# Succeed the request with a semantic value awaiting response negotiation.
|
|
177
|
+
# @parameter value [Object] The semantic result value.
|
|
178
|
+
# @parameter status [Integer | Symbol] The successful response status.
|
|
179
|
+
# @parameter headers [Hash] Additional response headers.
|
|
180
|
+
# @returns [Object] This method does not return normally.
|
|
181
|
+
def succeed!(value = nil, status: 200, headers: {})
|
|
125
182
|
status = HTTP::Status.new(status, 200...300)
|
|
126
183
|
|
|
127
|
-
|
|
128
|
-
headers[CONTENT_TYPE] = type.to_s
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
body = body_for(status, headers, options)
|
|
132
|
-
respond! [status.to_i, headers, body || []]
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
# Generate the body for the given status, headers and options.
|
|
136
|
-
def body_for(status, headers, options)
|
|
137
|
-
if body = options[:body]
|
|
138
|
-
return body
|
|
139
|
-
elsif content = options[:content]
|
|
140
|
-
return [content]
|
|
141
|
-
end
|
|
184
|
+
throw :response, Result.new(status.to_i, headers, value)
|
|
142
185
|
end
|
|
143
186
|
end
|
|
144
187
|
end
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Released under the MIT License.
|
|
4
|
-
# Copyright,
|
|
4
|
+
# Copyright, 2025-2026, by Samuel Williams.
|
|
5
5
|
|
|
6
6
|
require_relative "../path"
|
|
7
7
|
require_relative "../middleware"
|
|
8
|
+
require_relative "../request"
|
|
9
|
+
require_relative "../response"
|
|
8
10
|
|
|
9
11
|
require_relative "variables"
|
|
10
12
|
require_relative "base"
|
|
@@ -17,14 +19,16 @@ require "concurrent/map"
|
|
|
17
19
|
module Utopia
|
|
18
20
|
# A middleware which loads controller classes and invokes functionality based on the requested path.
|
|
19
21
|
module Controller
|
|
20
|
-
|
|
22
|
+
# Dispatches requests to filesystem-backed controller classes.
|
|
23
|
+
class Middleware < Protocol::HTTP::Middleware
|
|
21
24
|
# The controller filename.
|
|
22
25
|
CONTROLLER_RB = "controller.rb".freeze
|
|
23
26
|
|
|
24
27
|
# @param root [String] The content root where controllers will be loaded from.
|
|
25
28
|
# @param base [Class] The base class for controllers.
|
|
26
29
|
def initialize(app, root: Utopia::default_root, base: Controller::Base)
|
|
27
|
-
|
|
30
|
+
super(app)
|
|
31
|
+
|
|
28
32
|
@root = root
|
|
29
33
|
|
|
30
34
|
@controller_cache = Concurrent::Map.new
|
|
@@ -32,8 +36,8 @@ module Utopia
|
|
|
32
36
|
@base = base
|
|
33
37
|
end
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
# Freeze this object and its internal state.
|
|
40
|
+
# @returns [self] This object.
|
|
37
41
|
def freeze
|
|
38
42
|
return self if frozen?
|
|
39
43
|
|
|
@@ -82,7 +86,7 @@ module Utopia
|
|
|
82
86
|
|
|
83
87
|
# Invoke the controller layer for a given request. The request path may be rewritten.
|
|
84
88
|
def invoke_controllers(request)
|
|
85
|
-
request_path =
|
|
89
|
+
request_path = request.path
|
|
86
90
|
|
|
87
91
|
# The request path must be absolute. We could handle this internally but it is probably better for this to be an error:
|
|
88
92
|
raise ArgumentError.new("Invalid request path #{request_path}") unless request_path.absolute?
|
|
@@ -91,7 +95,7 @@ module Utopia
|
|
|
91
95
|
controller_path = Path.new
|
|
92
96
|
|
|
93
97
|
# Controller instance variables which eventually get processed by the view:
|
|
94
|
-
variables = request.
|
|
98
|
+
variables = request.variables
|
|
95
99
|
|
|
96
100
|
while request_path.components.any?
|
|
97
101
|
# We copy one path component from the relative path to the controller path at a time. The controller, when invoked, can modify the relative path (by assigning to relative_path.components). This allows for controller-relative rewrites, but only the remaining path postfix can be modified.
|
|
@@ -110,23 +114,24 @@ module Utopia
|
|
|
110
114
|
end
|
|
111
115
|
end
|
|
112
116
|
|
|
113
|
-
# Controllers can directly modify
|
|
114
|
-
request.
|
|
117
|
+
# Controllers can directly modify the remaining path. Preserve those rewrites at the request boundary:
|
|
118
|
+
request.path = controller_path
|
|
115
119
|
|
|
116
120
|
# No controller gave a useful result:
|
|
117
121
|
return nil
|
|
118
122
|
end
|
|
119
123
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
# Attach controller variables while processing the request.
|
|
125
|
+
# @parameter request [Utopia::Request] The request.
|
|
126
|
+
# @returns [Protocol::HTTP::Response] The controller or downstream response.
|
|
127
|
+
def call(request)
|
|
128
|
+
request.variables ||= Variables.new
|
|
124
129
|
|
|
125
130
|
if result = invoke_controllers(request)
|
|
126
|
-
return result
|
|
131
|
+
return Utopia::Response.wrap(result)
|
|
127
132
|
end
|
|
128
133
|
|
|
129
|
-
return @
|
|
134
|
+
return @delegate.call(request)
|
|
130
135
|
end
|
|
131
136
|
end
|
|
132
137
|
end
|
|
@@ -4,62 +4,66 @@
|
|
|
4
4
|
# Copyright, 2016-2025, by Samuel Williams.
|
|
5
5
|
|
|
6
6
|
require_relative "../http"
|
|
7
|
+
require_relative "../response"
|
|
7
8
|
require_relative "responder"
|
|
8
9
|
|
|
9
10
|
module Utopia
|
|
10
11
|
module Controller
|
|
11
12
|
# A controller layer which provides a convenient way to respond to different requested content types. The order in which you add converters matters, as it determines how the incoming Accept: header is mapped, e.g. the first converter is also defined as matching the media range */*.
|
|
12
13
|
module Respond
|
|
14
|
+
# Extend a controller class with response negotiation.
|
|
15
|
+
# @parameter base [Class] The controller class.
|
|
16
|
+
# @returns [Class] The extended controller class.
|
|
13
17
|
def self.prepended(base)
|
|
14
18
|
base.extend(ClassMethods)
|
|
15
19
|
end
|
|
16
20
|
|
|
21
|
+
# Defines response handlers on controller classes.
|
|
17
22
|
module ClassMethods
|
|
23
|
+
# Return this controller's responder.
|
|
24
|
+
# @returns [Responder] The responder.
|
|
18
25
|
def responds
|
|
19
26
|
@responder ||= Responder.new
|
|
20
27
|
end
|
|
21
28
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@responder&.respond_to(context, request).with(*response[2])
|
|
29
|
+
# Serialize a semantic value according to the request's accepted media types.
|
|
30
|
+
# @parameter context [Controller::Base] The controller context.
|
|
31
|
+
# @parameter request [Utopia::Request] The request.
|
|
32
|
+
# @parameter value [Object] The semantic value.
|
|
33
|
+
# @returns [Array(Object, Object) | Nil] The selected content type and body.
|
|
34
|
+
def response_for(context, request, value)
|
|
35
|
+
@responder&.call(context, request, value)
|
|
30
36
|
end
|
|
31
37
|
end
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def response_for(request,
|
|
38
|
-
response =
|
|
39
|
-
|
|
39
|
+
# Build a protocol response for a semantic controller result.
|
|
40
|
+
# @parameter request [Utopia::Request] The request.
|
|
41
|
+
# @parameter result [Controller::Result] The semantic controller result.
|
|
42
|
+
# @returns [Protocol::HTTP::Response] The response.
|
|
43
|
+
def response_for(request, result)
|
|
44
|
+
if response = self.class.response_for(self, request, result.value)
|
|
45
|
+
content_type, body = response
|
|
46
|
+
headers = result.headers.dup
|
|
40
47
|
|
|
41
|
-
|
|
42
|
-
|
|
48
|
+
if content_type
|
|
49
|
+
headers[HTTP::CONTENT_TYPE] = content_type.to_s
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
return Utopia::Response[result.status, headers, body]
|
|
43
53
|
end
|
|
44
54
|
|
|
45
|
-
|
|
46
|
-
if response
|
|
47
|
-
# There was an updated response so merge it:
|
|
48
|
-
return [original_response[0], original_response[1].merge(response[1]), response[2] || original_response[2]]
|
|
49
|
-
end
|
|
55
|
+
raise TypeError, "Could not negotiate a response for #{result.value.class}!"
|
|
50
56
|
end
|
|
51
57
|
|
|
52
|
-
#
|
|
58
|
+
# Serialize semantic controller results, while passing complete protocol responses through unchanged.
|
|
53
59
|
def process!(request, path)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return self.response_for(request, response)
|
|
62
|
-
end
|
|
60
|
+
result = super
|
|
61
|
+
|
|
62
|
+
case result
|
|
63
|
+
when Result
|
|
64
|
+
return self.response_for(request, result)
|
|
65
|
+
else
|
|
66
|
+
return result
|
|
63
67
|
end
|
|
64
68
|
end
|
|
65
69
|
end
|
|
@@ -5,98 +5,159 @@
|
|
|
5
5
|
|
|
6
6
|
require_relative "middleware"
|
|
7
7
|
|
|
8
|
+
require "protocol/http/header/accept"
|
|
9
|
+
require "protocol/media/map"
|
|
10
|
+
require "protocol/media/type"
|
|
11
|
+
require "protocol/media/range"
|
|
12
|
+
|
|
8
13
|
module Utopia
|
|
9
14
|
module Controller
|
|
15
|
+
# @namespace
|
|
10
16
|
module Handlers
|
|
17
|
+
# Serializes controller values as JSON responses.
|
|
11
18
|
module JSON
|
|
12
|
-
APPLICATION_JSON =
|
|
13
|
-
|
|
14
|
-
def self.split(*arguments)
|
|
15
|
-
APPLICATION_JSON.split(*arguments)
|
|
16
|
-
end
|
|
19
|
+
APPLICATION_JSON = Protocol::Media::Type.new("application", "json").freeze
|
|
17
20
|
|
|
21
|
+
# Serialize an object as JSON.
|
|
22
|
+
# @parameter context [Object] The context.
|
|
23
|
+
# @parameter request [Utopia::Request] The request.
|
|
24
|
+
# @parameter media_range [Protocol::HTTP::Header::Accept::MediaRange] The negotiated media range.
|
|
25
|
+
# @parameter object [Object] The object.
|
|
26
|
+
# @parameter options [Hash] The options.
|
|
27
|
+
# @returns [String] The serialized JSON body.
|
|
18
28
|
def self.call(context, request, media_range, object, **options)
|
|
19
29
|
if version = media_range.parameters["version"]
|
|
20
30
|
options[:version] = version.to_s
|
|
21
31
|
end
|
|
22
32
|
|
|
23
|
-
|
|
33
|
+
return object.to_json(options)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The media type produced by this handler.
|
|
37
|
+
# @returns [Protocol::Media::Type] The JSON media type.
|
|
38
|
+
def self.content_type
|
|
39
|
+
APPLICATION_JSON
|
|
24
40
|
end
|
|
25
41
|
end
|
|
26
42
|
|
|
43
|
+
# Passes response values through without transformation.
|
|
27
44
|
module Passthrough
|
|
28
|
-
WILDCARD =
|
|
45
|
+
WILDCARD = Protocol::Media::Range.new("*", "*").freeze
|
|
29
46
|
|
|
30
|
-
|
|
31
|
-
|
|
47
|
+
# Pass an object through without transformation.
|
|
48
|
+
# @parameter context [Object] The context.
|
|
49
|
+
# @parameter request [Utopia::Request] The request.
|
|
50
|
+
# @parameter media_range [Protocol::HTTP::Header::Accept::MediaRange] The negotiated media range.
|
|
51
|
+
# @parameter object [Object] The object.
|
|
52
|
+
# @parameter options [Hash] The options.
|
|
53
|
+
# @returns [Object] The original body.
|
|
54
|
+
def self.call(context, request, media_range, object, **options)
|
|
55
|
+
return object
|
|
32
56
|
end
|
|
33
57
|
|
|
34
|
-
|
|
35
|
-
|
|
58
|
+
# The passthrough handler does not specify a response media type.
|
|
59
|
+
# @returns [Nil] No media type.
|
|
60
|
+
def self.content_type
|
|
61
|
+
return nil
|
|
36
62
|
end
|
|
37
63
|
end
|
|
38
64
|
end
|
|
39
65
|
|
|
66
|
+
# Negotiates response content types and invokes the matching handler.
|
|
40
67
|
class Responder
|
|
68
|
+
# A content-type handler and its response block.
|
|
41
69
|
Handler = Struct.new(:content_type, :block) do
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
70
|
+
# Invoke this handler's block in the controller context.
|
|
71
|
+
# @parameter context [Object] The context.
|
|
72
|
+
# @parameter request [Utopia::Request] The request.
|
|
73
|
+
# @parameter media_range [Protocol::HTTP::Header::Accept::MediaRange] The negotiated media range.
|
|
74
|
+
# @parameter arguments [Array] The arguments.
|
|
75
|
+
# @parameter options [Hash] The options.
|
|
76
|
+
# @returns [Object] The handler block's result.
|
|
46
77
|
def call(context, request, media_range, *arguments, **options)
|
|
47
78
|
context.instance_exec(media_range, *arguments, **options, &self.block)
|
|
48
79
|
end
|
|
49
80
|
end
|
|
50
81
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def initialize
|
|
59
|
-
@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
|
|
60
88
|
end
|
|
61
89
|
|
|
62
90
|
attr :handlers
|
|
63
91
|
|
|
92
|
+
# Freeze this responder and compile its handler map.
|
|
93
|
+
# @returns [self] This responder.
|
|
64
94
|
def freeze
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
super
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def call(context, request, *arguments, **options)
|
|
71
|
-
# Parse the list of browser preferred content types and return ordered by priority:
|
|
72
|
-
media_types = HTTP::Accept::MediaTypes.browser_preferred_media_types(request.env)
|
|
95
|
+
return self if frozen?
|
|
73
96
|
|
|
74
|
-
|
|
97
|
+
@handlers.freeze
|
|
75
98
|
|
|
76
|
-
|
|
77
|
-
handler.call(context, request, media_range, *arguments, **options)
|
|
78
|
-
end
|
|
99
|
+
return super
|
|
79
100
|
end
|
|
80
101
|
|
|
81
|
-
# Add a
|
|
102
|
+
# 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.
|
|
82
106
|
def handle(content_type, &block)
|
|
83
|
-
@handlers
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def respond_to(context, request)
|
|
87
|
-
Responds.new(self, context, request)
|
|
107
|
+
@handlers[content_type] = Handler.new(content_type, block).freeze
|
|
108
|
+
return self
|
|
88
109
|
end
|
|
89
110
|
|
|
111
|
+
# Register the default JSON handler.
|
|
112
|
+
# @returns [self] This responder.
|
|
90
113
|
def with_json
|
|
91
|
-
@handlers
|
|
114
|
+
@handlers[Handlers::JSON::APPLICATION_JSON] = Handlers::JSON
|
|
115
|
+
return self
|
|
92
116
|
end
|
|
93
117
|
|
|
118
|
+
# Register the wildcard passthrough handler.
|
|
119
|
+
# @returns [self] This responder.
|
|
94
120
|
def with_passthrough
|
|
95
|
-
@
|
|
121
|
+
@passthrough = Handlers::Passthrough
|
|
122
|
+
return self
|
|
96
123
|
end
|
|
97
124
|
|
|
125
|
+
# Add a serializer for the specified content type.
|
|
126
|
+
# @parameter content_type [String | Protocol::Media::Type] The produced media type.
|
|
127
|
+
# @yields The response handler body.
|
|
128
|
+
# @returns [self] This responder.
|
|
98
129
|
def with(content_type, &block)
|
|
99
|
-
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
|
|
100
161
|
end
|
|
101
162
|
end
|
|
102
163
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
module Utopia
|
|
7
|
+
module Controller
|
|
8
|
+
# A semantic controller result awaiting response negotiation.
|
|
9
|
+
Result = Data.define(:status, :headers, :value)
|
|
10
|
+
end
|
|
11
|
+
end
|