Compare commits
73 commits
3248706eb2
...
8986f35184
| Author | SHA1 | Date | |
|---|---|---|---|
| 8986f35184 | |||
|
|
0b2d32d499 | ||
|
|
f2285f9e16 | ||
|
|
990fdf2a42 | ||
|
|
af81c4c624 | ||
|
|
01b4f5a37d | ||
|
|
1c7b33cfa6 | ||
|
|
b06dd5023a | ||
|
|
f618eece5b | ||
|
|
f45f6744c8 | ||
|
|
7a1a07f733 | ||
|
|
6c6270dee7 | ||
|
|
89966f6f7b | ||
|
|
c09f0eaefb | ||
|
|
1460c68d5f | ||
|
|
676a35f089 | ||
|
|
c65e6d1dd6 | ||
|
|
83ae194d7c | ||
|
|
33104b19f3 | ||
|
|
71f4e4528b | ||
|
|
3b45f93dae | ||
|
|
669c867482 | ||
|
|
80f84b12a3 | ||
|
|
8fba1c0edb | ||
|
|
951be7524e | ||
|
|
dafa4c9b44 | ||
|
|
53cb26fad4 | ||
|
|
3c858bf24e | ||
|
|
1c5cc8c0f9 | ||
|
|
8b6f282954 | ||
|
|
6731ed9271 | ||
|
|
8205e8e817 | ||
|
|
e4e04654a3 | ||
|
|
1643905df4 | ||
|
|
a5653eee92 | ||
|
|
2648a32f27 | ||
|
|
24609c1b31 | ||
|
|
d9352d5115 | ||
|
|
c673b3960e | ||
|
|
fa0034d007 | ||
|
|
10ad40cbce | ||
|
|
5bd65bcb73 | ||
|
|
e684d2ec9f | ||
|
|
c60a8dcb81 | ||
|
|
f1a53520ac | ||
|
|
6752511525 | ||
|
|
72e7b9c902 | ||
|
|
5f87f549ec | ||
|
|
6eb9b6bafc | ||
|
|
f41025c2c4 | ||
|
|
47263d4f19 | ||
|
|
1fdccb2786 | ||
|
|
acd43a6f7f | ||
|
|
fc1dfbbdad | ||
|
|
2408828c85 | ||
|
|
610eb3d424 | ||
|
|
54fc02150b | ||
|
|
085c3d703b | ||
|
|
56e58fc4d6 | ||
|
|
95864868a0 | ||
|
|
d4c842e82d | ||
|
|
fa4bf060b5 | ||
|
|
0335e861aa | ||
|
|
e3de2ada63 | ||
|
|
19d3913b5d | ||
|
|
4352774968 | ||
|
|
d4af7e216d | ||
|
|
75f9cd9c0e | ||
|
|
bf249fe507 | ||
|
|
369371bb83 | ||
|
|
5359781a64 | ||
|
|
a9324bee6f | ||
|
|
6b884dd4ea |
73 changed files with 3937 additions and 426 deletions
24
.ameba.yml
24
.ameba.yml
|
|
@ -1,24 +0,0 @@
|
||||||
# This configuration file was generated by `ameba --gen-config`
|
|
||||||
# on 2023-01-30 12:35:15 UTC using Ameba version 1.4.0.
|
|
||||||
# The point is for the user to remove these configuration records
|
|
||||||
# one by one as the reported problems are removed from the code base.
|
|
||||||
|
|
||||||
# Problems found: 2
|
|
||||||
# Run `ameba --only Lint/UselessAssign` for details
|
|
||||||
Lint/UselessAssign:
|
|
||||||
Description: Disallows useless variable assignments
|
|
||||||
Excluded:
|
|
||||||
- spec/view_spec.cr
|
|
||||||
Enabled: true
|
|
||||||
Severity: Warning
|
|
||||||
|
|
||||||
# Problems found: 6
|
|
||||||
# Run `ameba --only Lint/NotNil` for details
|
|
||||||
Lint/NotNil:
|
|
||||||
Description: Identifies usage of `not_nil!` calls
|
|
||||||
Excluded:
|
|
||||||
- src/kemal/param_parser.cr
|
|
||||||
- src/kemal/static_file_handler.cr
|
|
||||||
- src/kemal/config.cr
|
|
||||||
Enabled: true
|
|
||||||
Severity: Warning
|
|
||||||
19
.github/workflows/ameba.yml
vendored
Normal file
19
.github/workflows/ameba.yml
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
name: Ameba
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download source
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Run Ameba Linter
|
||||||
|
uses: crystal-ameba/github-action@master
|
||||||
44
.github/workflows/ci.yml
vendored
44
.github/workflows/ci.yml
vendored
|
|
@ -30,47 +30,3 @@ jobs:
|
||||||
- name: Run specs
|
- name: Run specs
|
||||||
run: |
|
run: |
|
||||||
crystal spec
|
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
|
|
||||||
|
|
||||||
153
CHANGELOG.md
153
CHANGELOG.md
|
|
@ -1,3 +1,156 @@
|
||||||
|
# 1.10.1 (24-03-2026)
|
||||||
|
|
||||||
|
- Add `shutdown_timeout` configuration for graceful shutdown: after `Kemal.stop`, Kemal can wait before exit so in-flight work can finish [#745](https://github.com/kemalcr/kemal/pull/745). Thanks @sdogruyol :pray:
|
||||||
|
|
||||||
|
```crystal
|
||||||
|
Kemal.config.shutdown_timeout = 10.seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
# 1.10.0 (03-03-2026)
|
||||||
|
|
||||||
|
- Add modular `Kemal::Router` with namespaced routing, scoped filters, WebSocket support and flexible mounting while keeping the existing DSL fully compatible [#731](https://github.com/kemalcr/kemal/pull/731). Thanks @sdogruyol :pray:
|
||||||
|
|
||||||
|
```crystal
|
||||||
|
require "kemal"
|
||||||
|
|
||||||
|
api = Kemal::Router.new
|
||||||
|
|
||||||
|
api.namespace "/users" do
|
||||||
|
get "/" do |env|
|
||||||
|
env.json({users: ["alice", "bob"]})
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/:id" do |env|
|
||||||
|
env.text "user #{env.params.url["id"]}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/api/v1", api
|
||||||
|
|
||||||
|
Kemal.run
|
||||||
|
```
|
||||||
|
|
||||||
|
- Add `use` keyword for registering global and path-specific middleware, including support for arrays and insertion at a specific position in the handler chain [#734](https://github.com/kemalcr/kemal/pull/734). Thanks @sdogruyol :pray:
|
||||||
|
|
||||||
|
```crystal
|
||||||
|
require "kemal"
|
||||||
|
|
||||||
|
# Path-specific middlewares for /api routes
|
||||||
|
use "/api", [CORSHandler.new, AuthHandler.new]
|
||||||
|
|
||||||
|
get "/" do
|
||||||
|
"Public home"
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/api/users" do |env|
|
||||||
|
env.json({users: ["alice", "bob"]})
|
||||||
|
end
|
||||||
|
|
||||||
|
Kemal.run
|
||||||
|
```
|
||||||
|
|
||||||
|
- Enhance response helpers to provide chainable JSON/HTML/text/XML helpers, `HTTP::Status` support and the ability to halt execution from a chained response for concise API error handling [#733](https://github.com/kemalcr/kemal/pull/733), [#735](https://github.com/kemalcr/kemal/pull/735), [#736](https://github.com/kemalcr/kemal/pull/736). Thanks @sdogruyol and @mamantoha :pray:
|
||||||
|
|
||||||
|
```crystal
|
||||||
|
require "kemal"
|
||||||
|
|
||||||
|
get "/users" do |env|
|
||||||
|
# Default JSON response
|
||||||
|
env.json({users: ["alice", "bob"]})
|
||||||
|
end
|
||||||
|
|
||||||
|
post "/users" do |env|
|
||||||
|
# Symbol-based HTTP::Status and chained JSON
|
||||||
|
env.status(:created).json({id: 1, created: true})
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/admin" do |env|
|
||||||
|
# Halt immediately with HTML response
|
||||||
|
halt env.status(403).html("<h1>Forbidden</h1>")
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/api/users" do |env|
|
||||||
|
# Custom content type (JSON:API)
|
||||||
|
env.json({data: ["alice", "bob"]}, content_type: "application/vnd.api+json")
|
||||||
|
end
|
||||||
|
|
||||||
|
Kemal.run
|
||||||
|
```
|
||||||
|
|
||||||
|
- Ensure global wildcard filters always execute while keeping namespace filters isolated to their routes [#737](https://github.com/kemalcr/kemal/pull/737). Thanks @mamantoha :pray:
|
||||||
|
- Fix CLI SSL validation and expand CLI option parsing specs [#738](https://github.com/kemalcr/kemal/pull/738). Thanks @sdogruyol :pray:
|
||||||
|
- Make route LRU cache concurrency-safe with Mutex [#739](https://github.com/kemalcr/kemal/pull/739). Thanks @sdogruyol :pray:
|
||||||
|
- Add `raw_body` to ParamParser for multi-handler body access (e.g. kemal-session) [#740](https://github.com/kemalcr/kemal/pull/740). Thanks @sdogruyol :pray:
|
||||||
|
|
||||||
|
```crystal
|
||||||
|
post "/" do |env|
|
||||||
|
raw = env.params.raw_body # raw body, multiple handlers can call it
|
||||||
|
env.params.body["name"] # parsed body
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
- Fix OverrideMethodHandler route cache bug when using `_method` override [#741](https://github.com/kemalcr/kemal/pull/741), [#742](https://github.com/kemalcr/kemal/pull/742). Thanks @skojin and @sdogruyol :pray:
|
||||||
|
|
||||||
|
# 1.9.0 (28-01-2026)
|
||||||
|
|
||||||
|
- Crystal 1.19.0 support :tada:
|
||||||
|
- ***(SECURITY)*** Limit maximum request body size to avoid DoS attacks [#730](https://github.com/kemalcr/kemal/pull/730). Thanks @sdogruyol :pray:
|
||||||
|
- Optimize JSON parameter parsing by directly using the request body IO. Thanks @sdogruyol :pray:
|
||||||
|
|
||||||
|
# 1.8.0 (07-11-2025)
|
||||||
|
|
||||||
|
- Enhance HEAD request handling by caching GET route lookups and optimize path construction using string interpolation for improved performance [#728](https://github.com/kemalcr/kemal/pull/728). Thanks @sdogruyol :pray:
|
||||||
|
- Improve error messages [#726](https://github.com/kemalcr/kemal/pull/726). Thanks @sdogruyol :pray:
|
||||||
|
- Optimize route and websocket lookups by caching results to reduce redundant processing in the HTTP server context [#725](https://github.com/kemalcr/kemal/pull/725). Thanks @sdogruyol :pray:
|
||||||
|
- Replace full-flush Route cache with LRU and add a configurable max cache size [#724](https://github.com/kemalcr/kemal/pull/724). Thanks @sdogruyol :pray:
|
||||||
|
|
||||||
|
# 1.7.3 (02-10-2025)
|
||||||
|
|
||||||
|
- Refactor [#719](https://github.com/kemalcr/kemal/pull/719). Thanks @sdogruyol :pray:
|
||||||
|
- Improve Kemal test suite. Thanks @sdogruyol :pray:
|
||||||
|
|
||||||
|
# 1.7.2 (04-08-2025)
|
||||||
|
|
||||||
|
- Move Kemal::Handler logic into separate module [#717](https://github.com/kemalcr/kemal/pull/717). Thanks @syeopite :pray:
|
||||||
|
- Refactor server binding logic to avoid binding in test environment [#719](https://github.com/kemalcr/kemal/pull/719). Thanks @sdogruyol :pray:
|
||||||
|
|
||||||
|
# 1.7.1 (14-04-2025)
|
||||||
|
|
||||||
|
- Improve `StaticFileHandler` to align with latest Crystal implementation [#711](https://github.com/kemalcr/kemal/pull/711). Thanks @sdogruyol :pray:
|
||||||
|
|
||||||
|
# 1.7.0 (14-04-2025)
|
||||||
|
|
||||||
|
- ***(SECURITY)*** Fix a Path Traversal Security issue in `StaticFileHandler`. [See](https://packetstorm.news/files/id/190294/) for more details. Thanks a lot @ahmetumitbayram :pray:
|
||||||
|
- Crystal 1.16.0 support :tada:
|
||||||
|
- Add ability to add handlers for raised exceptions [#688](https://github.com/kemalcr/kemal/pull/688). Thanks @syeopite :pray:
|
||||||
|
|
||||||
|
```crystal
|
||||||
|
require "kemal"
|
||||||
|
|
||||||
|
class NewException < Exception
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/" do | env |
|
||||||
|
raise NewException.new()
|
||||||
|
end
|
||||||
|
|
||||||
|
error NewException do | env |
|
||||||
|
"An error occured!"
|
||||||
|
end
|
||||||
|
|
||||||
|
Kemal.run
|
||||||
|
```
|
||||||
|
|
||||||
|
- Add `all_files` method to `params` to support multiple file uploads in names ending with `[]` [#701](https://github.com/kemalcr/kemal/pull/701). Thanks @sdogruyol :pray:
|
||||||
|
|
||||||
|
```crystal
|
||||||
|
images = env.params.all_files["images[]"]?
|
||||||
|
```
|
||||||
|
|
||||||
|
- Embrace Crystal standard Log for logging [#705](https://github.com/kemalcr/kemal/pull/705). Thanks @hugopl :pray:
|
||||||
|
- Cleanup temporary files for file uploads [#707](https://github.com/kemalcr/kemal/pull/707). Thanks @sdogruyol :pray:
|
||||||
|
- Implement multiple partial ranges [#708](https://github.com/kemalcr/kemal/pull/708). Thanks @sdogruyol :pray:
|
||||||
|
|
||||||
# 1.6.0 (12-10-2024)
|
# 1.6.0 (12-10-2024)
|
||||||
|
|
||||||
- Crystal 1.14.0 support :tada:
|
- Crystal 1.14.0 support :tada:
|
||||||
|
|
|
||||||
59
CONTRIBUTING.md
Normal file
59
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Contributing to Kemal
|
||||||
|
|
||||||
|
Thank you for your interest in contributing to Kemal! We love pull requests from everyone.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. **Fork** the repository on GitHub.
|
||||||
|
2. **Clone** your fork locally:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/YOUR_USERNAME/kemal.git
|
||||||
|
cd kemal
|
||||||
|
```
|
||||||
|
3. **Install dependencies**:
|
||||||
|
```bash
|
||||||
|
shards install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
Before submitting a pull request, please ensure that all tests pass.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crystal spec
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
Kemal follows the standard Crystal code style. Please ensure your code is formatted correctly before committing.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crystal tool format
|
||||||
|
```
|
||||||
|
|
||||||
|
## Submitting a Pull Request
|
||||||
|
|
||||||
|
1. Create a new branch for your feature or bug fix:
|
||||||
|
```bash
|
||||||
|
git checkout -b my-new-feature
|
||||||
|
```
|
||||||
|
2. Commit your changes with descriptive commit messages.
|
||||||
|
3. Push your branch to your fork:
|
||||||
|
```bash
|
||||||
|
git push origin my-new-feature
|
||||||
|
```
|
||||||
|
4. Open a **Pull Request** on the main Kemal repository.
|
||||||
|
5. Describe your changes and link to any relevant issues.
|
||||||
|
|
||||||
|
## Reporting Bugs
|
||||||
|
|
||||||
|
If you find a bug, please open an issue on GitHub with:
|
||||||
|
- A clear title and description.
|
||||||
|
- Steps to reproduce the issue.
|
||||||
|
- The version of Kemal and Crystal you are using.
|
||||||
|
|
||||||
|
## Feature Requests
|
||||||
|
|
||||||
|
We welcome new ideas! Please open an issue to discuss your feature request before implementing it.
|
||||||
|
|
||||||
|
Thank you for contributing to Kemal! 🚀
|
||||||
125
README.md
125
README.md
|
|
@ -2,45 +2,32 @@
|
||||||
|
|
||||||
# Kemal
|
# Kemal
|
||||||
|
|
||||||
Lightning Fast, Super Simple web framework.
|
Kemal is the Fast, Effective, Simple Web Framework for Crystal. It's perfect for building Web Applications and APIs with minimal code.
|
||||||
|
|
||||||
**THIS IS A FORK OF KEMAL. DIRECT TO FORK.MD FOR SPECIFICS ON THIS FORK.**
|
**THIS IS A FORK OF KEMAL. DIRECT TO FORK.MD FOR SPECIFICS ON THIS FORK.**
|
||||||
|
|
||||||
[](https://github.com/kemalcr/kemal/actions/workflows/ci.yml)
|
[](https://github.com/kemalcr/kemal/actions/workflows/ci.yml)
|
||||||
[](https://gitter.im/sdogruyol/kemal?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
|
||||||
|
|
||||||
# Super Simple ⚡️
|
## Why Kemal?
|
||||||
|
|
||||||
```ruby
|
- 🚀 **Lightning Fast**: Built on Crystal, known for C-like performance
|
||||||
require "kemal"
|
- 💡 **Super Simple**: Minimal code needed to get started
|
||||||
|
- 🛠 **Feature Rich**: Everything you need for modern web development
|
||||||
|
- 🔧 **Flexible**: Easy to extend with middleware support
|
||||||
|
|
||||||
# Matches GET "http://host:port/"
|
## Quick Start
|
||||||
get "/" do
|
|
||||||
"Hello World!"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Creates a WebSocket handler.
|
1. First, make sure you have [Crystal installed](https://crystal-lang.org/install/).
|
||||||
# Matches "ws://host:port/socket"
|
|
||||||
ws "/socket" do |socket|
|
|
||||||
socket.send "Hello from Kemal!"
|
|
||||||
end
|
|
||||||
|
|
||||||
Kemal.run
|
2. Create a new Crystal application and step into it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crystal init app my-kemal-app
|
||||||
|
cd my-kemal-app
|
||||||
```
|
```
|
||||||
|
|
||||||
Start your application!
|
3. Add Kemal to your app's `shard.yml`:
|
||||||
|
>>>>>>> upstream/master
|
||||||
```
|
|
||||||
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
|
```yaml
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -48,22 +35,78 @@ dependencies:
|
||||||
github: kemalcr/kemal
|
github: kemalcr/kemal
|
||||||
```
|
```
|
||||||
|
|
||||||
See also [Getting Started](http://kemalcr.com/guide/).
|
4. Replace the contents of `src/my_kemal_app.cr` with your first Kemal app:
|
||||||
|
|
||||||
# Features
|
```crystal
|
||||||
|
require "kemal"
|
||||||
|
|
||||||
- Support all REST verbs
|
# Basic route - responds to GET "http://localhost:3000/"
|
||||||
- Websocket support
|
get "/" do
|
||||||
- Request/Response context, easy parameter handling
|
"Hello World!"
|
||||||
- Middleware support
|
end
|
||||||
- Built-in JSON support
|
|
||||||
- Built-in static file serving
|
|
||||||
- Built-in view templating via [ECR](https://crystal-lang.org/api/ECR.html)
|
|
||||||
|
|
||||||
# Documentation
|
# JSON API example
|
||||||
|
get "/api/status" do |env|
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
{"status": "ok"}.to_json
|
||||||
|
end
|
||||||
|
|
||||||
You can read the documentation at the official site [kemalcr.com](http://kemalcr.com)
|
# WebSocket support
|
||||||
|
ws "/chat" do |socket|
|
||||||
|
socket.send "Hello from Kemal WebSocket!"
|
||||||
|
end
|
||||||
|
|
||||||
## Thanks
|
Kemal.run
|
||||||
|
```
|
||||||
|
|
||||||
Thanks to Manas for their awesome work on [Frank](https://github.com/manastech/frank).
|
5. Install dependencies and run your application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shards install
|
||||||
|
crystal run src/my_kemal_app.cr
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Visit [http://localhost:3000](http://localhost:3000) - That's it! 🎉
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- 🚀 **High-performance by default**: Built on Crystal with a thin abstraction layer so you can serve a large number of requests with low latency and low memory footprint.
|
||||||
|
- 🌐 **Full REST & HTTP support**: Handle all HTTP verbs (GET, POST, PUT, PATCH, DELETE, etc.) with a straightforward routing DSL.
|
||||||
|
- 🔌 **WebSocket & real-time**: First-class WebSocket support for building chats, dashboards and other real-time experiences.
|
||||||
|
- 📦 **JSON-first APIs**: Native JSON handling makes building JSON APIs and microservices feel natural.
|
||||||
|
- 🗄️ **Static assets made easy**: Serve static files (assets, uploads, SPA bundles) efficiently from the same application.
|
||||||
|
- 📝 **Template engine included**: Built-in ECR template engine for server‑rendered HTML when you need it.
|
||||||
|
- 🔒 **Composable middleware**: Flexible middleware system to add logging, auth, rate limiting, metrics and more.
|
||||||
|
- 🎯 **Ergonomic request/response API**: Simple access to params, headers, cookies and bodies via a clear context object.
|
||||||
|
- 🍪 **Session management**: Easy session handling with [kemal-session](https://github.com/kemalcr/kemal-session), suitable for production apps.
|
||||||
|
|
||||||
|
## Philosophy
|
||||||
|
|
||||||
|
Kemal aims to be a simple, fast and reliable foundation for building production-grade web applications and APIs in Crystal.
|
||||||
|
|
||||||
|
- **Simple core, powerful building blocks**: The core is intentionally simple and easy to reason about. Most power comes from Crystal itself and from middleware, not from hidden magic.
|
||||||
|
- **Performance as a baseline, not a feature**: Crystal's native speed means high performance is the default. Kemal keeps abstractions thin so you stay close to the metal when you need to.
|
||||||
|
- **Minimal assumptions, maximum flexibility**: Kemal does not force a specific ORM, template engine, or project layout. You are free to choose the tools that fit your application and your team.
|
||||||
|
- **Batteries within reason**: Kemal ships with the essentials (routing, middleware, templates, static files, request/response helpers) while keeping advanced concerns in separate shards you can opt into as your app grows.
|
||||||
|
|
||||||
|
Kemal is designed to feel familiar if you come from popular web frameworks, while embracing Crystal's strengths and keeping your application code straightforward, maintainable, and ready for production.
|
||||||
|
|
||||||
|
## Learning Resources
|
||||||
|
|
||||||
|
- 📚 [Official Documentation](http://kemalcr.com)
|
||||||
|
- 💻 [Example Applications](https://github.com/kemalcr/kemal/tree/master/examples)
|
||||||
|
- 🚀 [Kemal Guide](http://kemalcr.com/guide/)
|
||||||
|
- 💬 [Community Chat](https://discord.gg/prSVAZJEpz)
|
||||||
|
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
We love contributions! Please read our [Contributing Guide](CONTRIBUTING.md) to get started.
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
Special thanks to Manas for their work on [Frank](https://github.com/manastech/frank).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Kemal is released under the MIT License.
|
||||||
|
|
|
||||||
67
examples/cookies/app.cr
Normal file
67
examples/cookies/app.cr
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
require "kemal"
|
||||||
|
|
||||||
|
# This example demonstrates different ways to work with cookies in Kemal
|
||||||
|
|
||||||
|
# Route to set various types of cookies
|
||||||
|
get "/set-cookies" do |env|
|
||||||
|
# Basic cookie with just name and value
|
||||||
|
basic_cookie = HTTP::Cookie.new(
|
||||||
|
name: "BasicCookie",
|
||||||
|
value: "Hello from Kemal!"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Secure cookie with additional security options
|
||||||
|
secure_cookie = HTTP::Cookie.new(
|
||||||
|
name: "SecureCookie",
|
||||||
|
value: "Sensitive Data",
|
||||||
|
http_only: true, # Cookie cannot be accessed via JavaScript
|
||||||
|
secure: true, # Cookie only sent over HTTPS
|
||||||
|
path: "/", # Cookie available for all paths
|
||||||
|
expires: Time.local + Time::Span.new(days: 7) # Cookie expires in 7 days
|
||||||
|
)
|
||||||
|
|
||||||
|
# Session cookie that expires when browser closes
|
||||||
|
session_cookie = HTTP::Cookie.new(
|
||||||
|
name: "SessionCookie",
|
||||||
|
value: "Temporary",
|
||||||
|
http_only: true
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add all cookies to response
|
||||||
|
env.response.cookies << basic_cookie
|
||||||
|
env.response.cookies << secure_cookie
|
||||||
|
env.response.cookies << session_cookie
|
||||||
|
|
||||||
|
"Cookies have been set! Visit /show-cookies to view them."
|
||||||
|
end
|
||||||
|
|
||||||
|
# Route to display current cookies
|
||||||
|
get "/show-cookies" do |env|
|
||||||
|
cookies = env.request.cookies
|
||||||
|
response = String.build do |str|
|
||||||
|
str << "<h1>Current Cookies:</h1>"
|
||||||
|
str << "<ul>"
|
||||||
|
cookies.each do |cookie|
|
||||||
|
str << "<li>#{cookie.name}: #{cookie.value}</li>"
|
||||||
|
end
|
||||||
|
str << "</ul>"
|
||||||
|
end
|
||||||
|
response
|
||||||
|
end
|
||||||
|
|
||||||
|
# Route to delete a specific cookie
|
||||||
|
get "/delete-cookie/:name" do |env|
|
||||||
|
cookie_name = env.params.url["name"]
|
||||||
|
|
||||||
|
# Set cookie with immediate expiration to delete it
|
||||||
|
delete_cookie = HTTP::Cookie.new(
|
||||||
|
name: cookie_name,
|
||||||
|
value: "",
|
||||||
|
expires: Time.local - 1.day
|
||||||
|
)
|
||||||
|
|
||||||
|
env.response.cookies << delete_cookie
|
||||||
|
"Cookie '#{cookie_name}' has been deleted!"
|
||||||
|
end
|
||||||
|
|
||||||
|
Kemal.run
|
||||||
17
examples/cors/app.cr
Normal file
17
examples/cors/app.cr
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
require "kemal"
|
||||||
|
|
||||||
|
# Configure headers for static files using Kemal's static_headers helper
|
||||||
|
static_headers do |response, filepath, filestat|
|
||||||
|
# For HTML files, add CORS header to allow requests from example.com
|
||||||
|
# This restricts access to HTML files to only that domain
|
||||||
|
if filepath =~ /\.html$/
|
||||||
|
response.headers.add("Access-Control-Allow-Origin", "example.com")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add Content-Size header for all static files
|
||||||
|
# This helps clients know the file size before downloading
|
||||||
|
response.headers.add("Content-Size", filestat.size.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Start the Kemal web server
|
||||||
|
Kemal.run
|
||||||
18
examples/file-download/app.cr
Normal file
18
examples/file-download/app.cr
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
require "kemal"
|
||||||
|
|
||||||
|
# Define a route for the root path "/" that will handle file downloads
|
||||||
|
get "/" do |env|
|
||||||
|
# Use Kemal's send_file helper to stream a file to the client
|
||||||
|
# Parameters:
|
||||||
|
# - env: The HTTP environment containing request/response data
|
||||||
|
# - "/path/to/your_file": The path to the file you want to download
|
||||||
|
#
|
||||||
|
# send_file will:
|
||||||
|
# - Set appropriate Content-Type header based on file extension
|
||||||
|
# - Stream the file in chunks to handle large files efficiently
|
||||||
|
# - Set Content-Disposition header for browser download behavior
|
||||||
|
send_file env, "/path/to/your_file"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Start the Kemal web server
|
||||||
|
Kemal.run
|
||||||
25
examples/file-upload/app.cr
Normal file
25
examples/file-upload/app.cr
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
require "kemal"
|
||||||
|
|
||||||
|
# Handle file uploads via POST request to /upload endpoint
|
||||||
|
post "/upload" do |env|
|
||||||
|
# Get the uploaded file from the "image" field in the form
|
||||||
|
# The file is initially stored in a temporary location
|
||||||
|
uploaded_file = env.params.files["image"].tempfile
|
||||||
|
|
||||||
|
# Construct the destination path where we'll save the file
|
||||||
|
# - Kemal.config.public_folder is the configured public directory
|
||||||
|
# - "uploads/" is the subdirectory where we'll store uploads
|
||||||
|
# - File.basename gets just the filename from the temp file path
|
||||||
|
uploaded_file_path = ::File.join [Kemal.config.public_folder, "uploads/", File.basename(uploaded_file.path)]
|
||||||
|
|
||||||
|
# Open the destination file for writing and copy the uploaded file to it
|
||||||
|
File.open(uploaded_file_path, "w") do |file|
|
||||||
|
IO.copy(uploaded_file, file)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Return a simple success message
|
||||||
|
"Upload ok"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Start the Kemal server
|
||||||
|
Kemal.run
|
||||||
7
examples/hello-world/app.cr
Normal file
7
examples/hello-world/app.cr
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
require "kemal"
|
||||||
|
|
||||||
|
get "/" do
|
||||||
|
"Hello Kemal!"
|
||||||
|
end
|
||||||
|
|
||||||
|
Kemal.run
|
||||||
18
examples/http-basic-auth/app.cr
Normal file
18
examples/http-basic-auth/app.cr
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
require "kemal"
|
||||||
|
require "kemal-basic-auth"
|
||||||
|
|
||||||
|
# Enable HTTP Basic Authentication
|
||||||
|
# This will protect all routes with username/password authentication
|
||||||
|
# - username: "username"
|
||||||
|
# - password: "password"
|
||||||
|
basic_auth "username", "password"
|
||||||
|
|
||||||
|
# Define a route for the root path "/"
|
||||||
|
get "/" do |_|
|
||||||
|
# This route will only execute if authentication is successful
|
||||||
|
# Otherwise, the browser will show a login prompt
|
||||||
|
"This is shown if basic auth successful."
|
||||||
|
end
|
||||||
|
|
||||||
|
# Start the Kemal web server
|
||||||
|
Kemal.run
|
||||||
23
examples/http-basic-auth/custom-handler.cr
Normal file
23
examples/http-basic-auth/custom-handler.cr
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
require "kemal-basic-auth"
|
||||||
|
|
||||||
|
# Create a custom authentication handler by inheriting from Kemal::BasicAuth::Handler
|
||||||
|
class CustomAuthHandler < Kemal::BasicAuth::Handler
|
||||||
|
# Specify which routes should be protected by basic auth
|
||||||
|
# In this case, only /dashboard and /admin routes will require authentication
|
||||||
|
only ["/dashboard", "/admin"]
|
||||||
|
|
||||||
|
# Override the call method to implement custom authentication logic
|
||||||
|
def call(context)
|
||||||
|
# Skip authentication if the current route is not in the protected routes list
|
||||||
|
# This allows other routes to be accessed without authentication
|
||||||
|
return call_next(context) unless only_match?(context)
|
||||||
|
|
||||||
|
# Call the parent class's authentication logic for protected routes
|
||||||
|
# This will prompt for username/password and validate credentials
|
||||||
|
super
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Register our custom authentication handler with Kemal
|
||||||
|
# This enables basic auth for the specified routes
|
||||||
|
Kemal.config.auth_handler = CustomAuthHandler
|
||||||
72
examples/json-api/app.cr
Normal file
72
examples/json-api/app.cr
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
require "kemal"
|
||||||
|
require "json"
|
||||||
|
|
||||||
|
# Set JSON content type for all routes
|
||||||
|
before_all do |env|
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
end
|
||||||
|
|
||||||
|
# In-memory storage for users
|
||||||
|
USERS = [] of Hash(String, JSON::Any)
|
||||||
|
|
||||||
|
# GET - List all users
|
||||||
|
get "/users" do |_|
|
||||||
|
USERS.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET - Get a specific user by index
|
||||||
|
get "/users/:id" do |env|
|
||||||
|
id = env.params.url["id"].to_i
|
||||||
|
|
||||||
|
if id < USERS.size
|
||||||
|
USERS[id].to_json
|
||||||
|
else
|
||||||
|
env.response.status_code = 404
|
||||||
|
{error: "User not found"}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST - Create a new user
|
||||||
|
post "/users" do |env|
|
||||||
|
# Parse request body as JSON
|
||||||
|
# ameba:disable Lint/NotNil
|
||||||
|
user = JSON.parse(env.request.body.not_nil!.gets_to_end)
|
||||||
|
# ameba:enable Lint/NotNil
|
||||||
|
USERS << user.as_h
|
||||||
|
|
||||||
|
env.response.status_code = 201
|
||||||
|
user.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
# PUT - Update a user
|
||||||
|
put "/users/:id" do |env|
|
||||||
|
id = env.params.url["id"].to_i
|
||||||
|
|
||||||
|
if id < USERS.size
|
||||||
|
# Parse request body as JSON
|
||||||
|
# ameba:disable Lint/NotNil
|
||||||
|
updated_user = JSON.parse(env.request.body.not_nil!.gets_to_end)
|
||||||
|
# ameba:enable Lint/NotNil
|
||||||
|
USERS[id] = updated_user.as_h
|
||||||
|
updated_user.to_json
|
||||||
|
else
|
||||||
|
env.response.status_code = 404
|
||||||
|
{error: "User not found"}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE - Remove a user
|
||||||
|
delete "/users/:id" do |env|
|
||||||
|
id = env.params.url["id"].to_i
|
||||||
|
|
||||||
|
if id < USERS.size
|
||||||
|
deleted_user = USERS.delete_at(id)
|
||||||
|
deleted_user.to_json
|
||||||
|
else
|
||||||
|
env.response.status_code = 404
|
||||||
|
{error: "User not found"}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Start the Kemal web server
|
||||||
|
Kemal.run
|
||||||
32
examples/json-mapping/app.cr
Normal file
32
examples/json-mapping/app.cr
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
require "kemal"
|
||||||
|
require "json"
|
||||||
|
|
||||||
|
# Define a User class that can be created from JSON data
|
||||||
|
class User
|
||||||
|
# Include JSON::Serializable to add JSON parsing capabilities
|
||||||
|
# This allows converting JSON strings to User objects and vice versa
|
||||||
|
include JSON::Serializable
|
||||||
|
|
||||||
|
# Define properties that will be mapped from JSON
|
||||||
|
# These properties must match the keys in the incoming JSON
|
||||||
|
property username : String # User's username as a string
|
||||||
|
property password : String # User's password as a string
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle POST requests to the root path "/"
|
||||||
|
post "/" do |env|
|
||||||
|
# Parse the request body as JSON and create a User object
|
||||||
|
# env.request.body contains the raw JSON data
|
||||||
|
# not_nil! ensures the body exists
|
||||||
|
# User.from_json converts the JSON string to a User object
|
||||||
|
# ameba:disable Lint/NotNil
|
||||||
|
user = User.from_json env.request.body.not_nil!
|
||||||
|
# ameba:enable Lint/NotNil
|
||||||
|
|
||||||
|
# Convert the user object back to JSON and return it
|
||||||
|
# This creates a JSON object with username and password fields
|
||||||
|
{username: user.username, password: user.password}.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
# Start the Kemal web server
|
||||||
|
Kemal.run
|
||||||
56
examples/mysql-db/app.cr
Normal file
56
examples/mysql-db/app.cr
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
require "kemal"
|
||||||
|
require "db"
|
||||||
|
require "mysql"
|
||||||
|
|
||||||
|
# Initialize a single DB connection
|
||||||
|
DB_URL = "mysql://root:password@localhost:3306/mydb"
|
||||||
|
DBC = DB.open(DB_URL)
|
||||||
|
|
||||||
|
# Example User model
|
||||||
|
class User
|
||||||
|
include JSON::Serializable # To render json in HTTP::Response
|
||||||
|
include DB::Serializable # To serialize from DB::ResultSet
|
||||||
|
|
||||||
|
property id : Int32
|
||||||
|
property name : String
|
||||||
|
property email : String
|
||||||
|
|
||||||
|
def initialize(@id, @name, @email)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# List all users
|
||||||
|
get "/users" do |_|
|
||||||
|
# Serialize ResultSet
|
||||||
|
users = User.from_rs(DBC.query("SELECT * FROM users"))
|
||||||
|
|
||||||
|
# Return users array as JSON response
|
||||||
|
users.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a new user
|
||||||
|
post "/users" do |env|
|
||||||
|
name = env.params.json["name"].as(String)
|
||||||
|
email = env.params.json["email"].as(String)
|
||||||
|
|
||||||
|
user = User.from_rs(DBC.query("INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email", name, email)).first
|
||||||
|
|
||||||
|
{message: "User created with id: #{user.id}"}.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
# Delete a user
|
||||||
|
delete "/users/:id" do |env|
|
||||||
|
id = env.params.url["id"].to_i
|
||||||
|
|
||||||
|
# Delete user and check if any rows were affected
|
||||||
|
result = DBC.exec "DELETE FROM users WHERE id = ?", id
|
||||||
|
|
||||||
|
if result.rows_affected > 0
|
||||||
|
{message: "User deleted successfully"}.to_json
|
||||||
|
else
|
||||||
|
env.response.status_code = 404
|
||||||
|
{message: "User not found"}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Kemal.run
|
||||||
56
examples/postgresql-db/app.cr
Normal file
56
examples/postgresql-db/app.cr
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
require "kemal"
|
||||||
|
require "db"
|
||||||
|
require "pg"
|
||||||
|
|
||||||
|
# Initialize a single DB connection
|
||||||
|
DB_URL = "postgres://postgres:postgres@localhost:5432/mydb"
|
||||||
|
DBC = DB.open(DB_URL)
|
||||||
|
|
||||||
|
# Example User model
|
||||||
|
class User
|
||||||
|
include JSON::Serializable # To render json in HTTP::Response
|
||||||
|
include DB::Serializable # To serialize from DB::ResultSet
|
||||||
|
|
||||||
|
property id : Int32
|
||||||
|
property name : String
|
||||||
|
property email : String
|
||||||
|
|
||||||
|
def initialize(@id, @name, @email)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# List all users
|
||||||
|
get "/users" do |_|
|
||||||
|
# Serialize ResultSet
|
||||||
|
users = User.from_rs(DBC.query("SELECT * FROM users"))
|
||||||
|
|
||||||
|
# Return users array as JSON response
|
||||||
|
users.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a new user
|
||||||
|
post "/users" do |env|
|
||||||
|
name = env.params.json["name"].as(String)
|
||||||
|
email = env.params.json["email"].as(String)
|
||||||
|
|
||||||
|
user = User.from_rs(DBC.query("INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email", name, email)).first
|
||||||
|
|
||||||
|
{message: "User created with id: #{user.id}"}.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
# Delete a user
|
||||||
|
delete "/users/:id" do |env|
|
||||||
|
id = env.params.url["id"].to_i
|
||||||
|
|
||||||
|
# Delete user and check if any rows were affected
|
||||||
|
result = DBC.exec "DELETE FROM users WHERE id = $1", id
|
||||||
|
|
||||||
|
if result.rows_affected > 0
|
||||||
|
{message: "User deleted successfully"}.to_json
|
||||||
|
else
|
||||||
|
env.response.status_code = 404
|
||||||
|
{message: "User not found"}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Kemal.run
|
||||||
58
examples/redis/app.cr
Normal file
58
examples/redis/app.cr
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
require "kemal"
|
||||||
|
require "redis"
|
||||||
|
|
||||||
|
# Initialize Redis client
|
||||||
|
REDIS = Redis.new(host: "localhost", port: 6379)
|
||||||
|
|
||||||
|
# Store a value
|
||||||
|
post "/store/:key" do |env|
|
||||||
|
key = env.params.url["key"]
|
||||||
|
value = env.params.json["value"].as(String)
|
||||||
|
|
||||||
|
REDIS.set(key, value)
|
||||||
|
{message: "Value stored successfully"}.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
# Retrieve a value
|
||||||
|
get "/get/:key" do |env|
|
||||||
|
key = env.params.url["key"]
|
||||||
|
|
||||||
|
if value = REDIS.get(key)
|
||||||
|
{key: key, value: value}.to_json
|
||||||
|
else
|
||||||
|
env.response.status_code = 404
|
||||||
|
{message: "Key not found"}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Delete a value
|
||||||
|
delete "/:key" do |env|
|
||||||
|
key = env.params.url["key"]
|
||||||
|
|
||||||
|
if REDIS.del(key) > 0
|
||||||
|
{message: "Key deleted successfully"}.to_json
|
||||||
|
else
|
||||||
|
env.response.status_code = 404
|
||||||
|
{message: "Key not found"}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Increment a counter
|
||||||
|
post "/incr/:key" do |env|
|
||||||
|
key = env.params.url["key"]
|
||||||
|
new_value = REDIS.incr(key)
|
||||||
|
|
||||||
|
{key: key, value: new_value}.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
# Store with expiration
|
||||||
|
post "/store_temp/:key" do |env|
|
||||||
|
key = env.params.url["key"]
|
||||||
|
value = env.params.json["value"].as(String)
|
||||||
|
ttl = env.params.json["ttl"].as(Int64)
|
||||||
|
|
||||||
|
REDIS.setex(key, ttl, value)
|
||||||
|
{message: "Value stored with expiration"}.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
Kemal.run
|
||||||
19
examples/reuse-port/app.cr
Normal file
19
examples/reuse-port/app.cr
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
require "kemal"
|
||||||
|
|
||||||
|
# Define a simple route that returns a message
|
||||||
|
get "/" do
|
||||||
|
"Reusing port 3000"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Start Kemal with custom server configuration
|
||||||
|
Kemal.run do |config|
|
||||||
|
# Get the server instance from the config
|
||||||
|
# ameba:disable Lint/NotNil
|
||||||
|
server = config.server.not_nil!
|
||||||
|
# ameba:enable Lint/NotNil
|
||||||
|
|
||||||
|
# Bind the server to port 3000 with reuse_port enabled
|
||||||
|
# reuse_port: true allows multiple processes to listen on the same port
|
||||||
|
# This is useful for load balancing across multiple worker processes
|
||||||
|
server.bind_tcp "0.0.0.0", 3000, reuse_port: true
|
||||||
|
end
|
||||||
14
examples/unix-domain-socket/app.cr
Normal file
14
examples/unix-domain-socket/app.cr
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
require "kemal"
|
||||||
|
|
||||||
|
# Start Kemal with custom server configuration to use Unix Domain Socket
|
||||||
|
Kemal.run do |config|
|
||||||
|
# Get the server instance from the config
|
||||||
|
# ameba:disable Lint/NotNil
|
||||||
|
server = config.server.not_nil!
|
||||||
|
# ameba:enable Lint/NotNil
|
||||||
|
|
||||||
|
# Bind the server to a Unix Domain Socket instead of TCP port
|
||||||
|
# Unix Domain Sockets provide faster inter-process communication on the same machine
|
||||||
|
# They are commonly used when the client and server are on the same host
|
||||||
|
server.bind_unix "path/to/socket.sock"
|
||||||
|
end
|
||||||
33
examples/websocket-chat/app.cr
Normal file
33
examples/websocket-chat/app.cr
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
require "kemal"
|
||||||
|
|
||||||
|
# Array to store chat message history
|
||||||
|
messages = [] of String
|
||||||
|
# Array to keep track of connected WebSocket clients
|
||||||
|
sockets = [] of HTTP::WebSocket
|
||||||
|
|
||||||
|
# Create WebSocket endpoint at root path "/"
|
||||||
|
ws "/" do |socket|
|
||||||
|
# Add newly connected client socket to our sockets array
|
||||||
|
sockets.push socket
|
||||||
|
|
||||||
|
# Handle incoming messages from clients
|
||||||
|
socket.on_message do |message|
|
||||||
|
# Store the new message in history
|
||||||
|
messages.push message
|
||||||
|
# Broadcast the updated message history to all connected clients
|
||||||
|
sockets.each do |a_socket|
|
||||||
|
a_socket.send messages.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle client disconnection
|
||||||
|
socket.on_close do |_|
|
||||||
|
# Remove disconnected client's socket from our array
|
||||||
|
sockets.delete(socket)
|
||||||
|
# Log disconnection event
|
||||||
|
puts "Closing Socket: #{socket}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Start the Kemal server
|
||||||
|
Kemal.run
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
require "kemal"
|
|
||||||
|
|
||||||
# Set root. If not specified the default content_type is 'text'
|
|
||||||
get "/" do
|
|
||||||
"Hello Kemal!"
|
|
||||||
end
|
|
||||||
|
|
||||||
Kemal.run
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
require "kemal"
|
|
||||||
require "json"
|
|
||||||
|
|
||||||
# You can easily access the context and set content_type like 'application/json'.
|
|
||||||
# Look how easy to build a JSON serving API.
|
|
||||||
get "/" do |env|
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
{name: "Serdar", age: 27}.to_json
|
|
||||||
end
|
|
||||||
|
|
||||||
Kemal.run
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
require "kemal"
|
|
||||||
|
|
||||||
ws "/" do |socket|
|
|
||||||
socket.send "Hello from Kemal!"
|
|
||||||
|
|
||||||
socket.on_message do |message|
|
|
||||||
socket.send "Echo back from server #{message}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Kemal.run
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
name: kemal
|
name: kemal
|
||||||
version: 1.6.0
|
version: 1.10.1
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Serdar Dogruyol <dogruyolserdar@gmail.com>
|
- Serdar Dogruyol <dogruyolserdar@gmail.com>
|
||||||
|
|
@ -15,6 +15,7 @@ dependencies:
|
||||||
development_dependencies:
|
development_dependencies:
|
||||||
ameba:
|
ameba:
|
||||||
github: crystal-ameba/ameba
|
github: crystal-ameba/ameba
|
||||||
|
branch: master
|
||||||
|
|
||||||
crystal: ">= 0.36.0"
|
crystal: ">= 0.36.0"
|
||||||
|
|
||||||
|
|
|
||||||
90
spec/cli_spec.cr
Normal file
90
spec/cli_spec.cr
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
require "./spec_helper"
|
||||||
|
|
||||||
|
{% if !flag?(:without_openssl) %}
|
||||||
|
private def run_cli_eval(cli_args : String)
|
||||||
|
output = IO::Memory.new
|
||||||
|
error = IO::Memory.new
|
||||||
|
status = Process.run(
|
||||||
|
"crystal",
|
||||||
|
[
|
||||||
|
"eval",
|
||||||
|
%(require "./src/kemal"; Kemal::CLI.new(#{cli_args})),
|
||||||
|
],
|
||||||
|
output: output,
|
||||||
|
error: error,
|
||||||
|
)
|
||||||
|
|
||||||
|
{status, output.to_s, error.to_s}
|
||||||
|
end
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
describe "Kemal::CLI" do
|
||||||
|
it "parses host binding with long option" do
|
||||||
|
Kemal::CLI.new(["--bind", "127.0.0.1"])
|
||||||
|
Kemal.config.host_binding.should eq("127.0.0.1")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "parses host binding with short option" do
|
||||||
|
Kemal::CLI.new(["-b", "192.168.1.10"])
|
||||||
|
Kemal.config.host_binding.should eq("192.168.1.10")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "parses port with long and short options" do
|
||||||
|
Kemal::CLI.new(["--port", "4001"])
|
||||||
|
Kemal.config.port.should eq(4001)
|
||||||
|
|
||||||
|
Kemal::CLI.new(["-p", "5002"])
|
||||||
|
Kemal.config.port.should eq(5002)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "raises for non-numeric port values" do
|
||||||
|
expect_raises(ArgumentError) do
|
||||||
|
Kemal::CLI.new(["--port", "abc"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
{% if !flag?(:without_openssl) %}
|
||||||
|
it "fails when ssl is enabled but key file is missing" do
|
||||||
|
status, _, stderr = run_cli_eval(%(["--ssl", "--ssl-cert-file", "cert.pem"]))
|
||||||
|
|
||||||
|
status.success?.should be_false
|
||||||
|
stderr.should contain("SSL configuration error: SSL key file not specified")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "fails when ssl is enabled but certificate file is missing" do
|
||||||
|
status, _, stderr = run_cli_eval(%(["--ssl", "--ssl-key-file", "key.pem"]))
|
||||||
|
|
||||||
|
status.success?.should be_false
|
||||||
|
stderr.should contain("SSL configuration error: SSL certificate file not specified")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "fails when short ssl flag is used without key file" do
|
||||||
|
status, _, stderr = run_cli_eval(%(["-s", "--ssl-cert-file", "cert.pem"]))
|
||||||
|
|
||||||
|
status.success?.should be_false
|
||||||
|
stderr.should contain("SSL configuration error: SSL key file not specified")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "fails when key file argument is empty" do
|
||||||
|
status, _, stderr = run_cli_eval(%(["--ssl", "--ssl-key-file", "", "--ssl-cert-file", "cert.pem"]))
|
||||||
|
|
||||||
|
status.success?.should be_false
|
||||||
|
stderr.should contain("SSL configuration error: SSL key file not specified")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "fails when cert file argument is empty" do
|
||||||
|
status, _, stderr = run_cli_eval(%(["--ssl", "--ssl-key-file", "key.pem", "--ssl-cert-file", ""]))
|
||||||
|
|
||||||
|
status.success?.should be_false
|
||||||
|
stderr.should contain("SSL configuration error: SSL certificate file not specified")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not hit missing-file validation when both flags are present" do
|
||||||
|
status, _, stderr = run_cli_eval(%(["--ssl", "--ssl-key-file", "key.pem", "--ssl-cert-file", "cert.pem"]))
|
||||||
|
|
||||||
|
status.success?.should be_false
|
||||||
|
stderr.should_not contain("SSL configuration error: SSL key file not specified")
|
||||||
|
stderr.should_not contain("SSL configuration error: SSL certificate file not specified")
|
||||||
|
end
|
||||||
|
{% end %}
|
||||||
|
end
|
||||||
|
|
@ -29,15 +29,19 @@ describe "Config" do
|
||||||
config = Kemal.config
|
config = Kemal.config
|
||||||
config.add_handler CustomTestHandler.new
|
config.add_handler CustomTestHandler.new
|
||||||
Kemal.config.setup
|
Kemal.config.setup
|
||||||
config.handlers.size.should eq(8)
|
config.handlers.size.should eq(7)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "toggles the shutdown message" do
|
it "toggles the shutdown message" do
|
||||||
config = Kemal.config
|
config = Kemal.config
|
||||||
config.shutdown_message = false
|
config.shutdown_message = false
|
||||||
config.shutdown_message.should eq false
|
config.shutdown_message.should be_false
|
||||||
config.shutdown_message = true
|
config.shutdown_message = true
|
||||||
config.shutdown_message.should eq true
|
config.shutdown_message.should be_true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sets default shutdown timeout to zero" do
|
||||||
|
Kemal::Config.new.shutdown_timeout.should eq 0.seconds
|
||||||
end
|
end
|
||||||
|
|
||||||
it "adds custom options" do
|
it "adds custom options" do
|
||||||
|
|
|
||||||
|
|
@ -104,4 +104,29 @@ describe "Context" do
|
||||||
context.get?("another_non_existent_key").should be_nil
|
context.get?("another_non_existent_key").should be_nil
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,99 @@ describe "Kemal::ExceptionHandler" do
|
||||||
response.body.should eq "Something happened"
|
response.body.should eq "Something happened"
|
||||||
end
|
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
|
it "overrides the content type for filters" do
|
||||||
before_get do |env|
|
before_get do |env|
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ describe "Handler" do
|
||||||
filter_middleware._add_route_filter("GET", "/", :before) do |env|
|
filter_middleware._add_route_filter("GET", "/", :before) do |env|
|
||||||
env.response << " so"
|
env.response << " so"
|
||||||
end
|
end
|
||||||
add_handler CustomTestHandler.new
|
use CustomTestHandler.new
|
||||||
|
|
||||||
get "/" do
|
get "/" do
|
||||||
" Great"
|
" Great"
|
||||||
|
|
@ -92,7 +92,7 @@ describe "Handler" do
|
||||||
get "/only" do
|
get "/only" do
|
||||||
"Get"
|
"Get"
|
||||||
end
|
end
|
||||||
add_handler OnlyHandler.new
|
use OnlyHandler.new
|
||||||
request = HTTP::Request.new("GET", "/only")
|
request = HTTP::Request.new("GET", "/only")
|
||||||
client_response = call_request_on_app(request)
|
client_response = call_request_on_app(request)
|
||||||
client_response.body.should eq "OnlyGet"
|
client_response.body.should eq "OnlyGet"
|
||||||
|
|
@ -105,7 +105,7 @@ describe "Handler" do
|
||||||
get "/exclude" do
|
get "/exclude" do
|
||||||
"Exclude"
|
"Exclude"
|
||||||
end
|
end
|
||||||
add_handler ExcludeHandler.new
|
use ExcludeHandler.new
|
||||||
request = HTTP::Request.new("GET", "/")
|
request = HTTP::Request.new("GET", "/")
|
||||||
client_response = call_request_on_app(request)
|
client_response = call_request_on_app(request)
|
||||||
client_response.body.should eq "ExcludeGet"
|
client_response.body.should eq "ExcludeGet"
|
||||||
|
|
@ -118,7 +118,7 @@ describe "Handler" do
|
||||||
get "/only" do
|
get "/only" do
|
||||||
"Get"
|
"Get"
|
||||||
end
|
end
|
||||||
add_handler PostOnlyHandler.new
|
use PostOnlyHandler.new
|
||||||
request = HTTP::Request.new("POST", "/only")
|
request = HTTP::Request.new("POST", "/only")
|
||||||
client_response = call_request_on_app(request)
|
client_response = call_request_on_app(request)
|
||||||
client_response.body.should eq "OnlyPost"
|
client_response.body.should eq "OnlyPost"
|
||||||
|
|
@ -131,8 +131,8 @@ describe "Handler" do
|
||||||
post "/only" do
|
post "/only" do
|
||||||
"Post"
|
"Post"
|
||||||
end
|
end
|
||||||
add_handler PostOnlyHandler.new
|
use PostOnlyHandler.new
|
||||||
add_handler PostExcludeHandler.new
|
use PostExcludeHandler.new
|
||||||
request = HTTP::Request.new("POST", "/only")
|
request = HTTP::Request.new("POST", "/only")
|
||||||
client_response = call_request_on_app(request)
|
client_response = call_request_on_app(request)
|
||||||
client_response.body.should eq "OnlyExcludePost"
|
client_response.body.should eq "OnlyExcludePost"
|
||||||
|
|
@ -140,7 +140,7 @@ describe "Handler" do
|
||||||
|
|
||||||
it "adds a handler at given position" do
|
it "adds a handler at given position" do
|
||||||
post_handler = PostOnlyHandler.new
|
post_handler = PostOnlyHandler.new
|
||||||
add_handler post_handler, 1
|
use post_handler, position: 1
|
||||||
Kemal.config.setup
|
Kemal.config.setup
|
||||||
Kemal.config.handlers[1].should eq post_handler
|
Kemal.config.handlers[1].should eq post_handler
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -9,38 +9,23 @@ describe "Macros" do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "#add_handler" do
|
describe "#use" do
|
||||||
it "adds a custom handler" do
|
it "adds a custom handler" do
|
||||||
add_handler CustomTestHandler.new
|
use CustomTestHandler.new
|
||||||
Kemal.config.setup
|
Kemal.config.setup
|
||||||
Kemal.config.handlers.size.should eq 8
|
Kemal.config.handlers.size.should eq 7
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "#logging" do
|
describe "#logging" do
|
||||||
it "sets logging status" do
|
it "sets logging status" do
|
||||||
logging false
|
logging false
|
||||||
Kemal.config.logging.should eq false
|
Kemal.config.logging.should be_false
|
||||||
end
|
|
||||||
|
|
||||||
it "sets a custom logger" do
|
|
||||||
config = Kemal::Config::INSTANCE
|
|
||||||
logger CustomLogHandler.new
|
|
||||||
config.logger.should be_a(CustomLogHandler)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "#halt" do
|
describe "#halt" do
|
||||||
it "can break block with halt macro" 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|
|
get "/breaking" do |env|
|
||||||
halt env, 404, "hello"
|
halt env, 404, "hello"
|
||||||
"world"
|
"world"
|
||||||
|
|
@ -61,6 +46,61 @@ describe "Macros" do
|
||||||
client_response.status_code.should eq(200)
|
client_response.status_code.should eq(200)
|
||||||
client_response.body.should eq("")
|
client_response.body.should eq("")
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "#callbacks" do
|
describe "#callbacks" do
|
||||||
|
|
@ -79,6 +119,23 @@ describe "Macros" do
|
||||||
client_response.status_code.should eq(400)
|
client_response.status_code.should eq(400)
|
||||||
client_response.body.should eq("Missing origin.")
|
client_response.body.should eq("Missing origin.")
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "#headers" do
|
describe "#headers" do
|
||||||
|
|
@ -145,29 +202,139 @@ describe "Macros" do
|
||||||
response.status_code.should eq(200)
|
response.status_code.should eq(200)
|
||||||
response.headers["Content-Disposition"].should eq("attachment; filename=\"image.jpg\"")
|
response.headers["Content-Disposition"].should eq("attachment; filename=\"image.jpg\"")
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "#gzip" do
|
describe "#gzip" do
|
||||||
it "adds HTTP::CompressHandler to handlers" do
|
it "adds HTTP::CompressHandler to handlers" do
|
||||||
gzip true
|
gzip true
|
||||||
Kemal.config.setup
|
Kemal.config.setup
|
||||||
Kemal.config.handlers[5].should be_a(HTTP::CompressHandler)
|
Kemal.config.handlers[4].should be_a(HTTP::CompressHandler)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "#serve_static" do
|
describe "#serve_static" do
|
||||||
it "should disable static file hosting" do
|
it "should disable static file hosting" do
|
||||||
serve_static false
|
serve_static false
|
||||||
Kemal.config.serve_static.should eq false
|
Kemal.config.serve_static.should be_false
|
||||||
end
|
end
|
||||||
|
|
||||||
it "should disble enable gzip and dir_listing" do
|
it "should enable gzip and dir_listing" do
|
||||||
serve_static({"gzip" => true, "dir_listing" => true})
|
serve_static({"gzip" => true, "dir_listing" => true})
|
||||||
conf = Kemal.config.serve_static
|
conf = Kemal.config.serve_static
|
||||||
conf.is_a?(Hash).should eq true
|
conf.is_a?(Hash).should be_true
|
||||||
if conf.is_a?(Hash)
|
if conf.is_a?(Hash)
|
||||||
conf["gzip"].should eq true
|
conf["gzip"].should be_true
|
||||||
conf["dir_listing"].should eq true
|
conf["dir_listing"].should be_true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,6 @@
|
||||||
require "./spec_helper"
|
require "./spec_helper"
|
||||||
|
|
||||||
describe "Kemal::LogHandler" do
|
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
|
it "creates log message for each request" do
|
||||||
request = HTTP::Request.new("GET", "/")
|
request = HTTP::Request.new("GET", "/")
|
||||||
io = IO::Memory.new
|
io = IO::Memory.new
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,22 @@ describe "Kemal::FilterHandler" do
|
||||||
client_response = HTTP::Client::Response.from_io(io_with_context, decompress: false)
|
client_response = HTTP::Client::Response.from_io(io_with_context, decompress: false)
|
||||||
client_response.body.should eq("true-true")
|
client_response.body.should eq("true-true")
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
class FilterTest
|
class FilterTest
|
||||||
|
|
|
||||||
|
|
@ -26,4 +26,48 @@ describe "Kemal::OverrideMethodHandler" do
|
||||||
|
|
||||||
context.request.method.should eq "PATCH"
|
context.request.method.should eq "PATCH"
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,7 @@ describe "ParamParser" do
|
||||||
body_params.to_s.should eq("")
|
body_params.to_s.should eq("")
|
||||||
|
|
||||||
json_params = Kemal::ParamParser.new(request).json
|
json_params = Kemal::ParamParser.new(request).json
|
||||||
json_params.should eq({} of String => Nil | String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any))
|
json_params.should eq({} of String => String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any)?)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -201,4 +201,127 @@ describe "ParamParser" do
|
||||||
body_params.to_s.should eq("")
|
body_params.to_s.should eq("")
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
265
spec/path_handler_spec.cr
Normal file
265
spec/path_handler_spec.cr
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
require "./spec_helper"
|
||||||
|
|
||||||
|
# Test middleware that sets a header
|
||||||
|
class TestHeaderHandler < Kemal::Handler
|
||||||
|
def initialize(@header_name : String, @header_value : String)
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
env.response.headers[@header_name] = @header_value
|
||||||
|
call_next(env)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test middleware that blocks requests
|
||||||
|
class BlockingHandler < Kemal::Handler
|
||||||
|
def call(env)
|
||||||
|
env.response.status_code = 401
|
||||||
|
env.response.print "Blocked"
|
||||||
|
# Don't call_next - stop the chain
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test middleware that sets context value
|
||||||
|
class ContextSetterHandler < Kemal::Handler
|
||||||
|
def initialize(@key : String, @value : String)
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
env.set @key, @value
|
||||||
|
call_next(env)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "PathHandler" do
|
||||||
|
describe "use (global)" do
|
||||||
|
it "adds middleware that runs for all requests" do
|
||||||
|
use TestHeaderHandler.new("X-Global", "yes")
|
||||||
|
|
||||||
|
get "/test1" do
|
||||||
|
"test1"
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/other/path" do
|
||||||
|
"other"
|
||||||
|
end
|
||||||
|
|
||||||
|
request1 = HTTP::Request.new("GET", "/test1")
|
||||||
|
response1 = call_request_on_app(request1)
|
||||||
|
response1.headers["X-Global"].should eq("yes")
|
||||||
|
|
||||||
|
request2 = HTTP::Request.new("GET", "/other/path")
|
||||||
|
response2 = call_request_on_app(request2)
|
||||||
|
response2.headers["X-Global"].should eq("yes")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "use with path prefix" do
|
||||||
|
it "runs middleware only for matching path prefix" do
|
||||||
|
use "/api", TestHeaderHandler.new("X-API", "true")
|
||||||
|
|
||||||
|
get "/api/users" do
|
||||||
|
"api users"
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/web/home" do
|
||||||
|
"web home"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Should have header for /api/*
|
||||||
|
api_request = HTTP::Request.new("GET", "/api/users")
|
||||||
|
api_response = call_request_on_app(api_request)
|
||||||
|
api_response.headers["X-API"]?.should eq("true")
|
||||||
|
api_response.body.should eq("api users")
|
||||||
|
|
||||||
|
# Should NOT have header for /web/*
|
||||||
|
web_request = HTTP::Request.new("GET", "/web/home")
|
||||||
|
web_response = call_request_on_app(web_request)
|
||||||
|
web_response.headers["X-API"]?.should be_nil
|
||||||
|
web_response.body.should eq("web home")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "matches exact path" do
|
||||||
|
use "/api", TestHeaderHandler.new("X-Exact", "matched")
|
||||||
|
|
||||||
|
get "/api" do
|
||||||
|
"api root"
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/api")
|
||||||
|
response = call_request_on_app(request)
|
||||||
|
response.headers["X-Exact"]?.should eq("matched")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "matches nested paths" do
|
||||||
|
use "/api", TestHeaderHandler.new("X-Nested", "yes")
|
||||||
|
|
||||||
|
get "/api/v1/users/123/posts" do
|
||||||
|
"nested"
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/api/v1/users/123/posts")
|
||||||
|
response = call_request_on_app(request)
|
||||||
|
response.headers["X-Nested"]?.should eq("yes")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not match similar prefixes" do
|
||||||
|
use "/api", TestHeaderHandler.new("X-API-Only", "true")
|
||||||
|
|
||||||
|
get "/apiv2/users" do
|
||||||
|
"apiv2"
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/api-old/users" do
|
||||||
|
"api-old"
|
||||||
|
end
|
||||||
|
|
||||||
|
# /apiv2 should NOT match /api
|
||||||
|
request1 = HTTP::Request.new("GET", "/apiv2/users")
|
||||||
|
response1 = call_request_on_app(request1)
|
||||||
|
response1.headers["X-API-Only"]?.should be_nil
|
||||||
|
|
||||||
|
# /api-old should NOT match /api
|
||||||
|
request2 = HTTP::Request.new("GET", "/api-old/users")
|
||||||
|
response2 = call_request_on_app(request2)
|
||||||
|
response2.headers["X-API-Only"]?.should be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not match root when prefix is set" do
|
||||||
|
use "/admin", TestHeaderHandler.new("X-Admin", "true")
|
||||||
|
|
||||||
|
get "/" do
|
||||||
|
"home"
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/")
|
||||||
|
response = call_request_on_app(request)
|
||||||
|
response.headers["X-Admin"]?.should be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "multiple middlewares" do
|
||||||
|
it "runs multiple middlewares in order" do
|
||||||
|
use "/api", TestHeaderHandler.new("X-First", "1")
|
||||||
|
use "/api", TestHeaderHandler.new("X-Second", "2")
|
||||||
|
|
||||||
|
get "/api/test" do
|
||||||
|
"test"
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/api/test")
|
||||||
|
response = call_request_on_app(request)
|
||||||
|
response.headers["X-First"]?.should eq("1")
|
||||||
|
response.headers["X-Second"]?.should eq("2")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "supports array of middlewares" do
|
||||||
|
use "/multi", [
|
||||||
|
TestHeaderHandler.new("X-A", "a"),
|
||||||
|
TestHeaderHandler.new("X-B", "b"),
|
||||||
|
TestHeaderHandler.new("X-C", "c"),
|
||||||
|
]
|
||||||
|
|
||||||
|
get "/multi/test" do
|
||||||
|
"multi"
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/multi/test")
|
||||||
|
response = call_request_on_app(request)
|
||||||
|
response.headers["X-A"]?.should eq("a")
|
||||||
|
response.headers["X-B"]?.should eq("b")
|
||||||
|
response.headers["X-C"]?.should eq("c")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "different paths have different middlewares" do
|
||||||
|
use "/api", TestHeaderHandler.new("X-API", "api")
|
||||||
|
use "/admin", TestHeaderHandler.new("X-Admin", "admin")
|
||||||
|
|
||||||
|
get "/api/data" do
|
||||||
|
"api data"
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/admin/dashboard" do
|
||||||
|
"admin dashboard"
|
||||||
|
end
|
||||||
|
|
||||||
|
api_request = HTTP::Request.new("GET", "/api/data")
|
||||||
|
api_response = call_request_on_app(api_request)
|
||||||
|
api_response.headers["X-API"]?.should eq("api")
|
||||||
|
api_response.headers["X-Admin"]?.should be_nil
|
||||||
|
|
||||||
|
admin_request = HTTP::Request.new("GET", "/admin/dashboard")
|
||||||
|
admin_response = call_request_on_app(admin_request)
|
||||||
|
admin_response.headers["X-Admin"]?.should eq("admin")
|
||||||
|
admin_response.headers["X-API"]?.should be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "middleware can block requests" do
|
||||||
|
it "middleware can stop the chain" do
|
||||||
|
use "/protected", BlockingHandler.new
|
||||||
|
|
||||||
|
get "/protected/secret" do
|
||||||
|
"secret data"
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/public" do
|
||||||
|
"public data"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Protected route should be blocked
|
||||||
|
protected_request = HTTP::Request.new("GET", "/protected/secret")
|
||||||
|
protected_response = call_request_on_app(protected_request)
|
||||||
|
protected_response.status_code.should eq(401)
|
||||||
|
protected_response.body.should eq("Blocked")
|
||||||
|
|
||||||
|
# Public route should work
|
||||||
|
public_request = HTTP::Request.new("GET", "/public")
|
||||||
|
public_response = call_request_on_app(public_request)
|
||||||
|
public_response.status_code.should eq(200)
|
||||||
|
public_response.body.should eq("public data")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "middleware with context" do
|
||||||
|
it "middleware can set context values" do
|
||||||
|
use "/ctx", ContextSetterHandler.new("middleware_ran", "yes")
|
||||||
|
|
||||||
|
get "/ctx/check" do |env|
|
||||||
|
env.get("middleware_ran").to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/ctx/check")
|
||||||
|
response = call_request_on_app(request)
|
||||||
|
response.body.should eq("yes")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "PathHandler" do
|
||||||
|
describe "#matches_prefix?" do
|
||||||
|
it "root prefix matches all" do
|
||||||
|
get "/anything" do
|
||||||
|
"ok"
|
||||||
|
end
|
||||||
|
|
||||||
|
use "/", TestHeaderHandler.new("X-Root", "all")
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/anything")
|
||||||
|
response = call_request_on_app(request)
|
||||||
|
response.headers["X-Root"]?.should eq("all")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "empty prefix matches all" do
|
||||||
|
use "", TestHeaderHandler.new("X-Empty", "all")
|
||||||
|
|
||||||
|
get "/some/path" do
|
||||||
|
"ok"
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/some/path")
|
||||||
|
response = call_request_on_app(request)
|
||||||
|
response.headers["X-Empty"]?.should eq("all")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
17
spec/request_log_handler_spec.cr
Normal file
17
spec/request_log_handler_spec.cr
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
require "log/spec"
|
||||||
|
require "./spec_helper"
|
||||||
|
|
||||||
|
describe Kemal::RequestLogHandler do
|
||||||
|
it "creates log message for each request" do
|
||||||
|
Log.setup(:none)
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/")
|
||||||
|
response = HTTP::Server::Response.new(IO::Memory.new)
|
||||||
|
context = HTTP::Server::Context.new(request, response)
|
||||||
|
logger = Kemal::RequestLogHandler.new
|
||||||
|
Log.capture do |logs|
|
||||||
|
logger.call(context)
|
||||||
|
logs.check(:info, /404 GET \/ \d+.*s/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
245
spec/response_helpers_spec.cr
Normal file
245
spec/response_helpers_spec.cr
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
require "./spec_helper"
|
||||||
|
|
||||||
|
describe "Response Helpers" do
|
||||||
|
describe "#json" do
|
||||||
|
it "sets content-type to application/json" do
|
||||||
|
get "/json-test" do |env|
|
||||||
|
env.json({message: "hello"})
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/json-test")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.headers["Content-Type"].should eq("application/json")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "serializes hash to JSON" do
|
||||||
|
get "/json-hash" do |env|
|
||||||
|
env.json({name: "alice", age: 30})
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/json-hash")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.body.should eq(%({"name":"alice","age":30}))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "serializes array to JSON" do
|
||||||
|
get "/json-array" do |env|
|
||||||
|
env.json([1, 2, 3])
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/json-array")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.body.should eq("[1,2,3]")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "accepts custom content_type (e.g. JSON API)" do
|
||||||
|
get "/json-api" do |env|
|
||||||
|
env.json({data: [] of String}, content_type: "application/vnd.api+json")
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/json-api")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.headers["Content-Type"].should eq("application/vnd.api+json")
|
||||||
|
client_response.body.should eq(%({"data":[]}))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#html" do
|
||||||
|
it "sets content-type to text/html" do
|
||||||
|
get "/html-test" do |env|
|
||||||
|
env.html("<h1>Hello</h1>")
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/html-test")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.headers["Content-Type"].should eq("text/html; charset=utf-8")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns HTML content" do
|
||||||
|
get "/html-content" do |env|
|
||||||
|
env.html("<div>Content</div>")
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/html-content")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.body.should eq("<div>Content</div>")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#text" do
|
||||||
|
it "sets content-type to text/plain" do
|
||||||
|
get "/text-test" do |env|
|
||||||
|
env.text("Hello World")
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/text-test")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.headers["Content-Type"].should eq("text/plain; charset=utf-8")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns plain text content" do
|
||||||
|
get "/text-content" do |env|
|
||||||
|
env.text("Plain text here")
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/text-content")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.body.should eq("Plain text here")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#xml" do
|
||||||
|
it "sets content-type to application/xml" do
|
||||||
|
get "/xml-test" do |env|
|
||||||
|
env.xml("<root></root>")
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/xml-test")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.headers["Content-Type"].should eq("application/xml; charset=utf-8")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns XML content" do
|
||||||
|
get "/xml-content" do |env|
|
||||||
|
env.xml(%(<?xml version="1.0"?><rss><channel></channel></rss>))
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/xml-content")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.body.should eq(%(<?xml version="1.0"?><rss><channel></channel></rss>))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#status" do
|
||||||
|
it "sets the response status code" do
|
||||||
|
get "/status-only" do |env|
|
||||||
|
env.status(204)
|
||||||
|
""
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/status-only")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.status_code.should eq(204)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "is chainable with json" do
|
||||||
|
get "/status-json" do |env|
|
||||||
|
env.status(201).json({id: 1, created: true})
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/status-json")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.status_code.should eq(201)
|
||||||
|
client_response.headers["Content-Type"].should eq("application/json")
|
||||||
|
client_response.body.should eq(%({"id":1,"created":true}))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "is chainable with html" do
|
||||||
|
get "/status-html" do |env|
|
||||||
|
env.status(404).html("<h1>Not Found</h1>")
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/status-html")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.status_code.should eq(404)
|
||||||
|
client_response.headers["Content-Type"].should eq("text/html; charset=utf-8")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "is chainable with text" do
|
||||||
|
get "/status-text" do |env|
|
||||||
|
env.status(500).text("Internal Server Error")
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/status-text")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.status_code.should eq(500)
|
||||||
|
client_response.headers["Content-Type"].should eq("text/plain; charset=utf-8")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "is chainable with xml" do
|
||||||
|
get "/status-xml" do |env|
|
||||||
|
env.status(400).xml("<error>Bad Request</error>")
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/status-xml")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.status_code.should eq(400)
|
||||||
|
client_response.headers["Content-Type"].should eq("application/xml; charset=utf-8")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "accepts HTTP::Status and is chainable with json" do
|
||||||
|
get "/status-enum-json" do |env|
|
||||||
|
env.status(:not_found).json({error: "User not found"})
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/status-enum-json")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.status_code.should eq(404)
|
||||||
|
client_response.headers["Content-Type"].should eq("application/json")
|
||||||
|
client_response.body.should contain("User not found")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "real-world scenarios" do
|
||||||
|
it "handles REST API create endpoint" do
|
||||||
|
post "/api/users" do |env|
|
||||||
|
env.status(201).json({id: 42, name: "Alice", created_at: "2024-01-01"})
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("POST", "/api/users")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.status_code.should eq(201)
|
||||||
|
client_response.headers["Content-Type"].should eq("application/json")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "handles REST API not found" do
|
||||||
|
get "/api/users/999" do |env|
|
||||||
|
env.status(404).json({error: "User not found", code: "USER_NOT_FOUND"})
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/api/users/999")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.status_code.should eq(404)
|
||||||
|
client_response.body.should contain("User not found")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "handles validation error" do
|
||||||
|
post "/api/validate" do |env|
|
||||||
|
env.status(422).json({
|
||||||
|
error: "Validation failed",
|
||||||
|
fields: {
|
||||||
|
email: "is invalid",
|
||||||
|
name: "is required",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("POST", "/api/validate")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.status_code.should eq(422)
|
||||||
|
client_response.body.should contain("Validation failed")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "handles health check endpoint" do
|
||||||
|
get "/health" do |env|
|
||||||
|
env.text("OK")
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/health")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.status_code.should eq(200)
|
||||||
|
client_response.body.should eq("OK")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "handles RSS feed" do
|
||||||
|
get "/feed.xml" do |env|
|
||||||
|
env.xml(%(<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>Blog</title></channel></rss>))
|
||||||
|
end
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/feed.xml")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.headers["Content-Type"].should eq("application/xml; charset=utf-8")
|
||||||
|
client_response.body.should contain("<rss")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -130,7 +130,7 @@ describe "Kemal::RouteHandler" do
|
||||||
client_response = call_request_on_app(request)
|
client_response = call_request_on_app(request)
|
||||||
client_response.status_code.should eq(302)
|
client_response.status_code.should eq(302)
|
||||||
client_response.body.should eq("")
|
client_response.body.should eq("")
|
||||||
client_response.headers.has_key?("Location").should eq(true)
|
client_response.headers.has_key?("Location").should be_true
|
||||||
end
|
end
|
||||||
|
|
||||||
it "redirects with body" do
|
it "redirects with body" do
|
||||||
|
|
@ -141,7 +141,7 @@ describe "Kemal::RouteHandler" do
|
||||||
client_response = call_request_on_app(request)
|
client_response = call_request_on_app(request)
|
||||||
client_response.status_code.should eq(302)
|
client_response.status_code.should eq(302)
|
||||||
client_response.body.should eq("Redirecting to /login")
|
client_response.body.should eq("Redirecting to /login")
|
||||||
client_response.headers.has_key?("Location").should eq(true)
|
client_response.headers.has_key?("Location").should be_true
|
||||||
end
|
end
|
||||||
|
|
||||||
it "redirects and closes response in before filter" do
|
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 = call_request_on_app(request)
|
||||||
client_response.status_code.should eq(302)
|
client_response.status_code.should eq(302)
|
||||||
client_response.body.should eq("")
|
client_response.body.should eq("")
|
||||||
client_response.headers.has_key?("Location").should eq(true)
|
client_response.headers.has_key?("Location").should be_true
|
||||||
end
|
end
|
||||||
|
|
||||||
it "redirects in before filter without closing response" do
|
it "redirects in before filter without closing response" do
|
||||||
|
|
@ -177,6 +177,136 @@ describe "Kemal::RouteHandler" do
|
||||||
client_response = call_request_on_app(request)
|
client_response = call_request_on_app(request)
|
||||||
client_response.status_code.should eq(302)
|
client_response.status_code.should eq(302)
|
||||||
client_response.body.should eq("home page")
|
client_response.body.should eq("home page")
|
||||||
client_response.headers.has_key?("Location").should eq(true)
|
client_response.headers.has_key?("Location").should be_true
|
||||||
|
end
|
||||||
|
|
||||||
|
context "LRU cache" do
|
||||||
|
it "evicts least recently used entries instead of clearing entirely" do
|
||||||
|
# Use a small capacity to make the test fast and deterministic
|
||||||
|
small_capacity = 8
|
||||||
|
# Replace the cache instance with a smaller-capacity LRU for this test
|
||||||
|
Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(small_capacity)
|
||||||
|
|
||||||
|
# Define more routes than capacity
|
||||||
|
0.upto(15) do |i|
|
||||||
|
get "/lru_eviction_#{i}" do
|
||||||
|
"ok"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Access the first `small_capacity` routes to fill the cache
|
||||||
|
0.upto(small_capacity - 1) do |i|
|
||||||
|
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_eviction_#{i}")
|
||||||
|
end
|
||||||
|
|
||||||
|
Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq small_capacity
|
||||||
|
|
||||||
|
# Access some new routes to trigger eviction
|
||||||
|
small_capacity.upto(small_capacity + 3) do |i|
|
||||||
|
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_eviction_#{i}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Cache should still be capped at capacity
|
||||||
|
Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq small_capacity
|
||||||
|
end
|
||||||
|
|
||||||
|
it "retains recently used keys and evicts the least recently used" do
|
||||||
|
small_capacity = 4
|
||||||
|
Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(small_capacity)
|
||||||
|
|
||||||
|
0.upto(5) do |i|
|
||||||
|
get "/lru_recency_#{i}" do
|
||||||
|
"ok"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Fill cache with 0..3
|
||||||
|
0.upto(3) do |i|
|
||||||
|
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_#{i}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Touch 0 and 1 to make them most recent
|
||||||
|
[0, 1].each do |i|
|
||||||
|
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_#{i}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Insert 4 -> should evict least recent among {2,3}
|
||||||
|
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_4")
|
||||||
|
|
||||||
|
# Insert 5 -> should evict the other of {2,3}
|
||||||
|
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_5")
|
||||||
|
|
||||||
|
# Now 0 and 1 must still resolve from cache, and size is capped
|
||||||
|
Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq small_capacity
|
||||||
|
|
||||||
|
# A fresh lookup for 0 and 1 should be cache hits and not raise
|
||||||
|
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_0").found?.should be_true
|
||||||
|
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_1").found?.should be_true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "caches HEAD fallback GET lookups without growing beyond 1 for same path" do
|
||||||
|
cap = 16
|
||||||
|
Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(cap)
|
||||||
|
|
||||||
|
get "/head_fallback" do
|
||||||
|
"ok"
|
||||||
|
end
|
||||||
|
|
||||||
|
# First HEAD should fallback to GET and cache one entry keyed by HEAD+path
|
||||||
|
Kemal::RouteHandler::INSTANCE.lookup_route("HEAD", "/head_fallback").found?.should be_true
|
||||||
|
Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq 1
|
||||||
|
|
||||||
|
# Second HEAD lookup should be a cache hit; size must remain 1
|
||||||
|
Kemal::RouteHandler::INSTANCE.lookup_route("HEAD", "/head_fallback").found?.should be_true
|
||||||
|
Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq 1
|
||||||
|
end
|
||||||
|
|
||||||
|
it "keeps size capped under heavy churn with large capacity" do
|
||||||
|
large_capacity = 4096
|
||||||
|
Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(large_capacity)
|
||||||
|
|
||||||
|
0.upto(12000) do |i|
|
||||||
|
get "/lru_heavy_#{i}" do
|
||||||
|
"ok"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Fill and churn beyond capacity
|
||||||
|
0.upto(11999) do |i|
|
||||||
|
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_heavy_#{i}")
|
||||||
|
end
|
||||||
|
|
||||||
|
Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq large_capacity
|
||||||
|
|
||||||
|
# Additional churn should not increase size
|
||||||
|
12000.upto(14000) do |i|
|
||||||
|
get "/lru_heavy_more_#{i}" do
|
||||||
|
"ok"
|
||||||
|
end
|
||||||
|
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_heavy_more_#{i}")
|
||||||
|
end
|
||||||
|
|
||||||
|
Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq large_capacity
|
||||||
|
end
|
||||||
|
|
||||||
|
it "handles concurrent lookups safely" do
|
||||||
|
Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(256)
|
||||||
|
|
||||||
|
get "/concurrent" do
|
||||||
|
"ok"
|
||||||
|
end
|
||||||
|
|
||||||
|
channel = Channel(Nil).new
|
||||||
|
fiber_count = 100
|
||||||
|
fiber_count.times do
|
||||||
|
spawn do
|
||||||
|
Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/concurrent").found?.should be_true
|
||||||
|
channel.send(nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
fiber_count.times { channel.receive }
|
||||||
|
|
||||||
|
Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq 1
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
474
spec/router_spec.cr
Normal file
474
spec/router_spec.cr
Normal file
|
|
@ -0,0 +1,474 @@
|
||||||
|
require "./spec_helper"
|
||||||
|
|
||||||
|
describe "Kemal::Router" do
|
||||||
|
describe "basic routing" do
|
||||||
|
it "routes GET request with prefix" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
router.get "/users" do
|
||||||
|
"users list"
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/api", router
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/api/users")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.body.should eq("users list")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "routes POST request with prefix" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
router.post "/users" do
|
||||||
|
"user created"
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/api", router
|
||||||
|
|
||||||
|
request = HTTP::Request.new("POST", "/api/users")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.body.should eq("user created")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "routes PUT request with prefix" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
router.put "/users/:id" do |env|
|
||||||
|
"user #{env.params.url["id"]} updated"
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/api", router
|
||||||
|
|
||||||
|
request = HTTP::Request.new("PUT", "/api/users/123")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.body.should eq("user 123 updated")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "routes DELETE request with prefix" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
router.delete "/users/:id" do |env|
|
||||||
|
"user #{env.params.url["id"]} deleted"
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/api", router
|
||||||
|
|
||||||
|
request = HTTP::Request.new("DELETE", "/api/users/456")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.body.should eq("user 456 deleted")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "routes PATCH request" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
router.patch "/users/:id" do
|
||||||
|
"user patched"
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/api", router
|
||||||
|
|
||||||
|
request = HTTP::Request.new("PATCH", "/api/users/1")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.body.should eq("user patched")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "routes OPTIONS request" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
router.options "/users" do |env|
|
||||||
|
env.response.headers["Allow"] = "GET, POST, OPTIONS"
|
||||||
|
""
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/api", router
|
||||||
|
|
||||||
|
request = HTTP::Request.new("OPTIONS", "/api/users")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.headers["Allow"].should eq("GET, POST, OPTIONS")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mounts router without prefix" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
router.get "/status" do
|
||||||
|
"ok"
|
||||||
|
end
|
||||||
|
|
||||||
|
mount router
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/status")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.body.should eq("ok")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "works alongside global DSL routes" do
|
||||||
|
# Global DSL route
|
||||||
|
get "/global" do
|
||||||
|
"global route"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Router route
|
||||||
|
router = Kemal::Router.new
|
||||||
|
router.get "/local" do
|
||||||
|
"router route"
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/api", router
|
||||||
|
|
||||||
|
# Test global route
|
||||||
|
global_request = HTTP::Request.new("GET", "/global")
|
||||||
|
global_response = call_request_on_app(global_request)
|
||||||
|
global_response.body.should eq("global route")
|
||||||
|
|
||||||
|
# Test router route
|
||||||
|
router_request = HTTP::Request.new("GET", "/api/local")
|
||||||
|
router_response = call_request_on_app(router_request)
|
||||||
|
router_response.body.should eq("router route")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "router-scoped filters" do
|
||||||
|
it "applies before filter to router routes" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
|
||||||
|
router.before do |env|
|
||||||
|
env.set "filtered", "yes"
|
||||||
|
end
|
||||||
|
|
||||||
|
router.get "/test" do |env|
|
||||||
|
env.get("filtered").to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/api", router
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/api/test")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.body.should eq("yes")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "applies after filter to router routes" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
|
||||||
|
router.after do |env|
|
||||||
|
env.response.headers["X-After-Filter"] = "applied"
|
||||||
|
end
|
||||||
|
|
||||||
|
router.get "/test" do
|
||||||
|
"test"
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/api", router
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/api/test")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.headers["X-After-Filter"].should eq("applied")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "applies method-specific before filter" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
|
||||||
|
router.before_post do |env|
|
||||||
|
env.set "method", "post"
|
||||||
|
end
|
||||||
|
|
||||||
|
router.post "/test" do |env|
|
||||||
|
env.get("method").to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
router.get "/test" do |env|
|
||||||
|
env.get?("method").to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/api", router
|
||||||
|
|
||||||
|
post_request = HTTP::Request.new("POST", "/api/test")
|
||||||
|
post_response = call_request_on_app(post_request)
|
||||||
|
post_response.body.should eq("post")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "applies filter to specific path" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
|
||||||
|
router.before "/protected" do |env|
|
||||||
|
env.set "auth", "required"
|
||||||
|
end
|
||||||
|
|
||||||
|
router.get "/protected" do |env|
|
||||||
|
env.get("auth").to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
router.get "/public" do |env|
|
||||||
|
env.get?("auth").to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/api", router
|
||||||
|
|
||||||
|
protected_request = HTTP::Request.new("GET", "/api/protected")
|
||||||
|
protected_response = call_request_on_app(protected_request)
|
||||||
|
protected_response.body.should eq("required")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "applies namespace filters only within the namespace" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
|
||||||
|
router.namespace "/admin" do
|
||||||
|
before do |env|
|
||||||
|
halt env, 401, "unauthorized" unless env.request.headers["X-Admin"]? == "true"
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/dashboard" do |env|
|
||||||
|
env.get("path").to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
router.get "/public" do |env|
|
||||||
|
env.get("path").to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/api", router
|
||||||
|
|
||||||
|
before_all do |env|
|
||||||
|
env.set "path", env.request.path
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/public" do |env|
|
||||||
|
env.get("path").to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
unauthorized_request = HTTP::Request.new("GET", "/api/admin/dashboard")
|
||||||
|
unauthorized_response = call_request_on_app(unauthorized_request)
|
||||||
|
unauthorized_response.status_code.should eq(401)
|
||||||
|
unauthorized_response.body.should eq("unauthorized")
|
||||||
|
|
||||||
|
authorized_request = HTTP::Request.new(
|
||||||
|
"GET",
|
||||||
|
"/api/admin/dashboard",
|
||||||
|
headers: HTTP::Headers{"X-Admin" => "true"},
|
||||||
|
)
|
||||||
|
authorized_response = call_request_on_app(authorized_request)
|
||||||
|
authorized_response.status_code.should eq(200)
|
||||||
|
authorized_response.body.should eq("/api/admin/dashboard")
|
||||||
|
|
||||||
|
api_public_request = HTTP::Request.new("GET", "/api/public")
|
||||||
|
api_public_response = call_request_on_app(api_public_request)
|
||||||
|
api_public_response.status_code.should eq(200)
|
||||||
|
api_public_response.body.should eq("/api/public")
|
||||||
|
|
||||||
|
public_request = HTTP::Request.new("GET", "/public")
|
||||||
|
public_response = call_request_on_app(public_request)
|
||||||
|
public_response.status_code.should eq(200)
|
||||||
|
public_response.body.should eq("/public")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "nested routers" do
|
||||||
|
it "namespaces routes correctly" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
|
||||||
|
router.namespace "/users" do
|
||||||
|
get "/" do
|
||||||
|
"users index"
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/:id" do |env|
|
||||||
|
"user #{env.params.url["id"]}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/api/v1", router
|
||||||
|
|
||||||
|
index_request = HTTP::Request.new("GET", "/api/v1/users")
|
||||||
|
index_response = call_request_on_app(index_request)
|
||||||
|
index_response.body.should eq("users index")
|
||||||
|
|
||||||
|
show_request = HTTP::Request.new("GET", "/api/v1/users/42")
|
||||||
|
show_response = call_request_on_app(show_request)
|
||||||
|
show_response.body.should eq("user 42")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "supports multiple namespaces" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
|
||||||
|
router.namespace "/users" do
|
||||||
|
get "/" do
|
||||||
|
"users"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
router.namespace "/posts" do
|
||||||
|
get "/" do
|
||||||
|
"posts"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/api", router
|
||||||
|
|
||||||
|
users_request = HTTP::Request.new("GET", "/api/users")
|
||||||
|
users_response = call_request_on_app(users_request)
|
||||||
|
users_response.body.should eq("users")
|
||||||
|
|
||||||
|
posts_request = HTTP::Request.new("GET", "/api/posts")
|
||||||
|
posts_response = call_request_on_app(posts_request)
|
||||||
|
posts_response.body.should eq("posts")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "supports deeply nested routers" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
|
||||||
|
router.namespace "/api" do
|
||||||
|
namespace "/v1" do
|
||||||
|
namespace "/users" do
|
||||||
|
get "/" do
|
||||||
|
"deeply nested users"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
mount router
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/api/v1/users")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.body.should eq("deeply nested users")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mounts sub-router with mount method" do
|
||||||
|
users_router = Kemal::Router.new
|
||||||
|
users_router.get "/" do
|
||||||
|
"users from sub-router"
|
||||||
|
end
|
||||||
|
users_router.get "/:id" do |env|
|
||||||
|
"user #{env.params.url["id"]} from sub-router"
|
||||||
|
end
|
||||||
|
|
||||||
|
api_router = Kemal::Router.new
|
||||||
|
api_router.mount "/users", users_router
|
||||||
|
|
||||||
|
mount "/api", api_router
|
||||||
|
|
||||||
|
index_request = HTTP::Request.new("GET", "/api/users")
|
||||||
|
index_response = call_request_on_app(index_request)
|
||||||
|
index_response.body.should eq("users from sub-router")
|
||||||
|
|
||||||
|
show_request = HTTP::Request.new("GET", "/api/users/99")
|
||||||
|
show_response = call_request_on_app(show_request)
|
||||||
|
show_response.body.should eq("user 99 from sub-router")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "applies namespace filters correctly" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
|
||||||
|
router.namespace "/admin" do
|
||||||
|
before do |env|
|
||||||
|
env.set "admin", "true"
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/dashboard" do |env|
|
||||||
|
"admin: #{env.get("admin")}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
mount router
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/admin/dashboard")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.body.should eq("admin: true")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "websocket support" do
|
||||||
|
it "registers websocket route with prefix" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
router.ws "/chat" do |socket|
|
||||||
|
socket.send("connected")
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/ws", router
|
||||||
|
|
||||||
|
handler = Kemal::WebSocketHandler::INSTANCE
|
||||||
|
headers = HTTP::Headers{
|
||||||
|
"Upgrade" => "websocket",
|
||||||
|
"Connection" => "Upgrade",
|
||||||
|
"Sec-WebSocket-Key" => "dGhlIHNhbXBsZSBub25jZQ==",
|
||||||
|
"Sec-WebSocket-Version" => "13",
|
||||||
|
}
|
||||||
|
request = HTTP::Request.new("GET", "/ws/chat", headers)
|
||||||
|
|
||||||
|
io_with_context = create_ws_request_and_return_io_and_context(handler, request)[0]
|
||||||
|
io_with_context.to_s.should contain("101 Switching Protocols")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "websocket route with url parameters" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
router.ws "/room/:id" do |socket|
|
||||||
|
socket.send("room")
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/ws", router
|
||||||
|
|
||||||
|
handler = Kemal::WebSocketHandler::INSTANCE
|
||||||
|
headers = HTTP::Headers{
|
||||||
|
"Upgrade" => "websocket",
|
||||||
|
"Connection" => "Upgrade",
|
||||||
|
"Sec-WebSocket-Key" => "dGhlIHNhbXBsZSBub25jZQ==",
|
||||||
|
"Sec-WebSocket-Version" => "13",
|
||||||
|
}
|
||||||
|
request = HTTP::Request.new("GET", "/ws/room/123", headers)
|
||||||
|
|
||||||
|
io_with_context = create_ws_request_and_return_io_and_context(handler, request)[0]
|
||||||
|
io_with_context.to_s.should contain("101 Switching Protocols")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "router with prefix" do
|
||||||
|
it "initializes router with prefix" do
|
||||||
|
router = Kemal::Router.new("/v2")
|
||||||
|
router.get "/status" do
|
||||||
|
"v2 status"
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/api", router
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/api/v2/status")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.body.should eq("v2 status")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "edge cases" do
|
||||||
|
it "handles trailing slashes correctly" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
router.get "/users/" do
|
||||||
|
"users with trailing slash"
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/api/", router
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/api/users/")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.body.should eq("users with trailing slash")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "handles root path in namespace" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
|
||||||
|
router.namespace "/users" do
|
||||||
|
get "" do
|
||||||
|
"users root"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
mount "/api", router
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/api/users")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.body.should eq("users root")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns non-string values as empty string" do
|
||||||
|
router = Kemal::Router.new
|
||||||
|
router.get "/number" do
|
||||||
|
42
|
||||||
|
end
|
||||||
|
|
||||||
|
mount router
|
||||||
|
|
||||||
|
request = HTTP::Request.new("GET", "/number")
|
||||||
|
client_response = call_request_on_app(request)
|
||||||
|
client_response.body.should eq("")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -3,6 +3,10 @@ require "./spec_helper"
|
||||||
private def run(code)
|
private def run(code)
|
||||||
code = <<-CR
|
code = <<-CR
|
||||||
require "./src/kemal"
|
require "./src/kemal"
|
||||||
|
|
||||||
|
Kemal.config.env = "test"
|
||||||
|
Kemal.config.port = 8000
|
||||||
|
|
||||||
#{code}
|
#{code}
|
||||||
CR
|
CR
|
||||||
|
|
||||||
|
|
@ -15,35 +19,56 @@ end
|
||||||
|
|
||||||
describe "Run" do
|
describe "Run" do
|
||||||
it "runs a code block after starting" do
|
it "runs a code block after starting" do
|
||||||
run(<<-CR).should eq "started\nstopped\n"
|
run(<<-CR).should contain("started")
|
||||||
Kemal.config.env = "test"
|
Kemal.run do
|
||||||
|
log "started"
|
||||||
|
end
|
||||||
|
CR
|
||||||
|
end
|
||||||
|
|
||||||
|
it "runs a code block after stopping" do
|
||||||
|
run(<<-CR).should contain("stopped")
|
||||||
Kemal.run do
|
Kemal.run do
|
||||||
puts "started"
|
|
||||||
Kemal.stop
|
Kemal.stop
|
||||||
puts "stopped"
|
log "stopped"
|
||||||
end
|
end
|
||||||
CR
|
CR
|
||||||
end
|
end
|
||||||
|
|
||||||
it "runs without a block being specified" do
|
it "runs without a block being specified" do
|
||||||
run(<<-CR).should contain "[test] Kemal is running in test mode."
|
run(<<-CR).should contain "[test] Kemal is running in test mode."
|
||||||
Kemal.config.env = "test"
|
|
||||||
Kemal.run
|
Kemal.run
|
||||||
puts Kemal.config.running
|
Kemal.config.running
|
||||||
CR
|
CR
|
||||||
end
|
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
|
it "allows custom HTTP::Server bind" do
|
||||||
run(<<-CR).should contain "[test] Kemal is running in test mode."
|
run(<<-CR).should contain "[test] Kemal is running in test mode."
|
||||||
Kemal.config.env = "test"
|
|
||||||
Kemal.run do |config|
|
Kemal.run do |config|
|
||||||
server = config.server.not_nil!
|
server = config.server.not_nil!
|
||||||
|
|
||||||
{% if flag?(:windows) %}
|
{% if flag?(:windows) %}
|
||||||
server.bind_tcp "127.0.0.1", 3000
|
server.bind_tcp "127.0.0.1", 8000
|
||||||
{% else %}
|
{% else %}
|
||||||
server.bind_tcp "127.0.0.1", 3000, reuse_port: true
|
server.bind_tcp "127.0.0.1", 8000, reuse_port: true
|
||||||
server.bind_tcp "0.0.0.0", 3001, reuse_port: true
|
server.bind_tcp "0.0.0.0", 8001, reuse_port: true
|
||||||
{% end %}
|
{% end %}
|
||||||
end
|
end
|
||||||
CR
|
CR
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,12 @@ class AnotherContextStorageType
|
||||||
@name = "kemal-context"
|
@name = "kemal-context"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
class CustomExceptionType < Exception
|
||||||
|
end
|
||||||
|
|
||||||
|
class ChildCustomExceptionType < CustomExceptionType
|
||||||
|
end
|
||||||
|
|
||||||
add_context_storage_type(TestContextStorageType)
|
add_context_storage_type(TestContextStorageType)
|
||||||
add_context_storage_type(AnotherContextStorageType)
|
add_context_storage_type(AnotherContextStorageType)
|
||||||
|
|
||||||
|
|
@ -87,6 +93,6 @@ Spec.after_each do
|
||||||
Kemal.config.clear
|
Kemal.config.clear
|
||||||
Kemal::FilterHandler::INSTANCE.tree = Radix::Tree(Array(Kemal::FilterHandler::FilterBlock)).new
|
Kemal::FilterHandler::INSTANCE.tree = Radix::Tree(Array(Kemal::FilterHandler::FilterBlock)).new
|
||||||
Kemal::RouteHandler::INSTANCE.routes = Radix::Tree(Route).new
|
Kemal::RouteHandler::INSTANCE.routes = Radix::Tree(Route).new
|
||||||
Kemal::RouteHandler::INSTANCE.cached_routes = Hash(String, Radix::Result(Route)).new
|
Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(Kemal.config.max_route_cache_size)
|
||||||
Kemal::WebSocketHandler::INSTANCE.routes = Radix::Tree(WebSocket).new
|
Kemal::WebSocketHandler::INSTANCE.routes = Radix::Tree(WebSocket).new
|
||||||
end
|
end
|
||||||
|
|
|
||||||
2
spec/static/dir/nested/path/test.txt
Normal file
2
spec/static/dir/nested/path/test.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
hello
|
||||||
|
world
|
||||||
|
|
@ -13,6 +13,7 @@ end
|
||||||
|
|
||||||
describe Kemal::StaticFileHandler do
|
describe Kemal::StaticFileHandler do
|
||||||
file = File.open "#{__DIR__}/static/dir/test.txt"
|
file = File.open "#{__DIR__}/static/dir/test.txt"
|
||||||
|
File.open "#{__DIR__}/static/dir/nested/path/test.txt"
|
||||||
file_size = file.size
|
file_size = file.size
|
||||||
|
|
||||||
it "should serve a file with content type and etag" do
|
it "should serve a file with content type and etag" do
|
||||||
|
|
@ -97,12 +98,12 @@ describe Kemal::StaticFileHandler do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "should handle only GET and HEAD method" do
|
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 = handle HTTP::Request.new(method, "/dir/test.txt")
|
||||||
response.status_code.should eq(200)
|
response.status_code.should eq(200)
|
||||||
end
|
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 = handle HTTP::Request.new(method, "/dir/test.txt")
|
||||||
response.status_code.should eq(404)
|
response.status_code.should eq(404)
|
||||||
response = handle HTTP::Request.new(method, "/dir/test.txt"), false
|
response = handle HTTP::Request.new(method, "/dir/test.txt"), false
|
||||||
|
|
@ -112,19 +113,18 @@ describe Kemal::StaticFileHandler do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "should send part of files when requested (RFC7233)" do
|
it "should send part of files when requested (RFC7233)" do
|
||||||
%w(POST PUT DELETE HEAD).each do |method|
|
%w[POST PUT DELETE HEAD].each do |method|
|
||||||
headers = HTTP::Headers{"Range" => "0-100"}
|
headers = HTTP::Headers{"Range" => "bytes=0-4"}
|
||||||
response = handle HTTP::Request.new(method, "/dir/test.txt", headers)
|
response = handle HTTP::Request.new(method, "/dir/test.txt", headers)
|
||||||
response.status_code.should_not eq(206)
|
response.status_code.should_not eq(206)
|
||||||
response.headers.has_key?("Content-Range").should eq(false)
|
response.headers.has_key?("Content-Range").should be_false
|
||||||
end
|
end
|
||||||
|
|
||||||
%w(GET).each do |method|
|
%w[GET].each do |method|
|
||||||
headers = HTTP::Headers{"Range" => "0-100"}
|
headers = HTTP::Headers{"Range" => "bytes=0-4"}
|
||||||
response = handle HTTP::Request.new(method, "/dir/test.txt", headers)
|
response = handle HTTP::Request.new(method, "/dir/test.txt", headers)
|
||||||
response.status_code.should eq(206 || 200)
|
response.status_code.should eq(206)
|
||||||
if response.status_code == 206
|
response.headers.has_key?("Content-Range").should be_true
|
||||||
response.headers.has_key?("Content-Range").should eq true
|
|
||||||
match = response.headers["Content-Range"].match(/bytes (\d+)-(\d+)\/(\d+)/)
|
match = response.headers["Content-Range"].match(/bytes (\d+)-(\d+)\/(\d+)/)
|
||||||
match.should_not be_nil
|
match.should_not be_nil
|
||||||
if match
|
if match
|
||||||
|
|
@ -133,15 +133,14 @@ describe Kemal::StaticFileHandler do
|
||||||
range_size = match[3].to_i { 0 }
|
range_size = match[3].to_i { 0 }
|
||||||
|
|
||||||
range_size.should eq file_size
|
range_size.should eq file_size
|
||||||
(end_range < file_size).should eq true
|
(end_range < file_size).should be_true
|
||||||
(start_range < end_range).should eq true
|
(start_range < end_range).should be_true
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "should handle setting custom headers" do
|
it "should handle setting custom headers" do
|
||||||
headers = Proc(HTTP::Server::Context, String, File::Info, Void).new do |env, path, stat|
|
headers = Proc(HTTP::Server::Context, String, File::Info, Nil).new do |env, path, stat|
|
||||||
if path =~ /\.html$/
|
if path =~ /\.html$/
|
||||||
env.response.headers.add("Access-Control-Allow-Origin", "*")
|
env.response.headers.add("Access-Control-Allow-Origin", "*")
|
||||||
end
|
end
|
||||||
|
|
@ -159,4 +158,30 @@ describe Kemal::StaticFileHandler do
|
||||||
response = handle HTTP::Request.new("GET", "/dir/index.html")
|
response = handle HTTP::Request.new("GET", "/dir/index.html")
|
||||||
response.headers["Access-Control-Allow-Origin"].should eq("*")
|
response.headers["Access-Control-Allow-Origin"].should eq("*")
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
require "./spec_helper"
|
require "./spec_helper"
|
||||||
|
|
||||||
macro render_with_base_and_layout(filename)
|
macro render_with_base_and_layout(filename)
|
||||||
render "#{__DIR__}/asset/#{{{filename}}}", "#{__DIR__}/asset/layout.ecr"
|
render "#{__DIR__}/asset/#{{{ filename }}}", "#{__DIR__}/asset/layout.ecr"
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Views" do
|
describe "Views" do
|
||||||
|
|
@ -38,8 +38,8 @@ describe "Views" do
|
||||||
it "renders layout with variables" do
|
it "renders layout with variables" do
|
||||||
get "/view/:name" do |env|
|
get "/view/:name" do |env|
|
||||||
name = env.params.url["name"]
|
name = env.params.url["name"]
|
||||||
var1 = "serdar"
|
var1 = "serdar" # ameba:disable Lint/UselessAssign
|
||||||
var2 = "kemal"
|
var2 = "kemal" # ameba:disable Lint/UselessAssign
|
||||||
render "#{__DIR__}/asset/hello_with_content_for.ecr", "#{__DIR__}/asset/layout_with_yield_and_vars.ecr"
|
render "#{__DIR__}/asset/hello_with_content_for.ecr", "#{__DIR__}/asset/layout_with_yield_and_vars.ecr"
|
||||||
end
|
end
|
||||||
request = HTTP::Request.new("GET", "/view/world")
|
request = HTTP::Request.new("GET", "/view/world")
|
||||||
|
|
|
||||||
30
src/kemal.cr
30
src/kemal.cr
|
|
@ -1,24 +1,27 @@
|
||||||
require "http"
|
require "http"
|
||||||
require "json"
|
require "json"
|
||||||
|
require "log"
|
||||||
require "uri"
|
require "uri"
|
||||||
require "./kemal/*"
|
require "./kemal/*"
|
||||||
require "./kemal/ext/*"
|
require "./kemal/ext/*"
|
||||||
require "./kemal/helpers/*"
|
require "./kemal/helpers/*"
|
||||||
|
|
||||||
module Kemal
|
module Kemal
|
||||||
|
Log = ::Log.for(self)
|
||||||
|
|
||||||
# Overload of `self.run` with the default startup logging.
|
# Overload of `self.run` with the default startup logging.
|
||||||
def self.run(port : Int32?, args = ARGV, trap_signal : Bool = true)
|
def self.run(port : Int32?, args = ARGV, trap_signal : Bool = true)
|
||||||
self.run(port, args, trap_signal) { }
|
run(port, args, trap_signal) { }
|
||||||
end
|
end
|
||||||
|
|
||||||
# Overload of `self.run` without port.
|
# Overload of `self.run` without port.
|
||||||
def self.run(args = ARGV, trap_signal : Bool = true)
|
def self.run(args = ARGV, trap_signal : Bool = true)
|
||||||
self.run(nil, args: args, trap_signal: trap_signal)
|
run(nil, args: args, trap_signal: trap_signal)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Overload of `self.run` to allow just a block.
|
# Overload of `self.run` to allow just a block.
|
||||||
def self.run(args = ARGV, &block)
|
def self.run(args = ARGV, &block)
|
||||||
self.run(nil, args: args, trap_signal: true, &block)
|
run(nil, args: args, trap_signal: true, &block)
|
||||||
end
|
end
|
||||||
|
|
||||||
# The command to run a `Kemal` application.
|
# The command to run a `Kemal` application.
|
||||||
|
|
@ -46,9 +49,10 @@ module Kemal
|
||||||
yield config
|
yield config
|
||||||
|
|
||||||
# Abort if block called `Kemal.stop`
|
# Abort if block called `Kemal.stop`
|
||||||
return unless config.running
|
return if !config.running
|
||||||
|
|
||||||
unless server.each_address { |_| break true }
|
if config.env != "test"
|
||||||
|
if !server.each_address { |_| break true }
|
||||||
{% if flag?(:without_openssl) %}
|
{% if flag?(:without_openssl) %}
|
||||||
server.bind_tcp(config.host_binding, config.port)
|
server.bind_tcp(config.host_binding, config.port)
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -59,28 +63,32 @@ module Kemal
|
||||||
end
|
end
|
||||||
{% end %}
|
{% end %}
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
display_startup_message(config, server)
|
display_startup_message(config, server)
|
||||||
|
|
||||||
server.listen unless config.env == "test"
|
server.listen if config.env != "test"
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.display_startup_message(config, server)
|
def self.display_startup_message(config, server)
|
||||||
if config.env != "test"
|
if config.env != "test"
|
||||||
addresses = server.addresses.join ", " { |address| "#{config.scheme}://#{address}" }
|
addresses = server.addresses.join ", " { |address| "#{config.scheme}://#{address}" }
|
||||||
log "[#{config.env}] #{config.app_name} is ready to lead at #{addresses}"
|
Log.info { "[#{config.env}] #{config.app_name} is ready to lead at #{addresses}" }
|
||||||
else
|
else
|
||||||
log "[#{config.env}] #{config.app_name} is running in test mode. Server not listening"
|
Log.info { "[#{config.env}] #{config.app_name} is running in test mode. Server not listening" }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.stop
|
def self.stop
|
||||||
raise "#{Kemal.config.app_name} is already stopped." if !config.running
|
raise "#{Kemal.config.app_name} is already stopped. Cannot stop an already stopped server." if !config.running
|
||||||
if server = config.server
|
if server = config.server
|
||||||
server.close unless server.closed?
|
server.close unless server.closed?
|
||||||
config.running = false
|
config.running = false
|
||||||
|
if config.shutdown_timeout.positive?
|
||||||
|
sleep(config.shutdown_timeout)
|
||||||
|
end
|
||||||
else
|
else
|
||||||
raise "Kemal.config.server is not set. Please use Kemal.run to set the server."
|
raise "Cannot stop #{Kemal.config.app_name}: server instance is not set. Please ensure Kemal.run has been called before calling Kemal.stop."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -94,7 +102,7 @@ module Kemal
|
||||||
|
|
||||||
private def self.setup_trap_signal
|
private def self.setup_trap_signal
|
||||||
Process.on_terminate do
|
Process.on_terminate do
|
||||||
log "#{Kemal.config.app_name} is going to take a rest!" if Kemal.config.shutdown_message
|
Log.info { "#{Kemal.config.app_name} is going to take a rest!" } if Kemal.config.shutdown_message
|
||||||
Kemal.stop
|
Kemal.stop
|
||||||
exit
|
exit
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,11 @@ module Kemal
|
||||||
private def configure_ssl
|
private def configure_ssl
|
||||||
{% if !flag?(:without_openssl) %}
|
{% if !flag?(:without_openssl) %}
|
||||||
if @ssl_enabled
|
if @ssl_enabled
|
||||||
abort "SSL Key Not Found" if !@key_file
|
abort "SSL configuration error: SSL key file not specified. Use --ssl-key-file FILE to specify the key file." if @key_file.empty?
|
||||||
abort "SSL Certificate Not Found" if !@cert_file
|
abort "SSL configuration error: SSL certificate file not specified. Use --ssl-cert-file FILE to specify the certificate file." if @cert_file.empty?
|
||||||
ssl = Kemal::SSL.new
|
ssl = Kemal::SSL.new
|
||||||
ssl.key_file = @key_file.not_nil!
|
ssl.key_file = @key_file
|
||||||
ssl.cert_file = @cert_file.not_nil!
|
ssl.cert_file = @cert_file
|
||||||
Kemal.config.ssl = ssl.context
|
Kemal.config.ssl = ssl.context
|
||||||
end
|
end
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@ module Kemal
|
||||||
class Config
|
class Config
|
||||||
INSTANCE = Config.new
|
INSTANCE = Config.new
|
||||||
HANDLERS = [] of HTTP::Handler
|
HANDLERS = [] of HTTP::Handler
|
||||||
CUSTOM_HANDLERS = [] of Tuple(Nil | Int32, HTTP::Handler)
|
CUSTOM_HANDLERS = [] of Tuple(Int32?, HTTP::Handler)
|
||||||
FILTER_HANDLERS = [] of HTTP::Handler
|
FILTER_HANDLERS = [] of HTTP::Handler
|
||||||
ERROR_HANDLERS = {} of Int32 => HTTP::Server::Context, Exception -> String
|
ERROR_HANDLERS = {} of Int32 => HTTP::Server::Context, Exception -> String
|
||||||
|
EXCEPTION_HANDLERS = {} of Exception.class => HTTP::Server::Context, Exception -> String
|
||||||
|
|
||||||
{% if flag?(:without_openssl) %}
|
{% if flag?(:without_openssl) %}
|
||||||
@ssl : Bool?
|
@ssl : Bool?
|
||||||
|
|
@ -21,10 +22,12 @@ module Kemal
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|
||||||
property app_name, host_binding, ssl, port, env, public_folder, logging, running
|
property app_name, host_binding, ssl, port, env, public_folder, logging, running
|
||||||
property always_rescue, server : HTTP::Server?, extra_options, shutdown_message
|
property always_rescue, server : HTTP::Server?, extra_options, shutdown_message, shutdown_timeout
|
||||||
property serve_static : (Bool | Hash(String, Bool))
|
property serve_static : (Bool | Hash(String, Bool))
|
||||||
property static_headers : (HTTP::Server::Context, String, File::Info -> Void)?
|
property static_headers : (HTTP::Server::Context, String, File::Info ->)?
|
||||||
property? powered_by_header : Bool = true
|
property? powered_by_header : Bool = true
|
||||||
|
property max_route_cache_size : Int32
|
||||||
|
property max_request_body_size : Int32
|
||||||
|
|
||||||
def initialize
|
def initialize
|
||||||
@app_name = "Kemal"
|
@app_name = "Kemal"
|
||||||
|
|
@ -41,13 +44,23 @@ module Kemal
|
||||||
@default_handlers_setup = false
|
@default_handlers_setup = false
|
||||||
@running = false
|
@running = false
|
||||||
@shutdown_message = true
|
@shutdown_message = true
|
||||||
|
@shutdown_timeout = 0.seconds
|
||||||
@handler_position = 0
|
@handler_position = 0
|
||||||
|
@max_route_cache_size = 1024
|
||||||
|
@max_request_body_size = 8 * 1024 * 1024 # 8MB
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@[Deprecated("Use standard library Log")]
|
||||||
def logger
|
def logger
|
||||||
@logger.not_nil!
|
@logger || NullLogHandler.new
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# :nodoc:
|
||||||
|
def logger?
|
||||||
|
@logger
|
||||||
|
end
|
||||||
|
|
||||||
|
@[Deprecated("Use standard library Log")]
|
||||||
def logger=(logger : Kemal::BaseLogHandler)
|
def logger=(logger : Kemal::BaseLogHandler)
|
||||||
@logger = logger
|
@logger = logger
|
||||||
end
|
end
|
||||||
|
|
@ -61,10 +74,13 @@ module Kemal
|
||||||
@router_included = false
|
@router_included = false
|
||||||
@handler_position = 0
|
@handler_position = 0
|
||||||
@default_handlers_setup = false
|
@default_handlers_setup = false
|
||||||
|
@max_route_cache_size = 1024
|
||||||
|
@max_request_body_size = 8 * 1024 * 1024
|
||||||
HANDLERS.clear
|
HANDLERS.clear
|
||||||
CUSTOM_HANDLERS.clear
|
CUSTOM_HANDLERS.clear
|
||||||
FILTER_HANDLERS.clear
|
FILTER_HANDLERS.clear
|
||||||
ERROR_HANDLERS.clear
|
ERROR_HANDLERS.clear
|
||||||
|
EXCEPTION_HANDLERS.clear
|
||||||
end
|
end
|
||||||
|
|
||||||
def handlers
|
def handlers
|
||||||
|
|
@ -88,14 +104,26 @@ module Kemal
|
||||||
FILTER_HANDLERS << handler
|
FILTER_HANDLERS << handler
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Returns the defined error handlers for HTTP status codes
|
||||||
def error_handlers
|
def error_handlers
|
||||||
ERROR_HANDLERS
|
ERROR_HANDLERS
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Adds an error handler for the given HTTP status code
|
||||||
def add_error_handler(status_code : Int32, &handler : HTTP::Server::Context, Exception -> _)
|
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 }
|
ERROR_HANDLERS[status_code] = ->(context : HTTP::Server::Context, error : Exception) { handler.call(context, error).to_s }
|
||||||
end
|
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 ->)
|
def extra_options(&@extra_options : OptionParser ->)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -121,12 +149,11 @@ module Kemal
|
||||||
end
|
end
|
||||||
|
|
||||||
private def setup_log_handler
|
private def setup_log_handler
|
||||||
@logger ||= if @logging
|
return unless @logging
|
||||||
Kemal::LogHandler.new
|
|
||||||
else
|
log_handler = @logger || Kemal::RequestLogHandler.new
|
||||||
Kemal::NullLogHandler.new
|
|
||||||
end
|
HANDLERS.insert(@handler_position, log_handler)
|
||||||
HANDLERS.insert(@handler_position, @logger.not_nil!)
|
|
||||||
@handler_position += 1
|
@handler_position += 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -137,8 +164,8 @@ module Kemal
|
||||||
|
|
||||||
private def setup_error_handler
|
private def setup_error_handler
|
||||||
if @always_rescue
|
if @always_rescue
|
||||||
@error_handler ||= Kemal::ExceptionHandler.new
|
handler = @error_handler ||= Kemal::ExceptionHandler.new
|
||||||
HANDLERS.insert(@handler_position, @error_handler.not_nil!)
|
HANDLERS.insert(@handler_position, handler)
|
||||||
@handler_position += 1
|
@handler_position += 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
184
src/kemal/dsl.cr
184
src/kemal/dsl.cr
|
|
@ -1,43 +1,195 @@
|
||||||
# Kemal DSL is defined here and it's baked into global scope.
|
# Kemal DSL is defined here and it's baked into global scope.
|
||||||
|
# These methods are available globally in your application.
|
||||||
#
|
#
|
||||||
# The DSL currently consists of:
|
# ## Available DSL Methods
|
||||||
#
|
#
|
||||||
# - get post put patch delete options
|
# - **HTTP Routes**: `get`, `post`, `put`, `patch`, `delete`, `options`
|
||||||
# - WebSocket(ws)
|
# - **WebSocket**: `ws`
|
||||||
# - before_*
|
# - **Filters**: `before_all`, `before_get`, `after_all`, `after_get`, etc.
|
||||||
# - error
|
# - **Error Handling**: `error`
|
||||||
|
# - **Modular Routing**: `mount`
|
||||||
HTTP_METHODS = %w(get post put patch delete options head)
|
HTTP_METHODS = %w(get post put patch delete options head)
|
||||||
FILTER_METHODS = %w(get post put patch delete options head all)
|
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 %}
|
{% for method in HTTP_METHODS %}
|
||||||
def {{method.id}}(path : String, &block : HTTP::Server::Context -> _)
|
def {{ method.id }}(path : String, &block : HTTP::Server::Context -> _)
|
||||||
raise Kemal::Exceptions::InvalidPathStartException.new({{method}}, path) unless Kemal::Utils.path_starts_with_slash?(path)
|
raise Kemal::Exceptions::InvalidPathStartException.new({{ method }}, path) unless Kemal::Utils.path_starts_with_slash?(path)
|
||||||
Kemal::RouteHandler::INSTANCE.add_route({{method}}.upcase, path, &block)
|
Kemal::RouteHandler::INSTANCE.add_route({{ method }}.upcase, path, &block)
|
||||||
end
|
end
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|
||||||
def ws(path : String, &block : HTTP::WebSocket, HTTP::Server::Context -> Void)
|
# Defines a WebSocket route.
|
||||||
|
#
|
||||||
|
# NOTE: The path must start with a `/`.
|
||||||
|
#
|
||||||
|
# ```
|
||||||
|
# ws "/chat" do |socket, env|
|
||||||
|
# socket.on_message do |msg|
|
||||||
|
# socket.send "Echo: #{msg}"
|
||||||
|
# end
|
||||||
|
# end
|
||||||
|
# ```
|
||||||
|
def ws(path : String, &block : HTTP::WebSocket, HTTP::Server::Context ->)
|
||||||
raise Kemal::Exceptions::InvalidPathStartException.new("ws", path) unless Kemal::Utils.path_starts_with_slash?(path)
|
raise Kemal::Exceptions::InvalidPathStartException.new("ws", path) unless Kemal::Utils.path_starts_with_slash?(path)
|
||||||
Kemal::WebSocketHandler::INSTANCE.add_route path, &block
|
Kemal::WebSocketHandler::INSTANCE.add_route path, &block
|
||||||
end
|
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 -> _)
|
def error(status_code : Int32, &block : HTTP::Server::Context, Exception -> _)
|
||||||
Kemal.config.add_error_handler status_code, &block
|
Kemal.config.add_error_handler status_code, &block
|
||||||
end
|
end
|
||||||
|
|
||||||
# All the helper methods available are:
|
# Defines an error handler for the given `HTTP::Status`.
|
||||||
# - 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
|
# ```
|
||||||
|
# error :not_found do |env|
|
||||||
|
# "Page not found"
|
||||||
|
# end
|
||||||
|
# ```
|
||||||
|
def error(status : HTTP::Status, &block : HTTP::Server::Context, Exception -> _)
|
||||||
|
Kemal.config.add_error_handler status.code, &block
|
||||||
|
end
|
||||||
|
|
||||||
|
# Defines an error handler for the given exception type.
|
||||||
|
#
|
||||||
|
# ```
|
||||||
|
# error MyCustomException do |env, ex|
|
||||||
|
# "Error: #{ex.message}"
|
||||||
|
# end
|
||||||
|
# ```
|
||||||
|
def error(exception : Exception.class, &block : HTTP::Server::Context, Exception -> _)
|
||||||
|
Kemal.config.add_exception_handler exception, &block
|
||||||
|
end
|
||||||
|
|
||||||
|
# Defines filters that run before or after requests.
|
||||||
|
#
|
||||||
|
# Available methods:
|
||||||
|
# - `before_all`, `before_get`, `before_post`, `before_put`, `before_patch`, `before_delete`, `before_options`
|
||||||
|
# - `after_all`, `after_get`, `after_post`, `after_put`, `after_patch`, `after_delete`, `after_options`
|
||||||
|
#
|
||||||
|
# ```
|
||||||
|
# before_all do |env|
|
||||||
|
# env.response.content_type = "application/json"
|
||||||
|
# end
|
||||||
|
#
|
||||||
|
# before_get "/admin/*" do |env|
|
||||||
|
# # Authentication check
|
||||||
|
# end
|
||||||
|
#
|
||||||
|
# # Multiple paths
|
||||||
|
# after_post ["/users", "/posts"] do |env|
|
||||||
|
# # Logging
|
||||||
|
# end
|
||||||
|
# ```
|
||||||
{% for type in ["before", "after"] %}
|
{% for type in ["before", "after"] %}
|
||||||
{% for method in FILTER_METHODS %}
|
{% for method in FILTER_METHODS %}
|
||||||
def {{type.id}}_{{method.id}}(path : String = "*", &block : HTTP::Server::Context -> _)
|
def {{ type.id }}_{{ method.id }}(path : String = "*", &block : HTTP::Server::Context -> _)
|
||||||
Kemal::FilterHandler::INSTANCE.{{type.id}}({{method}}.upcase, path, &block)
|
Kemal::FilterHandler::INSTANCE.{{ type.id }}({{ method }}.upcase, path, &block)
|
||||||
end
|
end
|
||||||
|
|
||||||
def {{type.id}}_{{method.id}}(paths : Array(String), &block : HTTP::Server::Context -> _)
|
def {{ type.id }}_{{ method.id }}(paths : Enumerable(String), &block : HTTP::Server::Context -> _)
|
||||||
paths.each do |path|
|
paths.each do |path|
|
||||||
Kemal::FilterHandler::INSTANCE.{{type.id}}({{method}}.upcase, path, &block)
|
Kemal::FilterHandler::INSTANCE.{{ type.id }}({{ method }}.upcase, path, &block)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
{% 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
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,44 @@ module Kemal
|
||||||
call_exception_with_status_code(context, ex, 404)
|
call_exception_with_status_code(context, ex, 404)
|
||||||
rescue ex : Kemal::Exceptions::CustomException
|
rescue ex : Kemal::Exceptions::CustomException
|
||||||
call_exception_with_status_code(context, ex, context.response.status_code)
|
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
|
rescue ex : Exception
|
||||||
log("Exception: #{ex.inspect_with_backtrace}")
|
# Matches an error handler for the given exception
|
||||||
|
#
|
||||||
|
# Matches based on order of declaration rather than inheritance relationship
|
||||||
|
# for child exceptions
|
||||||
|
Kemal.config.exception_handlers.each do |expected_exception, handler|
|
||||||
|
if ex.class <= expected_exception
|
||||||
|
return call_exception_with_exception(context, ex, handler, 500)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Log.error(exception: ex) { ex.message }
|
||||||
|
# Else use generic 500 handler if defined
|
||||||
return call_exception_with_status_code(context, ex, 500) if Kemal.config.error_handlers.has_key?(500)
|
return call_exception_with_status_code(context, ex, 500) if Kemal.config.error_handlers.has_key?(500)
|
||||||
verbosity = Kemal.config.env == "production" ? false : true
|
verbosity = Kemal.config.env == "production" ? false : true
|
||||||
render_500(context, ex, verbosity)
|
render_500(context, ex, verbosity)
|
||||||
end
|
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)
|
private def call_exception_with_status_code(context : HTTP::Server::Context, exception : Exception, status_code : Int32)
|
||||||
return if context.response.closed?
|
return if context.response.closed?
|
||||||
if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(status_code)
|
if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(status_code)
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,16 @@ class HTTP::Server
|
||||||
macro finished
|
macro finished
|
||||||
alias StoreTypes = Union({{ STORE_MAPPINGS.splat }})
|
alias StoreTypes = Union({{ STORE_MAPPINGS.splat }})
|
||||||
@store = {} of String => StoreTypes
|
@store = {} of String => StoreTypes
|
||||||
|
@cached_route_lookup : Radix::Result(Kemal::Route)?
|
||||||
|
@cached_ws_route_lookup : Radix::Result(Kemal::WebSocket)?
|
||||||
end
|
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
|
def params
|
||||||
if ws_route_found?
|
ws_lookup = ws_route_lookup
|
||||||
@params ||= Kemal::ParamParser.new(@request, ws_route_lookup.params)
|
if ws_lookup.found?
|
||||||
|
@params ||= Kemal::ParamParser.new(@request, ws_lookup.params)
|
||||||
else
|
else
|
||||||
@params ||= Kemal::ParamParser.new(@request, route_lookup.params)
|
@params ||= Kemal::ParamParser.new(@request, route_lookup.params)
|
||||||
end
|
end
|
||||||
|
|
@ -36,16 +41,31 @@ class HTTP::Server
|
||||||
ws_route_lookup.payload
|
ws_route_lookup.payload
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Optimized: Cache route lookup result to avoid redundant lookups
|
||||||
|
# when called multiple times (e.g., route_found?, route, params)
|
||||||
def route_lookup
|
def route_lookup
|
||||||
Kemal::RouteHandler::INSTANCE.lookup_route(@request.method.as(String), @request.path)
|
@cached_route_lookup ||= Kemal::RouteHandler::INSTANCE.lookup_route(@request.method.as(String), @request.path)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Clears the cached route lookup and updates params with new route. Used by handlers that
|
||||||
|
# modify the request (e.g. OverrideMethodHandler) so the next route lookup uses the updated request.
|
||||||
|
def invalidate_route_cache
|
||||||
|
@cached_route_lookup = nil
|
||||||
|
params = @params
|
||||||
|
if params
|
||||||
|
new_lookup = Kemal::RouteHandler::INSTANCE.lookup_route(@request.method.as(String), @request.path)
|
||||||
|
@cached_route_lookup = new_lookup
|
||||||
|
params.update_url_params(new_lookup.params)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def route_found?
|
def route_found?
|
||||||
route_lookup.found?
|
route_lookup.found?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Optimized: Cache websocket route lookup result to avoid redundant lookups
|
||||||
def ws_route_lookup
|
def ws_route_lookup
|
||||||
Kemal::WebSocketHandler::INSTANCE.lookup_ws_route(@request.path)
|
@cached_ws_route_lookup ||= Kemal::WebSocketHandler::INSTANCE.lookup_ws_route(@request.path)
|
||||||
end
|
end
|
||||||
|
|
||||||
def ws_route_found?
|
def ws_route_found?
|
||||||
|
|
@ -63,5 +83,99 @@ class HTTP::Server
|
||||||
def get?(name : String)
|
def get?(name : String)
|
||||||
@store[name]?
|
@store[name]?
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -20,5 +20,10 @@ module Kemal
|
||||||
@read_time = upload.read_time
|
@read_time = upload.read_time
|
||||||
@size = upload.size
|
@size = upload.size
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def cleanup
|
||||||
|
@tempfile.close
|
||||||
|
::File.delete(@tempfile.path) if ::File.exists?(@tempfile.path)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,41 @@ module Kemal
|
||||||
class FilterHandler
|
class FilterHandler
|
||||||
include HTTP::Handler
|
include HTTP::Handler
|
||||||
INSTANCE = new
|
INSTANCE = new
|
||||||
property tree
|
|
||||||
|
# Path used to represent wildcard filters that apply to all routes
|
||||||
|
private WILDCARD_PATH = "*"
|
||||||
|
|
||||||
|
@tree : Radix::Tree(Array(FilterBlock))
|
||||||
|
|
||||||
|
# Hash cache for exact path filters to avoid repeated tree lookups
|
||||||
|
# Key format: "/#{type}/#{verb}/#{path}" (e.g., "/before/ALL/*")
|
||||||
|
@exact_filters : Hash(String, Array(FilterBlock))
|
||||||
|
|
||||||
|
def tree
|
||||||
|
@tree
|
||||||
|
end
|
||||||
|
|
||||||
|
def tree=(tree : Radix::Tree(Array(FilterBlock)))
|
||||||
|
@tree = tree
|
||||||
|
@exact_filters = Hash(String, Array(FilterBlock)).new
|
||||||
|
end
|
||||||
|
|
||||||
# This middleware is lazily instantiated and added to the handlers as soon as a call to `after_X` or `before_X` is made.
|
# 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
|
def initialize
|
||||||
@tree = Radix::Tree(Array(FilterBlock)).new
|
@tree = Radix::Tree(Array(FilterBlock)).new
|
||||||
|
@exact_filters = Hash(String, Array(FilterBlock)).new
|
||||||
Kemal.config.add_filter_handler(self)
|
Kemal.config.add_filter_handler(self)
|
||||||
end
|
end
|
||||||
|
|
||||||
# The call order of the filters is `before_all -> before_x -> X -> after_x -> after_all`.
|
# The call order of the filters is `before_all -> before_x -> X -> after_x -> after_all`.
|
||||||
def call(context : HTTP::Server::Context)
|
def call(context : HTTP::Server::Context)
|
||||||
return call_next(context) unless context.route_found?
|
if !context.route_found?
|
||||||
|
if Kemal.config.error_handlers.has_key?(404)
|
||||||
|
call_block_for_path_type("ALL", context.request.path, :before, context)
|
||||||
|
end
|
||||||
|
return call_next(context)
|
||||||
|
end
|
||||||
|
|
||||||
call_block_for_path_type("ALL", context.request.path, :before, context)
|
call_block_for_path_type("ALL", context.request.path, :before, context)
|
||||||
call_block_for_path_type(context.request.method, 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)
|
if Kemal.config.error_handlers.has_key?(context.response.status_code)
|
||||||
|
|
@ -25,15 +49,21 @@ module Kemal
|
||||||
context
|
context
|
||||||
end
|
end
|
||||||
|
|
||||||
# :nodoc: This shouldn't be called directly, it's not private because
|
# :nodoc:
|
||||||
# I need to call it for testing purpose since I can't call the macros in the spec.
|
# 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.
|
#
|
||||||
|
# Registers a filter block for the given verb/path/type combination.
|
||||||
|
# Uses @exact_filters hash for O(1) lookup when adding multiple filters to the same path.
|
||||||
def _add_route_filter(verb : String, path, type, &block : HTTP::Server::Context -> _)
|
def _add_route_filter(verb : String, path, type, &block : HTTP::Server::Context -> _)
|
||||||
lookup = lookup_filters_for_path_type(verb, path, type)
|
key = radix_path(verb, path, type)
|
||||||
if lookup.found? && lookup.payload.is_a?(Array(FilterBlock))
|
|
||||||
lookup.payload << FilterBlock.new(&block)
|
if filters = @exact_filters[key]?
|
||||||
|
filters << FilterBlock.new(&block)
|
||||||
else
|
else
|
||||||
@tree.add radix_path(verb, path, type), [FilterBlock.new(&block)]
|
filters = [FilterBlock.new(&block)]
|
||||||
|
@exact_filters[key] = filters
|
||||||
|
|
||||||
|
@tree.add key, filters
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -51,8 +81,24 @@ module Kemal
|
||||||
_add_route_filter verb, path, :after, &block
|
_add_route_filter verb, path, :after, &block
|
||||||
end
|
end
|
||||||
|
|
||||||
# This will fetch the block for the verb/path/type from the tree and call it.
|
# Executes filters for a given path, ensuring global wildcard filters run first.
|
||||||
|
#
|
||||||
|
# Execution order:
|
||||||
|
# 1. Global wildcard filters ("*") - if path is not already a wildcard
|
||||||
|
# 2. Exact path filters - filters registered for the specific path
|
||||||
|
#
|
||||||
|
# This ensures that global filters (like `before_all`) always execute,
|
||||||
|
# while namespace-specific filters only apply to their registered paths.
|
||||||
private def call_block_for_path_type(verb : String?, path : String, type, context : HTTP::Server::Context)
|
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)
|
lookup = lookup_filters_for_path_type(verb, path, type)
|
||||||
if lookup.found? && lookup.payload.is_a? Array(FilterBlock)
|
if lookup.found? && lookup.payload.is_a? Array(FilterBlock)
|
||||||
blocks = lookup.payload
|
blocks = lookup.payload
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,34 @@
|
||||||
module Kemal
|
module Kemal
|
||||||
# `Kemal::Handler` is a subclass of `HTTP::Handler`.
|
# Kemal::HandlerInterface provides helpful methods for use in middleware creation
|
||||||
#
|
#
|
||||||
# It adds `only`, `only_match?`, `exclude`, `exclude_match?`.
|
# More specifically, `only`, `only_match?`, `exclude`, `exclude_match?`
|
||||||
# These methods are useful for the conditional execution of custom handlers .
|
# allows one to define the conditional execution of custom handlers.
|
||||||
class Handler
|
#
|
||||||
|
# To use, simply `include` it within your type.
|
||||||
|
#
|
||||||
|
# It is an implementation of `HTTP::Handler` and can be used anywhere that
|
||||||
|
# requests an `HTTP::Handler` type.
|
||||||
|
module HandlerInterface
|
||||||
include HTTP::Handler
|
include HTTP::Handler
|
||||||
|
|
||||||
|
macro included
|
||||||
@@only_routes_tree = Radix::Tree(String).new
|
@@only_routes_tree = Radix::Tree(String).new
|
||||||
@@exclude_routes_tree = Radix::Tree(String).new
|
@@exclude_routes_tree = Radix::Tree(String).new
|
||||||
|
end
|
||||||
|
|
||||||
macro only(paths, method = "GET")
|
macro only(paths, method = "GET")
|
||||||
class_name = {{@type.name}}
|
class_name = {{ @type.name }}
|
||||||
class_name_method = "#{class_name}/#{{{method}}}"
|
class_name_method = "#{class_name}/#{{{ method }}}"
|
||||||
({{paths}}).each do |path|
|
({{ paths }}).each do |path|
|
||||||
@@only_routes_tree.add class_name_method + path, '/' + {{method}} + path
|
@@only_routes_tree.add class_name_method + path, '/' + {{ method }} + path
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
macro exclude(paths, method = "GET")
|
macro exclude(paths, method = "GET")
|
||||||
class_name = {{@type.name}}
|
class_name = {{ @type.name }}
|
||||||
class_name_method = "#{class_name}/#{{{method}}}"
|
class_name_method = "#{class_name}/#{{{ method }}}"
|
||||||
({{paths}}).each do |path|
|
({{ paths }}).each do |path|
|
||||||
@@exclude_routes_tree.add class_name_method + path, '/' + {{method}} + path
|
@@exclude_routes_tree.add class_name_method + path, '/' + {{ method }} + path
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -75,4 +82,13 @@ module Kemal
|
||||||
"#{self.class}/#{method}#{path}"
|
"#{self.class}/#{method}#{path}"
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,10 @@ module Kemal::Exceptions
|
||||||
super message
|
super message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
class PayloadTooLarge < Exception
|
||||||
|
def initialize
|
||||||
|
super "Payload Too Large"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,12 @@ require "mime"
|
||||||
# - `Kemal::StaticFileHandler`
|
# - `Kemal::StaticFileHandler`
|
||||||
# - Here goes custom handlers
|
# - Here goes custom handlers
|
||||||
# - `Kemal::RouteHandler`
|
# - `Kemal::RouteHandler`
|
||||||
|
@[Deprecated("Use `use` instead")]
|
||||||
def add_handler(handler : HTTP::Handler)
|
def add_handler(handler : HTTP::Handler)
|
||||||
Kemal.config.add_handler handler
|
Kemal.config.add_handler handler
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@[Deprecated("Use `use` with position parameter instead")]
|
||||||
def add_handler(handler : HTTP::Handler, position : Int32)
|
def add_handler(handler : HTTP::Handler, position : Int32)
|
||||||
Kemal.config.add_handler handler, position
|
Kemal.config.add_handler handler, position
|
||||||
end
|
end
|
||||||
|
|
@ -32,8 +34,14 @@ end
|
||||||
|
|
||||||
# Logs the output via `logger`.
|
# Logs the output via `logger`.
|
||||||
# This is the built-in `Kemal::LogHandler` by default which uses STDOUT.
|
# This is the built-in `Kemal::LogHandler` by default which uses STDOUT.
|
||||||
|
@[Deprecated("Use standard library Log")]
|
||||||
def log(message : String)
|
def log(message : String)
|
||||||
Kemal.config.logger.write "#{message}\n"
|
logger = Kemal.config.logger?
|
||||||
|
if logger
|
||||||
|
logger.write "#{message}\n"
|
||||||
|
else
|
||||||
|
Log.info { message }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Enables / Disables logging.
|
# Enables / Disables logging.
|
||||||
|
|
@ -70,6 +78,7 @@ end
|
||||||
# ```
|
# ```
|
||||||
# logger MyCustomLogger.new
|
# logger MyCustomLogger.new
|
||||||
# ```
|
# ```
|
||||||
|
@[Deprecated("Use standard library Log")]
|
||||||
def logger(logger : Kemal::BaseLogHandler)
|
def logger(logger : Kemal::BaseLogHandler)
|
||||||
Kemal.config.logger = logger
|
Kemal.config.logger = logger
|
||||||
end
|
end
|
||||||
|
|
@ -205,29 +214,66 @@ end
|
||||||
private def multipart(file, env : HTTP::Server::Context)
|
private def multipart(file, env : HTTP::Server::Context)
|
||||||
# See http://httpwg.org/specs/rfc7233.html
|
# See http://httpwg.org/specs/rfc7233.html
|
||||||
fileb = file.size
|
fileb = file.size
|
||||||
startb = endb = 0_i64
|
ranges = parse_ranges(env.request.headers["Range"]?, fileb)
|
||||||
|
|
||||||
if match = env.request.headers["Range"].match /bytes=(\d{1,})-(\d{0,})/
|
if ranges.empty?
|
||||||
startb = match[1].to_i64 { 0_i64 } if match.size >= 2
|
env.response.content_length = fileb
|
||||||
endb = match[2].to_i64 { 0_i64 } if match.size >= 3
|
env.response.status_code = 200 # Range not satisfiable
|
||||||
|
IO.copy(file, env.response)
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
endb = fileb - 1 if endb == 0
|
if ranges.size == 1
|
||||||
|
# Single range - send as regular partial content
|
||||||
if startb < endb < fileb
|
startb, endb = ranges[0]
|
||||||
content_length = 1_i64 + endb - startb
|
content_length = 1_i64 + endb - startb
|
||||||
env.response.status_code = 206
|
env.response.status_code = 206
|
||||||
env.response.content_length = content_length
|
env.response.content_length = content_length
|
||||||
env.response.headers["Accept-Ranges"] = "bytes"
|
env.response.headers["Accept-Ranges"] = "bytes"
|
||||||
env.response.headers["Content-Range"] = "bytes #{startb}-#{endb}/#{fileb}" # MUST
|
env.response.headers["Content-Range"] = "bytes #{startb}-#{endb}/#{fileb}"
|
||||||
|
|
||||||
file.seek(startb)
|
file.seek(startb)
|
||||||
IO.copy(file, env.response, content_length)
|
IO.copy(file, env.response, content_length)
|
||||||
else
|
else
|
||||||
env.response.content_length = fileb
|
# Multiple ranges - send as multipart/byteranges
|
||||||
env.response.status_code = 200 # Range not satisfable, see 4.4 Note
|
boundary = "kemal-#{Random::Secure.hex(16)}"
|
||||||
IO.copy(file, env.response)
|
env.response.content_type = "multipart/byteranges; boundary=#{boundary}"
|
||||||
|
env.response.status_code = 206
|
||||||
|
env.response.headers["Accept-Ranges"] = "bytes"
|
||||||
|
|
||||||
|
ranges.each do |start_byte, end_byte|
|
||||||
|
env.response.print "--#{boundary}\r\n"
|
||||||
|
env.response.print "Content-Type: #{env.response.headers["Content-Type"]}\r\n"
|
||||||
|
env.response.print "Content-Range: bytes #{start_byte}-#{end_byte}/#{fileb}\r\n"
|
||||||
|
env.response.print "\r\n"
|
||||||
|
|
||||||
|
file.seek(start_byte)
|
||||||
|
IO.copy(file, env.response, 1_i64 + end_byte - start_byte)
|
||||||
|
env.response.print "\r\n"
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
# Set the Content-Disposition to "attachment" with the specified filename,
|
# Set the Content-Disposition to "attachment" with the specified filename,
|
||||||
|
|
@ -244,7 +290,7 @@ end
|
||||||
#
|
#
|
||||||
# Disabled by default.
|
# Disabled by default.
|
||||||
def gzip(status : Bool = false)
|
def gzip(status : Bool = false)
|
||||||
add_handler HTTP::CompressHandler.new if status
|
use HTTP::CompressHandler.new if status
|
||||||
end
|
end
|
||||||
|
|
||||||
# Adds headers to `Kemal::StaticFileHandler`. This is especially useful for `CORS`.
|
# Adds headers to `Kemal::StaticFileHandler`. This is especially useful for `CORS`.
|
||||||
|
|
@ -257,6 +303,6 @@ end
|
||||||
# env.response.headers.add("Content-Size", filestat.size.to_s)
|
# env.response.headers.add("Content-Size", filestat.size.to_s)
|
||||||
# end
|
# end
|
||||||
# ```
|
# ```
|
||||||
def static_headers(&headers : HTTP::Server::Context, String, File::Info -> Void)
|
def static_headers(&headers : HTTP::Server::Context, String, File::Info ->)
|
||||||
Kemal.config.static_headers = headers
|
Kemal.config.static_headers = headers
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -34,15 +34,15 @@ CONTENT_FOR_BLOCKS = Hash(String, Tuple(String, Proc(Nil))).new
|
||||||
# layout, inside the <head> tag, and each view can call `content_for`
|
# layout, inside the <head> tag, and each view can call `content_for`
|
||||||
# setting the appropriate set of tags that should be added to the layout.
|
# setting the appropriate set of tags that should be added to the layout.
|
||||||
macro content_for(key, file = __FILE__)
|
macro content_for(key, file = __FILE__)
|
||||||
CONTENT_FOR_BLOCKS[{{key}}] = Tuple.new {{file}}, ->() { {{ yield }} }
|
CONTENT_FOR_BLOCKS[{{ key }}] = Tuple.new {{ file }}, ->() { {{ yield }} }
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
# Yields content for the given key if a `content_for` block exists for that key.
|
# Yields content for the given key if a `content_for` block exists for that key.
|
||||||
macro yield_content(key)
|
macro yield_content(key)
|
||||||
if CONTENT_FOR_BLOCKS.has_key?({{key}})
|
if CONTENT_FOR_BLOCKS.has_key?({{ key }})
|
||||||
__caller_filename__ = CONTENT_FOR_BLOCKS[{{key}}][0]
|
__caller_filename__ = CONTENT_FOR_BLOCKS[{{ key }}][0]
|
||||||
%proc = CONTENT_FOR_BLOCKS[{{key}}][1]
|
%proc = CONTENT_FOR_BLOCKS[{{ key }}][1]
|
||||||
|
|
||||||
if __content_filename__ == __caller_filename__
|
if __content_filename__ == __caller_filename__
|
||||||
%old_content_io, content_io = content_io, IO::Memory.new
|
%old_content_io, content_io = content_io, IO::Memory.new
|
||||||
|
|
@ -60,18 +60,45 @@ end
|
||||||
# render "src/views/index.ecr", "src/views/layout.ecr"
|
# render "src/views/index.ecr", "src/views/layout.ecr"
|
||||||
# ```
|
# ```
|
||||||
macro render(filename, layout)
|
macro render(filename, layout)
|
||||||
__content_filename__ = {{filename}}
|
__content_filename__ = {{ filename }}
|
||||||
content_io = IO::Memory.new
|
content_io = IO::Memory.new
|
||||||
ECR.embed {{filename}}, content_io
|
ECR.embed {{ filename }}, content_io
|
||||||
content = content_io.to_s
|
content = content_io.to_s
|
||||||
layout_io = IO::Memory.new
|
layout_io = IO::Memory.new
|
||||||
ECR.embed {{layout}}, layout_io
|
ECR.embed {{ layout }}, layout_io
|
||||||
layout_io.to_s
|
layout_io.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
# Render view with the given filename.
|
# Render view with the given filename.
|
||||||
macro render(filename)
|
macro render(filename)
|
||||||
ECR.render({{filename}})
|
ECR.render({{ filename }})
|
||||||
|
end
|
||||||
|
|
||||||
|
# Halts execution by closing the response. Designed for use with chained response method calls.
|
||||||
|
#
|
||||||
|
# ```
|
||||||
|
# # Example: Send a JSON error and halt immediately
|
||||||
|
# halt env.status(500).json({error: "Internal Server Error"})
|
||||||
|
#
|
||||||
|
# # Example: Immediately close and halt after rendering HTML
|
||||||
|
# halt env.status(403).html("Forbidden")
|
||||||
|
# ```
|
||||||
|
#
|
||||||
|
# NOTE: For most cases that require setting a specific status code and body, prefer the alternative:
|
||||||
|
#
|
||||||
|
# ```
|
||||||
|
# halt env, status_code: 403, response: "Forbidden"
|
||||||
|
# ```
|
||||||
|
macro halt(response)
|
||||||
|
{% if response.is_a?(Call) && response.receiver %}
|
||||||
|
%env = {{ response.receiver }}
|
||||||
|
{{ response }}
|
||||||
|
%env.response.close
|
||||||
|
next
|
||||||
|
{% else %}
|
||||||
|
{{ response }}.response.close
|
||||||
|
next
|
||||||
|
{% end %}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Halt execution with the current context.
|
# Halt execution with the current context.
|
||||||
|
|
@ -81,9 +108,9 @@ end
|
||||||
# halt env, status_code: 403, response: "Forbidden"
|
# halt env, status_code: 403, response: "Forbidden"
|
||||||
# ```
|
# ```
|
||||||
macro halt(env, status_code = 200, response = "")
|
macro halt(env, status_code = 200, response = "")
|
||||||
{{env}}.response.status_code = {{status_code}}
|
{{ env }}.response.status_code = {{ status_code }}
|
||||||
{{env}}.response.print {{response}}
|
{{ env }}.response.print {{ response }}
|
||||||
{{env}}.response.close
|
{{ env }}.response.close
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
module Kemal
|
module Kemal
|
||||||
# Uses `STDOUT` by default and handles the logging of request/response process time.
|
# Uses `STDOUT` by default and handles the logging of request/response process time.
|
||||||
|
@[Deprecated("Setup Log instead.")]
|
||||||
class LogHandler < Kemal::BaseLogHandler
|
class LogHandler < Kemal::BaseLogHandler
|
||||||
def initialize(@io : IO = STDOUT)
|
def initialize(@io : IO = STDOUT)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
module Kemal
|
module Kemal
|
||||||
# This is here to represent the logger corresponding to Null Object Pattern.
|
# This is here to represent the logger corresponding to Null Object Pattern.
|
||||||
|
@[Deprecated("Use standard library Log")]
|
||||||
class NullLogHandler < Kemal::BaseLogHandler
|
class NullLogHandler < Kemal::BaseLogHandler
|
||||||
def call(context : HTTP::Server::Context)
|
def call(context : HTTP::Server::Context)
|
||||||
call_next(context)
|
call_next(context)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ module Kemal
|
||||||
# This middleware is **not** in the default Kemal handlers. You need to explicitly add this to your handlers:
|
# This middleware is **not** in the default Kemal handlers. You need to explicitly add this to your handlers:
|
||||||
#
|
#
|
||||||
# ```ruby
|
# ```ruby
|
||||||
# add_handler Kemal::OverrideMethodHandler
|
# use Kemal::OverrideMethodHandler::INSTANCE
|
||||||
# ```
|
# ```
|
||||||
#
|
#
|
||||||
# **Important:** This middleware consumes `params.body` to read the `_method` magic parameter.
|
# **Important:** This middleware consumes `params.body` to read the `_method` magic parameter.
|
||||||
|
|
@ -21,6 +21,7 @@ module Kemal
|
||||||
if request.method == OVERRIDE_METHOD
|
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])
|
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
|
request.method = context.params.body["_method"].upcase
|
||||||
|
context.invalidate_route_cache
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
call_next(context)
|
call_next(context)
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,61 @@ module Kemal
|
||||||
URL_ENCODED_FORM = "application/x-www-form-urlencoded"
|
URL_ENCODED_FORM = "application/x-www-form-urlencoded"
|
||||||
APPLICATION_JSON = "application/json"
|
APPLICATION_JSON = "application/json"
|
||||||
MULTIPART_FORM = "multipart/form-data"
|
MULTIPART_FORM = "multipart/form-data"
|
||||||
PARTS = %w(url query body json files)
|
PARTS = %w[url query body json files]
|
||||||
# :nodoc:
|
# :nodoc:
|
||||||
alias AllParamTypes = Nil | String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any)
|
alias AllParamTypes = String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any)?
|
||||||
getter files
|
getter files, all_files
|
||||||
|
|
||||||
def initialize(@request : HTTP::Request, @url : Hash(String, String) = {} of String => String)
|
def initialize(@request : HTTP::Request, @url : Hash(String, String) = {} of String => String)
|
||||||
@query = HTTP::Params.new({} of String => Array(String))
|
@query = HTTP::Params.new({} of String => Array(String))
|
||||||
@body = HTTP::Params.new({} of String => Array(String))
|
@body = HTTP::Params.new({} of String => Array(String))
|
||||||
@json = {} of String => AllParamTypes
|
@json = {} of String => AllParamTypes
|
||||||
@files = {} of String => FileUpload
|
@files = {} of String => FileUpload
|
||||||
|
@all_files = {} of String => Array(FileUpload)
|
||||||
@url_parsed = false
|
@url_parsed = false
|
||||||
@query_parsed = false
|
@query_parsed = false
|
||||||
@body_parsed = false
|
@body_parsed = false
|
||||||
@json_parsed = false
|
@json_parsed = false
|
||||||
@files_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
|
end
|
||||||
|
|
||||||
private def unescape_url_param(value : String)
|
private def unescape_url_param(value : String)
|
||||||
|
|
@ -30,14 +70,14 @@ module Kemal
|
||||||
end
|
end
|
||||||
|
|
||||||
{% for method in PARTS %}
|
{% for method in PARTS %}
|
||||||
def {{method.id}}
|
def {{ method.id }}
|
||||||
# check memoization
|
# check memoization
|
||||||
return @{{method.id}} if @{{method.id}}_parsed
|
return @{{ method.id }} if @{{ method.id }}_parsed
|
||||||
|
|
||||||
parse_{{method.id}}
|
parse_{{ method.id }}
|
||||||
# memoize
|
# memoize
|
||||||
@{{method.id}}_parsed = true
|
@{{ method.id }}_parsed = true
|
||||||
@{{method.id}}
|
@{{ method.id }}
|
||||||
end
|
end
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|
||||||
|
|
@ -46,8 +86,10 @@ module Kemal
|
||||||
|
|
||||||
return unless content_type
|
return unless content_type
|
||||||
|
|
||||||
|
validate_content_length!
|
||||||
|
|
||||||
if content_type.try(&.starts_with?(URL_ENCODED_FORM))
|
if content_type.try(&.starts_with?(URL_ENCODED_FORM))
|
||||||
@body = parse_part(@request.body)
|
@body = parse_part(raw_body)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -67,15 +109,23 @@ module Kemal
|
||||||
private def parse_files
|
private def parse_files
|
||||||
return if @files_parsed
|
return if @files_parsed
|
||||||
|
|
||||||
|
validate_content_length!
|
||||||
|
|
||||||
HTTP::FormData.parse(@request) do |upload|
|
HTTP::FormData.parse(@request) do |upload|
|
||||||
next unless upload
|
next unless upload
|
||||||
|
|
||||||
filename = upload.filename
|
filename = upload.filename
|
||||||
|
name = upload.name
|
||||||
|
|
||||||
if !filename.nil?
|
if !filename.nil?
|
||||||
@files[upload.name] = FileUpload.new(upload)
|
if name.ends_with?("[]")
|
||||||
|
@all_files[name] ||= [] of FileUpload
|
||||||
|
@all_files[name] << FileUpload.new(upload)
|
||||||
else
|
else
|
||||||
@body.add(upload.name, upload.body.gets_to_end)
|
@files[name] = FileUpload.new(upload)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
@body.add(name, upload.body.gets_to_end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -87,10 +137,12 @@ module Kemal
|
||||||
# - If request body is a JSON `Hash` then all the params are parsed and added into `params`.
|
# - If request body is a JSON `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"]`.
|
# - 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
|
private def parse_json
|
||||||
return unless @request.body && @request.headers["Content-Type"]?.try(&.starts_with?(APPLICATION_JSON))
|
return unless @request.headers["Content-Type"]?.try(&.starts_with?(APPLICATION_JSON))
|
||||||
|
|
||||||
body = @request.body.not_nil!.gets_to_end
|
body_str = raw_body
|
||||||
case json = JSON.parse(body).raw
|
return if body_str.empty?
|
||||||
|
|
||||||
|
case json = JSON.parse(body_str).raw
|
||||||
when Hash
|
when Hash
|
||||||
json.each do |key, value|
|
json.each do |key, value|
|
||||||
@json[key] = value.raw
|
@json[key] = value.raw
|
||||||
|
|
@ -103,11 +155,31 @@ module Kemal
|
||||||
end
|
end
|
||||||
|
|
||||||
private def parse_part(part : IO?)
|
private def parse_part(part : IO?)
|
||||||
HTTP::Params.parse(part ? part.gets_to_end : "")
|
return HTTP::Params.new({} of String => Array(String)) unless part
|
||||||
|
body_str = read_body_with_limit(part)
|
||||||
|
HTTP::Params.parse(body_str)
|
||||||
end
|
end
|
||||||
|
|
||||||
private def parse_part(part : String?)
|
private def parse_part(part : String?)
|
||||||
HTTP::Params.parse part.to_s
|
HTTP::Params.parse part.to_s
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
46
src/kemal/path_handler.cr
Normal file
46
src/kemal/path_handler.cr
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
module Kemal
|
||||||
|
# `PathHandler` wraps a `HTTP::Handler` to only execute for specific path prefixes.
|
||||||
|
#
|
||||||
|
# ## Example
|
||||||
|
#
|
||||||
|
# ```
|
||||||
|
# use "/api", AuthHandler.new
|
||||||
|
# ```
|
||||||
|
#
|
||||||
|
# The handler will only execute for requests matching the path prefix:
|
||||||
|
# - `/api` matches `/api`, `/api/users`, `/api/posts/1`
|
||||||
|
# - `/api` does NOT match `/`, `/apiv2`, `/other`
|
||||||
|
class PathHandler
|
||||||
|
include HTTP::Handler
|
||||||
|
|
||||||
|
getter path_prefix : String
|
||||||
|
getter handler : HTTP::Handler
|
||||||
|
|
||||||
|
def initialize(@path_prefix : String, @handler : HTTP::Handler)
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(context : HTTP::Server::Context)
|
||||||
|
if matches_prefix?(context.request.path)
|
||||||
|
# Set next handler for the wrapped handler
|
||||||
|
@handler.next = self.next
|
||||||
|
@handler.call(context)
|
||||||
|
else
|
||||||
|
call_next(context)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Checks if the request path matches the handler's path prefix.
|
||||||
|
# - "/" or "" matches all paths
|
||||||
|
# - "/api" matches "/api" and "/api/*"
|
||||||
|
# - "/api" does NOT match "/apiv2"
|
||||||
|
private def matches_prefix?(path : String) : Bool
|
||||||
|
return true if path_prefix.in?("/", "")
|
||||||
|
|
||||||
|
# Exact match
|
||||||
|
return true if path == path_prefix
|
||||||
|
|
||||||
|
# Prefix match (must be followed by /)
|
||||||
|
path.starts_with?("#{path_prefix}/")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
20
src/kemal/request_log_handler.cr
Normal file
20
src/kemal/request_log_handler.cr
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
module Kemal
|
||||||
|
# :nodoc:
|
||||||
|
class RequestLogHandler
|
||||||
|
include HTTP::Handler
|
||||||
|
|
||||||
|
def call(context : HTTP::Server::Context)
|
||||||
|
elapsed_time = Time.measure { call_next(context) }
|
||||||
|
elapsed_text = elapsed_text(elapsed_time)
|
||||||
|
Log.info { "#{context.response.status_code} #{context.request.method} #{context.request.resource} #{elapsed_text}" }
|
||||||
|
context
|
||||||
|
end
|
||||||
|
|
||||||
|
private def elapsed_text(elapsed)
|
||||||
|
millis = elapsed.total_milliseconds
|
||||||
|
return "#{millis.round(2)}ms" if millis >= 1
|
||||||
|
|
||||||
|
"#{(millis * 1000).round(2)}µs"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,16 +1,122 @@
|
||||||
require "radix"
|
require "radix"
|
||||||
|
|
||||||
module Kemal
|
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
|
class RouteHandler
|
||||||
include HTTP::Handler
|
include HTTP::Handler
|
||||||
|
|
||||||
INSTANCE = new
|
INSTANCE = new
|
||||||
CACHED_ROUTES_LIMIT = 1024
|
property routes
|
||||||
property routes, cached_routes
|
|
||||||
|
getter cached_routes
|
||||||
|
|
||||||
|
# Setter is synchronized for thread-safety when specs reset the cache.
|
||||||
|
def cached_routes=(cache : LRUCache(String, Radix::Result(Route)))
|
||||||
|
@cache_mutex.synchronize { @cached_routes = cache }
|
||||||
|
end
|
||||||
|
|
||||||
def initialize
|
def initialize
|
||||||
@routes = Radix::Tree(Route).new
|
@routes = Radix::Tree(Route).new
|
||||||
@cached_routes = Hash(String, Radix::Result(Route)).new
|
@cached_routes = LRUCache(String, Radix::Result(Route)).new(Kemal.config.max_route_cache_size)
|
||||||
|
@cache_mutex = Mutex.new
|
||||||
end
|
end
|
||||||
|
|
||||||
def call(context : HTTP::Server::Context)
|
def call(context : HTTP::Server::Context)
|
||||||
|
|
@ -23,23 +129,29 @@ module Kemal
|
||||||
end
|
end
|
||||||
|
|
||||||
# Looks up the route from the Radix::Tree for the first time and caches to improve performance.
|
# 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)
|
def lookup_route(verb : String, path : String)
|
||||||
lookup_path = radix_path(verb, path)
|
lookup_path = radix_path(verb, path)
|
||||||
|
|
||||||
if cached_route = @cached_routes[lookup_path]?
|
@cache_mutex.synchronize do
|
||||||
|
if cached_route = @cached_routes.get(lookup_path)
|
||||||
return cached_route
|
return cached_route
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
route = @routes.find(lookup_path)
|
route = @routes.find(lookup_path)
|
||||||
|
|
||||||
if verb == "HEAD" && !route.found?
|
if verb == "HEAD" && !route.found?
|
||||||
# On HEAD requests, implicitly fallback to running the GET handler.
|
# On HEAD requests, implicitly fallback to running the GET handler.
|
||||||
route = @routes.find(radix_path("GET", path))
|
get_lookup_path = radix_path("GET", path)
|
||||||
|
get_route = @routes.find(get_lookup_path)
|
||||||
|
# Cache the HEAD->GET fallback result using the original HEAD lookup_path
|
||||||
|
if get_route.found?
|
||||||
|
@cache_mutex.synchronize { @cached_routes.put(lookup_path, get_route) }
|
||||||
end
|
end
|
||||||
|
route = get_route
|
||||||
if route.found?
|
elsif route.found?
|
||||||
@cached_routes.clear if @cached_routes.size == CACHED_ROUTES_LIMIT
|
@cache_mutex.synchronize { @cached_routes.put(lookup_path, route) }
|
||||||
@cached_routes[lookup_path] = route
|
|
||||||
end
|
end
|
||||||
|
|
||||||
route
|
route
|
||||||
|
|
@ -56,11 +168,14 @@ module Kemal
|
||||||
end
|
end
|
||||||
|
|
||||||
context.response.print(content)
|
context.response.print(content)
|
||||||
|
|
||||||
context
|
context
|
||||||
|
ensure
|
||||||
|
context.params.cleanup_temporary_files
|
||||||
end
|
end
|
||||||
|
|
||||||
private def radix_path(method, path)
|
private def radix_path(method, path)
|
||||||
'/' + method + path
|
"/#{method}#{path}"
|
||||||
end
|
end
|
||||||
|
|
||||||
private def add_to_radix_tree(method, path, route)
|
private def add_to_radix_tree(method, path, route)
|
||||||
|
|
|
||||||
304
src/kemal/router.cr
Normal file
304
src/kemal/router.cr
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
module Kemal
|
||||||
|
# Router provides modular routing capabilities for Kemal applications.
|
||||||
|
#
|
||||||
|
# It allows grouping routes under a common prefix and applying filters
|
||||||
|
# to specific route groups. Routers can be nested using `namespace`.
|
||||||
|
#
|
||||||
|
# ## Example
|
||||||
|
#
|
||||||
|
# ```
|
||||||
|
# api = Kemal::Router.new
|
||||||
|
#
|
||||||
|
# api.before do |env|
|
||||||
|
# env.response.content_type = "application/json"
|
||||||
|
# end
|
||||||
|
#
|
||||||
|
# api.get "/users" do |env|
|
||||||
|
# User.all.to_json
|
||||||
|
# end
|
||||||
|
#
|
||||||
|
# api.namespace "/admin" do
|
||||||
|
# get "/dashboard" do |env|
|
||||||
|
# {status: "ok"}.to_json
|
||||||
|
# end
|
||||||
|
# end
|
||||||
|
#
|
||||||
|
# mount "/api/v1", api
|
||||||
|
# ```
|
||||||
|
class Router
|
||||||
|
alias RouteHandler = HTTP::Server::Context -> String
|
||||||
|
alias FilterHandler = HTTP::Server::Context -> String
|
||||||
|
alias WSHandler = HTTP::WebSocket, HTTP::Server::Context ->
|
||||||
|
|
||||||
|
# Stored route definition
|
||||||
|
private record RouteDefinition,
|
||||||
|
method : String,
|
||||||
|
path : String,
|
||||||
|
handler : RouteHandler
|
||||||
|
|
||||||
|
# Stored filter definition
|
||||||
|
private record FilterDefinition,
|
||||||
|
type : Symbol,
|
||||||
|
method : String,
|
||||||
|
path : String,
|
||||||
|
handler : FilterHandler
|
||||||
|
|
||||||
|
# Stored websocket definition
|
||||||
|
private record WSDefinition,
|
||||||
|
path : String,
|
||||||
|
handler : WSHandler
|
||||||
|
|
||||||
|
# Stored sub-router
|
||||||
|
private record SubRouter,
|
||||||
|
path : String,
|
||||||
|
router : Router
|
||||||
|
|
||||||
|
getter prefix : String
|
||||||
|
|
||||||
|
@routes : Array(RouteDefinition)
|
||||||
|
@filters : Array(FilterDefinition)
|
||||||
|
@websockets : Array(WSDefinition)
|
||||||
|
@sub_routers : Array(SubRouter)
|
||||||
|
|
||||||
|
def initialize(@prefix : String = "")
|
||||||
|
@routes = [] of RouteDefinition
|
||||||
|
@filters = [] of FilterDefinition
|
||||||
|
@websockets = [] of WSDefinition
|
||||||
|
@sub_routers = [] of SubRouter
|
||||||
|
end
|
||||||
|
|
||||||
|
# HTTP method helpers
|
||||||
|
{% for method in HTTP_METHODS %}
|
||||||
|
# Defines a {{ method.id.upcase }} route.
|
||||||
|
#
|
||||||
|
# ```
|
||||||
|
# router.{{ method.id }} "/path" do |env|
|
||||||
|
# "response"
|
||||||
|
# end
|
||||||
|
# ```
|
||||||
|
def {{ method.id }}(path : String, &block : HTTP::Server::Context -> _)
|
||||||
|
add_route({{ method.upcase }}, path, &block)
|
||||||
|
end
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
# Defines a WebSocket route.
|
||||||
|
#
|
||||||
|
# ```
|
||||||
|
# router.ws "/chat" do |socket, env|
|
||||||
|
# socket.on_message do |msg|
|
||||||
|
# socket.send "Echo: #{msg}"
|
||||||
|
# end
|
||||||
|
# end
|
||||||
|
# ```
|
||||||
|
def ws(path : String, &block : HTTP::WebSocket, HTTP::Server::Context ->)
|
||||||
|
@websockets << WSDefinition.new(path: path, handler: block)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Defines a before filter for all HTTP methods.
|
||||||
|
#
|
||||||
|
# ```
|
||||||
|
# router.before do |env|
|
||||||
|
# env.response.content_type = "application/json"
|
||||||
|
# end
|
||||||
|
# ```
|
||||||
|
def before(path : String = "*", &block : HTTP::Server::Context -> _)
|
||||||
|
add_filter(:before, "ALL", path, &block)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Defines an after filter for all HTTP methods.
|
||||||
|
#
|
||||||
|
# ```
|
||||||
|
# router.after do |env|
|
||||||
|
# puts "Request completed"
|
||||||
|
# end
|
||||||
|
# ```
|
||||||
|
def after(path : String = "*", &block : HTTP::Server::Context -> _)
|
||||||
|
add_filter(:after, "ALL", path, &block)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Method-specific before/after filters
|
||||||
|
{% for method in FILTER_METHODS %}
|
||||||
|
# Defines a before filter for {{ method.id.upcase }} requests.
|
||||||
|
def before_{{ method.id }}(path : String = "*", &block : HTTP::Server::Context -> _)
|
||||||
|
add_filter(:before, {{ method.upcase }}, path, &block)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Defines an after filter for {{ method.id.upcase }} requests.
|
||||||
|
def after_{{ method.id }}(path : String = "*", &block : HTTP::Server::Context -> _)
|
||||||
|
add_filter(:after, {{ method.upcase }}, path, &block)
|
||||||
|
end
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
# Creates a nested namespace/group with the given *path* prefix.
|
||||||
|
#
|
||||||
|
# NOTE: The path must start with a `/`.
|
||||||
|
#
|
||||||
|
# All routes defined inside the block will be prefixed with the given path.
|
||||||
|
#
|
||||||
|
# ```
|
||||||
|
# router.namespace "/users" do
|
||||||
|
# get "/" do |env|
|
||||||
|
# User.all.to_json
|
||||||
|
# end
|
||||||
|
#
|
||||||
|
# get "/:id" do |env|
|
||||||
|
# User.find(env.params.url["id"]).to_json
|
||||||
|
# end
|
||||||
|
# end
|
||||||
|
# ```
|
||||||
|
def namespace(path : String, &)
|
||||||
|
sub_router = Router.new
|
||||||
|
with sub_router yield
|
||||||
|
@sub_routers << SubRouter.new(path: path, router: sub_router)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mounts another router at the given *path* prefix.
|
||||||
|
#
|
||||||
|
# NOTE: The path must start with a `/`.
|
||||||
|
#
|
||||||
|
# ```
|
||||||
|
# users_router = Kemal::Router.new
|
||||||
|
# users_router.get "/" { |env| "users" }
|
||||||
|
#
|
||||||
|
# api = Kemal::Router.new
|
||||||
|
# api.mount "/users", users_router
|
||||||
|
#
|
||||||
|
# mount "/api", api
|
||||||
|
# # Result: GET /api/users
|
||||||
|
# ```
|
||||||
|
def mount(path : String, router : Router)
|
||||||
|
@sub_routers << SubRouter.new(path: path, router: router)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mounts another router without additional prefix.
|
||||||
|
def mount(router : Router)
|
||||||
|
mount("", router)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Registers all routes, filters, and websockets with Kemal's handlers.
|
||||||
|
# This is called automatically when using `mount` from DSL.
|
||||||
|
#
|
||||||
|
# :nodoc:
|
||||||
|
def register_routes(base_prefix : String = "")
|
||||||
|
full_prefix = join_paths(base_prefix, @prefix)
|
||||||
|
|
||||||
|
# Collect all route paths for filter registration
|
||||||
|
route_paths = collect_all_route_paths(full_prefix)
|
||||||
|
|
||||||
|
# Register filters for each route path
|
||||||
|
register_filters(full_prefix, route_paths)
|
||||||
|
|
||||||
|
# Register routes
|
||||||
|
@routes.each do |route|
|
||||||
|
full_path = join_paths(full_prefix, route.path)
|
||||||
|
validate_path!(route.method.downcase, full_path)
|
||||||
|
Kemal::RouteHandler::INSTANCE.add_route(route.method, full_path) do |env|
|
||||||
|
route.handler.call(env)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Register websockets
|
||||||
|
@websockets.each do |ws_def|
|
||||||
|
full_path = join_paths(full_prefix, ws_def.path)
|
||||||
|
validate_path!("ws", full_path)
|
||||||
|
Kemal::WebSocketHandler::INSTANCE.add_route(full_path, &ws_def.handler)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Register sub-routers recursively
|
||||||
|
@sub_routers.each do |sub|
|
||||||
|
sub_prefix = join_paths(full_prefix, sub.path)
|
||||||
|
sub.router.register_routes(sub_prefix)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Collect all route paths including sub-routers
|
||||||
|
protected def collect_all_route_paths(full_prefix : String) : Array(Tuple(String, String))
|
||||||
|
paths = [] of Tuple(String, String)
|
||||||
|
|
||||||
|
# This router's routes
|
||||||
|
@routes.each do |route|
|
||||||
|
full_path = join_paths(full_prefix, route.path)
|
||||||
|
paths << {route.method, full_path}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sub-router routes
|
||||||
|
@sub_routers.each do |sub|
|
||||||
|
sub_prefix = join_paths(full_prefix, sub.path)
|
||||||
|
paths.concat(sub.router.collect_all_route_paths(sub_prefix))
|
||||||
|
end
|
||||||
|
|
||||||
|
paths
|
||||||
|
end
|
||||||
|
|
||||||
|
# Register filters for specific route paths
|
||||||
|
private def register_filters(full_prefix : String, route_paths : Array(Tuple(String, String)))
|
||||||
|
return if @filters.empty?
|
||||||
|
|
||||||
|
# Ensure FilterHandler is registered with Kemal (may have been cleared between tests)
|
||||||
|
unless Kemal::Config::FILTER_HANDLERS.includes?(Kemal::FilterHandler::INSTANCE)
|
||||||
|
Kemal.config.add_filter_handler(Kemal::FilterHandler::INSTANCE)
|
||||||
|
end
|
||||||
|
|
||||||
|
@filters.each do |filter|
|
||||||
|
# Determine which paths this filter applies to
|
||||||
|
applicable_paths = if filter.path == "*"
|
||||||
|
# Apply to all routes in this router
|
||||||
|
route_paths
|
||||||
|
else
|
||||||
|
# Apply to specific path
|
||||||
|
filter_full_path = join_paths(full_prefix, filter.path)
|
||||||
|
route_paths.select { |_, path| path == filter_full_path || path.starts_with?(filter_full_path + "/") }
|
||||||
|
end
|
||||||
|
|
||||||
|
applicable_paths.each do |route_method, route_path|
|
||||||
|
# Check if filter method matches route method
|
||||||
|
next unless filter.method.in?("ALL", route_method)
|
||||||
|
|
||||||
|
# Use filter's method (ALL or specific) when registering
|
||||||
|
register_method = filter.method
|
||||||
|
|
||||||
|
case filter.type
|
||||||
|
when :before
|
||||||
|
Kemal::FilterHandler::INSTANCE.before(register_method, route_path) do |env|
|
||||||
|
filter.handler.call(env)
|
||||||
|
end
|
||||||
|
when :after
|
||||||
|
Kemal::FilterHandler::INSTANCE.after(register_method, route_path) do |env|
|
||||||
|
filter.handler.call(env)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def add_route(method : String, path : String, &block : HTTP::Server::Context -> _)
|
||||||
|
handler = ->(ctx : HTTP::Server::Context) do
|
||||||
|
result = block.call(ctx)
|
||||||
|
result.is_a?(String) ? result : ""
|
||||||
|
end
|
||||||
|
@routes << RouteDefinition.new(method: method, path: path, handler: handler)
|
||||||
|
end
|
||||||
|
|
||||||
|
private def add_filter(type : Symbol, method : String, path : String, &block : HTTP::Server::Context -> _)
|
||||||
|
handler = ->(ctx : HTTP::Server::Context) do
|
||||||
|
result = block.call(ctx)
|
||||||
|
result.is_a?(String) ? result : ""
|
||||||
|
end
|
||||||
|
@filters << FilterDefinition.new(type: type, method: method, path: path, handler: handler)
|
||||||
|
end
|
||||||
|
|
||||||
|
private def join_paths(a : String, b : String) : String
|
||||||
|
a = a.chomp('/')
|
||||||
|
b = b.lchop('/') if b.starts_with?('/')
|
||||||
|
return "/#{b}" if a.empty?
|
||||||
|
return a if b.empty?
|
||||||
|
"#{a}/#{b}"
|
||||||
|
end
|
||||||
|
|
||||||
|
private def validate_path!(method : String, path : String)
|
||||||
|
unless Utils.path_starts_with_slash?(path)
|
||||||
|
raise Exceptions::InvalidPathStartException.new(method, path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,6 +1,37 @@
|
||||||
module Kemal
|
module Kemal
|
||||||
class StaticFileHandler < HTTP::StaticFileHandler
|
class StaticFileHandler < HTTP::StaticFileHandler
|
||||||
# ameba:disable Metrics/CyclomaticComplexity
|
{% 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 %}
|
||||||
def call(context : HTTP::Server::Context)
|
def call(context : HTTP::Server::Context)
|
||||||
return call_next(context) if context.request.path.not_nil! == "/"
|
return call_next(context) if context.request.path.not_nil! == "/"
|
||||||
|
|
||||||
|
|
@ -16,35 +47,40 @@ module Kemal
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
config = Kemal.config.serve_static
|
|
||||||
original_path = context.request.path.not_nil!
|
original_path = context.request.path.not_nil!
|
||||||
|
is_dir_path = original_path.ends_with?("/")
|
||||||
request_path = URI.decode(original_path)
|
request_path = URI.decode(original_path)
|
||||||
|
|
||||||
# File path cannot contains '\0' (NUL) because all filesystem I know
|
# File path cannot contains '\0' (NUL) because all filesystem I know
|
||||||
# don't accept '\0' character as file name.
|
# don't accept '\0' character as file name.
|
||||||
if request_path.includes? '\0'
|
if request_path.includes? '\0'
|
||||||
context.response.status_code = 400
|
context.response.respond_with_status(:bad_request)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
expanded_path = request_path
|
request_path = Path.posix(request_path)
|
||||||
is_dir_path = if original_path.ends_with?('/') && !expanded_path.ends_with? '/'
|
expanded_path = request_path.expand("/")
|
||||||
expanded_path = expanded_path + '/'
|
|
||||||
true
|
file_path = @public_dir.join(expanded_path.to_kind(Path::Kind.native))
|
||||||
else
|
file_info = File.info? file_path
|
||||||
expanded_path.ends_with? '/'
|
is_dir = @directory_listing && file_info && file_info.directory?
|
||||||
|
is_file = file_info && file_info.file?
|
||||||
|
|
||||||
|
if request_path != expanded_path || is_dir && !is_dir_path
|
||||||
|
redirect_path = expanded_path
|
||||||
|
if is_dir && !is_dir_path
|
||||||
|
# Append / to path if missing
|
||||||
|
redirect_path = expanded_path.join("")
|
||||||
|
end
|
||||||
|
redirect_to context, redirect_path
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
file_path = File.join(@public_dir, expanded_path)
|
return call_next(context) unless file_info
|
||||||
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
|
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"))
|
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")
|
file_path = File.join(@public_dir, expanded_path, "index.html")
|
||||||
|
|
||||||
|
|
@ -62,7 +98,7 @@ module Kemal
|
||||||
else
|
else
|
||||||
call_next(context)
|
call_next(context)
|
||||||
end
|
end
|
||||||
elsif File.exists?(file_path)
|
elsif is_file
|
||||||
last_modified = modification_time(file_path)
|
last_modified = modification_time(file_path)
|
||||||
add_cache_headers(context.response.headers, last_modified)
|
add_cache_headers(context.response.headers, last_modified)
|
||||||
|
|
||||||
|
|
@ -70,8 +106,8 @@ module Kemal
|
||||||
context.response.status_code = 304
|
context.response.status_code = 304
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
send_file(context, file_path)
|
send_file(context, file_path.to_s)
|
||||||
else
|
else # Not a normal file (FIFO/device/socket)
|
||||||
call_next(context)
|
call_next(context)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -79,5 +115,6 @@ module Kemal
|
||||||
private def modification_time(file_path)
|
private def modification_time(file_path)
|
||||||
File.info(file_path).modification_time
|
File.info(file_path).modification_time
|
||||||
end
|
end
|
||||||
|
{% end %}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ module Kemal
|
||||||
class WebSocket < HTTP::WebSocketHandler
|
class WebSocket < HTTP::WebSocketHandler
|
||||||
getter proc
|
getter proc
|
||||||
|
|
||||||
def initialize(@path : String, &@proc : HTTP::WebSocket, HTTP::Server::Context -> Void)
|
def initialize(@path : String, &@proc : HTTP::WebSocket, HTTP::Server::Context ->)
|
||||||
end
|
end
|
||||||
|
|
||||||
def error(code : Int16, message : String)
|
def error(code : Int16, message : String)
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ module Kemal
|
||||||
@routes.find "/ws" + path
|
@routes.find "/ws" + path
|
||||||
end
|
end
|
||||||
|
|
||||||
def add_route(path : String, &handler : HTTP::WebSocket, HTTP::Server::Context -> Void)
|
def add_route(path : String, &handler : HTTP::WebSocket, HTTP::Server::Context ->)
|
||||||
add_to_radix_tree path, WebSocket.new(path, &handler)
|
add_to_radix_tree path, WebSocket.new(path, &handler)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue