From 4ec6e8a6b580c25a346c3e62928c06e28737df44 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 22 Jun 2026 13:02:12 +1200 Subject: [PATCH 01/16] Add protocol HTTP application boundary Assisted-By: devx/166ed168-1c4d-4c63-a5f6-8d0d9cbff13f --- lib/utopia.rb | 3 +- lib/utopia/application.rb | 74 ++++++++ lib/utopia/request.rb | 60 +++++++ lib/utopia/response.rb | 54 ++++++ plan.md | 348 +++++++++++++++++++++++++++++++++++++ test/utopia/application.rb | 78 +++++++++ test/utopia/request.rb | 32 ++++ test/utopia/response.rb | 28 +++ utopia.gemspec | 1 + 9 files changed, 677 insertions(+), 1 deletion(-) create mode 100644 lib/utopia/application.rb create mode 100644 lib/utopia/request.rb create mode 100644 lib/utopia/response.rb create mode 100644 plan.md create mode 100644 test/utopia/application.rb create mode 100644 test/utopia/request.rb create mode 100644 test/utopia/response.rb diff --git a/lib/utopia.rb b/lib/utopia.rb index e19499f..b101276 100644 --- a/lib/utopia.rb +++ b/lib/utopia.rb @@ -5,6 +5,7 @@ require_relative "utopia/version" +require_relative "utopia/application" require_relative "utopia/import_map" require_relative "utopia/content" require_relative "utopia/controller" @@ -12,6 +13,6 @@ require_relative "utopia/redirection" require_relative "utopia/static" -# Utopia is a web application framework built on top of Rack. +# Utopia is a web application framework. module Utopia end diff --git a/lib/utopia/application.rb b/lib/utopia/application.rb new file mode 100644 index 0000000..d2b920e --- /dev/null +++ b/lib/utopia/application.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/http/middleware" +require "protocol/http/middleware/builder" + +require_relative "request" +require_relative "response" + +module Utopia + # The protocol-facing entrypoint for a Utopia application. + # + # This object accepts {Protocol::HTTP::Request} instances, wraps them in a + # {Utopia::Request}, dispatches to the Utopia application stack, and normalizes + # the result back to a {Protocol::HTTP::Response}. + class Application < Protocol::HTTP::Middleware + CONFIGURATION_PATH = "config/application.rb".freeze + + def self.build(default_app = Response::NotFound, **options, &block) + builder = Protocol::HTTP::Middleware::Builder.new(default_app) + builder.build(&block) + + return self.new(builder.to_app, **options) + end + + def self.default(**options) + self.build(**options) + end + + def self.load(path = CONFIGURATION_PATH, **options) + if File.exist?(path) + Kernel.load(path) + + if Object.const_defined?(:Application, false) + application = Object.const_get(:Application) + + if application.is_a?(Class) + return application.new(**options) + else + return application + end + end + elsif Object.const_defined?(:Application, false) + application = Object.const_get(:Application) + + if application.is_a?(Class) + return application.new(**options) + else + return application + end + end + + return self.default(**options) + end + + def initialize(delegate, request_class: Request, response_class: Response) + super(delegate) + + @request_class = request_class + @response_class = response_class + end + + attr :request_class + attr :response_class + + def call(http_request) + request = @request_class.new(http_request) + + return @response_class.wrap(super(request)) + end + end +end diff --git a/lib/utopia/request.rb b/lib/utopia/request.rb new file mode 100644 index 0000000..c0a4e50 --- /dev/null +++ b/lib/utopia/request.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Utopia + # The application-facing request wrapper. + # + # This class intentionally keeps a small surface area. Framework features such + # as arguments, sessions, localization and controller variables should be added + # as explicit Utopia concepts rather than relying on a Rack-style env hash. + class Request + def initialize(http, attributes: nil) + @http = http + @attributes = attributes || {} + end + + # The underlying {Protocol::HTTP::Request}. + attr :http + + # Request-local application state. + attr :attributes + + def method + @http.method + end + + def path + @http.path + end + + def path=(value) + @http.path = value + end + + def path_info + @http.path&.split("?", 2)&.first + end + + def path_info=(value) + if query = self.query + @http.path = "#{value}?#{query}" + else + @http.path = value + end + end + + def query + @http.path&.split("?", 2)&.last if @http.path&.include?("?") + end + + def headers + @http.headers + end + + def body + @http.body + end + end +end diff --git a/lib/utopia/response.rb b/lib/utopia/response.rb new file mode 100644 index 0000000..e78569a --- /dev/null +++ b/lib/utopia/response.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/http/response" +require "protocol/http/middleware" + +module Utopia + # Response helpers for Utopia applications. + # + # The canonical transport response remains {Protocol::HTTP::Response}. This + # module provides convenience constructors and normalization at the application + # boundary. + module Response + CONTENT_TYPE = "content-type".freeze + LOCATION = "location".freeze + + NotFound = Protocol::HTTP::Middleware::NotFound + + def self.[](status, headers = nil, body = nil, **options) + Protocol::HTTP::Response[status, headers, body, **options] + end + + def self.wrap(response) + case response + when Protocol::HTTP::Response + response + when Array + Protocol::HTTP::Response[*response] + else + if response.respond_to?(:to_protocol_response) + response.to_protocol_response + elsif response.respond_to?(:to_ary) + Protocol::HTTP::Response[*response.to_ary] + else + response + end + end + end + + def self.redirect(location, status = 302, headers = {}) + self[status, headers.merge(LOCATION => location), []] + end + + def self.text(content, status = 200, headers = {}) + self[status, {CONTENT_TYPE => "text/plain; charset=utf-8"}.merge(headers), [content]] + end + + def self.html(content, status = 200, headers = {}) + self[status, {CONTENT_TYPE => "text/html; charset=utf-8"}.merge(headers), [content]] + end + end +end diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..970426b --- /dev/null +++ b/plan.md @@ -0,0 +1,348 @@ +# Utopia v3 Protocol HTTP Application Design + +## Direction + +Utopia v3 should move the core application interface from Rack to `protocol-http`. +Rack support can remain available through an adapter, but it should no longer be the +internal ABI for requests, responses, middleware, sessions, static files, or +controllers. + +The main goal is to keep the HTTP boundary small while giving Utopia its own +application request shape. Rack has been valuable because it codifies request, +response, and middleware conventions, but that same shared surface has made it hard +to evolve and hard for application frameworks to make different performance, +security, and usability choices. + +## Layering + +The proposed stack is: + +```text +Protocol::HTTP::Request + -> Utopia::Application + -> Utopia::Request + -> Utopia application middleware/controllers/content + -> Utopia::Response or Protocol::HTTP::Response shaped value + -> Protocol::HTTP::Response +``` + +`Utopia::Application` is the adaptation boundary. Everything above it is ordinary +`protocol-http` middleware. Everything below it is Utopia application middleware. + +## Application + +`Utopia::Application` should be directly usable anywhere a +`Protocol::HTTP::Middleware` is expected: + +```ruby +application = Utopia::Application.load +application.call(protocol_http_request) +application.close +``` + +Construction should keep object construction separate from DSL configuration: + +```ruby +Application = Utopia::Application.build do + use Utopia::Static, root: "public" + use Utopia::Controller + run Utopia::Content +end +``` + +Preferred API: + +```ruby +Utopia::Application.new(delegate, **options) +Utopia::Application.build(**options) { ... } +Utopia::Application.load(path = "config/application.rb", **options) +Utopia::Application.default(**options) +``` + +Responsibilities: + +- `new` wraps an already-built Utopia application stack. +- `build` evaluates the Utopia middleware DSL and returns a protocol-compatible + application. +- `load` loads the conventional application file and returns the configured + application. +- `default` returns a useful default Utopia site stack. + +Avoid accepting a block to `initialize`; use `Application.build do ... end` for +DSL configuration. + +`Utopia::Application.build` may use `Protocol::HTTP::Middleware::Builder` +internally for mechanical stack composition. Utopia does not need to expose a +separate builder class unless the DSL needs to diverge from the protocol builder. + +Even when the protocol builder is used internally, `Utopia::Application.build` +defines the Utopia application middleware contract and owns compatibility for +Utopia middleware. + +## Application Configuration + +The canonical app file should be: + +```text +config/application.rb +``` + +It should define a top-level `Application` constant: + +```ruby +require "utopia" + +Application = Utopia::Application.build do + use Utopia::Static, root: "public" + use Utopia::Controller + run Utopia::Content +end +``` + +The loader should also support a default when no `Application` constant exists. +This mirrors the useful pattern in Lively: a top-level `Application` constant for +normal projects, with a default fallback for quick starts and generic tooling. + +The `Application` constant may be either: + +- a configured middleware object, or +- a class/subclass that can be instantiated as protocol middleware. + +Utopia should normalize both cases internally. + +## Falcon Configuration + +Use the modern Falcon service definition shape. Do not use the old +`load :supervisor` style. + +Explicit app configuration: + +```ruby +require_relative "config/application" + +service "utopia" do + include Falcon::Environment::Server + + def middleware + Application + end +end +``` + +Generic/default configuration: + +```ruby +require "utopia" + +service "utopia" do + include Falcon::Environment::Server + + def middleware + Utopia::Application.load + end +end +``` + +## Request + +Introduce `Utopia::Request` as the application request shape. It should be thin, +explicit, and lazy, not a reimplementation of `Rack::Request`. + +Likely shape: + +```ruby +request.http +request.method +request.path +request.path_info +request.path_info= +request.query +request.headers +request.cookies +request.body +request.arguments +request.session +request.variables +request.locale +request.attributes +``` + +Guidelines: + +- Keep `request.http` available for direct access to the underlying + `Protocol::HTTP::Request`. +- Avoid a global magical `params` hash. +- Prefer `arguments` over `params`. +- Parse request data lazily. +- Keep query, form, JSON, and multipart parsing separable where possible. +- Use Utopia-owned request-local state rather than Rack-style `env`. + +Possible arguments shape: + +```ruby +request.arguments.query +request.arguments.form +request.arguments.json +request.arguments.multipart +``` + +## Response + +Use `Protocol::HTTP::Response` as the canonical transport response. + +`Utopia::Response` should be a helper/factory/normalizer, not necessarily a +mandatory rich response object: + +```ruby +Utopia::Response[200, {"content-type" => "text/plain"}, ["Hello"]] +Utopia::Response.redirect("/target") +Utopia::Response.text("Hello") +Utopia::Response.html(document) +``` + +Application middleware and controllers may return: + +- `Protocol::HTTP::Response` +- `Utopia::Response` values +- compatible response tuples, if supported during migration + +Normalize at the `Utopia::Application` boundary. + +## Middleware + +There should be two explicit middleware layers: + +1. HTTP middleware, operating on `Protocol::HTTP::Request` and + `Protocol::HTTP::Response`. +2. Utopia application middleware, operating on `Utopia::Request`. + +HTTP middleware is appropriate for low-level protocol behavior, tracing, +compression, authority policy, early routing, static transport optimizations, and +protocol upgrades. + +Application middleware is appropriate for sessions, localization, arguments, +content negotiation, controller variables, CSRF, authentication, and other +framework-specific semantics. + +The regular Utopia DSL should compose application middleware: + +```ruby +Utopia::Application.build do + use Utopia::Session + use Utopia::Localization, locales: ["en", "ja"] + run Utopia::Content +end +``` + +Utopia owns what `use` and `run` mean for application middleware. The app +middleware contract should be: + +```text +initialize(delegate, ...) +call(Utopia::Request) -> response-like value +``` + +and terminal apps should satisfy: + +```text +call(Utopia::Request) -> response-like value +``` + +`Utopia::Application.build` can decide compatibility details such as: + +- whether `use` accepts classes, objects, or both. +- whether `run Utopia::Content, root: ...` instantiates the app automatically. +- whether response tuples are accepted during migration. +- whether `close` is propagated through the stack. +- whether middleware may return `nil` to pass through. +- whether middleware may mutate `request.path_info`. +- how legacy Rack middleware is wrapped explicitly, if supported. + +Do not try to preserve Rack middleware compatibility in the core Utopia stack. +Provide Rack compatibility through an optional adapter if needed. + +## Programmatic Applications + +Frameworks and gems should be able to construct Utopia applications without relying +on project-level constants. + +For example, `utopia-project` should move from mutating a Rack builder: + +```ruby +Utopia::Project.call(builder) +``` + +to returning a protocol-compatible middleware: + +```ruby +module Utopia + module Project + def self.application(root: Dir.pwd, locales: nil) + Utopia::Application.build(root: root) do + use Utopia::Static, root: root + use Utopia::Static, root: PUBLIC_ROOT + + use Utopia::Redirection::Rewrite, "/" => "/index" + use Utopia::Redirection::DirectoryIndex + use Utopia::Redirection::Errors, 404 => "/errors/file-not-found" + + if locales + use Utopia::Localization, default_locale: locales.first, locales: locales + end + + use Utopia::Controller, root: PAGES_ROOT + run Utopia::Content, root: PAGES_ROOT + end + end + end +end +``` + +Consumers can then choose: + +```ruby +Application = Utopia::Project.application +``` + +or: + +```ruby +app = Utopia::Project.application(root: "/path/to/project") +``` + +## Shared Gem + +Do not extract a shared `protocol-http-application` gem initially. + +The generic code is likely small, and the useful pieces quickly become +framework-specific: default root, default file name, fallback behavior, request +wrapper, response helpers, error behavior, middleware DSL, and constant +resolution. + +Keep the implementation in Utopia first. Extract later only if multiple frameworks +end up sharing the same stable, low-opinion code. + +## Migration Notes + +Expected breaking changes: + +- Core Utopia middleware no longer receives Rack env hashes. +- Controllers no longer receive `Rack::Request`. +- `env[...]`, `rack.session`, `rack.input`, and Rack response tuple assumptions + need migration. +- Static file serving should move away from `Rack::Sendfile` and Rack range + helpers. +- `config.ru` should become optional Rack compatibility rather than the native + boot path. +- Tests should move from `rack-test` to protocol-http/async-http oriented tests. + +Useful preparatory work before the v3 transport change: + +- Introduce internal request/response helpers while still Rack-backed. +- Replace direct `Rack::PATH_INFO`, `Rack::HTTP_HOST`, etc. usage with local + accessors. +- Move cookie parsing and serialization behind Utopia-owned helpers. +- Isolate static range/sendfile behavior from `Rack::Utils`. +- Make session storage names Utopia-native, with Rack aliases only for + compatibility. +- Start normalizing response values internally. diff --git a/test/utopia/application.rb b/test/utopia/application.rb new file mode 100644 index 0000000..a166b29 --- /dev/null +++ b/test/utopia/application.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/http/request" +require "tmpdir" +require "utopia/application" + +describe Utopia::Application do + let(:http_request) {Protocol::HTTP::Request["GET", "/hello?name=sam"]} + + it "wraps protocol requests for the application stack" do + application_request = nil + + application = subject.build do + run lambda {|request| + application_request = request + + Utopia::Response.text("Hello") + } + end + + response = application.call(http_request) + + expect(application_request).to be_a(Utopia::Request) + expect(application_request.http).to be_equal(http_request) + expect(application_request.path_info).to be == "/hello" + expect(application_request.query).to be == "name=sam" + + expect(response).to be_a(Protocol::HTTP::Response) + expect(response.status).to be == 200 + expect(response.headers["content-type"]).to be == "text/plain; charset=utf-8" + end + + it "normalizes tuple responses" do + application = subject.build do + run lambda {|request| [201, {"content-type" => "text/plain"}, ["Created: ", request.path_info]]} + end + + response = application.call(http_request) + + expect(response).to be_a(Protocol::HTTP::Response) + expect(response.status).to be == 201 + expect(response.headers["content-type"]).to be == "text/plain" + end + + it "uses a not found default" do + application = subject.default + + response = application.call(http_request) + + expect(response).to be_a(Protocol::HTTP::Response) + expect(response.status).to be == 404 + end + + it "loads a top-level application constant" do + Dir.mktmpdir do |directory| + path = File.join(directory, "application.rb") + + File.write(path, <<~RUBY) + require "utopia/application" + + Application = Utopia::Application.build do + run lambda {|request| Utopia::Response.text(request.path_info)} + end + RUBY + + application = subject.load(path) + response = application.call(http_request) + + expect(response.status).to be == 200 + expect(response.read).to be == "/hello" + ensure + Object.send(:remove_const, :Application) if Object.const_defined?(:Application, false) + end + end +end diff --git a/test/utopia/request.rb b/test/utopia/request.rb new file mode 100644 index 0000000..be1b4bb --- /dev/null +++ b/test/utopia/request.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/http/request" +require "utopia/request" + +describe Utopia::Request do + let(:http_request) {Protocol::HTTP::Request["POST", "/search?q=utopia"]} + let(:request) {subject.new(http_request)} + + it "exposes the underlying protocol request" do + expect(request.http).to be_equal(http_request) + expect(request.method).to be == "POST" + expect(request.path).to be == "/search?q=utopia" + expect(request.path_info).to be == "/search" + expect(request.query).to be == "q=utopia" + end + + it "updates path info while preserving the query string" do + request.path_info = "/find" + + expect(request.path).to be == "/find?q=utopia" + end + + it "provides request-local attributes" do + request.attributes[:locale] = "en" + + expect(request.attributes[:locale]).to be == "en" + end +end diff --git a/test/utopia/response.rb b/test/utopia/response.rb new file mode 100644 index 0000000..a5e8637 --- /dev/null +++ b/test/utopia/response.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "utopia/response" + +describe Utopia::Response do + it "builds protocol responses" do + response = subject[200, {"content-type" => "text/plain"}, ["Hello"]] + + expect(response).to be_a(Protocol::HTTP::Response) + expect(response.status).to be == 200 + end + + it "builds redirects" do + response = subject.redirect("/target") + + expect(response.status).to be == 302 + expect(response.headers["location"]).to be == "/target" + end + + it "passes through protocol responses" do + response = Protocol::HTTP::Response[204] + + expect(subject.wrap(response)).to be_equal(response) + end +end diff --git a/utopia.gemspec b/utopia.gemspec index 9c20128..7429456 100644 --- a/utopia.gemspec +++ b/utopia.gemspec @@ -34,6 +34,7 @@ Gem::Specification.new do |spec| spec.add_dependency "mime-types", "~> 3.0" spec.add_dependency "msgpack" spec.add_dependency "net-smtp" + spec.add_dependency "protocol-http", "~> 0.62" spec.add_dependency "protocol-url", "~> 0.4" spec.add_dependency "rack", "~> 3.0" spec.add_dependency "samovar", "~> 2.1" From d6b8c45608a48103d342b9818e6b50b1d75d920c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 22 Jun 2026 13:05:43 +1200 Subject: [PATCH 02/16] Fix protocol application CI checks Assisted-By: devx/166ed168-1c4d-4c63-a5f6-8d0d9cbff13f --- lib/utopia/application.rb | 28 ++++++++++ lib/utopia/request.rb | 13 +++++ lib/utopia/response.rb | 24 +++++++++ plan.md | 104 ++++++++++++++++++------------------- test/utopia/application.rb | 6 +-- 5 files changed, 120 insertions(+), 55 deletions(-) diff --git a/lib/utopia/application.rb b/lib/utopia/application.rb index d2b920e..f57915a 100644 --- a/lib/utopia/application.rb +++ b/lib/utopia/application.rb @@ -18,6 +18,11 @@ module Utopia class Application < Protocol::HTTP::Middleware CONFIGURATION_PATH = "config/application.rb".freeze + # Build a Utopia application stack using the protocol HTTP middleware builder. + # @parameter default_app [Interface(:call)] The terminal application used when the block does not call `run`. + # @parameter options [Hash] Options passed to {.new}. + # @parameter block [Proc] The middleware builder block. + # @returns [Application] The protocol-facing Utopia application. def self.build(default_app = Response::NotFound, **options, &block) builder = Protocol::HTTP::Middleware::Builder.new(default_app) builder.build(&block) @@ -25,10 +30,23 @@ def self.build(default_app = Response::NotFound, **options, &block) return self.new(builder.to_app, **options) end + # Build the default Utopia application. + # @parameter options [Hash] Options passed to {.build}. + # @returns [Application] The default protocol-facing Utopia application. def self.default(**options) self.build(**options) end + # Load a Utopia application from a conventional configuration file. + # + # If the file defines a top-level `Application` constant, it will be + # returned directly. If the constant is a class, it will be instantiated. + # If the file does not exist, or does not define `Application`, the default + # application is returned. + # + # @parameter path [String] The application configuration path. + # @parameter options [Hash] Options passed to the application constructor. + # @returns [Interface(:call)] The loaded protocol-facing application. def self.load(path = CONFIGURATION_PATH, **options) if File.exist?(path) Kernel.load(path) @@ -55,6 +73,10 @@ def self.load(path = CONFIGURATION_PATH, **options) return self.default(**options) end + # Initialize the protocol-facing application boundary. + # @parameter delegate [Interface(:call)] The Utopia application stack. + # @parameter request_class [Class] The request wrapper class. + # @parameter response_class [#wrap] The response normalization object. def initialize(delegate, request_class: Request, response_class: Response) super(delegate) @@ -62,9 +84,15 @@ def initialize(delegate, request_class: Request, response_class: Response) @response_class = response_class end + # @attribute [Class] The request wrapper class. attr :request_class + + # @attribute [#wrap] The response normalization object. attr :response_class + # Process a protocol HTTP request. + # @parameter http_request [Protocol::HTTP::Request] The incoming protocol request. + # @returns [Protocol::HTTP::Response] The normalized protocol response. def call(http_request) request = @request_class.new(http_request) diff --git a/lib/utopia/request.rb b/lib/utopia/request.rb index c0a4e50..2cc3138 100644 --- a/lib/utopia/request.rb +++ b/lib/utopia/request.rb @@ -10,6 +10,9 @@ module Utopia # as arguments, sessions, localization and controller variables should be added # as explicit Utopia concepts rather than relying on a Rack-style env hash. class Request + # Initialize a request wrapper. + # @parameter http [Protocol::HTTP::Request] The underlying protocol request. + # @parameter attributes [Hash | Nil] Request-local application state. def initialize(http, attributes: nil) @http = http @attributes = attributes || {} @@ -21,22 +24,29 @@ def initialize(http, attributes: nil) # Request-local application state. attr :attributes + # @returns [String] The HTTP request method. def method @http.method end + # @returns [String] The full request path, including query string. def path @http.path end + # Set the full request path. + # @parameter value [String] The full request path, including optional query string. def path=(value) @http.path = value end + # @returns [String | Nil] The request path without query string. def path_info @http.path&.split("?", 2)&.first end + # Set the request path while preserving the current query string. + # @parameter value [String] The request path without query string. def path_info=(value) if query = self.query @http.path = "#{value}?#{query}" @@ -45,14 +55,17 @@ def path_info=(value) end end + # @returns [String | Nil] The query string without the leading `?`. def query @http.path&.split("?", 2)&.last if @http.path&.include?("?") end + # @returns [Protocol::HTTP::Headers] The request headers. def headers @http.headers end + # @returns [Protocol::HTTP::Body::Readable | Nil] The request body. def body @http.body end diff --git a/lib/utopia/response.rb b/lib/utopia/response.rb index e78569a..0888aa7 100644 --- a/lib/utopia/response.rb +++ b/lib/utopia/response.rb @@ -18,10 +18,19 @@ module Response NotFound = Protocol::HTTP::Middleware::NotFound + # Build a protocol HTTP response. + # @parameter status [Integer] The HTTP status code. + # @parameter headers [Hash | Protocol::HTTP::Headers | Nil] The response headers. + # @parameter body [Object] The response body. + # @parameter options [Hash] Additional options passed to `Protocol::HTTP::Response[]`. + # @returns [Protocol::HTTP::Response] The response object. def self.[](status, headers = nil, body = nil, **options) Protocol::HTTP::Response[status, headers, body, **options] end + # Normalize a response-like value to a protocol response. + # @parameter response [Object] The response-like value. + # @returns [Protocol::HTTP::Response | Object] The normalized response, or the original object if it cannot be normalized. def self.wrap(response) case response when Protocol::HTTP::Response @@ -39,14 +48,29 @@ def self.wrap(response) end end + # Build a redirect response. + # @parameter location [String] The redirect location. + # @parameter status [Integer] The redirect status code. + # @parameter headers [Hash] Additional response headers. + # @returns [Protocol::HTTP::Response] The redirect response. def self.redirect(location, status = 302, headers = {}) self[status, headers.merge(LOCATION => location), []] end + # Build a plain text response. + # @parameter content [String] The response content. + # @parameter status [Integer] The response status. + # @parameter headers [Hash] Additional response headers. + # @returns [Protocol::HTTP::Response] The text response. def self.text(content, status = 200, headers = {}) self[status, {CONTENT_TYPE => "text/plain; charset=utf-8"}.merge(headers), [content]] end + # Build an HTML response. + # @parameter content [String] The response content. + # @parameter status [Integer] The response status. + # @parameter headers [Hash] Additional response headers. + # @returns [Protocol::HTTP::Response] The HTML response. def self.html(content, status = 200, headers = {}) self[status, {CONTENT_TYPE => "text/html; charset=utf-8"}.merge(headers), [content]] end diff --git a/plan.md b/plan.md index 970426b..cc1f736 100644 --- a/plan.md +++ b/plan.md @@ -34,7 +34,7 @@ Protocol::HTTP::Request `Utopia::Application` should be directly usable anywhere a `Protocol::HTTP::Middleware` is expected: -```ruby +```text application = Utopia::Application.load application.call(protocol_http_request) application.close @@ -42,17 +42,17 @@ application.close Construction should keep object construction separate from DSL configuration: -```ruby +```text Application = Utopia::Application.build do - use Utopia::Static, root: "public" - use Utopia::Controller - run Utopia::Content + use Utopia::Static, root: "public" + use Utopia::Controller + run Utopia::Content end ``` Preferred API: -```ruby +```text Utopia::Application.new(delegate, **options) Utopia::Application.build(**options) { ... } Utopia::Application.load(path = "config/application.rb", **options) @@ -89,13 +89,13 @@ config/application.rb It should define a top-level `Application` constant: -```ruby +```text require "utopia" Application = Utopia::Application.build do - use Utopia::Static, root: "public" - use Utopia::Controller - run Utopia::Content + use Utopia::Static, root: "public" + use Utopia::Controller + run Utopia::Content end ``` @@ -117,29 +117,29 @@ Use the modern Falcon service definition shape. Do not use the old Explicit app configuration: -```ruby +```text require_relative "config/application" service "utopia" do - include Falcon::Environment::Server - - def middleware - Application - end + include Falcon::Environment::Server + + def middleware + Application + end end ``` Generic/default configuration: -```ruby +```text require "utopia" service "utopia" do - include Falcon::Environment::Server - - def middleware - Utopia::Application.load - end + include Falcon::Environment::Server + + def middleware + Utopia::Application.load + end end ``` @@ -150,7 +150,7 @@ explicit, and lazy, not a reimplementation of `Rack::Request`. Likely shape: -```ruby +```text request.http request.method request.path @@ -179,7 +179,7 @@ Guidelines: Possible arguments shape: -```ruby +```text request.arguments.query request.arguments.form request.arguments.json @@ -193,7 +193,7 @@ Use `Protocol::HTTP::Response` as the canonical transport response. `Utopia::Response` should be a helper/factory/normalizer, not necessarily a mandatory rich response object: -```ruby +```text Utopia::Response[200, {"content-type" => "text/plain"}, ["Hello"]] Utopia::Response.redirect("/target") Utopia::Response.text("Hello") @@ -226,11 +226,11 @@ framework-specific semantics. The regular Utopia DSL should compose application middleware: -```ruby +```text Utopia::Application.build do - use Utopia::Session - use Utopia::Localization, locales: ["en", "ja"] - run Utopia::Content + use Utopia::Session + use Utopia::Localization, locales: ["en", "ja"] + run Utopia::Content end ``` @@ -268,45 +268,45 @@ on project-level constants. For example, `utopia-project` should move from mutating a Rack builder: -```ruby +```text Utopia::Project.call(builder) ``` to returning a protocol-compatible middleware: -```ruby +```text module Utopia - module Project - def self.application(root: Dir.pwd, locales: nil) - Utopia::Application.build(root: root) do - use Utopia::Static, root: root - use Utopia::Static, root: PUBLIC_ROOT - - use Utopia::Redirection::Rewrite, "/" => "/index" - use Utopia::Redirection::DirectoryIndex - use Utopia::Redirection::Errors, 404 => "/errors/file-not-found" - - if locales - use Utopia::Localization, default_locale: locales.first, locales: locales - end - - use Utopia::Controller, root: PAGES_ROOT - run Utopia::Content, root: PAGES_ROOT - end - end - end + module Project + def self.application(root: Dir.pwd, locales: nil) + Utopia::Application.build(root: root) do + use Utopia::Static, root: root + use Utopia::Static, root: PUBLIC_ROOT + + use Utopia::Redirection::Rewrite, "/" => "/index" + use Utopia::Redirection::DirectoryIndex + use Utopia::Redirection::Errors, 404 => "/errors/file-not-found" + + if locales + use Utopia::Localization, default_locale: locales.first, locales: locales + end + + use Utopia::Controller, root: PAGES_ROOT + run Utopia::Content, root: PAGES_ROOT + end + end + end end ``` Consumers can then choose: -```ruby +```text Application = Utopia::Project.application ``` or: -```ruby +```text app = Utopia::Project.application(root: "/path/to/project") ``` diff --git a/test/utopia/application.rb b/test/utopia/application.rb index a166b29..d405e89 100644 --- a/test/utopia/application.rb +++ b/test/utopia/application.rb @@ -14,7 +14,7 @@ application_request = nil application = subject.build do - run lambda {|request| + run lambda{|request| application_request = request Utopia::Response.text("Hello") @@ -35,7 +35,7 @@ it "normalizes tuple responses" do application = subject.build do - run lambda {|request| [201, {"content-type" => "text/plain"}, ["Created: ", request.path_info]]} + run lambda{|request| [201, {"content-type" => "text/plain"}, ["Created: ", request.path_info]]} end response = application.call(http_request) @@ -62,7 +62,7 @@ require "utopia/application" Application = Utopia::Application.build do - run lambda {|request| Utopia::Response.text(request.path_info)} + run lambda{|request| Utopia::Response.text(request.path_info)} end RUBY From 33f4c0078089265c6c7a05d702cbefcd8853aec7 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 22 Jun 2026 14:03:54 +1200 Subject: [PATCH 03/16] Avoid unnecessary protocol dependency upgrades Assisted-By: devx/166ed168-1c4d-4c63-a5f6-8d0d9cbff13f --- lib/utopia/application.rb | 9 ++++++++- utopia.gemspec | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/utopia/application.rb b/lib/utopia/application.rb index f57915a..7a6026f 100644 --- a/lib/utopia/application.rb +++ b/lib/utopia/application.rb @@ -25,7 +25,14 @@ class Application < Protocol::HTTP::Middleware # @returns [Application] The protocol-facing Utopia application. def self.build(default_app = Response::NotFound, **options, &block) builder = Protocol::HTTP::Middleware::Builder.new(default_app) - builder.build(&block) + + if block + if block.arity.zero? + builder.instance_exec(&block) + else + block.call(builder) + end + end return self.new(builder.to_app, **options) end diff --git a/utopia.gemspec b/utopia.gemspec index 7429456..1610dc3 100644 --- a/utopia.gemspec +++ b/utopia.gemspec @@ -34,7 +34,7 @@ Gem::Specification.new do |spec| spec.add_dependency "mime-types", "~> 3.0" spec.add_dependency "msgpack" spec.add_dependency "net-smtp" - spec.add_dependency "protocol-http", "~> 0.62" + spec.add_dependency "protocol-http", "~> 0.58.0" spec.add_dependency "protocol-url", "~> 0.4" spec.add_dependency "rack", "~> 3.0" spec.add_dependency "samovar", "~> 2.1" From b99fee2bc34499a7c8919ce71321d7bb22921eae Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 22 Jun 2026 14:04:34 +1200 Subject: [PATCH 04/16] Relax protocol HTTP dependency constraint Assisted-By: devx/166ed168-1c4d-4c63-a5f6-8d0d9cbff13f --- utopia.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utopia.gemspec b/utopia.gemspec index 1610dc3..50b2d25 100644 --- a/utopia.gemspec +++ b/utopia.gemspec @@ -34,7 +34,7 @@ Gem::Specification.new do |spec| spec.add_dependency "mime-types", "~> 3.0" spec.add_dependency "msgpack" spec.add_dependency "net-smtp" - spec.add_dependency "protocol-http", "~> 0.58.0" + spec.add_dependency "protocol-http", "~> 0.58" spec.add_dependency "protocol-url", "~> 0.4" spec.add_dependency "rack", "~> 3.0" spec.add_dependency "samovar", "~> 2.1" From 8fc28136f1d48c4fbbf64454afba32108c2499e7 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 22 Jun 2026 15:42:48 +1200 Subject: [PATCH 05/16] Avoid leaking loaded application constants Assisted-By: devx/166ed168-1c4d-4c63-a5f6-8d0d9cbff13f --- lib/utopia/application.rb | 19 ++++++------------- test/utopia/application.rb | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/lib/utopia/application.rb b/lib/utopia/application.rb index 7a6026f..b24a022 100644 --- a/lib/utopia/application.rb +++ b/lib/utopia/application.rb @@ -46,8 +46,8 @@ def self.default(**options) # Load a Utopia application from a conventional configuration file. # - # If the file defines a top-level `Application` constant, it will be - # returned directly. If the constant is a class, it will be instantiated. + # If the file defines an `Application` constant, it will be returned + # directly. If the constant is a class, it will be instantiated. # If the file does not exist, or does not define `Application`, the default # application is returned. # @@ -56,10 +56,11 @@ def self.default(**options) # @returns [Interface(:call)] The loaded protocol-facing application. def self.load(path = CONFIGURATION_PATH, **options) if File.exist?(path) - Kernel.load(path) + top = Module.new + top.class_eval(File.read(path), path) - if Object.const_defined?(:Application, false) - application = Object.const_get(:Application) + if top.const_defined?(:Application, false) + application = top.const_get(:Application) if application.is_a?(Class) return application.new(**options) @@ -67,14 +68,6 @@ def self.load(path = CONFIGURATION_PATH, **options) return application end end - elsif Object.const_defined?(:Application, false) - application = Object.const_get(:Application) - - if application.is_a?(Class) - return application.new(**options) - else - return application - end end return self.default(**options) diff --git a/test/utopia/application.rb b/test/utopia/application.rb index d405e89..9cb3f39 100644 --- a/test/utopia/application.rb +++ b/test/utopia/application.rb @@ -71,8 +71,22 @@ expect(response.status).to be == 200 expect(response.read).to be == "/hello" - ensure - Object.send(:remove_const, :Application) if Object.const_defined?(:Application, false) + expect(Object.const_defined?(:Application, false)).to be == false + end + end + + it "uses the default application if no application constant is defined" do + Dir.mktmpdir do |directory| + path = File.join(directory, "application.rb") + + File.write(path, <<~RUBY) + require "utopia/application" + RUBY + + application = subject.load(path) + response = application.call(http_request) + + expect(response.status).to be == 404 end end end From 05ce830eb5315cef46d72dac7fde969e2f3bbb2f Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 22 Jun 2026 16:01:13 +1200 Subject: [PATCH 06/16] Simplify application request boundary Assisted-By: devx/166ed168-1c4d-4c63-a5f6-8d0d9cbff13f --- lib/utopia/application.rb | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/lib/utopia/application.rb b/lib/utopia/application.rb index b24a022..aa4322c 100644 --- a/lib/utopia/application.rb +++ b/lib/utopia/application.rb @@ -20,10 +20,9 @@ class Application < Protocol::HTTP::Middleware # Build a Utopia application stack using the protocol HTTP middleware builder. # @parameter default_app [Interface(:call)] The terminal application used when the block does not call `run`. - # @parameter options [Hash] Options passed to {.new}. # @parameter block [Proc] The middleware builder block. # @returns [Application] The protocol-facing Utopia application. - def self.build(default_app = Response::NotFound, **options, &block) + def self.build(default_app = Response::NotFound, &block) builder = Protocol::HTTP::Middleware::Builder.new(default_app) if block @@ -34,14 +33,13 @@ def self.build(default_app = Response::NotFound, **options, &block) end end - return self.new(builder.to_app, **options) + return self.new(builder.to_app) end # Build the default Utopia application. - # @parameter options [Hash] Options passed to {.build}. # @returns [Application] The default protocol-facing Utopia application. - def self.default(**options) - self.build(**options) + def self.default + self.build end # Load a Utopia application from a conventional configuration file. @@ -70,33 +68,22 @@ def self.load(path = CONFIGURATION_PATH, **options) end end - return self.default(**options) + return self.default end # Initialize the protocol-facing application boundary. # @parameter delegate [Interface(:call)] The Utopia application stack. - # @parameter request_class [Class] The request wrapper class. - # @parameter response_class [#wrap] The response normalization object. - def initialize(delegate, request_class: Request, response_class: Response) + def initialize(delegate) super(delegate) - - @request_class = request_class - @response_class = response_class end - # @attribute [Class] The request wrapper class. - attr :request_class - - # @attribute [#wrap] The response normalization object. - attr :response_class - # Process a protocol HTTP request. # @parameter http_request [Protocol::HTTP::Request] The incoming protocol request. # @returns [Protocol::HTTP::Response] The normalized protocol response. def call(http_request) - request = @request_class.new(http_request) + request = Request.new(http_request) - return @response_class.wrap(super(request)) + return Response.wrap(super(request)) end end end From 0cabd25dfccbc8d3094ee96f1452f93c4f76a1ac Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 22 Jun 2026 18:31:10 +1200 Subject: [PATCH 07/16] Rewrite middleware for Utopia request Assisted-By: devx/166ed168-1c4d-4c63-a5f6-8d0d9cbff13f --- lib/utopia/content/middleware.rb | 19 ++- lib/utopia/controller/middleware.rb | 15 +- lib/utopia/exceptions/handler.rb | 26 +-- lib/utopia/exceptions/mailer.rb | 59 +++---- lib/utopia/localization/middleware.rb | 70 ++++---- lib/utopia/middleware.rb | 21 +++ lib/utopia/redirection.rb | 39 +++-- lib/utopia/request.rb | 223 ++++++++++++++++++++++++++ lib/utopia/response.rb | 24 +++ lib/utopia/session/middleware.rb | 52 ++++-- lib/utopia/static/local_file.rb | 51 ++++-- lib/utopia/static/middleware.rb | 32 ++-- test/utopia/application_middleware.rb | 74 +++++++++ 13 files changed, 563 insertions(+), 142 deletions(-) create mode 100644 test/utopia/application_middleware.rb diff --git a/lib/utopia/content/middleware.rb b/lib/utopia/content/middleware.rb index 7840696..b6f070d 100644 --- a/lib/utopia/content/middleware.rb +++ b/lib/utopia/content/middleware.rb @@ -5,6 +5,7 @@ require_relative "../middleware" require_relative "../localization" +require_relative "../response" require_relative "links" require_relative "node" @@ -92,16 +93,18 @@ def resolve_link(link) def respond(link, request) if node = resolve_link(link) - attributes = request.env.fetch(VARIABLES_KEY, {}).to_hash + attributes = request.fetch(VARIABLES_KEY, {}).to_hash return node.process!(request, attributes) elsif redirect_uri = link[:uri] - return [307, {HTTP::LOCATION => redirect_uri}, []] + return Utopia::Response[307, {HTTP::LOCATION => redirect_uri}, []] end end - def call(env) - request = Rack::Request.new(env) + def call(request) + legacy = Utopia::Middleware.legacy_request?(request) + request = Utopia::Middleware.request(request) + path = Path.create(request.path_info) # Check if the request is to a non-specific index. This only works for requests with a given name: @@ -112,17 +115,17 @@ def call(env) if File.directory? directory_path index_path = [basename, INDEX] - return [307, {HTTP::LOCATION => path.dirname.join(index_path).to_s}, []] + return Utopia::Middleware.response(Utopia::Response[307, {HTTP::LOCATION => path.dirname.join(index_path).to_s}, []], legacy) end - locale = env[Localization::CURRENT_LOCALE_KEY] + locale = request[Localization::CURRENT_LOCALE_KEY] if link = @links.for(path, locale) if response = self.respond(link, request) - return response + return Utopia::Middleware.response(response, legacy) end end - return @app.call(env) + return Utopia::Middleware.response(@app.call(request), legacy) end private diff --git a/lib/utopia/controller/middleware.rb b/lib/utopia/controller/middleware.rb index 18d7c0f..ed111ab 100644 --- a/lib/utopia/controller/middleware.rb +++ b/lib/utopia/controller/middleware.rb @@ -91,7 +91,7 @@ def invoke_controllers(request) controller_path = Path.new # Controller instance variables which eventually get processed by the view: - variables = request.env[VARIABLES_KEY] + variables = request[VARIABLES_KEY] while request_path.components.any? # 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. @@ -111,22 +111,23 @@ def invoke_controllers(request) end # 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: - request.env[Rack::PATH_INFO] = controller_path.to_s + request.path_info = controller_path.to_s # No controller gave a useful result: return nil end - def call(env) - env[VARIABLES_KEY] ||= Variables.new + def call(request) + legacy = Utopia::Middleware.legacy_request?(request) + request = Utopia::Middleware.request(request) - request = Rack::Request.new(env) + request[VARIABLES_KEY] ||= Variables.new if result = invoke_controllers(request) - return result + return Utopia::Middleware.response(result, legacy) end - return @app.call(env) + return Utopia::Middleware.response(@app.call(request), legacy) end end end diff --git a/lib/utopia/exceptions/handler.rb b/lib/utopia/exceptions/handler.rb index 1cf98c9..2885c8d 100644 --- a/lib/utopia/exceptions/handler.rb +++ b/lib/utopia/exceptions/handler.rb @@ -6,6 +6,9 @@ require "console" +require_relative "../middleware" +require_relative "../response" + module Utopia module Exceptions # A middleware which catches exceptions and performs an internal redirect. @@ -25,28 +28,31 @@ def freeze super end - def call(env) + def call(request) + legacy = Utopia::Middleware.legacy_request?(request) + request = Utopia::Middleware.request(request) + begin - return @app.call(env) + return Utopia::Middleware.response(@app.call(request), legacy) rescue Exception => exception Console.warn(self, "An error occurred while processing the request.", error: exception) begin # We do an internal redirection to the error location: - error_request = env.merge( - Rack::PATH_INFO => @location, - Rack::REQUEST_METHOD => Rack::GET, - "utopia.exception" => exception, + error_request = request.with( + method: "GET", + path_info: @location, + attributes: {"utopia.exception" => exception} ) - error_response = @app.call(error_request) - error_response[0] = 500 + error_response = Response.wrap(@app.call(error_request)) + error_response.status = 500 - return error_response + return Utopia::Middleware.response(error_response, legacy) rescue Exception => exception # If redirection fails, we also finish with a fatal error: Console.error(self, "An error occurred while invoking the error handler.", error: exception) - return [500, {"content-type" => "text/plain"}, ["An error occurred while processing the request."]] + return Utopia::Middleware.response(Response[500, {"content-type" => "text/plain"}, ["An error occurred while processing the request."]], legacy) end end end diff --git a/lib/utopia/exceptions/mailer.rb b/lib/utopia/exceptions/mailer.rb index a06fd62..7fd01aa 100644 --- a/lib/utopia/exceptions/mailer.rb +++ b/lib/utopia/exceptions/mailer.rb @@ -6,6 +6,8 @@ require "net/smtp" require "mail" +require_relative "../middleware" + module Utopia module Exceptions # A middleware which catches all exceptions raised from the app it wraps and sends a useful email with the exception, stacktrace, and contents of the environment. @@ -47,11 +49,14 @@ def freeze super end - def call(env) + def call(request) + legacy = Utopia::Middleware.legacy_request?(request) + request = Utopia::Middleware.request(request) + begin - return @app.call(env) + return Utopia::Middleware.response(@app.call(request), legacy) rescue => exception - send_notification exception, env + send_notification exception, request raise end @@ -95,13 +100,10 @@ def generate_backtrace(io, exception, prefix: "Exception") end end - def generate_body(exception, env) + def generate_body(exception, request) io = StringIO.new - # Dump out useful rack environment variables: - request = Rack::Request.new(env) - - io.puts "#{request.request_method} #{request.url}" + io.puts "#{request.method} #{request.url}" # TODO embed `rack.input` if it's textual? # TODO dump and embed `utopia.variables`? @@ -113,21 +115,27 @@ def generate_body(exception, env) io.puts "request.#{key}: #{value.inspect}" end - request.params.each do |key, value| - io.puts "request.params.#{key}: #{value.inspect}" + request.arguments.each do |key, value| + io.puts "request.arguments.#{key}: #{value.inspect}" end io.puts ENV_KEYS.each do |key| - value = env[key] - io.puts "env[#{key.inspect}]: #{value.inspect}" + value = request[key] + io.puts "request[#{key.inspect}]: #{value.inspect}" end io.puts - env.select{|key,_| key.start_with? "HTTP_"}.each do |key, value| - io.puts "#{key}: #{value.inspect}" + request.headers.each do |key, value| + io.puts "header[#{key.inspect}]: #{value.inspect}" + end + + request.attributes.each do |key, value| + if key.is_a?(String) && key.start_with?("HTTP_") + io.puts "#{key}: #{value.inspect}" + end end io.puts @@ -137,7 +145,7 @@ def generate_body(exception, env) return io.string end - def attributes_for(exception, env) + def attributes_for(exception, request) { exception: exception.class.name, pid: $$, @@ -145,29 +153,29 @@ def attributes_for(exception, env) } end - def generate_mail(exception, env) + def generate_mail(exception, request) mail = Mail.new( :from => @from, :to => @to, - :subject => @subject % attributes_for(exception, env) + :subject => @subject % attributes_for(exception, request) ) mail.text_part = Mail::Part.new - mail.text_part.body = generate_body(exception, env) + mail.text_part.body = generate_body(exception, request) - if body = extract_body(env) and body.size > 0 + if body = extract_body(request) and body.size > 0 mail.attachments["body.bin"] = body end if @dump_environment - mail.attachments["environment.yaml"] = YAML.dump(env) + mail.attachments["attributes.yaml"] = YAML.dump(request.attributes) end return mail end - def send_notification(exception, env) - mail = generate_mail(exception, env) + def send_notification(exception, request) + mail = generate_mail(exception, request) mail.delivery_method(*@delivery_method) if @delivery_method @@ -177,11 +185,8 @@ def send_notification(exception, env) $stderr.puts mail_exception.backtrace end - def extract_body(env) - if io = env["rack.input"] - io.rewind if io.respond_to?(:rewind) - io.read - end + def extract_body(request) + request.body&.read end end end diff --git a/lib/utopia/localization/middleware.rb b/lib/utopia/localization/middleware.rb index ce2ae88..3d2893f 100644 --- a/lib/utopia/localization/middleware.rb +++ b/lib/utopia/localization/middleware.rb @@ -4,6 +4,8 @@ # Copyright, 2025-2026, by Samuel Williams. require_relative "wrapper" +require_relative "../middleware" +require_relative "../response" module Utopia module Localization @@ -61,34 +63,34 @@ def freeze attr :all_locales attr :default_locale - def preferred_locales(env) - return to_enum(:preferred_locales, env) unless block_given? + def preferred_locales(request) + return to_enum(:preferred_locales, request) unless block_given? # Keep track of what locales have been tried: locales = Set.new - host_preferred_locales(env) do |locale| - yield env.merge(CURRENT_LOCALE_KEY => locale) if locales.add? locale + host_preferred_locales(request) do |locale| + yield request.with(attributes: {CURRENT_LOCALE_KEY => locale}) if locales.add? locale end - request_preferred_locale(env) do |locale, path| + request_preferred_locale(request) do |locale, path| # We have extracted a locale from the path, so from this point on we should use the updated path: - env = env.merge(Rack::PATH_INFO => path.to_s) + request = request.with(path_info: path.to_s) - yield env.merge(CURRENT_LOCALE_KEY => locale) if locales.add? locale + yield request.with(attributes: {CURRENT_LOCALE_KEY => locale}) if locales.add? locale end - browser_preferred_locales(env).each do |locale| - yield env.merge(CURRENT_LOCALE_KEY => locale) if locales.add? locale + browser_preferred_locales(request).each do |locale| + yield request.with(attributes: {CURRENT_LOCALE_KEY => locale}) if locales.add? locale end @default_locales.each do |locale| - yield env.merge(CURRENT_LOCALE_KEY => locale) if locales.add? locale + yield request.with(attributes: {CURRENT_LOCALE_KEY => locale}) if locales.add? locale end end - def host_preferred_locales(env) - http_host = env[Rack::HTTP_HOST] + def host_preferred_locales(request) + http_host = request.host.to_s # Yield all hosts which match the incoming http_host: @hosts.each do |pattern, locale| @@ -96,8 +98,8 @@ def host_preferred_locales(env) end end - def request_preferred_locale(env) - path = Path[env[Rack::PATH_INFO]] + def request_preferred_locale(request) + path = Path[request.path_info] if request_locale = @all_locales.patterns[path.first] # Remove the localization prefix: @@ -107,8 +109,8 @@ def request_preferred_locale(env) end end - def browser_preferred_locales(env) - accept_languages = env[HTTP_ACCEPT_LANGUAGE] + def browser_preferred_locales(request) + accept_languages = request.headers["accept-language"]&.to_s # No user prefered languages: return [] unless accept_languages @@ -123,50 +125,54 @@ def browser_preferred_locales(env) return [] end - def localized?(env) + def localized?(request) # Ignore requests which match the ignored paths: - path_info = env[Rack::PATH_INFO] + path_info = request.path_info return false if @ignore.any?{|pattern| path_info[pattern] != nil} return true end # Set the Vary: header on the response to indicate that this response should include the header in the cache key. - def vary(env, response) - headers = response[1].to_a + def vary(request, response) + response = Response.wrap(response) + headers = response.headers # This response was based on the Accept-Language header: - headers << ["Vary", "Accept-Language"] + headers.add("vary", "Accept-Language") # Althought this header is generally not supported, we supply it anyway as it is useful for debugging: - if locale = env[CURRENT_LOCALE_KEY] + if locale = request[CURRENT_LOCALE_KEY] # Set the Content-Location to point to the localized URI as requested: - headers["Content-Location"] = "/#{locale}" + env[Rack::PATH_INFO] + headers["content-location"] = "/#{locale}" + request.path_info end return response end - def call(env) + def call(request) + legacy = Utopia::Middleware.legacy_request?(request) + request = Utopia::Middleware.request(request) + # Pass the request through if it shouldn't be localized: - return @app.call(env) unless localized?(env) + return Utopia::Middleware.response(@app.call(request), legacy) unless localized?(request) - env[LOCALIZATION_KEY] = self + request[LOCALIZATION_KEY] = self response = nil # We have a non-localized request, but there might be a localized resource. We return the best localization possible: - preferred_locales(env) do |localized_env| - # puts "Trying locale: #{localized_env[CURRENT_LOCALE_KEY]}: #{localized_env[Rack::PATH_INFO]}..." + preferred_locales(request) do |localized_request| + # puts "Trying locale: #{localized_request[CURRENT_LOCALE_KEY]}: #{localized_request.path_info}..." - response = @app.call(localized_env) + response = Response.wrap(@app.call(localized_request)) - break unless response[0] >= 400 + break unless response.status >= 400 - response[2].close if response[2].respond_to?(:close) + response.close if response.respond_to?(:close) end - return vary(env, response) + return Utopia::Middleware.response(vary(request, response), legacy) end end end diff --git a/lib/utopia/middleware.rb b/lib/utopia/middleware.rb index 79e4a58..43073dd 100644 --- a/lib/utopia/middleware.rb +++ b/lib/utopia/middleware.rb @@ -5,8 +5,29 @@ require_relative "http" require_relative "path" +require_relative "request" +require_relative "response" module Utopia + # Shared helpers for middleware boundary compatibility. + module Middleware + def self.legacy_request?(request) + request.is_a?(Hash) + end + + def self.request(request) + Request.wrap(request) + end + + def self.response(response, legacy) + if legacy + Response.to_rack(response) + else + response + end + end + end + # The default pages path for {Utopia::Content} middleware. PAGES_PATH = "pages".freeze diff --git a/lib/utopia/redirection.rb b/lib/utopia/redirection.rb index 28448f3..eca9986 100644 --- a/lib/utopia/redirection.rb +++ b/lib/utopia/redirection.rb @@ -4,6 +4,7 @@ # Copyright, 2009-2026, by Samuel Williams. require_relative "middleware" +require_relative "response" module Utopia # A middleware which assists with redirecting from one path to another. @@ -38,25 +39,28 @@ def freeze end def unhandled_error?(response) - response[0] >= 400 && response[1].empty? + response.status >= 400 && response.headers.empty? end - def call(env) - response = @app.call(env) + def call(request) + legacy = Middleware.legacy_request?(request) + request = Middleware.request(request) - if unhandled_error?(response) && location = @codes[response[0]] - error_request = env.merge(Rack::PATH_INFO => location, Rack::REQUEST_METHOD => Rack::GET) - error_response = @app.call(error_request) + response = Response.wrap(@app.call(request)) + + if unhandled_error?(response) && location = @codes[response.status] + error_request = request.with(method: "GET", path_info: location) + error_response = Response.wrap(@app.call(error_request)) - if error_response[0] >= 400 - raise RequestFailure.new(env[Rack::PATH_INFO], response[0], location, error_response[0]) + if error_response.status >= 400 + raise RequestFailure.new(request.path_info, response.status, location, error_response.status) else # Feed the error code back with the error document: - error_response[0] = response[0] - return error_response + error_response.status = response.status + return Middleware.response(error_response, legacy) end else - return response + return Middleware.response(response, legacy) end end end @@ -97,24 +101,27 @@ def make_headers(location) end def redirect(location) - return [self.status, self.make_headers(location), []] + return Response[self.status, self.make_headers(location), []] end def [] path false end - def call(env) + def call(request) + legacy = Middleware.legacy_request?(request) + request = Middleware.request(request) + # Normalize the path to remove redundant slashes, `.` and `..` segments. # This prevents protocol-relative redirect URLs (e.g. //evil.com/index) # from being generated when PATH_INFO contains a double leading slash. - path = Path.create(env[Rack::PATH_INFO]).simplify.to_s + path = Path.create(request.path_info).simplify.to_s if redirection = self[path] - return redirection + return Middleware.response(redirection, legacy) end - return @app.call(env) + return Middleware.response(@app.call(request), legacy) end end diff --git a/lib/utopia/request.rb b/lib/utopia/request.rb index 2cc3138..d8f8f79 100644 --- a/lib/utopia/request.rb +++ b/lib/utopia/request.rb @@ -3,6 +3,12 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. +require "cgi" +require "uri" + +require "protocol/http/headers" +require "protocol/http/request" + module Utopia # The application-facing request wrapper. # @@ -10,12 +16,66 @@ module Utopia # as arguments, sessions, localization and controller variables should be added # as explicit Utopia concepts rather than relying on a Rack-style env hash. class Request + # Wrap either a {Protocol::HTTP::Request} or a legacy Rack env hash. + def self.wrap(request) + case request + when self + request + when Protocol::HTTP::Request + self.new(request) + when Hash + self.from_rack_env(request) + else + raise ArgumentError, "Unable to wrap request: #{request.inspect}!" + end + end + + # Build a Utopia request from a Rack env hash for legacy compatibility. + def self.from_rack_env(env) + path = env["PATH_INFO"].to_s + + query = env["QUERY_STRING"] + + if query && !query.empty? + path = "#{path}?#{query}" + end + + headers = Protocol::HTTP::Headers.new + + env.each do |key, value| + case key + when "CONTENT_TYPE" + headers["content-type"] = value + when "CONTENT_LENGTH" + headers["content-length"] = value + else + if key.is_a?(String) && key.start_with?("HTTP_") + headers[key[5..].downcase.tr("_", "-")] = value + end + end + end + + http = Protocol::HTTP::Request.new( + env["rack.url_scheme"], + env["HTTP_HOST"], + env["REQUEST_METHOD"], + path, + env["SERVER_PROTOCOL"], + headers, + env["rack.input"] + ) + + self.new(http, attributes: env) + end + # Initialize a request wrapper. # @parameter http [Protocol::HTTP::Request] The underlying protocol request. # @parameter attributes [Hash | Nil] Request-local application state. def initialize(http, attributes: nil) @http = http @attributes = attributes || {} + + @attributes["REQUEST_PATH"] ||= self.path_info end # The underlying {Protocol::HTTP::Request}. @@ -23,6 +83,84 @@ def initialize(http, attributes: nil) # Request-local application state. attr :attributes + alias env attributes + + # Fetch request-local application state. + def [] key + case key + when "REQUEST_METHOD" + self.method + when "PATH_INFO", "REQUEST_PATH" + self.path_info + when "QUERY_STRING" + self.query.to_s + when "HTTP_HOST" + self.host + when "HTTP_USER_AGENT" + self.user_agent + when "HTTP_ACCEPT_LANGUAGE" + self.headers["accept-language"] + when "HTTP_IF_MODIFIED_SINCE" + self.headers["if-modified-since"] + when "HTTP_IF_NONE_MATCH" + self.headers["if-none-match"] + when "HTTP_RANGE" + self.headers["range"] + when "rack.input" + self.body + else + if key.is_a?(String) && key.start_with?("HTTP_") + self.headers[key[5..].downcase.tr("_", "-")] + else + @attributes[key] + end + end + end + + # Assign request-local application state. + def []= key, value + case key + when "REQUEST_METHOD" + @http.method = value + when "PATH_INFO" + self.path_info = value + else + @attributes[key] = value + end + end + + # Fetch request-local application state. + def fetch(...) + @attributes.fetch(...) + end + + # Select request-local application state. + def select(&block) + @attributes.select(&block) + end + + # Build a derived request with the specified attributes merged in. + def merge(attributes) + return self.with(attributes: attributes) + end + + # Build a derived request with updated protocol fields and request-local state. + def with(method: self.method, path: self.path, path_info: nil, attributes: {}) + http = @http.dup + http.method = method + + if path_info + if query = self.query + http.path = "#{path_info}?#{query}" + else + http.path = path_info + end + else + http.path = path + end + + return self.class.new(http, attributes: @attributes.merge(attributes)) + end # @returns [String] The HTTP request method. def method @@ -60,6 +198,49 @@ def query @http.path&.split("?", 2)&.last if @http.path&.include?("?") end + # @returns [Hash] The decoded query arguments. + def arguments + @arguments ||= decode_arguments(self.query) + end + alias params arguments + + # @returns [Hash] The decoded request cookies. + def cookies + @cookies ||= parse_cookies(@http.headers["cookie"]) + end + + # @returns [String | Nil] The request host. + def host + @http.authority || @http.headers["host"] + end + + # @returns [String | Nil] The request user agent. + def user_agent + @http.headers["user-agent"] + end + + # @returns [String | Nil] The request referrer. + def referrer + @http.headers["referer"] + end + + # @returns [String | Nil] The remote peer address, if available. + def ip + @http.peer&.ip_address + end + + # @returns [String] The full request URL if scheme and host are available. + def url + scheme = @http.scheme + host = self.host + + if scheme && host + "#{scheme}://#{host}#{self.path}" + else + self.path + end + end + # @returns [Protocol::HTTP::Headers] The request headers. def headers @http.headers @@ -69,5 +250,47 @@ def headers def body @http.body end + + private + + def decode_arguments(query) + arguments = {} + + return arguments unless query + + URI.decode_www_form(query).each do |key, value| + values = arguments.fetch(key){arguments[key] = []} + values << value + end + + arguments.transform_values! do |values| + if values.size == 1 + values.first + else + values + end + end + + return arguments + end + + def parse_cookies(cookie_header) + cookies = {} + + return cookies unless cookie_header + + if cookie_header.respond_to?(:to_str) + cookie_header = cookie_header.to_str + else + cookie_header = cookie_header.to_s + end + + cookie_header.split(/;\s*/).each do |pair| + key, value = pair.split("=", 2) + cookies[CGI.unescape(key)] = CGI.unescape(value || "") + end + + return cookies + end end end diff --git a/lib/utopia/response.rb b/lib/utopia/response.rb index 0888aa7..79288e4 100644 --- a/lib/utopia/response.rb +++ b/lib/utopia/response.rb @@ -48,6 +48,30 @@ def self.wrap(response) end end + # Convert a response-like value to a Rack response tuple. + def self.to_rack(response) + return response if response.is_a?(Array) + + response = self.wrap(response) + + case response + when Protocol::HTTP::Response + headers = {} + + response.headers.each do |key, value| + if existing = headers[key] + headers[key] = "#{existing}\n#{value}" + else + headers[key] = value.to_s + end + end + + [response.status, headers, response.body || []] + else + response + end + end + # Build a redirect response. # @parameter location [String] The redirect location. # @parameter status [Integer] The redirect status code. diff --git a/lib/utopia/session/middleware.rb b/lib/utopia/session/middleware.rb index 565ec98..2cb74ef 100644 --- a/lib/utopia/session/middleware.rb +++ b/lib/utopia/session/middleware.rb @@ -7,9 +7,12 @@ require "digest/sha2" require "console" require "json" +require "cgi" require_relative "lazy_hash" require_relative "serialization" +require_relative "../middleware" +require_relative "../response" module Utopia module Session @@ -22,6 +25,7 @@ class PayloadError < StandardError SECRET_KEY = "UTOPIA_SESSION_SECRET".freeze + SESSION_KEY = "utopia.session".freeze RACK_SESSION = "rack.session".freeze CIPHER_ALGORITHM = "aes-256-cbc" @@ -35,7 +39,7 @@ class PayloadError < StandardError # @param secret [Array] The secret text used to generate a symetric encryption key for the coookie data. # @param same_site [Symbol, String] Controls how the cookie is provided to the site. # @param expires_after [String] The cache-control header to set for static content. - # @param options [Hash] Additional defaults used for generating the cookie by `Rack::Utils.set_cookie_header!`. + # @param options [Hash] Additional defaults used for generating the session cookie. def initialize(app, session_name: RACK_SESSION, secret: nil, expires_after: DEFAULT_EXPIRES_AFTER, update_timeout: DEFAULT_UPDATE_TIMEOUT, secure: false, same_site: :lax, maximum_size: MAXIMUM_SIZE, **options) @app = app @@ -90,25 +94,31 @@ def freeze super end - def call(env) - session_hash = prepare_session(env) + def call(request) + legacy = Utopia::Middleware.legacy_request?(request) + request = Utopia::Middleware.request(request) - status, headers, body = @app.call(env) + session_hash = prepare_session(request) - update_session(env, session_hash, headers) + response = Response.wrap(@app.call(request)) - return [status, headers, body] + update_session(session_hash, response.headers) + + return Utopia::Middleware.response(response, legacy) end protected - def prepare_session(env) - env[RACK_SESSION] = LazyHash.new do - self.load_session_values(env) + def prepare_session(request) + session = LazyHash.new do + self.load_session_values(request) end + + request[SESSION_KEY] = session + request[RACK_SESSION] = session end - def update_session(env, session_hash, headers) + def update_session(session_hash, headers) if session_hash.needs_update?(@update_timeout) values = session_hash.values @@ -131,9 +141,7 @@ def build_initial_session(request) # Load session from user supplied cookie. If the data is invalid or otherwise fails validation, `build_iniital_session` is invoked. # @return hash of values. - def load_session_values(env) - request = Rack::Request.new(env) - + def load_session_values(request) # Decrypt the data from the user if possible: if data = request.cookies[@cookie_name] begin @@ -177,7 +185,23 @@ def commit(value, updated_at, headers) expires: expires(updated_at) }.merge(@cookie_defaults) - Rack::Utils.set_cookie_header!(headers, @cookie_name, cookie) + headers.add("set-cookie", cookie_header(@cookie_name, cookie)) + end + + def cookie_header(name, cookie) + parts = ["#{CGI.escape(name)}=#{CGI.escape(cookie.fetch(:value))}"] + + parts << "Domain=#{cookie[:domain]}" if cookie[:domain] + parts << "Path=#{cookie[:path]}" if cookie[:path] + parts << "Expires=#{cookie[:expires].httpdate}" if cookie[:expires] + parts << "Secure" if cookie[:secure] + parts << "HttpOnly" if cookie[:http_only] + + if same_site = cookie[:same_site] + parts << "SameSite=#{same_site.to_s.capitalize}" + end + + return parts.join("; ") end def encrypt(hash) diff --git a/lib/utopia/static/local_file.rb b/lib/utopia/static/local_file.rb index 684c53b..ab97172 100644 --- a/lib/utopia/static/local_file.rb +++ b/lib/utopia/static/local_file.rb @@ -6,6 +6,8 @@ require "time" require "digest/sha1" +require_relative "../response" + module Utopia # A middleware which serves static files from the specified root directory. module Static @@ -63,12 +65,12 @@ def each end end - def modified?(env) - if modified_since = env["HTTP_IF_MODIFIED_SINCE"] + def modified?(request) + if modified_since = request.headers["if-modified-since"] return false if File.mtime(full_path) <= Time.parse(modified_since) end - if etags = env["HTTP_IF_NONE_MATCH"] + if etags = request.headers["if-none-match"] etags = etags.split(/\s*,\s*/) return false if etags.include?(etag) || etags.include?("*") end @@ -76,32 +78,53 @@ def modified?(env) return true end - CONTENT_LENGTH = Rack::CONTENT_LENGTH - CONTENT_RANGE = "Content-Range".freeze + CONTENT_LENGTH = "content-length".freeze + CONTENT_RANGE = "content-range".freeze - def serve(env, response_headers) - ranges = Rack::Utils.get_byte_ranges(env["HTTP_RANGE"], size) - response = [200, response_headers, self] + def serve(request, response_headers) + ranges = byte_ranges(request.headers["range"]) # puts "Requesting ranges: #{ranges.inspect} (#{size})" if ranges == nil or ranges.size != 1 # No ranges, or multiple ranges (which we don't support). # TODO: Support multiple byte-ranges, for now just send entire file: - response[0] = 200 - response[1][CONTENT_LENGTH] = size.to_s + status = 200 + response_headers[CONTENT_LENGTH] = size.to_s @range = 0...size else # Partial content: @range = ranges[0] partial_size = @range.size - response[0] = 206 - response[1][CONTENT_LENGTH] = partial_size.to_s - response[1][CONTENT_RANGE] = "bytes #{@range.min}-#{@range.max}/#{size}" + status = 206 + response_headers[CONTENT_LENGTH] = partial_size.to_s + response_headers[CONTENT_RANGE] = "bytes #{@range.min}-#{@range.max}/#{size}" end - return response + return Response[status, response_headers, self] + end + + def byte_ranges(header) + return nil unless header + + units, ranges = header.split("=", 2) + return nil unless units == "bytes" && ranges + + ranges.split(/\s*,\s*/).map do |range| + first, last = range.split("-", 2) + + if first.empty? + length = Integer(last) + (size - length)...size + else + first = Integer(first) + last = last.empty? ? size - 1 : Integer(last) + first..last + end + end + rescue ArgumentError + nil end end end diff --git a/lib/utopia/static/middleware.rb b/lib/utopia/static/middleware.rb index 0d07c3b..c285fce 100644 --- a/lib/utopia/static/middleware.rb +++ b/lib/utopia/static/middleware.rb @@ -5,6 +5,7 @@ require_relative "../middleware" require_relative "../localization" +require_relative "../response" require_relative "local_file" require_relative "mime_types" @@ -52,11 +53,11 @@ def fetch_file(path) attr :extensions - LAST_MODIFIED = "Last-Modified".freeze + LAST_MODIFIED = "last-modified".freeze CONTENT_TYPE = HTTP::CONTENT_TYPE CACHE_CONTROL = HTTP::CACHE_CONTROL - ETAG = "ETag".freeze - ACCEPT_RANGES = "Accept-Ranges".freeze + ETAG = "etag".freeze + ACCEPT_RANGES = "accept-ranges".freeze def response_headers_for(file, content_type) if @cache_control.respond_to?(:call) @@ -74,41 +75,44 @@ def response_headers_for(file, content_type) } end - def respond(env, path_info, extension) + def respond(request, path_info, extension) path = Path[path_info].simplify - if locale = env[Localization::CURRENT_LOCALE_KEY] + if locale = request[Localization::CURRENT_LOCALE_KEY] path.last.insert(path.last.rindex(".") || -1, ".#{locale}") end if file = fetch_file(path) response_headers = self.response_headers_for(file, @extensions[extension]) - if file.modified?(env) - return file.serve(env, response_headers) + if file.modified?(request) + return file.serve(request, response_headers) else - return [304, response_headers, []] + return Response[304, response_headers, []] end end end - def call(env) - path_info = env[Rack::PATH_INFO] + def call(request) + legacy = Utopia::Middleware.legacy_request?(request) + request = Utopia::Middleware.request(request) + + path_info = request.path_info extension = File.extname(path_info) if @extensions.key?(extension.downcase) - if response = self.respond(env, path_info, extension) - return response + if response = self.respond(request, path_info, extension) + return Utopia::Middleware.response(response, legacy) end end # else if no file was found: - return @app.call(env) + return Utopia::Middleware.response(@app.call(request), legacy) end end Traces::Provider(Static) do - def respond(env, path_info, extension) + def respond(request, path_info, extension) attributes = { path_info: path_info, } diff --git a/test/utopia/application_middleware.rb b/test/utopia/application_middleware.rb new file mode 100644 index 0000000..4e5cc22 --- /dev/null +++ b/test/utopia/application_middleware.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/http/request" +require "tmpdir" + +require "utopia/application" +require "utopia/redirection" +require "utopia/session" +require "utopia/static" + +describe "Utopia application middleware" do + def request(path, headers: nil) + Protocol::HTTP::Request["GET", path, headers] + end + + it "passes Utopia::Request through first-party middleware" do + seen_request = nil + + application = Utopia::Application.build do + use Utopia::Redirection::Rewrite, {"/old" => "/new"} + + run lambda{|request| + seen_request = request + Utopia::Response.text(request.path_info) + } + end + + response = application.call(request("/hello")) + + expect(seen_request).to be_a(Utopia::Request) + expect(response.status).to be == 200 + expect(response.read).to be == "/hello" + + response = application.call(request("/old")) + + expect(response.status).to be == 301 + expect(response.headers["location"]).to be == "/new" + end + + it "serves static files from protocol requests" do + Dir.mktmpdir do |directory| + File.write(File.join(directory, "hello.txt"), "Hello") + + application = Utopia::Application.build do + use Utopia::Static, root: directory + end + + response = application.call(request("/hello.txt")) + + expect(response.status).to be == 200 + expect(response.headers["content-type"]).to be == "text/plain" + expect(response.read).to be == "Hello" + end + end + + it "provides request-local session state" do + application = Utopia::Application.build do + use Utopia::Session, session_name: Utopia::Session::Middleware::SESSION_KEY, secret: "test-secret" + + run lambda{|request| + request[Utopia::Session::Middleware::SESSION_KEY][:value] = "Hello" + Utopia::Response.text("OK") + } + end + + response = application.call(request("/", headers: {"user-agent" => "Sus"})) + + expect(response.status).to be == 200 + expect(response.headers["set-cookie"].any?{|value| value.start_with?("utopia.session.encrypted=")}).to be == true + end +end From 93754f6193b73500bd4c07932cf0dbacc1df2206 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 22 Jun 2026 18:40:04 +1200 Subject: [PATCH 08/16] Add request compatibility conveniences Assisted-By: devx/166ed168-1c4d-4c63-a5f6-8d0d9cbff13f --- lib/utopia/request.rb | 78 +++++++++++++++++++++++++++++++++++++++--- test/utopia/request.rb | 35 +++++++++++++++++++ 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/lib/utopia/request.rb b/lib/utopia/request.rb index d8f8f79..e70556f 100644 --- a/lib/utopia/request.rb +++ b/lib/utopia/request.rb @@ -111,8 +111,12 @@ def [] key else if key.is_a?(String) && key.start_with?("HTTP_") self.headers[key[5..].downcase.tr("_", "-")] - else + elsif @attributes.key?(key) @attributes[key] + elsif key.is_a?(Symbol) && @attributes.key?(key.to_s) + @attributes[key.to_s] + else + self.arguments[key.to_s] end end end @@ -166,6 +170,42 @@ def with(method: self.method, path: self.path, path_info: nil, attributes: {}) def method @http.method end + alias request_method method + + # @returns [Boolean] Whether the HTTP request method is GET. + def get? + @http.method == "GET" + end + + # @returns [Boolean] Whether the HTTP request method is HEAD. + def head? + @http.method == "HEAD" + end + + # @returns [Boolean] Whether the HTTP request method is POST. + def post? + @http.method == "POST" + end + + # @returns [Boolean] Whether the HTTP request method is PUT. + def put? + @http.method == "PUT" + end + + # @returns [Boolean] Whether the HTTP request method is PATCH. + def patch? + @http.method == "PATCH" + end + + # @returns [Boolean] Whether the HTTP request method is DELETE. + def delete? + @http.method == "DELETE" + end + + # @returns [Boolean] Whether the HTTP request method is OPTIONS. + def options? + @http.method == "OPTIONS" + end # @returns [String] The full request path, including query string. def path @@ -213,6 +253,29 @@ def cookies def host @http.authority || @http.headers["host"] end + alias host_with_port host + + # @returns [String | Nil] The request URL scheme. + def scheme + @http.scheme + end + + # @returns [Boolean] Whether the request URL scheme is HTTPS. + def ssl? + self.scheme == "https" + end + + # @returns [String] The request base URL if scheme and host are available. + def base_url + scheme = self.scheme + host = self.host + + if scheme && host + "#{scheme}://#{host}" + else + "" + end + end # @returns [String | Nil] The request user agent. def user_agent @@ -223,6 +286,12 @@ def user_agent def referrer @http.headers["referer"] end + alias referer referrer + + # @returns [Hash | Nil] The request session, if installed by Utopia::Session. + def session + @attributes["utopia.session"] || @attributes["rack.session"] + end # @returns [String | Nil] The remote peer address, if available. def ip @@ -231,11 +300,10 @@ def ip # @returns [String] The full request URL if scheme and host are available. def url - scheme = @http.scheme - host = self.host + base_url = self.base_url - if scheme && host - "#{scheme}://#{host}#{self.path}" + if !base_url.empty? + "#{base_url}#{self.path}" else self.path end diff --git a/test/utopia/request.rb b/test/utopia/request.rb index be1b4bb..497d9dd 100644 --- a/test/utopia/request.rb +++ b/test/utopia/request.rb @@ -29,4 +29,39 @@ expect(request.attributes[:locale]).to be == "en" end + + it "provides HTTP method predicates" do + expect(request.request_method).to be == "POST" + expect(request.post?).to be == true + expect(request.get?).to be == false + expect(request.options?).to be == false + end + + it "looks up arguments by string or symbol keys" do + expect(request["q"]).to be == "utopia" + expect(request[:q]).to be == "utopia" + end + + it "prefers request-local attributes over arguments" do + request[:q] = "local" + + expect(request[:q]).to be == "local" + end + + it "provides common request conveniences" do + http_request.scheme = "https" + http_request.authority = "example.com" + http_request.headers["referer"] = "/from" + + request["utopia.session"] = {"user_id" => 10} + + expect(request.scheme).to be == "https" + expect(request.ssl?).to be == true + expect(request.host_with_port).to be == "example.com" + expect(request.base_url).to be == "https://example.com" + expect(request.url).to be == "https://example.com/search?q=utopia" + expect(request.referer).to be == "/from" + expect(request.referrer).to be == "/from" + expect(request.session).to be == {"user_id" => 10} + end end From 66368232c7f1e0c142b8543c855e01f52a30577e Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 22 Jun 2026 19:10:38 +1200 Subject: [PATCH 09/16] Drop Rack middleware compatibility Assisted-By: devx/166ed168-1c4d-4c63-a5f6-8d0d9cbff13f --- bake/utopia/site.rb | 2 +- bake/utopia/static.rb | 5 +- gems.rb | 3 - lib/utopia/content/document.rb | 4 +- lib/utopia/content/middleware.rb | 9 +- lib/utopia/content/node.rb | 2 +- lib/utopia/content/response.rb | 7 +- lib/utopia/controller/base.rb | 21 +++-- lib/utopia/controller/middleware.rb | 7 +- lib/utopia/controller/respond.rb | 10 ++- lib/utopia/controller/responder.rb | 4 +- lib/utopia/controller/variables.rb | 2 +- lib/utopia/exceptions/handler.rb | 9 +- lib/utopia/exceptions/mailer.rb | 9 +- lib/utopia/http.rb | 5 +- lib/utopia/localization/middleware.rb | 9 +- lib/utopia/localization/wrapper.rb | 10 +-- lib/utopia/middleware.rb | 22 ----- lib/utopia/redirection.rb | 14 +--- lib/utopia/request.rb | 49 +---------- lib/utopia/response.rb | 28 ------- lib/utopia/session/middleware.rb | 9 +- lib/utopia/shell.rb | 18 ++-- lib/utopia/static/local_file.rb | 2 +- lib/utopia/static/middleware.rb | 7 +- setup/site/bake.rb | 2 +- setup/site/config.ru | 49 ----------- setup/site/config/application.rb | 50 +++++++++++ setup/site/falcon.rb | 21 ++++- setup/site/fixtures/website.rb | 27 +++--- setup/site/gems.rb | 1 - setup/site/lib/readme.txt | 2 +- setup/site/pages/welcome/index.xnode | 4 +- test/utopia/.performance/config.ru | 33 -------- .../utopia/.performance/config/application.rb | 27 ++++++ test/utopia/.performance/lib/readme.txt | 2 +- test/utopia/application.rb | 12 ++- test/utopia/command.rb | 8 +- test/utopia/content.rb | 28 ++++--- test/utopia/content.ru | 6 -- test/utopia/content/document.rb | 9 +- test/utopia/content/node.rb | 6 +- .../.websocket/server/controller.rb | 2 +- test/utopia/controller/middleware.rb | 21 +++-- test/utopia/controller/middleware.ru | 6 -- test/utopia/controller/respond.rb | 59 +++++++------ test/utopia/controller/respond.ru | 12 --- test/utopia/controller/rewrite.rb | 9 +- test/utopia/controller/sequence.rb | 20 +++-- test/utopia/controller/variables.rb | 10 ++- test/utopia/controller/websocket.rb | 13 ++- test/utopia/controller/websocket.ru | 5 -- test/utopia/empty.rb | 6 +- test/utopia/empty.ru | 6 -- test/utopia/exceptions/handler.rb | 18 ++-- test/utopia/exceptions/handler.ru | 8 -- test/utopia/exceptions/mailer.rb | 21 ++++- test/utopia/exceptions/mailer.ru | 10 --- test/utopia/localization.rb | 53 +++++++----- test/utopia/localization.ru | 15 ---- test/utopia/performance.rb | 23 +++--- test/utopia/protocol_application.rb | 71 ++++++++++++++++ test/utopia/redirection.rb | 31 ++++++- test/utopia/redirection_spec.ru | 27 ------ test/utopia/session.rb | 82 ++++++++++++++----- test/utopia/session_spec.ru | 24 ------ test/utopia/static.rb | 21 +++-- test/utopia/static.ru | 5 -- utopia.gemspec | 1 - 69 files changed, 555 insertions(+), 578 deletions(-) delete mode 100755 setup/site/config.ru create mode 100644 setup/site/config/application.rb delete mode 100755 test/utopia/.performance/config.ru create mode 100644 test/utopia/.performance/config/application.rb delete mode 100644 test/utopia/content.ru delete mode 100644 test/utopia/controller/middleware.ru delete mode 100644 test/utopia/controller/respond.ru delete mode 100644 test/utopia/controller/websocket.ru delete mode 100644 test/utopia/empty.ru delete mode 100644 test/utopia/exceptions/handler.ru delete mode 100644 test/utopia/exceptions/mailer.ru delete mode 100644 test/utopia/localization.ru create mode 100644 test/utopia/protocol_application.rb delete mode 100644 test/utopia/redirection_spec.ru delete mode 100644 test/utopia/session_spec.ru delete mode 100644 test/utopia/static.ru diff --git a/bake/utopia/site.rb b/bake/utopia/site.rb index 4723bbe..9999fb8 100644 --- a/bake/utopia/site.rb +++ b/bake/utopia/site.rb @@ -18,7 +18,7 @@ def initialize(...) SETUP_ROOT = File.expand_path("../../setup", __dir__) # Configuration files which should be installed/updated: -CONFIGURATION_FILES = [".gitignore", "config.ru", "config/environment.rb", "falcon.rb", "gems.rb", "bake.rb", "test/website.rb", "fixtures/website.rb"] +CONFIGURATION_FILES = [".gitignore", "config/application.rb", "config/environment.rb", "falcon.rb", "gems.rb", "bake.rb", "test/website.rb", "fixtures/website.rb"] # Directories that should exist: DIRECTORIES = ["config", "lib", "pages", "public", "bake", "fixtures", "test"] diff --git a/bake/utopia/static.rb b/bake/utopia/static.rb index 5d38b3a..3ca51f2 100644 --- a/bake/utopia/static.rb +++ b/bake/utopia/static.rb @@ -8,12 +8,13 @@ def generate(output_path: "static") require "async/io" require "async/http/endpoint" require "async/container" + require "utopia/application" - config_path = File.join(Dir.pwd, "config.ru") + application_path = File.join(Dir.pwd, Utopia::Application::CONFIGURATION_PATH) container_class = Async::Container::Threaded server_port = 9090 - app, options = Rack::Builder.parse_file(config_path) + app = Utopia::Application.load(application_path) container = container_class.run(count: 2) do Async do diff --git a/gems.rb b/gems.rb index 3930cda..74f50da 100644 --- a/gems.rb +++ b/gems.rb @@ -21,7 +21,6 @@ group :development do gem "json" - gem "rackula" end group :test do @@ -40,6 +39,4 @@ gem "bake-test-external" gem "benchmark-ips" - - gem "rack-test" end diff --git a/lib/utopia/content/document.rb b/lib/utopia/content/document.rb index d222ddf..cfd29ca 100644 --- a/lib/utopia/content/document.rb +++ b/lib/utopia/content/document.rb @@ -41,7 +41,7 @@ def initialize(request, attributes = {}) # @returns [Path] The original request path, if known. def request_path - Path[request.env["REQUEST_PATH"]] + Path[request.attributes["REQUEST_PATH"]] end protected def current_base_uri_path @@ -87,7 +87,7 @@ def parse_markup(markup) MarkupParser.parse(markup, self) end - # The Rack::Request for this document. + # The request for this document. attr :request # Per-document global attributes. diff --git a/lib/utopia/content/middleware.rb b/lib/utopia/content/middleware.rb index b6f070d..c242e9b 100644 --- a/lib/utopia/content/middleware.rb +++ b/lib/utopia/content/middleware.rb @@ -102,9 +102,6 @@ def respond(link, request) end def call(request) - legacy = Utopia::Middleware.legacy_request?(request) - request = Utopia::Middleware.request(request) - path = Path.create(request.path_info) # Check if the request is to a non-specific index. This only works for requests with a given name: @@ -115,17 +112,17 @@ def call(request) if File.directory? directory_path index_path = [basename, INDEX] - return Utopia::Middleware.response(Utopia::Response[307, {HTTP::LOCATION => path.dirname.join(index_path).to_s}, []], legacy) + return Utopia::Response[307, {HTTP::LOCATION => path.dirname.join(index_path).to_s}, []] end locale = request[Localization::CURRENT_LOCALE_KEY] if link = @links.for(path, locale) if response = self.respond(link, request) - return Utopia::Middleware.response(response, legacy) + return response end end - return Utopia::Middleware.response(@app.call(request), legacy) + return @app.call(request) end private diff --git a/lib/utopia/content/node.rb b/lib/utopia/content/node.rb index 8a743bf..377de20 100644 --- a/lib/utopia/content/node.rb +++ b/lib/utopia/content/node.rb @@ -107,7 +107,7 @@ def call(document, state) end def process!(request, attributes = {}) - Document.render(self, request, attributes).to_a + Document.render(self, request, attributes).to_protocol_response end # This is a special context in which a limited set of well defined methods are exposed in the content view. diff --git a/lib/utopia/content/response.rb b/lib/utopia/content/response.rb index 7d6dbcc..ac4af7c 100644 --- a/lib/utopia/content/response.rb +++ b/lib/utopia/content/response.rb @@ -3,9 +3,10 @@ # Released under the MIT License. # Copyright, 2010-2025, by Samuel Williams. +require_relative "../response" + module Utopia module Content - # Compatibility with older versions of rack: EXPIRES = "expires".freeze CACHE_CONTROL = "cache-control".freeze CONTENT_TYPE = "content-type".freeze @@ -34,8 +35,8 @@ def lookup(tag) return nil end - def to_a - [@status, @headers, @body] + def to_protocol_response + Utopia::Response[@status, @headers, @body] end # Specifies that the content shouldn't be cached. Overrides `cache!` if already called. diff --git a/lib/utopia/controller/base.rb b/lib/utopia/controller/base.rb index 3ca02b6..ec093f5 100644 --- a/lib/utopia/controller/base.rb +++ b/lib/utopia/controller/base.rb @@ -4,6 +4,7 @@ # Copyright, 2014-2025, by Samuel Williams. require_relative "../http" +require_relative "../response" module Utopia module Controller @@ -11,6 +12,12 @@ module Controller # The base implementation of a controller class. class Base + Result = Struct.new(:status, :headers, :body) do + def to_protocol_response + Utopia::Response[status, headers, body || []] + end + end + URI_PATH = nil BASE_PATH = nil CONTROLLER = nil @@ -67,7 +74,7 @@ def catch_response end end - # 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. + # Return nil if this controller didn't do anything. Request will keep on processing. Return a valid response if the controller can do so. def process!(request, relative_path) return nil end @@ -79,9 +86,9 @@ def copy_instance_variables(from) end end - # Call into the next app as defined by rack. - def call(env) - self.class.controller.app.call(env) + # Call into the next application. + def call(request) + self.class.controller.app.call(request) end # This will cause the middleware to generate a response. @@ -104,7 +111,7 @@ def redirect!(target, status = 302) status = HTTP::Status.new(status, 300...400) location = target.to_s - respond! [status.to_i, {HTTP::LOCATION => location}, [status.to_s]] + respond! Result.new(status.to_i, {HTTP::LOCATION => location}, [status.to_s]) end # Controller relative redirect. @@ -117,7 +124,7 @@ def fail!(error = 400, message = nil) status = HTTP::Status.new(error, 400...600) message ||= status.to_s - respond! [status.to_i, {}, [message]] + respond! Result.new(status.to_i, {}, [message]) end # Succeed the request and immediately respond. @@ -129,7 +136,7 @@ def succeed!(status: 200, headers: {}, type: nil, **options) end body = body_for(status, headers, options) - respond! [status.to_i, headers, body || []] + respond! Result.new(status.to_i, headers, body || []) end # Generate the body for the given status, headers and options. diff --git a/lib/utopia/controller/middleware.rb b/lib/utopia/controller/middleware.rb index ed111ab..74659cd 100644 --- a/lib/utopia/controller/middleware.rb +++ b/lib/utopia/controller/middleware.rb @@ -118,16 +118,13 @@ def invoke_controllers(request) end def call(request) - legacy = Utopia::Middleware.legacy_request?(request) - request = Utopia::Middleware.request(request) - request[VARIABLES_KEY] ||= Variables.new if result = invoke_controllers(request) - return Utopia::Middleware.response(result, legacy) + return result end - return Utopia::Middleware.response(@app.call(request), legacy) + return @app.call(request) end end end diff --git a/lib/utopia/controller/respond.rb b/lib/utopia/controller/respond.rb index e0aa6d6..0d7c6be 100644 --- a/lib/utopia/controller/respond.rb +++ b/lib/utopia/controller/respond.rb @@ -4,6 +4,7 @@ # Copyright, 2016-2025, by Samuel Williams. require_relative "../http" +require_relative "../response" require_relative "responder" module Utopia @@ -26,7 +27,7 @@ def respond_to(context, request) end def response_for(context, request, response) - @responder&.respond_to(context, request).with(*response[2]) + @responder&.respond_to(context, request).with(*response.body) end end @@ -45,14 +46,17 @@ def response_for(request, original_response) # If the user called {Base#ignore!}, it's possible response is nil: if response # There was an updated response so merge it: - return [original_response[0], original_response[1].merge(response[1]), response[2] || original_response[2]] + headers = original_response.headers.dup + headers.update(response.headers) + + return Utopia::Response[original_response.status, headers, response.body || original_response.body] end end # Invokes super. If a response is generated, format it based on the Accept: header, unless the content type was already specified. def process!(request, path) if response = super - headers = response[1] + headers = response.headers # Don't try to convert the response if a content type was explicitly specified. if headers[HTTP::CONTENT_TYPE] diff --git a/lib/utopia/controller/responder.rb b/lib/utopia/controller/responder.rb index 0a2f6ae..eb72791 100644 --- a/lib/utopia/controller/responder.rb +++ b/lib/utopia/controller/responder.rb @@ -69,7 +69,9 @@ def freeze 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(request.env) + media_types = HTTP::Accept::MediaTypes.browser_preferred_media_types( + HTTP::Accept::MediaTypes::HTTP_ACCEPT => Array(request.headers["accept"]).join(",") + ) handler, media_range = @handlers.for(media_types) diff --git a/lib/utopia/controller/variables.rb b/lib/utopia/controller/variables.rb index ebee07d..7f43a78 100644 --- a/lib/utopia/controller/variables.rb +++ b/lib/utopia/controller/variables.rb @@ -65,7 +65,7 @@ def [] key end def self.[] request - request.env[VARIABLES_KEY] + request.attributes[VARIABLES_KEY] end end end diff --git a/lib/utopia/exceptions/handler.rb b/lib/utopia/exceptions/handler.rb index 2885c8d..1aa4380 100644 --- a/lib/utopia/exceptions/handler.rb +++ b/lib/utopia/exceptions/handler.rb @@ -29,11 +29,8 @@ def freeze end def call(request) - legacy = Utopia::Middleware.legacy_request?(request) - request = Utopia::Middleware.request(request) - begin - return Utopia::Middleware.response(@app.call(request), legacy) + return @app.call(request) rescue Exception => exception Console.warn(self, "An error occurred while processing the request.", error: exception) @@ -48,11 +45,11 @@ def call(request) error_response = Response.wrap(@app.call(error_request)) error_response.status = 500 - return Utopia::Middleware.response(error_response, legacy) + return error_response rescue Exception => exception # If redirection fails, we also finish with a fatal error: Console.error(self, "An error occurred while invoking the error handler.", error: exception) - return Utopia::Middleware.response(Response[500, {"content-type" => "text/plain"}, ["An error occurred while processing the request."]], legacy) + return Response[500, {"content-type" => "text/plain"}, ["An error occurred while processing the request."]] end end end diff --git a/lib/utopia/exceptions/mailer.rb b/lib/utopia/exceptions/mailer.rb index 7fd01aa..787b7ad 100644 --- a/lib/utopia/exceptions/mailer.rb +++ b/lib/utopia/exceptions/mailer.rb @@ -26,7 +26,7 @@ class Mailer # @param from [String] The from address for error reports. # @param subject [String] The subject template which can access attributes defined by `#attributes_for`. # @param delivery_method [Object] The delivery method as required by the mail gem. - # @param dump_environment [Boolean] Attach `env` as `environment.yaml` to the error report. + # @param dump_environment [Boolean] Attach request attributes as `attributes.yaml` to the error report. def initialize(app, to: "postmaster", from: DEFAULT_FROM, subject: DEFAULT_SUBJECT, delivery_method: LOCAL_SMTP, dump_environment: false) @app = app @@ -50,11 +50,8 @@ def freeze end def call(request) - legacy = Utopia::Middleware.legacy_request?(request) - request = Utopia::Middleware.request(request) - begin - return Utopia::Middleware.response(@app.call(request), legacy) + return @app.call(request) rescue => exception send_notification exception, request @@ -105,7 +102,7 @@ def generate_body(exception, request) io.puts "#{request.method} #{request.url}" - # TODO embed `rack.input` if it's textual? + # TODO embed the request body if it's textual? # TODO dump and embed `utopia.variables`? io.puts diff --git a/lib/utopia/http.rb b/lib/utopia/http.rb index e8d49ca..a78929d 100644 --- a/lib/utopia/http.rb +++ b/lib/utopia/http.rb @@ -3,8 +3,6 @@ # Released under the MIT License. # Copyright, 2010-2025, by Samuel Williams. -require "rack" - require "http/accept" module Utopia @@ -71,7 +69,7 @@ module HTTP 500 => "Internal Server Error".freeze, 501 => "Not Implemented".freeze, 503 => "Service Unavailable".freeze - }.merge(Rack::Utils::HTTP_STATUS_CODES) + } CONTENT_TYPE = "content-type".freeze LOCATION = "location".freeze @@ -99,7 +97,6 @@ def to_s STATUS_DESCRIPTIONS[@code] || @code.to_s end - # Allow to be used for rack body: def each yield to_s end diff --git a/lib/utopia/localization/middleware.rb b/lib/utopia/localization/middleware.rb index 3d2893f..4eefab3 100644 --- a/lib/utopia/localization/middleware.rb +++ b/lib/utopia/localization/middleware.rb @@ -10,7 +10,7 @@ module Utopia module Localization class Middleware - RESOURCE_NOT_FOUND = [400, {}, []].freeze + RESOURCE_NOT_FOUND = Response[400, {}, []].freeze HTTP_ACCEPT_LANGUAGE = "HTTP_ACCEPT_LANGUAGE".freeze @@ -151,11 +151,8 @@ def vary(request, response) end def call(request) - legacy = Utopia::Middleware.legacy_request?(request) - request = Utopia::Middleware.request(request) - # Pass the request through if it shouldn't be localized: - return Utopia::Middleware.response(@app.call(request), legacy) unless localized?(request) + return @app.call(request) unless localized?(request) request[LOCALIZATION_KEY] = self @@ -172,7 +169,7 @@ def call(request) response.close if response.respond_to?(:close) end - return Utopia::Middleware.response(vary(request, response), legacy) + return vary(request, response) end end end diff --git a/lib/utopia/localization/wrapper.rb b/lib/utopia/localization/wrapper.rb index d081234..a9e0c5e 100644 --- a/lib/utopia/localization/wrapper.rb +++ b/lib/utopia/localization/wrapper.rb @@ -13,12 +13,12 @@ module Localization # A wrapper to provide easy access to locale related data in the request. class Wrapper - def initialize(env) - @env = env + def initialize(attributes) + @attributes = attributes end def localization - @env[LOCALIZATION_KEY] + @attributes[LOCALIZATION_KEY] end def localized? @@ -27,7 +27,7 @@ def localized? # Returns the current locale or nil if not localized. def current_locale - @env[CURRENT_LOCALE_KEY] + @attributes[CURRENT_LOCALE_KEY] end # Returns the default locale or nil if not localized. @@ -46,7 +46,7 @@ def localized_path(path, locale) end def self.[] request - Wrapper.new(request.env) + Wrapper.new(request.attributes) end end end diff --git a/lib/utopia/middleware.rb b/lib/utopia/middleware.rb index 43073dd..088c872 100644 --- a/lib/utopia/middleware.rb +++ b/lib/utopia/middleware.rb @@ -5,29 +5,7 @@ require_relative "http" require_relative "path" -require_relative "request" -require_relative "response" - module Utopia - # Shared helpers for middleware boundary compatibility. - module Middleware - def self.legacy_request?(request) - request.is_a?(Hash) - end - - def self.request(request) - Request.wrap(request) - end - - def self.response(response, legacy) - if legacy - Response.to_rack(response) - else - response - end - end - end - # The default pages path for {Utopia::Content} middleware. PAGES_PATH = "pages".freeze diff --git a/lib/utopia/redirection.rb b/lib/utopia/redirection.rb index eca9986..36ebd8a 100644 --- a/lib/utopia/redirection.rb +++ b/lib/utopia/redirection.rb @@ -43,9 +43,6 @@ def unhandled_error?(response) end def call(request) - legacy = Middleware.legacy_request?(request) - request = Middleware.request(request) - response = Response.wrap(@app.call(request)) if unhandled_error?(response) && location = @codes[response.status] @@ -57,10 +54,10 @@ def call(request) else # Feed the error code back with the error document: error_response.status = response.status - return Middleware.response(error_response, legacy) + return error_response end else - return Middleware.response(response, legacy) + return response end end end @@ -109,19 +106,16 @@ def [] path end def call(request) - legacy = Middleware.legacy_request?(request) - request = Middleware.request(request) - # Normalize the path to remove redundant slashes, `.` and `..` segments. # This prevents protocol-relative redirect URLs (e.g. //evil.com/index) # from being generated when PATH_INFO contains a double leading slash. path = Path.create(request.path_info).simplify.to_s if redirection = self[path] - return Middleware.response(redirection, legacy) + return redirection end - return Middleware.response(@app.call(request), legacy) + return @app.call(request) end end diff --git a/lib/utopia/request.rb b/lib/utopia/request.rb index e70556f..c3bfffe 100644 --- a/lib/utopia/request.rb +++ b/lib/utopia/request.rb @@ -14,60 +14,20 @@ module Utopia # # This class intentionally keeps a small surface area. Framework features such # as arguments, sessions, localization and controller variables should be added - # as explicit Utopia concepts rather than relying on a Rack-style env hash. + # as explicit Utopia concepts rather than relying on transport-specific state. class Request - # Wrap either a {Protocol::HTTP::Request} or a legacy Rack env hash. + # Wrap either a {Protocol::HTTP::Request} or an existing Utopia request. def self.wrap(request) case request when self request when Protocol::HTTP::Request self.new(request) - when Hash - self.from_rack_env(request) else raise ArgumentError, "Unable to wrap request: #{request.inspect}!" end end - # Build a Utopia request from a Rack env hash for legacy compatibility. - def self.from_rack_env(env) - path = env["PATH_INFO"].to_s - - query = env["QUERY_STRING"] - - if query && !query.empty? - path = "#{path}?#{query}" - end - - headers = Protocol::HTTP::Headers.new - - env.each do |key, value| - case key - when "CONTENT_TYPE" - headers["content-type"] = value - when "CONTENT_LENGTH" - headers["content-length"] = value - else - if key.is_a?(String) && key.start_with?("HTTP_") - headers[key[5..].downcase.tr("_", "-")] = value - end - end - end - - http = Protocol::HTTP::Request.new( - env["rack.url_scheme"], - env["HTTP_HOST"], - env["REQUEST_METHOD"], - path, - env["SERVER_PROTOCOL"], - headers, - env["rack.input"] - ) - - self.new(http, attributes: env) - end - # Initialize a request wrapper. # @parameter http [Protocol::HTTP::Request] The underlying protocol request. # @parameter attributes [Hash | Nil] Request-local application state. @@ -83,7 +43,6 @@ def initialize(http, attributes: nil) # Request-local application state. attr :attributes - alias env attributes # Fetch request-local application state. def [] key @@ -106,8 +65,6 @@ def [] key self.headers["if-none-match"] when "HTTP_RANGE" self.headers["range"] - when "rack.input" - self.body else if key.is_a?(String) && key.start_with?("HTTP_") self.headers[key[5..].downcase.tr("_", "-")] @@ -290,7 +247,7 @@ def referrer # @returns [Hash | Nil] The request session, if installed by Utopia::Session. def session - @attributes["utopia.session"] || @attributes["rack.session"] + @attributes["utopia.session"] end # @returns [String | Nil] The remote peer address, if available. diff --git a/lib/utopia/response.rb b/lib/utopia/response.rb index 79288e4..926caab 100644 --- a/lib/utopia/response.rb +++ b/lib/utopia/response.rb @@ -35,43 +35,15 @@ def self.wrap(response) case response when Protocol::HTTP::Response response - when Array - Protocol::HTTP::Response[*response] else if response.respond_to?(:to_protocol_response) response.to_protocol_response - elsif response.respond_to?(:to_ary) - Protocol::HTTP::Response[*response.to_ary] else response end end end - # Convert a response-like value to a Rack response tuple. - def self.to_rack(response) - return response if response.is_a?(Array) - - response = self.wrap(response) - - case response - when Protocol::HTTP::Response - headers = {} - - response.headers.each do |key, value| - if existing = headers[key] - headers[key] = "#{existing}\n#{value}" - else - headers[key] = value.to_s - end - end - - [response.status, headers, response.body || []] - else - response - end - end - # Build a redirect response. # @parameter location [String] The redirect location. # @parameter status [Integer] The redirect status code. diff --git a/lib/utopia/session/middleware.rb b/lib/utopia/session/middleware.rb index 2cb74ef..0ad6ebf 100644 --- a/lib/utopia/session/middleware.rb +++ b/lib/utopia/session/middleware.rb @@ -26,7 +26,6 @@ class PayloadError < StandardError SECRET_KEY = "UTOPIA_SESSION_SECRET".freeze SESSION_KEY = "utopia.session".freeze - RACK_SESSION = "rack.session".freeze CIPHER_ALGORITHM = "aes-256-cbc" # The session will expire if no requests were made within 24 hours: @@ -40,7 +39,7 @@ class PayloadError < StandardError # @param same_site [Symbol, String] Controls how the cookie is provided to the site. # @param expires_after [String] The cache-control header to set for static content. # @param options [Hash] Additional defaults used for generating the session cookie. - def initialize(app, session_name: RACK_SESSION, secret: nil, expires_after: DEFAULT_EXPIRES_AFTER, update_timeout: DEFAULT_UPDATE_TIMEOUT, secure: false, same_site: :lax, maximum_size: MAXIMUM_SIZE, **options) + def initialize(app, session_name: SESSION_KEY, secret: nil, expires_after: DEFAULT_EXPIRES_AFTER, update_timeout: DEFAULT_UPDATE_TIMEOUT, secure: false, same_site: :lax, maximum_size: MAXIMUM_SIZE, **options) @app = app @session_name = session_name @@ -95,16 +94,13 @@ def freeze end def call(request) - legacy = Utopia::Middleware.legacy_request?(request) - request = Utopia::Middleware.request(request) - session_hash = prepare_session(request) response = Response.wrap(@app.call(request)) update_session(session_hash, response.headers) - return Utopia::Middleware.response(response, legacy) + return response end protected @@ -115,7 +111,6 @@ def prepare_session(request) end request[SESSION_KEY] = session - request[RACK_SESSION] = session end def update_session(session_hash, headers) diff --git a/lib/utopia/shell.rb b/lib/utopia/shell.rb index eb4c12e..9bb50e7 100644 --- a/lib/utopia/shell.rb +++ b/lib/utopia/shell.rb @@ -3,24 +3,28 @@ # Released under the MIT License. # Copyright, 2020-2025, by Samuel Williams. -require "rack/builder" -require "rack/test" +require "protocol/http/request" +require_relative "application" require "irb" module Utopia # This is designed to be used with the corresponding bake task. class Shell - include Rack::Test::Methods - def initialize(context) @context = context @app = nil end def app - @app ||= Rack::Builder.parse_file( - File.expand_path("config.ru", @context.root) - ).first + @app ||= Application.load(File.expand_path(Application::CONFIGURATION_PATH, @context.root)) + end + + def get(path, headers = nil) + app.call(Protocol::HTTP::Request["GET", path, headers]) + end + + def post(path, headers = nil, body = nil) + app.call(Protocol::HTTP::Request["POST", path, headers, body]) end def to_s diff --git a/lib/utopia/static/local_file.rb b/lib/utopia/static/local_file.rb index ab97172..a6c4507 100644 --- a/lib/utopia/static/local_file.rb +++ b/lib/utopia/static/local_file.rb @@ -26,7 +26,7 @@ def initialize(root, path) attr :etag attr :range - # Fit in with Rack::Sendfile + # Expose the filesystem path for upstream sendfile support. def to_path full_path end diff --git a/lib/utopia/static/middleware.rb b/lib/utopia/static/middleware.rb index c285fce..29790e2 100644 --- a/lib/utopia/static/middleware.rb +++ b/lib/utopia/static/middleware.rb @@ -94,20 +94,17 @@ def respond(request, path_info, extension) end def call(request) - legacy = Utopia::Middleware.legacy_request?(request) - request = Utopia::Middleware.request(request) - path_info = request.path_info extension = File.extname(path_info) if @extensions.key?(extension.downcase) if response = self.respond(request, path_info, extension) - return Utopia::Middleware.response(response, legacy) + return response end end # else if no file was found: - return Utopia::Middleware.response(@app.call(request), legacy) + return @app.call(request) end end diff --git a/setup/site/bake.rb b/setup/site/bake.rb index ad407c9..4f58c9a 100644 --- a/setup/site/bake.rb +++ b/setup/site/bake.rb @@ -9,7 +9,7 @@ def deploy # Restart the application server. def restart - call "falcon:supervisor:restart" + puts "Restart the Falcon service using your process manager." end # Start the development server. diff --git a/setup/site/config.ru b/setup/site/config.ru deleted file mode 100755 index e60b0f5..0000000 --- a/setup/site/config.ru +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env rackup -# frozen_string_literal: true - -require_relative "config/environment" - -self.freeze_app - -if UTOPIA.production? - # Handle exceptions in production with a error page and send an email notification: - use Utopia::Exceptions::Handler - use Utopia::Exceptions::Mailer -else - # We want to propate exceptions up when running tests: - use Rack::ShowExceptions unless UTOPIA.testing? -end - -# Serve static files from "public" directory: -use Utopia::Static, root: "public" - -use Utopia::Redirection::Rewrite, { - "/" => "/welcome/index" -} - -use Utopia::Redirection::DirectoryIndex - -use Utopia::Redirection::Errors, { - 404 => "/errors/file-not-found" -} - -require "utopia/localization" -use Utopia::Localization, - default_locale: "en", - locales: ["en", "de", "ja", "zh"] - -require "utopia/session" -use Utopia::Session, - expires_after: 3600 * 24, - secret: UTOPIA.secret_for(:session), - secure: true - -use Utopia::Controller - -# Serve static files from "pages" directory: -use Utopia::Static - -# Serve dynamic content: -use Utopia::Content - -run lambda{|env| [404, {}, []]} diff --git a/setup/site/config/application.rb b/setup/site/config/application.rb new file mode 100644 index 0000000..1d0af6b --- /dev/null +++ b/setup/site/config/application.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require_relative "environment" + +require "utopia/application" +require "utopia/controller" +require "utopia/content" +require "utopia/exceptions" +require "utopia/localization" +require "utopia/redirection" +require "utopia/session" +require "utopia/static" + +Application = Utopia::Application.build do + if UTOPIA.production? + # Handle exceptions in production with an error page and send an email notification: + use Utopia::Exceptions::Handler + use Utopia::Exceptions::Mailer + end + + # Serve static files from "public" directory: + use Utopia::Static, root: "public" + + use Utopia::Redirection::Rewrite, { + "/" => "/welcome/index" + } + + use Utopia::Redirection::DirectoryIndex + + use Utopia::Redirection::Errors, { + 404 => "/errors/file-not-found" + } + + use Utopia::Localization, + default_locale: "en", + locales: ["en", "de", "ja", "zh"] + + use Utopia::Session, + expires_after: 3600 * 24, + secret: UTOPIA.secret_for(:session), + secure: true + + use Utopia::Controller + + # Serve static files from "pages" directory: + use Utopia::Static + + # Serve dynamic content: + use Utopia::Content +end diff --git a/setup/site/falcon.rb b/setup/site/falcon.rb index 2eeba07..6075d9f 100755 --- a/setup/site/falcon.rb +++ b/setup/site/falcon.rb @@ -2,11 +2,24 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2019-2022, by Samuel Williams. +# Copyright, 2019-2026, by Samuel Williams. -load :rack, :lets_encrypt_tls, :supervisor +require "async/service/supervisor" +require "falcon/environment/application" +require "falcon/environment/lets_encrypt_tls" +require "utopia/application" hostname = File.basename(__dir__) -rack hostname, :lets_encrypt_tls -supervisor +service hostname do + include Falcon::Environment::Application + include Falcon::Environment::LetsEncryptTLS + + def middleware + Utopia::Application.load + end +end + +service "supervisor" do + include Async::Service::Supervisor::Environment +end diff --git a/setup/site/fixtures/website.rb b/setup/site/fixtures/website.rb index a6e37d6..85f200d 100644 --- a/setup/site/fixtures/website.rb +++ b/setup/site/fixtures/website.rb @@ -3,27 +3,27 @@ # Released under the MIT License. # Copyright, 2016-2025, by Samuel Williams. -require "rack/test" +require "protocol/http/request" require "sus/fixtures/async/http" -require "protocol/rack" +require "utopia/application" AWebsite = Sus::Shared("a website") do - include Rack::Test::Methods + let(:application_path) {File.expand_path("../config/application.rb", __dir__)} + let(:application_directory) {File.dirname(application_path)} - let(:rackup_path) {File.expand_path("../config.ru", __dir__)} - let(:rackup_directory) {File.dirname(rackup_path)} + let(:app) {Utopia::Application.load(application_path)} - let(:app) {Rack::Builder.parse_file(rackup_path)} + def get(path) + @last_response = app.call(Protocol::HTTP::Request["GET", path]) + end + + attr :last_response end AValidPage = Sus::Shared("a valid page") do |path| it "can access #{path}" do get path - while last_response.redirect? - follow_redirect! - end - expect(last_response.status).to be == 200 end end @@ -31,9 +31,8 @@ AServer = Sus::Shared("a server") do include Sus::Fixtures::Async::HTTP::ServerContext - let(:rackup_path) {File.expand_path("../config.ru", __dir__)} - let(:rackup_directory) {File.dirname(rackup_path)} + let(:application_path) {File.expand_path("../config/application.rb", __dir__)} + let(:application_directory) {File.dirname(application_path)} - let(:rack_app) {Rack::Builder.parse_file(rackup_path)} - let(:app) {Protocol::Rack::Adapter.new(rack_app)} + let(:app) {Utopia::Application.load(application_path)} end diff --git a/setup/site/gems.rb b/setup/site/gems.rb index b6aa672..da2f122 100644 --- a/setup/site/gems.rb +++ b/setup/site/gems.rb @@ -17,7 +17,6 @@ group :development do gem "bake-test" - gem "rack-test" gem "sus" gem "sus-fixtures-async-http" diff --git a/setup/site/lib/readme.txt b/setup/site/lib/readme.txt index 43afc24..a0bc189 100644 --- a/setup/site/lib/readme.txt +++ b/setup/site/lib/readme.txt @@ -1 +1 @@ -You can add additional code for your application in this directory, and require it directly from the config.ru. \ No newline at end of file +You can add additional code for your application in this directory, and require it directly from config/application.rb. diff --git a/setup/site/pages/welcome/index.xnode b/setup/site/pages/welcome/index.xnode index c60b4c1..91bbc44 100644 --- a/setup/site/pages/welcome/index.xnode +++ b/setup/site/pages/welcome/index.xnode @@ -11,7 +11,7 @@

