Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: kemalcr/kemal
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v1.1.1
Choose a base ref
...
head repository: kemalcr/kemal
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: master
Choose a head ref

Commits on Feb 24, 2022

  1. Fix content rendering

    matthewmcgarvey authored and sdogruyol committed Feb 24, 2022
    Copy the full SHA
    d6dc893 View commit details
  2. Bump version to 1.1.2

    sdogruyol committed Feb 24, 2022
    Copy the full SHA
    1d54971 View commit details

Commits on May 31, 2022

  1. Update ameba to 1.0

    straight-shoota authored and sdogruyol committed May 31, 2022
    Copy the full SHA
    f706c78 View commit details
  2. Copy the full SHA
    d53d253 View commit details

Commits on Jun 27, 2022

  1. Copy the full SHA
    317d086 View commit details

Commits on Jun 29, 2022

  1. Copy the full SHA
    9bd24ca View commit details
  2. fix ameba warnings

    sdogruyol committed Jun 29, 2022
    Copy the full SHA
    f8fc8ce View commit details
  3. fix Crystal 1.5.0 warnings

    sdogruyol committed Jun 29, 2022
    Copy the full SHA
    268e501 View commit details

Commits on Jun 30, 2022

  1. Copy the full SHA
    05d5554 View commit details

Commits on Jul 16, 2022

  1. Copy the full SHA
    1a45f54 View commit details

Commits on Jul 30, 2022

  1. Add changelog for 1.2.0

    sdogruyol committed Jul 30, 2022
    Copy the full SHA
    c993a05 View commit details
  2. Bump version to 1.2.0

    sdogruyol committed Jul 30, 2022
    Copy the full SHA
    707e616 View commit details
  3. Enable GH sponsors

    sdogruyol committed Jul 30, 2022
    Copy the full SHA
    4aa28c4 View commit details

Commits on Aug 4, 2022

  1. Copy the full SHA
    f5d767f View commit details

Commits on Aug 15, 2022

  1. Copy the full SHA
    93521b7 View commit details

Commits on Sep 15, 2022

  1. Copy the full SHA
    c8f857d View commit details

Commits on Oct 9, 2022

  1. Add CHANGELOG for 1.3.0

    sdogruyol committed Oct 9, 2022
    Copy the full SHA
    d20dbc7 View commit details
  2. Bump version to 1.3.0

    sdogruyol committed Oct 9, 2022
    Copy the full SHA
    ae7cda8 View commit details

Commits on Feb 17, 2023

  1. Copy the full SHA
    1966189 View commit details
  2. Copy the full SHA
    6a10ea8 View commit details

Commits on Feb 19, 2023

  1. Copy the full SHA
    84ea662 View commit details

Commits on Feb 22, 2023

  1. Copy the full SHA
    8ebe171 View commit details

Commits on Apr 15, 2023

  1. Add CHANGELOG for 1.4.0

    sdogruyol committed Apr 15, 2023
    Copy the full SHA
    aa004af View commit details
  2. Bump version to 1.4.0

    sdogruyol committed Apr 15, 2023
    Copy the full SHA
    c995a2a View commit details

Commits on Sep 23, 2023

  1. Copy the full SHA
    a939a57 View commit details

Commits on Sep 24, 2023

  1. Copy the full SHA
    cb9adcd View commit details

Commits on Oct 31, 2023

  1. Copy the full SHA
    13fd4f8 View commit details

Commits on Jan 23, 2024

  1. Copy the full SHA
    0de0e99 View commit details
  2. Copy the full SHA
    9628043 View commit details

Commits on Feb 1, 2024

  1. Copy the full SHA
    bb9105f View commit details

Commits on Feb 3, 2024

  1. 1
    Copy the full SHA
    bef7351 View commit details

Commits on Feb 14, 2024

  1. Copy the full SHA
    7c47bbc View commit details

