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

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

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

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

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