diff --git a/.ameba.yml b/.ameba.yml deleted file mode 100644 index d7b2b9a..0000000 --- a/.ameba.yml +++ /dev/null @@ -1,24 +0,0 @@ -# This configuration file was generated by `ameba --gen-config` -# on 2023-01-30 12:35:15 UTC using Ameba version 1.4.0. -# The point is for the user to remove these configuration records -# one by one as the reported problems are removed from the code base. - -# Problems found: 2 -# Run `ameba --only Lint/UselessAssign` for details -Lint/UselessAssign: - Description: Disallows useless variable assignments - Excluded: - - spec/view_spec.cr - Enabled: true - Severity: Warning - -# Problems found: 6 -# Run `ameba --only Lint/NotNil` for details -Lint/NotNil: - Description: Identifies usage of `not_nil!` calls - Excluded: - - src/kemal/param_parser.cr - - src/kemal/static_file_handler.cr - - src/kemal/config.cr - Enabled: true - Severity: Warning diff --git a/.github/workflows/ameba.yml b/.github/workflows/ameba.yml new file mode 100644 index 0000000..e321d6a --- /dev/null +++ b/.github/workflows/ameba.yml @@ -0,0 +1,19 @@ +name: Ameba + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Download source + uses: actions/checkout@v6 + + - name: Run Ameba Linter + uses: crystal-ameba/github-action@master diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f473bda..8e65750 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,47 +30,3 @@ jobs: - name: Run specs run: | crystal spec - - format: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest] - crystal: [latest, nightly] - runs-on: ${{ matrix.os }} - - steps: - - name: Install Crystal - uses: crystal-lang/install-crystal@v1 - with: - crystal: ${{ matrix.crystal }} - - - name: Download source - uses: actions/checkout@v4 - - - name: Check formatting - run: crystal tool format --check - - ameba: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest] - crystal: [latest] - runs-on: ${{ matrix.os }} - - steps: - - name: Install Crystal - uses: crystal-lang/install-crystal@v1 - with: - crystal: ${{ matrix.crystal }} - - - name: Download source - uses: actions/checkout@v4 - - - name: Install dependencies - run: shards install - - - name: Run ameba linter - run: bin/ameba - \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 130192c..2382024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,156 @@ +# 1.10.1 (24-03-2026) + +- Add `shutdown_timeout` configuration for graceful shutdown: after `Kemal.stop`, Kemal can wait before exit so in-flight work can finish [#745](https://github.com/kemalcr/kemal/pull/745). Thanks @sdogruyol :pray: + +```crystal +Kemal.config.shutdown_timeout = 10.seconds +``` + +# 1.10.0 (03-03-2026) + +- Add modular `Kemal::Router` with namespaced routing, scoped filters, WebSocket support and flexible mounting while keeping the existing DSL fully compatible [#731](https://github.com/kemalcr/kemal/pull/731). Thanks @sdogruyol :pray: + +```crystal +require "kemal" + +api = Kemal::Router.new + +api.namespace "/users" do + get "/" do |env| + env.json({users: ["alice", "bob"]}) + end + + get "/:id" do |env| + env.text "user #{env.params.url["id"]}" + end +end + +mount "/api/v1", api + +Kemal.run +``` + +- Add `use` keyword for registering global and path-specific middleware, including support for arrays and insertion at a specific position in the handler chain [#734](https://github.com/kemalcr/kemal/pull/734). Thanks @sdogruyol :pray: + +```crystal +require "kemal" + +# Path-specific middlewares for /api routes +use "/api", [CORSHandler.new, AuthHandler.new] + +get "/" do + "Public home" +end + +get "/api/users" do |env| + env.json({users: ["alice", "bob"]}) +end + +Kemal.run +``` + +- Enhance response helpers to provide chainable JSON/HTML/text/XML helpers, `HTTP::Status` support and the ability to halt execution from a chained response for concise API error handling [#733](https://github.com/kemalcr/kemal/pull/733), [#735](https://github.com/kemalcr/kemal/pull/735), [#736](https://github.com/kemalcr/kemal/pull/736). Thanks @sdogruyol and @mamantoha :pray: + +```crystal +require "kemal" + +get "/users" do |env| + # Default JSON response + env.json({users: ["alice", "bob"]}) +end + +post "/users" do |env| + # Symbol-based HTTP::Status and chained JSON + env.status(:created).json({id: 1, created: true}) +end + +get "/admin" do |env| + # Halt immediately with HTML response + halt env.status(403).html("

Forbidden

") +end + +get "/api/users" do |env| + # Custom content type (JSON:API) + env.json({data: ["alice", "bob"]}, content_type: "application/vnd.api+json") +end + +Kemal.run +``` + +- 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: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..356782c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing to Kemal + +Thank you for your interest in contributing to Kemal! We love pull requests from everyone. + +## Getting Started + +1. **Fork** the repository on GitHub. +2. **Clone** your fork locally: + ```bash + git clone https://github.com/YOUR_USERNAME/kemal.git + cd kemal + ``` +3. **Install dependencies**: + ```bash + shards install + ``` + +## Running Tests + +Before submitting a pull request, please ensure that all tests pass. + +```bash +crystal spec +``` + +## Code Style + +Kemal follows the standard Crystal code style. Please ensure your code is formatted correctly before committing. + +```bash +crystal tool format +``` + +## Submitting a Pull Request + +1. Create a new branch for your feature or bug fix: + ```bash + git checkout -b my-new-feature + ``` +2. Commit your changes with descriptive commit messages. +3. Push your branch to your fork: + ```bash + git push origin my-new-feature + ``` +4. Open a **Pull Request** on the main Kemal repository. +5. Describe your changes and link to any relevant issues. + +## Reporting Bugs + +If you find a bug, please open an issue on GitHub with: +- A clear title and description. +- Steps to reproduce the issue. +- The version of Kemal and Crystal you are using. + +## Feature Requests + +We welcome new ideas! Please open an issue to discuss your feature request before implementing it. + +Thank you for contributing to Kemal! πŸš€ diff --git a/README.md b/README.md index aba67b4..de923a3 100644 --- a/README.md +++ b/README.md @@ -2,45 +2,32 @@ # Kemal -Lightning Fast, Super Simple web framework. +Kemal is the Fast, Effective, Simple Web Framework for Crystal. It's perfect for building Web Applications and APIs with minimal code. **THIS IS A FORK OF KEMAL. DIRECT TO FORK.MD FOR SPECIFICS ON THIS FORK.** [![CI](https://github.com/kemalcr/kemal/actions/workflows/ci.yml/badge.svg)](https://github.com/kemalcr/kemal/actions/workflows/ci.yml) -[![Join the chat at https://gitter.im/sdogruyol/kemal](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/sdogruyol/kemal?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) -# Super Simple ⚑️ +## Why Kemal? -```ruby -require "kemal" +- πŸš€ **Lightning Fast**: Built on Crystal, known for C-like performance +- πŸ’‘ **Super Simple**: Minimal code needed to get started +- πŸ›  **Feature Rich**: Everything you need for modern web development +- πŸ”§ **Flexible**: Easy to extend with middleware support -# Matches GET "http://host:port/" -get "/" do - "Hello World!" -end +## Quick Start -# Creates a WebSocket handler. -# Matches "ws://host:port/socket" -ws "/socket" do |socket| - socket.send "Hello from Kemal!" -end +1. First, make sure you have [Crystal installed](https://crystal-lang.org/install/). -Kemal.run +2. Create a new Crystal application and step into it: + +```bash +crystal init app my-kemal-app +cd my-kemal-app ``` -Start your application! - -``` -crystal src/kemal_sample.cr -``` - -Go to _http://localhost:3000_ - -Check [documentation](http://kemalcr.com) or [samples](https://github.com/kemalcr/kemal/tree/master/samples) for more. - -# Installation - -Add this to your application's `shard.yml`: +3. Add Kemal to your app's `shard.yml`: +>>>>>>> upstream/master ```yaml dependencies: @@ -48,22 +35,78 @@ dependencies: github: kemalcr/kemal ``` -See also [Getting Started](http://kemalcr.com/guide/). +4. Replace the contents of `src/my_kemal_app.cr` with your first Kemal app: -# Features +```crystal +require "kemal" -- Support all REST verbs -- Websocket support -- Request/Response context, easy parameter handling -- Middleware support -- Built-in JSON support -- Built-in static file serving -- Built-in view templating via [ECR](https://crystal-lang.org/api/ECR.html) +# Basic route - responds to GET "http://localhost:3000/" +get "/" do + "Hello World!" +end -# Documentation +# JSON API example +get "/api/status" do |env| + env.response.content_type = "application/json" + {"status": "ok"}.to_json +end -You can read the documentation at the official site [kemalcr.com](http://kemalcr.com) +# WebSocket support +ws "/chat" do |socket| + socket.send "Hello from Kemal WebSocket!" +end -## Thanks +Kemal.run +``` -Thanks to Manas for their awesome work on [Frank](https://github.com/manastech/frank). +5. Install dependencies and run your application: + +```bash +shards install +crystal run src/my_kemal_app.cr +``` + +6. Visit [http://localhost:3000](http://localhost:3000) - That's it! πŸŽ‰ + +## Key Features + +- πŸš€ **High-performance by default**: Built on Crystal with a thin abstraction layer so you can serve a large number of requests with low latency and low memory footprint. +- 🌐 **Full REST & HTTP support**: Handle all HTTP verbs (GET, POST, PUT, PATCH, DELETE, etc.) with a straightforward routing DSL. +- πŸ”Œ **WebSocket & real-time**: First-class WebSocket support for building chats, dashboards and other real-time experiences. +- πŸ“¦ **JSON-first APIs**: Native JSON handling makes building JSON APIs and microservices feel natural. +- πŸ—„οΈ **Static assets made easy**: Serve static files (assets, uploads, SPA bundles) efficiently from the same application. +- πŸ“ **Template engine included**: Built-in ECR template engine for 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. diff --git a/examples/cookies/app.cr b/examples/cookies/app.cr new file mode 100644 index 0000000..644e01a --- /dev/null +++ b/examples/cookies/app.cr @@ -0,0 +1,67 @@ +require "kemal" + +# This example demonstrates different ways to work with cookies in Kemal + +# Route to set various types of cookies +get "/set-cookies" do |env| + # Basic cookie with just name and value + basic_cookie = HTTP::Cookie.new( + name: "BasicCookie", + value: "Hello from Kemal!" + ) + + # Secure cookie with additional security options + secure_cookie = HTTP::Cookie.new( + name: "SecureCookie", + value: "Sensitive Data", + http_only: true, # Cookie cannot be accessed via JavaScript + secure: true, # Cookie only sent over HTTPS + path: "/", # Cookie available for all paths + expires: Time.local + Time::Span.new(days: 7) # Cookie expires in 7 days + ) + + # Session cookie that expires when browser closes + session_cookie = HTTP::Cookie.new( + name: "SessionCookie", + value: "Temporary", + http_only: true + ) + + # Add all cookies to response + env.response.cookies << basic_cookie + env.response.cookies << secure_cookie + env.response.cookies << session_cookie + + "Cookies have been set! Visit /show-cookies to view them." +end + +# Route to display current cookies +get "/show-cookies" do |env| + cookies = env.request.cookies + response = String.build do |str| + str << "

Current Cookies:

" + str << "" + end + response +end + +# Route to delete a specific cookie +get "/delete-cookie/:name" do |env| + cookie_name = env.params.url["name"] + + # Set cookie with immediate expiration to delete it + delete_cookie = HTTP::Cookie.new( + name: cookie_name, + value: "", + expires: Time.local - 1.day + ) + + env.response.cookies << delete_cookie + "Cookie '#{cookie_name}' has been deleted!" +end + +Kemal.run diff --git a/examples/cors/app.cr b/examples/cors/app.cr new file mode 100644 index 0000000..ae5b067 --- /dev/null +++ b/examples/cors/app.cr @@ -0,0 +1,17 @@ +require "kemal" + +# Configure headers for static files using Kemal's static_headers helper +static_headers do |response, filepath, filestat| + # For HTML files, add CORS header to allow requests from example.com + # This restricts access to HTML files to only that domain + if filepath =~ /\.html$/ + response.headers.add("Access-Control-Allow-Origin", "example.com") + end + + # Add Content-Size header for all static files + # This helps clients know the file size before downloading + response.headers.add("Content-Size", filestat.size.to_s) +end + +# Start the Kemal web server +Kemal.run diff --git a/examples/file-download/app.cr b/examples/file-download/app.cr new file mode 100644 index 0000000..dd23487 --- /dev/null +++ b/examples/file-download/app.cr @@ -0,0 +1,18 @@ +require "kemal" + +# Define a route for the root path "/" that will handle file downloads +get "/" do |env| + # Use Kemal's send_file helper to stream a file to the client + # Parameters: + # - env: The HTTP environment containing request/response data + # - "/path/to/your_file": The path to the file you want to download + # + # send_file will: + # - Set appropriate Content-Type header based on file extension + # - Stream the file in chunks to handle large files efficiently + # - Set Content-Disposition header for browser download behavior + send_file env, "/path/to/your_file" +end + +# Start the Kemal web server +Kemal.run diff --git a/examples/file-upload/app.cr b/examples/file-upload/app.cr new file mode 100644 index 0000000..7259ab0 --- /dev/null +++ b/examples/file-upload/app.cr @@ -0,0 +1,25 @@ +require "kemal" + +# Handle file uploads via POST request to /upload endpoint +post "/upload" do |env| + # Get the uploaded file from the "image" field in the form + # The file is initially stored in a temporary location + 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 diff --git a/examples/hello-world/app.cr b/examples/hello-world/app.cr new file mode 100644 index 0000000..fa687db --- /dev/null +++ b/examples/hello-world/app.cr @@ -0,0 +1,7 @@ +require "kemal" + +get "/" do + "Hello Kemal!" +end + +Kemal.run diff --git a/examples/http-basic-auth/app.cr b/examples/http-basic-auth/app.cr new file mode 100644 index 0000000..5cd2ca3 --- /dev/null +++ b/examples/http-basic-auth/app.cr @@ -0,0 +1,18 @@ +require "kemal" +require "kemal-basic-auth" + +# Enable HTTP Basic Authentication +# This will protect all routes with username/password authentication +# - username: "username" +# - password: "password" +basic_auth "username", "password" + +# Define a route for the root path "/" +get "/" do |_| + # This route will only execute if authentication is successful + # Otherwise, the browser will show a login prompt + "This is shown if basic auth successful." +end + +# Start the Kemal web server +Kemal.run diff --git a/examples/http-basic-auth/custom-handler.cr b/examples/http-basic-auth/custom-handler.cr new file mode 100644 index 0000000..0911973 --- /dev/null +++ b/examples/http-basic-auth/custom-handler.cr @@ -0,0 +1,23 @@ +require "kemal-basic-auth" + +# Create a custom authentication handler by inheriting from Kemal::BasicAuth::Handler +class CustomAuthHandler < Kemal::BasicAuth::Handler + # Specify which routes should be protected by basic auth + # In this case, only /dashboard and /admin routes will require authentication + only ["/dashboard", "/admin"] + + # Override the call method to implement custom authentication logic + def call(context) + # Skip authentication if the current route is not in the protected routes list + # This allows other routes to be accessed without authentication + return call_next(context) unless only_match?(context) + + # Call the parent class's authentication logic for protected routes + # This will prompt for username/password and validate credentials + super + end +end + +# Register our custom authentication handler with Kemal +# This enables basic auth for the specified routes +Kemal.config.auth_handler = CustomAuthHandler diff --git a/examples/json-api/app.cr b/examples/json-api/app.cr new file mode 100644 index 0000000..af9ae63 --- /dev/null +++ b/examples/json-api/app.cr @@ -0,0 +1,72 @@ +require "kemal" +require "json" + +# Set JSON content type for all routes +before_all do |env| + env.response.content_type = "application/json" +end + +# In-memory storage for users +USERS = [] of Hash(String, JSON::Any) + +# GET - List all users +get "/users" do |_| + USERS.to_json +end + +# GET - Get a specific user by index +get "/users/:id" do |env| + id = env.params.url["id"].to_i + + if id < USERS.size + USERS[id].to_json + else + env.response.status_code = 404 + {error: "User not found"}.to_json + end +end + +# POST - Create a new user +post "/users" do |env| + # Parse request body as JSON + # ameba:disable Lint/NotNil + user = JSON.parse(env.request.body.not_nil!.gets_to_end) + # ameba:enable Lint/NotNil + USERS << user.as_h + + env.response.status_code = 201 + user.to_json +end + +# PUT - Update a user +put "/users/:id" do |env| + id = env.params.url["id"].to_i + + if id < USERS.size + # Parse request body as JSON + # ameba:disable Lint/NotNil + updated_user = JSON.parse(env.request.body.not_nil!.gets_to_end) + # ameba:enable Lint/NotNil + USERS[id] = updated_user.as_h + updated_user.to_json + else + env.response.status_code = 404 + {error: "User not found"}.to_json + end +end + +# DELETE - Remove a user +delete "/users/:id" do |env| + id = env.params.url["id"].to_i + + if id < USERS.size + deleted_user = USERS.delete_at(id) + deleted_user.to_json + else + env.response.status_code = 404 + {error: "User not found"}.to_json + end +end + +# Start the Kemal web server +Kemal.run diff --git a/examples/json-mapping/app.cr b/examples/json-mapping/app.cr new file mode 100644 index 0000000..08b4ba7 --- /dev/null +++ b/examples/json-mapping/app.cr @@ -0,0 +1,32 @@ +require "kemal" +require "json" + +# Define a User class that can be created from JSON data +class User + # Include JSON::Serializable to add JSON parsing capabilities + # This allows converting JSON strings to User objects and vice versa + include JSON::Serializable + + # Define properties that will be mapped from JSON + # These properties must match the keys in the incoming JSON + property username : String # User's username as a string + property password : String # User's password as a string +end + +# Handle POST requests to the root path "/" +post "/" do |env| + # Parse the request body as JSON and create a User object + # env.request.body contains the raw JSON data + # not_nil! ensures the body exists + # User.from_json converts the JSON string to a User object + # ameba:disable Lint/NotNil + user = User.from_json env.request.body.not_nil! + # ameba:enable Lint/NotNil + + # Convert the user object back to JSON and return it + # This creates a JSON object with username and password fields + {username: user.username, password: user.password}.to_json +end + +# Start the Kemal web server +Kemal.run diff --git a/examples/mysql-db/app.cr b/examples/mysql-db/app.cr new file mode 100644 index 0000000..14045cc --- /dev/null +++ b/examples/mysql-db/app.cr @@ -0,0 +1,56 @@ +require "kemal" +require "db" +require "mysql" + +# Initialize a single DB connection +DB_URL = "mysql://root:password@localhost:3306/mydb" +DBC = DB.open(DB_URL) + +# Example User model +class User + include JSON::Serializable # To render json in HTTP::Response + include DB::Serializable # To serialize from DB::ResultSet + + property id : Int32 + property name : String + property email : String + + def initialize(@id, @name, @email) + end +end + +# List all users +get "/users" do |_| + # Serialize ResultSet + users = User.from_rs(DBC.query("SELECT * FROM users")) + + # Return users array as JSON response + users.to_json +end + +# Create a new user +post "/users" do |env| + name = env.params.json["name"].as(String) + email = env.params.json["email"].as(String) + + user = User.from_rs(DBC.query("INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email", name, email)).first + + {message: "User created with id: #{user.id}"}.to_json +end + +# Delete a user +delete "/users/:id" do |env| + id = env.params.url["id"].to_i + + # Delete user and check if any rows were affected + result = DBC.exec "DELETE FROM users WHERE id = ?", id + + if result.rows_affected > 0 + {message: "User deleted successfully"}.to_json + else + env.response.status_code = 404 + {message: "User not found"}.to_json + end +end + +Kemal.run diff --git a/examples/postgresql-db/app.cr b/examples/postgresql-db/app.cr new file mode 100644 index 0000000..01aa629 --- /dev/null +++ b/examples/postgresql-db/app.cr @@ -0,0 +1,56 @@ +require "kemal" +require "db" +require "pg" + +# Initialize a single DB connection +DB_URL = "postgres://postgres:postgres@localhost:5432/mydb" +DBC = DB.open(DB_URL) + +# Example User model +class User + include JSON::Serializable # To render json in HTTP::Response + include DB::Serializable # To serialize from DB::ResultSet + + property id : Int32 + property name : String + property email : String + + def initialize(@id, @name, @email) + end +end + +# List all users +get "/users" do |_| + # Serialize ResultSet + users = User.from_rs(DBC.query("SELECT * FROM users")) + + # Return users array as JSON response + users.to_json +end + +# Create a new user +post "/users" do |env| + name = env.params.json["name"].as(String) + email = env.params.json["email"].as(String) + + user = User.from_rs(DBC.query("INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email", name, email)).first + + {message: "User created with id: #{user.id}"}.to_json +end + +# Delete a user +delete "/users/:id" do |env| + id = env.params.url["id"].to_i + + # Delete user and check if any rows were affected + result = DBC.exec "DELETE FROM users WHERE id = $1", id + + if result.rows_affected > 0 + {message: "User deleted successfully"}.to_json + else + env.response.status_code = 404 + {message: "User not found"}.to_json + end +end + +Kemal.run diff --git a/examples/redis/app.cr b/examples/redis/app.cr new file mode 100644 index 0000000..c7a8492 --- /dev/null +++ b/examples/redis/app.cr @@ -0,0 +1,58 @@ +require "kemal" +require "redis" + +# Initialize Redis client +REDIS = Redis.new(host: "localhost", port: 6379) + +# Store a value +post "/store/:key" do |env| + key = env.params.url["key"] + value = env.params.json["value"].as(String) + + REDIS.set(key, value) + {message: "Value stored successfully"}.to_json +end + +# Retrieve a value +get "/get/:key" do |env| + key = env.params.url["key"] + + if value = REDIS.get(key) + {key: key, value: value}.to_json + else + env.response.status_code = 404 + {message: "Key not found"}.to_json + end +end + +# Delete a value +delete "/:key" do |env| + key = env.params.url["key"] + + if REDIS.del(key) > 0 + {message: "Key deleted successfully"}.to_json + else + env.response.status_code = 404 + {message: "Key not found"}.to_json + end +end + +# Increment a counter +post "/incr/:key" do |env| + key = env.params.url["key"] + new_value = REDIS.incr(key) + + {key: key, value: new_value}.to_json +end + +# Store with expiration +post "/store_temp/:key" do |env| + key = env.params.url["key"] + value = env.params.json["value"].as(String) + ttl = env.params.json["ttl"].as(Int64) + + REDIS.setex(key, ttl, value) + {message: "Value stored with expiration"}.to_json +end + +Kemal.run diff --git a/examples/reuse-port/app.cr b/examples/reuse-port/app.cr new file mode 100644 index 0000000..b059252 --- /dev/null +++ b/examples/reuse-port/app.cr @@ -0,0 +1,19 @@ +require "kemal" + +# Define a simple route that returns a message +get "/" do + "Reusing port 3000" +end + +# Start Kemal with custom server configuration +Kemal.run do |config| + # Get the server instance from the config + # ameba:disable Lint/NotNil + server = config.server.not_nil! + # ameba:enable Lint/NotNil + + # Bind the server to port 3000 with reuse_port enabled + # reuse_port: true allows multiple processes to listen on the same port + # This is useful for load balancing across multiple worker processes + server.bind_tcp "0.0.0.0", 3000, reuse_port: true +end diff --git a/examples/unix-domain-socket/app.cr b/examples/unix-domain-socket/app.cr new file mode 100644 index 0000000..aae5973 --- /dev/null +++ b/examples/unix-domain-socket/app.cr @@ -0,0 +1,14 @@ +require "kemal" + +# Start Kemal with custom server configuration to use Unix Domain Socket +Kemal.run do |config| + # Get the server instance from the config + # ameba:disable Lint/NotNil + server = config.server.not_nil! + # ameba:enable Lint/NotNil + + # Bind the server to a Unix Domain Socket instead of TCP port + # Unix Domain Sockets provide faster inter-process communication on the same machine + # They are commonly used when the client and server are on the same host + server.bind_unix "path/to/socket.sock" +end diff --git a/examples/websocket-chat/app.cr b/examples/websocket-chat/app.cr new file mode 100644 index 0000000..b7e380d --- /dev/null +++ b/examples/websocket-chat/app.cr @@ -0,0 +1,33 @@ +require "kemal" + +# Array to store chat message history +messages = [] of String +# Array to keep track of connected WebSocket clients +sockets = [] of HTTP::WebSocket + +# Create WebSocket endpoint at root path "/" +ws "/" do |socket| + # Add newly connected client socket to our sockets array + sockets.push socket + + # Handle incoming messages from clients + socket.on_message do |message| + # Store the new message in history + messages.push message + # Broadcast the updated message history to all connected clients + sockets.each do |a_socket| + a_socket.send messages.to_json + end + end + + # Handle client disconnection + socket.on_close do |_| + # Remove disconnected client's socket from our array + sockets.delete(socket) + # Log disconnection event + puts "Closing Socket: #{socket}" + end +end + +# Start the Kemal server +Kemal.run diff --git a/samples/hello_world.cr b/samples/hello_world.cr deleted file mode 100644 index c04f1d5..0000000 --- a/samples/hello_world.cr +++ /dev/null @@ -1,8 +0,0 @@ -require "kemal" - -# Set root. If not specified the default content_type is 'text' -get "/" do - "Hello Kemal!" -end - -Kemal.run diff --git a/samples/json_api.cr b/samples/json_api.cr deleted file mode 100644 index 0132c14..0000000 --- a/samples/json_api.cr +++ /dev/null @@ -1,11 +0,0 @@ -require "kemal" -require "json" - -# You can easily access the context and set content_type like 'application/json'. -# Look how easy to build a JSON serving API. -get "/" do |env| - env.response.content_type = "application/json" - {name: "Serdar", age: 27}.to_json -end - -Kemal.run diff --git a/samples/websocket_server.cr b/samples/websocket_server.cr deleted file mode 100644 index 61a0802..0000000 --- a/samples/websocket_server.cr +++ /dev/null @@ -1,11 +0,0 @@ -require "kemal" - -ws "/" do |socket| - socket.send "Hello from Kemal!" - - socket.on_message do |message| - socket.send "Echo back from server #{message}" - end -end - -Kemal.run diff --git a/shard.yml b/shard.yml index d0371c4..e23257b 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: kemal -version: 1.6.0 +version: 1.10.1 authors: - Serdar Dogruyol @@ -15,6 +15,7 @@ dependencies: development_dependencies: ameba: github: crystal-ameba/ameba + branch: master crystal: ">= 0.36.0" diff --git a/spec/asset/hello_with_content_for.ecr b/spec/asset/hello_with_content_for.ecr index b5460f9..8b4dc69 100644 --- a/spec/asset/hello_with_content_for.ecr +++ b/spec/asset/hello_with_content_for.ecr @@ -2,4 +2,4 @@ Hello <%= name %> <% content_for "meta" do %> Kemal Spec -<% end %> \ No newline at end of file +<% end %> diff --git a/spec/asset/layout_with_yield.ecr b/spec/asset/layout_with_yield.ecr index 3710c4a..a025b2a 100644 --- a/spec/asset/layout_with_yield.ecr +++ b/spec/asset/layout_with_yield.ecr @@ -5,4 +5,4 @@ <%= content %> - \ No newline at end of file + diff --git a/spec/asset/layout_with_yield_and_vars.ecr b/spec/asset/layout_with_yield_and_vars.ecr index d2a8a35..1595d81 100644 --- a/spec/asset/layout_with_yield_and_vars.ecr +++ b/spec/asset/layout_with_yield_and_vars.ecr @@ -7,4 +7,4 @@ <%= var1 %> <%= var2 %> - \ No newline at end of file + diff --git a/spec/cli_spec.cr b/spec/cli_spec.cr new file mode 100644 index 0000000..e5650bd --- /dev/null +++ b/spec/cli_spec.cr @@ -0,0 +1,90 @@ +require "./spec_helper" + +{% if !flag?(:without_openssl) %} + private def run_cli_eval(cli_args : String) + output = IO::Memory.new + error = IO::Memory.new + status = Process.run( + "crystal", + [ + "eval", + %(require "./src/kemal"; Kemal::CLI.new(#{cli_args})), + ], + output: output, + error: error, + ) + + {status, output.to_s, error.to_s} + end +{% end %} + +describe "Kemal::CLI" do + it "parses host binding with long option" do + Kemal::CLI.new(["--bind", "127.0.0.1"]) + Kemal.config.host_binding.should eq("127.0.0.1") + end + + it "parses host binding with short option" do + Kemal::CLI.new(["-b", "192.168.1.10"]) + Kemal.config.host_binding.should eq("192.168.1.10") + end + + it "parses port with long and short options" do + Kemal::CLI.new(["--port", "4001"]) + Kemal.config.port.should eq(4001) + + Kemal::CLI.new(["-p", "5002"]) + Kemal.config.port.should eq(5002) + end + + it "raises for non-numeric port values" do + expect_raises(ArgumentError) do + Kemal::CLI.new(["--port", "abc"]) + end + end + + {% if !flag?(:without_openssl) %} + it "fails when ssl is enabled but key file is missing" do + status, _, stderr = run_cli_eval(%(["--ssl", "--ssl-cert-file", "cert.pem"])) + + status.success?.should be_false + stderr.should contain("SSL configuration error: SSL key file not specified") + end + + it "fails when ssl is enabled but certificate file is missing" do + status, _, stderr = run_cli_eval(%(["--ssl", "--ssl-key-file", "key.pem"])) + + status.success?.should be_false + stderr.should contain("SSL configuration error: SSL certificate file not specified") + end + + it "fails when short ssl flag is used without key file" do + status, _, stderr = run_cli_eval(%(["-s", "--ssl-cert-file", "cert.pem"])) + + status.success?.should be_false + stderr.should contain("SSL configuration error: SSL key file not specified") + end + + it "fails when key file argument is empty" do + status, _, stderr = run_cli_eval(%(["--ssl", "--ssl-key-file", "", "--ssl-cert-file", "cert.pem"])) + + status.success?.should be_false + stderr.should contain("SSL configuration error: SSL key file not specified") + end + + it "fails when cert file argument is empty" do + status, _, stderr = run_cli_eval(%(["--ssl", "--ssl-key-file", "key.pem", "--ssl-cert-file", ""])) + + status.success?.should be_false + stderr.should contain("SSL configuration error: SSL certificate file not specified") + end + + it "does not hit missing-file validation when both flags are present" do + status, _, stderr = run_cli_eval(%(["--ssl", "--ssl-key-file", "key.pem", "--ssl-cert-file", "cert.pem"])) + + status.success?.should be_false + stderr.should_not contain("SSL configuration error: SSL key file not specified") + stderr.should_not contain("SSL configuration error: SSL certificate file not specified") + end + {% end %} +end diff --git a/spec/config_spec.cr b/spec/config_spec.cr index 11aa6d3..b4a0af5 100644 --- a/spec/config_spec.cr +++ b/spec/config_spec.cr @@ -29,15 +29,19 @@ describe "Config" do config = Kemal.config config.add_handler CustomTestHandler.new Kemal.config.setup - config.handlers.size.should eq(8) + config.handlers.size.should eq(7) end it "toggles the shutdown message" do config = Kemal.config config.shutdown_message = false - config.shutdown_message.should eq false + config.shutdown_message.should be_false config.shutdown_message = true - config.shutdown_message.should eq true + config.shutdown_message.should be_true + end + + it "sets default shutdown timeout to zero" do + Kemal::Config.new.shutdown_timeout.should eq 0.seconds end it "adds custom options" do diff --git a/spec/context_spec.cr b/spec/context_spec.cr index c972926..f542f8d 100644 --- a/spec/context_spec.cr +++ b/spec/context_spec.cr @@ -104,4 +104,29 @@ describe "Context" do context.get?("another_non_existent_key").should be_nil end end + + context "route cache invalidation" do + it "refreshes route lookup and url params after request method changes" do + put "/items/:id" { "ok" } + + request = HTTP::Request.new( + "POST", + "/items/42", + body: "_method=PUT", + headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded; charset=UTF-8"} + ) + + io = IO::Memory.new + response = HTTP::Server::Response.new(io) + context = HTTP::Server::Context.new(request, response) + + context.params.url.empty?.should be_true + + request.method = "PUT" + context.invalidate_route_cache + + context.route_found?.should be_true + context.params.url["id"].should eq "42" + end + end end diff --git a/spec/exception_handler_spec.cr b/spec/exception_handler_spec.cr index 7064e84..4486b14 100644 --- a/spec/exception_handler_spec.cr +++ b/spec/exception_handler_spec.cr @@ -59,6 +59,99 @@ describe "Kemal::ExceptionHandler" do response.body.should eq "Something happened" end + it "renders custom error for a crystal exception" do + error RuntimeError do + "A RuntimeError has 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" diff --git a/spec/handler_spec.cr b/spec/handler_spec.cr index 9b1019f..4bd1d04 100644 --- a/spec/handler_spec.cr +++ b/spec/handler_spec.cr @@ -77,7 +77,7 @@ describe "Handler" do filter_middleware._add_route_filter("GET", "/", :before) do |env| env.response << " so" end - add_handler CustomTestHandler.new + use CustomTestHandler.new get "/" do " Great" @@ -92,7 +92,7 @@ describe "Handler" do get "/only" do "Get" end - add_handler OnlyHandler.new + use OnlyHandler.new request = HTTP::Request.new("GET", "/only") client_response = call_request_on_app(request) client_response.body.should eq "OnlyGet" @@ -105,7 +105,7 @@ describe "Handler" do get "/exclude" do "Exclude" end - add_handler ExcludeHandler.new + use ExcludeHandler.new request = HTTP::Request.new("GET", "/") client_response = call_request_on_app(request) client_response.body.should eq "ExcludeGet" @@ -118,7 +118,7 @@ describe "Handler" do get "/only" do "Get" end - add_handler PostOnlyHandler.new + use PostOnlyHandler.new request = HTTP::Request.new("POST", "/only") client_response = call_request_on_app(request) client_response.body.should eq "OnlyPost" @@ -131,8 +131,8 @@ describe "Handler" do post "/only" do "Post" end - add_handler PostOnlyHandler.new - add_handler PostExcludeHandler.new + use PostOnlyHandler.new + use PostExcludeHandler.new request = HTTP::Request.new("POST", "/only") client_response = call_request_on_app(request) client_response.body.should eq "OnlyExcludePost" @@ -140,7 +140,7 @@ describe "Handler" do it "adds a handler at given position" do post_handler = PostOnlyHandler.new - add_handler post_handler, 1 + use post_handler, position: 1 Kemal.config.setup Kemal.config.handlers[1].should eq post_handler end diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr index 334d4cc..9942cf3 100644 --- a/spec/helpers_spec.cr +++ b/spec/helpers_spec.cr @@ -9,38 +9,23 @@ describe "Macros" do end end - describe "#add_handler" do + describe "#use" do it "adds a custom handler" do - add_handler CustomTestHandler.new + use CustomTestHandler.new Kemal.config.setup - Kemal.config.handlers.size.should eq 8 + Kemal.config.handlers.size.should eq 7 end end describe "#logging" do it "sets logging status" do logging false - Kemal.config.logging.should eq false - end - - it "sets a custom logger" do - config = Kemal::Config::INSTANCE - logger CustomLogHandler.new - config.logger.should be_a(CustomLogHandler) + Kemal.config.logging.should be_false end end describe "#halt" do it "can break block with halt macro" do - get "/non-breaking" do - "hello" - "world" - end - request = HTTP::Request.new("GET", "/non-breaking") - client_response = call_request_on_app(request) - client_response.status_code.should eq(200) - client_response.body.should eq("world") - get "/breaking" do |env| halt env, 404, "hello" "world" @@ -61,6 +46,61 @@ describe "Macros" do client_response.status_code.should eq(200) client_response.body.should eq("") end + + it "halts with chained status/json" do + get "/halt-status-json" do |env| + halt env.status(500).json({error: "Something went wrong"}) + "should-not-render" + end + + request = HTTP::Request.new("GET", "/halt-status-json") + client_response = call_request_on_app(request) + client_response.status_code.should eq(500) + client_response.headers["Content-Type"].should eq("application/json") + client_response.body.should eq(%({"error":"Something went wrong"})) + end + + it "halts with chained json" do + get "/halt-json" do |env| + halt env.json({error: "Something went wrong"}) + "should-not-render" + end + + request = HTTP::Request.new("GET", "/halt-json") + client_response = call_request_on_app(request) + client_response.status_code.should eq(200) + client_response.headers["Content-Type"].should eq("application/json") + client_response.body.should eq(%({"error":"Something went wrong"})) + end + + it "writes body when halting with chained json" do + get "/halt-json-raw" do |env| + halt env.status(500).json({error: "Something went wrong"}) + "should-not-render" + end + + request = HTTP::Request.new("GET", "/halt-json-raw") + client_response = call_request_on_app(request) + client_response.status_code.should eq(500) + client_response.headers["Content-Type"].should eq("application/json") + client_response.body.should eq(%({"error":"Something went wrong"})) + end + + it "halts env" do + get "/halt-env" do |env| + env.response.status_code = 500 + env.response.content_type = "application/json" + env.response.print({error: "Something went wrong"}.to_json) + halt env + "should-not-render" + end + + request = HTTP::Request.new("GET", "/halt-env") + client_response = call_request_on_app(request) + client_response.status_code.should eq(500) + client_response.headers["Content-Type"].should eq("application/json") + client_response.body.should eq(%({"error":"Something went wrong"})) + end end describe "#callbacks" do @@ -79,6 +119,23 @@ describe "Macros" do client_response.status_code.should eq(400) client_response.body.should eq("Missing origin.") end + + it "writes body when halting with chained json in before filter" do + filter_middleware = Kemal::FilterHandler.new + filter_middleware._add_route_filter("GET", "/halt-json-filter", :before) do |env| + halt env.status(500).json({error: "Something went wrong"}) + end + + get "/halt-json-filter" do |_env| + "should-not-render" + end + + request = HTTP::Request.new("GET", "/halt-json-filter") + client_response = call_request_on_app(request) + client_response.status_code.should eq(500) + client_response.headers["Content-Type"].should eq("application/json") + client_response.body.should eq(%({"error":"Something went wrong"})) + end end describe "#headers" do @@ -145,29 +202,139 @@ describe "Macros" do response.status_code.should eq(200) response.headers["Content-Disposition"].should eq("attachment; filename=\"image.jpg\"") end + + it "handles multiple range requests" do + get "/" do |env| + send_file env, "#{__DIR__}/asset/hello.ecr" + end + + headers = HTTP::Headers{"Range" => "bytes=0-4,7-11"} + request = HTTP::Request.new("GET", "/", headers) + response = call_request_on_app(request) + + response.status_code.should eq(206) + response.headers["Content-Type"].should match(/^multipart\/byteranges; boundary=kemal-/) + response.headers["Accept-Ranges"].should eq("bytes") + + # Verify multipart response structure + body = response.body + boundary = response.headers["Content-Type"].split("boundary=")[1] + parts = body.split("--#{boundary}") + # Parts structure: + # 1. Empty part before first boundary + # 2. First content part (0-4) + # 3. Second content part (7-11) + # 4. Trailing part after last boundary + parts.size.should eq(4) + + # First part (0-4) + first_part = parts[1] + first_part.should contain("Content-Type: multipart/byteranges") + first_part.should contain("Content-Range: bytes 0-4/18") + first_part.split("\r\n\r\n")[1].strip.should eq("Hello") + + # Second part (7-11) + second_part = parts[2] + second_part.should contain("Content-Type: multipart/byteranges") + second_part.should contain("Content-Range: bytes 7-11/18") + second_part.split("\r\n\r\n")[1].strip.should eq("%= na") + end + + it "handles invalid range requests" do + get "/" do |env| + send_file env, "#{__DIR__}/asset/hello.ecr" + end + + # Invalid range format + headers = HTTP::Headers{"Range" => "invalid"} + request = HTTP::Request.new("GET", "/", headers) + response = call_request_on_app(request) + response.status_code.should eq(200) + response.body.should eq(File.read("#{__DIR__}/asset/hello.ecr")) + + # Range out of bounds + headers = HTTP::Headers{"Range" => "bytes=100-200"} + request = HTTP::Request.new("GET", "/", headers) + response = call_request_on_app(request) + response.status_code.should eq(200) + response.body.should eq(File.read("#{__DIR__}/asset/hello.ecr")) + + # Invalid range values + headers = HTTP::Headers{"Range" => "bytes=5-3"} + request = HTTP::Request.new("GET", "/", headers) + response = call_request_on_app(request) + response.status_code.should eq(200) + response.body.should eq(File.read("#{__DIR__}/asset/hello.ecr")) + end + + it "handles empty range requests" do + get "/" do |env| + send_file env, "#{__DIR__}/asset/hello.ecr" + end + + headers = HTTP::Headers{"Range" => "bytes="} + request = HTTP::Request.new("GET", "/", headers) + response = call_request_on_app(request) + response.status_code.should eq(200) + response.body.should eq(File.read("#{__DIR__}/asset/hello.ecr")) + end + + it "handles overlapping ranges" do + get "/" do |env| + send_file env, "#{__DIR__}/asset/hello.ecr" + end + + headers = HTTP::Headers{"Range" => "bytes=0-5,3-8"} + request = HTTP::Request.new("GET", "/", headers) + response = call_request_on_app(request) + + response.status_code.should eq(206) + response.headers["Content-Type"].should match(/^multipart\/byteranges; boundary=kemal-/) + + # Verify both ranges are included + body = response.body + boundary = response.headers["Content-Type"].split("boundary=")[1] + parts = body.split("--#{boundary}") + # Parts structure: + # 1. Empty part before first boundary + # 2. First content part (0-5) + # 3. Second content part (3-8) + # 4. Trailing part after last boundary + parts.size.should eq(4) + + # First part (0-5) + first_part = parts[1] + first_part.should contain("Content-Range: bytes 0-5/18") + first_part.split("\r\n\r\n")[1].strip.should eq("Hello") + + # Second part (3-8) + second_part = parts[2] + second_part.should contain("Content-Range: bytes 3-8/18") + second_part.split("\r\n\r\n")[1].strip.should eq("lo <%=") + end end describe "#gzip" do it "adds HTTP::CompressHandler to handlers" do gzip true Kemal.config.setup - Kemal.config.handlers[5].should be_a(HTTP::CompressHandler) + Kemal.config.handlers[4].should be_a(HTTP::CompressHandler) end end describe "#serve_static" do it "should disable static file hosting" do serve_static false - Kemal.config.serve_static.should eq false + Kemal.config.serve_static.should be_false end - it "should disble enable gzip and dir_listing" do + it "should enable gzip and dir_listing" do serve_static({"gzip" => true, "dir_listing" => true}) conf = Kemal.config.serve_static - conf.is_a?(Hash).should eq true + conf.is_a?(Hash).should be_true if conf.is_a?(Hash) - conf["gzip"].should eq true - conf["dir_listing"].should eq true + conf["gzip"].should be_true + conf["dir_listing"].should be_true end end end diff --git a/spec/log_handler_spec.cr b/spec/log_handler_spec.cr index 5ee9c86..6e9a9a4 100644 --- a/spec/log_handler_spec.cr +++ b/spec/log_handler_spec.cr @@ -1,13 +1,6 @@ require "./spec_helper" describe "Kemal::LogHandler" do - it "logs to the given IO" do - io = IO::Memory.new - logger = Kemal::LogHandler.new io - logger.write "Something" - io.to_s.should eq "Something" - end - it "creates log message for each request" do request = HTTP::Request.new("GET", "/") io = IO::Memory.new diff --git a/spec/middleware/filters_spec.cr b/spec/middleware/filters_spec.cr index 5c3b477..2ba3f95 100644 --- a/spec/middleware/filters_spec.cr +++ b/spec/middleware/filters_spec.cr @@ -207,6 +207,22 @@ describe "Kemal::FilterHandler" do client_response = HTTP::Client::Response.from_io(io_with_context, decompress: false) client_response.body.should eq("true-true") end + + it "executes before_all filter on 404" do + before_filter = FilterTest.new + before_filter.modified = "false" + + filter_middleware = Kemal::FilterHandler.new + filter_middleware._add_route_filter("ALL", "*", :before) { before_filter.modified = "true" } + + error 404 do + before_filter.modified + end + + request = HTTP::Request.new("GET", "/not_found") + client_response = call_request_on_app(request) + client_response.body.should eq("true") + end end class FilterTest diff --git a/spec/override_method_handler_spec.cr b/spec/override_method_handler_spec.cr index 2b34e29..f0caf10 100644 --- a/spec/override_method_handler_spec.cr +++ b/spec/override_method_handler_spec.cr @@ -26,4 +26,48 @@ describe "Kemal::OverrideMethodHandler" do context.request.method.should eq "PATCH" end + + it "routes POST with _method=PUT to PUT handler in real app" do + use Kemal::OverrideMethodHandler::INSTANCE + + put "/items/:id" do |env| + "updated #{env.params.url["id"]}" + end + + request = HTTP::Request.new( + "POST", + "/items/42", + body: "_method=PUT", + headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded; charset=UTF-8"} + ) + + response = call_request_on_app(request) + + response.status_code.should eq 200 + response.body.should eq "updated 42" + end + + it "does not override method when _method is not allowed" do + use Kemal::OverrideMethodHandler::INSTANCE + + post "/items/:id" do |env| + "posted #{env.params.url["id"]}" + end + + put "/items/:id" do |env| + "updated #{env.params.url["id"]}" + end + + request = HTTP::Request.new( + "POST", + "/items/42", + body: "_method=TRACE", + headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded; charset=UTF-8"} + ) + + response = call_request_on_app(request) + + response.status_code.should eq 200 + response.body.should eq "posted 42" + end end diff --git a/spec/param_parser_spec.cr b/spec/param_parser_spec.cr index d63a229..0b1d1d7 100644 --- a/spec/param_parser_spec.cr +++ b/spec/param_parser_spec.cr @@ -174,7 +174,7 @@ describe "ParamParser" do body_params.to_s.should eq("") json_params = Kemal::ParamParser.new(request).json - json_params.should eq({} of String => Nil | String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any)) + json_params.should eq({} of String => String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any)?) end end @@ -201,4 +201,127 @@ describe "ParamParser" do body_params.to_s.should eq("") end end + + describe "raw_body" do + it "returns raw body for url-encoded form" do + request = HTTP::Request.new( + "POST", + "/", + body: "name=serdar&age=99", + headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"}, + ) + + parser = Kemal::ParamParser.new(request) + parser.raw_body.should eq("name=serdar&age=99") + parser.body["name"].should eq("serdar") + parser.body["age"].should eq("99") + end + + it "returns raw body for JSON" do + request = HTTP::Request.new( + "POST", + "/", + body: "{\"name\": \"Serdar\"}", + headers: HTTP::Headers{"Content-Type" => "application/json"}, + ) + + parser = Kemal::ParamParser.new(request) + parser.raw_body.should eq("{\"name\": \"Serdar\"}") + parser.json["name"].should eq("Serdar") + end + + it "caches body so it can be accessed multiple times" do + request = HTTP::Request.new( + "POST", + "/", + body: "foo=bar&baz=qux", + headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"}, + ) + + parser = Kemal::ParamParser.new(request) + parser.raw_body.should eq("foo=bar&baz=qux") + parser.raw_body.should eq("foo=bar&baz=qux") + parser.body["foo"].should eq("bar") + parser.raw_body.should eq("foo=bar&baz=qux") + end + + it "returns empty string for unsupported content types" do + request = HTTP::Request.new( + "POST", + "/", + body: "some body", + headers: HTTP::Headers{"Content-Type" => "text/plain"}, + ) + + parser = Kemal::ParamParser.new(request) + parser.raw_body.should eq("") + end + + it "returns empty string when content-type is missing" do + request = HTTP::Request.new("POST", "/", body: "some body") + + parser = Kemal::ParamParser.new(request) + parser.raw_body.should eq("") + end + end + + context "Payload too large" do + it "raises PayloadTooLarge when body exceeds limit" do + Kemal.config.max_request_body_size = 10 + request = HTTP::Request.new( + "POST", + "/", + body: "12345678901", + headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"}, + ) + + expect_raises(Kemal::Exceptions::PayloadTooLarge) do + Kemal::ParamParser.new(request).body + end + end + + it "raises PayloadTooLarge when Content-Length exceeds limit" do + Kemal.config.max_request_body_size = 10 + request = HTTP::Request.new( + "POST", + "/", + body: "1", + headers: HTTP::Headers{ + "Content-Type" => "application/x-www-form-urlencoded", + }, + ) + request.headers["Content-Length"] = "11" + + expect_raises(Kemal::Exceptions::PayloadTooLarge) do + Kemal::ParamParser.new(request).body + end + end + + it "parses body when size is within limit" do + Kemal.config.max_request_body_size = 20 + request = HTTP::Request.new( + "POST", + "/", + body: "name=serdar", + headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"}, + ) + + body_params = Kemal::ParamParser.new(request).body + body_params["name"].should eq("serdar") + end + + it "raises PayloadTooLarge for JSON body exceeding limit" do + Kemal.config.max_request_body_size = 10 + request = HTTP::Request.new( + "POST", + "/", + body: "{\"foo\":\"bar\"}", + headers: HTTP::Headers{"Content-Type" => "application/json"}, + ) + + expect_raises(Kemal::Exceptions::PayloadTooLarge) do + Kemal::ParamParser.new(request).json + end + end + end end diff --git a/spec/path_handler_spec.cr b/spec/path_handler_spec.cr new file mode 100644 index 0000000..05cdb4b --- /dev/null +++ b/spec/path_handler_spec.cr @@ -0,0 +1,265 @@ +require "./spec_helper" + +# Test middleware that sets a header +class TestHeaderHandler < Kemal::Handler + def initialize(@header_name : String, @header_value : String) + end + + def call(env) + env.response.headers[@header_name] = @header_value + call_next(env) + end +end + +# Test middleware that blocks requests +class BlockingHandler < Kemal::Handler + def call(env) + env.response.status_code = 401 + env.response.print "Blocked" + # Don't call_next - stop the chain + end +end + +# Test middleware that sets context value +class ContextSetterHandler < Kemal::Handler + def initialize(@key : String, @value : String) + end + + def call(env) + env.set @key, @value + call_next(env) + end +end + +describe "PathHandler" do + describe "use (global)" do + it "adds middleware that runs for all requests" do + use TestHeaderHandler.new("X-Global", "yes") + + get "/test1" do + "test1" + end + + get "/other/path" do + "other" + end + + request1 = HTTP::Request.new("GET", "/test1") + response1 = call_request_on_app(request1) + response1.headers["X-Global"].should eq("yes") + + request2 = HTTP::Request.new("GET", "/other/path") + response2 = call_request_on_app(request2) + response2.headers["X-Global"].should eq("yes") + end + end + + describe "use with path prefix" do + it "runs middleware only for matching path prefix" do + use "/api", TestHeaderHandler.new("X-API", "true") + + get "/api/users" do + "api users" + end + + get "/web/home" do + "web home" + end + + # Should have header for /api/* + api_request = HTTP::Request.new("GET", "/api/users") + api_response = call_request_on_app(api_request) + api_response.headers["X-API"]?.should eq("true") + api_response.body.should eq("api users") + + # Should NOT have header for /web/* + web_request = HTTP::Request.new("GET", "/web/home") + web_response = call_request_on_app(web_request) + web_response.headers["X-API"]?.should be_nil + web_response.body.should eq("web home") + end + + it "matches exact path" do + use "/api", TestHeaderHandler.new("X-Exact", "matched") + + get "/api" do + "api root" + end + + request = HTTP::Request.new("GET", "/api") + response = call_request_on_app(request) + response.headers["X-Exact"]?.should eq("matched") + end + + it "matches nested paths" do + use "/api", TestHeaderHandler.new("X-Nested", "yes") + + get "/api/v1/users/123/posts" do + "nested" + end + + request = HTTP::Request.new("GET", "/api/v1/users/123/posts") + response = call_request_on_app(request) + response.headers["X-Nested"]?.should eq("yes") + end + + it "does not match similar prefixes" do + use "/api", TestHeaderHandler.new("X-API-Only", "true") + + get "/apiv2/users" do + "apiv2" + end + + get "/api-old/users" do + "api-old" + end + + # /apiv2 should NOT match /api + request1 = HTTP::Request.new("GET", "/apiv2/users") + response1 = call_request_on_app(request1) + response1.headers["X-API-Only"]?.should be_nil + + # /api-old should NOT match /api + request2 = HTTP::Request.new("GET", "/api-old/users") + response2 = call_request_on_app(request2) + response2.headers["X-API-Only"]?.should be_nil + end + + it "does not match root when prefix is set" do + use "/admin", TestHeaderHandler.new("X-Admin", "true") + + get "/" do + "home" + end + + request = HTTP::Request.new("GET", "/") + response = call_request_on_app(request) + response.headers["X-Admin"]?.should be_nil + end + end + + describe "multiple middlewares" do + it "runs multiple middlewares in order" do + use "/api", TestHeaderHandler.new("X-First", "1") + use "/api", TestHeaderHandler.new("X-Second", "2") + + get "/api/test" do + "test" + end + + request = HTTP::Request.new("GET", "/api/test") + response = call_request_on_app(request) + response.headers["X-First"]?.should eq("1") + response.headers["X-Second"]?.should eq("2") + end + + it "supports array of middlewares" do + use "/multi", [ + TestHeaderHandler.new("X-A", "a"), + TestHeaderHandler.new("X-B", "b"), + TestHeaderHandler.new("X-C", "c"), + ] + + get "/multi/test" do + "multi" + end + + request = HTTP::Request.new("GET", "/multi/test") + response = call_request_on_app(request) + response.headers["X-A"]?.should eq("a") + response.headers["X-B"]?.should eq("b") + response.headers["X-C"]?.should eq("c") + end + + it "different paths have different middlewares" do + use "/api", TestHeaderHandler.new("X-API", "api") + use "/admin", TestHeaderHandler.new("X-Admin", "admin") + + get "/api/data" do + "api data" + end + + get "/admin/dashboard" do + "admin dashboard" + end + + api_request = HTTP::Request.new("GET", "/api/data") + api_response = call_request_on_app(api_request) + api_response.headers["X-API"]?.should eq("api") + api_response.headers["X-Admin"]?.should be_nil + + admin_request = HTTP::Request.new("GET", "/admin/dashboard") + admin_response = call_request_on_app(admin_request) + admin_response.headers["X-Admin"]?.should eq("admin") + admin_response.headers["X-API"]?.should be_nil + end + end + + describe "middleware can block requests" do + it "middleware can stop the chain" do + use "/protected", BlockingHandler.new + + get "/protected/secret" do + "secret data" + end + + get "/public" do + "public data" + end + + # Protected route should be blocked + protected_request = HTTP::Request.new("GET", "/protected/secret") + protected_response = call_request_on_app(protected_request) + protected_response.status_code.should eq(401) + protected_response.body.should eq("Blocked") + + # Public route should work + public_request = HTTP::Request.new("GET", "/public") + public_response = call_request_on_app(public_request) + public_response.status_code.should eq(200) + public_response.body.should eq("public data") + end + end + + describe "middleware with context" do + it "middleware can set context values" do + use "/ctx", ContextSetterHandler.new("middleware_ran", "yes") + + get "/ctx/check" do |env| + env.get("middleware_ran").to_s + end + + request = HTTP::Request.new("GET", "/ctx/check") + response = call_request_on_app(request) + response.body.should eq("yes") + end + end + + describe "PathHandler" do + describe "#matches_prefix?" do + it "root prefix matches all" do + get "/anything" do + "ok" + end + + use "/", TestHeaderHandler.new("X-Root", "all") + + request = HTTP::Request.new("GET", "/anything") + response = call_request_on_app(request) + response.headers["X-Root"]?.should eq("all") + end + + it "empty prefix matches all" do + use "", TestHeaderHandler.new("X-Empty", "all") + + get "/some/path" do + "ok" + end + + request = HTTP::Request.new("GET", "/some/path") + response = call_request_on_app(request) + response.headers["X-Empty"]?.should eq("all") + end + end + end +end diff --git a/spec/request_log_handler_spec.cr b/spec/request_log_handler_spec.cr new file mode 100644 index 0000000..798f322 --- /dev/null +++ b/spec/request_log_handler_spec.cr @@ -0,0 +1,17 @@ +require "log/spec" +require "./spec_helper" + +describe Kemal::RequestLogHandler do + it "creates log message for each request" do + Log.setup(:none) + + request = HTTP::Request.new("GET", "/") + response = HTTP::Server::Response.new(IO::Memory.new) + context = HTTP::Server::Context.new(request, response) + logger = Kemal::RequestLogHandler.new + Log.capture do |logs| + logger.call(context) + logs.check(:info, /404 GET \/ \d+.*s/) + end + end +end diff --git a/spec/response_helpers_spec.cr b/spec/response_helpers_spec.cr new file mode 100644 index 0000000..670fdf3 --- /dev/null +++ b/spec/response_helpers_spec.cr @@ -0,0 +1,245 @@ +require "./spec_helper" + +describe "Response Helpers" do + describe "#json" do + it "sets content-type to application/json" do + get "/json-test" do |env| + env.json({message: "hello"}) + end + + request = HTTP::Request.new("GET", "/json-test") + client_response = call_request_on_app(request) + client_response.headers["Content-Type"].should eq("application/json") + end + + it "serializes hash to JSON" do + get "/json-hash" do |env| + env.json({name: "alice", age: 30}) + end + + request = HTTP::Request.new("GET", "/json-hash") + client_response = call_request_on_app(request) + client_response.body.should eq(%({"name":"alice","age":30})) + end + + it "serializes array to JSON" do + get "/json-array" do |env| + env.json([1, 2, 3]) + end + + request = HTTP::Request.new("GET", "/json-array") + client_response = call_request_on_app(request) + client_response.body.should eq("[1,2,3]") + end + + it "accepts custom content_type (e.g. JSON API)" do + get "/json-api" do |env| + env.json({data: [] of String}, content_type: "application/vnd.api+json") + end + + request = HTTP::Request.new("GET", "/json-api") + client_response = call_request_on_app(request) + client_response.headers["Content-Type"].should eq("application/vnd.api+json") + client_response.body.should eq(%({"data":[]})) + end + end + + describe "#html" do + it "sets content-type to text/html" do + get "/html-test" do |env| + env.html("

