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