From 8f237b81a7e1cd2d4b6d31bafddaa5e23c7082b3 Mon Sep 17 00:00:00 2001 From: Harper Henn Date: Sat, 23 May 2015 16:15:42 -0700 Subject: [PATCH] Initial commit --- .gitignore | 1 + .rubocop.yml | 10 ++ LICENSE.txt | 22 +++ README.md | 73 ++++++++ Rakefile | 3 + grape-route-helpers.gemspec | 21 +++ lib/grape-route-helpers.rb | 11 ++ lib/grape-route-helpers/all_routes.rb | 15 ++ lib/grape-route-helpers/decorated_route.rb | 134 ++++++++++++++ .../named_route_matcher.rb | 28 +++ lib/grape-route-helpers/route_displayer.rb | 25 +++ lib/grape-route-helpers/version.rb | 4 + lib/grape/route_helpers.rb | 1 + lib/tasks/grape_route_helpers.rake | 6 + .../decorated_route_spec.rb | 169 ++++++++++++++++++ .../named_route_matcher_spec.rb | 164 +++++++++++++++++ spec/spec_helper.rb | 9 + spec/support/route_matcher_helpers.rb | 34 ++++ 18 files changed, 730 insertions(+) create mode 100644 .gitignore create mode 100644 .rubocop.yml create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 Rakefile create mode 100644 grape-route-helpers.gemspec create mode 100644 lib/grape-route-helpers.rb create mode 100644 lib/grape-route-helpers/all_routes.rb create mode 100644 lib/grape-route-helpers/decorated_route.rb create mode 100644 lib/grape-route-helpers/named_route_matcher.rb create mode 100644 lib/grape-route-helpers/route_displayer.rb create mode 100644 lib/grape-route-helpers/version.rb create mode 100644 lib/grape/route_helpers.rb create mode 100644 lib/tasks/grape_route_helpers.rake create mode 100644 spec/grape_route_helpers/decorated_route_spec.rb create mode 100644 spec/grape_route_helpers/named_route_matcher_spec.rb create mode 100644 spec/spec_helper.rb create mode 100644 spec/support/route_matcher_helpers.rb diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1377554 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.swp diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..a968464 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,10 @@ +Metrics/ClassLength: + Max: 104 + Enabled: false + +Metrics/MethodLength: + Max: 21 + Enabled: false + +Style/FileName: + Enabled: false diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..9bd4750 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,22 @@ +MIT License (MIT) + +Copyright (c) 2015 Harper Henn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..3cdf409 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# grape-route-helpers + + Provides named route helpers for Grape APIs, similar to Rails' route helpers. + +### Installation + + 1.) Add the gem to your Gemfile if you're using Bundler. + +```bash +$ bundle install grape-route-helpers +``` + +Run `gem install grape-route-helpers` if you're not. + +2.) Require the gem after you `require 'grape'` in your application setup. + +```ruby +require 'grape/route_helpers' +``` + +### Usage examples + +* To see which methods correspond to which paths, and which options you can pass them: + +```bash +$ rake grape:route_helpers +``` + +* Use the methods inside your Grape API actions. Given this example API: + +```ruby +class ExampleAPI < Grape::API + version 'v1' + prefix 'api' + format 'json' + + get 'ping' do + 'pong' + end + + resource :cats do + get '/' do + %w(cats cats cats) + end + + route_param :id do + get do + 'cat' + end + end + end + + route :any, '*anything' do + redirect_to api_v1_cats_path + end +end +``` + +You'd have the following methods available inside your Grape API actions: + +```ruby +# specifying the version when using Grape's "path" versioning strategy +api_v1_ping_path # => '/api/v1/ping' + +# specifying the format +api_v1_cats_path(format: 'xml') # => '/api/v1/cats.xml' + +# passing in values required to build a path +api_v1_cats_path(id: 1) # => '/api/v1/cats/1' + +# catch-all paths have helpers +api_v1_anything_path # => '/api/v1/*anything' +``` diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..5641642 --- /dev/null +++ b/Rakefile @@ -0,0 +1,3 @@ +$LOAD_PATH.unshift File.expand_path('lib') + +require 'grape/route_helpers' diff --git a/grape-route-helpers.gemspec b/grape-route-helpers.gemspec new file mode 100644 index 0000000..82db85e --- /dev/null +++ b/grape-route-helpers.gemspec @@ -0,0 +1,21 @@ +require File.join(Dir.pwd, 'lib', 'grape-route-helpers', 'version') + +Gem::Specification.new do |gem| + gem.name = 'grape-route-helpers' + gem.version = GrapeRouteHelpers::VERSION + gem.licenses = ['MIT'] + gem.summary = 'Route helpers for Grape' + gem.description = 'Route helpers for Grape' + gem.authors = ['Harper Henn'] + gem.email = 'harper.henn@legitscript.com' + gem.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) + gem.homepage = 'https://github.com/reprah/grape-route-helpers' + + gem.add_runtime_dependency 'grape' + gem.add_runtime_dependency 'activesupport' + gem.add_runtime_dependency 'rake' + + gem.add_development_dependency 'pry' + gem.add_development_dependency 'rspec' + gem.add_development_dependency 'rubocop' +end diff --git a/lib/grape-route-helpers.rb b/lib/grape-route-helpers.rb new file mode 100644 index 0000000..42e4466 --- /dev/null +++ b/lib/grape-route-helpers.rb @@ -0,0 +1,11 @@ +require 'grape' +require 'active_support' +require 'active_support/core_ext/class' + +require 'grape-route-helpers/decorated_route' +require 'grape-route-helpers/named_route_matcher' +require 'grape-route-helpers/all_routes' +require 'grape-route-helpers/route_displayer' + +Grape::API.extend GrapeRouteHelpers::AllRoutes +Grape::Endpoint.include GrapeRouteHelpers::NamedRouteMatcher diff --git a/lib/grape-route-helpers/all_routes.rb b/lib/grape-route-helpers/all_routes.rb new file mode 100644 index 0000000..509f3cb --- /dev/null +++ b/lib/grape-route-helpers/all_routes.rb @@ -0,0 +1,15 @@ +module GrapeRouteHelpers + # methods to extend Grape::API's behavior so it can get a + # list of routes from all APIs and decorate them with + # the DecoratedRoute class + module AllRoutes + def decorated_routes + # memoize so that construction of decorated routes happens once + @decorated_routes ||= all_routes.map { |r| DecoratedRoute.new(r) } + end + + def all_routes + subclasses.flat_map { |s| s.send(:prepare_routes) } + end + end +end diff --git a/lib/grape-route-helpers/decorated_route.rb b/lib/grape-route-helpers/decorated_route.rb new file mode 100644 index 0000000..7163e6e --- /dev/null +++ b/lib/grape-route-helpers/decorated_route.rb @@ -0,0 +1,134 @@ +module GrapeRouteHelpers + # wrapper around Grape::Route that adds a helper method + class DecoratedRoute + attr_reader :route, :helper_names, :helper_arguments + + def initialize(route) + @route = route + @helper_names = [] + @helper_arguments = required_helper_segments + define_path_helpers + end + + def define_path_helpers + route_versions.each do |version| + route_attributes = { version: version } + method_name = path_helper_name(route_attributes) + @helper_names << method_name + define_path_helper(method_name, route_attributes) + end + end + + def define_path_helper(method_name, route_attributes) + method_body = <<-RUBY + def #{method_name}(attributes = {}) + attrs = HashWithIndifferentAccess.new( + #{route_attributes}.merge(attributes) + ) + + content_type = attrs.delete(:format) + path = '/' + path_segments_with_values(attrs).join('/') + extension = content_type ? '.' + content_type : '' + + path + extension + end + RUBY + instance_eval method_body + end + + def route_versions + if route_version + route_version.split('|') + else + [nil] + end + end + + def path_helper_name(opts = {}) + segments = path_segments_with_values(opts) + + name = if segments.empty? + 'root' + else + segments.join('_') + end + name + '_path' + end + + def segment_to_value(segment, opts = {}) + options = HashWithIndifferentAccess.new( + route_options.merge(opts) + ) + + if dynamic_segment?(segment) + key = segment.slice(1..-1) + options[key] + else + segment + end + end + + def path_segments_with_values(opts) + segments = path_segments.map { |s| segment_to_value(s, opts) } + segments.reject(&:blank?) + end + + def path_segments + pattern = %r{\(/?\.:format\)|/|\*} + route_path.split(pattern).reject(&:blank?) + end + + def dynamic_path_segments + segments = path_segments.select do |segment| + dynamic_segment?(segment) + end + segments.map { |s| s.slice(1..-1) } + end + + def dynamic_segment?(segment) + segment.start_with?(':') + end + + def required_helper_segments + segments_in_options = dynamic_path_segments.select do |segment| + route_options[segment.to_sym] + end + dynamic_path_segments - segments_in_options + end + + def optional_segments + ['format'] + end + + def uses_segments_in_path_helper?(segments) + requested = segments - optional_segments + required = required_helper_segments + + if requested.empty? && required.empty? + true + else + requested.all? do |segment| + required.include?(segment) + end + end + end + + # accessing underlying Grape::Route + + def route_path + route.route_path + end + + def route_options + route.instance_variable_get(:@options) + end + + def route_version + route.route_version + end + + def route_namespace + route.route_namespace + end + end +end diff --git a/lib/grape-route-helpers/named_route_matcher.rb b/lib/grape-route-helpers/named_route_matcher.rb new file mode 100644 index 0000000..b68ca1e --- /dev/null +++ b/lib/grape-route-helpers/named_route_matcher.rb @@ -0,0 +1,28 @@ +module GrapeRouteHelpers + # methods to extend Grape::Endpoint so that calls + # to unknown methods will look for a route with a matching + # helper function name + module NamedRouteMatcher + def method_missing(method_id, *arguments) + segments = arguments.first || {} + + route = Grape::API.decorated_routes.detect do |r| + route_match?(r, method_id, segments) + end + + if route + route.send(method_id, *arguments) + else + super + end + end + + def route_match?(route, method_name, segments) + return false unless route.respond_to?(method_name) + fail ArgumentError, + 'Helper options must be a hash' unless segments.is_a?(Hash) + requested_segments = segments.keys.map(&:to_s) + route.uses_segments_in_path_helper?(requested_segments) + end + end +end diff --git a/lib/grape-route-helpers/route_displayer.rb b/lib/grape-route-helpers/route_displayer.rb new file mode 100644 index 0000000..18f869a --- /dev/null +++ b/lib/grape-route-helpers/route_displayer.rb @@ -0,0 +1,25 @@ +module GrapeRouteHelpers + # class for displaying the path, helper method name, + # and required arguments for every Grape::Route. + class RouteDisplayer + def route_attributes + Grape::API.decorated_routes.map do |route| + { + route_path: route.route_path, + helper_names: route.helper_names, + helper_arguments: route.helper_arguments + } + end + end + + def display + puts 'Path, Helper, Arguments' + route_attributes.each do |attributes| + print "#{attributes[:route_path]}," + print "#{attributes[:helper_names]}," + print "#{attributes[:helper_arguments]}" + puts("\n") + end + end + end +end diff --git a/lib/grape-route-helpers/version.rb b/lib/grape-route-helpers/version.rb new file mode 100644 index 0000000..53a3b10 --- /dev/null +++ b/lib/grape-route-helpers/version.rb @@ -0,0 +1,4 @@ +# Gem version +module GrapeRouteHelpers + VERSION = '1.0.0' +end diff --git a/lib/grape/route_helpers.rb b/lib/grape/route_helpers.rb new file mode 100644 index 0000000..d09d0e4 --- /dev/null +++ b/lib/grape/route_helpers.rb @@ -0,0 +1 @@ +require 'grape-route-helpers' diff --git a/lib/tasks/grape_route_helpers.rake b/lib/tasks/grape_route_helpers.rake new file mode 100644 index 0000000..e7b238c --- /dev/null +++ b/lib/tasks/grape_route_helpers.rake @@ -0,0 +1,6 @@ +namespace :grape do + desc 'Print route helper methods.' + task routes: :environment do + GrapeRouteHelpers::RouteDisplayer.new.display + end +end diff --git a/spec/grape_route_helpers/decorated_route_spec.rb b/spec/grape_route_helpers/decorated_route_spec.rb new file mode 100644 index 0000000..4f1a202 --- /dev/null +++ b/spec/grape_route_helpers/decorated_route_spec.rb @@ -0,0 +1,169 @@ +require 'spec_helper' + +describe GrapeRouteHelpers::DecoratedRoute do + let(:api) { Spec::Support::RouteMatcherHelpers.api } + + let(:routes) do + api.routes.map do |route| + described_class.new(route) + end + end + + let(:index_route) do + routes.detect { |route| route.route_namespace == '/cats' } + end + + let(:show_route) do + routes.detect { |route| route.route_namespace == '/cats/:id' } + end + + let(:catch_all_route) do + routes.detect { |route| route.route_path =~ /\*/ } + end + + describe '#helper_names' do + context 'when an API has multiple versions' do + let(:api_versions) { %w(beta alpha v1) } + + before do + api.version api_versions + end + + it "returns the route's helper name for each version" do + helper_names = show_route.helper_names + expect(helper_names.size).to eq(api_versions.size) + end + end + + context 'when an API has one version' do + it "returns the route's helper name for that version" do + helper_name = show_route.helper_names.first + expect(helper_name).to eq('api_v1_cats_path') + end + end + end + + describe '#helper_arguments' do + context 'when no user input is needed to generate the correct path' do + it 'returns an empty array' do + expect(index_route.helper_arguments).to eq([]) + end + end + + context 'when user input is needed to generate the correct path' do + it 'returns an array of required segments' do + expect(show_route.helper_arguments).to eq(['id']) + end + end + end + + describe '#path_segments_with_values' do + context 'when path has dynamic segments' do + it 'replaces segments with corresponding values found in @options' do + opts = { id: 1 } + result = show_route.path_segments_with_values(opts) + expect(result).to include(1) + end + + context 'when options contains string keys' do + it 'replaces segments with corresponding values found in the options' do + opts = { 'id' => 1 } + result = show_route.path_segments_with_values(opts) + expect(result).to include(1) + end + end + end + end + + describe '#path_helper_name' do + it "returns the name of a route's helper method" do + expect(index_route.path_helper_name).to eq('api_v1_cats_path') + end + + context 'when the path is the root path' do + let(:api_with_root) do + Class.new(Grape::API) do + get '/' do + end + end + end + + let(:root_route) do + grape_route = api_with_root.routes.first + described_class.new(grape_route) + end + + it 'returns "root_path"' do + result = root_route.path_helper_name + expect(result).to eq('root_path') + end + end + + context 'when the path is a catch-all path' do + it 'returns a name without the glob star' do + result = catch_all_route.path_helper_name + expect(result).to eq('api_v1_path_path') + end + end + end + + describe '#segment_to_value' do + context 'when segment is dynamic' do + it 'returns the value the segment corresponds to' do + result = index_route.segment_to_value(':version') + expect(result).to eq('v1') + end + + context 'when segment is found in options' do + it 'returns the value found in options' do + options = { id: 1 } + result = show_route.segment_to_value(':id', options) + expect(result).to eq(1) + end + end + end + + context 'when segment is static' do + it 'returns the segment' do + result = index_route.segment_to_value('api') + expect(result).to eq('api') + end + end + end + + describe 'path helper method' do + context 'when helper does not require arguments' do + it 'returns the correct path' do + path = index_route.api_v1_cats_path + expect(path).to eq('/api/v1/cats') + end + end + + context 'when arguments are needed required to construct the right path' do + context 'when not missing arguments' do + it 'returns the correct path' do + path = show_route.api_v1_cats_path(id: 1) + expect(path).to eq('/api/v1/cats/1') + end + end + end + + context "when a route's API has multiple versions" do + before(:each) do + api.version %w(v1 v2) + end + + it 'returns a path for each version' do + expect(index_route.api_v1_cats_path).to eq('/api/v1/cats') + expect(index_route.api_v2_cats_path).to eq('/api/v2/cats') + end + end + + context 'when a format is given' do + it 'returns the path with a correct extension' do + path = show_route.api_v1_cats_path(id: 1, format: 'xml') + expect(path).to eq('/api/v1/cats/1.xml') + end + end + end +end diff --git a/spec/grape_route_helpers/named_route_matcher_spec.rb b/spec/grape_route_helpers/named_route_matcher_spec.rb new file mode 100644 index 0000000..96345a6 --- /dev/null +++ b/spec/grape_route_helpers/named_route_matcher_spec.rb @@ -0,0 +1,164 @@ +require 'spec_helper' + +describe GrapeRouteHelpers::NamedRouteMatcher do + include described_class + + let(:api) { Spec::Support::RouteMatcherHelpers.api } + + let(:routes) do + api + Grape::API.decorated_routes + end + + let(:ping_route) do + routes.detect do |route| + route.route_path =~ /ping/ + end + end + + let(:index_route) do + routes.detect do |route| + route.route_namespace =~ /cats$/ + end + end + + let(:show_route) do + routes.detect do |route| + route.route_namespace =~ /cats\/:id/ + end + end + + describe '#route_match?' do + context 'when route responds to a method name' do + let(:route) { ping_route } + let(:method_name) { :api_v1_ping_path } + let(:segments) { {} } + + context 'when segments is not a hash' do + it 'raises an ArgumentError' do + expect do + route_match?(route, method_name, 1234) + end.to raise_error(ArgumentError) + end + end + + it 'returns true' do + is_match = route_match?(route, method_name, segments) + expect(is_match).to eq(true) + end + + context 'when requested segments contains expected options' do + let(:segments) { { 'format' => 'xml' } } + + it 'returns true' do + is_match = route_match?(route, method_name, segments) + expect(is_match).to eq(true) + end + + context 'when no dynamic segments are requested' do + context 'when the route requires dynamic segments' do + let(:route) { show_route } + let(:method_name) { :ap1_v1_cats_path } + + it 'returns false' do + is_match = route_match?(route, method_name, segments) + expect(is_match).to eq(false) + end + end + + context 'when the route does not require dynamic segments' do + it 'returns true' do + is_match = route_match?(route, method_name, segments) + expect(is_match).to eq(true) + end + end + end + + context 'when route requires the requested segments' do + let(:route) { show_route } + let(:method_name) { :api_v1_cats_path } + let(:segments) { { id: 1 } } + + it 'returns true' do + is_match = route_match?(route, method_name, segments) + expect(is_match).to eq(true) + end + end + + context 'when route does not require the requested segments' do + let(:segments) { { some_option: 'some value' } } + + it 'returns false' do + is_match = route_match?(route, method_name, segments) + expect(is_match).to eq(false) + end + end + end + + context 'when segments contains unexpected options' do + let(:segments) { { some_option: 'some value' } } + + it 'returns false' do + is_match = route_match?(route, method_name, segments) + expect(is_match).to eq(false) + end + end + end + + context 'when route does not respond to a method name' do + let(:method_name) { :some_other_path } + let(:route) { ping_route } + let(:segments) { {} } + + it 'returns false' do + is_match = route_match?(route, method_name, segments) + expect(is_match).to eq(false) + end + end + end + + describe '#method_missing' do + context 'when method name matches a Grape::Route path helper name' do + it 'returns the path for that route object' do + api + + path = api_v1_ping_path + expect(path).to eq('/api/v1/ping') + end + + context 'when argument to the helper is not a hash' do + it 'raises an ArgumentError' do + api + + expect do + api_v1_ping_path(1234) + end.to raise_error(ArgumentError) + end + end + end + + context 'when method name does not match a Grape::Route path helper name' do + it 'raises a NameError' do + api + + expect do + some_method_name + end.to raise_error(NameError) + end + end + end + + context 'when Grape::Route objects share the same helper name' do + context 'when helpers require different segments to generate their path' do + it 'uses arguments to infer which route to use' do + api + + show_path = api_v1_cats_path('id' => 1) + expect(show_path).to eq('/api/v1/cats/1') + + index_path = api_v1_cats_path + expect(index_path).to eq('/api/v1/cats') + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..1e49fb7 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,9 @@ +$LOAD_PATH.unshift File.expand_path('lib') + +require 'pry' +require 'grape/route_helpers' + +support_files = Dir.glob('spec/support/*') +support_files.each { |f| require File.expand_path(f) } + +include Spec::Support::RouteMatcherHelpers diff --git a/spec/support/route_matcher_helpers.rb b/spec/support/route_matcher_helpers.rb new file mode 100644 index 0000000..f149d2d --- /dev/null +++ b/spec/support/route_matcher_helpers.rb @@ -0,0 +1,34 @@ +module Spec + module Support + # container for methods used in specs + module RouteMatcherHelpers + def self.api + Class.new(Grape::API) do + version 'v1' + prefix 'api' + format 'json' + + get 'ping' do + 'pong' + end + + resource :cats do + get '/' do + %w(cats cats cats) + end + + route_param :id do + get do + 'cat' + end + end + end + + route :any, '*path' do + 'catch-all route' + end + end + end + end + end +end