├── .ci.gemfile ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG ├── MIT-LICENSE ├── README.rdoc ├── Rakefile ├── bin └── roda-parse_routes ├── lib ├── roda-route_parser.rb └── roda │ └── plugins │ └── route_list.rb ├── roda-route_list.gemspec └── spec ├── roda-route_list_spec.rb ├── routes-pretty.json ├── routes.example ├── routes.json └── routes2.json /.ci.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rake' 4 | gem 'roda' 5 | gem 'minitest-global_expectations' 6 | 7 | if RUBY_VERSION < '2.2.0' 8 | gem 'rack', '<2' 9 | end 10 | 11 | if RUBY_VERSION < '2.4.0' 12 | gem 'minitest', '5.11.3' 13 | end 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | tests: 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ubuntu-latest] 18 | ruby: [ "2.0.0", 2.1, 2.3, 2.4, 2.5, 2.6, 2.7, "3.0", 3.1, 3.2, 3.3, 3.4, jruby-9.3, jruby-9.4, jruby-10.0 ] 19 | include: 20 | - { os: ubuntu-22.04, ruby: "1.9.3" } 21 | - { os: ubuntu-22.04, ruby: jruby-9.1 } 22 | - { os: ubuntu-22.04, ruby: jruby-9.2 } 23 | runs-on: ${{ matrix.os }} 24 | name: ${{ matrix.ruby }} 25 | env: 26 | BUNDLE_GEMFILE: .ci.gemfile 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: ruby/setup-ruby@v1 30 | with: 31 | ruby-version: ${{ matrix.ruby }} 32 | bundler-cache: true 33 | - run: bundle exec rake 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /rdoc 2 | /roda-route_list-*.gem 3 | /coverage 4 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | = 2.1.0 (2017-10-09) 2 | 3 | * Add support for -p flag for bin/roda-parse_routes to pretty print the JSON file (oldgreen) (#3) 4 | 5 | = 2.0.0 (2016-02-24) 6 | 7 | * Use Roda.listed_route instead of Roda.named_route to not conflict with multi_route plugin (jeremyevans) (#1) 8 | 9 | = 1.0.0 (2015-03-08) 10 | 11 | * Initial public release 12 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2017 Jeremy Evans 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = roda-route_list 2 | 3 | The Roda route_list plugin reads route information from a json 4 | file, and then makes the route metadata available for 5 | introspection. This provides a workaround to the general 6 | issue of routing trees being unable to introspect the routes. 7 | 8 | == Installation 9 | 10 | gem install roda-route_list 11 | 12 | == Source Code 13 | 14 | Source code is available on GitHub at https://github.com/jeremyevans/roda-route_list 15 | 16 | == Basic Usage 17 | 18 | This plugin assumes that a json file containing the routes 19 | metadata has already been created. The recommended way to 20 | create one is to add comments above each route in the Roda 21 | app, in one of the following formats: 22 | 23 | # route: /path/to/foo 24 | # route: GET /path/to/foo 25 | # route: GET|POST /path/to/foo/:foo_id 26 | # route[route_name]: /path/to/foo 27 | # route[route_name]: GET /path/to/foo 28 | # route[foo]: GET|POST /path/to/foo/:foo_id 29 | 30 | As you can see, the general style is a comment followed by 31 | the word route. If you want to name the route, you can 32 | put the name in brackets. Then you have a colon. Optionally 33 | after that you can have the method for the route, or multiple 34 | methods separated by pipes if the path works with multiple 35 | methods. The end is the path for the route. 36 | 37 | Assuming you have added the appropriate comments as explained 38 | above, you can create the json file using the roda-parse_routes 39 | executable that came with the roda-route_list gem: 40 | 41 | roda-parse_routes -f routes.json app.rb 42 | 43 | Assuming you have the necessary json file created, you can then 44 | get route information: 45 | 46 | plugin :route_list 47 | 48 | # Array of route metadata hashes 49 | route_list # => [{:path=>'/path/to/foo', :methods=>['GET', 'POST']}] 50 | 51 | # path for the route with the given name 52 | listed_route(:route_name) # => '/path/to/foo' 53 | 54 | # path for the route with the given name, supplying hash for placeholders 55 | listed_route(:foo, :foo_id=>3) # => '/path/to/foo/3' 56 | 57 | # path for the route with the given name, supplying array for placeholders 58 | listed_route(:foo, [3]) # => '/path/to/foo/3' 59 | 60 | The +listed_route+ method is also available at the instance level to make it 61 | easier to use inside the route block. 62 | 63 | === Automatically Updating the Routes Metadata 64 | 65 | ==== On Heroku 66 | 67 | You can get this to work on Heroku by hooking into the facility for 68 | precompiling assets. If this consider your route list an asset, this 69 | makes sense. You just need to add an assets:precompile task, similar to 70 | this (or add the code an existing assets:precompile task): 71 | 72 | namespace :assets do 73 | desc "Update the routes metadata" 74 | task :precompile do 75 | sh 'roda-parse_routes -f routes.json app.rb' 76 | end 77 | end 78 | 79 | ==== Otherwise 80 | 81 | At the top of your Roda app file, or at least before you create the Roda app 82 | subclass, just call the roda-parse_routes program: 83 | 84 | # app.rb 85 | system 'roda-parse_routes', '-f', 'routes.json', __FILE__ 86 | require 'roda' 87 | 88 | class App < Roda 89 | # ... 90 | end 91 | 92 | == License 93 | 94 | MIT 95 | 96 | == Maintainer 97 | 98 | Jeremy Evans 99 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rake/clean" 2 | 3 | CLEAN.include ["rdoc", "roda-route_list-*.gem", "coverage"] 4 | 5 | ### Specs 6 | 7 | desc "Run all specs" 8 | task :spec do |p| 9 | ENV['RUBY'] = FileUtils::RUBY 10 | sh %{#{FileUtils::RUBY} #{"-w" if RUBY_VERSION >= '3'} #{'-W:strict_unused_block' if RUBY_VERSION >= '3.4'} spec/roda-route_list_spec.rb } 11 | end 12 | task :default=>:spec 13 | 14 | desc "Run tests with coverage" 15 | task :spec_cov do 16 | ENV['COVERAGE'] = '1' 17 | sh "#{FileUtils::RUBY} spec/roda-route_list_spec.rb" 18 | end 19 | 20 | ### RDoc 21 | 22 | desc "Generate rdoc" 23 | task :rdoc do 24 | rdoc_dir = "rdoc" 25 | rdoc_opts = ["--line-numbers", "--inline-source", '--title', 'roda-route_list: List routes when using Roda'] 26 | 27 | begin 28 | gem 'hanna' 29 | rdoc_opts.concat(['-f', 'hanna']) 30 | rescue Gem::LoadError 31 | end 32 | 33 | rdoc_opts.concat(['--main', 'README.rdoc', "-o", rdoc_dir] + 34 | %w"README.rdoc CHANGELOG MIT-LICENSE" + 35 | Dir["lib/**/*.rb"] 36 | ) 37 | 38 | FileUtils.rm_rf(rdoc_dir) 39 | 40 | require "rdoc" 41 | RDoc::RDoc.new.document(rdoc_opts) 42 | end 43 | -------------------------------------------------------------------------------- /bin/roda-parse_routes: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'optparse' 4 | require 'json' 5 | require File.join(File.dirname(File.dirname(File.expand_path(__FILE__))), 'lib', 'roda-route_parser') 6 | 7 | file = $stdout 8 | json_gen_opts = {} 9 | options = OptionParser.new do |opts| 10 | opts.banner = "roda-parse_routes: Parse route comments from roda app files" 11 | opts.define_head "Usage: roda-parse_routes [options] [file] ..." 12 | opts.separator "Options:" 13 | 14 | opts.on_tail("-h", "-?", "--help", "Show this message") do 15 | puts opts 16 | exit 17 | end 18 | 19 | opts.on("-f", "--file ", "output to given file instead of stdout") do |v| 20 | file = File.open(v, 'wb') 21 | end 22 | 23 | opts.on("-p", "--pretty", "output pretty json (with indentation and newlines)") do 24 | json_gen_opts = {:indent => ' ', :space => ' ', :object_nl => "\n", :array_nl => "\n"} 25 | end 26 | end 27 | opts = options 28 | opts.parse! 29 | 30 | file.puts(RodaRouteParser.parse(ARGF).to_json(json_gen_opts)) 31 | -------------------------------------------------------------------------------- /lib/roda-route_parser.rb: -------------------------------------------------------------------------------- 1 | class RodaRouteParser 2 | def self.parse(input) 3 | new.parse(input) 4 | end 5 | 6 | def parse(input) 7 | if input.is_a?(String) 8 | require 'stringio' 9 | return parse(StringIO.new(input)) 10 | end 11 | 12 | routes = [] 13 | regexp = /\A\s*#\s*route(?:\[(\w+)\])?:\s+(?:([A-Z|]+)?\s+)?(\S+)\s*\z/ 14 | input.each_line do |line| 15 | if md = regexp.match(line) 16 | name, methods, route = md.captures 17 | route = {'path'=>route} 18 | 19 | if methods 20 | route['methods'] = methods.split('|').compact 21 | end 22 | 23 | if name 24 | route['name'] = name 25 | end 26 | 27 | routes << route 28 | end 29 | end 30 | 31 | routes 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/roda/plugins/route_list.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | class Roda 4 | module RodaPlugins 5 | # The route_list plugin reads route information from a json 6 | # file, and then makes the route metadata available for 7 | # introspection. This provides a workaround to the general 8 | # issue of routing trees being unable to introspect the routes. 9 | # 10 | # This plugin assumes that a json file containing the routes 11 | # metadata has already been created. The recommended way to 12 | # create one is to add comments above each route in the Roda 13 | # app, in one of the following formats: 14 | # 15 | # # route: /path/to/foo 16 | # # route: GET /path/to/foo 17 | # # route: GET|POST /path/to/foo/:foo_id 18 | # # route[route_name]: /path/to/foo 19 | # # route[route_name]: GET /path/to/foo 20 | # # route[foo]: GET|POST /path/to/foo/:foo_id 21 | # 22 | # As you can see, the general style is a comment followed by 23 | # the word route. If you want to name the route, you can 24 | # put the name in brackets. Then you have a colon. Optionally 25 | # after that you can have the method for the route, or multiple 26 | # methods separated by pipes if the path works with multiple 27 | # methods. The end is the path for the route. 28 | # 29 | # Assuming you have added the appropriate comments as explained 30 | # above, you can create the json file using the roda-route_parser 31 | # executable that came with the roda-route_list gem: 32 | # 33 | # roda-route_parser -f routes.json app.rb 34 | # 35 | # Assuming you have the necessary json file created, you can then 36 | # get route information: 37 | # 38 | # plugin :route_list 39 | # 40 | # # Array of route metadata hashes 41 | # route_list # => [{:path=>'/path/to/foo', :methods=>['GET', 'POST']}] 42 | # 43 | # # path for the route with the given name 44 | # listed_route(:route_name) # => '/path/to/foo' 45 | # 46 | # # path for the route with the given name, supplying hash for placeholders 47 | # listed_route(:foo, :foo_id=>3) # => '/path/to/foo/3' 48 | # 49 | # # path for the route with the given name, supplying array for placeholders 50 | # listed_route(:foo, [3]) # => '/path/to/foo/3' 51 | # 52 | # The +listed_route+ method is also available at the instance level to make it 53 | # easier to use inside the route block. 54 | module RouteList 55 | # Set the file to load the routes metadata from. Options: 56 | # :file :: The JSON file containing the routes metadata (default: 'routes.json') 57 | def self.configure(app, opts={}) 58 | file = File.expand_path(opts.fetch(:file, 'routes.json'), app.opts[:root]) 59 | app.send(:load_routes, file) 60 | end 61 | 62 | module ClassMethods 63 | # Array of route metadata hashes. 64 | attr_reader :route_list 65 | 66 | # Return the path for the given named route. If args is not given, 67 | # this returns the path directly. If args is a hash, any placeholder 68 | # values in the path are replaced with the matching values in args. 69 | # If args is an array, placeholder values are taken from the array 70 | # in order. 71 | def listed_route(name, args=nil) 72 | unless path = @route_list_names[name] 73 | raise RodaError, "no route exists with the name: #{name.inspect}" 74 | end 75 | 76 | if args 77 | if args.is_a?(Hash) 78 | range = 1..-1 79 | path = path.gsub(/:[^\/]+/) do |match| 80 | key = match[range].to_sym 81 | value = args[key] 82 | if value.nil? 83 | msg = if args.key?(key) 84 | "nil value exists in the hash for named route #{name}: #{match}" 85 | else 86 | "no matching value exists in the hash for named route #{name}: #{match}" 87 | end 88 | raise RodaError, msg 89 | end 90 | value 91 | end 92 | else 93 | values = args.dup 94 | path = path.gsub(/:[^\/]+/) do |match| 95 | if values.empty? 96 | raise RodaError, "not enough placeholder values provided for named route #{name}: #{match}" 97 | end 98 | values.shift 99 | end 100 | 101 | unless values.empty? 102 | raise RodaError, "too many placeholder values provided for named route #{name}" 103 | end 104 | end 105 | end 106 | 107 | path 108 | end 109 | 110 | private 111 | 112 | # Load the route metadata from the given json file. 113 | def load_routes(file) 114 | @route_list_names = {} 115 | 116 | routes = JSON.parse(File.read(file)) 117 | @route_list = routes.map do |r| 118 | path = r['path'].freeze 119 | route = {:path=>path} 120 | 121 | if methods = r['methods'] 122 | route[:methods] = methods.map(&:to_sym) 123 | end 124 | 125 | if name = r['name'] 126 | name = name.to_sym 127 | route[:name] = name.to_sym 128 | @route_list_names[name] = path 129 | end 130 | 131 | route.freeze 132 | end.freeze 133 | 134 | @route_list_names.freeze 135 | 136 | nil 137 | end 138 | end 139 | 140 | module InstanceMethods 141 | # Calls the app's listed_route method. If the app's :add_script_name option 142 | # has been setting, prefixes the resulting path with the script name. 143 | def listed_route(name, args=nil) 144 | app = self.class 145 | path = app.listed_route(name, args) 146 | path = request.script_name.to_s + path if app.opts[:add_script_name] 147 | path 148 | end 149 | end 150 | end 151 | 152 | register_plugin(:route_list, RouteList) 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /roda-route_list.gemspec: -------------------------------------------------------------------------------- 1 | spec = Gem::Specification.new do |s| 2 | s.name = 'roda-route_list' 3 | s.version = '2.1.0' 4 | s.platform = Gem::Platform::RUBY 5 | s.extra_rdoc_files = ["README.rdoc", "CHANGELOG", "MIT-LICENSE"] 6 | s.rdoc_options += ["--quiet", "--line-numbers", "--inline-source", '--title', 'roda-route_list: List routes when using Roda', '--main', 'README.rdoc'] 7 | s.license = "MIT" 8 | s.summary = "List routes when using Roda" 9 | s.author = "Jeremy Evans" 10 | s.email = "code@jeremyevans.net" 11 | s.homepage = "http://github.com/jeremyevans/roda-route_list" 12 | s.files = %w(MIT-LICENSE CHANGELOG README.rdoc Rakefile) + Dir["{spec,lib}/**/*.rb"] 13 | s.executables << 'roda-parse_routes' 14 | s.description = < "GET", "PATH_INFO" => "/", "SCRIPT_NAME" => ""}.merge(env) 29 | @app.call(env) 30 | end 31 | 32 | def body(path='/', env={}) 33 | s = String.new 34 | b = req(path, env)[2] 35 | b.each{|x| s << x} 36 | b.close if b.respond_to?(:close) 37 | s 38 | end 39 | 40 | before do 41 | @app = Class.new(Roda) 42 | @app.plugin :route_list, :file=>'spec/routes.json' 43 | @app.route do |r| 44 | listed_route(env['PATH_INFO'].to_sym) 45 | end 46 | @app 47 | end 48 | 49 | after do 50 | File.delete('routes.json') if File.exist?('routes.json') 51 | end 52 | 53 | it "should correctly parse the routes from the json file" do 54 | @app.route_list.must_equal [ 55 | {:path=>'/foo'}, 56 | {:path=>'/foo/bar', :name=>:bar}, 57 | {:path=>'/foo/baz', :methods=>[:GET]}, 58 | {:path=>'/foo/baz/quux/:quux_id', :name=>:quux, :methods=>[:GET, :POST]}, 59 | ] 60 | end 61 | 62 | it "should respect :root option when parsing json file" do 63 | @app = Class.new(Roda) 64 | @app.opts[:root] = 'spec' 65 | @app.plugin :route_list, :file=>'routes2.json' 66 | @app.route_list.must_equal [{:path=>'/foo'}] 67 | end 68 | 69 | it ".listed_route should return path for route" do 70 | @app.listed_route(:bar).must_equal '/foo/bar' 71 | @app.listed_route(:quux).must_equal '/foo/baz/quux/:quux_id' 72 | end 73 | 74 | it ".listed_route should return path for route when given a values hash" do 75 | @app.listed_route(:quux, :quux_id=>3).must_equal '/foo/baz/quux/3' 76 | end 77 | 78 | it ".listed_route should return path for route when given a values array" do 79 | @app.listed_route(:quux, [3]).must_equal '/foo/baz/quux/3' 80 | end 81 | 82 | it ".listed_route should raise RodaError if there is no matching route" do 83 | proc{@app.listed_route(:foo)}.must_raise(Roda::RodaError) 84 | end 85 | 86 | it ".listed_route should raise RodaError if there is no matching value when using a values hash" do 87 | ex = proc{@app.listed_route(:quux, {})}.must_raise(Roda::RodaError) 88 | ex.message.must_equal "no matching value exists in the hash for named route quux: :quux_id" 89 | end 90 | 91 | it ".listed_route should raise RodaError if provided value is nil when using a values hash" do 92 | ex = proc{@app.listed_route(:quux, :quux_id => nil)}.must_raise(Roda::RodaError) 93 | ex.message.must_equal "nil value exists in the hash for named route quux: :quux_id" 94 | end 95 | 96 | it ".listed_route should raise RodaError if there is no matching value when using a values array" do 97 | proc{@app.listed_route(:quux, [])}.must_raise(Roda::RodaError) 98 | end 99 | 100 | it ".listed_route should raise RodaError if there are too many values when using a values array" do 101 | proc{@app.listed_route(:quux, [3, 1])}.must_raise(Roda::RodaError) 102 | end 103 | 104 | it "should allow parsing routes from a separate file" do 105 | @app.plugin :route_list, :file=>'spec/routes2.json' 106 | @app.route_list.must_equal [{:path=>'/foo'}] 107 | end 108 | 109 | it "#listed_route should work" do 110 | body('bar').must_equal '/foo/bar' 111 | end 112 | 113 | it "#listed_route should respect :add_script_name option" do 114 | @app.opts[:add_script_name] = true 115 | body('bar').must_equal '/foo/bar' 116 | body('bar', 'SCRIPT_NAME'=>'/a').must_equal '/a/foo/bar' 117 | end 118 | end 119 | 120 | describe 'roda-route_parser executable' do 121 | after do 122 | %w[spec/routes-example.json spec/routes-example-pretty.json].each do |file| 123 | begin 124 | File.delete(file) 125 | rescue Errno::ENOENT 126 | end 127 | end 128 | end 129 | 130 | it "should correctly parse the routes" do 131 | system(ENV['RUBY'] || 'ruby', "bin/roda-parse_routes", "-f", "spec/routes-example.json", "spec/routes.example") 132 | File.file?("spec/routes-example.json").must_equal true 133 | JSON.parse(File.read('spec/routes-example.json')).must_equal JSON.parse(File.read('spec/routes.json')) 134 | end 135 | 136 | it "should correctly write the pretty routes" do 137 | system(ENV['RUBY'] || 'ruby', "bin/roda-parse_routes", "-f", "spec/routes-example-pretty.json", "-p", "spec/routes.example") 138 | File.file?("spec/routes-example-pretty.json").must_equal true 139 | File.read("spec/routes-example-pretty.json").must_equal(File.read("spec/routes-pretty.json")) 140 | end 141 | 142 | it "should correctly parse the pretty routes" do 143 | system(ENV['RUBY'] || 'ruby', "bin/roda-parse_routes", "-f", "spec/routes-example-pretty.json", "-p", "spec/routes.example") 144 | File.file?("spec/routes-example-pretty.json").must_equal true 145 | JSON.parse(File.read('spec/routes-example-pretty.json')).must_equal JSON.parse(File.read('spec/routes.json')) 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /spec/routes-pretty.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "path": "/foo" 4 | }, 5 | { 6 | "path": "/foo/bar", 7 | "name": "bar" 8 | }, 9 | { 10 | "path": "/foo/baz", 11 | "methods": [ 12 | "GET" 13 | ] 14 | }, 15 | { 16 | "path": "/foo/baz/quux/:quux_id", 17 | "methods": [ 18 | "GET", 19 | "POST" 20 | ], 21 | "name": "quux" 22 | } 23 | ] 24 | -------------------------------------------------------------------------------- /spec/routes.example: -------------------------------------------------------------------------------- 1 | 2 | # route: /foo 3 | foo 4 | 5 | #route[bar]: /foo/bar 6 | bar 7 | 8 | #route: GET /foo/baz 9 | baz 10 | 11 | # route[quux]: GET|POST /foo/baz/quux/:quux_id 12 | quux 13 | -------------------------------------------------------------------------------- /spec/routes.json: -------------------------------------------------------------------------------- 1 | [{"path":"/foo"},{"path":"/foo/bar","name":"bar"},{"path":"/foo/baz","methods":["GET"]},{"path":"/foo/baz/quux/:quux_id","methods":["GET","POST"],"name":"quux"}] 2 | -------------------------------------------------------------------------------- /spec/routes2.json: -------------------------------------------------------------------------------- 1 | [{"path":"/foo"}] 2 | --------------------------------------------------------------------------------