Compare commits

..

No commits in common. "8986f3518463feacc60f1f54c5f95664bf235bc9" and "3248706eb23557faf778c2dbc5f0ef2080f6c878" have entirely different histories.

73 changed files with 424 additions and 3935 deletions

24
.ameba.yml Normal file
View file

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

View file

@ -1,19 +0,0 @@
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,3 +30,47 @@ 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,156 +1,3 @@
# 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:

View file

@ -1,59 +0,0 @@
# 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,32 +2,45 @@
# Kemal
Kemal is the Fast, Effective, Simple Web Framework for Crystal. It's perfect for building Web Applications and APIs with minimal code.
Lightning Fast, Super Simple web framework.
**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)
## Why Kemal?
# Super Simple ⚡️
- 🚀 **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
```ruby
require "kemal"
## Quick Start
# Matches GET "http://host:port/"
get "/" do
"Hello World!"
end
1. First, make sure you have [Crystal installed](https://crystal-lang.org/install/).
# Creates a WebSocket handler.
# Matches "ws://host:port/socket"
ws "/socket" do |socket|
socket.send "Hello from Kemal!"
end
2. Create a new Crystal application and step into it:
```bash
crystal init app my-kemal-app
cd my-kemal-app
Kemal.run
```
3. Add Kemal to your app's `shard.yml`:
>>>>>>> upstream/master
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`:
```yaml
dependencies:
@ -35,78 +48,22 @@ dependencies:
github: kemalcr/kemal
```
4. Replace the contents of `src/my_kemal_app.cr` with your first Kemal app:
See also [Getting Started](http://kemalcr.com/guide/).
```crystal
require "kemal"
# Features
# Basic route - responds to GET "http://localhost:3000/"
get "/" do
"Hello World!"
end
- 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)
# JSON API example
get "/api/status" do |env|
env.response.content_type = "application/json"
{"status": "ok"}.to_json
end
# Documentation
# WebSocket support
ws "/chat" do |socket|
socket.send "Hello from Kemal WebSocket!"
end
You can read the documentation at the official site [kemalcr.com](http://kemalcr.com)
Kemal.run
```
## Thanks
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.
Thanks to Manas for their awesome work on [Frank](https://github.com/manastech/frank).

View file

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

View file

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

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

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

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

View file

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

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

View file

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

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

View file

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

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

View file

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

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

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

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

8
samples/hello_world.cr Normal file
View file

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

11
samples/json_api.cr Normal file
View file

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

@ -0,0 +1,11 @@
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.10.1
version: 1.6.0
authors:
- Serdar Dogruyol <dogruyolserdar@gmail.com>
@ -15,7 +15,6 @@ dependencies:
development_dependencies:
ameba:
github: crystal-ameba/ameba
branch: master
crystal: ">= 0.36.0"

View file

@ -1,90 +0,0 @@
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,19 +29,15 @@ describe "Config" do
config = Kemal.config
config.add_handler CustomTestHandler.new
Kemal.config.setup
config.handlers.size.should eq(7)
config.handlers.size.should eq(8)
end
it "toggles the shutdown message" do
config = Kemal.config
config.shutdown_message = false
config.shutdown_message.should be_false
config.shutdown_message.should eq false
config.shutdown_message = 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
config.shutdown_message.should eq true
end
it "adds custom options" do

View file

@ -104,29 +104,4 @@ 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,99 +59,6 @@ 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
use CustomTestHandler.new
add_handler CustomTestHandler.new
get "/" do
" Great"
@ -92,7 +92,7 @@ describe "Handler" do
get "/only" do
"Get"
end
use OnlyHandler.new
add_handler 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
use ExcludeHandler.new
add_handler 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
use PostOnlyHandler.new
add_handler 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
use PostOnlyHandler.new
use PostExcludeHandler.new
add_handler PostOnlyHandler.new
add_handler 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
use post_handler, position: 1
add_handler post_handler, 1
Kemal.config.setup
Kemal.config.handlers[1].should eq post_handler
end

View file

@ -9,23 +9,38 @@ describe "Macros" do
end
end
describe "#use" do
describe "#add_handler" do
it "adds a custom handler" do
use CustomTestHandler.new
add_handler CustomTestHandler.new
Kemal.config.setup
Kemal.config.handlers.size.should eq 7
Kemal.config.handlers.size.should eq 8
end
end
describe "#logging" do
it "sets logging status" do
logging false
Kemal.config.logging.should be_false
Kemal.config.logging.should eq false
end
it "sets a custom logger" do
config = Kemal::Config::INSTANCE
logger CustomLogHandler.new
config.logger.should be_a(CustomLogHandler)
end
end
describe "#halt" do
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"
@ -46,61 +61,6 @@ 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
@ -119,23 +79,6 @@ 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
@ -202,139 +145,29 @@ 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[4].should be_a(HTTP::CompressHandler)
Kemal.config.handlers[5].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 be_false
Kemal.config.serve_static.should eq false
end
it "should enable gzip and dir_listing" do
it "should disble enable gzip and dir_listing" do
serve_static({"gzip" => true, "dir_listing" => true})
conf = Kemal.config.serve_static
conf.is_a?(Hash).should be_true
conf.is_a?(Hash).should eq true
if conf.is_a?(Hash)
conf["gzip"].should be_true
conf["dir_listing"].should be_true
conf["gzip"].should eq true
conf["dir_listing"].should eq true
end
end
end

View file

@ -1,6 +1,13 @@
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,22 +207,6 @@ 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,48 +26,4 @@ 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 => String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any)?)
json_params.should eq({} of String => Nil | String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any))
end
end
@ -201,127 +201,4 @@ 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

View file

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

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

@ -1,245 +0,0 @@
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 be_true
client_response.headers.has_key?("Location").should eq(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 be_true
client_response.headers.has_key?("Location").should eq(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 be_true
client_response.headers.has_key?("Location").should eq(true)
end
it "redirects in before filter without closing response" do
@ -177,136 +177,6 @@ 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 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
client_response.headers.has_key?("Location").should eq(true)
end
end

View file

@ -1,474 +0,0 @@
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,10 +3,6 @@ require "./spec_helper"
private def run(code)
code = <<-CR
require "./src/kemal"
Kemal.config.env = "test"
Kemal.config.port = 8000
#{code}
CR
@ -19,56 +15,35 @@ end
describe "Run" do
it "runs a code block after starting" do
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")
run(<<-CR).should eq "started\nstopped\n"
Kemal.config.env = "test"
Kemal.run do
puts "started"
Kemal.stop
log "stopped"
puts "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
Kemal.config.running
puts 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", 8000
server.bind_tcp "127.0.0.1", 3000
{% else %}
server.bind_tcp "127.0.0.1", 8000, reuse_port: true
server.bind_tcp "0.0.0.0", 8001, reuse_port: true
server.bind_tcp "127.0.0.1", 3000, reuse_port: true
server.bind_tcp "0.0.0.0", 3001, reuse_port: true
{% end %}
end
CR

View file

@ -26,12 +26,6 @@ class AnotherContextStorageType
@name = "kemal-context"
end
class CustomExceptionType < Exception
end
class ChildCustomExceptionType < CustomExceptionType
end
add_context_storage_type(TestContextStorageType)
add_context_storage_type(AnotherContextStorageType)
@ -93,6 +87,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 = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(Kemal.config.max_route_cache_size)
Kemal::RouteHandler::INSTANCE.cached_routes = Hash(String, Radix::Result(Route)).new
Kemal::WebSocketHandler::INSTANCE.routes = Radix::Tree(WebSocket).new
end

View file

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

View file

@ -13,7 +13,6 @@ 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
@ -98,12 +97,12 @@ describe Kemal::StaticFileHandler do
end
it "should handle only GET and HEAD method" do
%w[GET HEAD].each do |method|
%w(GET HEAD).each do |method|
response = handle HTTP::Request.new(method, "/dir/test.txt")
response.status_code.should eq(200)
end
%w[POST PUT DELETE].each do |method|
%w(POST PUT DELETE).each do |method|
response = handle HTTP::Request.new(method, "/dir/test.txt")
response.status_code.should eq(404)
response = handle HTTP::Request.new(method, "/dir/test.txt"), false
@ -113,18 +112,19 @@ 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" => "bytes=0-4"}
%w(POST PUT DELETE HEAD).each do |method|
headers = HTTP::Headers{"Range" => "0-100"}
response = handle HTTP::Request.new(method, "/dir/test.txt", headers)
response.status_code.should_not eq(206)
response.headers.has_key?("Content-Range").should be_false
response.headers.has_key?("Content-Range").should eq(false)
end
%w[GET].each do |method|
headers = HTTP::Headers{"Range" => "bytes=0-4"}
%w(GET).each do |method|
headers = HTTP::Headers{"Range" => "0-100"}
response = handle HTTP::Request.new(method, "/dir/test.txt", headers)
response.status_code.should eq(206)
response.headers.has_key?("Content-Range").should be_true
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
@ -133,14 +133,15 @@ describe Kemal::StaticFileHandler do
range_size = match[3].to_i { 0 }
range_size.should eq file_size
(end_range < file_size).should be_true
(start_range < end_range).should be_true
(end_range < file_size).should eq true
(start_range < end_range).should eq true
end
end
end
end
it "should handle setting custom headers" do
headers = Proc(HTTP::Server::Context, String, File::Info, Nil).new do |env, path, stat|
headers = Proc(HTTP::Server::Context, String, File::Info, Void).new do |env, path, stat|
if path =~ /\.html$/
env.response.headers.add("Access-Control-Allow-Origin", "*")
end
@ -158,30 +159,4 @@ 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

@ -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" # ameba:disable Lint/UselessAssign
var2 = "kemal" # ameba:disable Lint/UselessAssign
var1 = "serdar"
var2 = "kemal"
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,27 +1,24 @@
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)
run(port, args, trap_signal) { }
self.run(port, args, trap_signal) { }
end
# Overload of `self.run` without port.
def self.run(args = ARGV, trap_signal : Bool = true)
run(nil, args: args, trap_signal: trap_signal)
self.run(nil, args: args, trap_signal: trap_signal)
end
# Overload of `self.run` to allow just a block.
def self.run(args = ARGV, &block)
run(nil, args: args, trap_signal: true, &block)
self.run(nil, args: args, trap_signal: true, &block)
end
# The command to run a `Kemal` application.
@ -49,10 +46,9 @@ module Kemal
yield config
# Abort if block called `Kemal.stop`
return if !config.running
return unless config.running
if config.env != "test"
if !server.each_address { |_| break true }
unless server.each_address { |_| break true }
{% if flag?(:without_openssl) %}
server.bind_tcp(config.host_binding, config.port)
{% else %}
@ -63,32 +59,28 @@ module Kemal
end
{% end %}
end
end
display_startup_message(config, server)
server.listen if config.env != "test"
server.listen unless config.env == "test"
end
def self.display_startup_message(config, server)
if config.env != "test"
addresses = server.addresses.join ", " { |address| "#{config.scheme}://#{address}" }
Log.info { "[#{config.env}] #{config.app_name} is ready to lead at #{addresses}" }
log "[#{config.env}] #{config.app_name} is ready to lead at #{addresses}"
else
Log.info { "[#{config.env}] #{config.app_name} is running in test mode. Server not listening" }
log "[#{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. Cannot stop an already stopped server." if !config.running
raise "#{Kemal.config.app_name} is already stopped." 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 "Cannot stop #{Kemal.config.app_name}: server instance is not set. Please ensure Kemal.run has been called before calling Kemal.stop."
raise "Kemal.config.server is not set. Please use Kemal.run to set the server."
end
end
@ -102,7 +94,7 @@ module Kemal
private def self.setup_trap_signal
Process.on_terminate do
Log.info { "#{Kemal.config.app_name} is going to take a rest!" } if Kemal.config.shutdown_message
log "#{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 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?
abort "SSL Key Not Found" if !@key_file
abort "SSL Certificate Not Found" if !@cert_file
ssl = Kemal::SSL.new
ssl.key_file = @key_file
ssl.cert_file = @cert_file
ssl.key_file = @key_file.not_nil!
ssl.cert_file = @cert_file.not_nil!
Kemal.config.ssl = ssl.context
end
{% end %}

View file

@ -10,10 +10,9 @@ module Kemal
class Config
INSTANCE = Config.new
HANDLERS = [] of HTTP::Handler
CUSTOM_HANDLERS = [] of Tuple(Int32?, HTTP::Handler)
CUSTOM_HANDLERS = [] of Tuple(Nil | Int32, HTTP::Handler)
FILTER_HANDLERS = [] of HTTP::Handler
ERROR_HANDLERS = {} of Int32 => HTTP::Server::Context, Exception -> String
EXCEPTION_HANDLERS = {} of Exception.class => HTTP::Server::Context, Exception -> String
{% if flag?(:without_openssl) %}
@ssl : Bool?
@ -22,12 +21,10 @@ 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, shutdown_timeout
property always_rescue, server : HTTP::Server?, extra_options, shutdown_message
property serve_static : (Bool | Hash(String, Bool))
property static_headers : (HTTP::Server::Context, String, File::Info ->)?
property static_headers : (HTTP::Server::Context, String, File::Info -> Void)?
property? powered_by_header : Bool = true
property max_route_cache_size : Int32
property max_request_body_size : Int32
def initialize
@app_name = "Kemal"
@ -44,23 +41,13 @@ 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 || NullLogHandler.new
@logger.not_nil!
end
# :nodoc:
def logger?
@logger
end
@[Deprecated("Use standard library Log")]
def logger=(logger : Kemal::BaseLogHandler)
@logger = logger
end
@ -74,13 +61,10 @@ 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
@ -104,26 +88,14 @@ 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
@ -149,11 +121,12 @@ module Kemal
end
private def setup_log_handler
return unless @logging
log_handler = @logger || Kemal::RequestLogHandler.new
HANDLERS.insert(@handler_position, log_handler)
@logger ||= if @logging
Kemal::LogHandler.new
else
Kemal::NullLogHandler.new
end
HANDLERS.insert(@handler_position, @logger.not_nil!)
@handler_position += 1
end
@ -164,8 +137,8 @@ module Kemal
private def setup_error_handler
if @always_rescue
handler = @error_handler ||= Kemal::ExceptionHandler.new
HANDLERS.insert(@handler_position, handler)
@error_handler ||= Kemal::ExceptionHandler.new
HANDLERS.insert(@handler_position, @error_handler.not_nil!)
@handler_position += 1
end
end

View file

@ -1,29 +1,14 @@
# Kemal DSL is defined here and it's baked into global scope.
# These methods are available globally in your application.
#
# ## Available DSL Methods
# The DSL currently consists of:
#
# - **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`
# - get post put patch delete options
# - WebSocket(ws)
# - before_*
# - error
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)
@ -31,165 +16,28 @@ FILTER_METHODS = %w(get post put patch delete options head all)
end
{% end %}
# Defines a WebSocket route.
#
# NOTE: The path must start with a `/`.
#
# ```
# ws "/chat" do |socket, env|
# socket.on_message do |msg|
# socket.send "Echo: #{msg}"
# end
# end
# ```
def ws(path : String, &block : HTTP::WebSocket, HTTP::Server::Context ->)
def ws(path : String, &block : HTTP::WebSocket, HTTP::Server::Context -> Void)
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
# 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
# ```
# 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
{% for type in ["before", "after"] %}
{% for method in FILTER_METHODS %}
def {{type.id}}_{{method.id}}(path : String = "*", &block : HTTP::Server::Context -> _)
Kemal::FilterHandler::INSTANCE.{{type.id}}({{method}}.upcase, path, &block)
end
def {{ type.id }}_{{ method.id }}(paths : Enumerable(String), &block : HTTP::Server::Context -> _)
def {{type.id}}_{{method.id}}(paths : Array(String), &block : HTTP::Server::Context -> _)
paths.each do |path|
Kemal::FilterHandler::INSTANCE.{{type.id}}({{method}}.upcase, path, &block)
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,44 +10,13 @@ module Kemal
call_exception_with_status_code(context, ex, 404)
rescue ex : Kemal::Exceptions::CustomException
call_exception_with_status_code(context, ex, context.response.status_code)
rescue ex : Kemal::Exceptions::PayloadTooLarge
call_exception_with_status_code(context, ex, 413)
rescue ex : Exception
# Matches an error handler for the given exception
#
# 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
log("Exception: #{ex.inspect_with_backtrace}")
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,16 +11,11 @@ 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
ws_lookup = ws_route_lookup
if ws_lookup.found?
@params ||= Kemal::ParamParser.new(@request, ws_lookup.params)
if ws_route_found?
@params ||= Kemal::ParamParser.new(@request, ws_route_lookup.params)
else
@params ||= Kemal::ParamParser.new(@request, route_lookup.params)
end
@ -41,31 +36,16 @@ 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
@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
Kemal::RouteHandler::INSTANCE.lookup_route(@request.method.as(String), @request.path)
end
def route_found?
route_lookup.found?
end
# Optimized: Cache websocket route lookup result to avoid redundant lookups
def ws_route_lookup
@cached_ws_route_lookup ||= Kemal::WebSocketHandler::INSTANCE.lookup_ws_route(@request.path)
Kemal::WebSocketHandler::INSTANCE.lookup_ws_route(@request.path)
end
def ws_route_found?
@ -83,99 +63,5 @@ 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,10 +20,5 @@ 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,41 +3,17 @@ module Kemal
class FilterHandler
include HTTP::Handler
INSTANCE = new
# 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
property tree
# 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)
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
return call_next(context) unless context.route_found?
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)
@ -49,21 +25,15 @@ 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.
#
# 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.
# :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.
def _add_route_filter(verb : String, path, type, &block : HTTP::Server::Context -> _)
key = radix_path(verb, path, type)
if filters = @exact_filters[key]?
filters << FilterBlock.new(&block)
lookup = lookup_filters_for_path_type(verb, path, type)
if lookup.found? && lookup.payload.is_a?(Array(FilterBlock))
lookup.payload << FilterBlock.new(&block)
else
filters = [FilterBlock.new(&block)]
@exact_filters[key] = filters
@tree.add key, filters
@tree.add radix_path(verb, path, type), [FilterBlock.new(&block)]
end
end
@ -81,24 +51,8 @@ module Kemal
_add_route_filter verb, path, :after, &block
end
# 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.
# This will fetch the block for the verb/path/type from the tree and call it.
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,20 +1,13 @@
module Kemal
# Kemal::HandlerInterface provides helpful methods for use in middleware creation
# `Kemal::Handler` is a subclass of `HTTP::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
# It adds `only`, `only_match?`, `exclude`, `exclude_match?`.
# These methods are useful for the conditional execution of custom handlers .
class Handler
include HTTP::Handler
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}}
@ -82,13 +75,4 @@ 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

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

