├── .github └── workflows │ └── crystal.yml ├── .gitignore ├── CHANGELOG ├── CONTRIBUTING ├── LICENSE ├── README.md ├── makefile ├── shard.yml ├── spec ├── spec_helper.cr ├── toro_spec.cr └── views │ └── index.ecr └── src ├── toro.cr └── toro ├── driver.cr └── version.cr /.github/workflows/crystal.yml: -------------------------------------------------------------------------------- 1 | name: Crystal CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | container: 11 | image: crystallang/crystal 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Install dependencies 16 | run: shards install 17 | - name: Run tests 18 | run: crystal spec 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lib/ 2 | /.crystal/ 3 | /.shards/ 4 | /shard.lock 5 | 6 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 0.5.1 2 | 3 | * Use Process.on_interrupt instead of Signal::INT.trap 4 | 5 | 0.5.0 6 | 7 | * Modify how the server is configured 8 | 9 | 0.4.3 10 | 11 | * Preserve the inbox in mounted apps 12 | * Update dependency version 13 | 14 | 0.4.2 15 | 16 | * Declare compatibility with Crystal 1.0 17 | 18 | 0.4.1 19 | 20 | * Adapt internals to Crystal 0.25 21 | 22 | 0.4.0 23 | 24 | * Add ability to configure the server with a block 25 | 26 | 0.3.3 27 | 28 | * Fix behavior of verb matchers at root level 29 | 30 | 0.3.2 31 | 32 | * Update Seg repository 33 | 34 | 0.3.1 35 | 36 | * Use macros only if needed 37 | 38 | 0.3.0 39 | 40 | * Add json helper 41 | 42 | 0.2.0 43 | 44 | * Internal rewrite 45 | 46 | 0.1.0 47 | 48 | * Initial release 49 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | This code tries to solve a particular problem with a very simple 2 | implementation. We try to keep the code to a minimum while making 3 | it as clear as possible. The design is very likely finished, and 4 | if some feature is missing it is possible that it was left out on 5 | purpose. That said, new usage patterns may arise, and when that 6 | happens we are ready to adapt if necessary. 7 | 8 | A good first step for contributing is to meet us on IRC and discuss 9 | ideas. We spend a lot of time on #lesscode at freenode, always ready 10 | to talk about code and simplicity. If connecting to IRC is not an 11 | option, you can create an issue explaining the proposed change and 12 | a use case. We pay a lot of attention to use cases, because our 13 | goal is to keep the code base simple. Usually the result of a 14 | conversation is the creation of a different tool. 15 | 16 | Please don't start the conversation with a pull request. The code 17 | should come at last, and even though it may help to convey an idea, 18 | more often than not it draws the attention to a particular 19 | implementation. 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Michel Martens 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Toro 2 | 3 | ![Toro](http://files.soveran.com/toro/img/toro.png) 4 | 5 | ![CI](https://github.com/soveran/toro/workflows/Crystal%20CI/badge.svg) 6 | 7 | Tree Oriented Routing 8 | 9 | ## Usage 10 | 11 | Here's a `hello world` app that you can copy and paste to get a 12 | sense of how Toro works: 13 | 14 | ```crystal 15 | require "toro" 16 | 17 | class App < Toro::Router 18 | def routes 19 | get do 20 | text "hello world" 21 | end 22 | end 23 | end 24 | 25 | App.run do |server| 26 | server.listen "0.0.0.0", 8080 27 | end 28 | ``` 29 | 30 | Save it to a file called `hello_world.cr` and run it with 31 | `crystal run hello_world.cr`. Then access your `hello world` application with 32 | your browser, or simply by calling `curl http://localhost:8080/` from the 33 | command line. 34 | 35 | What follows is an example that showcases some basic routing features: 36 | 37 | ```crystal 38 | require "toro" 39 | 40 | class App < Toro::Router 41 | 42 | # You must define the `routes` methods. It will be the 43 | # entry point to your web application. 44 | def routes 45 | 46 | # The `get` matcher will execute the block when two conditions 47 | # are met: the `REQUEST_METHOD` is equal to "GET", and there are 48 | # no more path segments to match. In this case, as we haven't 49 | # consumed any path segment, the only way for this block to run 50 | # would be to have a "GET" request to "/". Check the API section 51 | # to see all available matchers. 52 | get do 53 | 54 | # The text method sets the Content-Type to "text/plain", and 55 | # prints the string to the response. 56 | text "hello world" 57 | end 58 | 59 | # A `String` matcher will run the block only if its content is equal 60 | # to the next segment in the current path. In this example, it will 61 | # match the request if the first segment is equal to "users". 62 | # You can always inspect the current path by looking at `path.curr`. 63 | on "users" do 64 | 65 | # If we get here it's because the previous matcher succeeded. It 66 | # means we were able to consume a segment off the current path. More 67 | # specifically, we consumed the "users" segment, and if we now 68 | # inspect the `path.prev` string we will find its value is "/users". 69 | # 70 | # With the next matcher we want to capture a segment. Let's say a 71 | # request is made to "/users/42". When we arrive at this point, this 72 | # symbol will match the number "42" and store it in the inbox. 73 | on :id do 74 | 75 | # If there are no more segments in the request path and if the 76 | # request method is "GET", this block will run. 77 | get do 78 | 79 | # Now, `inbox[:id]` has the value "42". The templates have access 80 | # to the inbox and to any other variables defined here. 81 | # 82 | # The `html` macro expects a path to a template. It automatically 83 | # appends the `.ecr` extension, which stands for Embedded Crystal 84 | # and is part of the standard library. It also sets the content 85 | # type to "text/html". For the html example to work, you need to 86 | # create the file ./views/users/show.ecr with the following content: 87 | # 88 | # hello user <%= inbox[:id] %> 89 | # 90 | # 91 | # Once you have created the file, uncomment the line below. 92 | # 93 | # html "views/users/show" 94 | 95 | 96 | # As a placeholder, the following directive renders the same message 97 | # as plain text. Once you have the HTML template in place, you can 98 | # comment or remove both this comment and the `text` directive. 99 | # 100 | text "hello user #{inbox[:id]}" 101 | end 102 | end 103 | end 104 | 105 | # The `default` matcher always succeeds, but it doesn't mean the program's 106 | # flow will always reach this point. Once a matcher succeeds and runs a 107 | # block, the control is never returned. There's an implicit return at the 108 | # end of every block, which stops the processing of the request and 109 | # returns the response immediately. 110 | # 111 | # This route will match all the requests that don't have "users" as the 112 | # first segment (because of the previous matcher), and it will pass the 113 | # control to the `Guests` application, which has to be an instance of 114 | # `Toro::Router`. This illustrates how you can compose your applications 115 | # and split the logic among different routers. 116 | default do 117 | mount Guests 118 | end 119 | end 120 | end 121 | 122 | # This is another Toro application. You can mount apps on top of other Toro 123 | # in order to achieve a modular design. 124 | class Guests < Toro::Router 125 | def routes 126 | on "about" do 127 | get do 128 | text "about this site" 129 | end 130 | end 131 | end 132 | end 133 | 134 | # Start the app on port 8080. 135 | App.run do |server| 136 | server.listen "0.0.0.0", 8080 137 | end 138 | ``` 139 | 140 | Once you have this application running, try the requests below: 141 | 142 | ```shell 143 | $ curl http://localhost:8080/ 144 | $ curl http://localhost:8080/about 145 | $ curl http://localhost:8080/users/42 146 | ``` 147 | 148 | The routes are evaluated in a sandbox where the following methods 149 | are available: `context`, `path`, `inbox`, `mount`, `basic_auth`, 150 | `root`, `root?`, `default`, `on`, `get`, `put`, `head`, `post`, 151 | `patch`, `delete`, `options`, `text`, `html`, `json`, `write` and 152 | `render`. 153 | 154 | ## API 155 | 156 | `context`: Environment variables for the request. 157 | 158 | `path`: Helper object that tracks the previous and current path. 159 | 160 | `inbox`: Hash with captures and potentially other variables local 161 | to the request. 162 | 163 | `mount`: Mounts a sub app. 164 | 165 | `basic_auth`: Yields a username and password from the Authorization 166 | header, and returns whatever the block returns or nil. 167 | 168 | `root?`: Returns true if the path yet to be consumed is empty. 169 | 170 | `root`: Receives a block and calls it only if `root?` is true. 171 | 172 | `default`: Receives a block that will be executed inconditionally. 173 | 174 | `on`: Receives a value to be matched, and a block that will be 175 | executed only if the request is matched. 176 | 177 | `get`: Receives a block and calls it only if `root?` and `get?` are 178 | true. 179 | 180 | `put`: Receives a block and calls it only if `root?` and `put?` are 181 | true. 182 | 183 | `head`: Receives a block and calls it only if `root?` and `head?` 184 | are true. 185 | 186 | `post`: Receives a block and calls it only if `root?` and `post?` 187 | are true. 188 | 189 | `patch`: Receives a block and calls it only if `root?` and `patch?` 190 | are true. 191 | 192 | `delete`: Receives a block and calls it only if `root?` and `delete?` 193 | are true. 194 | 195 | `options`: Receives a block and calls it only if `root?` and 196 | `options?` are true. 197 | 198 | ## Matchers 199 | 200 | The `on` method can receive a `String` to perform path matches; a 201 | `Symbol` to perform path captures; and a boolean to match any true 202 | values. 203 | 204 | Each time `on` matches or captures a segment of the PATH, that part 205 | of the path is consumed. The current and previous paths can be 206 | queried by calling `prev` and `curr` on the `path` object: `path.prev` 207 | returns the part of the path already consumed, and `path.curr` 208 | provides the current version of the path. Any expression that 209 | evaluates to a boolean can also be used as a matcher. 210 | 211 | Captures 212 | -------- 213 | 214 | When a symbol is provided, `on` will try to consume a segment of 215 | the path. A segment is defined as any sequence of characters after 216 | a slash and until either another slash or the end of the string. 217 | The captured value is stored in the `inbox` hash under the key that 218 | was provided as the argument to `on`. For example, after a call to 219 | `on(:user_id)`, the value for the segment will be stored at 220 | `inbox[:user_id]`. 221 | 222 | Security 223 | -------- 224 | 225 | There are no security features built into this routing library. A 226 | framework using this library should implement the security layer. 227 | 228 | Rendering 229 | --------- 230 | 231 | The most basic way of returning a string is by calling the method 232 | `text`. It sets the `Content-Type` header to `text/plain` and writes 233 | the passed string to the response. A similar helper is called `html`: 234 | it takes as an argument the path to an `ECR` template and renders 235 | its content. A lower level `render` macro is available: it also 236 | expects the path to a template, but it doesn't modify the headers. 237 | There's a `json` helper method expecting a Crystal generic Object. 238 | It will call the `to_json` serializer on the generic object. Please 239 | note that you need to require JSON from the standard library in 240 | order to use this helper (adding `require "json"` to your app should 241 | suffice). The lower level `write` method writes a string to the 242 | response object. It is used internally by `text` and `json`. 243 | 244 | Running the server 245 | ------------------ 246 | 247 | If `App` is an instance of `Toro`, then you can start the server by 248 | calling `App.run`. It yields an instance of `HTTP://Server` that you 249 | can configure: 250 | 251 | For example, you can start the server on port 80: 252 | 253 | ```crystal 254 | App.run do |server| 255 | server.listen "0.0.0.0", 80 256 | end 257 | ``` 258 | 259 | The following example shows how to configure SSL certificates: 260 | 261 | ```crystal 262 | App.run do |server| 263 | ssl = OpenSSL::SSL::Context::Server.new 264 | ssl.private_key = "path/to/private_key" 265 | ssl.certificate_chain = "path/to/certificate_chain" 266 | server.tls = ssl 267 | server.listen "0.0.0.0", 443 268 | end 269 | ``` 270 | 271 | Refer to Crystal's documentation for more options. 272 | 273 | Status codes 274 | ------------ 275 | 276 | The default status code is `404`. It can be changed and queried 277 | with the `status` method: 278 | 279 | ```crystal 280 | status 281 | #=> 404 282 | 283 | status 200 284 | 285 | status 286 | #=> 200 287 | ``` 288 | 289 | When a request method matcher succeeds, the status code for the 290 | request is changed to `200`. 291 | 292 | Basic Auth 293 | ---------- 294 | 295 | The `basic_auth` method checks the `Authentication` header and, if 296 | present, yields to the block the values for username and password. 297 | 298 | Here's an example of how you can use it: 299 | 300 | ```crystal 301 | class A < Toro::Router 302 | def users(user : User) 303 | get do 304 | text "Hello #{user.name}" 305 | end 306 | end 307 | 308 | def users(user : Nil) 309 | get do 310 | text "Hello guest!" 311 | end 312 | end 313 | 314 | def routes 315 | user = basic_auth do |name, pass| 316 | User.authenticate(name, pass) 317 | end 318 | 319 | users(user) 320 | end 321 | end 322 | ``` 323 | 324 | The example overloads the `users` method so that it can deal both 325 | with instances of `User` and with `nil`. The flow of your router 326 | will naturally continue in one of those methods. You are free to 327 | define any other methods like `users` in order to split the logic 328 | of your application. 329 | 330 | To illustrate the `basic_auth` feature we used an imaginary `User` 331 | class that responds to the `authenticate` method and returns either 332 | an instance of `User` or nil. 333 | 334 | ## Installation 335 | 336 | Add this to your application's `shard.yml`: 337 | 338 | ```yaml 339 | dependencies: 340 | toro: 341 | github: soveran/toro 342 | branch: master 343 | ``` 344 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | test: 2 | crystal spec 3 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: toro 2 | version: 0.5.1 3 | 4 | authors: 5 | - Michel Martens 6 | 7 | license: MIT 8 | 9 | dependencies: 10 | seg: 11 | github: soveran/seg 12 | branch: master 13 | 14 | development_dependencies: 15 | crotest: 16 | github: emancu/crotest 17 | version: ~> 1.0.1 18 | 19 | crystal: ">= 0.36.0, < 2.0.0" 20 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "crotest" 2 | require "../src/toro" 3 | require "../src/toro/driver" 4 | -------------------------------------------------------------------------------- /spec/toro_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | class A < Toro::Router 4 | def routes 5 | on false do 6 | context.response.puts "shouldn't get here" 7 | end 8 | 9 | on true do 10 | context.response.puts "true" 11 | 12 | on false do 13 | context.response.puts "shouldn't get here" 14 | end 15 | 16 | on true do 17 | context.response.puts "again true" 18 | end 19 | end 20 | 21 | on true do 22 | context.response.puts "shouldn't get here" 23 | end 24 | end 25 | end 26 | 27 | describe "boolean matchers" do 28 | response = Toro.drive(A, "GET", "/") 29 | 30 | it "should only progress when the value is true" do 31 | assert_equal "true\nagain true\n", response.body 32 | end 33 | 34 | it "should return 404 unless a verb is matched" do 35 | assert_equal 404, response.status_code 36 | end 37 | end 38 | 39 | class B < Toro::Router 40 | def routes 41 | on "bar" do 42 | text "shouldn't get here" 43 | end 44 | 45 | on "foo" do 46 | text "got here" 47 | 48 | on "foo" do 49 | text "shouldn't get here" 50 | end 51 | 52 | on "bar" do 53 | text "and here" 54 | end 55 | end 56 | 57 | on true do 58 | text "shouldn't get here" 59 | end 60 | end 61 | end 62 | 63 | describe "string matchers" do 64 | request = Toro.drive(B, "GET", "/foo/bar") 65 | 66 | it "should only progress when the string matches a path segment" do 67 | assert_equal "got here\nand here\n", request.body 68 | end 69 | end 70 | 71 | class C < Toro::Router 72 | def routes 73 | on :x do 74 | text "got :x == #{inbox[:x]}" 75 | end 76 | 77 | on :y do 78 | text "got :y == #{inbox[:y]}" 79 | end 80 | 81 | on true do 82 | text "shouldn't get here" 83 | end 84 | end 85 | end 86 | 87 | describe "symbol matchers" do 88 | response = Toro.drive(C, "GET", "/foo/bar") 89 | 90 | it "should only progress when there are segments to capture" do 91 | assert_equal "got :x == foo\n", response.body 92 | end 93 | end 94 | 95 | class D < Toro::Router 96 | def routes 97 | default do 98 | text "got here" 99 | end 100 | 101 | default do 102 | text "not here" 103 | end 104 | end 105 | end 106 | 107 | describe "default matcher" do 108 | response = Toro.drive(D, "GET", "/foo/bar") 109 | 110 | it "should always progress" do 111 | assert_equal "got here\n", response.body 112 | end 113 | end 114 | 115 | class E < Toro::Router 116 | def routes 117 | root do 118 | text "not here" 119 | end 120 | 121 | on "foo" do 122 | root do 123 | text "got here" 124 | end 125 | end 126 | 127 | on "baz" do 128 | root do 129 | text "got #{inbox[:name]}" 130 | end 131 | end 132 | 133 | default do 134 | text "not here" 135 | end 136 | end 137 | end 138 | 139 | describe "root matcher" do 140 | response = Toro.drive(E, "GET", "/foo") 141 | 142 | it "should progress when there are no segments left in the path" do 143 | assert_equal "got here\n", response.body 144 | end 145 | end 146 | 147 | class F < Toro::Router 148 | def routes 149 | root do 150 | write "root" 151 | end 152 | 153 | inbox[:name] = "someone" 154 | 155 | on "bar" do 156 | mount E 157 | end 158 | 159 | default do 160 | write "not here" 161 | end 162 | end 163 | end 164 | 165 | describe "mounted apps" do 166 | response = Toro.drive(F, "GET", "/bar/foo") 167 | 168 | it "should process the request and the rest of the path" do 169 | assert_equal "got here\n", response.body 170 | end 171 | 172 | response = Toro.drive(F, "GET", "/bar/baz") 173 | 174 | it "should preserve the inbox" do 175 | assert_equal "got someone\n", response.body 176 | end 177 | end 178 | 179 | class G < Toro::Router 180 | def routes 181 | get do 182 | text "here at root" 183 | end 184 | 185 | post do 186 | text "not here" 187 | end 188 | 189 | on "foo" do 190 | get do 191 | text "not here" 192 | end 193 | 194 | post do 195 | text "here!" 196 | end 197 | end 198 | 199 | default do 200 | text "not here either" 201 | end 202 | end 203 | end 204 | 205 | describe "verb matchers" do 206 | response = Toro.drive(G, "GET", "/") 207 | 208 | it "should work at root" do 209 | assert_equal "here at root\n", response.body 210 | end 211 | 212 | it "should return 200" do 213 | assert_equal 200, response.status_code 214 | end 215 | 216 | response = Toro.drive(G, "POST", "/foo") 217 | 218 | it "should progress when the verb matches" do 219 | assert_equal "here!\n", response.body 220 | end 221 | 222 | it "should return 200" do 223 | assert_equal 200, response.status_code 224 | end 225 | end 226 | 227 | class H < Toro::Router 228 | def routes 229 | get do 230 | @name = "foo" 231 | 232 | html "spec/views/index" 233 | end 234 | end 235 | end 236 | 237 | describe "html renderer" do 238 | it "should render the template" do 239 | response = Toro.drive(H, "GET", "/") 240 | 241 | assert_equal 200, response.status_code 242 | assert_equal "hello foo!\n", response.body 243 | end 244 | 245 | it "should not render if not found" do 246 | response = Toro.drive(H, "PUT", "/") 247 | 248 | assert_equal 404, response.status_code 249 | assert_equal "", response.body 250 | end 251 | end 252 | 253 | class I < Toro::Router 254 | def routes 255 | post do 256 | redirect "/dashboard" 257 | end 258 | end 259 | end 260 | 261 | describe "redirects" do 262 | response = Toro.drive(I, "POST", "/") 263 | 264 | it "should return 302" do 265 | assert_equal 302, response.status_code 266 | end 267 | 268 | it "should return a location" do 269 | assert_equal "/dashboard", response.headers["Location"] 270 | end 271 | end 272 | 273 | class J < Toro::Router 274 | def routes 275 | user = basic_auth do |name, pass| 276 | name == "foo" && 277 | pass == "bar" && 278 | "user:1" 279 | end 280 | 281 | post do 282 | text user.to_s 283 | end 284 | end 285 | end 286 | 287 | describe "basic auth" do 288 | headers = HTTP::Headers.new 289 | 290 | auth1 = sprintf("Basic %s", Base64.strict_encode("foo:bar")) 291 | auth2 = sprintf("Basic %s", Base64.strict_encode("bar:baz")) 292 | 293 | it "should return nil if there's no Authorization header" do 294 | response = Toro.drive(J, "POST", "/") 295 | 296 | assert_equal "\n", response.body 297 | end 298 | 299 | it "should return nil if the credentials are wrong" do 300 | headers["Authorization"] = "Basic %s" % Base64.strict_encode("bar:baz") 301 | 302 | request = HTTP::Request.new("POST", "/", headers) 303 | 304 | response = Toro.drive(J).call(request) 305 | 306 | assert_equal "\n", response.body 307 | end 308 | 309 | it "should return the result of the basic_auth block if credentials match" do 310 | headers["Authorization"] = "Basic %s" % Base64.strict_encode("foo:bar") 311 | 312 | request = HTTP::Request.new("POST", "/", headers) 313 | 314 | response = Toro.drive(J).call(request) 315 | 316 | assert_equal "user:1\n", response.body 317 | end 318 | end 319 | 320 | class K < Toro::Router 321 | getter counter = 0 322 | 323 | def incr 324 | @counter += 1 325 | end 326 | 327 | def routes 328 | on incr == 1 do 329 | text "here" 330 | end 331 | 332 | on incr == 2 do 333 | text "not here" 334 | end 335 | 336 | text "counter: #{counter}" 337 | end 338 | end 339 | 340 | describe "halt" do 341 | response = Toro.drive(K, "GET", "/foo/bar") 342 | 343 | it "should stop the execution once a matcher succeeds" do 344 | assert_equal "here\n", response.body 345 | end 346 | end 347 | 348 | require "json" 349 | 350 | class L < Toro::Router 351 | def routes 352 | post do 353 | test = {"hello" => "world"} 354 | json test 355 | end 356 | end 357 | end 358 | 359 | describe "json method helper" do 360 | response = Toro.drive(L, "POST", "/") 361 | 362 | it "should return json content-type" do 363 | assert_equal "application/json", response.headers["Content-Type"] 364 | end 365 | 366 | it "should return json {\"hello\":\"world\"}" do 367 | assert_equal "{\"hello\":\"world\"}", response.body 368 | end 369 | end 370 | -------------------------------------------------------------------------------- /spec/views/index.ecr: -------------------------------------------------------------------------------- 1 | hello <%= @name %>! 2 | -------------------------------------------------------------------------------- /src/toro.cr: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Michel Martens 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | require "seg" 22 | require "http/server" 23 | 24 | module Toro 25 | abstract class Router 26 | def self.run(*args) 27 | run(*args) {} 28 | end 29 | 30 | def self.run(*args, &block) 31 | server = HTTP::Server.new(*args) do |context| 32 | new(context).call 33 | end 34 | 35 | Process.on_interrupt do 36 | server.close 37 | exit 38 | end 39 | 40 | yield server 41 | end 42 | 43 | getter path : Seg 44 | getter inbox : Hash(Symbol, String) 45 | getter context : HTTP::Server::Context 46 | 47 | def initialize(@context) 48 | @path = Seg.new(@context.request.path.as String) 49 | @inbox = Hash(Symbol, String).new 50 | end 51 | 52 | def initialize(@context, @path, @inbox) 53 | end 54 | 55 | def call 56 | status 404 57 | routes 58 | end 59 | 60 | def auth_header 61 | context.request.headers["Authorization"]? 62 | end 63 | 64 | def basic_auth 65 | auth = auth_header 66 | 67 | if auth 68 | type, cred = auth.split(" ") 69 | user, pass = Base64.decode_string(cred).split(":") 70 | 71 | if type == "Basic" 72 | yield(user, pass) || nil 73 | end 74 | end 75 | end 76 | 77 | def on?(cond : Bool) 78 | cond 79 | end 80 | 81 | def on?(str : String) 82 | path.consume(str) 83 | end 84 | 85 | def on?(sym : Symbol) 86 | path.capture(sym, inbox) 87 | end 88 | 89 | def root? 90 | path.root? 91 | end 92 | 93 | {% for method in %w(get put head post patch delete options) %} 94 | 95 | def {{method.id}}? 96 | context.request.method == {{method.upcase}} 97 | end 98 | 99 | {% end %} 100 | 101 | macro get 102 | root { status 200; {{yield}} } if get? 103 | end 104 | 105 | macro put 106 | root { status 200; {{yield}} } if put? 107 | end 108 | 109 | macro head 110 | root { status 200; {{yield}} } if head? 111 | end 112 | 113 | macro post 114 | root { status 200; {{yield}} } if post? 115 | end 116 | 117 | macro patch 118 | root { status 200; {{yield}} } if patch? 119 | end 120 | 121 | macro delete 122 | root { status 200; {{yield}} } if delete? 123 | end 124 | 125 | macro options 126 | root { status 200; {{yield}} } if options? 127 | end 128 | 129 | macro mount(app) 130 | {{app.id}}.new(context, path, inbox).call 131 | return 132 | end 133 | 134 | macro default 135 | {{yield}} 136 | return 137 | end 138 | 139 | macro on(matcher) 140 | default { {{yield}} } if on?({{matcher}}) 141 | end 142 | 143 | macro root 144 | default { {{yield}} } if root? 145 | end 146 | 147 | def status 148 | context.response.status_code 149 | end 150 | 151 | def status(code) 152 | context.response.status_code = code 153 | end 154 | 155 | def header(name, value) 156 | context.response.headers[name] = value 157 | end 158 | 159 | def content_type(type) 160 | context.response.content_type = type 161 | end 162 | 163 | def write(str) 164 | context.response.puts(str) 165 | end 166 | 167 | macro render(template) 168 | ECR.embed "#{ {{template}} }.ecr", context.response 169 | end 170 | 171 | def text(str) 172 | header "Content-Type", "text/plain" 173 | write str 174 | end 175 | 176 | macro html(template) 177 | header "Content-Type", "text/html" 178 | render {{template}} 179 | end 180 | 181 | def json(response) 182 | header "Content-Type", "application/json" 183 | response.to_json(context.response) 184 | end 185 | 186 | def redirect(url) 187 | status 302 188 | header "Location", url 189 | end 190 | 191 | abstract def routes 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /src/toro/driver.cr: -------------------------------------------------------------------------------- 1 | module Toro 2 | def self.drive(router) 3 | Driver.new(router) 4 | end 5 | 6 | def self.drive(router, method, path) 7 | drive(router).call(method, path) 8 | end 9 | 10 | class Driver 11 | getter router : Toro::Router.class 12 | 13 | def initialize(@router) 14 | end 15 | 16 | def call(req : HTTP::Request) 17 | io = IO::Memory.new 18 | res = HTTP::Server::Response.new(io) 19 | 20 | @router.new(HTTP::Server::Context.new(req, res)).call 21 | 22 | res.close 23 | 24 | HTTP::Client::Response.from_io(io.rewind, decompress: false) 25 | end 26 | 27 | def call(method : String, path : String) 28 | call(HTTP::Request.new(method, path)) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /src/toro/version.cr: -------------------------------------------------------------------------------- 1 | module Toro 2 | VERSION = "0.1.0" 3 | end 4 | --------------------------------------------------------------------------------