shard: track dependencies

Commit the whole ./lib/ folder which stores the Crystal dependencies.
This has a few benefits:

- Allows to build the project without a connection to the Internet
  to retrieve dependencies.
- Makes the project resistant against dependency re-tags which might
  include malicious code.
This commit is contained in:
Leon Klingele 2019-08-14 23:53:00 +02:00
parent dcff1ec25f
commit 40fb17791e
No known key found for this signature in database
GPG Key ID: 0C8AF48831EEC211
238 changed files with 17164 additions and 8 deletions

2
.gitignore vendored
View File

@ -1,9 +1,7 @@
/doc/
/dev/
/lib/
/bin/
/.shards/
/.vscode/
/invidious
/sentry
shard.lock

View File

@ -5,9 +5,6 @@ jobs:
- stage: build
language: crystal
crystal: latest
before_install:
- shards update
- shards install
install:
- crystal build --error-on-warnings src/invidious.cr
script:

View File

@ -124,7 +124,6 @@ $ exit
```bash
$ sudo -i -u invidious
$ cd invidious
$ shards update && shards install
$ crystal build src/invidious.cr --release
# test compiled binary
$ ./invidious # stop with ctrl c
@ -161,7 +160,6 @@ $ psql invidious kemal < config/sql/nonces.sql
$ psql invidious kemal < config/sql/annotations.sql
# Setup Invidious
$ shards update && shards install
$ crystal build src/invidious.cr --release
```

View File

@ -3,7 +3,8 @@ RUN apk add -u crystal shards libc-dev \
yaml-dev libxml2-dev sqlite-dev sqlite-static zlib-dev openssl-dev
WORKDIR /invidious
COPY ./shard.yml ./shard.yml
RUN shards update && shards install
COPY ./shard.lock ./shard.lock
COPY ./lib/ ./lib/
COPY ./src/ ./src/
# TODO: .git folder is required for building this is destructive.
# See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION.

10
lib/db/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
/docs/
/lib/
/bin/
/.shards/
*.dwarf
# Libraries don't need dependency lock
# Dependencies will be locked in application that uses them
/shard.lock

7
lib/db/.travis.yml Normal file
View File

@ -0,0 +1,7 @@
language: crystal
crystal:
- latest
- nightly
script:
- crystal spec
- crystal tool format --check

93
lib/db/CHANGELOG.md Normal file
View File

