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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/bake/utopia/environment.rb +6 -6
  4. data/bake/utopia/server.rb +4 -1
  5. data/bake/utopia/site.rb +14 -14
  6. data/bake/utopia/static.rb +26 -13
  7. data/bake/utopia.rb +1 -1
  8. data/context/getting-started.md +7 -5
  9. data/context/index.yaml +1 -2
  10. data/context/middleware.md +42 -13
  11. data/lib/utopia/application.rb +83 -0
  12. data/lib/utopia/content/builder.rb +25 -0
  13. data/lib/utopia/content/document.rb +64 -6
  14. data/lib/utopia/content/link.rb +50 -5
  15. data/lib/utopia/content/links.rb +44 -7
  16. data/lib/utopia/content/markup.rb +74 -0
  17. data/lib/utopia/content/middleware.rb +48 -20
  18. data/lib/utopia/content/namespace.rb +10 -1
  19. data/lib/utopia/content/node.rb +66 -4
  20. data/lib/utopia/content/response.rb +12 -3
  21. data/lib/utopia/content.rb +3 -0
  22. data/lib/utopia/controller/actions.md +4 -4
  23. data/lib/utopia/controller/actions.rb +51 -3
  24. data/lib/utopia/controller/base.rb +72 -29
  25. data/lib/utopia/controller/middleware.rb +20 -15
  26. data/lib/utopia/controller/respond.rb +36 -32
  27. data/lib/utopia/controller/responder.rb +106 -45
  28. data/lib/utopia/controller/result.rb +11 -0
  29. data/lib/utopia/controller/rewrite.rb +39 -0
  30. data/lib/utopia/controller/variables.rb +15 -3
  31. data/lib/utopia/controller.rb +2 -0
  32. data/lib/utopia/exceptions/handler.rb +20 -11
  33. data/lib/utopia/exceptions/mailer.rb +56 -51
  34. data/lib/utopia/extensions/array_split.rb +6 -0
  35. data/lib/utopia/extensions/date_comparisons.rb +6 -0
  36. data/lib/utopia/http.rb +11 -48
  37. data/lib/utopia/import_map.rb +19 -11
  38. data/lib/utopia/localization/locales.rb +60 -0
  39. data/lib/utopia/localization/middleware.rb +107 -73
  40. data/lib/utopia/localization/preferences.rb +76 -0
  41. data/lib/utopia/localization/resolver.rb +47 -0
  42. data/lib/utopia/localization.rb +6 -0
  43. data/lib/utopia/middleware.rb +3 -4
  44. data/lib/utopia/path/matcher.rb +15 -0
  45. data/lib/utopia/path.rb +146 -10
  46. data/lib/utopia/redirection/client_redirect.rb +87 -0
  47. data/lib/utopia/redirection/directory_index.rb +41 -0
  48. data/lib/utopia/redirection/errors.rb +78 -0
  49. data/lib/utopia/redirection/moved.rb +52 -0
  50. data/lib/utopia/redirection/request_failure.rb +26 -0
  51. data/lib/utopia/redirection/rewrite.rb +42 -0
  52. data/lib/utopia/redirection.rb +7 -165
  53. data/lib/utopia/request.rb +202 -0
  54. data/lib/utopia/response.rb +73 -0
  55. data/lib/utopia/session/lazy_hash.rb +27 -1
  56. data/lib/utopia/session/middleware.rb +103 -30
  57. data/lib/utopia/session/serialization.rb +8 -0
  58. data/lib/utopia/session.rb +5 -0
  59. data/lib/utopia/setup.rb +23 -3
  60. data/lib/utopia/shell.rb +28 -7
  61. data/lib/utopia/static/local_file.rb +84 -61
  62. data/lib/utopia/static/middleware.rb +65 -33
  63. data/lib/utopia/static/mime_types.rb +38 -29
  64. data/lib/utopia/static.rb +2 -0
  65. data/lib/utopia/version.rb +3 -2
  66. data/lib/utopia.rb +1 -1
  67. data/license.md +1 -1
  68. data/readme.md +33 -4
  69. data/releases.md +15 -0
  70. data/setup/site/bake.rb +1 -1
  71. data/setup/site/config/application.rb +51 -0
  72. data/setup/site/config/serve.rb +8 -0
  73. data/setup/site/falcon.rb +17 -4
  74. data/setup/site/fixtures/website.rb +27 -11
  75. data/setup/site/gems.rb +1 -3
  76. data/setup/site/lib/readme.txt +1 -1
  77. data/setup/site/pages/welcome/index.xnode +3 -3
  78. data/setup/site/readme.md +0 -3
  79. data/setup/site/test/website.rb +2 -2
  80. data.tar.gz.sig +0 -0
  81. metadata +60 -34
  82. metadata.gz.sig +0 -0
  83. data/lib/utopia/localization/wrapper.rb +0 -52
  84. data/setup/site/Guardfile +0 -12
  85. 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 rack response if the controller can do so.
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 app as defined by rack.
83
- def call(env)
84
- self.class.controller.app.call(env)
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
- # This will cause the middleware to generate a response.
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
- respond!(response) if response
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
- respond! [status.to_i, {HTTP::LOCATION => location}, [status.to_s]]
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
- respond! [status.to_i, {}, [message]]
173
+ throw :response, Result.new(status.to_i, {}, message)
121
174
  end
122
175
 
123
- # Succeed the request and immediately respond.
124
- def succeed!(status: 200, headers: {}, type: nil, **options)
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
- if type
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, 2009-2025, by Samuel Williams.
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
- class Middleware
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
- @app = app
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
- attr :app
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 = Path.from_string(request.path_info)
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.env[VARIABLES_KEY]
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 relative_path, which is copied into controller_path. The controllers may have rewriten the path so we update the path info:
114
- request.env[Rack::PATH_INFO] = controller_path.to_s
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
- def call(env)
121
- env[VARIABLES_KEY] ||= Variables.new
122
-
123
- request = Rack::Request.new(env)
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 @app.call(env)
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
- alias respond responds
23
-
24
- def respond_to(context, request)
25
- @responder&.respond_to(context, request)
26
- end
27
-
28
- def response_for(context, request, response)
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
- def respond_to(request)
34
- self.class.respond_to(self, request)
35
- end
36
-
37
- def response_for(request, original_response)
38
- response = catch(:response) do
39
- self.class.response_for(self, request, original_response)
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
- # If the above code did not throw a new response, we return the original:
42
- return original_response
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
- # If the user called {Base#ignore!}, it's possible response is nil:
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
- # Invokes super. If a response is generated, format it based on the Accept: header, unless the content type was already specified.
58
+ # Serialize semantic controller results, while passing complete protocol responses through unchanged.
53
59
  def process!(request, path)
54
- if response = super
55
- headers = response[1]
56
-
57
- # Don't try to convert the response if a content type was explicitly specified.
58
- if headers[HTTP::CONTENT_TYPE]
59
- return response
60
- else
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 = HTTP::Accept::ContentType.new("application", "json").freeze
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
- context.succeed! content: object.to_json(options), type: APPLICATION_JSON
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 = HTTP::Accept::MediaTypes::MediaRange.new("*", "*").freeze
45
+ WILDCARD = Protocol::Media::Range.new("*", "*").freeze
29
46
 
30
- def self.split(*arguments)
31
- WILDCARD.split(*arguments)
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
- def self.call(context, request, media_range, object, **options)
35
- # Do nothing.
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
- def split(*arguments)
43
- self.content_type.split(*arguments)
44
- end
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
- Responds = Struct.new(:responder, :context, :request) do
52
- # @todo Refactor `object` -> `*arguments`...
53
- def with(object, **options)
54
- responder.call(context, request, object, **options)
55
- end
56
- end
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
- @handlers.freeze
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
- handler, media_range = @handlers.for(media_types)
97
+ @handlers.freeze
75
98
 
76
- if handler
77
- handler.call(context, request, media_range, *arguments, **options)
78
- end
99
+ return super
79
100
  end
80
101
 
81
- # Add a converter for the specified content type. Call the block with the response content if the request accepts the specified content_type.
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 << Handler.new(content_type, block)
84
- end
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 << Handlers::JSON
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
- @handlers << Handlers::Passthrough
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