diff --git a/bake/utopia/site.rb b/bake/utopia/site.rb index 4723bbe6..9999fb82 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 5d38b3a3..3ca51f2f 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/config/external.yaml b/config/external.yaml index 393983fe..4db608ff 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 8290da91..92d437b4 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 dda2787f..733daf01 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 97ff8b40..c7b50ec8 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/gems.rb b/gems.rb index 3930cda9..74f50da9 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/guides/getting-started/readme.md b/guides/getting-started/readme.md index 8290da91..92d437b4 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 97ff8b40..c7b50ec8 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/lib/utopia.rb b/lib/utopia.rb index e19499f6..b1012765 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 00000000..aa4322cf --- /dev/null +++ b/lib/utopia/application.rb @@ -0,0 +1,89 @@ +# 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 + + # 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 block [Proc] The middleware builder block. + # @returns [Application] The protocol-facing Utopia application. + def self.build(default_app = Response::NotFound, &block) + builder = Protocol::HTTP::Middleware::Builder.new(default_app) + + if block + if block.arity.zero? + builder.instance_exec(&block) + else + block.call(builder) + end + end + + return self.new(builder.to_app) + end + + # Build the default Utopia application. + # @returns [Application] The default protocol-facing Utopia application. + def self.default + self.build + end + + # Load a Utopia application from a conventional configuration file. + # + # 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. + # + # @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) + top = Module.new + top.class_eval(File.read(path), path) + + if top.const_defined?(:Application, false) + application = top.const_get(:Application) + + if application.is_a?(Class) + return application.new(**options) + else + return application + end + end + end + + return self.default + end + + # Initialize the protocol-facing application boundary. + # @parameter delegate [Interface(:call)] The Utopia application stack. + def initialize(delegate) + super(delegate) + end + + # 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.new(http_request) + + return Response.wrap(super(request)) + end + end +end diff --git a/lib/utopia/content/document.rb b/lib/utopia/content/document.rb index d222ddfc..cfd29ca0 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 78406960..c242e9bc 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,15 @@ 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) 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 +112,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::Response[307, {HTTP::LOCATION => path.dirname.join(index_path).to_s}, []] 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 end end - return @app.call(env) + return @app.call(request) end private diff --git a/lib/utopia/content/node.rb b/lib/utopia/content/node.rb index 8a743bf8..377de20b 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 7d6dbccd..ac4af7c0 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 3ca02b6f..ec093f52 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 18d7c0fc..74659cd5 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,20 @@ 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 - - request = Rack::Request.new(env) + def call(request) + request[VARIABLES_KEY] ||= Variables.new if result = invoke_controllers(request) return result end - return @app.call(env) + return @app.call(request) end end end diff --git a/lib/utopia/controller/respond.rb b/lib/utopia/controller/respond.rb index e0aa6d6b..0d7c6be6 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 0a2f6ae7..eb727915 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 ebee07d7..7f43a787 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 1cf98c93..1aa4380a 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,28 @@ def freeze super end - def call(env) + def call(request) begin - return @app.call(env) + return @app.call(request) 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 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 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 a06fd629..787b7ad0 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. @@ -24,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 @@ -47,11 +49,11 @@ def freeze super end - def call(env) + def call(request) begin - return @app.call(env) + return @app.call(request) rescue => exception - send_notification exception, env + send_notification exception, request raise end @@ -95,15 +97,12 @@ 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 embed the request body if it's textual? # TODO dump and embed `utopia.variables`? io.puts @@ -113,21 +112,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 +142,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 +150,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 +182,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/http.rb b/lib/utopia/http.rb index e8d49ca4..a78929da 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 ce2ae882..4eefab36 100644 --- a/lib/utopia/localization/middleware.rb +++ b/lib/utopia/localization/middleware.rb @@ -4,11 +4,13 @@ # Copyright, 2025-2026, by Samuel Williams. require_relative "wrapper" +require_relative "../middleware" +require_relative "../response" module Utopia module Localization class Middleware - RESOURCE_NOT_FOUND = [400, {}, []].freeze + RESOURCE_NOT_FOUND = Response[400, {}, []].freeze HTTP_ACCEPT_LANGUAGE = "HTTP_ACCEPT_LANGUAGE".freeze @@ -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,51 @@ 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) # Pass the request through if it shouldn't be localized: - return @app.call(env) unless localized?(env) + return @app.call(request) 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 vary(request, response) end end end diff --git a/lib/utopia/localization/wrapper.rb b/lib/utopia/localization/wrapper.rb index d081234b..a9e0c5e9 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 79e4a583..088c8720 100644 --- a/lib/utopia/middleware.rb +++ b/lib/utopia/middleware.rb @@ -5,7 +5,6 @@ require_relative "http" require_relative "path" - module Utopia # 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 28448f3d..36ebd8aa 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,21 +39,21 @@ 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) + response = Response.wrap(@app.call(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) + 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] + error_response.status = response.status return error_response end else @@ -97,24 +98,24 @@ 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) # 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 end - return @app.call(env) + return @app.call(request) end end diff --git a/lib/utopia/request.rb b/lib/utopia/request.rb new file mode 100644 index 00000000..c3bfffe3 --- /dev/null +++ b/lib/utopia/request.rb @@ -0,0 +1,321 @@ +# frozen_string_literal: true + +# 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. + # + # 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}!" + 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 + 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 + @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 + 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 + "" + 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 + + if !base_url.empty? + "#{base_url}#{self.path}" + else + self.path + 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 + + 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 new file mode 100644 index 00000000..926caab0 --- /dev/null +++ b/lib/utopia/response.rb @@ -0,0 +1,74 @@ +# 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 + + # 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 + response + else + if response.respond_to?(:to_protocol_response) + response.to_protocol_response + else + response + end + 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 + end +end diff --git a/lib/utopia/session/middleware.rb b/lib/utopia/session/middleware.rb index 565ec989..0ad6ebfc 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,7 +25,7 @@ class PayloadError < StandardError SECRET_KEY = "UTOPIA_SESSION_SECRET".freeze - RACK_SESSION = "rack.session".freeze + SESSION_KEY = "utopia.session".freeze CIPHER_ALGORITHM = "aes-256-cbc" # The session will expire if no requests were made within 24 hours: @@ -35,8 +38,8 @@ 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!`. - 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) + # @param options [Hash] Additional defaults used for generating the session cookie. + 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 @@ -90,25 +93,27 @@ def freeze super end - def call(env) - session_hash = prepare_session(env) + def call(request) + session_hash = prepare_session(request) - status, headers, body = @app.call(env) + response = Response.wrap(@app.call(request)) - update_session(env, session_hash, headers) + update_session(session_hash, response.headers) - return [status, headers, body] + return response 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 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 +136,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 +180,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/shell.rb b/lib/utopia/shell.rb index eb4c12e8..9bb50e7b 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 684c53b5..a6c4507f 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 @@ -24,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 @@ -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 0d07c3b9..29790e27 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,41 @@ 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) + path_info = request.path_info extension = File.extname(path_info) if @extensions.key?(extension.downcase) - if response = self.respond(env, path_info, extension) + if response = self.respond(request, path_info, extension) return response end end # else if no file was found: - return @app.call(env) + return @app.call(request) 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/plan.md b/plan.md new file mode 100644 index 00000000..28324564 --- /dev/null +++ b/plan.md @@ -0,0 +1,343 @@ +# 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: + +```text +application = Utopia::Application.load +application.call(protocol_http_request) +application.close +``` + +Construction should keep object construction separate from DSL configuration: + +```text +Application = Utopia::Application.build do + use Utopia::Static, root: "public" + use Utopia::Controller + run Utopia::Content +end +``` + +Preferred API: + +```text +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: + +```text +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: + +```text +require_relative "config/application" + +service "utopia" do + include Falcon::Environment::Server + + def middleware + Application + end +end +``` + +Generic/default configuration: + +```text +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: + +```text +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: + +```text +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: + +```text +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: + +```text +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 `close` is propagated through the stack. +- whether middleware may return `nil` to pass through. +- whether middleware may mutate `request.path_info`. + +Do not try to preserve Rack middleware compatibility in the core Utopia stack. + +## 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: + +```text +Utopia::Project.call(builder) +``` + +to returning a protocol-compatible middleware: + +```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 +end +``` + +Consumers can then choose: + +```text +Application = Utopia::Project.application +``` + +or: + +```text +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` 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: + +- 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. +- Start normalizing response values internally. diff --git a/setup/site/bake.rb b/setup/site/bake.rb index ad407c9e..4f58c9a3 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 e60b0f5b..00000000 --- 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 00000000..1d0af6b4 --- /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 2eeba077..6075d9f5 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 a6e37d6b..85f200dd 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 b6aa6728..da2f1220 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 43afc245..a0bc1898 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 c60b4c17..91bbc44a 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 11568e7c..00000000 --- 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 00000000..46c0f513 --- /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 43afc245..a0bc1898 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 new file mode 100644 index 00000000..865dd11a --- /dev/null +++ b/test/utopia/application.rb @@ -0,0 +1,98 @@ +# 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 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| response_object} + end + + response = application.call(http_request) + + expect(response).to be_a(Protocol::HTTP::Response) + expect(response.status).to be == 201 + expect(response.read).to be == "Created" + 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" + 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 diff --git a/test/utopia/application_middleware.rb b/test/utopia/application_middleware.rb new file mode 100644 index 00000000..4e5cc22f --- /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 diff --git a/test/utopia/command.rb b/test/utopia/command.rb index 59de189e..96c9ecd8 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 a140f051..6afbf20c 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 43d093fb..00000000 --- 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 8cf17c4d..36b7c0b3 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 569b3476..7d311985 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 2460ed23..95b5e03c 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 db4613bc..637d386b 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 08f61ae8..00000000 --- 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 9d9028a4..c68b2d5d 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 3c537cdd..00000000 --- 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 73c48b2e..8aa15169 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 8f22b415..66187578 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 7610bb6d..e7b70a8e 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 989316c7..cb74de81 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 3f76159b..00000000 --- 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 d8d676b3..d666bba8 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 fb86ec9d..00000000 --- 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 00d94663..ac5217e0 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 0977f6b4..00000000 --- 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 f9c22cf3..24ac6b8b 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 91b88de2..00000000 --- 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 b083c577..437922c4 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 0aa0c73b..00000000 --- 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 d84d6db5..920725a9 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 00000000..05c551ce --- /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 9d031e4d..fc899229 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 4b771d8b..00000000 --- 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/request.rb b/test/utopia/request.rb new file mode 100644 index 00000000..497d9dda --- /dev/null +++ b/test/utopia/request.rb @@ -0,0 +1,67 @@ +# 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 + + 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 diff --git a/test/utopia/response.rb b/test/utopia/response.rb new file mode 100644 index 00000000..a5e86378 --- /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/test/utopia/session.rb b/test/utopia/session.rb index fb706721..4717e743 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 d655a6ff..00000000 --- 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 fafb7a5a..0c3ffde0 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 44c86d36..00000000 --- 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 9c201281..dc8c7a11 100644 --- a/utopia.gemspec +++ b/utopia.gemspec @@ -34,8 +34,8 @@ 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" 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"