├── .travis.yml ├── Gemfile ├── Gemfile.lock ├── README.md ├── Rakefile ├── benchmark.rb ├── config.ru ├── lib ├── pendragon.rb └── pendragon │ ├── constants.rb │ ├── errors.rb │ ├── linear.rb │ ├── realism.rb │ ├── router.rb │ └── version.rb ├── pendragon.gemspec └── test ├── helper.rb ├── router ├── test_linear.rb └── test_realism.rb ├── supports └── shared_examples_for_routing.rb └── test_router.rb /.travis.yml: -------------------------------------------------------------------------------- 1 | lang: ruby 2 | before_install: gem install bundler --pre 3 | install: 4 | - gem update --system 5 | - bundle update 6 | rvm: 7 | - 2.1.10 8 | - 2.2.6 9 | - 2.3.3 10 | - 2.4.0-preview3 11 | - jruby 12 | - rbx 13 | notifications: 14 | recipients: 15 | - namusyaka@gmail.com 16 | branches: 17 | only: 18 | - master 19 | matrix: 20 | allow_failures: 21 | - rvm: rbx 22 | - rvm: jruby 23 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rake' 4 | gem 'rack-test' 5 | gem 'test-unit' 6 | gem 'mocha' 7 | gem 'sinatra' 8 | gem 'yard' 9 | gem 'modulla' 10 | gemspec 11 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | pendragon (0.6.2) 5 | mustermann 6 | rack (>= 1.3.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | metaclass (0.0.4) 12 | mocha (1.1.0) 13 | metaclass (~> 0.0.1) 14 | modulla (0.1.1) 15 | mustermann (0.4.0) 16 | tool (~> 0.2) 17 | power_assert (0.3.0) 18 | rack (1.5.5) 19 | rack-protection (1.5.3) 20 | rack 21 | rack-test (0.6.3) 22 | rack (>= 1.0) 23 | rake (11.3.0) 24 | sinatra (1.4.7) 25 | rack (~> 1.5) 26 | rack-protection (~> 1.4) 27 | tilt (>= 1.3, < 3) 28 | test-unit (3.2.1) 29 | power_assert 30 | tilt (2.0.5) 31 | tool (0.2.3) 32 | yard (0.9.5) 33 | 34 | PLATFORMS 35 | ruby 36 | 37 | DEPENDENCIES 38 | mocha 39 | modulla 40 | pendragon! 41 | rack-test 42 | rake 43 | sinatra 44 | test-unit 45 | yard 46 | 47 | BUNDLED WITH 48 | 1.12.5 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pendragon 2 | 3 | [![Build Status](https://travis-ci.org/namusyaka/pendragon.svg?branch=master)](https://travis-ci.org/namusyaka/pendragon) [![Gem Version](https://badge.fury.io/rb/pendragon.svg)](http://badge.fury.io/rb/pendragon) 4 | 5 | Pendragon provides an HTTP router and its toolkit for use in Rack. As a Rack application, it makes it easy to define complicated routing. 6 | Algorithms of the router are used in [Padrino](https://github.com/padrino/padrino-framework) and [Grape](https://github.com/ruby-grape/grape), it's fast, flexible and robust. 7 | 8 | *If you want to use in Ruby-1.9, you can do it by using [mustermann19](https://github.com/namusyaka/mustermann19).* 9 | 10 | 11 | ```ruby 12 | Pendragon.new do 13 | get('/') { [200, {}, ['hello world']] } 14 | namespace :users do 15 | get('/', to: -> { [200, {}, ['User page index']] }) 16 | get('/:id', to: -> (id) { [200, {}, [id]] }) 17 | get('/:id/comments') { |id| [200, {}, [User.find_by(id: id).comments.to_json]] } 18 | end 19 | end 20 | ``` 21 | 22 | ## Router Patterns 23 | 24 | |Type |Description |Note | 25 | |---|---|---| 26 | |[liner](https://github.com/namusyaka/pendragon/blob/master/lib/pendragon/liner.rb) |Linear search, Optimized Mustermann patterns | | 27 | |[realism](https://github.com/namusyaka/pendragon/blob/master/lib/pendragon/realism.rb) |First route is detected by union regexps (Actually, O(1) in ruby level), routes since the first time will be retected by linear search | this algorithm is using in Grape | 28 | |[radix](https://github.com/namusyaka/pendragon-radix) |Radix Tree, not using Mustermann and regexp| requires C++11 | 29 | 30 | ## Installation 31 | 32 | Add this line to your application's Gemfile: 33 | 34 | ```ruby 35 | gem 'pendragon' 36 | ``` 37 | 38 | And then execute: 39 | 40 | $ bundle 41 | 42 | Or install it yourself as: 43 | 44 | $ gem install pendragon 45 | 46 | 47 | ## Usage 48 | 49 | ### Selects router pattern 50 | 51 | You can select router pattern as following code. 52 | 53 | ```ruby 54 | # Gets Linear router class by passing type in `Pendragon.[]` 55 | Pendragon[:linear] #=> Pendragon::Linear 56 | 57 | # Specify :type to construction of Pendragon. 58 | Pendragon.new(type: :linear) { ... } 59 | ``` 60 | 61 | ### Registers a route 62 | 63 | It has some methods to register a route. For example, `#get`, `#post` and `#delete` are so. 64 | This section introduces all those methods. 65 | 66 | #### `route(method, path, **options, &block)` 67 | 68 | 69 | The method is the basis of the registration method of all. 70 | In comparison with other registration methods, one argument is increased. 71 | 72 | ```ruby 73 | Pendragon.new do 74 | route('GET', ?/){ [200, {}, ['hello']] } 75 | end 76 | ``` 77 | 78 | #### `get(path, **options, &block)`, `post`, `delete`, `put` and `head` 79 | 80 | Basically the usage is the same with `#route`. 81 | You may as well use those methods instead of `#route` because those methods are easy to understand. 82 | 83 | ```ruby 84 | Pendragon.new do 85 | get (?/) { [200, {}, ['hello']] } 86 | post (?/) { [200, {}, ['hello']] } 87 | delete(?/) { [200, {}, ['hello']] } 88 | put (?/) { [200, {}, ['hello']] } 89 | head (?/) { [200, {}, ['hello']] } 90 | end 91 | ``` 92 | 93 | ### Mounts Rack Application 94 | 95 | You can easily mount your rack application onto Pendragon. 96 | 97 | *Please note that pendragon distinguishes between processing Proc and Rack Application.* 98 | 99 | ```ruby 100 | class RackApp 101 | def call(env) 102 | puts env #=> rack default env 103 | [200, {}, ['hello']] 104 | end 105 | end 106 | 107 | Pendragon.new do 108 | get '/ids/:id', to: -> (id) { p id } # Block parameters are available 109 | get '/rack/:id', to: RackApp.new # RackApp#call will be called, `id` is not passed and `env` is passed instead. 110 | end 111 | ``` 112 | 113 | ### Halt 114 | 115 | You can halt to processing by calling `throw :halt` inside your route. 116 | 117 | ```ruby 118 | Pendragon.new do 119 | get ?/ do 120 | throw :halt, [404, {}, ['not found']] 121 | [200, {}, ['failed to halt']] 122 | end 123 | end 124 | ``` 125 | 126 | ### Cascading 127 | 128 | A route can punt to the next matching route by using `X-Cascade` header. 129 | 130 | ```ruby 131 | pendragon = Pendragon.new do 132 | foo = 1 133 | get ?/ do 134 | [200, { 'X-Cascade' => 'pass' }, ['']] 135 | end 136 | 137 | get ?/ do 138 | [200, {}, ['goal!']] 139 | end 140 | end 141 | 142 | env = Rack::MockRequest.env_for(?/) 143 | pendragon.call(env) #=> [200, {}, ['goal!']] 144 | ``` 145 | 146 | ## Contributing 147 | 148 | 1. fork the project. 149 | 2. create your feature branch. (`git checkout -b my-feature`) 150 | 3. commit your changes. (`git commit -am 'commit message'`) 151 | 4. push to the branch. (`git push origin my-feature`) 152 | 5. send pull request. 153 | 154 | ## License 155 | 156 | the MIT License 157 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'rake/testtask' 3 | require 'pendragon' 4 | 5 | Rake::TestTask.new(:test) do |test| 6 | test.libs << 'test' 7 | test.test_files = Dir['test/**/test_*.rb'] 8 | end 9 | 10 | task default: :test 11 | -------------------------------------------------------------------------------- /benchmark.rb: -------------------------------------------------------------------------------- 1 | require 'benchmark' 2 | require 'pendragon' 3 | require 'rack' 4 | 5 | routers = %i[liner realism radix].map do |type| 6 | Pendragon[type].new do 7 | 1000.times { |n| get "/#{n}", to: ->(env) { [200, {}, [n.to_s]] } } 8 | namespace :foo do 9 | get '/:user_id' do 10 | [200, {}, ['yahoo']] 11 | end 12 | end 13 | end 14 | end 15 | 16 | env = Rack::MockRequest.env_for("/999") 17 | 18 | routers.each do |router| 19 | p "router_class: #{router.class}" 20 | p router.call(env) 21 | end 22 | 23 | Benchmark.bm do |x| 24 | routers.each do |router| 25 | x.report do 26 | 10000.times do |n| 27 | router.call(env) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | load File.expand_path('../lib/pendragon.rb', __FILE__) 2 | 3 | pendragon = Pendragon.new 4 | 5 | pendragon.add(:get, "/") do 6 | "hello world !" 7 | end 8 | 9 | run pendragon 10 | -------------------------------------------------------------------------------- /lib/pendragon.rb: -------------------------------------------------------------------------------- 1 | require 'pendragon/router' 2 | require 'thread' 3 | 4 | module Pendragon 5 | # Type to use if no type is given. 6 | # @api private 7 | DEFAULT_TYPE = :realism 8 | 9 | # Creates a new router. 10 | # 11 | # @example creating new routes. 12 | # require 'pendragon' 13 | # 14 | # Pendragon.new do 15 | # get('/') { [200, {}, ['hello world']] } 16 | # namespace :users do 17 | # get('/', to: ->(env) { [200, {}, ['User page index']] }) 18 | # get('/:id', to: UserApplication.new) 19 | # end 20 | # end 21 | # 22 | # @yield block for definig routes, it will be evaluated in instance context. 23 | # @yieldreturn [Pendragon::Router] 24 | def self.new(type: DEFAULT_TYPE, &block) 25 | type ||= DEFAULT_TYPE 26 | self[type].new(&block) 27 | end 28 | 29 | @mutex ||= Mutex.new 30 | @types ||= {} 31 | 32 | # Returns router by given name. 33 | # 34 | # @example 35 | # Pendragon[:realism] #=> Pendragon::Realism 36 | # 37 | # @param [Symbol] name a router type identifier 38 | # @raise [ArgumentError] if the name is not supported 39 | # @return [Class, #new] 40 | def self.[](name) 41 | @types.fetch(normalized = normalize_type(name)) do 42 | @mutex.synchronize do 43 | error = try_require "pendragon/#{normalized}" 44 | @types.fetch(normalized) do 45 | fail ArgumentError, 46 | "unsupported type %p #{ " (#{error.message})" if error }" % name 47 | end 48 | end 49 | end 50 | end 51 | 52 | # @return [LoadError, nil] 53 | # @!visibility private 54 | def self.try_require(path) 55 | require(path) 56 | nil 57 | rescue LoadError => error 58 | raise(error) unless error.path == path 59 | error 60 | end 61 | 62 | # @!visibility private 63 | def self.register(name, type) 64 | @types[normalize_type(name)] = type 65 | end 66 | 67 | # @!visibility private 68 | def self.normalize_type(type) 69 | type.to_s.gsub('-', '_').downcase 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/pendragon/constants.rb: -------------------------------------------------------------------------------- 1 | module Pendragon 2 | # A module for unifying magic numbers 3 | # @!visibility private 4 | module Constants 5 | module Http 6 | GET = 'GET'.freeze 7 | POST = 'POST'.freeze 8 | PUT = 'PUT'.freeze 9 | DELETE = 'DELETE'.freeze 10 | HEAD = 'HEAD'.freeze 11 | OPTIONS = 'OPTIONS'.freeze 12 | 13 | NOT_FOUND = 404.freeze 14 | METHOD_NOT_ALLOWED = 405.freeze 15 | INTERNAL_SERVER_ERROR = 500.freeze 16 | end 17 | 18 | module Header 19 | CASCADE = 'X-Cascade'.freeze 20 | end 21 | 22 | module Env 23 | PATH_INFO = 'PATH_INFO'.freeze 24 | REQUEST_METHOD = 'REQUEST_METHOD'.freeze 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/pendragon/errors.rb: -------------------------------------------------------------------------------- 1 | require 'rack/utils' 2 | 3 | module Pendragon 4 | # Module for creating any error classes. 5 | module Errors 6 | # Class for handling HTTP error. 7 | class Base < StandardError 8 | attr_accessor :status, :headers, :message 9 | 10 | # Creates a new error class. 11 | # 12 | # @example 13 | # require 'pendragon/errors' 14 | # 15 | # BadRequest = Pendragon::Errors::Base.create(status: 400) 16 | # 17 | # @option [Integer] status 18 | # @option [Hash{String => String}] headers 19 | # @option [String] message 20 | # @return [Class] 21 | def self.create(**options, &block) 22 | Class.new(self) do 23 | options.each { |k, v| define_singleton_method(k) { v } } 24 | class_eval(&block) if block_given? 25 | end 26 | end 27 | 28 | # Returns default message. 29 | # 30 | # @see [Rack::Utils::HTTP_STATUS_CODES] 31 | # @return [String] default message for current status. 32 | def self.default_message 33 | @default_message ||= Rack::Utils::HTTP_STATUS_CODES.fetch(status, 'server error').downcase 34 | end 35 | 36 | # Returns default headers. 37 | # 38 | # @return [Hash{String => String}] HTTP headers 39 | def self.default_headers 40 | @default_headers ||= { 'Content-Type' => 'text/plain' } 41 | end 42 | 43 | # Constructs an instance of Errors::Base 44 | # 45 | # @option [Hash{String => String}] headers 46 | # @option [Integer] status 47 | # @option [String] message 48 | # @options payload 49 | # @return [Pendragon::Errors::Base] 50 | def initialize(headers: {}, status: self.class.status, message: self.class.default_message, **payload) 51 | self.headers = self.class.default_headers.merge(headers) 52 | self.status, self.message = status, message 53 | parse_payload(**payload) if payload.kind_of?(Hash) && respond_to?(:parse_payload) 54 | super(message) 55 | end 56 | 57 | # Converts self into response conformed Rack style. 58 | # 59 | # @return [Array String}, #each>] response 60 | def to_response 61 | [status, headers, [message]] 62 | end 63 | end 64 | 65 | NotFound = Base.create(status: 404) 66 | MethodNotAllowed = Base.create(status: 405) do 67 | define_method(:parse_payload) do |allows: [], **payload| 68 | self.headers['Allows'] = allows.join(?,) unless allows.empty? 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/pendragon/linear.rb: -------------------------------------------------------------------------------- 1 | require 'pendragon/router' 2 | 3 | module Pendragon 4 | class Linear < Router 5 | register :linear 6 | 7 | on(:call) { |env| rotation(env) { |route| route.exec(env) } } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/pendragon/realism.rb: -------------------------------------------------------------------------------- 1 | require 'pendragon/router' 2 | 3 | module Pendragon 4 | class Realism < Router 5 | register :realism 6 | 7 | on :call do |env| 8 | identity(env) || rotation(env) { |route| route.exec(env) } 9 | end 10 | 11 | on :compile do |method, routes| 12 | patterns = routes.map.with_index do |route, index| 13 | route.index = index 14 | route.regexp = /(?<_#{index}>#{route.pattern.to_regexp})/ 15 | end 16 | omap[method] = Regexp.union(patterns) 17 | end 18 | 19 | private 20 | 21 | # @!visibility private 22 | def omap 23 | @omap ||= Hash.new { |hash, key| hash[key] = // } 24 | end 25 | 26 | # @!visibility private 27 | def match?(input, method) 28 | current_regexp = omap[method] 29 | return unless current_regexp.match(input) 30 | last_match = Regexp.last_match 31 | map[method].detect { |route| last_match["_#{route.index}"] } 32 | end 33 | 34 | # @!visibility private 35 | def identity(env, route = nil) 36 | with_transaction(env) do |input, method| 37 | route = match?(input, method) 38 | route.exec(env) if route 39 | end 40 | end 41 | 42 | # @!visibility private 43 | def with_transaction(env) 44 | input, method = extract(env) 45 | response = yield(input, method) 46 | response && !(cascade = cascade?(response)) ? response : nil 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/pendragon/router.rb: -------------------------------------------------------------------------------- 1 | require 'pendragon/constants' 2 | require 'pendragon/errors' 3 | require 'mustermann' 4 | require 'forwardable' 5 | require 'ostruct' 6 | 7 | module Pendragon 8 | class Router 9 | # @!visibility private 10 | attr_accessor :prefix 11 | 12 | # Registers new router type onto global maps. 13 | # 14 | # @example registring new router type. 15 | # require 'pendragon' 16 | # 17 | # class Pendragon::SuperFast < Pendragon::Router 18 | # register :super_fast 19 | # end 20 | # 21 | # Pendragon[:super_fast] #=> Pendragon::SuperFast 22 | # 23 | # @param [Symbol] name a router type identifier 24 | # @see Pendragon.register 25 | def self.register(name) 26 | Pendragon.register(name, self) 27 | end 28 | 29 | # Adds event listener in router class. 30 | # 31 | # @example 32 | # require 'pendragon' 33 | # 34 | # class Pendragon::SuperFast < Pendragon::Router 35 | # register :super_fast 36 | # 37 | # on :call do |env| 38 | # rotation(env) { |route| route.exec(env) } 39 | # end 40 | # 41 | # on :compile do |method, routes| 42 | # routes.each do |route| 43 | # route.pattern = route.pattern.to_regexp 44 | # end 45 | # end 46 | # end 47 | # 48 | # @param [Symbol] event a event name which is :call or :compile 49 | # 50 | # @yieldparam [optional, Hash] env a request environment variables on :call event. 51 | # @yieldreturn [optional, Array, Rack::Response] response 52 | # 53 | # @yieldparam [String] method 54 | # @yieldparam [Array] routes 55 | def self.on(event, &listener) 56 | define_method('on_%s_listener' % event, &listener) 57 | end 58 | 59 | # Construcsts an instance of router class. 60 | # 61 | # @example construction for router class 62 | # require 'pendragon' 63 | # 64 | # Pendragon.new do 65 | # get '/', to: -> { [200, {}, ['hello']] } 66 | # end 67 | # 68 | # @yield block a block is evaluated in instance context. 69 | # @return [Pendragon::Router] 70 | def initialize(&block) 71 | @compiled = false 72 | instance_eval(&block) if block_given? 73 | end 74 | 75 | # Prefixes a namespace to route path inside given block. 76 | # 77 | # @example 78 | # require 'pendragon' 79 | # 80 | # Pendragon.new do 81 | # namespace :foo do 82 | # # This definition is dispatched to '/foo/bar'. 83 | # get '/bar', to: -> { [200, {}, ['hello']] } 84 | # end 85 | # end 86 | # 87 | # @yield block a block is evaluated in instance context. 88 | def namespace(name, &block) 89 | fail ArgumentError unless block_given? 90 | (self.prefix ||= []) << name.to_s 91 | instance_eval(&block) 92 | ensure 93 | prefix.pop 94 | end 95 | 96 | # Calls by given env, returns a response conformed Rack style. 97 | # 98 | # @example 99 | # require 'pendragon' 100 | # 101 | # router = Pendragon.new do 102 | # get '/', to: -> { [200, {}, ['hello']] } 103 | # end 104 | # 105 | # env = Rack::MockRequest.env_for('/') 106 | # router.call(env) #=> [200, {}, ['hello']] 107 | # 108 | # @return [Array, Rack::Response] response conformed Rack style 109 | def call(env) 110 | catch(:halt) { with_optimization { invoke(env) } } 111 | end 112 | 113 | # Class for delegation based structure. 114 | # @!visibility private 115 | class Route < OpenStruct 116 | # @!visibility private 117 | attr_accessor :pattern 118 | 119 | # @!visibility private 120 | attr_reader :request_method, :path 121 | 122 | extend Forwardable 123 | def_delegators :@pattern, :match, :params 124 | 125 | # @!visibility private 126 | def initialize(method:, pattern:, application:, **attributes) 127 | super(attributes) 128 | 129 | @app = application 130 | @path = pattern 131 | @pattern = Mustermann.new(pattern) 132 | @executable = to_executable 133 | @request_method = method.to_s.upcase 134 | end 135 | 136 | # @!visibility private 137 | def exec(env) 138 | return @app.call(env) unless executable? 139 | path_info = env[Constants::Env::PATH_INFO] 140 | params = pattern.params(path_info) 141 | captures = pattern.match(path_info).captures 142 | Context.new(env, params: params, captures: captures).trigger(@executable) 143 | end 144 | 145 | private 146 | 147 | # @!visibility private 148 | def executable? 149 | @app.kind_of?(Proc) 150 | end 151 | 152 | # @!visibility private 153 | def to_executable 154 | return @app unless executable? 155 | Context.to_method(request_method, path, @app) 156 | end 157 | 158 | # Class for providing helpers like :env, :params and :captures. 159 | # This class will be available if given application is an kind of Proc. 160 | # @!visibility private 161 | class Context 162 | # @!visibility private 163 | attr_reader :env, :params, :captures 164 | 165 | # @!visibility private 166 | def self.generate_method(name, callable) 167 | define_method(name, &callable) 168 | method = instance_method(name) 169 | remove_method(name) 170 | method 171 | end 172 | 173 | # @!visibility private 174 | def self.to_method(*args, callable) 175 | unbound = generate_method(args.join(' '), callable) 176 | if unbound.arity.zero? 177 | proc { |app, captures| unbound.bind(app).call } 178 | else 179 | proc { |app, captures| unbound.bind(app).call(*captures) } 180 | end 181 | end 182 | 183 | # @!visibility private 184 | def initialize(env, params: {}, captures: []) 185 | @env = env 186 | @params = params 187 | @captures = captures 188 | end 189 | 190 | # @!visibility private 191 | def trigger(executable) 192 | executable[self, captures] 193 | end 194 | end 195 | end 196 | 197 | # Appends a route of GET method 198 | # @see [Pendragon::Router#route] 199 | def get(path, to: nil, **options, &block) 200 | route Constants::Http::GET, path, to: to, **options, &block 201 | end 202 | 203 | # Appends a route of POST method 204 | # @see [Pendragon::Router#route] 205 | def post(path, to: nil, **options, &block) 206 | route Constants::Http::POST, path, to: to, **options, &block 207 | end 208 | 209 | # Appends a route of PUT method 210 | # @see [Pendragon::Router#route] 211 | def put(path, to: nil, **options, &block) 212 | route Constants::Http::PUT, path, to: to, **options, &block 213 | end 214 | 215 | # Appends a route of DELETE method 216 | # @see [Pendragon::Router#route] 217 | def delete(path, to: nil, **options, &block) 218 | route Constants::Http::DELETE, path, to: to, **options, &block 219 | end 220 | 221 | # Appends a route of HEAD method 222 | # @see [Pendragon::Router#route] 223 | def head(path, to: nil, **options, &block) 224 | route Constants::Http::HEAD, path, to: to, **options, &block 225 | end 226 | 227 | # Appends a route of OPTIONS method 228 | # @see [Pendragon::Router#route] 229 | def options(path, to: nil, **options, &block) 230 | route Constants::Http::OPTIONS, path, to: to, **options, &block 231 | end 232 | 233 | # Appends a new route to router. 234 | # 235 | # @param [String] method A request method, it should be upcased. 236 | # @param [String] path The application is dispatched to given path. 237 | # @option [Class, #call] :to 238 | def route(method, path, to: nil, **options, &block) 239 | app = block_given? ? block : to 240 | fail ArgumentError, 'Rack application could not be found' unless app 241 | path = ?/ + prefix.join(?/) + path if prefix && !prefix.empty? 242 | append Route.new(method: method, pattern: path, application: app, **options) 243 | end 244 | 245 | # Maps all routes for each request methods. 246 | # @return [Hash{String => Array}] map 247 | def map 248 | @map ||= Hash.new { |hash, key| hash[key] = [] } 249 | end 250 | 251 | # Maps all routes. 252 | # @return [Array] flat_map 253 | def flat_map 254 | @flat_map ||= [] 255 | end 256 | 257 | private 258 | 259 | # @!visibility private 260 | def append(route) 261 | flat_map << route 262 | map[route.request_method] << route 263 | end 264 | 265 | # @!visibility private 266 | def invoke(env) 267 | response = on_call_listener(env) 268 | if !response && (allows = find_allows(env)) 269 | error!(Errors::MethodNotAllowed, allows: allows) 270 | end 271 | response || error!(Errors::NotFound) 272 | end 273 | 274 | # @!visibility private 275 | def error!(error_class, **payload) 276 | throw :halt, error_class.new(**payload).to_response 277 | end 278 | 279 | # @!visibility private 280 | def find_allows(env) 281 | pattern = env[Constants::Env::PATH_INFO] 282 | hits = flat_map.select { |route| route.match(pattern) }.map(&:request_method) 283 | hits.empty? ? nil : hits 284 | end 285 | 286 | # @!visibility private 287 | def extract(env, required: [:input, :method]) 288 | extracted = [] 289 | extracted << env[Constants::Env::PATH_INFO] if required.include?(:input) 290 | extracted << env[Constants::Env::REQUEST_METHOD] if required.include?(:method) 291 | extracted 292 | end 293 | 294 | # @!visibility private 295 | def rotation(env, exact_route = nil) 296 | input, method = extract(env) 297 | response = nil 298 | map[method].each do |route| 299 | next unless route.match(input) 300 | response = yield(route) 301 | break(response) unless cascade?(response) 302 | response = nil 303 | end 304 | response 305 | end 306 | 307 | # @!visibility private 308 | def cascade?(response) 309 | response && response[1][Constants::Header::CASCADE] == 'pass' 310 | end 311 | 312 | # @!visibility private 313 | def compile 314 | map.each(&method(:on_compile_listener)) 315 | @compiled = true 316 | end 317 | 318 | # @!visibility private 319 | def with_optimization 320 | compile unless compiled? 321 | yield 322 | end 323 | 324 | # Optional event listener 325 | # @param [String] method A request method like GET, POST 326 | # @param [Array] routes All routes associated to the method 327 | # @!visibility private 328 | def on_compile_listener(method, routes) 329 | end 330 | 331 | # @!visibility private 332 | def on_call_listener(env) 333 | fail NotImplementedError 334 | end 335 | 336 | # @!visibility private 337 | def compiled? 338 | @compiled 339 | end 340 | end 341 | end 342 | -------------------------------------------------------------------------------- /lib/pendragon/version.rb: -------------------------------------------------------------------------------- 1 | 2 | module Pendragon 3 | VERSION = '1.0.0' 4 | end 5 | -------------------------------------------------------------------------------- /pendragon.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path("../lib/pendragon/version", __FILE__) 2 | 3 | Gem::Specification.new "pendragon", Pendragon::VERSION do |s| 4 | s.description = "Toolkit for implementing HTTP Router in Ruby" 5 | s.summary = <<-summary 6 | Pendragon is toolkit for implementing HTTP router. 7 | The router created by pendragon can be used as a rack application. 8 | summary 9 | s.authors = ["namusyaka"] 10 | s.email = "namusyaka@gmail.com" 11 | s.homepage = "https://github.com/namusyaka/pendragon" 12 | s.files = `git ls-files`.split("\n") - %w(.gitignore) 13 | s.test_files = s.files.select { |path| path =~ /^test\/.*_test\.rb/ } 14 | s.license = "MIT" 15 | 16 | s.add_dependency "rack", ">= 1.3.0" 17 | s.add_dependency "mustermann" 18 | end 19 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require File.expand_path('../lib/pendragon', __dir__) 3 | require File.expand_path('../lib/pendragon/realism', __dir__) 4 | require File.expand_path('../lib/pendragon/linear', __dir__) 5 | 6 | Bundler.require(:default) 7 | 8 | require 'test/unit' 9 | require 'mocha/setup' 10 | require 'rack' 11 | require 'rack/test' 12 | 13 | module Supports 14 | end 15 | 16 | $:.unshift(File.expand_path('..', __dir__)) 17 | Dir.glob('test/supports/*.rb').each(&method(:require)) 18 | -------------------------------------------------------------------------------- /test/router/test_linear.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../helper', __dir__) 2 | 3 | class TestLinear < Test::Unit::TestCase 4 | include Supports::SharedExamplesForRouting 5 | router_class Pendragon::Linear 6 | end 7 | -------------------------------------------------------------------------------- /test/router/test_realism.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../helper', __dir__) 2 | 3 | class TestRealism < Test::Unit::TestCase 4 | include Supports::SharedExamplesForRouting 5 | router_class Pendragon::Realism 6 | end 7 | -------------------------------------------------------------------------------- /test/supports/shared_examples_for_routing.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | module Supports::SharedExamplesForRouting 4 | extend Modulla 5 | include Rack::Test::Methods 6 | 7 | module ClassMethods 8 | def router_class(klass) 9 | define_method(:router) { klass } 10 | end 11 | 12 | def disable(*features) 13 | features.each do |feature| 14 | define_method('%p disabled' % feature) {} 15 | end 16 | end 17 | end 18 | 19 | alias_method :response, :last_response 20 | 21 | def disabled?(feature) 22 | respond_to?('%p disabled' % feature) 23 | end 24 | 25 | def mock_env_for(path = ?/, **options) 26 | Rack::MockRequest.env_for(path, **options) 27 | end 28 | 29 | def mock_app(base = nil, &block) 30 | @app = router.new(&block) 31 | end 32 | 33 | def app 34 | Rack::Lint.new(@app) 35 | end 36 | 37 | def assert_response(status, body, headers = {}) 38 | assert { last_response.status == status } 39 | assert { last_response.body == body } 40 | headers.each_pair do |key, val| 41 | assert { last_response.headers[key] == val } 42 | end 43 | end 44 | 45 | sub_test_case '#call' do 46 | test 'return response conformed rack format' do 47 | assert_nothing_raised do 48 | Rack::Lint.new(router.new).call(mock_env_for) 49 | end 50 | end 51 | end 52 | 53 | sub_test_case 'default response' do 54 | setup { mock_app { } } 55 | test 'return default response if given request does not match with any routes' do 56 | get ?/ 57 | assert_response 404, 'not found', 'Content-Type' => 'text/plain' 58 | end 59 | end 60 | 61 | sub_test_case 'basic' do 62 | setup do 63 | mock_app do 64 | get(?/) { [200, {}, ['hello']] } 65 | end 66 | end 67 | test 'return response if given request matches with any routes' do 68 | get ?/ 69 | assert_response 200, 'hello' 70 | end 71 | end 72 | 73 | sub_test_case 'duck typing' do 74 | setup do 75 | _lambda = -> { [200, {}, ['lambda']] } 76 | rack_app_class = Class.new { 77 | def call(env) 78 | [200, {}, ['rackapp']] 79 | end 80 | } 81 | mock_app do 82 | get '/lambda', to: _lambda 83 | get '/rack_app', to: rack_app_class.new 84 | end 85 | end 86 | 87 | test 'duck typing for lambda' do 88 | get '/lambda' 89 | assert_response 200, 'lambda' 90 | end 91 | 92 | test 'duck typing for rack app' do 93 | get '/rack_app' 94 | assert_response 200, 'rackapp' 95 | end 96 | end 97 | 98 | sub_test_case 'namespacing' do 99 | setup do 100 | mock_app do 101 | namespace :foo do 102 | get('/123') { [200, {}, ['hey']] } 103 | end 104 | end 105 | end 106 | 107 | test 'append given namespace as a prefix' do 108 | get '/foo/123' 109 | assert_response 200, 'hey' 110 | end 111 | end 112 | 113 | sub_test_case 'nested namespacing' do 114 | setup do 115 | mock_app do 116 | namespace :foo do 117 | get('/') { [200, {}, ['foo']] } 118 | namespace :bar do 119 | get('/') { [200, {}, ['bar']] } 120 | namespace :baz do 121 | get('/') { [200, {}, ['baz']] } 122 | end 123 | end 124 | end 125 | end 126 | end 127 | 128 | test 'append given namespace as a prefix' do 129 | get '/foo/' 130 | assert_response 200, 'foo' 131 | get '/foo/bar/' 132 | assert_response 200, 'bar' 133 | get '/foo/bar/baz/' 134 | assert_response 200, 'baz' 135 | end 136 | end 137 | 138 | sub_test_case 'complex routing' do 139 | setup do 140 | mock_app do 141 | 1000.times do |n| 142 | [:get, :post, :put, :delete, :options].each do |verb| 143 | public_send(verb, "/#{n}") { [200, {}, ["#{verb} #{n}"]] } 144 | end 145 | end 146 | end 147 | end 148 | 149 | test 'recognize a route correctly' do 150 | put '/376' 151 | assert_response 200, 'put 376' 152 | end 153 | 154 | test 'recognize a route correctly (part 2)' do 155 | delete '/999' 156 | assert_response 200, 'delete 999' 157 | end 158 | end 159 | 160 | sub_test_case 'method not allowed' do 161 | setup do 162 | mock_app do 163 | get '/testing' do 164 | [200, {}, ['hello testing']] 165 | end 166 | end 167 | end 168 | 169 | test 'returns 405 if given method is not allowed' do 170 | post '/testing' 171 | assert_response 405, 'method not allowed', { 'Allows' => 'GET' } 172 | end 173 | end 174 | 175 | sub_test_case 'block arguments' do 176 | setup do 177 | mock_app do 178 | get '/foo/:name/*/*' do |name, a, b| 179 | body = "#{name}, #{a}, #{b}" 180 | [200, {}, [body]] 181 | end 182 | end 183 | end 184 | 185 | test 'gets params as block parameters' do 186 | omit_if disabled?(:multiple_splats) 187 | get '/foo/yoman/1234/5678' 188 | assert_response 200, 'yoman, 1234, 5678' 189 | end 190 | end 191 | 192 | sub_test_case '#params' do 193 | setup do 194 | mock_app do 195 | get '/foo/:name/*/*' do 196 | body = params.to_json 197 | [200, {}, [body]] 198 | end 199 | end 200 | end 201 | 202 | test 'gets params correctly' do 203 | omit_if disabled?(:multiple_splats) 204 | get '/foo/heyman/1234/5678' 205 | assert_response 200, {name: 'heyman', splat: ['1234', '5678']}.to_json 206 | end 207 | end 208 | 209 | sub_test_case 'capturing' do 210 | setup do 211 | mock_app do 212 | get '/users/:name/articles/:article_id' do 213 | [200, {}, [captures.join(' ')]] 214 | end 215 | end 216 | end 217 | 218 | test 'gets captures correctly' do 219 | get '/users/namusyaka/articles/1234' 220 | assert_response 200, 'namusyaka 1234' 221 | end 222 | end 223 | 224 | sub_test_case 'splat' do 225 | sub_test_case 'multiple splats' do 226 | setup do 227 | mock_app do 228 | get '/splatting/*/*/*' do |a, b, c| 229 | [200, {}, ["captures: #{captures.join(' ')}, block: #{a} #{b} #{c}"]] 230 | end 231 | end 232 | end 233 | 234 | test 'gets multiple splats correctly' do 235 | omit_if disabled?(:multiple_splats) 236 | get '/splatting/1234/5678/90' 237 | assert_response 200, 'captures: 1234 5678 90, block: 1234 5678 90' 238 | end 239 | end 240 | end 241 | 242 | sub_test_case 'cascading' do 243 | setup do 244 | mock_app do 245 | get '/cascading' do 246 | [200, { 'X-Cascade' => 'pass' }, ['']] 247 | end 248 | 249 | get '/cascading' do 250 | [200, {}, ['yay']] 251 | end 252 | end 253 | end 254 | 255 | test 'succeeds to cascading' do 256 | omit_if disabled?(:cascading) 257 | get '/cascading' 258 | assert_response 200, 'yay' 259 | end 260 | end 261 | 262 | sub_test_case 'halting' do 263 | setup do 264 | mock_app do 265 | get ?/ do 266 | throw :halt, [404, {}, ['not found']] 267 | [200, {}, ['failed to halt']] 268 | end 269 | end 270 | end 271 | 272 | test 'succeeds to halting' do 273 | get ?/ 274 | assert_response 404, 'not found' 275 | end 276 | end 277 | end 278 | -------------------------------------------------------------------------------- /test/test_router.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('helper', __dir__) 2 | 3 | class TestRouter < Test::Unit::TestCase 4 | attr_accessor :router 5 | 6 | setup { self.router = Pendragon::Router.new } 7 | 8 | sub_test_case '#call' do 9 | setup do 10 | @mock_request = Rack::MockRequest.env_for(?/) 11 | router.get(?/) { 'hello' } 12 | end 13 | 14 | test 'should recognize a route inside #with_block' do 15 | router.expects(:with_optimization) 16 | router.call(@mock_request) 17 | end 18 | 19 | test 'raises NotImplementedError' do 20 | assert_raise NotImplementedError do 21 | router.call(@mock_request) 22 | end 23 | end 24 | 25 | sub_test_case 'without matched route' do 26 | end 27 | end 28 | 29 | %w[get post put delete head options].each do |request_method| 30 | sub_test_case "##{request_method}" do 31 | setup { @expected_block = Proc.new {} } 32 | test "should append #{request_method} route correctly" do 33 | router.public_send(request_method, ?/, &@expected_block) 34 | actual = router.map[request_method.upcase].first 35 | assert { actual.request_method == request_method.upcase } 36 | assert { actual.path == ?/ } 37 | end 38 | end 39 | end 40 | end 41 | --------------------------------------------------------------------------------