@ -0,0 +1,93 @@
## v0.6.0 (2019-08-02)
* Fix compatibility issues for Crystal 0.30.0. ([#108](https://github.com/crystal-lang/crystal-db/pull/108), thanks @bcardiff)
* Fix `BeginTransaction#transaction` rollback due to protocol error. ([#101](https://github.com/crystal-lang/crystal-db/pull/101), thanks @straight-shoota)
* CI includes Crystal nightly. ([#106](https://github.com/crystal-lang/crystal-db/pull/106), thanks @bcardiff)
* Add the Cassandra driver. ([#94](https://github.com/crystal-lang/crystal-db/pull/94), thanks @kaukas)
* Several docs improvements. ([#99](https://github.com/crystal-lang/crystal-db/pull/99), [#96](https://github.com/crystal-lang/crystal-db/pull/96), [#107](https://github.com/crystal-lang/crystal-db/pull/107), thanks @nickelghost, @greenbigfrog, @MatthiasWinkelmann)
## v0.5.1 (2018-11-07)
* Fix `QueryMethods#query_one?` handling no rows. ([#86](https://github.com/crystal-lang/crystal-db/pull/86), thanks @robdavid)
* Documentation improvements. ([#87](https://github.com/crystal-lang/crystal-db/pull/87), [#82](https://github.com/crystal-lang/crystal-db/pull/82), [#76](https://github.com/crystal-lang/crystal-db/pull/76), thanks @wontruefree, @Heaven31415, @vtambourine)
## v0.5.0 (2017-12-29)
* Fix compatibility issues for crystal 0.24.0. No changes in the api.
## v0.4.4 (2017-12-29)
* Allow query results to be read as named tuples directly (see [#56](https://github.com/crystal-lang/crystal-db/pull/56), thanks @Nephos)
* Fix sqlite samples in documentation (see [#71](https://github.com/crystal-lang/crystal-db/pull/71), thanks @hinrik)
## v0.4.3 (2017-11-07)
* Fix connections were not released when building invalid statements. (see [#65](https://github.com/crystal-lang/crystal-db/pull/65), thanks @crisward)
* Fix some exceptions were not deriving from `DB::Error`. (see [#70](https://github.com/crystal-lang/crystal-db/pull/70), thanks @exts)
## v0.4.2 (2017-04-21)
* Fix compatibility issues for crystal 0.22.0
## v0.4.1 (2017-04-10)
* Add spec helper for drivers. [#48](https://github.com/crystal-lang/crystal-db/pull/48)
* Add `#query_each`. [#18](https://github.com/crystal-lang/crystal-db/issues/18)
* Fix `#read(T.class)` to deal better with unhandled types.
## v0.4.0 (2017-03-20)
* Add `DB.connect` to create non pooled connections
* Add `Database#checkout` to allow explicit checkout/release connection (see #38)
* Fix `Mapping.from_rs` closes the result_set
* Fix `Mapping` works with nilable types (see #40, thanks @RX14)
## v0.3.3 (2016-12-24)
* Fix compatibility issues for crystal 0.20.3
## v0.3.2 (2016-12-16)
* Allow connection pool retry logic in `#scalar` queries.
## v0.3.1 (2016-12-15)
* Add ConnectionRefused exception to flag issues when opening new connections.
## v0.3.0 (2016-12-14)
* Add support for non prepared statements. [#25](https://github.com/crystal-lang/crystal-db/pull/25)
* Add support for transactions & nested transactions. [#27](https://github.com/crystal-lang/crystal-db/pull/27)
* Add `Bool` and `Time` to `DB::Any`.
## v0.2.2 (2016-12-06)
This release requires crystal 0.20.1
* Changed default connection pool size limit is now 0 (unlimited).
* Fixed allow new connections right away if pool can be increased.
## ~~v0.2.1 (2016-12-06)~~ [YANKED]
## v0.2.0 (2016-10-20)
* Fixed release DB connection if an exception occurs during execution of a query (thanks @ggiraldez)
## ~~v0.1.1 (2016-09-28)~~ [YANKED]
This release requires crystal 0.19.2
Note: v0.1.1 is yanked since is incompatible with v0.1.0 [more](https://github.com/crystal-lang/crystal-mysql/issues/10).
* Added connection pool. `DB.open` works with a underlying connection pool. Use `Database#using_connection` to ensure the same connection is been used across multiple statements. [more](https://github.com/crystal-lang/crystal-db/pull/12)
* Added mappings. JSON/YAML-like mapping macros (thanks @spalladino) [more](https://github.com/crystal-lang/crystal-db/pull/2)
* Changed require ResultSet implementors to just implement `read`, optionally implementing `read(T.class)`.
## v0.1.0 (2016-06-24)
* Initial release

21
lib/db/LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Brian J. Cardiff
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

97
lib/db/README.md Normal file
View File

@ -0,0 +1,97 @@
[![Build Status](https://travis-ci.org/crystal-lang/crystal-db.svg?branch=master)](https://travis-ci.org/crystal-lang/crystal-db)
# crystal-db
Common db api for crystal. You will need to have a specific driver to access a database.
* [SQLite](https://github.com/crystal-lang/crystal-sqlite3)
* [MySQL](https://github.com/crystal-lang/crystal-mysql)
* [PostgreSQL](https://github.com/will/crystal-pg)
* [Cassandra](https://github.com/kaukas/crystal-cassandra)
## Installation
If you are creating a shard that will work with _any_ driver, then add `crystal-db` as a dependency in `shard.yml`:
```yaml
dependencies:
db:
github: crystal-lang/crystal-db
```
If you are creating an application that will work with _some specific_ driver(s), then add them in `shard.yml`:
```yaml
dependencies:
sqlite3:
github: crystal-lang/crystal-sqlite3
```
`crystal-db` itself will be a nested dependency if drivers are included.
Note: Multiple drivers can be included in the same application.
## Documentation
* [Latest API](http://crystal-lang.github.io/crystal-db/api/latest/)
* [Crystal book](https://crystal-lang.org/docs/database/)
## Usage
This shard only provides an abstract database API. In order to use it, a specific driver for the intended database has to be required as well:
The following example uses SQLite where `?` indicates the arguments. If PostgreSQL is used `$1`, `$2`, etc. should be used. `crystal-db` does not interpret the statements.
```crystal
require "db"
require "sqlite3"
DB.open "sqlite3:./file.db" do |db|
# When using the pg driver, use $1, $2, etc. instead of ?
db.exec "create table contacts (name text, age integer)"
db.exec "insert into contacts values (?, ?)", "John Doe", 30
args = [] of DB::Any
args << "Sarah"
args << 33
db.exec "insert into contacts values (?, ?)", args
puts "max age:"
puts db.scalar "select max(age) from contacts" # => 33
puts "contacts:"
db.query "select name, age from contacts order by age desc" do |rs|
puts "#{rs.column_name(0)} (#{rs.column_name(1)})"
# => name (age)
rs.each do
puts "#{rs.read(String)} (#{rs.read(Int32)})"
# => Sarah (33)
# => John Doe (30)
end
end
end
```
## Roadmap
Issues not yet addressed:
- [x] Support non prepared statements. [#25](https://github.com/crystal-lang/crystal-db/pull/25)
- [x] Time data type. (implementation details depends on actual drivers)
- [x] Data type extensibility. Allow each driver to extend the data types allowed.
- [x] Transactions & nested transactions. [#27](https://github.com/crystal-lang/crystal-db/pull/27)
- [x] Connection pool.
- [ ] Logging
- [ ] Direct access to `IO` to avoid memory allocation for blobs.
## Contributing
1. Fork it ( https://github.com/crystal-lang/crystal-db/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
## Contributors
- [bcardiff](https://github.com/bcardiff) Brian J. Cardiff - creator, maintainer

9
lib/db/shard.yml Normal file
View File

@ -0,0 +1,9 @@
name: db
version: 0.6.0
authors:
- Brian J. Cardiff <bcardiff@manas.tech>
crystal: 0.24.0
license: MIT

View File

@ -0,0 +1,293 @@
require "./spec_helper"
module GenericResultSet
@index = 0
def move_next : Bool
@index = 0
true
end
def column_count : Int32
@row.size
end
def column_name(index : Int32) : String
index.to_s
end
def read
@index += 1
@row[@index - 1]
end
end
class FooValue
def initialize(@value : Int32)
end
def value
@value
end
end
class FooDriver < DB::Driver
alias Any = DB::Any | FooValue
@@row = [] of Any
def self.fake_row=(row : Array(Any))
@@row = row
end
def self.fake_row
@@row
end
def build_connection(context : DB::ConnectionContext) : DB::Connection
FooConnection.new(context)
end
class FooConnection < DB::Connection
def build_prepared_statement(query) : DB::Statement
FooStatement.new(self)
end
def build_unprepared_statement(query) : DB::Statement
raise "not implemented"
end
end
class FooStatement < DB::Statement
protected def perform_query(args : Enumerable) : DB::ResultSet
args.each { |arg| process_arg arg }
FooResultSet.new(self, FooDriver.fake_row)
end
protected def perform_exec(args : Enumerable) : DB::ExecResult
args.each { |arg| process_arg arg }
DB::ExecResult.new 0i64, 0i64
end
private def process_arg(value : FooDriver::Any)
end
private def process_arg(value)
raise "#{self.class} does not support #{value.class} params"
end
end
class FooResultSet < DB::ResultSet
include GenericResultSet
def initialize(statement, @row : Array(FooDriver::Any))
super(statement)
end
end
end
DB.register_driver "foo", FooDriver
class BarValue
getter value
def initialize(@value : Int32)
end
end
class BarDriver < DB::Driver
alias Any = DB::Any | BarValue
@@row = [] of Any
def self.fake_row=(row : Array(Any))
@@row = row
end
def self.fake_row
@@row
end
def build_connection(context : DB::ConnectionContext) : DB::Connection
BarConnection.new(context)
end
class BarConnection < DB::Connection
def build_prepared_statement(query) : DB::Statement
BarStatement.new(self)
end
def build_unprepared_statement(query) : DB::Statement
raise "not implemented"
end
end
class BarStatement < DB::Statement
protected def perform_query(args : Enumerable) : DB::ResultSet
args.each { |arg| process_arg arg }
BarResultSet.new(self, BarDriver.fake_row)
end
protected def perform_exec(args : Enumerable) : DB::ExecResult
args.each { |arg| process_arg arg }
DB::ExecResult.new 0i64, 0i64
end
private def process_arg(value : BarDriver::Any)
end
private def process_arg(value)
raise "#{self.class} does not support #{value.class} params"
end
end
class BarResultSet < DB::ResultSet
include GenericResultSet
def initialize(statement, @row : Array(BarDriver::Any))
super(statement)
end
end
end
DB.register_driver "bar", BarDriver
describe DB do
it "should be able to register multiple drivers" do
DB.open("foo://host").driver.should be_a(FooDriver)
DB.open("bar://host").driver.should be_a(BarDriver)
end
it "Foo and Bar drivers should return fake_row" do
with_witness do |w|
DB.open("foo://host") do |db|
FooDriver.fake_row = [1, "string", FooValue.new(3)] of FooDriver::Any
db.query "query" do |rs|
w.check
rs.move_next
rs.read(Int32).should eq(1)
rs.read(String).should eq("string")
rs.read(FooValue).value.should eq(3)
end
end
end
with_witness do |w|
DB.open("bar://host") do |db|
BarDriver.fake_row = [BarValue.new(4), "lorem", 1.0] of BarDriver::Any
db.query "query" do |rs|
w.check
rs.move_next
rs.read(BarValue).value.should eq(4)
rs.read(String).should eq("lorem")
rs.read(Float64).should eq(1.0)
end
end
end
end
it "drivers should return custom values as scalar" do
DB.open("foo://host") do |db|
FooDriver.fake_row = [FooValue.new(3)] of FooDriver::Any
db.scalar("query").as(FooValue).value.should eq(3)
end
end
it "Foo and Bar drivers should not implement each other read" do
with_witness do |w|
DB.open("foo://host") do |db|
FooDriver.fake_row = [1] of FooDriver::Any
db.query "query" do |rs|
rs.move_next
expect_raises(Exception, "FooResultSet#read returned a Int32. A BarValue was expected.") do
w.check
rs.read(BarValue)
end
end
end
end
with_witness do |w|
DB.open("bar://host") do |db|
BarDriver.fake_row = [1] of BarDriver::Any
db.query "query" do |rs|
rs.move_next
expect_raises(Exception, "BarResultSet#read returned a Int32. A FooValue was expected.") do
w.check
rs.read(FooValue)
end
end
end
end
end
it "allow custom types to be used as arguments for query" do
DB.open("foo://host") do |db|
FooDriver.fake_row = [1, "string"] of FooDriver::Any
db.query "query" { }
db.query "query", 1 { }
db.query "query", 1, "string" { }
db.query("query", Bytes.new(4)) { }
db.query("query", 1, "string", FooValue.new(5)) { }
db.query "query", [1, "string", FooValue.new(5)] { }
db.query("query").close
db.query("query", 1).close
db.query("query", 1, "string").close
db.query("query", Bytes.new(4)).close
db.query("query", 1, "string", FooValue.new(5)).close
db.query("query", [1, "string", FooValue.new(5)]).close
end
DB.open("bar://host") do |db|
BarDriver.fake_row = [1, "string"] of BarDriver::Any
db.query "query" { }
db.query "query", 1 { }
db.query "query", 1, "string" { }
db.query("query", Bytes.new(4)) { }
db.query("query", 1, "string", BarValue.new(5)) { }
db.query "query", [1, "string", BarValue.new(5)] { }
db.query("query").close
db.query("query", 1).close
db.query("query", 1, "string").close
db.query("query", Bytes.new(4)).close
db.query("query", 1, "string", BarValue.new(5)).close
db.query("query", [1, "string", BarValue.new(5)]).close
end
end
it "allow custom types to be used as arguments for exec" do
DB.open("foo://host") do |db|
FooDriver.fake_row = [1, "string"] of FooDriver::Any
db.exec("query")
db.exec("query", 1)
db.exec("query", 1, "string")
db.exec("query", Bytes.new(4))
db.exec("query", 1, "string", FooValue.new(5))
db.exec("query", [1, "string", FooValue.new(5)])
end
DB.open("bar://host") do |db|
BarDriver.fake_row = [1, "string"] of BarDriver::Any
db.exec("query")
db.exec("query", 1)
db.exec("query", 1, "string")
db.exec("query", Bytes.new(4))
db.exec("query", 1, "string", BarValue.new(5))
db.exec("query", [1, "string", BarValue.new(5)])
end
end
it "Foo and Bar drivers should not implement each other params" do
DB.open("foo://host") do |db|
expect_raises Exception, "FooDriver::FooStatement does not support BarValue params" do
db.exec("query", [BarValue.new(5)])
end
end
DB.open("bar://host") do |db|
expect_raises Exception, "BarDriver::BarStatement does not support FooValue params" do
db.exec("query", [FooValue.new(5)])
end
end
end
end

View File

@ -0,0 +1,255 @@
require "./spec_helper"
describe DB::Database do
it "allows connection initialization" do
cnn_setup = 0
DB.open "dummy://localhost:1027?initial_pool_size=2&max_pool_size=4&max_idle_pool_size=1" do |db|
cnn_setup.should eq(0)
db.setup_connection do |cnn|
cnn_setup += 1
end
cnn_setup.should eq(2)
db.using_connection do
cnn_setup.should eq(2)
db.using_connection do
cnn_setup.should eq(2)
db.using_connection do
cnn_setup.should eq(3)
db.using_connection do
cnn_setup.should eq(4)
end
# the pool didn't shrink no new initialization should be done next
db.using_connection do
cnn_setup.should eq(4)
end
end
# the pool shrink 1. max_idle_pool_size=1
# after the previous end there where 2.
db.using_connection do
cnn_setup.should eq(4)
# so now there will be a new connection created
db.using_connection do
cnn_setup.should eq(5)
end
end
end
end
end
end
it "should allow creation of more statements than pool connections" do
DB.open "dummy://localhost:1027?initial_pool_size=1&max_pool_size=2" do |db|
db.build("query1").should be_a(DB::PoolPreparedStatement)
db.build("query2").should be_a(DB::PoolPreparedStatement)
db.build("query3").should be_a(DB::PoolPreparedStatement)
end
end
it "should return same statement in pool per query" do
with_dummy do |db|
stmt = db.build("query1")
db.build("query2").should_not eq(stmt)
db.build("query1").should eq(stmt)
end
end
it "should close pool statements when closing db" do
stmt = uninitialized DB::PoolStatement
with_dummy do |db|
stmt = db.build("query1")
end
stmt.closed?.should be_true
end
it "should not reconnect if connection is lost and retry_attempts=0" do
DummyDriver::DummyConnection.clear_connections
DB.open "dummy://localhost:1027?initial_pool_size=1&max_pool_size=1&retry_attempts=0" do |db|
db.exec("stmt1")
DummyDriver::DummyConnection.connections.size.should eq(1)
DummyDriver::DummyConnection.connections.first.disconnect!
expect_raises DB::PoolRetryAttemptsExceeded do
db.exec("stmt1")
end
DummyDriver::DummyConnection.connections.size.should eq(1)
end
end
it "should reconnect if connection is lost and executing same statement" do
DummyDriver::DummyConnection.clear_connections
DB.open "dummy://localhost:1027?initial_pool_size=1&max_pool_size=1&retry_attempts=1" do |db|
db.exec("stmt1")
DummyDriver::DummyConnection.connections.size.should eq(1)
DummyDriver::DummyConnection.connections.first.disconnect!
db.exec("stmt1")
DummyDriver::DummyConnection.connections.size.should eq(2)
end
end
it "should allow new connections if pool can increased and retry is not allowed" do
DummyDriver::DummyConnection.clear_connections
DB.open "dummy://localhost:1027?initial_pool_size=1&max_pool_size=2&retry_attempts=0" do |db|
db.query("stmt1")
DummyDriver::DummyConnection.connections.size.should eq(1)
db.query("stmt1")
DummyDriver::DummyConnection.connections.size.should eq(2)
end
end
it "should not return connection to pool if checkout explicitly" do
DummyDriver::DummyConnection.clear_connections
DB.open "dummy://localhost:1027?initial_pool_size=1&max_pool_size=1&retry_attempts=0" do |db|
the_cnn = uninitialized DB::Connection
db.using_connection do |cnn|
the_cnn = cnn
db.pool.is_available?(cnn).should be_false
3.times do
cnn.exec("stmt1")
db.pool.is_available?(cnn).should be_false
end
end
db.pool.is_available?(the_cnn).should be_true
end
end
it "should checkout different connections until they are released" do
DummyDriver::DummyConnection.clear_connections
DB.open "dummy://localhost:1027?initial_pool_size=1&max_pool_size=2&retry_attempts=0" do |db|
the_first_cnn = uninitialized DB::Connection
the_second_cnn = uninitialized DB::Connection
the_first_cnn = db.checkout
the_second_cnn = db.checkout
the_second_cnn.should_not eq(the_first_cnn)
db.pool.is_available?(the_first_cnn).should be_false
db.pool.is_available?(the_second_cnn).should be_false
the_first_cnn.release
db.pool.is_available?(the_first_cnn).should be_true
db.pool.is_available?(the_second_cnn).should be_false
db.checkout.should eq(the_first_cnn)
the_first_cnn.release
the_second_cnn.release
end
end
it "should not return explicit checked out connections to the pool after query" do
DummyDriver::DummyConnection.clear_connections
DB.open "dummy://localhost:1027?initial_pool_size=1&max_pool_size=2&retry_attempts=0" do |db|
cnn = db.checkout
cnn.query_all("1", as: String)
db.pool.is_available?(cnn).should be_false
cnn.release
db.pool.is_available?(cnn).should be_true
end
end
it "should return connection to the pool if prepared statement is unable to be built" do
connection = uninitialized DB::Connection
with_dummy "dummy://localhost:1027?initial_pool_size=1" do |db|
connection = DummyDriver::DummyConnection.connections.first
expect_raises DB::Error do
db.prepared.exec("syntax error")
end
db.pool.is_available?(connection).should be_true
end
end
it "should return connection to the pool if unprepared statement is unable to be built" do
connection = uninitialized DB::Connection
with_dummy "dummy://localhost:1027?initial_pool_size=1" do |db|
connection = DummyDriver::DummyConnection.connections.first
expect_raises DB::Error do
db.unprepared.exec("syntax error")
end
db.pool.is_available?(connection).should be_true
end
end
describe "prepared_statements connection option" do
it "defaults to true" do
with_dummy "dummy://localhost:1027" do |db|
db.prepared_statements?.should be_true
end
end
it "can be set to false" do
with_dummy "dummy://localhost:1027?prepared_statements=false" do |db|
db.prepared_statements?.should be_false
end
end
it "is copied to connections (false)" do
with_dummy "dummy://localhost:1027?prepared_statements=false&initial_pool_size=1" do |db|
connection = DummyDriver::DummyConnection.connections.first
connection.prepared_statements?.should be_false
end
end
it "is copied to connections (true)" do
with_dummy "dummy://localhost:1027?prepared_statements=true&initial_pool_size=1" do |db|
connection = DummyDriver::DummyConnection.connections.first
connection.prepared_statements?.should be_true
end
end
it "should build prepared statements if true" do
with_dummy "dummy://localhost:1027?prepared_statements=true" do |db|
db.build("the query").should be_a(DB::PoolPreparedStatement)
end
end
it "should build unprepared statements if false" do
with_dummy "dummy://localhost:1027?prepared_statements=false" do |db|
db.build("the query").should be_a(DB::PoolUnpreparedStatement)
end
end
it "should be overrided by dsl" do
with_dummy "dummy://localhost:1027?prepared_statements=true" do |db|
stmt = db.unprepared.query("the query").statement.as(DummyDriver::DummyStatement)
stmt.prepared?.should be_false
end
with_dummy "dummy://localhost:1027?prepared_statements=false" do |db|
stmt = db.prepared.query("the query").statement.as(DummyDriver::DummyStatement)
stmt.prepared?.should be_true
end
end
end
describe "unprepared statements in pool" do
it "creating statements should not create new connections" do
with_dummy "dummy://localhost:1027?initial_pool_size=1" do |db|
stmt1 = db.unprepared.build("query1")
stmt2 = db.unprepared.build("query2")
DummyDriver::DummyConnection.connections.size.should eq(1)
end
end
it "simultaneous statements should go to different connections" do
with_dummy "dummy://localhost:1027?initial_pool_size=1" do |db|
rs1 = db.unprepared.query("query1")
rs2 = db.unprepared.query("query2")
rs1.statement.connection.should_not eq(rs2.statement.connection)
DummyDriver::DummyConnection.connections.size.should eq(2)
end
end
it "sequential statements should go to different connections" do
with_dummy "dummy://localhost:1027?initial_pool_size=1" do |db|
rs1 = db.unprepared.query("query1")
rs1.close
rs2 = db.unprepared.query("query2")
rs2.close
rs1.statement.connection.should eq(rs2.statement.connection)
DummyDriver::DummyConnection.connections.size.should eq(1)
end
end
end
end

134
lib/db/spec/db_spec.cr Normal file
View File

@ -0,0 +1,134 @@
require "./spec_helper"
private def connections
DummyDriver::DummyConnection.connections
end
describe DB do
it "should get driver class by name" do
DB.driver_class("dummy").should eq(DummyDriver)
end
it "should instantiate driver with connection uri" do
db = DB.open "dummy://localhost:1027"
db.driver.should be_a(DummyDriver)
db.uri.scheme.should eq("dummy")
db.uri.host.should eq("localhost")
db.uri.port.should eq(1027)
end
it "should create a connection and close it" do
DummyDriver::DummyConnection.clear_connections
DB.open "dummy://localhost" do |db|
end
connections.size.should eq(1)
connections.first.closed?.should be_true
end
it "should create a connection and close it" do
DummyDriver::DummyConnection.clear_connections
DB.connect "dummy://localhost" do |cnn|
cnn.should be_a(DummyDriver::DummyConnection)
end
connections.size.should eq(1)
connections.first.closed?.should be_true
end
it "should create a connection and wait for explicit closing" do
DummyDriver::DummyConnection.clear_connections
cnn = DB.connect "dummy://localhost"
cnn.should be_a(DummyDriver::DummyConnection)
connections.size.should eq(1)
connections.first.closed?.should be_false
cnn.close
connections.first.closed?.should be_true
end
it "query should close result_set" do
with_witness do |w|
with_dummy do |db|
db.query "1,2" do
break
end
w.check
DummyDriver::DummyResultSet.last_result_set.closed?.should be_true
end
end
end
it "scalar should close statement" do
with_dummy do |db|
db.scalar "1"
DummyDriver::DummyResultSet.last_result_set.closed?.should be_true
end
end
it "initially a single connection should be created" do
with_dummy do |db|
connections.size.should eq(1)
end
end
it "the connection should be closed after db usage" do
with_dummy do |db|
connections.first.closed?.should be_false
end
connections.first.closed?.should be_true
end
it "should raise if the sole connection is been used" do
with_dummy "dummy://host?max_pool_size=1&checkout_timeout=0.5" do |db|
db.query "1" do |rs|
expect_raises DB::PoolTimeout do
db.scalar "2"
end
end
end
end
it "should use 'unlimited' connections by default" do
with_dummy "dummy://host?checkout_timeout=0.5" do |db|
rs = [] of DB::ResultSet
500.times do
rs << db.query "1"
end
DummyDriver::DummyConnection.connections.size.should eq(500)
end
end
it "exec should return to pool" do
with_dummy do |db|
db.exec "foo"
db.exec "bar"
end
end
it "scalar should return to pool" do
with_dummy do |db|
db.scalar "foo"
db.scalar "bar"
end
end
it "gives nice error message when no driver is registered for schema (#21)" do
expect_raises(ArgumentError, %(no driver was registered for the schema "foobar", did you maybe forget to require the database driver?)) do
DB.open "foobar://baz"
end
end
it "should parse boolean query string params" do
DB.fetch_bool(HTTP::Params.parse("foo=true"), "foo", false).should be_true
DB.fetch_bool(HTTP::Params.parse("foo=True"), "foo", false).should be_true
DB.fetch_bool(HTTP::Params.parse("foo=false"), "foo", true).should be_false
DB.fetch_bool(HTTP::Params.parse("foo=False"), "foo", true).should be_false
DB.fetch_bool(HTTP::Params.parse("bar=true"), "foo", false).should be_false
DB.fetch_bool(HTTP::Params.parse("bar=true"), "foo", true).should be_true
expect_raises(ArgumentError, %(invalid "other" value for option "foo")) do
DB.fetch_bool(HTTP::Params.parse("foo=other"), "foo", true)
end
end
end

View File

@ -0,0 +1,31 @@
require "./spec_helper"
class ADisposable
include DB::Disposable
@raise = false
property raise
protected def do_close
raise "Unable to close" if @raise
end
end
describe DB::Disposable do
it "should mark as closed if able to close" do
obj = ADisposable.new
obj.closed?.should be_false
obj.close
obj.closed?.should be_true
end
it "should not mark as closed if unable to close" do
obj = ADisposable.new
obj.raise = true
obj.closed?.should be_false
expect_raises Exception do
obj.close
end
obj.closed?.should be_false
end
end

268
lib/db/spec/dummy_driver.cr Normal file
View File

@ -0,0 +1,268 @@
require "spec"
require "../src/db"
class DummyDriver < DB::Driver
def build_connection(context : DB::ConnectionContext) : DB::Connection
DummyConnection.new(context)
end
class DummyConnection < DB::Connection
def initialize(context)
super(context)
@connected = true
@@connections ||= [] of DummyConnection
@@connections.not_nil! << self
end
def self.connections
@@connections.not_nil!
end
def self.clear_connections
@@connections.try &.clear
end
def build_prepared_statement(query) : DB::Statement
DummyStatement.new(self, query, true)
end
def build_unprepared_statement(query) : DB::Statement
DummyStatement.new(self, query, false)
end
def last_insert_id : Int64
0
end
def check
raise DB::ConnectionLost.new(self) unless @connected
end
def disconnect!
@connected = false
end
def create_transaction
DummyTransaction.new(self)
end
protected def do_close
super
end
end
class DummyTransaction < DB::TopLevelTransaction
getter committed = false
getter rolledback = false
def initialize(connection)
super(connection)
end
def commit
super
@committed = true
end
def rollback
super
@rolledback = true
end
protected def create_save_point_transaction(parent, savepoint_name : String)
DummySavePointTransaction.new(parent, savepoint_name)
end
end
class DummySavePointTransaction < DB::SavePointTransaction
getter committed = false
getter rolledback = false
def initialize(parent, savepoint_name)
super(parent, savepoint_name)
end
def commit
super
@committed = true
end
def rollback
super
@rolledback = true
end
end
class DummyStatement < DB::Statement
property params
def initialize(connection, @query : String, @prepared : Bool)
@params = Hash(Int32 | String, DB::Any).new
super(connection)
raise DB::Error.new(query) if query == "syntax error"
end
protected def perform_query(args : Enumerable) : DB::ResultSet
@connection.as(DummyConnection).check
set_params args
DummyResultSet.new self, @query
end
protected def perform_exec(args : Enumerable) : DB::ExecResult
@connection.as(DummyConnection).check
set_params args
raise DB::Error.new("forced exception due to query") if @query == "raise"
DB::ExecResult.new 0i64, 0_i64
end
private def set_params(args)
@params.clear
args.each_with_index do |arg, index|
set_param(index, arg)
end
end
private def set_param(index, value : DB::Any)
@params[index] = value
end
private def set_param(index, value)
raise "not implemented for #{value.class}"
end
def prepared?
@prepared
end
protected def do_close
super
end
end
class DummyResultSet < DB::ResultSet
@top_values : Array(Array(String))
@values : Array(String)?
@@last_result_set : self?
def initialize(statement, query)
super(statement)
@top_values = query.split.map { |r| r.split(',') }.to_a
@column_count = @top_values.size > 0 ? @top_values[0].size : 2
@@last_result_set = self
end
protected def do_close
super
end
def self.last_result_set
@@last_result_set.not_nil!
end
def move_next : Bool
@values = @top_values.shift?
!!@values
end
def column_count : Int32
@column_count
end
def column_name(index) : String
"c#{index}"
end
def read
n = @values.not_nil!.shift?
raise "end of row" if n.is_a?(Nil)
return nil if n == "NULL"
if n == "?"
return (@statement.as(DummyStatement)).params[0]
end
return n
end
def read(t : String.class)
read.to_s
end
def read(t : String?.class)
read.try &.to_s
end
def read(t : Int32.class)
read(String).to_i32
end
def read(t : Int32?.class)
read(String?).try &.to_i32
end
def read(t : Int64.class)
read(String).to_i64
end
def read(t : Int64?.class)
read(String?).try &.to_i64
end
def read(t : Float32.class)
read(String).to_f32
end
def read(t : Float64.class)
read(String).to_f64
end
def read(t : Bytes.class)
case value = read
when String
ary = value.bytes
Slice.new(ary.to_unsafe, ary.size)
when Bytes
value
else
raise "#{value} is not convertible to Bytes"
end
end
end
end
DB.register_driver "dummy", DummyDriver
class Witness
getter count
def initialize(@count = 1)
end
def check
@count -= 1
end
end
def with_witness(count = 1)
w = Witness.new(count)
yield w
w.count.should eq(0), "The expected coverage was unmet"
end
def with_dummy(uri : String = "dummy://host?checkout_timeout=0.5")
DummyDriver::DummyConnection.clear_connections
DB.open uri do |db|
yield db
end
end
def with_dummy_connection(options = "")
with_dummy("dummy://host?checkout_timeout=0.5&#{options}") do |db|
db.using_connection do |cnn|
yield cnn.as(DummyDriver::DummyConnection)
end
end
end

View File

@ -0,0 +1,310 @@
require "./spec_helper"
describe DummyDriver do
it "with_dummy executes the block with a database" do
with_witness do |w|
with_dummy do |db|
w.check
db.should be_a(DB::Database)
end
end
end
describe DummyDriver::DummyStatement do
it "should enumerate split rows by spaces" do
with_dummy do |db|
rs = db.query("")
rs.move_next.should be_false
rs.close
rs = db.query("a,b")
rs.move_next.should be_true
rs.move_next.should be_false
rs.close
rs = db.query("a,b 1,2")
rs.move_next.should be_true
rs.move_next.should be_true
rs.move_next.should be_false
rs.close
rs = db.query("a,b 1,2 c,d")
rs.move_next.should be_true
rs.move_next.should be_true
rs.move_next.should be_true
rs.move_next.should be_false
rs.close
end
end
# it "should query with block should executes always" do
# with_witness do |w|
# with_dummy do |db|
# db.query "a" do |rs|
# w.check
# end
# end
# end
# end
#
# it "should query with block should executes always" do
# with_witness do |w|
# with_dummy do |db|
# db.query "lorem ipsum" do |rs|
# w.check
# end
# end
# end
# end
it "should enumerate string fields" do
with_dummy do |db|
db.query "a,b 1,2" do |rs|
rs.move_next
rs.read(String).should eq("a")
rs.read(String).should eq("b")
rs.move_next
rs.read(String).should eq("1")
rs.read(String).should eq("2")
end
end
end
it "should enumerate nil fields" do
with_dummy do |db|
db.query "a,NULL 1,NULL" do |rs|
rs.move_next
rs.read(String).should eq("a")
rs.read(String | Nil).should be_nil
rs.move_next
rs.read(Int64).should eq(1)
rs.read(Int64 | Nil).should be_nil
end
end
end
it "should enumerate int64 fields" do
with_dummy do |db|
db.query "3,4 1,2" do |rs|
rs.move_next
rs.read(Int64).should eq(3i64)
rs.read(Int64).should eq(4i64)
rs.move_next
rs.read(Int64).should eq(1i64)
rs.read(Int64).should eq(2i64)
end
end
end
it "should enumerate nillable int64 fields" do
with_dummy do |db|
db.query "3,4 1,NULL" do |rs|
rs.move_next
rs.read(Int64 | Nil).should eq(3i64)
rs.read(Int64 | Nil).should eq(4i64)
rs.move_next
rs.read(Int64 | Nil).should eq(1i64)
rs.read(Int64 | Nil).should be_nil
end
end
end
describe "query one" do
it "queries" do
with_dummy do |db|
db.query_one("3,4", &.read(Int64, Int64)).should eq({3i64, 4i64})
end
end
it "raises if more than one row" do
with_dummy do |db|
expect_raises(DB::Error, "more than one row") do
db.query_one("3,4 5,6") { }
end
end
end
it "raises if no rows" do
with_dummy do |db|
expect_raises(DB::Error, "no rows") do
db.query_one("") { }
end
end
end
it "with as" do
with_dummy do |db|
db.query_one("3,4", as: {Int64, Int64}).should eq({3i64, 4i64})
end
end
it "with a named tuple" do
with_dummy do |db|
db.query_one("3,4", as: {a: Int64, b: Int64}).should eq({a: 3i64, b: 4i64})
end
end
it "with as, just one" do
with_dummy do |db|
db.query_one("3", as: Int64).should eq(3i64)
end
end
end
describe "query one?" do
it "queries" do
with_dummy do |db|
value = db.query_one?("3,4", &.read(Int64, Int64))
value.should eq({3i64, 4i64})
value.should be_a(Tuple(Int64, Int64)?)
end
end
it "raises if more than one row" do
with_dummy do |db|
expect_raises(DB::Error, "more than one row") do
db.query_one?("3,4 5,6") { }
end
end
end
it "returns nil if no rows" do
with_dummy do |db|
db.query_one?("") { fail("block shouldn't be invoked") }.should be_nil
end
end
it "with as" do
with_dummy do |db|
value = db.query_one?("3,4", as: {Int64, Int64})
value.should be_a(Tuple(Int64, Int64)?)
value.should eq({3i64, 4i64})
end
end
it "with as" do
with_dummy do |db|
value = db.query_one?("3,4", as: {a: Int64, b: Int64})
value.should be_a(NamedTuple(a: Int64, b: Int64)?)
value.should eq({a: 3i64, b: 4i64})
end
end
it "with as, no rows" do
with_dummy do |db|
value = db.query_one?("", as: {a: Int64, b: Int64})
value.should be_nil
end
end
it "with as, just one" do
with_dummy do |db|
value = db.query_one?("3", as: Int64)
value.should be_a(Int64?)
value.should eq(3i64)
end
end
end
describe "query all" do
it "queries" do
with_dummy do |db|
ary = db.query_all "3,4 1,2", &.read(Int64, Int64)
ary.should eq([{3, 4}, {1, 2}])
end
end
it "queries with as" do
with_dummy do |db|
ary = db.query_all "3,4 1,2", as: {Int64, Int64}
ary.should eq([{3, 4}, {1, 2}])
end
end
it "queries with a named tuple" do
with_dummy do |db|
ary = db.query_all "3,4 1,2", as: {a: Int64, b: Int64}
ary.should eq([{a: 3, b: 4}, {a: 1, b: 2}])
end
end
it "queries with as, just one" do
with_dummy do |db|
ary = db.query_all "3 1", as: Int64
ary.should eq([3, 1])
end
end
end
describe "query each" do
it "queries" do
with_dummy do |db|
i = 0
db.query_each "3,4 1,2" do |rs|
case i
when 0
rs.read(Int64, Int64).should eq({3i64, 4i64})
when 1
rs.read(Int64, Int64).should eq({1i64, 2i64})
end
i += 1
end
i.should eq(2)
end
end
end
it "reads multiple values" do
with_dummy do |db|
db.query "3,4 1,2" do |rs|
rs.move_next
rs.read(Int64, Int64).should eq({3i64, 4i64})
rs.move_next
rs.read(Int64, Int64).should eq({1i64, 2i64})
end
end
end
it "should enumerate blob fields" do
with_dummy do |db|
db.query("az,AZ") do |rs|
rs.move_next
ary = [97u8, 122u8]
rs.read(Bytes).should eq(Bytes.new(ary.to_unsafe, ary.size))
ary = [65u8, 90u8]
rs.read(Bytes).should eq(Bytes.new(ary.to_unsafe, ary.size))
end
end
end
it "should get Nil scalars" do
with_dummy do |db|
db.scalar("NULL").should be_nil
end
end
it "should raise executing raise query" do
with_dummy do |db|
expect_raises DB::Error do
db.exec "raise"
end
end
end
{% for value in [1, 1_i64, "hello", 1.5, 1.5_f32] %}
it "should set positional arguments for {{value.id}}" do
with_dummy do |db|
db.scalar("?", {{value}}).should eq({{value}})
end
end
{% end %}
it "executes and selects blob" do
with_dummy do |db|
ary = UInt8[0x53, 0x51, 0x4C]
slice = Bytes.new(ary.to_unsafe, ary.size)
(db.scalar("?", slice).as(Bytes)).to_a.should eq(ary)
end
end
end
end

194
lib/db/spec/mapping_spec.cr Normal file
View File

@ -0,0 +1,194 @@
require "./spec_helper"
require "base64"
class SimpleMapping
DB.mapping({
c0: Int32,
c1: String,
})
end
class NonStrictMapping
DB.mapping({
c1: Int32,
c2: String,
}, strict: false)
end
class MappingWithDefaults
DB.mapping({
c0: {type: Int32, default: 10},
c1: {type: String, default: "c"},
})
end
class MappingWithNilables
DB.mapping({
c0: {type: Int32, nilable: true, default: 10},
c1: {type: String, nilable: true},
})
end
class MappingWithNilTypes
DB.mapping({
c0: {type: Int32?, default: 10},
c1: String?,
})
end
class MappingWithNilUnionTypes
DB.mapping({
c0: {type: Int32 | Nil, default: 10},
c1: Nil | String,
})
end
class MappingWithKeys
DB.mapping({
foo: {type: Int32, key: "c0"},
bar: {type: String, key: "c1"},
})
end
class MappingWithConverter
module Base64Converter
def self.from_rs(rs)
Base64.decode(rs.read(String))
end
end
DB.mapping({
c0: {type: Slice(UInt8), converter: MappingWithConverter::Base64Converter},
c1: {type: String},
})
end
macro from_dummy(query, type)
with_dummy do |db|
rs = db.query({{ query }})
rs.move_next
%obj = {{ type }}.new(rs)
rs.close
%obj
end
end
macro expect_mapping(query, t, values)
%obj = from_dummy({{ query }}, {{ t }})
%obj.should be_a({{ t }})
{% for key, value in values %}
%obj.{{key.id}}.should eq({{value}})
{% end %}
end
describe "DB.mapping" do
it "should initialize a simple mapping" do
expect_mapping("1,a", SimpleMapping, {c0: 1, c1: "a"})
end
it "should fail to initialize a simple mapping if types do not match" do
expect_raises ArgumentError do
from_dummy("b,a", SimpleMapping)
end
end
it "should fail to initialize a simple mapping if there is a missing column" do
expect_raises DB::MappingException do
from_dummy("1", SimpleMapping)
end
end
it "should fail to initialize a simple mapping if there is an unexpected column" do
expect_raises DB::MappingException do
from_dummy("1,a,b", SimpleMapping)
end
end
it "should initialize a non-strict mapping if there is an unexpected column" do
expect_mapping("1,2,a,b", NonStrictMapping, {c1: 2, c2: "a"})
end
it "should initialize a mapping with default values" do
expect_mapping("1,a", MappingWithDefaults, {c0: 1, c1: "a"})
end
it "should initialize a mapping using default values if columns are missing" do
expect_mapping("1", MappingWithDefaults, {c0: 1, c1: "c"})
end
it "should initialize a mapping using default values if values are nil and types are non nilable" do
expect_mapping("1,NULL", MappingWithDefaults, {c0: 1, c1: "c"})
end
it "should initialize a mapping with nilable set if columns are missing" do
expect_mapping("1", MappingWithNilables, {c0: 1, c1: nil})
end
it "should initialize a mapping with nilable set ignoring default value if NULL" do
expect_mapping("NULL,a", MappingWithNilables, {c0: nil, c1: "a"})
end
it "should initialize a mapping with nilable types if columns are missing" do
expect_mapping("1", MappingWithNilTypes, {c0: 1, c1: nil})
expect_mapping("1", MappingWithNilUnionTypes, {c0: 1, c1: nil})
end
it "should initialize a mapping with nilable types ignoring default value if NULL" do
expect_mapping("NULL,a", MappingWithNilTypes, {c0: nil, c1: "a"})
expect_mapping("NULL,a", MappingWithNilUnionTypes, {c0: nil, c1: "a"})
end
it "should initialize a mapping with different keys" do
expect_mapping("1,a", MappingWithKeys, {foo: 1, bar: "a"})
end
it "should initialize a mapping with a value converter" do
expect_mapping("Zm9v,a", MappingWithConverter, {c0: "foo".to_slice, c1: "a"})
end
it "should initialize multiple instances from a single resultset" do
with_dummy do |db|
db.query("1,a 2,b") do |rs|
objs = SimpleMapping.from_rs(rs)
objs.size.should eq(2)
objs[0].c0.should eq(1)
objs[0].c1.should eq("a")
objs[1].c0.should eq(2)
objs[1].c1.should eq("b")
end
end
end
it "Class.from_rs should close resultset" do
with_dummy do |db|
rs = db.query("1,a 2,b")
objs = SimpleMapping.from_rs(rs)
rs.closed?.should be_true
objs.size.should eq(2)
objs[0].c0.should eq(1)
objs[0].c1.should eq("a")
objs[1].c0.should eq(2)
objs[1].c1.should eq("b")
end
end
it "should initialize from a query_one" do
with_dummy do |db|
obj = db.query_one "1,a", as: SimpleMapping
obj.c0.should eq(1)
obj.c1.should eq("a")
end
end
it "should initialize from a query_all" do
with_dummy do |db|
objs = db.query_all "1,a 2,b", as: SimpleMapping
objs.size.should eq(2)
objs[0].c0.should eq(1)
objs[0].c1.should eq("a")
objs[1].c0.should eq(2)
objs[1].c1.should eq("b")
end
end
end

206
lib/db/spec/pool_spec.cr Normal file
View File

@ -0,0 +1,206 @@
require "./spec_helper"
class ShouldSleepingOp
@is_sleeping = false
getter is_sleeping
getter sleep_happened
def initialize
@sleep_happened = Channel(Nil).new
end
def should_sleep
s = self
@is_sleeping = true
spawn do
sleep 0.1
s.is_sleeping.should be_true
s.sleep_happened.send(nil)
end
yield
@is_sleeping = false
end
def wait_for_sleep
@sleep_happened.receive
end
end
class WaitFor
def initialize
@channel = Channel(Nil).new
end
def wait
@channel.receive
end
def check
@channel.send(nil)
end
end
class Closable
include DB::Disposable
property before_checkout_called : Bool = false
property after_release_called : Bool = false
protected def do_close
end
def before_checkout
@before_checkout_called = true
end
def after_release
@after_release_called = true
end
end
describe DB::Pool do
it "should use proc to create objects" do
block_called = 0
pool = DB::Pool.new(initial_pool_size: 3) { block_called += 1; Closable.new }
block_called.should eq(3)
end
it "should get resource" do
pool = DB::Pool.new { Closable.new }
resource = pool.checkout
resource.should be_a Closable
resource.before_checkout_called.should be_true
end
it "should be available if not checkedout" do
resource = uninitialized Closable
pool = DB::Pool.new(initial_pool_size: 1) { resource = Closable.new }
pool.is_available?(resource).should be_true
end
it "should not be available if checkedout" do
pool = DB::Pool.new { Closable.new }
resource = pool.checkout
pool.is_available?(resource).should be_false
end
it "should be available if returned" do
pool = DB::Pool.new { Closable.new }
resource = pool.checkout
resource.after_release_called.should be_false
pool.release resource
pool.is_available?(resource).should be_true
resource.after_release_called.should be_true
end
it "should wait for available resource" do
pool = DB::Pool.new(max_pool_size: 1, initial_pool_size: 1) { Closable.new }
b_cnn_request = ShouldSleepingOp.new
wait_a = WaitFor.new
wait_b = WaitFor.new
spawn do
a_cnn = pool.checkout
b_cnn_request.wait_for_sleep
pool.release a_cnn
wait_a.check
end
spawn do
b_cnn_request.should_sleep do
pool.checkout
end
wait_b.check
end
wait_a.wait
wait_b.wait
end
it "should create new if max was not reached" do
block_called = 0
pool = DB::Pool.new(max_pool_size: 2, initial_pool_size: 1) { block_called += 1; Closable.new }
block_called.should eq 1
pool.checkout
block_called.should eq 1
pool.checkout
block_called.should eq 2
end
it "should reuse returned resources" do
all = [] of Closable
pool = DB::Pool.new(max_pool_size: 2, initial_pool_size: 1) { Closable.new.tap { |c| all << c } }
pool.checkout
b1 = pool.checkout
pool.release b1
b2 = pool.checkout
b1.should eq b2
all.size.should eq 2
end
it "should close available and total" do
all = [] of Closable
pool = DB::Pool.new(max_pool_size: 2, initial_pool_size: 1) { Closable.new.tap { |c| all << c } }
a = pool.checkout
b = pool.checkout
pool.release b
all.size.should eq 2
all[0].closed?.should be_false
all[1].closed?.should be_false
pool.close
all[0].closed?.should be_true
all[1].closed?.should be_true
end
it "should timeout" do
pool = DB::Pool.new(max_pool_size: 1, checkout_timeout: 0.1) { Closable.new }
pool.checkout
expect_raises DB::PoolTimeout do
pool.checkout
end
end
it "should be able to release after a timeout" do
pool = DB::Pool.new(max_pool_size: 1, checkout_timeout: 0.1) { Closable.new }
a = pool.checkout
pool.checkout rescue nil
pool.release a
end
it "should close if max idle amount is reached" do
all = [] of Closable
pool = DB::Pool.new(max_pool_size: 3, max_idle_pool_size: 1) { Closable.new.tap { |c| all << c } }
pool.checkout
pool.checkout
pool.checkout
all.size.should eq 3
all.any?(&.closed?).should be_false
pool.release all[0]
all.any?(&.closed?).should be_false
pool.release all[1]
all[0].closed?.should be_false
all[1].closed?.should be_true
all[2].closed?.should be_false
end
it "should create resource after max_pool was reached if idle forced some close up" do
all = [] of Closable
pool = DB::Pool.new(max_pool_size: 3, max_idle_pool_size: 1) { Closable.new.tap { |c| all << c } }
pool.checkout
pool.checkout
pool.checkout
pool.release all[0]
pool.release all[1]
pool.checkout
pool.checkout
all.size.should eq 4
end
end

View File

@ -0,0 +1,67 @@
require "./spec_helper"
class DummyException < Exception
end
describe DB::ResultSet do
it "should enumerate records using each" do
nums = [] of Int32
with_dummy do |db|
db.query "3,4 1,2" do |rs|
rs.each do
nums << rs.read(Int32)
nums << rs.read(Int32)
end
end
end
nums.should eq([3, 4, 1, 2])
end
it "should close ResultSet after query" do
with_dummy do |db|
the_rs = uninitialized DB::ResultSet
db.query "3,4 1,2" do |rs|
the_rs = rs
end
the_rs.closed?.should be_true
end
end
it "should close ResultSet after query even with exception" do
with_dummy do |db|
the_rs = uninitialized DB::ResultSet
begin
db.query "3,4 1,2" do |rs|
the_rs = rs
raise DummyException.new
end
rescue DummyException
end
the_rs.closed?.should be_true
end
end
it "should enumerate columns" do
cols = [] of String
with_dummy do |db|
db.query "3,4 1,2" do |rs|
rs.each_column do |col|
cols << col
end
end
end
cols.should eq(["c0", "c1"])
end
it "gets all column names" do
with_dummy do |db|
db.query "1,2" do |rs|
rs.column_names.should eq(%w(c0 c1))
end
end
end
end

View File

@ -0,0 +1,160 @@
require "./spec_helper"
private class FooException < Exception
end
private def with_dummy_top_transaction
with_dummy_connection do |cnn|
cnn.transaction do |tx|
yield tx.as(DummyDriver::DummyTransaction), cnn
end
end
end
private def with_dummy_nested_transaction
with_dummy_connection do |cnn|
cnn.transaction do |tx|
tx.transaction do |nested|
yield nested.as(DummyDriver::DummySavePointTransaction), cnn
end
end
end
end
describe DB::SavePointTransaction do
{% for context in [:with_dummy_top_transaction, :with_dummy_nested_transaction] %}
describe "{{context.id}}" do
it "begin/commit transaction from parent transaction" do
{{context.id}} do |parent_tx|
tx = parent_tx.begin_transaction
tx.commit
end
end
it "begin/rollback transaction from parent transaction" do
{{context.id}} do |parent_tx|
tx = parent_tx.begin_transaction
tx.rollback
end
end
it "raise if begin over existing transaction" do
{{context.id}} do |parent_tx|
parent_tx.begin_transaction
expect_raises(DB::Error, "There is an existing nested transaction in this transaction") do
parent_tx.begin_transaction
end
end
end
it "allow sequential transactions" do
{{context.id}} do |parent_tx|
tx = parent_tx.begin_transaction
tx.rollback
tx = parent_tx.begin_transaction
tx.commit
end
end
it "transaction with block from parent transaction should be committed" do
t = uninitialized DummyDriver::DummySavePointTransaction
with_witness do |w|
{{context.id}} do |parent_tx|
parent_tx.transaction do |tx|
if tx.is_a?(DummyDriver::DummySavePointTransaction)
t = tx
w.check
end
end
end
end
t.committed.should be_true
t.rolledback.should be_false
end
end
{% end %}
it "only nested transaction with block from parent transaction should be rolledback if raise DB::Rollback" do
top = uninitialized DummyDriver::DummyTransaction
t = uninitialized DummyDriver::DummySavePointTransaction
with_witness do |w|
with_dummy_top_transaction do |parent_tx|
top = parent_tx
parent_tx.transaction do |tx|
if tx.is_a?(DummyDriver::DummySavePointTransaction)
t = tx
w.check
end
raise DB::Rollback.new
end
end
end
t.rolledback.should be_true
t.committed.should be_false
top.rolledback.should be_false
top.committed.should be_true
end
it "only nested transaction with block from parent nested transaction should be rolledback if raise DB::Rollback" do
top = uninitialized DummyDriver::DummySavePointTransaction
t = uninitialized DummyDriver::DummySavePointTransaction
with_witness do |w|
with_dummy_nested_transaction do |parent_tx|
top = parent_tx
parent_tx.transaction do |tx|
if tx.is_a?(DummyDriver::DummySavePointTransaction)
t = tx
w.check
end
raise DB::Rollback.new
end
end
end
t.rolledback.should be_true
t.committed.should be_false
top.rolledback.should be_false
top.committed.should be_true
end
it "releasing result_set from within inner transaction should not return connection to pool" do
cnn = uninitialized DB::Connection
with_dummy do |db|
db.transaction do |tx|
tx.transaction do |inner|
cnn = inner.connection
cnn.scalar "1"
db.pool.is_available?(cnn).should be_false
end
db.pool.is_available?(cnn).should be_false
end
db.pool.is_available?(cnn).should be_true
end
end
it "releasing result_set from within inner inner transaction should not return connection to pool" do
cnn = uninitialized DB::Connection
with_dummy do |db|
db.transaction do |tx|
tx.transaction do |inner|
inner.transaction do |inner_inner|
cnn = inner_inner.connection
cnn.scalar "1"
db.pool.is_available?(cnn).should be_false
end
db.pool.is_available?(cnn).should be_false
end
db.pool.is_available?(cnn).should be_false
end
db.pool.is_available?(cnn).should be_true
end
end
end

View File

@ -0,0 +1,3 @@
require "spec"
require "./dummy_driver"
require "../src/db"

View File

@ -0,0 +1,157 @@
require "./spec_helper"
describe DB::Statement do
it "should build prepared statements" do
with_dummy_connection do |cnn|
prepared = cnn.prepared("the query")
prepared.should be_a(DB::Statement)
prepared.as(DummyDriver::DummyStatement).prepared?.should be_true
end
end
it "should build unprepared statements" do
with_dummy_connection("prepared_statements=false") do |cnn|
prepared = cnn.unprepared("the query")
prepared.should be_a(DB::Statement)
prepared.as(DummyDriver::DummyStatement).prepared?.should be_false
end
end
describe "prepared_statements flag" do
it "should build prepared statements if true" do
with_dummy_connection("prepared_statements=true") do |cnn|
stmt = cnn.query("the query").statement
stmt.as(DummyDriver::DummyStatement).prepared?.should be_true
end
end
it "should build unprepared statements if false" do
with_dummy_connection("prepared_statements=false") do |cnn|
stmt = cnn.query("the query").statement
stmt.as(DummyDriver::DummyStatement).prepared?.should be_false
end
end
end
it "should initialize positional params in query" do
with_dummy_connection do |cnn|
stmt = cnn.prepared("the query").as(DummyDriver::DummyStatement)
stmt.query "a", 1, nil
stmt.params[0].should eq("a")
stmt.params[1].should eq(1)
stmt.params[2].should eq(nil)
end
end
it "should initialize positional params in query with array" do
with_dummy_connection do |cnn|
stmt = cnn.prepared("the query").as(DummyDriver::DummyStatement)
stmt.query ["a", 1, nil]
stmt.params[0].should eq("a")
stmt.params[1].should eq(1)
stmt.params[2].should eq(nil)
end
end
it "should initialize positional params in exec" do
with_dummy_connection do |cnn|
stmt = cnn.prepared("the query").as(DummyDriver::DummyStatement)
stmt.exec "a", 1, nil
stmt.params[0].should eq("a")
stmt.params[1].should eq(1)
stmt.params[2].should eq(nil)
end
end
it "should initialize positional params in exec with array" do
with_dummy_connection do |cnn|
stmt = cnn.prepared("the query").as(DummyDriver::DummyStatement)
stmt.exec ["a", 1, nil]
stmt.params[0].should eq("a")
stmt.params[1].should eq(1)
stmt.params[2].should eq(nil)
end
end
it "should initialize positional params in scalar" do
with_dummy_connection do |cnn|
stmt = cnn.prepared("the query").as(DummyDriver::DummyStatement)
stmt.scalar "a", 1, nil
stmt.params[0].should eq("a")
stmt.params[1].should eq(1)
stmt.params[2].should eq(nil)
end
end
it "query with block should not close statement" do
with_dummy_connection do |cnn|
stmt = cnn.prepared "3,4 1,2"
stmt.query
stmt.closed?.should be_false
end
end
it "closing connection should close statement" do
stmt = uninitialized DB::Statement
with_dummy_connection do |cnn|
stmt = cnn.prepared "3,4 1,2"
stmt.query
end
stmt.closed?.should be_true
end
it "query with block should not close statement" do
with_dummy_connection do |cnn|
stmt = cnn.prepared "3,4 1,2"
stmt.query do |rs|
end
stmt.closed?.should be_false
end
end
it "query should not close statement" do
with_dummy_connection do |cnn|
stmt = cnn.prepared "3,4 1,2"
stmt.query do |rs|
end
stmt.closed?.should be_false
end
end
it "scalar should not close statement" do
with_dummy_connection do |cnn|
stmt = cnn.prepared "3,4 1,2"
stmt.scalar
stmt.closed?.should be_false
end
end
it "exec should not close statement" do
with_dummy_connection do |cnn|
stmt = cnn.prepared "3,4 1,2"
stmt.exec
stmt.closed?.should be_false
end
end
it "connection should cache statements by query" do
with_dummy_connection do |cnn|
rs = cnn.prepared.query "1, ?", 2
stmt = rs.statement
rs.close
rs = cnn.prepared.query "1, ?", 4
rs.statement.should be(stmt)
end
end
it "connection should be released if error occurs during exec" do
with_dummy do |db|
expect_raises DB::Error do
db.exec "raise"
end
DummyDriver::DummyConnection.connections.size.should eq(1)
db.pool.is_available?(DummyDriver::DummyConnection.connections.first)
end
end
end

View File

@ -0,0 +1,178 @@
require "./spec_helper"
private class FooException < Exception
end
describe DB::Transaction do
it "begin/commit transaction from connection" do
with_dummy_connection do |cnn|
tx = cnn.begin_transaction
tx.commit
end
end
it "begin/rollback transaction from connection" do
with_dummy_connection do |cnn|
tx = cnn.begin_transaction
tx.rollback
end
end
it "raise if begin over existing transaction" do
with_dummy_connection do |cnn|
cnn.begin_transaction
expect_raises(DB::Error, "There is an existing transaction in this connection") do
cnn.begin_transaction
end
end
end
it "allow sequential transactions" do
with_dummy_connection do |cnn|
tx = cnn.begin_transaction
tx.rollback
tx = cnn.begin_transaction
tx.commit
end
end
it "transaction with block from connection should be committed" do
t = uninitialized DummyDriver::DummyTransaction
with_witness do |w|
with_dummy_connection do |cnn|
cnn.transaction do |tx|
if tx.is_a?(DummyDriver::DummyTransaction)
t = tx
w.check
end
end
end
end
t.committed.should be_true
t.rolledback.should be_false
end
it "transaction with block from connection should be rolledback if raise DB::Rollback" do
t = uninitialized DummyDriver::DummyTransaction
with_witness do |w|
with_dummy_connection do |cnn|
cnn.transaction do |tx|
if tx.is_a?(DummyDriver::DummyTransaction)
t = tx
w.check
end
raise DB::Rollback.new
end
end
end
t.rolledback.should be_true
t.committed.should be_false
end
it "transaction with block from connection should be rolledback if raise" do
t = uninitialized DummyDriver::DummyTransaction
with_witness do |w|
with_dummy_connection do |cnn|
expect_raises(FooException) do
cnn.transaction do |tx|
if tx.is_a?(DummyDriver::DummyTransaction)
t = tx
w.check
end
raise FooException.new
end
end
end
end
t.rolledback.should be_true
t.committed.should be_false
end
it "transaction can be committed within block" do
with_dummy_connection do |cnn|
cnn.transaction do |tx|
tx.commit
end
end
end
it "transaction can be rolledback within block" do
with_dummy_connection do |cnn|
cnn.transaction do |tx|
tx.rollback
end
end
end
it "transaction can be rolledback within block and later raise" do
with_dummy_connection do |cnn|
expect_raises(FooException) do
cnn.transaction do |tx|
tx.rollback
raise FooException.new
end
end
end
end
it "transaction can be rolledback within block and later raise DB::Rollback without forwarding it" do
with_dummy_connection do |cnn|
cnn.transaction do |tx|
tx.rollback
raise DB::Rollback.new
end
end
end
it "transaction can't be committed twice" do
with_dummy_connection do |cnn|
cnn.transaction do |tx|
tx.commit
expect_raises(DB::Error, "Transaction already closed") do
tx.commit
end
end
end
end
it "transaction can't be rolledback twice" do
with_dummy_connection do |cnn|
cnn.transaction do |tx|
tx.rollback
expect_raises(DB::Error, "Transaction already closed") do
tx.rollback
end
end
end
end
it "return connection to pool after transaction block in db" do
DummyDriver::DummyConnection.clear_connections
with_dummy do |db|
db.transaction do |tx|
db.pool.is_available?(DummyDriver::DummyConnection.connections.first).should be_false
end
db.pool.is_available?(DummyDriver::DummyConnection.connections.first).should be_true
end
end
it "releasing result_set from within transaction should not return connection to pool" do
cnn = uninitialized DB::Connection
with_dummy do |db|
db.transaction do |tx|
cnn = tx.connection
cnn.scalar "1"
db.pool.is_available?(cnn).should be_false
end
db.pool.is_available?(cnn).should be_true
end
end
end

200
lib/db/src/db.cr Normal file
View File

@ -0,0 +1,200 @@
require "uri"
# The DB module is a unified interface for database access.
# Individual database systems are supported by specific database driver shards.
#
# Available drivers include:
# * [crystal-lang/crystal-sqlite3](https://github.com/crystal-lang/crystal-sqlite3) for SQLite
# * [crystal-lang/crystal-mysql](https://github.com/crystal-lang/crystal-mysql) for MySQL and MariaDB
# * [will/crystal-pg](https://github.com/will/crystal-pg) for PostgreSQL
# * [kaukas/crystal-cassandra](https://github.com/kaukas/crystal-cassandra) for Cassandra
#
# For basic instructions on implementing a new database driver, check `Driver` and the existing drivers.
#
# DB manages a connection pool. The connection pool can be configured by query parameters to the
# connection `URI` as described in `Database`.
#
# ### Usage
#
# Assuming `crystal-sqlite3` is included a SQLite3 database can be opened with `#open`.
#
# ```
# db = DB.open "sqlite3:./path/to/db/file.db"
# db.close
# ```
#
# If a block is given to `#open` the database is closed automatically
#
# ```
# DB.open "sqlite3:./file.db" do |db|
# # work with db
# end # db is closed
# ```
#
# In the code above `db` is a `Database`. Methods available for querying it are described in `QueryMethods`.
#
# Three kind of statements can be performed:
# 1. `Database#exec` waits no response from the database.
# 2. `Database#scalar` reads a single value of the response.
# 3. `Database#query` returns a ResultSet that allows iteration over the rows in the response and column information.
#
# All of the above methods allows parametrised query. Either positional or named arguments.
#
# Check a full working version:
#
# The following example uses SQLite where `?` indicates the arguments. If PostgreSQL is used `$1`, `$2`, etc. should be used. `crystal-db` does not interpret the statements.
#
# ```
# require "db"
# require "sqlite3"
#
# DB.open "sqlite3:./file.db" do |db|
# # When using the pg driver, use $1, $2, etc. instead of ?
# db.exec "create table contacts (name text, age integer)"
# db.exec "insert into contacts values (?, ?)", "John Doe", 30
#
# args = [] of DB::Any
# args << "Sarah"
# args << 33
# db.exec "insert into contacts values (?, ?)", args
#
# puts "max age:"
# puts db.scalar "select max(age) from contacts" # => 33
#
# puts "contacts:"
# db.query "select name, age from contacts order by age desc" do |rs|
# puts "#{rs.column_name(0)} (#{rs.column_name(1)})"
# # => name (age)
# rs.each do
# puts "#{rs.read(String)} (#{rs.read(Int32)})"
# # => Sarah (33)
# # => John Doe (30)
# end
# end
# end
# ```
#
module DB
# Types supported to interface with database driver.
# These can be used in any `ResultSet#read` or any `Database#query` related
# method to be used as query parameters
TYPES = [Nil, String, Bool, Int32, Int64, Float32, Float64, Time, Bytes]
# See `DB::TYPES` in `DB`. `Any` is a union of all types in `DB::TYPES`
{% begin %}
alias Any = Union({{*TYPES}})
{% end %}
# Result of a `#exec` statement.
record ExecResult, rows_affected : Int64, last_insert_id : Int64
# :nodoc:
def self.driver_class(driver_name) : Driver.class
drivers[driver_name]? ||
raise(ArgumentError.new(%(no driver was registered for the schema "#{driver_name}", did you maybe forget to require the database driver?)))
end
# Registers a driver class for a given *driver_name*.
# Should be called by drivers implementors only.
def self.register_driver(driver_name, driver_class : Driver.class)
drivers[driver_name] = driver_class
end
private def self.drivers
@@drivers ||= {} of String => Driver.class
end
# Creates a `Database` pool and opens initial connection(s) as specified in the connection *uri*.
# Use `DB#connect` to open a single connection.
#
# The scheme of the *uri* determines the driver to use.
# Connection parameters such as hostname, user, database name, etc. are specified according
# to each database driver's specific format.
#
# The returned database must be closed by `Database#close`.
def self.open(uri : URI | String)
build_database(uri)
end
# Same as `#open` but the database is yielded and closed automatically at the end of the block.
def self.open(uri : URI | String, &block)
db = build_database(uri)
begin
yield db
ensure
db.close
end
end
# Opens a connection using the specified *uri*.
# The scheme of the *uri* determines the driver to use.
# Returned connection must be closed by `Connection#close`.
# If a block is used the connection is yielded and closed automatically.
def self.connect(uri : URI | String)
build_connection(uri)
end
# ditto
def self.connect(uri : URI | String, &block)
cnn = build_connection(uri)
begin
yield cnn
ensure
cnn.close
end
end
private def self.build_database(connection_string : String)
build_database(URI.parse(connection_string))
end
private def self.build_database(uri : URI)
Database.new(build_driver(uri), uri)
end
private def self.build_connection(connection_string : String)
build_connection(URI.parse(connection_string))
end
private def self.build_connection(uri : URI)
build_driver(uri).build_connection(SingleConnectionContext.new(uri)).as(Connection)
end
private def self.build_driver(uri : URI)
driver_class(uri.scheme).new
end
# :nodoc:
def self.fetch_bool(params : HTTP::Params, name, default : Bool)
case (value = params[name]?).try &.downcase
when nil
default
when "true"
true
when "false"
false
else
raise ArgumentError.new(%(invalid "#{value}" value for option "#{name}"))
end
end
end
require "./db/pool"
require "./db/string_key_cache"
require "./db/query_methods"
require "./db/session_methods"
require "./db/disposable"
require "./db/driver"
require "./db/statement"
require "./db/begin_transaction"
require "./db/connection_context"
require "./db/connection"
require "./db/transaction"
require "./db/statement"
require "./db/pool_statement"
require "./db/database"
require "./db/pool_prepared_statement"
require "./db/pool_unprepared_statement"
require "./db/result_set"
require "./db/error"
require "./db/mapping"

View File

@ -0,0 +1,33 @@
module DB
module BeginTransaction
# Creates a transaction from the current context.
# If is expected that either `Transaction#commit` or `Transaction#rollback`
# are called explicitly to release the context.
abstract def begin_transaction : Transaction
# yields a transaction from the current context.
# Query the database through `Transaction#connection` object.
# If an exception is thrown within the block a rollback is performed.
# The exception thrown is bubbled unless it is a `DB::Rollback`.
# From the yielded object `Transaction#commit` or `Transaction#rollback`
# can be called explicitly.
def transaction
tx = begin_transaction
begin
yield tx
rescue DB::Rollback
tx.rollback unless tx.closed?
rescue e
unless tx.closed?
# Ignore error in rollback.
# It would only be a secondary error to the original one, caused by
# corrupted connection state.
tx.rollback rescue nil
end
raise e
else
tx.commit unless tx.closed?
end
end
end
end

121
lib/db/src/db/connection.cr Normal file
View File

@ -0,0 +1,121 @@
module DB
# Database driver implementors must subclass `Connection`.
#
# Represents one active connection to a database.
#
# Users should never instantiate a `Connection` manually. Use `DB#open` or `Database#connection`.
#
# Refer to `QueryMethods` for documentation about querying the database through this connection.
#
# ### Note to implementors
#
# The connection must be initialized in `#initialize` and closed in `#do_close`.
#
# Override `#build_prepared_statement` method in order to return a prepared `Statement` to allow querying.
# Override `#build_unprepared_statement` method in order to return a unprepared `Statement` to allow querying.
# See also `Statement` to define how the statements are executed.
#
# If at any give moment the connection is lost a DB::ConnectionLost should be raised. This will
# allow the connection pool to try to reconnect or use another connection if available.
#
abstract class Connection
include Disposable
include SessionMethods(Connection, Statement)
include BeginTransaction
# :nodoc:
getter context
@statements_cache = StringKeyCache(Statement).new
@transaction = false
getter? prepared_statements : Bool
# :nodoc:
property auto_release : Bool = true
def initialize(@context : ConnectionContext)
@prepared_statements = @context.prepared_statements?
end
# :nodoc:
def fetch_or_build_prepared_statement(query) : Statement
@statements_cache.fetch(query) { build_prepared_statement(query) }
end
# :nodoc:
abstract def build_prepared_statement(query) : Statement
# :nodoc:
abstract def build_unprepared_statement(query) : Statement
def begin_transaction : Transaction
raise DB::Error.new("There is an existing transaction in this connection") if @transaction
@transaction = true
create_transaction
end
protected def create_transaction : Transaction
TopLevelTransaction.new(self)
end
protected def do_close
@statements_cache.each_value &.close
@statements_cache.clear
@context.discard self
end
# :nodoc:
protected def before_checkout
@auto_release = true
end
# :nodoc:
protected def after_release
end
# return this connection to the pool
# managed by the database. Should be used
# only if the connection was obtained by `Database#checkout`.
def release
@context.release(self)
end
# :nodoc:
def release_from_statement
self.release if @auto_release && !@transaction
end
# :nodoc:
def release_from_transaction
@transaction = false
end
# :nodoc:
def perform_begin_transaction
self.unprepared.exec "BEGIN"
end
# :nodoc:
def perform_commit_transaction
self.unprepared.exec "COMMIT"
end
# :nodoc:
def perform_rollback_transaction
self.unprepared.exec "ROLLBACK"
end
# :nodoc:
def perform_create_savepoint(name)
self.unprepared.exec "SAVEPOINT #{name}"
end
# :nodoc:
def perform_release_savepoint(name)
self.unprepared.exec "RELEASE SAVEPOINT #{name}"
end
# :nodoc:
def perform_rollback_savepoint(name)
self.unprepared.exec "ROLLBACK TO #{name}"
end
end
end

View File

@ -0,0 +1,36 @@
module DB
module ConnectionContext
# Returns the uri with the connection settings to the database
abstract def uri : URI
# Return whether the statements should be prepared by default
abstract def prepared_statements? : Bool
# Indicates that the *connection* was permanently closed
# and should not be used in the future.
abstract def discard(connection : Connection)
# Indicates that the *connection* is no longer needed
# and can be reused in the future.
abstract def release(connection : Connection)
end
# :nodoc:
class SingleConnectionContext
include ConnectionContext
getter uri : URI
getter? prepared_statements : Bool
def initialize(@uri : URI)
params = HTTP::Params.parse(uri.query || "")
@prepared_statements = DB.fetch_bool(params, "prepared_statements", true)
end
def discard(connection : Connection)
end
def release(connection : Connection)
end
end
end

146
lib/db/src/db/database.cr Normal file
View File

@ -0,0 +1,146 @@
require "http/params"
require "weak_ref"
module DB
# Acts as an entry point for database access.
# Connections are managed by a pool.
# Use `DB#open` to create a `Database` instance.
#
# Refer to `QueryMethods` and `SessionMethods` for documentation about querying the database.
#
# ## Database URI
#
# Connection parameters are configured in a URI. The format is specified by the individual
# database drivers. See the [reference book](https://crystal-lang.org/reference/database/) for examples.
#
# The connection pool can be configured from URI parameters:
#
# - `initial_pool_size` (default 1)
# - `max_pool_size` (default 0 = unlimited)
# - `max_idle_pool_size` (default 1)
# - `checkout_timeout` (default 5.0)
# - `retry_attempts` (default 1)
# - `retry_delay` (in seconds, default 1.0)
#
# When querying a database, prepared statements are used by default.
# This can be changed from the `prepared_statements` URI parameter:
#
# - `prepared_statements` (true, or false, default true)
#
class Database
include SessionMethods(Database, PoolStatement)
include ConnectionContext
# :nodoc:
getter driver
# :nodoc:
getter pool
# Returns the uri with the connection settings to the database
getter uri : URI
getter? prepared_statements : Bool
@pool : Pool(Connection)
@setup_connection : Connection -> Nil
@statements_cache = StringKeyCache(PoolPreparedStatement).new
# :nodoc:
def initialize(@driver : Driver, @uri : URI)
params = HTTP::Params.parse(uri.query || "")
@prepared_statements = DB.fetch_bool(params, "prepared_statements", true)
pool_options = @driver.connection_pool_options(params)
@setup_connection = ->(conn : Connection) {}
@pool = uninitialized Pool(Connection) # in order to use self in the factory proc
@pool = Pool.new(**pool_options) {
conn = @driver.build_connection(self).as(Connection)
@setup_connection.call conn
conn
}
end
def setup_connection(&proc : Connection -> Nil)
@setup_connection = proc
@pool.each_resource do |conn|
@setup_connection.call conn
end
end
# Closes all connection to the database.
def close
@statements_cache.each_value &.close
@statements_cache.clear
@pool.close
end
# :nodoc:
def discard(connection : Connection)
@pool.delete connection
end
# :nodoc:
def release(connection : Connection)
@pool.release connection
end
# :nodoc:
def fetch_or_build_prepared_statement(query) : PoolStatement
@statements_cache.fetch(query) { build_prepared_statement(query) }
end
# :nodoc:
def build_prepared_statement(query) : PoolStatement
PoolPreparedStatement.new(self, query)
end
# :nodoc:
def build_unprepared_statement(query) : PoolStatement
PoolUnpreparedStatement.new(self, query)
end
# :nodoc:
def checkout_some(candidates : Enumerable(WeakRef(Connection))) : {Connection, Bool}
@pool.checkout_some candidates
end
# yields a connection from the pool
# the connection is returned to the pool
# when the block ends
def using_connection
connection = self.checkout
begin
yield connection
ensure
connection.release
end
end
# returns a connection from the pool
# the returned connection must be returned
# to the pool by explictly calling `Connection#release`
def checkout
connection = @pool.checkout
connection.auto_release = false
connection
end
# yields a `Transaction` from a connection of the pool
# Refer to `BeginTransaction#transaction` for documentation.
def transaction
using_connection do |cnn|
cnn.transaction do |tx|
yield tx
end
end
end
# :nodoc:
def retry
@pool.retry do
yield
end
end
end
end

View File

@ -0,0 +1,24 @@
module DB
# Generic module to encapsulate disposable db resources.
module Disposable
macro included
@closed = false
end
# Closes this object.
def close
return if @closed
do_close
@closed = true
end
# Returns `true` if this object is closed. See `#close`.
def closed?
@closed
end
# Implementors overrides this method to perform resource cleanup
# If an exception is raised, the resource will not be marked as closed.
protected abstract def do_close
end
end

42
lib/db/src/db/driver.cr Normal file
View File

@ -0,0 +1,42 @@
module DB
# Database driver implementors must subclass `Driver`,
# register with a driver_name using `DB#register_driver` and
# override the factory method `#build_connection`.
#
# ```
# require "db"
#
# class FakeDriver < DB::Driver
# def build_connection(context : DB::ConnectionContext)
# FakeConnection.new context
# end
# end
#
# DB.register_driver "fake", FakeDriver
# ```
#
# Access to this fake datbase will be available with
#
# ```
# DB.open "fake://..." do |db|
# # ... use db ...
# end
# ```
#
# Refer to `Connection`, `Statement` and `ResultSet` for further
# driver implementation instructions.
abstract class Driver
abstract def build_connection(context : ConnectionContext) : Connection
def connection_pool_options(params : HTTP::Params)
{
initial_pool_size: params.fetch("initial_pool_size", 1).to_i,
max_pool_size: params.fetch("max_pool_size", 0).to_i,
max_idle_pool_size: params.fetch("max_idle_pool_size", 1).to_i,
checkout_timeout: params.fetch("checkout_timeout", 5.0).to_f,
retry_attempts: params.fetch("retry_attempts", 1).to_i,
retry_delay: params.fetch("retry_delay", 1.0).to_f,
}
end
end
end

32
lib/db/src/db/error.cr Normal file
View File

@ -0,0 +1,32 @@
module DB
class Error < Exception
end
class MappingException < Error
end
class PoolTimeout < Error
end
class PoolRetryAttemptsExceeded < Error
end
# Raised when an established connection is lost
# probably due to socket/network issues.
# It is used by the connection pool retry logic.
class ConnectionLost < Error
getter connection : Connection
def initialize(@connection)
end
end
# Raised when a connection is unable to be established
# probably due to socket/network or configuration issues.
# It is used by the connection pool retry logic.
class ConnectionRefused < Error
end
class Rollback < Error
end
end

154
lib/db/src/db/mapping.cr Normal file
View File

@ -0,0 +1,154 @@
module DB
# Empty module used for marking a class as supporting DB:Mapping
module Mappable; end
# The `DB.mapping` macro defines how an object is built from a `ResultSet`.
#
# It takes hash literal as argument, in which attributes and types are defined.
# Once defined, `ResultSet#read(t)` populates properties of the class from the
# `ResultSet`.
#
# ```crystal
# require "db"
#
# class Employee
# DB.mapping({
# title: String,
# name: String,
# })
# end
#
# employees = Employee.from_rs(db.query("SELECT title, name FROM employees"))
# employees[0].title # => "Manager"
# employees[0].name # => "John"
# ```
#
# Attributes not mapped with `DB.mapping` are not defined as properties.
# Also, missing attributes raise a `DB::MappingException`.
#
# You can also define attributes for each property.
#
# ```crystal
# class Employee
# DB.mapping({
# title: String,
# name: {
# type: String,
# nilable: true,
# key: "firstname",
# },
# })
# end
# ```
#
# Available attributes:
#
# * *type* (required) defines its type. In the example above, *title: String* is a shortcut to *title: {type: String}*.
# * *nilable* defines if a property can be a `Nil`.
# * **default**: value to use if the property is missing in the result set, or if it's `null` and `nilable` was not set to `true`. If the default value creates a new instance of an object (for example `[1, 2, 3]` or `SomeObject.new`), a different instance will be used each time a row is parsed.
# * *key* defines which column to read from a `ResultSet`. It defaults to the name of the property.
# * *converter* takes an alternate type for parsing. It requires a `#from_rs` method in that class, and returns an instance of the given type.
#
# The mapping also automatically defines Crystal properties (getters and setters) for each
# of the keys. It doesn't define a constructor accepting those arguments, but you can provide
# an overload.
#
# The macro basically defines a constructor accepting a `ResultSet` that reads from
# it and initializes this type's instance variables.
#
# This macro also declares instance variables of the types given in the mapping.
macro mapping(properties, strict = true)
include ::DB::Mappable
{% for key, value in properties %}
{% properties[key] = {type: value} unless value.is_a?(HashLiteral) || value.is_a?(NamedTupleLiteral) %}
{% end %}
{% for key, value in properties %}
{% value[:nilable] = true if value[:type].is_a?(Generic) && value[:type].type_vars.map(&.resolve).includes?(Nil) %}
{% if value[:type].is_a?(Call) && value[:type].name == "|" &&
(value[:type].receiver.resolve == Nil || value[:type].args.map(&.resolve).any?(&.==(Nil))) %}
{% value[:nilable] = true %}
{% end %}
{% end %}
{% for key, value in properties %}
@{{key.id}} : {{value[:type]}} {{ (value[:nilable] ? "?" : "").id }}
def {{key.id}}=(_{{key.id}} : {{value[:type]}} {{ (value[:nilable] ? "?" : "").id }})
@{{key.id}} = _{{key.id}}
end
def {{key.id}}
@{{key.id}}
end
{% end %}
def self.from_rs(%rs : ::DB::ResultSet)
%objs = Array(self).new
%rs.each do
%objs << self.new(%rs)
end
%objs
ensure
%rs.close
end
def initialize(%rs : ::DB::ResultSet)
{% for key, value in properties %}
%var{key.id} = nil
%found{key.id} = false
{% end %}
%rs.each_column do |col_name|
case col_name
{% for key, value in properties %}
when {{value[:key] || key.id.stringify}}
%found{key.id} = true
%var{key.id} =
{% if value[:converter] %}
{{value[:converter]}}.from_rs(%rs)
{% elsif value[:nilable] || value[:default] != nil %}
%rs.read(::Union({{value[:type]}} | Nil))
{% else %}
%rs.read({{value[:type]}})
{% end %}
{% end %}
else
{% if strict %}
raise ::DB::MappingException.new("unknown result set attribute: #{col_name}")
{% else %}
%rs.read
{% end %}
end
end
{% for key, value in properties %}
{% unless value[:nilable] || value[:default] != nil %}
if %var{key.id}.is_a?(Nil) && !%found{key.id}
raise ::DB::MappingException.new("missing result set attribute: {{(value[:key] || key).id}}")
end
{% end %}
{% end %}
{% for key, value in properties %}
{% if value[:nilable] %}
{% if value[:default] != nil %}
@{{key.id}} = %found{key.id} ? %var{key.id} : {{value[:default]}}
{% else %}
@{{key.id}} = %var{key.id}
{% end %}
{% elsif value[:default] != nil %}
@{{key.id}} = %var{key.id}.is_a?(Nil) ? {{value[:default]}} : %var{key.id}
{% else %}
@{{key.id}} = %var{key.id}.as({{value[:type]}})
{% end %}
{% end %}
end
end
macro mapping(**properties)
::DB.mapping({{properties}})
end
end

207
lib/db/src/db/pool.cr Normal file
View File

@ -0,0 +1,207 @@
require "weak_ref"
module DB
class Pool(T)
@initial_pool_size : Int32
# maximum amount of objects in the pool. Either available or in use.
@max_pool_size : Int32
@available = Set(T).new
@total = [] of T
@checkout_timeout : Float64
# maximum amount of retry attempts to reconnect to the db. See `Pool#retry`.
@retry_attempts : Int32
@retry_delay : Float64
def initialize(@initial_pool_size = 1, @max_pool_size = 0, @max_idle_pool_size = 1, @checkout_timeout = 5.0,
@retry_attempts = 1, @retry_delay = 0.2, &@factory : -> T)
@initial_pool_size.times { build_resource }
@availability_channel = Channel(Nil).new
@waiting_resource = 0
@mutex = Mutex.new
end
# close all resources in the pool
def close : Nil
@total.each &.close
@total.clear
@available.clear
end
def checkout : T
resource = if @available.empty?
if can_increase_pool
build_resource
else
wait_for_available
pick_available
end
else
pick_available
end
@available.delete resource
resource.before_checkout
resource
end
# ```
# selected, is_candidate = pool.checkout_some(candidates)
# ```
# `selected` be a resource from the `candidates` list and `is_candidate` == `true`
# or `selected` will be a new resource and `is_candidate` == `false`
def checkout_some(candidates : Enumerable(WeakRef(T))) : {T, Bool}
# TODO honor candidates while waiting for availables
# this will allow us to remove `candidates.includes?(resource)`
candidates.each do |ref|
resource = ref.value
if resource && is_available?(resource)
@available.delete resource
resource.before_checkout
return {resource, true}
end
end
resource = checkout
{resource, candidates.any? { |ref| ref.value == resource }}
end
def release(resource : T) : Nil
if can_increase_idle_pool
@available << resource
resource.after_release
@availability_channel.send nil if are_waiting_for_resource?
else
resource.close
@total.delete(resource)
end
end
# :nodoc:
# Will retry the block if a `ConnectionLost` exception is thrown.
# It will try to reuse all of the available connection right away,
# but if a new connection is needed there is a `retry_delay` seconds delay.
def retry
current_available = @available.size
# if the pool hasn't reach the max size, allow 1 attempt
# to make a new connection if needed without sleeping
current_available += 1 if can_increase_pool
(current_available + @retry_attempts).times do |i|
begin
sleep @retry_delay if i >= current_available
return yield
rescue e : ConnectionLost
# if the connection is lost close it to release resources
# and remove it from the known pool.
delete(e.connection)
e.connection.close
rescue e : ConnectionRefused
# a ConnectionRefused means a new connection
# was intended to be created
# nothing to due but to retry soon
end
end
raise PoolRetryAttemptsExceeded.new
end
# :nodoc:
def each_resource
@available.each do |resource|
yield resource
end
end
# :nodoc:
def is_available?(resource : T)
@available.includes?(resource)
end
# :nodoc:
def delete(resource : T)
@total.delete(resource)
@available.delete(resource)
end
private def build_resource : T
resource = @factory.call
@total << resource
@available << resource
resource
end
private def can_increase_pool
@max_pool_size == 0 || @total.size < @max_pool_size
end
private def can_increase_idle_pool
@available.size < @max_idle_pool_size
end
private def pick_available
@available.first
end
private def wait_for_available
timeout = TimeoutHelper.new(@checkout_timeout.to_f64)
inc_waiting_resource
timeout.start
# TODO update to select keyword for crystal 0.19
index, _ = Channel.select(@availability_channel.receive_select_action, timeout.receive_select_action)
case index
when 0
timeout.cancel
dec_waiting_resource
when 1
dec_waiting_resource
raise DB::PoolTimeout.new
else
raise DB::Error.new
end
end
private def inc_waiting_resource
@mutex.synchronize do
@waiting_resource += 1
end
end
private def dec_waiting_resource
@mutex.synchronize do
@waiting_resource -= 1
end
end
private def are_waiting_for_resource?
@mutex.synchronize do
@waiting_resource > 0
end
end
class TimeoutHelper
def initialize(@timeout : Float64)
@abort_timeout = false
@timeout_channel = Channel(Nil).new
end
def receive_select_action
@timeout_channel.receive_select_action
end
def start
spawn do
sleep @timeout
unless @abort_timeout
@timeout_channel.send nil
end
end
end
def cancel
@abort_timeout = true
end
end
end
end

View File

@ -0,0 +1,56 @@
module DB
# Represents a statement to be executed in any of the connections
# of the pool. The statement is not be executed in a prepared fashion.
# The execution of the statement is retried according to the pool configuration.
#
# See `PoolStatement`
class PoolPreparedStatement < PoolStatement
# connections where the statement was prepared
@connections = Set(WeakRef(Connection)).new
def initialize(db : Database, query : String)
super
# Prepares a statement on some connection
# otherwise the preparation is delayed until the first execution.
# After the first initialization the connection must be released
# it will be checked out when executing it.
statement_with_retry &.release_connection
# TODO use a round-robin selection in the pool so multiple sequentially
# initialized statements are assigned to different connections.
end
protected def do_close
# TODO close all statements on all connections.
# currently statements are closed when the connection is closed.
# WHAT-IF the connection is busy? Should each statement be able to
# deallocate itself when the connection is free.
@connections.clear
end
# builds a statement over a real connection
# the connection is registered in `@connections`
private def build_statement : Statement
clean_connections
conn, existing = @db.checkout_some(@connections)
begin
stmt = conn.prepared.build(@query)
rescue ex
conn.release
raise ex
end
@connections << WeakRef.new(conn) unless existing
stmt
end
private def clean_connections
# remove disposed or closed connections
@connections.each do |ref|
conn = ref.value
if !conn || conn.closed?
@connections.delete ref
end
end
end
end
end

View File

@ -0,0 +1,57 @@
module DB
# When a statement is to be executed in a DB that has a connection pool
# a statement from the DB needs to be able to represent a statement in any
# of the connections of the pool. Otherwise the user will need to deal with
# actual connections in some point.
abstract class PoolStatement
include StatementMethods
def initialize(@db : Database, @query : String)
end
# See `QueryMethods#exec`
def exec : ExecResult
statement_with_retry &.exec
end
# See `QueryMethods#exec`
def exec(*args) : ExecResult
statement_with_retry &.exec(*args)
end
# See `QueryMethods#exec`
def exec(args : Array) : ExecResult
statement_with_retry &.exec(args)
end
# See `QueryMethods#query`
def query : ResultSet
statement_with_retry &.query
end
# See `QueryMethods#query`
def query(*args) : ResultSet
statement_with_retry &.query(*args)
end
# See `QueryMethods#query`
def query(args : Array) : ResultSet
statement_with_retry &.query(args)
end
# See `QueryMethods#scalar`
def scalar(*args)
statement_with_retry &.scalar(*args)
end
# builds a statement over a real connection
# the conneciton is registered in `@connections`
private abstract def build_statement : Statement
private def statement_with_retry
@db.retry do
return yield build_statement
end
end
end
end

View File

@ -0,0 +1,27 @@
module DB
# Represents a statement to be executed in any of the connections
# of the pool. The statement is not be executed in a non prepared fashion.
# The execution of the statement is retried according to the pool configuration.
#
# See `PoolStatement`
class PoolUnpreparedStatement < PoolStatement
def initialize(db : Database, query : String)
super
end
protected def do_close
# unprepared statements do not need to be release in each connection
end
# builds a statement over a real connection
private def build_statement : Statement
conn = @db.pool.checkout
begin
conn.unprepared.build(@query)
rescue ex
conn.release
raise ex
end
end
end
end

View File

@ -0,0 +1,275 @@
module DB
# Methods to allow querying a database.
# All methods accepts a `query : String` and a set arguments.
#
# Three kind of statements can be performed:
# 1. `#exec` waits no record response from the database. An `ExecResult` is returned.
# 2. `#scalar` reads a single value of the response. A union of possible values is returned.
# 3. `#query` returns a `ResultSet` that allows iteration over the rows in the response and column information.
#
# Arguments can be passed by position
#
# ```
# db.query("SELECT name FROM ... WHERE age > ?", age)
# ```
#
# Convention of mapping how arguments are mapped to the query depends on each driver.
#
# Including `QueryMethods` requires a `build(query) : Statement` method that is not expected
# to be called directly.
module QueryMethods(Stmt)
# :nodoc:
abstract def build(query) : Stmt
# Executes a *query* and returns a `ResultSet` with the results.
# The `ResultSet` must be closed manually.
#
# ```
# result = db.query "select name from contacts where id = ?", 10
# begin
# if result.move_next
# id = result.read(Int32)
# end
# ensure
# result.close
# end
# ```
def query(query, *args)
build(query).query(*args)
end
# Executes a *query* and yields a `ResultSet` with the results.
# The `ResultSet` is closed automatically.
#
# ```
# db.query("select name from contacts where age > ?", 18) do |rs|
# rs.each do
# name = rs.read(String)
# end
# end
# ```
def query(query, *args)
# CHECK build(query).query(*args, &block)
rs = query(query, *args)
yield rs ensure rs.close
end
# Executes a *query* that expects a single row and yields a `ResultSet`
# positioned at that first row.
#
# The given block must not invoke `move_next` on the yielded result set.
#
# Raises `DB::Error` if there were no rows, or if there were more than one row.
#
# ```
# name = db.query_one "select name from contacts where id = ?", 18, &.read(String)
# ```
def query_one(query, *args, &block : ResultSet -> U) : U forall U
query(query, *args) do |rs|
raise DB::Error.new("no rows") unless rs.move_next
value = yield rs
raise DB::Error.new("more than one row") if rs.move_next
return value
end
end
# Executes a *query* that expects a single row and returns it
# as a tuple of the given *types*.
#
# Raises `DB::Error` if there were no rows, or if there were more than one row.
#
# ```
# db.query_one "select name, age from contacts where id = ?", 1, as: {String, Int32}
# ```
def query_one(query, *args, as types : Tuple)
query_one(query, *args) do |rs|
rs.read(*types)
end
end
# Executes a *query* that expects a single row and returns it
# as a named tuple of the given *types* (the keys of the named tuple
# are not necessarily the column names).
#
# Raises `DB::Error` if there were no rows, or if there were more than one row.
#
# ```
# db.query_one "select name, age from contacts where id = ?", 1, as: {name: String, age: Int32}
# ```
def query_one(query, *args, as types : NamedTuple)
query_one(query, *args) do |rs|
rs.read(**types)
end
end
# Executes a *query* that expects a single row
# and returns the first column's value as the given *type*.
#
# Raises `DB::Error` if there were no rows, or if there were more than one row.
#
# ```
# db.query_one "select name from contacts where id = ?", 1, as: String
# ```
def query_one(query, *args, as type : Class)
query_one(query, *args) do |rs|
rs.read(type)
end
end
# Executes a *query* that expects at most a single row and yields a `ResultSet`
# positioned at that first row.
#
# Returns `nil`, not invoking the block, if there were no rows.
#
# Raises `DB::Error` if there were more than one row
# (this ends up invoking the block once).
#
# ```
# name = db.query_one? "select name from contacts where id = ?", 18, &.read(String)
# typeof(name) # => String | Nil
# ```
def query_one?(query, *args, &block : ResultSet -> U) : U? forall U
query(query, *args) do |rs|
return nil unless rs.move_next
value = yield rs
raise DB::Error.new("more than one row") if rs.move_next
return value
end
end
# Executes a *query* that expects a single row and returns it
# as a tuple of the given *types*.
#
# Returns `nil` if there were no rows.
#
# Raises `DB::Error` if there were more than one row.
#
# ```
# result = db.query_one? "select name, age from contacts where id = ?", 1, as: {String, Int32}
# typeof(result) # => Tuple(String, Int32) | Nil
# ```
def query_one?(query, *args, as types : Tuple)
query_one?(query, *args) do |rs|
rs.read(*types)
end
end
# Executes a *query* that expects a single row and returns it
# as a named tuple of the given *types* (the keys of the named tuple
# are not necessarily the column names).
#
# Returns `nil` if there were no rows.
#
# Raises `DB::Error` if there were more than one row.
#
# ```
# result = db.query_one? "select name, age from contacts where id = ?", 1, as: {age: String, name: Int32}
# typeof(result) # => NamedTuple(age: String, name: Int32) | Nil
# ```
def query_one?(query, *args, as types : NamedTuple)
query_one?(query, *args) do |rs|
rs.read(**types)
end
end
# Executes a *query* that expects a single row
# and returns the first column's value as the given *type*.
#
# Returns `nil` if there were no rows.
#
# Raises `DB::Error` if there were more than one row.
#
# ```
# name = db.query_one? "select name from contacts where id = ?", 1, as: String
# typeof(name) # => String?
# ```
def query_one?(query, *args, as type : Class)
query_one?(query, *args) do |rs|
rs.read(type)
end
end
# Executes a *query* and yield a `ResultSet` positioned at the beginning
# of each row, returning an array of the values of the blocks.
#
# ```
# names = db.query_all "select name from contacts", &.read(String)
# ```
def query_all(query, *args, &block : ResultSet -> U) : Array(U) forall U
ary = [] of U
query_each(query, *args) do |rs|
ary.push(yield rs)
end
ary
end
# Executes a *query* and returns an array where each row is
# read as a tuple of the given *types*.
#
# ```
# contacts = db.query_all "select name, age from contacts", as: {String, Int32}
# ```
def query_all(query, *args, as types : Tuple)
query_all(query, *args) do |rs|
rs.read(*types)
end
end
# Executes a *query* and returns an array where each row is
# read as a named tuple of the given *types* (the keys of the named tuple
# are not necessarily the column names).
#
# ```
# contacts = db.query_all "select name, age from contacts", as: {name: String, age: Int32}
# ```
def query_all(query, *args, as types : NamedTuple)
query_all(query, *args) do |rs|
rs.read(**types)
end
end
# Executes a *query* and returns an array where the
# value of each row is read as the given *type*.
#
# ```
# names = db.query_all "select name from contacts", as: String
# ```
def query_all(query, *args, as type : Class)
query_all(query, *args) do |rs|
rs.read(type)
end
end
# Executes a *query* and yields the `ResultSet` once per each row.
# The `ResultSet` is closed automatically.
#
# ```
# db.query_each "select name from contacts" do |rs|
# puts rs.read(String)
# end
# ```
def query_each(query, *args)
query(query, *args) do |rs|
rs.each do
yield rs
end
end
end
# Performs the `query` and returns an `ExecResult`
def exec(query, *args)
build(query).exec(*args)
end
# Performs the `query` and returns a single scalar value
#
# ```
# puts db.scalar("SELECT MAX(name)").as(String) # => (a String)
# ```
def scalar(query, *args)
build(query).scalar(*args)
end
end
end

125
lib/db/src/db/result_set.cr Normal file
View File

@ -0,0 +1,125 @@
module DB
# The response of a query performed on a `Database`.
#
# See `DB` for a complete sample.
#
# Each `#read` call consumes the result and moves to the next column.
# Each column must be read in order.
# At any moment a `#move_next` can be invoked, meaning to skip the
# remaining, or even all the columns, in the current row.
# Also it is not mandatory to consume the whole `ResultSet`, hence an iteration
# through `#each` or `#move_next` can be stopped.
#
# **Note:** depending on how the `ResultSet` was obtained it might be mandatory an
# explicit call to `#close`. Check `QueryMethods#query`.
#
# ### Note to implementors
#
# 1. Override `#move_next` to move to the next row.
# 2. Override `#read` returning the next value in the row.
# 3. (Optional) Override `#read(t)` for some types `t` for which custom logic other than a simple cast is needed.
# 4. Override `#column_count`, `#column_name`.
abstract class ResultSet
include Disposable
# :nodoc:
getter statement
def initialize(@statement : DB::Statement)
end
protected def do_close
statement.release_connection
end
# TODO add_next_result_set : Bool
# Iterates over all the rows
def each
while move_next
yield
end
end
# Iterates over all the columns
def each_column
column_count.times do |x|
yield column_name(x)
end
end
# Move the next row in the result.
# Return `false` if no more rows are available.
# See `#each`
abstract def move_next : Bool
# TODO def empty? : Bool, handle internally with move_next (?)
# Returns the number of columns in the result
abstract def column_count : Int32
# Returns the name of the column in `index` 0-based position.
abstract def column_name(index : Int32) : String
# Returns the name of the columns.
def column_names
Array(String).new(column_count) { |i| column_name(i) }
end
# Reads the next column value
abstract def read
# Reads the next columns and maps them to a class
def read(type : DB::Mappable.class)
type.new(self)
end
# Reads the next column value as a **type**
def read(type : T.class) : T forall T
value = read
if value.is_a?(T)
value
else
raise "#{self.class}#read returned a #{value.class}. A #{T} was expected."
end
end
# Reads the next columns and returns a tuple of the values.
def read(*types : Class)
internal_read(*types)
end
# Reads the next columns and returns a named tuple of the values.
def read(**types : Class)
internal_read(**types)
end
private def internal_read(*types : *T) forall T
{% begin %}
Tuple.new(
{% for type in T %}
read({{type.instance}}),
{% end %}
)
{% end %}
end
private def internal_read(**types : **T) forall T
{% begin %}
NamedTuple.new(
{% for name, type in T %}
{{ name }}: read({{type.instance}}),
{% end %}
)
{% end %}
end
# def read_blob
# yield ... io ....
# end
# def read_text
# yield ... io ....
# end
end
end

View File

@ -0,0 +1,73 @@
module DB
# Methods that are shared accross session like objects:
# - Database
# - Connection
#
# Classes that includes this module are able to execute
# queries and statements in both prepared and unprepared fashion.
#
# This module serves for dsl reuse over session like objects.
module SessionMethods(Session, Stmt)
include QueryMethods(Stmt)
# Returns whether by default the statements should
# be prepared or not.
abstract def prepared_statements? : Bool
abstract def fetch_or_build_prepared_statement(query) : Stmt
abstract def build_unprepared_statement(query) : Stmt
def build(query) : Stmt
if prepared_statements?
fetch_or_build_prepared_statement(query)
else
build_unprepared_statement(query)
end
end
# dsl helper to build prepared statements
# returns a value that includes `QueryMethods`
def prepared
PreparedQuery(Session, Stmt).new(self)
end
# Returns a prepared `Statement` that has not been executed yet.
def prepared(query)
prepared.build(query)
end
# dsl helper to build unprepared statements
# returns a value that includes `QueryMethods`
def unprepared
UnpreparedQuery(Session, Stmt).new(self)
end
# Returns an unprepared `Statement` that has not been executed yet.
def unprepared(query)
unprepared.build(query)
end
struct PreparedQuery(Session, Stmt)
include QueryMethods(Stmt)
def initialize(@session : Session)
end
def build(query) : Stmt
@session.fetch_or_build_prepared_statement(query)
end
end
struct UnpreparedQuery(Session, Stmt)
include QueryMethods(Stmt)
def initialize(@session : Session)
end
def build(query) : Stmt
@session.build_unprepared_statement(query)
end
end
end
end

114
lib/db/src/db/statement.cr Normal file
View File

@ -0,0 +1,114 @@
module DB
# Common interface for connection based statements
# and for connection pool statements.
module StatementMethods
include Disposable
protected def do_close
end
# See `QueryMethods#scalar`
def scalar(*args)
query(*args) do |rs|
rs.each do
return rs.read
end
end
raise "no results"
end
# See `QueryMethods#query`
def query(*args)
rs = query(*args)
yield rs ensure rs.close
end
# See `QueryMethods#exec`
abstract def exec : ExecResult
# See `QueryMethods#exec`
abstract def exec(*args) : ExecResult
# See `QueryMethods#exec`
abstract def exec(args : Array) : ExecResult
# See `QueryMethods#query`
abstract def query : ResultSet
# See `QueryMethods#query`
abstract def query(*args) : ResultSet
# See `QueryMethods#query`
abstract def query(args : Array) : ResultSet
end
# Represents a query in a `Connection`.
# It should be created by `QueryMethods`.
#
# ### Note to implementors
#
# 1. Subclass `Statements`
# 2. `Statements` are created from a custom driver `Connection#prepare` method.
# 3. `#perform_query` executes a query that is expected to return a `ResultSet`
# 4. `#perform_exec` executes a query that is expected to return an `ExecResult`
# 6. `#do_close` is called to release the statement resources.
abstract class Statement
include StatementMethods
# :nodoc:
getter connection
def initialize(@connection : Connection)
end
def release_connection
@connection.release_from_statement
end
# See `QueryMethods#exec`
def exec : DB::ExecResult
perform_exec_and_release(Slice(Any).empty)
end
# See `QueryMethods#exec`
def exec(args : Array) : DB::ExecResult
perform_exec_and_release(args)
end
# See `QueryMethods#exec`
def exec(*args)
# TODO better way to do it
perform_exec_and_release(args)
end
# See `QueryMethods#query`
def query : DB::ResultSet
perform_query_with_rescue Tuple.new
end
# See `QueryMethods#query`
def query(args : Array) : DB::ResultSet
perform_query_with_rescue args
end
# See `QueryMethods#query`
def query(*args)
perform_query_with_rescue args
end
private def perform_exec_and_release(args : Enumerable) : ExecResult
return perform_exec(args)
ensure
release_connection
end
private def perform_query_with_rescue(args : Enumerable) : ResultSet
return perform_query(args)
rescue e : Exception
# Release connection only when an exception occurs during the query
# execution since we need the connection open while the ResultSet is open
release_connection
raise e
end
protected abstract def perform_query(args : Enumerable) : ResultSet
protected abstract def perform_exec(args : Enumerable) : ExecResult
end
end

View File

@ -0,0 +1,21 @@
module DB
class StringKeyCache(T)
@cache = {} of String => T
def fetch(key : String) : T
value = @cache.fetch(key, nil)
value = @cache[key] = yield unless value
value
end
def each_value
@cache.each do |_, value|
yield value
end
end
def clear
@cache.clear
end
end
end

View File

@ -0,0 +1,131 @@
module DB
# Transactions should be started from `DB#transaction`, `Connection#transaction`
# or `Connection#begin_transaction`.
#
# Use `Transaction#connection` to submit statements to the database.
#
# Use `Transaction#commit` or `Transaction#rollback` to close the ongoing transaction
# explicitly. Or refer to `BeginTransaction#transaction` for documentation on how to
# use `#transaction(&block)` methods in `DB` and `Connection`.
#
# Nested transactions are supported by using sql `SAVEPOINT`. To start a nested
# transaction use `Transaction#transaction` or `Transaction#begin_transaction`.
#
abstract class Transaction
include Disposable
include BeginTransaction
abstract def connection : Connection
# commits the current transaction
def commit
close!
end
# rollbacks the current transaction
def rollback
close!
end
private def close!
raise DB::Error.new("Transaction already closed") if closed?
close
end
abstract def release_from_nested_transaction
end
class TopLevelTransaction < Transaction
getter connection : Connection
# :nodoc:
property savepoint_name : String? = nil
def initialize(@connection : Connection)
@nested_transaction = false
@connection.perform_begin_transaction
end
def commit
@connection.perform_commit_transaction
super
end
def rollback
@connection.perform_rollback_transaction
super
end
protected def do_close
connection.release_from_transaction
end
def begin_transaction : Transaction
raise DB::Error.new("There is an existing nested transaction in this transaction") if @nested_transaction
@nested_transaction = true
create_save_point_transaction(self)
end
# :nodoc:
def create_save_point_transaction(parent : Transaction) : SavePointTransaction
# TODO should we wrap this in a mutex?
previous_savepoint = @savepoint_name
savepoint_name = if previous_savepoint
previous_savepoint.succ
else
# random prefix to avoid determinism
"cr_#{@connection.object_id}_#{Random.rand(10_000)}_00001"
end
@savepoint_name = savepoint_name
create_save_point_transaction(parent, savepoint_name)
end
protected def create_save_point_transaction(parent : Transaction, savepoint_name : String) : SavePointTransaction
SavePointTransaction.new(parent, savepoint_name)
end
# :nodoc:
def release_from_nested_transaction
@nested_transaction = false
end
end
class SavePointTransaction < Transaction
getter connection : Connection
def initialize(@parent : Transaction, @savepoint_name : String)
@nested_transaction = false
@connection = @parent.connection
@connection.perform_create_savepoint(@savepoint_name)
end
def commit
@connection.perform_release_savepoint(@savepoint_name)
super
end
def rollback
@connection.perform_rollback_savepoint(@savepoint_name)
super
end
protected def do_close
@parent.release_from_nested_transaction
end
def begin_transaction : Transaction
raise DB::Error.new("There is an existing nested transaction in this transaction") if @nested_transaction
@nested_transaction = true
create_save_point_transaction(self)
end
def create_save_point_transaction(parent : Transaction)
@parent.create_save_point_transaction(parent)
end
def release_from_nested_transaction
@nested_transaction = false
end
end
end

3
lib/db/src/db/version.cr Normal file
View File

@ -0,0 +1,3 @@
module DB
VERSION = "0.6.0"
end

514
lib/db/src/spec.cr Normal file
View File

@ -0,0 +1,514 @@
require "spec"
private def assert_single_read(rs, value_type, value)
rs.move_next.should be_true
rs.read(value_type).should eq(value)
rs.move_next.should be_false
end
module DB
# Helper class to ensure behaviour of custom drivers
#
# ```
# require "db/spec"
#
# DB::DriverSpecs(DB::Any).run do
# # How to connect to database
# connection_string "scheme://database_url"
#
# # Clean up database if needed using before/after callbacks
# before do
# # ...
# end
#
# after do
# # ...
# end
#
# # Sample values that will be stored, retrieved across many specs
# sample_value "hello", "varchar(25)", "'hello'"
#
# it "custom spec with a db initialized" do |db|
# # assert something using *db*
# end
#
# # Configure the appropiate syntax for different commands needed to run the specs
# binding_syntax do |index|
# "?"
# end
#
# create_table_1column_syntax do |table_name, col1|
# "create table #{table_name} (#{col1.name} #{col1.sql_type} #{col1.null ? "NULL" : "NOT NULL"})"
# end
# end
# ```
#
# The following methods needs to be called to configure the appropiate syntax
# for different commands and allow all the specs to run: `binding_syntax`, `create_table_1column_syntax`,
# `create_table_2columns_syntax`, `select_1column_syntax`, `select_2columns_syntax`, `select_count_syntax`,
# `select_scalar_syntax`, `insert_1column_syntax`, `insert_2columns_syntax`, `drop_table_if_exists_syntax`.
#
class DriverSpecs(DBAnyType)
record ColumnDef, name : String, sql_type : String, null : Bool
@before : Proc(Nil) = ->{}
@after : Proc(Nil) = ->{}
@encode_null = "NULL"
@support_prepared = true
@support_unprepared = true
def before(&@before : -> Nil)
end
def after(&@after : -> Nil)
end
def encode_null(@encode_null : String)
end
# Allow specs that uses prepared statements (default `true`)
def support_prepared(@support_prepared : Bool)
end
# :nodoc:
def support_prepared
@support_prepared
end
# Allow specs that uses unprepared statements (default `true`)
def support_unprepared(@support_unprepared : Bool)
end
# :nodoc:
def support_unprepared
@support_unprepared
end
# :nodoc:
macro db_spec_config(name, *, block = false)
{% if name.is_a?(TypeDeclaration) %}
@{{name.var.id}} : {{name.type}}?
{% if block %}
def {{name.var.id}}(&@{{name.var.id}} : {{name.type}})
end
{% else %}
def {{name.var.id}}(@{{name.var.id}} : {{name.type}})
end
{% end %}
# :nodoc:
def {{name.var.id}}
res = @{{name.var.id}}
raise "Missing {{name.var.id}} to setup db" unless res
res
end
{% end %}
end
db_spec_config connection_string : String
db_spec_config binding_syntax : Proc(Int32, String), block: true
db_spec_config select_scalar_syntax : Proc(String, String?, String), block: true
db_spec_config create_table_1column_syntax : Proc(String, ColumnDef, String), block: true
db_spec_config create_table_2columns_syntax : Proc(String, ColumnDef, ColumnDef, String), block: true
db_spec_config insert_1column_syntax : Proc(String, ColumnDef, String, String), block: true
db_spec_config insert_2columns_syntax : Proc(String, ColumnDef, String, ColumnDef, String, String), block: true
db_spec_config select_1column_syntax : Proc(String, ColumnDef, String), block: true
db_spec_config select_2columns_syntax : Proc(String, ColumnDef, ColumnDef, String), block: true
db_spec_config select_count_syntax : Proc(String, String), block: true
db_spec_config drop_table_if_exists_syntax : Proc(String, String), block: true
# :nodoc:
record SpecIt, description : String, prepared : Symbol, file : String, line : Int32, end_line : Int32, block : DB::Database -> Nil
getter its = [] of SpecIt
def it(description = "assert", prepared = :default, file = __FILE__, line = __LINE__, end_line = __END_LINE__, &block : DB::Database ->)
return unless Spec.matches?(description, file, line, end_line)
@its << SpecIt.new(description, prepared, file, line, end_line, block)
end
# :nodoc:
record ValueDef(T), value : T, sql_type : String, value_encoded : String
@values = [] of ValueDef(DBAnyType)
# Use *value* as sample value that should be stored in columns of type *sql_type*.
# *value_encoded* is driver specific expression that should generate that value in the database.
# *type_safe_value* indicates whether *value_encoded* is expected to generate the *value* even without
# been stored in a table (default `true`).
def sample_value(value, sql_type, value_encoded, *, type_safe_value = true)
@values << ValueDef(DBAnyType).new(value, sql_type, value_encoded)
it "select nil as (#{typeof(value)} | Nil)", prepared: :both do |db|
db.query select_scalar(encode_null, nil) do |rs|
assert_single_read rs, typeof(value || nil), nil
end
end
value_desc = value.to_s
value_desc = "#{value_desc[0..25]}...(#{value_desc.size})" if value_desc.size > 25
value_desc = "#{value_desc} as #{sql_type}"
if type_safe_value
it "executes with bind #{value_desc}" do |db|
db.scalar(select_scalar(param(1), sql_type), value).should eq(value)
end
it "executes with bind #{value_desc} as array" do |db|
db.scalar(select_scalar(param(1), sql_type), [value]).should eq(value)
end
it "select #{value_desc} as literal" do |db|
db.scalar(select_scalar(value_encoded, sql_type)).should eq(value)
db.query select_scalar(value_encoded, sql_type) do |rs|
assert_single_read rs, typeof(value), value
end
end
end
it "insert/get value #{value_desc} from table", prepared: :both do |db|
db.exec sql_create_table_table1(c1 = col1(sql_type))
db.exec sql_insert_table1(c1, value_encoded)
db.query_one(sql_select_table1(c1), as: typeof(value)).should eq(value)
end
it "insert/get value #{value_desc} from table as nillable", prepared: :both do |db|
db.exec sql_create_table_table1(c1 = col1(sql_type))
db.exec sql_insert_table1(c1, value_encoded)
db.query_one(sql_select_table1(c1), as: ::Union(typeof(value) | Nil)).should eq(value)
end
it "insert/get value nil from table as nillable #{sql_type}", prepared: :both do |db|
db.exec sql_create_table_table1(c1 = col1(sql_type, null: true))
db.exec sql_insert_table1(c1, encode_null)
db.query_one(sql_select_table1(c1), as: ::Union(typeof(value) | Nil)).should eq(nil)
end
it "insert/get value #{value_desc} from table with binding" do |db|
db.exec sql_create_table_table2(c1 = col1(sql_type_for(String)), c2 = col2(sql_type))
# the next statement will force a union in the *args
db.exec sql_insert_table2(c1, param(1), c2, param(2)), value_for(String), value
db.query_one(sql_select_table2(c2), as: typeof(value)).should eq(value)
end
it "insert/get value #{value_desc} from table as nillable with binding" do |db|
db.exec sql_create_table_table2(c1 = col1(sql_type_for(String)), c2 = col2(sql_type))
# the next statement will force a union in the *args
db.exec sql_insert_table2(c1, param(1), c2, param(2)), value_for(String), value
db.query_one(sql_select_table2(c2), as: ::Union(typeof(value) | Nil)).should eq(value)
end
it "insert/get value nil from table as nillable #{sql_type} with binding" do |db|
db.exec sql_create_table_table2(c1 = col1(sql_type_for(String)), c2 = col2(sql_type, null: true))
db.exec sql_insert_table2(c1, param(1), c2, param(2)), value_for(String), nil
db.query_one(sql_select_table2(c2), as: ::Union(typeof(value) | Nil)).should eq(nil)
end
it "can use read(#{typeof(value)}) with DB::ResultSet", prepared: :both do |db|
db.exec sql_create_table_table1(c1 = col1(sql_type))
db.exec sql_insert_table1(c1, value_encoded)
db.query(sql_select_table1(c1)) do |rs|
assert_single_read rs.as(DB::ResultSet), typeof(value), value
end
end
it "can use read(#{typeof(value)}?) with DB::ResultSet", prepared: :both do |db|
db.exec sql_create_table_table1(c1 = col1(sql_type))
db.exec sql_insert_table1(c1, value_encoded)
db.query(sql_select_table1(c1)) do |rs|
assert_single_read rs.as(DB::ResultSet), ::Union(typeof(value) | Nil), value
end
end
it "can use read(#{typeof(value)}?) with DB::ResultSet for nil", prepared: :both do |db|
db.exec sql_create_table_table1(c1 = col1(sql_type, null: true))
db.exec sql_insert_table1(c1, encode_null)
db.query(sql_select_table1(c1)) do |rs|
assert_single_read rs.as(DB::ResultSet), ::Union(typeof(value) | Nil), nil
end
end
end
# :nodoc:
def include_shared_specs
it "connects using connection_string" do |db|
db.is_a?(DB::Database)
end
it "can create direct connection" do
DB.connect(connection_string) do |cnn|
cnn.is_a?(DB::Connection)
cnn.scalar(select_scalar(encode_null, nil)).should be_nil
end
end
it "binds nil" do |db|
# PG is unable to perform this query without a type annotation
db.scalar(select_scalar(param(1), sql_type_for(String)), nil).should be_nil
end
it "selects nil as scalar", prepared: :both do |db|
db.scalar(select_scalar(encode_null, nil)).should be_nil
end
it "gets column count", prepared: :both do |db|
db.exec sql_create_table_person
db.query "select * from person" do |rs|
rs.column_count.should eq(2)
end
end
it "gets column name", prepared: :both do |db|
db.exec sql_create_table_person
db.query "select name, age from person" do |rs|
rs.column_name(0).should eq("name")
rs.column_name(1).should eq("age")
end
end
it "gets many rows from table" do |db|
db.exec sql_create_table_person
db.exec sql_insert_person, "foo", 10
db.exec sql_insert_person, "bar", 20
db.exec sql_insert_person, "baz", 30
names = [] of String
ages = [] of Int32
db.query sql_select_person do |rs|
rs.each do
names << rs.read(String)
ages << rs.read(Int32)
end
end
names.should eq(["foo", "bar", "baz"])
ages.should eq([10, 20, 30])
end
# describe "transactions" do
it "transactions: can read inside transaction and rollback after" do |db|
db.exec sql_create_table_person
db.transaction do |tx|
tx.connection.scalar(sql_select_count_person).should eq(0)
tx.connection.exec sql_insert_person, "John Doe", 10
tx.connection.scalar(sql_select_count_person).should eq(1)
tx.rollback
end
db.scalar(sql_select_count_person).should eq(0)
end
it "transactions: can read inside transaction or after commit" do |db|
db.exec sql_create_table_person
db.transaction do |tx|
tx.connection.scalar(sql_select_count_person).should eq(0)
tx.connection.exec sql_insert_person, "John Doe", 10
tx.connection.scalar(sql_select_count_person).should eq(1)
# using other connection
db.scalar(sql_select_count_person).should eq(0)
end
db.scalar("select count(*) from person").should eq(1)
end
# end
# describe "nested transactions" do
it "nested transactions: can read inside transaction and rollback after" do |db|
db.exec sql_create_table_person
db.transaction do |tx_0|
tx_0.connection.scalar(sql_select_count_person).should eq(0)
tx_0.connection.exec sql_insert_person, "John Doe", 10
tx_0.transaction do |tx_1|
tx_1.connection.exec sql_insert_person, "Sarah", 11
tx_1.connection.scalar(sql_select_count_person).should eq(2)
tx_1.transaction do |tx_2|
tx_2.connection.exec sql_insert_person, "Jimmy", 12
tx_2.connection.scalar(sql_select_count_person).should eq(3)
tx_2.rollback
end
end
tx_0.connection.scalar(sql_select_count_person).should eq(2)
tx_0.rollback
end
db.scalar(sql_select_count_person).should eq(0)
end
# end
end
# :nodoc:
def with_db(options = nil)
@before.call
DB.open("#{connection_string}#{"?#{options}" if options}") do |db|
db.exec(sql_drop_table("table1"))
db.exec(sql_drop_table("table2"))
db.exec(sql_drop_table("person"))
yield db
end
ensure
@after.call
end
# :nodoc:
def select_scalar(expression, sql_type)
select_scalar_syntax.call(expression, sql_type)
end
# :nodoc:
def param(index)
binding_syntax.call(index)
end
# :nodoc:
def encode_null
@encode_null
end
# :nodoc:
def sql_type_for(a_class)
value = @values.select { |v| v.value.class == a_class }.first?
if value
value.sql_type
else
raise "missing sample_value with #{a_class}"
end
end
# :nodoc:
macro value_for(a_class)
_value_for({{a_class}}).as({{a_class}})
end
# :nodoc:
def _value_for(a_class)
value = @values.select { |v| v.value.class == a_class }.first?
if value
value.value
else
raise "missing sample_value with #{a_class}"
end
end
# :nodoc:
def col_name
ColumnDef.new("name", sql_type_for(String), false)
end
# :nodoc:
def col_age
ColumnDef.new("age", sql_type_for(Int32), false)
end
# :nodoc:
def sql_create_table_person
create_table_2columns_syntax.call("person", col_name, col_age)
end
# :nodoc:
def sql_select_person
select_2columns_syntax.call("person", col_name, col_age)
end
# :nodoc:
def sql_insert_person
insert_2columns_syntax.call("person", col_name, param(1), col_age, param(2))
end
# :nodoc:
def sql_select_count_person
select_count_syntax.call("person")
end
# :nodoc:
def col1(sql_type, *, null = false)
ColumnDef.new("col1", sql_type, null)
end
# :nodoc:
def col2(sql_type, *, null = false)
ColumnDef.new("col2", sql_type, null)
end
# :nodoc:
def sql_create_table_table1(col : ColumnDef)
create_table_1column_syntax.call("table1", col)
end
# :nodoc:
def sql_create_table_table2(col1 : ColumnDef, col2 : ColumnDef)
create_table_2columns_syntax.call("table2", col1, col2)
end
# :nodoc:
def sql_insert_table1(col1 : ColumnDef, expression)
insert_1column_syntax.call("table1", col1, expression)
end
# :nodoc:
def sql_insert_table2(col1 : ColumnDef, expr1, col2 : ColumnDef, expr2)
insert_2columns_syntax.call("table2", col1, expr1, col2, expr2)
end
# :nodoc:
def sql_select_table1(col : ColumnDef)
select_1column_syntax.call("table1", col)
end
# :nodoc:
def sql_select_table2(col : ColumnDef)
select_1column_syntax.call("table2", col)
end
# :nodoc:
def sql_drop_table(table_name)
drop_table_if_exists_syntax.call(table_name)
end
def self.run(description = "as a db")
ctx = self.new
with ctx yield
describe description do
ctx.include_shared_specs
ctx.its.each do |db_it|
case db_it.prepared
when :default
it(db_it.description, db_it.file, db_it.line, db_it.end_line) do
ctx.with_db do |db|
db_it.block.call db
nil
end
end
when :both
values = [] of Bool
values << true if ctx.support_prepared
values << false if ctx.support_unprepared
case values.size
when 0
raise "Neither prepared non unprepared statements allowed"
when 1
it(db_it.description, db_it.file, db_it.line, db_it.end_line) do
ctx.with_db do |db|
db_it.block.call db
nil
end
end
else
values.each do |prepared_statements|
it("#{db_it.description} (prepared_statements=#{prepared_statements})", db_it.file, db_it.line, db_it.end_line) do
ctx.with_db "prepared_statements=#{prepared_statements}" do |db|
db_it.block.call db
nil
end
end
end
end
end
end
end
end
end
end

View File

@ -0,0 +1,9 @@
root = true
[*.cr]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true

9
lib/exception_page/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
/docs/
/lib/
/bin/
/.shards/
*.dwarf
# Libraries don't need dependency lock
# Dependencies will be locked in application that uses them
/shard.lock

View File

@ -0,0 +1,13 @@
language: crystal
addons:
chrome: stable
before_install:
# Setup chromedriver for LuckyFlow
- sudo apt-get install chromium-chromedriver
- sudo ln -s /usr/lib/chromium-browser/chromedriver /usr/bin/chromedriver
- "export DISPLAY=:99.0"
- "sh -e /etc/init.d/xvfb start"
- sleep 3 # give xvfb some time to start
script:
- crystal spec
- crystal tool format spec src --check

View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2018 Paul Smith
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -0,0 +1,111 @@
# Exception Page
A library for displaying exceptional exception pages for easier debugging.
![screen shot 2018-06-29 at 2 39 18 pm](https://user-images.githubusercontent.com/22394/42109073-6e767d06-7baa-11e8-9ec9-0a2afce605be.png)
## Installation
Add this to your application's `shard.yml`:
```yaml
dependencies:
exception_page:
github: crystal-loot/exception_page
```
## Usage
Require the shard:
```crystal
require "exception_page"
```
Create an exception page:
```crystal
class MyApp::ExceptionPage < ExceptionPage
def styles
ExceptionPage::Styles.new(
accent: "purple", # Choose the HTML color value. Can be hex
)
end
end
```
Render the HTML when an exception occurs:
```crystal
class MyErrorHandler
include HTTP::Handler
def call_next(context)
begin
# Normally you'd call some code to handle the request
# We're hard-coding an error here to show you how to use the lib.
raise SomeError.new("Something went wrong")
rescue e
context.response.status_code = 500
context.response.print MyApp::ExceptionPage.for_runtime_exception(context, e).to_s
end
end
```
## Customizing the page
```crystal
class MyApp::ExceptionPage < ExceptionPage
def styles
ExceptionPage::Styles.new(
accent: "purple", # Required
highlight: "gray", # Optional
flash_highlight: "red", # Optional
logo_uri: "base64_encoded_data_uri" # Optional. Defaults to Crystal logo. Generate a logo here: https://dopiaza.org/tools/datauri/index.php
)
end
# Optional. If provided, clicking the logo will open this page
def project_url
"https://myproject.com"
end
# Optional
def stack_trace_heading_html
<<-HTML
<a href="#" onclick="sayHi()">Say hi</a>
HTML
end
# Optional
def extra_javascript
<<-JAVASCRIPT
window.sayHi = function() {
alert("Say Hi!");
}
JAVASCRIPT
end
end
```
## Development
TODO: Write development instructions here
## Contributing
1. Fork it (<https://github.com/crystal-loot/exception_page/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
## Contributors
- [@paulcsmith](https://github.com/paulcsmith) Paul Smith
- [@faustinoaq](https://github.com/faustinoaq) Faustino Aigular - Wrote the initial [Amber PR adding exception pages](https://github.com/amberframework/amber/pull/864)
## Special Thanks
This exception page is heavily based on the [Phoenix error page](https://github.com/phoenixframework/phoenix/issues/1776)
by [@rstacruz](https://github.com/rstacruz). Thanks to the Phoenix team and @rstacruz!

View File

@ -0,0 +1,15 @@
name: exception_page
version: 0.1.2
authors:
- Paul Smith <paulcsmith0218@gmail.com>
- Faustino Aguilar
development_dependencies:
lucky_flow:
github: luckyframework/lucky_flow
version: ~> 0.2.0
crystal: 0.25.0
license: MIT

View File

@ -0,0 +1,33 @@
require "./spec_helper"
describe ExceptionPage do
it "allows debugging the exception page" do
flow = ErrorDebuggingFlow.new
flow.view_error_page
flow.should_have_information_for_debugging
flow.show_all_frames
flow.should_be_able_to_view_other_frames
end
end
class ErrorDebuggingFlow < LuckyFlow
def view_error_page
visit "/"
end
def should_have_information_for_debugging
el("@exception-title", text: "Something went very wrong").should be_on_page
el("@code-frames", text: "test_server.cr").should be_on_page
el("@code-preview").should be_on_page
end
def show_all_frames
el("@show-all-frames").click
end
def should_be_able_to_view_other_frames
el("@code-frame-file", "request_processor.cr").click
el("@code-frame-summary", text: "request_processor.cr").should be_on_page
end
end

View File

@ -0,0 +1,27 @@
require "./spec_helper"
describe "Frame parsing" do
it "returns the correct label" do
frame = frame_for("from usr/crystal-lang/frame_spec.cr:6:7 in '->'")
frame.label.should eq("crystal")
frame = frame_for("from usr/crystal/frame_spec.cr:6:7 in '->'")
frame.label.should eq("crystal")
frame = frame_for("from lib/exception_page/spec/frame_spec.cr:6:7 in '->'")
frame.label.should eq("exception_page")
frame = frame_for("from lib/exception_page/frame_spec.cr:6:7 in '->'")
frame.label.should eq("exception_page")
frame = frame_for("from lib/frame_spec.cr:6:7 in '->'")
frame.label.should eq("app")
frame = frame_for("from src/frame_spec.cr:6:7 in '->'")
frame.label.should eq("app")
end
end
private def frame_for(backtrace_line)
ExceptionPage::FrameGenerator.generate_frames(backtrace_line).first
end

View File

@ -0,0 +1,25 @@
require "spec"
require "lucky_flow"
require "http"
require "../src/exception_page"
require "./support/**"
include LuckyFlow::Expectations
server = TestServer.new(3002)
LuckyFlow.configure do |settings|
settings.base_uri = "http://localhost:3002"
settings.stop_retrying_after = 40.milliseconds
end
spawn do
server.listen
end
at_exit do
LuckyFlow.shutdown
server.close
end
Habitat.raise_if_missing_settings!

View File

@ -0,0 +1,19 @@
class MyApp::ExceptionPage < ExceptionPage
def styles
Styles.new(accent: "purple")
end
def stack_trace_heading_html
<<-HTML
<a href="#" onclick="sayHi()">Say hi</a>
HTML
end
def extra_javascript
<<-JAVASCRIPT
window.sayHi = function() {
alert("Say Hi!");
}
JAVASCRIPT
end
end

View File

@ -0,0 +1,22 @@
class TestServer
delegate listen, close, to: @server
def initialize(port : Int32)
@server = HTTP::Server.new do |context|
if context.request.resource == "/favicon.ico"
context.response.print ""
else
begin
raise CustomException.new("Something went very wrong")
rescue e : CustomException
context.response.content_type = "text/html"
context.response.print MyApp::ExceptionPage.for_runtime_exception(context, e).to_s
end
end
end
@server.bind_tcp port: port
end
end
class CustomException < Exception
end

View File

@ -0,0 +1,54 @@
abstract class ExceptionPage
end
require "ecr"
require "./exception_page/*"
# :nodoc:
abstract class ExceptionPage
@params : Hash(String, String)
@headers : Hash(String, Array(String))
@session : Hash(String, HTTP::Cookie)
@method : String
@path : String
@message : String
@query : String
@frames = [] of Frame
@title : String
abstract def styles : Styles
# Add an optional link to your project
def project_url : String?
nil
end
# Override this method to add extra HTML to the top of the stack trace heading
def stack_trace_heading_html
""
end
# Override this method to add extra javascript to the page
def extra_javascript
""
end
# :nodoc:
def initialize(context : HTTP::Server::Context, @message, @title, @frames)
@params = context.request.query_params.to_h
@headers = context.response.headers.to_h
@method = context.request.method
@path = context.request.path
@url = "#{context.request.host_with_port}#{context.request.path}"
@query = context.request.query_params.to_s
@session = context.response.cookies.to_h
end
def self.for_runtime_exception(context : HTTP::Server::Context, ex : Exception)
title = "Error #{context.response.status_code}"
frames = FrameGenerator.generate_frames(ex.inspect_with_backtrace)
new(context, ex.message.to_s, title: title, frames: frames)
end
ECR.def_to_s "#{__DIR__}/exception_page/exception_page.ecr"
end

View File

@ -0,0 +1,855 @@
<!-- Copyright (c) 2013 Plataformatec.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License. -->
<%-
monospace_font = "menlo, consolas, monospace"
-%>
<!DOCTYPE html>
<html>
<head>
<%
details = @message.split('\n')
headline = details.first
%>
<meta charset="utf-8">
<title><%= @title %> at <%= @method %> <%= @path %> - <%= headline %></title>
<meta name="viewport" content="width=device-width">
<style>/*! normalize.css v4.2.0 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block}audio:not([controls]){display:none;height:0}progress{vertical-align:baseline}template,[hidden]{display:none}a{background-color:transparent;-webkit-text-decoration-skip:objects}a:active,a:hover{outline-width:0}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:inherit}b,strong{font-weight:bolder}dfn{font-style:italic}h1{font-size:2em;margin:0.67em 0}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-0.25em}sup{top:-0.5em}img{border-style:none}svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-family:monospace, monospace;font-size:1em}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;overflow:visible}button,input,optgroup,select,textarea{font:inherit;margin:0}optgroup{font-weight:bold}button,input{overflow:visible}button,select{text-transform:none}button,html [type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button}button::-moz-focus-inner,[type="button"]::-moz-focus-inner,[type="reset"]::-moz-focus-inner,[type="submit"]::-moz-focus-inner{border-style:none;padding:0}button:-moz-focusring,[type="button"]:-moz-focusring,[type="reset"]:-moz-focusring,[type="submit"]:-moz-focusring{outline:1px dotted ButtonText}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:0.35em 0.625em 0.75em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}textarea{overflow:auto}[type="checkbox"],[type="radio"]{box-sizing:border-box;padding:0}[type="number"]::-webkit-inner-spin-button,[type="number"]::-webkit-outer-spin-button{height:auto}[type="search"]{-webkit-appearance:textfield;outline-offset:-2px}[type="search"]::-webkit-search-cancel-button,[type="search"]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-input-placeholder{color:inherit;opacity:0.54}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}</style>
<style>
html, body, td, input {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
}
* {
box-sizing: border-box;
}
html {
font-size: 15px;
line-height: 1.6;
background: #fff;
color: #271708;
}
@media (max-width: 768px) {
html {
font-size: 14px;
}
}
@media (max-width: 480px) {
html {
font-size: 13px;
}
}
button:focus,
summary:focus {
outline: 0;
}
summary {
cursor: pointer;
}
pre {
font-family: <%= monospace_font %>;
overflow: auto;
max-width: 100%;
}
.top-details {
padding: 48px;
background: #f9f9fa;
}
.top-details,
.conn-info {
padding: 48px;
}
@media (max-width: 768px) {
.top-details,
.conn-info {
padding: 32px;
}
}
@media (max-width: 480px) {
.top-details,
.conn-info {
padding: 16px;
}
}
/*
* Exception logo
*/
.exception-logo {
position: absolute;
right: 48px;
top: 48px;
width: 64px;
}
.exception-logo:before {
content: '';
display: block;
height: 64px;
width: 100%;
background-size: auto 100%;
<%- if styles.logo_uri -%>
background-image: url("<%= styles.logo_uri %>");
<%- end -%>
background-position: right 0;
background-repeat: no-repeat;
}
@media (max-width: 768px) {
.exception-logo {
position: static;
}
.exception-logo:before {
height: 32px;
background-position: left 0;
}
}
@media (max-width: 480px) {
.exception-logo {
display: none;
}
}
/*
* Exception info
*/
/* Compensate for logo placement */
@media (min-width: 769px) {
.exception-info {
max-width: 90%;
}
}
.exception-info > .struct,
.exception-info > .title,
.exception-info > .detail {
margin: 0;
padding: 0;
}
.exception-info > .struct {
font-size: 1em;
font-weight: 700;
color: <%= styles.accent %>;
}
.exception-info > .struct > small {
font-size: 1em;
color: #a0a0a0;
font-weight: 400;
}
.exception-info > .title {
font-size: <%= 1.2 ** 4 %>em;
line-height: 1.4;
font-weight: 300;
color: <%= styles.accent %>;
}
@media (max-width: 768px) {
.exception-info > .title {
font-size: <%= 1.15 ** 4 %>em;
}
}
@media (max-width: 480px) {
.exception-info > .title {
font-size: <%= 1.1 ** 4 %>em;
}
}
.exception-info > .detail {
margin-top: 1.3em;
white-space: pre;
}
/*
* Code explorer
*/
.code-explorer {
margin: 32px 0 0 0;
}
@media (max-width: 768px) {
.code-explorer {
margin-top: 16px;
}
}
.code-explorer:after {
content: '';
display: table;
clear: both;
zoom: 1;
}
.code-explorer > .code-snippets {
float: left;
width: 45%;
}
.code-explorer > .stack-trace {
float: right;
width: 55%;
padding-left: 32px;
}
/* Collapse to single-column */
@media (max-width: 960px) {
.code-explorer > .code-snippets {
float: none;
width: auto;
margin-bottom: 16px;
}
.code-explorer > .stack-trace {
float: none;
width: auto;
padding-left: 0;
}
}
/*
* Snippets
*/
.code-snippets {
}
/*
* Frame info:
* Holds the code (code-block) and more
*/
.frame-info {
background: white;
box-shadow:
0 1px 3px rgba(80, 100, 140, .1),
0 8px 15px rgba(80, 100, 140, .05);
}
.frame-info > .meta,
.frame-info > .file {
padding: 12px 16px;
white-space: no-wrap;
font-size: <%= 1.2 ** -1 %>em;
}
@media (max-width: 480px) {
.frame-info > .meta,
.frame-info > .file {
padding: 6px 16px;
font-size: <%= 1.1 ** -1 %>em;
}
}
.frame-info > .file > a {
text-decoration: none;
color: #271708;
font-weight: 700;
}
.frame-info > .code {
border-top: solid 1px #eee;
border-bottom: solid 1px #eee;
}
/* Hiding */
.frame-info {
display: none;
}
.frame-info.-active {
display: block;
}
.frame-info > details.meta {
padding: 0;
}
.frame-info > details.meta > summary {
padding: 12px 16px;
}
/*
* Frame details
*/
.frame-summary.-short {
color: #a0a0a0;
}
.frame-summary > .app {
color: <%= styles.accent %>;
font-weight: 700;
}
.frame-summary > .app:after {
content: '·';
margin: 0 .2em;
}
/*
* Code block:
* The `pre` that holds the code
*/
.code-block {
margin: 0;
padding: 12px 0;
font-size: .8em;
line-height: 1.4;
white-space: normal;
}
.code-block > .line {
white-space: pre;
display: block;
padding: 0 16px;
}
/* Line highlight */
.code-block > .line.-highlight {
background-color: <%= styles.highlight %>;
-webkit-animation: line-highlight 750ms linear;
animation: line-highlight 750ms linear;
}
@-webkit-keyframes line-highlight {
0% { background-color: <%= styles.highlight %>; }
25% { background-color: <%= styles.flash_highlight %>; }
50% { background-color: <%= styles.highlight %>; }
75% { background-color: <%= styles.flash_highlight %>; }
}
@keyframes line-highlight {
0% { background-color: <%= styles.highlight %>; }
25% { background-color: <%= styles.flash_highlight %>; }
50% { background-color: <%= styles.highlight %>; }
75% { background-color: <%= styles.flash_highlight %>; }
}
.code-block > .line > .ln {
color: #a0a0a0;
margin-right: 1.5em;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.code-block > .line > .code {
font-family: <%= monospace_font %>;
}
/*
* Empty code
*/
.code-block-empty {
text-align: center;
color: #a0a0a0;
padding-top: 48px;
padding-bottom: 48px;
}
/*
* Stack trace heading
*/
.stack-trace-heading {
padding-top: 8px;
}
.stack-trace-heading:after {
content: '';
display: block;
clear: both;
zoom: 1;
border-bottom: solid 1px #eee;
padding-top: 12px;
margin-bottom: 16px;
}
.stack-trace-heading > h3 {
display: none;
}
.stack-trace-heading > label {
display: block;
padding-left: 8px;
line-height: 1.9;
font-size: <%= 1.2 ** -1 %>em;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.stack-trace-heading > label > input {
margin-right: .3em;
}
@media (max-width: 480px) {
.stack-trace-heading > label {
font-size: <%= 1.1 ** -1 %>em;
}
}
/*
* Stack trace
*/
.stack-trace-list,
.stack-trace-list > li {
margin: 0;
padding: 0;
list-style-type: none;
}
.stack-trace-list > li > .stack-trace-item.-all {
display: none;
}
.stack-trace-list.-show-all > li > .stack-trace-item.-all {
display: block;
}
/*
* Stack trace item:
* The clickable line to inspect a stack trace
*/
.stack-trace-item {
font-size: <%= 1.2 ** -1 %>em;
display: block;
width: 100%;
border: 0;
margin: 0;
padding: 4px 8px;
background: transparent;
cursor: pointer;
text-align: left;
overflow: hidden;
white-space: nowrap;
}
.stack-trace-item:hover,
.stack-trace-item:focus {
background-color: rgba(80, 100, 140, 0.05);
}
.stack-trace-item,
.stack-trace-item:active {
color: #271708;
}
.stack-trace-item:active {
background-color: rgba(80, 100, 140, 0.1);
}
.stack-trace-item.-active {
background-color: white;
}
/* Circle */
.stack-trace-item > .left:before {
content: '';
display: inline-block;
width: 8px;
height: 8px;
background: #a0a0a0;
border-radius: 50%;
margin-right: 8px;
}
.stack-trace-item.-app > .left:before {
background: <%= styles.accent %>;
opacity: 1;
}
.stack-trace-item.-app > .left > .app {
display: none;
}
.stack-trace-item > .left {
float: left;
max-width: 55%;
}
.stack-trace-item > .info {
color: #a0a0a0;
float: right;
max-width: 45%;
}
.stack-trace-item > .left,
.stack-trace-item > .info {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stack-trace-item > .left > .filename > .line {
color: #a0a0a0;
}
/* App name */
.stack-trace-item > .left > .app {
color: #a0a0a0;
}
.stack-trace-item > .left > .app:after {
content: '·';
margin: 0 .2em;
}
/*
* Code as a blockquote:
* Like `pre` but with wrapping
*/
.code-quote {
font-family: <%= monospace_font %>;
font-size: <%= 1.2 ** -1 %>em;
margin: 0;
overflow: auto;
max-width: 100%;
word-wrap: break-word;
white-space: normal;
}
.code-quote.-padded {
padding: 0 16px 16px 16px;
}
/*
* Conn info
*/
.conn-info {
border-top: solid 1px #eee;
}
/*
* Conn details
*/
.conn-details {
}
.conn-details + .conn-details {
margin-top: 16px;
}
.conn-details > summary {
}
.conn-details > dl {
display: block;
overflow: hidden;
margin: 0;
padding: 4px 0;
border-bottom: solid 1px #eee;
white-space: nowrap;
text-overflow: ellipsis;
}
.conn-details > dl:first-of-type {
margin-top: 16px;
border-top: solid 1px #eee;
}
/* Term */
.conn-details > dl > dt {
width: 20%;
float: left;
font-size: <%= 1.2 ** -1 %>em;
color: #a0a0a0;
overflow: hidden;
text-overflow: ellipsis;
position: relative;
top: -1px; /* Compensate for font metrics */
}
/* Definition */
.conn-details > dl > dd {
width: 80%;
float: left;
}
@media (max-width: 480px) {
.conn-details > dl > dt {
font-size: <%= 1.1 ** -1 %>em;
}
}
</style>
</head>
<body>
<div class="top-details">
<%- if project_url -%>
<a class="exception-logo" href="<%= project_url %>" target="_blank"></a>
<%- else %>
<div class="exception-logo"></div>
<%- end %>
<header class="exception-info" flow-id="exception-title">
<h5 class="struct">
<%= @title %>
<small>at <%= @method %> <%= @path %></small>
</h5>
<h1 class="title"><%= HTML.escape(headline).gsub("&#39;", '\'').gsub("&quot;", '"') %></h1>
<details class="meta">
<summary class="frame-summary">
See raw message
</summary>
<pre class="code code-block" flow-id="code-preview"><%- details.each do |detail| -%><span class="line"><span class="code"><%= HTML.escape(detail).gsub("&#39;", '\'').gsub("&quot;", '"') %></span></span>
<%- end -%>
</pre>
</details>
</header>
<% if !@frames.empty? %>
<div class="code-explorer">
<div class="code-snippets" flow-id="code-frames">
<% @frames.each do |frame| %>
<div class="frame-info" data-index="<%= frame.index %>" role="stack-trace-details">
<div class="file">
<a href="#"><%= frame.file %></a>
</div>
<%- if !frame.snippets.empty? -%>
<pre class="code code-block">
<%- frame.snippets.each do |snippet| -%>
<span class="line <% if snippet.highlight %>-highlight<% end %>"><span class="ln"><%= snippet.line %></span><span class="code"><%= HTML.escape(snippet.code.rstrip).gsub("&#39;", '\'').gsub("&quot;", '"') %></span></span>
<%- end -%>
</pre>
<%- else -%>
<div class="code code-block-empty">No code available.</div>
<%- end -%>
<% if !frame.args.blank? %>
<details class="meta">
<summary class="frame-summary" flow-id="code-frame-summary">
<span class="app"><%= frame.label %></span>
<%= frame.filename %>
</summary>
<blockquote class="code-quote -padded"><%= HTML.escape(frame.args).gsub("&#39;", '\'').gsub("&quot;", '"') %></blockquote>
</details>
<% else %>
<div class="meta">
<div class="frame-summary -short">
<span class="app"><%= frame.label %></span>
<%= frame.filename %>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
<div class="stack-trace">
<div class="stack-trace-heading">
<%= stack_trace_heading_html %>
<label><input type="checkbox" role="show-all-toggle" flow-id="show-all-frames"> Show all frames</label>
</div>
<ul class="stack-trace-list" role="stack-trace-list">
<% @frames.each do |frame| %>
<li>
<button flow-id="code-frame-file" class="stack-trace-item -<%= frame.context %>" role="stack-trace-item" data-index="<%= frame.index %>">
<span class="left">
<span class="app"><%= frame.label %></span>
<span class="filename">
<%= frame.file %><% if frame.line %><span class="line">:<%= frame.line %></span><% end %>
</span>
</span>
<span class="info"><%= frame.filename %></span>
</button>
</li>
<% end %>
</ul>
</div>
</div>
<% end %>
</div>
<div class="conn-info">
<% if @params && !@params.empty? %>
<details class="conn-details" open>
<summary>Params</summary>
<% @params.each do |key, value| %>
<dl>
<dt><%= key %></dt>
<dd><pre><%= value.inspect %></pre></dd>
</dl>
<% end %>
</details>
<% end %>
<details class="conn-details">
<summary>Request info</summary>
<dl>
<dt>URI:</dt>
<dd class="code-quote"><%= @url %></dd>
</dl>
<dl>
<dt>Query string:</dt>
<dd class="code-quote"><%= @query %></dd>
</dl>
</details>
<details class="conn-details">
<summary>Headers</summary>
<% @headers.each do |key, value| %>
<dl>
<dt><%= key %></dt>
<dd class="code-quote"><%= value %></dd>
</dl>
<% end %>
</details>
<% if (session = @session) && !session.empty? %>
<details class="conn-details">
<summary>Session</summary>
<% session.each do |key, value| %>
<dl>
<dt><%= key %></dt>
<dd><pre><%= value.inspect %></pre></dd>
</dl>
<% end %>
</details>
<% end %>
</div>
<script>
(function () {
var items = document.querySelectorAll('[role~="stack-trace-item"]');
var toggle = document.querySelector('[role~="show-all-toggle"]');
var list = document.querySelector('[role~="stack-trace-list"]');
each(items, function (item) {
on(item, 'click', itemOnclick);
})
on(toggle, 'click', toggleOnclick);
// Auto-check "show all" if there are no app frames.
if (document.querySelectorAll('[role~="stack-trace-list"] .-app').length === 0) {
toggle.checked = true;
toggleOnclick.call(toggle);
}
function toggleOnclick() {
if (this.checked) {
addClass(list, '-show-all');
} else {
removeClass(list, '-show-all');
}
}
function itemOnclick() {
var idx = this.getAttribute('data-index')
var detail = document.querySelector('[role~="stack-trace-details"].-active');
if (detail) {
removeClass(detail, '-active');
}
detail = document.querySelector('[role~="stack-trace-details"][data-index="' + idx + '"]')
if (detail) {
addClass(detail, '-active')
}
var item = document.querySelector('[role~="stack-trace-item"].-active')
if (item) {
removeClass(item, '-active')
}
item = document.querySelector('[role~="stack-trace-item"][data-index="' + idx + '"]')
if (item) {
addClass(item, '-active')
}
}
var first =
document.querySelector('[role~="stack-trace-item"].-app:first-of-type') ||
document.querySelector('[role~="stack-trace-item"]:first-of-type');
if (first) {
itemOnclick.call(first);
}
/*
* Helpers
*/
function each(list, fn) {
for (var i = 0, len = list.length; i < len; i++) {
var item = list[i];
fn(item);
}
}
function addClass(el, className) {
if (el.classList) {
el.classList.add(className);
} else {
el.className += ' ' + className;
}
}
function removeClass(el, className) {
if (el.classList) {
el.classList.remove(className);
} else {
var expr = new RegExp('(^|\\b)' + className.split(' ').join('|') + '(\\b|$)', 'gi');
el.className = el.className.replace(expr, ' ');
}
}
function on(el, event, handler) {
if (el.addEventListener) {
el.addEventListener(event, handler);
} else {
el.attachEvent('on' + event, function () {
handler.call(el);
});
}
}
<%= extra_javascript %>
}());
</script>
</body>
</html>

View File

@ -0,0 +1,77 @@
# :nodoc:
struct ExceptionPage::Frame
property index : Int32, raw_frame : Regex::MatchData
def initialize(@raw_frame, @index)
end
def snippets : Array(Snippet)
snippets = [] of Snippet
if File.exists?(file)
lines = File.read_lines(file)
lines.each_with_index do |code, code_index|
if line_is_nearby?(code_index)
highlight = (code_index + 1 == line) ? true : false
snippets << Snippet.new(
line: code_index + 1,
code: code,
highlight: highlight
)
end
end
end
snippets
end
private def line_is_nearby?(code_index : Int32)
(code_index + 1) <= (line + 5) && (code_index + 1) >= (line - 5)
end
def file : String
raw_frame[1]
end
def filename : String
file.split('/').last
end
def line : Int32
raw_frame[2].to_i
end
def args
"#{file}:#{line}#{column_with_surrounding_method_name}"
end
private def column_with_surrounding_method_name
raw_frame[3]
end
def label : String
case file
when .includes?("/crystal/"), .includes?("/crystal-lang/")
"crystal"
when /lib\/(?<name>[^\/]+)\/.+/
$~["name"]
else
"app"
end
end
def context : String
if label == "app"
"app"
else
"all"
end
end
struct Snippet
property line : Int32,
code : String,
highlight : Bool
def initialize(@line, @code, @highlight)
end
end
end

View File

@ -0,0 +1,13 @@
# :nodoc:
class ExceptionPage::FrameGenerator
def self.generate_frames(message)
generated_frames = [] of Frame
if raw_frames = message.scan(/\s([^\s\:]+):(\d+)([^\n]+)/)
raw_frames.each_with_index do |frame, index|
generated_frames << Frame.new(raw_frame: frame, index: index)
end
end
generated_frames
end
end

View File

@ -0,0 +1,18 @@
class ExceptionPage::Styles
getter accent : String,
highlight : String,
flash_highlight : String,
logo_uri : String?
def initialize(
@accent,
@highlight = "#e5e5e5",
@flash_highlight = "#ffdc93",
@logo_uri = crystal_logo
)
end
private def crystal_logo
"data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAyMS4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjxzdmcgdmVyc2lvbj0iMS4xIiBpZD0iTGF5ZXJfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiDQoJIHZpZXdCb3g9IjAgMCAxOTMuMiAyMDYuNyIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMTkzLjIgMjA2Ljc7IiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+DQoJLnN0MHtmaWxsOm5vbmU7fQ0KPC9zdHlsZT4NCjxnPg0KCTxwYXRoIGQ9Ik0xNjUuNCwxMjJsLTUwLDQ5LjljLTAuMiwwLjItMC41LDAuMy0wLjcsMC4ybC02OC4zLTE4LjNjLTAuMy0wLjEtMC41LTAuMy0wLjUtMC41TDI3LjUsODUuMWMtMC4xLTAuMywwLTAuNSwwLjItMC43DQoJCWw1MC00OS45YzAuMi0wLjIsMC41LTAuMywwLjctMC4ybDY4LjMsMTguM2MwLjMsMC4xLDAuNSwwLjMsMC41LDAuNWwxOC4zLDY4LjJDMTY1LjcsMTIxLjYsMTY1LjYsMTIxLjgsMTY1LjQsMTIyeiBNOTguNCw2Ny43DQoJCUwzMS4zLDg1LjZjLTAuMSwwLTAuMiwwLjItMC4xLDAuM2w0OS4xLDQ5YzAuMSwwLjEsMC4zLDAuMSwwLjMtMC4xbDE4LTY3Qzk4LjcsNjcuOCw5OC41LDY3LjYsOTguNCw2Ny43eiIvPg0KCTxnPg0KCQk8cmVjdCBjbGFzcz0ic3QwIiB3aWR0aD0iMTkzLjIiIGhlaWdodD0iMjA2LjciLz4NCgk8L2c+DQo8L2c+DQo8L3N2Zz4NCg=="
end
end

View File

@ -0,0 +1,3 @@
class ExceptionPage
VERSION = "0.1.2"
end

42
lib/kemal/.ameba.yml Normal file
View File

@ -0,0 +1,42 @@
# This configuration file was generated by `ameba --gen-config`
# on 2019-06-14 15:05:57 UTC using Ameba version 0.10.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
# Run `ameba --only Lint/UselessAssign` for details
Lint/UselessAssign:
Description: Disallows useless variable assignments
Enabled: true
Severity: Warning
Excluded:
- spec/view_spec.cr
# Problems found: 1
# Run `ameba --only Lint/ShadowingOuterLocalVar` for details
Lint/ShadowingOuterLocalVar:
Description: Disallows the usage of the same name as outer local variables for block
or proc arguments.
Enabled: true
Severity: Warning
Excluded:
- spec/run_spec.cr
# Problems found: 1
# Run `ameba --only Style/NegatedConditionsInUnless` for details
Style/NegatedConditionsInUnless:
Description: Disallows negated conditions in unless
Enabled: true
Severity: Convention
Excluded:
- src/kemal/ext/response.cr
# Problems found: 1
# Run `ameba --only Metrics/CyclomaticComplexity` for details
Metrics/CyclomaticComplexity:
Description: Disallows methods with a cyclomatic complexity higher than `MaxComplexity`
MaxComplexity: 10
Enabled: true
Severity: Convention
Excluded:
- src/kemal/static_file_handler.cr

8
lib/kemal/.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,8 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: sdogruyol
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
custom: # Replace with a single custom sponsorship URL

23
lib/kemal/.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,23 @@
### Description
[Description of the issue]
### Steps to Reproduce
1. [First Step]
2. [Second Step]
3. [and so on...]
**Expected behavior:** [What you expect to happen]
**Actual behavior:** [What actually happens]
**Reproduces how often:** [What percentage of the time does it reproduce?]
### Versions
You can get this information from copy and pasting the output of `crystal --version`.Also, please include the OS and what version of the OS you're running.
### Additional Information
Any additional information, configuration or data that might be necessary to reproduce the issue.

View File

@ -0,0 +1,15 @@
### Description of the Change
<!-- We must be able to understand the design of your change from this description. If we can't get a good idea of what the code will be doing from the description here, the pull request may be closed at the maintainers' discretion. Keep in mind that the maintainer reviewing this PR may not be familiar with or have worked with the code here recently, so please walk us through the concepts. -->
### Alternate Designs
<!-- Explain what other alternates were considered and why the proposed version was selected -->
### Benefits
<!-- What benefits will be realized by the code change? -->
### Possible Drawbacks
<!-- What are the possible side-effects or negative impacts of the code change? -->

8
lib/kemal/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
/lib/
/.crystal/
/.shards/
*.log
/bin/
# Libraries don't need dependency lock
# Dependencies will be locked in application that uses them
/shard.lock

14
lib/kemal/.travis.yml Normal file
View File

@ -0,0 +1,14 @@
language: crystal
crystal:
- latest
- nightly
script:
- crystal spec
- crystal spec --release --no-debug
- crystal tool format --check
- bin/ameba src
matrix:
allow_failures:
- crystal: nightly

370
lib/kemal/CHANGELOG.md Normal file
View File

@ -0,0 +1,370 @@
# 0.26.0 (05-08-2019)
- Crystal 0.30.0 support :tada: [#548](https://github.com/kemalcr/kemal/pull/548) and [#544](https://github.com/kemalcr/kemal/pull/544). Thanks @bcardiff and @straight-shoota :pray:
- Add support for serving files greater than 2^31 bytes [#546](https://github.com/kemalcr/kemal/pull/546). Thanks @omarroth :pray:
- Properly measure request time using `Time.monotonic` [#527](https://github.com/kemalcr/kemal/pull/527). Thanks @spinscale :pray:
# 0.25.2 (08-02-2019)
- Add option to config to parse or not command line parameters [#483](https://github.com/kemalcr/kemal/pull/483). Thanks @diegogub :pray:
- Allow to set filename for `send_file` [#512](https://github.com/kemalcr/kemal/pull/512). Thanks @mamantoha :pray:
```ruby
send_file env, "./asset/image.jpeg", filename: "image.jpg"
```
- Set `status_code` before response [#513](https://github.com/kemalcr/kemal/pull/513). Thanks @mamantohoa :pray:
- Use Crystal MIME registry. [#516](https://github.com/kemalcr/kemal/pull/516) Thanks @Sija :pray:
# 0.25.1 (06-10-2018)
- Fix `params.files` memoization https://github.com/kemalcr/kemal/pull/503. Thanks @mamantoha :pray:
# 0.25.0 (05-10-2018)
- Crystal 0.27.0 support.
- *[breaking change]* Added back `env.params.files`.
Here's a fully working sample for reading a image file upload `image1` and saving it under `public/uploads`.
```crystal
post "/upload" do |env|
file = env.params.files["image1"].tempfile
file_path = ::File.join [Kemal.config.public_folder, "uploads/", File.basename(file.path)]
File.open(file_path, "w") do |f|
IO.copy(file, f)
end
"Upload ok"
end
```
To test
`curl -F "image1=@/Users/serdar/Downloads/kemal.png" http://localhost:3000/upload`
- Cache HTTP routes to increase performance :rocket: https://github.com/kemalcr/kemal/pull/493
# 0.24.0 (14-08-2018)
- *[breaking change]* Removed `env.params.files`. You can use Crystal's built-in `HTTP::FormData.parse` instead
```ruby
post "/upload" do |env|
HTTP::FormData.parse(env.request) do |upload|
filename = file.filename
if !filename.is_a?(String)
"No filename included in upload"
else
file_path = ::File.join [Kemal.config.public_folder, "uploads/", filename]
File.open(file_path, "w") do |f|
IO.copy(file.tmpfile, f)
end
"Upload OK"
end
end
```
- *[breaking change]* From now on to access dynamic url params in a WebSocket route you have to use:
```ruby
ws "/:id" do |socket, context|
id = context.ws_route_lookup.params["id"]
end
```
- *[breaking change]* Removed `_method` magic param.
- Added new exception page [#466](https://github.com/kemalcr/kemal/pull/466). Thanks @mamantoha 🙏
- Support custom port binding. Thanks @straight-shoota 🙏
```ruby
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
end
```
# 0.23.0 (17-06-2018)
- Crystal 0.25.0 support 🎉
- Add `Kemal::Context.get?` to safely access context storage :sunglasses:
- [Security] Don't serve 404 image dynamically :thumbsup:
- Disable `X-Powered-By` header [#449](https://github.com/kemalcr/kemal/pull/449). Thanks @Blacksmoke16 🙏
# 0.22.0 (29-12-2017)
- Crystal 0.24.1 support 🎉
- Only return string from route.[#408](https://github.com/kemalcr/kemal/pull/408) thanks @crisward 🙏
- Don't crash on empty path when compiled in --release. [#407](https://github.com/kemalcr/kemal/pull/407) thanks @crisward 🙏
- Rename `Kemal::CommonLogHandler` to `Kemal::LogHandler` and `Kemal::CommonExceptionHandler` to `Kemal::ExceptionHandler`.
- Allow videos to be opened with correct mime type. [#406](https://github.com/kemalcr/kemal/pull/406) thanks @crisward 🙏
- Add webm mime type.[#413](https://github.com/kemalcr/kemal/pull/413) thanks @reindeer-cafe 🙏
# 0.21.0 (05-09-2017)
- Dynamically insert handlers :muscle: Fixes [#376](https://github.com/kemalcr/kemal/pull/376).
- Add context to WebSocket. This allows one to use `HTTP::Server::Context` in `ws` declarations :heart_eyes: Fixes [#349](https://github.com/kemalcr/kemal/pull/349).
```ruby
ws "/:room_name" do |socket, env|
env.params.url["room_name"]
end
```
- Add support for customizing the headers of built-in `Kemal::StaticFileHandler` :hammer: Useful for supporting `CORS` for single page applications :clap:
```ruby
static_headers do |response, filepath, filestat|
if filepath =~ /\.html$/
response.headers.add("Access-Control-Allow-Origin", "*")
end
response.headers.add("Content-Size", filestat.size.to_s)
end
end
```
- Allow %w in Handler macros [#385](https://github.com/kemalcr/kemal/pull/385). Thanks @will :pray:
- Security: X-Content-Type-Options: nosniff for static files. Fixes [#379](https://github.com/kemalcr/kemal/issues/379). Thanks @crisward :pray:
- Performance: [Remove tempfile management to OS](https://github.com/kemalcr/kemal/commit/a1520de7ed3865fa73258343a80fad4f20666a99). This brings %10 - 15 performance boost to Kemal :rocket:
# 0.20.0 (01-07-2017)
- Crystal 0.23.0 support! As always, Kemal is compatible with the latest major release of Crystal 💎
- Great news everyone 🎉 All handlers are now completely ***customizable***!. Use the default `Kemal` handlers or go wild, it's all up to you ⛏
```ruby
# Don't forget to add `Kemal::RouteHandler::INSTANCE` or your routes won't work!
Kemal.config.handlers = [Kemal::InitHandler.new, YourHandler.new, Kemal::RouteHandler::INSTANCE]
```
You can also insert a handler into a specific position.
```ruby
# This adds MyCustomHandler instance to 1 position. Be aware that the index starts from 0.
add_handler MyCustomHandler.new, 1
```
- Updated [Kilt](https://github.com/jeromegn/kilt) to v0.4.0.
- Make `Route` a `Struct`. This improves the performance of route declarations.
# 0.19.0 (09-05-2017)
- Return no body for head route fixes #323. (thanks @crisward)
- Update `radix` to `0.3.8`. (thanks @waghanza)
- User defined context store types. (thanks @neovitange)
```ruby
class User
property name
end
add_context_storage_type(User)
```
- Prevent `send_file returning filesize. (thanks @crisward)
- Dont call setup in `config#add_filter_handler` fixes #338.
# 0.18.3 (07-03-2017)
- Remove `Gzip::Header` monkey patch since it's fixed in `Crystal 0.21.1`.
# 0.18.2 (24-02-2017)
- Fix [Gzip in Kemal Seems broken for static files](https://github.com/kemalcr/kemal/issues/316). This was caused by `Gzip::Writer` in `Crystal 0.21.0` and currently mitigated by monkey patching `Gzip::Header`.
# 0.18.1 (21-02-2017)
- Crystal 0.21.0 support
- Drop `multipart.cr` dependency. `multipart` support is now built-into Crystal <3
- Since Crystal 0.21.0 comes built-in with `multipart` there are some improvements and deprecations.
`meta` has been removed from `FileUpload` and it has the following properties
+ `tmpfile`: This is temporary file for file upload. Useful for saving the upload file.
+ `filename`: File name of the file upload. (logo.png, images.zip e.g)
+ `headers`: Headers for the file upload.
+ `creation_time`: Creation time of the file upload.
+ `modification_time`: Last Modification time of the file upload.
+ `read_time`: Read time of the file upload.
+ `size`: Size of the file upload.
# 0.18.0 (11-02-2017)
- Simpler file upload. File uploads can now be access from `HTTP::Server::Context` like `env.params.files["filename"]`.
`env.params.files["filename"]` has 5 methods
- `tmpfile`: This is temporary file for file upload. Useful for saving the upload file.
- `tmpfile_path`: File path of `tmpfile`.
- `filename`: File name of the file upload. (logo.png, images.zip e.g)
- `meta`: Meta information for the file upload.
- `headers`: Headers for the file upload.
Here's a fully working sample for reading a image file upload `image1` and saving it under `public/uploads`.
```crystal
post "/upload" do |env|
file = env.params.files["image1"].tmpfile
file_path = ::File.join [Kemal.config.public_folder, "uploads/", file.filename]
File.open(file_path, "w") do |f|
IO.copy(file, f)
end
"Upload ok"
end
```
To test
`curl -F "image1=@/Users/serdar/Downloads/kemal.png" http://localhost:3000/upload`
- RF7233 support a.k.a file streaming. (https://github.com/kemalcr/kemal/pull/299) (thanks @denysvitali)
- Update Radix to 0.3.7. Fixes https://github.com/kemalcr/kemal/issues/293
- Configurable startup / shutdown logging. https://github.com/kemalcr/kemal/issues/291 and https://github.com/kemalcr/kemal/issues/292 (thanks @twisterghost).
# 0.17.5 (09-01-2017)
- Update multipart.cr to 0.1.2. Fixes #285 related to multipart.cr
# 0.17.4 (24-12-2016)
- Support for Crystal 0.20.3
- Add `Kemal.stop`. Fixes #269.
- `HTTP::Handler` is not a class anymore, it's a module. See https://github.com/crystal-lang/crystal/releases/tag/0.20.3
# 0.17.3 (03-12-2016)
- Handle missing 404 image. Fixes #263
- Remove basic auth middleware from core and move to [kemalcr/kemal-basic-auth](https://github.com/kemalcr/kemal-basic-auth).
# 0.17.2 (25-11-2016)
- Use body.gets_to_end for parse_json. Fixes #260.
- Update Radix to 0.3.5 and lock pessimistically. (thanks @luislavena)
# 0.17.1 (24-11-2016)
- Treat `HTTP::Request` body as an `IO`. Fixes [#257](https://github.com/sdogruyol/kemal/issues/257)
# 0.17.0 (23-11-2016)
- Reimplemented Request middleware / filter routing.
Now all requests will first go through the Middleware stack then Filters (before_*) and will finally reach the matching route.
Which is illustrated as,
```
Request -> Middleware -> Filter -> Route
```
- Rename `return_with` as `halt`.
- Route declaration must start with `/`. Fixes [#242](https://github.com/sdogruyol/kemal/issues/242)
- Set default exception Content-Type to text/html. Fixes [#202](https://github.com/sdogruyol/kemal/issues/242)
- Add `only` and `exclude` paths for `Kemal::Handler`. This change requires that all handlers must inherit from `Kemal::Handler`.
For example this handler will only work on `/` path. By default the HTTP method is `GET`.
```crystal
class OnlyHandler < Kemal::Handler
only ["/"]
def call(env)
return call_next(env) unless only_match?(env)
puts "If the path is / i will be doing some processing here."
end
end
```
The handlers using `exclude` will work on the paths that isn't specified. For example this handler will work on any routes other than `/`.
```crystal
class ExcludeHandler < Kemal::Handler
exclude ["/"]
def call(env)
return call_next(env) unless only_match?(env)
puts "If the path is NOT / i will be doing some processing here."
end
end
```
- Close response on `halt`. (thanks @samueleaton).
- Update `Radix` to `v0.3.4`.
- `error` handler now also yields error. For example you can get the error mesasage like
```crystal
error 500 do |env, err|
err.message
end
```
- Update `multipart.cr` to `v0.1.1`
# 0.16.1 (12-10-2016)
- Improved Multipart support with more info on parsed files. `parse_multipart(env)` now yields
an `UploadFile` object which has the following properties `field`,`data`,`meta`,`headers.
```crystal
post "/upload" do |env|
parse_multipart(env) do |f|
image1 = f.data if f.field == "image1"
image2 = f.data if f.field == "image2"
puts f.meta
puts f.headers
"Upload complete"
end
end
```
# 0.16.0
- Multipart support <3 (thanks @RX14). Now you can handle file uploads.
```crystal
post "/upload" do |env|
parse_multipart(env) do |field, data|
image1 = data if field == "image1"
image2 = data if field == "image2"
"Upload complete"
end
end
```
- Make session configurable. Now you can specify session name and expire time wit
```crystal
Kemal.config.session["name"] = "your_app"
Kemal.config.session["expire_time"] = 48.hours
```
- Session now supports more types. (String, Int32, Float64, Bool)
- Add `gzip` helper to enable / disable gzip compression on responses.
- Static file caching with etag and gzip (thanks @crisward)
- `Kemal.run` now accepts port to listen.
# 0.15.1 (05-09-2016)
- Don't forget to call_next on NullLogHandler
# 0.15.0 (03-09-2016)
- Add context store
- `KEMAL_ENV` respects to `Kemal.config.env` and needs to be explicitly set.
- `Kemal::InitHandler` is introduced. Adds initial configuration, headers like `X-Powered-By`.
- Add `send_file` to helpers.
- Add mime types.
- Fix parsing JSON params when "charset" is present in "Content-Type" header.
- Use http-only cookie for session
- Inject STDOUT by default in CommonLogHandler

19
lib/kemal/LICENSE Normal file
View File

@ -0,0 +1,19 @@
Copyright (c) 2016 Serdar Doğruyol
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE

67
lib/kemal/README.md Normal file
View File

@ -0,0 +1,67 @@
[![Kemal](https://avatars3.githubusercontent.com/u/15321198?v=3&s=200)](http://kemalcr.com)
# Kemal
Lightning Fast, Super Simple web framework.
[![Build Status](https://travis-ci.org/kemalcr/kemal.svg?branch=master)](https://travis-ci.org/kemalcr/kemal)
[![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 ⚡️
```ruby
require "kemal"
# Matches GET "http://host:port/"
get "/" do
"Hello World!"
end
# Creates a WebSocket handler.
# Matches "ws://host:port/socket"
ws "/socket" do |socket|
socket.send "Hello from Kemal!"
end
Kemal.run
```
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:
kemal:
github: kemalcr/kemal
```
See also [Getting Started](http://kemalcr.com/guide/).
# Features
- 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 [Kilt](https://github.com/jeromegn/kilt)
# Documentation
You can read the documentation at the official site [kemalcr.com](http://kemalcr.com)
## Thanks
Thanks to Manas for their awesome work on [Frank](https://github.com/manastech/frank).

View File

@ -0,0 +1,8 @@
require "kemal"
# Set root. If not specified the default content_type is 'text'
get "/" do
"Hello Kemal!"
end
Kemal.run

View File

@ -0,0 +1,11 @@
require "kemal"
require "json"
# You can easily access the context and set content_type like 'application/json'.
# Look how easy to build a JSON serving API.
get "/" do |env|
env.response.content_type = "application/json"
{name: "Serdar", age: 27}.to_json
end
Kemal.run

View File

@ -0,0 +1,11 @@
require "kemal"
ws "/" do |socket|
socket.send "Hello from Kemal!"
socket.on_message do |message|
socket.send "Echo back from server #{message}"
end
end
Kemal.run

25
lib/kemal/shard.yml Normal file
View File

@ -0,0 +1,25 @@
name: kemal
version: 0.26.0
authors:
- Serdar Dogruyol <dogruyolserdar@gmail.com>
dependencies:
radix:
github: luislavena/radix
version: ~> 0.3.8
kilt:
github: jeromegn/kilt
version: ~> 0.4.0
exception_page:
github: crystal-loot/exception_page
version: ~> 0.1.1
development_dependencies:
ameba:
github: veelenga/ameba
version: ~> 0.10.0
crystal: 0.30.0
license: MIT

View File

@ -0,0 +1 @@
require "./*"

View File

@ -0,0 +1 @@
Hello <%= name %>

View File

@ -0,0 +1,5 @@
Hello <%= name %>
<% content_for "custom" do %>
<h1>Hello from otherside</h1>
<% end %>

View File

@ -0,0 +1 @@
<html><%= content %></html>

View File

@ -0,0 +1,6 @@
<html>
<body>
<%= content %>
<%= yield_content "custom" %>
</body>
</html>

View File

@ -0,0 +1,8 @@
<html>
<body>
<%= content %>
<%= yield_content "custom" %>
<%= var1 %>
<%= var2 %>
</body>
</html>

View File

@ -0,0 +1,61 @@
require "./spec_helper"
describe "Config" do
it "sets default port to 3000" do
Kemal::Config.new.port.should eq 3000
end
it "sets default environment to development" do
Kemal::Config.new.env.should eq "development"
end
it "sets environment to production" do
config = Kemal.config
config.env = "production"
config.env.should eq "production"
end
it "sets default powered_by_header to true" do
Kemal::Config.new.powered_by_header.should be_true
end
it "sets host binding" do
config = Kemal.config
config.host_binding = "127.0.0.1"
config.host_binding.should eq "127.0.0.1"
end
it "adds a custom handler" do
config = Kemal.config
config.add_handler CustomTestHandler.new
Kemal.config.setup
config.handlers.size.should eq(7)
end
it "toggles the shutdown message" do
config = Kemal.config
config.shutdown_message = false
config.shutdown_message.should eq false
config.shutdown_message = true
config.shutdown_message.should eq true
end
it "adds custom options" do
config = Kemal.config
ARGV.push("--test")
ARGV.push("FOOBAR")
test_option = nil
config.extra_options do |parser|
parser.on("--test TEST_OPTION", "Test an option") do |opt|
test_option = opt
end
end
Kemal::CLI.new ARGV
test_option.should eq("FOOBAR")
end
it "gets the version from shards.yml" do
Kemal::VERSION.should_not be("")
end
end

View File

@ -0,0 +1,107 @@
require "./spec_helper"
describe "Context" do
context "headers" do
it "sets content type" do
get "/" do |env|
env.response.content_type = "application/json"
"Hello"
end
request = HTTP::Request.new("GET", "/")
client_response = call_request_on_app(request)
client_response.headers["Content-Type"].should eq("application/json")
end
it "parses headers" do
get "/" do |env|
name = env.request.headers["name"]
"Hello #{name}"
end
headers = HTTP::Headers.new
headers["name"] = "kemal"
request = HTTP::Request.new("GET", "/", headers)
client_response = call_request_on_app(request)
client_response.body.should eq "Hello kemal"
end
it "sets response headers" do
get "/" do |env|
env.response.headers.add "Accept-Language", "tr"
end
request = HTTP::Request.new("GET", "/")
client_response = call_request_on_app(request)
client_response.headers["Accept-Language"].should eq "tr"
end
end
context "storage" do
it "can store primitive types" do
before_get "/" do |env|
env.set "before_get", "Kemal"
env.set "before_get_int", 123
env.set "before_get_float", 3.5
end
get "/" do |env|
{
before_get: env.get("before_get"),
before_get_int: env.get("before_get_int"),
before_get_float: env.get("before_get_float"),
}
end
request = HTTP::Request.new("GET", "/")
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
Kemal::FilterHandler::INSTANCE.call(context)
Kemal::RouteHandler::INSTANCE.call(context)
context.get("before_get").should eq "Kemal"
context.get("before_get_int").should eq 123
context.get("before_get_float").should eq 3.5
end
it "can store custom types" do
before_get "/" do |env|
t = TestContextStorageType.new
t.id = 32
a = AnotherContextStorageType.new
env.set "before_get_context_test", t
env.set "another_context_test", a
end
get "/" do |env|
{
before_get_context_test: env.get("before_get_context_test"),
another_context_test: env.get("another_context_test"),
}
end
request = HTTP::Request.new("GET", "/")
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
Kemal::FilterHandler::INSTANCE.call(context)
Kemal::RouteHandler::INSTANCE.call(context)
context.get("before_get_context_test").as(TestContextStorageType).id.should eq 32
context.get("another_context_test").as(AnotherContextStorageType).name.should eq "kemal-context"
end
it "fetches non-existent keys from store with get?" do
get "/" { }
request = HTTP::Request.new("GET", "/")
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
Kemal::FilterHandler::INSTANCE.call(context)
Kemal::RouteHandler::INSTANCE.call(context)
context.get?("non_existent_key").should be_nil
context.get?("another_non_existent_key").should be_nil
end
end
end

View File

@ -0,0 +1,115 @@
require "./spec_helper"
describe "Kemal::ExceptionHandler" do
it "renders 404 on route not found" do
get "/" do
"Hello"
end
request = HTTP::Request.new("GET", "/asd")
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
Kemal::ExceptionHandler::INSTANCE.call(context)
response.close
io.rewind
response = HTTP::Client::Response.from_io(io, decompress: false)
response.status_code.should eq 404
end
it "renders custom error" do
error 403 do
"403 error"
end
get "/" do |env|
env.response.status_code = 403
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 403
response.headers["Content-Type"].should eq "text/html"
response.body.should eq "403 error"
end
it "renders custom 500 error" do
error 500 do
"Something happened"
end
get "/" do |env|
env.response.status_code = 500
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 "Something happened"
end
it "keeps the specified error Content-Type" do
error 500 do
"Something happened"
end
get "/" do |env|
env.response.content_type = "application/json"
env.response.status_code = 500
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 "application/json"
response.body.should eq "Something happened"
end
it "renders custom error with env and error" do
error 500 do |_, err|
err.message
end
get "/" do |env|
env.response.content_type = "application/json"
env.response.status_code = 500
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 "application/json"
response.body.should eq "Rendered error with 500"
end
it "does not do anything on a closed io" do
get "/" do |env|
halt env, status_code: 404
end
request = HTTP::Request.new("GET", "/")
client_response = call_request_on_app(request)
client_response.status_code.should eq 404
end
end

View File

@ -0,0 +1,161 @@
require "./spec_helper"
class CustomTestHandler < Kemal::Handler
def call(env)
env.response << "Kemal"
call_next env
end
end
class OnlyHandler < Kemal::Handler
only ["/only"]
def call(env)
return call_next(env) unless only_match?(env)
env.response.print "Only"
call_next env
end
end
class ExcludeHandler < Kemal::Handler
exclude ["/exclude"]
def call(env)
return call_next(env) if exclude_match?(env)
env.response.print "Exclude"
call_next env
end
end
class PostOnlyHandler < Kemal::Handler
only ["/only", "/route1", "/route2"], "POST"
def call(env)
return call_next(env) unless only_match?(env)
env.response.print "Only"
call_next env
end
end
class PostExcludeHandler < Kemal::Handler
exclude ["/exclude"], "POST"
def call(env)
return call_next(env) if exclude_match?(env)
env.response.print "Exclude"
call_next env
end
end
class ExcludeHandlerPercentW < Kemal::Handler
exclude %w[/exclude]
def call(env)
return call_next(env) if exclude_match?(env)
env.response.print "Exclude"
call_next env
end
end
class PostOnlyHandlerPercentW < Kemal::Handler
only %w[/only /route1 /route2], "POST"
def call(env)
return call_next(env) unless only_match?(env)
env.response.print "Only"
call_next env
end
end
describe "Handler" do
it "adds custom handler before before_*" do
filter_middleware = Kemal::FilterHandler.new
filter_middleware._add_route_filter("GET", "/", :before) do |env|
env.response << " is"
end
filter_middleware._add_route_filter("GET", "/", :before) do |env|
env.response << " so"
end
add_handler CustomTestHandler.new
get "/" do
" Great"
end
request = HTTP::Request.new("GET", "/")
client_response = call_request_on_app(request)
client_response.status_code.should eq(200)
client_response.body.should eq("Kemal is so Great")
end
it "runs specified only_routes in middleware" do
get "/only" do
"Get"
end
add_handler OnlyHandler.new
request = HTTP::Request.new("GET", "/only")
client_response = call_request_on_app(request)
client_response.body.should eq "OnlyGet"
end
it "doesn't run specified exclude_routes in middleware" do
get "/" do
"Get"
end
get "/exclude" do
"Exclude"
end
add_handler ExcludeHandler.new
request = HTTP::Request.new("GET", "/")
client_response = call_request_on_app(request)
client_response.body.should eq "ExcludeGet"
end
it "runs specified only_routes with method in middleware" do
post "/only" do
"Post"
end
get "/only" do
"Get"
end
add_handler PostOnlyHandler.new
request = HTTP::Request.new("POST", "/only")
client_response = call_request_on_app(request)
client_response.body.should eq "OnlyPost"
end
it "doesn't run specified exclude_routes with method in middleware" do
post "/exclude" do
"Post"
end
post "/only" do
"Post"
end
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"
end
it "adds a handler at given position" do
post_handler = PostOnlyHandler.new
add_handler post_handler, 1
Kemal.config.setup
Kemal.config.handlers[1].should eq post_handler
end
it "assigns custom handlers" do
post_only_handler = PostOnlyHandler.new
post_exclude_handler = PostExcludeHandler.new
Kemal.config.handlers = [post_only_handler, post_exclude_handler]
Kemal.config.handlers.should eq [post_only_handler, post_exclude_handler]
end
it "is able to use %w in macros" do
post_only_handler = PostOnlyHandlerPercentW.new
exclude_handler = ExcludeHandlerPercentW.new
Kemal.config.handlers = [post_only_handler, exclude_handler]
Kemal.config.handlers.should eq [post_only_handler, exclude_handler]
end
end

View File

@ -0,0 +1,155 @@
require "./spec_helper"
require "./handler_spec"
describe "Macros" do
describe "#public_folder" do
it "sets public folder" do
public_folder "/some/path/to/folder"
Kemal.config.public_folder.should eq("/some/path/to/folder")
end
end
describe "#add_handler" do
it "adds a custom handler" do
add_handler CustomTestHandler.new
Kemal.config.setup
Kemal.config.handlers.size.should eq 7
end
end
describe "#logging" do
it "sets logging status" do
logging false
Kemal.config.logging.should eq false
end
it "sets a custom logger" do
config = Kemal::Config::INSTANCE
logger CustomLogHandler.new
config.logger.should be_a(CustomLogHandler)
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"
end
request = HTTP::Request.new("GET", "/breaking")
client_response = call_request_on_app(request)
client_response.status_code.should eq(404)
client_response.body.should eq("hello")
end
it "can break block with halt macro using default values" do
get "/" do |env|
halt env
"world"
end
request = HTTP::Request.new("GET", "/")
client_response = call_request_on_app(request)
client_response.status_code.should eq(200)
client_response.body.should eq("")
end
end
describe "#headers" do
it "can add headers" do
get "/headers" do |env|
env.response.headers.add "Content-Type", "image/png"
headers env, {
"Access-Control-Allow-Origin" => "*",
"Content-Type" => "text/plain",
}
end
request = HTTP::Request.new("GET", "/headers")
response = call_request_on_app(request)
response.headers["Access-Control-Allow-Origin"].should eq("*")
response.headers["Content-Type"].should eq("text/plain")
end
end
describe "#send_file" do
it "sends file with given path and default mime-type" do
get "/" do |env|
send_file env, "./spec/asset/hello.ecr"
end
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
it "sends file with given path and given mime-type" do
get "/" do |env|
send_file env, "./spec/asset/hello.ecr", "image/jpeg"
end
request = HTTP::Request.new("GET", "/")
response = call_request_on_app(request)
response.status_code.should eq(200)
response.headers["Content-Type"].should eq("image/jpeg")
response.headers["Content-Length"].should eq("18")
end
it "sends file with binary stream" do
get "/" do |env|
send_file env, "Serdar".to_slice
end
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("6")
end
it "sends file with given path and given filename" do
get "/" do |env|
send_file env, "./spec/asset/hello.ecr", filename: "image.jpg"
end
request = HTTP::Request.new("GET", "/")
response = call_request_on_app(request)
response.status_code.should eq(200)
response.headers["Content-Disposition"].should eq("attachment; filename=\"image.jpg\"")
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)
end
end
describe "#serve_static" do
it "should disable static file hosting" do
serve_static false
Kemal.config.serve_static.should eq false
end
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 eq true
if conf.is_a?(Hash)
conf["gzip"].should eq true
conf["dir_listing"].should eq true
end
end
end
end

View File

@ -0,0 +1,32 @@
require "./spec_helper"
describe "Kemal::InitHandler" do
it "initializes context with Content-Type: text/html" 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)
context.response.headers["Content-Type"].should eq "text/html"
end
it "initializes context with X-Powered-By: Kemal" 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.call(context)
context.response.headers["X-Powered-By"].should eq "Kemal"
end
it "does not initialize context with X-Powered-By: Kemal if disabled" do
Kemal.config.powered_by_header = false
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.call(context)
context.response.headers["X-Powered-By"]?.should be_nil
end
end

View File

@ -0,0 +1,21 @@
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
context_io = IO::Memory.new
response = HTTP::Server::Response.new(context_io)
context = HTTP::Server::Context.new(request, response)
logger = Kemal::LogHandler.new io
logger.call(context)
io.to_s.should_not be nil
end
end

View File

@ -0,0 +1,190 @@
require "../spec_helper"
describe "Kemal::FilterHandler" do
it "executes code before home request" do
test_filter = FilterTest.new
test_filter.modified = "false"
filter_middleware = Kemal::FilterHandler.new
filter_middleware._add_route_filter("GET", "/greetings", :before) { test_filter.modified = "true" }
kemal = Kemal::RouteHandler::INSTANCE
kemal.add_route "GET", "/greetings" { test_filter.modified }
test_filter.modified.should eq("false")
request = HTTP::Request.new("GET", "/greetings")
create_request_and_return_io_and_context(filter_middleware, request)
io_with_context = create_request_and_return_io_and_context(kemal, request)[0]
client_response = HTTP::Client::Response.from_io(io_with_context, decompress: false)
client_response.body.should eq("true")
end
it "executes code before GET home request but not POST home request" do
test_filter = FilterTest.new
test_filter.modified = "false"
filter_middleware = Kemal::FilterHandler.new
filter_middleware._add_route_filter("GET", "/greetings", :before) { test_filter.modified = test_filter.modified == "true" ? "false" : "true" }
kemal = Kemal::RouteHandler::INSTANCE
kemal.add_route "GET", "/greetings" { test_filter.modified }
kemal.add_route "POST", "/greetings" { test_filter.modified }
test_filter.modified.should eq("false")
request = HTTP::Request.new("GET", "/greetings")
create_request_and_return_io_and_context(filter_middleware, request)
io_with_context = create_request_and_return_io_and_context(kemal, request)[0]
client_response = HTTP::Client::Response.from_io(io_with_context, decompress: false)
client_response.body.should eq("true")
request = HTTP::Request.new("POST", "/greetings")
create_request_and_return_io_and_context(filter_middleware, request)
io_with_context = create_request_and_return_io_and_context(kemal, request)[0]
client_response = HTTP::Client::Response.from_io(io_with_context, decompress: false)
client_response.body.should eq("true")
end
it "executes code before all GET/POST home request" do
test_filter = FilterTest.new
test_filter.modified = "false"
filter_middleware = Kemal::FilterHandler.new
filter_middleware._add_route_filter("ALL", "/greetings", :before) { test_filter.modified = test_filter.modified == "true" ? "false" : "true" }
filter_middleware._add_route_filter("GET", "/greetings", :before) { test_filter.modified = test_filter.modified == "true" ? "false" : "true" }
filter_middleware._add_route_filter("POST", "/greetings", :before) { test_filter.modified = test_filter.modified == "true" ? "false" : "true" }
kemal = Kemal::RouteHandler::INSTANCE
kemal.add_route "GET", "/greetings" { test_filter.modified }
kemal.add_route "POST", "/greetings" { test_filter.modified }
test_filter.modified.should eq("false")
request = HTTP::Request.new("GET", "/greetings")
create_request_and_return_io_and_context(filter_middleware, request)
io_with_context = create_request_and_return_io_and_context(kemal, request)[0]
client_response = HTTP::Client::Response.from_io(io_with_context, decompress: false)
client_response.body.should eq("false")
request = HTTP::Request.new("POST", "/greetings")
create_request_and_return_io_and_context(filter_middleware, request)
io_with_context = create_request_and_return_io_and_context(kemal, request)[0]
client_response = HTTP::Client::Response.from_io(io_with_context, decompress: false)
client_response.body.should eq("false")
end
it "executes code after home request" do
test_filter = FilterTest.new
test_filter.modified = "false"
filter_middleware = Kemal::FilterHandler.new
filter_middleware._add_route_filter("GET", "/greetings", :after) { test_filter.modified = "true" }
kemal = Kemal::RouteHandler::INSTANCE
kemal.add_route "GET", "/greetings" { test_filter.modified }
test_filter.modified.should eq("false")
request = HTTP::Request.new("GET", "/greetings")
create_request_and_return_io_and_context(filter_middleware, request)
io_with_context = create_request_and_return_io_and_context(kemal, request)[0]
client_response = HTTP::Client::Response.from_io(io_with_context, decompress: false)
client_response.body.should eq("true")
end
it "executes code after GET home request but not POST home request" do
test_filter = FilterTest.new
test_filter.modified = "false"
filter_middleware = Kemal::FilterHandler.new
filter_middleware._add_route_filter("GET", "/greetings", :after) { test_filter.modified = test_filter.modified == "true" ? "false" : "true" }
kemal = Kemal::RouteHandler::INSTANCE
kemal.add_route "GET", "/greetings" { test_filter.modified }
kemal.add_route "POST", "/greetings" { test_filter.modified }
test_filter.modified.should eq("false")
request = HTTP::Request.new("GET", "/greetings")
create_request_and_return_io_and_context(filter_middleware, request)
io_with_context = create_request_and_return_io_and_context(kemal, request)[0]
client_response = HTTP::Client::Response.from_io(io_with_context, decompress: false)
client_response.body.should eq("true")
request = HTTP::Request.new("POST", "/greetings")
create_request_and_return_io_and_context(filter_middleware, request)
io_with_context = create_request_and_return_io_and_context(kemal, request)[0]
client_response = HTTP::Client::Response.from_io(io_with_context, decompress: false)
client_response.body.should eq("true")
end
it "executes code after all GET/POST home request" do
test_filter = FilterTest.new
test_filter.modified = "false"
filter_middleware = Kemal::FilterHandler.new
filter_middleware._add_route_filter("ALL", "/greetings", :after) { test_filter.modified = test_filter.modified == "true" ? "false" : "true" }
filter_middleware._add_route_filter("GET", "/greetings", :after) { test_filter.modified = test_filter.modified == "true" ? "false" : "true" }
filter_middleware._add_route_filter("POST", "/greetings", :after) { test_filter.modified = test_filter.modified == "true" ? "false" : "true" }
kemal = Kemal::RouteHandler::INSTANCE
kemal.add_route "GET", "/greetings" { test_filter.modified }
kemal.add_route "POST", "/greetings" { test_filter.modified }
test_filter.modified.should eq("false")
request = HTTP::Request.new("GET", "/greetings")
create_request_and_return_io_and_context(filter_middleware, request)
io_with_context = create_request_and_return_io_and_context(kemal, request)[0]
client_response = HTTP::Client::Response.from_io(io_with_context, decompress: false)
client_response.body.should eq("false")
request = HTTP::Request.new("POST", "/greetings")
create_request_and_return_io_and_context(filter_middleware, request)
io_with_context = create_request_and_return_io_and_context(kemal, request)[0]
client_response = HTTP::Client::Response.from_io(io_with_context, decompress: false)
client_response.body.should eq("false")
end
it "executes 3 differents blocks after all request" do
test_filter = FilterTest.new
test_filter.modified = "false"
test_filter_second = FilterTest.new
test_filter_second.modified = "false"
test_filter_third = FilterTest.new
test_filter_third.modified = "false"
filter_middleware = Kemal::FilterHandler.new
filter_middleware._add_route_filter("ALL", "/greetings", :before) { test_filter.modified = test_filter.modified == "true" ? "false" : "true" }
filter_middleware._add_route_filter("ALL", "/greetings", :before) { test_filter_second.modified = test_filter_second.modified == "true" ? "false" : "true" }
filter_middleware._add_route_filter("ALL", "/greetings", :before) { test_filter_third.modified = test_filter_third.modified == "true" ? "false" : "true" }
kemal = Kemal::RouteHandler::INSTANCE
kemal.add_route "GET", "/greetings" { test_filter.modified }
kemal.add_route "POST", "/greetings" { test_filter_second.modified }
kemal.add_route "PUT", "/greetings" { test_filter_third.modified }
test_filter.modified.should eq("false")
test_filter_second.modified.should eq("false")
test_filter_third.modified.should eq("false")
request = HTTP::Request.new("GET", "/greetings")
create_request_and_return_io_and_context(filter_middleware, request)
io_with_context = create_request_and_return_io_and_context(kemal, request)[0]
client_response = HTTP::Client::Response.from_io(io_with_context, decompress: false)
client_response.body.should eq("true")
request = HTTP::Request.new("POST", "/greetings")
create_request_and_return_io_and_context(filter_middleware, request)
io_with_context = create_request_and_return_io_and_context(kemal, request)[0]
client_response = HTTP::Client::Response.from_io(io_with_context, decompress: false)
client_response.body.should eq("false")
request = HTTP::Request.new("PUT", "/greetings")
create_request_and_return_io_and_context(filter_middleware, request)
io_with_context = create_request_and_return_io_and_context(kemal, request)[0]
client_response = HTTP::Client::Response.from_io(io_with_context, decompress: false)
client_response.body.should eq("true")
end
end
class FilterTest
property modified : String?
end

View File

@ -0,0 +1,204 @@
require "./spec_helper"
describe "ParamParser" do
it "parses query params" do
Route.new "POST", "/" do |env|
hasan = env.params.query["hasan"]
"Hello #{hasan}"
end
request = HTTP::Request.new("POST", "/?hasan=cemal")
query_params = Kemal::ParamParser.new(request).query
query_params["hasan"].should eq "cemal"
end
it "parses multiple values for query params" do
Route.new "POST", "/" do |env|
hasan = env.params.query["hasan"]
"Hello #{hasan}"
end
request = HTTP::Request.new("POST", "/?hasan=cemal&hasan=lamec")
query_params = Kemal::ParamParser.new(request).query
query_params.fetch_all("hasan").should eq ["cemal", "lamec"]
end
it "parses url params" do
kemal = Kemal::RouteHandler::INSTANCE
kemal.add_route "POST", "/hello/:hasan" do |env|
"hello #{env.params.url["hasan"]}"
end
request = HTTP::Request.new("POST", "/hello/cemal")
# Radix tree MUST be run to parse url params.
context = create_request_and_return_io_and_context(kemal, request)[1]
url_params = Kemal::ParamParser.new(request, context.route_lookup.params).url
url_params["hasan"].should eq "cemal"
end
it "decodes url params" do
kemal = Kemal::RouteHandler::INSTANCE
kemal.add_route "POST", "/hello/:email/:money/:spanish" do |env|
email = env.params.url["email"]
money = env.params.url["money"]
spanish = env.params.url["spanish"]
"Hello, #{email}. You have #{money}. The spanish word of the day is #{spanish}."
end
request = HTTP::Request.new("POST", "/hello/sam%2Bspec%40gmail.com/%2419.99/a%C3%B1o")
# Radix tree MUST be run to parse url params.
context = create_request_and_return_io_and_context(kemal, request)[1]
url_params = Kemal::ParamParser.new(request, context.route_lookup.params).url
url_params["email"].should eq "sam+spec@gmail.com"
url_params["money"].should eq "$19.99"
url_params["spanish"].should eq "año"
end
it "parses request body" do
Route.new "POST", "/" do |env|
name = env.params.query["name"]
age = env.params.query["age"]
hasan = env.params.body["hasan"]
"Hello #{name} #{hasan} #{age}"
end
request = HTTP::Request.new(
"POST",
"/?hasan=cemal",
body: "name=serdar&age=99",
headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"},
)
query_params = Kemal::ParamParser.new(request).query
{"hasan" => "cemal"}.each do |k, v|
query_params[k].should eq(v)
end
body_params = Kemal::ParamParser.new(request).body
{"name" => "serdar", "age" => "99"}.each do |k, v|
body_params[k].should eq(v)
end
end
it "parses multiple values in request body" do
Route.new "POST", "/" do |env|
hasan = env.params.body["hasan"]
"Hello #{hasan}"
end
request = HTTP::Request.new(
"POST",
"/",
body: "hasan=cemal&hasan=lamec",
headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"},
)
body_params = Kemal::ParamParser.new(request).body
body_params.fetch_all("hasan").should eq(["cemal", "lamec"])
end
context "when content type is application/json" do
it "parses request body" do
Route.new "POST", "/" { }
request = HTTP::Request.new(
"POST",
"/",
body: "{\"name\": \"Serdar\"}",
headers: HTTP::Headers{"Content-Type" => "application/json"},
)
json_params = Kemal::ParamParser.new(request).json
json_params.should eq({"name" => "Serdar"})
end
it "parses request body when passed charset" do
Route.new "POST", "/" { }
request = HTTP::Request.new(
"POST",
"/",
body: "{\"name\": \"Serdar\"}",
headers: HTTP::Headers{"Content-Type" => "application/json; charset=utf-8"},
)
json_params = Kemal::ParamParser.new(request).json
json_params.should eq({"name" => "Serdar"})
end
it "parses request body for array" do
Route.new "POST", "/" { }
request = HTTP::Request.new(
"POST",
"/",
body: "[1]",
headers: HTTP::Headers{"Content-Type" => "application/json"},
)
json_params = Kemal::ParamParser.new(request).json
json_params.should eq({"_json" => [1]})
end
it "parses request body and query params" do
Route.new "POST", "/" { }
request = HTTP::Request.new(
"POST",
"/?foo=bar",
body: "[1]",
headers: HTTP::Headers{"Content-Type" => "application/json"},
)
query_params = Kemal::ParamParser.new(request).query
{"foo" => "bar"}.each do |k, v|
query_params[k].should eq(v)
end
json_params = Kemal::ParamParser.new(request).json
json_params.should eq({"_json" => [1]})
end
it "handles no request body" do
Route.new "GET", "/" { }
request = HTTP::Request.new(
"GET",
"/",
headers: HTTP::Headers{"Content-Type" => "application/json"},
)
url_params = Kemal::ParamParser.new(request).url
url_params.should eq({} of String => String)
query_params = Kemal::ParamParser.new(request).query
query_params.to_s.should eq("")
body_params = Kemal::ParamParser.new(request).body
body_params.to_s.should eq("")
json_params = Kemal::ParamParser.new(request).json
json_params.should eq({} of String => Nil | String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any))
end
end
context "when content type is incorrect" do
it "does not parse request body" do
Route.new "POST", "/" do |env|
name = env.params.body["name"]
age = env.params.body["age"]
hasan = env.params.query["hasan"]
"Hello #{name} #{hasan} #{age}"
end
request = HTTP::Request.new(
"POST",
"/?hasan=cemal",
body: "name=serdar&age=99",
headers: HTTP::Headers{"Content-Type" => "text/plain"},
)
query_params = Kemal::ParamParser.new(request).query
query_params["hasan"].should eq("cemal")
body_params = Kemal::ParamParser.new(request).body
body_params.to_s.should eq("")
end
end
end

View File

@ -0,0 +1,123 @@
require "./spec_helper"
describe "Kemal::RouteHandler" do
it "routes" do
get "/" do
"hello"
end
request = HTTP::Request.new("GET", "/")
client_response = call_request_on_app(request)
client_response.body.should eq("hello")
end
it "routes should only return strings" do
get "/" do
100
end
request = HTTP::Request.new("GET", "/")
client_response = call_request_on_app(request)
client_response.body.should eq("")
end
it "routes request with query string" do
get "/" do |env|
"hello #{env.params.query["message"]}"
end
request = HTTP::Request.new("GET", "/?message=world")
client_response = call_request_on_app(request)
client_response.body.should eq("hello world")
end
it "routes request with multiple query strings" do
get "/" do |env|
"hello #{env.params.query["message"]} time #{env.params.query["time"]}"
end
request = HTTP::Request.new("GET", "/?message=world&time=now")
client_response = call_request_on_app(request)
client_response.body.should eq("hello world time now")
end
it "route parameter has more precedence than query string arguments" do
get "/:message" do |env|
"hello #{env.params.url["message"]}"
end
request = HTTP::Request.new("GET", "/world?message=coco")
client_response = call_request_on_app(request)
client_response.body.should eq("hello world")
end
it "parses simple JSON body" do
post "/" do |env|
name = env.params.json["name"]
age = env.params.json["age"]
"Hello #{name} Age #{age}"
end
json_payload = {"name": "Serdar", "age": 26}
request = HTTP::Request.new(
"POST",
"/",
body: json_payload.to_json,
headers: HTTP::Headers{"Content-Type" => "application/json"},
)
client_response = call_request_on_app(request)
client_response.body.should eq("Hello Serdar Age 26")
end
it "parses JSON with string array" do
post "/" do |env|
skills = env.params.json["skills"].as(Array)
"Skills #{skills.each.join(',')}"
end
json_payload = {"skills": ["ruby", "crystal"]}
request = HTTP::Request.new(
"POST",
"/",
body: json_payload.to_json,
headers: HTTP::Headers{"Content-Type" => "application/json"},
)
client_response = call_request_on_app(request)
client_response.body.should eq("Skills ruby,crystal")
end
it "parses JSON with json object array" do
post "/" do |env|
skills = env.params.json["skills"].as(Array)
skills_from_languages = skills.map do |skill|
skill["language"]
end
"Skills #{skills_from_languages.each.join(',')}"
end
json_payload = {"skills": [{"language": "ruby"}, {"language": "crystal"}]}
request = HTTP::Request.new(
"POST",
"/",
body: json_payload.to_json,
headers: HTTP::Headers{"Content-Type" => "application/json"},
)
client_response = call_request_on_app(request)
client_response.body.should eq("Skills ruby,crystal")
end
it "can process HTTP HEAD requests for defined GET routes" do
get "/" do
"Hello World from GET"
end
request = HTTP::Request.new("HEAD", "/")
client_response = call_request_on_app(request)
client_response.status_code.should eq(200)
end
it "redirects user to provided url" do
get "/" do |env|
env.redirect "/login"
end
request = HTTP::Request.new("GET", "/")
client_response = call_request_on_app(request)
client_response.status_code.should eq(302)
client_response.headers.has_key?("Location").should eq(true)
end
end

View File

@ -0,0 +1,25 @@
require "./spec_helper"
describe "Route" do
describe "match?" do
it "matches the correct route" do
get "/route1" do
"Route 1"
end
get "/route2" do
"Route 2"
end
request = HTTP::Request.new("GET", "/route2")
client_response = call_request_on_app(request)
client_response.body.should eq("Route 2")
end
it "doesn't allow a route declaration start without /" do
expect_raises Kemal::Exceptions::InvalidPathStartException, "Route declaration get \"route\" needs to start with '/', should be get \"/route\"" do
get "route" do
"Route 1"
end
end
end
end
end

View File

@ -0,0 +1,48 @@
require "./spec_helper"
private def run(code)
code = <<-CR
require "./src/kemal"
#{code}
CR
String.build do |stdout|
stderr = String.build do |stderr|
Process.new("crystal", ["eval"], input: IO::Memory.new(code), output: stdout, error: stderr).wait
end
unless stderr.empty?
fail(stderr)
end
end
end
describe "Run" do
it "runs a code block after starting" do
run(<<-CR).should eq "started\nstopped\n"
Kemal.config.env = "test"
Kemal.run do
puts "started"
Kemal.stop
puts "stopped"
end
CR
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"
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"
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
end
CR
end
end

View File

@ -0,0 +1,88 @@
require "spec"
require "../src/*"
include Kemal
class CustomLogHandler < Kemal::BaseLogHandler
def call(env)
call_next env
end
def write(message)
end
end
class TestContextStorageType
property id
@id = 1
def to_s
@id
end
end
class AnotherContextStorageType
property name
@name = "kemal-context"
end
add_context_storage_type(TestContextStorageType)
add_context_storage_type(AnotherContextStorageType)
def create_request_and_return_io_and_context(handler, request)
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
handler.call(context)
response.close
io.rewind
{io, context}
end
def create_ws_request_and_return_io_and_context(handler, request)
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
begin
handler.call context
rescue IO::Error
# Raises because the IO::Memory is empty
end
io.rewind
{io, context}
end
def call_request_on_app(request)
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
main_handler = build_main_handler
main_handler.call context
response.close
io.rewind
HTTP::Client::Response.from_io(io, decompress: false)
end
def build_main_handler
Kemal.config.setup
main_handler = Kemal.config.handlers.first
current_handler = main_handler
Kemal.config.handlers.each do |handler|
current_handler.next = handler
current_handler = handler
end
main_handler
end
Spec.before_each do
config = Kemal.config
config.env = "development"
config.logging = false
end
Spec.after_each do
Kemal.config.clear
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
end

View File

@ -0,0 +1,9 @@
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse posuere cursus consectetur. Donec mauris lorem, sodales a eros a, ultricies convallis ante. Quisque elementum lacus purus, sagittis mollis justo dignissim ac. Suspendisse potenti. Cras non mauris accumsan mi porttitor congue. Quisque posuere aliquam tellus sit amet ultrices. Sed at tortor sed libero fringilla luctus vitae quis magna. In maximus congue felis, et porta tortor egestas sed. Phasellus orci eros, finibus sed ipsum eget, euismod bibendum nisl. Etiam ultrices facilisis diam in gravida. Praesent lobortis leo vitae aliquet volutpat. Praesent vel blandit risus. In suscipit eget nunc at ultrices. Proin dapibus feugiat diam ut tincidunt. Donec lectus diam, ornare ut consequat nec, gravida sit amet metus.
Nunc a viverra urna, quis ullamcorper augue. Morbi posuere auctor nibh, tempor luctus massa mollis laoreet. Pellentesque sagittis leo eu felis interdum finibus. Pellentesque porttitor lobortis arcu, eu mollis dui iaculis nec. Vestibulum sit amet sodales erat. Nullam quis mi massa. Suspendisse sit amet elit auctor, feugiat ipsum a, placerat metus. Vestibulum quis felis a lectus blandit aliquam. Nam consectetur iaculis nulla. Mauris sit amet condimentum erat, in vestibulum dui. Nullam nec mattis tortor, non viverra nunc. Proin eget congue augue. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed ut hendrerit nulla. Etiam cursus sagittis metus, et feugiat ligula molestie sit amet. Aliquam laoreet auctor sagittis.
Aliquam tempor urna non consectetur tincidunt. Maecenas porttitor augue diam, ac lobortis nulla suscipit eget. Ut quis lacus facilisis, euismod lacus non, ullamcorper urna. Cras pretium fringilla pharetra. Praesent sed nunc at elit vulputate elementum. Suspendisse ac molestie nunc, sit amet consectetur nunc. Cras placerat ligula tortor, non bibendum massa tempus ut. Etiam eros erat, gravida id felis eget, congue suscipit ipsum. Sed condimentum erat at facilisis dictum. Cras venenatis vitae turpis vitae sagittis. Proin id posuere est, non ornare sem. Donec vitae sollicitudin dolor, a pulvinar ex. Integer porta velit lectus, et imperdiet enim commodo a.
Donec sit amet ipsum tempus, tincidunt neque eget, luctus massa. Praesent vel nulla pretium, bibendum enim a, pulvinar enim. Vestibulum non libero eu est dignissim cursus. Nullam commodo tellus imperdiet feugiat placerat. Sed sed dolor ut nibh blandit maximus ac eget neque. Ut sit amet augue maximus, lacinia eros non, faucibus eros. Suspendisse ac bibendum libero, eu lobortis nulla. Mauris arcu nulla, tempus eu varius eu, bibendum at nibh. Donec id libero consequat, volutpat ex vitae, molestie velit. Aliquam aliquam sem ac arcu pellentesque, placerat bibendum enim dapibus. Duis consectetur ligula non placerat euismod.
Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Proin commodo ullamcorper venenatis. Cras ac lorem sit amet augue varius convallis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris dolor nisi, efficitur id aliquet ut, ultricies sed elit. Proin ultricies turpis dolor, in auctor velit aliquet nec. Praesent vehicula aliquam viverra. Suspendisse potenti. Donec aliquet iaculis ultricies. Proin dignissim vitae nisl at rutrum.

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>title</title>
<link rel="stylesheet" href="style.css">
<script src="script.js"></script>
</head>
<body>
<!-- page content -->
</body>
</html>

View File

@ -0,0 +1,2 @@
hello
world

View File

@ -0,0 +1,153 @@
require "./spec_helper"
private def handle(request, fallthrough = true)
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
handler = Kemal::StaticFileHandler.new "#{__DIR__}/static", fallthrough
handler.call context
response.close
io.rewind
HTTP::Client::Response.from_io(io)
end
describe Kemal::StaticFileHandler do
file = File.open "#{__DIR__}/static/dir/test.txt"
file_size = file.size
it "should serve a file with content type and etag" do
response = handle HTTP::Request.new("GET", "/dir/test.txt")
response.status_code.should eq(200)
response.headers["Content-Type"].should eq "text/plain"
response.headers["Etag"].should contain "W/\""
response.body.should eq(File.read("#{__DIR__}/static/dir/test.txt"))
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)
etag = response.headers["Etag"]
headers = HTTP::Headers{"If-None-Match" => etag}
response = handle HTTP::Request.new("GET", "/dir/test.txt", headers)
response.headers["Content-Type"]?.should be_nil
response.status_code.should eq(304)
response.body.should eq ""
end
it "should not list directory's entries" do
serve_static({"gzip" => true, "dir_listing" => false})
response = handle HTTP::Request.new("GET", "/dir/")
response.status_code.should eq(404)
end
it "should list directory's entries when config is set" do
serve_static({"gzip" => true, "dir_listing" => true})
response = handle HTTP::Request.new("GET", "/dir/")
response.status_code.should eq(200)
response.body.should match(/test.txt/)
end
it "should gzip a file if config is true, headers accept gzip and file is > 880 bytes" do
serve_static({"gzip" => true, "dir_listing" => true})
headers = HTTP::Headers{"Accept-Encoding" => "gzip, deflate, sdch, br"}
response = handle HTTP::Request.new("GET", "/dir/bigger.txt", headers)
response.status_code.should eq(200)
response.headers["Content-Encoding"].should eq "gzip"
end
it "should not gzip a file if config is true, headers accept gzip and file is < 880 bytes" do
serve_static({"gzip" => true, "dir_listing" => true})
headers = HTTP::Headers{"Accept-Encoding" => "gzip, deflate, sdch, br"}
response = handle HTTP::Request.new("GET", "/dir/test.txt", headers)
response.status_code.should eq(200)
response.headers["Content-Encoding"]?.should be_nil
end
it "should not gzip a file if config is false, headers accept gzip and file is > 880 bytes" do
serve_static({"gzip" => false, "dir_listing" => true})
headers = HTTP::Headers{"Accept-Encoding" => "gzip, deflate, sdch, br"}
response = handle HTTP::Request.new("GET", "/dir/bigger.txt", headers)
response.status_code.should eq(200)
response.headers["Content-Encoding"]?.should be_nil
end
it "should not serve a not found file" do
response = handle HTTP::Request.new("GET", "/not_found_file.txt")
response.status_code.should eq(404)
end
it "should not serve a not found directory" do
response = handle HTTP::Request.new("GET", "/not_found_dir/")
response.status_code.should eq(404)
end
it "should not serve a file as directory" do
response = handle HTTP::Request.new("GET", "/dir/test.txt/")
response.status_code.should eq(404)
end
it "should handle only GET and HEAD method" do
%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|
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
response.status_code.should eq(405)
response.headers["Allow"].should eq("GET, HEAD")
end
end
it "should send part of files when requested (RFC7233)" do
%w(POST PUT DELETE HEAD).each do |method|
headers = HTTP::Headers{"Range" => "0-100"}
response = handle HTTP::Request.new(method, "/dir/test.txt", headers)
response.status_code.should_not eq(206)
response.headers.has_key?("Content-Range").should eq(false)
end
%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 || 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 eq true
(start_range < end_range).should eq true
end
end
end
end
it "should handle setting custom headers" do
headers = Proc(HTTP::Server::Response, String, File::Info, Void).new do |response, path, stat|
if path =~ /\.html$/
response.headers.add("Access-Control-Allow-Origin", "*")
end
response.headers.add("Content-Size", stat.size.to_s)
end
static_headers(&headers)
response = handle HTTP::Request.new("GET", "/dir/test.txt")
response.headers.has_key?("Access-Control-Allow-Origin").should be_false
response.headers["Content-Size"].should eq(
File.info("#{__DIR__}/static/dir/test.txt").size.to_s
)
response = handle HTTP::Request.new("GET", "/dir/index.html")
response.headers["Access-Control-Allow-Origin"].should eq("*")
end
end

View File

@ -0,0 +1,62 @@
require "./spec_helper"
macro render_with_base_and_layout(filename)
render "spec/asset/#{{{filename}}}", "spec/asset/layout.ecr"
end
describe "Views" do
it "renders file" do
get "/view/:name" do |env|
name = env.params.url["name"]
render "spec/asset/hello.ecr"
end
request = HTTP::Request.new("GET", "/view/world")
client_response = call_request_on_app(request)
client_response.body.should contain("Hello world")
end
it "renders file with dynamic variables" do
get "/view/:name" do |env|
name = env.params.url["name"]
render_with_base_and_layout "hello.ecr"
end
request = HTTP::Request.new("GET", "/view/world")
client_response = call_request_on_app(request)
client_response.body.should contain("Hello world")
end
it "renders layout" do
get "/view/:name" do |env|
name = env.params.url["name"]
render "spec/asset/hello.ecr", "spec/asset/layout.ecr"
end
request = HTTP::Request.new("GET", "/view/world")
client_response = call_request_on_app(request)
client_response.body.should contain("<html>Hello world")
end
it "renders layout with variables" do
get "/view/:name" do |env|
name = env.params.url["name"]
var1 = "serdar"
var2 = "kemal"
render "spec/asset/hello_with_content_for.ecr", "spec/asset/layout_with_yield_and_vars.ecr"
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("serdar")
client_response.body.should contain("kemal")
end
it "renders layout with content_for" do
get "/view/:name" do |env|
name = env.params.url["name"]
render "spec/asset/hello_with_content_for.ecr", "spec/asset/layout_with_yield.ecr"
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>")
end
end

View File

@ -0,0 +1,68 @@
require "./spec_helper"
describe "Kemal::WebSocketHandler" do
it "doesn't match on wrong route" do
handler = Kemal::WebSocketHandler::INSTANCE
handler.next = Kemal::RouteHandler::INSTANCE
ws "/" { }
headers = HTTP::Headers{
"Upgrade" => "websocket",
"Connection" => "Upgrade",
"Sec-WebSocket-Key" => "dGhlIHNhbXBsZSBub25jZQ==",
}
request = HTTP::Request.new("GET", "/asd", headers)
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
expect_raises(Kemal::Exceptions::RouteNotFound) do
handler.call context
end
end
it "matches on given route" do
handler = Kemal::WebSocketHandler::INSTANCE
ws "/" { |socket| socket.send("Match") }
ws "/no_match" { |socket| socket.send "No Match" }
headers = HTTP::Headers{
"Upgrade" => "websocket",
"Connection" => "Upgrade",
"Sec-WebSocket-Key" => "dGhlIHNhbXBsZSBub25jZQ==",
"Sec-WebSocket-Version" => "13",
}
request = HTTP::Request.new("GET", "/", headers)
io_with_context = create_ws_request_and_return_io_and_context(handler, request)[0]
io_with_context.to_s.should eq("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n\x81\u0005Match")
end
it "fetches named url parameters" do
handler = Kemal::WebSocketHandler::INSTANCE
ws "/:id" { |_, c| c.ws_route_lookup.params["id"] }
headers = HTTP::Headers{
"Upgrade" => "websocket",
"Connection" => "Upgrade",
"Sec-WebSocket-Key" => "dGhlIHNhbXBsZSBub25jZQ==",
"Sec-WebSocket-Version" => "13",
}
request = HTTP::Request.new("GET", "/1234", headers)
io_with_context = create_ws_request_and_return_io_and_context(handler, request)[0]
io_with_context.to_s.should eq("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n")
end
it "matches correct verb" do
handler = Kemal::WebSocketHandler::INSTANCE
handler.next = Kemal::RouteHandler::INSTANCE
ws "/" { }
get "/" { "get" }
request = HTTP::Request.new("GET", "/")
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
handler.call(context)
response.close
io.rewind
client_response = HTTP::Client::Response.from_io(io, decompress: false)
client_response.body.should eq("get")
end
end

98
lib/kemal/src/kemal.cr Normal file
View File

@ -0,0 +1,98 @@
require "http"
require "json"
require "uri"
require "./kemal/*"
require "./kemal/ext/*"
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) { }
end
# Overload of `self.run` without port.
def self.run(args = ARGV)
self.run(nil, args: args)
end
# Overload of `self.run` to allow just a block.
def self.run(args = ARGV, &block)
self.run(nil, args: args, &block)
end
# The command to run a `Kemal` application.
#
# If *port* is not given Kemal will use `Kemal::Config#port`
#
# To use custom command line arguments, set args to nil
#
def self.run(port : Int32? = nil, args = ARGV, &block)
Kemal::CLI.new args
config = Kemal.config
config.setup
config.port = port if port
# Test environment doesn't need to have signal trap and logging.
if config.env != "test"
setup_404
setup_trap_signal
end
server = config.server ||= HTTP::Server.new(config.handlers)
config.running = true
yield config
# Abort if block called `Kemal.stop`
return unless config.running
unless server.each_address { |_| break true }
{% if flag?(:without_openssl) %}
server.bind_tcp(config.host_binding, config.port)
{% else %}
if ssl = config.ssl
server.bind_tls(config.host_binding, config.port, ssl)
else
server.bind_tcp(config.host_binding, config.port)
end
{% end %}
end
display_startup_message(config, server)
server.listen unless config.env == "test"
end
def self.display_startup_message(config, server)
addresses = server.addresses.map { |address| "#{config.scheme}://#{address}" }.join ", "
log "[#{config.env}] Kemal is ready to lead at #{addresses}"
end
def self.stop
raise "Kemal is already stopped." if !config.running
if server = config.server
server.close unless server.closed?
config.running = false
else
raise "Kemal.config.server is not set. Please use Kemal.run to set the server."
end
end
private def self.setup_404
unless Kemal.config.error_handlers.has_key?(404)
error 404 do
render_404
end
end
end
private def self.setup_trap_signal
Signal::INT.trap do
log "Kemal is going to take a rest!" if Kemal.config.shutdown_message
Kemal.stop
exit
end
end
end

Some files were not shown because too many files have changed in this diff Show More