├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── samples ├── basic.cr ├── logging.cr └── server.cr ├── shard.lock ├── shard.yml ├── src ├── application.cr ├── artanis.cr ├── dsl.cr ├── logging.cr ├── render.cr └── response.cr └── test ├── dsl_bench.cr ├── dsl_test.cr ├── logging_test.cr ├── render_test.cr ├── test_helper.cr └── views ├── custom.ecr ├── index.ecr ├── layout.ecr └── raw.ecr /.gitignore: -------------------------------------------------------------------------------- 1 | /.crystal 2 | /.shards 3 | /lib 4 | /doc 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | script: make test bench 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Julien Portalier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .POSIX: 2 | 3 | CRYSTAL = crystal 4 | CRFLAGS = 5 | 6 | test: .phony 7 | $(CRYSTAL) run $(CRFLAGS) test/*_test.cr 8 | 9 | bench: .phony 10 | $(CRYSTAL) run $(CRFLAGS) --release test/*_bench.cr 11 | 12 | server: .phony 13 | $(CRYSTAL) run $(CRFLAGS) --release samples/server.cr 14 | 15 | wrk: .phony 16 | wrk -c 1000 -t 2 -d 5 http://localhost:9292/ 17 | 18 | .phony: 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Artanis 2 | 3 | Crystal's metaprogramming macros to build a Sinatra-like DSL for the Crystal 4 | language. 5 | 6 | ## Rationale 7 | 8 | The DSL doesn't stock blocks to be invoked later on, but rather produces actual 9 | methods using macros (`match` and the sugar `get`, `post`, etc.) where special 10 | chars like `/`, `.`, `(` and `)` are replaced as `_SLASH_`, `_DOT_`, `_LPAREN_` 11 | and `_RPAREN_`. Also, `:param` segments are replaced to `_PARAM_`. 12 | 13 | Eventually methods look like: 14 | 15 | get "/posts" | def match_GET__SLASH_posts 16 | get "/posts.xml" | def match_GET__SLASH_posts_DOT_xml 17 | 18 | Please read [dsl.cr](https://github.com/ysbaddaden/artanis/tree/master/src/dsl.cr) 19 | for more details. 20 | 21 | Eventually a call method is generated. It iterates over the class methods, 22 | selects the generated `match_*` methods and generates a big `case` statement, 23 | transforming the method names back to regular expressions, and eventually 24 | calling the method with matched data (if any). 25 | 26 | Please read [application.cr](https://github.com/ysbaddaden/artanis/tree/master/src/application.cr) 27 | for more details. 28 | 29 | ## Usage 30 | 31 | ```crystal 32 | require "http/server" 33 | require "artanis" 34 | 35 | class App < Artanis::Application 36 | get "/" do 37 | "ROOT" 38 | end 39 | 40 | get "/forbidden" do 41 | 403 42 | end 43 | 44 | get "/posts/:id.:format" do 45 | p params["id"] 46 | p params["format"] 47 | "post" 48 | end 49 | 50 | get "/posts/:post_id/comments/:id(.:format)" do |post_id, id, format| 51 | p params["format"]? 52 | 200 53 | end 54 | end 55 | 56 | server = HTTP::Server.new(9292) do |context| 57 | App.call(context) 58 | end 59 | 60 | puts "Listening on http://0.0.0.0:9292" 61 | server.listen 62 | ``` 63 | 64 | ## Benchmark 65 | 66 | Running wrk against the above example (pointless hello world) gives the following 67 | results (TL;DR 12µs per request): 68 | 69 | ``` 70 | $ wrk -c 1000 -t 2 -d 5 http://localhost:9292/fast 71 | Running 5s test @ http://localhost:9292/fast 72 | 2 threads and 1000 connections 73 | Thread Stats Avg Stdev Max +/- Stdev 74 | Latency 12.26ms 14.48ms 423.28ms 99.05% 75 | Req/Sec 41.17k 2.93k 48.65k 76.00% 76 | 409663 requests in 5.01s, 25.79MB read 77 | Requests/sec: 81722.08 78 | Transfer/sec: 5.14MB 79 | ``` 80 | 81 | A better benchmark is available in `test/dsl_bench.cr` which monitors some 82 | limits of the generated Crystal code, like going over all routes to find nothing 83 | takes an awful lot of time, since it must build/execute a regular expression 84 | against EVERY routes to eventually... find nothing. 85 | 86 | ``` 87 | $ crystal run --release test/dsl_bench.cr 88 | get root: 0.84 µs 89 | get param: 1.51 µs 90 | get params (block args): 2.18 µs 91 | get many params: 3.75 µs 92 | get many params (block args): 2.59 µs 93 | not found (method): 0.73 µs 94 | not found (path): 15.93 µs 95 | ``` 96 | 97 | Keep in mind these numbers tell nothing about reality. They only measure how 98 | fast the generated `Application.call(request)` method is in predefined cases. 99 | 100 | ## License 101 | 102 | Licensed under the MIT License 103 | -------------------------------------------------------------------------------- /samples/basic.cr: -------------------------------------------------------------------------------- 1 | require "../src/artanis" 2 | 3 | class App < Artanis::Application 4 | get "/" do 5 | "ROOT" 6 | end 7 | 8 | get "/posts" do 9 | "POSTS" 10 | end 11 | 12 | get "/posts/:id.json" do 13 | "POST: #{params["id"]}" 14 | end 15 | 16 | delete "/blog/:name/posts/:post_id/comments/:id.:format" do |name, post_id, id, format| 17 | "COMMENT: #{params.inspect} #{[name, post_id, id, format]}" 18 | end 19 | 20 | get "/wiki/*path" do |path| 21 | "WIKI: #{path}" 22 | end 23 | 24 | get "/kiwi/*path.:format" do |path, format| 25 | "KIWI: #{path} (#{format})" 26 | end 27 | 28 | get "/optional(.:format)" do |format| 29 | "OPTIONAL (#{format})" 30 | end 31 | end 32 | 33 | def call(method, path) 34 | request = HTTP::Request.new(method, path) 35 | response = HTTP::Server::Response.new(IO::Memory.new) 36 | context = HTTP::Server::Context.new(request, response) 37 | App.call(context) 38 | end 39 | 40 | puts "ASSERTIONS:" 41 | puts call("GET", "/").body 42 | puts call("GET", "/posts").body 43 | puts call("GET", "/posts/1.json").body 44 | puts call("DELETE", "/blog/me/posts/123/comments/456.xml").body 45 | puts call("GET", "/wiki/category/page.html").body 46 | puts call("GET", "/kiwi/category/page.html").body 47 | puts call("GET", "/optional").body 48 | puts call("GET", "/optional.html").body 49 | puts 50 | 51 | puts "REFUTATIONS:" 52 | puts call("GET", "/fail").body 53 | puts call("GET", "/posts/1").body 54 | puts call("DELETE", "/blog/me/posts/123/comments/456").body 55 | puts 56 | -------------------------------------------------------------------------------- /samples/logging.cr: -------------------------------------------------------------------------------- 1 | require "../src/artanis" 2 | require "../src/logging" 3 | 4 | class LogApp < Artanis::Application 5 | include Artanis::Logging 6 | 7 | before do 8 | info "lorem ipsum" 9 | end 10 | 11 | get "/" do 12 | warn "ERR" 13 | "ERR\n" 14 | end 15 | end 16 | 17 | server = HTTP::Server.new { |context| LogApp.call(context) } 18 | server.bind_tcp(9292) 19 | server.listen 20 | -------------------------------------------------------------------------------- /samples/server.cr: -------------------------------------------------------------------------------- 1 | require "../src/artanis" 2 | 3 | class App < Artanis::Application 4 | get "/" do 5 | "ROOT" 6 | end 7 | 8 | get "/fast" do 9 | response << "FAST" 10 | nil 11 | end 12 | 13 | get "/posts/:post_id/comments/:id(.:format)" do |post_id, id, format| 14 | p params 15 | 200 16 | end 17 | end 18 | 19 | server = HTTP::Server.new { |context| App.call(context) } 20 | server.bind_tcp(9292) 21 | 22 | puts "Listening on http://0.0.0.0:9292" 23 | server.listen 24 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 2.0 2 | shards: 3 | minitest: 4 | git: https://github.com/ysbaddaden/minitest.cr.git 5 | version: 1.0.1 6 | 7 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: artanis 2 | version: 0.1.0 3 | 4 | author: 5 | - Julien Portalier 6 | 7 | crystal: 0.21.1 8 | 9 | development_dependencies: 10 | minitest: 11 | github: ysbaddaden/minitest.cr 12 | -------------------------------------------------------------------------------- /src/application.cr: -------------------------------------------------------------------------------- 1 | require "./dsl" 2 | require "./render" 3 | require "./response" 4 | require "http/server" 5 | 6 | module Artanis 7 | class Application 8 | include DSL 9 | include Render 10 | 11 | getter :context 12 | 13 | def self.call(context) 14 | new(context).call 15 | end 16 | 17 | def initialize(@context : HTTP::Server::Context) 18 | @params = {} of String => String 19 | @parsed_body = false 20 | end 21 | 22 | def request 23 | context.request 24 | end 25 | 26 | def params 27 | parse_body_params 28 | @params 29 | end 30 | 31 | def response 32 | @response ||= Response.new(context.response) 33 | end 34 | 35 | def status(code) 36 | response.status_code = code.to_i 37 | end 38 | 39 | def body(str) 40 | response.body = str 41 | end 42 | 43 | def headers(hsh) 44 | hsh.each { |k, v| response.headers.add(k.to_s, v.to_s) } 45 | end 46 | 47 | def redirect(uri) 48 | response.headers["Location"] = uri.to_s 49 | end 50 | 51 | def not_found 52 | status 404 53 | body yield 54 | end 55 | 56 | private def no_such_route 57 | not_found { "NOT FOUND: #{request.method} #{request.path}" } 58 | end 59 | 60 | private def parse_body_params 61 | return if @parsed_body 62 | @parsed_body = true 63 | return unless request.headers["Content-Type"]? == "application/x-www-form-urlencoded" 64 | if body = request.body 65 | HTTP::Params.parse(body.gets_to_end) { |key, value| @params[key] = value } 66 | end 67 | end 68 | 69 | macro inherited 70 | views_path "views" 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /src/artanis.cr: -------------------------------------------------------------------------------- 1 | require "./application" 2 | -------------------------------------------------------------------------------- /src/dsl.cr: -------------------------------------------------------------------------------- 1 | require "http/params" 2 | 3 | module Artanis 4 | module DSL 5 | # TODO: error(code, &block) macro to install handlers for returned statuses 6 | 7 | # :nodoc: 8 | FIND_PARAM_NAME = /:([\w\d_]+)/ 9 | 10 | {% for method in %w(head options get post put patch delete) %} 11 | macro {{ method.id }}(path, &block) 12 | match({{ method }}, \{\{ path }}) \{\{ block }} 13 | end 14 | {% end %} 15 | 16 | macro match(method, path, &block) 17 | gen_match("match", {{ method }}, {{ path }}) {{ block }} 18 | end 19 | 20 | macro before(path = "*", &block) 21 | gen_match("before", nil, {{ path }}) {{ block }} 22 | end 23 | 24 | macro after(path = "*", &block) 25 | gen_match("after", nil, {{ path }}) {{ block }} 26 | end 27 | 28 | # TODO: use __match_000000 routes to more easily support whatever in routes (?) 29 | # TODO: match regexp routes 30 | # TODO: add conditions on routes 31 | macro gen_match(type, method, path, &block) 32 | {% 33 | prepared = path 34 | .gsub(/\*[^\/.()]+/, "_SPLAT_") 35 | .gsub(/\*/, "_ANY_") 36 | .gsub(/:[^\/.()]+/, "_PARAM_") 37 | .gsub(/\./, "_DOT_") 38 | .gsub(/\//, "_SLASH_") 39 | .gsub(/\(/, "_LPAREN_") 40 | .gsub(/\)/, "_RPAREN_") 41 | 42 | matcher = prepared 43 | .gsub(/_SPLAT_/, "(.*?)") 44 | .gsub(/_ANY_/, ".*?") 45 | .gsub(/_PARAM_/, "([^\\/.]+)") 46 | .gsub(/_DOT_/, "\\.") 47 | .gsub(/_SLASH_/, "\\/") 48 | #.gsub(/_LPAREN_(.+?)_RPAREN_/, "(?:\1)") 49 | .gsub(/_LPAREN_/, "(?:") 50 | .gsub(/_RPAREN_/, ")?") 51 | 52 | method_name = prepared 53 | .gsub(/-/, "_MINUS_") 54 | 55 | method_name = "#{method.upcase.id}_#{method_name.id}" if method 56 | %} 57 | {{ type.upcase.id }}_{{ method_name.upcase.id }} = /\A{{ matcher.id }}\Z/ 58 | 59 | def {{ type.id }}_{{ method_name.id }}(matchdata) 60 | parse_query_params 61 | 62 | {{ path }} 63 | .scan(FIND_PARAM_NAME) 64 | .each_with_index do |m, i| 65 | if value = matchdata[i + 1]? 66 | @params[m[1]] = value 67 | end 68 | end 69 | 70 | {% for arg, index in block.args %} 71 | {{ arg.id }} = matchdata[{{ index + 1 }}]? 72 | {% end %} 73 | 74 | res = {{ yield }} 75 | 76 | {% if type == "match" %} 77 | if res.is_a?(Int) 78 | status res 79 | else 80 | body res 81 | end 82 | {% end %} 83 | 84 | # see https://github.com/manastech/crystal/issues/821 85 | :ok 86 | end 87 | end 88 | 89 | macro call_action(method_name) 90 | if %m = URI.decode(request.path || "/").match({{@type}}::{{ method_name.upcase.id }}) 91 | %ret = {{ method_name.id }}(%m) 92 | 93 | {% if method_name.starts_with?("match_") %} 94 | break unless %ret == :pass 95 | {% else %} 96 | break if %ret == :halt 97 | {% end %} 98 | end 99 | end 100 | 101 | macro call_method(method) 102 | {% method_names = @type.methods.map(&.name.stringify) %} 103 | 104 | loop do 105 | {% for method_name in method_names.select(&.starts_with?("before_")) %} 106 | call_action {{ method_name }} 107 | {% end %} 108 | 109 | loop do 110 | {% for method_name in method_names.select(&.starts_with?("match_#{ method.id }")) %} 111 | call_action {{ method_name }} 112 | {% end %} 113 | 114 | no_such_route 115 | break 116 | end 117 | 118 | {% for method_name in method_names.select(&.starts_with?("after_")) %} 119 | call_action {{ method_name }} 120 | {% end %} 121 | 122 | break 123 | end 124 | end 125 | 126 | def call : Artanis::Response 127 | {% begin %} 128 | {% 129 | methods = @type.methods 130 | .map(&.name.stringify) 131 | .select(&.starts_with?("match_")) 132 | .map { |method_name| method_name.split("_")[1] } 133 | .uniq 134 | %} 135 | 136 | {% if methods.empty? %} 137 | no_such_route 138 | {% else %} 139 | case request.method.upcase 140 | {% for method in methods %} 141 | when {{ method }} then call_method {{ method }} 142 | {% end %} 143 | else 144 | no_such_route 145 | end 146 | {% end %} 147 | 148 | response.write_body 149 | response 150 | {% end %} 151 | end 152 | 153 | macro halt(code_or_message = 200) 154 | {% if code_or_message.is_a?(NumberLiteral) %} 155 | status {{ code_or_message }}.to_i 156 | {% else %} 157 | body {{ code_or_message }}.to_s 158 | {% end %} 159 | return :halt 160 | end 161 | 162 | macro halt(code, message) 163 | status {{ code }}.to_i 164 | body {{ message }}.to_s 165 | return :halt 166 | end 167 | 168 | macro pass 169 | return :pass 170 | end 171 | 172 | private def parse_query_params 173 | query = request.query 174 | if !@query_parsed && query 175 | HTTP::Params.parse(query) { |key, value| @params[key] = value } 176 | @query_parsed = true 177 | end 178 | end 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /src/logging.cr: -------------------------------------------------------------------------------- 1 | require "log" 2 | 3 | module Artanis::Logging 4 | module ClassMethods 5 | macro extended 6 | @@logger : Log? 7 | end 8 | 9 | def logger 10 | @@logger ||= Log.for("artanis") 11 | end 12 | 13 | def logger=(@@logger : Log) 14 | end 15 | end 16 | 17 | macro included 18 | extend ClassMethods 19 | end 20 | 21 | {% for constant in Log::Severity.constants %} 22 | {% name = constant.downcase.id %} 23 | 24 | def {{name}}(message) 25 | self.class.logger.{{name}} { message } 26 | end 27 | 28 | def {{name}} 29 | self.class.logger.{{name}} { yield } 30 | end 31 | {% end %} 32 | end 33 | -------------------------------------------------------------------------------- /src/render.cr: -------------------------------------------------------------------------------- 1 | require "ecr/macros" 2 | require "json" 3 | 4 | module Artanis 5 | # TODO: render views in subpaths (eg: views/blog/posts/show.ecr => render_blog_posts_show_ecr) 6 | module Render 7 | def json(contents) 8 | header "Content-Type", "application/json; charset=utf-8" 9 | body contents.to_json 10 | end 11 | 12 | macro ecr(name, layout = "layout") 13 | render {{ name }}, "ecr", layout: {{ layout }} 14 | end 15 | 16 | macro render(name, engine, layout = "layout") 17 | {% if layout %} 18 | render_{{ layout.id }}_{{ engine.id }} do 19 | render_{{ name.gsub(/\//, "__SLASH__").id }}_{{ engine.id }} 20 | end 21 | {% else %} 22 | render_{{ name.gsub(/\//, "__SLASH__").id }}_{{ engine.id }} 23 | {% end %} 24 | end 25 | 26 | macro views_path(path) 27 | {% 28 | views = `cd #{ path } 2>/dev/null && find . -name "*.ecr" -type f || true` 29 | .lines 30 | .map(&.strip 31 | .gsub(/^\.\//, "") 32 | .gsub(/\.ecr/, "") 33 | .gsub(/\//, "__SLASH__")) 34 | %} 35 | 36 | {% for view in views %} 37 | def render_{{ view.id }}_ecr 38 | render_{{ view.id }}_ecr {} 39 | end 40 | 41 | def render_{{ view.id }}_ecr(&block) 42 | String.build do |__str__| 43 | ECR.embed "{{ path.id }}/{{ view.gsub(/__SLASH__/, "/").id }}.ecr", "__str__" 44 | end 45 | end 46 | {% end %} 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /src/response.cr: -------------------------------------------------------------------------------- 1 | require "http/server/response" 2 | 3 | module Artanis 4 | class Response 5 | forward_missing_to @original 6 | 7 | @body : String? 8 | 9 | def initialize(@original : HTTP::Server::Response) 10 | @wrote_body = false 11 | end 12 | 13 | def body 14 | @body || "" 15 | end 16 | 17 | def body? 18 | @body 19 | end 20 | 21 | def body=(str) 22 | @body = str 23 | end 24 | 25 | def write_body 26 | if (body = @body) && !@wrote_body 27 | self << body 28 | @wrote_body = true 29 | end 30 | end 31 | 32 | def flush 33 | write_body 34 | @original.flush 35 | end 36 | 37 | def reset 38 | @body = @wrote_body = nil 39 | @original.reset 40 | end 41 | 42 | def close 43 | flush 44 | @original.close 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/dsl_bench.cr: -------------------------------------------------------------------------------- 1 | require "./test_helper" 2 | 3 | N = 10_000 4 | 5 | def bench(title) 6 | elapsed = Time.measure do 7 | N.times { yield } 8 | end 9 | puts "#{title}: #{(elapsed.to_f / N * 1_000_000).round(2)} µs" 10 | end 11 | 12 | class BenchApp < Artanis::Application 13 | get("/") { "ROOT" } 14 | get("/posts/:id") { "POSTS/ID" } 15 | get("/comments/:id") { |id| "COMMENTS/ID" } 16 | get("/blog/:name/posts/:post_id/comments/:id") { "BLOG/POST/COMMENT" } 17 | delete("/blog/:name/posts/:post_id/comments/:id") { |name, post_id, id| "DELETE COMMENT" } 18 | 19 | {% for i in 1 .. 100 %} 20 | get("/posts/{{ i }}") { "" } 21 | post("/posts/{{ i }}") { "" } 22 | put("/posts/{{ i }}") { "" } 23 | patch("/posts/{{ i }}") { "" } 24 | delete("/posts/{{ i }}") { "" } 25 | {% end %} 26 | end 27 | 28 | def context(method, path) 29 | request = HTTP::Request.new(method, path) 30 | response = HTTP::Server::Response.new(IO::Memory.new) 31 | HTTP::Server::Context.new(request, response) 32 | end 33 | 34 | method_not_found = context("UNKNOWN", "/fail") 35 | path_not_found = context("GET", "/fail") 36 | get_root = context("GET", "/") 37 | get_post = context("GET", "/posts/123") 38 | get_comment = context("GET", "/comments/456") 39 | get_post_comment = context("GET", "/blog/me/posts/123/comments/456") 40 | delete_comment = context("DELETE", "/blog/me/posts/123/comments/456") 41 | 42 | #puts BenchApp.call(get_root) 43 | #puts BenchApp.call(get_post) 44 | #puts BenchApp.call(get_comment) 45 | #puts BenchApp.call(get_post_comment) 46 | #puts BenchApp.call(delete_comment) 47 | #puts BenchApp.call(not_found) 48 | 49 | bench("get root") { BenchApp.call(get_root) } 50 | bench("get param") { BenchApp.call(get_post) } 51 | bench("get params (block args)") { BenchApp.call(get_comment) } 52 | bench("get many params") { BenchApp.call(get_post_comment) } 53 | bench("get many params (block args)") { BenchApp.call(delete_comment) } 54 | bench("not found (method)") { BenchApp.call(method_not_found) } 55 | bench("not found (path)") { BenchApp.call(path_not_found) } 56 | -------------------------------------------------------------------------------- /test/dsl_test.cr: -------------------------------------------------------------------------------- 1 | require "./test_helper" 2 | 3 | class Artanis::DSLTest < Minitest::Test 4 | def test_simple_routes 5 | response = call("GET", "/") 6 | assert_equal "ROOT", response.body 7 | assert_equal 200, response.status_code 8 | 9 | assert_equal "POSTS", call("GET", "/posts").body 10 | end 11 | 12 | def test_simple_status_code 13 | response = call("POST", "/forbidden") 14 | assert_equal 403, response.status_code 15 | assert_empty response.body 16 | end 17 | 18 | def test_no_such_routes 19 | response = call("GET", "/fail") 20 | assert_equal "NOT FOUND: GET /fail", response.body 21 | assert_equal 404, response.status_code 22 | 23 | response = call("POST", "/") 24 | assert_equal "NOT FOUND: POST /", response.body 25 | assert_equal 404, response.status_code 26 | 27 | assert_equal "NOT FOUND: DELETE /posts", call("DELETE", "/posts").body 28 | end 29 | 30 | def test_routes_with_lowercase_method 31 | assert_equal "ROOT", call("get", "/").body 32 | end 33 | 34 | def test_routes_with_unicode_chars 35 | assert_equal "TELUGU", call("get", "/lang/#{URI.encode_path_segment("తెలుగు")}").body 36 | end 37 | 38 | def test_routes_with_special_chars 39 | assert_equal "POST-OFFICE", call("get", "/online-post-office").body 40 | assert_equal "POST_OFFICE", call("get", "/online_post_office").body 41 | end 42 | 43 | def test_routes_with_dot_separator 44 | assert_equal "POSTS (xml)", call("GET", "/posts.xml").body 45 | assert_equal "NOT FOUND: GET /posts.json", call("GET", "/posts.json").body 46 | end 47 | 48 | def test_routes_with_params 49 | assert_equal "POST: 1", call("GET", "/posts/1.json").body 50 | assert_equal "POST: 456", call("GET", "/posts/456.json").body 51 | assert_equal "COMMENT: #{ { "name" => "me", "post_id" => "123", "id" => "456", "format" => "xml" }.inspect } #{ ["me", "123", "456", "xml"].inspect }", 52 | call("DELETE", "/blog/me/posts/123/comments/456.xml").body 53 | end 54 | 55 | def test_routes_with_splat_params 56 | assert_equal "WIKI: category/page.html", call("GET", "/wiki/category/page.html").body 57 | assert_equal "KIWI: category/page (html)", call("GET", "/kiwi/category/page.html").body 58 | end 59 | 60 | def test_routes_with_optional_segments 61 | assert_equal "OPTIONAL ()", call("GET", "/optional").body 62 | assert_equal "OPTIONAL (html)", call("GET", "/optional.html").body 63 | end 64 | 65 | def test_halt 66 | response = call("GET", "/halt") 67 | assert_equal 200, response.status_code 68 | assert_empty response.body 69 | 70 | response = call("GET", "/halt/gone") 71 | assert_equal 410, response.status_code 72 | assert_empty response.body 73 | 74 | response = call("GET", "/halt/message") 75 | assert_equal 200, response.status_code 76 | assert_equal "message", response.body 77 | 78 | response = call("GET", "/halt/code/message") 79 | assert_equal 401, response.status_code 80 | assert_equal "please sign in", response.body 81 | 82 | response = call("GET", "/halt/args") 83 | assert_equal 403, response.status_code 84 | end 85 | 86 | def test_pass 87 | response = call("GET", "/pass/check") 88 | assert_equal 401, response.status_code 89 | assert_equal "PASS NEXT", response.body 90 | end 91 | 92 | def test_before_filter 93 | assert_equal "BEFORE=GLOBAL", call("GET", "/").headers["Before-Filter"] 94 | assert_equal "BEFORE=GLOBAL,FORBIDDEN", call("GET", "/forbidden").headers["Before-Filter"] 95 | end 96 | 97 | def test_after_filter 98 | assert_equal "AFTER=GLOBAL", call("GET", "/").headers["After-Filter"] 99 | assert_equal "AFTER=GLOBAL,HALT", call("GET", "/halt/message").headers["After-Filter"] 100 | end 101 | 102 | def test_filters_can_access_intance_variables 103 | assert_equal "before filter, 2", FilterApp.call(context("GET", "/filters")).body 104 | end 105 | 106 | def test_before_filter_can_halt 107 | response = FilterApp.call(context("GET", "/filters", headers: HTTP::Headers{ 108 | "halt" => "before" 109 | })) 110 | assert_equal 401, response.status_code 111 | assert_empty response.body 112 | end 113 | 114 | def test_after_filter_can_halt 115 | response = FilterApp.call(context("GET", "/filters", headers: HTTP::Headers{ 116 | "halt" => "after" 117 | })) 118 | assert_equal 200, response.status_code 119 | assert_equal "before filter", response.body 120 | end 121 | 122 | def test_query_params 123 | assert_equal({ "id" => "123" }, JSON.parse(call("GET", "/params/123").body).as_h) 124 | assert_equal({ "id" => "123", "q" => "term" }, JSON.parse(call("GET", "/params/123?id=456&q=term").body).as_h) 125 | assert_equal({ "id" => "123", "bid" => "456" }, JSON.parse(call("GET", "/params/123?bid=456").body).as_h) 126 | end 127 | 128 | def test_body_params 129 | response = call "POST", "/params", 130 | headers: HTTP::Headers{ "Content-Type" => "application/x-www-form-urlencoded" } 131 | assert_empty JSON.parse(response.body).as_h 132 | 133 | response = call "POST", "/params?id=789&lang=fr", 134 | headers: HTTP::Headers{ "Content-Type" => "application/x-www-form-urlencoded" }, 135 | body: "q=term&id=123" 136 | assert_equal({ "id" => "123", "q" => "term", "lang" => "fr" }, JSON.parse(response.body).as_h) 137 | end 138 | 139 | def test_encoded_body 140 | response = call "POST", "/params_body?id=789&lang=fr", 141 | headers: HTTP::Headers{ "Content-Type" => "application/x-www-form-urlencoded" }, 142 | body: "q=term&id=123" 143 | assert_equal({ "body" => "q=term&id=123", "id" => "789", "lang" => "fr" }, JSON.parse(response.body).as_h) 144 | end 145 | 146 | private def call(request, method, headers = nil, body = nil) 147 | App.call(context(request, method, io: nil, headers: headers, body: body)) 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /test/logging_test.cr: -------------------------------------------------------------------------------- 1 | require "./test_helper" 2 | require "../src/logging" 3 | 4 | class Artanis::LoggingTest < Minitest::Test 5 | class LogApp < Artanis::Application 6 | include Artanis::Logging 7 | 8 | get "/debug" do 9 | debug "this is a debug message" 10 | "DEBUG" 11 | end 12 | 13 | get "/info" do 14 | info "some info message" 15 | "DEBUG" 16 | end 17 | end 18 | 19 | private def backend 20 | @backend ||= Log::MemoryBackend.new 21 | end 22 | 23 | def setup 24 | LogApp.logger = Log.for("log_app") 25 | Log.setup("log_app", :debug, backend) 26 | end 27 | 28 | def test_debug 29 | LogApp.call(context("GET", "/debug")) 30 | assert_equal ["this is a debug message"], backend.entries.map(&.message) 31 | end 32 | 33 | def test_info 34 | LogApp.call(context("GET", "/info")) 35 | assert_equal ["some info message"], backend.entries.map(&.message) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/render_test.cr: -------------------------------------------------------------------------------- 1 | require "./test_helper" 2 | 3 | class RenderApp < Artanis::Application 4 | views_path "#{ __DIR__ }/views" 5 | 6 | get "/" do 7 | @message = "message: index" 8 | ecr "index" 9 | end 10 | 11 | get "/custom" do 12 | @message = "message: custom" 13 | ecr "index", layout: "custom" 14 | end 15 | 16 | get "/raw" do 17 | ecr "raw", layout: false 18 | end 19 | 20 | get "/raw.json" do 21 | contents = { "row" => "message" } 22 | json contents 23 | end 24 | end 25 | 26 | class Artanis::RenderTest < Minitest::Test 27 | def test_json 28 | response = call("GET", "/raw.json") 29 | assert_equal({ "row" => "message" }, JSON.parse(response.body)) 30 | end 31 | 32 | def test_render 33 | response = call("GET", "/") 34 | assert_match /INDEX/, response.body 35 | assert_match /LAYOUT: DEFAULT/, response.body 36 | assert_match /message: index/, response.body 37 | end 38 | 39 | def test_no_layout 40 | response = call("GET", "/raw") 41 | assert_match /RAW/, response.body 42 | refute_match /LAYOUT/, response.body 43 | end 44 | 45 | def test_specified_layout 46 | response = call("GET", "/custom") 47 | assert_match /LAYOUT: CUSTOM/, response.body 48 | assert_match /message: custom/, response.body 49 | end 50 | 51 | def test_writes_body_to_io 52 | response = call("GET", "/custom", io = IO::Memory.new) 53 | response.close # body is buffered by HTTP::Response::Output 54 | body = io.to_s 55 | assert_match /LAYOUT: CUSTOM/, body 56 | assert_match /message: custom/, body 57 | end 58 | 59 | private def call(method, path, io = nil) 60 | RenderApp.call(context(method, path, io: io)) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/test_helper.cr: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "http/server" 3 | require "../src/artanis" 4 | 5 | class Minitest::Test 6 | def context(method, path, io = nil, headers = nil, body = nil) 7 | request = HTTP::Request.new(method, path, headers || HTTP::Headers.new, body) 8 | response = HTTP::Server::Response.new(io || IO::Memory.new) 9 | HTTP::Server::Context.new(request, response) 10 | end 11 | end 12 | 13 | class FilterApp < Artanis::Application 14 | property! :count 15 | 16 | before do 17 | halt 401 if request.headers["halt"]? == "before" 18 | end 19 | 20 | before "/filters" do 21 | @message = "before filter" 22 | @count = 1 23 | end 24 | 25 | get "/filters" do 26 | self.count += 1 27 | @message 28 | end 29 | 30 | after do 31 | halt if request.headers["halt"]? == "after" 32 | end 33 | 34 | after "/filters" do 35 | response.body += ", #{@count}" 36 | end 37 | end 38 | 39 | class App < Artanis::Application 40 | before do 41 | response.headers.add("Before-Filter", "BEFORE=GLOBAL") 42 | end 43 | 44 | before "/forbidden" do 45 | response.headers.add("Before-Filter", "FORBIDDEN") 46 | end 47 | 48 | after do 49 | response.headers.add("After-Filter", "AFTER=GLOBAL") 50 | end 51 | 52 | after "/halt/*splat" do 53 | response.headers.add("After-Filter", "HALT") 54 | end 55 | 56 | get "/" do 57 | "ROOT" 58 | end 59 | 60 | post "/forbidden" do 61 | 403 62 | end 63 | 64 | get "/posts" do 65 | "POSTS" 66 | end 67 | 68 | get "/posts.xml" do 69 | "POSTS (xml)" 70 | end 71 | 72 | get "/posts/:id.json" do 73 | "POST: #{params["id"]}" 74 | end 75 | 76 | delete "/blog/:name/posts/:post_id/comments/:id.:format" do |name, post_id, id, format| 77 | "COMMENT: #{params.inspect} #{[name, post_id, id, format]}" 78 | end 79 | 80 | get "/wiki/*path" do |path| 81 | "WIKI: #{path}" 82 | end 83 | 84 | get "/kiwi/*path.:format" do |path, format| 85 | "KIWI: #{path} (#{format})" 86 | end 87 | 88 | get "/optional(.:format)" do |format| 89 | "OPTIONAL (#{format})" 90 | end 91 | 92 | get "/lang/తెలుగు" do 93 | "TELUGU" 94 | end 95 | 96 | get "/online-post-office" do 97 | "POST-OFFICE" 98 | end 99 | 100 | get "/online_post_office" do 101 | "POST_OFFICE" 102 | end 103 | 104 | get "/halt" do 105 | halt 106 | "NEVER REACHED" 107 | end 108 | 109 | get "/halt/gone" do 110 | halt 410 111 | "NEVER REACHED" 112 | end 113 | 114 | get "/halt/message" do 115 | halt "message" 116 | "NEVER REACHED" 117 | end 118 | 119 | get "/halt/code/message" do 120 | halt 401, "please sign in" 121 | "NEVER REACHED" 122 | end 123 | 124 | get "/halt/:name" do |name| 125 | halt 403 126 | "NEVER REACHED" 127 | end 128 | 129 | get "/pass/check" do 130 | status 401 131 | pass 132 | "NEVER REACHED" 133 | end 134 | 135 | get "/never_reached_by_skip" do 136 | "NEVER REACHED BY SKIP" 137 | end 138 | 139 | get "/pass/*x" do 140 | "PASS NEXT" 141 | end 142 | 143 | get "/params/:id" do 144 | json(params) 145 | end 146 | 147 | post "/params" do 148 | json(params) 149 | end 150 | 151 | post "/params_body" do 152 | json({"body" => request.body.try &.gets_to_end}.merge(params)) 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /test/views/custom.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | LAYOUT: CUSTOM 4 | 5 | 6 | <%= yield %> 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/views/index.ecr: -------------------------------------------------------------------------------- 1 |

INDEX

2 | 3 |

<%= @message %>

4 | -------------------------------------------------------------------------------- /test/views/layout.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | LAYOUT: DEFAULT 4 | 5 | 6 | <%= yield %> 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/views/raw.ecr: -------------------------------------------------------------------------------- 1 | RAW 2 | --------------------------------------------------------------------------------