Modular code and structure

-

Utopia provides independently useful Rack middleware and has been designed with simplicity in mind. Several fully-featured webapps and a ton of commercial websites have guided the development of the Utopia stack. It is capable of handling a diverse range of requirements.

+

Utopia provides independently useful HTTP middleware and has been designed with simplicity in mind. Several fully-featured webapps and a ton of commercial websites have guided the development of the Utopia stack. It is capable of handling a diverse range of requirements.

@@ -32,4 +32,4 @@

Utopia supports the Accept-Language header and transparently selects the correct view to render. Build multi-lingual websites and webapps easily: translate content incrementally as required, or not at all.

- \ No newline at end of file + diff --git a/test/utopia/.performance/config.ru b/test/utopia/.performance/config.ru deleted file mode 100755 index 11568e7..0000000 --- a/test/utopia/.performance/config.ru +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env rackup -# frozen_string_literal: true - -require 'utopia' -require 'json' - -self.freeze_app - -use Utopia::Redirection::Rewrite, { - '/' => '/welcome/index' -} - -use Utopia::Redirection::DirectoryIndex - -use Utopia::Redirection::Errors, { - 404 => '/errors/file-not-found' -} - -# use Utopia::Localization, -# :default_locale => 'en', -# :locales => ['en', 'de', 'ja', 'zh'] - -use Utopia::Controller, - root: File.expand_path('pages', __dir__) - -use Utopia::Static, - root: File.expand_path('pages', __dir__) - -# Serve dynamic content -use Utopia::Content, - root: File.expand_path('pages', __dir__) - -run lambda { |env| [404, {}, []] } diff --git a/test/utopia/.performance/config/application.rb b/test/utopia/.performance/config/application.rb new file mode 100644 index 0000000..46c0f51 --- /dev/null +++ b/test/utopia/.performance/config/application.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "json" + +require "utopia/application" +require "utopia/controller" +require "utopia/content" +require "utopia/redirection" +require "utopia/static" + +ROOT = File.expand_path("../pages", __dir__) + +Application = Utopia::Application.build do + use Utopia::Redirection::Rewrite, { + "/" => "/welcome/index" + } + + use Utopia::Redirection::DirectoryIndex + + use Utopia::Redirection::Errors, { + 404 => "/errors/file-not-found" + } + + use Utopia::Controller, root: ROOT + use Utopia::Static, root: ROOT + use Utopia::Content, root: ROOT +end diff --git a/test/utopia/.performance/lib/readme.txt b/test/utopia/.performance/lib/readme.txt index 43afc24..a0bc189 100644 --- a/test/utopia/.performance/lib/readme.txt +++ b/test/utopia/.performance/lib/readme.txt @@ -1 +1 @@ -You can add additional code for your application in this directory, and require it directly from the config.ru. \ No newline at end of file +You can add additional code for your application in this directory, and require it directly from config/application.rb. diff --git a/test/utopia/application.rb b/test/utopia/application.rb index 9cb3f39..865dd11 100644 --- a/test/utopia/application.rb +++ b/test/utopia/application.rb @@ -33,16 +33,22 @@ expect(response.headers["content-type"]).to be == "text/plain; charset=utf-8" end - it "normalizes tuple responses" do + it "normalizes protocol response objects" do + response_object = Object.new + + def response_object.to_protocol_response + Utopia::Response.text("Created", 201) + end + application = subject.build do - run lambda{|request| [201, {"content-type" => "text/plain"}, ["Created: ", request.path_info]]} + run lambda{|request| response_object} end response = application.call(http_request) expect(response).to be_a(Protocol::HTTP::Response) expect(response.status).to be == 201 - expect(response.headers["content-type"]).to be == "text/plain" + expect(response.read).to be == "Created" end it "uses a not found default" do diff --git a/test/utopia/command.rb b/test/utopia/command.rb index 59de189..96c9ecd 100644 --- a/test/utopia/command.rb +++ b/test/utopia/command.rb @@ -25,7 +25,7 @@ def group_rw(path) return gaccess == "6" || gaccess == "7" end - REQUIRED_GEMS = ["bake", "bake-test", "sus", "covered", "rack-test", "sus-fixtures-async-http", "falcon", "net-smtp", "benchmark-http", "protocol-rack"] + REQUIRED_GEMS = ["bake", "bake-test", "sus", "covered", "sus-fixtures-async-http", "falcon", "net-smtp", "benchmark-http"] def bundle_path File.join(utopia_path, "vendor/bundle") @@ -50,7 +50,7 @@ def install_packages(dir) system("bundle", "exec", "bake", "utopia:site:create", chdir: root, exception: true) - expected_files = [".git", "gems.rb", "gems.locked", "readme.md", "bake.rb", "config.ru", "lib", "pages", "public", "test"] + expected_files = [".git", "gems.rb", "gems.locked", "readme.md", "bake.rb", "config", "lib", "pages", "public", "test"] site_files = Dir.entries(root) expected_files.each do |file| @@ -114,13 +114,13 @@ def install_packages(dir) system("git", "push", "--set-upstream", server_path, "main", chdir: site_path, exception: true) - expected_files = %W[.git gems.rb gems.locked readme.md bake.rb config.ru lib pages public] + expected_files = %W[.git gems.rb gems.locked readme.md bake.rb config lib pages public] server_files = Dir.entries(server_path) expected_files.each do |file| expect(server_files).to be(:include?, file) end - expect(File.executable? File.join(server_path, "config.ru")).to be == true + expect(File.file? File.join(server_path, "config/application.rb")).to be == true end end diff --git a/test/utopia/content.rb b/test/utopia/content.rb index a140f05..6afbf20 100755 --- a/test/utopia/content.rb +++ b/test/utopia/content.rb @@ -3,24 +3,30 @@ # Released under the MIT License. # Copyright, 2012-2025, by Samuel Williams. -require "rack/test" require "utopia/content" +require_relative "protocol_application" describe Utopia::Content do - include Rack::Test::Methods + include ProtocolApplication - let(:app) {Rack::Builder.parse_file(File.expand_path("content.ru", __dir__))} + let(:app) do + root = File.expand_path(".content", __dir__) + + Utopia::Application.build do + use Utopia::Content, root: root + end + end it "should generate identical html" do get "/test" - expect(last_response.body).to be == File.read(File.expand_path(".content/test.xnode", __dir__)) + expect(body).to be == File.read(File.expand_path(".content/test.xnode", __dir__)) end it "should get a local path" do get "/node/index" - expect(last_response.body).to be == File.expand_path(".content/node", __dir__) + expect(body).to be == File.expand_path(".content/node", __dir__) end it "should successfully redirect to the index page" do @@ -38,19 +44,19 @@ it "should successfully render the index page" do get "/index" - expect(last_response.body).to be == "