View file

@ -15,12 +15,10 @@ 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
@ -34,14 +32,8 @@ 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)
logger = Kemal.config.logger?
if logger
logger.write "#{message}\n"
else
Log.info { message }
end
Kemal.config.logger.write "#{message}\n"
end
# Enables / Disables logging.
@ -78,7 +70,6 @@ end
# ```
# logger MyCustomLogger.new
# ```
@[Deprecated("Use standard library Log")]
def logger(logger : Kemal::BaseLogHandler)
Kemal.config.logger = logger
end
@ -214,66 +205,29 @@ end
private def multipart(file, env : HTTP::Server::Context)
# See http://httpwg.org/specs/rfc7233.html
fileb = file.size
ranges = parse_ranges(env.request.headers["Range"]?, fileb)
startb = endb = 0_i64
if ranges.empty?
env.response.content_length = fileb
env.response.status_code = 200 # Range not satisfiable
IO.copy(file, env.response)
return
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
end
if ranges.size == 1
# Single range - send as regular partial content
startb, endb = ranges[0]
endb = fileb - 1 if endb == 0
if startb < endb < fileb
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}"
env.response.headers["Content-Range"] = "bytes #{startb}-#{endb}/#{fileb}" # MUST
file.seek(startb)
IO.copy(file, env.response, content_length)
else
# 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"
env.response.content_length = fileb
env.response.status_code = 200 # Range not satisfable, see 4.4 Note
IO.copy(file, env.response)
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,
@ -290,7 +244,7 @@ end
#
# Disabled by default.
def gzip(status : Bool = false)
use HTTP::CompressHandler.new if status
add_handler HTTP::CompressHandler.new if status
end
# Adds headers to `Kemal::StaticFileHandler`. This is especially useful for `CORS`.
@ -303,6 +257,6 @@ end
# env.response.headers.add("Content-Size", filestat.size.to_s)
# end
# ```
def static_headers(&headers : HTTP::Server::Context, String, File::Info ->)
def static_headers(&headers : HTTP::Server::Context, String, File::Info -> Void)
Kemal.config.static_headers = headers
end

