├── .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 |
--------------------------------------------------------------------------------