├── test
├── views
│ ├── raw.ecr
│ ├── index.ecr
│ ├── custom.ecr
│ └── layout.ecr
├── logging_test.cr
├── render_test.cr
├── dsl_bench.cr
├── test_helper.cr
└── dsl_test.cr
├── src
├── artanis.cr
├── logging.cr
├── response.cr
├── render.cr
├── application.cr
└── dsl.cr
├── .gitignore
├── .travis.yml
├── shard.lock
├── shard.yml
├── Makefile
├── samples
├── logging.cr
├── server.cr
└── basic.cr
├── LICENSE
└── README.md
/test/views/raw.ecr:
--------------------------------------------------------------------------------
1 | RAW
2 |
--------------------------------------------------------------------------------
/src/artanis.cr:
--------------------------------------------------------------------------------
1 | require "./application"
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.crystal
2 | /.shards
3 | /lib
4 | /doc
5 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: crystal
2 | script: make test bench
3 |
--------------------------------------------------------------------------------
/test/views/index.ecr:
--------------------------------------------------------------------------------
1 |
INDEX
2 |
3 | <%= @message %>
4 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/test/views/custom.ecr:
--------------------------------------------------------------------------------
1 |
2 |
3 | LAYOUT: CUSTOM
4 |
5 |
6 | <%= yield %>
7 |
8 |
9 |
--------------------------------------------------------------------------------
/test/views/layout.ecr:
--------------------------------------------------------------------------------
1 |
2 |
3 | LAYOUT: DEFAULT
4 |
5 |
6 | <%= yield %>
7 |
8 |
9 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------