View file

@ -74,33 +74,6 @@ macro render(filename)
ECR.render({{filename}})
end
# Halts execution by closing the response. Designed for use with chained response method calls.
#
# ```
# # Example: Send a JSON error and halt immediately
# halt env.status(500).json({error: "Internal Server Error"})
#
# # Example: Immediately close and halt after rendering HTML
# halt env.status(403).html("Forbidden")
# ```
#
# NOTE: For most cases that require setting a specific status code and body, prefer the alternative:
#
# ```
# halt env, status_code: 403, response: "Forbidden"
# ```
macro halt(response)
{% if response.is_a?(Call) && response.receiver %}
%env = {{ response.receiver }}
{{ response }}
%env.response.close
next
{% else %}
{{ response }}.response.close
next
{% end %}
end
# Halt execution with the current context.
# Returns 200 and an empty response by default.
#

View file

@ -1,6 +1,5 @@
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,6 +1,5 @@
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
# use Kemal::OverrideMethodHandler::INSTANCE
# add_handler Kemal::OverrideMethodHandler
# ```
#
# **Important:** This middleware consumes `params.body` to read the `_method` magic parameter.
@ -21,7 +21,6 @@ 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,61 +6,21 @@ 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 = String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any)?
getter files, all_files
alias AllParamTypes = Nil | String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any)
getter 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)
@ -86,10 +46,8 @@ module Kemal
return unless content_type
validate_content_length!
if content_type.try(&.starts_with?(URL_ENCODED_FORM))
@body = parse_part(raw_body)
@body = parse_part(@request.body)
return
end
@ -109,23 +67,15 @@ 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?
if name.ends_with?("[]")
@all_files[name] ||= [] of FileUpload
@all_files[name] << FileUpload.new(upload)
@files[upload.name] = FileUpload.new(upload)
else
@files[name] = FileUpload.new(upload)
end
else
@body.add(name, upload.body.gets_to_end)
@body.add(upload.name, upload.body.gets_to_end)
end
end
@ -137,12 +87,10 @@ module Kemal
# - If request body is a JSON `Hash` then all the params are parsed and added into `params`.
# - If request body is a JSON `Array` it's added into `params` as `_json` and can be accessed like `params["_json"]`.
private def parse_json
return unless @request.headers["Content-Type"]?.try(&.starts_with?(APPLICATION_JSON))
return unless @request.body && @request.headers["Content-Type"]?.try(&.starts_with?(APPLICATION_JSON))
body_str = raw_body
return if body_str.empty?
case json = JSON.parse(body_str).raw
body = @request.body.not_nil!.gets_to_end
case json = JSON.parse(body).raw
when Hash
json.each do |key, value|
@json[key] = value.raw
@ -155,31 +103,11 @@ module Kemal
end
private def parse_part(part : IO?)
return HTTP::Params.new({} of String => Array(String)) unless part
body_str = read_body_with_limit(part)
HTTP::Params.parse(body_str)
HTTP::Params.parse(part ? part.gets_to_end : "")
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