Commits on Apr 10, 2024

  1. Copy the full SHA
    b074578 View commit details
  2. Tweak CHANGELOG.md (#677)

    Sija authored Apr 10, 2024
    Copy the full SHA
    a15ba83 View commit details
  3. Update CHANGELOG

    sdogruyol committed Apr 10, 2024
    Copy the full SHA
    ca7d6e1 View commit details
  4. Bump version to 1.5.0

    sdogruyol committed Apr 10, 2024
    Copy the full SHA
    52ab623 View commit details

Commits on May 7, 2024

  1. Copy the full SHA
    e69bd40 View commit details

Commits on May 10, 2024

  1. Copy the full SHA
    6a29240 View commit details

Commits on May 16, 2024

  1. Followup to #679 (#680)

    Sija authored May 16, 2024
    Copy the full SHA
    5554d3d View commit details

Commits on Jul 24, 2024

  1. fix #683 (#684)

    a-alhusaini authored Jul 24, 2024
    Copy the full SHA
    1d46fd1 View commit details

Commits on Jul 25, 2024

  1. Copy the full SHA
    0afbd12 View commit details

Commits on Jul 30, 2024

  1. Copy the full SHA
    3243b8e View commit details

Commits on Oct 1, 2024

  1. Windows support (#690)

    Windows support
    sdogruyol authored Oct 1, 2024
    Copy the full SHA
    85fcbbe View commit details

Commits on Oct 12, 2024

  1. Copy the full SHA
    dc031c6 View commit details
  2. Update CHANGELOG

    sdogruyol committed Oct 12, 2024
    Copy the full SHA
    2d8df1f View commit details
  3. Bump version to 1.6.0

    sdogruyol committed Oct 12, 2024
    Copy the full SHA
    75d5ef1 View commit details

Commits on Oct 28, 2024

  1. Copy the full SHA
    749c537 View commit details

Commits on Dec 19, 2024

  1. Add ability to add handlers for raised exceptions (#688)

    Add ability to add handlers for raised exceptions. Closes #622
    syeopite authored Dec 19, 2024
    Copy the full SHA
    6b884dd View commit details

Commits on Dec 20, 2024

  1. Copy the full SHA
    a9324be View commit details

Commits on Jan 13, 2025

  1. Improve README

    sdogruyol committed Jan 13, 2025
    Copy the full SHA
    5359781 View commit details
17 changes: 14 additions & 3 deletions .ameba.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
# This configuration file was generated by `ameba --gen-config`
# on 2019-08-25 09:29:24 UTC using Ameba version 0.10.0.
# 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: 7
# 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:
- spec/view_spec.cr
- src/kemal/param_parser.cr
- src/kemal/static_file_handler.cr
- src/kemal/config.cr
Enabled: true
Severity: Warning
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.cr text eol=lf
*.ecr text eol=lf
2 changes: 1 addition & 1 deletion .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# These are supported funding model platforms

github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
github: sdogruyol
patreon: sdogruyol
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
47 changes: 41 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -11,31 +11,66 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
os: [ubuntu-latest, macos-latest, windows-latest]
crystal: [latest, nightly]
runs-on: ${{ matrix.os }}

steps:
- name: Install Crystal
uses: oprypin/install-crystal@v1
uses: crystal-lang/install-crystal@v1
with:
crystal: ${{ matrix.crystal }}

- name: Download source
uses: actions/checkout@v2
uses: actions/checkout@v4

- name: Install dependencies
run: shards install
env:
SHARDS_OPTS: --ignore-crystal-version

- name: Run specs
run: |
crystal spec
crystal spec --release --no-debug
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

391 changes: 210 additions & 181 deletions CHANGELOG.md

Large diffs are not rendered by default.

97 changes: 62 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,67 +1,94 @@

[![Kemal](https://avatars3.githubusercontent.com/u/15321198?v=3&s=200)](http://kemalcr.com)

# Kemal

Lightning Fast, Super Simple web framework.
Kemal is the Fast, Effective, Simple Web Framework for Crystal. It's perfect for building Web Applications and APIs with minimal code.

[![CI](https://github.com/kemalcr/kemal/actions/workflows/ci.yml/badge.svg)](https://github.com/kemalcr/kemal/actions/workflows/ci.yml)
[![Join the chat at https://gitter.im/sdogruyol/kemal](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/sdogruyol/kemal?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)

# Super Simple ⚡️
## Why Kemal?

- 🚀 **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

## Quick Start

1. First, make sure you have [Crystal installed](https://crystal-lang.org/install/).

2. Add Kemal to your project's `shard.yml`:

```yaml
dependencies:
kemal:
github: kemalcr/kemal
```
3. Create your first Kemal app:
```ruby
```crystal
require "kemal"

# Matches GET "http://host:port/"
# Basic route - responds to GET "http://localhost:3000/"
get "/" do
"Hello World!"
end

# Creates a WebSocket handler.
# Matches "ws://host:port/socket"
ws "/socket" do |socket|
socket.send "Hello from Kemal!"
# JSON API example
get "/api/status" do |env|
env.response.content_type = "application/json"
{"status": "ok"}.to_json
end

# WebSocket support
ws "/chat" do |socket|
socket.send "Hello from Kemal WebSocket!"
end

Kemal.run
```

Start your application!
4. Run your application:

```bash
crystal run src/your_app.cr
```
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.
5. Visit [http://localhost:3000](http://localhost:3000) - That's it! 🎉

# Installation
## Key Features

Add this to your application's `shard.yml`:
-**Full REST Support**: Handle all HTTP verbs (GET, POST, PUT, DELETE, etc.)
- 🔌 **WebSocket Support**: Real-time bidirectional communication
- 📦 **Built-in JSON Support**: Native JSON handling
- 🗄️ **Static File Serving**: Serve your static assets easily
- 📝 **Template Support**: Built-in ECR template engine
- 🔒 **Middleware System**: Add functionality with middleware
- 🎯 **Request/Response Context**: Easy parameter and request handling

## Learning Resources

- 📚 [Official Documentation](http://kemalcr.com)
- 💻 [Sample Applications](https://github.com/kemalcr/kemal/tree/master/samples)
- 🚀 [Getting Started Guide](http://kemalcr.com/guide/)
- 💬 [Community Chat](https://discord.gg/prSVAZJEpz)

```yaml
dependencies:
kemal:
github: kemalcr/kemal
```

See also [Getting Started](http://kemalcr.com/guide/).
## Contributing

# Features
We love contributions! If you'd like to contribute:

- 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)
1. Fork it (https://github.com/kemalcr/kemal/fork)
2. Create your feature branch (git checkout -b my-new-feature)
3. Commit your changes (git commit -am 'Add some feature')
4. Push to the branch (git push origin my-new-feature)
5. Create a new Pull Request

# Documentation
## Acknowledgments

You can read the documentation at the official site [kemalcr.com](http://kemalcr.com)
Special thanks to Manas for their work on [Frank](https://github.com/manastech/frank).

## Thanks
## License

Thanks to Manas for their awesome work on [Frank](https://github.com/manastech/frank).
Kemal is released under the MIT License.
5 changes: 2 additions & 3 deletions shard.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: kemal
version: 1.1.1
version: 1.6.0

authors:
- Serdar Dogruyol <dogruyolserdar@gmail.com>
@@ -10,12 +10,11 @@ dependencies:
version: ~> 0.4.0
exception_page:
github: crystal-loot/exception_page
version: ~> 0.2.0
version: ~> 0.5.0

development_dependencies:
ameba:
github: crystal-ameba/ameba
version: ~> 0.14.0

crystal: ">= 0.36.0"

4 changes: 2 additions & 2 deletions spec/asset/hello_with_content_for.ecr
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Hello <%= name %>

<% content_for "custom" do %>
<h1>Hello from otherside</h1>
<% content_for "meta" do %>
<title>Kemal Spec</title>
<% end %>
4 changes: 3 additions & 1 deletion spec/asset/layout_with_yield.ecr
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<html>
<head>
<%= yield_content "meta" %>
</head>
<body>
<%= content %>
<%= yield_content "custom" %>
</body>
</html>
8 changes: 5 additions & 3 deletions spec/asset/layout_with_yield_and_vars.ecr
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<html>
<head>
<%= yield_content "meta" %>
</head>
<body>
<%= content %>
<%= yield_content "custom" %>
<%= var1 %>
<%= var2 %>
<%= var1 %>
<%= var2 %>
</body>
</html>
4 changes: 2 additions & 2 deletions spec/config_spec.cr
Original file line number Diff line number Diff line change
@@ -16,7 +16,7 @@ describe "Config" do
end

it "sets default powered_by_header to true" do
Kemal::Config.new.powered_by_header.should be_true
Kemal::Config.new.powered_by_header?.should be_true
end

it "sets host binding" do
@@ -29,7 +29,7 @@ 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
93 changes: 93 additions & 0 deletions spec/exception_handler_spec.cr
Original file line number Diff line number Diff line change
@@ -59,6 +59,99 @@ describe "Kemal::ExceptionHandler" do
response.body.should eq "Something happened"
end

it "renders custom error for a crystal exception" do
error RuntimeError do
"A RuntimeError has occured"
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 occured"
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"
37 changes: 37 additions & 0 deletions spec/filters_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
require "./spec_helper"

describe "Kemal::FilterHandler" do
it "handles with upcased 'POST'" do
filter_handler = Kemal::FilterHandler.new
filter_handler._add_route_filter("POST", "*", :before) do |env|
env.set "sensitive", "1"
end
Kemal.config.add_filter_handler(filter_handler)

post "/sensitive_post" do |env|
env.get "sensitive"
end

request = HTTP::Request.new("POST", "/sensitive_post")
client_response = call_request_on_app(request)
client_response.status_code.should eq(200)
client_response.body.should eq("1")
end

it "handles with downcased 'post'" do
filter_handler = Kemal::FilterHandler.new
filter_handler._add_route_filter("POST", "*", :before) do |env|
env.set "sensitive", "1"
end
Kemal.config.add_filter_handler(filter_handler)

post "/sensitive_post" do
"sensitive"
end

request = HTTP::Request.new("post", "/sensitive_post")
client_response = call_request_on_app(request)
client_response.status_code.should eq(200)
client_response.body.should eq("")
end
end
37 changes: 37 additions & 0 deletions spec/head_request_handler_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
require "./spec_helper"

describe "Kemal::HeadRequestHandler" do
it "implicitly handles GET endpoints, with Content-Length header" do
get "/" do
"hello"
end
request = HTTP::Request.new("HEAD", "/")
client_response = call_request_on_app(request)
client_response.body.should eq("")
client_response.headers["Content-Length"].should eq("5")
end

it "prefers explicit HEAD endpoint if specified" do
Kemal::RouteHandler::INSTANCE.add_route("HEAD", "/") { "hello" }
get "/" do
raise "shouldn't be called!"
end
request = HTTP::Request.new("HEAD", "/")
client_response = call_request_on_app(request)
client_response.body.should eq("")
client_response.headers["Content-Length"].should eq("5")
end

it "gives compressed Content-Length when gzip enabled" do
gzip true
get "/" do
"hello"
end
headers = HTTP::Headers{"Accept-Encoding" => "gzip"}
request = HTTP::Request.new("HEAD", "/", headers)
client_response = call_request_on_app(request)
client_response.body.should eq("")
client_response.headers["Content-Encoding"].should eq("gzip")
client_response.headers["Content-Length"].should eq("25")
end
end
5 changes: 3 additions & 2 deletions spec/helpers_spec.cr
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@ describe "Macros" do
it "adds a custom handler" do
add_handler CustomTestHandler.new
Kemal.config.setup
Kemal.config.handlers.size.should eq 7
Kemal.config.handlers.size.should eq 8
end
end

@@ -106,6 +106,7 @@ describe "Macros" do
request = HTTP::Request.new("GET", "/")
response = call_request_on_app(request)
response.status_code.should eq(200)

response.headers["Content-Type"].should eq("application/octet-stream")
response.headers["Content-Length"].should eq("18")
end
@@ -150,7 +151,7 @@ describe "Macros" 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

14 changes: 13 additions & 1 deletion spec/init_handler_spec.cr
Original file line number Diff line number Diff line change
@@ -6,11 +6,23 @@ describe "Kemal::InitHandler" do
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
Kemal::InitHandler::INSTANCE.next = ->(_context : HTTP::Server::Context) {}
Kemal::InitHandler::INSTANCE.next = ->(_context : HTTP::Server::Context) { }
Kemal::InitHandler::INSTANCE.call(context)
context.response.headers["Content-Type"].should eq "text/html"
end

it "initializes context with Date header" do
request = HTTP::Request.new("GET", "/")
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
Kemal::InitHandler::INSTANCE.next = ->(_context : HTTP::Server::Context) { }
Kemal::InitHandler::INSTANCE.call(context)
date = context.response.headers["Date"]?.should_not be_nil
date = HTTP.parse_time(date).should_not be_nil
date.should be_close(Time.utc, 1.second)
end

it "initializes context with X-Powered-By: Kemal" do
request = HTTP::Request.new("GET", "/")
io = IO::Memory.new
29 changes: 29 additions & 0 deletions spec/override_method_handler_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
require "./spec_helper"

describe "Kemal::OverrideMethodHandler" do
it "does not override method without _method for POST requests" do
request = HTTP::Request.new(
"POST",
"/",
body: "_not_method=PATCH",
headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded; charset=UTF-8"}
)

context = create_request_and_return_io_and_context(Kemal::OverrideMethodHandler::INSTANCE, request)[1]

context.request.method.should eq "POST"
end

it "overrides method with _method for POST requests" do
request = HTTP::Request.new(
"POST",
"/",
body: "_method=PATCH",
headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded; charset=UTF-8"}
)

context = create_request_and_return_io_and_context(Kemal::OverrideMethodHandler::INSTANCE, request)[1]

context.request.method.should eq "PATCH"
end
end
36 changes: 36 additions & 0 deletions spec/route_handler_spec.cr
Original file line number Diff line number Diff line change
@@ -143,4 +143,40 @@ describe "Kemal::RouteHandler" do
client_response.body.should eq("Redirecting to /login")
client_response.headers.has_key?("Location").should eq(true)
end

it "redirects and closes response in before filter" do
filter_handler = Kemal::FilterHandler.new
filter_handler._add_route_filter("GET", "/", :before) do |env|
env.redirect "/login"
end
Kemal.config.add_filter_handler(filter_handler)

get "/" do
"home page"
end

request = HTTP::Request.new("GET", "/")
client_response = call_request_on_app(request)
client_response.status_code.should eq(302)
client_response.body.should eq("")
client_response.headers.has_key?("Location").should eq(true)
end

it "redirects in before filter without closing response" do
filter_handler = Kemal::FilterHandler.new
filter_handler._add_route_filter("GET", "/", :before) do |env|
env.redirect "/login", close: false
end
Kemal.config.add_filter_handler(filter_handler)

get "/" do
"home page"
end

request = HTTP::Request.new("GET", "/")
client_response = call_request_on_app(request)
client_response.status_code.should eq(302)
client_response.body.should eq("home page")
client_response.headers.has_key?("Location").should eq(true)
end
end
13 changes: 9 additions & 4 deletions spec/run_spec.cr
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@

describe "Run" do
it "runs a code block after starting" do
run(<<-CR).should eq "started\nstopped\n"

Check failure on line 18 in spec/run_spec.cr

GitHub Actions / test (ubuntu-latest, nightly)

got: ""

Check failure on line 18 in spec/run_spec.cr

GitHub Actions / test (macos-latest, nightly)

got: ""
Kemal.config.env = "test"
Kemal.run do
puts "started"
@@ -26,20 +26,25 @@
end

it "runs without a block being specified" do
run(<<-CR).should eq "[test] Kemal is ready to lead at http://0.0.0.0:3000\ntrue\n"
run(<<-CR).should contain "[test] Kemal is running in test mode."

Check failure on line 29 in spec/run_spec.cr

GitHub Actions / test (ubuntu-latest, nightly)

to include: "[test] Kemal is running in test mode."

Check failure on line 29 in spec/run_spec.cr

GitHub Actions / test (macos-latest, nightly)

to include: "[test] Kemal is running in test mode."
Kemal.config.env = "test"
Kemal.run
puts Kemal.config.running
CR
end

it "allows custom HTTP::Server bind" do
run(<<-CR).should eq "[test] Kemal is ready to lead at http://127.0.0.1:3000, http://0.0.0.0:3001\n"
run(<<-CR).should contain "[test] Kemal is running in test mode."

Check failure on line 37 in spec/run_spec.cr

GitHub Actions / test (ubuntu-latest, nightly)

to include: "[test] Kemal is running in test mode."

Check failure on line 37 in spec/run_spec.cr

GitHub Actions / test (macos-latest, nightly)

to include: "[test] Kemal is running in test mode."
Kemal.config.env = "test"
Kemal.run do |config|
server = config.server.not_nil!
server.bind_tcp "127.0.0.1", 3000, reuse_port: true
server.bind_tcp "0.0.0.0", 3001, reuse_port: true
{% if flag?(:windows) %}
server.bind_tcp "127.0.0.1", 3000
{% else %}
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
end
11 changes: 9 additions & 2 deletions spec/spec_helper.cr
Original file line number Diff line number Diff line change
@@ -4,8 +4,8 @@ require "../src/*"
include Kemal

class CustomLogHandler < Kemal::BaseLogHandler
def call(env)
call_next env
def call(context)
call_next(context)
end

def write(message)
@@ -26,6 +26,12 @@ class AnotherContextStorageType
@name = "kemal-context"
end

class CustomExceptionType < Exception
end

class ChildCustomExceptionType < CustomExceptionType
end

add_context_storage_type(TestContextStorageType)
add_context_storage_type(AnotherContextStorageType)

@@ -85,6 +91,7 @@ end

Spec.after_each do
Kemal.config.clear
Kemal::FilterHandler::INSTANCE.tree = Radix::Tree(Array(Kemal::FilterHandler::FilterBlock)).new
Kemal::RouteHandler::INSTANCE.routes = Radix::Tree(Route).new
Kemal::RouteHandler::INSTANCE.cached_routes = Hash(String, Radix::Result(Route)).new
Kemal::WebSocketHandler::INSTANCE.routes = Radix::Tree(WebSocket).new
15 changes: 12 additions & 3 deletions spec/static_file_handler_spec.cr
Original file line number Diff line number Diff line change
@@ -23,6 +23,15 @@ describe Kemal::StaticFileHandler do
response.body.should eq(File.read("#{__DIR__}/static/dir/test.txt"))
end

it "should serve the 'index.html' file when a directory is requested and index serving is enabled" do
serve_static({"dir_index" => true})
response = handle HTTP::Request.new("GET", "/dir/")
response.status_code.should eq(200)
response.headers["Content-Type"].should eq "text/html"
response.headers["Etag"].should contain "W/\""
response.body.should eq(File.read("#{__DIR__}/static/dir/index.html"))
end

it "should respond with 304 if file has not changed" do
response = handle HTTP::Request.new("GET", "/dir/test.txt")
response.status_code.should eq(200)
@@ -132,11 +141,11 @@ describe Kemal::StaticFileHandler do
end

it "should handle setting custom headers" do
headers = Proc(HTTP::Server::Response, String, File::Info, Void).new do |response, path, stat|
headers = Proc(HTTP::Server::Context, String, File::Info, Void).new do |env, path, stat|
if path =~ /\.html$/
response.headers.add("Access-Control-Allow-Origin", "*")
env.response.headers.add("Access-Control-Allow-Origin", "*")
end
response.headers.add("Content-Size", stat.size.to_s)
env.response.headers.add("Content-Size", stat.size.to_s)
end

static_headers(&headers)
16 changes: 13 additions & 3 deletions spec/view_spec.cr
Original file line number Diff line number Diff line change
@@ -22,7 +22,7 @@ describe "Views" do
end
request = HTTP::Request.new("GET", "/view/world")
client_response = call_request_on_app(request)
client_response.body.should contain("Hello world")
client_response.body.strip.should eq("<html>Hello world\n</html>")
end

it "renders layout" do
@@ -56,7 +56,17 @@ describe "Views" do
end
request = HTTP::Request.new("GET", "/view/world")
client_response = call_request_on_app(request)
client_response.body.should contain("Hello world")
client_response.body.should contain("<h1>Hello from otherside</h1>")
client_response.body.scan("Hello world").size.should eq(1)
client_response.body.should contain("<title>Kemal Spec</title>")
end

it "does not render content_for that was not yielded" do
get "/view/:name" do |env|
name = env.params.url["name"]
render "#{__DIR__}/asset/hello_with_content_for.ecr", "#{__DIR__}/asset/layout.ecr"
end
request = HTTP::Request.new("GET", "/view/world")
client_response = call_request_on_app(request)
client_response.body.should_not contain("<h1>Hello from otherside</h1>")
end
end
2 changes: 1 addition & 1 deletion spec/websocket_handler_spec.cr
Original file line number Diff line number Diff line change
@@ -38,7 +38,7 @@ describe "Kemal::WebSocketHandler" do

it "fetches named url parameters" do
handler = Kemal::WebSocketHandler::INSTANCE
ws "/:id" { |_, c| c.ws_route_lookup.params["id"] }
ws "/:id" { |_, context| context.ws_route_lookup.params["id"] }
headers = HTTP::Headers{
"Upgrade" => "websocket",
"Connection" => "Upgrade",
24 changes: 14 additions & 10 deletions src/kemal.cr
Original file line number Diff line number Diff line change
@@ -7,18 +7,18 @@ require "./kemal/helpers/*"

module Kemal
# Overload of `self.run` with the default startup logging.
def self.run(port : Int32?, args = ARGV)
self.run(port, args) { }
def self.run(port : Int32?, args = ARGV, trap_signal : Bool = true)
self.run(port, args, trap_signal) { }
end

# Overload of `self.run` without port.
def self.run(args = ARGV)
self.run(nil, args: args)
def self.run(args = ARGV, trap_signal : Bool = true)
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)
self.run(nil, args: args, &block)
self.run(nil, args: args, trap_signal: true, &block)
end

# The command to run a `Kemal` application.
@@ -27,7 +27,7 @@ module Kemal
#
# To use custom command line arguments, set args to nil
#
def self.run(port : Int32? = nil, args = ARGV, &block)
def self.run(port : Int32? = nil, args = ARGV, trap_signal : Bool = true, &)
Kemal::CLI.new args
config = Kemal.config
config.setup
@@ -36,7 +36,7 @@ module Kemal
# Test environment doesn't need to have signal trap and logging.
if config.env != "test"
setup_404
setup_trap_signal
setup_trap_signal if trap_signal
end

server = config.server ||= HTTP::Server.new(config.handlers)
@@ -66,8 +66,12 @@ module Kemal
end

def self.display_startup_message(config, server)
addresses = server.addresses.join ", " { |address| "#{config.scheme}://#{address}" }
log "[#{config.env}] #{config.app_name} is ready to lead at #{addresses}"
if config.env != "test"
addresses = server.addresses.join ", " { |address| "#{config.scheme}://#{address}" }
log "[#{config.env}] #{config.app_name} is ready to lead at #{addresses}"
else
log "[#{config.env}] #{config.app_name} is running in test mode. Server not listening"
end
end

def self.stop
@@ -89,7 +93,7 @@ module Kemal
end

private def self.setup_trap_signal
Signal::INT.trap do
Process.on_terminate do
log "#{Kemal.config.app_name} is going to take a rest!" if Kemal.config.shutdown_message
Kemal.stop
exit
45 changes: 32 additions & 13 deletions src/kemal/config.cr
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
module Kemal
VERSION = {{ `shards version #{__DIR__}`.chomp.stringify }}
VERSION = {{ `shards version "#{__DIR__}"`.chomp.stringify }}

# Stores all the configuration options for a Kemal application.
# It's a singleton and you can access it like.
@@ -8,30 +8,31 @@ module Kemal
# Kemal.config
# ```
class Config
INSTANCE = Config.new
HANDLERS = [] of HTTP::Handler
CUSTOM_HANDLERS = [] of Tuple(Nil | Int32, HTTP::Handler)
FILTER_HANDLERS = [] of HTTP::Handler
ERROR_HANDLERS = {} of Int32 => HTTP::Server::Context, Exception -> String
INSTANCE = Config.new
HANDLERS = [] of HTTP::Handler
CUSTOM_HANDLERS = [] of Tuple(Nil | Int32, HTTP::Handler)
FILTER_HANDLERS = [] of HTTP::Handler
ERROR_HANDLERS = {} of Int32 => HTTP::Server::Context, Exception -> String
EXCEPTION_HANDLERS = {} of Exception.class => HTTP::Server::Context, Exception -> String

{% if flag?(:without_openssl) %}
@ssl : Bool?
{% else %}
@ssl : OpenSSL::SSL::Context::Server?
{% end %}

property host_binding, ssl, port, env, public_folder, logging, running
property app_name, host_binding, ssl, port, env, public_folder, logging, running
property always_rescue, server : HTTP::Server?, extra_options, shutdown_message
property serve_static : (Bool | Hash(String, Bool))
property static_headers : (HTTP::Server::Response, String, File::Info -> Void)?
property powered_by_header : Bool = true, app_name
property static_headers : (HTTP::Server::Context, String, File::Info -> Void)?
property? powered_by_header : Bool = true

def initialize
@app_name = "Kemal"
@host_binding = "0.0.0.0"
@port = 3000
@env = ENV["KEMAL_ENV"]? || "development"
@serve_static = {"dir_listing" => false, "gzip" => true}
@serve_static = {"dir_listing" => false, "gzip" => true, "dir_index" => false}
@public_folder = "./public"
@logging = true
@logger = nil
@@ -88,21 +89,34 @@ 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

def setup
unless @default_handlers_setup && @router_included
setup_init_handler
setup_log_handler
setup_head_request_handler
setup_error_handler
setup_static_file_handler
setup_custom_handlers
@@ -129,6 +143,11 @@ module Kemal
@handler_position += 1
end

private def setup_head_request_handler
HANDLERS.insert(@handler_position, Kemal::HeadRequestHandler::INSTANCE)
@handler_position += 1
end

private def setup_error_handler
if @always_rescue
@error_handler ||= Kemal::ExceptionHandler.new
@@ -153,13 +172,13 @@ module Kemal
end

private def setup_filter_handlers
FILTER_HANDLERS.each do |h|
HANDLERS.insert(@handler_position, h)
FILTER_HANDLERS.each do |handler|
HANDLERS.insert(@handler_position, handler)
end
end
end

def self.config
def self.config(&)
yield Config::INSTANCE
end

6 changes: 6 additions & 0 deletions src/kemal/dsl.cr
Original file line number Diff line number Diff line change
@@ -21,10 +21,16 @@ def ws(path : String, &block : HTTP::WebSocket, HTTP::Server::Context -> Void)
Kemal::WebSocketHandler::INSTANCE.add_route path, &block
end

# Defines an error handler to be called when route returns the given HTTP status code
def error(status_code : Int32, &block : HTTP::Server::Context, Exception -> _)
Kemal.config.add_error_handler status_code, &block
end

# Defines an error handler to be called when the given exception is raised
def error(exception : Exception.class, &block : HTTP::Server::Context, Exception -> _)
Kemal.config.add_exception_handler exception, &block
end

# All the helper methods available are:
# - before_all, before_get, before_post, before_put, before_patch, before_delete, before_options
# - after_all, after_get, after_post, after_put, after_patch, after_delete, after_options
29 changes: 29 additions & 0 deletions src/kemal/exception_handler.cr
Original file line number Diff line number Diff line change
@@ -11,12 +11,41 @@ module Kemal
rescue ex : Kemal::Exceptions::CustomException
call_exception_with_status_code(context, ex, context.response.status_code)
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("Exception: #{ex.inspect_with_backtrace}")
# Else use generic 500 handler if defined
return call_exception_with_status_code(context, ex, 500) if Kemal.config.error_handlers.has_key?(500)
verbosity = Kemal.config.env == "production" ? false : true
render_500(context, ex, verbosity)
end

# Calls the given error handler with the current exception
#
# The logic for validating that the current exception should be handled
# by the given error handler should be done by the caller of this method.
private def call_exception_with_exception(
context : HTTP::Server::Context,
exception : Exception,
handler : Proc(HTTP::Server::Context, Exception, String),
status_code : Int32 = 500,
)
return if context.response.closed?

context.response.content_type = "text/html" unless context.response.headers.has_key?("Content-Type")
context.response.status_code = status_code
context.response.print handler.call(context, exception)
context
end

private def call_exception_with_status_code(context : HTTP::Server::Context, exception : Exception, status_code : Int32)
return if context.response.closed?
if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(status_code)
13 changes: 9 additions & 4 deletions src/kemal/ext/context.cr
Original file line number Diff line number Diff line change
@@ -9,18 +9,23 @@ class HTTP::Server
STORE_MAPPINGS = [Nil, String, Int32, Int64, Float64, Bool]

macro finished
alias StoreTypes = Union({{ *STORE_MAPPINGS }})
alias StoreTypes = Union({{ STORE_MAPPINGS.splat }})
@store = {} of String => StoreTypes
end

def params
@params ||= Kemal::ParamParser.new(@request, route_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
end

def redirect(url : String, status_code : Int32 = 302, *, body : String? = nil)
@response.headers.add "Location", url
def redirect(url : String | URI, status_code : Int32 = 302, *, body : String? = nil, close : Bool = true)
@response.headers.add "Location", url.to_s
@response.status_code = status_code
@response.print(body) if body
@response.close if close
end

def route
1 change: 1 addition & 0 deletions src/kemal/filter_handler.cr
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ module Kemal
class FilterHandler
include HTTP::Handler
INSTANCE = new
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
16 changes: 7 additions & 9 deletions src/kemal/handler.cr
Original file line number Diff line number Diff line change
@@ -11,24 +11,22 @@ module Kemal

macro only(paths, method = "GET")
class_name = {{@type.name}}
method_downcase = {{method.downcase}}
class_name_method = "#{class_name}/#{method_downcase}"
class_name_method = "#{class_name}/#{{{method}}}"
({{paths}}).each do |path|
@@only_routes_tree.add class_name_method + path, '/' + method_downcase + path
@@only_routes_tree.add class_name_method + path, '/' + {{method}} + path
end
end

macro exclude(paths, method = "GET")
class_name = {{@type.name}}
method_downcase = {{method.downcase}}
class_name_method = "#{class_name}/#{method_downcase}"
class_name_method = "#{class_name}/#{{{method}}}"
({{paths}}).each do |path|
@@exclude_routes_tree.add class_name_method + path, '/' + method_downcase + path
@@exclude_routes_tree.add class_name_method + path, '/' + {{method}} + path
end
end

def call(env : HTTP::Server::Context)
call_next(env)
def call(context : HTTP::Server::Context)
call_next(context)
end

# Processes the path based on `only` paths which is a `Array(String)`.
@@ -74,7 +72,7 @@ module Kemal
end

private def radix_path(method : String, path : String)
"#{self.class}/#{method.downcase}#{path}"
"#{self.class}/#{method}#{path}"
end
end
end
60 changes: 60 additions & 0 deletions src/kemal/head_request_handler.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
require "http/server/handler"

module Kemal
class HeadRequestHandler
include HTTP::Handler

INSTANCE = new

private class NullIO < IO
@original_output : IO
@out_count : Int32
@response : HTTP::Server::Response

def initialize(@response)
@closed = false
@original_output = @response.output
@out_count = 0
end

def read(slice : Bytes)
raise NotImplementedError.new("read")
end

def write(slice : Bytes) : Nil
@out_count += slice.bytesize
end

def close : Nil
return if @closed
@closed = true

# Matching HTTP::Server::Response#close behavior:
# Conditionally determine based on status if the `content-length` header should be added automatically.
# See https://tools.ietf.org/html/rfc7230#section-3.3.2.
status = @response.status
set_content_length = !(status.not_modified? || status.no_content? || status.informational?)

if !@response.headers.has_key?("Content-Length") && set_content_length
@response.content_length = @out_count
end

@original_output.close
end

def closed? : Bool
@closed
end
end

def call(context) : Nil
if context.request.method == "HEAD"
# Capture and count bytes of response body generated on HEAD requests without actually sending the body back.
capture_io = NullIO.new(context.response)
context.response.output = capture_io
end

call_next(context)
end
end
end
5 changes: 3 additions & 2 deletions src/kemal/helpers/exceptions.cr
Original file line number Diff line number Diff line change
@@ -13,8 +13,9 @@ module Kemal::Exceptions
end

class CustomException < Exception
def initialize(context : HTTP::Server::Context)
super "Rendered error with #{context.response.status_code}"
def initialize(@context : HTTP::Server::Context, message : String? = nil)
message ||= "Rendered error with #{context.response.status_code}"
super message
end
end
end
94 changes: 43 additions & 51 deletions src/kemal/helpers/helpers.cr
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
{% if compare_versions(Crystal::VERSION, "0.35.0-0") >= 0 %}
{% if compare_versions(Crystal::VERSION, "0.35.0-0") >= 0 && !flag?(:without_zlib) %}
require "compress/deflate"
require "compress/gzip"
{% end %}
require "mime"

# Adds given `Kemal::Handler` to handlers chain.
# There are 5 handlers by default and all the custom handlers
# goes between the first 4 and the last `Kemal::RouteHandler`.
# There are 6 handlers by default and all the custom handlers
# goes between the first 5 and the last `Kemal::RouteHandler`.
#
# - `Kemal::InitHandler`
# - `Kemal::LogHandler`
# - `Kemal::HeadRequestHandler`
# - `Kemal::ExceptionHandler`
# - `Kemal::StaticFileHandler`
# - Here goes custom handlers
@@ -48,13 +49,13 @@ end
# This is used to replace the built-in `Kemal::LogHandler` with a custom logger.
#
# A custom logger must inherit from `Kemal::BaseLogHandler` and must implement
# `call(env)`, `write(message)` methods.
# `call(context)`, `write(message)` methods.
#
# ```
# class MyCustomLogger < Kemal::BaseLogHandler
# def call(env)
# def call(context)
# puts "I'm logging some custom stuff here."
# call_next(env) # => This calls the next handler
# call_next(context) # => This calls the next handler
# end
#
# # This is used from `log` method.
@@ -71,7 +72,6 @@ end
# ```
def logger(logger : Kemal::BaseLogHandler)
Kemal.config.logger = logger
Kemal.config.add_handler logger
end

# Enables / Disables static file serving.
@@ -134,40 +134,45 @@ def send_file(env : HTTP::Server::Context, path : String, mime_type : String? =
filestat = File.info(file_path)
attachment(env, filename, disposition)

Kemal.config.static_headers.try(&.call(env.response, file_path, filestat))
Kemal.config.static_headers.try(&.call(env, file_path, filestat))

File.open(file_path) do |file|
if env.request.method == "GET" && env.request.headers.has_key?("Range")
next multipart(file, env)
end

condition = config.is_a?(Hash) && config["gzip"]? == true && filesize > minsize && Kemal::Utils.zip_types(file_path)
if condition && request_headers.includes_word?("Accept-Encoding", "gzip")
env.response.headers["Content-Encoding"] = "gzip"
{% if compare_versions(Crystal::VERSION, "0.35.0-0") >= 0 %}
Compress::Gzip::Writer.open(env.response) do |deflate|
IO.copy(file, deflate)
end
{% else %}
Gzip::Writer.open(env.response) do |deflate|
IO.copy(file, deflate)
end
{% end %}
elsif condition && request_headers.includes_word?("Accept-Encoding", "deflate")
env.response.headers["Content-Encoding"] = "deflate"
{% if compare_versions(Crystal::VERSION, "0.35.0-0") >= 0 %}
Compress::Deflate::Writer.open(env.response) do |deflate|
IO.copy(file, deflate)
end
{% else %}
Flate::Writer.open(env.response) do |deflate|
IO.copy(file, deflate)
end
{% end %}
else
{% if flag?(:without_zlib) %}
env.response.content_length = filesize
IO.copy(file, env.response)
end
{% else %}
condition = config.is_a?(Hash) && config["gzip"]? == true && filesize > minsize && Kemal::Utils.zip_types(file_path)
if condition && request_headers.includes_word?("Accept-Encoding", "gzip")
env.response.headers["Content-Encoding"] = "gzip"
{% if compare_versions(Crystal::VERSION, "0.35.0-0") >= 0 %}
Compress::Gzip::Writer.open(env.response) do |deflate|
IO.copy(file, deflate)
end
{% else %}
Gzip::Writer.open(env.response) do |deflate|
IO.copy(file, deflate)
end
{% end %}
elsif condition && request_headers.includes_word?("Accept-Encoding", "deflate")
env.response.headers["Content-Encoding"] = "deflate"
{% if compare_versions(Crystal::VERSION, "0.35.0-0") >= 0 %}
Compress::Deflate::Writer.open(env.response) do |deflate|
IO.copy(file, deflate)
end
{% else %}
Flate::Writer.open(env.response) do |deflate|
IO.copy(file, deflate)
end
{% end %}
else
env.response.content_length = filesize
IO.copy(file, env.response)
end
{% end %}
end
return
end
@@ -216,20 +221,7 @@ private def multipart(file, env : HTTP::Server::Context)
env.response.headers["Accept-Ranges"] = "bytes"
env.response.headers["Content-Range"] = "bytes #{startb}-#{endb}/#{fileb}" # MUST

if startb > 1024
skipped = 0_i64
# file.skip only accepts values less or equal to 1024 (buffer size, undocumented)
until (increase_skipped = skipped + 1024_i64) > startb
file.skip(1024)
skipped = increase_skipped
end
if (skipped_minus_startb = skipped - startb) > 0
file.skip skipped_minus_startb
end
else
file.skip(startb)
end

file.seek(startb)
IO.copy(file, env.response, content_length)
else
env.response.content_length = fileb
@@ -258,13 +250,13 @@ end
# Adds headers to `Kemal::StaticFileHandler`. This is especially useful for `CORS`.
#
# ```
# static_headers do |response, filepath, filestat|
# static_headers do |env, filepath, filestat|
# if filepath =~ /\.html$/
# response.headers.add("Access-Control-Allow-Origin", "*")
# env.response.headers.add("Access-Control-Allow-Origin", "*")
# end
# response.headers.add("Content-Size", filestat.size.to_s)
# env.response.headers.add("Content-Size", filestat.size.to_s)
# end
# ```
def static_headers(&headers : HTTP::Server::Response, String, File::Info -> Void)
def static_headers(&headers : HTTP::Server::Context, String, File::Info -> Void)
Kemal.config.static_headers = headers
end
29 changes: 16 additions & 13 deletions src/kemal/helpers/macros.cr
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
CONTENT_FOR_BLOCKS = Hash(String, Tuple(String, Proc(String))).new
CONTENT_FOR_BLOCKS = Hash(String, Tuple(String, Proc(Nil))).new

# `content_for` is a set of helpers that allows you to capture
# blocks inside views to be rendered later during the request. The most
@@ -34,13 +34,7 @@ CONTENT_FOR_BLOCKS = Hash(String, Tuple(String, Proc(String))).new
# layout, inside the <head> tag, and each view can call `content_for`
# setting the appropriate set of tags that should be added to the layout.
macro content_for(key, file = __FILE__)
%proc = ->() {
__view_io__ = IO::Memory.new
{{ yield }}
__view_io__.to_s
}

CONTENT_FOR_BLOCKS[{{key}}] = Tuple.new {{file}}, %proc
CONTENT_FOR_BLOCKS[{{key}}] = Tuple.new {{file}}, ->() { {{ yield }} }
nil
end

@@ -49,7 +43,14 @@ macro yield_content(key)
if CONTENT_FOR_BLOCKS.has_key?({{key}})
__caller_filename__ = CONTENT_FOR_BLOCKS[{{key}}][0]
%proc = CONTENT_FOR_BLOCKS[{{key}}][1]
%proc.call if __content_filename__ == __caller_filename__

if __content_filename__ == __caller_filename__
%old_content_io, content_io = content_io, IO::Memory.new
%proc.call
%result = content_io.to_s
content_io = %old_content_io
%result
end
end
end

@@ -60,10 +61,12 @@ end
# ```
macro render(filename, layout)
__content_filename__ = {{filename}}
io = IO::Memory.new
content = ECR.embed {{filename}}, io
ECR.embed {{layout}}, io
io.to_s
content_io = IO::Memory.new
ECR.embed {{filename}}, content_io
content = content_io.to_s
layout_io = IO::Memory.new
ECR.embed {{layout}}, layout_io
layout_io.to_s
end

# Render view with the given filename.
2 changes: 1 addition & 1 deletion src/kemal/helpers/templates.cr
Original file line number Diff line number Diff line change
@@ -26,7 +26,7 @@ def render_500(context, exception, verbosity)
context.response.status_code = 500

template = if verbosity
Kemal::ExceptionPage.for_runtime_exception(context, exception).to_s
Kemal::ExceptionPage.new(context, exception).to_s
else
Kemal::ExceptionPage.for_production_exception
end
5 changes: 4 additions & 1 deletion src/kemal/init_handler.cr
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require "http"

module Kemal
# Initializes the context with default values, such as
# *Content-Type* or *X-Powered-By* headers.
@@ -7,8 +9,9 @@ module Kemal
INSTANCE = new

def call(context : HTTP::Server::Context)
context.response.headers.add "X-Powered-By", "Kemal" if Kemal.config.powered_by_header
context.response.headers.add "X-Powered-By", "Kemal" if Kemal.config.powered_by_header?
context.response.content_type = "text/html" unless context.response.headers.has_key?("Content-Type")
context.response.headers.add "Date", HTTP.format_time(Time.utc)
call_next context
end
end
33 changes: 33 additions & 0 deletions src/kemal/override_method_handler.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
module Kemal
# Adds support for `_method` magic parameter to simulate PUT, PATCH, DELETE requests in an html form.
#
# This middleware is **not** in the default Kemal handlers. You need to explicitly add this to your handlers:
#
# ```ruby
# add_handler Kemal::OverrideMethodHandler
# ```
#
# **Important:** This middleware consumes `params.body` to read the `_method` magic parameter.
class OverrideMethodHandler
include HTTP::Handler
INSTANCE = new

ALLOWED_METHODS = ["PUT", "PATCH", "DELETE"]
OVERRIDE_METHOD = "POST"
OVERRIDE_METHOD_PARAM_KEY = "_method"

def call(context)
request = context.request
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
end
end
call_next(context)
end

private def override_method_valid?(override_method : String)
ALLOWED_METHODS.includes?(override_method.upcase)
end
end
end
13 changes: 10 additions & 3 deletions src/kemal/param_parser.cr
Original file line number Diff line number Diff line change
@@ -9,13 +9,14 @@ module Kemal
PARTS = %w(url query body json files)
# :nodoc:
alias AllParamTypes = Nil | String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any)
getter files
getter files, all_files

def initialize(@request : HTTP::Request, @url : Hash(String, String) = {} of String => String)
@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
@@ -71,11 +72,17 @@ module Kemal
next unless upload

filename = upload.filename
name = upload.name

if !filename.nil?
@files[upload.name] = FileUpload.new(upload)
if name.ends_with?("[]")
@all_files[name] ||= [] of FileUpload
@all_files[name] << FileUpload.new(upload)
else
@files[name] = FileUpload.new(upload)
end
else
@body.add(upload.name, upload.body.gets_to_end)
@body.add(name, upload.body.gets_to_end)
end
end

11 changes: 7 additions & 4 deletions src/kemal/route_handler.cr
Original file line number Diff line number Diff line change
@@ -17,11 +17,9 @@ module Kemal
process_request(context)
end

# Adds a given route to routing tree. As an exception each `GET` route additionaly defines
# a corresponding `HEAD` route.
# Adds a given route to routing tree.
def add_route(method : String, path : String, &handler : HTTP::Server::Context -> _)
add_to_radix_tree method, path, Route.new(method, path, &handler)
add_to_radix_tree("HEAD", path, Route.new("HEAD", path) { }) if method == "GET"
end

# Looks up the route from the Radix::Tree for the first time and caches to improve performance.
@@ -34,6 +32,11 @@ module Kemal

route = @routes.find(lookup_path)

if verb == "HEAD" && !route.found?
# On HEAD requests, implicitly fallback to running the GET handler.
route = @routes.find(radix_path("GET", path))
end

if route.found?
@cached_routes.clear if @cached_routes.size == CACHED_ROUTES_LIMIT
@cached_routes[lookup_path] = route
@@ -57,7 +60,7 @@ module Kemal
end

private def radix_path(method, path)
'/' + method.downcase + path
'/' + method + path
end

private def add_to_radix_tree(method, path, route)
27 changes: 22 additions & 5 deletions src/kemal/static_file_handler.cr
Original file line number Diff line number Diff line change
@@ -27,7 +27,7 @@ module Kemal
return
end

expanded_path = File.expand_path(request_path, "/")
expanded_path = request_path
is_dir_path = if original_path.ends_with?('/') && !expanded_path.ends_with? '/'
expanded_path = expanded_path + '/'
true
@@ -36,17 +36,30 @@ module Kemal
end

file_path = File.join(@public_dir, expanded_path)
is_dir = Dir.exists? file_path
is_dir = Dir.exists?(file_path)

if request_path != expanded_path
redirect_to context, expanded_path
return
elsif is_dir && !is_dir_path
redirect_to context, expanded_path + '/'
return
end

if Dir.exists?(file_path)
if config.is_a?(Hash) && config["dir_listing"] == true
context.response.content_type = "text/html"
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")

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)
@@ -64,5 +77,9 @@ module Kemal
call_next(context)
end
end

private def modification_time(file_path)
File.info(file_path).modification_time
end
end
end