Hello

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

Not Found

") + end + + request = HTTP::Request.new("GET", "/status-html") + client_response = call_request_on_app(request) + client_response.status_code.should eq(404) + client_response.headers["Content-Type"].should eq("text/html; charset=utf-8") + end + + it "is chainable with text" do + get "/status-text" do |env| + env.status(500).text("Internal Server Error") + end + + request = HTTP::Request.new("GET", "/status-text") + client_response = call_request_on_app(request) + client_response.status_code.should eq(500) + client_response.headers["Content-Type"].should eq("text/plain; charset=utf-8") + end + + it "is chainable with xml" do + get "/status-xml" do |env| + env.status(400).xml("Bad Request") + end + + request = HTTP::Request.new("GET", "/status-xml") + client_response = call_request_on_app(request) + client_response.status_code.should eq(400) + client_response.headers["Content-Type"].should eq("application/xml; charset=utf-8") + end + + 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(%(Blog)) + end + + request = HTTP::Request.new("GET", "/feed.xml") + client_response = call_request_on_app(request) + client_response.headers["Content-Type"].should eq("application/xml; charset=utf-8") + client_response.body.should contain(" should evict least recent among {2,3} + Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_4") + + # Insert 5 -> should evict the other of {2,3} + Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_5") + + # Now 0 and 1 must still resolve from cache, and size is capped + Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq small_capacity + + # A fresh lookup for 0 and 1 should be cache hits and not raise + Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_0").found?.should be_true + Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_recency_1").found?.should be_true + end + + it "caches HEAD fallback GET lookups without growing beyond 1 for same path" do + cap = 16 + Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(cap) + + get "/head_fallback" do + "ok" + end + + # First HEAD should fallback to GET and cache one entry keyed by HEAD+path + Kemal::RouteHandler::INSTANCE.lookup_route("HEAD", "/head_fallback").found?.should be_true + Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq 1 + + # Second HEAD lookup should be a cache hit; size must remain 1 + Kemal::RouteHandler::INSTANCE.lookup_route("HEAD", "/head_fallback").found?.should be_true + Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq 1 + end + + it "keeps size capped under heavy churn with large capacity" do + large_capacity = 4096 + Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(large_capacity) + + 0.upto(12000) do |i| + get "/lru_heavy_#{i}" do + "ok" + end + end + + # Fill and churn beyond capacity + 0.upto(11999) do |i| + Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_heavy_#{i}") + end + + Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq large_capacity + + # Additional churn should not increase size + 12000.upto(14000) do |i| + get "/lru_heavy_more_#{i}" do + "ok" + end + Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/lru_heavy_more_#{i}") + end + + Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq large_capacity + end + + it "handles concurrent lookups safely" do + Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(256) + + get "/concurrent" do + "ok" + end + + channel = Channel(Nil).new + fiber_count = 100 + fiber_count.times do + spawn do + Kemal::RouteHandler::INSTANCE.lookup_route("GET", "/concurrent").found?.should be_true + channel.send(nil) + end + end + fiber_count.times { channel.receive } + + Kemal::RouteHandler::INSTANCE.cached_routes.size.should eq 1 + end end end diff --git a/spec/router_spec.cr b/spec/router_spec.cr new file mode 100644 index 0000000..5b71267 --- /dev/null +++ b/spec/router_spec.cr @@ -0,0 +1,474 @@ +require "./spec_helper" + +describe "Kemal::Router" do + describe "basic routing" do + it "routes GET request with prefix" do + router = Kemal::Router.new + router.get "/users" do + "users list" + end + + mount "/api", router + + request = HTTP::Request.new("GET", "/api/users") + client_response = call_request_on_app(request) + client_response.body.should eq("users list") + end + + it "routes POST request with prefix" do + router = Kemal::Router.new + router.post "/users" do + "user created" + end + + mount "/api", router + + request = HTTP::Request.new("POST", "/api/users") + client_response = call_request_on_app(request) + client_response.body.should eq("user created") + end + + it "routes PUT request with prefix" do + router = Kemal::Router.new + router.put "/users/:id" do |env| + "user #{env.params.url["id"]} updated" + end + + mount "/api", router + + request = HTTP::Request.new("PUT", "/api/users/123") + client_response = call_request_on_app(request) + client_response.body.should eq("user 123 updated") + end + + it "routes DELETE request with prefix" do + router = Kemal::Router.new + router.delete "/users/:id" do |env| + "user #{env.params.url["id"]} deleted" + end + + mount "/api", router + + request = HTTP::Request.new("DELETE", "/api/users/456") + client_response = call_request_on_app(request) + client_response.body.should eq("user 456 deleted") + end + + it "routes PATCH request" do + router = Kemal::Router.new + router.patch "/users/:id" do + "user patched" + end + + mount "/api", router + + request = HTTP::Request.new("PATCH", "/api/users/1") + client_response = call_request_on_app(request) + client_response.body.should eq("user patched") + end + + it "routes OPTIONS request" do + router = Kemal::Router.new + router.options "/users" do |env| + env.response.headers["Allow"] = "GET, POST, OPTIONS" + "" + end + + mount "/api", router + + request = HTTP::Request.new("OPTIONS", "/api/users") + client_response = call_request_on_app(request) + client_response.headers["Allow"].should eq("GET, POST, OPTIONS") + end + + it "mounts router without prefix" do + router = Kemal::Router.new + router.get "/status" do + "ok" + end + + mount router + + request = HTTP::Request.new("GET", "/status") + client_response = call_request_on_app(request) + client_response.body.should eq("ok") + end + + it "works alongside global DSL routes" do + # Global DSL route + get "/global" do + "global route" + end + + # Router route + router = Kemal::Router.new + router.get "/local" do + "router route" + end + + mount "/api", router + + # Test global route + global_request = HTTP::Request.new("GET", "/global") + global_response = call_request_on_app(global_request) + global_response.body.should eq("global route") + + # Test router route + router_request = HTTP::Request.new("GET", "/api/local") + router_response = call_request_on_app(router_request) + router_response.body.should eq("router route") + end + end + + describe "router-scoped filters" do + it "applies before filter to router routes" do + router = Kemal::Router.new + + router.before do |env| + env.set "filtered", "yes" + end + + router.get "/test" do |env| + env.get("filtered").to_s + end + + mount "/api", router + + request = HTTP::Request.new("GET", "/api/test") + client_response = call_request_on_app(request) + client_response.body.should eq("yes") + end + + it "applies after filter to router routes" do + router = Kemal::Router.new + + router.after do |env| + env.response.headers["X-After-Filter"] = "applied" + end + + router.get "/test" do + "test" + end + + mount "/api", router + + request = HTTP::Request.new("GET", "/api/test") + client_response = call_request_on_app(request) + client_response.headers["X-After-Filter"].should eq("applied") + end + + it "applies method-specific before filter" do + router = Kemal::Router.new + + router.before_post do |env| + env.set "method", "post" + end + + router.post "/test" do |env| + env.get("method").to_s + end + + router.get "/test" do |env| + env.get?("method").to_s + end + + mount "/api", router + + post_request = HTTP::Request.new("POST", "/api/test") + post_response = call_request_on_app(post_request) + post_response.body.should eq("post") + end + + it "applies filter to specific path" do + router = Kemal::Router.new + + router.before "/protected" do |env| + env.set "auth", "required" + end + + router.get "/protected" do |env| + env.get("auth").to_s + end + + router.get "/public" do |env| + env.get?("auth").to_s + end + + mount "/api", router + + protected_request = HTTP::Request.new("GET", "/api/protected") + protected_response = call_request_on_app(protected_request) + protected_response.body.should eq("required") + end + + it "applies namespace filters only within the namespace" do + router = Kemal::Router.new + + router.namespace "/admin" do + before do |env| + halt env, 401, "unauthorized" unless env.request.headers["X-Admin"]? == "true" + end + + get "/dashboard" do |env| + env.get("path").to_s + end + end + + router.get "/public" do |env| + env.get("path").to_s + end + + mount "/api", router + + before_all do |env| + env.set "path", env.request.path + end + + get "/public" do |env| + env.get("path").to_s + end + + unauthorized_request = HTTP::Request.new("GET", "/api/admin/dashboard") + unauthorized_response = call_request_on_app(unauthorized_request) + unauthorized_response.status_code.should eq(401) + unauthorized_response.body.should eq("unauthorized") + + authorized_request = HTTP::Request.new( + "GET", + "/api/admin/dashboard", + headers: HTTP::Headers{"X-Admin" => "true"}, + ) + authorized_response = call_request_on_app(authorized_request) + authorized_response.status_code.should eq(200) + authorized_response.body.should eq("/api/admin/dashboard") + + api_public_request = HTTP::Request.new("GET", "/api/public") + api_public_response = call_request_on_app(api_public_request) + api_public_response.status_code.should eq(200) + api_public_response.body.should eq("/api/public") + + public_request = HTTP::Request.new("GET", "/public") + public_response = call_request_on_app(public_request) + public_response.status_code.should eq(200) + public_response.body.should eq("/public") + end + end + + describe "nested routers" do + it "namespaces routes correctly" do + router = Kemal::Router.new + + router.namespace "/users" do + get "/" do + "users index" + end + + get "/:id" do |env| + "user #{env.params.url["id"]}" + end + end + + mount "/api/v1", router + + index_request = HTTP::Request.new("GET", "/api/v1/users") + index_response = call_request_on_app(index_request) + index_response.body.should eq("users index") + + show_request = HTTP::Request.new("GET", "/api/v1/users/42") + show_response = call_request_on_app(show_request) + show_response.body.should eq("user 42") + end + + it "supports multiple namespaces" do + router = Kemal::Router.new + + router.namespace "/users" do + get "/" do + "users" + end + end + + router.namespace "/posts" do + get "/" do + "posts" + end + end + + mount "/api", router + + users_request = HTTP::Request.new("GET", "/api/users") + users_response = call_request_on_app(users_request) + users_response.body.should eq("users") + + posts_request = HTTP::Request.new("GET", "/api/posts") + posts_response = call_request_on_app(posts_request) + posts_response.body.should eq("posts") + end + + it "supports deeply nested routers" do + router = Kemal::Router.new + + router.namespace "/api" do + namespace "/v1" do + namespace "/users" do + get "/" do + "deeply nested users" + end + end + end + end + + mount router + + request = HTTP::Request.new("GET", "/api/v1/users") + client_response = call_request_on_app(request) + client_response.body.should eq("deeply nested users") + end + + it "mounts sub-router with mount method" do + users_router = Kemal::Router.new + users_router.get "/" do + "users from sub-router" + end + users_router.get "/:id" do |env| + "user #{env.params.url["id"]} from sub-router" + end + + api_router = Kemal::Router.new + api_router.mount "/users", users_router + + mount "/api", api_router + + index_request = HTTP::Request.new("GET", "/api/users") + index_response = call_request_on_app(index_request) + index_response.body.should eq("users from sub-router") + + show_request = HTTP::Request.new("GET", "/api/users/99") + show_response = call_request_on_app(show_request) + show_response.body.should eq("user 99 from sub-router") + end + + it "applies namespace filters correctly" do + router = Kemal::Router.new + + router.namespace "/admin" do + before do |env| + env.set "admin", "true" + end + + get "/dashboard" do |env| + "admin: #{env.get("admin")}" + end + end + + mount router + + request = HTTP::Request.new("GET", "/admin/dashboard") + client_response = call_request_on_app(request) + client_response.body.should eq("admin: true") + end + end + + describe "websocket support" do + it "registers websocket route with prefix" do + router = Kemal::Router.new + router.ws "/chat" do |socket| + socket.send("connected") + end + + mount "/ws", router + + handler = Kemal::WebSocketHandler::INSTANCE + headers = HTTP::Headers{ + "Upgrade" => "websocket", + "Connection" => "Upgrade", + "Sec-WebSocket-Key" => "dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Version" => "13", + } + request = HTTP::Request.new("GET", "/ws/chat", headers) + + io_with_context = create_ws_request_and_return_io_and_context(handler, request)[0] + io_with_context.to_s.should contain("101 Switching Protocols") + end + + it "websocket route with url parameters" do + router = Kemal::Router.new + router.ws "/room/:id" do |socket| + socket.send("room") + end + + mount "/ws", router + + handler = Kemal::WebSocketHandler::INSTANCE + headers = HTTP::Headers{ + "Upgrade" => "websocket", + "Connection" => "Upgrade", + "Sec-WebSocket-Key" => "dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Version" => "13", + } + request = HTTP::Request.new("GET", "/ws/room/123", headers) + + io_with_context = create_ws_request_and_return_io_and_context(handler, request)[0] + io_with_context.to_s.should contain("101 Switching Protocols") + end + end + + describe "router with prefix" do + it "initializes router with prefix" do + router = Kemal::Router.new("/v2") + router.get "/status" do + "v2 status" + end + + mount "/api", router + + request = HTTP::Request.new("GET", "/api/v2/status") + client_response = call_request_on_app(request) + client_response.body.should eq("v2 status") + end + end + + describe "edge cases" do + it "handles trailing slashes correctly" do + router = Kemal::Router.new + router.get "/users/" do + "users with trailing slash" + end + + mount "/api/", router + + request = HTTP::Request.new("GET", "/api/users/") + client_response = call_request_on_app(request) + client_response.body.should eq("users with trailing slash") + end + + it "handles root path in namespace" do + router = Kemal::Router.new + + router.namespace "/users" do + get "" do + "users root" + end + end + + mount "/api", router + + request = HTTP::Request.new("GET", "/api/users") + client_response = call_request_on_app(request) + client_response.body.should eq("users root") + end + + it "returns non-string values as empty string" do + router = Kemal::Router.new + router.get "/number" do + 42 + end + + mount router + + request = HTTP::Request.new("GET", "/number") + client_response = call_request_on_app(request) + client_response.body.should eq("") + end + end +end diff --git a/spec/run_spec.cr b/spec/run_spec.cr index c15a7e9..e14e1d4 100644 --- a/spec/run_spec.cr +++ b/spec/run_spec.cr @@ -3,6 +3,10 @@ require "./spec_helper" private def run(code) code = <<-CR require "./src/kemal" + + Kemal.config.env = "test" + Kemal.config.port = 8000 + #{code} CR @@ -15,35 +19,56 @@ end describe "Run" do it "runs a code block after starting" do - run(<<-CR).should eq "started\nstopped\n" - Kemal.config.env = "test" + run(<<-CR).should contain("started") + Kemal.run do + log "started" + end + CR + end + + it "runs a code block after stopping" do + run(<<-CR).should contain("stopped") Kemal.run do - puts "started" Kemal.stop - puts "stopped" + log "stopped" end CR end it "runs without a block being specified" do run(<<-CR).should contain "[test] Kemal is running in test mode." - Kemal.config.env = "test" Kemal.run - puts Kemal.config.running + Kemal.config.running CR end + it "applies shutdown_timeout during graceful shutdown" do + output = run(<<-'CRYSTAL') + Kemal.config.shutdown_timeout = 30.milliseconds + start = Time.monotonic + + Kemal.run do + Kemal.stop + end + + elapsed_ms = (Time.monotonic - start).total_milliseconds + puts "elapsed_ms=#{elapsed_ms}" + CRYSTAL + + match = output.match!(/elapsed_ms=([0-9]+(?:\.[0-9]+)?)/) + match[1].to_f.should be >= 20.0 + end + it "allows custom HTTP::Server bind" do run(<<-CR).should contain "[test] Kemal is running in test mode." - Kemal.config.env = "test" Kemal.run do |config| server = config.server.not_nil! {% if flag?(:windows) %} - server.bind_tcp "127.0.0.1", 3000 + server.bind_tcp "127.0.0.1", 8000 {% else %} - server.bind_tcp "127.0.0.1", 3000, reuse_port: true - server.bind_tcp "0.0.0.0", 3001, reuse_port: true + server.bind_tcp "127.0.0.1", 8000, reuse_port: true + server.bind_tcp "0.0.0.0", 8001, reuse_port: true {% end %} end CR diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 6509e1e..7b18fbd 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -26,6 +26,12 @@ class AnotherContextStorageType @name = "kemal-context" end +class CustomExceptionType < Exception +end + +class ChildCustomExceptionType < CustomExceptionType +end + add_context_storage_type(TestContextStorageType) add_context_storage_type(AnotherContextStorageType) @@ -87,6 +93,6 @@ Spec.after_each do Kemal.config.clear Kemal::FilterHandler::INSTANCE.tree = Radix::Tree(Array(Kemal::FilterHandler::FilterBlock)).new Kemal::RouteHandler::INSTANCE.routes = Radix::Tree(Route).new - Kemal::RouteHandler::INSTANCE.cached_routes = Hash(String, Radix::Result(Route)).new + Kemal::RouteHandler::INSTANCE.cached_routes = Kemal::LRUCache(String, Radix::Result(Kemal::Route)).new(Kemal.config.max_route_cache_size) Kemal::WebSocketHandler::INSTANCE.routes = Radix::Tree(WebSocket).new end diff --git a/spec/static/dir/nested/path/test.txt b/spec/static/dir/nested/path/test.txt new file mode 100644 index 0000000..9db7df0 --- /dev/null +++ b/spec/static/dir/nested/path/test.txt @@ -0,0 +1,2 @@ +hello +world \ No newline at end of file diff --git a/spec/static_file_handler_spec.cr b/spec/static_file_handler_spec.cr index 5f8c029..13e8832 100644 --- a/spec/static_file_handler_spec.cr +++ b/spec/static_file_handler_spec.cr @@ -13,6 +13,7 @@ end describe Kemal::StaticFileHandler do file = File.open "#{__DIR__}/static/dir/test.txt" + File.open "#{__DIR__}/static/dir/nested/path/test.txt" file_size = file.size it "should serve a file with content type and etag" do @@ -97,12 +98,12 @@ describe Kemal::StaticFileHandler do end it "should handle only GET and HEAD method" do - %w(GET HEAD).each do |method| + %w[GET HEAD].each do |method| response = handle HTTP::Request.new(method, "/dir/test.txt") response.status_code.should eq(200) end - %w(POST PUT DELETE).each do |method| + %w[POST PUT DELETE].each do |method| response = handle HTTP::Request.new(method, "/dir/test.txt") response.status_code.should eq(404) response = handle HTTP::Request.new(method, "/dir/test.txt"), false @@ -112,36 +113,34 @@ describe Kemal::StaticFileHandler do end it "should send part of files when requested (RFC7233)" do - %w(POST PUT DELETE HEAD).each do |method| - headers = HTTP::Headers{"Range" => "0-100"} + %w[POST PUT DELETE HEAD].each do |method| + headers = HTTP::Headers{"Range" => "bytes=0-4"} response = handle HTTP::Request.new(method, "/dir/test.txt", headers) response.status_code.should_not eq(206) - response.headers.has_key?("Content-Range").should eq(false) + response.headers.has_key?("Content-Range").should be_false end - %w(GET).each do |method| - headers = HTTP::Headers{"Range" => "0-100"} + %w[GET].each do |method| + headers = HTTP::Headers{"Range" => "bytes=0-4"} response = handle HTTP::Request.new(method, "/dir/test.txt", headers) - response.status_code.should eq(206 || 200) - if response.status_code == 206 - response.headers.has_key?("Content-Range").should eq true - match = response.headers["Content-Range"].match(/bytes (\d+)-(\d+)\/(\d+)/) - match.should_not be_nil - if match - start_range = match[1].to_i { 0 } - end_range = match[2].to_i { 0 } - range_size = match[3].to_i { 0 } + response.status_code.should eq(206) + response.headers.has_key?("Content-Range").should be_true + match = response.headers["Content-Range"].match(/bytes (\d+)-(\d+)\/(\d+)/) + match.should_not be_nil + if match + start_range = match[1].to_i { 0 } + end_range = match[2].to_i { 0 } + range_size = match[3].to_i { 0 } - range_size.should eq file_size - (end_range < file_size).should eq true - (start_range < end_range).should eq true - end + range_size.should eq file_size + (end_range < file_size).should be_true + (start_range < end_range).should be_true end end end it "should handle setting custom headers" do - headers = Proc(HTTP::Server::Context, String, File::Info, Void).new do |env, path, stat| + headers = Proc(HTTP::Server::Context, String, File::Info, Nil).new do |env, path, stat| if path =~ /\.html$/ env.response.headers.add("Access-Control-Allow-Origin", "*") end @@ -159,4 +158,30 @@ describe Kemal::StaticFileHandler do response = handle HTTP::Request.new("GET", "/dir/index.html") response.headers["Access-Control-Allow-Origin"].should eq("*") end + + # Path Traversal Security Tests + it "should prevent path traversal attacks with .." do + response = handle HTTP::Request.new("GET", "/../../../etc/passwd") + response.status_code.should eq(302) + end + + it "should prevent path traversal attacks with URL encoded .." do + response = handle HTTP::Request.new("GET", "/..%2f..%2f..%2fetc%2fpasswd") + response.status_code.should eq(302) + end + + it "should prevent path traversal attacks with mixed .. and URL encoded .." do + response = handle HTTP::Request.new("GET", "/..%2f../..%2fetc%2fpasswd") + response.status_code.should eq(302) + end + + it "should allow legitimate nested paths" do + response = handle HTTP::Request.new("GET", "/dir/nested/path/test.txt") + response.status_code.should eq(200) + end + + it "should handle requests with trailing slashes in nested paths" do + response = handle HTTP::Request.new("GET", "/dir/nested/path/") + response.status_code.should eq(200) + end end diff --git a/spec/view_spec.cr b/spec/view_spec.cr index 79b8768..d355b73 100644 --- a/spec/view_spec.cr +++ b/spec/view_spec.cr @@ -1,7 +1,7 @@ require "./spec_helper" macro render_with_base_and_layout(filename) - render "#{__DIR__}/asset/#{{{filename}}}", "#{__DIR__}/asset/layout.ecr" + render "#{__DIR__}/asset/#{{{ filename }}}", "#{__DIR__}/asset/layout.ecr" end describe "Views" do @@ -38,8 +38,8 @@ describe "Views" do it "renders layout with variables" do get "/view/:name" do |env| name = env.params.url["name"] - var1 = "serdar" - var2 = "kemal" + var1 = "serdar" # ameba:disable Lint/UselessAssign + var2 = "kemal" # ameba:disable Lint/UselessAssign render "#{__DIR__}/asset/hello_with_content_for.ecr", "#{__DIR__}/asset/layout_with_yield_and_vars.ecr" end request = HTTP::Request.new("GET", "/view/world") diff --git a/src/kemal.cr b/src/kemal.cr index bf7af65..d567445 100644 --- a/src/kemal.cr +++ b/src/kemal.cr @@ -1,24 +1,27 @@ require "http" require "json" +require "log" require "uri" require "./kemal/*" require "./kemal/ext/*" require "./kemal/helpers/*" module Kemal + Log = ::Log.for(self) + # Overload of `self.run` with the default startup logging. def self.run(port : Int32?, args = ARGV, trap_signal : Bool = true) - self.run(port, args, trap_signal) { } + run(port, args, trap_signal) { } end # Overload of `self.run` without port. def self.run(args = ARGV, trap_signal : Bool = true) - self.run(nil, args: args, trap_signal: trap_signal) + run(nil, args: args, trap_signal: trap_signal) end # Overload of `self.run` to allow just a block. def self.run(args = ARGV, &block) - self.run(nil, args: args, trap_signal: true, &block) + run(nil, args: args, trap_signal: true, &block) end # The command to run a `Kemal` application. @@ -46,41 +49,46 @@ module Kemal yield config # Abort if block called `Kemal.stop` - return unless config.running + return if !config.running - unless server.each_address { |_| break true } - {% if flag?(:without_openssl) %} - server.bind_tcp(config.host_binding, config.port) - {% else %} - if ssl = config.ssl - server.bind_tls(config.host_binding, config.port, ssl) - else + if config.env != "test" + if !server.each_address { |_| break true } + {% if flag?(:without_openssl) %} server.bind_tcp(config.host_binding, config.port) - end - {% end %} + {% else %} + if ssl = config.ssl + server.bind_tls(config.host_binding, config.port, ssl) + else + server.bind_tcp(config.host_binding, config.port) + end + {% end %} + end end display_startup_message(config, server) - server.listen unless config.env == "test" + server.listen if config.env != "test" end def self.display_startup_message(config, server) if config.env != "test" addresses = server.addresses.join ", " { |address| "#{config.scheme}://#{address}" } - log "[#{config.env}] #{config.app_name} is ready to lead at #{addresses}" + Log.info { "[#{config.env}] #{config.app_name} is ready to lead at #{addresses}" } else - log "[#{config.env}] #{config.app_name} is running in test mode. Server not listening" + Log.info { "[#{config.env}] #{config.app_name} is running in test mode. Server not listening" } end end def self.stop - raise "#{Kemal.config.app_name} is already stopped." if !config.running + raise "#{Kemal.config.app_name} is already stopped. Cannot stop an already stopped server." if !config.running if server = config.server server.close unless server.closed? config.running = false + if config.shutdown_timeout.positive? + sleep(config.shutdown_timeout) + end else - raise "Kemal.config.server is not set. Please use Kemal.run to set the server." + raise "Cannot stop #{Kemal.config.app_name}: server instance is not set. Please ensure Kemal.run has been called before calling Kemal.stop." end end @@ -94,7 +102,7 @@ module Kemal private def self.setup_trap_signal Process.on_terminate do - log "#{Kemal.config.app_name} is going to take a rest!" if Kemal.config.shutdown_message + Log.info { "#{Kemal.config.app_name} is going to take a rest!" } if Kemal.config.shutdown_message Kemal.stop exit end diff --git a/src/kemal/cli.cr b/src/kemal/cli.cr index b166708..d4913b8 100644 --- a/src/kemal/cli.cr +++ b/src/kemal/cli.cr @@ -42,11 +42,11 @@ module Kemal private def configure_ssl {% if !flag?(:without_openssl) %} if @ssl_enabled - abort "SSL Key Not Found" if !@key_file - abort "SSL Certificate Not Found" if !@cert_file + abort "SSL configuration error: SSL key file not specified. Use --ssl-key-file FILE to specify the key file." if @key_file.empty? + abort "SSL configuration error: SSL certificate file not specified. Use --ssl-cert-file FILE to specify the certificate file." if @cert_file.empty? ssl = Kemal::SSL.new - ssl.key_file = @key_file.not_nil! - ssl.cert_file = @cert_file.not_nil! + ssl.key_file = @key_file + ssl.cert_file = @cert_file Kemal.config.ssl = ssl.context end {% end %} diff --git a/src/kemal/config.cr b/src/kemal/config.cr index b079c7b..2d76602 100644 --- a/src/kemal/config.cr +++ b/src/kemal/config.cr @@ -8,11 +8,12 @@ module Kemal # Kemal.config # ``` class Config - INSTANCE = Config.new - HANDLERS = [] of HTTP::Handler - CUSTOM_HANDLERS = [] of Tuple(Nil | Int32, HTTP::Handler) - FILTER_HANDLERS = [] of HTTP::Handler - ERROR_HANDLERS = {} of Int32 => HTTP::Server::Context, Exception -> String + INSTANCE = Config.new + HANDLERS = [] of HTTP::Handler + CUSTOM_HANDLERS = [] of Tuple(Int32?, HTTP::Handler) + FILTER_HANDLERS = [] of HTTP::Handler + ERROR_HANDLERS = {} of Int32 => HTTP::Server::Context, Exception -> String + EXCEPTION_HANDLERS = {} of Exception.class => HTTP::Server::Context, Exception -> String {% if flag?(:without_openssl) %} @ssl : Bool? @@ -21,10 +22,12 @@ module Kemal {% end %} property app_name, host_binding, ssl, port, env, public_folder, logging, running - property always_rescue, server : HTTP::Server?, extra_options, shutdown_message + property always_rescue, server : HTTP::Server?, extra_options, shutdown_message, shutdown_timeout property serve_static : (Bool | Hash(String, Bool)) - property static_headers : (HTTP::Server::Context, String, File::Info -> Void)? + property static_headers : (HTTP::Server::Context, String, File::Info ->)? property? powered_by_header : Bool = true + property max_route_cache_size : Int32 + property max_request_body_size : Int32 def initialize @app_name = "Kemal" @@ -41,13 +44,23 @@ module Kemal @default_handlers_setup = false @running = false @shutdown_message = true + @shutdown_timeout = 0.seconds @handler_position = 0 + @max_route_cache_size = 1024 + @max_request_body_size = 8 * 1024 * 1024 # 8MB end + @[Deprecated("Use standard library Log")] def logger - @logger.not_nil! + @logger || NullLogHandler.new end + # :nodoc: + def logger? + @logger + end + + @[Deprecated("Use standard library Log")] def logger=(logger : Kemal::BaseLogHandler) @logger = logger end @@ -61,10 +74,13 @@ module Kemal @router_included = false @handler_position = 0 @default_handlers_setup = false + @max_route_cache_size = 1024 + @max_request_body_size = 8 * 1024 * 1024 HANDLERS.clear CUSTOM_HANDLERS.clear FILTER_HANDLERS.clear ERROR_HANDLERS.clear + EXCEPTION_HANDLERS.clear end def handlers @@ -88,14 +104,26 @@ module Kemal FILTER_HANDLERS << handler end + # Returns the defined error handlers for HTTP status codes def error_handlers ERROR_HANDLERS end + # Adds an error handler for the given HTTP status code def add_error_handler(status_code : Int32, &handler : HTTP::Server::Context, Exception -> _) ERROR_HANDLERS[status_code] = ->(context : HTTP::Server::Context, error : Exception) { handler.call(context, error).to_s } end + # Returns the defined error handlers for exceptions + def exception_handlers + EXCEPTION_HANDLERS + end + + # Adds an error handler for the given exception + def add_exception_handler(exception : Exception.class, &handler : HTTP::Server::Context, Exception -> _) + EXCEPTION_HANDLERS[exception] = ->(context : HTTP::Server::Context, error : Exception) { handler.call(context, error).to_s } + end + def extra_options(&@extra_options : OptionParser ->) end @@ -121,12 +149,11 @@ module Kemal end private def setup_log_handler - @logger ||= if @logging - Kemal::LogHandler.new - else - Kemal::NullLogHandler.new - end - HANDLERS.insert(@handler_position, @logger.not_nil!) + return unless @logging + + log_handler = @logger || Kemal::RequestLogHandler.new + + HANDLERS.insert(@handler_position, log_handler) @handler_position += 1 end @@ -137,8 +164,8 @@ module Kemal private def setup_error_handler if @always_rescue - @error_handler ||= Kemal::ExceptionHandler.new - HANDLERS.insert(@handler_position, @error_handler.not_nil!) + handler = @error_handler ||= Kemal::ExceptionHandler.new + HANDLERS.insert(@handler_position, handler) @handler_position += 1 end end diff --git a/src/kemal/dsl.cr b/src/kemal/dsl.cr index 699be02..3780c7e 100644 --- a/src/kemal/dsl.cr +++ b/src/kemal/dsl.cr @@ -1,43 +1,195 @@ # Kemal DSL is defined here and it's baked into global scope. +# These methods are available globally in your application. # -# The DSL currently consists of: +# ## Available DSL Methods # -# - get post put patch delete options -# - WebSocket(ws) -# - before_* -# - error +# - **HTTP Routes**: `get`, `post`, `put`, `patch`, `delete`, `options` +# - **WebSocket**: `ws` +# - **Filters**: `before_all`, `before_get`, `after_all`, `after_get`, etc. +# - **Error Handling**: `error` +# - **Modular Routing**: `mount` HTTP_METHODS = %w(get post put patch delete options head) FILTER_METHODS = %w(get post put patch delete options head all) +# Defines a route for the given HTTP method. +# +# NOTE: The path must start with a `/`. +# +# ``` +# get "/hello" do |env| +# "Hello World!" +# end +# +# post "/users" do |env| +# "User created" +# end +# ``` {% for method in HTTP_METHODS %} - def {{method.id}}(path : String, &block : HTTP::Server::Context -> _) - raise Kemal::Exceptions::InvalidPathStartException.new({{method}}, path) unless Kemal::Utils.path_starts_with_slash?(path) - Kemal::RouteHandler::INSTANCE.add_route({{method}}.upcase, path, &block) + def {{ method.id }}(path : String, &block : HTTP::Server::Context -> _) + raise Kemal::Exceptions::InvalidPathStartException.new({{ method }}, path) unless Kemal::Utils.path_starts_with_slash?(path) + Kemal::RouteHandler::INSTANCE.add_route({{ method }}.upcase, path, &block) end {% end %} -def ws(path : String, &block : HTTP::WebSocket, HTTP::Server::Context -> Void) +# Defines a WebSocket route. +# +# NOTE: The path must start with a `/`. +# +# ``` +# ws "/chat" do |socket, env| +# socket.on_message do |msg| +# socket.send "Echo: #{msg}" +# end +# end +# ``` +def ws(path : String, &block : HTTP::WebSocket, HTTP::Server::Context ->) raise Kemal::Exceptions::InvalidPathStartException.new("ws", path) unless Kemal::Utils.path_starts_with_slash?(path) Kemal::WebSocketHandler::INSTANCE.add_route path, &block end +# Defines an error handler for the given HTTP status code. +# +# ``` +# error 404 do |env| +# "Page not found" +# end +# ``` def error(status_code : Int32, &block : HTTP::Server::Context, Exception -> _) Kemal.config.add_error_handler status_code, &block end -# All the helper methods available are: -# - before_all, before_get, before_post, before_put, before_patch, before_delete, before_options -# - after_all, after_get, after_post, after_put, after_patch, after_delete, after_options +# Defines an error handler for the given `HTTP::Status`. +# +# ``` +# error :not_found do |env| +# "Page not found" +# end +# ``` +def error(status : HTTP::Status, &block : HTTP::Server::Context, Exception -> _) + Kemal.config.add_error_handler status.code, &block +end + +# Defines an error handler for the given exception type. +# +# ``` +# error MyCustomException do |env, ex| +# "Error: #{ex.message}" +# end +# ``` +def error(exception : Exception.class, &block : HTTP::Server::Context, Exception -> _) + Kemal.config.add_exception_handler exception, &block +end + +# Defines filters that run before or after requests. +# +# Available methods: +# - `before_all`, `before_get`, `before_post`, `before_put`, `before_patch`, `before_delete`, `before_options` +# - `after_all`, `after_get`, `after_post`, `after_put`, `after_patch`, `after_delete`, `after_options` +# +# ``` +# before_all do |env| +# env.response.content_type = "application/json" +# end +# +# before_get "/admin/*" do |env| +# # Authentication check +# end +# +# # Multiple paths +# after_post ["/users", "/posts"] do |env| +# # Logging +# end +# ``` {% for type in ["before", "after"] %} {% for method in FILTER_METHODS %} - def {{type.id}}_{{method.id}}(path : String = "*", &block : HTTP::Server::Context -> _) - Kemal::FilterHandler::INSTANCE.{{type.id}}({{method}}.upcase, path, &block) + def {{ type.id }}_{{ method.id }}(path : String = "*", &block : HTTP::Server::Context -> _) + Kemal::FilterHandler::INSTANCE.{{ type.id }}({{ method }}.upcase, path, &block) end - def {{type.id}}_{{method.id}}(paths : Array(String), &block : HTTP::Server::Context -> _) + def {{ type.id }}_{{ method.id }}(paths : Enumerable(String), &block : HTTP::Server::Context -> _) paths.each do |path| - Kemal::FilterHandler::INSTANCE.{{type.id}}({{method}}.upcase, path, &block) + Kemal::FilterHandler::INSTANCE.{{ type.id }}({{ method }}.upcase, path, &block) end end {% end %} {% end %} + +# Adds a `HTTP::Handler` (middleware) to the handler chain. +# The handler runs for all requests. +# +# ``` +# use MyHandler.new +# ``` +def use(handler : HTTP::Handler) + Kemal.config.add_handler(handler) +end + +# Adds a `HTTP::Handler` (middleware) at a specific position in the handler chain. +# +# ``` +# use MyHandler.new, position: 1 +# ``` +def use(handler : HTTP::Handler, position : Int32) + Kemal.config.add_handler(handler, position) +end + +# Adds a `HTTP::Handler` (middleware) that only runs for requests matching the path prefix. +# +# ``` +# use "/api", AuthHandler.new +# ``` +# +# The handler will execute for: +# - Exact match: `/api` +# - Prefix match: `/api/users`, `/api/posts/1` +# +# But NOT for: +# - `/`, `/apiv2`, `/other` +def use(path : String, handler : HTTP::Handler) + Kemal.config.add_handler(Kemal::PathHandler.new(path, handler)) +end + +# Adds multiple `HTTP::Handler` (middlewares) for a specific path prefix. +# +# ``` +# use "/api", [AuthHandler.new, RateLimiter.new, CorsHandler.new] +# ``` +def use(path : String, handlers : Enumerable(HTTP::Handler)) + handlers.each do |handler| + use(path, handler) + end +end + +# Mounts a router without additional prefix. +# +# ``` +# api = Kemal::Router.new +# api.get "/users" do |env| +# "users" +# end +# +# mount api +# # Result: GET /users +# ``` +def mount(router : Kemal::Router) + router.register_routes +end + +# Mounts a router at the given *path* prefix. +# +# NOTE: The path must start with a `/`. +# +# All routes defined in the router will be prefixed with the given path. +# +# ``` +# api = Kemal::Router.new +# api.get "/users" do |env| +# "users" +# end +# +# mount "/api/v1", api +# # Result: GET /api/v1/users +# ``` +def mount(path : String, router : Kemal::Router) + router.register_routes(path) +end diff --git a/src/kemal/exception_handler.cr b/src/kemal/exception_handler.cr index eee6eec..ded1c58 100644 --- a/src/kemal/exception_handler.cr +++ b/src/kemal/exception_handler.cr @@ -10,13 +10,44 @@ module Kemal call_exception_with_status_code(context, ex, 404) rescue ex : Kemal::Exceptions::CustomException call_exception_with_status_code(context, ex, context.response.status_code) + rescue ex : Kemal::Exceptions::PayloadTooLarge + call_exception_with_status_code(context, ex, 413) rescue ex : Exception - log("Exception: #{ex.inspect_with_backtrace}") + # Matches an error handler for the given exception + # + # Matches based on order of declaration rather than inheritance relationship + # for child exceptions + Kemal.config.exception_handlers.each do |expected_exception, handler| + if ex.class <= expected_exception + return call_exception_with_exception(context, ex, handler, 500) + end + end + + Log.error(exception: ex) { ex.message } + # Else use generic 500 handler if defined return call_exception_with_status_code(context, ex, 500) if Kemal.config.error_handlers.has_key?(500) verbosity = Kemal.config.env == "production" ? false : true render_500(context, ex, verbosity) end + # Calls the given error handler with the current exception + # + # The logic for validating that the current exception should be handled + # by the given error handler should be done by the caller of this method. + private def call_exception_with_exception( + context : HTTP::Server::Context, + exception : Exception, + handler : Proc(HTTP::Server::Context, Exception, String), + status_code : Int32 = 500, + ) + return if context.response.closed? + + context.response.content_type = "text/html" unless context.response.headers.has_key?("Content-Type") + context.response.status_code = status_code + context.response.print handler.call(context, exception) + context + end + private def call_exception_with_status_code(context : HTTP::Server::Context, exception : Exception, status_code : Int32) return if context.response.closed? if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(status_code) diff --git a/src/kemal/ext/context.cr b/src/kemal/ext/context.cr index 2ed168d..cf7c952 100644 --- a/src/kemal/ext/context.cr +++ b/src/kemal/ext/context.cr @@ -11,11 +11,16 @@ class HTTP::Server macro finished alias StoreTypes = Union({{ STORE_MAPPINGS.splat }}) @store = {} of String => StoreTypes + @cached_route_lookup : Radix::Result(Kemal::Route)? + @cached_ws_route_lookup : Radix::Result(Kemal::WebSocket)? end + # Optimized: Use cached lookup results to avoid redundant route lookups + # when params is accessed after route_found? or route has already been called def params - if ws_route_found? - @params ||= Kemal::ParamParser.new(@request, ws_route_lookup.params) + ws_lookup = ws_route_lookup + if ws_lookup.found? + @params ||= Kemal::ParamParser.new(@request, ws_lookup.params) else @params ||= Kemal::ParamParser.new(@request, route_lookup.params) end @@ -36,16 +41,31 @@ class HTTP::Server ws_route_lookup.payload end + # Optimized: Cache route lookup result to avoid redundant lookups + # when called multiple times (e.g., route_found?, route, params) def route_lookup - Kemal::RouteHandler::INSTANCE.lookup_route(@request.method.as(String), @request.path) + @cached_route_lookup ||= Kemal::RouteHandler::INSTANCE.lookup_route(@request.method.as(String), @request.path) + end + + # Clears the cached route lookup and updates params with new route. Used by handlers that + # modify the request (e.g. OverrideMethodHandler) so the next route lookup uses the updated request. + def invalidate_route_cache + @cached_route_lookup = nil + params = @params + if params + new_lookup = Kemal::RouteHandler::INSTANCE.lookup_route(@request.method.as(String), @request.path) + @cached_route_lookup = new_lookup + params.update_url_params(new_lookup.params) + end end def route_found? route_lookup.found? end + # Optimized: Cache websocket route lookup result to avoid redundant lookups def ws_route_lookup - Kemal::WebSocketHandler::INSTANCE.lookup_ws_route(@request.path) + @cached_ws_route_lookup ||= Kemal::WebSocketHandler::INSTANCE.lookup_ws_route(@request.path) end def ws_route_found? @@ -63,5 +83,99 @@ class HTTP::Server def get?(name : String) @store[name]? end + + # Sets the response status code and returns self for chaining. + # + # ``` + # get "/users/:id" do |env| + # if user = User.find?(env.params.url["id"]) + # env.json(user) + # else + # env.status(404).json({error: "User not found"}) + # end + # end + # ``` + def status(code : Int32) : self + @response.status_code = code + self + end + + # Sets the response status from an *HTTP::Status* and returns self for chaining. + # + # ``` + # get "/users/:id" do |env| + # env.status(:not_found).json({error: "User not found"}) + # end + # + # post "/users" do |env| + # env.status(:created).json({id: 1}) + # end + # ``` + def status(status : HTTP::Status) : self + @response.status = status + self + end + + # Sends a JSON response with the proper `Content-Type` header. + # Serializes the data to a string and writes it to the response in a single operation. + # Use *content_type* for custom types (e.g. `application/vnd.api+json` for JSON API). + # + # ``` + # get "/users" do |env| + # env.json({users: ["alice", "bob"]}) + # end + # + # post "/users" do |env| + # env.status(201).json({created: true}) + # end + # + # # JSON API + # get "/api/users" do |env| + # env.json({data: users}, content_type: "application/vnd.api+json") + # end + # ``` + def json(data, *, content_type : String = "application/json") : Nil + @response.content_type = content_type + @response << data.to_json + end + + # Sends an HTML response with the proper `Content-Type` header. + # Serializes the content to a string and writes it to the response in a single operation. + # + # ``` + # get "/" do |env| + # env.html("

