├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ └── docs.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bin └── test ├── shard.yml ├── spec ├── fragment_spec.cr ├── integration_spec.cr ├── path_normalizer_spec.cr ├── path_part_spec.cr └── spec_helper.cr └── src ├── benchmark.cr ├── lucky_router.cr └── lucky_router ├── errors.cr ├── fragment.cr ├── match.cr ├── matcher.cr ├── no_match.cr ├── path_normalizer.cr ├── path_part.cr ├── path_reader.cr └── version.cr /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.cr] 2 | charset = utf-8 3 | end_of_line = lf 4 | insert_final_newline = true 5 | indent_style = space 6 | indent_size = 2 7 | trim_trailing_whitespace = true 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Lucky Router CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: "*" 8 | 9 | jobs: 10 | check_format: 11 | strategy: 12 | fail-fast: false 13 | runs-on: ubuntu-latest 14 | continue-on-error: false 15 | steps: 16 | - name: Download source 17 | uses: actions/checkout@v4 18 | - name: Install Crystal 19 | uses: crystal-lang/install-crystal@v1 20 | - name: Install shards 21 | run: shards install 22 | - name: Format 23 | run: crystal tool format --check 24 | - name: Lint 25 | run: ./bin/ameba 26 | specs: 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | os: [ubuntu-latest, windows-latest] 31 | crystal_version: [latest] 32 | include: 33 | - os: ubuntu-latest 34 | crystal_version: 1.10.0 35 | runs-on: ${{ matrix.os }} 36 | continue-on-error: false 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: crystal-lang/install-crystal@v1 40 | with: 41 | crystal: ${{ matrix.crystal_version }} 42 | - name: Install dependencies 43 | run: shards install --skip-postinstall --skip-executables 44 | - name: Run tests 45 | run: crystal spec 46 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy docs 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | persist-credentials: false 14 | - uses: crystal-lang/install-crystal@v1 15 | with: 16 | crystal: latest 17 | - name: "Install shards" 18 | run: shards install --skip-postinstall --skip-executables 19 | - name: "Generate docs" 20 | run: crystal docs 21 | - name: Deploy to GitHub Pages 22 | uses: peaceiris/actions-gh-pages@v4 23 | with: 24 | github_token: ${{ secrets.GITHUB_TOKEN }} 25 | publish_dir: ./docs 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /doc/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | 6 | # Libraries don't need dependency lock 7 | # Dependencies will be locked in application that uses them 8 | /shard.lock 9 | benchmark 10 | *.dwarf 11 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Open Source Code of Conduct 2 | 3 | In order to foster an inclusive, kind, harassment-free, and cooperative community, thoughtbot enforces this code of conduct on our open source projects. 4 | 5 | ## Summary 6 | Harassment in code and discussion or violation of physical boundaries is completely unacceptable anywhere in thoughtbot’s project codebases, issue trackers, chatrooms, mailing lists, meetups, and other events. Violators will be warned by the core team. Repeat violations will result in being blocked or banned by the core team at or before the 3rd violation. 7 | 8 | ## In detail 9 | Harassment includes offensive verbal comments related to gender identity, gender expression, sexual orientation, disability, physical appearance, body size, race, religion, sexual images, deliberate intimidation, stalking, sustained disruption, and unwelcome sexual attention. 10 | 11 | Individuals asked to stop any harassing behavior are expected to comply immediately. 12 | 13 | Maintainers are also subject to the anti-harassment policy. 14 | 15 | If anyone engages in harassing behavior, including maintainers, we may take appropriate action, up to and including warning the offender, deletion of comments, removal from the project’s codebase and communication systems, and escalation to GitHub support. 16 | 17 | If you are being harassed, notice that someone else is being harassed, or have any other concerns, please contact a member of the core team or email conduct@thoughtbot.com immediately. 18 | 19 | We expect everyone to follow these rules anywhere in thoughtbot's project codebases, issue trackers, chatrooms, and mailing lists. 20 | 21 | Finally, don't forget that it is human to make mistakes! We all do. Let’s work together to help each other, resolve issues, and learn from the mistakes that we will all inevitably make from time to time. 22 | 23 | ## Thanks 24 | Thanks to the CocoaPods Code of Conduct, Bundler Code of Conduct, JSConf Code of Conduct, and Contributor Covenant for inspiration and ideas. 25 | 26 | ## License 27 | To the extent possible under law, the thoughtbot team has waived all copyright and related or neighboring rights to thoughtbot Code of Conduct. This work is published from the United States. 28 | 29 | [homepage](https://thoughtbot.com/open-source-code-of-conduct) 30 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Lucky 2 | 3 | We love pull requests from everyone. By participating in this project, you 4 | agree to abide by the thoughtbot [code of conduct]. 5 | 6 | [code of conduct]: https://thoughtbot.com/open-source-code-of-conduct 7 | 8 | Here are some ways *you* can contribute: 9 | 10 | * by using alpha, beta, and prerelease versions 11 | * by reporting bugs 12 | * by suggesting new features 13 | * by writing or editing documentation 14 | * by writing specifications 15 | * by writing code ( **no patch is too small** : fix typos, add comments, clean up inconsistent whitespace ) 16 | * by refactoring code 17 | * by closing [issues][] 18 | * by reviewing patches 19 | 20 | [issues]: https://github.com/luckyframework/lucky_router/issues 21 | 22 | ## Submitting an Issue 23 | 24 | * We use the [GitHub issue tracker][issues] to track bugs and features. 25 | * Before submitting a bug report or feature request, check to make sure it hasn't 26 | already been submitted. 27 | * When submitting a bug report, please include a [Gist][] that includes a stack 28 | trace and any details that may be necessary to reproduce the bug, including 29 | your Crystal version, and operating system. Ideally, a bug report 30 | should include a pull request with failing specs. 31 | 32 | [gist]: https://gist.github.com/ 33 | 34 | ## Cleaning up issues 35 | 36 | * Issues that have no response from the submitter will be closed after 30 days. 37 | * Issues will be closed once they're assumed to be fixed or answered. If the 38 | maintainer is wrong, it can be opened again. 39 | * If your issue is closed by mistake, please understand and explain the issue. 40 | We will happily reopen the issue. 41 | 42 | ## Submitting a Pull Request 43 | 1. [Fork][fork] the [official repository][repo]. 44 | 2. [Create a topic branch.][branch] 45 | 3. Implement your feature or bug fix. 46 | 4. Add, commit, and push your changes. 47 | 5. [Submit a pull request.][pr] 48 | 49 | ## Notes 50 | * Please add tests if you changed code. Contributions without tests won't be accepted. 51 | * If you don't know how to add tests, please put in a PR and leave a comment 52 | asking for help. We love helping! 53 | * Please don't update the Gem version. 54 | 55 | [repo]: https://github.com/luckyframework/lucky_router/ 56 | [fork]: https://help.github.com/articles/fork-a-repo/ 57 | [branch]: https://help.github.com/articles/creating-and-deleting-branches-within-your-repository/ 58 | [pr]: https://help.github.com/articles/using-pull-requests/ 59 | 60 | Inspired by https://github.com/middleman/middleman-heroku/blob/master/CONTRIBUTING.md 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Paul Smith 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LuckyRouter 2 | 3 | [![API Documentation Website](https://img.shields.io/website?down_color=red&down_message=Offline&label=API%20Documentation&up_message=Online&url=https%3A%2F%2Fluckyframework.github.io%2Flucky_router%2F)](https://luckyframework.github.io/lucky_router) 4 | 5 | A library for routing HTTP request with Crystal 6 | 7 | ## Installation 8 | 9 | Add this to your application's `shard.yml`: 10 | 11 | ```yaml 12 | dependencies: 13 | lucky_router: 14 | github: luckyframework/lucky_router 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```crystal 20 | require "lucky_router" 21 | 22 | router = LuckyRouter::Matcher(Symbol).new 23 | 24 | router.add("get", "/users", :index) 25 | router.add("delete", "/users/:id", :delete) 26 | 27 | router.match("get", "/users").payload # :index 28 | router.match("get", "/users").params # {} of String => String 29 | router.match("delete", "/users/1").payload # :delete 30 | router.match("delete", "/users/1").params # {"id" => "1"} 31 | router.match("get", "/missing_route").payload # nil 32 | ``` 33 | 34 | ## Contributing 35 | 36 | 1. Fork it ( https://github.com/luckyframework/lucky_router/fork ) 37 | 2. Create your feature branch (git checkout -b my-new-feature) 38 | 3. Make your changes 39 | 4. Run `./bin/test` to run the specs, build shards, and check formatting 40 | 5. Commit your changes (git commit -am 'Add some feature') 41 | 6. Push to the branch (git push origin my-new-feature) 42 | 7. Create a new Pull Request 43 | 44 | ## Contributors 45 | 46 | - [paulcsmith](https://github.com/paulcsmith) Paul Smith - creator, maintainer 47 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | printf "\nbuilding shards with 'shards build'\n\n" 4 | shards build 5 | printf "\nrunning specs with 'crystal spec'\n\n" 6 | crystal spec 7 | printf "\nformatting code with 'crystal tool format src spec'\n\n" 8 | crystal tool format src spec 9 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: lucky_router 2 | version: 0.6.0 3 | 4 | authors: 5 | - Paul Smith 6 | 7 | crystal: ">= 1.10.0" 8 | license: MIT 9 | 10 | development_dependencies: 11 | ameba: 12 | github: crystal-ameba/ameba 13 | version: ~> 1.5.0 14 | -------------------------------------------------------------------------------- /spec/fragment_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe LuckyRouter::Fragment do 4 | it "adds parts successfully" do 5 | fragment = build_fragment 6 | 7 | fragment.process_parts(build_path_parts("users", ":id"), "get", :show) 8 | 9 | users_fragment = fragment.static_parts["users"] 10 | users_fragment.dynamic_parts.should_not be_empty 11 | end 12 | 13 | it "static parts after dynamic parts do not overwrite each other" do 14 | fragment = build_fragment 15 | 16 | fragment.process_parts(build_path_parts("users", ":id", "edit"), "get", :edit) 17 | fragment.process_parts(build_path_parts("users", ":id", "new"), "get", :new) 18 | 19 | users_fragment = fragment.static_parts["users"] 20 | id_fragment = users_fragment.dynamic_parts.first 21 | id_fragment.static_parts["edit"].should_not be_nil 22 | id_fragment.static_parts["new"].should_not be_nil 23 | end 24 | 25 | describe "#collect_routes" do 26 | it "returns list of routes from fragment" do 27 | fragment = build_fragment 28 | fragment.process_parts(build_path_parts("users", ":id"), "get", :show) 29 | 30 | result = fragment.collect_routes 31 | 32 | result.size.should eq(1) 33 | result[0].should eq({ 34 | [LuckyRouter::PathPart.new(""), LuckyRouter::PathPart.new("users"), LuckyRouter::PathPart.new(":id")], 35 | "get", 36 | :show, 37 | }) 38 | end 39 | end 40 | end 41 | 42 | private def build_path_parts(*path_parts) : Array(LuckyRouter::PathPart) 43 | path_parts.map { |part| LuckyRouter::PathPart.new(part) }.to_a 44 | end 45 | 46 | private def build_fragment 47 | LuckyRouter::Fragment(Symbol).new(path_part: LuckyRouter::PathPart.new("")) 48 | end 49 | -------------------------------------------------------------------------------- /spec/integration_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe LuckyRouter do 4 | it "handles many routes" do 5 | router = LuckyRouter::Matcher(Symbol).new 6 | 7 | # Here to makes sure things run super fast even with lots of routes 8 | 1000.times do 9 | router.add("put", "#{UUID.random}", :fake_show) 10 | router.add("get", "#{UUID.random}/edit", :fake_edit) 11 | router.add("get", "#{UUID.random}/new/edit", :fake_new_edit) 12 | end 13 | 14 | router.add("get", "/:organization", :organization) 15 | router.add("get", "/:organization/:repo", :repo) 16 | router.add("get", "/posts/:id", :post_index) 17 | router.add("get", "/users", :index) 18 | router.add("post", "/users", :create) 19 | router.add("get", "/users/:id", :show) 20 | router.add("delete", "/users/:id", :delete) 21 | router.add("put", "/users/:id", :update) 22 | router.add("get", "/users/:id/edit", :edit) 23 | router.add("get", "/users/:id/new", :new) 24 | router.add("get", "/users/:user_id/tasks/:id", :user_tasks) 25 | router.add("get", "/admin/users/:user_id/tasks/:id", :admin_user_tasks) 26 | router.add("get", "/complex_posts/:required/?:optional_1/?:optional_2", :posts_with_complex_params) 27 | 28 | 1000.times do 29 | router.match!("get", "/luckyframework").payload.should eq :organization 30 | router.match!("get", "/luckyframework/lucky").payload.should eq :repo 31 | router.match!("get", "/posts/1").payload.should eq :post_index 32 | router.match!("get", "/users").payload.should eq :index 33 | router.match!("post", "/users").payload.should eq :create 34 | router.match!("get", "/users/1").payload.should eq :show 35 | router.match!("delete", "/users/1").payload.should eq :delete 36 | router.match!("put", "/users/1").payload.should eq :update 37 | router.match!("get", "/users/1/edit").payload.should eq :edit 38 | router.match!("get", "/users/1/new").payload.should eq :new 39 | router.match!("get", "/users/1/tasks/1").payload.should eq :user_tasks 40 | router.match!("get", "/admin/users/1/tasks/1").payload.should eq :admin_user_tasks 41 | router.match!("get", "/complex_posts/1/2/3").payload.should eq :posts_with_complex_params 42 | router.match!("get", "/complex_posts/1/2").payload.should eq :posts_with_complex_params 43 | router.match!("get", "/complex_posts/1").payload.should eq :posts_with_complex_params 44 | end 45 | end 46 | 47 | it "allows optional route params" do 48 | router = LuckyRouter::Matcher(Symbol).new 49 | router.add("get", "/posts/:required/?:optional_1/?:optional_2", :post_index) 50 | 51 | router.match!("get", "/posts/1").params.should eq({ 52 | "required" => "1", 53 | }) 54 | router.match!("get", "/posts/1/2").params.should eq({ 55 | "required" => "1", 56 | "optional_1" => "2", 57 | }) 58 | router.match!("get", "/posts/1/2/3/").params.should eq({ 59 | "required" => "1", 60 | "optional_1" => "2", 61 | "optional_2" => "3", 62 | }) 63 | end 64 | 65 | it "handles root routes" do 66 | router = LuckyRouter::Matcher(Symbol).new 67 | 68 | router.add("get", "/", :root) 69 | 70 | router.match!("get", "/").payload.should eq :root 71 | end 72 | 73 | it "allows head routes when a get route is defined" do 74 | router = LuckyRouter::Matcher(Symbol).new 75 | router.add("get", "/health", :health) 76 | router.match!("head", "/health").payload.should eq :health 77 | end 78 | 79 | it "does not allow head routes when something else is defined" do 80 | router = LuckyRouter::Matcher(Symbol).new 81 | router.add("put", "/update", :update) 82 | router.match("head", "/update").should be_nil 83 | end 84 | 85 | it "does not blow up when there are no routes" do 86 | router = LuckyRouter::Matcher(Symbol).new 87 | router.match("post", "/users") 88 | end 89 | 90 | it "returns nil if nothing matches" do 91 | router = LuckyRouter::Matcher(Symbol).new 92 | 93 | router.add("get", "/whatever", :index) 94 | router.match("get", "/something_else").should be_nil 95 | end 96 | 97 | it "gets params" do 98 | router = LuckyRouter::Matcher(Symbol).new 99 | router.add("get", "/users/:user_id/tasks/:id", :show) 100 | 101 | router.match!("get", "/users/user_param/tasks/task_param").params.should eq({ 102 | "user_id" => "user_param", 103 | "id" => "task_param", 104 | }) 105 | end 106 | 107 | it "gets params when starting with dynamic paths" do 108 | router = LuckyRouter::Matcher(Symbol).new 109 | router.add("get", "/:organization/:repo", :unused) 110 | 111 | router.match!("get", "/luckyframework/lucky").params.should eq({ 112 | "organization" => "luckyframework", 113 | "repo" => "lucky", 114 | }) 115 | end 116 | 117 | it "does not add params if there is a static match" do 118 | router = LuckyRouter::Matcher(Symbol).new 119 | router.add("get", "/users/foo/tasks/bar", :show) 120 | router.add("get", "/users/:user_id/tasks/:id", :show) 121 | 122 | params = router.match!("get", "/users/foo/tasks/bar").params 123 | 124 | params.should eq({} of String => String) 125 | end 126 | 127 | it "handles conflicting routes by matching static routes first" do 128 | router = LuckyRouter::Matcher(Symbol).new 129 | 130 | router.add("get", "/users", :index) 131 | router.add("get", "/:categories", :category_index) 132 | 133 | router.match!("get", "/users").payload.should eq :index 134 | router.match!("get", "/something").payload.should eq :category_index 135 | end 136 | 137 | it "handles multiple path variables at the same path level" do 138 | router = LuckyRouter::Matcher(Symbol).new 139 | 140 | router.add("get", "/users/:user_id/inventory", :index) 141 | router.add("get", "/users/:id", :show) 142 | 143 | index_match = router.match!("get", "/users/123/inventory") 144 | index_match.payload.should eq :index 145 | index_match.params.should eq({"user_id" => "123"}) 146 | 147 | show_match = router.match!("get", "/users/123") 148 | show_match.payload.should eq :show 149 | show_match.params.should eq({"id" => "123"}) 150 | end 151 | 152 | it "requires globs to be on the end of the path" do 153 | router = LuckyRouter::Matcher(Symbol).new 154 | expect_raises LuckyRouter::InvalidPathError do 155 | router.add("get", "/posts/*/invalid_path", :invalid_path) 156 | end 157 | end 158 | 159 | it "allows route globbing" do 160 | router = LuckyRouter::Matcher(Symbol).new 161 | router.add("get", "/posts/something/*", :post_index) 162 | 163 | router.match!("get", "/posts/something").params.should eq({} of String => String) 164 | 165 | router.match!("get", "/posts/something/1").params.should eq({ 166 | "glob" => "1", 167 | }) 168 | 169 | router.match!("get", "/posts/something/1/something/longer").params.should eq({ 170 | "glob" => "1/something/longer", 171 | }) 172 | end 173 | 174 | it "allows route globbing and optional parts" do 175 | router = LuckyRouter::Matcher(Symbol).new 176 | router.add("get", "/posts/something/?:optional_1/?:optional_2/*:glob_param", :post_index) 177 | 178 | router.match!("get", "/posts/something/1").params.should eq({ 179 | "optional_1" => "1", 180 | }) 181 | router.match!("get", "/posts/something/1/2").params.should eq({ 182 | "optional_1" => "1", 183 | "optional_2" => "2", 184 | }) 185 | router.match!("get", "/posts/something/1/2/3").params.should eq({ 186 | "optional_1" => "1", 187 | "optional_2" => "2", 188 | "glob_param" => "3", 189 | }) 190 | router.match!("get", "/posts/something/1/2/3/4").params.should eq({ 191 | "optional_1" => "1", 192 | "optional_2" => "2", 193 | "glob_param" => "3/4", 194 | }) 195 | end 196 | 197 | it "matches both get and head with route globbing" do 198 | router = LuckyRouter::Matcher(Symbol).new 199 | router.add("get", "/posts/something/*", :post_index) 200 | 201 | router.match!("get", "/posts/something/1").params.should eq({ 202 | "glob" => "1", 203 | }) 204 | 205 | router.match!("head", "/posts/something/1").params.should eq({ 206 | "glob" => "1", 207 | }) 208 | end 209 | 210 | it "matches a route with more than 16 segments" do 211 | router = LuckyRouter::Matcher(Symbol).new 212 | router.add("get", "/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/:z", :match) 213 | 214 | match = router.match!("get", "/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z") 215 | match.payload.should eq(:match) 216 | match.params["z"].should eq("z") 217 | end 218 | 219 | describe "route with trailing slash" do 220 | context "is defined with a trailing slash" do 221 | it "should treat it as a index route when called without a trailing slash" do 222 | router = LuckyRouter::Matcher(Symbol).new 223 | router.add("get", "/users/", :index) 224 | router.match!("get", "/users").payload.should eq :index 225 | end 226 | 227 | it "should treat it as a index route when called with a trailing slash" do 228 | router = LuckyRouter::Matcher(Symbol).new 229 | router.add("get", "/users/", :index) 230 | router.match!("get", "/users/").payload.should eq :index 231 | end 232 | end 233 | 234 | context "is defined without a trailing slash" do 235 | it "should treat it as a index route when called without a trailing slash" do 236 | router = LuckyRouter::Matcher(Symbol).new 237 | router.add("get", "/users", :index) 238 | router.match!("get", "/users").payload.should eq :index 239 | end 240 | 241 | it "should treat it as a index route when called with a trailing slash" do 242 | router = LuckyRouter::Matcher(Symbol).new 243 | router.add("get", "/users", :index) 244 | router.match!("get", "/users/").payload.should eq :index 245 | end 246 | end 247 | end 248 | 249 | describe "duplicate route checking" do 250 | it "raises on normal duplicate" do 251 | router = LuckyRouter::Matcher(Symbol).new 252 | router.add("get", "/posts/something", :post_index) 253 | 254 | expect_raises LuckyRouter::DuplicateRouteError do 255 | router.add("get", "/posts/something", :other_post_index) 256 | end 257 | end 258 | 259 | it "does not raise on duplicate path with different methods" do 260 | router = LuckyRouter::Matcher(Symbol).new 261 | router.add("get", "/posts/something", :post_index) 262 | router.add("post", "/posts/something", :create_something) 263 | end 264 | 265 | it "raises even if path variable is different" do 266 | router = LuckyRouter::Matcher(Symbol).new 267 | router.add("get", "/posts/:id", :post_show) 268 | 269 | expect_raises LuckyRouter::DuplicateRouteError do 270 | router.add("get", "/posts/:post_id", :other_post_show) 271 | end 272 | end 273 | 274 | it "raises on optional paths when pre-existing path does not have optional part" do 275 | router = LuckyRouter::Matcher(Symbol).new 276 | router.add("get", "/posts", :post_index) 277 | 278 | expect_raises LuckyRouter::DuplicateRouteError do 279 | router.add("get", "/posts/?something", :post_something) 280 | end 281 | end 282 | 283 | it "raises on optional paths when pre-existing path has optional part" do 284 | router = LuckyRouter::Matcher(Symbol).new 285 | router.add("get", "/posts/something", :post_something) 286 | 287 | expect_raises LuckyRouter::DuplicateRouteError do 288 | router.add("get", "/posts/?something", :post_something_maybe) 289 | end 290 | end 291 | 292 | it "raises on glob routes if path without glob matches pre-existing" do 293 | router = LuckyRouter::Matcher(Symbol).new 294 | router.add("get", "/posts", :post_index) 295 | 296 | expect_raises LuckyRouter::DuplicateRouteError do 297 | router.add("get", "/posts/*", :post_glob) 298 | end 299 | end 300 | end 301 | 302 | it "URI decodes path parts" do 303 | router = LuckyRouter::Matcher(Symbol).new 304 | router.add("get", "/users/:email/tasks", :show) 305 | 306 | router.match!("get", "/users/foo%40example.com/tasks").params.should eq({ 307 | "email" => "foo@example.com", 308 | }) 309 | end 310 | end 311 | -------------------------------------------------------------------------------- /spec/path_normalizer_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe LuckyRouter::PathNormalizer do 4 | describe ".normalize" do 5 | it "turns regular path parts into slash delimitted string" do 6 | path_parts = LuckyRouter::PathPart.split_path("/api/v1/users") 7 | 8 | result = LuckyRouter::PathNormalizer.normalize(path_parts) 9 | 10 | result.should eq("/api/v1/users") 11 | end 12 | 13 | it "gives path variables a generic name" do 14 | path_parts = LuckyRouter::PathPart.split_path("/users/:id") 15 | 16 | result = LuckyRouter::PathNormalizer.normalize(path_parts) 17 | 18 | result.should eq("/users/:path_variable") 19 | end 20 | 21 | it "removes question mark from optional path" do 22 | path_parts = LuckyRouter::PathPart.split_path("/users/?name") 23 | 24 | result = LuckyRouter::PathNormalizer.normalize(path_parts) 25 | 26 | result.should eq("/users/name") 27 | end 28 | 29 | it "removes question mark and gives generic name to optional path variables" do 30 | path_parts = LuckyRouter::PathPart.split_path("/users/?:name") 31 | 32 | result = LuckyRouter::PathNormalizer.normalize(path_parts) 33 | 34 | result.should eq("/users/:path_variable") 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/path_part_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe LuckyRouter::PathPart do 4 | describe ".split_path" do 5 | it "returns an array of path parts" do 6 | path_parts = LuckyRouter::PathPart.split_path("/users/:id") 7 | 8 | path_parts.size.should eq 3 9 | path_parts[0].part.should eq "" 10 | path_parts[1].part.should eq "users" 11 | path_parts[2].part.should eq ":id" 12 | end 13 | 14 | it "ignores trailing slashes" do 15 | path_parts = LuckyRouter::PathPart.split_path("/users/") 16 | 17 | path_parts.size.should eq 2 18 | path_parts[0].part.should eq "" 19 | path_parts[1].part.should eq "users" 20 | end 21 | 22 | it "decodes path parts" do 23 | path_parts = LuckyRouter::PathPart.split_path("/users/foo%40example.com") 24 | 25 | path_parts.size.should eq 3 26 | path_parts[0].part.should eq "" 27 | path_parts[1].part.should eq "users" 28 | path_parts[2].part.should eq "foo@example.com" 29 | end 30 | end 31 | 32 | describe "#path_variable?" do 33 | it "is true if it starts with colon" do 34 | path_part = LuckyRouter::PathPart.new(":id") 35 | 36 | path_part.path_variable?.should be_truthy 37 | end 38 | 39 | it "is false if it does not start with a colon" do 40 | path_part = LuckyRouter::PathPart.new("users") 41 | 42 | path_part.path_variable?.should be_falsey 43 | end 44 | 45 | it "is true if it is an optional path variable" do 46 | path_part = LuckyRouter::PathPart.new("?:id") 47 | 48 | path_part.path_variable?.should be_truthy 49 | end 50 | 51 | it "is true if it is a glob path variable" do 52 | path_part = LuckyRouter::PathPart.new("*:id") 53 | 54 | path_part.path_variable?.should be_truthy 55 | end 56 | 57 | it "is true if it is just a glob so that it will be assigned correctly" do 58 | path_part = LuckyRouter::PathPart.new("*") 59 | 60 | path_part.path_variable?.should be_truthy 61 | end 62 | end 63 | 64 | describe "#optional?" do 65 | it "is true if it starts with a question mark" do 66 | path_part = LuckyRouter::PathPart.new("?users") 67 | 68 | path_part.optional?.should be_truthy 69 | end 70 | 71 | it "is false if it does not start with question mark" do 72 | path_part = LuckyRouter::PathPart.new("users") 73 | 74 | path_part.optional?.should be_falsey 75 | end 76 | end 77 | 78 | describe "#glob?" do 79 | it "is true if starts with asterisk" do 80 | path_part = LuckyRouter::PathPart.new("*") 81 | 82 | path_part.glob?.should be_truthy 83 | end 84 | 85 | it "is false if does not start with asterisk" do 86 | path_part = LuckyRouter::PathPart.new("users") 87 | 88 | path_part.glob?.should be_falsey 89 | end 90 | end 91 | 92 | describe "#name" do 93 | it "returns part if part is not path variable" do 94 | path_part = LuckyRouter::PathPart.new("users") 95 | 96 | path_part.name.should eq "users" 97 | end 98 | 99 | it "returns path variable name if path variable" do 100 | path_part = LuckyRouter::PathPart.new(":id") 101 | 102 | path_part.name.should eq "id" 103 | end 104 | 105 | it "handles optional path parts" do 106 | path_part = LuckyRouter::PathPart.new("?users") 107 | 108 | path_part.name.should eq "users" 109 | end 110 | 111 | it "handles optional path variables" do 112 | path_part = LuckyRouter::PathPart.new("?:id") 113 | 114 | path_part.name.should eq "id" 115 | end 116 | 117 | it "handles glob path variables" do 118 | path_part = LuckyRouter::PathPart.new("*:id") 119 | 120 | path_part.name.should eq "id" 121 | end 122 | 123 | it "is glob if glob without path variable name" do 124 | path_part = LuckyRouter::PathPart.new("*") 125 | 126 | path_part.name.should eq "glob" 127 | end 128 | end 129 | 130 | describe "equality" do 131 | it "is equal to another path part if their part is the same" do 132 | part_a = LuckyRouter::PathPart.new("users") 133 | part_b = LuckyRouter::PathPart.new("users") 134 | 135 | part_a.should eq part_b 136 | part_a.hash.should eq part_b.hash 137 | end 138 | 139 | it "is not equal to another path part if their part is different" do 140 | part_a = LuckyRouter::PathPart.new("users") 141 | part_b = LuckyRouter::PathPart.new(":users") 142 | 143 | part_a.should_not eq part_b 144 | part_a.hash.should_not eq part_b.hash 145 | end 146 | end 147 | 148 | describe "#validate!" do 149 | it "does nothing if path part is valid" do 150 | part = LuckyRouter::PathPart.new("users") 151 | 152 | part.validate! 153 | end 154 | 155 | it "raises error if glob named incorrectly" do 156 | part = LuckyRouter::PathPart.new("*users") 157 | 158 | expect_raises(LuckyRouter::InvalidGlobError) do 159 | part.validate! 160 | end 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/lucky_router" 3 | require "uuid" 4 | -------------------------------------------------------------------------------- /src/benchmark.cr: -------------------------------------------------------------------------------- 1 | require "./lucky_router" 2 | 3 | router = LuckyRouter::Matcher(Symbol).new 4 | 5 | router.add("get", "/users", :index) 6 | router.add("post", "/users", :create) 7 | router.add("get", "/users/:id", :show) 8 | router.add("delete", "/users/:id", :delete) 9 | router.add("put", "/users/:id", :update) 10 | router.add("get", "/users/:id/edit", :edit) 11 | router.add("get", "/users/:id/new", :new) 12 | 13 | elapsed_times = [] of Time::Span 14 | 10.times do 15 | elapsed = Time.measure do 16 | 100_000.times do 17 | router.match!("post", "/users") 18 | router.match!("get", "/users/1") 19 | router.match!("delete", "/users/1") 20 | router.match!("put", "/users/1") 21 | router.match!("get", "/users/1/edit") 22 | router.match!("get", "/users/1/new") 23 | router.match("get", "/no/match/found") 24 | end 25 | end 26 | elapsed_times << elapsed 27 | end 28 | 29 | sum = elapsed_times.sum 30 | average = sum / elapsed_times.size 31 | puts "Average time: " + elapsed_text(average) 32 | 33 | private def elapsed_text(elapsed) 34 | minutes = elapsed.total_minutes 35 | return "#{minutes.round(2)}m" if minutes >= 1 36 | 37 | seconds = elapsed.total_seconds 38 | return "#{seconds.round(2)}s" if seconds >= 1 39 | 40 | millis = elapsed.total_milliseconds 41 | return "#{millis.round(2)}ms" if millis >= 1 42 | 43 | "#{(millis * 1000).round(2)}µs" 44 | end 45 | -------------------------------------------------------------------------------- /src/lucky_router.cr: -------------------------------------------------------------------------------- 1 | require "./lucky_router/*" 2 | 3 | module LuckyRouter 4 | end 5 | -------------------------------------------------------------------------------- /src/lucky_router/errors.cr: -------------------------------------------------------------------------------- 1 | module LuckyRouter 2 | abstract class LuckyRouterError < Exception 3 | end 4 | 5 | class InvalidPathError < LuckyRouterError 6 | end 7 | 8 | class InvalidGlobError < LuckyRouterError 9 | def initialize(glob) 10 | super "Tried to define a glob as `#{glob}`, but it is invalid. Globs must be defined like `*` or given a name like `*:name`." 11 | end 12 | end 13 | 14 | class DuplicateRouteError < LuckyRouterError 15 | def initialize(method, new_path, duplicated_path) 16 | super <<-ERROR 17 | A route was attempted to be added that would overlap with an existing route. 18 | 19 | Route to be added: #{method.upcase} #{new_path} 20 | Existing route: #{method.upcase} #{duplicated_path} 21 | 22 | One of the routes should be updated to avoid the overlap. 23 | 24 | ERROR 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /src/lucky_router/fragment.cr: -------------------------------------------------------------------------------- 1 | # A fragment represents possible combinations for a part of the path. The first/top 2 | # fragment represents the first "part" of a path. 3 | # 4 | # The fragment contains the possible static parts or a single dynamic part 5 | # Each static part or dynamic part has another fragment, that represents the 6 | # next set of fragments that could match. This is a bit confusing so let's dive 7 | # into an example: 8 | # 9 | # * `/users/foo` 10 | # * `/users/:id` 11 | # * `/posts/foo` 12 | # 13 | # The Fragment would represent the possible combinations for the first part 14 | # 15 | # ``` 16 | # # 'nil' because there is no route with a dynamic part in the first slot 17 | # fragment.dynamic_part # nil 18 | # 19 | # # This returns a Hash whose keys are the possible values, and a value for the 20 | # *next* Fragment 21 | # fragment.static_parts 22 | # 23 | # # Would return: 24 | # {"users" => Fragment, "posts" => Fragment} 25 | # 26 | # # The Fragment in the 'users' key would have: 27 | # 28 | # # Fragment.new(PathPart(":id")) 29 | # fragment.dynamic_part 30 | # 31 | # # Static parts 32 | # fragment.static_parts 33 | # {"foo" => Fragment} 34 | # ``` 35 | # 36 | # ## Gotcha 37 | # 38 | # The last fragment of a path is "empty". It does not have static parts or 39 | # dynamic parts 40 | class LuckyRouter::Fragment(T) 41 | getter dynamic_parts = Array(Fragment(T)).new 42 | getter static_parts = Hash(String, Fragment(T)).new 43 | property glob_part : Fragment(T)? 44 | # Every path can have multiple request methods 45 | # and since each fragment represents a request path 46 | # the final step to finding the payload is to search for a matching request method 47 | getter method_to_payload = Hash(String, T).new 48 | getter path_part : PathPart 49 | 50 | def initialize(@path_part) 51 | end 52 | 53 | def collect_routes : Array(Tuple(Array(PathPart), String, T)) 54 | routes = [] of Tuple(Array(PathPart), String, T) 55 | method_to_payload.each do |method, payload| 56 | routes << {[path_part], method, payload} 57 | end 58 | 59 | routes += dynamic_parts.flat_map(&.collect_routes).map do |item| 60 | item[0].unshift(path_part) 61 | item 62 | end 63 | routes += static_parts.values.flat_map(&.collect_routes).map do |item| 64 | item[0].unshift(path_part) 65 | item 66 | end 67 | if gp = glob_part 68 | routes += gp.collect_routes.map do |item| 69 | item[0].unshift(path_part) 70 | item 71 | end 72 | end 73 | routes 74 | end 75 | 76 | # This looks for a matching fragment for the given parts 77 | # and returns NoMatch if one is not found 78 | def find(parts : Array(String), method : String) : Match(T) | NoMatch 79 | find_match(parts, method) || NoMatch.new 80 | end 81 | 82 | # :ditto: 83 | def find(parts : Slice(String), method : String) : Match(T) | NoMatch 84 | find_match(parts, method) || NoMatch.new 85 | end 86 | 87 | def process_parts(parts : Array(PathPart), method : String, payload : T) 88 | leaf_fragment = parts.reduce(self) { |fragment, part| fragment.add_part(part) } 89 | leaf_fragment.method_to_payload[method] = payload 90 | end 91 | 92 | def add_part(path_part : PathPart) : Fragment(T) 93 | if path_part.glob? 94 | self.glob_part ||= Fragment(T).new(path_part: path_part) 95 | elsif path_part.path_variable? 96 | existing = self.dynamic_parts.find { |fragment| fragment.path_part == path_part } 97 | return existing if existing 98 | 99 | fragment = Fragment(T).new(path_part: path_part) 100 | self.dynamic_parts << fragment 101 | fragment 102 | else 103 | static_parts[path_part.part] ||= Fragment(T).new(path_part: path_part) 104 | end 105 | end 106 | 107 | def dynamic? : Bool 108 | path_part.path_variable? 109 | end 110 | 111 | def find_match(path_parts : Array(String), method : String) : Match(T)? 112 | find_match(path_parts, 0, method) 113 | end 114 | 115 | def find_match(path_parts : Slice(String), method : String) : Match(T)? 116 | find_match(path_parts, 0, method) 117 | end 118 | 119 | def match_for_method(method) 120 | payload = method_to_payload[method]? 121 | payload ? Match(T).new(payload, Hash(String, String).new) : nil 122 | end 123 | 124 | protected def find_match(path_parts, index, method : String) : Match(T)? 125 | return match_for_method(method) if index >= path_parts.size 126 | 127 | path_part = path_parts[index] 128 | index += 1 129 | 130 | find_match_with_static_parts(path_part, path_parts, index, method) || 131 | find_match_with_dynamics(path_part, path_parts, index, method) || 132 | find_match_with_glob(path_part, path_parts, index, method) 133 | end 134 | 135 | private def find_match_with_static_parts(path_part, path_parts, index, method) 136 | static_part = static_parts[path_part]? 137 | return unless static_part 138 | 139 | static_part.find_match(path_parts, index, method) 140 | end 141 | 142 | private def find_match_with_dynamics(path_part, path_parts, index, method) 143 | dynamic_parts.each do |dynamic_part| 144 | if match = dynamic_part.find_match(path_parts, index, method) 145 | match.params[dynamic_part.path_part.name] = path_part 146 | return match 147 | end 148 | end 149 | end 150 | 151 | private def find_match_with_glob(path_part, path_parts, index, method) 152 | glob = glob_part 153 | return unless glob 154 | 155 | if match = glob.match_for_method(method) 156 | match.params[glob.path_part.name] = String.build do |io| 157 | io << path_part 158 | index.upto(path_parts.size - 1) do |sub_index| 159 | io << '/' 160 | io << path_parts[sub_index] 161 | end 162 | end 163 | match 164 | end 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /src/lucky_router/match.cr: -------------------------------------------------------------------------------- 1 | struct LuckyRouter::Match(T) 2 | getter payload : T 3 | getter params : Hash(String, String) 4 | 5 | def initialize(@payload, @params) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /src/lucky_router/matcher.cr: -------------------------------------------------------------------------------- 1 | # Add routes and match routes 2 | # 3 | # 'T' is the type of the 'payload'. The 'payload' is what will be returned 4 | # if the route matches. 5 | # 6 | # ## Example 7 | # 8 | # ``` 9 | # # 'T' will be 'Symbol' 10 | # router = LuckyRouter::Matcher(Symbol).new 11 | # 12 | # # Tell the router what payload to return if matched 13 | # router.add("get", "/users", :index) 14 | # 15 | # # This will return :index 16 | # router.match("get", "/users").payload # :index 17 | # ``` 18 | class LuckyRouter::Matcher(T) 19 | # starting point from which all fragments are located 20 | getter root = Fragment(T).new(path_part: PathPart.new("")) 21 | getter normalized_paths = Hash(String, String).new 22 | 23 | def add(method : String, path : String, payload : T) 24 | all_path_parts = PathPart.split_path(path) 25 | validate!(path, all_path_parts) 26 | optional_parts = all_path_parts.select(&.optional?) 27 | glob_part = nil 28 | if last_part = all_path_parts.last? 29 | glob_part = all_path_parts.pop if last_part.glob? 30 | end 31 | 32 | path_without_optional_params = all_path_parts.reject(&.optional?) 33 | 34 | process_and_add_path(method, path_without_optional_params, payload, path) 35 | optional_parts.each do |optional_part| 36 | path_without_optional_params << optional_part 37 | process_and_add_path(method, path_without_optional_params, payload, path) 38 | end 39 | if glob_part 40 | path_without_optional_params << glob_part 41 | process_and_add_path(method, path_without_optional_params, payload, path) 42 | end 43 | end 44 | 45 | # Array of the path, method, and payload 46 | def list_routes : Array(Tuple(String, String, T)) 47 | root.collect_routes.map do |(path_parts, method, payload)| 48 | path = "/" + path_parts.reject(&.part.presence.nil?).map(&.part).join("/") 49 | Tuple.new(path, method, payload) 50 | end 51 | end 52 | 53 | private def process_and_add_path(method : String, parts : Array(PathPart), payload : T, path : String) 54 | if method.downcase == "get" 55 | root.process_parts(parts, "head", payload) 56 | end 57 | 58 | duplicate_check(method, parts, path) 59 | 60 | root.process_parts(parts, method, payload) 61 | end 62 | 63 | private def duplicate_check(method : String, parts : Array(PathPart), path : String) 64 | normalized_path = method.downcase + PathNormalizer.normalize(parts) 65 | if duplicated_path = normalized_paths[normalized_path]? 66 | raise DuplicateRouteError.new( 67 | method, 68 | new_path: path, 69 | duplicated_path: duplicated_path 70 | ) 71 | end 72 | normalized_paths[normalized_path] = path 73 | end 74 | 75 | def match(method : String, path_to_match : String) : Match(T)? 76 | # To avoid allocating an array for the segment parts, we use a static 77 | # array with up to 16 segments. 78 | parts_static_array = StaticArray(String, 16).new("") 79 | 80 | # In the general case we still have to support more than 16 segments. 81 | # We'll fallback to using an Array for that case. 82 | parts_array = nil 83 | 84 | index = 0 85 | LuckerRouter::PathReader.new(path_to_match).each do |part| 86 | if index == parts_static_array.size 87 | # We don't have any more space in the static array: 88 | # more contents to the array. 89 | parts_array = Array(String).new(32) 90 | parts_array.concat(parts_static_array) 91 | parts_array << part 92 | elsif parts_array 93 | # We are using the fallback array, so push parts there. 94 | parts_array << part 95 | else 96 | # We are still using the static array 97 | parts_static_array[index] = part 98 | end 99 | index += 1 100 | end 101 | 102 | match = 103 | if parts_array 104 | root.find(parts_array, method) 105 | else 106 | root.find(parts_static_array.to_slice[0...index], method) 107 | end 108 | 109 | if match.is_a?(Match) 110 | match 111 | end 112 | end 113 | 114 | def match!(method : String, path_to_match : String) : Match(T) 115 | match(method, path_to_match) || raise "No matching route found for: #{path_to_match}" 116 | end 117 | 118 | private def validate!(path : String, parts : Array(PathPart)) 119 | last_index = parts.size - 1 120 | parts.each_with_index do |part, idx| 121 | if part.glob? && idx != last_index 122 | raise InvalidPathError.new("`#{path}` must only contain a glob at the end") 123 | end 124 | part.validate! 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /src/lucky_router/no_match.cr: -------------------------------------------------------------------------------- 1 | struct LuckyRouter::NoMatch 2 | end 3 | -------------------------------------------------------------------------------- /src/lucky_router/path_normalizer.cr: -------------------------------------------------------------------------------- 1 | class LuckyRouter::PathNormalizer 2 | DEFAULT_PATH_VARIABLE_NAME = ":path_variable" 3 | 4 | def self.normalize(path_parts : Array(PathPart)) : String 5 | path_parts.map { |path_part| normalize(path_part) }.join('/') 6 | end 7 | 8 | private def self.normalize(path_part : PathPart) : String 9 | path_part.path_variable? ? DEFAULT_PATH_VARIABLE_NAME : path_part.name 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /src/lucky_router/path_part.cr: -------------------------------------------------------------------------------- 1 | # A PathPart represents a single section of a path 2 | # 3 | # It can be a static path 4 | # 5 | # ``` 6 | # path_part = PathPart.new("users") 7 | # path_part.path_variable? => false 8 | # path_part.optional? => false 9 | # path_part.name => "users" 10 | # ``` 11 | # 12 | # It can be a path variable 13 | # 14 | # ``` 15 | # path_part = PathPart.new(":id") 16 | # path_part.path_variable? => true 17 | # path_part.optional? => false 18 | # path_part.name => "id" 19 | # ``` 20 | # 21 | # It can be optional 22 | # 23 | # ``` 24 | # path_part = PathPart.new("?users") 25 | # path_part.path_variable? => false 26 | # path_part.optional? => true 27 | # path_part.name => "users" 28 | # ``` 29 | struct LuckyRouter::PathPart 30 | def self.split_path(path : String) : Array(PathPart) 31 | parts = LuckerRouter::PathReader.new(path) 32 | parts.map { |part| new(part) }.to_a 33 | end 34 | 35 | getter part : String 36 | 37 | def initialize(@part) 38 | end 39 | 40 | def name : String 41 | name = part.lchop('?').lchop('*').lchop(':') 42 | unnamed_glob?(name) ? "glob" : name 43 | end 44 | 45 | def optional? : Bool 46 | part.starts_with?('?') 47 | end 48 | 49 | def path_variable? : Bool 50 | part.starts_with?(':') || part.starts_with?("?:") || glob? 51 | end 52 | 53 | def glob? : Bool 54 | part.starts_with?('*') 55 | end 56 | 57 | def validate! 58 | raise InvalidGlobError.new(part) if invalid_glob? 59 | end 60 | 61 | private def unnamed_glob?(name) 62 | name.blank? && glob? 63 | end 64 | 65 | private def invalid_glob? 66 | return false unless glob? 67 | 68 | part.size != 1 && part != "*:#{name}" 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /src/lucky_router/path_reader.cr: -------------------------------------------------------------------------------- 1 | require "char/reader" 2 | require "uri" 3 | 4 | # A PathReader parses a URI path into segments. 5 | # 6 | # It can be used to read a String representing a full path into the individual 7 | # segments it contains. 8 | # 9 | # ``` 10 | # path = "/foo/bar/baz" 11 | # PathReader.new(path).to_a => ["", "foo", "bar", "baz"] 12 | # ``` 13 | # 14 | # Percent-encoded characters are automatically decoded following segmentation 15 | # 16 | # ``` 17 | # path = "/user/foo%40example.com/details" 18 | # PathReader.new(path).to_a => ["", "user", "foo@example.com", "details"] 19 | # ``` 20 | struct LuckerRouter::PathReader 21 | include Enumerable(String) 22 | 23 | def initialize(@path : String) 24 | end 25 | 26 | def each(&) 27 | each_segment do |offset, length, decode| 28 | segment = String.new(@path.to_unsafe + offset, length) 29 | if decode 30 | yield URI.decode(segment) 31 | else 32 | yield segment 33 | end 34 | end 35 | end 36 | 37 | private def each_segment(&) 38 | index = 0 39 | offset = 0 40 | decode = false 41 | slice = @path.to_slice 42 | 43 | while index < slice.size 44 | byte = slice[index] 45 | case byte 46 | when '/' 47 | length = index - offset 48 | yield offset, length, decode 49 | decode = false 50 | index += 1 51 | offset = index 52 | when '%' 53 | decode = true 54 | index += 3 55 | else 56 | index += 1 57 | end 58 | end 59 | 60 | length = @path.bytesize - offset 61 | return if length.zero? 62 | yield offset, length, decode 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /src/lucky_router/version.cr: -------------------------------------------------------------------------------- 1 | module LuckyRouter 2 | # The current LuckyRouter version is defined in `shard.yml` 3 | VERSION = {{ `shards version "#{__DIR__}"`.chomp.stringify }} 4 | end 5 | --------------------------------------------------------------------------------