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..831cc2c9 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::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..0f99202b 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, @@ -90,7 +90,7 @@ def passthrough(request, path) # Succeed the request and immediately respond. # def succeed!(status: 200, headers: {}, **options) - # options may include content: string or body: Enumerable (as per Rack specifications + # options may include content: String or body: Enumerable. suceed! end @@ -108,7 +108,7 @@ end on "edit" do |request, path| if request.post? - @user.update_attributes(request[:user]) + @user.update_attributes(request.arguments["user"]) end end @@ -155,3 +155,9 @@ use Utopia::Session, ``` All session data is stored on the client, but it's encrypted with a salt and the secret key. It is impossible for the client to decrypt the data without the secret stored on the server. + +When the middleware is installed, the session is available on the request: + +```ruby +request.session[:user_id] = user.id +``` diff --git a/fixtures/a_rack_application.rb b/fixtures/a_rack_application.rb deleted file mode 100644 index 8e9232ed..00000000 --- a/fixtures/a_rack_application.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2016-2025, by Samuel Williams. - -require "rack/test" -require "rack/builder" - -ARackApplication = Sus::Shared("a rack app") do |rackup_path| - include Rack::Test::Methods - - let(:rackup_directory) {File.dirname(rackup_path)} - let(:app) {Rack::Builder.parse_file(rackup_path)} -end 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..0f99202b 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, @@ -90,7 +90,7 @@ def passthrough(request, path) # Succeed the request and immediately respond. # def succeed!(status: 200, headers: {}, **options) - # options may include content: string or body: Enumerable (as per Rack specifications + # options may include content: String or body: Enumerable. suceed! end @@ -108,7 +108,7 @@ end on "edit" do |request, path| if request.post? - @user.update_attributes(request[:user]) + @user.update_attributes(request.arguments["user"]) end end @@ -155,3 +155,9 @@ use Utopia::Session, ``` All session data is stored on the client, but it's encrypted with a salt and the secret key. It is impossible for the client to decrypt the data without the secret stored on the server. + +When the middleware is installed, the session is available on the request: + +```ruby +request.session[:user_id] = user.id +``` diff --git a/lib/utopia.rb b/lib/utopia.rb index 219d5275..59218a7c 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" diff --git a/lib/utopia/application.rb b/lib/utopia/application.rb new file mode 100644 index 00000000..964ae41c --- /dev/null +++ b/lib/utopia/application.rb @@ -0,0 +1,84 @@ +# 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, dispatches to the + # Utopia application stack, and normalizes the result back to a + # {Protocol::HTTP::Response}. + class Application < Protocol::HTTP::Middleware + 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. + # @parameter options [Hash] Options passed to the application constructor. + # @returns [Application] The default protocol-facing Utopia application. + def self.default(**options) + self.build(**options) + end + + # Load a Utopia application from a conventional configuration file. + # + # If the file defines 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 = 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(**options) + end + + # Process a protocol HTTP request. + # @parameter request [Protocol::HTTP::Request] The incoming protocol request. + # @returns [Protocol::HTTP::Response] The normalized protocol response. + def call(request) + request = Request.new(request) + + return Response.wrap(super(request)) + end + end +end diff --git a/lib/utopia/content/document.rb b/lib/utopia/content/document.rb index 4696f8cb..e91f1c7e 100644 --- a/lib/utopia/content/document.rb +++ b/lib/utopia/content/document.rb @@ -7,6 +7,7 @@ require_relative "response" require_relative "markup" require_relative "builder" +require_relative "../request" module Utopia module Content @@ -27,7 +28,7 @@ def initialize(tag) class Document < Response # Render a content node into a new document. # @parameter node [Utopia::Content::Node] The content node. - # @parameter request [Rack::Request] The request. + # @parameter request [Utopia::Request] The application request. # @parameter attributes [Hash] The attributes. # @returns [Document] The rendered document. def self.render(node, request, attributes) @@ -35,7 +36,7 @@ def self.render(node, request, attributes) end # Initialize a document for a protocol request. - # @parameter request [Rack::Request] The request. + # @parameter request [Utopia::Request] The application request. # @parameter attributes [Hash] The attributes. def initialize(request, attributes = {}) @request = request @@ -51,7 +52,7 @@ def initialize(request, attributes = {}) # @returns [Path] The original request path, if known. def request_path - Path[request.env["REQUEST_PATH"]] + Path[request.request_path] end protected def current_base_uri_path @@ -113,7 +114,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 8b9190e7..a7a79b80 100644 --- a/lib/utopia/content/middleware.rb +++ b/lib/utopia/content/middleware.rb @@ -5,6 +5,9 @@ require_relative "../middleware" require_relative "../localization" +require_relative "../request" +require_relative "../response" +require_relative "../controller/variables" require_relative "links" require_relative "node" @@ -100,23 +103,22 @@ def resolve_link(link) # Respond. # @parameter link [Utopia::Content::Link] The content link. - # @parameter request [Rack::Request] The request. - # @returns [Array] The response. + # @parameter request [Utopia::Request] The application request. + # @returns [Protocol::HTTP::Response] The response. def respond(link, request) if node = resolve_link(link) - attributes = request.env.fetch(VARIABLES_KEY, {}).to_hash + attributes = request.variables&.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 # Serve or redirect filesystem-backed content, otherwise invoke the application. - # @parameter env [Hash] The Rack environment. - # @returns [Array] The Rack response. - def call(env) - request = Rack::Request.new(env) + # @parameter request [Utopia::Request] The request. + # @returns [Protocol::HTTP::Response] The content, redirect, or downstream response. + 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: @@ -127,17 +129,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.locale 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 2cd5439a..5cea9a4e 100644 --- a/lib/utopia/content/node.rb +++ b/lib/utopia/content/node.rb @@ -136,11 +136,11 @@ def call(document, state) end # Process the request and return the resulting response. - # @parameter request [Rack::Request] The request. + # @parameter request [Utopia::Request] The application request. # @parameter attributes [Hash] The attributes. - # @returns [Array] The response. + # @returns [Protocol::HTTP::Response] The response. def process!(request, attributes = {}) - Document.render(self, request, attributes).to_a + Document.render(self, request, attributes).to_response end # This is a special context in which a limited set of well defined methods are exposed in the content view. @@ -173,8 +173,8 @@ def localization document.localization end - # Return the protocol request being rendered. - # @returns [Rack::Request] The request. + # Return the application request being rendered. + # @returns [Utopia::Request] The request. def request document.request end diff --git a/lib/utopia/content/response.rb b/lib/utopia/content/response.rb index 5f02eeae..68a4db38 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 @@ -40,10 +41,10 @@ def lookup(tag) return nil end - # Convert this value to a Rack response tuple. - # @returns [Array] The status, headers, and body. - def to_a - [@status, @headers, @body] + # Convert this value to a protocol HTTP response. + # @returns [Protocol::HTTP::Response] The response. + def to_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/actions.md b/lib/utopia/controller/actions.md index a5d4b4ca..d545e6f7 100644 --- a/lib/utopia/controller/actions.md +++ b/lib/utopia/controller/actions.md @@ -25,24 +25,24 @@ on "new" do |request| @user = User.new if request.post? - @user.update_attributes(request.params["user"]) + @user.update_attributes(parse_body(request)["user"]) redirect! "index" end end on "edit" do |request| - @user = User.find(request.params["id"]) + @user = User.find(request.query_arguments["id"]) if request.post? - @user.update_attributes(request.params["user"]) + @user.update_attributes(parse_body(request)["user"]) redirect! "index" end end on "delete" do |request| - User.find(request.params["id"]).destroy + User.find(request.query_arguments["id"]).destroy redirect! "index" end diff --git a/lib/utopia/controller/actions.rb b/lib/utopia/controller/actions.rb index 06d31627..21b4f57e 100644 --- a/lib/utopia/controller/actions.rb +++ b/lib/utopia/controller/actions.rb @@ -176,7 +176,7 @@ def otherwise(&block) # Dispatch the request to the first matching action. # @parameter controller [Utopia::Controller::Base] The controller instance. - # @parameter request [Rack::Request] The request. + # @parameter request [Utopia::Request] The request. # @parameter path [Utopia::Path | String] The path. # @returns [Object | Nil] The result of the final matching action or fallback action. def dispatch(controller, request, path) diff --git a/lib/utopia/controller/base.rb b/lib/utopia/controller/base.rb index 7b5a117d..0b44bd9c 100644 --- a/lib/utopia/controller/base.rb +++ b/lib/utopia/controller/base.rb @@ -4,11 +4,23 @@ # Copyright, 2014-2025, by Samuel Williams. require_relative "../http" +require_relative "../response" + +require "protocol/content/default" module Utopia module Controller CONTENT_TYPE = HTTP::CONTENT_TYPE + # A controller response that can be converted to a protocol HTTP response. + Response = Struct.new(:status, :headers, :body) do + # Convert this value to a protocol HTTP response. + # @returns [Protocol::HTTP::Response] The response. + def to_response + Utopia::Response[status, headers, body || []] + end + end + # The base implementation of a controller class. class Base URI_PATH = nil @@ -76,14 +88,14 @@ def self.direct?(path) # Catch and return a response thrown while executing the block. # @yields The controller operation that may throw a response. - # @returns [Array | Nil] The thrown response, or `nil` if the block completes. + # @returns [Protocol::HTTP::Response | Nil] The thrown response, or `nil` if the block completes. def catch_response catch(:response) do yield and nil 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 @@ -95,9 +107,30 @@ 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 + + # Parse the request body according to its media type. + # @parameter request [Utopia::Request] The request containing the body. + # @parameter parser [Protocol::Content::Parser] The content parser. + # @yields {|name, value| ...} Form entries, including streaming uploads. + # @returns [Object | Nil] The parsed body, or nil when there is no body. + def parse_body(request, parser: Protocol::Content::Parser.default, &block) + body = request.body + return unless body + + input = body.to_io + error = nil + + begin + return parser.parse(request.headers["content-type"], input, &block) + rescue => error + raise + ensure + input.close_read(error) + end end # This will cause the middleware to generate a response. @@ -120,7 +153,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! Response.new(status.to_i, {HTTP::LOCATION => location}, [status.to_s]) end # Controller relative redirect. @@ -133,7 +166,7 @@ def fail!(error = 400, message = nil) status = HTTP::Status.new(error, 400...600) message ||= status.to_s - respond! [status.to_i, {}, [message]] + respond! Response.new(status.to_i, {}, [message]) end # Succeed the request and immediately respond. @@ -145,7 +178,7 @@ def succeed!(status: 200, headers: {}, type: nil, **options) end body = body_for(status, headers, options) - respond! [status.to_i, headers, body || []] + respond! Response.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 9afdb2d5..04679249 100644 --- a/lib/utopia/controller/middleware.rb +++ b/lib/utopia/controller/middleware.rb @@ -5,6 +5,7 @@ require_relative "../path" require_relative "../middleware" +require_relative "../request" require_relative "variables" require_relative "base" @@ -94,7 +95,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 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. @@ -114,25 +115,23 @@ def invoke_controllers(request) end # Controllers can directly modify relative_path, which is copied into controller_path. The controllers may have rewriten the path so we update the path info: - request.env[Rack::PATH_INFO] = controller_path.to_s + request.path_info = controller_path.to_s # No controller gave a useful result: return nil end - # Invoke matching controllers before passing the request downstream. - # @parameter env [Hash] The Rack environment. - # @returns [Array] The controller or downstream Rack response. - def call(env) - env[VARIABLES_KEY] ||= Variables.new - - request = Rack::Request.new(env) + # Attach controller variables while processing the request. + # @parameter request [Utopia::Request] The request. + # @returns [Protocol::HTTP::Response] The controller or downstream response. + def call(request) + request.variables ||= 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 3414faf9..b53ea41a 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 @@ -29,7 +30,7 @@ def responds # Bind this controller's responder to a context and request. # @parameter context [Controller::Base] The controller context. - # @parameter request [Rack::Request] The request. + # @parameter request [Utopia::Request] The request. # @returns [Responder::Responds | Nil] The bound responder, if one has been configured. def respond_to(context, request) @responder&.respond_to(context, request) @@ -37,25 +38,25 @@ def respond_to(context, request) # Build a response for the negotiated content type. # @parameter context [Object] The context. - # @parameter request [Rack::Request] The request. - # @parameter response [Array] The response. - # @returns [Array] The response. + # @parameter request [Utopia::Request] The request. + # @parameter response [Protocol::HTTP::Response] The response. + # @returns [Protocol::HTTP::Response] The response. def response_for(context, request, response) - @responder&.respond_to(context, request).with(*response[2]) + @responder&.respond_to(context, request).with(*response.body) end end # Bind this controller's responder to the request. - # @parameter request [Rack::Request] The request. + # @parameter request [Utopia::Request] The request. # @returns [Responder::Responds | Nil] The bound responder, if one has been configured. def respond_to(request) self.class.respond_to(self, request) end # Build a response for the negotiated content type. - # @parameter request [Rack::Request] The request. + # @parameter request [Utopia::Request] The request. # @parameter original_response [Object] The original response. - # @returns [Array] The response. + # @returns [Protocol::HTTP::Response] The response. def response_for(request, original_response) response = catch(:response) do self.class.response_for(self, request, original_response) @@ -67,14 +68,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 6c69eed4..2d0799b0 100644 --- a/lib/utopia/controller/responder.rb +++ b/lib/utopia/controller/responder.rb @@ -22,7 +22,7 @@ def self.split(*arguments) # Serialize an object as a successful JSON response. # @parameter context [Object] The context. - # @parameter request [Rack::Request] The request. + # @parameter request [Utopia::Request] The request. # @parameter media_range [HTTP::Accept::MediaTypes::MediaRange] The negotiated media range. # @parameter object [Object] The object. # @parameter options [Hash] The options. @@ -49,7 +49,7 @@ def self.split(*arguments) # Accept an object without producing a response. # @parameter context [Object] The context. - # @parameter request [Rack::Request] The request. + # @parameter request [Utopia::Request] The request. # @parameter media_range [HTTP::Accept::MediaTypes::MediaRange] The negotiated media range. # @parameter object [Object] The object. # @parameter options [Hash] The options. @@ -73,7 +73,7 @@ def split(*arguments) # Invoke this handler's block in the controller context. # @parameter context [Object] The context. - # @parameter request [Rack::Request] The request. + # @parameter request [Utopia::Request] The request. # @parameter media_range [HTTP::Accept::MediaTypes::MediaRange] The negotiated media range. # @parameter arguments [Array] The arguments. # @parameter options [Hash] The options. @@ -108,13 +108,15 @@ def freeze # Negotiate the request's accepted media types and invoke the best handler. # @parameter context [Object] The context. - # @parameter request [Rack::Request] The request. + # @parameter request [Utopia::Request] The request. # @parameter arguments [Array] The arguments. # @parameter options [Hash] The options. # @returns [Object | Nil] The selected handler's result, or `nil` if none matches. 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) @@ -130,7 +132,7 @@ def handle(content_type, &block) # Bind this responder to a context and request. # @parameter context [Controller::Base] The controller context. - # @parameter request [Rack::Request] The request. + # @parameter request [Utopia::Request] The request. # @returns [Responds] The bound responder. def respond_to(context, request) Responds.new(self, context, request) diff --git a/lib/utopia/controller/rewrite.rb b/lib/utopia/controller/rewrite.rb index e363043e..56818aa8 100644 --- a/lib/utopia/controller/rewrite.rb +++ b/lib/utopia/controller/rewrite.rb @@ -56,7 +56,7 @@ def freeze # Apply this prefix rule and execute its callback when it matches. # @parameter context [Object] The context. - # @parameter request [Rack::Request] The request. + # @parameter request [Utopia::Request] The request. # @parameter path [Utopia::Path | String] The path. # @returns [Path] The unmatched suffix, or the original path when the rule does not match. def apply(context, request, path) @@ -93,7 +93,7 @@ def extract_prefix(**patterns, &block) # Apply every rewrite rule in order. # @parameter context [Object] The context. - # @parameter request [Rack::Request] The request. + # @parameter request [Utopia::Request] The request. # @parameter path [Utopia::Path | String] The path. # @returns [Path] The rewritten path. def apply(context, request, path) @@ -106,7 +106,7 @@ def apply(context, request, path) # Rewrite a path's components in place. # @parameter context [Object] The context. - # @parameter request [Rack::Request] The request. + # @parameter request [Utopia::Request] The request. # @parameter path [Utopia::Path | String] The path. # @returns [Array(String)] The rewritten components. def call(context, request, path) @@ -124,7 +124,7 @@ def rewrite # Apply configured rewrite rules to the request path. # @parameter controller [Utopia::Controller::Base] The controller instance. - # @parameter request [Rack::Request] The request. + # @parameter request [Utopia::Request] The request. # @parameter path [Utopia::Path | String] The path. # @returns [Array(String) | Nil] The rewritten components, or `nil` when no rewriter is configured. def rewrite_request(controller, request, path) diff --git a/lib/utopia/controller/variables.rb b/lib/utopia/controller/variables.rb index 4e37215c..96cb7094 100644 --- a/lib/utopia/controller/variables.rb +++ b/lib/utopia/controller/variables.rb @@ -3,8 +3,6 @@ # Released under the MIT License. # Copyright, 2014-2025, by Samuel Williams. -require_relative "../middleware" - module Utopia module Controller # Provides a stack-based instance variable lookup mechanism. It can flatten a stack of controllers into a single hash. @@ -75,11 +73,11 @@ def [] key end end - # Fetch the controller variables associated with a request. - # @parameter request [Rack::Request] The request. - # @returns [Variables | Nil] The controller variables, when available. + # Return the controller variables associated with the request. + # @parameter request [Utopia::Request] The application request. + # @returns [Variables | Nil] The current variables. def self.[] request - request.env[VARIABLES_KEY] + request.variables end end end diff --git a/lib/utopia/exceptions/handler.rb b/lib/utopia/exceptions/handler.rb index c33fecd4..16c6582e 100644 --- a/lib/utopia/exceptions/handler.rb +++ b/lib/utopia/exceptions/handler.rb @@ -6,6 +6,10 @@ require "console" +require_relative "../middleware" +require_relative "../request" +require_relative "../response" + module Utopia module Exceptions # A middleware which catches exceptions and performs an internal redirect. @@ -28,30 +32,30 @@ def freeze end # Convert application exceptions into internal-server-error responses. - # @parameter env [Hash] The Rack environment. - # @returns [Array] The application or generated error response. - def call(env) + # @parameter request [Utopia::Request] The request. + # @returns [Protocol::HTTP::Response] The application response or a generated error response. + 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 ) + error_request.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 ba0bf82b..4035fc24 100644 --- a/lib/utopia/exceptions/mailer.rb +++ b/lib/utopia/exceptions/mailer.rb @@ -6,9 +6,13 @@ require "net/smtp" require "mail" +require_relative "../middleware" +require_relative "../request" +require_relative "handler" + 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. + # A middleware which catches application exceptions and sends an email containing the exception, backtrace, request, and application state. class Mailer # A basic local non-authenticated SMTP server. LOCAL_SMTP = [:smtp, { @@ -24,7 +28,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 @@ -49,14 +53,15 @@ def freeze super end - # Report application exceptions by email before reraising them. - # @parameter env [Hash] The Rack environment. - # @returns [Array] The application response. - def call(env) + # Report application exceptions by email before returning an error response. + # @parameter request [Utopia::Request] The request. + # @returns [Protocol::HTTP::Response] The application response or a generated error response. + def call(request) begin - return @app.call(env) + return @app.call(request) rescue => exception - send_notification exception, env + request.exception = exception + send_notification exception, request raise end @@ -64,28 +69,21 @@ def call(env) private - REQUEST_KEYS = [ + REQUEST_ATTRIBUTES = [ + :method, + :scheme, + :authority, + :protocol, + :version, :ip, :referrer, :path, + :request_path, + :path_info, + :query, :user_agent, ] - ENV_KEYS = [ - "PATH_INFO", - "REQUEST_METHOD", - "REQUEST_PATH", - "REQUEST_URI", - "SCRIPT_NAME", - "QUERY_STRING", - "SERVER_PROTOCOL", - "SERVER_NAME", - "SERVER_PORT", - "REMOTE_ADDR", - "CONTENT_TYPE", - "CONTENT_LENGTH", - ] - def generate_backtrace(io, exception, prefix: "Exception") io.puts "#{prefix} #{exception.class.name}: #{exception.to_s}" @@ -100,39 +98,33 @@ 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.method} #{request.url}" - io.puts "#{request.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 - REQUEST_KEYS.each do |key| + REQUEST_ATTRIBUTES.each do |key| value = request.send(key) io.puts "request.#{key}: #{value.inspect}" end - request.params.each do |key, value| - io.puts "request.params.#{key}: #{value.inspect}" + request.query_arguments.each do |key, value| + io.puts "request.query_arguments.#{key}: #{value.inspect}" end io.puts - ENV_KEYS.each do |key| - value = env[key] - io.puts "env[#{key.inspect}]: #{value.inspect}" + request.headers.each do |key, value| + io.puts "header[#{key.inspect}]: #{value.inspect}" end - io.puts - - env.select{|key,_| key.start_with? "HTTP_"}.each do |key, value| - io.puts "#{key}: #{value.inspect}" + self.current_state(request).each do |key, value| + io.puts "state.#{key}: #{value.inspect}" end io.puts @@ -142,7 +134,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: $$, @@ -150,29 +142,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["state.yaml"] = YAML.dump(self.current_state(request)) 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 @@ -182,10 +174,21 @@ 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 + def current_state(request) + { + session: request.session, + variables: request.variables, + localization: request.localization, + current_locale: request.locale, + exception: request.exception, + } + end + + def extract_body(request) + body = request.body + + if body&.rewindable? && body.rewind + return body.join end end end diff --git a/lib/utopia/http.rb b/lib/utopia/http.rb index f0b97690..d63f31c6 100644 --- a/lib/utopia/http.rb +++ b/lib/utopia/http.rb @@ -1,11 +1,10 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2010-2025, by Samuel Williams. - -require "rack" +# Copyright, 2010-2026, by Samuel Williams. require "http/accept" +require "protocol/http/status" module Utopia # HTTP protocol implementation. @@ -38,41 +37,6 @@ module HTTP :unavailable => 503 } - # A list of human readable descriptions for a given status code. - # For a more detailed description, see https://en.wikipedia.org/wiki/List_of_HTTP_status_codes - STATUS_DESCRIPTIONS = { - 200 => "OK".freeze, - 201 => "Created".freeze, - 202 => "Accepted".freeze, - 203 => "Non-Authoritive Information".freeze, - 204 => "No Content".freeze, - 205 => "Reset Content".freeze, - 206 => "Partial Content".freeze, - 300 => "Multiple Choices".freeze, - 301 => "Moved Permanently".freeze, - 302 => "Found".freeze, - 303 => "See Other".freeze, - 304 => "Not Modified".freeze, - 305 => "Use Proxy".freeze, - 307 => "Temporary Redirect".freeze, - 308 => "Permanent Redirect".freeze, - 400 => "Bad Request".freeze, - 401 => "Permission Denied".freeze, - 402 => "Payment Required".freeze, - 403 => "Access Forbidden".freeze, - 404 => "Resource Not Found".freeze, - 405 => "Unsupported Method".freeze, - 406 => "Not Acceptable".freeze, - 408 => "Request Timeout".freeze, - 409 => "Request Conflict".freeze, - 410 => "Resource Removed".freeze, - 416 => "Byte range unsatisfiable".freeze, - 422 => "Unprocessible Entity".freeze, - 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 CACHE_CONTROL = "cache-control".freeze @@ -104,12 +68,7 @@ def to_i # Convert this object to a string. # @returns [String] The resulting string. def to_s - STATUS_DESCRIPTIONS[@code] || @code.to_s - end - - # Allow to be used for rack body: - def each - yield to_s + return Protocol::HTTP::Status.description(@code) || @code.to_s end end end diff --git a/lib/utopia/localization/middleware.rb b/lib/utopia/localization/middleware.rb index 585fd20f..69468525 100644 --- a/lib/utopia/localization/middleware.rb +++ b/lib/utopia/localization/middleware.rb @@ -4,20 +4,21 @@ # Copyright, 2025-2026, by Samuel Williams. require_relative "wrapper" +require_relative "../middleware" +require_relative "../request" +require_relative "../response" module Utopia module Localization # Selects a locale for each request and rewrites localized paths. class Middleware - RESOURCE_NOT_FOUND = [400, {}, []].freeze - - HTTP_ACCEPT_LANGUAGE = "HTTP_ACCEPT_LANGUAGE".freeze + RESOURCE_NOT_FOUND = Response[400, {}, []].freeze # @param locales [Array] An array of all supported locales. # @param default_locale [String] The default locale if none is provided. # @param default_locales [String] The locales to try in order if none is provided. - # @param hosts [Hash] Specify a mapping of the HTTP_HOST header to a given locale. - # @param ignore [Array] A list of patterns matched against PATH_INFO which will not be localized. + # @param hosts [Hash] Specify a mapping of request hosts to locales. + # @param ignore [Array] A list of patterns matched against request paths which will not be localized. def initialize(app, locales:, default_locale: nil, default_locales: nil, hosts: {}, ignore: []) @app = app @@ -64,55 +65,65 @@ def freeze attr :all_locales attr :default_locale - # Compute the preferred locales for the Rack environment. - # @parameter env [Hash] The Rack environment. - # @yields {|env| ...} Each localized environment in preference order. + # Compute the preferred locales for the request. + # @parameter request [Utopia::Request] The derived request. + # @yields {|request, locale| ...} Each unique request and locale pair in preference order. # @returns [Enumerator | Array] An enumerator when no block is given, otherwise the configured default locales. - 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| + if locales.add? locale + yield request, locale + end 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 + if locales.add? locale + yield request, locale + end 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| + if locales.add? locale + yield request, locale + end end @default_locales.each do |locale| - yield env.merge(CURRENT_LOCALE_KEY => locale) if locales.add? locale + if locales.add? locale + yield request, locale + end end end # Infer preferred locales from the request host. - # @parameter env [Hash] The Rack environment. - # @yields {|locale| ...} Each locale whose host pattern matches. + # @parameter request [Utopia::Request] The application request. + # @yields {|locale| ...} Each locale whose host pattern matches the request host. # @returns [Hash] The configured host mappings. - 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| - yield locale if http_host[pattern] + if http_host[pattern] + yield locale + end end end - # Select the preferred locale from the request path. - # @parameter env [Hash] The Rack environment. - # @yields {|locale, path| ...} The locale and path without its locale prefix. + # Select the preferred locale for the request. + # @parameter request [Utopia::Request] The application request. + # @yields {|locale, path| ...} The locale and path with its locale prefix removed, when present. # @returns [Object | Nil] The block result when a locale prefix is present. - 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: @@ -123,10 +134,10 @@ def request_preferred_locale(env) end # Parse the locales preferred by the browser. - # @parameter env [Hash] The Rack environment. - # @returns [Array(String)] Supported locales in preference order. - def browser_preferred_locales(env) - accept_languages = env[HTTP_ACCEPT_LANGUAGE] + # @parameter request [Utopia::Request] The application request. + # @returns [Array(String)] Supported locales accepted by the browser, in preference order. + def browser_preferred_locales(request) + accept_languages = request.headers["accept-language"]&.to_s # No user prefered languages: return [] unless accept_languages @@ -141,56 +152,63 @@ def browser_preferred_locales(env) return [] end - # Check whether the request path is eligible for localization. - # @parameter env [Hash] The Rack environment. + # Check whether the request path includes a locale. + # @parameter request [Utopia::Request] The application request. # @returns [Boolean] Whether the path is eligible for localization. - 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 + # Mark the response as varying by language and expose its localized content location. + # @parameter request [Utopia::Request] The application request. + # @parameter response [Protocol::HTTP::Response] The response. + # @returns [Protocol::HTTP::Response] The response with localization headers. + 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.locale # 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 - # Try the preferred locales until the application returns a successful response. - # @parameter env [Hash] The Rack environment. - # @returns [Array] The localized Rack response. - def call(env) + # Try the request's preferred locales until the application returns a successful response. + # @parameter request [Utopia::Request] The request. + # @returns [Protocol::HTTP::Response] The localized response with cache-variation headers. + def call(request) # Pass the request through if it shouldn't be localized: - return @app.call(env) unless localized?(env) - - env[LOCALIZATION_KEY] = self + return @app.call(request) unless localized?(request) response = nil + localized_request = request # 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 |candidate, locale| + # puts "Trying locale: #{locale}: #{localized_request.path_info}..." + response&.close - response = @app.call(localized_env) + localized_request = candidate.with + localized_request.variables = nil + localized_request.localization = self + localized_request.locale = locale - break unless response[0] >= 400 + response = Response.wrap(@app.call(localized_request)) - response[2].close if response[2].respond_to?(:close) + break unless response.status >= 400 end - return vary(env, response) + return vary(localized_request, response) end end end diff --git a/lib/utopia/localization/wrapper.rb b/lib/utopia/localization/wrapper.rb index 9ade06b6..383fffc0 100644 --- a/lib/utopia/localization/wrapper.rb +++ b/lib/utopia/localization/wrapper.rb @@ -8,21 +8,18 @@ module Utopia # A middleware which attempts to find localized content. module Localization - LOCALIZATION_KEY = "utopia.localization".freeze - CURRENT_LOCALE_KEY = "utopia.localization.current_locale".freeze - # A wrapper to provide easy access to locale related data in the request. class Wrapper - # Initialize a localization wrapper for a Rack environment. - # @parameter env [Hash] The Rack environment. - def initialize(env) - @env = env + # Initialize a localization wrapper for the request. + # @parameter request [Utopia::Request] The application request. + def initialize(request) + @request = request end - # Fetch the localization middleware associated with this request. - # @returns [Middleware | Nil] The localization middleware, when active. + # Return the localization middleware associated with the request. + # @returns [Localization::Middleware | Nil] The current localization middleware. def localization - @env[LOCALIZATION_KEY] + @request.localization end # Check whether the request path includes a locale. @@ -33,7 +30,7 @@ def localized? # Returns the current locale or nil if not localized. def current_locale - @env[CURRENT_LOCALE_KEY] + @request.locale end # Returns the default locale or nil if not localized. @@ -55,11 +52,18 @@ def localized_path(path, locale) end end - # Build a localization wrapper for a request. - # @parameter request [Rack::Request] The request. - # @returns [Wrapper] The localization wrapper. + # Build a localization wrapper for the request. + # @parameter request [Utopia::Request] The application request. + # @returns [Wrapper] A localization wrapper for the current request. + def self.wrapper(request) + Wrapper.new(request) + end + + # Return a localization wrapper for the current request context. + # @parameter request [Utopia::Request] The application request. + # @returns [Wrapper] A localization wrapper for the current request. def self.[] request - Wrapper.new(request.env) + self.wrapper(request) end end end diff --git a/lib/utopia/middleware.rb b/lib/utopia/middleware.rb index 79e4a583..591e7004 100644 --- a/lib/utopia/middleware.rb +++ b/lib/utopia/middleware.rb @@ -5,14 +5,10 @@ require_relative "http" require_relative "path" - module Utopia # The default pages path for {Utopia::Content} middleware. PAGES_PATH = "pages".freeze - # This is used for shared controller variables which get consumed by the content middleware. - VARIABLES_KEY = "utopia.variables".freeze - # The default root directory for middleware to operate within, e.g. the web-site directory. Convention over configuration. # @param subdirectory [String] Appended to the default root to make a more specific path. # @param pwd [String] The working directory for the current site. diff --git a/lib/utopia/redirection.rb b/lib/utopia/redirection.rb index 24ea4e28..37355e2f 100644 --- a/lib/utopia/redirection.rb +++ b/lib/utopia/redirection.rb @@ -4,6 +4,8 @@ # Copyright, 2009-2026, by Samuel Williams. require_relative "middleware" +require_relative "request" +require_relative "response" module Utopia # A middleware which assists with redirecting from one path to another. @@ -45,28 +47,29 @@ def freeze end # Check whether the response status requires error handling. - # @parameter response [Array] The response. + # @parameter response [Protocol::HTTP::Response] The response. # @returns [Boolean] Whether the response is an error without handler-provided headers. def unhandled_error?(response) - response[0] >= 400 && response[1].empty? + response.status >= 400 && response.headers.empty? end # Replace an unhandled error response with its configured error document. - # @parameter env [Hash] The Rack environment. - # @returns [Array] The original or error-document response. + # @parameter request [Utopia::Request] The request. + # @returns [Protocol::HTTP::Response] The original or error-document response. # @raises [RequestFailure] If the configured error document also fails. - 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) - if error_response[0] >= 400 - raise RequestFailure.new(env[Rack::PATH_INFO], response[0], location, error_response[0]) + error_response = Response.wrap(@app.call(error_request)) + + 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 @@ -123,32 +126,32 @@ def make_headers(location) # Build a redirect response for the given location. # @parameter location [String] The redirect location. - # @returns [Array] The redirect response. + # @returns [Protocol::HTTP::Response] The redirect response. def redirect(location) - return [self.status, self.make_headers(location), []] + return Response[self.status, self.make_headers(location), []] end # Resolve a normalized request path to a redirect response. # @parameter path [String] The normalized request path. - # @returns [Array | false] The redirect response, or `false` by default. + # @returns [Protocol::HTTP::Response | false] The redirect response, or `false` by default. def [] path false end # Redirect a normalized request path when it matches, otherwise invoke the application. - # @parameter env [Hash] The Rack environment. - # @returns [Array] The redirect or downstream Rack response. - def call(env) + # @parameter request [Utopia::Request] The request. + # @returns [Protocol::HTTP::Response] The redirect or downstream response. + 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 @@ -166,7 +169,7 @@ def initialize(app, index: "index") # Redirect a directory path to its index path. # @parameter path [String] The normalized request path. - # @returns [Array | Nil] The redirect response when the path ends with `/`. + # @returns [Protocol::HTTP::Response | Nil] The redirect response when the path ends with `/`. def [] path if path.end_with?("/") return redirect(path + @index) @@ -188,7 +191,7 @@ def initialize(app, patterns, status: 301) # Redirect a path found in the rewrite map. # @parameter path [String] The normalized request path. - # @returns [Array | Nil] The redirect response when the path is mapped. + # @returns [Protocol::HTTP::Response | Nil] The redirect response when the path is mapped. def [] path if location = @patterns[path] return redirect(location) @@ -216,7 +219,7 @@ def initialize(app, pattern, prefix, status: 301, flatten: false) # Redirect a matching path to the configured prefix. # @parameter path [String] The normalized request path. - # @returns [Array | Nil] The redirect response when the pattern matches. + # @returns [Protocol::HTTP::Response | Nil] The redirect response when the pattern matches. def [] path if path.start_with?(@pattern) if @flatten diff --git a/lib/utopia/request.rb b/lib/utopia/request.rb new file mode 100644 index 00000000..da1ab825 --- /dev/null +++ b/lib/utopia/request.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "stringio" + +require "protocol/http/request" +require "protocol/url/form_data/parser" + +module Utopia + # Utopia's application-facing request wrapper. + # + # Protocol request methods are delegated to the underlying request; parsing + # and application conveniences live here rather than on protocol-http itself. + class Request + # Build a Utopia request from the given protocol request arguments. + def self.[](*arguments) + self.new(Protocol::HTTP::Request[*arguments]) + end + + # Initialize the request proxy. + # @parameter delegate [Protocol::HTTP::Request] The underlying protocol request. + # @parameter request_path [String | Nil] The original path before internal rewrites. + def initialize(delegate, request_path: nil) + @delegate = delegate + @request_path = request_path + @session = nil + @variables = nil + @locale = nil + @localization = nil + @exception = nil + + @query_arguments = nil + @cookies = nil + end + + # The underlying protocol request. + attr :delegate + + # Duplicate the underlying protocol request when duplicating this proxy. + # @parameter other [Request] The request being copied. + def initialize_copy(other) + super + + @delegate = other.delegate.dup + @query_arguments = nil + @cookies = nil + end + + # Assign the request path including query string. + def path= value + if value != @delegate.path + @request_path ||= self.path_info + end + + @delegate.path = value + @query_arguments = nil + end + + # Whether the request method is POST. + def post? + self.method == "POST" + end + + # The request path without the query string. + def path_info + self.path&.split("?", 2)&.first + end + + # Set the request path while preserving the query string. + def path_info= value + @request_path ||= self.path_info + + if query = self.query + self.path = "#{value}?#{query}" + else + self.path = value + end + end + + # The original request path, before any internal request rewrites. + def request_path + @request_path || self.path_info + end + + # The query string without the leading question mark. + def query + path = self.path + + if path&.include?("?") + return path.split("?", 2).last + end + end + + # Decoded query arguments. + def query_arguments + @query_arguments ||= decode_arguments(self.query) + end + + # Decoded request cookies. + def cookies + @cookies ||= parse_cookies(self.headers["cookie"]) + end + + # The request host with optional port. + def host + self.authority || self.headers["host"] + end + + # The request user agent. + def user_agent + self.headers["user-agent"] + end + + # The request referrer. + def referrer + self.headers["referer"] + end + + # The session associated with this request, if installed. + attr_accessor :session + + # The controller variables associated with this request, if installed. + attr_accessor :variables + + # The locale selected for this request, if any. + attr_accessor :locale + + # The localization middleware associated with this request, if any. + attr_accessor :localization + + # The exception associated with this request, if any. + attr_accessor :exception + + # The remote peer IP address, if available. + def ip + self.peer&.ip_address + end + + # The full request URL, if scheme and host are available. + def url + if scheme = self.scheme and host = self.host + "#{scheme}://#{host}#{self.path}" + else + self.path + end + end + + # Build a derived request with updated protocol fields. + def with(method: self.method, path: self.path, path_info: nil) + delegate = @delegate.dup + delegate.method = method + + request = self.class.new(delegate, request_path: self.request_path) + request.session = @session + request.variables = @variables + request.locale = @locale + request.localization = @localization + request.exception = @exception + + if path_info + if query = self.query + request.path = "#{path_info}?#{query}" + else + request.path = path_info + end + else + request.path = path + end + + return request + end + + private + + # These inherited methods conflict with the protocol request interface, so remove them to allow delegation. + undef_method :method, :to_s + + def method_missing(name, ...) + if @delegate.respond_to?(name) + @delegate.public_send(name, ...) + else + super + end + end + + def respond_to_missing?(name, include_private = false) + @delegate.respond_to?(name) || super(name, include_private) + end + + def decode_arguments(query) + return {} unless query + + parser = Protocol::URL::FormData::Parser.new + return parser.parse(StringIO.new(query)) + 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[key] = 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..49b2d5da --- /dev/null +++ b/lib/utopia/response.rb @@ -0,0 +1,73 @@ +# 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] The normalized response. + # @raises [TypeError] If the response cannot be normalized. + def self.wrap(response) + return response if response.is_a?(Protocol::HTTP::Response) + + if response.respond_to?(:to_response) + response = response.to_response + return response if response.is_a?(Protocol::HTTP::Response) + end + + raise TypeError, "Expected a Protocol::HTTP::Response, but got #{response.class}!" + 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/lazy_hash.rb b/lib/utopia/session/lazy_hash.rb index 7d5cfa87..e28d7497 100644 --- a/lib/utopia/session/lazy_hash.rb +++ b/lib/utopia/session/lazy_hash.rb @@ -16,6 +16,8 @@ def initialize(&block) @loader = block end + # The loaded session values, if already loaded. + # @returns [Hash | Nil] The loaded values. attr :values # Fetch a value by key, loading the hash if necessary. diff --git a/lib/utopia/session/middleware.rb b/lib/utopia/session/middleware.rb index da4d7905..ab1881c2 100644 --- a/lib/utopia/session/middleware.rb +++ b/lib/utopia/session/middleware.rb @@ -8,8 +8,13 @@ require "console" require "json" +require "protocol/http/cookie" + require_relative "lazy_hash" require_relative "serialization" +require_relative "../middleware" +require_relative "../request" +require_relative "../response" module Utopia module Session @@ -23,7 +28,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: @@ -36,8 +41,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 @@ -93,28 +98,28 @@ def freeze super end - # Attach a lazily loaded session to the Rack environment and persist it after the request. - # @parameter env [Hash] The Rack environment. - # @returns [Array] The Rack response. - def call(env) - session_hash = prepare_session(env) + # Attach a lazily loaded session to the request, then persist it. + # @parameter request [Utopia::Request] The request. + # @returns [Protocol::HTTP::Response] The wrapped application response. + def call(request) + request.session = prepare_session(request) - status, headers, body = @app.call(env) + response = Response.wrap(@app.call(request)) - update_session(env, session_hash, headers) + update_session(request.session, 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) + LazyHash.new do + self.load_session_values(request) end 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 @@ -137,9 +142,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 @@ -183,7 +186,37 @@ 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) + directives = {} + + if domain = cookie[:domain] + directives["Domain"] = domain + end + + if path = cookie[:path] + directives["Path"] = path + end + + if expires = cookie[:expires] + directives["Expires"] = expires.httpdate + end + + if cookie[:secure] + directives["Secure"] = true + end + + if cookie[:http_only] + directives["HttpOnly"] = true + end + + if same_site = cookie[:same_site] + directives["SameSite"] = same_site.to_s.capitalize + end + + return Protocol::HTTP::Cookie.new(name, cookie.fetch(:value), directives).to_s end def encrypt(hash) @@ -197,7 +230,7 @@ def encrypt(hash) e = c.update(@serialization.dump(hash)) e << c.final - return [iv, e].pack("m16m*") + return [iv + e].pack("m0") end def decrypt(data) @@ -205,7 +238,9 @@ def decrypt(data) raise PayloadError, "Session payload size #{data.bytesize}bytes exceeds maximum allowed size #{@maximum_size}bytes!" end - iv, e = data.unpack("m16m*") + payload = data.unpack1("m0") + iv = payload.byteslice(0, 16) + e = payload.byteslice(16..) c = OpenSSL::Cipher.new(CIPHER_ALGORITHM) c.decrypt diff --git a/lib/utopia/shell.rb b/lib/utopia/shell.rb index 78220d79..64d5e044 100644 --- a/lib/utopia/shell.rb +++ b/lib/utopia/shell.rb @@ -3,15 +3,13 @@ # 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 - # Initialize a shell for a Bake context. # @parameter context [Bake::Context] The context that provides the application root. def initialize(context) @@ -22,9 +20,24 @@ def initialize(context) # Load the configured application on first access. # @returns [Application] The application middleware. def app - @app ||= Rack::Builder.parse_file( - File.expand_path("config.ru", @context.root) - ).first + @app ||= Application.load(File.expand_path(Application::PATH, @context.root)) + end + + # Perform a GET request. + # @parameter path [Utopia::Path | String] The path. + # @parameter headers [Hash] The headers. + # @returns [Protocol::HTTP::Response] The application response. + def get(path, headers = nil) + app.call(Protocol::HTTP::Request["GET", path, headers]) + end + + # Perform a POST request. + # @parameter path [Utopia::Path | String] The path. + # @parameter headers [Hash] The headers. + # @parameter body [Protocol::HTTP::Body::Readable | Nil] The request body. + # @returns [Protocol::HTTP::Response] The application response. + def post(path, headers = nil, body = nil) + app.call(Protocol::HTTP::Request["POST", path, headers, body]) end # Convert this object to a string. diff --git a/lib/utopia/static/local_file.rb b/lib/utopia/static/local_file.rb index 919a2cb6..87415764 100644 --- a/lib/utopia/static/local_file.rb +++ b/lib/utopia/static/local_file.rb @@ -6,6 +6,11 @@ require "time" require "digest/sha1" +require "protocol/http/body/file" +require "protocol/http/header/range" + +require_relative "../response" + module Utopia # A middleware which serves static files from the specified root directory. module Static @@ -27,7 +32,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 @@ -75,14 +80,14 @@ def each end # Check whether the file has changed since the request validators. - # @parameter env [Hash] The Rack environment. + # @parameter request [Utopia::Request] The request. # @returns [Boolean] Whether the file is newer than the request validators. - 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 @@ -90,36 +95,46 @@ 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 - # Serve the file, honoring a single byte range when requested. - # @parameter env [Hash] The Rack environment. + # Serve. + # @parameter request [Utopia::Request] The request. # @parameter response_headers [Hash] The response headers. - # @returns [Array] The Rack response. - def serve(env, response_headers) - ranges = Rack::Utils.get_byte_ranges(env["HTTP_RANGE"], size) - response = [200, response_headers, self] + # @returns [Protocol::HTTP::Response] The response. + 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 + body = Protocol::HTTP::Body::File.open(full_path, status == 206 ? @range : nil, size: size) + + return Response[status, response_headers, body] + end + + # Resolve satisfiable byte ranges from the parsed range header. + # @parameter range [Protocol::HTTP::Header::Range | Nil] The parsed range header. + # @returns [Array | Nil] The resulting values, or `nil` if the range is not applicable. + def byte_ranges(range) + return nil unless range&.bytes? + + return range.resolve(size) end end end diff --git a/lib/utopia/static/middleware.rb b/lib/utopia/static/middleware.rb index 5522fe48..f8a60c69 100644 --- a/lib/utopia/static/middleware.rb +++ b/lib/utopia/static/middleware.rb @@ -4,7 +4,8 @@ # Copyright, 2025-2026, by Samuel Williams. require_relative "../middleware" -require_relative "../localization" +require_relative "../request" +require_relative "../response" require_relative "local_file" require_relative "mime_types" @@ -57,11 +58,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 # Build response headers for the given file. # @parameter file [LocalFile] The file. @@ -83,49 +84,49 @@ def response_headers_for(file, content_type) } end - # Serve a static file for the requested path and extension. - # @parameter env [Hash] The Rack environment. - # @parameter path_info [String] The request path. + # Respond. + # @parameter request [Utopia::Request] The request. + # @parameter path_info [String] The request path to serve. # @parameter extension [String] The file extension. - # @returns [Array | Nil] The Rack response when a file is found. - def respond(env, path_info, extension) + # @returns [Protocol::HTTP::Response] The response. + def respond(request, path_info, extension) path = Path[path_info].simplify - if locale = env[Localization::CURRENT_LOCALE_KEY] + if locale = request.locale 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 - # Serve a recognized static file or pass the request downstream. - # @parameter env [Hash] The Rack environment. - # @returns [Array] The static-file or downstream Rack response. - def call(env) - path_info = env[Rack::PATH_INFO] + # Serve a recognized static file or pass the request to the next middleware. + # @parameter request [Utopia::Request] The request. + # @returns [Protocol::HTTP::Response] The static-file or downstream response. + 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/readme.md b/readme.md index cdc0095f..911e964c 100644 --- a/readme.md +++ b/readme.md @@ -8,7 +8,7 @@ Utopia is a website generation framework which provides a robust set of tools to - Designed for both content-based websites and applications. Does not depend on a database. - Supports flexible content localization based on industry recommendations. - - Rack middleware compatible with all major Ruby application servers. Small memory footprint by default. + - Built directly on `protocol-http` with a small application and middleware interface. - Low latency and high throughput. Capable of 10,000+ requests/second out of the box. ## Usage @@ -17,7 +17,7 @@ Please see the [project documentation](https://socketry.github.io/utopia/) for m - [Getting Started](https://socketry.github.io/utopia/guides/getting-started/index) - This guide explains how to set up a `utopia` website for local development and deployment. - - [Middleware](https://socketry.github.io/utopia/guides/middleware/index) - This guide gives an overview of the different Rack middleware used by Utopia. + - [Middleware](https://socketry.github.io/utopia/guides/middleware/index) - This guide gives an overview of Utopia application middleware. - [Server Setup](https://socketry.github.io/utopia/guides/server-setup/index) - This guide explains how to deploy a `utopia` web application. @@ -60,7 +60,6 @@ Please see the [project releases](https://socketry.github.io/utopia/releases/ind - [HTTP::Accept](https://github.com/ioquatix/http-accept) — RFC compliant header parser. - [Samovar](https://github.com/ioquatix/samovar) — Command line parser used by Utopia. - [Mapping](https://github.com/ioquatix/mapping) — Provide structured conversions for web interfaces. - - [Rack::Test::Body](https://github.com/ioquatix/rack-test-body) — Provide convenient helpers for testing web interfaces. ### Examples 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..19d14993 100644 --- a/setup/site/fixtures/website.rb +++ b/setup/site/fixtures/website.rb @@ -3,24 +3,41 @@ # 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" +require "uri" 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_request = Protocol::HTTP::Request["GET", path] + @last_response = app.call(@last_request) + end + + attr :last_request + attr :last_response + + def follow_redirect! + location = last_response.headers["location"] + raise "Cannot follow redirect without a location header!" unless location + + current = URI.join("http://localhost", last_request.path) + target = URI.join(current, location.to_s) + + get(target.request_uri) + end end AValidPage = Sus::Shared("a valid page") do |path| it "can access #{path}" do get path - while last_response.redirect? + while last_response.redirection? follow_redirect! end @@ -31,9 +48,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..c281cf0c --- /dev/null +++ b/test/utopia/application.rb @@ -0,0 +1,97 @@ +# 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 "passes request proxies through 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.delegate).to be_equal(http_request) + expect(application_request.path).to be == http_request.path + + 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_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..a5711ef5 --- /dev/null +++ b/test/utopia/application_middleware.rb @@ -0,0 +1,75 @@ +# 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 request proxies 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(seen_request).to be(:respond_to?, :headers) + 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.session[: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..4a848104 100644 --- a/test/utopia/content/document.rb +++ b/test/utopia/content/document.rb @@ -4,13 +4,24 @@ # Copyright, 2017-2025, by Samuel Williams. require "utopia/content/document" -require "rack/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["GET", path]} let(:document) {subject.new(request, {})} + it "retains the application request" do + expect(document.request).to be == request + expect(document.request.delegate).to be == request.delegate + end + + it "uses the original request path" do + request.path_info = "/rewritten" + + expect(document.request_path).to be == Utopia::Path["/index"] + end + it "should generate valid self-closing markup" do node = proc do |document, state| document.tag("img", src: "cats.jpg") @@ -50,7 +61,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..46ec3583 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) do |connection| connection.write({type: "test", data: "Hello World"}.to_json) end diff --git a/test/utopia/controller/base.rb b/test/utopia/controller/base.rb new file mode 100644 index 00000000..ac666c16 --- /dev/null +++ b/test/utopia/controller/base.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/http/request" +require "protocol/multipart/form_data" +require "utopia/controller/base" +require "utopia/request" + +require "stringio" + +describe Utopia::Controller::Base do + let(:controller) {subject.new} + + it "parses request bodies independently of the request method" do + request = Utopia::Request[ + "QUERY", + "/search", + {"content-type" => "application/json"}, + Protocol::HTTP::Body::Buffered.wrap('{"name":"Samuel"}') + ] + + expect(controller.parse_body(request)).to be == {"name" => "Samuel"} + end + + it "returns nil when there is no request body" do + request = Utopia::Request["GET", "/"] + + expect(controller.parse_body(request)).to be_nil + end + + it "supports action-specific content parsers" do + parser = Protocol::Content::Parser.build do |parser| + parser.register("application/example") do |input| + input.read.upcase + end + end + + request = Utopia::Request[ + "POST", + "/", + {"content-type" => "application/example"}, + Protocol::HTTP::Body::Buffered.wrap("parsed") + ] + + expect(controller.parse_body(request, parser: parser)).to be == "PARSED" + end + + it "propagates content parsing errors" do + request = Utopia::Request[ + "POST", + "/", + {"content-type" => "application/octet-stream"}, + Protocol::HTTP::Body::Buffered.wrap("data") + ] + + expect do + controller.parse_body(request) + end.to raise_exception(Protocol::Content::UnsupportedMediaTypeError) + end + + it "streams multipart uploads through the parse block" do + form = Protocol::Multipart::FormData.new + form.add_field("user[name]", "Samuel") + form.parts << Protocol::Multipart::StringPart.new( + { + "content-disposition" => 'form-data; name="avatar"; filename="samuel.txt"', + "content-type" => "text/plain" + }, + "Hello!" + ) + + body = StringIO.new + form.call(body) + request = Utopia::Request[ + "POST", + "/", + form.headers, + Protocol::HTTP::Body::Buffered.wrap(body.string) + ] + uploads = {} + + result = controller.parse_body(request) do |name, value| + case value + when Protocol::Multipart::FormData::Upload + content = String.new.b + value.each{|chunk| content << chunk} + uploads[name] = content + value.filename + else + value + end + end + + expect(result).to be == { + "user" => {"name" => "Samuel"}, + "avatar" => "samuel.txt" + } + expect(uploads).to be == {"avatar" => "Hello!"} + end +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..b2a270fc 100644 --- a/test/utopia/controller/respond.rb +++ b/test/utopia/controller/respond.rb @@ -3,13 +3,12 @@ # Released under the MIT License. # Copyright, 2016-2025, by Samuel Williams. -require "rack/test" -require "rack/mock" require "json" - 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 +34,58 @@ 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["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" - - 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 == "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/*" + response = controller.process!(request, relative_path) - status, headers, body = controller.process!(request, relative_path) + 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 + + it "should select the highest quality response" do + request, path = mock_request("/fetch", {"accept" => "text/plain;q=0.5, application/json;q=1.0"}) + relative_path = path - controller.class.uri_path - expect(status).to be == 200 - expect(headers["content-type"]).to be == "text/plain" - expect(body.join).to be == {user_id: 10}.to_s + response = controller.process!(request, relative_path) + + expect(response.headers["content-type"]).to be == "application/json" + expect(response.read).to be == '{"user_id":10}' 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 +95,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 +105,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 +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":"Hello World"}' + expect(body).to be == '{"message":"Hello World"}' end it "should get version 2 response" do @@ -110,7 +125,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 +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 == "{}" + expect(body).to be == "{}" end it "should give record as JSON" do @@ -129,7 +144,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 +154,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..cbce008d 100644 --- a/test/utopia/controller/rewrite.rb +++ b/test/utopia/controller/rewrite.rb @@ -3,8 +3,8 @@ # Released under the MIT License. # Copyright, 2015-2025, by Samuel Williams. -require "rack/mock" require "utopia/controller" +require "utopia/request" describe Utopia::Controller do class TestController < Utopia::Controller::Base @@ -32,8 +32,9 @@ 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["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..d999251e 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,19 @@ def initialize describe Utopia::Controller do let(:variables) {Utopia::Controller::Variables.new} + let(:request) {Protocol::HTTP::Request["GET", "/"]} 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_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_response.read).to be == "Bad Request" result = controller.process!(request, Utopia::Path["variable"]) expect(result).to be == nil @@ -75,7 +77,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 +85,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 +94,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 +103,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..ecc2b7ce 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,17 +44,12 @@ def copy_instance_variables(from) end describe Utopia::Controller do - it "returns variables from request env" do - variables = Utopia::Controller::Variables.new - request = Rack::Request.new(Utopia::VARIABLES_KEY => variables) + it "returns variables from the request" do + request = Utopia::Request["GET", "/"] + controller_variables = Utopia::Controller::Variables.new + request.variables = controller_variables - expect(Utopia::Controller[request]).to be == variables - end - - it "returns nil when variables are not set" do - request = Rack::Request.new({}) - - expect(Utopia::Controller[request]).to be_nil + expect(Utopia::Controller[request]).to be_equal(controller_variables) end end 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/controller.rb b/test/utopia/exceptions/.handler/controller.rb index e3174d14..69c80de3 100644 --- a/test/utopia/exceptions/.handler/controller.rb +++ b/test/utopia/exceptions/.handler/controller.rb @@ -14,9 +14,9 @@ class TharSheBlows < StandardError # The ExceptionHandler middleware will redirect here when an exception occurs. If this also fails, things get ugly. on 'exception' do |request| - if request.params['fatal'] + if request.query_arguments["fatal"] raise TharSheBlows.new("Yarrh!") else - succeed! :content => 'Error Will Robertson', :type => 'text/plain' + succeed! :content => "Error: #{request.exception.message}", :type => 'text/plain' end end diff --git a/test/utopia/exceptions/handler.rb b/test/utopia/exceptions/handler.rb index 00d94663..3e03de80 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: Arrrh!") 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..bb6873b1 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,24 @@ 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 + + it "extracts rewindable request bodies" do + request = Utopia::Request["POST", "/", {}, ["Hello", " World!"]] + request.body.read + mailer = subject.new(->(_request){}, delivery_method: nil) + + expect(mailer.send(:extract_body, request)).to be == "Hello World!" + end + + it "does not extract streaming request bodies" do + body = Object.new + def body.rewindable? = false + request = Struct.new(:body).new(body) + mailer = subject.new(->(_request){}, delivery_method: nil) + + expect(mailer.send(:extract_body, request)).to be_nil + 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/http/status.rb b/test/utopia/http/status.rb index 27ffbce3..963dc8c4 100644 --- a/test/utopia/http/status.rb +++ b/test/utopia/http/status.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2016-2025, by Samuel Williams. +# Copyright, 2016-2026, by Samuel Williams. require "utopia/http" @@ -14,13 +14,20 @@ expect(subject.to_s).to be == "Found" end - it "can be used as a response body" do - body = subject.to_enum(:each).next - expect(body).to be == "Found" - end end describe Utopia::HTTP::Status do + it "provides descriptions for standard status codes" do + expect(Utopia::HTTP::Status.new(103).to_s).to be == "Early Hints" + expect(Utopia::HTTP::Status.new(418).to_s).to be == "I'm a Teapot" + expect(Utopia::HTTP::Status.new(429).to_s).to be == "Too Many Requests" + expect(Utopia::HTTP::Status.new(502).to_s).to be == "Bad Gateway" + end + + it "uses the numeric code when no description exists" do + expect(Utopia::HTTP::Status.new(444).to_s).to be == "444" + end + it "should fail when given invalid code" do expect{Utopia::HTTP::Status.new(1000)}.to raise_exception(ArgumentError) end diff --git a/test/utopia/localization.rb b/test/utopia/localization.rb index b083c577..914041c4 100755 --- a/test/utopia/localization.rb +++ b/test/utopia/localization.rb @@ -3,70 +3,107 @@ # 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 + + it "preserves the final failure response" do + closed = false + first_body = Protocol::HTTP::Body::Buffered.wrap(["First failure"]) + first_body.define_singleton_method(:close) do |error = nil| + closed = true + super(error) + end + + calls = 0 + application = Utopia::Application.build(lambda do |_request| + calls += 1 + + if calls == 1 + Utopia::Response[404, {}, first_body] + else + Utopia::Response.text("Final failure", 404) + end + end) do + use Utopia::Localization, locales: ["en"], default_locale: "en" + end + + response = application.call(Protocol::HTTP::Request["GET", "/missing"]) + + expect(closed).to be == true + expect(response.status).to be == 404 + expect(response.read).to be == "Final failure" 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..a103b822 --- /dev/null +++ b/test/utopia/request.rb @@ -0,0 +1,141 @@ +# 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(:request) {subject["POST", "/search?q=utopia&tag[]=ruby&tag[]=async", {"cookie" => "a=1; b=2"}]} + + it "proxies a protocol HTTP request" do + expect(request.delegate).to be_a(Protocol::HTTP::Request) + expect(request.headers).to be_equal(request.delegate.headers) + expect(request.to_s).to be == request.delegate.to_s + expect(request).to be(:respond_to?, :headers) + expect(request).to be(:respond_to?, :scheme=) + end + + it "duplicates the underlying protocol request" do + copy = request.dup + copy.path = "/copy" + + expect(copy.delegate).not.to be_equal(request.delegate) + expect(copy.path).to be == "/copy" + expect(request.path).to be == "/search?q=utopia&tag[]=ruby&tag[]=async" + end + + it "does not proxy unknown methods" do + expect(request).not.to be(:respond_to?, :unknown_request_method) + expect{request.unknown_request_method}.to raise_exception(NoMethodError) + end + + it "provides path information" do + expect(request.path_info).to be == "/search" + expect(request.query).to be == "q=utopia&tag[]=ruby&tag[]=async" + end + + it "updates path information while preserving query string" do + request.path_info = "/find" + + expect(request.path).to be == "/find?q=utopia&tag[]=ruby&tag[]=async" + expect(request.path_info).to be == "/find" + expect(request.request_path).to be == "/search" + end + + it "identifies POST requests" do + expect(request.post?).to be == true + + request.method = "GET" + expect(request.post?).to be == false + end + + it "provides decoded query arguments" do + expect(request.query_arguments).to be == { + "q" => "utopia", + "tag" => ["ruby", "async"] + } + end + + it "provides nested query arguments" do + request.path = "/search?user[name]=Samuel&query=hello+world" + + expect(request.query_arguments).to be == { + "user" => {"name" => "Samuel"}, + "query" => "hello world" + } + end + + it "distinguishes absent and empty query values" do + request.path = "/search?absent&empty=" + + expect(request.query_arguments).to be == {"absent" => nil, "empty" => ""} + end + + it "does not expose ambiguous Rack request accessors" do + expect(request).not.to be(:respond_to?, :params) + expect(request).not.to be(:respond_to?, :[]) + expect(request).not.to be(:respond_to?, :arguments) + expect(request).not.to be(:respond_to?, :form_arguments) + expect(request).not.to be(:respond_to?, :parsed_body) + end + + it "provides decoded cookies" do + expect(request.cookies).to be == {"a" => "1", "b" => "2"} + end + + it "preserves cookie values without applying form decoding" do + request.headers["cookie"] = "plus=a+b; encoded=%2F" + + expect(request.cookies).to be == {"plus" => "a+b", "encoded" => "%2F"} + end + + it "has no application state by default" do + expect(request.session).to be_nil + expect(request.variables).to be_nil + expect(request.locale).to be_nil + expect(request.exception).to be_nil + end + + it "provides request metadata" do + request.scheme = "https" + request.authority = "example.com" + request.headers["referer"] = "/from" + + expect(request.scheme).to be == "https" + expect(request.host).to be == "example.com" + expect(request.url).to be == "https://example.com/search?q=utopia&tag[]=ruby&tag[]=async" + expect(request.referrer).to be == "/from" + end + + it "builds derived requests" do + session = Object.new + variables = Object.new + request.session = session + request.variables = variables + request.locale = "en" + exception = StandardError.new("Boom") + request.exception = exception + + derived = request.with(method: "GET", path_info: "/find") + + expect(derived).not.to be_equal(request) + expect(derived.method).to be == "GET" + expect(derived.path).to be == "/find?q=utopia&tag[]=ruby&tag[]=async" + expect(derived.request_path).to be == "/search" + expect(derived.delegate).not.to be_equal(request.delegate) + expect(derived.session).to be_equal(session) + expect(derived.variables).to be_equal(variables) + expect(derived.locale).to be == "en" + expect(derived.exception).to be_equal(exception) + end + + it "preserves the original request path across multiple derived requests" do + derived = request.with(path_info: "/find") + derived = derived.with(path_info: "/lookup") + + expect(derived.path_info).to be == "/lookup" + expect(derived.request_path).to be == "/search" + end +end diff --git a/test/utopia/response.rb b/test/utopia/response.rb new file mode 100644 index 00000000..d8f8094e --- /dev/null +++ b/test/utopia/response.rb @@ -0,0 +1,39 @@ +# 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 + + it "rejects unsupported responses" do + expect{subject.wrap(Object.new)}.to raise_exception(TypeError) + end + + it "rejects invalid converted responses" do + response = Object.new + def response.to_response = nil + + expect{subject.wrap(response)}.to raise_exception(TypeError) + end +end diff --git a/test/utopia/session.rb b/test/utopia/session.rb index fb706721..884efad0 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.query_arguments["key"].to_sym] = request.query_arguments["value"] + + Utopia::Response[200, {}, []] + when "/session-get" + Utopia::Response[200, {}, [request.session[request.query_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,65 @@ 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") + expect(last_response.headers["set-cookie"].first).not.to be(:include?, "%") 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.query_arguments["key"].to_sym] = request.query_arguments["value"] + + Utopia::Response[200, {}, []] + when "/session-get" + Utopia::Response[200, {}, [request.session[request.query_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 +117,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 +125,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 +142,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..1414cac8 100755 --- a/test/utopia/static.rb +++ b/test/utopia/static.rb @@ -3,27 +3,76 @@ # 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" expect(last_response.headers["content-type"]).to be == "text/plain" + expect(last_response.body).to be_a(Protocol::HTTP::Body::File) 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(body.bytesize).to be == 4 + expect(body).to be == "ello" + end + + it "should clamp partial content to the file size" do + get "/test.txt", {"range" => "bytes=1-999"} + + expect(last_response.status).to be == 206 + expect(last_response.headers["content-range"]).to be == "bytes 1-11/12" + expect(body).to be == "ello World!" + end + + it "should clamp suffix ranges to the file size" do + get "/test.txt", {"range" => "bytes=-999"} expect(last_response.status).to be == 206 - expect(last_response.content_length).to be == 4 - expect(last_response.body).to be == "ello" + expect(last_response.headers["content-range"]).to be == "bytes 0-11/12" + expect(body).to be == "Hello World!" + end + + it "should ignore unsatisfiable ranges" do + get "/test.txt", {"range" => "bytes=999-1000"} + + expect(last_response.status).to be == 200 + expect(body).to be == "Hello World!" + end + + it "should ignore multiple ranges" do + get "/test.txt", {"range" => "bytes=0-1,4-5"} + + expect(last_response.status).to be == 200 + expect(body).to be == "Hello World!" + end + + it "should ignore unsupported range units" do + get "/test.txt", {"range" => "example=alpha"} + + expect(last_response.status).to be == 200 + expect(body).to be == "Hello World!" + end + + it "should reject malformed ranges" do + expect do + get "/test.txt", {"range" => "bytes=4-1"} + end.to raise_exception(Protocol::HTTP::Header::Range::ParseError) 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..2fcfb842 100644 --- a/utopia.gemspec +++ b/utopia.gemspec @@ -34,8 +34,9 @@ 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-url", "~> 0.4" - spec.add_dependency "rack", "~> 3.0" + spec.add_dependency "protocol-content", "~> 0.1" + spec.add_dependency "protocol-http", "~> 0.68" + spec.add_dependency "protocol-url", "~> 0.10" spec.add_dependency "samovar", "~> 2.1" spec.add_dependency "traces", "~> 0.10" spec.add_dependency "variant", "~> 0.1"