View file

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

@ -1,20 +0,0 @@
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,122 +1,16 @@
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
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
CACHED_ROUTES_LIMIT = 1024
property routes, cached_routes
def initialize
@routes = Radix::Tree(Route).new
@cached_routes = LRUCache(String, Radix::Result(Route)).new(Kemal.config.max_route_cache_size)
@cache_mutex = Mutex.new
@cached_routes = Hash(String, Radix::Result(Route)).new
end
def call(context : HTTP::Server::Context)
@ -129,29 +23,23 @@ 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)
@cache_mutex.synchronize do
if cached_route = @cached_routes.get(lookup_path)
if cached_route = @cached_routes[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.
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) }
route = @routes.find(radix_path("GET", path))
end
route = get_route
elsif route.found?
@cache_mutex.synchronize { @cached_routes.put(lookup_path, route) }
if route.found?
@cached_routes.clear if @cached_routes.size == CACHED_ROUTES_LIMIT
@cached_routes[lookup_path] = route
end
route
@ -168,14 +56,11 @@ 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)

View file

@ -1,304 +0,0 @@
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,37 +1,6 @@
module Kemal
class StaticFileHandler < HTTP::StaticFileHandler
{% 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
end
# NOTE: This override opts out of some behaviour from HTTP::StaticFileHandler,
# such as serving content ranges.
private def serve_file(context : HTTP::Server::Context, file_info, file_path : Path, original_file_path : Path, last_modified : Time)
send_file(context, file_path.to_s)
end
{% else %}
# ameba:disable Metrics/CyclomaticComplexity
def call(context : HTTP::Server::Context)
return call_next(context) if context.request.path.not_nil! == "/"
@ -47,40 +16,35 @@ module Kemal
return
end
config = Kemal.config.serve_static
original_path = context.request.path.not_nil!
is_dir_path = original_path.ends_with?("/")
request_path = URI.decode(original_path)
# File path cannot contains '\0' (NUL) because all filesystem I know
# don't accept '\0' character as file name.
if request_path.includes? '\0'
context.response.respond_with_status(:bad_request)
context.response.status_code = 400
return
end
request_path = Path.posix(request_path)
expanded_path = request_path.expand("/")
file_path = @public_dir.join(expanded_path.to_kind(Path::Kind.native))
file_info = File.info? file_path
is_dir = @directory_listing && file_info && file_info.directory?
is_file = file_info && file_info.file?
if request_path != expanded_path || is_dir && !is_dir_path
redirect_path = expanded_path
if is_dir && !is_dir_path
# Append / to path if missing
redirect_path = expanded_path.join("")
end
redirect_to context, redirect_path
return
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
return call_next(context) unless file_info
file_path = File.join(@public_dir, expanded_path)
is_dir = Dir.exists?(file_path)
if request_path != expanded_path
redirect_to context, expanded_path
elsif is_dir && !is_dir_path
redirect_to context, expanded_path + '/'
end
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")
@ -98,7 +62,7 @@ module Kemal
else
call_next(context)
end
elsif is_file
elsif File.exists?(file_path)
last_modified = modification_time(file_path)
add_cache_headers(context.response.headers, last_modified)
@ -106,8 +70,8 @@ module Kemal
context.response.status_code = 304
return
end
send_file(context, file_path.to_s)
else # Not a normal file (FIFO/device/socket)
send_file(context, file_path)
else
call_next(context)
end
end
@ -115,6 +79,5 @@ module Kemal
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 ->)
def initialize(@path : String, &@proc : HTTP::WebSocket, HTTP::Server::Context -> Void)
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 ->)
def add_route(path : String, &handler : HTTP::WebSocket, HTTP::Server::Context -> Void)
add_to_radix_tree path, WebSocket.new(path, &handler)
end