Welcome

") + # 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("...") + # end + # ``` + def xml(content : String, *, content_type : String = "application/xml; charset=utf-8") : Nil + @response.content_type = content_type + @response << content + end end end diff --git a/src/kemal/file_upload.cr b/src/kemal/file_upload.cr index 30eb26a..767ce85 100644 --- a/src/kemal/file_upload.cr +++ b/src/kemal/file_upload.cr @@ -20,5 +20,10 @@ module Kemal @read_time = upload.read_time @size = upload.size end + + def cleanup + @tempfile.close + ::File.delete(@tempfile.path) if ::File.exists?(@tempfile.path) + end end end diff --git a/src/kemal/filter_handler.cr b/src/kemal/filter_handler.cr index 5bf9fd6..e80b1cf 100644 --- a/src/kemal/filter_handler.cr +++ b/src/kemal/filter_handler.cr @@ -3,17 +3,41 @@ module Kemal class FilterHandler include HTTP::Handler INSTANCE = new - property tree + + # Path used to represent wildcard filters that apply to all routes + private WILDCARD_PATH = "*" + + @tree : Radix::Tree(Array(FilterBlock)) + + # Hash cache for exact path filters to avoid repeated tree lookups + # Key format: "/#{type}/#{verb}/#{path}" (e.g., "/before/ALL/*") + @exact_filters : Hash(String, Array(FilterBlock)) + + def tree + @tree + end + + def tree=(tree : Radix::Tree(Array(FilterBlock))) + @tree = tree + @exact_filters = Hash(String, Array(FilterBlock)).new + end # This middleware is lazily instantiated and added to the handlers as soon as a call to `after_X` or `before_X` is made. def initialize @tree = Radix::Tree(Array(FilterBlock)).new + @exact_filters = Hash(String, Array(FilterBlock)).new Kemal.config.add_filter_handler(self) end # The call order of the filters is `before_all -> before_x -> X -> after_x -> after_all`. def call(context : HTTP::Server::Context) - return call_next(context) unless context.route_found? + if !context.route_found? + if Kemal.config.error_handlers.has_key?(404) + call_block_for_path_type("ALL", context.request.path, :before, context) + end + return call_next(context) + end + call_block_for_path_type("ALL", context.request.path, :before, context) call_block_for_path_type(context.request.method, context.request.path, :before, context) if Kemal.config.error_handlers.has_key?(context.response.status_code) @@ -25,15 +49,21 @@ module Kemal context end - # :nodoc: This shouldn't be called directly, it's not private because - # I need to call it for testing purpose since I can't call the macros in the spec. - # It adds the block for the corresponding verb/path/type combination to the tree. + # :nodoc: + # This shouldn't be called directly, it's not private because I need to call it for testing purpose since I can't call the macros in the spec. + # + # Registers a filter block for the given verb/path/type combination. + # Uses @exact_filters hash for O(1) lookup when adding multiple filters to the same path. def _add_route_filter(verb : String, path, type, &block : HTTP::Server::Context -> _) - lookup = lookup_filters_for_path_type(verb, path, type) - if lookup.found? && lookup.payload.is_a?(Array(FilterBlock)) - lookup.payload << FilterBlock.new(&block) + key = radix_path(verb, path, type) + + if filters = @exact_filters[key]? + filters << FilterBlock.new(&block) else - @tree.add radix_path(verb, path, type), [FilterBlock.new(&block)] + filters = [FilterBlock.new(&block)] + @exact_filters[key] = filters + + @tree.add key, filters end end @@ -51,8 +81,24 @@ module Kemal _add_route_filter verb, path, :after, &block end - # This will fetch the block for the verb/path/type from the tree and call it. + # Executes filters for a given path, ensuring global wildcard filters run first. + # + # Execution order: + # 1. Global wildcard filters ("*") - if path is not already a wildcard + # 2. Exact path filters - filters registered for the specific path + # + # This ensures that global filters (like `before_all`) always execute, + # while namespace-specific filters only apply to their registered paths. private def call_block_for_path_type(verb : String?, path : String, type, context : HTTP::Server::Context) + if path != WILDCARD_PATH + call_block_for_exact_path_type(verb, "*", type, context) + end + + # Executes all filter blocks registered for a specific verb/path/type combination + call_block_for_exact_path_type(verb, path, type, context) + end + + private def call_block_for_exact_path_type(verb : String?, path : String, type, context : HTTP::Server::Context) lookup = lookup_filters_for_path_type(verb, path, type) if lookup.found? && lookup.payload.is_a? Array(FilterBlock) blocks = lookup.payload diff --git a/src/kemal/handler.cr b/src/kemal/handler.cr index f87e60a..287edb4 100644 --- a/src/kemal/handler.cr +++ b/src/kemal/handler.cr @@ -1,27 +1,34 @@ module Kemal - # `Kemal::Handler` is a subclass of `HTTP::Handler`. + # Kemal::HandlerInterface provides helpful methods for use in middleware creation # - # It adds `only`, `only_match?`, `exclude`, `exclude_match?`. - # These methods are useful for the conditional execution of custom handlers . - class Handler + # More specifically, `only`, `only_match?`, `exclude`, `exclude_match?` + # allows one to define the conditional execution of custom handlers. + # + # To use, simply `include` it within your type. + # + # It is an implementation of `HTTP::Handler` and can be used anywhere that + # requests an `HTTP::Handler` type. + module HandlerInterface include HTTP::Handler - @@only_routes_tree = Radix::Tree(String).new - @@exclude_routes_tree = Radix::Tree(String).new + macro included + @@only_routes_tree = Radix::Tree(String).new + @@exclude_routes_tree = Radix::Tree(String).new + end macro only(paths, method = "GET") - class_name = {{@type.name}} - class_name_method = "#{class_name}/#{{{method}}}" - ({{paths}}).each do |path| - @@only_routes_tree.add class_name_method + path, '/' + {{method}} + path + class_name = {{ @type.name }} + class_name_method = "#{class_name}/#{{{ method }}}" + ({{ paths }}).each do |path| + @@only_routes_tree.add class_name_method + path, '/' + {{ method }} + path end end macro exclude(paths, method = "GET") - class_name = {{@type.name}} - class_name_method = "#{class_name}/#{{{method}}}" - ({{paths}}).each do |path| - @@exclude_routes_tree.add class_name_method + path, '/' + {{method}} + path + class_name = {{ @type.name }} + class_name_method = "#{class_name}/#{{{ method }}}" + ({{ paths }}).each do |path| + @@exclude_routes_tree.add class_name_method + path, '/' + {{ method }} + path end end @@ -75,4 +82,13 @@ module Kemal "#{self.class}/#{method}#{path}" end end + + # `Kemal::Handler` is an implementation of `HTTP::Handler`. + # + # It includes `HandlerInterface` to add the methods + # `only`, `only_match?`, `exclude`, `exclude_match?`. + # These methods are useful for the conditional execution of custom handlers . + class Handler + include HandlerInterface + end end diff --git a/src/kemal/helpers/exception_page.cr b/src/kemal/helpers/exception_page.cr index 4ec180c..2168c37 100644 --- a/src/kemal/helpers/exception_page.cr +++ b/src/kemal/helpers/exception_page.cr @@ -32,7 +32,7 @@ module Kemal