Hello World

" + expect(body).to be == "

Hello World

" end it "should render partials correctly" do get "/content/test-partial" - expect(last_response.body).to be == "10" + expect(body).to be == "10" end it "should generate valid importmap" do get "/script/importmap" - expect(last_response.body).to be == <<~IMPORTMAP.chomp + expect(body).to be == <<~IMPORTMAP.chomp @@ -90,8 +96,8 @@ node = content.lookup_node(path) expect(node).to be_a Utopia::Content::Node - status, headers, body = node.process!({}, {}) - expect(body.join).to be == "

Hello World

" + response = node.process!(nil, {}) + expect(response.read).to be == "

Hello World

" end it "should fetch template and use cache" do diff --git a/test/utopia/content.ru b/test/utopia/content.ru deleted file mode 100644 index 43d093f..0000000 --- a/test/utopia/content.ru +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -use Utopia::Content, - root: File.expand_path(".content", __dir__) - -run lambda{|env| [404, {}, []]} diff --git a/test/utopia/content/document.rb b/test/utopia/content/document.rb index 8cf17c4..36b7c0b 100644 --- a/test/utopia/content/document.rb +++ b/test/utopia/content/document.rb @@ -4,11 +4,12 @@ # Copyright, 2017-2025, by Samuel Williams. require "utopia/content/document" -require "rack/request" +require "protocol/http/request" +require "utopia/request" describe Utopia::Content::Document do - let(:env) {Hash["REQUEST_PATH" => "/index"]} - let(:request) {Rack::Request.new(env)} + let(:path) {"/index"} + let(:request) {Utopia::Request.new(Protocol::HTTP::Request["GET", path])} let(:document) {subject.new(request, {})} it "should generate valid self-closing markup" do @@ -50,7 +51,7 @@ end with "nested request path" do - let(:env) {Hash["REQUEST_PATH" => "/nested/index"]} + let(:path) {"/nested/index"} it "generates a relative base uri" do relative_to = Utopia::Path["/page"] diff --git a/test/utopia/content/node.rb b/test/utopia/content/node.rb index 569b347..7d31198 100644 --- a/test/utopia/content/node.rb +++ b/test/utopia/content/node.rb @@ -45,7 +45,11 @@ it "should look up node by path" do node = content.lookup_node(Utopia::Path["/lookup/index"]) - expect(node.process!(nil)).to be == [200, {"content-type"=>"text/html; charset=utf-8"}, ["

