mirror of
https://github.com/iv-org/invidious.git
synced 2025-08-03 19:28:29 +00:00
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:
parent
dcff1ec25f
commit
40fb17791e
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,9 +1,7 @@
|
||||
/doc/
|
||||
/dev/
|
||||
/lib/
|
||||
/bin/
|
||||
/.shards/
|
||||
/.vscode/
|
||||
/invidious
|
||||
/sentry
|
||||
shard.lock
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
```
|
||||
|
||||
|
@ -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
10
lib/db/.gitignore
vendored
Normal 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
7
lib/db/.travis.yml
Normal file
@ -0,0 +1,7 @@
|
||||
language: crystal
|
||||
crystal:
|
||||
- latest
|
||||
- nightly
|
||||
script:
|
||||
- crystal spec
|
||||
- crystal tool format --check
|
93
lib/db/CHANGELOG.md
Normal file
93
lib/db/CHANGELOG.md
Normal 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
21
lib/db/LICENSE
Normal 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
97
lib/db/README.md
Normal file
@ -0,0 +1,97 @@
|
||||
[](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
9
lib/db/shard.yml
Normal file
@ -0,0 +1,9 @@
|
||||
name: db
|
||||
version: 0.6.0
|
||||
|
||||
authors:
|
||||
- Brian J. Cardiff <bcardiff@manas.tech>
|
||||
|
||||
crystal: 0.24.0
|
||||
|
||||
license: MIT
|
293
lib/db/spec/custom_drivers_types_spec.cr
Normal file
293
lib/db/spec/custom_drivers_types_spec.cr
Normal 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
|
255
lib/db/spec/database_spec.cr
Normal file
255
lib/db/spec/database_spec.cr
Normal 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
134
lib/db/spec/db_spec.cr
Normal 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
|
31
lib/db/spec/disposable_spec.cr
Normal file
31
lib/db/spec/disposable_spec.cr
Normal 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
268
lib/db/spec/dummy_driver.cr
Normal 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
|
310
lib/db/spec/dummy_driver_spec.cr
Normal file
310
lib/db/spec/dummy_driver_spec.cr
Normal 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
194
lib/db/spec/mapping_spec.cr
Normal 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
206
lib/db/spec/pool_spec.cr
Normal 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
|
67
lib/db/spec/result_set_spec.cr
Normal file
67
lib/db/spec/result_set_spec.cr
Normal 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
|
160
lib/db/spec/save_point_transaction_spec.cr
Normal file
160
lib/db/spec/save_point_transaction_spec.cr
Normal 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
|
3
lib/db/spec/spec_helper.cr
Normal file
3
lib/db/spec/spec_helper.cr
Normal file
@ -0,0 +1,3 @@
|
||||
require "spec"
|
||||
require "./dummy_driver"
|
||||
require "../src/db"
|
157
lib/db/spec/statement_spec.cr
Normal file
157
lib/db/spec/statement_spec.cr
Normal 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
|
178
lib/db/spec/transaction_spec.cr
Normal file
178
lib/db/spec/transaction_spec.cr
Normal 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
200
lib/db/src/db.cr
Normal 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"
|
33
lib/db/src/db/begin_transaction.cr
Normal file
33
lib/db/src/db/begin_transaction.cr
Normal 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
121
lib/db/src/db/connection.cr
Normal 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
|
36
lib/db/src/db/connection_context.cr
Normal file
36
lib/db/src/db/connection_context.cr
Normal 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
146
lib/db/src/db/database.cr
Normal 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
|
24
lib/db/src/db/disposable.cr
Normal file
24
lib/db/src/db/disposable.cr
Normal 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
42
lib/db/src/db/driver.cr
Normal 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
32
lib/db/src/db/error.cr
Normal 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
154
lib/db/src/db/mapping.cr
Normal 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
207
lib/db/src/db/pool.cr
Normal 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
|
56
lib/db/src/db/pool_prepared_statement.cr
Normal file
56
lib/db/src/db/pool_prepared_statement.cr
Normal 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
|
57
lib/db/src/db/pool_statement.cr
Normal file
57
lib/db/src/db/pool_statement.cr
Normal 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
|
27
lib/db/src/db/pool_unprepared_statement.cr
Normal file
27
lib/db/src/db/pool_unprepared_statement.cr
Normal 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
|
275
lib/db/src/db/query_methods.cr
Normal file
275
lib/db/src/db/query_methods.cr
Normal 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
125
lib/db/src/db/result_set.cr
Normal 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
|
73
lib/db/src/db/session_methods.cr
Normal file
73
lib/db/src/db/session_methods.cr
Normal 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
114
lib/db/src/db/statement.cr
Normal 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
|
21
lib/db/src/db/string_key_cache.cr
Normal file
21
lib/db/src/db/string_key_cache.cr
Normal 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
|
131
lib/db/src/db/transaction.cr
Normal file
131
lib/db/src/db/transaction.cr
Normal 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
3
lib/db/src/db/version.cr
Normal file
@ -0,0 +1,3 @@
|
||||
module DB
|
||||
VERSION = "0.6.0"
|
||||
end
|
514
lib/db/src/spec.cr
Normal file
514
lib/db/src/spec.cr
Normal 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
|
9
lib/exception_page/.editorconfig
Normal file
9
lib/exception_page/.editorconfig
Normal 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
9
lib/exception_page/.gitignore
vendored
Normal 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
|
13
lib/exception_page/.travis.yml
Normal file
13
lib/exception_page/.travis.yml
Normal 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
|
21
lib/exception_page/LICENSE
Normal file
21
lib/exception_page/LICENSE
Normal 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.
|
111
lib/exception_page/README.md
Normal file
111
lib/exception_page/README.md
Normal file
@ -0,0 +1,111 @@
|
||||
# Exception Page
|
||||
|
||||
A library for displaying exceptional exception pages for easier debugging.
|
||||
|
||||

|
||||
|
||||
## 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!
|
15
lib/exception_page/shard.yml
Normal file
15
lib/exception_page/shard.yml
Normal 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
|
33
lib/exception_page/spec/exception_page_spec.cr
Normal file
33
lib/exception_page/spec/exception_page_spec.cr
Normal 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
|
27
lib/exception_page/spec/frame_spec.cr
Normal file
27
lib/exception_page/spec/frame_spec.cr
Normal 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
|
25
lib/exception_page/spec/spec_helper.cr
Normal file
25
lib/exception_page/spec/spec_helper.cr
Normal 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!
|
19
lib/exception_page/spec/support/app_exception_page.cr
Normal file
19
lib/exception_page/spec/support/app_exception_page.cr
Normal 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
|
22
lib/exception_page/spec/support/test_server.cr
Normal file
22
lib/exception_page/spec/support/test_server.cr
Normal 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
|
54
lib/exception_page/src/exception_page.cr
Normal file
54
lib/exception_page/src/exception_page.cr
Normal 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
|
855
lib/exception_page/src/exception_page/exception_page.ecr
Normal file
855
lib/exception_page/src/exception_page/exception_page.ecr
Normal 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("'", '\'').gsub(""", '"') %></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("'", '\'').gsub(""", '"') %></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("'", '\'').gsub(""", '"') %></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("'", '\'').gsub(""", '"') %></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>
|
77
lib/exception_page/src/exception_page/frame.cr
Normal file
77
lib/exception_page/src/exception_page/frame.cr
Normal 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
|
13
lib/exception_page/src/exception_page/frame_generator.cr
Normal file
13
lib/exception_page/src/exception_page/frame_generator.cr
Normal 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
|
18
lib/exception_page/src/exception_page/styles.cr
Normal file
18
lib/exception_page/src/exception_page/styles.cr
Normal 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
|
3
lib/exception_page/src/exception_page/version.cr
Normal file
3
lib/exception_page/src/exception_page/version.cr
Normal file
@ -0,0 +1,3 @@
|
||||
class ExceptionPage
|
||||
VERSION = "0.1.2"
|
||||
end
|
42
lib/kemal/.ameba.yml
Normal file
42
lib/kemal/.ameba.yml
Normal 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
8
lib/kemal/.github/FUNDING.yml
vendored
Normal 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
23
lib/kemal/.github/ISSUE_TEMPLATE.md
vendored
Normal 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.
|
15
lib/kemal/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
15
lib/kemal/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
8
lib/kemal/.gitignore
vendored
Normal 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
14
lib/kemal/.travis.yml
Normal 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
370
lib/kemal/CHANGELOG.md
Normal 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
19
lib/kemal/LICENSE
Normal 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
67
lib/kemal/README.md
Normal file
@ -0,0 +1,67 @@
|
||||
|
||||
[](http://kemalcr.com)
|
||||
|
||||
# Kemal
|
||||
|
||||
Lightning Fast, Super Simple web framework.
|
||||
|
||||
[](https://travis-ci.org/kemalcr/kemal)
|
||||
[](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).
|
8
lib/kemal/samples/hello_world.cr
Normal file
8
lib/kemal/samples/hello_world.cr
Normal file
@ -0,0 +1,8 @@
|
||||
require "kemal"
|
||||
|
||||
# Set root. If not specified the default content_type is 'text'
|
||||
get "/" do
|
||||
"Hello Kemal!"
|
||||
end
|
||||
|
||||
Kemal.run
|
11
lib/kemal/samples/json_api.cr
Normal file
11
lib/kemal/samples/json_api.cr
Normal file
@ -0,0 +1,11 @@
|
||||
require "kemal"
|
||||
require "json"
|
||||
|
||||
# You can easily access the context and set content_type like 'application/json'.
|
||||
# Look how easy to build a JSON serving API.
|
||||
get "/" do |env|
|
||||
env.response.content_type = "application/json"
|
||||
{name: "Serdar", age: 27}.to_json
|
||||
end
|
||||
|
||||
Kemal.run
|
11
lib/kemal/samples/websocket_server.cr
Normal file
11
lib/kemal/samples/websocket_server.cr
Normal file
@ -0,0 +1,11 @@
|
||||
require "kemal"
|
||||
|
||||
ws "/" do |socket|
|
||||
socket.send "Hello from Kemal!"
|
||||
|
||||
socket.on_message do |message|
|
||||
socket.send "Echo back from server #{message}"
|
||||
end
|
||||
end
|
||||
|
||||
Kemal.run
|
25
lib/kemal/shard.yml
Normal file
25
lib/kemal/shard.yml
Normal 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
|
1
lib/kemal/spec/all_spec.cr
Normal file
1
lib/kemal/spec/all_spec.cr
Normal file
@ -0,0 +1 @@
|
||||
require "./*"
|
1
lib/kemal/spec/asset/hello.ecr
Normal file
1
lib/kemal/spec/asset/hello.ecr
Normal file
@ -0,0 +1 @@
|
||||
Hello <%= name %>
|
5
lib/kemal/spec/asset/hello_with_content_for.ecr
Normal file
5
lib/kemal/spec/asset/hello_with_content_for.ecr
Normal file
@ -0,0 +1,5 @@
|
||||
Hello <%= name %>
|
||||
|
||||
<% content_for "custom" do %>
|
||||
<h1>Hello from otherside</h1>
|
||||
<% end %>
|
1
lib/kemal/spec/asset/layout.ecr
Normal file
1
lib/kemal/spec/asset/layout.ecr
Normal file
@ -0,0 +1 @@
|
||||
<html><%= content %></html>
|
6
lib/kemal/spec/asset/layout_with_yield.ecr
Normal file
6
lib/kemal/spec/asset/layout_with_yield.ecr
Normal file
@ -0,0 +1,6 @@
|
||||
<html>
|
||||
<body>
|
||||
<%= content %>
|
||||
<%= yield_content "custom" %>
|
||||
</body>
|
||||
</html>
|
8
lib/kemal/spec/asset/layout_with_yield_and_vars.ecr
Normal file
8
lib/kemal/spec/asset/layout_with_yield_and_vars.ecr
Normal file
@ -0,0 +1,8 @@
|
||||
<html>
|
||||
<body>
|
||||
<%= content %>
|
||||
<%= yield_content "custom" %>
|
||||
<%= var1 %>
|
||||
<%= var2 %>
|
||||
</body>
|
||||
</html>
|
61
lib/kemal/spec/config_spec.cr
Normal file
61
lib/kemal/spec/config_spec.cr
Normal 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
|
107
lib/kemal/spec/context_spec.cr
Normal file
107
lib/kemal/spec/context_spec.cr
Normal 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
|
115
lib/kemal/spec/exception_handler_spec.cr
Normal file
115
lib/kemal/spec/exception_handler_spec.cr
Normal 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
|
161
lib/kemal/spec/handler_spec.cr
Normal file
161
lib/kemal/spec/handler_spec.cr
Normal 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
|
155
lib/kemal/spec/helpers_spec.cr
Normal file
155
lib/kemal/spec/helpers_spec.cr
Normal 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
|
32
lib/kemal/spec/init_handler_spec.cr
Normal file
32
lib/kemal/spec/init_handler_spec.cr
Normal 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
|
21
lib/kemal/spec/log_handler_spec.cr
Normal file
21
lib/kemal/spec/log_handler_spec.cr
Normal 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
|
190
lib/kemal/spec/middleware/filters_spec.cr
Normal file
190
lib/kemal/spec/middleware/filters_spec.cr
Normal 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
|
204
lib/kemal/spec/param_parser_spec.cr
Normal file
204
lib/kemal/spec/param_parser_spec.cr
Normal 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
|
123
lib/kemal/spec/route_handler_spec.cr
Normal file
123
lib/kemal/spec/route_handler_spec.cr
Normal 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
|
25
lib/kemal/spec/route_spec.cr
Normal file
25
lib/kemal/spec/route_spec.cr
Normal 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
|
48
lib/kemal/spec/run_spec.cr
Normal file
48
lib/kemal/spec/run_spec.cr
Normal 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
|
88
lib/kemal/spec/spec_helper.cr
Normal file
88
lib/kemal/spec/spec_helper.cr
Normal 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
|
9
lib/kemal/spec/static/dir/bigger.txt
Normal file
9
lib/kemal/spec/static/dir/bigger.txt
Normal 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.
|
12
lib/kemal/spec/static/dir/index.html
Normal file
12
lib/kemal/spec/static/dir/index.html
Normal 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>
|
2
lib/kemal/spec/static/dir/test.txt
Normal file
2
lib/kemal/spec/static/dir/test.txt
Normal file
@ -0,0 +1,2 @@
|
||||
hello
|
||||
world
|
153
lib/kemal/spec/static_file_handler_spec.cr
Normal file
153
lib/kemal/spec/static_file_handler_spec.cr
Normal 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
|
62
lib/kemal/spec/view_spec.cr
Normal file
62
lib/kemal/spec/view_spec.cr
Normal 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
|
68
lib/kemal/spec/websocket_handler_spec.cr
Normal file
68
lib/kemal/spec/websocket_handler_spec.cr
Normal 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
98
lib/kemal/src/kemal.cr
Normal 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
Loading…
Reference in New Issue
Block a user