From 6b884dd4ea58446208eba7e8ea229e74b12e8ba2 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Thu, 19 Dec 2024 13:14:25 +0000 Subject: [PATCH 01/72] Add ability to add handlers for raised exceptions (#688) Add ability to add handlers for raised exceptions. Closes #622 --- spec/exception_handler_spec.cr | 93 ++++++++++++++++++++++++++++++++++ spec/spec_helper.cr | 6 +++ src/kemal/config.cr | 23 +++++++-- src/kemal/dsl.cr | 6 +++ src/kemal/exception_handler.cr | 29 +++++++++++ 5 files changed, 152 insertions(+), 5 deletions(-) diff --git a/spec/exception_handler_spec.cr b/spec/exception_handler_spec.cr index 7064e84..5aa2278 100644 --- a/spec/exception_handler_spec.cr +++ b/spec/exception_handler_spec.cr @@ -59,6 +59,99 @@ describe "Kemal::ExceptionHandler" do response.body.should eq "Something happened" end + it "renders custom error for a crystal exception" do + error RuntimeError do + "A RuntimeError has occured" + end + + get "/" do + raise RuntimeError.new + end + + request = HTTP::Request.new("GET", "/") + io = IO::Memory.new + response = HTTP::Server::Response.new(io) + context = HTTP::Server::Context.new(request, response) + Kemal::ExceptionHandler::INSTANCE.next = Kemal::RouteHandler::INSTANCE + Kemal::ExceptionHandler::INSTANCE.call(context) + response.close + io.rewind + response = HTTP::Client::Response.from_io(io, decompress: false) + response.status_code.should eq 500 + response.headers["Content-Type"].should eq "text/html" + response.body.should eq "A RuntimeError has occured" + end + + it "renders custom error for a custom exception" do + error CustomExceptionType do + "A custom exception of CustomExceptionType has occurred" + end + + get "/" do + raise CustomExceptionType.new + end + + request = HTTP::Request.new("GET", "/") + io = IO::Memory.new + response = HTTP::Server::Response.new(io) + context = HTTP::Server::Context.new(request, response) + Kemal::ExceptionHandler::INSTANCE.next = Kemal::RouteHandler::INSTANCE + Kemal::ExceptionHandler::INSTANCE.call(context) + response.close + io.rewind + response = HTTP::Client::Response.from_io(io, decompress: false) + response.status_code.should eq 500 + response.headers["Content-Type"].should eq "text/html" + response.body.should eq "A custom exception of CustomExceptionType has occurred" + end + + it "renders custom error for a custom exception with a specific HTTP status code" do + error CustomExceptionType do |env| + env.response.status_code = 503 + "A custom exception of CustomExceptionType has occurred" + end + + get "/" do + raise CustomExceptionType.new + end + + request = HTTP::Request.new("GET", "/") + io = IO::Memory.new + response = HTTP::Server::Response.new(io) + context = HTTP::Server::Context.new(request, response) + Kemal::ExceptionHandler::INSTANCE.next = Kemal::RouteHandler::INSTANCE + Kemal::ExceptionHandler::INSTANCE.call(context) + response.close + io.rewind + response = HTTP::Client::Response.from_io(io, decompress: false) + response.status_code.should eq 503 + response.headers["Content-Type"].should eq "text/html" + response.body.should eq "A custom exception of CustomExceptionType has occurred" + end + + it "renders custom error for a child of a custom exception" do + error CustomExceptionType do |env, error| + "A custom exception of #{error.class} has occurred" + end + + get "/" do + raise ChildCustomExceptionType.new + end + + request = HTTP::Request.new("GET", "/") + io = IO::Memory.new + response = HTTP::Server::Response.new(io) + context = HTTP::Server::Context.new(request, response) + Kemal::ExceptionHandler::INSTANCE.next = Kemal::RouteHandler::INSTANCE + Kemal::ExceptionHandler::INSTANCE.call(context) + response.close + io.rewind + response = HTTP::Client::Response.from_io(io, decompress: false) + response.status_code.should eq 500 + response.headers["Content-Type"].should eq "text/html" + response.body.should eq "A custom exception of ChildCustomExceptionType has occurred" + end + it "overrides the content type for filters" do before_get do |env| env.response.content_type = "application/json" diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 6509e1e..8ce02fa 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -26,6 +26,12 @@ class AnotherContextStorageType @name = "kemal-context" end +class CustomExceptionType < Exception +end + +class ChildCustomExceptionType < CustomExceptionType +end + add_context_storage_type(TestContextStorageType) add_context_storage_type(AnotherContextStorageType) diff --git a/src/kemal/config.cr b/src/kemal/config.cr index b079c7b..58e52f8 100644 --- a/src/kemal/config.cr +++ b/src/kemal/config.cr @@ -8,11 +8,12 @@ module Kemal # Kemal.config # ``` class Config - INSTANCE = Config.new - HANDLERS = [] of HTTP::Handler - CUSTOM_HANDLERS = [] of Tuple(Nil | Int32, HTTP::Handler) - FILTER_HANDLERS = [] of HTTP::Handler - ERROR_HANDLERS = {} of Int32 => HTTP::Server::Context, Exception -> String + INSTANCE = Config.new + HANDLERS = [] of HTTP::Handler + CUSTOM_HANDLERS = [] of Tuple(Nil | Int32, HTTP::Handler) + FILTER_HANDLERS = [] of HTTP::Handler + ERROR_HANDLERS = {} of Int32 => HTTP::Server::Context, Exception -> String + EXCEPTION_HANDLERS = {} of Exception.class => HTTP::Server::Context, Exception -> String {% if flag?(:without_openssl) %} @ssl : Bool? @@ -88,14 +89,26 @@ module Kemal FILTER_HANDLERS << handler end + # Returns the defined error handlers for HTTP status codes def error_handlers ERROR_HANDLERS end + # Adds an error handler for the given HTTP status code def add_error_handler(status_code : Int32, &handler : HTTP::Server::Context, Exception -> _) ERROR_HANDLERS[status_code] = ->(context : HTTP::Server::Context, error : Exception) { handler.call(context, error).to_s } end + # Returns the defined error handlers for exceptions + def exception_handlers + EXCEPTION_HANDLERS + end + + # Adds an error handler for the given exception + def add_exception_handler(exception : Exception.class, &handler : HTTP::Server::Context, Exception -> _) + EXCEPTION_HANDLERS[exception] = ->(context : HTTP::Server::Context, error : Exception) { handler.call(context, error).to_s } + end + def extra_options(&@extra_options : OptionParser ->) end diff --git a/src/kemal/dsl.cr b/src/kemal/dsl.cr index 50fa51b..202dca8 100644 --- a/src/kemal/dsl.cr +++ b/src/kemal/dsl.cr @@ -21,10 +21,16 @@ def ws(path : String, &block : HTTP::WebSocket, HTTP::Server::Context -> Void) Kemal::WebSocketHandler::INSTANCE.add_route path, &block end +# Defines an error handler to be called when route returns the given HTTP status code def error(status_code : Int32, &block : HTTP::Server::Context, Exception -> _) Kemal.config.add_error_handler status_code, &block end +# Defines an error handler to be called when the given exception is raised +def error(exception : Exception.class, &block : HTTP::Server::Context, Exception -> _) + Kemal.config.add_exception_handler exception, &block +end + # All the helper methods available are: # - before_all, before_get, before_post, before_put, before_patch, before_delete, before_options # - after_all, after_get, after_post, after_put, after_patch, after_delete, after_options diff --git a/src/kemal/exception_handler.cr b/src/kemal/exception_handler.cr index eee6eec..714651b 100644 --- a/src/kemal/exception_handler.cr +++ b/src/kemal/exception_handler.cr @@ -11,12 +11,41 @@ module Kemal rescue ex : Kemal::Exceptions::CustomException call_exception_with_status_code(context, ex, context.response.status_code) rescue ex : Exception + # Matches an error handler for the given exception + # + # Matches based on order of declaration rather than inheritance relationship + # for child exceptions + Kemal.config.exception_handlers.each do |expected_exception, handler| + if ex.class <= expected_exception + return call_exception_with_exception(context, ex, handler, 500) + end + end + log("Exception: #{ex.inspect_with_backtrace}") + # Else use generic 500 handler if defined return call_exception_with_status_code(context, ex, 500) if Kemal.config.error_handlers.has_key?(500) verbosity = Kemal.config.env == "production" ? false : true render_500(context, ex, verbosity) end + # Calls the given error handler with the current exception + # + # The logic for validating that the current exception should be handled + # by the given error handler should be done by the caller of this method. + private def call_exception_with_exception( + context : HTTP::Server::Context, + exception : Exception, + handler : Proc(HTTP::Server::Context, Exception, String), + status_code : Int32 = 500, + ) + return if context.response.closed? + + context.response.content_type = "text/html" unless context.response.headers.has_key?("Content-Type") + context.response.status_code = status_code + context.response.print handler.call(context, exception) + context + end + private def call_exception_with_status_code(context : HTTP::Server::Context, exception : Exception, status_code : Int32) return if context.response.closed? if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(status_code) From a9324bee6ffe01e4f5629e7afcde8f590a8eabb4 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Fri, 20 Dec 2024 07:46:06 +0000 Subject: [PATCH 02/72] Fix Lint/UnusedArgument in custom exception spec (#699) --- spec/exception_handler_spec.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/exception_handler_spec.cr b/spec/exception_handler_spec.cr index 5aa2278..f00621a 100644 --- a/spec/exception_handler_spec.cr +++ b/spec/exception_handler_spec.cr @@ -130,7 +130,7 @@ describe "Kemal::ExceptionHandler" do end it "renders custom error for a child of a custom exception" do - error CustomExceptionType do |env, error| + error CustomExceptionType do |_, error| "A custom exception of #{error.class} has occurred" end From 5359781a64a4baaa93064cfd919ca8d82ff3a9fe Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:03:29 +0300 Subject: [PATCH 03/72] Improve README --- README.md | 112 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 70 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index e0ab8ca..7da45e2 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,24 @@ - [![Kemal](https://avatars3.githubusercontent.com/u/15321198?v=3&s=200)](http://kemalcr.com) # Kemal -Lightning Fast, Super Simple web framework. +Kemal is the Fast, Effective, Simple Web Framework for the Crystal. It's perfect for building Web Applications and APIs with minimal code. [![CI](https://github.com/kemalcr/kemal/actions/workflows/ci.yml/badge.svg)](https://github.com/kemalcr/kemal/actions/workflows/ci.yml) [![Join the chat at https://gitter.im/sdogruyol/kemal](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/sdogruyol/kemal?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) -# Super Simple โšก๏ธ +## Why Kemal? -```ruby -require "kemal" +- ๐Ÿš€ **Lightning Fast**: Built on Crystal, known for C-like performance +- ๐Ÿ’ก **Super Simple**: Minimal code needed to get started +- ๐Ÿ›  **Feature Rich**: Everything you need for modern web development +- ๐Ÿ”ง **Flexible**: Easy to extend with middleware support -# Matches GET "http://host:port/" -get "/" do - "Hello World!" -end +## Quick Start -# Creates a WebSocket handler. -# Matches "ws://host:port/socket" -ws "/socket" do |socket| - socket.send "Hello from Kemal!" -end +1. First, make sure you have [Crystal installed](https://crystal-lang.org/install/). -Kemal.run -``` - -Start your application! - -``` -crystal src/kemal_sample.cr -``` -Go to *http://localhost:3000* - -Check [documentation](http://kemalcr.com) or [samples](https://github.com/kemalcr/kemal/tree/master/samples) for more. - -# Installation - -Add this to your application's `shard.yml`: +2. Add Kemal to your project's `shard.yml`: ```yaml dependencies: @@ -46,22 +26,70 @@ dependencies: github: kemalcr/kemal ``` -See also [Getting Started](http://kemalcr.com/guide/). +3. Create your first Kemal app: -# Features +```crystal +require "kemal" -- Support all REST verbs -- Websocket support -- Request/Response context, easy parameter handling -- Middleware support -- Built-in JSON support -- Built-in static file serving -- Built-in view templating via [ECR](https://crystal-lang.org/api/ECR.html) +# Basic route - responds to GET "http://localhost:3000/" +get "/" do + "Hello World!" +end -# Documentation +# JSON API example +get "/api/status" do |env| + env.response.content_type = "application/json" + {"status": "ok"}.to_json +end -You can read the documentation at the official site [kemalcr.com](http://kemalcr.com) +# WebSocket support +ws "/chat" do |socket| + socket.send "Hello from Kemal WebSocket!" +end -## Thanks +Kemal.run +``` -Thanks to Manas for their awesome work on [Frank](https://github.com/manastech/frank). +4. Run your application: + +```bash +crystal run src/your_app.cr +``` + +5. Visit [http://localhost:3000](http://localhost:3000) - That's it! ๐ŸŽ‰ + +## Key Features + +- โœ… **Full REST Support**: Handle all HTTP verbs (GET, POST, PUT, DELETE, etc.) +- ๐Ÿ”Œ **WebSocket Support**: Real-time bidirectional communication +- ๐Ÿ“ฆ **Built-in JSON Support**: Native JSON handling +- ๐Ÿ—„๏ธ **Static File Serving**: Serve your static assets easily +- ๐Ÿ“ **Template Support**: Built-in ECR template engine +- ๐Ÿ”’ **Middleware System**: Add functionality with middleware +- ๐ŸŽฏ **Request/Response Context**: Easy parameter and request handling + +## Learning Resources + +- ๐Ÿ“š [Official Documentation](http://kemalcr.com) +- ๐Ÿ’ป [Sample Applications](https://github.com/kemalcr/kemal/tree/master/samples) +- ๐Ÿš€ [Getting Started Guide](http://kemalcr.com/guide/) +- ๐Ÿ’ฌ [Community Chat](https://discord.gg/prSVAZJEpz) + + +## Contributing + +We love contributions! If you'd like to contribute: + +1. Fork it (https://github.com/kemalcr/kemal/fork) +2. Create your feature branch (git checkout -b my-new-feature) +3. Commit your changes (git commit -am 'Add some feature') +4. Push to the branch (git push origin my-new-feature) +5. Create a new Pull Request + +## Acknowledgments + +Special thanks to Manas for their work on [Frank](https://github.com/manastech/frank). + +## License + +Kemal is released under the MIT License. From 369371bb83e06570dc6964c0e164ffe3aadd6367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Wed, 15 Jan 2025 15:44:39 +0300 Subject: [PATCH 04/72] Add all_files to support multiple file uploads in names ending with [] (#701) --- src/kemal/param_parser.cr | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/kemal/param_parser.cr b/src/kemal/param_parser.cr index 5d87ba0..8d93776 100644 --- a/src/kemal/param_parser.cr +++ b/src/kemal/param_parser.cr @@ -9,13 +9,14 @@ module Kemal PARTS = %w(url query body json files) # :nodoc: alias AllParamTypes = Nil | String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any) - getter files + getter files, all_files def initialize(@request : HTTP::Request, @url : Hash(String, String) = {} of String => String) @query = HTTP::Params.new({} of String => Array(String)) @body = HTTP::Params.new({} of String => Array(String)) @json = {} of String => AllParamTypes @files = {} of String => FileUpload + @all_files = {} of String => Array(FileUpload) @url_parsed = false @query_parsed = false @body_parsed = false @@ -71,11 +72,17 @@ module Kemal next unless upload filename = upload.filename + name = upload.name if !filename.nil? - @files[upload.name] = FileUpload.new(upload) + if name.ends_with?("[]") + @all_files[name] ||= [] of FileUpload + @all_files[name] << FileUpload.new(upload) + else + @files[name] = FileUpload.new(upload) + end else - @body.add(upload.name, upload.body.gets_to_end) + @body.add(name, upload.body.gets_to_end) end end From bf249fe507722f0e3866e270ee07065532e27872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Wed, 22 Jan 2025 14:42:36 +0300 Subject: [PATCH 05/72] Update README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 7da45e2..aee6fb4 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,9 @@ # Kemal -Kemal is the Fast, Effective, Simple Web Framework for the Crystal. It's perfect for building Web Applications and APIs with minimal code. +Kemal is the Fast, Effective, Simple Web Framework for Crystal. It's perfect for building Web Applications and APIs with minimal code. [![CI](https://github.com/kemalcr/kemal/actions/workflows/ci.yml/badge.svg)](https://github.com/kemalcr/kemal/actions/workflows/ci.yml) -[![Join the chat at https://gitter.im/sdogruyol/kemal](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/sdogruyol/kemal?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) ## Why Kemal? From 75f9cd9c0eb0c3d2677fbd9dadde9c85d92c0143 Mon Sep 17 00:00:00 2001 From: Todd Sundsted Date: Thu, 13 Feb 2025 03:46:01 -0500 Subject: [PATCH 06/72] Return after `redirect_to`. (#702) See: https://github.com/crystal-lang/crystal/blob/master/src/http/server/handlers/static_file_handler.cr#L80 Co-authored-by: Todd Sundsted --- src/kemal/static_file_handler.cr | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/kemal/static_file_handler.cr b/src/kemal/static_file_handler.cr index 3b4cf0e..1c04adb 100644 --- a/src/kemal/static_file_handler.cr +++ b/src/kemal/static_file_handler.cr @@ -40,8 +40,10 @@ module Kemal if request_path != expanded_path redirect_to context, expanded_path + return elsif is_dir && !is_dir_path redirect_to context, expanded_path + '/' + return end if is_dir From d4af7e216daa22111a6f906cbcdde280646ba9bf Mon Sep 17 00:00:00 2001 From: Hugo Parente Lima Date: Mon, 17 Mar 2025 14:41:35 -0300 Subject: [PATCH 07/72] Use ameba github actions. (#703) Replace `format` and `ameba` jobs by a github action. --- .github/workflows/ci.yml | 31 ++++--------------------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f473bda..3bb6264 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,27 +30,6 @@ jobs: - name: Run specs run: | crystal spec - - format: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest] - crystal: [latest, nightly] - runs-on: ${{ matrix.os }} - - steps: - - name: Install Crystal - uses: crystal-lang/install-crystal@v1 - with: - crystal: ${{ matrix.crystal }} - - - name: Download source - uses: actions/checkout@v4 - - - name: Check formatting - run: crystal tool format --check - ameba: strategy: fail-fast: false @@ -68,9 +47,7 @@ jobs: - name: Download source uses: actions/checkout@v4 - - name: Install dependencies - run: shards install - - - name: Run ameba linter - run: bin/ameba - \ No newline at end of file + - name: Crystal Ameba Linter + uses: crystal-ameba/github-action@v0.12.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 4352774968e2052b011b349a5f507d6db17cb8a6 Mon Sep 17 00:00:00 2001 From: Hugo Parente Lima Date: Sat, 22 Mar 2025 08:15:57 -0300 Subject: [PATCH 08/72] Remove ameba developer dependency. (#704) Since d4af7e216daa22111a6f906cbcdde280646ba9bf added ameba as a github action, there's no need to include ameba as developer dependency, people that still want to run ameba locally can just install it in their system. This would remove the post-install in kemal and make some guys happy at https://forum.crystal-lang.org/t/shards-postinstall-considered-harmful/3910 --- shard.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/shard.yml b/shard.yml index d0371c4..4aa2245 100644 --- a/shard.yml +++ b/shard.yml @@ -12,10 +12,6 @@ dependencies: github: crystal-loot/exception_page version: ~> 0.5.0 -development_dependencies: - ameba: - github: crystal-ameba/ameba - crystal: ">= 0.36.0" license: MIT From 19d3913b5d12a7efcbd251dbdca20716e4ab95cf Mon Sep 17 00:00:00 2001 From: Hugo Parente Lima Date: Tue, 1 Apr 2025 07:25:09 -0300 Subject: [PATCH 09/72] Embrace Crystal standard Log for logging. (#705) * Embrace Crystal standard Log for logging. Kemal uses a LogHandler to log requests, this code predates the Crystal Log class so while the Kemal documentation says that logging is done using Log class, the http request log is done in a different way. This patch deprecates: - Kemal::Config#logger - Kemal::Config#logger=(Kemal::BaseLogHandler) - log(String) - Kemal::LogHandler.initialize(IO) - NullLogHandler and changes: - Add Kemal::Log (Log = ::Log.for(self)) - Kemal::LogHandler now uses Log. - No handler is created if logging is set to false. Old code using custom log handlers must work as before. * Let ExceptionHandler use Log instead of log. * Deprecate Kemal::LogHandler and adds Kemal::RequestLogHandler. * Don't break API on Kemal#logger. * Add test for Kemal::RequestLogHandler. * Do not log redundant informations like timestamp and exception message. * Use ex.message on unexpected exceptions log message. --- spec/config_spec.cr | 2 +- spec/helpers_spec.cr | 4 ++-- spec/log_handler_spec.cr | 7 ------- spec/request_log_handler_spec.cr | 17 +++++++++++++++++ src/kemal.cr | 9 ++++++--- src/kemal/config.cr | 20 +++++++++++++------- src/kemal/exception_handler.cr | 2 +- src/kemal/helpers/helpers.cr | 9 ++++++++- src/kemal/log_handler.cr | 1 + src/kemal/null_log_handler.cr | 1 + src/kemal/request_log_handler.cr | 20 ++++++++++++++++++++ 11 files changed, 70 insertions(+), 22 deletions(-) create mode 100644 spec/request_log_handler_spec.cr create mode 100644 src/kemal/request_log_handler.cr diff --git a/spec/config_spec.cr b/spec/config_spec.cr index 11aa6d3..a7fc35c 100644 --- a/spec/config_spec.cr +++ b/spec/config_spec.cr @@ -29,7 +29,7 @@ describe "Config" do config = Kemal.config config.add_handler CustomTestHandler.new Kemal.config.setup - config.handlers.size.should eq(8) + config.handlers.size.should eq(7) end it "toggles the shutdown message" do diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr index 334d4cc..01a9e93 100644 --- a/spec/helpers_spec.cr +++ b/spec/helpers_spec.cr @@ -13,7 +13,7 @@ describe "Macros" do it "adds a custom handler" do add_handler CustomTestHandler.new Kemal.config.setup - Kemal.config.handlers.size.should eq 8 + Kemal.config.handlers.size.should eq 7 end end @@ -151,7 +151,7 @@ describe "Macros" do it "adds HTTP::CompressHandler to handlers" do gzip true Kemal.config.setup - Kemal.config.handlers[5].should be_a(HTTP::CompressHandler) + Kemal.config.handlers[4].should be_a(HTTP::CompressHandler) end end diff --git a/spec/log_handler_spec.cr b/spec/log_handler_spec.cr index 5ee9c86..6e9a9a4 100644 --- a/spec/log_handler_spec.cr +++ b/spec/log_handler_spec.cr @@ -1,13 +1,6 @@ require "./spec_helper" describe "Kemal::LogHandler" do - it "logs to the given IO" do - io = IO::Memory.new - logger = Kemal::LogHandler.new io - logger.write "Something" - io.to_s.should eq "Something" - end - it "creates log message for each request" do request = HTTP::Request.new("GET", "/") io = IO::Memory.new diff --git a/spec/request_log_handler_spec.cr b/spec/request_log_handler_spec.cr new file mode 100644 index 0000000..798f322 --- /dev/null +++ b/spec/request_log_handler_spec.cr @@ -0,0 +1,17 @@ +require "log/spec" +require "./spec_helper" + +describe Kemal::RequestLogHandler do + it "creates log message for each request" do + Log.setup(:none) + + request = HTTP::Request.new("GET", "/") + response = HTTP::Server::Response.new(IO::Memory.new) + context = HTTP::Server::Context.new(request, response) + logger = Kemal::RequestLogHandler.new + Log.capture do |logs| + logger.call(context) + logs.check(:info, /404 GET \/ \d+.*s/) + end + end +end diff --git a/src/kemal.cr b/src/kemal.cr index bf7af65..76d72a1 100644 --- a/src/kemal.cr +++ b/src/kemal.cr @@ -1,11 +1,14 @@ require "http" require "json" +require "log" require "uri" require "./kemal/*" require "./kemal/ext/*" require "./kemal/helpers/*" module Kemal + Log = ::Log.for(self) + # Overload of `self.run` with the default startup logging. def self.run(port : Int32?, args = ARGV, trap_signal : Bool = true) self.run(port, args, trap_signal) { } @@ -68,9 +71,9 @@ module Kemal def self.display_startup_message(config, server) if config.env != "test" addresses = server.addresses.join ", " { |address| "#{config.scheme}://#{address}" } - log "[#{config.env}] #{config.app_name} is ready to lead at #{addresses}" + Log.info { "[#{config.env}] #{config.app_name} is ready to lead at #{addresses}" } else - log "[#{config.env}] #{config.app_name} is running in test mode. Server not listening" + Log.info { "[#{config.env}] #{config.app_name} is running in test mode. Server not listening" } end end @@ -94,7 +97,7 @@ module Kemal private def self.setup_trap_signal Process.on_terminate do - log "#{Kemal.config.app_name} is going to take a rest!" if Kemal.config.shutdown_message + Log.info { "#{Kemal.config.app_name} is going to take a rest!" } if Kemal.config.shutdown_message Kemal.stop exit end diff --git a/src/kemal/config.cr b/src/kemal/config.cr index 58e52f8..e4e614e 100644 --- a/src/kemal/config.cr +++ b/src/kemal/config.cr @@ -45,10 +45,17 @@ module Kemal @handler_position = 0 end + @[Deprecated("Use standard library Log")] def logger - @logger.not_nil! + @logger || NullLogHandler.new end + # :nodoc: + def logger? + @logger + end + + @[Deprecated("Use standard library Log")] def logger=(logger : Kemal::BaseLogHandler) @logger = logger end @@ -134,12 +141,11 @@ module Kemal end private def setup_log_handler - @logger ||= if @logging - Kemal::LogHandler.new - else - Kemal::NullLogHandler.new - end - HANDLERS.insert(@handler_position, @logger.not_nil!) + return unless @logging + + log_handler = @logger || Kemal::RequestLogHandler.new + + HANDLERS.insert(@handler_position, log_handler) @handler_position += 1 end diff --git a/src/kemal/exception_handler.cr b/src/kemal/exception_handler.cr index 714651b..b6056ed 100644 --- a/src/kemal/exception_handler.cr +++ b/src/kemal/exception_handler.cr @@ -21,7 +21,7 @@ module Kemal end end - log("Exception: #{ex.inspect_with_backtrace}") + Log.error(exception: ex) { ex.message } # Else use generic 500 handler if defined return call_exception_with_status_code(context, ex, 500) if Kemal.config.error_handlers.has_key?(500) verbosity = Kemal.config.env == "production" ? false : true diff --git a/src/kemal/helpers/helpers.cr b/src/kemal/helpers/helpers.cr index d8ef72b..b3dd63f 100644 --- a/src/kemal/helpers/helpers.cr +++ b/src/kemal/helpers/helpers.cr @@ -32,8 +32,14 @@ end # Logs the output via `logger`. # This is the built-in `Kemal::LogHandler` by default which uses STDOUT. +@[Deprecated("Use standard library Log")] def log(message : String) - Kemal.config.logger.write "#{message}\n" + logger = Kemal.config.logger? + if logger + logger.write "#{message}\n" + else + Log.info { message } + end end # Enables / Disables logging. @@ -70,6 +76,7 @@ end # ``` # logger MyCustomLogger.new # ``` +@[Deprecated("Use standard library Log")] def logger(logger : Kemal::BaseLogHandler) Kemal.config.logger = logger end diff --git a/src/kemal/log_handler.cr b/src/kemal/log_handler.cr index ce08e57..d7b1f79 100644 --- a/src/kemal/log_handler.cr +++ b/src/kemal/log_handler.cr @@ -1,5 +1,6 @@ module Kemal # Uses `STDOUT` by default and handles the logging of request/response process time. + @[Deprecated("Setup Log instead.")] class LogHandler < Kemal::BaseLogHandler def initialize(@io : IO = STDOUT) end diff --git a/src/kemal/null_log_handler.cr b/src/kemal/null_log_handler.cr index 9f3e03a..04d6242 100644 --- a/src/kemal/null_log_handler.cr +++ b/src/kemal/null_log_handler.cr @@ -1,5 +1,6 @@ module Kemal # This is here to represent the logger corresponding to Null Object Pattern. + @[Deprecated("Use standard library Log")] class NullLogHandler < Kemal::BaseLogHandler def call(context : HTTP::Server::Context) call_next(context) diff --git a/src/kemal/request_log_handler.cr b/src/kemal/request_log_handler.cr new file mode 100644 index 0000000..7d136f7 --- /dev/null +++ b/src/kemal/request_log_handler.cr @@ -0,0 +1,20 @@ +module Kemal + # :nodoc: + class RequestLogHandler + include HTTP::Handler + + def call(context : HTTP::Server::Context) + elapsed_time = Time.measure { call_next(context) } + elapsed_text = elapsed_text(elapsed_time) + Log.info { "#{context.response.status_code} #{context.request.method} #{context.request.resource} #{elapsed_text}" } + context + end + + private def elapsed_text(elapsed) + millis = elapsed.total_milliseconds + return "#{millis.round(2)}ms" if millis >= 1 + + "#{(millis * 1000).round(2)}ยตs" + end + end +end From e3de2ada634aada9760394c876062ad43efa2535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Thu, 3 Apr 2025 15:55:53 +0300 Subject: [PATCH 10/72] Implement multiple partial ranges (#708) --- spec/helpers_spec.cr | 110 +++++++++++++++++++++++++++++++ spec/static_file_handler_spec.cr | 28 ++++---- src/kemal/helpers/helpers.cr | 59 +++++++++++++---- 3 files changed, 171 insertions(+), 26 deletions(-) diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr index 01a9e93..7611221 100644 --- a/spec/helpers_spec.cr +++ b/spec/helpers_spec.cr @@ -145,6 +145,116 @@ describe "Macros" do response.status_code.should eq(200) response.headers["Content-Disposition"].should eq("attachment; filename=\"image.jpg\"") end + + it "handles multiple range requests" do + get "/" do |env| + send_file env, "#{__DIR__}/asset/hello.ecr" + end + + headers = HTTP::Headers{"Range" => "bytes=0-4,7-11"} + request = HTTP::Request.new("GET", "/", headers) + response = call_request_on_app(request) + + response.status_code.should eq(206) + response.headers["Content-Type"].should match(/^multipart\/byteranges; boundary=kemal-/) + response.headers["Accept-Ranges"].should eq("bytes") + + # Verify multipart response structure + body = response.body + boundary = response.headers["Content-Type"].split("boundary=")[1] + parts = body.split("--#{boundary}") + # Parts structure: + # 1. Empty part before first boundary + # 2. First content part (0-4) + # 3. Second content part (7-11) + # 4. Trailing part after last boundary + parts.size.should eq(4) + + # First part (0-4) + first_part = parts[1] + first_part.should contain("Content-Type: multipart/byteranges") + first_part.should contain("Content-Range: bytes 0-4/18") + first_part.split("\r\n\r\n")[1].strip.should eq("Hello") + + # Second part (7-11) + second_part = parts[2] + second_part.should contain("Content-Type: multipart/byteranges") + second_part.should contain("Content-Range: bytes 7-11/18") + second_part.split("\r\n\r\n")[1].strip.should eq("%= na") + end + + it "handles invalid range requests" do + get "/" do |env| + send_file env, "#{__DIR__}/asset/hello.ecr" + end + + # Invalid range format + headers = HTTP::Headers{"Range" => "invalid"} + request = HTTP::Request.new("GET", "/", headers) + response = call_request_on_app(request) + response.status_code.should eq(200) + response.body.should eq(File.read("#{__DIR__}/asset/hello.ecr")) + + # Range out of bounds + headers = HTTP::Headers{"Range" => "bytes=100-200"} + request = HTTP::Request.new("GET", "/", headers) + response = call_request_on_app(request) + response.status_code.should eq(200) + response.body.should eq(File.read("#{__DIR__}/asset/hello.ecr")) + + # Invalid range values + headers = HTTP::Headers{"Range" => "bytes=5-3"} + request = HTTP::Request.new("GET", "/", headers) + response = call_request_on_app(request) + response.status_code.should eq(200) + response.body.should eq(File.read("#{__DIR__}/asset/hello.ecr")) + end + + it "handles empty range requests" do + get "/" do |env| + send_file env, "#{__DIR__}/asset/hello.ecr" + end + + headers = HTTP::Headers{"Range" => "bytes="} + request = HTTP::Request.new("GET", "/", headers) + response = call_request_on_app(request) + response.status_code.should eq(200) + response.body.should eq(File.read("#{__DIR__}/asset/hello.ecr")) + end + + it "handles overlapping ranges" do + get "/" do |env| + send_file env, "#{__DIR__}/asset/hello.ecr" + end + + headers = HTTP::Headers{"Range" => "bytes=0-5,3-8"} + request = HTTP::Request.new("GET", "/", headers) + response = call_request_on_app(request) + + response.status_code.should eq(206) + response.headers["Content-Type"].should match(/^multipart\/byteranges; boundary=kemal-/) + + # Verify both ranges are included + body = response.body + boundary = response.headers["Content-Type"].split("boundary=")[1] + parts = body.split("--#{boundary}") + # Parts structure: + # 1. Empty part before first boundary + # 2. First content part (0-5) + # 3. Second content part (3-8) + # 4. Trailing part after last boundary + parts.size.should eq(4) + + # First part (0-5) + first_part = parts[1] + first_part.should contain("Content-Range: bytes 0-5/18") + first_part.split("\r\n\r\n")[1].strip.should eq("Hello") + + # Second part (3-8) + second_part = parts[2] + second_part.should contain("Content-Range: bytes 3-8/18") + second_part.split("\r\n\r\n")[1].strip.should eq("lo <%=") + end end describe "#gzip" do diff --git a/spec/static_file_handler_spec.cr b/spec/static_file_handler_spec.cr index 5f8c029..8596cdd 100644 --- a/spec/static_file_handler_spec.cr +++ b/spec/static_file_handler_spec.cr @@ -113,29 +113,27 @@ describe Kemal::StaticFileHandler do it "should send part of files when requested (RFC7233)" do %w(POST PUT DELETE HEAD).each do |method| - headers = HTTP::Headers{"Range" => "0-100"} + headers = HTTP::Headers{"Range" => "bytes=0-4"} response = handle HTTP::Request.new(method, "/dir/test.txt", headers) response.status_code.should_not eq(206) response.headers.has_key?("Content-Range").should eq(false) end %w(GET).each do |method| - headers = HTTP::Headers{"Range" => "0-100"} + headers = HTTP::Headers{"Range" => "bytes=0-4"} response = handle HTTP::Request.new(method, "/dir/test.txt", headers) - response.status_code.should eq(206 || 200) - if response.status_code == 206 - response.headers.has_key?("Content-Range").should eq true - match = response.headers["Content-Range"].match(/bytes (\d+)-(\d+)\/(\d+)/) - match.should_not be_nil - if match - start_range = match[1].to_i { 0 } - end_range = match[2].to_i { 0 } - range_size = match[3].to_i { 0 } + response.status_code.should eq(206) + response.headers.has_key?("Content-Range").should eq true + match = response.headers["Content-Range"].match(/bytes (\d+)-(\d+)\/(\d+)/) + match.should_not be_nil + if match + start_range = match[1].to_i { 0 } + end_range = match[2].to_i { 0 } + range_size = match[3].to_i { 0 } - range_size.should eq file_size - (end_range < file_size).should eq true - (start_range < end_range).should eq true - end + range_size.should eq file_size + (end_range < file_size).should eq true + (start_range < end_range).should eq true end end end diff --git a/src/kemal/helpers/helpers.cr b/src/kemal/helpers/helpers.cr index b3dd63f..c76093b 100644 --- a/src/kemal/helpers/helpers.cr +++ b/src/kemal/helpers/helpers.cr @@ -212,31 +212,68 @@ end private def multipart(file, env : HTTP::Server::Context) # See http://httpwg.org/specs/rfc7233.html fileb = file.size - startb = endb = 0_i64 + ranges = parse_ranges(env.request.headers["Range"]?, fileb) - if match = env.request.headers["Range"].match /bytes=(\d{1,})-(\d{0,})/ - startb = match[1].to_i64 { 0_i64 } if match.size >= 2 - endb = match[2].to_i64 { 0_i64 } if match.size >= 3 + if ranges.empty? + env.response.content_length = fileb + env.response.status_code = 200 # Range not satisfiable + IO.copy(file, env.response) + return end - endb = fileb - 1 if endb == 0 - - if startb < endb < fileb + if ranges.size == 1 + # Single range - send as regular partial content + startb, endb = ranges[0] content_length = 1_i64 + endb - startb env.response.status_code = 206 env.response.content_length = content_length env.response.headers["Accept-Ranges"] = "bytes" - env.response.headers["Content-Range"] = "bytes #{startb}-#{endb}/#{fileb}" # MUST + env.response.headers["Content-Range"] = "bytes #{startb}-#{endb}/#{fileb}" file.seek(startb) IO.copy(file, env.response, content_length) else - env.response.content_length = fileb - env.response.status_code = 200 # Range not satisfable, see 4.4 Note - IO.copy(file, env.response) + # Multiple ranges - send as multipart/byteranges + boundary = "kemal-#{Random::Secure.hex(16)}" + env.response.content_type = "multipart/byteranges; boundary=#{boundary}" + env.response.status_code = 206 + env.response.headers["Accept-Ranges"] = "bytes" + + ranges.each do |start_byte, end_byte| + env.response.print "--#{boundary}\r\n" + env.response.print "Content-Type: #{env.response.headers["Content-Type"]}\r\n" + env.response.print "Content-Range: bytes #{start_byte}-#{end_byte}/#{fileb}\r\n" + env.response.print "\r\n" + + file.seek(start_byte) + IO.copy(file, env.response, 1_i64 + end_byte - start_byte) + env.response.print "\r\n" + end + env.response.print "--#{boundary}--\r\n" end end +private def parse_ranges(range_header : String?, file_size : Int64) : Array({Int64, Int64}) + return [] of {Int64, Int64} unless range_header + + ranges = [] of {Int64, Int64} + return ranges unless range_header.starts_with?("bytes=") + + range_header[6..].split(",").each do |range| + if match = range.match /(\d{1,})-(\d{0,})/ + startb = match[1].to_i64 { 0_i64 } + endb = match[2].to_i64 { 0_i64 } + endb = file_size - 1 if endb == 0 + + if startb < endb && endb < file_size + ranges << {startb, endb} + end + end + end + + ranges +end + # Set the Content-Disposition to "attachment" with the specified filename, # instructing the user agents to prompt to save. private def attachment(env : HTTP::Server::Context, filename : String? = nil, disposition : String? = nil) From 0335e861aa0797e72eaa1564e344dc63ea7edd9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Thu, 3 Apr 2025 16:05:55 +0300 Subject: [PATCH 11/72] Add before_all filter execution for 404 errors in FilterHandler (#706) --- spec/middleware/filters_spec.cr | 16 ++++++++++++++++ src/kemal/filter_handler.cr | 8 +++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/spec/middleware/filters_spec.cr b/spec/middleware/filters_spec.cr index 5c3b477..2ba3f95 100644 --- a/spec/middleware/filters_spec.cr +++ b/spec/middleware/filters_spec.cr @@ -207,6 +207,22 @@ describe "Kemal::FilterHandler" do client_response = HTTP::Client::Response.from_io(io_with_context, decompress: false) client_response.body.should eq("true-true") end + + it "executes before_all filter on 404" do + before_filter = FilterTest.new + before_filter.modified = "false" + + filter_middleware = Kemal::FilterHandler.new + filter_middleware._add_route_filter("ALL", "*", :before) { before_filter.modified = "true" } + + error 404 do + before_filter.modified + end + + request = HTTP::Request.new("GET", "/not_found") + client_response = call_request_on_app(request) + client_response.body.should eq("true") + end end class FilterTest diff --git a/src/kemal/filter_handler.cr b/src/kemal/filter_handler.cr index 5bf9fd6..cd93c09 100644 --- a/src/kemal/filter_handler.cr +++ b/src/kemal/filter_handler.cr @@ -13,7 +13,13 @@ module Kemal # The call order of the filters is `before_all -> before_x -> X -> after_x -> after_all`. def call(context : HTTP::Server::Context) - return call_next(context) unless context.route_found? + if !context.route_found? + if Kemal.config.error_handlers.has_key?(404) + call_block_for_path_type("ALL", context.request.path, :before, context) + end + return call_next(context) + end + call_block_for_path_type("ALL", context.request.path, :before, context) call_block_for_path_type(context.request.method, context.request.path, :before, context) if Kemal.config.error_handlers.has_key?(context.response.status_code) From fa4bf060b5898f8190be4e1a58b0052383071432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Thu, 3 Apr 2025 16:13:21 +0300 Subject: [PATCH 12/72] Add cleanup methods for file uploads and temporary files (#707) --- src/kemal/file_upload.cr | 5 +++++ src/kemal/param_parser.cr | 9 +++++++++ src/kemal/route_handler.cr | 3 +++ 3 files changed, 17 insertions(+) diff --git a/src/kemal/file_upload.cr b/src/kemal/file_upload.cr index 30eb26a..767ce85 100644 --- a/src/kemal/file_upload.cr +++ b/src/kemal/file_upload.cr @@ -20,5 +20,10 @@ module Kemal @read_time = upload.read_time @size = upload.size end + + def cleanup + @tempfile.close + ::File.delete(@tempfile.path) if ::File.exists?(@tempfile.path) + end end end diff --git a/src/kemal/param_parser.cr b/src/kemal/param_parser.cr index 8d93776..9bb5f6d 100644 --- a/src/kemal/param_parser.cr +++ b/src/kemal/param_parser.cr @@ -24,6 +24,15 @@ module Kemal @files_parsed = false end + def cleanup_temporary_files + return if @files.empty? && @all_files.empty? + + @files.each_value &.cleanup + @all_files.each_value do |file_uploads| + file_uploads.each &.cleanup + end + end + private def unescape_url_param(value : String) value.empty? ? value : URI.decode(value) rescue diff --git a/src/kemal/route_handler.cr b/src/kemal/route_handler.cr index 44c8c92..5f267ba 100644 --- a/src/kemal/route_handler.cr +++ b/src/kemal/route_handler.cr @@ -56,7 +56,10 @@ module Kemal end context.response.print(content) + context + ensure + context.params.cleanup_temporary_files end private def radix_path(method, path) From d4c842e82ddb4d22fc3690ef93bb3bcd22eff367 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Thu, 3 Apr 2025 17:54:17 +0300 Subject: [PATCH 13/72] Remove depracated custom logger spec --- spec/helpers_spec.cr | 6 ------ 1 file changed, 6 deletions(-) diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr index 7611221..a85be6a 100644 --- a/spec/helpers_spec.cr +++ b/spec/helpers_spec.cr @@ -22,12 +22,6 @@ describe "Macros" do logging false Kemal.config.logging.should eq false end - - it "sets a custom logger" do - config = Kemal::Config::INSTANCE - logger CustomLogHandler.new - config.logger.should be_a(CustomLogHandler) - end end describe "#halt" do From 95864868a086b5b1f903dc7f14f47335e57d35f4 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Thu, 3 Apr 2025 18:19:11 +0300 Subject: [PATCH 14/72] Update run_spec to respect latest Log change --- spec/run_spec.cr | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/spec/run_spec.cr b/spec/run_spec.cr index c15a7e9..c849c00 100644 --- a/spec/run_spec.cr +++ b/spec/run_spec.cr @@ -15,12 +15,20 @@ end describe "Run" do it "runs a code block after starting" do - run(<<-CR).should eq "started\nstopped\n" + run(<<-CR).should contain("started") + Kemal.config.env = "test" + Kemal.run do + log "started" + end + CR + end + + it "runs a code block after stopping" do + run(<<-CR).should contain("stopped") Kemal.config.env = "test" Kemal.run do - puts "started" Kemal.stop - puts "stopped" + log "stopped" end CR end @@ -29,7 +37,7 @@ describe "Run" do run(<<-CR).should contain "[test] Kemal is running in test mode." Kemal.config.env = "test" Kemal.run - puts Kemal.config.running + Kemal.config.running CR end From 56e58fc4d6e862e13cb8f363834a040cc7a1f9c0 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Thu, 3 Apr 2025 22:22:09 +0300 Subject: [PATCH 15/72] Fix Path Traversal in StaticFileHandler, see https://packetstorm.news/files/id/190294/ --- spec/static/dir/nested/path/test.txt | 2 ++ spec/static_file_handler_spec.cr | 27 +++++++++++++++++++++++++++ src/kemal/static_file_handler.cr | 25 +++++++++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 spec/static/dir/nested/path/test.txt diff --git a/spec/static/dir/nested/path/test.txt b/spec/static/dir/nested/path/test.txt new file mode 100644 index 0000000..9db7df0 --- /dev/null +++ b/spec/static/dir/nested/path/test.txt @@ -0,0 +1,2 @@ +hello +world \ No newline at end of file diff --git a/spec/static_file_handler_spec.cr b/spec/static_file_handler_spec.cr index 8596cdd..b687085 100644 --- a/spec/static_file_handler_spec.cr +++ b/spec/static_file_handler_spec.cr @@ -13,6 +13,7 @@ end describe Kemal::StaticFileHandler do file = File.open "#{__DIR__}/static/dir/test.txt" + File.open "#{__DIR__}/static/dir/nested/path/test.txt" file_size = file.size it "should serve a file with content type and etag" do @@ -157,4 +158,30 @@ describe Kemal::StaticFileHandler do response = handle HTTP::Request.new("GET", "/dir/index.html") response.headers["Access-Control-Allow-Origin"].should eq("*") end + + # Path Traversal Security Tests + it "should prevent path traversal attacks with .." do + response = handle HTTP::Request.new("GET", "/../../../etc/passwd") + response.status_code.should eq(400) + end + + it "should prevent path traversal attacks with URL encoded .." do + response = handle HTTP::Request.new("GET", "/..%2f..%2f..%2fetc%2fpasswd") + response.status_code.should eq(400) + end + + it "should prevent path traversal attacks with mixed .. and URL encoded .." do + response = handle HTTP::Request.new("GET", "/..%2f../..%2fetc%2fpasswd") + response.status_code.should eq(400) + end + + it "should allow legitimate nested paths" do + response = handle HTTP::Request.new("GET", "/dir/nested/path/test.txt") + response.status_code.should eq(200) + end + + it "should handle requests with trailing slashes in nested paths" do + response = handle HTTP::Request.new("GET", "/dir/nested/path/") + response.status_code.should eq(200) + end end diff --git a/src/kemal/static_file_handler.cr b/src/kemal/static_file_handler.cr index 1c04adb..880ac54 100644 --- a/src/kemal/static_file_handler.cr +++ b/src/kemal/static_file_handler.cr @@ -27,6 +27,12 @@ module Kemal return end + # Check for directory traversal attacks + if request_path.includes? ".." + context.response.status_code = 400 + return + end + expanded_path = request_path is_dir_path = if original_path.ends_with?('/') && !expanded_path.ends_with? '/' expanded_path = expanded_path + '/' @@ -81,5 +87,24 @@ module Kemal private def modification_time(file_path) File.info(file_path).modification_time end + + # Sanitizes the path to prevent directory traversal attacks + private def sanitize_path(path : String) : String? + # Remove any leading or trailing slashes + path = path.strip('/') + + # Split the path into components + components = path.split('/') + + # Filter out empty components and normalize the path + normalized_components = components.reject(&.empty?) + + # Check for any directory traversal attempts + # Only reject paths that explicitly try to traverse up directories + return nil if normalized_components.any? { |component| component == ".." } + + # Reconstruct the path + normalized_components.join('/') + end end end From 085c3d703b97a1fc87c1d83b7059963cf6d22387 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Mon, 14 Apr 2025 14:41:18 +0300 Subject: [PATCH 16/72] Update CHANGELOG --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 130192c..b12c918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,36 @@ +# 1.7.0 (14-04-2025) + +- ***(SECURITY)*** Fix a Path Traversal Security issue in `StaticFileHandler`. [See](https://packetstorm.news/files/id/190294/) for more details. Thanks a lot @ahmetumitbayram :pray: +- Crystal 1.16.0 support :tada: +- Add ability to add handlers for raised exceptions [#688](https://github.com/kemalcr/kemal/pull/688). Thanks @syeopite :pray: + +```crystal +require "kemal" + +class NewException < Exception +end + +get "/" do | env | + raise NewException.new() +end + +error NewException do | env | + "An error occured!" +end + +Kemal.run +``` + +- Add `all_files` method to `params` to support multiple file uploads in names ending with `[]` [#701](https://github.com/kemalcr/kemal/pull/701). Thanks @sdogruyol :pray: + +```crystal +images = env.params.all_files["images[]"]? +``` + +- Embrace Crystal standard Log for logging [#705](https://github.com/kemalcr/kemal/pull/705). Thanks @hugopl :pray: +- Cleanup temporary files for file uploads [#707](Add cleanup methods for file uploads and temporary files). Thanks @sdogruyol :pray: +- Implement multiple partial ranges [#708](https://github.com/kemalcr/kemal/pull/708). Thanks @sdogruyol :pray: + # 1.6.0 (12-10-2024) - Crystal 1.14.0 support :tada: From 54fc02150b70fb26b812d335b81cbf0698086697 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Mon, 14 Apr 2025 14:43:23 +0300 Subject: [PATCH 17/72] Bump version to 1.7.0 --- shard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shard.yml b/shard.yml index 4aa2245..d47941e 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: kemal -version: 1.6.0 +version: 1.7.0 authors: - Serdar Dogruyol From 610eb3d42497db40feb72d73693f564b305f68c7 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Mon, 14 Apr 2025 15:30:17 +0300 Subject: [PATCH 18/72] Fix CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b12c918..71391f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ images = env.params.all_files["images[]"]? ``` - Embrace Crystal standard Log for logging [#705](https://github.com/kemalcr/kemal/pull/705). Thanks @hugopl :pray: -- Cleanup temporary files for file uploads [#707](Add cleanup methods for file uploads and temporary files). Thanks @sdogruyol :pray: +- Cleanup temporary files for file uploads [#707](https://github.com/kemalcr/kemal/pull/707). Thanks @sdogruyol :pray: - Implement multiple partial ranges [#708](https://github.com/kemalcr/kemal/pull/708). Thanks @sdogruyol :pray: # 1.6.0 (12-10-2024) From 2408828c858096f8270fa9f54b9956f2d39c0ee8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Mon, 14 Apr 2025 16:48:52 +0300 Subject: [PATCH 19/72] Improve Static File Handler to follow Crystal stdlib version (#711) --- spec/static_file_handler_spec.cr | 6 +-- src/kemal/static_file_handler.cr | 68 +++++++++++--------------------- 2 files changed, 26 insertions(+), 48 deletions(-) diff --git a/spec/static_file_handler_spec.cr b/spec/static_file_handler_spec.cr index b687085..4fdffb8 100644 --- a/spec/static_file_handler_spec.cr +++ b/spec/static_file_handler_spec.cr @@ -162,17 +162,17 @@ describe Kemal::StaticFileHandler do # Path Traversal Security Tests it "should prevent path traversal attacks with .." do response = handle HTTP::Request.new("GET", "/../../../etc/passwd") - response.status_code.should eq(400) + response.status_code.should eq(302) end it "should prevent path traversal attacks with URL encoded .." do response = handle HTTP::Request.new("GET", "/..%2f..%2f..%2fetc%2fpasswd") - response.status_code.should eq(400) + response.status_code.should eq(302) end it "should prevent path traversal attacks with mixed .. and URL encoded .." do response = handle HTTP::Request.new("GET", "/..%2f../..%2fetc%2fpasswd") - response.status_code.should eq(400) + response.status_code.should eq(302) end it "should allow legitimate nested paths" do diff --git a/src/kemal/static_file_handler.cr b/src/kemal/static_file_handler.cr index 880ac54..a549161 100644 --- a/src/kemal/static_file_handler.cr +++ b/src/kemal/static_file_handler.cr @@ -16,43 +16,40 @@ module Kemal return end - config = Kemal.config.serve_static original_path = context.request.path.not_nil! + is_dir_path = original_path.ends_with?("/") request_path = URI.decode(original_path) # File path cannot contains '\0' (NUL) because all filesystem I know # don't accept '\0' character as file name. if request_path.includes? '\0' - context.response.status_code = 400 + context.response.respond_with_status(:bad_request) return end - # Check for directory traversal attacks - if request_path.includes? ".." - context.response.status_code = 400 + request_path = Path.posix(request_path) + expanded_path = request_path.expand("/") + + file_path = @public_dir.join(expanded_path.to_kind(Path::Kind.native)) + file_info = File.info? file_path + is_dir = @directory_listing && file_info && file_info.directory? + is_file = file_info && file_info.file? + + if request_path != expanded_path || is_dir && !is_dir_path + redirect_path = expanded_path + if is_dir && !is_dir_path + # Append / to path if missing + redirect_path = expanded_path.join("") + end + redirect_to context, redirect_path return end - expanded_path = request_path - is_dir_path = if original_path.ends_with?('/') && !expanded_path.ends_with? '/' - expanded_path = expanded_path + '/' - true - else - expanded_path.ends_with? '/' - end - - file_path = File.join(@public_dir, expanded_path) - is_dir = Dir.exists?(file_path) - - if request_path != expanded_path - redirect_to context, expanded_path - return - elsif is_dir && !is_dir_path - redirect_to context, expanded_path + '/' - return - end + return call_next(context) unless file_info if is_dir + config = Kemal.config.serve_static + if config.is_a?(Hash) && config.fetch("dir_index", false) && File.exists?(File.join(file_path, "index.html")) file_path = File.join(@public_dir, expanded_path, "index.html") @@ -70,7 +67,7 @@ module Kemal else call_next(context) end - elsif File.exists?(file_path) + elsif is_file last_modified = modification_time(file_path) add_cache_headers(context.response.headers, last_modified) @@ -78,8 +75,8 @@ module Kemal context.response.status_code = 304 return end - send_file(context, file_path) - else + send_file(context, file_path.to_s) + else # Not a normal file (FIFO/device/socket) call_next(context) end end @@ -87,24 +84,5 @@ module Kemal private def modification_time(file_path) File.info(file_path).modification_time end - - # Sanitizes the path to prevent directory traversal attacks - private def sanitize_path(path : String) : String? - # Remove any leading or trailing slashes - path = path.strip('/') - - # Split the path into components - components = path.split('/') - - # Filter out empty components and normalize the path - normalized_components = components.reject(&.empty?) - - # Check for any directory traversal attempts - # Only reject paths that explicitly try to traverse up directories - return nil if normalized_components.any? { |component| component == ".." } - - # Reconstruct the path - normalized_components.join('/') - end end end From fc1dfbbdadc8ff68983ce9401af49185e6ef8e79 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Mon, 14 Apr 2025 16:50:46 +0300 Subject: [PATCH 20/72] Update CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71391f0..cf48aa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 1.7.1 (14-04-2025) + +- Improve `StaticFileHandler` to align with latest Crystal implementation [#711](https://github.com/kemalcr/kemal/pull/711). Thanks @sdogruyol :pray + # 1.7.0 (14-04-2025) - ***(SECURITY)*** Fix a Path Traversal Security issue in `StaticFileHandler`. [See](https://packetstorm.news/files/id/190294/) for more details. Thanks a lot @ahmetumitbayram :pray: From acd43a6f7f940bd412f6a3a0e63c6ec48fb41a4b Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Mon, 14 Apr 2025 16:53:34 +0300 Subject: [PATCH 21/72] Bump version to 1.7.1 --- shard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shard.yml b/shard.yml index d47941e..c9e7584 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: kemal -version: 1.7.0 +version: 1.7.1 authors: - Serdar Dogruyol From 1fdccb2786a6809ccdd55a02fe96ee9c8faa1260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sefa=20Y=C4=B1ld=C4=B1z?= Date: Thu, 17 Apr 2025 16:18:58 +0300 Subject: [PATCH 22/72] Update CHANGELOG.md (#713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ™ --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf48aa9..034a7d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # 1.7.1 (14-04-2025) -- Improve `StaticFileHandler` to align with latest Crystal implementation [#711](https://github.com/kemalcr/kemal/pull/711). Thanks @sdogruyol :pray +- Improve `StaticFileHandler` to align with latest Crystal implementation [#711](https://github.com/kemalcr/kemal/pull/711). Thanks @sdogruyol :pray: # 1.7.0 (14-04-2025) From 47263d4f1941630566d3fea182b1ef3512005c53 Mon Sep 17 00:00:00 2001 From: kojix2 <2xijok@gmail.com> Date: Fri, 18 Apr 2025 22:10:39 +0900 Subject: [PATCH 23/72] Fix typos in spec files (#715) --- spec/exception_handler_spec.cr | 4 ++-- spec/helpers_spec.cr | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/exception_handler_spec.cr b/spec/exception_handler_spec.cr index f00621a..4486b14 100644 --- a/spec/exception_handler_spec.cr +++ b/spec/exception_handler_spec.cr @@ -61,7 +61,7 @@ describe "Kemal::ExceptionHandler" do it "renders custom error for a crystal exception" do error RuntimeError do - "A RuntimeError has occured" + "A RuntimeError has occurred" end get "/" do @@ -79,7 +79,7 @@ describe "Kemal::ExceptionHandler" do response = HTTP::Client::Response.from_io(io, decompress: false) response.status_code.should eq 500 response.headers["Content-Type"].should eq "text/html" - response.body.should eq "A RuntimeError has occured" + response.body.should eq "A RuntimeError has occurred" end it "renders custom error for a custom exception" do diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr index a85be6a..f5de4e9 100644 --- a/spec/helpers_spec.cr +++ b/spec/helpers_spec.cr @@ -265,7 +265,7 @@ describe "Macros" do Kemal.config.serve_static.should eq false end - it "should disble enable gzip and dir_listing" do + it "should enable gzip and dir_listing" do serve_static({"gzip" => true, "dir_listing" => true}) conf = Kemal.config.serve_static conf.is_a?(Hash).should eq true From f41025c2c488dd7859154a14eb99880bcd01cb08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20M=C3=BCller?= Date: Tue, 3 Jun 2025 15:35:07 +0200 Subject: [PATCH 24/72] Make use of helper methods in `StaticFileHandler` (#714) --- src/kemal/static_file_handler.cr | 172 ++++++++++++++++++------------- 1 file changed, 102 insertions(+), 70 deletions(-) diff --git a/src/kemal/static_file_handler.cr b/src/kemal/static_file_handler.cr index a549161..787afe9 100644 --- a/src/kemal/static_file_handler.cr +++ b/src/kemal/static_file_handler.cr @@ -1,58 +1,104 @@ module Kemal class StaticFileHandler < HTTP::StaticFileHandler - # ameba:disable Metrics/CyclomaticComplexity - def call(context : HTTP::Server::Context) - return call_next(context) if context.request.path.not_nil! == "/" - - case context.request.method - when "GET", "HEAD" - else - if @fallthrough - call_next(context) - else - context.response.status_code = 405 - context.response.headers.add("Allow", "GET, HEAD") - end - return - end - - original_path = context.request.path.not_nil! - is_dir_path = original_path.ends_with?("/") - request_path = URI.decode(original_path) - - # File path cannot contains '\0' (NUL) because all filesystem I know - # don't accept '\0' character as file name. - if request_path.includes? '\0' - context.response.respond_with_status(:bad_request) - return - end - - request_path = Path.posix(request_path) - expanded_path = request_path.expand("/") - - file_path = @public_dir.join(expanded_path.to_kind(Path::Kind.native)) - file_info = File.info? file_path - is_dir = @directory_listing && file_info && file_info.directory? - is_file = file_info && file_info.file? - - if request_path != expanded_path || is_dir && !is_dir_path - redirect_path = expanded_path - if is_dir && !is_dir_path - # Append / to path if missing - redirect_path = expanded_path.join("") - end - redirect_to context, redirect_path - return - end - - return call_next(context) unless file_info - - if is_dir + {% if compare_versions(Crystal::VERSION, "1.17.0") >= 0 %} + private def directory_index(context : HTTP::Server::Context, request_path : Path, file_path : Path) config = Kemal.config.serve_static + unless config.is_a?(Hash) + return call_next(context) + end - if config.is_a?(Hash) && config.fetch("dir_index", false) && File.exists?(File.join(file_path, "index.html")) - file_path = File.join(@public_dir, expanded_path, "index.html") + index_path = file_path / "index.html" + if config.fetch("dir_index", false) && (index_info = File.info?(index_path)) + last_modified = index_info.modification_time + add_cache_headers(context.response.headers, last_modified) + if cache_request?(context, last_modified) + context.response.status = :not_modified + return + end + + send_file(context, index_path.to_s) + elsif config.fetch("dir_listing", false) + context.response.content_type = "text/html; charset=utf-8" + directory_listing(context.response, request_path, file_path) + else + call_next(context) + end + end + + # NOTE: This override opts out of some behaviour from HTTP::StaticFileHandler, + # such as serving content ranges. + private def serve_file(context : HTTP::Server::Context, file_info, file_path : Path, original_file_path : Path, last_modified : Time) + send_file(context, file_path.to_s) + end + {% else %} + def call(context : HTTP::Server::Context) + return call_next(context) if context.request.path.not_nil! == "/" + + case context.request.method + when "GET", "HEAD" + else + if @fallthrough + call_next(context) + else + context.response.status_code = 405 + context.response.headers.add("Allow", "GET, HEAD") + end + return + end + + original_path = context.request.path.not_nil! + is_dir_path = original_path.ends_with?("/") + request_path = URI.decode(original_path) + + # File path cannot contains '\0' (NUL) because all filesystem I know + # don't accept '\0' character as file name. + if request_path.includes? '\0' + context.response.respond_with_status(:bad_request) + return + end + + request_path = Path.posix(request_path) + expanded_path = request_path.expand("/") + + file_path = @public_dir.join(expanded_path.to_kind(Path::Kind.native)) + file_info = File.info? file_path + is_dir = @directory_listing && file_info && file_info.directory? + is_file = file_info && file_info.file? + + if request_path != expanded_path || is_dir && !is_dir_path + redirect_path = expanded_path + if is_dir && !is_dir_path + # Append / to path if missing + redirect_path = expanded_path.join("") + end + redirect_to context, redirect_path + return + end + + return call_next(context) unless file_info + + if is_dir + config = Kemal.config.serve_static + + if config.is_a?(Hash) && config.fetch("dir_index", false) && File.exists?(File.join(file_path, "index.html")) + file_path = File.join(@public_dir, expanded_path, "index.html") + + last_modified = modification_time(file_path) + add_cache_headers(context.response.headers, last_modified) + + if cache_request?(context, last_modified) + context.response.status_code = 304 + return + end + send_file(context, file_path) + elsif config.is_a?(Hash) && config.fetch("dir_listing", false) + context.response.content_type = "text/html; charset=utf-8" + directory_listing(context.response, request_path, file_path) + else + call_next(context) + end + elsif is_file last_modified = modification_time(file_path) add_cache_headers(context.response.headers, last_modified) @@ -60,29 +106,15 @@ module Kemal context.response.status_code = 304 return end - send_file(context, file_path) - elsif config.is_a?(Hash) && config.fetch("dir_listing", false) - context.response.content_type = "text/html; charset=utf-8" - directory_listing(context.response, request_path, file_path) - else + send_file(context, file_path.to_s) + else # Not a normal file (FIFO/device/socket) call_next(context) end - elsif is_file - last_modified = modification_time(file_path) - add_cache_headers(context.response.headers, last_modified) - - if cache_request?(context, last_modified) - context.response.status_code = 304 - return - end - send_file(context, file_path.to_s) - else # Not a normal file (FIFO/device/socket) - call_next(context) end - end - private def modification_time(file_path) - File.info(file_path).modification_time - end + private def modification_time(file_path) + File.info(file_path).modification_time + end + {% end %} end end From 6eb9b6bafc8954b9604f0c472ce70dfd39723dea Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Thu, 5 Jun 2025 10:21:46 +0000 Subject: [PATCH 25/72] Move `Kemal::Handler` logic into separate module (#717) This grants the application the additional freedom to inherit from different classes while still retaining the methods provided by `Kemal::Handler`. Should be fully backwards compatible as `Kemal::Handler` includes the new module, with the class vars being defined in an `included` macro. --- src/kemal/handler.cr | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/kemal/handler.cr b/src/kemal/handler.cr index f87e60a..c900980 100644 --- a/src/kemal/handler.cr +++ b/src/kemal/handler.cr @@ -1,13 +1,20 @@ module Kemal - # `Kemal::Handler` is a subclass of `HTTP::Handler`. + # Kemal::HandlerInterface provides helpful methods for use in middleware creation # - # It adds `only`, `only_match?`, `exclude`, `exclude_match?`. - # These methods are useful for the conditional execution of custom handlers . - class Handler + # More specifically, `only`, `only_match?`, `exclude`, `exclude_match?` + # allows one to define the conditional execution of custom handlers. + # + # To use, simply `include` it within your type. + # + # It is an implementation of `HTTP::Handler` and can be used anywhere that + # requests an `HTTP::Handler` type. + module HandlerInterface include HTTP::Handler - @@only_routes_tree = Radix::Tree(String).new - @@exclude_routes_tree = Radix::Tree(String).new + macro included + @@only_routes_tree = Radix::Tree(String).new + @@exclude_routes_tree = Radix::Tree(String).new + end macro only(paths, method = "GET") class_name = {{@type.name}} @@ -75,4 +82,13 @@ module Kemal "#{self.class}/#{method}#{path}" end end + + # `Kemal::Handler` is an implementation of `HTTP::Handler`. + # + # It includes `HandlerInterface` to add the methods + # `only`, `only_match?`, `exclude`, `exclude_match?`. + # These methods are useful for the conditional execution of custom handlers . + class Handler + include HandlerInterface + end end From 5f87f549ec081a72c66e7501ca9f72186bc7b88a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Tue, 10 Jun 2025 13:14:48 +0300 Subject: [PATCH 26/72] Refactor server binding logic to avoid binding in test environment (#719) --- src/kemal.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kemal.cr b/src/kemal.cr index 76d72a1..7ae7c3d 100644 --- a/src/kemal.cr +++ b/src/kemal.cr @@ -51,7 +51,7 @@ module Kemal # Abort if block called `Kemal.stop` return unless config.running - unless server.each_address { |_| break true } + unless config.env == "test" && server.each_address { |_| break true } {% if flag?(:without_openssl) %} server.bind_tcp(config.host_binding, config.port) {% else %} From 72e7b9c90262f176d7ce93ea15be0489cf2cc917 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:47:51 +0300 Subject: [PATCH 27/72] Add kemal-session ref to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index aee6fb4..7d68506 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ crystal run src/your_app.cr - ๐Ÿ“ **Template Support**: Built-in ECR template engine - ๐Ÿ”’ **Middleware System**: Add functionality with middleware - ๐ŸŽฏ **Request/Response Context**: Easy parameter and request handling +- ๐Ÿช **Session Management**: Easy session handling with [kemal-session](https://github.com/kemalcr/kemal-session) ## Learning Resources From 67525115257a1e422ab3d55a53ead677ae235f1c Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Mon, 4 Aug 2025 17:14:45 +0300 Subject: [PATCH 28/72] Update CHANGELOG --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 034a7d6..253216e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 1.7.2 (04-08-2025) + +- Move Kemal::Handler logic into separate module [#717](https://github.com/kemalcr/kemal/pull/717). Thanks @syeopite :pray: +- Refactor server binding logic to avoid binding in test environment [#719](https://github.com/kemalcr/kemal/pull/719). Thanks @sdogruyol :pray: + # 1.7.1 (14-04-2025) - Improve `StaticFileHandler` to align with latest Crystal implementation [#711](https://github.com/kemalcr/kemal/pull/711). Thanks @sdogruyol :pray: From f1a53520acc54ea625355da1fbdb8f351d34f526 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Mon, 4 Aug 2025 17:15:11 +0300 Subject: [PATCH 29/72] Bump version to 1.7.2 --- shard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shard.yml b/shard.yml index c9e7584..87f84e3 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: kemal -version: 1.7.1 +version: 1.7.2 authors: - Serdar Dogruyol From c60a8dcb8123713400da2742026ef2d396bf9c3b Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:20:05 +0300 Subject: [PATCH 30/72] Rename samples to examples. Add all code from cookbook --- examples/cookies/app.cr | 67 +++++++++++++++++++++ examples/cors/app.cr | 17 ++++++ examples/file-download/app.cr | 18 ++++++ examples/file-upload/app.cr | 25 ++++++++ examples/hello-world/app.cr | 7 +++ examples/http-basic-auth/app.cr | 18 ++++++ examples/http-basic-auth/custom-handler.cr | 23 ++++++++ examples/json-api/app.cr | 68 ++++++++++++++++++++++ examples/json-mapping/app.cr | 30 ++++++++++ examples/mysql-db/app.cr | 59 +++++++++++++++++++ examples/postgresql-db/app.cr | 59 +++++++++++++++++++ examples/redis/app.cr | 58 ++++++++++++++++++ examples/reuse-port/app.cr | 17 ++++++ examples/unix-domain-socket/app.cr | 12 ++++ examples/websocket-chat/app.cr | 33 +++++++++++ samples/hello_world.cr | 8 --- samples/json_api.cr | 11 ---- samples/websocket_server.cr | 11 ---- 18 files changed, 511 insertions(+), 30 deletions(-) create mode 100644 examples/cookies/app.cr create mode 100644 examples/cors/app.cr create mode 100644 examples/file-download/app.cr create mode 100644 examples/file-upload/app.cr create mode 100644 examples/hello-world/app.cr create mode 100644 examples/http-basic-auth/app.cr create mode 100644 examples/http-basic-auth/custom-handler.cr create mode 100644 examples/json-api/app.cr create mode 100644 examples/json-mapping/app.cr create mode 100644 examples/mysql-db/app.cr create mode 100644 examples/postgresql-db/app.cr create mode 100644 examples/redis/app.cr create mode 100644 examples/reuse-port/app.cr create mode 100644 examples/unix-domain-socket/app.cr create mode 100644 examples/websocket-chat/app.cr delete mode 100644 samples/hello_world.cr delete mode 100644 samples/json_api.cr delete mode 100644 samples/websocket_server.cr diff --git a/examples/cookies/app.cr b/examples/cookies/app.cr new file mode 100644 index 0000000..644e01a --- /dev/null +++ b/examples/cookies/app.cr @@ -0,0 +1,67 @@ +require "kemal" + +# This example demonstrates different ways to work with cookies in Kemal + +# Route to set various types of cookies +get "/set-cookies" do |env| + # Basic cookie with just name and value + basic_cookie = HTTP::Cookie.new( + name: "BasicCookie", + value: "Hello from Kemal!" + ) + + # Secure cookie with additional security options + secure_cookie = HTTP::Cookie.new( + name: "SecureCookie", + value: "Sensitive Data", + http_only: true, # Cookie cannot be accessed via JavaScript + secure: true, # Cookie only sent over HTTPS + path: "/", # Cookie available for all paths + expires: Time.local + Time::Span.new(days: 7) # Cookie expires in 7 days + ) + + # Session cookie that expires when browser closes + session_cookie = HTTP::Cookie.new( + name: "SessionCookie", + value: "Temporary", + http_only: true + ) + + # Add all cookies to response + env.response.cookies << basic_cookie + env.response.cookies << secure_cookie + env.response.cookies << session_cookie + + "Cookies have been set! Visit /show-cookies to view them." +end + +# Route to display current cookies +get "/show-cookies" do |env| + cookies = env.request.cookies + response = String.build do |str| + str << "

Current Cookies:

" + str << "
    " + cookies.each do |cookie| + str << "
  • #{cookie.name}: #{cookie.value}
  • " + end + str << "
" + end + response +end + +# Route to delete a specific cookie +get "/delete-cookie/:name" do |env| + cookie_name = env.params.url["name"] + + # Set cookie with immediate expiration to delete it + delete_cookie = HTTP::Cookie.new( + name: cookie_name, + value: "", + expires: Time.local - 1.day + ) + + env.response.cookies << delete_cookie + "Cookie '#{cookie_name}' has been deleted!" +end + +Kemal.run diff --git a/examples/cors/app.cr b/examples/cors/app.cr new file mode 100644 index 0000000..ae5b067 --- /dev/null +++ b/examples/cors/app.cr @@ -0,0 +1,17 @@ +require "kemal" + +# Configure headers for static files using Kemal's static_headers helper +static_headers do |response, filepath, filestat| + # For HTML files, add CORS header to allow requests from example.com + # This restricts access to HTML files to only that domain + if filepath =~ /\.html$/ + response.headers.add("Access-Control-Allow-Origin", "example.com") + end + + # Add Content-Size header for all static files + # This helps clients know the file size before downloading + response.headers.add("Content-Size", filestat.size.to_s) +end + +# Start the Kemal web server +Kemal.run diff --git a/examples/file-download/app.cr b/examples/file-download/app.cr new file mode 100644 index 0000000..dd23487 --- /dev/null +++ b/examples/file-download/app.cr @@ -0,0 +1,18 @@ +require "kemal" + +# Define a route for the root path "/" that will handle file downloads +get "/" do |env| + # Use Kemal's send_file helper to stream a file to the client + # Parameters: + # - env: The HTTP environment containing request/response data + # - "/path/to/your_file": The path to the file you want to download + # + # send_file will: + # - Set appropriate Content-Type header based on file extension + # - Stream the file in chunks to handle large files efficiently + # - Set Content-Disposition header for browser download behavior + send_file env, "/path/to/your_file" +end + +# Start the Kemal web server +Kemal.run diff --git a/examples/file-upload/app.cr b/examples/file-upload/app.cr new file mode 100644 index 0000000..bc8b1f4 --- /dev/null +++ b/examples/file-upload/app.cr @@ -0,0 +1,25 @@ +require "kemal" + +# Handle file uploads via POST request to /upload endpoint +post "/upload" do |env| + # Get the uploaded file from the "image" field in the form + # The file is initially stored in a temporary location + file = env.params.files["image"].tempfile + + # Construct the destination path where we'll save the file + # - Kemal.config.public_folder is the configured public directory + # - "uploads/" is the subdirectory where we'll store uploads + # - File.basename gets just the filename from the temp file path + file_path = ::File.join [Kemal.config.public_folder, "uploads/", File.basename(file.path)] + + # Open the destination file for writing and copy the uploaded file to it + File.open(file_path, "w") do |f| + IO.copy(file, f) + end + + # Return a simple success message + "Upload ok" +end + +# Start the Kemal server +Kemal.run diff --git a/examples/hello-world/app.cr b/examples/hello-world/app.cr new file mode 100644 index 0000000..fa687db --- /dev/null +++ b/examples/hello-world/app.cr @@ -0,0 +1,7 @@ +require "kemal" + +get "/" do + "Hello Kemal!" +end + +Kemal.run diff --git a/examples/http-basic-auth/app.cr b/examples/http-basic-auth/app.cr new file mode 100644 index 0000000..35facbd --- /dev/null +++ b/examples/http-basic-auth/app.cr @@ -0,0 +1,18 @@ +require "kemal" +require "kemal-basic-auth" + +# Enable HTTP Basic Authentication +# This will protect all routes with username/password authentication +# - username: "username" +# - password: "password" +basic_auth "username", "password" + +# Define a route for the root path "/" +get "/" do |env| + # This route will only execute if authentication is successful + # Otherwise, the browser will show a login prompt + "This is shown if basic auth successful." +end + +# Start the Kemal web server +Kemal.run diff --git a/examples/http-basic-auth/custom-handler.cr b/examples/http-basic-auth/custom-handler.cr new file mode 100644 index 0000000..0911973 --- /dev/null +++ b/examples/http-basic-auth/custom-handler.cr @@ -0,0 +1,23 @@ +require "kemal-basic-auth" + +# Create a custom authentication handler by inheriting from Kemal::BasicAuth::Handler +class CustomAuthHandler < Kemal::BasicAuth::Handler + # Specify which routes should be protected by basic auth + # In this case, only /dashboard and /admin routes will require authentication + only ["/dashboard", "/admin"] + + # Override the call method to implement custom authentication logic + def call(context) + # Skip authentication if the current route is not in the protected routes list + # This allows other routes to be accessed without authentication + return call_next(context) unless only_match?(context) + + # Call the parent class's authentication logic for protected routes + # This will prompt for username/password and validate credentials + super + end +end + +# Register our custom authentication handler with Kemal +# This enables basic auth for the specified routes +Kemal.config.auth_handler = CustomAuthHandler diff --git a/examples/json-api/app.cr b/examples/json-api/app.cr new file mode 100644 index 0000000..a80f766 --- /dev/null +++ b/examples/json-api/app.cr @@ -0,0 +1,68 @@ +require "kemal" +require "json" + +# Set JSON content type for all routes +before_all do |env| + env.response.content_type = "application/json" +end + +# In-memory storage for users +USERS = [] of Hash(String, JSON::Any) + +# GET - List all users +get "/users" do |env| + USERS.to_json +end + +# GET - Get a specific user by index +get "/users/:id" do |env| + id = env.params.url["id"].to_i + + if id < USERS.size + USERS[id].to_json + else + env.response.status_code = 404 + {error: "User not found"}.to_json + end +end + +# POST - Create a new user +post "/users" do |env| + # Parse request body as JSON + user = JSON.parse(env.request.body.not_nil!.gets_to_end) + USERS << user.as_h + + env.response.status_code = 201 + user.to_json +end + +# PUT - Update a user +put "/users/:id" do |env| + id = env.params.url["id"].to_i + + if id < USERS.size + # Parse request body as JSON + updated_user = JSON.parse(env.request.body.not_nil!.gets_to_end) + USERS[id] = updated_user.as_h + updated_user.to_json + else + env.response.status_code = 404 + {error: "User not found"}.to_json + end +end + +# DELETE - Remove a user +delete "/users/:id" do |env| + id = env.params.url["id"].to_i + + if id < USERS.size + deleted_user = USERS.delete_at(id) + deleted_user.to_json + else + env.response.status_code = 404 + {error: "User not found"}.to_json + end +end + +# Start the Kemal web server +Kemal.run diff --git a/examples/json-mapping/app.cr b/examples/json-mapping/app.cr new file mode 100644 index 0000000..903eeb5 --- /dev/null +++ b/examples/json-mapping/app.cr @@ -0,0 +1,30 @@ +require "kemal" +require "json" + +# Define a User class that can be created from JSON data +class User + # Include JSON::Serializable to add JSON parsing capabilities + # This allows converting JSON strings to User objects and vice versa + include JSON::Serializable + + # Define properties that will be mapped from JSON + # These properties must match the keys in the incoming JSON + property username : String # User's username as a string + property password : String # User's password as a string +end + +# Handle POST requests to the root path "/" +post "/" do |env| + # Parse the request body as JSON and create a User object + # env.request.body contains the raw JSON data + # not_nil! ensures the body exists + # User.from_json converts the JSON string to a User object + user = User.from_json env.request.body.not_nil! + + # Convert the user object back to JSON and return it + # This creates a JSON object with username and password fields + {username: user.username, password: user.password}.to_json +end + +# Start the Kemal web server +Kemal.run diff --git a/examples/mysql-db/app.cr b/examples/mysql-db/app.cr new file mode 100644 index 0000000..d6ba522 --- /dev/null +++ b/examples/mysql-db/app.cr @@ -0,0 +1,59 @@ +require "kemal" +require "db" +require "mysql" + +# Initialize a single DB connection +DB_URL = "mysql://root:password@localhost:3306/mydb" +DBC = DB.open(DB_URL) + +# Example User model +class User + include JSON::Serializable # To render json in HTTP::Response + include DB::Serializable # To serialize from DB::ResultSet + + property id : Int32 + property name : String + property email : String + + def initialize(@id, @name, @email) + end +end + +# List all users +get "/users" do |env| + # Initialize empty array to store User objects + users = [] of User + + # Serialize ResultSet + users = User.from_rs(DBC.query("SELECT * FROM users")) + + # Return users array as JSON response + users.to_json +end + +# Create a new user +post "/users" do |env| + name = env.params.json["name"].as(String) + email = env.params.json["email"].as(String) + + user = User.from_rs(DBC.query("INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email", name, email)).first + + {message: "User created with id: #{user.id}"}.to_json +end + +# Delete a user +delete "/users/:id" do |env| + id = env.params.url["id"].to_i + + # Delete user and check if any rows were affected + result = DB.exec "DELETE FROM users WHERE id = ?", id + + if result.rows_affected > 0 + {message: "User deleted successfully"}.to_json + else + env.response.status_code = 404 + {message: "User not found"}.to_json + end +end + +Kemal.run diff --git a/examples/postgresql-db/app.cr b/examples/postgresql-db/app.cr new file mode 100644 index 0000000..14fbc4b --- /dev/null +++ b/examples/postgresql-db/app.cr @@ -0,0 +1,59 @@ +require "kemal" +require "db" +require "pg" + +# Initialize a single DB connection +DB_URL = "postgres://postgres:postgres@localhost:5432/mydb" +DBC = DB.open(DB_URL) + +# Example User model +class User + include JSON::Serializable # To render json in HTTP::Response + include DB::Serializable # To serialize from DB::ResultSet + + property id : Int32 + property name : String + property email : String + + def initialize(@id, @name, @email) + end +end + +# List all users +get "/users" do |env| + # Initialize empty array to store User objects + users = [] of User + + # Serialize ResultSet + users = User.from_rs(DBC.query("SELECT * FROM users")) + + # Return users array as JSON response + users.to_json +end + +# Create a new user +post "/users" do |env| + name = env.params.json["name"].as(String) + email = env.params.json["email"].as(String) + + user = User.from_rs(DBC.query("INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email", name, email)).first + + {message: "User created with id: #{user.id}"}.to_json +end + +# Delete a user +delete "/users/:id" do |env| + id = env.params.url["id"].to_i + + # Delete user and check if any rows were affected + result = DB.exec "DELETE FROM users WHERE id = $1", id + + if result.rows_affected > 0 + {message: "User deleted successfully"}.to_json + else + env.response.status_code = 404 + {message: "User not found"}.to_json + end +end + +Kemal.run diff --git a/examples/redis/app.cr b/examples/redis/app.cr new file mode 100644 index 0000000..c7a8492 --- /dev/null +++ b/examples/redis/app.cr @@ -0,0 +1,58 @@ +require "kemal" +require "redis" + +# Initialize Redis client +REDIS = Redis.new(host: "localhost", port: 6379) + +# Store a value +post "/store/:key" do |env| + key = env.params.url["key"] + value = env.params.json["value"].as(String) + + REDIS.set(key, value) + {message: "Value stored successfully"}.to_json +end + +# Retrieve a value +get "/get/:key" do |env| + key = env.params.url["key"] + + if value = REDIS.get(key) + {key: key, value: value}.to_json + else + env.response.status_code = 404 + {message: "Key not found"}.to_json + end +end + +# Delete a value +delete "/:key" do |env| + key = env.params.url["key"] + + if REDIS.del(key) > 0 + {message: "Key deleted successfully"}.to_json + else + env.response.status_code = 404 + {message: "Key not found"}.to_json + end +end + +# Increment a counter +post "/incr/:key" do |env| + key = env.params.url["key"] + new_value = REDIS.incr(key) + + {key: key, value: new_value}.to_json +end + +# Store with expiration +post "/store_temp/:key" do |env| + key = env.params.url["key"] + value = env.params.json["value"].as(String) + ttl = env.params.json["ttl"].as(Int64) + + REDIS.setex(key, ttl, value) + {message: "Value stored with expiration"}.to_json +end + +Kemal.run diff --git a/examples/reuse-port/app.cr b/examples/reuse-port/app.cr new file mode 100644 index 0000000..f8abd82 --- /dev/null +++ b/examples/reuse-port/app.cr @@ -0,0 +1,17 @@ +require "kemal" + +# Define a simple route that returns a message +get "/" do + "Reusing port 3000" +end + +# Start Kemal with custom server configuration +Kemal.run do |config| + # Get the server instance from the config + server = config.server.not_nil! + + # Bind the server to port 3000 with reuse_port enabled + # reuse_port: true allows multiple processes to listen on the same port + # This is useful for load balancing across multiple worker processes + server.bind_tcp "0.0.0.0", 3000, reuse_port: true +end diff --git a/examples/unix-domain-socket/app.cr b/examples/unix-domain-socket/app.cr new file mode 100644 index 0000000..5ba0fa7 --- /dev/null +++ b/examples/unix-domain-socket/app.cr @@ -0,0 +1,12 @@ +require "kemal" + +# Start Kemal with custom server configuration to use Unix Domain Socket +Kemal.run do |config| + # Get the server instance from the config + server = config.server.not_nil! + + # Bind the server to a Unix Domain Socket instead of TCP port + # Unix Domain Sockets provide faster inter-process communication on the same machine + # They are commonly used when the client and server are on the same host + server.bind_unix "path/to/socket.sock" +end diff --git a/examples/websocket-chat/app.cr b/examples/websocket-chat/app.cr new file mode 100644 index 0000000..b7e380d --- /dev/null +++ b/examples/websocket-chat/app.cr @@ -0,0 +1,33 @@ +require "kemal" + +# Array to store chat message history +messages = [] of String +# Array to keep track of connected WebSocket clients +sockets = [] of HTTP::WebSocket + +# Create WebSocket endpoint at root path "/" +ws "/" do |socket| + # Add newly connected client socket to our sockets array + sockets.push socket + + # Handle incoming messages from clients + socket.on_message do |message| + # Store the new message in history + messages.push message + # Broadcast the updated message history to all connected clients + sockets.each do |a_socket| + a_socket.send messages.to_json + end + end + + # Handle client disconnection + socket.on_close do |_| + # Remove disconnected client's socket from our array + sockets.delete(socket) + # Log disconnection event + puts "Closing Socket: #{socket}" + end +end + +# Start the Kemal server +Kemal.run diff --git a/samples/hello_world.cr b/samples/hello_world.cr deleted file mode 100644 index c04f1d5..0000000 --- a/samples/hello_world.cr +++ /dev/null @@ -1,8 +0,0 @@ -require "kemal" - -# Set root. If not specified the default content_type is 'text' -get "/" do - "Hello Kemal!" -end - -Kemal.run diff --git a/samples/json_api.cr b/samples/json_api.cr deleted file mode 100644 index 0132c14..0000000 --- a/samples/json_api.cr +++ /dev/null @@ -1,11 +0,0 @@ -require "kemal" -require "json" - -# You can easily access the context and set content_type like 'application/json'. -# Look how easy to build a JSON serving API. -get "/" do |env| - env.response.content_type = "application/json" - {name: "Serdar", age: 27}.to_json -end - -Kemal.run diff --git a/samples/websocket_server.cr b/samples/websocket_server.cr deleted file mode 100644 index 61a0802..0000000 --- a/samples/websocket_server.cr +++ /dev/null @@ -1,11 +0,0 @@ -require "kemal" - -ws "/" do |socket| - socket.send "Hello from Kemal!" - - socket.on_message do |message| - socket.send "Echo back from server #{message}" - end -end - -Kemal.run From e684d2ec9fac727bf63865c738d6db7529a6924c Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:30:23 +0300 Subject: [PATCH 31/72] Fix ameba errors --- examples/file-upload/app.cr | 8 ++++---- examples/http-basic-auth/app.cr | 2 +- examples/json-api/app.cr | 6 +++++- examples/json-mapping/app.cr | 2 ++ examples/mysql-db/app.cr | 5 +---- examples/postgresql-db/app.cr | 5 +---- examples/reuse-port/app.cr | 2 ++ examples/unix-domain-socket/app.cr | 2 ++ 8 files changed, 18 insertions(+), 14 deletions(-) diff --git a/examples/file-upload/app.cr b/examples/file-upload/app.cr index bc8b1f4..7259ab0 100644 --- a/examples/file-upload/app.cr +++ b/examples/file-upload/app.cr @@ -4,17 +4,17 @@ require "kemal" post "/upload" do |env| # Get the uploaded file from the "image" field in the form # The file is initially stored in a temporary location - file = env.params.files["image"].tempfile + uploaded_file = env.params.files["image"].tempfile # Construct the destination path where we'll save the file # - Kemal.config.public_folder is the configured public directory # - "uploads/" is the subdirectory where we'll store uploads # - File.basename gets just the filename from the temp file path - file_path = ::File.join [Kemal.config.public_folder, "uploads/", File.basename(file.path)] + uploaded_file_path = ::File.join [Kemal.config.public_folder, "uploads/", File.basename(uploaded_file.path)] # Open the destination file for writing and copy the uploaded file to it - File.open(file_path, "w") do |f| - IO.copy(file, f) + File.open(uploaded_file_path, "w") do |file| + IO.copy(uploaded_file, file) end # Return a simple success message diff --git a/examples/http-basic-auth/app.cr b/examples/http-basic-auth/app.cr index 35facbd..5cd2ca3 100644 --- a/examples/http-basic-auth/app.cr +++ b/examples/http-basic-auth/app.cr @@ -8,7 +8,7 @@ require "kemal-basic-auth" basic_auth "username", "password" # Define a route for the root path "/" -get "/" do |env| +get "/" do |_| # This route will only execute if authentication is successful # Otherwise, the browser will show a login prompt "This is shown if basic auth successful." diff --git a/examples/json-api/app.cr b/examples/json-api/app.cr index a80f766..af9ae63 100644 --- a/examples/json-api/app.cr +++ b/examples/json-api/app.cr @@ -10,7 +10,7 @@ end USERS = [] of Hash(String, JSON::Any) # GET - List all users -get "/users" do |env| +get "/users" do |_| USERS.to_json end @@ -29,7 +29,9 @@ end # POST - Create a new user post "/users" do |env| # Parse request body as JSON + # ameba:disable Lint/NotNil user = JSON.parse(env.request.body.not_nil!.gets_to_end) + # ameba:enable Lint/NotNil USERS << user.as_h env.response.status_code = 201 @@ -42,7 +44,9 @@ put "/users/:id" do |env| if id < USERS.size # Parse request body as JSON + # ameba:disable Lint/NotNil updated_user = JSON.parse(env.request.body.not_nil!.gets_to_end) + # ameba:enable Lint/NotNil USERS[id] = updated_user.as_h updated_user.to_json else diff --git a/examples/json-mapping/app.cr b/examples/json-mapping/app.cr index 903eeb5..08b4ba7 100644 --- a/examples/json-mapping/app.cr +++ b/examples/json-mapping/app.cr @@ -19,7 +19,9 @@ post "/" do |env| # env.request.body contains the raw JSON data # not_nil! ensures the body exists # User.from_json converts the JSON string to a User object + # ameba:disable Lint/NotNil user = User.from_json env.request.body.not_nil! + # ameba:enable Lint/NotNil # Convert the user object back to JSON and return it # This creates a JSON object with username and password fields diff --git a/examples/mysql-db/app.cr b/examples/mysql-db/app.cr index d6ba522..3f2cef5 100644 --- a/examples/mysql-db/app.cr +++ b/examples/mysql-db/app.cr @@ -20,10 +20,7 @@ class User end # List all users -get "/users" do |env| - # Initialize empty array to store User objects - users = [] of User - +get "/users" do |_| # Serialize ResultSet users = User.from_rs(DBC.query("SELECT * FROM users")) diff --git a/examples/postgresql-db/app.cr b/examples/postgresql-db/app.cr index 14fbc4b..0f85218 100644 --- a/examples/postgresql-db/app.cr +++ b/examples/postgresql-db/app.cr @@ -20,10 +20,7 @@ class User end # List all users -get "/users" do |env| - # Initialize empty array to store User objects - users = [] of User - +get "/users" do |_| # Serialize ResultSet users = User.from_rs(DBC.query("SELECT * FROM users")) diff --git a/examples/reuse-port/app.cr b/examples/reuse-port/app.cr index f8abd82..b059252 100644 --- a/examples/reuse-port/app.cr +++ b/examples/reuse-port/app.cr @@ -8,7 +8,9 @@ end # Start Kemal with custom server configuration Kemal.run do |config| # Get the server instance from the config + # ameba:disable Lint/NotNil server = config.server.not_nil! + # ameba:enable Lint/NotNil # Bind the server to port 3000 with reuse_port enabled # reuse_port: true allows multiple processes to listen on the same port diff --git a/examples/unix-domain-socket/app.cr b/examples/unix-domain-socket/app.cr index 5ba0fa7..aae5973 100644 --- a/examples/unix-domain-socket/app.cr +++ b/examples/unix-domain-socket/app.cr @@ -3,7 +3,9 @@ require "kemal" # Start Kemal with custom server configuration to use Unix Domain Socket Kemal.run do |config| # Get the server instance from the config + # ameba:disable Lint/NotNil server = config.server.not_nil! + # ameba:enable Lint/NotNil # Bind the server to a Unix Domain Socket instead of TCP port # Unix Domain Sockets provide faster inter-process communication on the same machine From 5bd65bcb737d38ee3ecec708ac0426a1020d93f3 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:29:26 +0300 Subject: [PATCH 32/72] improve run_spec, move Kemal configuration to run method and update server bind ports --- spec/run_spec.cr | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/spec/run_spec.cr b/spec/run_spec.cr index c849c00..d69813b 100644 --- a/spec/run_spec.cr +++ b/spec/run_spec.cr @@ -3,6 +3,10 @@ require "./spec_helper" private def run(code) code = <<-CR require "./src/kemal" + + Kemal.config.env = "test" + Kemal.config.port = 8000 + #{code} CR @@ -16,7 +20,6 @@ end describe "Run" do it "runs a code block after starting" do run(<<-CR).should contain("started") - Kemal.config.env = "test" Kemal.run do log "started" end @@ -25,7 +28,6 @@ describe "Run" do it "runs a code block after stopping" do run(<<-CR).should contain("stopped") - Kemal.config.env = "test" Kemal.run do Kemal.stop log "stopped" @@ -35,7 +37,6 @@ describe "Run" do it "runs without a block being specified" do run(<<-CR).should contain "[test] Kemal is running in test mode." - Kemal.config.env = "test" Kemal.run Kemal.config.running CR @@ -43,15 +44,14 @@ describe "Run" do it "allows custom HTTP::Server bind" do run(<<-CR).should contain "[test] Kemal is running in test mode." - Kemal.config.env = "test" Kemal.run do |config| server = config.server.not_nil! {% if flag?(:windows) %} - server.bind_tcp "127.0.0.1", 3000 + server.bind_tcp "127.0.0.1", 8000 {% else %} - server.bind_tcp "127.0.0.1", 3000, reuse_port: true - server.bind_tcp "0.0.0.0", 3001, reuse_port: true + server.bind_tcp "127.0.0.1", 8000, reuse_port: true + server.bind_tcp "0.0.0.0", 8001, reuse_port: true {% end %} end CR From 10ad40cbced1083ed48d71e27c2f998edf896451 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:42:12 +0300 Subject: [PATCH 33/72] Refactor server binding logic to improve readability and maintainability --- src/kemal.cr | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/kemal.cr b/src/kemal.cr index 7ae7c3d..ec24c49 100644 --- a/src/kemal.cr +++ b/src/kemal.cr @@ -49,23 +49,25 @@ module Kemal yield config # Abort if block called `Kemal.stop` - return unless config.running + return if !config.running - unless config.env == "test" && server.each_address { |_| break true } - {% if flag?(:without_openssl) %} - server.bind_tcp(config.host_binding, config.port) - {% else %} - if ssl = config.ssl - server.bind_tls(config.host_binding, config.port, ssl) - else + if config.env != "test" + if !server.each_address { |_| break true } + {% if flag?(:without_openssl) %} server.bind_tcp(config.host_binding, config.port) - end - {% end %} + {% else %} + if ssl = config.ssl + server.bind_tls(config.host_binding, config.port, ssl) + else + server.bind_tcp(config.host_binding, config.port) + end + {% end %} + end end display_startup_message(config, server) - server.listen unless config.env == "test" + server.listen if config.env != "test" end def self.display_startup_message(config, server) From fa0034d007b85fab5fc5e95bc2c16b7f849c64dd Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:47:00 +0300 Subject: [PATCH 34/72] Update CHANGELOG --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 253216e..41717fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 1.7.3 (02-10-2025) + +- Refactor [#719](https://github.com/kemalcr/kemal/pull/719). Thanks @sdogruyol :pray: +- Improv Kemal test suite. Thanks @sdogruyol :pray: + # 1.7.2 (04-08-2025) - Move Kemal::Handler logic into separate module [#717](https://github.com/kemalcr/kemal/pull/717). Thanks @syeopite :pray: From c673b3960e44a23cf73879892abf9cc345fe27e6 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:48:09 +0300 Subject: [PATCH 35/72] Bump version to 1.7.3 --- shard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shard.yml b/shard.yml index 87f84e3..b57b2fb 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: kemal -version: 1.7.2 +version: 1.7.3 authors: - Serdar Dogruyol From d9352d511575e80c7a0550648604fc29d6a4a49e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Fri, 31 Oct 2025 19:11:20 +0300 Subject: [PATCH 36/72] Replace full-flush Route cache with LRU and add a configurable max cache size (#724) --- spec/route_handler_spec.cr | 110 +++++++++++++++++++++++++++++++++++++ spec/spec_helper.cr | 2 +- src/kemal/config.cr | 3 + src/kemal/route_handler.cr | 109 ++++++++++++++++++++++++++++++++++-- 4 files changed, 217 insertions(+), 7 deletions(-) diff --git a/spec/route_handler_spec.cr b/spec/route_handler_spec.cr index e3046e3..5019b8c 100644 --- a/spec/route_handler_spec.cr +++ b/spec/route_handler_spec.cr @@ -179,4 +179,114 @@ describe "Kemal::RouteHandler" do client_response.body.should eq("home page") client_response.headers.has_key?("Location").should eq(true) end + + context "LRU cache" do + it "evicts least recently used entries instead of clearing entirely" do + # Use a small capacity to make the test fast and deterministic + small_capacity = 8 + # Replace the cache instance with a smaller-capacity LRU for this test + Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(small_capacity) + + # Define more routes than capacity + 0.upto(15) do |i| + get "/lru_eviction_#{i}" do + "ok" + end + end + + # Access the first `small_capacity` routes to fill the cache + 0.upto(small_capacity - 1) do |i| + Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_eviction_#{i}") + end + + Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq small_capacity + + # Access some new routes to trigger eviction + small_capacity.upto(small_capacity + 3) do |i| + Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_eviction_#{i}") + end + + # Cache should still be capped at capacity + Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq small_capacity + end + + it "retains recently used keys and evicts the least recently used" do + small_capacity = 4 + Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(small_capacity) + + 0.upto(5) do |i| + get "/lru_recency_#{i}" do + "ok" + end + end + + # Fill cache with 0..3 + 0.upto(3) do |i| + Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_#{i}") + end + + # Touch 0 and 1 to make them most recent + [0, 1].each do |i| + Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_#{i}") + end + + # Insert 4 -> should evict least recent among {2,3} + Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_4") + + # Insert 5 -> should evict the other of {2,3} + Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_5") + + # Now 0 and 1 must still resolve from cache, and size is capped + Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq small_capacity + + # A fresh lookup for 0 and 1 should be cache hits and not raise + Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_0").found?.should be_true + Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_1").found?.should be_true + end + + it "caches HEAD fallback GET lookups without growing beyond 1 for same path" do + cap = 16 + Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(cap) + + get "/head_fallback" do + "ok" + end + + # First HEAD should fallback to GET and cache one entry keyed by HEAD+path + Kemal::RouteHandler::INSTANCE.lookup_route("HEAD", "/head_fallback").found?.should be_true + Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq 1 + + # Second HEAD lookup should be a cache hit; size must remain 1 + Kemal::RouteHandler::INSTANCE.lookup_route("HEAD", "/head_fallback").found?.should be_true + Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq 1 + end + + it "keeps size capped under heavy churn with large capacity" do + large_capacity = 4096 + Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(large_capacity) + + 0.upto(12000) do |i| + get "/lru_heavy_#{i}" do + "ok" + end + end + + # Fill and churn beyond capacity + 0.upto(11999) do |i| + Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_heavy_#{i}") + end + + Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq large_capacity + + # Additional churn should not increase size + 12000.upto(14000) do |i| + get "/lru_heavy_more_#{i}" do + "ok" + end + Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_heavy_more_#{i}") + end + + Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq large_capacity + end + end end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 8ce02fa..7b18fbd 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -93,6 +93,6 @@ Spec.after_each do Kemal.config.clear Kemal::FilterHandler::INSTANCE.tree = Radix::Tree(Array(Kemal::FilterHandler::FilterBlock)).new Kemal::RouteHandler::INSTANCE.routes = Radix::Tree(Route).new - Kemal::RouteHandler::INSTANCE.cached_routes = Hash(String, Radix::Result(Route)).new + Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(Kemal.config.max_route_cache_size) Kemal::WebSocketHandler::INSTANCE.routes = Radix::Tree(WebSocket).new end diff --git a/src/kemal/config.cr b/src/kemal/config.cr index e4e614e..3bf3b6c 100644 --- a/src/kemal/config.cr +++ b/src/kemal/config.cr @@ -26,6 +26,7 @@ module Kemal property serve_static : (Bool | Hash(String, Bool)) property static_headers : (HTTP::Server::Context, String, File::Info -> Void)? property? powered_by_header : Bool = true + property max_route_cache_size : Int32 def initialize @app_name = "Kemal" @@ -43,6 +44,7 @@ module Kemal @running = false @shutdown_message = true @handler_position = 0 + @max_route_cache_size = 1024 end @[Deprecated("Use standard library Log")] @@ -69,6 +71,7 @@ module Kemal @router_included = false @handler_position = 0 @default_handlers_setup = false + @max_route_cache_size = 1024 HANDLERS.clear CUSTOM_HANDLERS.clear FILTER_HANDLERS.clear diff --git a/src/kemal/route_handler.cr b/src/kemal/route_handler.cr index 5f267ba..46f9de5 100644 --- a/src/kemal/route_handler.cr +++ b/src/kemal/route_handler.cr @@ -1,16 +1,114 @@ require "radix" module Kemal + # Small, private LRU cache used by the router to avoid full cache clears + # when many distinct paths are accessed. Keeps get/put at O(1). + # This is intentionally minimal and file-local to avoid API surface. + class LRUCache(K, V) + # Doubly-linked list node + class Node(K, V) + property key : K + property value : V + property prev : Node(K, V)? + property next : Node(K, V)? + + def initialize(@key : K, @value : V) + @prev = nil + @next = nil + end + end + + @capacity : Int32 + @map : Hash(K, Node(K, V)) + @head : Node(K, V)? # most-recent + @tail : Node(K, V)? # least-recent + + def initialize(@capacity : Int32) + @map = Hash(K, Node(K, V)).new + @head = nil + @tail = nil + end + + def size : Int32 + @map.size + end + + def get(key : K) : V? + if node = @map[key]? + move_to_front(node) + return node.value + end + nil + end + + def put(key : K, value : V) : Nil + if node = @map[key]? + node.value = value + move_to_front(node) + return + end + + # Evict before adding to avoid unnecessary hash resize + evict_if_at_capacity + + node = Node(K, V).new(key, value) + @map[key] = node + insert_front(node) + end + + private def insert_front(node : Node(K, V)) + node.prev = nil + node.next = @head + @head.try(&.prev=(node)) + @head = node + @tail = node if @tail.nil? + end + + private def move_to_front(node : Node(K, V)) + return if node == @head + + # unlink + prev = node.prev + nxt = node.next + prev.try(&.next=(nxt)) + nxt.try(&.prev=(prev)) + + # fix tail if needed + if node == @tail + @tail = prev + end + + insert_front(node) + end + + private def evict_if_at_capacity + return if @map.size < @capacity + + if lru = @tail + # unlink tail + prev = lru.prev + if prev + prev.next = nil + @tail = prev + else + # only one element + @head = nil + @tail = nil + end + @map.delete(lru.key) + end + end + end + class RouteHandler include HTTP::Handler - INSTANCE = new - CACHED_ROUTES_LIMIT = 1024 + INSTANCE = new property routes, cached_routes def initialize @routes = Radix::Tree(Route).new - @cached_routes = Hash(String, Radix::Result(Route)).new + @cached_routes = LRUCache(String, Radix::Result(Route)).new(Kemal.config.max_route_cache_size) end def call(context : HTTP::Server::Context) @@ -26,7 +124,7 @@ module Kemal def lookup_route(verb : String, path : String) lookup_path = radix_path(verb, path) - if cached_route = @cached_routes[lookup_path]? + if cached_route = @cached_routes.get(lookup_path) return cached_route end @@ -38,8 +136,7 @@ module Kemal end if route.found? - @cached_routes.clear if @cached_routes.size == CACHED_ROUTES_LIMIT - @cached_routes[lookup_path] = route + @cached_routes.put(lookup_path, route) end route From 24609c1b314575071cee993af3728c30e72e3ca3 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Sat, 1 Nov 2025 10:39:38 +0300 Subject: [PATCH 37/72] Update README to change 'Sample Applications' to 'Example Applications' and fix link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7d68506..3d8a858 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ crystal run src/your_app.cr ## Learning Resources - ๐Ÿ“š [Official Documentation](http://kemalcr.com) -- ๐Ÿ’ป [Sample Applications](https://github.com/kemalcr/kemal/tree/master/samples) +- ๐Ÿ’ป [Example Applications](https://github.com/kemalcr/kemal/tree/master/examples) - ๐Ÿš€ [Getting Started Guide](http://kemalcr.com/guide/) - ๐Ÿ’ฌ [Community Chat](https://discord.gg/prSVAZJEpz) From 2648a32f275f8a34bcb970830d6db6fd12a6e024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Mon, 3 Nov 2025 11:54:42 +0300 Subject: [PATCH 38/72] Optimize route and websocket lookups by caching results to reduce redundant processing in the HTTP server context (#725) --- src/kemal/ext/context.cr | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/kemal/ext/context.cr b/src/kemal/ext/context.cr index 2ed168d..7e06f7b 100644 --- a/src/kemal/ext/context.cr +++ b/src/kemal/ext/context.cr @@ -11,11 +11,16 @@ class HTTP::Server macro finished alias StoreTypes = Union({{ STORE_MAPPINGS.splat }}) @store = {} of String => StoreTypes + @cached_route_lookup : Radix::Result(Kemal::Route)? + @cached_ws_route_lookup : Radix::Result(Kemal::WebSocket)? end + # Optimized: Use cached lookup results to avoid redundant route lookups + # when params is accessed after route_found? or route has already been called def params - if ws_route_found? - @params ||= Kemal::ParamParser.new(@request, ws_route_lookup.params) + ws_lookup = ws_route_lookup + if ws_lookup.found? + @params ||= Kemal::ParamParser.new(@request, ws_lookup.params) else @params ||= Kemal::ParamParser.new(@request, route_lookup.params) end @@ -36,16 +41,19 @@ class HTTP::Server ws_route_lookup.payload end + # Optimized: Cache route lookup result to avoid redundant lookups + # when called multiple times (e.g., route_found?, route, params) def route_lookup - Kemal::RouteHandler::INSTANCE.lookup_route(@request.method.as(String), @request.path) + @cached_route_lookup ||= Kemal::RouteHandler::INSTANCE.lookup_route(@request.method.as(String), @request.path) end def route_found? route_lookup.found? end + # Optimized: Cache websocket route lookup result to avoid redundant lookups def ws_route_lookup - Kemal::WebSocketHandler::INSTANCE.lookup_ws_route(@request.path) + @cached_ws_route_lookup ||= Kemal::WebSocketHandler::INSTANCE.lookup_ws_route(@request.path) end def ws_route_found? From a5653eee927d674d66824ae74ec6bb6ba7bebbc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:32:58 +0300 Subject: [PATCH 39/72] Improve error messages (#726) --- src/kemal.cr | 4 ++-- src/kemal/cli.cr | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/kemal.cr b/src/kemal.cr index ec24c49..04275f6 100644 --- a/src/kemal.cr +++ b/src/kemal.cr @@ -80,12 +80,12 @@ module Kemal end def self.stop - raise "#{Kemal.config.app_name} is already stopped." if !config.running + raise "#{Kemal.config.app_name} is already stopped. Cannot stop an already stopped server." if !config.running if server = config.server server.close unless server.closed? config.running = false else - raise "Kemal.config.server is not set. Please use Kemal.run to set the server." + raise "Cannot stop #{Kemal.config.app_name}: server instance is not set. Please ensure Kemal.run has been called before calling Kemal.stop." end end diff --git a/src/kemal/cli.cr b/src/kemal/cli.cr index b166708..8d7592b 100644 --- a/src/kemal/cli.cr +++ b/src/kemal/cli.cr @@ -42,8 +42,8 @@ module Kemal private def configure_ssl {% if !flag?(:without_openssl) %} if @ssl_enabled - abort "SSL Key Not Found" if !@key_file - abort "SSL Certificate Not Found" if !@cert_file + abort "SSL configuration error: SSL key file not specified. Use --ssl-key-file FILE to specify the key file." if !@key_file + abort "SSL configuration error: SSL certificate file not specified. Use --ssl-cert-file FILE to specify the certificate file." if !@cert_file ssl = Kemal::SSL.new ssl.key_file = @key_file.not_nil! ssl.cert_file = @cert_file.not_nil! From 1643905df441a0af397547afadf7ce411b8e30ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:33:35 +0300 Subject: [PATCH 40/72] Enhance HEAD request handling by caching GET route lookups and optimize path construction using string interpolation for improved performance. (#728) --- src/kemal/route_handler.cr | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/kemal/route_handler.cr b/src/kemal/route_handler.cr index 46f9de5..bda86dd 100644 --- a/src/kemal/route_handler.cr +++ b/src/kemal/route_handler.cr @@ -132,10 +132,14 @@ module Kemal if verb == "HEAD" && !route.found? # On HEAD requests, implicitly fallback to running the GET handler. - route = @routes.find(radix_path("GET", path)) - end - - if route.found? + get_lookup_path = radix_path("GET", path) + get_route = @routes.find(get_lookup_path) + # Cache the HEAD->GET fallback result using the original HEAD lookup_path + if get_route.found? + @cached_routes.put(lookup_path, get_route) + end + route = get_route + elsif route.found? @cached_routes.put(lookup_path, route) end @@ -160,7 +164,7 @@ module Kemal end private def radix_path(method, path) - '/' + method + path + "/#{method}#{path}" end private def add_to_radix_tree(method, path, route) From e4e04654a358e7aa78df84b4a6501bc4d6bbee0a Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Fri, 7 Nov 2025 10:27:02 +0300 Subject: [PATCH 41/72] Update CHANGELOG --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41717fa..40248d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# 1.8.0 (07-11-2025) + +- Enhance HEAD request handling by caching GET route lookups and optimize path construction using string interpolation for improved performance [#728](https://github.com/kemalcr/kemal/pull/728). Thanks @sdogruyol :pray: +- Improve error messages [#726](https://github.com/kemalcr/kemal/pull/726). Thanks @sdogruyol :pray: +- Optimize route and websocket lookups by caching results to reduce redundant processing in the HTTP server context [#725](https://github.com/kemalcr/kemal/pull/725). Thanks @sdogruyol :pray: +- Replace full-flush Route cache with LRU and add a configurable max cache size [#724](https://github.com/kemalcr/kemal/pull/724). Thanks @sdogruyol :pray: + # 1.7.3 (02-10-2025) - Refactor [#719](https://github.com/kemalcr/kemal/pull/719). Thanks @sdogruyol :pray: From 8205e8e81704174e5f9d444fc42583d56d789e4c Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Fri, 7 Nov 2025 10:28:07 +0300 Subject: [PATCH 42/72] Bump version to 1.8.0 --- shard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shard.yml b/shard.yml index b57b2fb..d5e995b 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: kemal -version: 1.7.3 +version: 1.8.0 authors: - Serdar Dogruyol From 6731ed92718ed068e3f3966e5845b10b1cc5199f Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Wed, 19 Nov 2025 16:36:34 +0300 Subject: [PATCH 43/72] add contributing guide, update README to reference it --- CONTRIBUTING.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 8 +------ 2 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..356782c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing to Kemal + +Thank you for your interest in contributing to Kemal! We love pull requests from everyone. + +## Getting Started + +1. **Fork** the repository on GitHub. +2. **Clone** your fork locally: + ```bash + git clone https://github.com/YOUR_USERNAME/kemal.git + cd kemal + ``` +3. **Install dependencies**: + ```bash + shards install + ``` + +## Running Tests + +Before submitting a pull request, please ensure that all tests pass. + +```bash +crystal spec +``` + +## Code Style + +Kemal follows the standard Crystal code style. Please ensure your code is formatted correctly before committing. + +```bash +crystal tool format +``` + +## Submitting a Pull Request + +1. Create a new branch for your feature or bug fix: + ```bash + git checkout -b my-new-feature + ``` +2. Commit your changes with descriptive commit messages. +3. Push your branch to your fork: + ```bash + git push origin my-new-feature + ``` +4. Open a **Pull Request** on the main Kemal repository. +5. Describe your changes and link to any relevant issues. + +## Reporting Bugs + +If you find a bug, please open an issue on GitHub with: +- A clear title and description. +- Steps to reproduce the issue. +- The version of Kemal and Crystal you are using. + +## Feature Requests + +We welcome new ideas! Please open an issue to discuss your feature request before implementing it. + +Thank you for contributing to Kemal! ๐Ÿš€ diff --git a/README.md b/README.md index 3d8a858..b44afc8 100644 --- a/README.md +++ b/README.md @@ -78,13 +78,7 @@ crystal run src/your_app.cr ## Contributing -We love contributions! If you'd like to contribute: - -1. Fork it (https://github.com/kemalcr/kemal/fork) -2. Create your feature branch (git checkout -b my-new-feature) -3. Commit your changes (git commit -am 'Add some feature') -4. Push to the branch (git push origin my-new-feature) -5. Create a new Pull Request +We love contributions! Please read our [Contributing Guide](CONTRIBUTING.md) to get started. ## Acknowledgments From 8b6f28295488d873508d3b3cfef7721e8b3fb981 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Thu, 20 Nov 2025 17:33:52 +0300 Subject: [PATCH 44/72] optimize JSON parameter parsing by directly using the request body IO. --- src/kemal/param_parser.cr | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/kemal/param_parser.cr b/src/kemal/param_parser.cr index 9bb5f6d..da8d024 100644 --- a/src/kemal/param_parser.cr +++ b/src/kemal/param_parser.cr @@ -105,8 +105,7 @@ module Kemal private def parse_json return unless @request.body && @request.headers["Content-Type"]?.try(&.starts_with?(APPLICATION_JSON)) - body = @request.body.not_nil!.gets_to_end - case json = JSON.parse(body).raw + case json = JSON.parse(@request.body.not_nil!).raw when Hash json.each do |key, value| @json[key] = value.raw From 1c5cc8c0f96287ed4ee4cb0071a0384e124b9422 Mon Sep 17 00:00:00 2001 From: Sijawusz Pur Rahnama Date: Sun, 11 Jan 2026 11:18:57 +0100 Subject: [PATCH 45/72] Ameba v1.7 (#729) --- .ameba.yml | 24 ----------------------- .github/workflows/ameba.yml | 19 ++++++++++++++++++ .github/workflows/ci.yml | 21 -------------------- shard.yml | 5 +++++ spec/asset/hello_with_content_for.ecr | 2 +- spec/asset/layout_with_yield.ecr | 2 +- spec/asset/layout_with_yield_and_vars.ecr | 2 +- spec/config_spec.cr | 4 ++-- spec/helpers_spec.cr | 19 +++++------------- spec/param_parser_spec.cr | 2 +- spec/route_handler_spec.cr | 8 ++++---- spec/static_file_handler_spec.cr | 18 ++++++++--------- spec/view_spec.cr | 6 +++--- src/kemal.cr | 6 +++--- src/kemal/config.cr | 8 ++++---- src/kemal/dsl.cr | 20 +++++++++---------- src/kemal/handler.cr | 16 +++++++-------- src/kemal/helpers/exception_page.cr | 2 +- src/kemal/helpers/helpers.cr | 2 +- src/kemal/helpers/macros.cr | 22 ++++++++++----------- src/kemal/helpers/templates.cr | 2 +- src/kemal/param_parser.cr | 19 +++++++++--------- src/kemal/websocket.cr | 2 +- src/kemal/websocket_handler.cr | 2 +- 24 files changed, 102 insertions(+), 131 deletions(-) delete mode 100644 .ameba.yml create mode 100644 .github/workflows/ameba.yml diff --git a/.ameba.yml b/.ameba.yml deleted file mode 100644 index d7b2b9a..0000000 --- a/.ameba.yml +++ /dev/null @@ -1,24 +0,0 @@ -# This configuration file was generated by `ameba --gen-config` -# on 2023-01-30 12:35:15 UTC using Ameba version 1.4.0. -# The point is for the user to remove these configuration records -# one by one as the reported problems are removed from the code base. - -# Problems found: 2 -# Run `ameba --only Lint/UselessAssign` for details -Lint/UselessAssign: - Description: Disallows useless variable assignments - Excluded: - - spec/view_spec.cr - Enabled: true - Severity: Warning - -# Problems found: 6 -# Run `ameba --only Lint/NotNil` for details -Lint/NotNil: - Description: Identifies usage of `not_nil!` calls - Excluded: - - src/kemal/param_parser.cr - - src/kemal/static_file_handler.cr - - src/kemal/config.cr - Enabled: true - Severity: Warning diff --git a/.github/workflows/ameba.yml b/.github/workflows/ameba.yml new file mode 100644 index 0000000..e321d6a --- /dev/null +++ b/.github/workflows/ameba.yml @@ -0,0 +1,19 @@ +name: Ameba + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Download source + uses: actions/checkout@v6 + + - name: Run Ameba Linter + uses: crystal-ameba/github-action@master diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bb6264..8e65750 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,24 +30,3 @@ jobs: - name: Run specs run: | crystal spec - ameba: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest] - crystal: [latest] - runs-on: ${{ matrix.os }} - - steps: - - name: Install Crystal - uses: crystal-lang/install-crystal@v1 - with: - crystal: ${{ matrix.crystal }} - - - name: Download source - uses: actions/checkout@v4 - - - name: Crystal Ameba Linter - uses: crystal-ameba/github-action@v0.12.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/shard.yml b/shard.yml index d5e995b..c291017 100644 --- a/shard.yml +++ b/shard.yml @@ -12,6 +12,11 @@ dependencies: github: crystal-loot/exception_page version: ~> 0.5.0 +development_dependencies: + ameba: + github: crystal-ameba/ameba + branch: master + crystal: ">= 0.36.0" license: MIT diff --git a/spec/asset/hello_with_content_for.ecr b/spec/asset/hello_with_content_for.ecr index b5460f9..8b4dc69 100644 --- a/spec/asset/hello_with_content_for.ecr +++ b/spec/asset/hello_with_content_for.ecr @@ -2,4 +2,4 @@ Hello <%= name %> <% content_for "meta" do %> Kemal Spec -<% end %> \ No newline at end of file +<% end %> diff --git a/spec/asset/layout_with_yield.ecr b/spec/asset/layout_with_yield.ecr index 3710c4a..a025b2a 100644 --- a/spec/asset/layout_with_yield.ecr +++ b/spec/asset/layout_with_yield.ecr @@ -5,4 +5,4 @@ <%= content %> - \ No newline at end of file + diff --git a/spec/asset/layout_with_yield_and_vars.ecr b/spec/asset/layout_with_yield_and_vars.ecr index d2a8a35..1595d81 100644 --- a/spec/asset/layout_with_yield_and_vars.ecr +++ b/spec/asset/layout_with_yield_and_vars.ecr @@ -7,4 +7,4 @@ <%= var1 %> <%= var2 %> - \ No newline at end of file + diff --git a/spec/config_spec.cr b/spec/config_spec.cr index a7fc35c..7592bf3 100644 --- a/spec/config_spec.cr +++ b/spec/config_spec.cr @@ -35,9 +35,9 @@ describe "Config" do it "toggles the shutdown message" do config = Kemal.config config.shutdown_message = false - config.shutdown_message.should eq false + config.shutdown_message.should be_false config.shutdown_message = true - config.shutdown_message.should eq true + config.shutdown_message.should be_true end it "adds custom options" do diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr index f5de4e9..aac62fb 100644 --- a/spec/helpers_spec.cr +++ b/spec/helpers_spec.cr @@ -20,21 +20,12 @@ describe "Macros" do describe "#logging" do it "sets logging status" do logging false - Kemal.config.logging.should eq false + Kemal.config.logging.should be_false end end describe "#halt" do it "can break block with halt macro" do - get "/non-breaking" do - "hello" - "world" - end - request = HTTP::Request.new("GET", "/non-breaking") - client_response = call_request_on_app(request) - client_response.status_code.should eq(200) - client_response.body.should eq("world") - get "/breaking" do |env| halt env, 404, "hello" "world" @@ -262,16 +253,16 @@ describe "Macros" do describe "#serve_static" do it "should disable static file hosting" do serve_static false - Kemal.config.serve_static.should eq false + Kemal.config.serve_static.should be_false end it "should enable gzip and dir_listing" do serve_static({"gzip" => true, "dir_listing" => true}) conf = Kemal.config.serve_static - conf.is_a?(Hash).should eq true + conf.is_a?(Hash).should be_true if conf.is_a?(Hash) - conf["gzip"].should eq true - conf["dir_listing"].should eq true + conf["gzip"].should be_true + conf["dir_listing"].should be_true end end end diff --git a/spec/param_parser_spec.cr b/spec/param_parser_spec.cr index d63a229..ff1e15b 100644 --- a/spec/param_parser_spec.cr +++ b/spec/param_parser_spec.cr @@ -174,7 +174,7 @@ describe "ParamParser" do body_params.to_s.should eq("") json_params = Kemal::ParamParser.new(request).json - json_params.should eq({} of String => Nil | String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any)) + json_params.should eq({} of String => String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any)?) end end diff --git a/spec/route_handler_spec.cr b/spec/route_handler_spec.cr index 5019b8c..33a32ab 100644 --- a/spec/route_handler_spec.cr +++ b/spec/route_handler_spec.cr @@ -130,7 +130,7 @@ describe "Kemal::RouteHandler" do client_response = call_request_on_app(request) client_response.status_code.should eq(302) client_response.body.should eq("") - client_response.headers.has_key?("Location").should eq(true) + client_response.headers.has_key?("Location").should be_true end it "redirects with body" do @@ -141,7 +141,7 @@ describe "Kemal::RouteHandler" do client_response = call_request_on_app(request) client_response.status_code.should eq(302) client_response.body.should eq("Redirecting to /login") - client_response.headers.has_key?("Location").should eq(true) + client_response.headers.has_key?("Location").should be_true end it "redirects and closes response in before filter" do @@ -159,7 +159,7 @@ describe "Kemal::RouteHandler" do client_response = call_request_on_app(request) client_response.status_code.should eq(302) client_response.body.should eq("") - client_response.headers.has_key?("Location").should eq(true) + client_response.headers.has_key?("Location").should be_true end it "redirects in before filter without closing response" do @@ -177,7 +177,7 @@ describe "Kemal::RouteHandler" do client_response = call_request_on_app(request) client_response.status_code.should eq(302) client_response.body.should eq("home page") - client_response.headers.has_key?("Location").should eq(true) + client_response.headers.has_key?("Location").should be_true end context "LRU cache" do diff --git a/spec/static_file_handler_spec.cr b/spec/static_file_handler_spec.cr index 4fdffb8..13e8832 100644 --- a/spec/static_file_handler_spec.cr +++ b/spec/static_file_handler_spec.cr @@ -98,12 +98,12 @@ describe Kemal::StaticFileHandler do end it "should handle only GET and HEAD method" do - %w(GET HEAD).each do |method| + %w[GET HEAD].each do |method| response = handle HTTP::Request.new(method, "/dir/test.txt") response.status_code.should eq(200) end - %w(POST PUT DELETE).each do |method| + %w[POST PUT DELETE].each do |method| response = handle HTTP::Request.new(method, "/dir/test.txt") response.status_code.should eq(404) response = handle HTTP::Request.new(method, "/dir/test.txt"), false @@ -113,18 +113,18 @@ describe Kemal::StaticFileHandler do end it "should send part of files when requested (RFC7233)" do - %w(POST PUT DELETE HEAD).each do |method| + %w[POST PUT DELETE HEAD].each do |method| headers = HTTP::Headers{"Range" => "bytes=0-4"} response = handle HTTP::Request.new(method, "/dir/test.txt", headers) response.status_code.should_not eq(206) - response.headers.has_key?("Content-Range").should eq(false) + response.headers.has_key?("Content-Range").should be_false end - %w(GET).each do |method| + %w[GET].each do |method| headers = HTTP::Headers{"Range" => "bytes=0-4"} response = handle HTTP::Request.new(method, "/dir/test.txt", headers) response.status_code.should eq(206) - response.headers.has_key?("Content-Range").should eq true + response.headers.has_key?("Content-Range").should be_true match = response.headers["Content-Range"].match(/bytes (\d+)-(\d+)\/(\d+)/) match.should_not be_nil if match @@ -133,14 +133,14 @@ describe Kemal::StaticFileHandler do range_size = match[3].to_i { 0 } range_size.should eq file_size - (end_range < file_size).should eq true - (start_range < end_range).should eq true + (end_range < file_size).should be_true + (start_range < end_range).should be_true end end end it "should handle setting custom headers" do - headers = Proc(HTTP::Server::Context, String, File::Info, Void).new do |env, path, stat| + headers = Proc(HTTP::Server::Context, String, File::Info, Nil).new do |env, path, stat| if path =~ /\.html$/ env.response.headers.add("Access-Control-Allow-Origin", "*") end diff --git a/spec/view_spec.cr b/spec/view_spec.cr index 79b8768..d355b73 100644 --- a/spec/view_spec.cr +++ b/spec/view_spec.cr @@ -1,7 +1,7 @@ require "./spec_helper" macro render_with_base_and_layout(filename) - render "#{__DIR__}/asset/#{{{filename}}}", "#{__DIR__}/asset/layout.ecr" + render "#{__DIR__}/asset/#{{{ filename }}}", "#{__DIR__}/asset/layout.ecr" end describe "Views" do @@ -38,8 +38,8 @@ describe "Views" do it "renders layout with variables" do get "/view/:name" do |env| name = env.params.url["name"] - var1 = "serdar" - var2 = "kemal" + var1 = "serdar" # ameba:disable Lint/UselessAssign + var2 = "kemal" # ameba:disable Lint/UselessAssign render "#{__DIR__}/asset/hello_with_content_for.ecr", "#{__DIR__}/asset/layout_with_yield_and_vars.ecr" end request = HTTP::Request.new("GET", "/view/world") diff --git a/src/kemal.cr b/src/kemal.cr index 04275f6..9a6d912 100644 --- a/src/kemal.cr +++ b/src/kemal.cr @@ -11,17 +11,17 @@ module Kemal # Overload of `self.run` with the default startup logging. def self.run(port : Int32?, args = ARGV, trap_signal : Bool = true) - self.run(port, args, trap_signal) { } + run(port, args, trap_signal) { } end # Overload of `self.run` without port. def self.run(args = ARGV, trap_signal : Bool = true) - self.run(nil, args: args, trap_signal: trap_signal) + run(nil, args: args, trap_signal: trap_signal) end # Overload of `self.run` to allow just a block. def self.run(args = ARGV, &block) - self.run(nil, args: args, trap_signal: true, &block) + run(nil, args: args, trap_signal: true, &block) end # The command to run a `Kemal` application. diff --git a/src/kemal/config.cr b/src/kemal/config.cr index 3bf3b6c..dd2e2f9 100644 --- a/src/kemal/config.cr +++ b/src/kemal/config.cr @@ -10,7 +10,7 @@ module Kemal class Config INSTANCE = Config.new HANDLERS = [] of HTTP::Handler - CUSTOM_HANDLERS = [] of Tuple(Nil | Int32, HTTP::Handler) + CUSTOM_HANDLERS = [] of Tuple(Int32?, HTTP::Handler) FILTER_HANDLERS = [] of HTTP::Handler ERROR_HANDLERS = {} of Int32 => HTTP::Server::Context, Exception -> String EXCEPTION_HANDLERS = {} of Exception.class => HTTP::Server::Context, Exception -> String @@ -24,7 +24,7 @@ module Kemal property app_name, host_binding, ssl, port, env, public_folder, logging, running property always_rescue, server : HTTP::Server?, extra_options, shutdown_message property serve_static : (Bool | Hash(String, Bool)) - property static_headers : (HTTP::Server::Context, String, File::Info -> Void)? + property static_headers : (HTTP::Server::Context, String, File::Info ->)? property? powered_by_header : Bool = true property max_route_cache_size : Int32 @@ -159,8 +159,8 @@ module Kemal private def setup_error_handler if @always_rescue - @error_handler ||= Kemal::ExceptionHandler.new - HANDLERS.insert(@handler_position, @error_handler.not_nil!) + handler = @error_handler ||= Kemal::ExceptionHandler.new + HANDLERS.insert(@handler_position, handler) @handler_position += 1 end end diff --git a/src/kemal/dsl.cr b/src/kemal/dsl.cr index 202dca8..166397c 100644 --- a/src/kemal/dsl.cr +++ b/src/kemal/dsl.cr @@ -6,17 +6,17 @@ # - WebSocket(ws) # - before_* # - error -HTTP_METHODS = %w(get post put patch delete options) -FILTER_METHODS = %w(get post put patch delete options all) +HTTP_METHODS = %w[get post put patch delete options] +FILTER_METHODS = %w[get post put patch delete options all] {% for method in HTTP_METHODS %} - def {{method.id}}(path : String, &block : HTTP::Server::Context -> _) - raise Kemal::Exceptions::InvalidPathStartException.new({{method}}, path) unless Kemal::Utils.path_starts_with_slash?(path) - Kemal::RouteHandler::INSTANCE.add_route({{method}}.upcase, path, &block) + def {{ method.id }}(path : String, &block : HTTP::Server::Context -> _) + raise Kemal::Exceptions::InvalidPathStartException.new({{ method }}, path) unless Kemal::Utils.path_starts_with_slash?(path) + Kemal::RouteHandler::INSTANCE.add_route({{ method }}.upcase, path, &block) end {% end %} -def ws(path : String, &block : HTTP::WebSocket, HTTP::Server::Context -> Void) +def ws(path : String, &block : HTTP::WebSocket, HTTP::Server::Context ->) raise Kemal::Exceptions::InvalidPathStartException.new("ws", path) unless Kemal::Utils.path_starts_with_slash?(path) Kemal::WebSocketHandler::INSTANCE.add_route path, &block end @@ -36,13 +36,13 @@ end # - after_all, after_get, after_post, after_put, after_patch, after_delete, after_options {% for type in ["before", "after"] %} {% for method in FILTER_METHODS %} - def {{type.id}}_{{method.id}}(path : String = "*", &block : HTTP::Server::Context -> _) - Kemal::FilterHandler::INSTANCE.{{type.id}}({{method}}.upcase, path, &block) + def {{ type.id }}_{{ method.id }}(path : String = "*", &block : HTTP::Server::Context -> _) + Kemal::FilterHandler::INSTANCE.{{ type.id }}({{ method }}.upcase, path, &block) end - def {{type.id}}_{{method.id}}(paths : Array(String), &block : HTTP::Server::Context -> _) + def {{ type.id }}_{{ method.id }}(paths : Array(String), &block : HTTP::Server::Context -> _) paths.each do |path| - Kemal::FilterHandler::INSTANCE.{{type.id}}({{method}}.upcase, path, &block) + Kemal::FilterHandler::INSTANCE.{{ type.id }}({{ method }}.upcase, path, &block) end end {% end %} diff --git a/src/kemal/handler.cr b/src/kemal/handler.cr index c900980..287edb4 100644 --- a/src/kemal/handler.cr +++ b/src/kemal/handler.cr @@ -17,18 +17,18 @@ module Kemal end macro only(paths, method = "GET") - class_name = {{@type.name}} - class_name_method = "#{class_name}/#{{{method}}}" - ({{paths}}).each do |path| - @@only_routes_tree.add class_name_method + path, '/' + {{method}} + path + class_name = {{ @type.name }} + class_name_method = "#{class_name}/#{{{ method }}}" + ({{ paths }}).each do |path| + @@only_routes_tree.add class_name_method + path, '/' + {{ method }} + path end end macro exclude(paths, method = "GET") - class_name = {{@type.name}} - class_name_method = "#{class_name}/#{{{method}}}" - ({{paths}}).each do |path| - @@exclude_routes_tree.add class_name_method + path, '/' + {{method}} + path + class_name = {{ @type.name }} + class_name_method = "#{class_name}/#{{{ method }}}" + ({{ paths }}).each do |path| + @@exclude_routes_tree.add class_name_method + path, '/' + {{ method }} + path end end diff --git a/src/kemal/helpers/exception_page.cr b/src/kemal/helpers/exception_page.cr index 4ec180c..2168c37 100644 --- a/src/kemal/helpers/exception_page.cr +++ b/src/kemal/helpers/exception_page.cr @@ -32,7 +32,7 @@ module Kemal

Something wrong with the server :(

- HTML + HTML end end end diff --git a/src/kemal/helpers/helpers.cr b/src/kemal/helpers/helpers.cr index c76093b..803e4bc 100644 --- a/src/kemal/helpers/helpers.cr +++ b/src/kemal/helpers/helpers.cr @@ -301,6 +301,6 @@ end # env.response.headers.add("Content-Size", filestat.size.to_s) # end # ``` -def static_headers(&headers : HTTP::Server::Context, String, File::Info -> Void) +def static_headers(&headers : HTTP::Server::Context, String, File::Info ->) Kemal.config.static_headers = headers end diff --git a/src/kemal/helpers/macros.cr b/src/kemal/helpers/macros.cr index 71ba2be..9a47f53 100644 --- a/src/kemal/helpers/macros.cr +++ b/src/kemal/helpers/macros.cr @@ -34,15 +34,15 @@ CONTENT_FOR_BLOCKS = Hash(String, Tuple(String, Proc(Nil))).new # layout, inside the tag, and each view can call `content_for` # setting the appropriate set of tags that should be added to the layout. macro content_for(key, file = __FILE__) - CONTENT_FOR_BLOCKS[{{key}}] = Tuple.new {{file}}, ->() { {{ yield }} } + CONTENT_FOR_BLOCKS[{{ key }}] = Tuple.new {{ file }}, ->() { {{ yield }} } nil end # Yields content for the given key if a `content_for` block exists for that key. macro yield_content(key) - if CONTENT_FOR_BLOCKS.has_key?({{key}}) - __caller_filename__ = CONTENT_FOR_BLOCKS[{{key}}][0] - %proc = CONTENT_FOR_BLOCKS[{{key}}][1] + if CONTENT_FOR_BLOCKS.has_key?({{ key }}) + __caller_filename__ = CONTENT_FOR_BLOCKS[{{ key }}][0] + %proc = CONTENT_FOR_BLOCKS[{{ key }}][1] if __content_filename__ == __caller_filename__ %old_content_io, content_io = content_io, IO::Memory.new @@ -60,18 +60,18 @@ end # render "src/views/index.ecr", "src/views/layout.ecr" # ``` macro render(filename, layout) - __content_filename__ = {{filename}} + __content_filename__ = {{ filename }} content_io = IO::Memory.new - ECR.embed {{filename}}, content_io + ECR.embed {{ filename }}, content_io content = content_io.to_s layout_io = IO::Memory.new - ECR.embed {{layout}}, layout_io + ECR.embed {{ layout }}, layout_io layout_io.to_s end # Render view with the given filename. macro render(filename) - ECR.render({{filename}}) + ECR.render({{ filename }}) end # Halt execution with the current context. @@ -81,9 +81,9 @@ end # halt env, status_code: 403, response: "Forbidden" # ``` macro halt(env, status_code = 200, response = "") - {{env}}.response.status_code = {{status_code}} - {{env}}.response.print {{response}} - {{env}}.response.close + {{ env }}.response.status_code = {{ status_code }} + {{ env }}.response.print {{ response }} + {{ env }}.response.close next end diff --git a/src/kemal/helpers/templates.cr b/src/kemal/helpers/templates.cr index 1769bd5..ad6efc6 100644 --- a/src/kemal/helpers/templates.cr +++ b/src/kemal/helpers/templates.cr @@ -18,7 +18,7 @@ def render_404 - HTML + HTML end def render_500(context, exception, verbosity) diff --git a/src/kemal/param_parser.cr b/src/kemal/param_parser.cr index da8d024..c6e5b1c 100644 --- a/src/kemal/param_parser.cr +++ b/src/kemal/param_parser.cr @@ -6,9 +6,9 @@ module Kemal URL_ENCODED_FORM = "application/x-www-form-urlencoded" APPLICATION_JSON = "application/json" MULTIPART_FORM = "multipart/form-data" - PARTS = %w(url query body json files) + PARTS = %w[url query body json files] # :nodoc: - alias AllParamTypes = Nil | String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any) + alias AllParamTypes = String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any)? getter files, all_files def initialize(@request : HTTP::Request, @url : Hash(String, String) = {} of String => String) @@ -40,14 +40,14 @@ module Kemal end {% for method in PARTS %} - def {{method.id}} + def {{ method.id }} # check memoization - return @{{method.id}} if @{{method.id}}_parsed + return @{{ method.id }} if @{{ method.id }}_parsed - parse_{{method.id}} + parse_{{ method.id }} # memoize - @{{method.id}}_parsed = true - @{{method.id}} + @{{ method.id }}_parsed = true + @{{ method.id }} end {% end %} @@ -103,9 +103,10 @@ module Kemal # - If request body is a JSON `Hash` then all the params are parsed and added into `params`. # - If request body is a JSON `Array` it's added into `params` as `_json` and can be accessed like `params["_json"]`. private def parse_json - return unless @request.body && @request.headers["Content-Type"]?.try(&.starts_with?(APPLICATION_JSON)) + return unless body = @request.body + return unless @request.headers["Content-Type"]?.try(&.starts_with?(APPLICATION_JSON)) - case json = JSON.parse(@request.body.not_nil!).raw + case json = JSON.parse(body).raw when Hash json.each do |key, value| @json[key] = value.raw diff --git a/src/kemal/websocket.cr b/src/kemal/websocket.cr index 2b65f8e..937b8d2 100644 --- a/src/kemal/websocket.cr +++ b/src/kemal/websocket.cr @@ -4,7 +4,7 @@ module Kemal class WebSocket < HTTP::WebSocketHandler getter proc - def initialize(@path : String, &@proc : HTTP::WebSocket, HTTP::Server::Context -> Void) + def initialize(@path : String, &@proc : HTTP::WebSocket, HTTP::Server::Context ->) end def call(context : HTTP::Server::Context) diff --git a/src/kemal/websocket_handler.cr b/src/kemal/websocket_handler.cr index b1432a0..884e4b5 100644 --- a/src/kemal/websocket_handler.cr +++ b/src/kemal/websocket_handler.cr @@ -18,7 +18,7 @@ module Kemal @routes.find "/ws" + path end - def add_route(path : String, &handler : HTTP::WebSocket, HTTP::Server::Context -> Void) + def add_route(path : String, &handler : HTTP::WebSocket, HTTP::Server::Context ->) add_to_radix_tree path, WebSocket.new(path, &handler) end From 3c858bf24e156f1f957535e8cf484ff372b36003 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:48:41 +0300 Subject: [PATCH 46/72] [Security] Limit maximum request body size to avoid DoS attacks (#730) --- spec/param_parser_spec.cr | 60 +++++++++++++++++++++++++++++++++ src/kemal/config.cr | 3 ++ src/kemal/exception_handler.cr | 2 ++ src/kemal/helpers/exceptions.cr | 6 ++++ src/kemal/param_parser.cr | 33 ++++++++++++++++-- 5 files changed, 102 insertions(+), 2 deletions(-) diff --git a/spec/param_parser_spec.cr b/spec/param_parser_spec.cr index ff1e15b..2c5a99f 100644 --- a/spec/param_parser_spec.cr +++ b/spec/param_parser_spec.cr @@ -201,4 +201,64 @@ describe "ParamParser" do body_params.to_s.should eq("") end end + + context "Payload too large" do + it "raises PayloadTooLarge when body exceeds limit" do + Kemal.config.max_request_body_size = 10 + request = HTTP::Request.new( + "POST", + "/", + body: "12345678901", + headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"}, + ) + + expect_raises(Kemal::Exceptions::PayloadTooLarge) do + Kemal::ParamParser.new(request).body + end + end + + it "raises PayloadTooLarge when Content-Length exceeds limit" do + Kemal.config.max_request_body_size = 10 + request = HTTP::Request.new( + "POST", + "/", + body: "1", + headers: HTTP::Headers{ + "Content-Type" => "application/x-www-form-urlencoded", + }, + ) + request.headers["Content-Length"] = "11" + + expect_raises(Kemal::Exceptions::PayloadTooLarge) do + Kemal::ParamParser.new(request).body + end + end + + it "parses body when size is within limit" do + Kemal.config.max_request_body_size = 20 + request = HTTP::Request.new( + "POST", + "/", + body: "name=serdar", + headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"}, + ) + + body_params = Kemal::ParamParser.new(request).body + body_params["name"].should eq("serdar") + end + + it "raises PayloadTooLarge for JSON body exceeding limit" do + Kemal.config.max_request_body_size = 10 + request = HTTP::Request.new( + "POST", + "/", + body: "{\"foo\":\"bar\"}", + headers: HTTP::Headers{"Content-Type" => "application/json"}, + ) + + expect_raises(Kemal::Exceptions::PayloadTooLarge) do + Kemal::ParamParser.new(request).json + end + end + end end diff --git a/src/kemal/config.cr b/src/kemal/config.cr index dd2e2f9..b040848 100644 --- a/src/kemal/config.cr +++ b/src/kemal/config.cr @@ -27,6 +27,7 @@ module Kemal property static_headers : (HTTP::Server::Context, String, File::Info ->)? property? powered_by_header : Bool = true property max_route_cache_size : Int32 + property max_request_body_size : Int32 def initialize @app_name = "Kemal" @@ -45,6 +46,7 @@ module Kemal @shutdown_message = true @handler_position = 0 @max_route_cache_size = 1024 + @max_request_body_size = 8 * 1024 * 1024 # 8MB end @[Deprecated("Use standard library Log")] @@ -72,6 +74,7 @@ module Kemal @handler_position = 0 @default_handlers_setup = false @max_route_cache_size = 1024 + @max_request_body_size = 8 * 1024 * 1024 HANDLERS.clear CUSTOM_HANDLERS.clear FILTER_HANDLERS.clear diff --git a/src/kemal/exception_handler.cr b/src/kemal/exception_handler.cr index b6056ed..ded1c58 100644 --- a/src/kemal/exception_handler.cr +++ b/src/kemal/exception_handler.cr @@ -10,6 +10,8 @@ module Kemal call_exception_with_status_code(context, ex, 404) rescue ex : Kemal::Exceptions::CustomException call_exception_with_status_code(context, ex, context.response.status_code) + rescue ex : Kemal::Exceptions::PayloadTooLarge + call_exception_with_status_code(context, ex, 413) rescue ex : Exception # Matches an error handler for the given exception # diff --git a/src/kemal/helpers/exceptions.cr b/src/kemal/helpers/exceptions.cr index cda5e59..dca2afc 100644 --- a/src/kemal/helpers/exceptions.cr +++ b/src/kemal/helpers/exceptions.cr @@ -18,4 +18,10 @@ module Kemal::Exceptions super message end end + + class PayloadTooLarge < Exception + def initialize + super "Payload Too Large" + end + end end diff --git a/src/kemal/param_parser.cr b/src/kemal/param_parser.cr index c6e5b1c..c084ffe 100644 --- a/src/kemal/param_parser.cr +++ b/src/kemal/param_parser.cr @@ -56,6 +56,8 @@ module Kemal return unless content_type + validate_content_length! + if content_type.try(&.starts_with?(URL_ENCODED_FORM)) @body = parse_part(@request.body) return @@ -77,6 +79,8 @@ module Kemal private def parse_files return if @files_parsed + validate_content_length! + HTTP::FormData.parse(@request) do |upload| next unless upload @@ -106,7 +110,12 @@ module Kemal return unless body = @request.body return unless @request.headers["Content-Type"]?.try(&.starts_with?(APPLICATION_JSON)) - case json = JSON.parse(body).raw + validate_content_length! + + body_str = read_body_with_limit(body) + return if body_str.empty? + + case json = JSON.parse(body_str).raw when Hash json.each do |key, value| @json[key] = value.raw @@ -119,11 +128,31 @@ module Kemal end private def parse_part(part : IO?) - HTTP::Params.parse(part ? part.gets_to_end : "") + return HTTP::Params.new({} of String => Array(String)) unless part + body_str = read_body_with_limit(part) + HTTP::Params.parse(body_str) end private def parse_part(part : String?) HTTP::Params.parse part.to_s end + + private def validate_content_length! + return unless length_str = @request.headers["Content-Length"]? + return unless length = length_str.to_i? + return if length <= Kemal.config.max_request_body_size + + raise Exceptions::PayloadTooLarge.new + end + + private def read_body_with_limit(io : IO) : String + limit = Kemal.config.max_request_body_size + String.build do |str| + bytes_read = IO.copy(io, str, limit + 1) + if bytes_read > limit + raise Exceptions::PayloadTooLarge.new + end + end + end end end From 53cb26fad4be94809daaf1a0a13f1d2cba909379 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:45:50 +0300 Subject: [PATCH 47/72] Update CHANGELOG --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40248d2..d5ad849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 1.9.0 (28-01-2026) + +- Crystal 1.19.0 support :tada: +- ***(SECURITY)*** Limit maximum request body size to avoid DoS attacks [#730](https://github.com/kemalcr/kemal/pull/730). Thanks @sdogruyol :pray: +- Optimize JSON parameter parsing by directly using the request body IO. Thanks @sdogruyol :pray: + # 1.8.0 (07-11-2025) - Enhance HEAD request handling by caching GET route lookups and optimize path construction using string interpolation for improved performance [#728](https://github.com/kemalcr/kemal/pull/728). Thanks @sdogruyol :pray: @@ -8,7 +14,7 @@ # 1.7.3 (02-10-2025) - Refactor [#719](https://github.com/kemalcr/kemal/pull/719). Thanks @sdogruyol :pray: -- Improv Kemal test suite. Thanks @sdogruyol :pray: +- Improve Kemal test suite. Thanks @sdogruyol :pray: # 1.7.2 (04-08-2025) From dafa4c9b4420c4192c7b6e62000a14357e3cf2ff Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:47:20 +0300 Subject: [PATCH 48/72] Bump version to 1.9.0 --- shard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shard.yml b/shard.yml index c291017..f209020 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: kemal -version: 1.8.0 +version: 1.9.0 authors: - Serdar Dogruyol From 951be7524e9577a92c8a9eec048a2fc0d886a91d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:00:28 +0300 Subject: [PATCH 49/72] Add response helpers for JSON, HTML, text, XML, and status (#733) --- spec/response_helpers_spec.cr | 222 ++++++++++++++++++++++++++++++++++ src/kemal/ext/context.cr | 70 +++++++++++ 2 files changed, 292 insertions(+) create mode 100644 spec/response_helpers_spec.cr diff --git a/spec/response_helpers_spec.cr b/spec/response_helpers_spec.cr new file mode 100644 index 0000000..5459e2c --- /dev/null +++ b/spec/response_helpers_spec.cr @@ -0,0 +1,222 @@ +require "./spec_helper" + +describe "Response Helpers" do + describe "#json" do + it "sets content-type to application/json" do + get "/json-test" do |env| + env.json({message: "hello"}) + end + + request = HTTP::Request.new("GET", "/json-test") + client_response = call_request_on_app(request) + client_response.headers["Content-Type"].should eq("application/json") + end + + it "serializes hash to JSON" do + get "/json-hash" do |env| + env.json({name: "alice", age: 30}) + end + + request = HTTP::Request.new("GET", "/json-hash") + client_response = call_request_on_app(request) + client_response.body.should eq(%({"name":"alice","age":30})) + end + + it "serializes array to JSON" do + get "/json-array" do |env| + env.json([1, 2, 3]) + end + + request = HTTP::Request.new("GET", "/json-array") + client_response = call_request_on_app(request) + client_response.body.should eq("[1,2,3]") + end + end + + describe "#html" do + it "sets content-type to text/html" do + get "/html-test" do |env| + env.html("

Hello

") + end + + request = HTTP::Request.new("GET", "/html-test") + client_response = call_request_on_app(request) + client_response.headers["Content-Type"].should eq("text/html; charset=utf-8") + end + + it "returns HTML content" do + get "/html-content" do |env| + env.html("
Content
") + end + + request = HTTP::Request.new("GET", "/html-content") + client_response = call_request_on_app(request) + client_response.body.should eq("
Content
") + end + end + + describe "#text" do + it "sets content-type to text/plain" do + get "/text-test" do |env| + env.text("Hello World") + end + + request = HTTP::Request.new("GET", "/text-test") + client_response = call_request_on_app(request) + client_response.headers["Content-Type"].should eq("text/plain; charset=utf-8") + end + + it "returns plain text content" do + get "/text-content" do |env| + env.text("Plain text here") + end + + request = HTTP::Request.new("GET", "/text-content") + client_response = call_request_on_app(request) + client_response.body.should eq("Plain text here") + end + end + + describe "#xml" do + it "sets content-type to application/xml" do + get "/xml-test" do |env| + env.xml("") + end + + request = HTTP::Request.new("GET", "/xml-test") + client_response = call_request_on_app(request) + client_response.headers["Content-Type"].should eq("application/xml; charset=utf-8") + end + + it "returns XML content" do + get "/xml-content" do |env| + env.xml(%()) + end + + request = HTTP::Request.new("GET", "/xml-content") + client_response = call_request_on_app(request) + client_response.body.should eq(%()) + end + end + + describe "#status" do + it "sets the response status code" do + get "/status-only" do |env| + env.status(204) + "" + end + + request = HTTP::Request.new("GET", "/status-only") + client_response = call_request_on_app(request) + client_response.status_code.should eq(204) + end + + it "is chainable with json" do + get "/status-json" do |env| + env.status(201).json({id: 1, created: true}) + end + + request = HTTP::Request.new("GET", "/status-json") + client_response = call_request_on_app(request) + client_response.status_code.should eq(201) + client_response.headers["Content-Type"].should eq("application/json") + client_response.body.should eq(%({"id":1,"created":true})) + end + + it "is chainable with html" do + get "/status-html" do |env| + env.status(404).html("

Not Found

") + end + + request = HTTP::Request.new("GET", "/status-html") + client_response = call_request_on_app(request) + client_response.status_code.should eq(404) + client_response.headers["Content-Type"].should eq("text/html; charset=utf-8") + end + + it "is chainable with text" do + get "/status-text" do |env| + env.status(500).text("Internal Server Error") + end + + request = HTTP::Request.new("GET", "/status-text") + client_response = call_request_on_app(request) + client_response.status_code.should eq(500) + client_response.headers["Content-Type"].should eq("text/plain; charset=utf-8") + end + + it "is chainable with xml" do + get "/status-xml" do |env| + env.status(400).xml("Bad Request") + end + + request = HTTP::Request.new("GET", "/status-xml") + client_response = call_request_on_app(request) + client_response.status_code.should eq(400) + client_response.headers["Content-Type"].should eq("application/xml; charset=utf-8") + end + end + + describe "real-world scenarios" do + it "handles REST API create endpoint" do + post "/api/users" do |env| + env.status(201).json({id: 42, name: "Alice", created_at: "2024-01-01"}) + end + + request = HTTP::Request.new("POST", "/api/users") + client_response = call_request_on_app(request) + client_response.status_code.should eq(201) + client_response.headers["Content-Type"].should eq("application/json") + end + + it "handles REST API not found" do + get "/api/users/999" do |env| + env.status(404).json({error: "User not found", code: "USER_NOT_FOUND"}) + end + + request = HTTP::Request.new("GET", "/api/users/999") + client_response = call_request_on_app(request) + client_response.status_code.should eq(404) + client_response.body.should contain("User not found") + end + + it "handles validation error" do + post "/api/validate" do |env| + env.status(422).json({ + error: "Validation failed", + fields: { + email: "is invalid", + name: "is required", + }, + }) + end + + request = HTTP::Request.new("POST", "/api/validate") + client_response = call_request_on_app(request) + client_response.status_code.should eq(422) + client_response.body.should contain("Validation failed") + end + + it "handles health check endpoint" do + get "/health" do |env| + env.text("OK") + end + + request = HTTP::Request.new("GET", "/health") + client_response = call_request_on_app(request) + client_response.status_code.should eq(200) + client_response.body.should eq("OK") + end + + it "handles RSS feed" do + get "/feed.xml" do |env| + env.xml(%(Blog)) + end + + request = HTTP::Request.new("GET", "/feed.xml") + client_response = call_request_on_app(request) + client_response.headers["Content-Type"].should eq("application/xml; charset=utf-8") + client_response.body.should contain("Welcome") + # end + # ``` + def html(content : String) : String + @response.content_type = "text/html; charset=utf-8" + content + end + + # Sends a plain text response with the proper content-type header. + # + # ``` + # get "/health" do |env| + # env.text("OK") + # end + # ``` + def text(content : String) : String + @response.content_type = "text/plain; charset=utf-8" + content + end + + # Sends an XML response with the proper content-type header. + # + # ``` + # get "/feed.xml" do |env| + # env.xml("...") + # end + # ``` + def xml(content : String) : String + @response.content_type = "application/xml; charset=utf-8" + content + end end end From 8fba1c0edb8dd0ae85b45f2d9de3b8ead1f9ee51 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:19:22 +0300 Subject: [PATCH 50/72] Enhance README with philosophy section outlining Kemal's design principles --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b44afc8..c9cac38 100644 --- a/README.md +++ b/README.md @@ -68,11 +68,22 @@ crystal run src/your_app.cr - ๐ŸŽฏ **Request/Response Context**: Easy parameter and request handling - ๐Ÿช **Session Management**: Easy session handling with [kemal-session](https://github.com/kemalcr/kemal-session) +## Philosophy + +Kemal aims to be a simple, fast and reliable foundation for building production-grade web applications and APIs in Crystal. + +- **Simple core, powerful building blocks**: The core is intentionally simple and easy to reason about. Most power comes from Crystal itself and from middleware, not from hidden magic. +- **Performance as a baseline, not a feature**: Crystal's native speed means high performance is the default. Kemal keeps abstractions thin so you stay close to the metal when you need to. +- **Minimal assumptions, maximum flexibility**: Kemal does not force a specific ORM, template engine, or project layout. You are free to choose the tools that fit your application and your team. +- **Batteries within reason**: Kemal ships with the essentials (routing, middleware, templates, static files, request/response helpers) while keeping advanced concerns in separate shards you can opt into as your app grows. + +Kemal is designed to feel familiar if you come from popular web frameworks, while embracing Crystal's strengths and keeping your application code straightforward, maintainable, and ready for production. + ## Learning Resources - ๐Ÿ“š [Official Documentation](http://kemalcr.com) - ๐Ÿ’ป [Example Applications](https://github.com/kemalcr/kemal/tree/master/examples) -- ๐Ÿš€ [Getting Started Guide](http://kemalcr.com/guide/) +- ๐Ÿš€ [Kemal Guide](http://kemalcr.com/guide/) - ๐Ÿ’ฌ [Community Chat](https://discord.gg/prSVAZJEpz) From 80f84b12a318636c7644eae1eed360b58b6293f6 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:27:54 +0300 Subject: [PATCH 51/72] Revise README to enhance key features section with clearer descriptions and improved formatting for better readability. --- README.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c9cac38..25408f1 100644 --- a/README.md +++ b/README.md @@ -59,14 +59,15 @@ crystal run src/your_app.cr ## Key Features -- โœ… **Full REST Support**: Handle all HTTP verbs (GET, POST, PUT, DELETE, etc.) -- ๐Ÿ”Œ **WebSocket Support**: Real-time bidirectional communication -- ๐Ÿ“ฆ **Built-in JSON Support**: Native JSON handling -- ๐Ÿ—„๏ธ **Static File Serving**: Serve your static assets easily -- ๐Ÿ“ **Template Support**: Built-in ECR template engine -- ๐Ÿ”’ **Middleware System**: Add functionality with middleware -- ๐ŸŽฏ **Request/Response Context**: Easy parameter and request handling -- ๐Ÿช **Session Management**: Easy session handling with [kemal-session](https://github.com/kemalcr/kemal-session) +- ๐Ÿš€ **High-performance by default**: Built on Crystal with a thin abstraction layer so you can serve a large number of requests with low latency and low memory footprint. +- ๐ŸŒ **Full REST & HTTP support**: Handle all HTTP verbs (GET, POST, PUT, PATCH, DELETE, etc.) with a straightforward routing DSL. +- ๐Ÿ”Œ **WebSocket & real-time**: First-class WebSocket support for building chats, dashboards and other real-time experiences. +- ๐Ÿ“ฆ **JSON-first APIs**: Native JSON handling makes building JSON APIs and microservices feel natural. +- ๐Ÿ—„๏ธ **Static assets made easy**: Serve static files (assets, uploads, SPA bundles) efficiently from the same application. +- ๐Ÿ“ **Template engine included**: Built-in ECR template engine for serverโ€‘rendered HTML when you need it. +- ๐Ÿ”’ **Composable middleware**: Flexible middleware system to add logging, auth, rate limiting, metrics and more. +- ๐ŸŽฏ **Ergonomic request/response API**: Simple access to params, headers, cookies and bodies via a clear context object. +- ๐Ÿช **Session management**: Easy session handling with [kemal-session](https://github.com/kemalcr/kemal-session), suitable for production apps. ## Philosophy From 669c867482bb9a70e47d4d0683f32e2c101c5c76 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:08:17 +0300 Subject: [PATCH 52/72] Update README to improve setup instructions by adding steps for creating a new Crystal application and clarifying the order of commands. --- README.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 25408f1..59bc4f1 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,14 @@ Kemal is the Fast, Effective, Simple Web Framework for Crystal. It's perfect for 1. First, make sure you have [Crystal installed](https://crystal-lang.org/install/). -2. Add Kemal to your project's `shard.yml`: +2. Create a new Crystal application and step into it: + +```bash +crystal init app my-kemal-app +cd my-kemal-app +``` + +3. Add Kemal to your app's `shard.yml`: ```yaml dependencies: @@ -25,7 +32,7 @@ dependencies: github: kemalcr/kemal ``` -3. Create your first Kemal app: +4. Replace the contents of `src/my_kemal_app.cr` with your first Kemal app: ```crystal require "kemal" @@ -49,13 +56,14 @@ end Kemal.run ``` -4. Run your application: +5. Install dependencies and run your application: ```bash -crystal run src/your_app.cr +shards install +crystal run src/my_kemal_app.cr ``` -5. Visit [http://localhost:3000](http://localhost:3000) - That's it! ๐ŸŽ‰ +6. Visit [http://localhost:3000](http://localhost:3000) - That's it! ๐ŸŽ‰ ## Key Features From 3b45f93daeda2710f27c61698a144c245d5c07fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:30:16 +0300 Subject: [PATCH 53/72] Add `use` keyword for middleware registration with path-specific support (#734) --- spec/handler_spec.cr | 14 +- spec/helpers_spec.cr | 4 +- spec/path_handler_spec.cr | 265 +++++++++++++++++++++++++++++++++++ src/kemal/dsl.cr | 46 ++++++ src/kemal/helpers/helpers.cr | 4 +- src/kemal/path_handler.cr | 46 ++++++ 6 files changed, 369 insertions(+), 10 deletions(-) create mode 100644 spec/path_handler_spec.cr create mode 100644 src/kemal/path_handler.cr diff --git a/spec/handler_spec.cr b/spec/handler_spec.cr index 9b1019f..4bd1d04 100644 --- a/spec/handler_spec.cr +++ b/spec/handler_spec.cr @@ -77,7 +77,7 @@ describe "Handler" do filter_middleware._add_route_filter("GET", "/", :before) do |env| env.response << " so" end - add_handler CustomTestHandler.new + use CustomTestHandler.new get "/" do " Great" @@ -92,7 +92,7 @@ describe "Handler" do get "/only" do "Get" end - add_handler OnlyHandler.new + use OnlyHandler.new request = HTTP::Request.new("GET", "/only") client_response = call_request_on_app(request) client_response.body.should eq "OnlyGet" @@ -105,7 +105,7 @@ describe "Handler" do get "/exclude" do "Exclude" end - add_handler ExcludeHandler.new + use ExcludeHandler.new request = HTTP::Request.new("GET", "/") client_response = call_request_on_app(request) client_response.body.should eq "ExcludeGet" @@ -118,7 +118,7 @@ describe "Handler" do get "/only" do "Get" end - add_handler PostOnlyHandler.new + use PostOnlyHandler.new request = HTTP::Request.new("POST", "/only") client_response = call_request_on_app(request) client_response.body.should eq "OnlyPost" @@ -131,8 +131,8 @@ describe "Handler" do post "/only" do "Post" end - add_handler PostOnlyHandler.new - add_handler PostExcludeHandler.new + use PostOnlyHandler.new + use PostExcludeHandler.new request = HTTP::Request.new("POST", "/only") client_response = call_request_on_app(request) client_response.body.should eq "OnlyExcludePost" @@ -140,7 +140,7 @@ describe "Handler" do it "adds a handler at given position" do post_handler = PostOnlyHandler.new - add_handler post_handler, 1 + use post_handler, position: 1 Kemal.config.setup Kemal.config.handlers[1].should eq post_handler end diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr index aac62fb..f8014b3 100644 --- a/spec/helpers_spec.cr +++ b/spec/helpers_spec.cr @@ -9,9 +9,9 @@ describe "Macros" do end end - describe "#add_handler" do + describe "#use" do it "adds a custom handler" do - add_handler CustomTestHandler.new + use CustomTestHandler.new Kemal.config.setup Kemal.config.handlers.size.should eq 7 end diff --git a/spec/path_handler_spec.cr b/spec/path_handler_spec.cr new file mode 100644 index 0000000..05cdb4b --- /dev/null +++ b/spec/path_handler_spec.cr @@ -0,0 +1,265 @@ +require "./spec_helper" + +# Test middleware that sets a header +class TestHeaderHandler < Kemal::Handler + def initialize(@header_name : String, @header_value : String) + end + + def call(env) + env.response.headers[@header_name] = @header_value + call_next(env) + end +end + +# Test middleware that blocks requests +class BlockingHandler < Kemal::Handler + def call(env) + env.response.status_code = 401 + env.response.print "Blocked" + # Don't call_next - stop the chain + end +end + +# Test middleware that sets context value +class ContextSetterHandler < Kemal::Handler + def initialize(@key : String, @value : String) + end + + def call(env) + env.set @key, @value + call_next(env) + end +end + +describe "PathHandler" do + describe "use (global)" do + it "adds middleware that runs for all requests" do + use TestHeaderHandler.new("X-Global", "yes") + + get "/test1" do + "test1" + end + + get "/other/path" do + "other" + end + + request1 = HTTP::Request.new("GET", "/test1") + response1 = call_request_on_app(request1) + response1.headers["X-Global"].should eq("yes") + + request2 = HTTP::Request.new("GET", "/other/path") + response2 = call_request_on_app(request2) + response2.headers["X-Global"].should eq("yes") + end + end + + describe "use with path prefix" do + it "runs middleware only for matching path prefix" do + use "/api", TestHeaderHandler.new("X-API", "true") + + get "/api/users" do + "api users" + end + + get "/web/home" do + "web home" + end + + # Should have header for /api/* + api_request = HTTP::Request.new("GET", "/api/users") + api_response = call_request_on_app(api_request) + api_response.headers["X-API"]?.should eq("true") + api_response.body.should eq("api users") + + # Should NOT have header for /web/* + web_request = HTTP::Request.new("GET", "/web/home") + web_response = call_request_on_app(web_request) + web_response.headers["X-API"]?.should be_nil + web_response.body.should eq("web home") + end + + it "matches exact path" do + use "/api", TestHeaderHandler.new("X-Exact", "matched") + + get "/api" do + "api root" + end + + request = HTTP::Request.new("GET", "/api") + response = call_request_on_app(request) + response.headers["X-Exact"]?.should eq("matched") + end + + it "matches nested paths" do + use "/api", TestHeaderHandler.new("X-Nested", "yes") + + get "/api/v1/users/123/posts" do + "nested" + end + + request = HTTP::Request.new("GET", "/api/v1/users/123/posts") + response = call_request_on_app(request) + response.headers["X-Nested"]?.should eq("yes") + end + + it "does not match similar prefixes" do + use "/api", TestHeaderHandler.new("X-API-Only", "true") + + get "/apiv2/users" do + "apiv2" + end + + get "/api-old/users" do + "api-old" + end + + # /apiv2 should NOT match /api + request1 = HTTP::Request.new("GET", "/apiv2/users") + response1 = call_request_on_app(request1) + response1.headers["X-API-Only"]?.should be_nil + + # /api-old should NOT match /api + request2 = HTTP::Request.new("GET", "/api-old/users") + response2 = call_request_on_app(request2) + response2.headers["X-API-Only"]?.should be_nil + end + + it "does not match root when prefix is set" do + use "/admin", TestHeaderHandler.new("X-Admin", "true") + + get "/" do + "home" + end + + request = HTTP::Request.new("GET", "/") + response = call_request_on_app(request) + response.headers["X-Admin"]?.should be_nil + end + end + + describe "multiple middlewares" do + it "runs multiple middlewares in order" do + use "/api", TestHeaderHandler.new("X-First", "1") + use "/api", TestHeaderHandler.new("X-Second", "2") + + get "/api/test" do + "test" + end + + request = HTTP::Request.new("GET", "/api/test") + response = call_request_on_app(request) + response.headers["X-First"]?.should eq("1") + response.headers["X-Second"]?.should eq("2") + end + + it "supports array of middlewares" do + use "/multi", [ + TestHeaderHandler.new("X-A", "a"), + TestHeaderHandler.new("X-B", "b"), + TestHeaderHandler.new("X-C", "c"), + ] + + get "/multi/test" do + "multi" + end + + request = HTTP::Request.new("GET", "/multi/test") + response = call_request_on_app(request) + response.headers["X-A"]?.should eq("a") + response.headers["X-B"]?.should eq("b") + response.headers["X-C"]?.should eq("c") + end + + it "different paths have different middlewares" do + use "/api", TestHeaderHandler.new("X-API", "api") + use "/admin", TestHeaderHandler.new("X-Admin", "admin") + + get "/api/data" do + "api data" + end + + get "/admin/dashboard" do + "admin dashboard" + end + + api_request = HTTP::Request.new("GET", "/api/data") + api_response = call_request_on_app(api_request) + api_response.headers["X-API"]?.should eq("api") + api_response.headers["X-Admin"]?.should be_nil + + admin_request = HTTP::Request.new("GET", "/admin/dashboard") + admin_response = call_request_on_app(admin_request) + admin_response.headers["X-Admin"]?.should eq("admin") + admin_response.headers["X-API"]?.should be_nil + end + end + + describe "middleware can block requests" do + it "middleware can stop the chain" do + use "/protected", BlockingHandler.new + + get "/protected/secret" do + "secret data" + end + + get "/public" do + "public data" + end + + # Protected route should be blocked + protected_request = HTTP::Request.new("GET", "/protected/secret") + protected_response = call_request_on_app(protected_request) + protected_response.status_code.should eq(401) + protected_response.body.should eq("Blocked") + + # Public route should work + public_request = HTTP::Request.new("GET", "/public") + public_response = call_request_on_app(public_request) + public_response.status_code.should eq(200) + public_response.body.should eq("public data") + end + end + + describe "middleware with context" do + it "middleware can set context values" do + use "/ctx", ContextSetterHandler.new("middleware_ran", "yes") + + get "/ctx/check" do |env| + env.get("middleware_ran").to_s + end + + request = HTTP::Request.new("GET", "/ctx/check") + response = call_request_on_app(request) + response.body.should eq("yes") + end + end + + describe "PathHandler" do + describe "#matches_prefix?" do + it "root prefix matches all" do + get "/anything" do + "ok" + end + + use "/", TestHeaderHandler.new("X-Root", "all") + + request = HTTP::Request.new("GET", "/anything") + response = call_request_on_app(request) + response.headers["X-Root"]?.should eq("all") + end + + it "empty prefix matches all" do + use "", TestHeaderHandler.new("X-Empty", "all") + + get "/some/path" do + "ok" + end + + request = HTTP::Request.new("GET", "/some/path") + response = call_request_on_app(request) + response.headers["X-Empty"]?.should eq("all") + end + end + end +end diff --git a/src/kemal/dsl.cr b/src/kemal/dsl.cr index 166397c..04389b7 100644 --- a/src/kemal/dsl.cr +++ b/src/kemal/dsl.cr @@ -47,3 +47,49 @@ end end {% end %} {% end %} + +# Adds a `HTTP::Handler` (middleware) to the handler chain. +# The handler runs for all requests. +# +# ``` +# use MyHandler.new +# ``` +def use(handler : HTTP::Handler) + Kemal.config.add_handler(handler) +end + +# Adds a `HTTP::Handler` (middleware) at a specific position in the handler chain. +# +# ``` +# use MyHandler.new, position: 1 +# ``` +def use(handler : HTTP::Handler, position : Int32) + Kemal.config.add_handler(handler, position) +end + +# Adds a `HTTP::Handler` (middleware) that only runs for requests matching the path prefix. +# +# ``` +# use "/api", AuthHandler.new +# ``` +# +# The handler will execute for: +# - Exact match: `/api` +# - Prefix match: `/api/users`, `/api/posts/1` +# +# But NOT for: +# - `/`, `/apiv2`, `/other` +def use(path : String, handler : HTTP::Handler) + Kemal.config.add_handler(Kemal::PathHandler.new(path, handler)) +end + +# Adds multiple `HTTP::Handler` (middlewares) for a specific path prefix. +# +# ``` +# use "/api", [AuthHandler.new, RateLimiter.new, CorsHandler.new] +# ``` +def use(path : String, handlers : Enumerable(HTTP::Handler)) + handlers.each do |handler| + use(path, handler) + end +end diff --git a/src/kemal/helpers/helpers.cr b/src/kemal/helpers/helpers.cr index 803e4bc..faf5600 100644 --- a/src/kemal/helpers/helpers.cr +++ b/src/kemal/helpers/helpers.cr @@ -15,10 +15,12 @@ require "mime" # - `Kemal::StaticFileHandler` # - Here goes custom handlers # - `Kemal::RouteHandler` +@[Deprecated("Use `use` instead")] def add_handler(handler : HTTP::Handler) Kemal.config.add_handler handler end +@[Deprecated("Use `use` with position parameter instead")] def add_handler(handler : HTTP::Handler, position : Int32) Kemal.config.add_handler handler, position end @@ -288,7 +290,7 @@ end # # Disabled by default. def gzip(status : Bool = false) - add_handler HTTP::CompressHandler.new if status + use HTTP::CompressHandler.new if status end # Adds headers to `Kemal::StaticFileHandler`. This is especially useful for `CORS`. diff --git a/src/kemal/path_handler.cr b/src/kemal/path_handler.cr new file mode 100644 index 0000000..3f0976b --- /dev/null +++ b/src/kemal/path_handler.cr @@ -0,0 +1,46 @@ +module Kemal + # `PathHandler` wraps a `HTTP::Handler` to only execute for specific path prefixes. + # + # ## Example + # + # ``` + # use "/api", AuthHandler.new + # ``` + # + # The handler will only execute for requests matching the path prefix: + # - `/api` matches `/api`, `/api/users`, `/api/posts/1` + # - `/api` does NOT match `/`, `/apiv2`, `/other` + class PathHandler + include HTTP::Handler + + getter path_prefix : String + getter handler : HTTP::Handler + + def initialize(@path_prefix : String, @handler : HTTP::Handler) + end + + def call(context : HTTP::Server::Context) + if matches_prefix?(context.request.path) + # Set next handler for the wrapped handler + @handler.next = self.next + @handler.call(context) + else + call_next(context) + end + end + + # Checks if the request path matches the handler's path prefix. + # - "/" or "" matches all paths + # - "/api" matches "/api" and "/api/*" + # - "/api" does NOT match "/apiv2" + private def matches_prefix?(path : String) : Bool + return true if path_prefix.in?("/", "") + + # Exact match + return true if path == path_prefix + + # Prefix match (must be followed by /) + path.starts_with?("#{path_prefix}/") + end + end +end From 71f4e4528be4d1f9a65fe13fc1f7b1f6368009cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:34:38 +0300 Subject: [PATCH 54/72] Improve response helpers: HTTP::Status, direct write, content_type param (#735) --- spec/response_helpers_spec.cr | 23 ++++++++++++++ src/kemal/ext/context.cr | 60 ++++++++++++++++++++++++----------- 2 files changed, 65 insertions(+), 18 deletions(-) diff --git a/spec/response_helpers_spec.cr b/spec/response_helpers_spec.cr index 5459e2c..670fdf3 100644 --- a/spec/response_helpers_spec.cr +++ b/spec/response_helpers_spec.cr @@ -31,6 +31,17 @@ describe "Response Helpers" do client_response = call_request_on_app(request) client_response.body.should eq("[1,2,3]") end + + it "accepts custom content_type (e.g. JSON API)" do + get "/json-api" do |env| + env.json({data: [] of String}, content_type: "application/vnd.api+json") + end + + request = HTTP::Request.new("GET", "/json-api") + client_response = call_request_on_app(request) + client_response.headers["Content-Type"].should eq("application/vnd.api+json") + client_response.body.should eq(%({"data":[]})) + end end describe "#html" do @@ -155,6 +166,18 @@ describe "Response Helpers" do client_response.status_code.should eq(400) client_response.headers["Content-Type"].should eq("application/xml; charset=utf-8") end + + it "accepts HTTP::Status and is chainable with json" do + get "/status-enum-json" do |env| + env.status(:not_found).json({error: "User not found"}) + end + + request = HTTP::Request.new("GET", "/status-enum-json") + client_response = call_request_on_app(request) + client_response.status_code.should eq(404) + client_response.headers["Content-Type"].should eq("application/json") + client_response.body.should contain("User not found") + end end describe "real-world scenarios" do diff --git a/src/kemal/ext/context.cr b/src/kemal/ext/context.cr index 6271bf6..3af5c90 100644 --- a/src/kemal/ext/context.cr +++ b/src/kemal/ext/context.cr @@ -88,58 +88,82 @@ class HTTP::Server self end - # Sends a JSON response with the proper content-type header. - # Automatically serializes the data to JSON. + # Sets the response status from an *HTTP::Status* and returns self for chaining. + # + # ``` + # get "/users/:id" do |env| + # env.status(:not_found).json({error: "User not found"}) + # end + # + # post "/users" do |env| + # env.status(:created).json({id: 1}) + # end + # ``` + def status(status : HTTP::Status) : self + @response.status = status + self + end + + # Sends a JSON response with the proper `Content-Type` header. + # Serializes the data directly to the response (no intermediate string). + # Use *content_type* for custom types (e.g. `application/vnd.api+json` for JSON API). # # ``` # get "/users" do |env| # env.json({users: ["alice", "bob"]}) # end # - # # With status code (use status chain) # post "/users" do |env| # env.status(201).json({created: true}) # end + # + # # JSON API + # get "/api/users" do |env| + # env.json({data: users}, content_type: "application/vnd.api+json") + # end # ``` - def json(data) : String - @response.content_type = "application/json" - data.to_json + def json(data, *, content_type : String = "application/json") : Nil + @response.content_type = content_type + data.to_json(@response) end - # Sends an HTML response with the proper content-type header. + # Sends an HTML response with the proper `Content-Type` header. + # Writes directly to the response. # # ``` # get "/" do |env| # env.html("

Welcome

") # end # ``` - def html(content : String) : String - @response.content_type = "text/html; charset=utf-8" - content + def html(content : String, *, content_type : String = "text/html; charset=utf-8") : Nil + @response.content_type = content_type + content.to_s(@response) end - # Sends a plain text response with the proper content-type header. + # Sends a plain text response with the proper `Content-Type` header. + # Writes directly to the response. # # ``` # get "/health" do |env| # env.text("OK") # end # ``` - def text(content : String) : String - @response.content_type = "text/plain; charset=utf-8" - content + def text(content : String, *, content_type : String = "text/plain; charset=utf-8") : Nil + @response.content_type = content_type + content.to_s(@response) end - # Sends an XML response with the proper content-type header. + # Sends an XML response with the proper `Content-Type` header. + # Writes directly to the response. # # ``` # get "/feed.xml" do |env| # env.xml("...") # end # ``` - def xml(content : String) : String - @response.content_type = "application/xml; charset=utf-8" - content + def xml(content : String, *, content_type : String = "application/xml; charset=utf-8") : Nil + @response.content_type = content_type + content.to_s(@response) end end end From 33104b19f35442dbb0e093e6a0419532968d2731 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:05:56 +0300 Subject: [PATCH 55/72] Refactor response methods in HTTP::Server to serialize content to a string before writing to the response --- src/kemal/ext/context.cr | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/kemal/ext/context.cr b/src/kemal/ext/context.cr index 3af5c90..4ca6156 100644 --- a/src/kemal/ext/context.cr +++ b/src/kemal/ext/context.cr @@ -105,7 +105,7 @@ class HTTP::Server end # Sends a JSON response with the proper `Content-Type` header. - # Serializes the data directly to the response (no intermediate string). + # Serializes the data to a string and writes it to the response in a single operation. # Use *content_type* for custom types (e.g. `application/vnd.api+json` for JSON API). # # ``` @@ -124,11 +124,11 @@ class HTTP::Server # ``` def json(data, *, content_type : String = "application/json") : Nil @response.content_type = content_type - data.to_json(@response) + @response << data.to_json end # Sends an HTML response with the proper `Content-Type` header. - # Writes directly to the response. + # Serializes the content to a string and writes it to the response in a single operation. # # ``` # get "/" do |env| @@ -137,11 +137,11 @@ class HTTP::Server # ``` def html(content : String, *, content_type : String = "text/html; charset=utf-8") : Nil @response.content_type = content_type - content.to_s(@response) + @response << content end # Sends a plain text response with the proper `Content-Type` header. - # Writes directly to the response. + # Serializes the content to a string and writes it to the response in a single operation. # # ``` # get "/health" do |env| @@ -150,11 +150,11 @@ class HTTP::Server # ``` def text(content : String, *, content_type : String = "text/plain; charset=utf-8") : Nil @response.content_type = content_type - content.to_s(@response) + @response << content end # Sends an XML response with the proper `Content-Type` header. - # Writes directly to the response. + # Serializes the content to a string and writes it to the response in a single operation. # # ``` # get "/feed.xml" do |env| @@ -163,7 +163,7 @@ class HTTP::Server # ``` def xml(content : String, *, content_type : String = "application/xml; charset=utf-8") : Nil @response.content_type = content_type - content.to_s(@response) + @response << content end end end From 83ae194d7c01716706d0e8702755ef5abd6d68e2 Mon Sep 17 00:00:00 2001 From: Anton Maminov Date: Fri, 6 Feb 2026 13:13:24 +0200 Subject: [PATCH 56/72] Add support to halt execution with a chained response (#736) --- spec/helpers_spec.cr | 72 +++++++++++++++++++++++++++++++++++++ src/kemal/helpers/macros.cr | 27 ++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr index f8014b3..9942cf3 100644 --- a/spec/helpers_spec.cr +++ b/spec/helpers_spec.cr @@ -46,6 +46,61 @@ describe "Macros" do client_response.status_code.should eq(200) client_response.body.should eq("") end + + it "halts with chained status/json" do + get "/halt-status-json" do |env| + halt env.status(500).json({error: "Something went wrong"}) + "should-not-render" + end + + request = HTTP::Request.new("GET", "/halt-status-json") + client_response = call_request_on_app(request) + client_response.status_code.should eq(500) + client_response.headers["Content-Type"].should eq("application/json") + client_response.body.should eq(%({"error":"Something went wrong"})) + end + + it "halts with chained json" do + get "/halt-json" do |env| + halt env.json({error: "Something went wrong"}) + "should-not-render" + end + + request = HTTP::Request.new("GET", "/halt-json") + client_response = call_request_on_app(request) + client_response.status_code.should eq(200) + client_response.headers["Content-Type"].should eq("application/json") + client_response.body.should eq(%({"error":"Something went wrong"})) + end + + it "writes body when halting with chained json" do + get "/halt-json-raw" do |env| + halt env.status(500).json({error: "Something went wrong"}) + "should-not-render" + end + + request = HTTP::Request.new("GET", "/halt-json-raw") + client_response = call_request_on_app(request) + client_response.status_code.should eq(500) + client_response.headers["Content-Type"].should eq("application/json") + client_response.body.should eq(%({"error":"Something went wrong"})) + end + + it "halts env" do + get "/halt-env" do |env| + env.response.status_code = 500 + env.response.content_type = "application/json" + env.response.print({error: "Something went wrong"}.to_json) + halt env + "should-not-render" + end + + request = HTTP::Request.new("GET", "/halt-env") + client_response = call_request_on_app(request) + client_response.status_code.should eq(500) + client_response.headers["Content-Type"].should eq("application/json") + client_response.body.should eq(%({"error":"Something went wrong"})) + end end describe "#callbacks" do @@ -64,6 +119,23 @@ describe "Macros" do client_response.status_code.should eq(400) client_response.body.should eq("Missing origin.") end + + it "writes body when halting with chained json in before filter" do + filter_middleware = Kemal::FilterHandler.new + filter_middleware._add_route_filter("GET", "/halt-json-filter", :before) do |env| + halt env.status(500).json({error: "Something went wrong"}) + end + + get "/halt-json-filter" do |_env| + "should-not-render" + end + + request = HTTP::Request.new("GET", "/halt-json-filter") + client_response = call_request_on_app(request) + client_response.status_code.should eq(500) + client_response.headers["Content-Type"].should eq("application/json") + client_response.body.should eq(%({"error":"Something went wrong"})) + end end describe "#headers" do diff --git a/src/kemal/helpers/macros.cr b/src/kemal/helpers/macros.cr index 9a47f53..cd5fe19 100644 --- a/src/kemal/helpers/macros.cr +++ b/src/kemal/helpers/macros.cr @@ -74,6 +74,33 @@ macro render(filename) ECR.render({{ filename }}) end +# Halts execution by closing the response. Designed for use with chained response method calls. +# +# ``` +# # Example: Send a JSON error and halt immediately +# halt env.status(500).json({error: "Internal Server Error"}) +# +# # Example: Immediately close and halt after rendering HTML +# halt env.status(403).html("Forbidden") +# ``` +# +# NOTE: For most cases that require setting a specific status code and body, prefer the alternative: +# +# ``` +# halt env, status_code: 403, response: "Forbidden" +# ``` +macro halt(response) + {% if response.is_a?(Call) && response.receiver %} + %env = {{ response.receiver }} + {{ response }} + %env.response.close + next + {% else %} + {{ response }}.response.close + next + {% end %} +end + # Halt execution with the current context. # Returns 200 and an empty response by default. # From c65e6d1dd66549f8c362083f3646c68158ce9681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:33:22 +0300 Subject: [PATCH 57/72] Add modular Router support (#731) --- spec/router_spec.cr | 422 ++++++++++++++++++++++++++++++++++++++++++++ src/kemal/dsl.cr | 122 +++++++++++-- src/kemal/router.cr | 304 +++++++++++++++++++++++++++++++ 3 files changed, 837 insertions(+), 11 deletions(-) create mode 100644 spec/router_spec.cr create mode 100644 src/kemal/router.cr diff --git a/spec/router_spec.cr b/spec/router_spec.cr new file mode 100644 index 0000000..e273282 --- /dev/null +++ b/spec/router_spec.cr @@ -0,0 +1,422 @@ +require "./spec_helper" + +describe "Kemal::Router" do + describe "basic routing" do + it "routes GET request with prefix" do + router = Kemal::Router.new + router.get "/users" do + "users list" + end + + mount "/api", router + + request = HTTP::Request.new("GET", "/api/users") + client_response = call_request_on_app(request) + client_response.body.should eq("users list") + end + + it "routes POST request with prefix" do + router = Kemal::Router.new + router.post "/users" do + "user created" + end + + mount "/api", router + + request = HTTP::Request.new("POST", "/api/users") + client_response = call_request_on_app(request) + client_response.body.should eq("user created") + end + + it "routes PUT request with prefix" do + router = Kemal::Router.new + router.put "/users/:id" do |env| + "user #{env.params.url["id"]} updated" + end + + mount "/api", router + + request = HTTP::Request.new("PUT", "/api/users/123") + client_response = call_request_on_app(request) + client_response.body.should eq("user 123 updated") + end + + it "routes DELETE request with prefix" do + router = Kemal::Router.new + router.delete "/users/:id" do |env| + "user #{env.params.url["id"]} deleted" + end + + mount "/api", router + + request = HTTP::Request.new("DELETE", "/api/users/456") + client_response = call_request_on_app(request) + client_response.body.should eq("user 456 deleted") + end + + it "routes PATCH request" do + router = Kemal::Router.new + router.patch "/users/:id" do + "user patched" + end + + mount "/api", router + + request = HTTP::Request.new("PATCH", "/api/users/1") + client_response = call_request_on_app(request) + client_response.body.should eq("user patched") + end + + it "routes OPTIONS request" do + router = Kemal::Router.new + router.options "/users" do |env| + env.response.headers["Allow"] = "GET, POST, OPTIONS" + "" + end + + mount "/api", router + + request = HTTP::Request.new("OPTIONS", "/api/users") + client_response = call_request_on_app(request) + client_response.headers["Allow"].should eq("GET, POST, OPTIONS") + end + + it "mounts router without prefix" do + router = Kemal::Router.new + router.get "/status" do + "ok" + end + + mount router + + request = HTTP::Request.new("GET", "/status") + client_response = call_request_on_app(request) + client_response.body.should eq("ok") + end + + it "works alongside global DSL routes" do + # Global DSL route + get "/global" do + "global route" + end + + # Router route + router = Kemal::Router.new + router.get "/local" do + "router route" + end + + mount "/api", router + + # Test global route + global_request = HTTP::Request.new("GET", "/global") + global_response = call_request_on_app(global_request) + global_response.body.should eq("global route") + + # Test router route + router_request = HTTP::Request.new("GET", "/api/local") + router_response = call_request_on_app(router_request) + router_response.body.should eq("router route") + end + end + + describe "router-scoped filters" do + it "applies before filter to router routes" do + router = Kemal::Router.new + + router.before do |env| + env.set "filtered", "yes" + end + + router.get "/test" do |env| + env.get("filtered").to_s + end + + mount "/api", router + + request = HTTP::Request.new("GET", "/api/test") + client_response = call_request_on_app(request) + client_response.body.should eq("yes") + end + + it "applies after filter to router routes" do + router = Kemal::Router.new + + router.after do |env| + env.response.headers["X-After-Filter"] = "applied" + end + + router.get "/test" do + "test" + end + + mount "/api", router + + request = HTTP::Request.new("GET", "/api/test") + client_response = call_request_on_app(request) + client_response.headers["X-After-Filter"].should eq("applied") + end + + it "applies method-specific before filter" do + router = Kemal::Router.new + + router.before_post do |env| + env.set "method", "post" + end + + router.post "/test" do |env| + env.get("method").to_s + end + + router.get "/test" do |env| + env.get?("method").to_s + end + + mount "/api", router + + post_request = HTTP::Request.new("POST", "/api/test") + post_response = call_request_on_app(post_request) + post_response.body.should eq("post") + end + + it "applies filter to specific path" do + router = Kemal::Router.new + + router.before "/protected" do |env| + env.set "auth", "required" + end + + router.get "/protected" do |env| + env.get("auth").to_s + end + + router.get "/public" do |env| + env.get?("auth").to_s + end + + mount "/api", router + + protected_request = HTTP::Request.new("GET", "/api/protected") + protected_response = call_request_on_app(protected_request) + protected_response.body.should eq("required") + end + end + + describe "nested routers" do + it "namespaces routes correctly" do + router = Kemal::Router.new + + router.namespace "/users" do + get "/" do + "users index" + end + + get "/:id" do |env| + "user #{env.params.url["id"]}" + end + end + + mount "/api/v1", router + + index_request = HTTP::Request.new("GET", "/api/v1/users") + index_response = call_request_on_app(index_request) + index_response.body.should eq("users index") + + show_request = HTTP::Request.new("GET", "/api/v1/users/42") + show_response = call_request_on_app(show_request) + show_response.body.should eq("user 42") + end + + it "supports multiple namespaces" do + router = Kemal::Router.new + + router.namespace "/users" do + get "/" do + "users" + end + end + + router.namespace "/posts" do + get "/" do + "posts" + end + end + + mount "/api", router + + users_request = HTTP::Request.new("GET", "/api/users") + users_response = call_request_on_app(users_request) + users_response.body.should eq("users") + + posts_request = HTTP::Request.new("GET", "/api/posts") + posts_response = call_request_on_app(posts_request) + posts_response.body.should eq("posts") + end + + it "supports deeply nested routers" do + router = Kemal::Router.new + + router.namespace "/api" do + namespace "/v1" do + namespace "/users" do + get "/" do + "deeply nested users" + end + end + end + end + + mount router + + request = HTTP::Request.new("GET", "/api/v1/users") + client_response = call_request_on_app(request) + client_response.body.should eq("deeply nested users") + end + + it "mounts sub-router with mount method" do + users_router = Kemal::Router.new + users_router.get "/" do + "users from sub-router" + end + users_router.get "/:id" do |env| + "user #{env.params.url["id"]} from sub-router" + end + + api_router = Kemal::Router.new + api_router.mount "/users", users_router + + mount "/api", api_router + + index_request = HTTP::Request.new("GET", "/api/users") + index_response = call_request_on_app(index_request) + index_response.body.should eq("users from sub-router") + + show_request = HTTP::Request.new("GET", "/api/users/99") + show_response = call_request_on_app(show_request) + show_response.body.should eq("user 99 from sub-router") + end + + it "applies namespace filters correctly" do + router = Kemal::Router.new + + router.namespace "/admin" do + before do |env| + env.set "admin", "true" + end + + get "/dashboard" do |env| + "admin: #{env.get("admin")}" + end + end + + mount router + + request = HTTP::Request.new("GET", "/admin/dashboard") + client_response = call_request_on_app(request) + client_response.body.should eq("admin: true") + end + end + + describe "websocket support" do + it "registers websocket route with prefix" do + router = Kemal::Router.new + router.ws "/chat" do |socket| + socket.send("connected") + end + + mount "/ws", router + + handler = Kemal::WebSocketHandler::INSTANCE + headers = HTTP::Headers{ + "Upgrade" => "websocket", + "Connection" => "Upgrade", + "Sec-WebSocket-Key" => "dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Version" => "13", + } + request = HTTP::Request.new("GET", "/ws/chat", headers) + + io_with_context = create_ws_request_and_return_io_and_context(handler, request)[0] + io_with_context.to_s.should contain("101 Switching Protocols") + end + + it "websocket route with url parameters" do + router = Kemal::Router.new + router.ws "/room/:id" do |socket| + socket.send("room") + end + + mount "/ws", router + + handler = Kemal::WebSocketHandler::INSTANCE + headers = HTTP::Headers{ + "Upgrade" => "websocket", + "Connection" => "Upgrade", + "Sec-WebSocket-Key" => "dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Version" => "13", + } + request = HTTP::Request.new("GET", "/ws/room/123", headers) + + io_with_context = create_ws_request_and_return_io_and_context(handler, request)[0] + io_with_context.to_s.should contain("101 Switching Protocols") + end + end + + describe "router with prefix" do + it "initializes router with prefix" do + router = Kemal::Router.new("/v2") + router.get "/status" do + "v2 status" + end + + mount "/api", router + + request = HTTP::Request.new("GET", "/api/v2/status") + client_response = call_request_on_app(request) + client_response.body.should eq("v2 status") + end + end + + describe "edge cases" do + it "handles trailing slashes correctly" do + router = Kemal::Router.new + router.get "/users/" do + "users with trailing slash" + end + + mount "/api/", router + + request = HTTP::Request.new("GET", "/api/users/") + client_response = call_request_on_app(request) + client_response.body.should eq("users with trailing slash") + end + + it "handles root path in namespace" do + router = Kemal::Router.new + + router.namespace "/users" do + get "" do + "users root" + end + end + + mount "/api", router + + request = HTTP::Request.new("GET", "/api/users") + client_response = call_request_on_app(request) + client_response.body.should eq("users root") + end + + it "returns non-string values as empty string" do + router = Kemal::Router.new + router.get "/number" do + 42 + end + + mount router + + request = HTTP::Request.new("GET", "/number") + client_response = call_request_on_app(request) + client_response.body.should eq("") + end + end +end diff --git a/src/kemal/dsl.cr b/src/kemal/dsl.cr index 04389b7..c49bda8 100644 --- a/src/kemal/dsl.cr +++ b/src/kemal/dsl.cr @@ -1,14 +1,29 @@ # Kemal DSL is defined here and it's baked into global scope. +# These methods are available globally in your application. # -# The DSL currently consists of: +# ## Available DSL Methods # -# - get post put patch delete options -# - WebSocket(ws) -# - before_* -# - error +# - **HTTP Routes**: `get`, `post`, `put`, `patch`, `delete`, `options` +# - **WebSocket**: `ws` +# - **Filters**: `before_all`, `before_get`, `after_all`, `after_get`, etc. +# - **Error Handling**: `error` +# - **Modular Routing**: `mount` HTTP_METHODS = %w[get post put patch delete options] FILTER_METHODS = %w[get post put patch delete options all] +# Defines a route for the given HTTP method. +# +# NOTE: The path must start with a `/`. +# +# ``` +# get "/hello" do |env| +# "Hello World!" +# end +# +# post "/users" do |env| +# "User created" +# end +# ``` {% for method in HTTP_METHODS %} def {{ method.id }}(path : String, &block : HTTP::Server::Context -> _) raise Kemal::Exceptions::InvalidPathStartException.new({{ method }}, path) unless Kemal::Utils.path_starts_with_slash?(path) @@ -16,31 +31,82 @@ FILTER_METHODS = %w[get post put patch delete options all] end {% end %} +# Defines a WebSocket route. +# +# NOTE: The path must start with a `/`. +# +# ``` +# ws "/chat" do |socket, env| +# socket.on_message do |msg| +# socket.send "Echo: #{msg}" +# end +# end +# ``` def ws(path : String, &block : HTTP::WebSocket, HTTP::Server::Context ->) raise Kemal::Exceptions::InvalidPathStartException.new("ws", path) unless Kemal::Utils.path_starts_with_slash?(path) Kemal::WebSocketHandler::INSTANCE.add_route path, &block end -# Defines an error handler to be called when route returns the given HTTP status code +# Defines an error handler for the given HTTP status code. +# +# ``` +# error 404 do |env| +# "Page not found" +# end +# ``` def error(status_code : Int32, &block : HTTP::Server::Context, Exception -> _) Kemal.config.add_error_handler status_code, &block end -# Defines an error handler to be called when the given exception is raised +# Defines an error handler for the given `HTTP::Status`. +# +# ``` +# error :not_found do |env| +# "Page not found" +# end +# ``` +def error(status : HTTP::Status, &block : HTTP::Server::Context, Exception -> _) + Kemal.config.add_error_handler status.code, &block +end + +# Defines an error handler for the given exception type. +# +# ``` +# error MyCustomException do |env, ex| +# "Error: #{ex.message}" +# end +# ``` def error(exception : Exception.class, &block : HTTP::Server::Context, Exception -> _) Kemal.config.add_exception_handler exception, &block end -# All the helper methods available are: -# - before_all, before_get, before_post, before_put, before_patch, before_delete, before_options -# - after_all, after_get, after_post, after_put, after_patch, after_delete, after_options +# Defines filters that run before or after requests. +# +# Available methods: +# - `before_all`, `before_get`, `before_post`, `before_put`, `before_patch`, `before_delete`, `before_options` +# - `after_all`, `after_get`, `after_post`, `after_put`, `after_patch`, `after_delete`, `after_options` +# +# ``` +# before_all do |env| +# env.response.content_type = "application/json" +# end +# +# before_get "/admin/*" do |env| +# # Authentication check +# end +# +# # Multiple paths +# after_post ["/users", "/posts"] do |env| +# # Logging +# end +# ``` {% for type in ["before", "after"] %} {% for method in FILTER_METHODS %} def {{ type.id }}_{{ method.id }}(path : String = "*", &block : HTTP::Server::Context -> _) Kemal::FilterHandler::INSTANCE.{{ type.id }}({{ method }}.upcase, path, &block) end - def {{ type.id }}_{{ method.id }}(paths : Array(String), &block : HTTP::Server::Context -> _) + def {{ type.id }}_{{ method.id }}(paths : Enumerable(String), &block : HTTP::Server::Context -> _) paths.each do |path| Kemal::FilterHandler::INSTANCE.{{ type.id }}({{ method }}.upcase, path, &block) end @@ -93,3 +159,37 @@ def use(path : String, handlers : Enumerable(HTTP::Handler)) use(path, handler) end end + +# Mounts a router without additional prefix. +# +# ``` +# api = Kemal::Router.new +# api.get "/users" do |env| +# "users" +# end +# +# mount api +# # Result: GET /users +# ``` +def mount(router : Kemal::Router) + router.register_routes +end + +# Mounts a router at the given *path* prefix. +# +# NOTE: The path must start with a `/`. +# +# All routes defined in the router will be prefixed with the given path. +# +# ``` +# api = Kemal::Router.new +# api.get "/users" do |env| +# "users" +# end +# +# mount "/api/v1", api +# # Result: GET /api/v1/users +# ``` +def mount(path : String, router : Kemal::Router) + router.register_routes(path) +end diff --git a/src/kemal/router.cr b/src/kemal/router.cr new file mode 100644 index 0000000..2ff4fad --- /dev/null +++ b/src/kemal/router.cr @@ -0,0 +1,304 @@ +module Kemal + # Router provides modular routing capabilities for Kemal applications. + # + # It allows grouping routes under a common prefix and applying filters + # to specific route groups. Routers can be nested using `namespace`. + # + # ## Example + # + # ``` + # api = Kemal::Router.new + # + # api.before do |env| + # env.response.content_type = "application/json" + # end + # + # api.get "/users" do |env| + # User.all.to_json + # end + # + # api.namespace "/admin" do + # get "/dashboard" do |env| + # {status: "ok"}.to_json + # end + # end + # + # mount "/api/v1", api + # ``` + class Router + alias RouteHandler = HTTP::Server::Context -> String + alias FilterHandler = HTTP::Server::Context -> String + alias WSHandler = HTTP::WebSocket, HTTP::Server::Context -> + + # Stored route definition + private record RouteDefinition, + method : String, + path : String, + handler : RouteHandler + + # Stored filter definition + private record FilterDefinition, + type : Symbol, + method : String, + path : String, + handler : FilterHandler + + # Stored websocket definition + private record WSDefinition, + path : String, + handler : WSHandler + + # Stored sub-router + private record SubRouter, + path : String, + router : Router + + getter prefix : String + + @routes : Array(RouteDefinition) + @filters : Array(FilterDefinition) + @websockets : Array(WSDefinition) + @sub_routers : Array(SubRouter) + + def initialize(@prefix : String = "") + @routes = [] of RouteDefinition + @filters = [] of FilterDefinition + @websockets = [] of WSDefinition + @sub_routers = [] of SubRouter + end + + # HTTP method helpers + {% for method in HTTP_METHODS %} + # Defines a {{ method.id.upcase }} route. + # + # ``` + # router.{{ method.id }} "/path" do |env| + # "response" + # end + # ``` + def {{ method.id }}(path : String, &block : HTTP::Server::Context -> _) + add_route({{ method.upcase }}, path, &block) + end + {% end %} + + # Defines a WebSocket route. + # + # ``` + # router.ws "/chat" do |socket, env| + # socket.on_message do |msg| + # socket.send "Echo: #{msg}" + # end + # end + # ``` + def ws(path : String, &block : HTTP::WebSocket, HTTP::Server::Context ->) + @websockets << WSDefinition.new(path: path, handler: block) + end + + # Defines a before filter for all HTTP methods. + # + # ``` + # router.before do |env| + # env.response.content_type = "application/json" + # end + # ``` + def before(path : String = "*", &block : HTTP::Server::Context -> _) + add_filter(:before, "ALL", path, &block) + end + + # Defines an after filter for all HTTP methods. + # + # ``` + # router.after do |env| + # puts "Request completed" + # end + # ``` + def after(path : String = "*", &block : HTTP::Server::Context -> _) + add_filter(:after, "ALL", path, &block) + end + + # Method-specific before/after filters + {% for method in FILTER_METHODS %} + # Defines a before filter for {{ method.id.upcase }} requests. + def before_{{ method.id }}(path : String = "*", &block : HTTP::Server::Context -> _) + add_filter(:before, {{ method.upcase }}, path, &block) + end + + # Defines an after filter for {{ method.id.upcase }} requests. + def after_{{ method.id }}(path : String = "*", &block : HTTP::Server::Context -> _) + add_filter(:after, {{ method.upcase }}, path, &block) + end + {% end %} + + # Creates a nested namespace/group with the given *path* prefix. + # + # NOTE: The path must start with a `/`. + # + # All routes defined inside the block will be prefixed with the given path. + # + # ``` + # router.namespace "/users" do + # get "/" do |env| + # User.all.to_json + # end + # + # get "/:id" do |env| + # User.find(env.params.url["id"]).to_json + # end + # end + # ``` + def namespace(path : String, &) + sub_router = Router.new + with sub_router yield + @sub_routers << SubRouter.new(path: path, router: sub_router) + end + + # Mounts another router at the given *path* prefix. + # + # NOTE: The path must start with a `/`. + # + # ``` + # users_router = Kemal::Router.new + # users_router.get "/" { |env| "users" } + # + # api = Kemal::Router.new + # api.mount "/users", users_router + # + # mount "/api", api + # # Result: GET /api/users + # ``` + def mount(path : String, router : Router) + @sub_routers << SubRouter.new(path: path, router: router) + end + + # Mounts another router without additional prefix. + def mount(router : Router) + mount("", router) + end + + # Registers all routes, filters, and websockets with Kemal's handlers. + # This is called automatically when using `mount` from DSL. + # + # :nodoc: + def register_routes(base_prefix : String = "") + full_prefix = join_paths(base_prefix, @prefix) + + # Collect all route paths for filter registration + route_paths = collect_all_route_paths(full_prefix) + + # Register filters for each route path + register_filters(full_prefix, route_paths) + + # Register routes + @routes.each do |route| + full_path = join_paths(full_prefix, route.path) + validate_path!(route.method.downcase, full_path) + Kemal::RouteHandler::INSTANCE.add_route(route.method, full_path) do |env| + route.handler.call(env) + end + end + + # Register websockets + @websockets.each do |ws_def| + full_path = join_paths(full_prefix, ws_def.path) + validate_path!("ws", full_path) + Kemal::WebSocketHandler::INSTANCE.add_route(full_path, &ws_def.handler) + end + + # Register sub-routers recursively + @sub_routers.each do |sub| + sub_prefix = join_paths(full_prefix, sub.path) + sub.router.register_routes(sub_prefix) + end + end + + # Collect all route paths including sub-routers + protected def collect_all_route_paths(full_prefix : String) : Array(Tuple(String, String)) + paths = [] of Tuple(String, String) + + # This router's routes + @routes.each do |route| + full_path = join_paths(full_prefix, route.path) + paths << {route.method, full_path} + end + + # Sub-router routes + @sub_routers.each do |sub| + sub_prefix = join_paths(full_prefix, sub.path) + paths.concat(sub.router.collect_all_route_paths(sub_prefix)) + end + + paths + end + + # Register filters for specific route paths + private def register_filters(full_prefix : String, route_paths : Array(Tuple(String, String))) + return if @filters.empty? + + # Ensure FilterHandler is registered with Kemal (may have been cleared between tests) + unless Kemal::Config::FILTER_HANDLERS.includes?(Kemal::FilterHandler::INSTANCE) + Kemal.config.add_filter_handler(Kemal::FilterHandler::INSTANCE) + end + + @filters.each do |filter| + # Determine which paths this filter applies to + applicable_paths = if filter.path == "*" + # Apply to all routes in this router + route_paths + else + # Apply to specific path + filter_full_path = join_paths(full_prefix, filter.path) + route_paths.select { |_, path| path == filter_full_path || path.starts_with?(filter_full_path + "/") } + end + + applicable_paths.each do |route_method, route_path| + # Check if filter method matches route method + next unless filter.method.in?("ALL", route_method) + + # Use filter's method (ALL or specific) when registering + register_method = filter.method + + case filter.type + when :before + Kemal::FilterHandler::INSTANCE.before(register_method, route_path) do |env| + filter.handler.call(env) + end + when :after + Kemal::FilterHandler::INSTANCE.after(register_method, route_path) do |env| + filter.handler.call(env) + end + end + end + end + end + + private def add_route(method : String, path : String, &block : HTTP::Server::Context -> _) + handler = ->(ctx : HTTP::Server::Context) do + result = block.call(ctx) + result.is_a?(String) ? result : "" + end + @routes << RouteDefinition.new(method: method, path: path, handler: handler) + end + + private def add_filter(type : Symbol, method : String, path : String, &block : HTTP::Server::Context -> _) + handler = ->(ctx : HTTP::Server::Context) do + result = block.call(ctx) + result.is_a?(String) ? result : "" + end + @filters << FilterDefinition.new(type: type, method: method, path: path, handler: handler) + end + + private def join_paths(a : String, b : String) : String + a = a.chomp('/') + b = b.lchop('/') if b.starts_with?('/') + return "/#{b}" if a.empty? + return a if b.empty? + "#{a}/#{b}" + end + + private def validate_path!(method : String, path : String) + unless Utils.path_starts_with_slash?(path) + raise Exceptions::InvalidPathStartException.new(method, path) + end + end + end +end From 676a35f0893d8d754ece6fc008e937c202872670 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:53:18 +0300 Subject: [PATCH 58/72] Add new features in version 1.10.0 to CHANGELOG --- CHANGELOG.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5ad849..74c808e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,74 @@ +# 1.10.0 (TBD) + +- Add modular `Kemal::Router` with namespaced routing, scoped filters, WebSocket support and flexible mounting while keeping the existing DSL fully compatible [#731](https://github.com/kemalcr/kemal/pull/731). Thanks @sdogruyol :pray: + +```crystal +require "kemal" + +api = Kemal::Router.new + +api.namespace "/users" do + get "/" do |env| + env.json({users: ["alice", "bob"]}) + end + + get "/:id" do |env| + env.text "user #{env.params.url["id"]}" + end +end + +mount "/api/v1", api + +Kemal.run +``` + +- Add `use` keyword for registering global and path-specific middleware, including support for arrays and insertion at a specific position in the handler chain [#734](https://github.com/kemalcr/kemal/pull/734). Thanks @sdogruyol :pray: + +```crystal +require "kemal" + +# Path-specific middlewares for /api routes +use "/api", [CORSHandler.new, AuthHandler.new] + +get "/" do + "Public home" +end + +get "/api/users" do |env| + env.json({users: ["alice", "bob"]}) +end + +Kemal.run +``` + +- Enhance response helpers to provide chainable JSON/HTML/text/XML helpers, `HTTP::Status` support and the ability to halt execution from a chained response for concise API error handling [#733](https://github.com/kemalcr/kemal/pull/733), [#735](https://github.com/kemalcr/kemal/pull/735), [#736](https://github.com/kemalcr/kemal/pull/736). Thanks @sdogruyol and @mamantoha :pray: + +```crystal +require "kemal" + +get "/users" do |env| + # Default JSON response + env.json({users: ["alice", "bob"]}) +end + +post "/users" do |env| + # Symbol-based HTTP::Status and chained JSON + env.status(:created).json({id: 1, created: true}) +end + +get "/admin" do |env| + # Halt immediately with HTML response + halt env.status(403).html("

Forbidden

") +end + +get "/api/users" do |env| + # Custom content type (JSON:API) + env.json({data: ["alice", "bob"]}, content_type: "application/vnd.api+json") +end + +Kemal.run +``` + # 1.9.0 (28-01-2026) - Crystal 1.19.0 support :tada: From 1460c68d5f70b7472851a931bd95bc19c0e21a2d Mon Sep 17 00:00:00 2001 From: Anton Maminov Date: Thu, 12 Feb 2026 14:34:41 +0200 Subject: [PATCH 59/72] ensures global wildcard filters always execute while keeping namespace filters isolated to their routes (#737) --- spec/router_spec.cr | 52 +++++++++++++++++++++++++++++++++ src/kemal/filter_handler.cr | 58 +++++++++++++++++++++++++++++++------ 2 files changed, 101 insertions(+), 9 deletions(-) diff --git a/spec/router_spec.cr b/spec/router_spec.cr index e273282..5b71267 100644 --- a/spec/router_spec.cr +++ b/spec/router_spec.cr @@ -200,6 +200,58 @@ describe "Kemal::Router" do protected_response = call_request_on_app(protected_request) protected_response.body.should eq("required") end + + it "applies namespace filters only within the namespace" do + router = Kemal::Router.new + + router.namespace "/admin" do + before do |env| + halt env, 401, "unauthorized" unless env.request.headers["X-Admin"]? == "true" + end + + get "/dashboard" do |env| + env.get("path").to_s + end + end + + router.get "/public" do |env| + env.get("path").to_s + end + + mount "/api", router + + before_all do |env| + env.set "path", env.request.path + end + + get "/public" do |env| + env.get("path").to_s + end + + unauthorized_request = HTTP::Request.new("GET", "/api/admin/dashboard") + unauthorized_response = call_request_on_app(unauthorized_request) + unauthorized_response.status_code.should eq(401) + unauthorized_response.body.should eq("unauthorized") + + authorized_request = HTTP::Request.new( + "GET", + "/api/admin/dashboard", + headers: HTTP::Headers{"X-Admin" => "true"}, + ) + authorized_response = call_request_on_app(authorized_request) + authorized_response.status_code.should eq(200) + authorized_response.body.should eq("/api/admin/dashboard") + + api_public_request = HTTP::Request.new("GET", "/api/public") + api_public_response = call_request_on_app(api_public_request) + api_public_response.status_code.should eq(200) + api_public_response.body.should eq("/api/public") + + public_request = HTTP::Request.new("GET", "/public") + public_response = call_request_on_app(public_request) + public_response.status_code.should eq(200) + public_response.body.should eq("/public") + end end describe "nested routers" do diff --git a/src/kemal/filter_handler.cr b/src/kemal/filter_handler.cr index cd93c09..e80b1cf 100644 --- a/src/kemal/filter_handler.cr +++ b/src/kemal/filter_handler.cr @@ -3,11 +3,29 @@ module Kemal class FilterHandler include HTTP::Handler INSTANCE = new - property tree + + # Path used to represent wildcard filters that apply to all routes + private WILDCARD_PATH = "*" + + @tree : Radix::Tree(Array(FilterBlock)) + + # Hash cache for exact path filters to avoid repeated tree lookups + # Key format: "/#{type}/#{verb}/#{path}" (e.g., "/before/ALL/*") + @exact_filters : Hash(String, Array(FilterBlock)) + + def tree + @tree + end + + def tree=(tree : Radix::Tree(Array(FilterBlock))) + @tree = tree + @exact_filters = Hash(String, Array(FilterBlock)).new + end # This middleware is lazily instantiated and added to the handlers as soon as a call to `after_X` or `before_X` is made. def initialize @tree = Radix::Tree(Array(FilterBlock)).new + @exact_filters = Hash(String, Array(FilterBlock)).new Kemal.config.add_filter_handler(self) end @@ -31,15 +49,21 @@ module Kemal context end - # :nodoc: This shouldn't be called directly, it's not private because - # I need to call it for testing purpose since I can't call the macros in the spec. - # It adds the block for the corresponding verb/path/type combination to the tree. + # :nodoc: + # This shouldn't be called directly, it's not private because I need to call it for testing purpose since I can't call the macros in the spec. + # + # Registers a filter block for the given verb/path/type combination. + # Uses @exact_filters hash for O(1) lookup when adding multiple filters to the same path. def _add_route_filter(verb : String, path, type, &block : HTTP::Server::Context -> _) - lookup = lookup_filters_for_path_type(verb, path, type) - if lookup.found? && lookup.payload.is_a?(Array(FilterBlock)) - lookup.payload << FilterBlock.new(&block) + key = radix_path(verb, path, type) + + if filters = @exact_filters[key]? + filters << FilterBlock.new(&block) else - @tree.add radix_path(verb, path, type), [FilterBlock.new(&block)] + filters = [FilterBlock.new(&block)] + @exact_filters[key] = filters + + @tree.add key, filters end end @@ -57,8 +81,24 @@ module Kemal _add_route_filter verb, path, :after, &block end - # This will fetch the block for the verb/path/type from the tree and call it. + # Executes filters for a given path, ensuring global wildcard filters run first. + # + # Execution order: + # 1. Global wildcard filters ("*") - if path is not already a wildcard + # 2. Exact path filters - filters registered for the specific path + # + # This ensures that global filters (like `before_all`) always execute, + # while namespace-specific filters only apply to their registered paths. private def call_block_for_path_type(verb : String?, path : String, type, context : HTTP::Server::Context) + if path != WILDCARD_PATH + call_block_for_exact_path_type(verb, "*", type, context) + end + + # Executes all filter blocks registered for a specific verb/path/type combination + call_block_for_exact_path_type(verb, path, type, context) + end + + private def call_block_for_exact_path_type(verb : String?, path : String, type, context : HTTP::Server::Context) lookup = lookup_filters_for_path_type(verb, path, type) if lookup.found? && lookup.payload.is_a? Array(FilterBlock) blocks = lookup.payload From c09f0eaefbf0468b7110cf6af38abc281ca3cb21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Mon, 16 Feb 2026 10:25:44 +0300 Subject: [PATCH 60/72] Improve CLI SSL validation and expand CLI option parsing specs (#738) --- spec/cli_spec.cr | 90 ++++++++++++++++++++++++++++++++++++++++++++++++ src/kemal/cli.cr | 8 ++--- 2 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 spec/cli_spec.cr diff --git a/spec/cli_spec.cr b/spec/cli_spec.cr new file mode 100644 index 0000000..e5650bd --- /dev/null +++ b/spec/cli_spec.cr @@ -0,0 +1,90 @@ +require "./spec_helper" + +{% if !flag?(:without_openssl) %} + private def run_cli_eval(cli_args : String) + output = IO::Memory.new + error = IO::Memory.new + status = Process.run( + "crystal", + [ + "eval", + %(require "./src/kemal"; Kemal::CLI.new(#{cli_args})), + ], + output: output, + error: error, + ) + + {status, output.to_s, error.to_s} + end +{% end %} + +describe "Kemal::CLI" do + it "parses host binding with long option" do + Kemal::CLI.new(["--bind", "127.0.0.1"]) + Kemal.config.host_binding.should eq("127.0.0.1") + end + + it "parses host binding with short option" do + Kemal::CLI.new(["-b", "192.168.1.10"]) + Kemal.config.host_binding.should eq("192.168.1.10") + end + + it "parses port with long and short options" do + Kemal::CLI.new(["--port", "4001"]) + Kemal.config.port.should eq(4001) + + Kemal::CLI.new(["-p", "5002"]) + Kemal.config.port.should eq(5002) + end + + it "raises for non-numeric port values" do + expect_raises(ArgumentError) do + Kemal::CLI.new(["--port", "abc"]) + end + end + + {% if !flag?(:without_openssl) %} + it "fails when ssl is enabled but key file is missing" do + status, _, stderr = run_cli_eval(%(["--ssl", "--ssl-cert-file", "cert.pem"])) + + status.success?.should be_false + stderr.should contain("SSL configuration error: SSL key file not specified") + end + + it "fails when ssl is enabled but certificate file is missing" do + status, _, stderr = run_cli_eval(%(["--ssl", "--ssl-key-file", "key.pem"])) + + status.success?.should be_false + stderr.should contain("SSL configuration error: SSL certificate file not specified") + end + + it "fails when short ssl flag is used without key file" do + status, _, stderr = run_cli_eval(%(["-s", "--ssl-cert-file", "cert.pem"])) + + status.success?.should be_false + stderr.should contain("SSL configuration error: SSL key file not specified") + end + + it "fails when key file argument is empty" do + status, _, stderr = run_cli_eval(%(["--ssl", "--ssl-key-file", "", "--ssl-cert-file", "cert.pem"])) + + status.success?.should be_false + stderr.should contain("SSL configuration error: SSL key file not specified") + end + + it "fails when cert file argument is empty" do + status, _, stderr = run_cli_eval(%(["--ssl", "--ssl-key-file", "key.pem", "--ssl-cert-file", ""])) + + status.success?.should be_false + stderr.should contain("SSL configuration error: SSL certificate file not specified") + end + + it "does not hit missing-file validation when both flags are present" do + status, _, stderr = run_cli_eval(%(["--ssl", "--ssl-key-file", "key.pem", "--ssl-cert-file", "cert.pem"])) + + status.success?.should be_false + stderr.should_not contain("SSL configuration error: SSL key file not specified") + stderr.should_not contain("SSL configuration error: SSL certificate file not specified") + end + {% end %} +end diff --git a/src/kemal/cli.cr b/src/kemal/cli.cr index 8d7592b..d4913b8 100644 --- a/src/kemal/cli.cr +++ b/src/kemal/cli.cr @@ -42,11 +42,11 @@ module Kemal private def configure_ssl {% if !flag?(:without_openssl) %} if @ssl_enabled - abort "SSL configuration error: SSL key file not specified. Use --ssl-key-file FILE to specify the key file." if !@key_file - abort "SSL configuration error: SSL certificate file not specified. Use --ssl-cert-file FILE to specify the certificate file." if !@cert_file + abort "SSL configuration error: SSL key file not specified. Use --ssl-key-file FILE to specify the key file." if @key_file.empty? + abort "SSL configuration error: SSL certificate file not specified. Use --ssl-cert-file FILE to specify the certificate file." if @cert_file.empty? ssl = Kemal::SSL.new - ssl.key_file = @key_file.not_nil! - ssl.cert_file = @cert_file.not_nil! + ssl.key_file = @key_file + ssl.cert_file = @cert_file Kemal.config.ssl = ssl.context end {% end %} From 89966f6f7b4c3ce8e39def4aafa9b33257232a82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:01:52 +0300 Subject: [PATCH 61/72] Make route LRU cache concurrency-safe with Mutex (#739) --- spec/route_handler_spec.cr | 20 ++++++++++++++++++++ src/kemal/route_handler.cr | 21 ++++++++++++++++----- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/spec/route_handler_spec.cr b/spec/route_handler_spec.cr index 33a32ab..7a17e92 100644 --- a/spec/route_handler_spec.cr +++ b/spec/route_handler_spec.cr @@ -288,5 +288,25 @@ describe "Kemal::RouteHandler" do Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq large_capacity end + + it "handles concurrent lookups safely" do + Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(256) + + get "/concurrent" do + "ok" + end + + channel = Channel(Nil).new + fiber_count = 100 + fiber_count.times do + spawn do + Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/concurrent").found?.should be_true + channel.send(nil) + end + end + fiber_count.times { channel.receive } + + Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq 1 + end end end diff --git a/src/kemal/route_handler.cr b/src/kemal/route_handler.cr index bda86dd..6d66b26 100644 --- a/src/kemal/route_handler.cr +++ b/src/kemal/route_handler.cr @@ -104,11 +104,19 @@ module Kemal include HTTP::Handler INSTANCE = new - property routes, cached_routes + property routes + + getter cached_routes + + # Setter is synchronized for thread-safety when specs reset the cache. + def cached_routes=(cache : LRUCache(String, Radix::Result(Route))) + @cache_mutex.synchronize { @cached_routes = cache } + end def initialize @routes = Radix::Tree(Route).new @cached_routes = LRUCache(String, Radix::Result(Route)).new(Kemal.config.max_route_cache_size) + @cache_mutex = Mutex.new end def call(context : HTTP::Server::Context) @@ -121,11 +129,14 @@ module Kemal end # Looks up the route from the Radix::Tree for the first time and caches to improve performance. + # Cache access is synchronized so multiple fibers can call this concurrently. def lookup_route(verb : String, path : String) lookup_path = radix_path(verb, path) - if cached_route = @cached_routes.get(lookup_path) - return cached_route + @cache_mutex.synchronize do + if cached_route = @cached_routes.get(lookup_path) + return cached_route + end end route = @routes.find(lookup_path) @@ -136,11 +147,11 @@ module Kemal get_route = @routes.find(get_lookup_path) # Cache the HEAD->GET fallback result using the original HEAD lookup_path if get_route.found? - @cached_routes.put(lookup_path, get_route) + @cache_mutex.synchronize { @cached_routes.put(lookup_path, get_route) } end route = get_route elsif route.found? - @cached_routes.put(lookup_path, route) + @cache_mutex.synchronize { @cached_routes.put(lookup_path, route) } end route From 6c6270dee7f915d7ed6184efd2199297a154666a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:46:33 +0300 Subject: [PATCH 62/72] Fix OverrideMethodHandler route cache bug (#741) (#742) --- spec/context_spec.cr | 25 ++++++++++++++++ spec/override_method_handler_spec.cr | 44 ++++++++++++++++++++++++++++ src/kemal/ext/context.cr | 12 ++++++++ src/kemal/override_method_handler.cr | 3 +- src/kemal/param_parser.cr | 6 ++++ 5 files changed, 89 insertions(+), 1 deletion(-) diff --git a/spec/context_spec.cr b/spec/context_spec.cr index c972926..f542f8d 100644 --- a/spec/context_spec.cr +++ b/spec/context_spec.cr @@ -104,4 +104,29 @@ describe "Context" do context.get?("another_non_existent_key").should be_nil end end + + context "route cache invalidation" do + it "refreshes route lookup and url params after request method changes" do + put "/items/:id" { "ok" } + + request = HTTP::Request.new( + "POST", + "/items/42", + body: "_method=PUT", + headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded; charset=UTF-8"} + ) + + io = IO::Memory.new + response = HTTP::Server::Response.new(io) + context = HTTP::Server::Context.new(request, response) + + context.params.url.empty?.should be_true + + request.method = "PUT" + context.invalidate_route_cache + + context.route_found?.should be_true + context.params.url["id"].should eq "42" + end + end end diff --git a/spec/override_method_handler_spec.cr b/spec/override_method_handler_spec.cr index 2b34e29..f0caf10 100644 --- a/spec/override_method_handler_spec.cr +++ b/spec/override_method_handler_spec.cr @@ -26,4 +26,48 @@ describe "Kemal::OverrideMethodHandler" do context.request.method.should eq "PATCH" end + + it "routes POST with _method=PUT to PUT handler in real app" do + use Kemal::OverrideMethodHandler::INSTANCE + + put "/items/:id" do |env| + "updated #{env.params.url["id"]}" + end + + request = HTTP::Request.new( + "POST", + "/items/42", + body: "_method=PUT", + headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded; charset=UTF-8"} + ) + + response = call_request_on_app(request) + + response.status_code.should eq 200 + response.body.should eq "updated 42" + end + + it "does not override method when _method is not allowed" do + use Kemal::OverrideMethodHandler::INSTANCE + + post "/items/:id" do |env| + "posted #{env.params.url["id"]}" + end + + put "/items/:id" do |env| + "updated #{env.params.url["id"]}" + end + + request = HTTP::Request.new( + "POST", + "/items/42", + body: "_method=TRACE", + headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded; charset=UTF-8"} + ) + + response = call_request_on_app(request) + + response.status_code.should eq 200 + response.body.should eq "posted 42" + end end diff --git a/src/kemal/ext/context.cr b/src/kemal/ext/context.cr index 4ca6156..cf7c952 100644 --- a/src/kemal/ext/context.cr +++ b/src/kemal/ext/context.cr @@ -47,6 +47,18 @@ class HTTP::Server @cached_route_lookup ||= Kemal::RouteHandler::INSTANCE.lookup_route(@request.method.as(String), @request.path) end + # Clears the cached route lookup and updates params with new route. Used by handlers that + # modify the request (e.g. OverrideMethodHandler) so the next route lookup uses the updated request. + def invalidate_route_cache + @cached_route_lookup = nil + params = @params + if params + new_lookup = Kemal::RouteHandler::INSTANCE.lookup_route(@request.method.as(String), @request.path) + @cached_route_lookup = new_lookup + params.update_url_params(new_lookup.params) + end + end + def route_found? route_lookup.found? end diff --git a/src/kemal/override_method_handler.cr b/src/kemal/override_method_handler.cr index 523e9e6..4968ded 100644 --- a/src/kemal/override_method_handler.cr +++ b/src/kemal/override_method_handler.cr @@ -4,7 +4,7 @@ module Kemal # This middleware is **not** in the default Kemal handlers. You need to explicitly add this to your handlers: # # ```ruby - # add_handler Kemal::OverrideMethodHandler + # use Kemal::OverrideMethodHandler # ``` # # **Important:** This middleware consumes `params.body` to read the `_method` magic parameter. @@ -21,6 +21,7 @@ module Kemal if request.method == OVERRIDE_METHOD if context.params.body.has_key?(OVERRIDE_METHOD_PARAM_KEY) && override_method_valid?(context.params.body[OVERRIDE_METHOD_PARAM_KEY]) request.method = context.params.body["_method"].upcase + context.invalidate_route_cache end end call_next(context) diff --git a/src/kemal/param_parser.cr b/src/kemal/param_parser.cr index c084ffe..12236ff 100644 --- a/src/kemal/param_parser.cr +++ b/src/kemal/param_parser.cr @@ -33,6 +33,12 @@ module Kemal end end + # Updates url params (e.g. after request method override). Used by Context#invalidate_route_cache. + def update_url_params(new_url : Hash(String, String)) + @url = new_url + @url_parsed = false + end + private def unescape_url_param(value : String) value.empty? ? value : URI.decode(value) rescue From 7a1a07f7332ec13ff1db9427b7267fe97c0e0555 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:55:48 +0300 Subject: [PATCH 63/72] Update documentation for OverrideMethodHandler to use INSTANCE constant --- src/kemal/override_method_handler.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kemal/override_method_handler.cr b/src/kemal/override_method_handler.cr index 4968ded..e02aa94 100644 --- a/src/kemal/override_method_handler.cr +++ b/src/kemal/override_method_handler.cr @@ -4,7 +4,7 @@ module Kemal # This middleware is **not** in the default Kemal handlers. You need to explicitly add this to your handlers: # # ```ruby - # use Kemal::OverrideMethodHandler + # use Kemal::OverrideMethodHandler::INSTANCE # ``` # # **Important:** This middleware consumes `params.body` to read the `_method` magic parameter. From f45f6744c80c94a73a85bbf88370ef35da7b69c0 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:57:39 +0300 Subject: [PATCH 64/72] Clear EXCEPTION_HANDLERS during configuration reset --- src/kemal/config.cr | 1 + 1 file changed, 1 insertion(+) diff --git a/src/kemal/config.cr b/src/kemal/config.cr index b040848..33b6fc1 100644 --- a/src/kemal/config.cr +++ b/src/kemal/config.cr @@ -79,6 +79,7 @@ module Kemal CUSTOM_HANDLERS.clear FILTER_HANDLERS.clear ERROR_HANDLERS.clear + EXCEPTION_HANDLERS.clear end def handlers From f618eece5b3a1802cd6f4a41b5a2097ee792be8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:29:39 +0300 Subject: [PATCH 65/72] Add raw_body to ParamParser for multi-handler body access (#740) --- spec/param_parser_spec.cr | 63 +++++++++++++++++++++++++++++++++++++++ src/kemal/param_parser.cr | 31 +++++++++++++++---- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/spec/param_parser_spec.cr b/spec/param_parser_spec.cr index 2c5a99f..0b1d1d7 100644 --- a/spec/param_parser_spec.cr +++ b/spec/param_parser_spec.cr @@ -202,6 +202,69 @@ describe "ParamParser" do end end + describe "raw_body" do + it "returns raw body for url-encoded form" do + request = HTTP::Request.new( + "POST", + "/", + body: "name=serdar&age=99", + headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"}, + ) + + parser = Kemal::ParamParser.new(request) + parser.raw_body.should eq("name=serdar&age=99") + parser.body["name"].should eq("serdar") + parser.body["age"].should eq("99") + end + + it "returns raw body for JSON" do + request = HTTP::Request.new( + "POST", + "/", + body: "{\"name\": \"Serdar\"}", + headers: HTTP::Headers{"Content-Type" => "application/json"}, + ) + + parser = Kemal::ParamParser.new(request) + parser.raw_body.should eq("{\"name\": \"Serdar\"}") + parser.json["name"].should eq("Serdar") + end + + it "caches body so it can be accessed multiple times" do + request = HTTP::Request.new( + "POST", + "/", + body: "foo=bar&baz=qux", + headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"}, + ) + + parser = Kemal::ParamParser.new(request) + parser.raw_body.should eq("foo=bar&baz=qux") + parser.raw_body.should eq("foo=bar&baz=qux") + parser.body["foo"].should eq("bar") + parser.raw_body.should eq("foo=bar&baz=qux") + end + + it "returns empty string for unsupported content types" do + request = HTTP::Request.new( + "POST", + "/", + body: "some body", + headers: HTTP::Headers{"Content-Type" => "text/plain"}, + ) + + parser = Kemal::ParamParser.new(request) + parser.raw_body.should eq("") + end + + it "returns empty string when content-type is missing" do + request = HTTP::Request.new("POST", "/", body: "some body") + + parser = Kemal::ParamParser.new(request) + parser.raw_body.should eq("") + end + end + context "Payload too large" do it "raises PayloadTooLarge when body exceeds limit" do Kemal.config.max_request_body_size = 10 diff --git a/src/kemal/param_parser.cr b/src/kemal/param_parser.cr index 12236ff..ad02ae0 100644 --- a/src/kemal/param_parser.cr +++ b/src/kemal/param_parser.cr @@ -22,6 +22,30 @@ module Kemal @body_parsed = false @json_parsed = false @files_parsed = false + @cached_body = nil + end + + # Returns the raw request body, read and cached on first access. + # Allows multiple handlers to access the body without consuming the IO. + # Only caches for `application/x-www-form-urlencoded` and `application/json`. + def raw_body : String + if cached = @cached_body + return cached + end + + content_type = @request.headers["Content-Type"]? + return @cached_body = "" if content_type.nil? + + if content_type.try(&.starts_with?(URL_ENCODED_FORM)) || content_type.try(&.starts_with?(APPLICATION_JSON)) + validate_content_length! + @cached_body = if body_io = @request.body + read_body_with_limit(body_io) + else + "" + end + else + @cached_body = "" + end end def cleanup_temporary_files @@ -65,7 +89,7 @@ module Kemal validate_content_length! if content_type.try(&.starts_with?(URL_ENCODED_FORM)) - @body = parse_part(@request.body) + @body = parse_part(raw_body) return end @@ -113,12 +137,9 @@ module Kemal # - If request body is a JSON `Hash` then all the params are parsed and added into `params`. # - If request body is a JSON `Array` it's added into `params` as `_json` and can be accessed like `params["_json"]`. private def parse_json - return unless body = @request.body return unless @request.headers["Content-Type"]?.try(&.starts_with?(APPLICATION_JSON)) - validate_content_length! - - body_str = read_body_with_limit(body) + body_str = raw_body return if body_str.empty? case json = JSON.parse(body_str).raw From b06dd5023aa49d750234a0fa6671eb5739ce72ba Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:36:07 +0300 Subject: [PATCH 66/72] Update CHANGELOG --- CHANGELOG.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74c808e..c410056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# 1.10.0 (TBD) +# 1.10.0 (03-03-2026) - Add modular `Kemal::Router` with namespaced routing, scoped filters, WebSocket support and flexible mounting while keeping the existing DSL fully compatible [#731](https://github.com/kemalcr/kemal/pull/731). Thanks @sdogruyol :pray: @@ -69,6 +69,20 @@ end Kemal.run ``` +- Ensure global wildcard filters always execute while keeping namespace filters isolated to their routes [#737](https://github.com/kemalcr/kemal/pull/737). Thanks @mamantoha :pray: +- Fix CLI SSL validation and expand CLI option parsing specs [#738](https://github.com/kemalcr/kemal/pull/738). Thanks @sdogruyol :pray: +- Make route LRU cache concurrency-safe with Mutex [#739](https://github.com/kemalcr/kemal/pull/739). Thanks @sdogruyol :pray: +- Add `raw_body` to ParamParser for multi-handler body access (e.g. kemal-session) [#740](https://github.com/kemalcr/kemal/pull/740). Thanks @sdogruyol :pray: + +```crystal +post "/" do |env| + raw = env.params.raw_body # raw body, multiple handlers can call it + env.params.body["name"] # parsed body +end +``` + +- Fix OverrideMethodHandler route cache bug when using `_method` override [#741](https://github.com/kemalcr/kemal/pull/741), [#742](https://github.com/kemalcr/kemal/pull/742). Thanks @skojin and @sdogruyol :pray: + # 1.9.0 (28-01-2026) - Crystal 1.19.0 support :tada: From 1c7b33cfa6376a852bb3a00e0a746484d9d6041c Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:37:13 +0300 Subject: [PATCH 67/72] Bump version to 1.10.0 --- shard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shard.yml b/shard.yml index f209020..6b0408b 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: kemal -version: 1.9.0 +version: 1.10.0 authors: - Serdar Dogruyol From 01b4f5a37da2b653a29444c36dec3d2ff8b7a5ac Mon Sep 17 00:00:00 2001 From: Jesse Clark <7525249+cornhusker@users.noreply.github.com> Date: Tue, 10 Mar 2026 03:39:08 -0500 Subject: [PATCH 68/72] posgresql-db-example act on connection object (#743) --- examples/postgresql-db/app.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/postgresql-db/app.cr b/examples/postgresql-db/app.cr index 0f85218..01aa629 100644 --- a/examples/postgresql-db/app.cr +++ b/examples/postgresql-db/app.cr @@ -43,7 +43,7 @@ delete "/users/:id" do |env| id = env.params.url["id"].to_i # Delete user and check if any rows were affected - result = DB.exec "DELETE FROM users WHERE id = $1", id + result = DBC.exec "DELETE FROM users WHERE id = $1", id if result.rows_affected > 0 {message: "User deleted successfully"}.to_json From af81c4c62412d4a55e97aa7704e53268cb1c9f0b Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:40:10 +0300 Subject: [PATCH 69/72] Fix typo in database connection object reference in MySQL example --- examples/mysql-db/app.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/mysql-db/app.cr b/examples/mysql-db/app.cr index 3f2cef5..14045cc 100644 --- a/examples/mysql-db/app.cr +++ b/examples/mysql-db/app.cr @@ -43,7 +43,7 @@ delete "/users/:id" do |env| id = env.params.url["id"].to_i # Delete user and check if any rows were affected - result = DB.exec "DELETE FROM users WHERE id = ?", id + result = DBC.exec "DELETE FROM users WHERE id = ?", id if result.rows_affected > 0 {message: "User deleted successfully"}.to_json From 990fdf2a42a6f839c4c2d9707044299c5cde366a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:15:57 +0300 Subject: [PATCH 70/72] Add shutdown_timeout configuration and implement graceful shutdown behavior (#745) --- spec/config_spec.cr | 4 ++++ spec/run_spec.cr | 17 +++++++++++++++++ src/kemal.cr | 3 +++ src/kemal/config.cr | 3 ++- 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/spec/config_spec.cr b/spec/config_spec.cr index 7592bf3..b4a0af5 100644 --- a/spec/config_spec.cr +++ b/spec/config_spec.cr @@ -40,6 +40,10 @@ describe "Config" do config.shutdown_message.should be_true end + it "sets default shutdown timeout to zero" do + Kemal::Config.new.shutdown_timeout.should eq 0.seconds + end + it "adds custom options" do config = Kemal.config ARGV.push("--test") diff --git a/spec/run_spec.cr b/spec/run_spec.cr index d69813b..e14e1d4 100644 --- a/spec/run_spec.cr +++ b/spec/run_spec.cr @@ -42,6 +42,23 @@ describe "Run" do CR end + it "applies shutdown_timeout during graceful shutdown" do + output = run(<<-'CRYSTAL') + Kemal.config.shutdown_timeout = 30.milliseconds + start = Time.monotonic + + Kemal.run do + Kemal.stop + end + + elapsed_ms = (Time.monotonic - start).total_milliseconds + puts "elapsed_ms=#{elapsed_ms}" + CRYSTAL + + match = output.match!(/elapsed_ms=([0-9]+(?:\.[0-9]+)?)/) + match[1].to_f.should be >= 20.0 + end + it "allows custom HTTP::Server bind" do run(<<-CR).should contain "[test] Kemal is running in test mode." Kemal.run do |config| diff --git a/src/kemal.cr b/src/kemal.cr index 9a6d912..d567445 100644 --- a/src/kemal.cr +++ b/src/kemal.cr @@ -84,6 +84,9 @@ module Kemal if server = config.server server.close unless server.closed? config.running = false + if config.shutdown_timeout.positive? + sleep(config.shutdown_timeout) + end else raise "Cannot stop #{Kemal.config.app_name}: server instance is not set. Please ensure Kemal.run has been called before calling Kemal.stop." end diff --git a/src/kemal/config.cr b/src/kemal/config.cr index 33b6fc1..2d76602 100644 --- a/src/kemal/config.cr +++ b/src/kemal/config.cr @@ -22,7 +22,7 @@ module Kemal {% end %} property app_name, host_binding, ssl, port, env, public_folder, logging, running - property always_rescue, server : HTTP::Server?, extra_options, shutdown_message + property always_rescue, server : HTTP::Server?, extra_options, shutdown_message, shutdown_timeout property serve_static : (Bool | Hash(String, Bool)) property static_headers : (HTTP::Server::Context, String, File::Info ->)? property? powered_by_header : Bool = true @@ -44,6 +44,7 @@ module Kemal @default_handlers_setup = false @running = false @shutdown_message = true + @shutdown_timeout = 0.seconds @handler_position = 0 @max_route_cache_size = 1024 @max_request_body_size = 8 * 1024 * 1024 # 8MB From f2285f9e161ea171712fc6b73d559ab3cf91687c Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:22:15 +0300 Subject: [PATCH 71/72] Update CHANGELOG --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c410056..2382024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# 1.10.1 (24-03-2026) + +- Add `shutdown_timeout` configuration for graceful shutdown: after `Kemal.stop`, Kemal can wait before exit so in-flight work can finish [#745](https://github.com/kemalcr/kemal/pull/745). Thanks @sdogruyol :pray: + +```crystal +Kemal.config.shutdown_timeout = 10.seconds +``` + # 1.10.0 (03-03-2026) - Add modular `Kemal::Router` with namespaced routing, scoped filters, WebSocket support and flexible mounting while keeping the existing DSL fully compatible [#731](https://github.com/kemalcr/kemal/pull/731). Thanks @sdogruyol :pray: From 0b2d32d4992ea75e2520be46922edd9144507d1b Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:24:00 +0300 Subject: [PATCH 72/72] Bump version to 1.10.1 --- shard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shard.yml b/shard.yml index 6b0408b..e23257b 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: kemal -version: 1.10.0 +version: 1.10.1 authors: - Serdar Dogruyol