Hello World

"]] + response = node.process!(nil) + + expect(response.status).to be == 200 + expect(response.headers["content-type"]).to be == "text/html; charset=utf-8" + expect(response.read).to be == "

Hello World

" end with "#local_path" do diff --git a/test/utopia/controller/.websocket/server/controller.rb b/test/utopia/controller/.websocket/server/controller.rb index 2460ed2..95b5e03 100644 --- a/test/utopia/controller/.websocket/server/controller.rb +++ b/test/utopia/controller/.websocket/server/controller.rb @@ -6,7 +6,7 @@ prepend Actions on 'events' do |request| - upgrade = Async::WebSocket::Adapters::Rack.open(request.env) do |connection| + upgrade = Async::WebSocket::Adapters::HTTP.open(request.http) do |connection| connection.write({type: "test", data: "Hello World"}.to_json) end diff --git a/test/utopia/controller/middleware.rb b/test/utopia/controller/middleware.rb index db4613b..637d386 100755 --- a/test/utopia/controller/middleware.rb +++ b/test/utopia/controller/middleware.rb @@ -3,14 +3,19 @@ # Released under the MIT License. # Copyright, 2013-2025, by Samuel Williams. -require "rack/mock" -require "rack/test" require "utopia/controller" +require_relative "../protocol_application" describe Utopia::Controller do - include Rack::Test::Methods + include ProtocolApplication - let(:app) {Rack::Builder.parse_file(File.expand_path("middleware.ru", __dir__))} + let(:app) do + root = File.expand_path(".middleware", __dir__) + + Utopia::Application.build do + use Utopia::Controller, root: root + end + end it "should successfully call empty controller" do get "/empty/index" @@ -22,21 +27,21 @@ get "/controller/flat" expect(last_response.status).to be == 200 - expect(last_response.body).to be == "flat" + expect(body).to be == "flat" end it "should invoke controller method from the top level" do get "/controller/hello-world" expect(last_response.status).to be == 200 - expect(last_response.body).to be == "Hello World" + expect(body).to be == "Hello World" end it "should invoke the controller method with a nested path" do get "/controller/nested/hello-world" expect(last_response.status).to be == 200 - expect(last_response.body).to be == "Hello World" + expect(body).to be == "Hello World" end it "shouldn't call the nested controller method" do @@ -63,6 +68,6 @@ get "/redirect/test/foo" expect(last_response.status).to be == 200 - expect(last_response.body).to be == "/redirect" + expect(body).to be == "/redirect" end end diff --git a/test/utopia/controller/middleware.ru b/test/utopia/controller/middleware.ru deleted file mode 100644 index 08f61ae..0000000 --- a/test/utopia/controller/middleware.ru +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -use Utopia::Controller, - root: File.expand_path(".middleware", __dir__) - -run lambda{|env| [404, {}, []]} diff --git a/test/utopia/controller/respond.rb b/test/utopia/controller/respond.rb index 9d9028a..c68b2d5 100644 --- a/test/utopia/controller/respond.rb +++ b/test/utopia/controller/respond.rb @@ -3,13 +3,14 @@ # Released under the MIT License. # Copyright, 2016-2025, by Samuel Williams. -require "rack/test" -require "rack/mock" require "json" +require "protocol/http/request" require "utopia/content" require "utopia/controller" require "utopia/redirection" +require "utopia/request" +require_relative "../protocol_application" describe Utopia::Controller do class TestController < Utopia::Controller::Base @@ -35,42 +36,46 @@ def self.uri_path let(:controller) {TestController.new} - def mock_request(*arguments) - request = Rack::Request.new(Rack::MockRequest.env_for(*arguments)) + def mock_request(path, headers = {}) + request = Utopia::Request.new(Protocol::HTTP::Request["GET", path, headers]) return request, Utopia::Path[request.path_info] end it "should serialize response as JSON" do - request, path = mock_request("/fetch") + request, path = mock_request("/fetch", {"accept" => "application/json"}) relative_path = path - controller.class.uri_path - request.env["HTTP_ACCEPT"] = "application/json" + response = controller.process!(request, relative_path) - status, headers, body = controller.process!(request, relative_path) - - expect(status).to be == 200 - expect(headers["content-type"]).to be == "application/json" - expect(body.join).to be == '{"user_id":10}' + expect(response.status).to be == 200 + expect(response.headers["content-type"]).to be == "application/json" + expect(response.read).to be == '{"user_id":10}' end it "should serialize response as text" do - request, path = mock_request("/fetch") + request, path = mock_request("/fetch", {"accept" => "text/*"}) relative_path = path - controller.class.uri_path - request.env["HTTP_ACCEPT"] = "text/*" - - status, headers, body = controller.process!(request, relative_path) + response = controller.process!(request, relative_path) - expect(status).to be == 200 - expect(headers["content-type"]).to be == "text/plain" - expect(body.join).to be == {user_id: 10}.to_s + expect(response.status).to be == 200 + expect(response.headers["content-type"]).to be == "text/plain" + expect(response.read).to be == {user_id: 10}.to_s end end describe Utopia::Controller do - include Rack::Test::Methods + include ProtocolApplication - let(:app) {Rack::Builder.parse_file(File.expand_path("respond.ru", __dir__))} + let(:app) do + root = File.expand_path(".respond", __dir__) + + Utopia::Application.build(lambda{|request| Utopia::Response[404, {}, []]}) do + use Utopia::Redirection::Errors, 404 => "/fail" + use Utopia::Controller, root: root + use Utopia::Content, root: root + end + end it "should get html error page" do # Standard web browser header: @@ -80,7 +85,7 @@ def mock_request(*arguments) expect(last_response.status).to be == 200 expect(last_response.headers["content-type"]).to be(:include?, "text/html") - expect(last_response.body).to be(:include?, "

File Not Found

") + expect(body).to be(:include?, "

File Not Found

") end it "should get html response" do @@ -90,7 +95,7 @@ def mock_request(*arguments) expect(last_response.status).to be == 200 expect(last_response.headers["content-type"]).to be == "text/html" - expect(last_response.body).to be == "

Hello World

" + expect(body).to be == "

Hello World

