Compare commits

...

73 commits

Author SHA1 Message Date
8986f35184 Merge remote-tracking branch 'upstream/master' 2026-04-02 21:50:56 -03:00
Serdar Dogruyol
0b2d32d499 Bump version to 1.10.1 2026-03-24 14:24:00 +03:00
Serdar Dogruyol
f2285f9e16 Update CHANGELOG 2026-03-24 14:22:15 +03:00
Serdar Dogruyol - Sedo セド
990fdf2a42
Add shutdown_timeout configuration and implement graceful shutdown behavior (#745) 2026-03-19 20:15:57 +03:00
Serdar Dogruyol
af81c4c624 Fix typo in database connection object reference in MySQL example 2026-03-10 11:40:10 +03:00
Jesse Clark
01b4f5a37d
posgresql-db-example act on connection object (#743) 2026-03-10 11:39:08 +03:00
Serdar Dogruyol
1c7b33cfa6 Bump version to 1.10.0 2026-03-03 14:37:13 +03:00
Serdar Dogruyol
b06dd5023a Update CHANGELOG 2026-03-03 14:36:07 +03:00
Serdar Dogruyol - Sedo セド
f618eece5b
Add raw_body to ParamParser for multi-handler body access (#740) 2026-03-03 14:29:39 +03:00
Serdar Dogruyol
f45f6744c8 Clear EXCEPTION_HANDLERS during configuration reset 2026-03-03 13:57:39 +03:00
Serdar Dogruyol
7a1a07f733 Update documentation for OverrideMethodHandler to use INSTANCE constant 2026-03-01 21:55:48 +03:00
Serdar Dogruyol - Sedo セド
6c6270dee7
Fix OverrideMethodHandler route cache bug (#741) (#742) 2026-03-01 21:46:33 +03:00
Serdar Dogruyol - Sedo セド
89966f6f7b
Make route LRU cache concurrency-safe with Mutex (#739) 2026-02-17 12:01:52 +03:00
Serdar Dogruyol - Sedo セド
c09f0eaefb
Improve CLI SSL validation and expand CLI option parsing specs (#738) 2026-02-16 10:25:44 +03:00
Anton Maminov
1460c68d5f
ensures global wildcard filters always execute while keeping namespace filters isolated to their routes (#737) 2026-02-12 15:34:41 +03:00
Serdar Dogruyol
676a35f089 Add new features in version 1.10.0 to CHANGELOG 2026-02-09 12:53:18 +03:00
Serdar Dogruyol - Sedo セド
c65e6d1dd6
Add modular Router support (#731) 2026-02-09 11:33:22 +03:00
Anton Maminov
83ae194d7c
Add support to halt execution with a chained response (#736) 2026-02-06 14:13:24 +03:00
Serdar Dogruyol
33104b19f3 Refactor response methods in HTTP::Server to serialize content to a string before writing to the response 2026-02-05 16:05:56 +03:00
Serdar Dogruyol - Sedo セド
71f4e4528b
Improve response helpers: HTTP::Status, direct write, content_type param (#735) 2026-02-05 13:34:38 +03:00
Serdar Dogruyol - Sedo セド
3b45f93dae
Add use keyword for middleware registration with path-specific support (#734) 2026-02-05 13:30:16 +03:00
Serdar Dogruyol
669c867482 Update README to improve setup instructions by adding steps for creating a new Crystal application and clarifying the order of commands. 2026-02-03 14:08:17 +03:00
Serdar Dogruyol
80f84b12a3 Revise README to enhance key features section with clearer descriptions and improved formatting for better readability. 2026-02-03 13:27:54 +03:00
Serdar Dogruyol
8fba1c0edb Enhance README with philosophy section outlining Kemal's design principles 2026-02-03 13:19:22 +03:00
Serdar Dogruyol - Sedo セド
951be7524e
Add response helpers for JSON, HTML, text, XML, and status (#733) 2026-02-03 11:00:28 +03:00
Serdar Dogruyol
dafa4c9b44 Bump version to 1.9.0 2026-01-28 14:47:20 +03:00
Serdar Dogruyol
53cb26fad4 Update CHANGELOG 2026-01-28 14:45:50 +03:00
Serdar Dogruyol - Sedo セド
3c858bf24e
[Security] Limit maximum request body size to avoid DoS attacks (#730) 2026-01-26 10:48:41 +03:00
Sijawusz Pur Rahnama
1c5cc8c0f9
Ameba v1.7 (#729) 2026-01-11 13:18:57 +03:00
Serdar Dogruyol
8b6f282954 optimize JSON parameter parsing by directly using the request body IO. 2025-11-20 17:33:52 +03:00
Serdar Dogruyol
6731ed9271 add contributing guide, update README to reference it 2025-11-19 16:36:34 +03:00
Serdar Dogruyol
8205e8e817 Bump version to 1.8.0 2025-11-07 10:28:07 +03:00
Serdar Dogruyol
e4e04654a3 Update CHANGELOG 2025-11-07 10:27:02 +03:00
Serdar Dogruyol - Sedo セド
1643905df4
Enhance HEAD request handling by caching GET route lookups and optimize path construction using string interpolation for improved performance. (#728) 2025-11-04 14:33:35 +03:00
Serdar Dogruyol - Sedo セド
a5653eee92
Improve error messages (#726) 2025-11-03 12:32:58 +03:00
Serdar Dogruyol - Sedo セド
2648a32f27
Optimize route and websocket lookups by caching results to reduce redundant processing in the HTTP server context (#725) 2025-11-03 11:54:42 +03:00
Serdar Dogruyol
24609c1b31 Update README to change 'Sample Applications' to 'Example Applications' and fix link 2025-11-01 10:40:26 +03:00
Serdar Dogruyol - Sedo セド
d9352d5115
Replace full-flush Route cache with LRU and add a configurable max cache size (#724) 2025-10-31 19:11:20 +03:00
Serdar Dogruyol
c673b3960e Bump version to 1.7.3 2025-10-02 11:48:09 +03:00
Serdar Dogruyol
fa0034d007 Update CHANGELOG 2025-10-02 11:47:00 +03:00
Serdar Dogruyol
10ad40cbce Refactor server binding logic to improve readability and maintainability 2025-10-02 11:42:12 +03:00
Serdar Dogruyol
5bd65bcb73 improve run_spec, move Kemal configuration to run method and update server bind ports 2025-09-29 14:29:26 +03:00
Serdar Dogruyol
e684d2ec9f Fix ameba errors 2025-08-13 16:30:23 +03:00
Serdar Dogruyol
c60a8dcb81 Rename samples to examples. Add all code from cookbook 2025-08-13 16:20:05 +03:00
Serdar Dogruyol
f1a53520ac Bump version to 1.7.2 2025-08-04 17:15:11 +03:00
Serdar Dogruyol
6752511525 Update CHANGELOG 2025-08-04 17:14:45 +03:00
Serdar Dogruyol
72e7b9c902 Add kemal-session ref to README 2025-06-25 14:47:51 +03:00
Serdar Dogruyol - Sedo セド
5f87f549ec
Refactor server binding logic to avoid binding in test environment (#719) 2025-06-10 13:14:48 +03:00
syeopite
6eb9b6bafc
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.
2025-06-05 13:21:46 +03:00
Johannes Müller
f41025c2c4
Make use of helper methods in StaticFileHandler (#714) 2025-06-03 16:35:07 +03:00
kojix2
47263d4f19
Fix typos in spec files (#715) 2025-04-18 16:10:39 +03:00
Sefa Yıldız
1fdccb2786
Update CHANGELOG.md (#713)
🙏
2025-04-17 16:18:58 +03:00
Serdar Dogruyol
acd43a6f7f Bump version to 1.7.1 2025-04-14 16:53:34 +03:00
Serdar Dogruyol
fc1dfbbdad Update CHANGELOG 2025-04-14 16:50:46 +03:00
Serdar Dogruyol - Sedo セド
2408828c85
Improve Static File Handler to follow Crystal stdlib version (#711) 2025-04-14 16:48:52 +03:00
Serdar Dogruyol
610eb3d424 Fix CHANGELOG 2025-04-14 15:30:17 +03:00
Serdar Dogruyol
54fc02150b Bump version to 1.7.0 2025-04-14 14:43:23 +03:00
Serdar Dogruyol
085c3d703b Update CHANGELOG 2025-04-14 14:41:18 +03:00
Serdar Dogruyol
56e58fc4d6 Fix Path Traversal in StaticFileHandler, see https://packetstorm.news/files/id/190294/ 2025-04-14 14:25:46 +03:00
Serdar Dogruyol
95864868a0 Update run_spec to respect latest Log change 2025-04-03 18:19:11 +03:00
Serdar Dogruyol
d4c842e82d Remove depracated custom logger spec 2025-04-03 17:54:17 +03:00
Serdar Dogruyol - Sedo セド
fa4bf060b5
Add cleanup methods for file uploads and temporary files (#707) 2025-04-03 16:13:21 +03:00
Serdar Dogruyol - Sedo セド
0335e861aa
Add before_all filter execution for 404 errors in FilterHandler (#706) 2025-04-03 16:05:55 +03:00
Serdar Dogruyol - Sedo セド
e3de2ada63
Implement multiple partial ranges (#708) 2025-04-03 15:55:53 +03:00
Hugo Parente Lima
19d3913b5d
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.
2025-04-01 13:25:09 +03:00
Hugo Parente Lima
4352774968
Remove ameba developer dependency. (#704)
Since d4af7e216d 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
2025-03-22 14:15:57 +03:00
Hugo Parente Lima
d4af7e216d
Use ameba github actions. (#703)
Replace `format` and `ameba` jobs by a github action.
2025-03-17 20:41:35 +03:00
Todd Sundsted
75f9cd9c0e
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 <toddsundsted@dragoncon.local>
2025-02-13 11:46:01 +03:00
Serdar Dogruyol - Sedo セド
bf249fe507
Update README.md 2025-01-22 14:42:36 +03:00
Serdar Dogruyol - Sedo セド
369371bb83
Add all_files to support multiple file uploads in names ending with [] (#701) 2025-01-15 15:44:39 +03:00
Serdar Dogruyol
5359781a64 Improve README 2025-01-13 13:03:52 +03:00
syeopite
a9324bee6f
Fix Lint/UnusedArgument in custom exception spec (#699) 2024-12-20 10:46:06 +03:00
syeopite
6b884dd4ea
Add ability to add handlers for raised exceptions (#688)
Add ability to add handlers for raised exceptions. Closes #622
2024-12-19 16:14:25 +03:00
73 changed files with 3937 additions and 426 deletions

View file

@ -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

19
.github/workflows/ameba.yml vendored Normal file
View file

@ -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

View file

@ -30,47 +30,3 @@ 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
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: Install dependencies
run: shards install
- name: Run ameba linter
run: bin/ameba

View file

@ -1,3 +1,156 @@
# 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:
```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("<h1>Forbidden</h1>")
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
```
- 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:
- ***(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:
- 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:
- Improve 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:
- 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:
# 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](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)
- Crystal 1.14.0 support :tada:

59
CONTRIBUTING.md Normal file
View file

@ -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! 🚀

125
README.md
View file

@ -2,45 +2,32 @@
# Kemal
Lightning Fast, Super Simple web framework.
Kemal is the Fast, Effective, Simple Web Framework for Crystal. It's perfect for building Web Applications and APIs with minimal code.
**THIS IS A FORK OF KEMAL. DIRECT TO FORK.MD FOR SPECIFICS ON THIS FORK.**
[![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
2. Create a new Crystal application and step into it:
```bash
crystal init app my-kemal-app
cd my-kemal-app
```
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`:
3. Add Kemal to your app's `shard.yml`:
>>>>>>> upstream/master
```yaml
dependencies:
@ -48,22 +35,78 @@ dependencies:
github: kemalcr/kemal
```
See also [Getting Started](http://kemalcr.com/guide/).
4. Replace the contents of `src/my_kemal_app.cr` with 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).
5. Install dependencies and run your application:
```bash
shards install
crystal run src/my_kemal_app.cr
```
6. Visit [http://localhost:3000](http://localhost:3000) - That's it! 🎉
## Key Features
- 🚀 **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 serverrendered 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
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)
- 🚀 [Kemal Guide](http://kemalcr.com/guide/)
- 💬 [Community Chat](https://discord.gg/prSVAZJEpz)
## Contributing
We love contributions! Please read our [Contributing Guide](CONTRIBUTING.md) to get started.
## Acknowledgments
Special thanks to Manas for their work on [Frank](https://github.com/manastech/frank).
## License
Kemal is released under the MIT License.

67
examples/cookies/app.cr Normal file
View file

@ -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 << "<h1>Current Cookies:</h1>"
str << "<ul>"
cookies.each do |cookie|
str << "<li>#{cookie.name}: #{cookie.value}</li>"
end
str << "</ul>"
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

17
examples/cors/app.cr Normal file
View file

@ -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

View file

@ -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

View file

@ -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
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
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(uploaded_file_path, "w") do |file|
IO.copy(uploaded_file, file)
end
# Return a simple success message
"Upload ok"
end
# Start the Kemal server
Kemal.run

View file

@ -0,0 +1,7 @@
require "kemal"
get "/" do
"Hello Kemal!"
end
Kemal.run

View file

@ -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 |_|
# 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

View file

@ -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

72
examples/json-api/app.cr Normal file
View file

@ -0,0 +1,72 @@
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 |_|
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
# 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
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
# 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
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

View file

@ -0,0 +1,32 @@
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
# 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
{username: user.username, password: user.password}.to_json
end
# Start the Kemal web server
Kemal.run

56
examples/mysql-db/app.cr Normal file
View file

@ -0,0 +1,56 @@
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 |_|
# 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 = DBC.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

View file

@ -0,0 +1,56 @@
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 |_|
# 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 = DBC.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

58
examples/redis/app.cr Normal file
View file

@ -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

View file

@ -0,0 +1,19 @@
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
# 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
# This is useful for load balancing across multiple worker processes
server.bind_tcp "0.0.0.0", 3000, reuse_port: true
end

View file

@ -0,0 +1,14 @@
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
# They are commonly used when the client and server are on the same host
server.bind_unix "path/to/socket.sock"
end

View file

@ -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

View file

@ -1,8 +0,0 @@
require "kemal"
# Set root. If not specified the default content_type is 'text'
get "/" do
"Hello Kemal!"
end
Kemal.run

View file

@ -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

View file

@ -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

View file

@ -1,5 +1,5 @@
name: kemal
version: 1.6.0
version: 1.10.1
authors:
- Serdar Dogruyol <dogruyolserdar@gmail.com>
@ -15,6 +15,7 @@ dependencies:
development_dependencies:
ameba:
github: crystal-ameba/ameba
branch: master
crystal: ">= 0.36.0"

View file

@ -2,4 +2,4 @@ Hello <%= name %>
<% content_for "meta" do %>
<title>Kemal Spec</title>
<% end %>
<% end %>

View file

@ -5,4 +5,4 @@
<body>
<%= content %>
</body>
</html>
</html>

View file

@ -7,4 +7,4 @@
<%= var1 %>
<%= var2 %>
</body>
</html>
</html>

90
spec/cli_spec.cr Normal file
View file

@ -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

View file

@ -29,15 +29,19 @@ 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
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 "sets default shutdown timeout to zero" do
Kemal::Config.new.shutdown_timeout.should eq 0.seconds
end
it "adds custom options" do

View file

@ -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

View file

@ -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 occurred"
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 occurred"
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 |_, 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"

View file

@ -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

View file

@ -9,38 +9,23 @@ 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 8
Kemal.config.handlers.size.should eq 7
end
end
describe "#logging" do
it "sets logging status" 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)
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"
@ -61,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
@ -79,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
@ -145,29 +202,139 @@ 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
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
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 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
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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
@ -201,4 +201,127 @@ describe "ParamParser" do
body_params.to_s.should eq("")
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
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

265
spec/path_handler_spec.cr Normal file
View file

@ -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

View file

@ -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

View file

@ -0,0 +1,245 @@
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
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
it "sets content-type to text/html" do
get "/html-test" do |env|
env.html("<h1>Hello</h1>")
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("<div>Content</div>")
end
request = HTTP::Request.new("GET", "/html-content")
client_response = call_request_on_app(request)
client_response.body.should eq("<div>Content</div>")
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("<root></root>")
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(%(<?xml version="1.0"?><rss><channel></channel></rss>))
end
request = HTTP::Request.new("GET", "/xml-content")
client_response = call_request_on_app(request)
client_response.body.should eq(%(<?xml version="1.0"?><rss><channel></channel></rss>))
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("<h1>Not Found</h1>")
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("<error>Bad Request</error>")
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
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
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(%(<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>Blog</title></channel></rss>))
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("<rss")
end
end
end

View file

@ -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,6 +177,136 @@ 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
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
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

474
spec/router_spec.cr Normal file
View file

@ -0,0 +1,474 @@
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
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
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

View file

@ -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
@ -15,35 +19,56 @@ end
describe "Run" do
it "runs a code block after starting" do
run(<<-CR).should eq "started\nstopped\n"
Kemal.config.env = "test"
run(<<-CR).should contain("started")
Kemal.run do
log "started"
end
CR
end
it "runs a code block after stopping" do
run(<<-CR).should contain("stopped")
Kemal.run do
puts "started"
Kemal.stop
puts "stopped"
log "stopped"
end
CR
end
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
puts Kemal.config.running
Kemal.config.running
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.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

View file

@ -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)
@ -87,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

View file

@ -0,0 +1,2 @@
hello
world

View file

@ -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
@ -97,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
@ -112,36 +113,34 @@ describe Kemal::StaticFileHandler do
end
it "should send part of files when requested (RFC7233)" do
%w(POST PUT DELETE HEAD).each do |method|
headers = HTTP::Headers{"Range" => "0-100"}
%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|
headers = HTTP::Headers{"Range" => "0-100"}
%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 || 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 be_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 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
@ -159,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(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(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(302)
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

View file

@ -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")

View file

@ -1,24 +1,27 @@
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) { }
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.
@ -46,41 +49,46 @@ module Kemal
yield config
# Abort if block called `Kemal.stop`
return unless config.running
return if !config.running
unless 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)
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
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
if config.shutdown_timeout.positive?
sleep(config.shutdown_timeout)
end
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
@ -94,7 +102,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

View file

@ -42,11 +42,11 @@ 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.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 %}

View file

@ -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(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?
@ -21,10 +22,12 @@ 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 -> Void)?
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"
@ -41,13 +44,23 @@ 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
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
@ -61,10 +74,13 @@ module Kemal
@router_included = false
@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
ERROR_HANDLERS.clear
EXCEPTION_HANDLERS.clear
end
def handlers
@ -88,14 +104,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
@ -121,12 +149,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
@ -137,8 +164,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

View file

@ -1,43 +1,195 @@
# 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 head)
FILTER_METHODS = %w(get post put patch delete options head 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)
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)
# 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 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
# 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 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
# 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)
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)
Kemal::FilterHandler::INSTANCE.{{ type.id }}({{ method }}.upcase, path, &block)
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
# 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

View file

@ -10,13 +10,44 @@ 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
log("Exception: #{ex.inspect_with_backtrace}")
# 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.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
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)

View file

@ -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,31 @@ 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
# 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
# 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?
@ -63,5 +83,99 @@ class HTTP::Server
def get?(name : String)
@store[name]?
end
# Sets the response status code and returns self for chaining.
#
# ```
# get "/users/:id" do |env|
# if user = User.find?(env.params.url["id"])
# env.json(user)
# else
# env.status(404).json({error: "User not found"})
# end
# end
# ```
def status(code : Int32) : self
@response.status_code = code
self
end
# 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 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).
#
# ```
# get "/users" do |env|
# env.json({users: ["alice", "bob"]})
# end
#
# 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, *, content_type : String = "application/json") : Nil
@response.content_type = content_type
@response << data.to_json
end
# Sends an HTML response with the proper `Content-Type` header.
# Serializes the content to a string and writes it to the response in a single operation.
#
# ```
# get "/" do |env|
# env.html("<h1>Welcome</h1>")
# end
# ```
def html(content : String, *, content_type : String = "text/html; charset=utf-8") : Nil
@response.content_type = content_type
@response << content
end
# Sends a plain text response with the proper `Content-Type` header.
# Serializes the content to a string and writes it to the response in a single operation.
#
# ```
# get "/health" do |env|
# env.text("OK")
# end
# ```
def text(content : String, *, content_type : String = "text/plain; charset=utf-8") : Nil
@response.content_type = content_type
@response << content
end
# Sends an XML response with the proper `Content-Type` header.
# Serializes the content to a string and writes it to the response in a single operation.
#
# ```
# get "/feed.xml" do |env|
# env.xml("<?xml version=\"1.0\"?><rss>...</rss>")
# end
# ```
def xml(content : String, *, content_type : String = "application/xml; charset=utf-8") : Nil
@response.content_type = content_type
@response << content
end
end
end

View file

@ -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

View file

@ -3,17 +3,41 @@ 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
# 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)
@ -25,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
@ -51,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

View file

@ -1,27 +1,34 @@
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}}
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
@ -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

View file

@ -32,7 +32,7 @@ module Kemal
<p>Something wrong with the server :(</p>
</body>
</html>
HTML
HTML
end
end
end

View file

@ -18,4 +18,10 @@ module Kemal::Exceptions
super message
end
end
class PayloadTooLarge < Exception
def initialize
super "Payload Too Large"
end
end
end

View file

@ -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
@ -32,8 +34,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 +78,7 @@ end
# ```
# logger MyCustomLogger.new
# ```
@[Deprecated("Use standard library Log")]
def logger(logger : Kemal::BaseLogHandler)
Kemal.config.logger = logger
end
@ -205,31 +214,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)
@ -244,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`.
@ -257,6 +303,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

View file

@ -34,15 +34,15 @@ CONTENT_FOR_BLOCKS = Hash(String, Tuple(String, Proc(Nil))).new
# layout, inside the <head> 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,45 @@ 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
# 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.
@ -81,9 +108,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

File diff suppressed because one or more lines are too long

View file

@ -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

View file

@ -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)

View file

@ -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::INSTANCE
# ```
#
# **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)

View file

@ -6,21 +6,61 @@ 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)
getter files
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)
@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
@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
return if @files.empty? && @all_files.empty?
@files.each_value &.cleanup
@all_files.each_value do |file_uploads|
file_uploads.each &.cleanup
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)
@ -30,14 +70,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 %}
@ -46,8 +86,10 @@ module Kemal
return unless content_type
validate_content_length!
if content_type.try(&.starts_with?(URL_ENCODED_FORM))
@body = parse_part(@request.body)
@body = parse_part(raw_body)
return
end
@ -67,15 +109,23 @@ module Kemal
private def parse_files
return if @files_parsed
validate_content_length!
HTTP::FormData.parse(@request) do |upload|
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
@ -87,10 +137,12 @@ 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 @request.headers["Content-Type"]?.try(&.starts_with?(APPLICATION_JSON))
body = @request.body.not_nil!.gets_to_end
case json = JSON.parse(body).raw
body_str = raw_body
return if body_str.empty?
case json = JSON.parse(body_str).raw
when Hash
json.each do |key, value|
@json[key] = value.raw
@ -103,11 +155,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

46
src/kemal/path_handler.cr Normal file
View file

@ -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

View file

@ -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

View file

@ -1,16 +1,122 @@
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
property routes, cached_routes
INSTANCE = new
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 = Hash(String, Radix::Result(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)
@ -23,23 +129,29 @@ 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[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)
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?
@cached_routes.clear if @cached_routes.size == CACHED_ROUTES_LIMIT
@cached_routes[lookup_path] = route
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?
@cache_mutex.synchronize { @cached_routes.put(lookup_path, get_route) }
end
route = get_route
elsif route.found?
@cache_mutex.synchronize { @cached_routes.put(lookup_path, route) }
end
route
@ -56,11 +168,14 @@ module Kemal
end
context.response.print(content)
context
ensure
context.params.cleanup_temporary_files
end
private def radix_path(method, path)
'/' + method + path
"/#{method}#{path}"
end
private def add_to_radix_tree(method, path, route)

304
src/kemal/router.cr Normal file
View file

@ -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

View file

@ -1,53 +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")
{% 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
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
return
end
config = Kemal.config.serve_static
original_path = context.request.path.not_nil!
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
return
# 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! == "/"
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
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
file_path = File.join(@public_dir, expanded_path)
is_dir = Dir.exists?(file_path)
original_path = context.request.path.not_nil!
is_dir_path = original_path.ends_with?("/")
request_path = URI.decode(original_path)
if request_path != expanded_path
redirect_to context, expanded_path
elsif is_dir && !is_dir_path
redirect_to context, expanded_path + '/'
end
# 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
if is_dir
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")
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)
@ -55,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 File.exists?(file_path)
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)
else
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

View file

@ -6,7 +6,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 error(code : Int16, message : String)

View file

@ -38,7 +38,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