├── .gems ├── CHANGELOG ├── CONTRIBUTING ├── LICENSE ├── README.md ├── lib └── syro.rb ├── makefile ├── syro.gemspec └── test ├── all.rb └── helper.rb /.gems: -------------------------------------------------------------------------------- 1 | cutest -v 1.2.3 2 | seg -v 1.2.0 3 | rack -v 2.2.3 4 | rack-test -v 1.1.0 5 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 3.0.0 2 | 3 | * Change how status codes are set. 4 | * Change how the Content-Type is set. 5 | * Add Content-Type helpers to Syro::Response. 6 | 7 | 2.2.0 8 | 9 | * Change gemspec to allow Rack 2. 10 | 11 | 2.1.2 12 | 13 | * Always set default content type if body is set. 14 | 15 | 2.1.1 16 | 17 | * Change gemspec to accept pre-release versions of Rack 2 18 | 19 | 2.1.0 20 | 21 | * Add matchers for head and options 22 | 23 | 2.0.0 24 | 25 | * No changes since 2.0.0.rc1 26 | 27 | 2.0.0.rc1 28 | 29 | * Add consume and capture primitives 30 | 31 | * Add default matcher 32 | 33 | * Revert yield of captured value (was added in 1.0.0) 34 | 35 | 1.1.1 36 | 37 | * Small internal refactoring 38 | 39 | 1.1.0 40 | 41 | * Move Deck methods to the Deck::API module 42 | 43 | * Add support for custom response and request classes 44 | 45 | 1.0.0 46 | 47 | * Yield captures as an alternative API 48 | 49 | * Add support for default headers in response 50 | 51 | 0.0.8 52 | 53 | * Reset PATH_INFO and SCRIPT_NAME after running a subapp 54 | 55 | 0.0.7 56 | 57 | * Change run so that it accepts Rack applications 58 | 59 | 0.0.6 60 | 61 | * Add put request method 62 | 63 | 0.0.5 64 | 65 | * Add explicit Rack version dependency (@luislavena) 66 | 67 | 0.0.4 68 | 69 | * Add concept of Decks 70 | 71 | 0.0.3 72 | 73 | * Remove request method overriding (@larrylv) 74 | 75 | 0.0.2 76 | 77 | * Defer method comparisons to Rack::Request (@pote) 78 | -------------------------------------------------------------------------------- /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) 2015 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 | Syro 2 | ==== 3 | 4 | Simple router for web applications. 5 | 6 | Community 7 | --------- 8 | 9 | Meet us on IRC: [#syro](irc://chat.freenode.net/#syro) on 10 | [freenode.net](http://freenode.net/). 11 | 12 | Description 13 | ----------- 14 | 15 | Syro is a very simple router for web applications. It was created 16 | in the tradition of libraries like [Rum][rum] and [Cuba][cuba], but 17 | it promotes a less flexible usage pattern. The design is inspired 18 | by the way some Cuba applications are architected: modularity is 19 | encouraged and sub-applications can be dispatched without any 20 | significant performance overhead. 21 | 22 | Check the [website][syro] for more information, and follow the 23 | [tutorial][tutorial] for a step by step introduction. 24 | 25 | [rum]: http://github.com/chneukirchen/rum 26 | [cuba]: http://cuba.is 27 | [syro]: http://soveran.github.io/syro/ 28 | [tutorial]: http://files.soveran.com/syro/ 29 | 30 | Usage 31 | ----- 32 | 33 | An example of a modular application would look like this: 34 | 35 | ```ruby 36 | Admin = Syro.new do 37 | get do 38 | res.write "Hello from admin!" 39 | end 40 | end 41 | 42 | App = Syro.new do 43 | on "admin" do 44 | run(Admin) 45 | end 46 | end 47 | ``` 48 | 49 | The block is evaluated in a sandbox where the following methods are 50 | available: `env`, `req`, `res`, `path`, `inbox`, `call`, `run`, 51 | `halt`, `handle`, `finish!`, `consume`, `capture`, `root?` `match`, 52 | `default`, `on`, `root`,`get`, `put`, `head`, `post`, `patch`, 53 | `delete` and `options`. Three other methods are available for 54 | customizations: `default_headers`, `request_class` and `response_class`. 55 | 56 | As a recommendation, user created variables should be instance 57 | variables. That way they won't mix with the API methods defined in 58 | the sandbox. All the internal instance variables defined by Syro 59 | are prefixed by `syro_`, like in `@syro_inbox`. 60 | 61 | API 62 | --- 63 | 64 | `env`: Environment variables for the request. 65 | 66 | `req`: Helper object for accessing the request variables. It's an 67 | instance of `Rack::Request`. 68 | 69 | `res`: Helper object for creating the response. It's an instance 70 | of `Syro::Response`. 71 | 72 | `path`: Helper object that tracks the previous and current path. 73 | 74 | `inbox`: Hash with captures and potentially other variables local 75 | to the request. 76 | 77 | `call`: Entry point for the application. It receives the environment 78 | and optionally an inbox. 79 | 80 | `run`: Runs a sub app, and accepts an inbox as an optional second 81 | argument. 82 | 83 | `halt`: Terminates the request. It receives an array with the 84 | response as per Rack's specification. 85 | 86 | `handle`: Installs a handler for a given status code. It receives 87 | a status code and a block that will be executed from `finish!`. 88 | 89 | `finish!`: Terminates the request by executing any installed handlers 90 | and then halting with the current value of `res.finish`. 91 | 92 | `consume`: Match and consume a path segment. 93 | 94 | `capture`: Match and capture a path segment. The value is stored in 95 | the inbox. 96 | 97 | `root?`: Returns true if the path yet to be consumed is empty. 98 | 99 | `match`: Receives a String, a Symbol or a boolean, and returns true 100 | if it matches the request. 101 | 102 | `default`: Receives a block that will be executed unconditionally. 103 | 104 | `on`: Receives a value to be matched, and a block that will be 105 | executed only if the request is matched. 106 | 107 | `root`: Receives a block and calls it only if `root?` is true. 108 | 109 | `get`: Receives a block and calls it only if `root?` and `req.get?` are 110 | true. 111 | 112 | `put`: Receives a block and calls it only if `root?` and `req.put?` are 113 | true. 114 | 115 | `head`: Receives a block and calls it only if `root?` and `req.head?` 116 | are true. 117 | 118 | `post`: Receives a block and calls it only if `root?` and `req.post?` 119 | are true. 120 | 121 | `patch`: Receives a block and calls it only if `root?` and `req.patch?` 122 | are true. 123 | 124 | `delete`: Receives a block and calls it only if `root?` and `req.delete?` 125 | are true. 126 | 127 | `options`: Receives a block and calls it only if `root?` and 128 | `req.options?` are true. 129 | 130 | Decks 131 | ----- 132 | 133 | The sandbox where the application is evaluated is an instance of 134 | `Syro::Deck`, and it provides the API described earlier. You can 135 | define your own `Deck` and pass it to the `Syro` constructor. All 136 | the methods defined in there will be accessible from your routes. 137 | Here's an example: 138 | 139 | ```ruby 140 | class TextualDeck < Syro::Deck 141 | def text(str) 142 | res[Rack::CONTENT_TYPE] = "text/plain" 143 | res.write(str) 144 | end 145 | end 146 | 147 | App = Syro.new(TextualDeck) do 148 | get do 149 | text("hello world") 150 | end 151 | end 152 | ``` 153 | 154 | The example is simple enough to showcase the concept, but maybe too 155 | simple to be meaningful. The idea is that you can create your own 156 | specialized decks and reuse them in different applications. You can 157 | also define modules and later include them in your decks: for 158 | example, you can write modules for rendering or serializing data, 159 | and then you can combine those modules in your custom decks. 160 | 161 | Examples 162 | -------- 163 | 164 | In the following examples, the response string represents 165 | the request path that was sent. 166 | 167 | ```ruby 168 | App = Syro.new do 169 | get do 170 | res.write "GET /" 171 | end 172 | 173 | post do 174 | res.write "POST /" 175 | end 176 | 177 | on "users" do 178 | on :id do 179 | 180 | # Captured values go to the inbox 181 | @user = User[inbox[:id]] 182 | 183 | get do 184 | res.write "GET /users/42" 185 | end 186 | 187 | put do 188 | res.write "PUT /users/42" 189 | end 190 | 191 | patch do 192 | res.write "PATCH /users/42" 193 | end 194 | 195 | delete do 196 | res.write "DELETE /users/42" 197 | end 198 | end 199 | 200 | get do 201 | res.write "GET /users" 202 | end 203 | 204 | post do 205 | res.write "POST /users" 206 | end 207 | end 208 | end 209 | ``` 210 | 211 | Matches 212 | ------- 213 | 214 | The `on` method can receive a `String` to perform path matches; a 215 | `Symbol` to perform path captures; and a boolean to match any true 216 | values. 217 | 218 | Each time `on` matches or captures a segment of the PATH, that part 219 | of the path is consumed. The current and previous paths can be 220 | queried by calling `prev` and `curr` on the `path` object: `path.prev` 221 | returns the part of the path already consumed, and `path.curr` 222 | provides the current version of the path. 223 | 224 | Any expression that evaluates to a boolean can also be used as a 225 | matcher. For example, a common pattern is to follow some route 226 | only if a user is authenticated. That can be accomplished with 227 | `on(authenticated(User))`. That example assumes there's a method 228 | called `authenticated` that returns true or false depending on 229 | whether or not an instance of `User` is authenticated. As a side 230 | note, [Shield][shield] is a library that provides just that. 231 | 232 | [shield]: https://github.com/cyx/shield 233 | 234 | Captures 235 | -------- 236 | 237 | When a symbol is provided, `on` will try to consume a segment of 238 | the path. A segment is defined as any sequence of characters after 239 | a slash and until either another slash or the end of the string. 240 | The captured value is stored in the `inbox` hash under the key that 241 | was provided as the argument to `on`. For example, after a call to 242 | `on(:user_id)`, the value for the segment will be stored at 243 | `inbox[:user_id]`. When mounting an application called `Users` with 244 | the command `run(Users)`, an inbox can be provided as the second 245 | argument: `run(Users, inbox)`. That allows apps to share previous 246 | captures. 247 | 248 | Status code 249 | ----------- 250 | 251 | By default the status code is set to `404`. If both path and request 252 | method are matched, the status is automatically changed to `200`. 253 | You can change the status code by assigning a number to `res.status`, 254 | for example: 255 | 256 | ```ruby 257 | post do 258 | ... 259 | res.status = 201 260 | end 261 | ``` 262 | 263 | Handlers 264 | -------- 265 | 266 | Status code handlers can be installed with the `handle` command, 267 | which receives a status code and a block to be executed just before 268 | finishing the request. 269 | 270 | By default, if there are no matches in a Syro application the 271 | response is a `404` with an empty body. If we decide to handle the 272 | `404` requests and return a string, we can do as follows: 273 | 274 | ```ruby 275 | App = Syro.new do 276 | handle 404 do 277 | res.text "Not found!" 278 | end 279 | 280 | get do 281 | res.text "Found!" 282 | end 283 | end 284 | ``` 285 | 286 | In this example, a `GET` request to `"/"` will return a status `200` 287 | with the body `"Found!"`. Any other request will return a `404` 288 | with the body `"Not found!"`. 289 | 290 | If a new handler is installed for the same status code, the previous 291 | handler is overwritten. A handler is valid in the current scope and 292 | in all its nested branches. Blocks that end before the handler is 293 | installed are not affected. 294 | 295 | This is a contrived example that shows some edge cases when using handlers: 296 | 297 | ```ruby 298 | App = Syro.new do 299 | on "foo" do 300 | # 404, empty body 301 | end 302 | 303 | handle 404 do 304 | res.text "Not found!" 305 | end 306 | 307 | on "bar" do 308 | # 404, body is "Not found!" 309 | end 310 | 311 | on "baz" do 312 | # 404, body is "Couldn't find baz" 313 | 314 | handle 404 do 315 | res.text "Couldn't find baz" 316 | end 317 | end 318 | end 319 | ``` 320 | 321 | A request to `"/foo"` will return a `404`, because the request 322 | method was not matched. But as the `on "foo"` block ends before the 323 | handler is installed, the result will be a blank screen. On the 324 | other hand, a request to `"/bar"` will return a `404` with the plain 325 | text `"Not found!"`. 326 | 327 | Finally, a request to `"/baz"` will return a `404` with the plain text 328 | `"Couldn't find baz"`, because by the time the `on "baz"` block ends 329 | a new handler is installed, and thus the previous one is overwritten. 330 | 331 | Any status code can be handled this way, even status `200`. In that 332 | case the handler will behave as a filter to be run after each 333 | successful request. 334 | 335 | Content type 336 | ------------ 337 | 338 | There's no default value for the content type header, but there's 339 | a handy way of setting the desired value. 340 | 341 | In order to write the body of the response, the `res.write` method 342 | is used: 343 | 344 | ```ruby 345 | res.write "hello world" 346 | ``` 347 | 348 | It has the drawback of leaving the `Content-Type` header empty. 349 | Three alternative methods are provided, and more can be added by 350 | using custom Decks. 351 | 352 | Setting the Content-Type as `"text/plain"`: 353 | 354 | ```ruby 355 | res.text "hello world" 356 | ``` 357 | 358 | Setting the Content-Type as `"text/html"`: 359 | 360 | ```ruby 361 | res.html "hello world" 362 | ``` 363 | 364 | Setting the Content-Type as `"application/json"`: 365 | 366 | ```ruby 367 | res.json "hello world" 368 | ``` 369 | 370 | Note that aside from writing the response body and setting the value 371 | for the Content-Type header, no encoding or serialization takes 372 | place. If you want to return a JSON encoded response, make sure to 373 | encode the objects yourself (i.e., `res.json JSON.dump(...)`). 374 | 375 | Security 376 | -------- 377 | 378 | There are no security features built into this routing library. A 379 | framework using this library should implement the security layer. 380 | 381 | Rendering 382 | --------- 383 | 384 | There are no rendering features built into this routing library. A 385 | framework that uses this routing library can easily implement helpers 386 | for rendering. 387 | 388 | Middleware 389 | ---------- 390 | 391 | Syro doesn't support Rack middleware out of the box. If you need them, 392 | just use `Rack::Builder`: 393 | 394 | ```ruby 395 | App = Rack::Builder.new do 396 | use Rack::Session::Cookie, secret: "..." 397 | 398 | run Syro.new { 399 | get do 400 | res.write("Hello, world") 401 | end 402 | } 403 | end 404 | ``` 405 | 406 | Trivia 407 | ------ 408 | 409 | An initial idea was to release a new version of [Cuba](http://cuba.is) 410 | that broke backward compatibility, but in the end my friends suggested 411 | to release this as a separate library. In the future, some ideas 412 | of this library could be included in Cuba as well. 413 | 414 | Installation 415 | ------------ 416 | 417 | ``` 418 | $ gem install syro 419 | ``` 420 | -------------------------------------------------------------------------------- /lib/syro.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | # 3 | # Copyright (c) 2015 Michel Martens 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 | 23 | require "rack" 24 | require "seg" 25 | 26 | class Syro 27 | INBOX = "syro.inbox".freeze # :nodoc: 28 | 29 | class Response 30 | LOCATION = "Location".freeze # :nodoc: 31 | 32 | module ContentType 33 | HTML = "text/html".freeze # :nodoc: 34 | TEXT = "text/plain".freeze # :nodoc: 35 | JSON = "application/json".freeze # :nodoc: 36 | end 37 | 38 | # The status of the response. 39 | # 40 | # res.status = 200 41 | # res.status # => 200 42 | # 43 | attr_accessor :status 44 | 45 | # Returns the body of the response. 46 | # 47 | # res.body 48 | # # => [] 49 | # 50 | # res.write("there is") 51 | # res.write("no try") 52 | # 53 | # res.body 54 | # # => ["there is", "no try"] 55 | # 56 | attr_reader :body 57 | 58 | # Returns a hash with the response headers. 59 | # 60 | # res.headers 61 | # # => { "Content-Type" => "text/html", "Content-Length" => "42" } 62 | # 63 | attr_reader :headers 64 | 65 | def initialize(headers = {}) 66 | @status = 404 67 | @headers = headers 68 | @body = [] 69 | @length = 0 70 | end 71 | 72 | # Returns the response header corresponding to `key`. 73 | # 74 | # res["Content-Type"] # => "text/html" 75 | # res["Content-Length"] # => "42" 76 | # 77 | def [](key) 78 | @headers[key] 79 | end 80 | 81 | # Sets the given `value` with the header corresponding to `key`. 82 | # 83 | # res["Content-Type"] = "application/json" 84 | # res["Content-Type"] # => "application/json" 85 | # 86 | def []=(key, value) 87 | @headers[key] = value 88 | end 89 | 90 | # Appends `str` to `body` and updates the `Content-Length` header. 91 | # 92 | # res.body # => [] 93 | # 94 | # res.write("foo") 95 | # res.write("bar") 96 | # 97 | # res.body 98 | # # => ["foo", "bar"] 99 | # 100 | # res["Content-Length"] 101 | # # => 6 102 | # 103 | def write(str) 104 | s = str.to_s 105 | 106 | @length += s.bytesize 107 | @headers[Rack::CONTENT_LENGTH] = @length.to_s 108 | @body << s 109 | end 110 | 111 | # Write response body as text/plain 112 | def text(str) 113 | @headers[Rack::CONTENT_TYPE] = ContentType::TEXT 114 | write(str) 115 | end 116 | 117 | # Write response body as text/html 118 | def html(str) 119 | @headers[Rack::CONTENT_TYPE] = ContentType::HTML 120 | write(str) 121 | end 122 | 123 | # Write response body as application/json 124 | def json(str) 125 | @headers[Rack::CONTENT_TYPE] = ContentType::JSON 126 | write(str) 127 | end 128 | 129 | # Sets the `Location` header to `path` and updates the status to 130 | # `status`. By default, `status` is `302`. 131 | # 132 | # res.redirect("/path") 133 | # 134 | # res["Location"] # => "/path" 135 | # res.status # => 302 136 | # 137 | # res.redirect("http://syro.ru", 303) 138 | # 139 | # res["Location"] # => "http://syro.ru" 140 | # res.status # => 303 141 | # 142 | def redirect(path, status = 302) 143 | @headers[LOCATION] = path 144 | @status = status 145 | end 146 | 147 | # Returns an array with three elements: the status, headers and 148 | # body. If the status is not set, the status is set to 404. If 149 | # a match is found for both path and request method, the status 150 | # is changed to 200. 151 | # 152 | # res.status = 200 153 | # res.finish 154 | # # => [200, {}, []] 155 | # 156 | # res.status = nil 157 | # res.finish 158 | # # => [404, {}, []] 159 | # 160 | # res.status = nil 161 | # res.write("syro") 162 | # res.finish 163 | # # => [200, { "Content-Type" => "text/html" }, ["syro"]] 164 | # 165 | def finish 166 | [@status, @headers, @body] 167 | end 168 | 169 | # Sets a cookie into the response. 170 | # 171 | # res.set_cookie("foo", "bar") 172 | # res["Set-Cookie"] # => "foo=bar" 173 | # 174 | # res.set_cookie("foo2", "bar2") 175 | # res["Set-Cookie"] # => "foo=bar\nfoo2=bar2" 176 | # 177 | # res.set_cookie("bar", { 178 | # domain: ".example.com", 179 | # path: "/", 180 | # # max_age: 0, 181 | # # expires: Time.now + 10_000, 182 | # secure: true, 183 | # httponly: true, 184 | # value: "bar" 185 | # }) 186 | # 187 | # res["Set-Cookie"].split("\n").last 188 | # # => "bar=bar; domain=.example.com; path=/; secure; HttpOnly 189 | # 190 | # **NOTE:** This method doesn't sign and/or encrypt the value of the cookie. 191 | # 192 | def set_cookie(key, value) 193 | Rack::Utils.set_cookie_header!(@headers, key, value) 194 | end 195 | 196 | # Deletes given cookie. 197 | # 198 | # res.set_cookie("foo", "bar") 199 | # res["Set-Cookie"] 200 | # # => "foo=bar" 201 | # 202 | # res.delete_cookie("foo") 203 | # res["Set-Cookie"] 204 | # # => "foo=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 -0000" 205 | # 206 | def delete_cookie(key, value = {}) 207 | Rack::Utils.delete_cookie_header!(@headers, key, value) 208 | end 209 | end 210 | 211 | class Deck 212 | 213 | # Attaches the supplied block to a subclass of Deck as #dispatch! 214 | # Returns the subclassed Deck. 215 | def self.implement(&code) 216 | Class.new(self) do 217 | define_method(:dispatch!, code) 218 | private :dispatch! 219 | 220 | # Instead of calling inspect on this anonymous class, 221 | # defer to the superclass which is likely Syro::Deck. 222 | define_method(:inspect) do 223 | self.class.superclass.inspect 224 | end 225 | end 226 | end 227 | 228 | module API 229 | def env 230 | @syro_env 231 | end 232 | 233 | # Returns the incoming request object. This object is an 234 | # instance of Rack::Request. 235 | # 236 | # req.post? # => true 237 | # req.params # => { "username" => "bob", "password" => "secret" } 238 | # req[:username] # => "bob" 239 | # 240 | def req 241 | @syro_req 242 | end 243 | 244 | # Returns the current response object. This object is an 245 | # instance of Syro::Response. 246 | # 247 | # res.status = 200 248 | # res["Content-Type"] = "text/html" 249 | # res.write("

Welcome back!

") 250 | # 251 | def res 252 | @syro_res 253 | end 254 | 255 | def path 256 | @syro_path 257 | end 258 | 259 | def inbox 260 | @syro_inbox 261 | end 262 | 263 | def default_headers 264 | {} 265 | end 266 | 267 | def request_class 268 | Rack::Request 269 | end 270 | 271 | def response_class 272 | Syro::Response 273 | end 274 | 275 | def call(env, inbox) 276 | @syro_env = env 277 | @syro_req = request_class.new(env) 278 | @syro_res = response_class.new(default_headers) 279 | @syro_path = Seg.new(env.fetch(Rack::PATH_INFO)) 280 | @syro_inbox = inbox 281 | 282 | catch(:halt) do 283 | dispatch! 284 | finish! 285 | end 286 | end 287 | 288 | def run(app, inbox = {}) 289 | path, script = env[Rack::PATH_INFO], env[Rack::SCRIPT_NAME] 290 | 291 | env[Rack::PATH_INFO] = @syro_path.curr 292 | env[Rack::SCRIPT_NAME] = script.to_s + @syro_path.prev 293 | env[Syro::INBOX] = inbox 294 | 295 | halt(app.call(env)) 296 | ensure 297 | env[Rack::PATH_INFO], env[Rack::SCRIPT_NAME] = path, script 298 | end 299 | 300 | # Immediately stops the request and returns `response` 301 | # as per Rack's specification. 302 | # 303 | # halt([200, { "Content-Type" => "text/html" }, ["hello"]]) 304 | # halt([res.status, res.headers, res.body]) 305 | # halt(res.finish) 306 | # 307 | def halt(response) 308 | throw(:halt, response) 309 | end 310 | 311 | # Install a handler for a given status code. Once a handler is 312 | # installed, it will be called by Syro before halting the 313 | # request. 314 | # 315 | # handle 404 do 316 | # res.text "Not found!" 317 | # end 318 | # 319 | # If a new handler is installed for the same status code, the 320 | # previous handler is overwritten. A handler is valid in the 321 | # current scope and in all its nested branches. Blocks that end 322 | # before the handler is installed are not affected. 323 | # 324 | # For example: 325 | # 326 | # on "foo" do 327 | # # Not found 328 | # end 329 | # 330 | # handle 404 do 331 | # res.text "Not found!" 332 | # end 333 | # 334 | # on "bar" do 335 | # # Not found 336 | # end 337 | # 338 | # on "baz" do 339 | # # Not found 340 | # 341 | # handle 404 do 342 | # res.text "Couldn't find baz" 343 | # end 344 | # end 345 | # 346 | # A request to "/foo" will return a 404, because the request 347 | # method was not matched. But as the `on "foo"` block ends 348 | # before the handler is installed, the result will be a blank 349 | # screen. On the other hand, a request to "/bar" will return a 350 | # 404 with the plain text "Not found!". 351 | # 352 | # Finally, a request to "/baz" will return a 404 with the plain text 353 | # "Couldn't find baz", because by the time the `on "baz"` block ends 354 | # a new handler is installed, and thus the previous one is overwritten. 355 | # 356 | # Any status code can be handled this way, even status `200`. 357 | # In that case the handler will behave as a filter to be run 358 | # after each successful request. 359 | # 360 | def handle(status, &block) 361 | inbox[status] = block 362 | end 363 | 364 | def finish! 365 | inbox[res.status]&.call 366 | halt(res.finish) 367 | end 368 | 369 | def consume(arg) 370 | @syro_path.consume(arg) 371 | end 372 | 373 | def capture(arg) 374 | @syro_path.capture(arg, inbox) 375 | end 376 | 377 | def root? 378 | @syro_path.root? 379 | end 380 | 381 | def match(arg) 382 | case arg 383 | when String then consume(arg) 384 | when Symbol then capture(arg) 385 | when true then true 386 | else false 387 | end 388 | end 389 | 390 | def default 391 | yield; finish! 392 | end 393 | 394 | def on(arg) 395 | default { yield } if match(arg) 396 | end 397 | 398 | def root 399 | default { yield } if root? 400 | end 401 | 402 | def get 403 | root { res.status = 200; yield } if req.get? 404 | end 405 | 406 | def put 407 | root { res.status = 200; yield } if req.put? 408 | end 409 | 410 | def head 411 | root { res.status = 200; yield } if req.head? 412 | end 413 | 414 | def post 415 | root { res.status = 200; yield } if req.post? 416 | end 417 | 418 | def patch 419 | root { res.status = 200; yield } if req.patch? 420 | end 421 | 422 | def delete 423 | root { res.status = 200; yield } if req.delete? 424 | end 425 | 426 | def options 427 | root { res.status = 200; yield } if req.options? 428 | end 429 | end 430 | 431 | include API 432 | end 433 | 434 | def initialize(deck = Deck, &code) 435 | @deck = deck.implement(&code) 436 | end 437 | 438 | def call(env, inbox = env.fetch(Syro::INBOX, {})) 439 | @deck.new.call(env, inbox) 440 | end 441 | end 442 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | 3 | test: 4 | cutest -r ./test/helper.rb ./test/*.rb 5 | -------------------------------------------------------------------------------- /syro.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = "syro" 3 | s.version = "3.2.1" 4 | s.summary = "Simple router" 5 | s.description = "Simple router for web applications" 6 | s.authors = ["Michel Martens"] 7 | s.email = ["michel@soveran.com"] 8 | s.homepage = "https://github.com/soveran/syro" 9 | s.license = "MIT" 10 | 11 | s.files = `git ls-files`.split("\n") 12 | 13 | s.add_dependency "seg" 14 | s.add_dependency "rack", ">= 1.6.0" 15 | s.add_development_dependency "cutest" 16 | s.add_development_dependency "rack-test" 17 | end 18 | -------------------------------------------------------------------------------- /test/all.rb: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | class RackApp 4 | def call(env) 5 | [200, {"Content-Type" => "text/html"}, ["GET /rack"]] 6 | end 7 | end 8 | 9 | class MarkdownDeck < Syro::Deck 10 | def markdown(str) 11 | res[Rack::CONTENT_TYPE] = "text/markdown" 12 | res.write(str) 13 | end 14 | end 15 | 16 | class DefaultHeaders < Syro::Deck 17 | def default_headers 18 | { Rack::CONTENT_TYPE => "text/html" } 19 | end 20 | end 21 | 22 | class CustomRequestAndResponse < Syro::Deck 23 | class JSONRequest < Rack::Request 24 | def params 25 | JSON.parse(body.read) 26 | end 27 | end 28 | 29 | class JSONResponse < Syro::Response 30 | def write(s) 31 | super(JSON.generate(s)) 32 | end 33 | end 34 | 35 | def request_class 36 | JSONRequest 37 | end 38 | 39 | def response_class 40 | JSONResponse 41 | end 42 | end 43 | 44 | markdown = Syro.new(MarkdownDeck) do 45 | get do 46 | markdown("GET /markdown") 47 | end 48 | end 49 | 50 | default_headers = Syro.new(DefaultHeaders) do end 51 | 52 | json = Syro.new(CustomRequestAndResponse) do 53 | root do 54 | params = req.params 55 | 56 | res.write(params) 57 | end 58 | end 59 | 60 | admin = Syro.new do 61 | get do 62 | res.write("GET /admin") 63 | end 64 | end 65 | 66 | platforms = Syro.new do 67 | @id = inbox.fetch(:id) 68 | 69 | get do 70 | res.write "GET /platforms/#{@id}" 71 | end 72 | end 73 | 74 | comments = Syro.new do 75 | get do 76 | res.write sprintf("GET %s/%s/comments", 77 | inbox[:path], 78 | inbox[:post_id]) 79 | end 80 | end 81 | 82 | handlers = Syro.new do 83 | on "without_handler" do 84 | # Not found 85 | end 86 | 87 | handle(404) do 88 | res.text "Not found!" 89 | end 90 | 91 | on "with_handler" do 92 | # Not found 93 | end 94 | 95 | on "with_local_handler" do 96 | handle(404) do 97 | res.text "Also not found!" 98 | end 99 | end 100 | end 101 | 102 | path_info = Syro.new do 103 | on "foo" do 104 | get do 105 | res.text req.path 106 | end 107 | end 108 | 109 | get do 110 | res.text req.path 111 | end 112 | end 113 | 114 | script_name = Syro.new do 115 | on "path" do 116 | run(path_info) 117 | end 118 | end 119 | 120 | exception = Syro.new do 121 | get { res.text(this_method_does_not_exist) } 122 | end 123 | 124 | app = Syro.new do 125 | get do 126 | res.write "GET /" 127 | end 128 | 129 | post do 130 | on req.POST["user"] != nil do 131 | res.write "POST / (user)" 132 | end 133 | 134 | on true do 135 | res.write "POST / (none)" 136 | end 137 | end 138 | 139 | on "foo" do 140 | on "bar" do 141 | on "baz" do 142 | res.write("error") 143 | end 144 | 145 | get do 146 | res.write("GET /foo/bar") 147 | end 148 | 149 | put do 150 | res.write("PUT /foo/bar") 151 | end 152 | 153 | head do 154 | res.write("HEAD /foo/bar") 155 | end 156 | 157 | post do 158 | res.write("POST /foo/bar") 159 | end 160 | 161 | patch do 162 | res.write("PATCH /foo/bar") 163 | end 164 | 165 | delete do 166 | res.write("DELETE /foo/bar") 167 | end 168 | 169 | options do 170 | res.write("OPTIONS /foo/bar") 171 | end 172 | end 173 | end 174 | 175 | on "bar/baz" do 176 | get do 177 | res.write("GET /bar/baz") 178 | end 179 | end 180 | 181 | on "admin" do 182 | run(admin) 183 | end 184 | 185 | on "platforms" do 186 | run(platforms, id: 42) 187 | end 188 | 189 | on "rack" do 190 | run(RackApp.new) 191 | end 192 | 193 | on "users" do 194 | on :id do 195 | res.write(sprintf("GET /users/%s", inbox[:id])) 196 | end 197 | end 198 | 199 | on "posts" do 200 | @path = path.prev 201 | 202 | on :post_id do 203 | on "comments" do 204 | run(comments, inbox.merge(path: @path)) 205 | end 206 | end 207 | end 208 | 209 | on "one" do 210 | @one = "1" 211 | 212 | get do 213 | res.write(@one) 214 | end 215 | end 216 | 217 | on "two" do 218 | get do 219 | res.write(@one) 220 | end 221 | 222 | post do 223 | res.redirect("/one") 224 | end 225 | end 226 | 227 | on "markdown" do 228 | run(markdown) 229 | end 230 | 231 | on "headers" do 232 | run(default_headers) 233 | end 234 | 235 | on "custom" do 236 | run(json) 237 | end 238 | 239 | on "handlers" do 240 | run(handlers) 241 | end 242 | 243 | on "private" do 244 | res.status = 401 245 | res.write("Unauthorized") 246 | end 247 | 248 | on "write" do 249 | res.write "nil!" 250 | end 251 | 252 | on "text" do 253 | res.text "plain!" 254 | end 255 | 256 | on "html" do 257 | res.html "html!" 258 | end 259 | 260 | on "json" do 261 | res.json "json!" 262 | end 263 | 264 | on "script" do 265 | run(script_name) 266 | end 267 | 268 | on "exception" do 269 | run(exception) 270 | end 271 | end 272 | 273 | setup do 274 | Driver.new(app) 275 | end 276 | 277 | test "path + verb" do |f| 278 | f.get("/foo/bar") 279 | assert_equal 200, f.last_response.status 280 | assert_equal "GET /foo/bar", f.last_response.body 281 | 282 | f.get("/bar/baz") 283 | assert_equal 404, f.last_response.status 284 | assert_equal "", f.last_response.body 285 | 286 | f.put("/foo/bar") 287 | assert_equal 200, f.last_response.status 288 | assert_equal "PUT /foo/bar", f.last_response.body 289 | 290 | f.head("/foo/bar") 291 | assert_equal 200, f.last_response.status 292 | assert_equal "HEAD /foo/bar", f.last_response.body 293 | 294 | f.post("/foo/bar") 295 | assert_equal 200, f.last_response.status 296 | assert_equal "POST /foo/bar", f.last_response.body 297 | 298 | f.patch("/foo/bar") 299 | assert_equal 200, f.last_response.status 300 | assert_equal "PATCH /foo/bar", f.last_response.body 301 | 302 | f.delete("/foo/bar") 303 | assert_equal 200, f.last_response.status 304 | assert_equal "DELETE /foo/bar", f.last_response.body 305 | 306 | f.options("/foo/bar") 307 | assert_equal 200, f.last_response.status 308 | assert_equal "OPTIONS /foo/bar", f.last_response.body 309 | end 310 | 311 | test "verbs match only on root" do |f| 312 | f.get("/bar/baz/foo") 313 | assert_equal "", f.last_response.body 314 | assert_equal 404, f.last_response.status 315 | end 316 | 317 | test "mounted app" do |f| 318 | f.get("/admin") 319 | assert_equal "GET /admin", f.last_response.body 320 | assert_equal 200, f.last_response.status 321 | end 322 | 323 | test "mounted app + inbox" do |f| 324 | f.get("/platforms") 325 | assert_equal "GET /platforms/42", f.last_response.body 326 | assert_equal 200, f.last_response.status 327 | end 328 | 329 | test "run rack app" do |f| 330 | f.get("/rack") 331 | assert_equal "GET /rack", f.last_response.body 332 | assert_equal 200, f.last_response.status 333 | end 334 | 335 | test "root" do |f| 336 | f.get("/") 337 | assert_equal "GET /", f.last_response.body 338 | assert_equal 200, f.last_response.status 339 | end 340 | 341 | test "captures" do |f| 342 | f.get("/users/42") 343 | assert_equal "GET /users/42", f.last_response.body 344 | 345 | # As the verb was not mached, the status is 404. 346 | assert_equal 404, f.last_response.status 347 | end 348 | 349 | test "post values" do |f| 350 | f.post("/", "user" => { "username" => "foo" }) 351 | assert_equal "POST / (user)", f.last_response.body 352 | assert_equal 200, f.last_response.status 353 | 354 | f.post("/") 355 | assert_equal "POST / (none)", f.last_response.body 356 | assert_equal 200, f.last_response.status 357 | end 358 | 359 | test "inherited inbox" do |f| 360 | f.get("/posts/42/comments") 361 | assert_equal "GET /posts/42/comments", f.last_response.body 362 | assert_equal 200, f.last_response.status 363 | end 364 | 365 | test "leaks" do |f| 366 | f.get("/one") 367 | assert_equal "1", f.last_response.body 368 | assert_equal 200, f.last_response.status 369 | 370 | f.get("/two") 371 | assert_equal "", f.last_response.body 372 | assert_equal 200, f.last_response.status 373 | end 374 | 375 | test "redirect" do |f| 376 | f.post("/two") 377 | assert_equal 302, f.last_response.status 378 | 379 | f.follow_redirect! 380 | assert_equal "1", f.last_response.body 381 | assert_equal 200, f.last_response.status 382 | end 383 | 384 | test "custom deck" do |f| 385 | f.get("/markdown") 386 | assert_equal "GET /markdown", f.last_response.body 387 | assert_equal "text/markdown", f.last_response.headers["Content-Type"] 388 | assert_equal 200, f.last_response.status 389 | end 390 | 391 | test "default headers" do |f| 392 | f.get("/headers") 393 | 394 | assert_equal "text/html", f.last_response.headers["Content-Type"] 395 | end 396 | 397 | test "custom request and response class" do |f| 398 | params = JSON.generate(foo: "foo") 399 | 400 | f.post("/custom", params) 401 | 402 | assert_equal params, f.last_response.body 403 | end 404 | 405 | test "don't set content type by default" do |f| 406 | f.get("/private") 407 | 408 | assert_equal 401, f.last_response.status 409 | assert_equal "Unauthorized", f.last_response.body 410 | assert_equal nil, f.last_response.headers["Content-Type"] 411 | end 412 | 413 | test "content type" do |f| 414 | f.get("/write") 415 | assert_equal nil, f.last_response.headers["Content-Type"] 416 | 417 | f.get("/text") 418 | assert_equal "text/plain", f.last_response.headers["Content-Type"] 419 | 420 | f.get("/html") 421 | assert_equal "text/html", f.last_response.headers["Content-Type"] 422 | 423 | f.get("/json") 424 | assert_equal "application/json", f.last_response.headers["Content-Type"] 425 | end 426 | 427 | test "status code handling" do |f| 428 | f.get("/handlers") 429 | assert_equal 404, f.last_response.status 430 | assert_equal "text/plain", f.last_response.headers["Content-Type"] 431 | assert_equal "Not found!", f.last_response.body 432 | 433 | f.get("/handlers/without_handler") 434 | assert_equal 404, f.last_response.status 435 | assert_equal nil, f.last_response.headers["Content-Type"] 436 | assert_equal "", f.last_response.body 437 | 438 | f.get("/handlers/with_handler") 439 | assert_equal 404, f.last_response.status 440 | assert_equal "text/plain", f.last_response.headers["Content-Type"] 441 | assert_equal "Not found!", f.last_response.body 442 | 443 | f.get("/handlers/with_local_handler") 444 | assert_equal 404, f.last_response.status 445 | assert_equal "text/plain", f.last_response.headers["Content-Type"] 446 | assert_equal "Also not found!", f.last_response.body 447 | end 448 | 449 | test "script name and path info" do |f| 450 | f.get("/script/path") 451 | assert_equal 200, f.last_response.status 452 | assert_equal "/script/path", f.last_response.body 453 | end 454 | 455 | test "deck exceptions reference a named class" do |f| 456 | f.get("/exception") 457 | rescue NameError => exception 458 | ensure 459 | assert exception.to_s.include?("Syro::Deck") 460 | end 461 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require_relative "../lib/syro" 2 | require "rack/test" 3 | 4 | class Driver 5 | include Rack::Test::Methods 6 | 7 | def initialize(app) 8 | @app = app 9 | end 10 | 11 | def app 12 | @app 13 | end 14 | end 15 | 16 | --------------------------------------------------------------------------------