Something wrong with the server :(

- HTML + HTML end end end diff --git a/src/kemal/helpers/exceptions.cr b/src/kemal/helpers/exceptions.cr index cda5e59..dca2afc 100644 --- a/src/kemal/helpers/exceptions.cr +++ b/src/kemal/helpers/exceptions.cr @@ -18,4 +18,10 @@ module Kemal::Exceptions super message end end + + class PayloadTooLarge < Exception + def initialize + super "Payload Too Large" + end + end end diff --git a/src/kemal/helpers/helpers.cr b/src/kemal/helpers/helpers.cr index d8ef72b..faf5600 100644 --- a/src/kemal/helpers/helpers.cr +++ b/src/kemal/helpers/helpers.cr @@ -15,10 +15,12 @@ require "mime" # - `Kemal::StaticFileHandler` # - Here goes custom handlers # - `Kemal::RouteHandler` +@[Deprecated("Use `use` instead")] def add_handler(handler : HTTP::Handler) Kemal.config.add_handler handler end +@[Deprecated("Use `use` with position parameter instead")] def add_handler(handler : HTTP::Handler, position : Int32) Kemal.config.add_handler handler, position end @@ -32,8 +34,14 @@ end # Logs the output via `logger`. # This is the built-in `Kemal::LogHandler` by default which uses STDOUT. +@[Deprecated("Use standard library Log")] def log(message : String) - Kemal.config.logger.write "#{message}\n" + logger = Kemal.config.logger? + if logger + logger.write "#{message}\n" + else + Log.info { message } + end end # Enables / Disables logging. @@ -70,6 +78,7 @@ end # ``` # logger MyCustomLogger.new # ``` +@[Deprecated("Use standard library Log")] def logger(logger : Kemal::BaseLogHandler) Kemal.config.logger = logger end @@ -205,31 +214,68 @@ end private def multipart(file, env : HTTP::Server::Context) # See http://httpwg.org/specs/rfc7233.html fileb = file.size - startb = endb = 0_i64 + ranges = parse_ranges(env.request.headers["Range"]?, fileb) - if match = env.request.headers["Range"].match /bytes=(\d{1,})-(\d{0,})/ - startb = match[1].to_i64 { 0_i64 } if match.size >= 2 - endb = match[2].to_i64 { 0_i64 } if match.size >= 3 + if ranges.empty? + env.response.content_length = fileb + env.response.status_code = 200 # Range not satisfiable + IO.copy(file, env.response) + return end - endb = fileb - 1 if endb == 0 - - if startb < endb < fileb + if ranges.size == 1 + # Single range - send as regular partial content + startb, endb = ranges[0] content_length = 1_i64 + endb - startb env.response.status_code = 206 env.response.content_length = content_length env.response.headers["Accept-Ranges"] = "bytes" - env.response.headers["Content-Range"] = "bytes #{startb}-#{endb}/#{fileb}" # MUST + env.response.headers["Content-Range"] = "bytes #{startb}-#{endb}/#{fileb}" file.seek(startb) IO.copy(file, env.response, content_length) else - env.response.content_length = fileb - env.response.status_code = 200 # Range not satisfable, see 4.4 Note - IO.copy(file, env.response) + # Multiple ranges - send as multipart/byteranges + boundary = "kemal-#{Random::Secure.hex(16)}" + env.response.content_type = "multipart/byteranges; boundary=#{boundary}" + env.response.status_code = 206 + env.response.headers["Accept-Ranges"] = "bytes" + + ranges.each do |start_byte, end_byte| + env.response.print "--#{boundary}\r\n" + env.response.print "Content-Type: #{env.response.headers["Content-Type"]}\r\n" + env.response.print "Content-Range: bytes #{start_byte}-#{end_byte}/#{fileb}\r\n" + env.response.print "\r\n" + + file.seek(start_byte) + IO.copy(file, env.response, 1_i64 + end_byte - start_byte) + env.response.print "\r\n" + end + env.response.print "--#{boundary}--\r\n" end end +private def parse_ranges(range_header : String?, file_size : Int64) : Array({Int64, Int64}) + return [] of {Int64, Int64} unless range_header + + ranges = [] of {Int64, Int64} + return ranges unless range_header.starts_with?("bytes=") + + range_header[6..].split(",").each do |range| + if match = range.match /(\d{1,})-(\d{0,})/ + startb = match[1].to_i64 { 0_i64 } + endb = match[2].to_i64 { 0_i64 } + endb = file_size - 1 if endb == 0 + + if startb < endb && endb < file_size + ranges << {startb, endb} + end + end + end + + ranges +end + # Set the Content-Disposition to "attachment" with the specified filename, # instructing the user agents to prompt to save. private def attachment(env : HTTP::Server::Context, filename : String? = nil, disposition : String? = nil) @@ -244,7 +290,7 @@ end # # Disabled by default. def gzip(status : Bool = false) - add_handler HTTP::CompressHandler.new if status + use HTTP::CompressHandler.new if status end # Adds headers to `Kemal::StaticFileHandler`. This is especially useful for `CORS`. @@ -257,6 +303,6 @@ end # env.response.headers.add("Content-Size", filestat.size.to_s) # end # ``` -def static_headers(&headers : HTTP::Server::Context, String, File::Info -> Void) +def static_headers(&headers : HTTP::Server::Context, String, File::Info ->) Kemal.config.static_headers = headers end diff --git a/src/kemal/helpers/macros.cr b/src/kemal/helpers/macros.cr index 71ba2be..cd5fe19 100644 --- a/src/kemal/helpers/macros.cr +++ b/src/kemal/helpers/macros.cr @@ -34,15 +34,15 @@ CONTENT_FOR_BLOCKS = Hash(String, Tuple(String, Proc(Nil))).new # layout, inside the tag, and each view can call `content_for` # setting the appropriate set of tags that should be added to the layout. macro content_for(key, file = __FILE__) - CONTENT_FOR_BLOCKS[{{key}}] = Tuple.new {{file}}, ->() { {{ yield }} } + CONTENT_FOR_BLOCKS[{{ key }}] = Tuple.new {{ file }}, ->() { {{ yield }} } nil end # Yields content for the given key if a `content_for` block exists for that key. macro yield_content(key) - if CONTENT_FOR_BLOCKS.has_key?({{key}}) - __caller_filename__ = CONTENT_FOR_BLOCKS[{{key}}][0] - %proc = CONTENT_FOR_BLOCKS[{{key}}][1] + if CONTENT_FOR_BLOCKS.has_key?({{ key }}) + __caller_filename__ = CONTENT_FOR_BLOCKS[{{ key }}][0] + %proc = CONTENT_FOR_BLOCKS[{{ key }}][1] if __content_filename__ == __caller_filename__ %old_content_io, content_io = content_io, IO::Memory.new @@ -60,18 +60,45 @@ end # render "src/views/index.ecr", "src/views/layout.ecr" # ``` macro render(filename, layout) - __content_filename__ = {{filename}} + __content_filename__ = {{ filename }} content_io = IO::Memory.new - ECR.embed {{filename}}, content_io + ECR.embed {{ filename }}, content_io content = content_io.to_s layout_io = IO::Memory.new - ECR.embed {{layout}}, layout_io + ECR.embed {{ layout }}, layout_io layout_io.to_s end # Render view with the given filename. macro render(filename) - ECR.render({{filename}}) + ECR.render({{ filename }}) +end + +# Halts execution by closing the response. Designed for use with chained response method calls. +# +# ``` +# # Example: Send a JSON error and halt immediately +# halt env.status(500).json({error: "Internal Server Error"}) +# +# # Example: Immediately close and halt after rendering HTML +# halt env.status(403).html("Forbidden") +# ``` +# +# NOTE: For most cases that require setting a specific status code and body, prefer the alternative: +# +# ``` +# halt env, status_code: 403, response: "Forbidden" +# ``` +macro halt(response) + {% if response.is_a?(Call) && response.receiver %} + %env = {{ response.receiver }} + {{ response }} + %env.response.close + next + {% else %} + {{ response }}.response.close + next + {% end %} end # Halt execution with the current context. @@ -81,9 +108,9 @@ end # halt env, status_code: 403, response: "Forbidden" # ``` macro halt(env, status_code = 200, response = "") - {{env}}.response.status_code = {{status_code}} - {{env}}.response.print {{response}} - {{env}}.response.close + {{ env }}.response.status_code = {{ status_code }} + {{ env }}.response.print {{ response }} + {{ env }}.response.close next end diff --git a/src/kemal/helpers/templates.cr b/src/kemal/helpers/templates.cr index 1769bd5..ad6efc6 100644 --- a/src/kemal/helpers/templates.cr +++ b/src/kemal/helpers/templates.cr @@ -18,7 +18,7 @@ def render_404 - HTML + HTML end def render_500(context, exception, verbosity) diff --git a/src/kemal/log_handler.cr b/src/kemal/log_handler.cr index ce08e57..d7b1f79 100644 --- a/src/kemal/log_handler.cr +++ b/src/kemal/log_handler.cr @@ -1,5 +1,6 @@ module Kemal # Uses `STDOUT` by default and handles the logging of request/response process time. + @[Deprecated("Setup Log instead.")] class LogHandler < Kemal::BaseLogHandler def initialize(@io : IO = STDOUT) end diff --git a/src/kemal/null_log_handler.cr b/src/kemal/null_log_handler.cr index 9f3e03a..04d6242 100644 --- a/src/kemal/null_log_handler.cr +++ b/src/kemal/null_log_handler.cr @@ -1,5 +1,6 @@ module Kemal # This is here to represent the logger corresponding to Null Object Pattern. + @[Deprecated("Use standard library Log")] class NullLogHandler < Kemal::BaseLogHandler def call(context : HTTP::Server::Context) call_next(context) diff --git a/src/kemal/override_method_handler.cr b/src/kemal/override_method_handler.cr index 523e9e6..e02aa94 100644 --- a/src/kemal/override_method_handler.cr +++ b/src/kemal/override_method_handler.cr @@ -4,7 +4,7 @@ module Kemal # This middleware is **not** in the default Kemal handlers. You need to explicitly add this to your handlers: # # ```ruby - # add_handler Kemal::OverrideMethodHandler + # use Kemal::OverrideMethodHandler::INSTANCE # ``` # # **Important:** This middleware consumes `params.body` to read the `_method` magic parameter. @@ -21,6 +21,7 @@ module Kemal if request.method == OVERRIDE_METHOD if context.params.body.has_key?(OVERRIDE_METHOD_PARAM_KEY) && override_method_valid?(context.params.body[OVERRIDE_METHOD_PARAM_KEY]) request.method = context.params.body["_method"].upcase + context.invalidate_route_cache end end call_next(context) diff --git a/src/kemal/param_parser.cr b/src/kemal/param_parser.cr index 5d87ba0..ad02ae0 100644 --- a/src/kemal/param_parser.cr +++ b/src/kemal/param_parser.cr @@ -6,21 +6,61 @@ module Kemal URL_ENCODED_FORM = "application/x-www-form-urlencoded" APPLICATION_JSON = "application/json" MULTIPART_FORM = "multipart/form-data" - PARTS = %w(url query body json files) + PARTS = %w[url query body json files] # :nodoc: - alias AllParamTypes = Nil | String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any) - getter files + alias AllParamTypes = String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any)? + getter files, all_files def initialize(@request : HTTP::Request, @url : Hash(String, String) = {} of String => String) @query = HTTP::Params.new({} of String => Array(String)) @body = HTTP::Params.new({} of String => Array(String)) @json = {} of String => AllParamTypes @files = {} of String => FileUpload + @all_files = {} of String => Array(FileUpload) @url_parsed = false @query_parsed = false @body_parsed = false @json_parsed = false @files_parsed = false + @cached_body = nil + end + + # Returns the raw request body, read and cached on first access. + # Allows multiple handlers to access the body without consuming the IO. + # Only caches for `application/x-www-form-urlencoded` and `application/json`. + def raw_body : String + if cached = @cached_body + return cached + end + + content_type = @request.headers["Content-Type"]? + return @cached_body = "" if content_type.nil? + + if content_type.try(&.starts_with?(URL_ENCODED_FORM)) || content_type.try(&.starts_with?(APPLICATION_JSON)) + validate_content_length! + @cached_body = if body_io = @request.body + read_body_with_limit(body_io) + else + "" + end + else + @cached_body = "" + end + end + + def cleanup_temporary_files + return if @files.empty? && @all_files.empty? + + @files.each_value &.cleanup + @all_files.each_value do |file_uploads| + file_uploads.each &.cleanup + end + end + + # Updates url params (e.g. after request method override). Used by Context#invalidate_route_cache. + def update_url_params(new_url : Hash(String, String)) + @url = new_url + @url_parsed = false end private def unescape_url_param(value : String) @@ -30,14 +70,14 @@ module Kemal end {% for method in PARTS %} - def {{method.id}} + def {{ method.id }} # check memoization - return @{{method.id}} if @{{method.id}}_parsed + return @{{ method.id }} if @{{ method.id }}_parsed - parse_{{method.id}} + parse_{{ method.id }} # memoize - @{{method.id}}_parsed = true - @{{method.id}} + @{{ method.id }}_parsed = true + @{{ method.id }} end {% end %} @@ -46,8 +86,10 @@ module Kemal return unless content_type + validate_content_length! + if content_type.try(&.starts_with?(URL_ENCODED_FORM)) - @body = parse_part(@request.body) + @body = parse_part(raw_body) return end @@ -67,15 +109,23 @@ module Kemal private def parse_files return if @files_parsed + validate_content_length! + HTTP::FormData.parse(@request) do |upload| next unless upload filename = upload.filename + name = upload.name if !filename.nil? - @files[upload.name] = FileUpload.new(upload) + if name.ends_with?("[]") + @all_files[name] ||= [] of FileUpload + @all_files[name] << FileUpload.new(upload) + else + @files[name] = FileUpload.new(upload) + end else - @body.add(upload.name, upload.body.gets_to_end) + @body.add(name, upload.body.gets_to_end) end end @@ -87,10 +137,12 @@ module Kemal # - If request body is a JSON `Hash` then all the params are parsed and added into `params`. # - If request body is a JSON `Array` it's added into `params` as `_json` and can be accessed like `params["_json"]`. private def parse_json - return unless @request.body && @request.headers["Content-Type"]?.try(&.starts_with?(APPLICATION_JSON)) + return unless @request.headers["Content-Type"]?.try(&.starts_with?(APPLICATION_JSON)) - body = @request.body.not_nil!.gets_to_end - case json = JSON.parse(body).raw + body_str = raw_body + return if body_str.empty? + + case json = JSON.parse(body_str).raw when Hash json.each do |key, value| @json[key] = value.raw @@ -103,11 +155,31 @@ module Kemal end private def parse_part(part : IO?) - HTTP::Params.parse(part ? part.gets_to_end : "") + return HTTP::Params.new({} of String => Array(String)) unless part + body_str = read_body_with_limit(part) + HTTP::Params.parse(body_str) end private def parse_part(part : String?) HTTP::Params.parse part.to_s end + + private def validate_content_length! + return unless length_str = @request.headers["Content-Length"]? + return unless length = length_str.to_i? + return if length <= Kemal.config.max_request_body_size + + raise Exceptions::PayloadTooLarge.new + end + + private def read_body_with_limit(io : IO) : String + limit = Kemal.config.max_request_body_size + String.build do |str| + bytes_read = IO.copy(io, str, limit + 1) + if bytes_read > limit + raise Exceptions::PayloadTooLarge.new + end + end + end end end diff --git a/src/kemal/path_handler.cr b/src/kemal/path_handler.cr new file mode 100644 index 0000000..3f0976b --- /dev/null +++ b/src/kemal/path_handler.cr @@ -0,0 +1,46 @@ +module Kemal + # `PathHandler` wraps a `HTTP::Handler` to only execute for specific path prefixes. + # + # ## Example + # + # ``` + # use "/api", AuthHandler.new + # ``` + # + # The handler will only execute for requests matching the path prefix: + # - `/api` matches `/api`, `/api/users`, `/api/posts/1` + # - `/api` does NOT match `/`, `/apiv2`, `/other` + class PathHandler + include HTTP::Handler + + getter path_prefix : String + getter handler : HTTP::Handler + + def initialize(@path_prefix : String, @handler : HTTP::Handler) + end + + def call(context : HTTP::Server::Context) + if matches_prefix?(context.request.path) + # Set next handler for the wrapped handler + @handler.next = self.next + @handler.call(context) + else + call_next(context) + end + end + + # Checks if the request path matches the handler's path prefix. + # - "/" or "" matches all paths + # - "/api" matches "/api" and "/api/*" + # - "/api" does NOT match "/apiv2" + private def matches_prefix?(path : String) : Bool + return true if path_prefix.in?("/", "") + + # Exact match + return true if path == path_prefix + + # Prefix match (must be followed by /) + path.starts_with?("#{path_prefix}/") + end + end +end diff --git a/src/kemal/request_log_handler.cr b/src/kemal/request_log_handler.cr new file mode 100644 index 0000000..7d136f7 --- /dev/null +++ b/src/kemal/request_log_handler.cr @@ -0,0 +1,20 @@ +module Kemal + # :nodoc: + class RequestLogHandler + include HTTP::Handler + + def call(context : HTTP::Server::Context) + elapsed_time = Time.measure { call_next(context) } + elapsed_text = elapsed_text(elapsed_time) + Log.info { "#{context.response.status_code} #{context.request.method} #{context.request.resource} #{elapsed_text}" } + context + end + + private def elapsed_text(elapsed) + millis = elapsed.total_milliseconds + return "#{millis.round(2)}ms" if millis >= 1 + + "#{(millis * 1000).round(2)}Β΅s" + end + end +end diff --git a/src/kemal/route_handler.cr b/src/kemal/route_handler.cr index 44c8c92..6d66b26 100644 --- a/src/kemal/route_handler.cr +++ b/src/kemal/route_handler.cr @@ -1,16 +1,122 @@ require "radix" module Kemal + # Small, private LRU cache used by the router to avoid full cache clears + # when many distinct paths are accessed. Keeps get/put at O(1). + # This is intentionally minimal and file-local to avoid API surface. + class LRUCache(K, V) + # Doubly-linked list node + class Node(K, V) + property key : K + property value : V + property prev : Node(K, V)? + property next : Node(K, V)? + + def initialize(@key : K, @value : V) + @prev = nil + @next = nil + end + end + + @capacity : Int32 + @map : Hash(K, Node(K, V)) + @head : Node(K, V)? # most-recent + @tail : Node(K, V)? # least-recent + + def initialize(@capacity : Int32) + @map = Hash(K, Node(K, V)).new + @head = nil + @tail = nil + end + + def size : Int32 + @map.size + end + + def get(key : K) : V? + if node = @map[key]? + move_to_front(node) + return node.value + end + nil + end + + def put(key : K, value : V) : Nil + if node = @map[key]? + node.value = value + move_to_front(node) + return + end + + # Evict before adding to avoid unnecessary hash resize + evict_if_at_capacity + + node = Node(K, V).new(key, value) + @map[key] = node + insert_front(node) + end + + private def insert_front(node : Node(K, V)) + node.prev = nil + node.next = @head + @head.try(&.prev=(node)) + @head = node + @tail = node if @tail.nil? + end + + private def move_to_front(node : Node(K, V)) + return if node == @head + + # unlink + prev = node.prev + nxt = node.next + prev.try(&.next=(nxt)) + nxt.try(&.prev=(prev)) + + # fix tail if needed + if node == @tail + @tail = prev + end + + insert_front(node) + end + + private def evict_if_at_capacity + return if @map.size < @capacity + + if lru = @tail + # unlink tail + prev = lru.prev + if prev + prev.next = nil + @tail = prev + else + # only one element + @head = nil + @tail = nil + end + @map.delete(lru.key) + end + end + end + class RouteHandler include HTTP::Handler - INSTANCE = new - CACHED_ROUTES_LIMIT = 1024 - property routes, cached_routes + INSTANCE = new + property routes + + getter cached_routes + + # Setter is synchronized for thread-safety when specs reset the cache. + def cached_routes=(cache : LRUCache(String, Radix::Result(Route))) + @cache_mutex.synchronize { @cached_routes = cache } + end def initialize @routes = Radix::Tree(Route).new - @cached_routes = Hash(String, Radix::Result(Route)).new + @cached_routes = LRUCache(String, Radix::Result(Route)).new(Kemal.config.max_route_cache_size) + @cache_mutex = Mutex.new end def call(context : HTTP::Server::Context) @@ -23,23 +129,29 @@ module Kemal end # Looks up the route from the Radix::Tree for the first time and caches to improve performance. + # Cache access is synchronized so multiple fibers can call this concurrently. def lookup_route(verb : String, path : String) lookup_path = radix_path(verb, path) - if cached_route = @cached_routes[lookup_path]? - return cached_route + @cache_mutex.synchronize do + if cached_route = @cached_routes.get(lookup_path) + return cached_route + end end route = @routes.find(lookup_path) if verb == "HEAD" && !route.found? # On HEAD requests, implicitly fallback to running the GET handler. - route = @routes.find(radix_path("GET", path)) - end - - if route.found? - @cached_routes.clear if @cached_routes.size == CACHED_ROUTES_LIMIT - @cached_routes[lookup_path] = route + get_lookup_path = radix_path("GET", path) + get_route = @routes.find(get_lookup_path) + # Cache the HEAD->GET fallback result using the original HEAD lookup_path + if get_route.found? + @cache_mutex.synchronize { @cached_routes.put(lookup_path, get_route) } + end + route = get_route + elsif route.found? + @cache_mutex.synchronize { @cached_routes.put(lookup_path, route) } end route @@ -56,11 +168,14 @@ module Kemal end context.response.print(content) + context + ensure + context.params.cleanup_temporary_files end private def radix_path(method, path) - '/' + method + path + "/#{method}#{path}" end private def add_to_radix_tree(method, path, route) diff --git a/src/kemal/router.cr b/src/kemal/router.cr new file mode 100644 index 0000000..2ff4fad --- /dev/null +++ b/src/kemal/router.cr @@ -0,0 +1,304 @@ +module Kemal + # Router provides modular routing capabilities for Kemal applications. + # + # It allows grouping routes under a common prefix and applying filters + # to specific route groups. Routers can be nested using `namespace`. + # + # ## Example + # + # ``` + # api = Kemal::Router.new + # + # api.before do |env| + # env.response.content_type = "application/json" + # end + # + # api.get "/users" do |env| + # User.all.to_json + # end + # + # api.namespace "/admin" do + # get "/dashboard" do |env| + # {status: "ok"}.to_json + # end + # end + # + # mount "/api/v1", api + # ``` + class Router + alias RouteHandler = HTTP::Server::Context -> String + alias FilterHandler = HTTP::Server::Context -> String + alias WSHandler = HTTP::WebSocket, HTTP::Server::Context -> + + # Stored route definition + private record RouteDefinition, + method : String, + path : String, + handler : RouteHandler + + # Stored filter definition + private record FilterDefinition, + type : Symbol, + method : String, + path : String, + handler : FilterHandler + + # Stored websocket definition + private record WSDefinition, + path : String, + handler : WSHandler + + # Stored sub-router + private record SubRouter, + path : String, + router : Router + + getter prefix : String + + @routes : Array(RouteDefinition) + @filters : Array(FilterDefinition) + @websockets : Array(WSDefinition) + @sub_routers : Array(SubRouter) + + def initialize(@prefix : String = "") + @routes = [] of RouteDefinition + @filters = [] of FilterDefinition + @websockets = [] of WSDefinition + @sub_routers = [] of SubRouter + end + + # HTTP method helpers + {% for method in HTTP_METHODS %} + # Defines a {{ method.id.upcase }} route. + # + # ``` + # router.{{ method.id }} "/path" do |env| + # "response" + # end + # ``` + def {{ method.id }}(path : String, &block : HTTP::Server::Context -> _) + add_route({{ method.upcase }}, path, &block) + end + {% end %} + + # Defines a WebSocket route. + # + # ``` + # router.ws "/chat" do |socket, env| + # socket.on_message do |msg| + # socket.send "Echo: #{msg}" + # end + # end + # ``` + def ws(path : String, &block : HTTP::WebSocket, HTTP::Server::Context ->) + @websockets << WSDefinition.new(path: path, handler: block) + end + + # Defines a before filter for all HTTP methods. + # + # ``` + # router.before do |env| + # env.response.content_type = "application/json" + # end + # ``` + def before(path : String = "*", &block : HTTP::Server::Context -> _) + add_filter(:before, "ALL", path, &block) + end + + # Defines an after filter for all HTTP methods. + # + # ``` + # router.after do |env| + # puts "Request completed" + # end + # ``` + def after(path : String = "*", &block : HTTP::Server::Context -> _) + add_filter(:after, "ALL", path, &block) + end + + # Method-specific before/after filters + {% for method in FILTER_METHODS %} + # Defines a before filter for {{ method.id.upcase }} requests. + def before_{{ method.id }}(path : String = "*", &block : HTTP::Server::Context -> _) + add_filter(:before, {{ method.upcase }}, path, &block) + end + + # Defines an after filter for {{ method.id.upcase }} requests. + def after_{{ method.id }}(path : String = "*", &block : HTTP::Server::Context -> _) + add_filter(:after, {{ method.upcase }}, path, &block) + end + {% end %} + + # Creates a nested namespace/group with the given *path* prefix. + # + # NOTE: The path must start with a `/`. + # + # All routes defined inside the block will be prefixed with the given path. + # + # ``` + # router.namespace "/users" do + # get "/" do |env| + # User.all.to_json + # end + # + # get "/:id" do |env| + # User.find(env.params.url["id"]).to_json + # end + # end + # ``` + def namespace(path : String, &) + sub_router = Router.new + with sub_router yield + @sub_routers << SubRouter.new(path: path, router: sub_router) + end + + # Mounts another router at the given *path* prefix. + # + # NOTE: The path must start with a `/`. + # + # ``` + # users_router = Kemal::Router.new + # users_router.get "/" { |env| "users" } + # + # api = Kemal::Router.new + # api.mount "/users", users_router + # + # mount "/api", api + # # Result: GET /api/users + # ``` + def mount(path : String, router : Router) + @sub_routers << SubRouter.new(path: path, router: router) + end + + # Mounts another router without additional prefix. + def mount(router : Router) + mount("", router) + end + + # Registers all routes, filters, and websockets with Kemal's handlers. + # This is called automatically when using `mount` from DSL. + # + # :nodoc: + def register_routes(base_prefix : String = "") + full_prefix = join_paths(base_prefix, @prefix) + + # Collect all route paths for filter registration + route_paths = collect_all_route_paths(full_prefix) + + # Register filters for each route path + register_filters(full_prefix, route_paths) + + # Register routes + @routes.each do |route| + full_path = join_paths(full_prefix, route.path) + validate_path!(route.method.downcase, full_path) + Kemal::RouteHandler::INSTANCE.add_route(route.method, full_path) do |env| + route.handler.call(env) + end + end + + # Register websockets + @websockets.each do |ws_def| + full_path = join_paths(full_prefix, ws_def.path) + validate_path!("ws", full_path) + Kemal::WebSocketHandler::INSTANCE.add_route(full_path, &ws_def.handler) + end + + # Register sub-routers recursively + @sub_routers.each do |sub| + sub_prefix = join_paths(full_prefix, sub.path) + sub.router.register_routes(sub_prefix) + end + end + + # Collect all route paths including sub-routers + protected def collect_all_route_paths(full_prefix : String) : Array(Tuple(String, String)) + paths = [] of Tuple(String, String) + + # This router's routes + @routes.each do |route| + full_path = join_paths(full_prefix, route.path) + paths << {route.method, full_path} + end + + # Sub-router routes + @sub_routers.each do |sub| + sub_prefix = join_paths(full_prefix, sub.path) + paths.concat(sub.router.collect_all_route_paths(sub_prefix)) + end + + paths + end + + # Register filters for specific route paths + private def register_filters(full_prefix : String, route_paths : Array(Tuple(String, String))) + return if @filters.empty? + + # Ensure FilterHandler is registered with Kemal (may have been cleared between tests) + unless Kemal::Config::FILTER_HANDLERS.includes?(Kemal::FilterHandler::INSTANCE) + Kemal.config.add_filter_handler(Kemal::FilterHandler::INSTANCE) + end + + @filters.each do |filter| + # Determine which paths this filter applies to + applicable_paths = if filter.path == "*" + # Apply to all routes in this router + route_paths + else + # Apply to specific path + filter_full_path = join_paths(full_prefix, filter.path) + route_paths.select { |_, path| path == filter_full_path || path.starts_with?(filter_full_path + "/") } + end + + applicable_paths.each do |route_method, route_path| + # Check if filter method matches route method + next unless filter.method.in?("ALL", route_method) + + # Use filter's method (ALL or specific) when registering + register_method = filter.method + + case filter.type + when :before + Kemal::FilterHandler::INSTANCE.before(register_method, route_path) do |env| + filter.handler.call(env) + end + when :after + Kemal::FilterHandler::INSTANCE.after(register_method, route_path) do |env| + filter.handler.call(env) + end + end + end + end + end + + private def add_route(method : String, path : String, &block : HTTP::Server::Context -> _) + handler = ->(ctx : HTTP::Server::Context) do + result = block.call(ctx) + result.is_a?(String) ? result : "" + end + @routes << RouteDefinition.new(method: method, path: path, handler: handler) + end + + private def add_filter(type : Symbol, method : String, path : String, &block : HTTP::Server::Context -> _) + handler = ->(ctx : HTTP::Server::Context) do + result = block.call(ctx) + result.is_a?(String) ? result : "" + end + @filters << FilterDefinition.new(type: type, method: method, path: path, handler: handler) + end + + private def join_paths(a : String, b : String) : String + a = a.chomp('/') + b = b.lchop('/') if b.starts_with?('/') + return "/#{b}" if a.empty? + return a if b.empty? + "#{a}/#{b}" + end + + private def validate_path!(method : String, path : String) + unless Utils.path_starts_with_slash?(path) + raise Exceptions::InvalidPathStartException.new(method, path) + end + end + end +end diff --git a/src/kemal/static_file_handler.cr b/src/kemal/static_file_handler.cr index 3b4cf0e..787afe9 100644 --- a/src/kemal/static_file_handler.cr +++ b/src/kemal/static_file_handler.cr @@ -1,53 +1,104 @@ module Kemal class StaticFileHandler < HTTP::StaticFileHandler - # ameba:disable Metrics/CyclomaticComplexity - def call(context : HTTP::Server::Context) - return call_next(context) if context.request.path.not_nil! == "/" - - case context.request.method - when "GET", "HEAD" - else - if @fallthrough - call_next(context) - else - context.response.status_code = 405 - context.response.headers.add("Allow", "GET, HEAD") + {% if compare_versions(Crystal::VERSION, "1.17.0") >= 0 %} + private def directory_index(context : HTTP::Server::Context, request_path : Path, file_path : Path) + config = Kemal.config.serve_static + unless config.is_a?(Hash) + return call_next(context) + end + + index_path = file_path / "index.html" + if config.fetch("dir_index", false) && (index_info = File.info?(index_path)) + last_modified = index_info.modification_time + add_cache_headers(context.response.headers, last_modified) + + if cache_request?(context, last_modified) + context.response.status = :not_modified + return + end + + send_file(context, index_path.to_s) + elsif config.fetch("dir_listing", false) + context.response.content_type = "text/html; charset=utf-8" + directory_listing(context.response, request_path, file_path) + else + call_next(context) end - return end - config = Kemal.config.serve_static - original_path = context.request.path.not_nil! - request_path = URI.decode(original_path) - - # File path cannot contains '\0' (NUL) because all filesystem I know - # don't accept '\0' character as file name. - if request_path.includes? '\0' - context.response.status_code = 400 - return + # NOTE: This override opts out of some behaviour from HTTP::StaticFileHandler, + # such as serving content ranges. + private def serve_file(context : HTTP::Server::Context, file_info, file_path : Path, original_file_path : Path, last_modified : Time) + send_file(context, file_path.to_s) end + {% else %} + def call(context : HTTP::Server::Context) + return call_next(context) if context.request.path.not_nil! == "/" - expanded_path = request_path - is_dir_path = if original_path.ends_with?('/') && !expanded_path.ends_with? '/' - expanded_path = expanded_path + '/' - true - else - expanded_path.ends_with? '/' - end + case context.request.method + when "GET", "HEAD" + else + if @fallthrough + call_next(context) + else + context.response.status_code = 405 + context.response.headers.add("Allow", "GET, HEAD") + end + return + end - file_path = File.join(@public_dir, expanded_path) - is_dir = Dir.exists?(file_path) + original_path = context.request.path.not_nil! + is_dir_path = original_path.ends_with?("/") + request_path = URI.decode(original_path) - if request_path != expanded_path - redirect_to context, expanded_path - elsif is_dir && !is_dir_path - redirect_to context, expanded_path + '/' - end + # File path cannot contains '\0' (NUL) because all filesystem I know + # don't accept '\0' character as file name. + if request_path.includes? '\0' + context.response.respond_with_status(:bad_request) + return + end - if is_dir - if config.is_a?(Hash) && config.fetch("dir_index", false) && File.exists?(File.join(file_path, "index.html")) - file_path = File.join(@public_dir, expanded_path, "index.html") + request_path = Path.posix(request_path) + expanded_path = request_path.expand("/") + file_path = @public_dir.join(expanded_path.to_kind(Path::Kind.native)) + file_info = File.info? file_path + is_dir = @directory_listing && file_info && file_info.directory? + is_file = file_info && file_info.file? + + if request_path != expanded_path || is_dir && !is_dir_path + redirect_path = expanded_path + if is_dir && !is_dir_path + # Append / to path if missing + redirect_path = expanded_path.join("") + end + redirect_to context, redirect_path + return + end + + return call_next(context) unless file_info + + if is_dir + config = Kemal.config.serve_static + + if config.is_a?(Hash) && config.fetch("dir_index", false) && File.exists?(File.join(file_path, "index.html")) + file_path = File.join(@public_dir, expanded_path, "index.html") + + last_modified = modification_time(file_path) + add_cache_headers(context.response.headers, last_modified) + + if cache_request?(context, last_modified) + context.response.status_code = 304 + return + end + send_file(context, file_path) + elsif config.is_a?(Hash) && config.fetch("dir_listing", false) + context.response.content_type = "text/html; charset=utf-8" + directory_listing(context.response, request_path, file_path) + else + call_next(context) + end + elsif is_file last_modified = modification_time(file_path) add_cache_headers(context.response.headers, last_modified) @@ -55,29 +106,15 @@ module Kemal context.response.status_code = 304 return end - send_file(context, file_path) - elsif config.is_a?(Hash) && config.fetch("dir_listing", false) - context.response.content_type = "text/html; charset=utf-8" - directory_listing(context.response, request_path, file_path) - else + send_file(context, file_path.to_s) + else # Not a normal file (FIFO/device/socket) call_next(context) end - elsif File.exists?(file_path) - last_modified = modification_time(file_path) - add_cache_headers(context.response.headers, last_modified) - - if cache_request?(context, last_modified) - context.response.status_code = 304 - return - end - send_file(context, file_path) - else - call_next(context) end - end - private def modification_time(file_path) - File.info(file_path).modification_time - end + private def modification_time(file_path) + File.info(file_path).modification_time + end + {% end %} end end diff --git a/src/kemal/websocket.cr b/src/kemal/websocket.cr index b06e9a7..ebc0401 100644 --- a/src/kemal/websocket.cr +++ b/src/kemal/websocket.cr @@ -6,7 +6,7 @@ module Kemal class WebSocket < HTTP::WebSocketHandler getter proc - def initialize(@path : String, &@proc : HTTP::WebSocket, HTTP::Server::Context -> Void) + def initialize(@path : String, &@proc : HTTP::WebSocket, HTTP::Server::Context ->) end def error(code : Int16, message : String) diff --git a/src/kemal/websocket_handler.cr b/src/kemal/websocket_handler.cr index 03aed1a..8657cab 100644 --- a/src/kemal/websocket_handler.cr +++ b/src/kemal/websocket_handler.cr @@ -38,7 +38,7 @@ module Kemal @routes.find "/ws" + path end - def add_route(path : String, &handler : HTTP::WebSocket, HTTP::Server::Context -> Void) + def add_route(path : String, &handler : HTTP::WebSocket, HTTP::Server::Context ->) add_to_radix_tree path, WebSocket.new(path, &handler) end