" end it "should get version 1 response" do @@ -100,7 +105,7 @@ def mock_request(*arguments) expect(last_response.status).to be == 200 expect(last_response.headers["content-type"]).to be == "application/json" - expect(last_response.body).to be == '{"message":"Hello World"}' + expect(body).to be == '{"message":"Hello World"}' end it "should get version 2 response" do @@ -110,7 +115,7 @@ def mock_request(*arguments) expect(last_response.status).to be == 200 expect(last_response.headers["content-type"]).to be == "application/json" - expect(last_response.body).to be == '{"message":"Goodbye World"}' + expect(body).to be == '{"message":"Goodbye World"}' end @@ -119,7 +124,7 @@ def mock_request(*arguments) expect(last_response.status).to be == 200 expect(last_response.headers["content-type"]).to be == "application/json" - expect(last_response.body).to be == "{}" + expect(body).to be == "{}" end it "should give record as JSON" do @@ -129,7 +134,7 @@ def mock_request(*arguments) expect(last_response.status).to be == 200 expect(last_response.headers["content-type"]).to be == "application/json" - expect(last_response.body).to be == '{"id":2,"foo":"bar"}' + expect(body).to be == '{"id":2,"foo":"bar"}' end it "should give error as JSON" do @@ -139,6 +144,6 @@ def mock_request(*arguments) expect(last_response.status).to be == 404 expect(last_response.headers["content-type"]).to be == "application/json" - expect(last_response.body).to be == '{"message":"Could not find record"}' + expect(body).to be == '{"message":"Could not find record"}' end end diff --git a/test/utopia/controller/respond.ru b/test/utopia/controller/respond.ru deleted file mode 100644 index 3c537cd..0000000 --- a/test/utopia/controller/respond.ru +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -use Utopia::Redirection::Errors, - 404 => "/fail" - -use Utopia::Controller, - root: File.expand_path(".respond", __dir__) - -use Utopia::Content, - root: File.expand_path(".respond", __dir__) - -run lambda{|env| [404, {}, []]} diff --git a/test/utopia/controller/rewrite.rb b/test/utopia/controller/rewrite.rb index 73c48b2..8aa1516 100644 --- a/test/utopia/controller/rewrite.rb +++ b/test/utopia/controller/rewrite.rb @@ -3,8 +3,9 @@ # Released under the MIT License. # Copyright, 2015-2025, by Samuel Williams. -require "rack/mock" +require "protocol/http/request" require "utopia/controller" +require "utopia/request" describe Utopia::Controller do class TestController < Utopia::Controller::Base @@ -32,8 +33,8 @@ def self.uri_path let(:controller) {TestController.new} - def mock_request(*arguments) - request = Rack::Request.new(Rack::MockRequest.env_for(*arguments)) + def mock_request(path) + request = Utopia::Request.new(Protocol::HTTP::Request["GET", path]) return request, Utopia::Path[request.path_info] end @@ -54,6 +55,6 @@ def mock_request(*arguments) response = controller.process!(request, relative_path) - expect(response[0]).to be == 444 + expect(response.status).to be == 444 end end diff --git a/test/utopia/controller/sequence.rb b/test/utopia/controller/sequence.rb index 8f22b41..6618757 100644 --- a/test/utopia/controller/sequence.rb +++ b/test/utopia/controller/sequence.rb @@ -3,9 +3,9 @@ # Released under the MIT License. # Copyright, 2015-2025, by Samuel Williams. -require "rack/mock" -require "rack/test" +require "protocol/http/request" require "utopia/controller" +require "utopia/request" class TestController < Utopia::Controller::Base prepend Utopia::Controller::Actions @@ -57,17 +57,23 @@ def initialize describe Utopia::Controller do let(:variables) {Utopia::Controller::Variables.new} + let(:request) do + request = Utopia::Request.new(Protocol::HTTP::Request["GET", "/"]) + request[Utopia::VARIABLES_KEY] = variables + request + end it "should call controller methods" do - request = Rack::Request.new(Utopia::VARIABLES_KEY => variables) controller = TestController.new variables << controller result = controller.process!(request, Utopia::Path["success"]) - expect(result).to be == [200, {}, []] + expect(result.status).to be == 200 + expect(result.to_protocol_response.read).to be == nil result = controller.process!(request, Utopia::Path["foo/bar/failure"]) - expect(result).to be == [400, {}, ["Bad Request"]] + expect(result.status).to be == 400 + expect(result.to_protocol_response.read).to be == "Bad Request" result = controller.process!(request, Utopia::Path["variable"]) expect(result).to be == nil @@ -75,7 +81,6 @@ def initialize end it "should call direct controller methods" do - request = Rack::Request.new(Utopia::VARIABLES_KEY => variables) controller = TestIndirectController.new variables << controller @@ -84,7 +89,6 @@ def initialize end it "should call indirect controller methods" do - request = Rack::Request.new(Utopia::VARIABLES_KEY => variables) controller = TestIndirectController.new variables << controller @@ -94,7 +98,6 @@ def initialize end it "should call multiple indirect controller methods in order" do - request = Rack::Request.new(Utopia::VARIABLES_KEY => variables) controller = TestIndirectController.new variables << controller @@ -104,7 +107,6 @@ def initialize end it "should match single patterns" do - request = Rack::Request.new(Utopia::VARIABLES_KEY => variables) controller = TestIndirectController.new variables << controller diff --git a/test/utopia/controller/variables.rb b/test/utopia/controller/variables.rb index 7610bb6..e7b70a8 100644 --- a/test/utopia/controller/variables.rb +++ b/test/utopia/controller/variables.rb @@ -4,7 +4,8 @@ # Copyright, 2016-2025, by Samuel Williams. require "utopia/controller/variables" -require "rack/request" +require "protocol/http/request" +require "utopia/request" class TestController attr_accessor :x, :y, :z @@ -43,15 +44,16 @@ def copy_instance_variables(from) end describe Utopia::Controller do - it "returns variables from request env" do + it "returns variables from request attributes" do variables = Utopia::Controller::Variables.new - request = Rack::Request.new(Utopia::VARIABLES_KEY => variables) + request = Utopia::Request.new(Protocol::HTTP::Request["GET", "/"]) + request[Utopia::VARIABLES_KEY] = variables expect(Utopia::Controller[request]).to be == variables end it "returns nil when variables are not set" do - request = Rack::Request.new({}) + request = Utopia::Request.new(Protocol::HTTP::Request["GET", "/"]) expect(Utopia::Controller[request]).to be_nil end diff --git a/test/utopia/controller/websocket.rb b/test/utopia/controller/websocket.rb index 989316c..cb74de8 100644 --- a/test/utopia/controller/websocket.rb +++ b/test/utopia/controller/websocket.rb @@ -3,11 +3,11 @@ # Released under the MIT License. # Copyright, 2019-2026, by Samuel Williams. -require "rack/test" require "utopia/controller" +require "utopia/application" require "async/websocket/client" -require "async/websocket/adapters/rack" +require "async/websocket/adapters/http" require "sus/fixtures/async/http/server_context" @@ -18,8 +18,13 @@ include Sus::Fixtures::Async::HTTP::ServerContext with Async::WebSocket::Client do - let(:rack_app) {Rack::Builder.parse_file(File.expand_path("websocket.ru", __dir__))} - let(:app) {::Protocol::Rack::Adapter.new(rack_app)} + let(:app) do + root = File.expand_path(".websocket", __dir__) + + Utopia::Application.build do + use Utopia::Controller, root: root + end + end it "fails for normal requests" do response = client.get "/server/events" diff --git a/test/utopia/controller/websocket.ru b/test/utopia/controller/websocket.ru deleted file mode 100644 index 3f76159..0000000 --- a/test/utopia/controller/websocket.ru +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -use Utopia::Controller, root: File.expand_path(".websocket", __dir__) - -run lambda{|env| [404, {}, []]} diff --git a/test/utopia/empty.rb b/test/utopia/empty.rb index d8d676b..d666bba 100644 --- a/test/utopia/empty.rb +++ b/test/utopia/empty.rb @@ -3,13 +3,13 @@ # Released under the MIT License. # Copyright, 2021-2025, by Samuel Williams. -require "rack/test" require "utopia/content" +require_relative "protocol_application" describe Utopia::Content do - include Rack::Test::Methods + include ProtocolApplication - let(:app) {Rack::Builder.parse_file(File.expand_path("empty.ru", __dir__))} + let(:app) {Utopia::Application.default} it "should report 404 missing" do get "/index" diff --git a/test/utopia/empty.ru b/test/utopia/empty.ru deleted file mode 100644 index fb86ec9..0000000 --- a/test/utopia/empty.ru +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -use Utopia::Content, - root: File.expand_path(".empty", __dir__) - -run lambda{|env| [404, {}, []]} diff --git a/test/utopia/exceptions/handler.rb b/test/utopia/exceptions/handler.rb index 00d9466..ac5217e 100644 --- a/test/utopia/exceptions/handler.rb +++ b/test/utopia/exceptions/handler.rb @@ -3,13 +3,21 @@ # Released under the MIT License. # Copyright, 2015-2025, by Samuel Williams. -require "a_rack_application" - require "utopia/exceptions" require "utopia/controller" +require_relative "../protocol_application" describe Utopia::Exceptions::Handler do - include_context ARackApplication, File.expand_path("handler.ru", __dir__) + include ProtocolApplication + + let(:app) do + root = File.expand_path(".handler", __dir__) + + Utopia::Application.build do + use Utopia::Exceptions::Handler, "/exception" + use Utopia::Controller, root: root + end + end it "should successfully call the controller method" do # This request will raise an exception, and then redirect to the /exception url which will fail again, and cause a fatal error. @@ -17,13 +25,13 @@ expect(last_response.status).to be == 500 expect(last_response.headers["content-type"]).to be == "text/plain" - expect(last_response.body).to be(:include?, "error") + expect(body).to be(:include?, "error") end it "should fail with a 500 error" do get "/blow" expect(last_response.status).to be == 500 - expect(last_response.body).to be(:include?, "Error Will Robertson") + expect(body).to be(:include?, "Error Will Robertson") end end diff --git a/test/utopia/exceptions/handler.ru b/test/utopia/exceptions/handler.ru deleted file mode 100644 index 0977f6b..0000000 --- a/test/utopia/exceptions/handler.ru +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -use Utopia::Exceptions::Handler, "/exception" - -use Utopia::Controller, - root: File.expand_path(".handler", __dir__) - -run lambda{|env| [404, {}, []]} diff --git a/test/utopia/exceptions/mailer.rb b/test/utopia/exceptions/mailer.rb index f9c22cf..24ac6b8 100644 --- a/test/utopia/exceptions/mailer.rb +++ b/test/utopia/exceptions/mailer.rb @@ -3,13 +3,24 @@ # Released under the MIT License. # Copyright, 2016-2025, by Samuel Williams. -require "a_rack_application" - require "utopia/exceptions" require "utopia/controller" +require_relative "../protocol_application" describe Utopia::Exceptions::Mailer do - include_context ARackApplication, File.expand_path("mailer.ru", __dir__) + include ProtocolApplication + + let(:app) do + root = File.expand_path(".handler", __dir__) + + Utopia::Application.build do + use Utopia::Exceptions::Mailer, + delivery_method: :test, + from: "test@localhost" + + use Utopia::Controller, root: root + end + end def before Mail::TestMailer.deliveries.clear @@ -18,6 +29,8 @@ def before end it "should send an email to report the failure" do + header "Accept", "text/plain" + expect{get "/blow"}.to raise_exception(StandardError, message: be =~ /Arrrh/) last_mail = Mail::TestMailer.deliveries.last @@ -25,7 +38,7 @@ def before expect(last_mail.to_s).to be(:include?, "GET") expect(last_mail.to_s).to be(:include?, "/blow") expect(last_mail.to_s).to be(:include?, "request.ip") - expect(last_mail.to_s).to be(:include?, "HTTP_") + expect(last_mail.to_s).to be(:include?, "header[") expect(last_mail.to_s).to be(:include?, "TharSheBlows") end end diff --git a/test/utopia/exceptions/mailer.ru b/test/utopia/exceptions/mailer.ru deleted file mode 100644 index 91b88de..0000000 --- a/test/utopia/exceptions/mailer.ru +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -use Utopia::Exceptions::Mailer, - delivery_method: :test, - from: "test@localhost" - -use Utopia::Controller, - root: File.expand_path(".handler", __dir__) - -run lambda{|env| [404, {}, []]} diff --git a/test/utopia/localization.rb b/test/utopia/localization.rb index b083c57..437922c 100755 --- a/test/utopia/localization.rb +++ b/test/utopia/localization.rb @@ -3,70 +3,79 @@ # Released under the MIT License. # Copyright, 2014-2025, by Samuel Williams. -require "rack" -require "rack/test" - require "utopia/static" require "utopia/content" require "utopia/controller" require "utopia/localization" +require_relative "protocol_application" describe Utopia::Localization do - include Rack::Test::Methods + include ProtocolApplication - let(:app) {Rack::Builder.parse_file(File.expand_path("localization.ru", __dir__))} + let(:app) do + root = File.expand_path(".localization", __dir__) + + Utopia::Application.build do + use Utopia::Localization, + locales: ["en", "ja", "de"], + hosts: {/foobar\.com$/ => "en", /foobar\.co\.jp$/ => "ja", /foobar\.de$/ => "de"} + + use Utopia::Controller, root: root + use Utopia::Static, root: root + end + end it "should respond with default localization" do get "/localized.txt" - expect(last_response.body).to be == "localized.en.txt" + expect(body).to be == "localized.en.txt" end it "should localize request based on path" do get "/en/localized.txt" - expect(last_response.body).to be == "localized.en.txt" + expect(body).to be == "localized.en.txt" get "/de/localized.txt" - expect(last_response.body).to be == "localized.de.txt" + expect(body).to be == "localized.de.txt" get "/ja/localized.txt" - expect(last_response.body).to be == "localized.ja.txt" + expect(body).to be == "localized.ja.txt" end it "should localize request based on domain name" do - get "/localized.txt", {}, "HTTP_HOST" => "foobar.com" - expect(last_response.body).to be == "localized.en.txt" + get "/localized.txt", {"host" => "foobar.com"} + expect(body).to be == "localized.en.txt" - get "/localized.txt", {}, "HTTP_HOST" => "foobar.de" - expect(last_response.body).to be == "localized.de.txt" + get "/localized.txt", {"host" => "foobar.de"} + expect(body).to be == "localized.de.txt" - get "/localized.txt", {}, "HTTP_HOST" => "foobar.co.jp" - expect(last_response.body).to be == "localized.ja.txt" + get "/localized.txt", {"host" => "foobar.co.jp"} + expect(body).to be == "localized.ja.txt" end it "should get a non-localized resource" do get "/en/test.txt" - expect(last_response.body).to be == "Hello World!" + expect(body).to be == "Hello World!" end it "should respond with accepted language localization" do - get "/localized.txt", {}, "HTTP_ACCEPT_LANGUAGE" => "ja,en" + get "/localized.txt", {"accept-language" => "ja,en"} - expect(last_response.body).to be == "localized.ja.txt" + expect(body).to be == "localized.ja.txt" end it "should get a list of all localizations" do get "/all_locales" - expect(last_response.body).to be == "en,ja,de" + expect(body).to be == "en,ja,de" end it "should get the default locale" do get "/default_locale" - expect(last_response.body).to be == "en" + expect(body).to be == "en" end it "should get the current locale (german)" do - get "/current_locale", {}, "HTTP_HOST" => "foobar.de" - expect(last_response.body).to be == "de" + get "/current_locale", {"host" => "foobar.de"} + expect(body).to be == "de" end end diff --git a/test/utopia/localization.ru b/test/utopia/localization.ru deleted file mode 100644 index 0aa0c73..0000000 --- a/test/utopia/localization.ru +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -localization_spec_root = File.expand_path(".localization", __dir__) - -use Utopia::Localization, - locales: ["en", "ja", "de"], - hosts: {/foobar\.com$/ => "en", /foobar\.co\.jp$/ => "ja", /foobar\.de$/ => "de"} - -use Utopia::Controller, - root: localization_spec_root - -use Utopia::Static, - root: localization_spec_root - -run lambda{|env| [404, {}, []]} diff --git a/test/utopia/performance.rb b/test/utopia/performance.rb index d84d6db..920725a 100644 --- a/test/utopia/performance.rb +++ b/test/utopia/performance.rb @@ -3,14 +3,14 @@ # Released under the MIT License. # Copyright, 2016-2025, by Samuel Williams. -require "a_rack_application" - require "benchmark/ips" if ENV["BENCHMARK"] require "ruby-prof" if ENV["PROFILE"] require "flamegraph" if ENV["FLAMEGRAPH"] +require "protocol/http/request" +require "utopia/application" describe "Utopia Performance" do - include_context ARackApplication, File.join(__dir__, ".performance/config.ru") + let(:app) {Utopia::Application.load(File.join(__dir__, ".performance/config/application.rb"))} if defined? Benchmark def benchmark(name = nil) @@ -52,24 +52,25 @@ def benchmark(name) end it "should be fast to access basic page" do - env = Rack::MockRequest.env_for("/welcome/index") - status, headers, response = app.call(env) + request = Protocol::HTTP::Request["GET", "/welcome/index"] + response = app.call(request) - expect(status).to be == 200 + expect(response.status).to be == 200 benchmark("/welcome/index") do |i| - i.times{app.call(env)} + i.times{app.call(request)} end end it "should be fast to invoke a controller" do - env = Rack::MockRequest.env_for("/api/fetch") - status, headers, response = app.call(env) + request = Protocol::HTTP::Request["GET", "/api/fetch"] + request.headers["accept"] = "application/json" + response = app.call(request) - expect(status).to be == 200 + expect(response.status).to be == 200 benchmark("/api/fetch") do |i| - i.times{app.call(env)} + i.times{app.call(request)} end end end diff --git a/test/utopia/protocol_application.rb b/test/utopia/protocol_application.rb new file mode 100644 index 0000000..05c551c --- /dev/null +++ b/test/utopia/protocol_application.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/http/request" +require "utopia/application" + +module ProtocolApplication + def cookies + @cookies ||= {} + end + + def headers + @headers ||= {} + end + + attr :last_request + attr :last_response + + def get(path, headers = {}) + self.request("GET", path, headers) + end + + def post(path, headers = {}) + self.request("POST", path, headers) + end + + def request(method, path, headers = {}) + request_headers = self.headers.merge(headers) + + unless cookies.empty? + request_headers["cookie"] = cookies.map{|key, value| "#{key}=#{value}"}.join("; ") + end + + @last_request = Protocol::HTTP::Request[method, path, request_headers] + @last_response = app.call(@last_request) + @body_read = false + @body = nil + + store_cookies(@last_response.headers["set-cookie"]) + + return @last_response + end + + def body + unless @body_read + @body = @last_response.read + @body_read = true + end + + return @body + end + + def header(name, value) + headers[name.downcase] = value + end + + def set_cookie(cookie) + name, value = cookie.split(";", 2).first.split("=", 2) + cookies[name] = value + end + + private + + def store_cookies(values) + Array(values).each do |cookie| + self.set_cookie(cookie) + end + end +end diff --git a/test/utopia/redirection.rb b/test/utopia/redirection.rb index 9d031e4..fc89922 100644 --- a/test/utopia/redirection.rb +++ b/test/utopia/redirection.rb @@ -4,10 +4,33 @@ # Copyright, 2016-2026, by Samuel Williams. require "utopia/redirection" -require "a_rack_application" +require_relative "protocol_application" describe Utopia::Redirection do - include_context ARackApplication, File.join(__dir__, "redirection_spec.ru") + include ProtocolApplication + + let(:app) do + Utopia::Application.build(lambda{|request| + case request.path_info + when "/error" + Utopia::Response.text("File not found :(", 200) + when "/teapot" + Utopia::Response[418, {}, ["I'm a teapot!"]] + else + Utopia::Response[404, {}, []] + end + }) do + use Utopia::Redirection::Rewrite, {"/" => "/welcome/index"} + use Utopia::Redirection::DirectoryIndex + use Utopia::Redirection::Errors, { + 404 => "/error", + 418 => "/teapot" + } + use Utopia::Redirection::Moved, "/a", "/b" + use Utopia::Redirection::Moved, "/hierarchy/", "/hierarchy", flatten: true + use Utopia::Redirection::Moved, "/weird", "/status", status: 333 + end + end it "should redirect directory to index" do get "/welcome/" @@ -22,7 +45,7 @@ # Must not redirect to //evil.com/index (external host) if last_response.status == 307 - expect(last_response.headers["location"]).not.to start_with("//") + expect(last_response.headers["location"]).not.to be(:start_with?, "//") end end @@ -46,7 +69,7 @@ get "/foo" expect(last_response.status).to be == 404 - expect(last_response.body).to be == "File not found :(" + expect(body).to be == "File not found :(" end it "should blow up if internal error redirect also fails" do diff --git a/test/utopia/redirection_spec.ru b/test/utopia/redirection_spec.ru deleted file mode 100644 index 4b771d8..0000000 --- a/test/utopia/redirection_spec.ru +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -use Utopia::Redirection::Rewrite, {"/" => "/welcome/index"} - -use Utopia::Redirection::DirectoryIndex - -use Utopia::Redirection::Errors, { - 404 => "/error", - 418 => "/teapot" -} - -use Utopia::Redirection::Moved, "/a", "/b" -use Utopia::Redirection::Moved, "/hierarchy/", "/hierarchy", flatten: true -use Utopia::Redirection::Moved, "/weird", "/status", status: 333 - -def error_handler(env) - request = Rack::Request.new(env) - if request.path_info == "/error" - [200, {}, ["File not found :("]] - elsif request.path_info == "/teapot" - [418, {}, ["I'm a teapot!"]] - else - [404, {}, []] - end -end - -run self.method(:error_handler) diff --git a/test/utopia/session.rb b/test/utopia/session.rb index fb70672..4717e74 100755 --- a/test/utopia/session.rb +++ b/test/utopia/session.rb @@ -4,15 +4,35 @@ # Copyright, 2014-2025, by Samuel Williams. # Copyright, 2019, by Huba Nagy. -require "rack" -require "rack/test" - require "utopia/session" +require_relative "protocol_application" describe Utopia::Session do - include Rack::Test::Methods + include ProtocolApplication - let(:app) {Rack::Builder.parse_file(File.expand_path("session_spec.ru", __dir__))} + let(:app) do + Utopia::Application.build(lambda{|request| + case request.path_info + when "/login" + request.session["login"] = "true" + + Utopia::Response[200, {}, []] + when "/session-set" + request.session[request.arguments["key"].to_sym] = request.arguments["value"] + + Utopia::Response[200, {}, []] + when "/session-get" + Utopia::Response[200, {}, [request.session[request.arguments["key"].to_sym]]] + else + Utopia::Response[404, {}, []] + end + }) do + use Utopia::Session, + secret: "97111cabf4c1a5e85b8029cf7c61aa44424fc24a", + expires_after: 5, + update_timeout: 1 + end + end it "shouldn't commit session values unless required" do # This URL doesn't update the session: @@ -26,45 +46,63 @@ it "should set and get values correctly" do get "/session-set?key=foo&value=bar" - expect(last_response.headers).to be(:include?, "Set-Cookie") + expect(last_response.headers).to have_keys("set-cookie") get "/session-get?key=foo" - expect(last_request.cookies).to be(:include?, "rack.session.encrypted") - expect(last_response.body).to be == "bar" + expect(cookies).to be(:include?, "utopia.session.encrypted") + expect(body).to be == "bar" end it "should ignore session if cookie value is invalid" do - set_cookie "rack.session.encrypted=junk" + set_cookie "utopia.session.encrypted=junk" get "/session-get?key=foo" - expect(last_response.body).to be == "" + expect(body).to be == nil end it "shouldn't update the session if there are no changes" do get "/session-set?key=foo&value=bar" - expect(last_response.headers).to be(:include?, "Set-Cookie") + expect(last_response.headers).to have_keys("set-cookie") get "/session-set?key=foo&value=bar" - expect(last_response.headers).not.to be(:include?, "Set-Cookie") + expect(last_response.headers).not.to have_keys("set-cookie") end it "should update the session if time has passed" do get "/session-set?key=foo&value=bar" - expect(last_response.headers).to be(:include?, "Set-Cookie") + expect(last_response.headers).to have_keys("set-cookie") # Sleep more than update_timeout sleep 2 get "/session-set?key=foo&value=bar" - expect(last_response.headers).to be(:include?, "Set-Cookie") + expect(last_response.headers).to have_keys("set-cookie") end end describe Utopia::Session do - include Rack::Test::Methods + include ProtocolApplication - let(:app) {Rack::Builder.parse_file(File.expand_path("session_spec.ru", __dir__))} + let(:app) do + Utopia::Application.build(lambda{|request| + case request.path_info + when "/session-set" + request.session[request.arguments["key"].to_sym] = request.arguments["value"] + + Utopia::Response[200, {}, []] + when "/session-get" + Utopia::Response[200, {}, [request.session[request.arguments["key"].to_sym]]] + else + Utopia::Response[404, {}, []] + end + }) do + use Utopia::Session, + secret: "97111cabf4c1a5e85b8029cf7c61aa44424fc24a", + expires_after: 5, + update_timeout: 1 + end + end def before # Initial user agent: @@ -77,7 +115,7 @@ def before it "should be able to retrive the value if there are no changes" do get "/session-get?key=foo" - expect(last_response.body).to be == "bar" + expect(body).to be == "bar" end it "should fail if user agent is changed" do @@ -85,16 +123,16 @@ def before header "User-Agent", "B" get "/session-get?key=foo" - expect(last_response.body).to be == "" + expect(body).to be == nil end it "should fail if expired cookie is sent with the request" do - session_cookie = last_response["Set-Cookie"].split(";")[0] + session_cookie = last_response.headers["set-cookie"].first.split(";")[0] sleep 6 # sleep longer than the session timeout - header "Cookie", session_cookie + set_cookie session_cookie get "/session-get?key=foo" - expect(last_response.body).to be == "" + expect(body).to be == nil end it "shouldn't fail if ip address is changed" do @@ -102,7 +140,7 @@ def before header "X-Forwarded-For", "127.0.0.10" get "/session-get?key=foo" - expect(last_response.body).to be == "bar" + expect(body).to be == "bar" end end diff --git a/test/utopia/session_spec.ru b/test/utopia/session_spec.ru deleted file mode 100644 index d655a6f..0000000 --- a/test/utopia/session_spec.ru +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -use Utopia::Session, - secret: "97111cabf4c1a5e85b8029cf7c61aa44424fc24a", - expires_after: 5, - update_timeout: 1 - -run do |env| - request = Rack::Request.new(env) - - if env[Rack::PATH_INFO] =~ /login/ - env["rack.session"]["login"] = "true" - - [200, {}, []] - elsif env[Rack::PATH_INFO] =~ /session-set/ - env["rack.session"][request.params["key"].to_sym] = request.params["value"] - - [200, {}, []] - elsif env[Rack::PATH_INFO] =~ /session-get/ - [200, {}, [env["rack.session"][request.params["key"].to_sym]]] - else - [404, {}, []] - end -end \ No newline at end of file diff --git a/test/utopia/static.rb b/test/utopia/static.rb index fafb7a5..0c3ffde 100755 --- a/test/utopia/static.rb +++ b/test/utopia/static.rb @@ -3,14 +3,19 @@ # Released under the MIT License. # Copyright, 2014-2025, by Samuel Williams. -require "rack" -require "rack/test" - require "utopia/static" +require_relative "protocol_application" describe Utopia::Static do - include Rack::Test::Methods - let(:app) {Rack::Builder.parse_file(File.expand_path("static.ru", __dir__))} + include ProtocolApplication + + let(:app) do + root = File.expand_path(".static", __dir__) + + Utopia::Application.build do + use Utopia::Static, root: root + end + end it "should give the correct mime type" do get "/test.txt" @@ -19,11 +24,11 @@ end it "should return partial content" do - get "/test.txt", {}, "HTTP_RANGE" => "bytes=1-4" + get "/test.txt", {"range" => "bytes=1-4"} expect(last_response.status).to be == 206 - expect(last_response.content_length).to be == 4 - expect(last_response.body).to be == "ello" + expect(body.bytesize).to be == 4 + expect(body).to be == "ello" end describe Utopia::Static::MIME_TYPES do diff --git a/test/utopia/static.ru b/test/utopia/static.ru deleted file mode 100644 index 44c86d3..0000000 --- a/test/utopia/static.ru +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -use Utopia::Static, root: File.expand_path(".static", __dir__) - -run lambda{|env| [404, {}, []]} diff --git a/utopia.gemspec b/utopia.gemspec index 50b2d25..dc8c7a1 100644 --- a/utopia.gemspec +++ b/utopia.gemspec @@ -36,7 +36,6 @@ Gem::Specification.new do |spec| spec.add_dependency "net-smtp" spec.add_dependency "protocol-http", "~> 0.58" spec.add_dependency "protocol-url", "~> 0.4" - spec.add_dependency "rack", "~> 3.0" spec.add_dependency "samovar", "~> 2.1" spec.add_dependency "traces", "~> 0.10" spec.add_dependency "variant", "~> 0.1" From 47037ba36dfe426638196188f0ae096da172f54a Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 22 Jun 2026 19:16:52 +1200 Subject: [PATCH 10/16] Update protocol migration documentation Assisted-By: devx/166ed168-1c4d-4c63-a5f6-8d0d9cbff13f --- config/external.yaml | 4 +--- context/getting-started.md | 6 +++--- context/index.yaml | 2 +- context/middleware.md | 4 ++-- guides/getting-started/readme.md | 6 +++--- guides/middleware/readme.md | 4 ++-- plan.md | 9 ++------- 7 files changed, 14 insertions(+), 21 deletions(-) diff --git a/config/external.yaml b/config/external.yaml index 393983f..4db608f 100644 --- a/config/external.yaml +++ b/config/external.yaml @@ -1,6 +1,4 @@ utopia-project: url: https://github.com/socketry/utopia-project.git - command: bundle exec bake test -www.codeotaku.com: - url: https://github.com/ioquatix/www.codeotaku.com.git + branch: v3-protocol-application command: bundle exec bake test diff --git a/context/getting-started.md b/context/getting-started.md index 8290da9..92d437b 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -4,7 +4,7 @@ This guide explains how to set up a `utopia` website for local development and d ## Installation -Utopia is built on Ruby and Rack. Therefore, Ruby (suggested 2.0+) should be installed and working. Then, to install `utopia` and all required dependencies, run: +Utopia is built on Ruby. Therefore, Ruby should be installed and working. Then, to install `utopia` and all required dependencies, run: ~~~ bash $ gem install utopia @@ -32,7 +32,7 @@ You will now have a basic template site running on `https://localhost:9292`. Utopia includes a redirection middleware to redirect all root-level requests to a given URI. The default being `/welcome/index`: ```ruby -# in config.ru +# in config/application.rb use Utopia::Redirection::Rewrite, "/" => "/welcome/index" @@ -84,7 +84,7 @@ website Least Coverage: pages/_page.xnode: 6 lines not executed! -config.ru: 4 lines not executed! +config/application.rb: 4 lines not executed! pages/welcome/index.xnode: 2 lines not executed! pages/_heading.xnode: 1 lines not executed! diff --git a/context/index.yaml b/context/index.yaml index dda2787..733daf0 100644 --- a/context/index.yaml +++ b/context/index.yaml @@ -13,7 +13,7 @@ files: and deployment. - path: middleware.md title: Middleware - description: This guide gives an overview of the different Rack middleware used + description: This guide gives an overview of the different middleware used by Utopia. - path: server-setup.md title: Server Setup diff --git a/context/middleware.md b/context/middleware.md index 97ff8b4..c7b50ec 100644 --- a/context/middleware.md +++ b/context/middleware.md @@ -1,10 +1,10 @@ # Middleware -This guide gives an overview of the different Rack middleware used by Utopia. +This guide gives an overview of the different middleware used by Utopia. ## Static -The {ruby Utopia::Static} middleware services static files efficiently. By default, it works with `Rack::Sendfile` and supports `ETag` based caching. Normally, you'd prefer to put static files into `public/_static` but it's also acceptable to put static content into `pages/` if it makes sense. +The {ruby Utopia::Static} middleware services static files efficiently and supports `ETag` based caching. Normally, you'd prefer to put static files into `public/_static` but it's also acceptable to put static content into `pages/` if it makes sense. ~~~ ruby use Utopia::Static, diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 8290da9..92d437b 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -4,7 +4,7 @@ This guide explains how to set up a `utopia` website for local development and d ## Installation -Utopia is built on Ruby and Rack. Therefore, Ruby (suggested 2.0+) should be installed and working. Then, to install `utopia` and all required dependencies, run: +Utopia is built on Ruby. Therefore, Ruby should be installed and working. Then, to install `utopia` and all required dependencies, run: ~~~ bash $ gem install utopia @@ -32,7 +32,7 @@ You will now have a basic template site running on `https://localhost:9292`. Utopia includes a redirection middleware to redirect all root-level requests to a given URI. The default being `/welcome/index`: ```ruby -# in config.ru +# in config/application.rb use Utopia::Redirection::Rewrite, "/" => "/welcome/index" @@ -84,7 +84,7 @@ website Least Coverage: pages/_page.xnode: 6 lines not executed! -config.ru: 4 lines not executed! +config/application.rb: 4 lines not executed! pages/welcome/index.xnode: 2 lines not executed! pages/_heading.xnode: 1 lines not executed! diff --git a/guides/middleware/readme.md b/guides/middleware/readme.md index 97ff8b4..c7b50ec 100644 --- a/guides/middleware/readme.md +++ b/guides/middleware/readme.md @@ -1,10 +1,10 @@ # Middleware -This guide gives an overview of the different Rack middleware used by Utopia. +This guide gives an overview of the different middleware used by Utopia. ## Static -The {ruby Utopia::Static} middleware services static files efficiently. By default, it works with `Rack::Sendfile` and supports `ETag` based caching. Normally, you'd prefer to put static files into `public/_static` but it's also acceptable to put static content into `pages/` if it makes sense. +The {ruby Utopia::Static} middleware services static files efficiently and supports `ETag` based caching. Normally, you'd prefer to put static files into `public/_static` but it's also acceptable to put static content into `pages/` if it makes sense. ~~~ ruby use Utopia::Static, diff --git a/plan.md b/plan.md index cc1f736..2832456 100644 --- a/plan.md +++ b/plan.md @@ -252,14 +252,11 @@ call(Utopia::Request) -> response-like value - whether `use` accepts classes, objects, or both. - whether `run Utopia::Content, root: ...` instantiates the app automatically. -- whether response tuples are accepted during migration. - whether `close` is propagated through the stack. - whether middleware may return `nil` to pass through. - whether middleware may mutate `request.path_info`. -- how legacy Rack middleware is wrapped explicitly, if supported. Do not try to preserve Rack middleware compatibility in the core Utopia stack. -Provide Rack compatibility through an optional adapter if needed. ## Programmatic Applications @@ -332,8 +329,7 @@ Expected breaking changes: need migration. - Static file serving should move away from `Rack::Sendfile` and Rack range helpers. -- `config.ru` should become optional Rack compatibility rather than the native - boot path. +- `config.ru` is no longer the native boot path. Use `config/application.rb`. - Tests should move from `rack-test` to protocol-http/async-http oriented tests. Useful preparatory work before the v3 transport change: @@ -343,6 +339,5 @@ Useful preparatory work before the v3 transport change: accessors. - Move cookie parsing and serialization behind Utopia-owned helpers. - Isolate static range/sendfile behavior from `Rack::Utils`. -- Make session storage names Utopia-native, with Rack aliases only for - compatibility. +- Make session storage names Utopia-native. - Start normalizing response values internally. From 80141eea73b67f06df9c137b8f581de18e273006 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 25 Jun 2026 13:21:45 +1200 Subject: [PATCH 11/16] Use fiber state for Utopia request context Assisted-By: devx/166ed168-1c4d-4c63-a5f6-8d0d9cbff13f --- lib/utopia/application.rb | 15 +- lib/utopia/content/document.rb | 3 +- lib/utopia/content/middleware.rb | 5 +- lib/utopia/context.rb | 62 +++ lib/utopia/controller/middleware.rb | 5 +- lib/utopia/controller/variables.rb | 9 +- lib/utopia/exceptions/handler.rb | 8 +- lib/utopia/exceptions/mailer.rb | 9 +- lib/utopia/localization/middleware.rb | 21 +- lib/utopia/localization/wrapper.rb | 17 +- lib/utopia/request.rb | 446 +++++++----------- lib/utopia/session.rb | 33 ++ lib/utopia/session/lazy_hash.rb | 40 ++ lib/utopia/session/middleware.rb | 19 +- lib/utopia/static/middleware.rb | 3 +- plan.md | 104 ++-- test/utopia/application.rb | 6 +- test/utopia/application_middleware.rb | 6 +- test/utopia/content/document.rb | 2 +- test/utopia/context.rb | 44 ++ .../.websocket/server/controller.rb | 2 +- test/utopia/controller/respond.rb | 2 +- test/utopia/controller/rewrite.rb | 2 +- test/utopia/controller/sequence.rb | 15 +- test/utopia/controller/variables.rb | 16 +- test/utopia/request.rb | 55 ++- test/utopia/session.rb | 36 +- 27 files changed, 565 insertions(+), 420 deletions(-) create mode 100644 lib/utopia/context.rb create mode 100644 test/utopia/context.rb diff --git a/lib/utopia/application.rb b/lib/utopia/application.rb index aa4322c..87697ea 100644 --- a/lib/utopia/application.rb +++ b/lib/utopia/application.rb @@ -6,15 +6,16 @@ require "protocol/http/middleware" require "protocol/http/middleware/builder" +require_relative "context" require_relative "request" require_relative "response" module Utopia # The protocol-facing entrypoint for a Utopia application. # - # This object accepts {Protocol::HTTP::Request} instances, wraps them in a - # {Utopia::Request}, dispatches to the Utopia application stack, and normalizes - # the result back to a {Protocol::HTTP::Response}. + # This object accepts {Protocol::HTTP::Request} instances, installs the + # request-scoped Utopia fiber state, dispatches to the Utopia application + # stack, and normalizes the result back to a {Protocol::HTTP::Response}. class Application < Protocol::HTTP::Middleware CONFIGURATION_PATH = "config/application.rb".freeze @@ -81,9 +82,13 @@ def initialize(delegate) # @parameter http_request [Protocol::HTTP::Request] The incoming protocol request. # @returns [Protocol::HTTP::Response] The normalized protocol response. def call(http_request) - request = Request.new(http_request) + Context.clear - return Response.wrap(super(request)) + Context.with(request: http_request, request_path: http_request.path_info) do + return Response.wrap(super(http_request)) + end + ensure + Context.clear end end end diff --git a/lib/utopia/content/document.rb b/lib/utopia/content/document.rb index cfd29ca..127634e 100644 --- a/lib/utopia/content/document.rb +++ b/lib/utopia/content/document.rb @@ -7,6 +7,7 @@ require_relative "response" require_relative "markup" require_relative "builder" +require_relative "../context" module Utopia module Content @@ -41,7 +42,7 @@ def initialize(request, attributes = {}) # @returns [Path] The original request path, if known. def request_path - Path[request.attributes["REQUEST_PATH"]] + Path[Context.request_path || request.path_info] end protected def current_base_uri_path diff --git a/lib/utopia/content/middleware.rb b/lib/utopia/content/middleware.rb index c242e9b..7732f85 100644 --- a/lib/utopia/content/middleware.rb +++ b/lib/utopia/content/middleware.rb @@ -4,6 +4,7 @@ # Copyright, 2025-2026, by Samuel Williams. require_relative "../middleware" +require_relative "../context" require_relative "../localization" require_relative "../response" @@ -93,7 +94,7 @@ def resolve_link(link) def respond(link, request) if node = resolve_link(link) - attributes = request.fetch(VARIABLES_KEY, {}).to_hash + attributes = Context.variables&.to_hash || {} return node.process!(request, attributes) elsif redirect_uri = link[:uri] @@ -115,7 +116,7 @@ def call(request) return Utopia::Response[307, {HTTP::LOCATION => path.dirname.join(index_path).to_s}, []] end - locale = request[Localization::CURRENT_LOCALE_KEY] + locale = Context.current_locale if link = @links.for(path, locale) if response = self.respond(link, request) return response diff --git a/lib/utopia/context.rb b/lib/utopia/context.rb new file mode 100644 index 0000000..9033bcc --- /dev/null +++ b/lib/utopia/context.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Utopia + # Accessors for request-scoped Utopia state stored directly in fiber storage. + module Context + KEYS = { + request: :utopia_request, + request_path: :utopia_request_path, + session: :utopia_session, + variables: :utopia_variables, + localization: :utopia_localization, + current_locale: :utopia_current_locale, + exception: :utopia_exception + }.freeze + + def self.[] key + Fiber[KEYS.fetch(key)] + end + + def self.[]= key, value + Fiber[KEYS.fetch(key)] = value + end + + def self.with(**values) + previous = {} + + values.each do |key, value| + previous[key] = self[key] + self[key] = value + end + + return yield + ensure + previous&.each do |key, value| + self[key] = value + end + end + + def self.clear + KEYS.each_value do |key| + Fiber[key] = nil + end + end + + def self.to_hash + KEYS.transform_values{|key| Fiber[key]} + end + + KEYS.each_key do |name| + define_singleton_method(name) do + self[name] + end + + define_singleton_method("#{name}=") do |value| + self[name] = value + end + end + end +end diff --git a/lib/utopia/controller/middleware.rb b/lib/utopia/controller/middleware.rb index 74659cd..15a8e73 100644 --- a/lib/utopia/controller/middleware.rb +++ b/lib/utopia/controller/middleware.rb @@ -5,6 +5,7 @@ require_relative "../path" require_relative "../middleware" +require_relative "../context" require_relative "variables" require_relative "base" @@ -91,7 +92,7 @@ def invoke_controllers(request) controller_path = Path.new # Controller instance variables which eventually get processed by the view: - variables = request[VARIABLES_KEY] + variables = Context.variables while request_path.components.any? # 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. @@ -118,7 +119,7 @@ def invoke_controllers(request) end def call(request) - request[VARIABLES_KEY] ||= Variables.new + Context.variables ||= Variables.new if result = invoke_controllers(request) return result diff --git a/lib/utopia/controller/variables.rb b/lib/utopia/controller/variables.rb index 7f43a78..0a78145 100644 --- a/lib/utopia/controller/variables.rb +++ b/lib/utopia/controller/variables.rb @@ -4,6 +4,7 @@ # Copyright, 2014-2025, by Samuel Williams. require_relative "../middleware" +require_relative "../context" module Utopia module Controller @@ -64,8 +65,12 @@ def [] key end end - def self.[] request - request.attributes[VARIABLES_KEY] + def self.current + Context.variables + end + + def self.[] request = nil + self.current end end end diff --git a/lib/utopia/exceptions/handler.rb b/lib/utopia/exceptions/handler.rb index 1aa4380..59a2503 100644 --- a/lib/utopia/exceptions/handler.rb +++ b/lib/utopia/exceptions/handler.rb @@ -6,6 +6,7 @@ require "console" +require_relative "../context" require_relative "../middleware" require_relative "../response" @@ -38,11 +39,12 @@ def call(request) # We do an internal redirection to the error location: error_request = request.with( method: "GET", - path_info: @location, - attributes: {"utopia.exception" => exception} + path_info: @location ) - error_response = Response.wrap(@app.call(error_request)) + error_response = Context.with(request: error_request, exception: exception) do + Response.wrap(@app.call(error_request)) + end error_response.status = 500 return error_response diff --git a/lib/utopia/exceptions/mailer.rb b/lib/utopia/exceptions/mailer.rb index 787b7ad..8b2c6c7 100644 --- a/lib/utopia/exceptions/mailer.rb +++ b/lib/utopia/exceptions/mailer.rb @@ -6,6 +6,7 @@ require "net/smtp" require "mail" +require_relative "../context" require_relative "../middleware" module Utopia @@ -129,10 +130,8 @@ def generate_body(exception, request) io.puts "header[#{key.inspect}]: #{value.inspect}" end - request.attributes.each do |key, value| - if key.is_a?(String) && key.start_with?("HTTP_") - io.puts "#{key}: #{value.inspect}" - end + Context.to_hash.each do |key, value| + io.puts "context.#{key}: #{value.inspect}" end io.puts @@ -165,7 +164,7 @@ def generate_mail(exception, request) end if @dump_environment - mail.attachments["attributes.yaml"] = YAML.dump(request.attributes) + mail.attachments["context.yaml"] = YAML.dump(Context.to_hash) end return mail diff --git a/lib/utopia/localization/middleware.rb b/lib/utopia/localization/middleware.rb index 4eefab3..442c2d4 100644 --- a/lib/utopia/localization/middleware.rb +++ b/lib/utopia/localization/middleware.rb @@ -4,6 +4,7 @@ # Copyright, 2025-2026, by Samuel Williams. require_relative "wrapper" +require_relative "../context" require_relative "../middleware" require_relative "../response" @@ -70,22 +71,22 @@ def preferred_locales(request) locales = Set.new host_preferred_locales(request) do |locale| - yield request.with(attributes: {CURRENT_LOCALE_KEY => locale}) if locales.add? locale + yield request, locale if locales.add? locale end request_preferred_locale(request) do |locale, path| # We have extracted a locale from the path, so from this point on we should use the updated path: request = request.with(path_info: path.to_s) - yield request.with(attributes: {CURRENT_LOCALE_KEY => locale}) if locales.add? locale + yield request, locale if locales.add? locale end browser_preferred_locales(request).each do |locale| - yield request.with(attributes: {CURRENT_LOCALE_KEY => locale}) if locales.add? locale + yield request, locale if locales.add? locale end @default_locales.each do |locale| - yield request.with(attributes: {CURRENT_LOCALE_KEY => locale}) if locales.add? locale + yield request, locale if locales.add? locale end end @@ -142,7 +143,7 @@ def vary(request, response) headers.add("vary", "Accept-Language") # Althought this header is generally not supported, we supply it anyway as it is useful for debugging: - if locale = request[CURRENT_LOCALE_KEY] + if locale = Context.current_locale # Set the Content-Location to point to the localized URI as requested: headers["content-location"] = "/#{locale}" + request.path_info end @@ -154,15 +155,15 @@ def call(request) # Pass the request through if it shouldn't be localized: return @app.call(request) unless localized?(request) - request[LOCALIZATION_KEY] = self - response = nil # We have a non-localized request, but there might be a localized resource. We return the best localization possible: - preferred_locales(request) do |localized_request| - # puts "Trying locale: #{localized_request[CURRENT_LOCALE_KEY]}: #{localized_request.path_info}..." + preferred_locales(request) do |localized_request, locale| + # puts "Trying locale: #{locale}: #{localized_request.path_info}..." - response = Response.wrap(@app.call(localized_request)) + response = Context.with(request: localized_request, localization: self, current_locale: locale) do + Response.wrap(@app.call(localized_request)) + end break unless response.status >= 400 diff --git a/lib/utopia/localization/wrapper.rb b/lib/utopia/localization/wrapper.rb index a9e0c5e..8f76010 100644 --- a/lib/utopia/localization/wrapper.rb +++ b/lib/utopia/localization/wrapper.rb @@ -4,6 +4,7 @@ # Copyright, 2015-2026, by Samuel Williams. require_relative "middleware" +require_relative "../context" module Utopia # A middleware which attempts to find localized content. @@ -13,12 +14,8 @@ module Localization # A wrapper to provide easy access to locale related data in the request. class Wrapper - def initialize(attributes) - @attributes = attributes - end - def localization - @attributes[LOCALIZATION_KEY] + Context.localization end def localized? @@ -27,7 +24,7 @@ def localized? # Returns the current locale or nil if not localized. def current_locale - @attributes[CURRENT_LOCALE_KEY] + Context.current_locale end # Returns the default locale or nil if not localized. @@ -45,8 +42,12 @@ def localized_path(path, locale) end end - def self.[] request - Wrapper.new(request.attributes) + def self.current + Wrapper.new + end + + def self.[] request = nil + self.current end end end diff --git a/lib/utopia/request.rb b/lib/utopia/request.rb index c3bfffe..ef596da 100644 --- a/lib/utopia/request.rb +++ b/lib/utopia/request.rb @@ -6,316 +6,204 @@ require "cgi" require "uri" -require "protocol/http/headers" require "protocol/http/request" -module Utopia - # The application-facing request wrapper. - # - # This class intentionally keeps a small surface area. Framework features such - # as arguments, sessions, localization and controller variables should be added - # as explicit Utopia concepts rather than relying on transport-specific state. - class Request - # Wrap either a {Protocol::HTTP::Request} or an existing Utopia request. - def self.wrap(request) - case request - when self - request - when Protocol::HTTP::Request - self.new(request) - else - raise ArgumentError, "Unable to wrap request: #{request.inspect}!" +require_relative "context" + +module Protocol + module HTTP + class Request + alias request_method method + + def get? + self.method == "GET" end - end - - # Initialize a request wrapper. - # @parameter http [Protocol::HTTP::Request] The underlying protocol request. - # @parameter attributes [Hash | Nil] Request-local application state. - def initialize(http, attributes: nil) - @http = http - @attributes = attributes || {} - @attributes["REQUEST_PATH"] ||= self.path_info - end - - # The underlying {Protocol::HTTP::Request}. - attr :http - - # Request-local application state. - attr :attributes - - # Fetch request-local application state. - def [] key - case key - when "REQUEST_METHOD" - self.method - when "PATH_INFO", "REQUEST_PATH" - self.path_info - when "QUERY_STRING" - self.query.to_s - when "HTTP_HOST" - self.host - when "HTTP_USER_AGENT" - self.user_agent - when "HTTP_ACCEPT_LANGUAGE" - self.headers["accept-language"] - when "HTTP_IF_MODIFIED_SINCE" - self.headers["if-modified-since"] - when "HTTP_IF_NONE_MATCH" - self.headers["if-none-match"] - when "HTTP_RANGE" - self.headers["range"] - else - if key.is_a?(String) && key.start_with?("HTTP_") - self.headers[key[5..].downcase.tr("_", "-")] - elsif @attributes.key?(key) - @attributes[key] - elsif key.is_a?(Symbol) && @attributes.key?(key.to_s) - @attributes[key.to_s] - else - self.arguments[key.to_s] - end + def head? + self.method == "HEAD" end - end - - # Assign request-local application state. - def []= key, value - case key - when "REQUEST_METHOD" - @http.method = value - when "PATH_INFO" - self.path_info = value - else - @attributes[key] = value + + def post? + self.method == "POST" end - end - - # Fetch request-local application state. - def fetch(...) - @attributes.fetch(...) - end - - # Select request-local application state. - def select(&block) - @attributes.select(&block) - end - - # Build a derived request with the specified attributes merged in. - def merge(attributes) - return self.with(attributes: attributes) - end - - # Build a derived request with updated protocol fields and request-local state. - def with(method: self.method, path: self.path, path_info: nil, attributes: {}) - http = @http.dup - http.method = method - if path_info + def put? + self.method == "PUT" + end + + def patch? + self.method == "PATCH" + end + + def delete? + self.method == "DELETE" + end + + def options? + self.method == "OPTIONS" + end + + def path_info + self.path&.split("?", 2)&.first + end + + def path_info=(value) if query = self.query - http.path = "#{path_info}?#{query}" + self.path = "#{value}?#{query}" else - http.path = path_info + self.path = value end - else - http.path = path + + @utopia_arguments = nil end - return self.class.new(http, attributes: @attributes.merge(attributes)) - end - - # @returns [String] The HTTP request method. - def method - @http.method - end - alias request_method method - - # @returns [Boolean] Whether the HTTP request method is GET. - def get? - @http.method == "GET" - end - - # @returns [Boolean] Whether the HTTP request method is HEAD. - def head? - @http.method == "HEAD" - end - - # @returns [Boolean] Whether the HTTP request method is POST. - def post? - @http.method == "POST" - end - - # @returns [Boolean] Whether the HTTP request method is PUT. - def put? - @http.method == "PUT" - end - - # @returns [Boolean] Whether the HTTP request method is PATCH. - def patch? - @http.method == "PATCH" - end - - # @returns [Boolean] Whether the HTTP request method is DELETE. - def delete? - @http.method == "DELETE" - end - - # @returns [Boolean] Whether the HTTP request method is OPTIONS. - def options? - @http.method == "OPTIONS" - end - - # @returns [String] The full request path, including query string. - def path - @http.path - end - - # Set the full request path. - # @parameter value [String] The full request path, including optional query string. - def path=(value) - @http.path = value - end - - # @returns [String | Nil] The request path without query string. - def path_info - @http.path&.split("?", 2)&.first - end - - # Set the request path while preserving the current query string. - # @parameter value [String] The request path without query string. - def path_info=(value) - if query = self.query - @http.path = "#{value}?#{query}" - else - @http.path = value + def query + self.path&.split("?", 2)&.last if self.path&.include?("?") end - end - - # @returns [String | Nil] The query string without the leading `?`. - def query - @http.path&.split("?", 2)&.last if @http.path&.include?("?") - end - - # @returns [Hash] The decoded query arguments. - def arguments - @arguments ||= decode_arguments(self.query) - end - alias params arguments - - # @returns [Hash] The decoded request cookies. - def cookies - @cookies ||= parse_cookies(@http.headers["cookie"]) - end - - # @returns [String | Nil] The request host. - def host - @http.authority || @http.headers["host"] - end - alias host_with_port host - - # @returns [String | Nil] The request URL scheme. - def scheme - @http.scheme - end - - # @returns [Boolean] Whether the request URL scheme is HTTPS. - def ssl? - self.scheme == "https" - end - - # @returns [String] The request base URL if scheme and host are available. - def base_url - scheme = self.scheme - host = self.host - if scheme && host - "#{scheme}://#{host}" - else - "" + def arguments + @utopia_arguments ||= decode_arguments(self.query) end - end - - # @returns [String | Nil] The request user agent. - def user_agent - @http.headers["user-agent"] - end - - # @returns [String | Nil] The request referrer. - def referrer - @http.headers["referer"] - end - alias referer referrer - - # @returns [Hash | Nil] The request session, if installed by Utopia::Session. - def session - @attributes["utopia.session"] - end - - # @returns [String | Nil] The remote peer address, if available. - def ip - @http.peer&.ip_address - end - - # @returns [String] The full request URL if scheme and host are available. - def url - base_url = self.base_url + alias params arguments - if !base_url.empty? - "#{base_url}#{self.path}" - else - self.path + def cookies + @utopia_cookies ||= parse_cookies(self.headers["cookie"]) end - end - - # @returns [Protocol::HTTP::Headers] The request headers. - def headers - @http.headers - end - - # @returns [Protocol::HTTP::Body::Readable | Nil] The request body. - def body - @http.body - end - - private - - def decode_arguments(query) - arguments = {} - return arguments unless query + def host + self.authority || self.headers["host"] + end + alias host_with_port host - URI.decode_www_form(query).each do |key, value| - values = arguments.fetch(key){arguments[key] = []} - values << value + def ssl? + self.scheme == "https" end - arguments.transform_values! do |values| - if values.size == 1 - values.first + def base_url + if self.scheme && self.host + "#{self.scheme}://#{self.host}" else - values + "" end end - return arguments - end - - def parse_cookies(cookie_header) - cookies = {} + def user_agent + self.headers["user-agent"] + end - return cookies unless cookie_header + def referrer + self.headers["referer"] + end + alias referer referrer - if cookie_header.respond_to?(:to_str) - cookie_header = cookie_header.to_str - else - cookie_header = cookie_header.to_s + def session + Utopia::Context.session end - cookie_header.split(/;\s*/).each do |pair| - key, value = pair.split("=", 2) - cookies[CGI.unescape(key)] = CGI.unescape(value || "") + def ip + self.peer&.ip_address end - return cookies + def url + base_url = self.base_url + + if !base_url.empty? + "#{base_url}#{self.path}" + else + self.path + end + end + + def with(method: self.method, path: self.path, path_info: nil) + request = self.dup + request.method = method + + if path_info + if query = self.query + request.path = "#{path_info}?#{query}" + else + request.path = path_info + end + else + request.path = path + end + + request.instance_variable_set(:@utopia_arguments, nil) + request.instance_variable_set(:@utopia_cookies, nil) + + return request + end + + def [] key + case key + when "REQUEST_METHOD" + self.method + when "PATH_INFO", "REQUEST_PATH" + self.path_info + when "QUERY_STRING" + self.query.to_s + when "HTTP_HOST" + self.host + when "HTTP_USER_AGENT" + self.user_agent + when "HTTP_ACCEPT_LANGUAGE" + self.headers["accept-language"] + when "HTTP_IF_MODIFIED_SINCE" + self.headers["if-modified-since"] + when "HTTP_IF_NONE_MATCH" + self.headers["if-none-match"] + when "HTTP_RANGE" + self.headers["range"] + else + if key.is_a?(String) && key.start_with?("HTTP_") + self.headers[key[5..].downcase.tr("_", "-")] + else + self.arguments[key.to_s] + end + end + end + + private + + def decode_arguments(query) + arguments = {} + + return arguments unless query + + URI.decode_www_form(query).each do |key, value| + values = arguments.fetch(key){arguments[key] = []} + values << value + end + + arguments.transform_values! do |values| + if values.size == 1 + values.first + else + values + end + end + + return arguments + end + + def parse_cookies(cookie_header) + cookies = {} + + return cookies unless cookie_header + + if cookie_header.respond_to?(:to_str) + cookie_header = cookie_header.to_str + else + cookie_header = cookie_header.to_s + end + + cookie_header.split(/;\s*/).each do |pair| + key, value = pair.split("=", 2) + cookies[CGI.unescape(key)] = CGI.unescape(value || "") + end + + return cookies + end end end end diff --git a/lib/utopia/session.rb b/lib/utopia/session.rb index d324291..b144f8e 100644 --- a/lib/utopia/session.rb +++ b/lib/utopia/session.rb @@ -4,12 +4,45 @@ # Copyright, 2014-2025, by Samuel Williams. # Copyright, 2019, by Huba Nagy. +require_relative "context" + +module Utopia + module Session + class Error < StandardError + end + + class MissingError < Error + end + end +end + require_relative "session/middleware" module Utopia module Session + def self.new(...) Middleware.new(...) end + + def self.current + Context.session + end + + def self.required + self.current or raise MissingError, "No current Utopia session!" + end + + def self.[] key + self.required[key] + end + + def self.[]= key, value + self.required[key] = value + end + + def self.delete(key) + self.required.delete(key) + end end end diff --git a/lib/utopia/session/lazy_hash.rb b/lib/utopia/session/lazy_hash.rb index a8ffa1b..8c39ec0 100644 --- a/lib/utopia/session/lazy_hash.rb +++ b/lib/utopia/session/lazy_hash.rb @@ -7,20 +7,34 @@ module Utopia module Session # A simple hash table which fetches it's values only when required. class LazyHash + class MutationError < Session::Error + end + + class AlreadyCommittedError < MutationError + end + + class WrongFiberError < MutationError + end + def initialize(&block) @changed = false @values = nil + @owner = Fiber.current + @committed = false @loader = block end attr :values + attr :owner def [] key load![key] end def []= key, value + check_mutable! + values = load! if values[key] != value @@ -36,6 +50,7 @@ def include?(key) end def delete(key) + check_mutable! load! @changed = true if @values.include? key @@ -47,6 +62,15 @@ def changed? @changed end + def committed? + @committed + end + + def commit! + check_owner! + @committed = true + end + def load! @values ||= @loader.call end @@ -67,6 +91,22 @@ def needs_update?(timeout = nil) return false end + + private + + def check_mutable! + check_owner! + + if @committed + raise AlreadyCommittedError, "Cannot mutate a committed session!" + end + end + + def check_owner! + unless Fiber.current.equal?(@owner) + raise WrongFiberError, "Cannot mutate session from a different fiber!" + end + end end end end diff --git a/lib/utopia/session/middleware.rb b/lib/utopia/session/middleware.rb index 0ad6ebf..523c892 100644 --- a/lib/utopia/session/middleware.rb +++ b/lib/utopia/session/middleware.rb @@ -11,6 +11,7 @@ require_relative "lazy_hash" require_relative "serialization" +require_relative "../context" require_relative "../middleware" require_relative "../response" @@ -96,21 +97,23 @@ def freeze def call(request) session_hash = prepare_session(request) - response = Response.wrap(@app.call(request)) - - update_session(session_hash, response.headers) - - return response + Context.with(session: session_hash) do + response = Response.wrap(@app.call(request)) + + update_session(session_hash, response.headers) + + return response + ensure + session_hash.commit! + end end protected def prepare_session(request) - session = LazyHash.new do + LazyHash.new do self.load_session_values(request) end - - request[SESSION_KEY] = session end def update_session(session_hash, headers) diff --git a/lib/utopia/static/middleware.rb b/lib/utopia/static/middleware.rb index 29790e2..a62fe73 100644 --- a/lib/utopia/static/middleware.rb +++ b/lib/utopia/static/middleware.rb @@ -4,6 +4,7 @@ # Copyright, 2025-2026, by Samuel Williams. require_relative "../middleware" +require_relative "../context" require_relative "../localization" require_relative "../response" @@ -78,7 +79,7 @@ def response_headers_for(file, content_type) def respond(request, path_info, extension) path = Path[path_info].simplify - if locale = request[Localization::CURRENT_LOCALE_KEY] + if locale = Context.current_locale path.last.insert(path.last.rindex(".") || -1, ".#{locale}") end diff --git a/plan.md b/plan.md index 2832456..7ce9120 100644 --- a/plan.md +++ b/plan.md @@ -7,8 +7,8 @@ Rack support can remain available through an adapter, but it should no longer be internal ABI for requests, responses, middleware, sessions, static files, or controllers. -The main goal is to keep the HTTP boundary small while giving Utopia its own -application request shape. Rack has been valuable because it codifies request, +The main goal is to keep the HTTP boundary small while letting Utopia own +application state explicitly. Rack has been valuable because it codifies request, response, and middleware conventions, but that same shared surface has made it hard to evolve and hard for application frameworks to make different performance, security, and usability choices. @@ -20,14 +20,15 @@ The proposed stack is: ```text Protocol::HTTP::Request -> Utopia::Application - -> Utopia::Request - -> Utopia application middleware/controllers/content + -> Utopia middleware/controllers/content -> Utopia::Response or Protocol::HTTP::Response shaped value -> Protocol::HTTP::Response ``` -`Utopia::Application` is the adaptation boundary. Everything above it is ordinary -`protocol-http` middleware. Everything below it is Utopia application middleware. +`Utopia::Application` is the lifecycle boundary. It receives +`Protocol::HTTP::Request`, installs Utopia fiber state for the request, dispatches +ordinary protocol middleware, normalizes the response, and clears Utopia fiber +state. ## Application @@ -143,15 +144,14 @@ service "utopia" do end ``` -## Request +## Request And State -Introduce `Utopia::Request` as the application request shape. It should be thin, -explicit, and lazy, not a reimplementation of `Rack::Request`. +Do not introduce a separate `Utopia::Request` wrapper in the core stack. Utopia +middleware should receive the normal `Protocol::HTTP::Request`. Likely shape: ```text -request.http request.method request.path request.path_info @@ -161,21 +161,18 @@ request.headers request.cookies request.body request.arguments -request.session -request.variables -request.locale -request.attributes ``` Guidelines: -- Keep `request.http` available for direct access to the underlying - `Protocol::HTTP::Request`. - Avoid a global magical `params` hash. - Prefer `arguments` over `params`. - Parse request data lazily. - Keep query, form, JSON, and multipart parsing separable where possible. -- Use Utopia-owned request-local state rather than Rack-style `env`. +- Add small convenience methods to `Protocol::HTTP::Request` only where they make + middleware substantially clearer. +- Use Utopia-owned fiber state rather than Rack-style `env` or a Utopia request + attribute hash. Possible arguments shape: @@ -186,6 +183,38 @@ request.arguments.json request.arguments.multipart ``` +Framework state should be exposed through named Utopia APIs: + +```text +Utopia::Session.current +Utopia::Session[:user_id] +Utopia::Session[:user_id] = 10 +Utopia::Controller.current +Utopia::Localization.current +``` + +The implementation can store this directly in fiber storage: + +```text +Fiber[:utopia_session] +Fiber[:utopia_variables] +Fiber[:utopia_current_locale] +``` + +`Utopia::Application` should clear Utopia fiber state before and after each +request. Since each request is handled by an independent fiber, a separate root +context object is not needed. + +Sessions are optional. If session middleware is not installed, +`Utopia::Session.current` should return `nil` and `Utopia::Session[...]` should +raise a clear missing-session error. + +Session mutation should be owned by the fiber that constructed the session and +should be rejected after commit. Nested fibers may read the inherited session, but +writes from non-owner fibers should fail. This makes session races visible and +matches the fact that only the request-owning fiber can reliably commit the +session back to the response. + ## Response Use `Protocol::HTTP::Response` as the canonical transport response. @@ -210,19 +239,19 @@ Normalize at the `Utopia::Application` boundary. ## Middleware -There should be two explicit middleware layers: - -1. HTTP middleware, operating on `Protocol::HTTP::Request` and - `Protocol::HTTP::Response`. -2. Utopia application middleware, operating on `Utopia::Request`. +Utopia middleware should use the protocol-http middleware shape: -HTTP middleware is appropriate for low-level protocol behavior, tracing, -compression, authority policy, early routing, static transport optimizations, and -protocol upgrades. +```text +initialize(delegate, ...) +call(Protocol::HTTP::Request) -> response-like value +``` -Application middleware is appropriate for sessions, localization, arguments, -content negotiation, controller variables, CSRF, authentication, and other -framework-specific semantics. +Low-level protocol behavior, tracing, compression, authority policy, early +routing, static transport optimizations, protocol upgrades, sessions, +localization, content negotiation, controller variables, CSRF, authentication, and +other framework-specific semantics can all be expressed in that shape. Utopia owns +the compatibility of its middleware APIs and the request-local state helpers they +use. The regular Utopia DSL should compose application middleware: @@ -234,18 +263,11 @@ Utopia::Application.build do end ``` -Utopia owns what `use` and `run` mean for application middleware. The app -middleware contract should be: - -```text -initialize(delegate, ...) -call(Utopia::Request) -> response-like value -``` - -and terminal apps should satisfy: +Utopia owns what `use` and `run` mean for middleware. Terminal apps should +satisfy: ```text -call(Utopia::Request) -> response-like value +call(Protocol::HTTP::Request) -> response-like value ``` `Utopia::Application.build` can decide compatibility details such as: @@ -313,8 +335,8 @@ Do not extract a shared `protocol-http-application` gem initially. The generic code is likely small, and the useful pieces quickly become framework-specific: default root, default file name, fallback behavior, request -wrapper, response helpers, error behavior, middleware DSL, and constant -resolution. +helpers, fiber state APIs, response helpers, error behavior, middleware DSL, and +constant resolution. Keep the implementation in Utopia first. Extract later only if multiple frameworks end up sharing the same stable, low-opinion code. @@ -325,6 +347,8 @@ Expected breaking changes: - Core Utopia middleware no longer receives Rack env hashes. - Controllers no longer receive `Rack::Request`. +- Core Utopia middleware receives `Protocol::HTTP::Request`, not + `Utopia::Request`. - `env[...]`, `rack.session`, `rack.input`, and Rack response tuple assumptions need migration. - Static file serving should move away from `Rack::Sendfile` and Rack range diff --git a/test/utopia/application.rb b/test/utopia/application.rb index 865dd11..0aa06f9 100644 --- a/test/utopia/application.rb +++ b/test/utopia/application.rb @@ -10,7 +10,7 @@ describe Utopia::Application do let(:http_request) {Protocol::HTTP::Request["GET", "/hello?name=sam"]} - it "wraps protocol requests for the application stack" do + it "passes protocol requests through the application stack" do application_request = nil application = subject.build do @@ -23,8 +23,8 @@ response = application.call(http_request) - expect(application_request).to be_a(Utopia::Request) - expect(application_request.http).to be_equal(http_request) + expect(application_request).to be_equal(http_request) + expect(Utopia::Context.request).to be_nil expect(application_request.path_info).to be == "/hello" expect(application_request.query).to be == "name=sam" diff --git a/test/utopia/application_middleware.rb b/test/utopia/application_middleware.rb index 4e5cc22..57bcae4 100644 --- a/test/utopia/application_middleware.rb +++ b/test/utopia/application_middleware.rb @@ -16,7 +16,7 @@ def request(path, headers: nil) Protocol::HTTP::Request["GET", path, headers] end - it "passes Utopia::Request through first-party middleware" do + it "passes protocol requests through first-party middleware" do seen_request = nil application = Utopia::Application.build do @@ -30,7 +30,7 @@ def request(path, headers: nil) response = application.call(request("/hello")) - expect(seen_request).to be_a(Utopia::Request) + expect(seen_request).to be_a(Protocol::HTTP::Request) expect(response.status).to be == 200 expect(response.read).to be == "/hello" @@ -61,7 +61,7 @@ def request(path, headers: nil) use Utopia::Session, session_name: Utopia::Session::Middleware::SESSION_KEY, secret: "test-secret" run lambda{|request| - request[Utopia::Session::Middleware::SESSION_KEY][:value] = "Hello" + Utopia::Session[:value] = "Hello" Utopia::Response.text("OK") } end diff --git a/test/utopia/content/document.rb b/test/utopia/content/document.rb index 36b7c0b..e03fbc6 100644 --- a/test/utopia/content/document.rb +++ b/test/utopia/content/document.rb @@ -9,7 +9,7 @@ describe Utopia::Content::Document do let(:path) {"/index"} - let(:request) {Utopia::Request.new(Protocol::HTTP::Request["GET", path])} + let(:request) {Protocol::HTTP::Request["GET", path]} let(:document) {subject.new(request, {})} it "should generate valid self-closing markup" do diff --git a/test/utopia/context.rb b/test/utopia/context.rb new file mode 100644 index 0000000..4e87139 --- /dev/null +++ b/test/utopia/context.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/http/request" +require "utopia/context" +require "utopia/request" + +describe Utopia::Context do + let(:request) {Protocol::HTTP::Request["GET", "/hello"]} + + after do + subject.clear + end + + it "stores request state directly in fiber storage" do + subject.request = request + subject.request_path = request.path_info + + expect(subject.request).to be_equal(request) + expect(subject.request_path).to be == "/hello" + end + + it "scopes temporary assignments" do + subject.current_locale = "en" + + subject.with(current_locale: "ja") do + expect(subject.current_locale).to be == "ja" + end + + expect(subject.current_locale).to be == "en" + end + + it "is inherited by nested fibers" do + subject.session = Object.new + + fiber = Fiber.new do + subject.session + end + + expect(fiber.resume).to be_equal(subject.session) + end +end diff --git a/test/utopia/controller/.websocket/server/controller.rb b/test/utopia/controller/.websocket/server/controller.rb index 95b5e03..46ec358 100644 --- a/test/utopia/controller/.websocket/server/controller.rb +++ b/test/utopia/controller/.websocket/server/controller.rb @@ -6,7 +6,7 @@ prepend Actions on 'events' do |request| - upgrade = Async::WebSocket::Adapters::HTTP.open(request.http) do |connection| + upgrade = Async::WebSocket::Adapters::HTTP.open(request) do |connection| connection.write({type: "test", data: "Hello World"}.to_json) end diff --git a/test/utopia/controller/respond.rb b/test/utopia/controller/respond.rb index c68b2d5..be98f9f 100644 --- a/test/utopia/controller/respond.rb +++ b/test/utopia/controller/respond.rb @@ -37,7 +37,7 @@ def self.uri_path let(:controller) {TestController.new} def mock_request(path, headers = {}) - request = Utopia::Request.new(Protocol::HTTP::Request["GET", path, headers]) + request = Protocol::HTTP::Request["GET", path, headers] return request, Utopia::Path[request.path_info] end diff --git a/test/utopia/controller/rewrite.rb b/test/utopia/controller/rewrite.rb index 8aa1516..bfdd665 100644 --- a/test/utopia/controller/rewrite.rb +++ b/test/utopia/controller/rewrite.rb @@ -34,7 +34,7 @@ def self.uri_path let(:controller) {TestController.new} def mock_request(path) - request = Utopia::Request.new(Protocol::HTTP::Request["GET", path]) + request = Protocol::HTTP::Request["GET", path] return request, Utopia::Path[request.path_info] end diff --git a/test/utopia/controller/sequence.rb b/test/utopia/controller/sequence.rb index 6618757..b54f46e 100644 --- a/test/utopia/controller/sequence.rb +++ b/test/utopia/controller/sequence.rb @@ -5,6 +5,7 @@ require "protocol/http/request" require "utopia/controller" +require "utopia/context" require "utopia/request" class TestController < Utopia::Controller::Base @@ -57,10 +58,16 @@ def initialize describe Utopia::Controller do let(:variables) {Utopia::Controller::Variables.new} - let(:request) do - request = Utopia::Request.new(Protocol::HTTP::Request["GET", "/"]) - request[Utopia::VARIABLES_KEY] = variables - request + let(:request) {Protocol::HTTP::Request["GET", "/"]} + + def before + super + Utopia::Context.variables = variables + end + + def after(error = nil) + Utopia::Context.clear + super end it "should call controller methods" do diff --git a/test/utopia/controller/variables.rb b/test/utopia/controller/variables.rb index e7b70a8..0d1fac2 100644 --- a/test/utopia/controller/variables.rb +++ b/test/utopia/controller/variables.rb @@ -6,6 +6,7 @@ require "utopia/controller/variables" require "protocol/http/request" require "utopia/request" +require "utopia/context" class TestController attr_accessor :x, :y, :z @@ -44,18 +45,19 @@ def copy_instance_variables(from) end describe Utopia::Controller do - it "returns variables from request attributes" do + after do + Utopia::Context.clear + end + + it "returns variables from fiber state" do variables = Utopia::Controller::Variables.new - request = Utopia::Request.new(Protocol::HTTP::Request["GET", "/"]) - request[Utopia::VARIABLES_KEY] = variables + Utopia::Context.variables = variables - expect(Utopia::Controller[request]).to be == variables + expect(Utopia::Controller.current).to be == variables end it "returns nil when variables are not set" do - request = Utopia::Request.new(Protocol::HTTP::Request["GET", "/"]) - - expect(Utopia::Controller[request]).to be_nil + expect(Utopia::Controller.current).to be_nil end end end diff --git a/test/utopia/request.rb b/test/utopia/request.rb index 497d9dd..31e6489 100644 --- a/test/utopia/request.rb +++ b/test/utopia/request.rb @@ -6,28 +6,19 @@ require "protocol/http/request" require "utopia/request" -describe Utopia::Request do - let(:http_request) {Protocol::HTTP::Request["POST", "/search?q=utopia"]} - let(:request) {subject.new(http_request)} +describe Protocol::HTTP::Request do + let(:request) {subject["POST", "/search?q=utopia&tag=ruby&tag=async", {"cookie" => "a=1; b=2"}]} - it "exposes the underlying protocol request" do - expect(request.http).to be_equal(http_request) - expect(request.method).to be == "POST" - expect(request.path).to be == "/search?q=utopia" + it "provides path information" do expect(request.path_info).to be == "/search" - expect(request.query).to be == "q=utopia" + expect(request.query).to be == "q=utopia&tag=ruby&tag=async" end - it "updates path info while preserving the query string" do + it "updates path information while preserving query string" do request.path_info = "/find" - expect(request.path).to be == "/find?q=utopia" - end - - it "provides request-local attributes" do - request.attributes[:locale] = "en" - - expect(request.attributes[:locale]).to be == "en" + expect(request.path).to be == "/find?q=utopia&tag=ruby&tag=async" + expect(request.path_info).to be == "/find" end it "provides HTTP method predicates" do @@ -37,31 +28,39 @@ expect(request.options?).to be == false end - it "looks up arguments by string or symbol keys" do + it "provides decoded query arguments" do + expect(request.arguments).to be == { + "q" => "utopia", + "tag" => ["ruby", "async"] + } + expect(request["q"]).to be == "utopia" expect(request[:q]).to be == "utopia" end - it "prefers request-local attributes over arguments" do - request[:q] = "local" - - expect(request[:q]).to be == "local" + it "provides decoded cookies" do + expect(request.cookies).to be == {"a" => "1", "b" => "2"} end it "provides common request conveniences" do - http_request.scheme = "https" - http_request.authority = "example.com" - http_request.headers["referer"] = "/from" - - request["utopia.session"] = {"user_id" => 10} + request.scheme = "https" + request.authority = "example.com" + request.headers["referer"] = "/from" expect(request.scheme).to be == "https" expect(request.ssl?).to be == true expect(request.host_with_port).to be == "example.com" expect(request.base_url).to be == "https://example.com" - expect(request.url).to be == "https://example.com/search?q=utopia" + expect(request.url).to be == "https://example.com/search?q=utopia&tag=ruby&tag=async" expect(request.referer).to be == "/from" expect(request.referrer).to be == "/from" - expect(request.session).to be == {"user_id" => 10} + end + + it "builds derived requests" do + derived = request.with(method: "GET", path_info: "/find") + + expect(derived).not.to be_equal(request) + expect(derived.method).to be == "GET" + expect(derived.path).to be == "/find?q=utopia&tag=ruby&tag=async" end end diff --git a/test/utopia/session.rb b/test/utopia/session.rb index 4717e74..d841c39 100755 --- a/test/utopia/session.rb +++ b/test/utopia/session.rb @@ -14,15 +14,15 @@ Utopia::Application.build(lambda{|request| case request.path_info when "/login" - request.session["login"] = "true" + Utopia::Session["login"] = "true" Utopia::Response[200, {}, []] when "/session-set" - request.session[request.arguments["key"].to_sym] = request.arguments["value"] + Utopia::Session[request.arguments["key"].to_sym] = request.arguments["value"] Utopia::Response[200, {}, []] when "/session-get" - Utopia::Response[200, {}, [request.session[request.arguments["key"].to_sym]]] + Utopia::Response[200, {}, [Utopia::Session[request.arguments["key"].to_sym]]] else Utopia::Response[404, {}, []] end @@ -88,11 +88,11 @@ Utopia::Application.build(lambda{|request| case request.path_info when "/session-set" - request.session[request.arguments["key"].to_sym] = request.arguments["value"] + Utopia::Session[request.arguments["key"].to_sym] = request.arguments["value"] Utopia::Response[200, {}, []] when "/session-get" - Utopia::Response[200, {}, [request.session[request.arguments["key"].to_sym]]] + Utopia::Response[200, {}, [Utopia::Session[request.arguments["key"].to_sym]]] else Utopia::Response[404, {}, []] end @@ -207,4 +207,30 @@ def before expect(hash).to be(:needs_update?) end + + it "does not allow mutation from another fiber" do + hash = Utopia::Session::LazyHash.new do + {} + end + + fiber = Fiber.new do + hash[:a] = 1 + end + + expect do + fiber.resume + end.to raise_exception(Utopia::Session::LazyHash::WrongFiberError) + end + + it "does not allow mutation after commit" do + hash = Utopia::Session::LazyHash.new do + {} + end + + hash.commit! + + expect do + hash[:a] = 1 + end.to raise_exception(Utopia::Session::LazyHash::AlreadyCommittedError) + end end From 9ab5abf3408cdc5923df7937daf7d2691cf0eb0b Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 25 Jun 2026 13:25:24 +1200 Subject: [PATCH 12/16] Document Utopia fiber state APIs Assisted-By: devx/166ed168-1c4d-4c63-a5f6-8d0d9cbff13f --- lib/utopia/context.rb | 8 ++++++++ lib/utopia/request.rb | 26 ++++++++++++++++++++++++++ lib/utopia/session.rb | 10 +++++++++- lib/utopia/session/lazy_hash.rb | 17 +++++++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/lib/utopia/context.rb b/lib/utopia/context.rb index 9033bcc..2c2024e 100644 --- a/lib/utopia/context.rb +++ b/lib/utopia/context.rb @@ -6,6 +6,7 @@ module Utopia # Accessors for request-scoped Utopia state stored directly in fiber storage. module Context + # The fiber storage keys used by Utopia. KEYS = { request: :utopia_request, request_path: :utopia_request_path, @@ -16,14 +17,17 @@ module Context exception: :utopia_exception }.freeze + # Fetch a Utopia fiber state value. def self.[] key Fiber[KEYS.fetch(key)] end + # Assign a Utopia fiber state value. def self.[]= key, value Fiber[KEYS.fetch(key)] = value end + # Temporarily assign Utopia fiber state values for the duration of the block. def self.with(**values) previous = {} @@ -39,21 +43,25 @@ def self.with(**values) end end + # Clear all Utopia fiber state values from the current fiber. def self.clear KEYS.each_value do |key| Fiber[key] = nil end end + # Convert the current Utopia fiber state to a hash. def self.to_hash KEYS.transform_values{|key| Fiber[key]} end KEYS.each_key do |name| + # Fetch a named Utopia fiber state value. define_singleton_method(name) do self[name] end + # Assign a named Utopia fiber state value. define_singleton_method("#{name}=") do |value| self[name] = value end diff --git a/lib/utopia/request.rb b/lib/utopia/request.rb index ef596da..5aa17c5 100644 --- a/lib/utopia/request.rb +++ b/lib/utopia/request.rb @@ -10,43 +10,56 @@ require_relative "context" +# Protocol namespaces extended with Utopia request helpers. module Protocol + # HTTP protocol types extended with Utopia request helpers. module HTTP + # Convenience methods used by Utopia middleware. class Request + # The HTTP request method. alias request_method method + # Whether the request method is GET. def get? self.method == "GET" end + # Whether the request method is HEAD. def head? self.method == "HEAD" end + # Whether the request method is POST. def post? self.method == "POST" end + # Whether the request method is PUT. def put? self.method == "PUT" end + # Whether the request method is PATCH. def patch? self.method == "PATCH" end + # Whether the request method is DELETE. def delete? self.method == "DELETE" end + # Whether the request method is OPTIONS. def options? self.method == "OPTIONS" end + # The request path without the query string. def path_info self.path&.split("?", 2)&.first end + # Set the request path while preserving the query string. def path_info=(value) if query = self.query self.path = "#{value}?#{query}" @@ -57,28 +70,34 @@ def path_info=(value) @utopia_arguments = nil end + # The query string without the leading question mark. def query self.path&.split("?", 2)&.last if self.path&.include?("?") end + # Decoded query arguments. def arguments @utopia_arguments ||= decode_arguments(self.query) end alias params arguments + # Decoded request cookies. def cookies @utopia_cookies ||= parse_cookies(self.headers["cookie"]) end + # The request host with optional port. def host self.authority || self.headers["host"] end alias host_with_port host + # Whether the request uses HTTPS. def ssl? self.scheme == "https" end + # The base URL for the request. def base_url if self.scheme && self.host "#{self.scheme}://#{self.host}" @@ -87,23 +106,28 @@ def base_url end end + # The request user agent. def user_agent self.headers["user-agent"] end + # The request referrer. def referrer self.headers["referer"] end alias referer referrer + # The current Utopia session, if installed. def session Utopia::Context.session end + # The remote peer IP address, if available. def ip self.peer&.ip_address end + # The full request URL, if scheme and host are available. def url base_url = self.base_url @@ -114,6 +138,7 @@ def url end end + # Build a derived request with updated protocol fields. def with(method: self.method, path: self.path, path_info: nil) request = self.dup request.method = method @@ -134,6 +159,7 @@ def with(method: self.method, path: self.path, path_info: nil) return request end + # Fetch a Rack-style compatibility value or query argument. def [] key case key when "REQUEST_METHOD" diff --git a/lib/utopia/session.rb b/lib/utopia/session.rb index b144f8e..0cd3d1c 100644 --- a/lib/utopia/session.rb +++ b/lib/utopia/session.rb @@ -7,10 +7,13 @@ require_relative "context" module Utopia + # Session access helpers and middleware constructor. module Session + # Base class for Utopia session errors. class Error < StandardError end + # Raised when session access requires installed session middleware. class MissingError < Error end end @@ -20,27 +23,32 @@ class MissingError < Error module Utopia module Session - + # Build a session middleware instance. def self.new(...) Middleware.new(...) end + # The current session, if session middleware is installed. def self.current Context.session end + # The current session, or raise a clear error if sessions are unavailable. def self.required self.current or raise MissingError, "No current Utopia session!" end + # Fetch a value from the current session. def self.[] key self.required[key] end + # Assign a value in the current session. def self.[]= key, value self.required[key] = value end + # Delete a value from the current session. def self.delete(key) self.required.delete(key) end diff --git a/lib/utopia/session/lazy_hash.rb b/lib/utopia/session/lazy_hash.rb index 8c39ec0..2174429 100644 --- a/lib/utopia/session/lazy_hash.rb +++ b/lib/utopia/session/lazy_hash.rb @@ -7,15 +7,19 @@ module Utopia module Session # A simple hash table which fetches it's values only when required. class LazyHash + # Base class for session mutation errors. class MutationError < Session::Error end + # Raised when mutating a session after it has been committed. class AlreadyCommittedError < MutationError end + # Raised when mutating a session from a non-owning fiber. class WrongFiberError < MutationError end + # Initialize a lazy hash with a block for loading values. def initialize(&block) @changed = false @values = nil @@ -25,13 +29,18 @@ def initialize(&block) @loader = block end + # The loaded session values, if already loaded. attr :values + + # The fiber which owns session mutation. attr :owner + # Fetch a session value. def [] key load![key] end + # Assign a session value. def []= key, value check_mutable! @@ -45,10 +54,12 @@ def []= key, value return value end + # Check whether the session includes the specified key. def include?(key) load!.include?(key) end + # Delete a session value. def delete(key) check_mutable! load! @@ -58,27 +69,33 @@ def delete(key) @values.delete(key) end + # Whether the session has changed since it was loaded. def changed? @changed end + # Whether the session has already been committed. def committed? @committed end + # Mark the session as committed. def commit! check_owner! @committed = true end + # Load the session values if they have not been loaded yet. def load! @values ||= @loader.call end + # Whether the session values have been loaded. def loaded? !@values.nil? end + # Whether the session should be committed to the response. def needs_update?(timeout = nil) # If data has changed, we need update: return true if @changed From ee11a9cc816fae8a6b48f7e27b96947574525899 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 25 Jun 2026 13:52:05 +1200 Subject: [PATCH 13/16] Remove Utopia context state Assisted-By: devx/166ed168-1c4d-4c63-a5f6-8d0d9cbff13f --- lib/utopia/application.rb | 15 ++---- lib/utopia/content/document.rb | 3 +- lib/utopia/content/middleware.rb | 6 +-- lib/utopia/context.rb | 70 --------------------------- lib/utopia/controller/middleware.rb | 9 ++-- lib/utopia/controller/variables.rb | 9 +++- lib/utopia/exceptions/handler.rb | 23 +++++++-- lib/utopia/exceptions/mailer.rb | 21 ++++++-- lib/utopia/localization/middleware.rb | 16 ++++-- lib/utopia/localization/wrapper.rb | 33 ++++++++++--- lib/utopia/request.rb | 17 ++++++- lib/utopia/session.rb | 11 +++-- lib/utopia/session/middleware.rb | 21 ++++---- lib/utopia/static/middleware.rb | 3 +- plan.md | 17 ++++--- test/utopia/application.rb | 1 - test/utopia/context.rb | 44 ----------------- test/utopia/controller/sequence.rb | 5 +- test/utopia/controller/variables.rb | 5 +- test/utopia/request.rb | 10 ++++ 20 files changed, 154 insertions(+), 185 deletions(-) delete mode 100644 lib/utopia/context.rb delete mode 100644 test/utopia/context.rb diff --git a/lib/utopia/application.rb b/lib/utopia/application.rb index 87697ea..09dcaf6 100644 --- a/lib/utopia/application.rb +++ b/lib/utopia/application.rb @@ -6,16 +6,15 @@ require "protocol/http/middleware" require "protocol/http/middleware/builder" -require_relative "context" require_relative "request" require_relative "response" module Utopia # The protocol-facing entrypoint for a Utopia application. # - # This object accepts {Protocol::HTTP::Request} instances, installs the - # request-scoped Utopia fiber state, dispatches to the Utopia application - # stack, and normalizes the result back to a {Protocol::HTTP::Response}. + # This object accepts {Protocol::HTTP::Request} instances, dispatches to the + # Utopia application stack, and normalizes the result back to a + # {Protocol::HTTP::Response}. class Application < Protocol::HTTP::Middleware CONFIGURATION_PATH = "config/application.rb".freeze @@ -82,13 +81,7 @@ def initialize(delegate) # @parameter http_request [Protocol::HTTP::Request] The incoming protocol request. # @returns [Protocol::HTTP::Response] The normalized protocol response. def call(http_request) - Context.clear - - Context.with(request: http_request, request_path: http_request.path_info) do - return Response.wrap(super(http_request)) - end - ensure - Context.clear + return Response.wrap(super(http_request)) end end end diff --git a/lib/utopia/content/document.rb b/lib/utopia/content/document.rb index 127634e..92aa633 100644 --- a/lib/utopia/content/document.rb +++ b/lib/utopia/content/document.rb @@ -7,7 +7,6 @@ require_relative "response" require_relative "markup" require_relative "builder" -require_relative "../context" module Utopia module Content @@ -42,7 +41,7 @@ def initialize(request, attributes = {}) # @returns [Path] The original request path, if known. def request_path - Path[Context.request_path || request.path_info] + Path[request.request_path] end protected def current_base_uri_path diff --git a/lib/utopia/content/middleware.rb b/lib/utopia/content/middleware.rb index 7732f85..a75bf42 100644 --- a/lib/utopia/content/middleware.rb +++ b/lib/utopia/content/middleware.rb @@ -4,9 +4,9 @@ # Copyright, 2025-2026, by Samuel Williams. require_relative "../middleware" -require_relative "../context" require_relative "../localization" require_relative "../response" +require_relative "../controller/variables" require_relative "links" require_relative "node" @@ -94,7 +94,7 @@ def resolve_link(link) def respond(link, request) if node = resolve_link(link) - attributes = Context.variables&.to_hash || {} + attributes = Controller.current&.to_hash || {} return node.process!(request, attributes) elsif redirect_uri = link[:uri] @@ -116,7 +116,7 @@ def call(request) return Utopia::Response[307, {HTTP::LOCATION => path.dirname.join(index_path).to_s}, []] end - locale = Context.current_locale + locale = Localization.current_locale if link = @links.for(path, locale) if response = self.respond(link, request) return response diff --git a/lib/utopia/context.rb b/lib/utopia/context.rb deleted file mode 100644 index 2c2024e..0000000 --- a/lib/utopia/context.rb +++ /dev/null @@ -1,70 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -module Utopia - # Accessors for request-scoped Utopia state stored directly in fiber storage. - module Context - # The fiber storage keys used by Utopia. - KEYS = { - request: :utopia_request, - request_path: :utopia_request_path, - session: :utopia_session, - variables: :utopia_variables, - localization: :utopia_localization, - current_locale: :utopia_current_locale, - exception: :utopia_exception - }.freeze - - # Fetch a Utopia fiber state value. - def self.[] key - Fiber[KEYS.fetch(key)] - end - - # Assign a Utopia fiber state value. - def self.[]= key, value - Fiber[KEYS.fetch(key)] = value - end - - # Temporarily assign Utopia fiber state values for the duration of the block. - def self.with(**values) - previous = {} - - values.each do |key, value| - previous[key] = self[key] - self[key] = value - end - - return yield - ensure - previous&.each do |key, value| - self[key] = value - end - end - - # Clear all Utopia fiber state values from the current fiber. - def self.clear - KEYS.each_value do |key| - Fiber[key] = nil - end - end - - # Convert the current Utopia fiber state to a hash. - def self.to_hash - KEYS.transform_values{|key| Fiber[key]} - end - - KEYS.each_key do |name| - # Fetch a named Utopia fiber state value. - define_singleton_method(name) do - self[name] - end - - # Assign a named Utopia fiber state value. - define_singleton_method("#{name}=") do |value| - self[name] = value - end - end - end -end diff --git a/lib/utopia/controller/middleware.rb b/lib/utopia/controller/middleware.rb index 15a8e73..a3083ed 100644 --- a/lib/utopia/controller/middleware.rb +++ b/lib/utopia/controller/middleware.rb @@ -5,7 +5,6 @@ require_relative "../path" require_relative "../middleware" -require_relative "../context" require_relative "variables" require_relative "base" @@ -92,7 +91,7 @@ def invoke_controllers(request) controller_path = Path.new # Controller instance variables which eventually get processed by the view: - variables = Context.variables + variables = Controller.current while request_path.components.any? # 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. @@ -119,13 +118,17 @@ def invoke_controllers(request) end def call(request) - Context.variables ||= Variables.new + previous_variables = Controller.current + + Controller.current ||= Variables.new if result = invoke_controllers(request) return result end return @app.call(request) + ensure + Controller.current = previous_variables end end end diff --git a/lib/utopia/controller/variables.rb b/lib/utopia/controller/variables.rb index 0a78145..b256ac4 100644 --- a/lib/utopia/controller/variables.rb +++ b/lib/utopia/controller/variables.rb @@ -4,10 +4,11 @@ # Copyright, 2014-2025, by Samuel Williams. require_relative "../middleware" -require_relative "../context" module Utopia module Controller + CURRENT_KEY = :utopia_variables + # Provides a stack-based instance variable lookup mechanism. It can flatten a stack of controllers into a single hash. class Variables def initialize @@ -66,7 +67,11 @@ def [] key end def self.current - Context.variables + Fiber[CURRENT_KEY] + end + + def self.current= variables + Fiber[CURRENT_KEY] = variables end def self.[] request = nil diff --git a/lib/utopia/exceptions/handler.rb b/lib/utopia/exceptions/handler.rb index 59a2503..6fff8d3 100644 --- a/lib/utopia/exceptions/handler.rb +++ b/lib/utopia/exceptions/handler.rb @@ -6,12 +6,23 @@ require "console" -require_relative "../context" require_relative "../middleware" require_relative "../response" module Utopia module Exceptions + CURRENT_KEY = :utopia_exception + + # The exception currently being handled. + def self.current + Fiber[CURRENT_KEY] + end + + # Assign the exception currently being handled. + def self.current= exception + Fiber[CURRENT_KEY] = exception + end + # A middleware which catches exceptions and performs an internal redirect. class Handler # @param location [String] Peform an internal redirect to this location when an exception is raised. @@ -42,8 +53,14 @@ def call(request) path_info: @location ) - error_response = Context.with(request: error_request, exception: exception) do - Response.wrap(@app.call(error_request)) + previous_exception = Exceptions.current + + begin + Exceptions.current = exception + + error_response = Response.wrap(@app.call(error_request)) + ensure + Exceptions.current = previous_exception end error_response.status = 500 diff --git a/lib/utopia/exceptions/mailer.rb b/lib/utopia/exceptions/mailer.rb index 8b2c6c7..f4cfdec 100644 --- a/lib/utopia/exceptions/mailer.rb +++ b/lib/utopia/exceptions/mailer.rb @@ -6,8 +6,11 @@ require "net/smtp" require "mail" -require_relative "../context" require_relative "../middleware" +require_relative "../session" +require_relative "../controller/variables" +require_relative "../localization" +require_relative "handler" module Utopia module Exceptions @@ -130,8 +133,8 @@ def generate_body(exception, request) io.puts "header[#{key.inspect}]: #{value.inspect}" end - Context.to_hash.each do |key, value| - io.puts "context.#{key}: #{value.inspect}" + self.current_state.each do |key, value| + io.puts "state.#{key}: #{value.inspect}" end io.puts @@ -164,7 +167,7 @@ def generate_mail(exception, request) end if @dump_environment - mail.attachments["context.yaml"] = YAML.dump(Context.to_hash) + mail.attachments["state.yaml"] = YAML.dump(self.current_state) end return mail @@ -181,6 +184,16 @@ def send_notification(exception, request) $stderr.puts mail_exception.backtrace end + def current_state + { + session: Session.current, + variables: Controller.current, + localization: Localization.current, + current_locale: Localization.current_locale, + exception: Exceptions.current, + } + end + def extract_body(request) request.body&.read end diff --git a/lib/utopia/localization/middleware.rb b/lib/utopia/localization/middleware.rb index 442c2d4..3cdd4cc 100644 --- a/lib/utopia/localization/middleware.rb +++ b/lib/utopia/localization/middleware.rb @@ -4,7 +4,6 @@ # Copyright, 2025-2026, by Samuel Williams. require_relative "wrapper" -require_relative "../context" require_relative "../middleware" require_relative "../response" @@ -143,7 +142,7 @@ def vary(request, response) headers.add("vary", "Accept-Language") # Althought this header is generally not supported, we supply it anyway as it is useful for debugging: - if locale = Context.current_locale + if locale = Localization.current_locale # Set the Content-Location to point to the localized URI as requested: headers["content-location"] = "/#{locale}" + request.path_info end @@ -161,8 +160,17 @@ def call(request) preferred_locales(request) do |localized_request, locale| # puts "Trying locale: #{locale}: #{localized_request.path_info}..." - response = Context.with(request: localized_request, localization: self, current_locale: locale) do - Response.wrap(@app.call(localized_request)) + previous_localization = Localization.current + previous_locale = Localization.current_locale + + begin + Localization.current = self + Localization.current_locale = locale + + response = Response.wrap(@app.call(localized_request)) + ensure + Localization.current = previous_localization + Localization.current_locale = previous_locale end break unless response.status >= 400 diff --git a/lib/utopia/localization/wrapper.rb b/lib/utopia/localization/wrapper.rb index 8f76010..a2e4dfe 100644 --- a/lib/utopia/localization/wrapper.rb +++ b/lib/utopia/localization/wrapper.rb @@ -4,18 +4,37 @@ # Copyright, 2015-2026, by Samuel Williams. require_relative "middleware" -require_relative "../context" module Utopia # A middleware which attempts to find localized content. module Localization - LOCALIZATION_KEY = "utopia.localization".freeze - CURRENT_LOCALE_KEY = "utopia.localization.current_locale".freeze + CURRENT_KEY = :utopia_localization + CURRENT_LOCALE_KEY = :utopia_current_locale + + # The current localization middleware, if localization is active. + def self.current + Fiber[CURRENT_KEY] + end + + # Assign the current localization middleware. + def self.current= localization + Fiber[CURRENT_KEY] = localization + end + + # The current locale, if localization is active. + def self.current_locale + Fiber[CURRENT_LOCALE_KEY] + end + + # Assign the current locale. + def self.current_locale= locale + Fiber[CURRENT_LOCALE_KEY] = locale + end # A wrapper to provide easy access to locale related data in the request. class Wrapper def localization - Context.localization + Localization.current end def localized? @@ -24,7 +43,7 @@ def localized? # Returns the current locale or nil if not localized. def current_locale - Context.current_locale + Localization.current_locale end # Returns the default locale or nil if not localized. @@ -42,12 +61,12 @@ def localized_path(path, locale) end end - def self.current + def self.wrapper Wrapper.new end def self.[] request = nil - self.current + self.wrapper end end end diff --git a/lib/utopia/request.rb b/lib/utopia/request.rb index 5aa17c5..69041ff 100644 --- a/lib/utopia/request.rb +++ b/lib/utopia/request.rb @@ -8,7 +8,7 @@ require "protocol/http/request" -require_relative "context" +require_relative "session" # Protocol namespaces extended with Utopia request helpers. module Protocol @@ -61,6 +61,8 @@ def path_info # Set the request path while preserving the query string. def path_info=(value) + @utopia_request_path ||= self.path_info + if query = self.query self.path = "#{value}?#{query}" else @@ -70,6 +72,11 @@ def path_info=(value) @utopia_arguments = nil end + # The original request path, before any internal request rewrites. + def request_path + @utopia_request_path || self.path_info + end + # The query string without the leading question mark. def query self.path&.split("?", 2)&.last if self.path&.include?("?") @@ -119,7 +126,7 @@ def referrer # The current Utopia session, if installed. def session - Utopia::Context.session + Utopia::Session.current end # The remote peer IP address, if available. @@ -144,12 +151,18 @@ def with(method: self.method, path: self.path, path_info: nil) request.method = method if path_info + request.instance_variable_set(:@utopia_request_path, self.request_path) + if query = self.query request.path = "#{path_info}?#{query}" else request.path = path_info end else + if path != self.path + request.instance_variable_set(:@utopia_request_path, self.request_path) + end + request.path = path end diff --git a/lib/utopia/session.rb b/lib/utopia/session.rb index 0cd3d1c..2a8f81d 100644 --- a/lib/utopia/session.rb +++ b/lib/utopia/session.rb @@ -4,11 +4,11 @@ # Copyright, 2014-2025, by Samuel Williams. # Copyright, 2019, by Huba Nagy. -require_relative "context" - module Utopia # Session access helpers and middleware constructor. module Session + CURRENT_KEY = :utopia_session + # Base class for Utopia session errors. class Error < StandardError end @@ -30,7 +30,12 @@ def self.new(...) # The current session, if session middleware is installed. def self.current - Context.session + Fiber[CURRENT_KEY] + end + + # Assign the current session. + def self.current= session + Fiber[CURRENT_KEY] = session end # The current session, or raise a clear error if sessions are unavailable. diff --git a/lib/utopia/session/middleware.rb b/lib/utopia/session/middleware.rb index 523c892..94a2c4e 100644 --- a/lib/utopia/session/middleware.rb +++ b/lib/utopia/session/middleware.rb @@ -11,7 +11,6 @@ require_relative "lazy_hash" require_relative "serialization" -require_relative "../context" require_relative "../middleware" require_relative "../response" @@ -96,16 +95,18 @@ def freeze def call(request) session_hash = prepare_session(request) + previous_session = Session.current - Context.with(session: session_hash) do - response = Response.wrap(@app.call(request)) - - update_session(session_hash, response.headers) - - return response - ensure - session_hash.commit! - end + Session.current = session_hash + + response = Response.wrap(@app.call(request)) + + update_session(session_hash, response.headers) + + return response + ensure + session_hash&.commit! + Session.current = previous_session end protected diff --git a/lib/utopia/static/middleware.rb b/lib/utopia/static/middleware.rb index a62fe73..6acf8ce 100644 --- a/lib/utopia/static/middleware.rb +++ b/lib/utopia/static/middleware.rb @@ -4,7 +4,6 @@ # Copyright, 2025-2026, by Samuel Williams. require_relative "../middleware" -require_relative "../context" require_relative "../localization" require_relative "../response" @@ -79,7 +78,7 @@ def response_headers_for(file, content_type) def respond(request, path_info, extension) path = Path[path_info].simplify - if locale = Context.current_locale + if locale = Localization.current_locale path.last.insert(path.last.rindex(".") || -1, ".#{locale}") end diff --git a/plan.md b/plan.md index 7ce9120..6136a04 100644 --- a/plan.md +++ b/plan.md @@ -26,9 +26,9 @@ Protocol::HTTP::Request ``` `Utopia::Application` is the lifecycle boundary. It receives -`Protocol::HTTP::Request`, installs Utopia fiber state for the request, dispatches -ordinary protocol middleware, normalizes the response, and clears Utopia fiber -state. +`Protocol::HTTP::Request`, dispatches ordinary protocol middleware, and +normalizes the response. The request itself flows explicitly down the middleware +chain. ## Application @@ -171,8 +171,9 @@ Guidelines: - Keep query, form, JSON, and multipart parsing separable where possible. - Add small convenience methods to `Protocol::HTTP::Request` only where they make middleware substantially clearer. -- Use Utopia-owned fiber state rather than Rack-style `env` or a Utopia request - attribute hash. +- Keep the request explicit. Do not expose ambient `Utopia.request` style state. +- Use Utopia-owned fiber state for optional adjacent application state rather + than Rack-style `env` or a Utopia request attribute hash. Possible arguments shape: @@ -201,9 +202,9 @@ Fiber[:utopia_variables] Fiber[:utopia_current_locale] ``` -`Utopia::Application` should clear Utopia fiber state before and after each -request. Since each request is handled by an independent fiber, a separate root -context object is not needed. +Each optional subsystem should own its own `current`/`current=` API for tests and +middleware setup. Since each request is handled by an independent fiber, a +separate root context object is not needed. Sessions are optional. If session middleware is not installed, `Utopia::Session.current` should return `nil` and `Utopia::Session[...]` should diff --git a/test/utopia/application.rb b/test/utopia/application.rb index 0aa06f9..c0afdfb 100644 --- a/test/utopia/application.rb +++ b/test/utopia/application.rb @@ -24,7 +24,6 @@ response = application.call(http_request) expect(application_request).to be_equal(http_request) - expect(Utopia::Context.request).to be_nil expect(application_request.path_info).to be == "/hello" expect(application_request.query).to be == "name=sam" diff --git a/test/utopia/context.rb b/test/utopia/context.rb deleted file mode 100644 index 4e87139..0000000 --- a/test/utopia/context.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -require "protocol/http/request" -require "utopia/context" -require "utopia/request" - -describe Utopia::Context do - let(:request) {Protocol::HTTP::Request["GET", "/hello"]} - - after do - subject.clear - end - - it "stores request state directly in fiber storage" do - subject.request = request - subject.request_path = request.path_info - - expect(subject.request).to be_equal(request) - expect(subject.request_path).to be == "/hello" - end - - it "scopes temporary assignments" do - subject.current_locale = "en" - - subject.with(current_locale: "ja") do - expect(subject.current_locale).to be == "ja" - end - - expect(subject.current_locale).to be == "en" - end - - it "is inherited by nested fibers" do - subject.session = Object.new - - fiber = Fiber.new do - subject.session - end - - expect(fiber.resume).to be_equal(subject.session) - end -end diff --git a/test/utopia/controller/sequence.rb b/test/utopia/controller/sequence.rb index b54f46e..8ab63f7 100644 --- a/test/utopia/controller/sequence.rb +++ b/test/utopia/controller/sequence.rb @@ -5,7 +5,6 @@ require "protocol/http/request" require "utopia/controller" -require "utopia/context" require "utopia/request" class TestController < Utopia::Controller::Base @@ -62,11 +61,11 @@ def initialize def before super - Utopia::Context.variables = variables + Utopia::Controller.current = variables end def after(error = nil) - Utopia::Context.clear + Utopia::Controller.current = nil super end diff --git a/test/utopia/controller/variables.rb b/test/utopia/controller/variables.rb index 0d1fac2..282c99e 100644 --- a/test/utopia/controller/variables.rb +++ b/test/utopia/controller/variables.rb @@ -6,7 +6,6 @@ require "utopia/controller/variables" require "protocol/http/request" require "utopia/request" -require "utopia/context" class TestController attr_accessor :x, :y, :z @@ -46,12 +45,12 @@ def copy_instance_variables(from) describe Utopia::Controller do after do - Utopia::Context.clear + Utopia::Controller.current = nil end it "returns variables from fiber state" do variables = Utopia::Controller::Variables.new - Utopia::Context.variables = variables + Utopia::Controller.current = variables expect(Utopia::Controller.current).to be == variables end diff --git a/test/utopia/request.rb b/test/utopia/request.rb index 31e6489..e12ee3b 100644 --- a/test/utopia/request.rb +++ b/test/utopia/request.rb @@ -19,6 +19,7 @@ expect(request.path).to be == "/find?q=utopia&tag=ruby&tag=async" expect(request.path_info).to be == "/find" + expect(request.request_path).to be == "/search" end it "provides HTTP method predicates" do @@ -62,5 +63,14 @@ expect(derived).not.to be_equal(request) expect(derived.method).to be == "GET" expect(derived.path).to be == "/find?q=utopia&tag=ruby&tag=async" + expect(derived.request_path).to be == "/search" + end + + it "preserves the original request path across multiple derived requests" do + derived = request.with(path_info: "/find") + derived = derived.with(path_info: "/lookup") + + expect(derived.path_info).to be == "/lookup" + expect(derived.request_path).to be == "/search" end end From 55fc7fe743dee4edbe6ef2b452e5a4db056577c2 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 25 Jun 2026 14:03:12 +1200 Subject: [PATCH 14/16] Use Utopia request wrapper Assisted-By: devx/166ed168-1c4d-4c63-a5f6-8d0d9cbff13f --- lib/utopia/application.rb | 2 +- lib/utopia/request.rb | 481 ++++++++++-------- plan.md | 25 +- test/utopia/application.rb | 3 +- test/utopia/application_middleware.rb | 2 +- test/utopia/content/document.rb | 3 +- .../.websocket/server/controller.rb | 2 +- test/utopia/controller/respond.rb | 4 +- test/utopia/controller/rewrite.rb | 3 +- test/utopia/request.rb | 6 +- 10 files changed, 304 insertions(+), 227 deletions(-) diff --git a/lib/utopia/application.rb b/lib/utopia/application.rb index 09dcaf6..e277fa5 100644 --- a/lib/utopia/application.rb +++ b/lib/utopia/application.rb @@ -81,7 +81,7 @@ def initialize(delegate) # @parameter http_request [Protocol::HTTP::Request] The incoming protocol request. # @returns [Protocol::HTTP::Response] The normalized protocol response. def call(http_request) - return Response.wrap(super(http_request)) + return Response.wrap(super(Request.new(http_request))) end end end diff --git a/lib/utopia/request.rb b/lib/utopia/request.rb index 69041ff..9f15764 100644 --- a/lib/utopia/request.rb +++ b/lib/utopia/request.rb @@ -10,239 +10,312 @@ require_relative "session" -# Protocol namespaces extended with Utopia request helpers. -module Protocol - # HTTP protocol types extended with Utopia request helpers. - module HTTP - # Convenience methods used by Utopia middleware. - class Request - # The HTTP request method. - alias request_method method - - # Whether the request method is GET. - def get? - self.method == "GET" - end - - # Whether the request method is HEAD. - def head? - self.method == "HEAD" - end - - # Whether the request method is POST. - def post? - self.method == "POST" - end +module Utopia + # Utopia's application-facing request wrapper. + # + # The underlying protocol request is available via {#http}; parsing and + # application conveniences live here rather than on protocol-http itself. + class Request + # Build a Utopia request from the given protocol request arguments. + def self.[](*arguments) + self.new(Protocol::HTTP::Request[*arguments]) + end + + # Initialize the request wrapper. + # @parameter http [Protocol::HTTP::Request] The underlying protocol request. + # @parameter request_path [String | Nil] The original path before internal rewrites. + def initialize(http, request_path: nil) + @http = http + @request_path = request_path - # Whether the request method is PUT. - def put? - self.method == "PUT" - end + @arguments = nil + @cookies = nil + end + + # The underlying protocol request. + attr :http + + # The HTTP request method. + def method + @http.method + end + + # Assign the HTTP request method. + def method= value + @http.method = value + end + + alias request_method method + + # The request path including query string. + def path + @http.path + end + + # Assign the request path including query string. + def path= value + @request_path ||= self.path_info if value != @http.path + @http.path = value + @arguments = nil + end + + # The protocol request headers. + def headers + @http.headers + end + + # The protocol request body. + def body + @http.body + end + + # Assign the protocol request body. + def body= value + @http.body = value + end + + # The request scheme. + def scheme + @http.scheme + end + + # Assign the request scheme. + def scheme= value + @http.scheme = value + end + + # The request authority. + def authority + @http.authority + end + + # Assign the request authority. + def authority= value + @http.authority = value + end + + # The remote peer, if available. + def peer + @http.peer + end + + # Whether the request method is GET. + def get? + self.method == "GET" + end + + # Whether the request method is HEAD. + def head? + self.method == "HEAD" + end + + # Whether the request method is POST. + def post? + self.method == "POST" + end + + # Whether the request method is PUT. + def put? + self.method == "PUT" + end + + # Whether the request method is PATCH. + def patch? + self.method == "PATCH" + end + + # Whether the request method is DELETE. + def delete? + self.method == "DELETE" + end + + # Whether the request method is OPTIONS. + def options? + self.method == "OPTIONS" + end + + # The request path without the query string. + def path_info + self.path&.split("?", 2)&.first + end + + # Set the request path while preserving the query string. + def path_info= value + @request_path ||= self.path_info - # Whether the request method is PATCH. - def patch? - self.method == "PATCH" + if query = self.query + self.path = "#{value}?#{query}" + else + self.path = value end - - # Whether the request method is DELETE. - def delete? - self.method == "DELETE" + end + + # The original request path, before any internal request rewrites. + def request_path + @request_path || self.path_info + end + + # The query string without the leading question mark. + def query + self.path&.split("?", 2)&.last if self.path&.include?("?") + end + + # Decoded query arguments. + def arguments + @arguments ||= decode_arguments(self.query) + end + + alias params arguments + + # Decoded request cookies. + def cookies + @cookies ||= parse_cookies(self.headers["cookie"]) + end + + # The request host with optional port. + def host + self.authority || self.headers["host"] + end + + alias host_with_port host + + # Whether the request uses HTTPS. + def ssl? + self.scheme == "https" + end + + # The base URL for the request. + def base_url + if self.scheme && self.host + "#{self.scheme}://#{self.host}" + else + "" end + end + + # The request user agent. + def user_agent + self.headers["user-agent"] + end + + # The request referrer. + def referrer + self.headers["referer"] + end + + alias referer referrer + + # The current Utopia session, if installed. + def session + Utopia::Session.current + end + + # The remote peer IP address, if available. + def ip + self.peer&.ip_address + end + + # The full request URL, if scheme and host are available. + def url + base_url = self.base_url - # Whether the request method is OPTIONS. - def options? - self.method == "OPTIONS" + if !base_url.empty? + "#{base_url}#{self.path}" + else + self.path end + end + + # Build a derived request with updated protocol fields. + def with(method: self.method, path: self.path, path_info: nil) + http = @http.dup + http.method = method - # The request path without the query string. - def path_info - self.path&.split("?", 2)&.first - end + request = self.class.new(http, request_path: self.request_path) - # Set the request path while preserving the query string. - def path_info=(value) - @utopia_request_path ||= self.path_info - + if path_info if query = self.query - self.path = "#{value}?#{query}" + request.path = "#{path_info}?#{query}" else - self.path = value + request.path = path_info end - - @utopia_arguments = nil + else + request.path = path end - # The original request path, before any internal request rewrites. - def request_path - @utopia_request_path || self.path_info - end - - # The query string without the leading question mark. - def query - self.path&.split("?", 2)&.last if self.path&.include?("?") - end - - # Decoded query arguments. - def arguments - @utopia_arguments ||= decode_arguments(self.query) - end - alias params arguments - - # Decoded request cookies. - def cookies - @utopia_cookies ||= parse_cookies(self.headers["cookie"]) - end - - # The request host with optional port. - def host - self.authority || self.headers["host"] - end - alias host_with_port host - - # Whether the request uses HTTPS. - def ssl? - self.scheme == "https" - end - - # The base URL for the request. - def base_url - if self.scheme && self.host - "#{self.scheme}://#{self.host}" + return request + end + + # Fetch a Rack-style compatibility value or query argument. + def [] key + case key + when "REQUEST_METHOD" + self.method + when "PATH_INFO", "REQUEST_PATH" + self.path_info + when "QUERY_STRING" + self.query.to_s + when "HTTP_HOST" + self.host + when "HTTP_USER_AGENT" + self.user_agent + when "HTTP_ACCEPT_LANGUAGE" + self.headers["accept-language"] + when "HTTP_IF_MODIFIED_SINCE" + self.headers["if-modified-since"] + when "HTTP_IF_NONE_MATCH" + self.headers["if-none-match"] + when "HTTP_RANGE" + self.headers["range"] + else + if key.is_a?(String) && key.start_with?("HTTP_") + self.headers[key[5..].downcase.tr("_", "-")] else - "" + self.arguments[key.to_s] end end + end + + private + + def decode_arguments(query) + arguments = {} - # The request user agent. - def user_agent - self.headers["user-agent"] - end - - # The request referrer. - def referrer - self.headers["referer"] - end - alias referer referrer - - # The current Utopia session, if installed. - def session - Utopia::Session.current - end - - # The remote peer IP address, if available. - def ip - self.peer&.ip_address - end + return arguments unless query - # The full request URL, if scheme and host are available. - def url - base_url = self.base_url - - if !base_url.empty? - "#{base_url}#{self.path}" - else - self.path - end + URI.decode_www_form(query).each do |key, value| + values = arguments.fetch(key){arguments[key] = []} + values << value end - # Build a derived request with updated protocol fields. - def with(method: self.method, path: self.path, path_info: nil) - request = self.dup - request.method = method - - if path_info - request.instance_variable_set(:@utopia_request_path, self.request_path) - - if query = self.query - request.path = "#{path_info}?#{query}" - else - request.path = path_info - end + arguments.transform_values! do |values| + if values.size == 1 + values.first else - if path != self.path - request.instance_variable_set(:@utopia_request_path, self.request_path) - end - - request.path = path + values end - - request.instance_variable_set(:@utopia_arguments, nil) - request.instance_variable_set(:@utopia_cookies, nil) - - return request end - # Fetch a Rack-style compatibility value or query argument. - def [] key - case key - when "REQUEST_METHOD" - self.method - when "PATH_INFO", "REQUEST_PATH" - self.path_info - when "QUERY_STRING" - self.query.to_s - when "HTTP_HOST" - self.host - when "HTTP_USER_AGENT" - self.user_agent - when "HTTP_ACCEPT_LANGUAGE" - self.headers["accept-language"] - when "HTTP_IF_MODIFIED_SINCE" - self.headers["if-modified-since"] - when "HTTP_IF_NONE_MATCH" - self.headers["if-none-match"] - when "HTTP_RANGE" - self.headers["range"] - else - if key.is_a?(String) && key.start_with?("HTTP_") - self.headers[key[5..].downcase.tr("_", "-")] - else - self.arguments[key.to_s] - end - end - end + return arguments + end + + def parse_cookies(cookie_header) + cookies = {} - private + return cookies unless cookie_header - def decode_arguments(query) - arguments = {} - - return arguments unless query - - URI.decode_www_form(query).each do |key, value| - values = arguments.fetch(key){arguments[key] = []} - values << value - end - - arguments.transform_values! do |values| - if values.size == 1 - values.first - else - values - end - end - - return arguments + if cookie_header.respond_to?(:to_str) + cookie_header = cookie_header.to_str + else + cookie_header = cookie_header.to_s end - def parse_cookies(cookie_header) - cookies = {} - - return cookies unless cookie_header - - if cookie_header.respond_to?(:to_str) - cookie_header = cookie_header.to_str - else - cookie_header = cookie_header.to_s - end - - cookie_header.split(/;\s*/).each do |pair| - key, value = pair.split("=", 2) - cookies[CGI.unescape(key)] = CGI.unescape(value || "") - end - - return cookies + cookie_header.split(/;\s*/).each do |pair| + key, value = pair.split("=", 2) + cookies[CGI.unescape(key)] = CGI.unescape(value || "") end + + return cookies end end end diff --git a/plan.md b/plan.md index 6136a04..7b84ced 100644 --- a/plan.md +++ b/plan.md @@ -20,15 +20,16 @@ The proposed stack is: ```text Protocol::HTTP::Request -> Utopia::Application + -> Utopia::Request -> Utopia middleware/controllers/content -> Utopia::Response or Protocol::HTTP::Response shaped value -> Protocol::HTTP::Response ``` `Utopia::Application` is the lifecycle boundary. It receives -`Protocol::HTTP::Request`, dispatches ordinary protocol middleware, and -normalizes the response. The request itself flows explicitly down the middleware -chain. +`Protocol::HTTP::Request`, adapts it to `Utopia::Request`, dispatches ordinary +Utopia middleware, and normalizes the response. The request itself flows +explicitly down the middleware chain; it is not ambient fiber state. ## Application @@ -146,8 +147,10 @@ end ## Request And State -Do not introduce a separate `Utopia::Request` wrapper in the core stack. Utopia -middleware should receive the normal `Protocol::HTTP::Request`. +Introduce a separate `Utopia::Request` wrapper in the core stack. Utopia +middleware should receive `Utopia::Request`, while the original +`Protocol::HTTP::Request` remains available as `request.http` for integrations +that need the transport-level object. Likely shape: @@ -169,8 +172,8 @@ Guidelines: - Prefer `arguments` over `params`. - Parse request data lazily. - Keep query, form, JSON, and multipart parsing separable where possible. -- Add small convenience methods to `Protocol::HTTP::Request` only where they make - middleware substantially clearer. +- Do not monkey patch `Protocol::HTTP::Request`; Utopia-specific convenience + methods belong on `Utopia::Request`. - Keep the request explicit. Do not expose ambient `Utopia.request` style state. - Use Utopia-owned fiber state for optional adjacent application state rather than Rack-style `env` or a Utopia request attribute hash. @@ -244,7 +247,7 @@ Utopia middleware should use the protocol-http middleware shape: ```text initialize(delegate, ...) -call(Protocol::HTTP::Request) -> response-like value +call(Utopia::Request) -> response-like value ``` Low-level protocol behavior, tracing, compression, authority policy, early @@ -268,7 +271,7 @@ Utopia owns what `use` and `run` mean for middleware. Terminal apps should satisfy: ```text -call(Protocol::HTTP::Request) -> response-like value +call(Utopia::Request) -> response-like value ``` `Utopia::Application.build` can decide compatibility details such as: @@ -348,8 +351,8 @@ Expected breaking changes: - Core Utopia middleware no longer receives Rack env hashes. - Controllers no longer receive `Rack::Request`. -- Core Utopia middleware receives `Protocol::HTTP::Request`, not - `Utopia::Request`. +- Core Utopia middleware receives `Utopia::Request`, not raw + `Protocol::HTTP::Request`. - `env[...]`, `rack.session`, `rack.input`, and Rack response tuple assumptions need migration. - Static file serving should move away from `Rack::Sendfile` and Rack range diff --git a/test/utopia/application.rb b/test/utopia/application.rb index c0afdfb..7d94b2c 100644 --- a/test/utopia/application.rb +++ b/test/utopia/application.rb @@ -23,7 +23,8 @@ response = application.call(http_request) - expect(application_request).to be_equal(http_request) + expect(application_request).to be_a(Utopia::Request) + expect(application_request.http).to be_equal(http_request) expect(application_request.path_info).to be == "/hello" expect(application_request.query).to be == "name=sam" diff --git a/test/utopia/application_middleware.rb b/test/utopia/application_middleware.rb index 57bcae4..c121979 100644 --- a/test/utopia/application_middleware.rb +++ b/test/utopia/application_middleware.rb @@ -30,7 +30,7 @@ def request(path, headers: nil) response = application.call(request("/hello")) - expect(seen_request).to be_a(Protocol::HTTP::Request) + expect(seen_request).to be_a(Utopia::Request) expect(response.status).to be == 200 expect(response.read).to be == "/hello" diff --git a/test/utopia/content/document.rb b/test/utopia/content/document.rb index e03fbc6..0478be0 100644 --- a/test/utopia/content/document.rb +++ b/test/utopia/content/document.rb @@ -4,12 +4,11 @@ # Copyright, 2017-2025, by Samuel Williams. require "utopia/content/document" -require "protocol/http/request" require "utopia/request" describe Utopia::Content::Document do let(:path) {"/index"} - let(:request) {Protocol::HTTP::Request["GET", path]} + let(:request) {Utopia::Request["GET", path]} let(:document) {subject.new(request, {})} it "should generate valid self-closing markup" do diff --git a/test/utopia/controller/.websocket/server/controller.rb b/test/utopia/controller/.websocket/server/controller.rb index 46ec358..95b5e03 100644 --- a/test/utopia/controller/.websocket/server/controller.rb +++ b/test/utopia/controller/.websocket/server/controller.rb @@ -6,7 +6,7 @@ prepend Actions on 'events' do |request| - upgrade = Async::WebSocket::Adapters::HTTP.open(request) do |connection| + upgrade = Async::WebSocket::Adapters::HTTP.open(request.http) do |connection| connection.write({type: "test", data: "Hello World"}.to_json) end diff --git a/test/utopia/controller/respond.rb b/test/utopia/controller/respond.rb index be98f9f..62ab785 100644 --- a/test/utopia/controller/respond.rb +++ b/test/utopia/controller/respond.rb @@ -4,8 +4,6 @@ # Copyright, 2016-2025, by Samuel Williams. require "json" -require "protocol/http/request" - require "utopia/content" require "utopia/controller" require "utopia/redirection" @@ -37,7 +35,7 @@ def self.uri_path let(:controller) {TestController.new} def mock_request(path, headers = {}) - request = Protocol::HTTP::Request["GET", path, headers] + request = Utopia::Request["GET", path, headers] return request, Utopia::Path[request.path_info] end diff --git a/test/utopia/controller/rewrite.rb b/test/utopia/controller/rewrite.rb index bfdd665..2a8340b 100644 --- a/test/utopia/controller/rewrite.rb +++ b/test/utopia/controller/rewrite.rb @@ -3,7 +3,6 @@ # Released under the MIT License. # Copyright, 2015-2025, by Samuel Williams. -require "protocol/http/request" require "utopia/controller" require "utopia/request" @@ -34,7 +33,7 @@ def self.uri_path let(:controller) {TestController.new} def mock_request(path) - request = Protocol::HTTP::Request["GET", path] + request = Utopia::Request["GET", path] return request, Utopia::Path[request.path_info] end diff --git a/test/utopia/request.rb b/test/utopia/request.rb index e12ee3b..dc95abb 100644 --- a/test/utopia/request.rb +++ b/test/utopia/request.rb @@ -6,9 +6,13 @@ require "protocol/http/request" require "utopia/request" -describe Protocol::HTTP::Request do +describe Utopia::Request do let(:request) {subject["POST", "/search?q=utopia&tag=ruby&tag=async", {"cookie" => "a=1; b=2"}]} + it "wraps a protocol HTTP request" do + expect(request.http).to be_a(Protocol::HTTP::Request) + end + it "provides path information" do expect(request.path_info).to be == "/search" expect(request.query).to be == "q=utopia&tag=ruby&tag=async" From 453c38e41f0e23e7f48e8f6dfe623cfbf8eb1e83 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 25 Jun 2026 14:14:02 +1200 Subject: [PATCH 15/16] Use ambient Utopia request state Assisted-By: devx/166ed168-1c4d-4c63-a5f6-8d0d9cbff13f --- lib/utopia/application.rb | 7 +- lib/utopia/content/document.rb | 3 +- lib/utopia/content/middleware.rb | 3 +- lib/utopia/controller/actions.md | 14 ++-- lib/utopia/controller/middleware.rb | 6 +- lib/utopia/exceptions/handler.rb | 8 +- lib/utopia/exceptions/mailer.rb | 3 +- lib/utopia/localization/middleware.rb | 20 ++++- lib/utopia/redirection.rb | 28 ++++--- lib/utopia/request.rb | 17 ++++ lib/utopia/session/middleware.rb | 3 +- lib/utopia/static/middleware.rb | 3 +- plan.md | 79 +++++++++++-------- test/utopia/application.rb | 34 ++++++-- test/utopia/application_middleware.rb | 4 +- test/utopia/content/document.rb | 11 ++- .../.websocket/server/controller.rb | 2 +- test/utopia/controller/respond.rb | 16 +++- test/utopia/controller/rewrite.rb | 16 +++- test/utopia/exceptions/.handler/controller.rb | 2 +- test/utopia/redirection.rb | 2 +- test/utopia/request.rb | 29 +++++++ test/utopia/session.rb | 16 ++-- 23 files changed, 244 insertions(+), 82 deletions(-) diff --git a/lib/utopia/application.rb b/lib/utopia/application.rb index e277fa5..90a1128 100644 --- a/lib/utopia/application.rb +++ b/lib/utopia/application.rb @@ -81,7 +81,12 @@ def initialize(delegate) # @parameter http_request [Protocol::HTTP::Request] The incoming protocol request. # @returns [Protocol::HTTP::Response] The normalized protocol response. def call(http_request) - return Response.wrap(super(Request.new(http_request))) + previous_request = Request.current + Request.current = Request.new(http_request) + + return Response.wrap(super(http_request)) + ensure + Request.current = previous_request end end end diff --git a/lib/utopia/content/document.rb b/lib/utopia/content/document.rb index 92aa633..5bcce49 100644 --- a/lib/utopia/content/document.rb +++ b/lib/utopia/content/document.rb @@ -7,6 +7,7 @@ require_relative "response" require_relative "markup" require_relative "builder" +require_relative "../request" module Utopia module Content @@ -41,7 +42,7 @@ def initialize(request, attributes = {}) # @returns [Path] The original request path, if known. def request_path - Path[request.request_path] + Path[Utopia::Request.required.request_path] end protected def current_base_uri_path diff --git a/lib/utopia/content/middleware.rb b/lib/utopia/content/middleware.rb index a75bf42..a2bc6a6 100644 --- a/lib/utopia/content/middleware.rb +++ b/lib/utopia/content/middleware.rb @@ -5,6 +5,7 @@ require_relative "../middleware" require_relative "../localization" +require_relative "../request" require_relative "../response" require_relative "../controller/variables" @@ -103,7 +104,7 @@ def respond(link, request) end def call(request) - path = Path.create(request.path_info) + path = Path.create(Utopia::Request.required.path_info) # Check if the request is to a non-specific index. This only works for requests with a given name: basename = path.basename diff --git a/lib/utopia/controller/actions.md b/lib/utopia/controller/actions.md index a5d4b4c..35d4681 100644 --- a/lib/utopia/controller/actions.md +++ b/lib/utopia/controller/actions.md @@ -22,27 +22,29 @@ on "index" do end on "new" do |request| + utopia_request = Utopia::Request.current @user = User.new - if request.post? - @user.update_attributes(request.params["user"]) + if utopia_request.post? + @user.update_attributes(utopia_request.arguments["user"]) redirect! "index" end end on "edit" do |request| - @user = User.find(request.params["id"]) + utopia_request = Utopia::Request.current + @user = User.find(utopia_request.arguments["id"]) - if request.post? - @user.update_attributes(request.params["user"]) + if utopia_request.post? + @user.update_attributes(utopia_request.arguments["user"]) redirect! "index" end end on "delete" do |request| - User.find(request.params["id"]).destroy + User.find(Utopia::Request.current.arguments["id"]).destroy redirect! "index" end diff --git a/lib/utopia/controller/middleware.rb b/lib/utopia/controller/middleware.rb index a3083ed..768c10f 100644 --- a/lib/utopia/controller/middleware.rb +++ b/lib/utopia/controller/middleware.rb @@ -5,6 +5,7 @@ require_relative "../path" require_relative "../middleware" +require_relative "../request" require_relative "variables" require_relative "base" @@ -82,7 +83,8 @@ def load_controller_file(uri_path) # Invoke the controller layer for a given request. The request path may be rewritten. def invoke_controllers(request) - request_path = Path.from_string(request.path_info) + utopia_request = Utopia::Request.required + request_path = Path.from_string(utopia_request.path_info) # The request path must be absolute. We could handle this internally but it is probably better for this to be an error: raise ArgumentError.new("Invalid request path #{request_path}") unless request_path.absolute? @@ -111,7 +113,7 @@ def invoke_controllers(request) end # 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: - request.path_info = controller_path.to_s + utopia_request.path_info = controller_path.to_s # No controller gave a useful result: return nil diff --git a/lib/utopia/exceptions/handler.rb b/lib/utopia/exceptions/handler.rb index 6fff8d3..d176ddb 100644 --- a/lib/utopia/exceptions/handler.rb +++ b/lib/utopia/exceptions/handler.rb @@ -7,6 +7,7 @@ require "console" require_relative "../middleware" +require_relative "../request" require_relative "../response" module Utopia @@ -48,18 +49,21 @@ def call(request) begin # We do an internal redirection to the error location: - error_request = request.with( + error_request = Request.required.with( method: "GET", path_info: @location ) + previous_request = Request.current previous_exception = Exceptions.current begin + Request.current = error_request Exceptions.current = exception - error_response = Response.wrap(@app.call(error_request)) + error_response = Response.wrap(@app.call(error_request.http)) ensure + Request.current = previous_request Exceptions.current = previous_exception end error_response.status = 500 diff --git a/lib/utopia/exceptions/mailer.rb b/lib/utopia/exceptions/mailer.rb index f4cfdec..d87a2e1 100644 --- a/lib/utopia/exceptions/mailer.rb +++ b/lib/utopia/exceptions/mailer.rb @@ -7,6 +7,7 @@ require "mail" require_relative "../middleware" +require_relative "../request" require_relative "../session" require_relative "../controller/variables" require_relative "../localization" @@ -57,7 +58,7 @@ def call(request) begin return @app.call(request) rescue => exception - send_notification exception, request + send_notification exception, Request.required raise end diff --git a/lib/utopia/localization/middleware.rb b/lib/utopia/localization/middleware.rb index 3cdd4cc..794164d 100644 --- a/lib/utopia/localization/middleware.rb +++ b/lib/utopia/localization/middleware.rb @@ -5,6 +5,7 @@ require_relative "wrapper" require_relative "../middleware" +require_relative "../request" require_relative "../response" module Utopia @@ -150,14 +151,25 @@ def vary(request, response) return response end + def call_with_request(request) + previous_request = Request.current + Request.current = request + + return @app.call(request.http) + ensure + Request.current = previous_request + end + def call(request) + utopia_request = Request.required + # Pass the request through if it shouldn't be localized: - return @app.call(request) unless localized?(request) + return @app.call(request) unless localized?(utopia_request) response = nil # We have a non-localized request, but there might be a localized resource. We return the best localization possible: - preferred_locales(request) do |localized_request, locale| + preferred_locales(utopia_request) do |localized_request, locale| # puts "Trying locale: #{locale}: #{localized_request.path_info}..." previous_localization = Localization.current @@ -167,7 +179,7 @@ def call(request) Localization.current = self Localization.current_locale = locale - response = Response.wrap(@app.call(localized_request)) + response = Response.wrap(call_with_request(localized_request)) ensure Localization.current = previous_localization Localization.current_locale = previous_locale @@ -178,7 +190,7 @@ def call(request) response.close if response.respond_to?(:close) end - return vary(request, response) + return vary(utopia_request, response) end end end diff --git a/lib/utopia/redirection.rb b/lib/utopia/redirection.rb index 36ebd8a..f95071b 100644 --- a/lib/utopia/redirection.rb +++ b/lib/utopia/redirection.rb @@ -4,6 +4,7 @@ # Copyright, 2009-2026, by Samuel Williams. require_relative "middleware" +require_relative "request" require_relative "response" module Utopia @@ -46,15 +47,24 @@ def call(request) response = Response.wrap(@app.call(request)) if unhandled_error?(response) && location = @codes[response.status] - error_request = request.with(method: "GET", path_info: location) - error_response = Response.wrap(@app.call(error_request)) + utopia_request = Request.required + error_request = utopia_request.with(method: "GET", path_info: location) - if error_response.status >= 400 - raise RequestFailure.new(request.path_info, response.status, location, error_response.status) - else - # Feed the error code back with the error document: - error_response.status = response.status - return error_response + previous_request = Request.current + Request.current = error_request + + begin + error_response = Response.wrap(@app.call(error_request.http)) + + if error_response.status >= 400 + raise RequestFailure.new(utopia_request.path_info, response.status, location, error_response.status) + else + # Feed the error code back with the error document: + error_response.status = response.status + return error_response + end + ensure + Request.current = previous_request end else return response @@ -109,7 +119,7 @@ def call(request) # Normalize the path to remove redundant slashes, `.` and `..` segments. # This prevents protocol-relative redirect URLs (e.g. //evil.com/index) # from being generated when PATH_INFO contains a double leading slash. - path = Path.create(request.path_info).simplify.to_s + path = Path.create(Request.required.path_info).simplify.to_s if redirection = self[path] return redirection diff --git a/lib/utopia/request.rb b/lib/utopia/request.rb index 9f15764..6d00914 100644 --- a/lib/utopia/request.rb +++ b/lib/utopia/request.rb @@ -16,6 +16,23 @@ module Utopia # The underlying protocol request is available via {#http}; parsing and # application conveniences live here rather than on protocol-http itself. class Request + CURRENT_KEY = :utopia_request + + # The current Utopia request wrapper. + def self.current + Fiber[CURRENT_KEY] + end + + # Assign the current Utopia request wrapper. + def self.current= request + Fiber[CURRENT_KEY] = request + end + + # The current Utopia request wrapper, or raise if none is installed. + def self.required + self.current or raise RuntimeError, "No current Utopia request!" + end + # Build a Utopia request from the given protocol request arguments. def self.[](*arguments) self.new(Protocol::HTTP::Request[*arguments]) diff --git a/lib/utopia/session/middleware.rb b/lib/utopia/session/middleware.rb index 94a2c4e..aea909f 100644 --- a/lib/utopia/session/middleware.rb +++ b/lib/utopia/session/middleware.rb @@ -12,6 +12,7 @@ require_relative "lazy_hash" require_relative "serialization" require_relative "../middleware" +require_relative "../request" require_relative "../response" module Utopia @@ -94,7 +95,7 @@ def freeze end def call(request) - session_hash = prepare_session(request) + session_hash = prepare_session(Utopia::Request.required) previous_session = Session.current Session.current = session_hash diff --git a/lib/utopia/static/middleware.rb b/lib/utopia/static/middleware.rb index 6acf8ce..9a92db4 100644 --- a/lib/utopia/static/middleware.rb +++ b/lib/utopia/static/middleware.rb @@ -5,6 +5,7 @@ require_relative "../middleware" require_relative "../localization" +require_relative "../request" require_relative "../response" require_relative "local_file" @@ -94,7 +95,7 @@ def respond(request, path_info, extension) end def call(request) - path_info = request.path_info + path_info = Utopia::Request.required.path_info extension = File.extname(path_info) if @extensions.key?(extension.downcase) diff --git a/plan.md b/plan.md index 7b84ced..fc82d4e 100644 --- a/plan.md +++ b/plan.md @@ -20,16 +20,15 @@ The proposed stack is: ```text Protocol::HTTP::Request -> Utopia::Application - -> Utopia::Request -> Utopia middleware/controllers/content -> Utopia::Response or Protocol::HTTP::Response shaped value -> Protocol::HTTP::Response ``` `Utopia::Application` is the lifecycle boundary. It receives -`Protocol::HTTP::Request`, adapts it to `Utopia::Request`, dispatches ordinary -Utopia middleware, and normalizes the response. The request itself flows -explicitly down the middleware chain; it is not ambient fiber state. +`Protocol::HTTP::Request`, explicitly constructs a `Utopia::Request` wrapper for +ambient application-facing request state, dispatches ordinary Utopia middleware +with the original protocol request, and normalizes the response. ## Application @@ -147,23 +146,33 @@ end ## Request And State -Introduce a separate `Utopia::Request` wrapper in the core stack. Utopia -middleware should receive `Utopia::Request`, while the original -`Protocol::HTTP::Request` remains available as `request.http` for integrations -that need the transport-level object. +Introduce a separate `Utopia::Request` wrapper in the core stack, but do not make +it the middleware request argument. Utopia middleware, controllers, and terminal +apps should continue to receive the normal `Protocol::HTTP::Request`. + +`Utopia::Application` should explicitly construct `Utopia::Request` at the start +of each request and assign it to `Utopia::Request.current`. The wrapper provides +richer, cached access to the protocol request while keeping the protocol request +argument available for middleware composition, upgrades, streaming, and +transport-level integrations. Likely shape: ```text -request.method -request.path -request.path_info -request.path_info= -request.query -request.headers -request.cookies -request.body -request.arguments +Utopia::Request.current +Utopia::Request.current = request +Utopia::Request.required + +utopia_request.http +utopia_request.method +utopia_request.path +utopia_request.path_info +utopia_request.path_info= +utopia_request.query +utopia_request.headers +utopia_request.cookies +utopia_request.body +utopia_request.arguments ``` Guidelines: @@ -174,17 +183,19 @@ Guidelines: - Keep query, form, JSON, and multipart parsing separable where possible. - Do not monkey patch `Protocol::HTTP::Request`; Utopia-specific convenience methods belong on `Utopia::Request`. -- Keep the request explicit. Do not expose ambient `Utopia.request` style state. +- Keep the protocol request explicit as the middleware argument. +- Do not expose generic ambient `Utopia.request` style state; use + `Utopia::Request.current` for this specific parsed request view. - Use Utopia-owned fiber state for optional adjacent application state rather than Rack-style `env` or a Utopia request attribute hash. Possible arguments shape: ```text -request.arguments.query -request.arguments.form -request.arguments.json -request.arguments.multipart +utopia_request.arguments.query +utopia_request.arguments.form +utopia_request.arguments.json +utopia_request.arguments.multipart ``` Framework state should be exposed through named Utopia APIs: @@ -193,6 +204,7 @@ Framework state should be exposed through named Utopia APIs: Utopia::Session.current Utopia::Session[:user_id] Utopia::Session[:user_id] = 10 +Utopia::Request.current Utopia::Controller.current Utopia::Localization.current ``` @@ -201,6 +213,7 @@ The implementation can store this directly in fiber storage: ```text Fiber[:utopia_session] +Fiber[:utopia_request] Fiber[:utopia_variables] Fiber[:utopia_current_locale] ``` @@ -247,15 +260,16 @@ Utopia middleware should use the protocol-http middleware shape: ```text initialize(delegate, ...) -call(Utopia::Request) -> response-like value +call(Protocol::HTTP::Request) -> response-like value ``` Low-level protocol behavior, tracing, compression, authority policy, early -routing, static transport optimizations, protocol upgrades, sessions, -localization, content negotiation, controller variables, CSRF, authentication, and -other framework-specific semantics can all be expressed in that shape. Utopia owns -the compatibility of its middleware APIs and the request-local state helpers they -use. +routing, static transport optimizations, and protocol upgrades can use the +protocol request argument directly. Framework-specific semantics such as sessions, +localization, content negotiation, controller variables, CSRF, and authentication +can use Utopia-owned ambient state APIs when they need richer parsed request +state. Utopia owns the compatibility of its middleware APIs and the request-local +state helpers they use. The regular Utopia DSL should compose application middleware: @@ -271,7 +285,7 @@ Utopia owns what `use` and `run` mean for middleware. Terminal apps should satisfy: ```text -call(Utopia::Request) -> response-like value +call(Protocol::HTTP::Request) -> response-like value ``` `Utopia::Application.build` can decide compatibility details such as: @@ -280,7 +294,8 @@ call(Utopia::Request) -> response-like value - whether `run Utopia::Content, root: ...` instantiates the app automatically. - whether `close` is propagated through the stack. - whether middleware may return `nil` to pass through. -- whether middleware may mutate `request.path_info`. +- whether middleware may derive a new `Utopia::Request.current` and pass the + derived protocol request downstream for internal rewrites. Do not try to preserve Rack middleware compatibility in the core Utopia stack. @@ -351,8 +366,8 @@ Expected breaking changes: - Core Utopia middleware no longer receives Rack env hashes. - Controllers no longer receive `Rack::Request`. -- Core Utopia middleware receives `Utopia::Request`, not raw - `Protocol::HTTP::Request`. +- Core Utopia middleware receives `Protocol::HTTP::Request`; parsed Utopia + request helpers move to `Utopia::Request.current`. - `env[...]`, `rack.session`, `rack.input`, and Rack response tuple assumptions need migration. - Static file serving should move away from `Rack::Sendfile` and Rack range diff --git a/test/utopia/application.rb b/test/utopia/application.rb index 7d94b2c..436aa55 100644 --- a/test/utopia/application.rb +++ b/test/utopia/application.rb @@ -23,16 +23,40 @@ response = application.call(http_request) - expect(application_request).to be_a(Utopia::Request) - expect(application_request.http).to be_equal(http_request) - expect(application_request.path_info).to be == "/hello" - expect(application_request.query).to be == "name=sam" + expect(application_request).to be_equal(http_request) expect(response).to be_a(Protocol::HTTP::Response) expect(response.status).to be == 200 expect(response.headers["content-type"]).to be == "text/plain; charset=utf-8" end + it "installs ambient Utopia request state" do + utopia_request = nil + previous_request = Object.new + + application = subject.build do + run lambda{|request| + utopia_request = Utopia::Request.current + + Utopia::Response.text(utopia_request.path_info) + } + end + + Utopia::Request.current = previous_request + + begin + response = application.call(http_request) + + expect(utopia_request).to be_a(Utopia::Request) + expect(utopia_request.http).to be_equal(http_request) + expect(utopia_request.query).to be == "name=sam" + expect(response.read).to be == "/hello" + expect(Utopia::Request.current).to be_equal(previous_request) + ensure + Utopia::Request.current = nil + end + end + it "normalizes protocol response objects" do response_object = Object.new @@ -68,7 +92,7 @@ def response_object.to_protocol_response require "utopia/application" Application = Utopia::Application.build do - run lambda{|request| Utopia::Response.text(request.path_info)} + run lambda{|request| Utopia::Response.text(Utopia::Request.current.path_info)} end RUBY diff --git a/test/utopia/application_middleware.rb b/test/utopia/application_middleware.rb index c121979..7edd1db 100644 --- a/test/utopia/application_middleware.rb +++ b/test/utopia/application_middleware.rb @@ -24,13 +24,13 @@ def request(path, headers: nil) run lambda{|request| seen_request = request - Utopia::Response.text(request.path_info) + Utopia::Response.text(Utopia::Request.current.path_info) } end response = application.call(request("/hello")) - expect(seen_request).to be_a(Utopia::Request) + expect(seen_request).to be_a(Protocol::HTTP::Request) expect(response.status).to be == 200 expect(response.read).to be == "/hello" diff --git a/test/utopia/content/document.rb b/test/utopia/content/document.rb index 0478be0..cba8d61 100644 --- a/test/utopia/content/document.rb +++ b/test/utopia/content/document.rb @@ -9,7 +9,16 @@ describe Utopia::Content::Document do let(:path) {"/index"} let(:request) {Utopia::Request["GET", path]} - let(:document) {subject.new(request, {})} + let(:document) {subject.new(request.http, {})} + + def around + previous_request = Utopia::Request.current + Utopia::Request.current = request + + super + ensure + Utopia::Request.current = previous_request + end it "should generate valid self-closing markup" do node = proc do |document, state| diff --git a/test/utopia/controller/.websocket/server/controller.rb b/test/utopia/controller/.websocket/server/controller.rb index 95b5e03..46ec358 100644 --- a/test/utopia/controller/.websocket/server/controller.rb +++ b/test/utopia/controller/.websocket/server/controller.rb @@ -6,7 +6,7 @@ prepend Actions on 'events' do |request| - upgrade = Async::WebSocket::Adapters::HTTP.open(request.http) do |connection| + upgrade = Async::WebSocket::Adapters::HTTP.open(request) do |connection| connection.write({type: "test", data: "Hello World"}.to_json) end diff --git a/test/utopia/controller/respond.rb b/test/utopia/controller/respond.rb index 62ab785..8728307 100644 --- a/test/utopia/controller/respond.rb +++ b/test/utopia/controller/respond.rb @@ -33,10 +33,22 @@ def self.uri_path end let(:controller) {TestController.new} + let(:utopia_request) {Utopia::Request["GET", "/fetch"]} + + def around + previous_request = Utopia::Request.current + Utopia::Request.current = utopia_request + + super + ensure + Utopia::Request.current = previous_request + end def mock_request(path, headers = {}) - request = Utopia::Request["GET", path, headers] - return request, Utopia::Path[request.path_info] + utopia_request = Utopia::Request["GET", path, headers] + Utopia::Request.current = utopia_request + + return utopia_request.http, Utopia::Path[utopia_request.path_info] end it "should serialize response as JSON" do diff --git a/test/utopia/controller/rewrite.rb b/test/utopia/controller/rewrite.rb index 2a8340b..23c3678 100644 --- a/test/utopia/controller/rewrite.rb +++ b/test/utopia/controller/rewrite.rb @@ -31,10 +31,22 @@ def self.uri_path end let(:controller) {TestController.new} + let(:utopia_request) {Utopia::Request["GET", "/"]} + + def around + previous_request = Utopia::Request.current + Utopia::Request.current = utopia_request + + super + ensure + Utopia::Request.current = previous_request + end def mock_request(path) - request = Utopia::Request["GET", path] - return request, Utopia::Path[request.path_info] + utopia_request = Utopia::Request["GET", path] + Utopia::Request.current = utopia_request + + return utopia_request.http, Utopia::Path[utopia_request.path_info] end it "should match path prefix and extract parameters" do diff --git a/test/utopia/exceptions/.handler/controller.rb b/test/utopia/exceptions/.handler/controller.rb index e3174d1..83797df 100644 --- a/test/utopia/exceptions/.handler/controller.rb +++ b/test/utopia/exceptions/.handler/controller.rb @@ -14,7 +14,7 @@ class TharSheBlows < StandardError # The ExceptionHandler middleware will redirect here when an exception occurs. If this also fails, things get ugly. on 'exception' do |request| - if request.params['fatal'] + if Utopia::Request.current.arguments['fatal'] raise TharSheBlows.new("Yarrh!") else succeed! :content => 'Error Will Robertson', :type => 'text/plain' diff --git a/test/utopia/redirection.rb b/test/utopia/redirection.rb index fc89922..56d19ab 100644 --- a/test/utopia/redirection.rb +++ b/test/utopia/redirection.rb @@ -11,7 +11,7 @@ let(:app) do Utopia::Application.build(lambda{|request| - case request.path_info + case Utopia::Request.current.path_info when "/error" Utopia::Response.text("File not found :(", 200) when "/teapot" diff --git a/test/utopia/request.rb b/test/utopia/request.rb index dc95abb..e604f87 100644 --- a/test/utopia/request.rb +++ b/test/utopia/request.rb @@ -13,6 +13,35 @@ expect(request.http).to be_a(Protocol::HTTP::Request) end + it "provides ambient request state" do + previous_request = subject.current + + begin + subject.current = request + + expect(subject.current).to be_equal(request) + expect(subject.required).to be_equal(request) + ensure + subject.current = previous_request + end + end + + it "inherits ambient request state into nested fibers" do + previous_request = subject.current + + begin + subject.current = request + + fiber = Fiber.new do + subject.current + end + + expect(fiber.resume).to be_equal(request) + ensure + subject.current = previous_request + end + end + it "provides path information" do expect(request.path_info).to be == "/search" expect(request.query).to be == "q=utopia&tag=ruby&tag=async" diff --git a/test/utopia/session.rb b/test/utopia/session.rb index d841c39..2826ae2 100755 --- a/test/utopia/session.rb +++ b/test/utopia/session.rb @@ -12,17 +12,19 @@ let(:app) do Utopia::Application.build(lambda{|request| - case request.path_info + utopia_request = Utopia::Request.current + + case utopia_request.path_info when "/login" Utopia::Session["login"] = "true" Utopia::Response[200, {}, []] when "/session-set" - Utopia::Session[request.arguments["key"].to_sym] = request.arguments["value"] + Utopia::Session[utopia_request.arguments["key"].to_sym] = utopia_request.arguments["value"] Utopia::Response[200, {}, []] when "/session-get" - Utopia::Response[200, {}, [Utopia::Session[request.arguments["key"].to_sym]]] + Utopia::Response[200, {}, [Utopia::Session[utopia_request.arguments["key"].to_sym]]] else Utopia::Response[404, {}, []] end @@ -86,13 +88,15 @@ let(:app) do Utopia::Application.build(lambda{|request| - case request.path_info + utopia_request = Utopia::Request.current + + case utopia_request.path_info when "/session-set" - Utopia::Session[request.arguments["key"].to_sym] = request.arguments["value"] + Utopia::Session[utopia_request.arguments["key"].to_sym] = utopia_request.arguments["value"] Utopia::Response[200, {}, []] when "/session-get" - Utopia::Response[200, {}, [Utopia::Session[request.arguments["key"].to_sym]]] + Utopia::Response[200, {}, [Utopia::Session[utopia_request.arguments["key"].to_sym]]] else Utopia::Response[404, {}, []] end From c28e7bc782167de2454a6e2bf525ab9c03a839bb Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 25 Jun 2026 14:19:11 +1200 Subject: [PATCH 16/16] Use current bang for required ambient state Assisted-By: devx/166ed168-1c4d-4c63-a5f6-8d0d9cbff13f --- lib/utopia/content/document.rb | 2 +- lib/utopia/content/middleware.rb | 2 +- lib/utopia/controller/actions.md | 6 +++--- lib/utopia/controller/middleware.rb | 4 ++-- lib/utopia/controller/variables.rb | 4 ++++ lib/utopia/exceptions/handler.rb | 2 +- lib/utopia/exceptions/mailer.rb | 2 +- lib/utopia/localization/middleware.rb | 2 +- lib/utopia/redirection.rb | 4 ++-- lib/utopia/request.rb | 2 +- lib/utopia/session.rb | 8 ++++---- lib/utopia/session/middleware.rb | 2 +- lib/utopia/static/middleware.rb | 2 +- plan.md | 4 +++- test/utopia/application.rb | 4 ++-- test/utopia/application_middleware.rb | 2 +- test/utopia/controller/variables.rb | 7 +++++++ test/utopia/redirection.rb | 2 +- test/utopia/request.rb | 2 +- test/utopia/session.rb | 10 ++++++++-- 20 files changed, 46 insertions(+), 27 deletions(-) diff --git a/lib/utopia/content/document.rb b/lib/utopia/content/document.rb index 5bcce49..d80ebeb 100644 --- a/lib/utopia/content/document.rb +++ b/lib/utopia/content/document.rb @@ -42,7 +42,7 @@ def initialize(request, attributes = {}) # @returns [Path] The original request path, if known. def request_path - Path[Utopia::Request.required.request_path] + Path[Utopia::Request.current!.request_path] end protected def current_base_uri_path diff --git a/lib/utopia/content/middleware.rb b/lib/utopia/content/middleware.rb index a2bc6a6..6c2afda 100644 --- a/lib/utopia/content/middleware.rb +++ b/lib/utopia/content/middleware.rb @@ -104,7 +104,7 @@ def respond(link, request) end def call(request) - path = Path.create(Utopia::Request.required.path_info) + path = Path.create(Utopia::Request.current!.path_info) # Check if the request is to a non-specific index. This only works for requests with a given name: basename = path.basename diff --git a/lib/utopia/controller/actions.md b/lib/utopia/controller/actions.md index 35d4681..b33c627 100644 --- a/lib/utopia/controller/actions.md +++ b/lib/utopia/controller/actions.md @@ -22,7 +22,7 @@ on "index" do end on "new" do |request| - utopia_request = Utopia::Request.current + utopia_request = Utopia::Request.current! @user = User.new if utopia_request.post? @@ -33,7 +33,7 @@ on "new" do |request| end on "edit" do |request| - utopia_request = Utopia::Request.current + utopia_request = Utopia::Request.current! @user = User.find(utopia_request.arguments["id"]) if utopia_request.post? @@ -44,7 +44,7 @@ on "edit" do |request| end on "delete" do |request| - User.find(Utopia::Request.current.arguments["id"]).destroy + User.find(Utopia::Request.current!.arguments["id"]).destroy redirect! "index" end diff --git a/lib/utopia/controller/middleware.rb b/lib/utopia/controller/middleware.rb index 768c10f..739ea46 100644 --- a/lib/utopia/controller/middleware.rb +++ b/lib/utopia/controller/middleware.rb @@ -83,7 +83,7 @@ def load_controller_file(uri_path) # Invoke the controller layer for a given request. The request path may be rewritten. def invoke_controllers(request) - utopia_request = Utopia::Request.required + utopia_request = Utopia::Request.current! request_path = Path.from_string(utopia_request.path_info) # The request path must be absolute. We could handle this internally but it is probably better for this to be an error: @@ -93,7 +93,7 @@ def invoke_controllers(request) controller_path = Path.new # Controller instance variables which eventually get processed by the view: - variables = Controller.current + variables = Controller.current! while request_path.components.any? # 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. diff --git a/lib/utopia/controller/variables.rb b/lib/utopia/controller/variables.rb index b256ac4..4b2a3e5 100644 --- a/lib/utopia/controller/variables.rb +++ b/lib/utopia/controller/variables.rb @@ -74,6 +74,10 @@ def self.current= variables Fiber[CURRENT_KEY] = variables end + def self.current! + self.current or raise RuntimeError, "No current Utopia controller variables!" + end + def self.[] request = nil self.current end diff --git a/lib/utopia/exceptions/handler.rb b/lib/utopia/exceptions/handler.rb index d176ddb..de659c5 100644 --- a/lib/utopia/exceptions/handler.rb +++ b/lib/utopia/exceptions/handler.rb @@ -49,7 +49,7 @@ def call(request) begin # We do an internal redirection to the error location: - error_request = Request.required.with( + error_request = Request.current!.with( method: "GET", path_info: @location ) diff --git a/lib/utopia/exceptions/mailer.rb b/lib/utopia/exceptions/mailer.rb index d87a2e1..e3e5071 100644 --- a/lib/utopia/exceptions/mailer.rb +++ b/lib/utopia/exceptions/mailer.rb @@ -58,7 +58,7 @@ def call(request) begin return @app.call(request) rescue => exception - send_notification exception, Request.required + send_notification exception, Request.current! raise end diff --git a/lib/utopia/localization/middleware.rb b/lib/utopia/localization/middleware.rb index 794164d..1ea3911 100644 --- a/lib/utopia/localization/middleware.rb +++ b/lib/utopia/localization/middleware.rb @@ -161,7 +161,7 @@ def call_with_request(request) end def call(request) - utopia_request = Request.required + utopia_request = Request.current! # Pass the request through if it shouldn't be localized: return @app.call(request) unless localized?(utopia_request) diff --git a/lib/utopia/redirection.rb b/lib/utopia/redirection.rb index f95071b..569ddb2 100644 --- a/lib/utopia/redirection.rb +++ b/lib/utopia/redirection.rb @@ -47,7 +47,7 @@ def call(request) response = Response.wrap(@app.call(request)) if unhandled_error?(response) && location = @codes[response.status] - utopia_request = Request.required + utopia_request = Request.current! error_request = utopia_request.with(method: "GET", path_info: location) previous_request = Request.current @@ -119,7 +119,7 @@ def call(request) # Normalize the path to remove redundant slashes, `.` and `..` segments. # This prevents protocol-relative redirect URLs (e.g. //evil.com/index) # from being generated when PATH_INFO contains a double leading slash. - path = Path.create(Request.required.path_info).simplify.to_s + path = Path.create(Request.current!.path_info).simplify.to_s if redirection = self[path] return redirection diff --git a/lib/utopia/request.rb b/lib/utopia/request.rb index 6d00914..4a5317e 100644 --- a/lib/utopia/request.rb +++ b/lib/utopia/request.rb @@ -29,7 +29,7 @@ def self.current= request end # The current Utopia request wrapper, or raise if none is installed. - def self.required + def self.current! self.current or raise RuntimeError, "No current Utopia request!" end diff --git a/lib/utopia/session.rb b/lib/utopia/session.rb index 2a8f81d..fff54c1 100644 --- a/lib/utopia/session.rb +++ b/lib/utopia/session.rb @@ -39,23 +39,23 @@ def self.current= session end # The current session, or raise a clear error if sessions are unavailable. - def self.required + def self.current! self.current or raise MissingError, "No current Utopia session!" end # Fetch a value from the current session. def self.[] key - self.required[key] + self.current![key] end # Assign a value in the current session. def self.[]= key, value - self.required[key] = value + self.current![key] = value end # Delete a value from the current session. def self.delete(key) - self.required.delete(key) + self.current!.delete(key) end end end diff --git a/lib/utopia/session/middleware.rb b/lib/utopia/session/middleware.rb index aea909f..2888929 100644 --- a/lib/utopia/session/middleware.rb +++ b/lib/utopia/session/middleware.rb @@ -95,7 +95,7 @@ def freeze end def call(request) - session_hash = prepare_session(Utopia::Request.required) + session_hash = prepare_session(Utopia::Request.current!) previous_session = Session.current Session.current = session_hash diff --git a/lib/utopia/static/middleware.rb b/lib/utopia/static/middleware.rb index 9a92db4..7610452 100644 --- a/lib/utopia/static/middleware.rb +++ b/lib/utopia/static/middleware.rb @@ -95,7 +95,7 @@ def respond(request, path_info, extension) end def call(request) - path_info = Utopia::Request.required.path_info + path_info = Utopia::Request.current!.path_info extension = File.extname(path_info) if @extensions.key?(extension.downcase) diff --git a/plan.md b/plan.md index fc82d4e..c85e153 100644 --- a/plan.md +++ b/plan.md @@ -161,7 +161,7 @@ Likely shape: ```text Utopia::Request.current Utopia::Request.current = request -Utopia::Request.required +Utopia::Request.current! utopia_request.http utopia_request.method @@ -202,10 +202,12 @@ Framework state should be exposed through named Utopia APIs: ```text Utopia::Session.current +Utopia::Session.current! Utopia::Session[:user_id] Utopia::Session[:user_id] = 10 Utopia::Request.current Utopia::Controller.current +Utopia::Controller.current! Utopia::Localization.current ``` diff --git a/test/utopia/application.rb b/test/utopia/application.rb index 436aa55..9363e54 100644 --- a/test/utopia/application.rb +++ b/test/utopia/application.rb @@ -36,7 +36,7 @@ application = subject.build do run lambda{|request| - utopia_request = Utopia::Request.current + utopia_request = Utopia::Request.current! Utopia::Response.text(utopia_request.path_info) } @@ -92,7 +92,7 @@ def response_object.to_protocol_response require "utopia/application" Application = Utopia::Application.build do - run lambda{|request| Utopia::Response.text(Utopia::Request.current.path_info)} + run lambda{|request| Utopia::Response.text(Utopia::Request.current!.path_info)} end RUBY diff --git a/test/utopia/application_middleware.rb b/test/utopia/application_middleware.rb index 7edd1db..2e9a846 100644 --- a/test/utopia/application_middleware.rb +++ b/test/utopia/application_middleware.rb @@ -24,7 +24,7 @@ def request(path, headers: nil) run lambda{|request| seen_request = request - Utopia::Response.text(Utopia::Request.current.path_info) + Utopia::Response.text(Utopia::Request.current!.path_info) } end diff --git a/test/utopia/controller/variables.rb b/test/utopia/controller/variables.rb index 282c99e..3088be8 100644 --- a/test/utopia/controller/variables.rb +++ b/test/utopia/controller/variables.rb @@ -53,10 +53,17 @@ def copy_instance_variables(from) Utopia::Controller.current = variables expect(Utopia::Controller.current).to be == variables + expect(Utopia::Controller.current!).to be == variables end it "returns nil when variables are not set" do expect(Utopia::Controller.current).to be_nil end + + it "raises when variables are required but not set" do + expect do + Utopia::Controller.current! + end.to raise_exception(RuntimeError, message: be =~ /No current Utopia controller variables/) + end end end diff --git a/test/utopia/redirection.rb b/test/utopia/redirection.rb index 56d19ab..a374c35 100644 --- a/test/utopia/redirection.rb +++ b/test/utopia/redirection.rb @@ -11,7 +11,7 @@ let(:app) do Utopia::Application.build(lambda{|request| - case Utopia::Request.current.path_info + case Utopia::Request.current!.path_info when "/error" Utopia::Response.text("File not found :(", 200) when "/teapot" diff --git a/test/utopia/request.rb b/test/utopia/request.rb index e604f87..68e3c0a 100644 --- a/test/utopia/request.rb +++ b/test/utopia/request.rb @@ -20,7 +20,7 @@ subject.current = request expect(subject.current).to be_equal(request) - expect(subject.required).to be_equal(request) + expect(subject.current!).to be_equal(request) ensure subject.current = previous_request end diff --git a/test/utopia/session.rb b/test/utopia/session.rb index 2826ae2..ea34423 100755 --- a/test/utopia/session.rb +++ b/test/utopia/session.rb @@ -12,7 +12,7 @@ let(:app) do Utopia::Application.build(lambda{|request| - utopia_request = Utopia::Request.current + utopia_request = Utopia::Request.current! case utopia_request.path_info when "/login" @@ -81,6 +81,12 @@ get "/session-set?key=foo&value=bar" expect(last_response.headers).to have_keys("set-cookie") end + + it "raises when the session is required but unavailable" do + expect do + Utopia::Session.current! + end.to raise_exception(Utopia::Session::MissingError, message: be =~ /No current Utopia session/) + end end describe Utopia::Session do @@ -88,7 +94,7 @@ let(:app) do Utopia::Application.build(lambda{|request| - utopia_request = Utopia::Request.current + utopia_request = Utopia::Request.current! case utopia_request.path_info when "/session-set"