├── .gitignore ├── .travis.yml ├── Gemfile ├── Guardfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── grape-reload.gemspec ├── lib ├── core_ext │ ├── object_space.rb │ └── string │ │ └── colorize.rb ├── grape │ ├── reload.rb │ └── reload │ │ ├── dependency_map.rb │ │ ├── grape_api.rb │ │ ├── rack.rb │ │ ├── rack_builder.rb │ │ ├── storage.rb │ │ ├── version.rb │ │ └── watcher.rb └── ripper │ └── extract_constants.rb └── spec ├── fixtures ├── app1 │ ├── mounts │ │ ├── lib.rb │ │ └── mount.rb │ └── test1.rb ├── app2 │ ├── mounts │ │ ├── lib.rb │ │ └── mount.rb │ └── test2.rb └── lib │ ├── lib1.rb │ └── lib2.rb ├── grape └── reload │ ├── autoreload_interceptor_spec.rb │ ├── dependency_map_spec.rb │ ├── rack_builder_spec.rb │ └── watcher_spec.rb ├── ripper └── extract_constants_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | *.bundle 19 | *.so 20 | *.o 21 | *.a 22 | mkmf.log 23 | .ruby-* 24 | .idea -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | cache: bundler 3 | 4 | rvm: 5 | - jruby 6 | - 2.0.0 7 | - 2.1.2 8 | - 2.2.0 9 | 10 | script: 'bundle exec rake' 11 | 12 | notifications: 13 | email: 14 | recipients: 15 | - amar4enko@gmail.com 16 | on_failure: change 17 | on_success: never -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in grape-reload.gemspec 4 | gemspec -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | notification :terminal_notifier 2 | 3 | guard 'rspec', cmd: 'bundle exec rspec --color --format progress' do 4 | # watch /lib/ files 5 | watch(%r{^lib/(.+).rb$}) do |m| 6 | "spec/#{m[1]}_spec.rb" 7 | end 8 | 9 | # watch /spec/ files 10 | watch(%r{^spec/(.+).rb$}) do |m| 11 | "spec/#{m[1]}.rb" 12 | end 13 | end -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 AMar4enko 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/AlexYankee/grape-reload.svg?branch=master)](https://travis-ci.org/AlexYankee/grape-reload) 2 | [![Gem Version](https://badge.fury.io/rb/grape-reload.svg)](http://badge.fury.io/rb/grape-reload) 3 | # Grape::Reload 4 | 5 | Expiremental approach for providing reloading of Grape-based rack applications in dev environment. 6 | It uses Ripper to extract class usage and definitions from code and reloads files and API classes based on dependency map. 7 | 8 | ## Installation 9 | 10 | Add this line to your application's Gemfile: 11 | 12 | gem 'grape-reload' 13 | 14 | And then execute: 15 | 16 | $ bundle 17 | 18 | Or install it yourself as: 19 | 20 | $ gem install grape-reload 21 | 22 | ## Usage 23 | 24 | In your config.ru you use Grape::RackBuilder to mount your apps: 25 | 26 | Grape::RackBuilder.setup do 27 | logger Logger.new(STDOUT) 28 | add_source_path File.expand_path('**/*.rb', YOUR_APP_ROOT) 29 | reload_threshold 1 # Reload sources not often one second 30 | force_reloading true # Force reloading for any environment (not just dev), useful for testing 31 | mount 'Your::App', to: '/' 32 | mount 'Your::App1', to: '/app1' 33 | end 34 | 35 | run Grape::RackBuilder.boot!.application 36 | 37 | Grape::Reload will resolve all class dependencies and load your files in appropriate order, so you don't need to include 'require' or 'require_relative' in your sources. 38 | 39 | ## Restrictions: 40 | ### Monkey patching 41 | If you want to monkey-patch class in code, you want to be reloaded, for any reason, you should use 42 | 43 | AlreadyDefined.class_eval do 44 | end 45 | 46 | instead of 47 | 48 | class AlreadyDefined 49 | end 50 | 51 | because it confuses dependency resolver 52 | 53 | ### Full-qualified const name usage 54 | Consider code 55 | 56 | require 'some_file' # (declares SomeModule::SomeClass) 57 | 58 | here_is_your_code(SomeClass) 59 | 60 | Ruby will resolve SomeClass to SomeModule::SomeClass in runtime. 61 | Dependency resolver will display an error, because it expects you to 62 | use full-qualified class name in this situation. 63 | Anyway, it would not raise exception anymore (since e5b58f4) 64 | 65 | here_is_your_code(SomeModule::SomeClass) 66 | 67 | ### Other restrictions 68 | 69 | Avoid declaring constants as follows 70 | 71 | class AlreadyDeclaredModule::MyClass 72 | end 73 | 74 | use 75 | 76 | module AlreadyDeclaredModule 77 | class MyClass 78 | end 79 | end 80 | 81 | instead 82 | 83 | ## Known issues 84 | 85 | * It still lacks of good design :( 86 | * MOAR TESTS!!!!111 87 | 88 | ## TODO 89 | 90 | * example Grape application with Grape::Reload 91 | * Spork integration example 92 | 93 | ## Contributing 94 | 95 | 1. Fork it ( https://github.com/AlexYankee/grape-reload/fork ) 96 | 2. Create your feature branch (`git checkout -b my-new-feature`) 97 | 3. Commit your changes (`git commit -am 'Add some feature'`) 98 | 4. Push to the branch (`git push origin my-new-feature`) 99 | 5. Create a new Pull Request 100 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rspec/core/rake_task' 2 | require 'bundler/gem_tasks' 3 | 4 | # Default directory to look in is `/specs` 5 | # Run with `rake spec` 6 | RSpec::Core::RakeTask.new(:spec) do |task| 7 | task.rspec_opts = ['--color', '--format', 'progress'] 8 | end 9 | 10 | task :default => :spec -------------------------------------------------------------------------------- /grape-reload.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'grape/reload/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "grape-reload" 8 | spec.version = Grape::Reload::VERSION 9 | spec.authors = ["AMar4enko"] 10 | spec.email = ["amar4enko@gmail.com"] 11 | spec.summary = 'Grape autoreload gem' 12 | spec.homepage = "https://github.com/AlexYankee/grape-reload" 13 | spec.license = "MIT" 14 | 15 | spec.files = `git ls-files -z`.split("\x0") 16 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 17 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 18 | spec.require_paths = ["lib"] 19 | 20 | spec.add_runtime_dependency "grape", ">= 0.10.1" 21 | spec.add_runtime_dependency "rack", ">= 1.5.2" 22 | 23 | spec.add_development_dependency "bundler", "~> 1.6" 24 | spec.add_development_dependency "rake" 25 | 26 | spec.add_development_dependency "rspec" 27 | spec.add_development_dependency "rack-test" 28 | spec.add_development_dependency "terminal-notifier-guard" 29 | spec.add_development_dependency "rspec-nc" 30 | spec.add_development_dependency "guard" 31 | spec.add_development_dependency "guard-rspec" 32 | spec.add_development_dependency "pry" 33 | spec.add_development_dependency "pry-remote" 34 | spec.add_development_dependency "pry-nav" 35 | end 36 | -------------------------------------------------------------------------------- /lib/core_ext/object_space.rb: -------------------------------------------------------------------------------- 1 | module ObjectSpace 2 | class << self 3 | ## 4 | # Returns all the classes in the object space. 5 | # Optionally, a block can be passed, for example the following code 6 | # would return the classes that start with the character "A": 7 | # 8 | # ObjectSpace.classes do |klass| 9 | # if klass.to_s[0] == "A" 10 | # klass 11 | # end 12 | # end 13 | # 14 | def classes(&block) 15 | rs = Set.new 16 | 17 | ObjectSpace.each_object(Class).each do |klass| 18 | if block 19 | if r = block.call(klass) 20 | # add the returned value if the block returns something 21 | rs << r 22 | end 23 | else 24 | rs << klass 25 | end 26 | end 27 | 28 | rs 29 | end 30 | 31 | ## 32 | # Returns a list of existing classes that are not included in "snapshot" 33 | # This method is useful to get the list of new classes that were loaded 34 | # after an event like requiring a file. 35 | # Usage: 36 | # 37 | # snapshot = ObjectSpace.classes 38 | # # require a file 39 | # ObjectSpace.new_classes(snapshot) 40 | # 41 | def new_classes(snapshot) 42 | self.classes do |klass| 43 | if !snapshot.include?(klass) 44 | klass 45 | end 46 | end 47 | end 48 | end 49 | end -------------------------------------------------------------------------------- /lib/core_ext/string/colorize.rb: -------------------------------------------------------------------------------- 1 | # Ruby color support for Windows 2 | # If you want colorize on Windows with Ruby 1.9, please use ansicon: 3 | # https://github.com/adoxa/ansicon 4 | # Other ways, add `gem 'win32console'` to your Gemfile. 5 | if RUBY_PLATFORM =~ /mswin|mingw/ && RUBY_VERSION < '2.0' && ENV['ANSICON'].nil? 6 | begin 7 | require 'win32console' 8 | rescue LoadError 9 | end 10 | end 11 | 12 | ## 13 | # Add colors 14 | # 15 | class String 16 | # colorize(:red) 17 | def colorize(args) 18 | case args 19 | when Symbol 20 | Colorizer.send(args, self) 21 | when Hash 22 | Colorizer.send(args[:color], self, args[:mode]) 23 | end 24 | end 25 | 26 | # Used to colorize strings for the shell 27 | class Colorizer 28 | # Returns colors integer mapping 29 | def self.colors 30 | @_colors ||= { 31 | :default => 9, 32 | :black => 30, 33 | :red => 31, 34 | :green => 32, 35 | :yellow => 33, 36 | :blue => 34, 37 | :magenta => 35, 38 | :cyan => 36, 39 | :white => 37 40 | } 41 | end 42 | 43 | # Returns modes integer mapping 44 | def self.modes 45 | @_modes ||= { 46 | :default => 0, 47 | :bold => 1 48 | } 49 | end 50 | 51 | # Defines class level color methods 52 | # i.e Colorizer.red("hello") 53 | class << self 54 | Colorizer.colors.each do |color, value| 55 | define_method(color) do |target, mode_name = :default| 56 | mode = modes[mode_name] || modes[:default] 57 | "\e[#{mode};#{value}m" << target << "\e[0m" 58 | end 59 | end 60 | end 61 | end 62 | end -------------------------------------------------------------------------------- /lib/grape/reload.rb: -------------------------------------------------------------------------------- 1 | require "grape/reload/version" 2 | require 'core_ext/string/colorize' 3 | require 'core_ext/object_space' 4 | require "grape/reload/rack_builder" 5 | require "grape/reload/rack" -------------------------------------------------------------------------------- /lib/grape/reload/dependency_map.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../ripper/extract_constants' 2 | 3 | module Grape 4 | module Reload 5 | class UnresolvedDependenciesError < RuntimeError 6 | def message; 'One or more unresolved dependencies found' end 7 | end 8 | 9 | 10 | class DependencyMap 11 | extend Forwardable 12 | include TSort 13 | 14 | attr_accessor :map 15 | 16 | def tsort_each_child(node, &block) 17 | @files_linked.fetch(node).each(&block) 18 | end 19 | 20 | def tsort_each_node(&block) 21 | @files_linked.each_key(&block) 22 | end 23 | 24 | def initialize(sources) 25 | @sources = sources 26 | files = @sources.map{|p| Dir[p]}.flatten.uniq 27 | @map = Hash[files.zip(files.map do |file| 28 | begin 29 | Ripper.extract_constants(File.read(file)) 30 | rescue 31 | Grape::RackBuilder.logger.error("Theres is an error while parsing #{file}") 32 | [] 33 | end 34 | end)] 35 | end 36 | 37 | def sorted_files 38 | tsort 39 | end 40 | 41 | def files 42 | map.keys 43 | end 44 | 45 | def dependent_classes(loaded_file) 46 | classes = [] 47 | sorted = sorted_files 48 | cycle_classes = ->(file, visited_files = []){ 49 | return if visited_files.include?(file) 50 | visited_files ||= [] 51 | visited_files << file 52 | classes |= map[file][:declared] 53 | map[file][:declared].map{|klass| 54 | file_class = map.each_pair 55 | .sort{|a1, a2| 56 | sorted.index(a1.first) - sorted.index(a2.first) 57 | } 58 | .select{|f, const_info| const_info[:used].include?(klass) } 59 | .map{|k,v| [k,v[:declared]]} 60 | 61 | file_class.each {|fc| 62 | classes |= fc.last 63 | cycle_classes.call(fc.first, visited_files) 64 | } 65 | } 66 | } 67 | cycle_classes.call(loaded_file) 68 | classes 69 | end 70 | 71 | def fs_changes(&block) 72 | result = { 73 | added: [], 74 | removed: [], 75 | changed: [] 76 | } 77 | files = @sources.map{|p| Dir[p]}.flatten.uniq 78 | result[:added] = files - map.keys 79 | result[:removed] = map.keys - files 80 | result[:changed] = map.keys.select(&block) 81 | result 82 | end 83 | 84 | def class_file(klass) 85 | @file_class['::'+klass.to_s] 86 | end 87 | 88 | def files_reloading(&block) 89 | yield 90 | initialize(@sources) 91 | resolve_dependencies! 92 | end 93 | 94 | def resolve_dependencies! 95 | @file_class = Hash[map.each_pair.map{|file, hash| 96 | hash[:declared].zip([file]*hash[:declared].size) 97 | }.flatten(1)] 98 | @files_linked = {} 99 | 100 | unresolved_classes = {} 101 | lib_classes = [] 102 | map.each_pair do |file, const_info| 103 | @files_linked[file] ||= [] 104 | const_info[:used].each_with_index do |variants, idx| 105 | next if lib_classes.include?(variants.last) 106 | variant = variants.find{|v| @file_class[v]} 107 | if variant.nil? 108 | const_ref = variants.last 109 | begin 110 | const_ref.constantize 111 | lib_classes << const_ref 112 | rescue 113 | unresolved_classes[const_ref] ||= [] 114 | unresolved_classes[const_ref] << file 115 | end 116 | else 117 | @files_linked[file] << @file_class[variant] unless @files_linked[file].include?(@file_class[variant]) 118 | const_info[:used][idx] = variant 119 | end 120 | end 121 | end 122 | 123 | unresolved_classes.each_pair do |klass, filenames| 124 | filenames.each {|filename| Grape::RackBuilder.logger.error("Unresolved const reference #{klass} from: #{filename}".colorize(:red)) } 125 | end 126 | 127 | Grape::RackBuilder.logger.error("One or more unresolved dependencies found".colorize(:red)) if unresolved_classes.any? 128 | end 129 | end 130 | 131 | class Sources 132 | extend Forwardable 133 | def_instance_delegators :'@dm', :sorted_files, :class_file, :fs_changes, :dependent_classes, :files_reloading 134 | def initialize(sources) 135 | @sources = sources 136 | @dm = DependencyMap.new(sources) 137 | @dm.resolve_dependencies! 138 | end 139 | 140 | def file_excluded?(file) 141 | @sources.find{|path| File.fnmatch?(path, file) }.nil? 142 | end 143 | end 144 | end 145 | end -------------------------------------------------------------------------------- /lib/grape/reload/grape_api.rb: -------------------------------------------------------------------------------- 1 | require 'grape' 2 | 3 | module Grape 4 | class ReloadMiddleware 5 | class << self 6 | def [](threshold) 7 | threshold ||= 2 8 | eval < 0 ? threshold.to_s+".seconds" : "false" } 13 | end 14 | } 15 | CLASS 16 | end 17 | end 18 | 19 | def initialize(app) 20 | @app_klass = app 21 | end 22 | 23 | def call(*args) 24 | if reload_threshold && (Time.now > (@last || reload_threshold.ago) + 1) 25 | Thread.list.size > 1 ? Thread.exclusive { Grape::Reload::Watcher.reload! } : Grape::Reload::Watcher.reload! 26 | @last = Time.now 27 | else 28 | Thread.list.size > 1 ? Thread.exclusive { Grape::Reload::Watcher.reload! } : Grape::Reload::Watcher.reload! 29 | end 30 | @app_klass.constantize.call(*args) 31 | end 32 | def reload_threshold; 2.seconds end 33 | end 34 | end 35 | 36 | module Grape 37 | module Reload 38 | module EndpointPatch 39 | def clear_inheritable_settings! 40 | @inheritable_settings.clear 41 | end 42 | end 43 | 44 | module AutoreloadInterceptor 45 | extend ActiveSupport::Concern 46 | 47 | def add_head_not_allowed_methods_and_options_methods(*args, &block) 48 | self.class.skip_declaration = true 49 | super(*args, &block) 50 | self.class.skip_declaration = false 51 | end 52 | 53 | module ClassMethods 54 | attr_accessor :skip_declaration 55 | 56 | def namespace(*args, &block) 57 | @skip_declaration = true 58 | class_declaration << [:namespace,args,block] 59 | super(*args, &block) 60 | @skip_declaration = false 61 | end 62 | 63 | [:set, :imbue, :mount, :route, :desc, :params, :helpers, :format, :formatter, :parser, :error_formatter, :content_type, :version].each do |method| 64 | eval <(value) { 110 | case value 111 | when Hash 112 | Hash[value.each_pair.map { |k,v| [proc.call(k), proc.call(v)] }] 113 | when Array 114 | value.map { |v| proc.call(v) } 115 | when Class 116 | return value if value.to_s[0,2] == '#<' 117 | value.to_s.constantize 118 | else 119 | value 120 | end 121 | } 122 | end 123 | end 124 | end 125 | end 126 | end 127 | 128 | module Grape 129 | module Util 130 | class InheritableSetting 131 | def clear! 132 | self.route = {} 133 | self.api_class = {} 134 | self.namespace = InheritableValues.new # only inheritable from a parent when 135 | # used with a mount, or should every API::Class be a seperate namespace by default? 136 | self.namespace_inheritable = InheritableValues.new 137 | self.namespace_stackable = StackableValues.new 138 | 139 | self.point_in_time_copies = [] 140 | 141 | # self.parent = nil 142 | end 143 | end 144 | end 145 | end 146 | 147 | 148 | Grape::API.singleton_class.class_eval do 149 | alias_method :inherited_shadowed, :inherited 150 | def inherited(*args) 151 | inherited_shadowed(*args) 152 | args.first.class_eval do 153 | include Grape::Reload::AutoreloadInterceptor 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /lib/grape/reload/rack.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | class Cascade 3 | def reinit! 4 | @apps.map{|app| app.reinit! if app.respond_to?('reinit!') } 5 | end 6 | end 7 | end -------------------------------------------------------------------------------- /lib/grape/reload/rack_builder.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/inflector' 2 | require_relative '../reload/watcher' 3 | require_relative '../reload/grape_api' 4 | require_relative '../reload/dependency_map' 5 | 6 | 7 | RACK_ENV = ENV["RACK_ENV"] ||= "development" unless defined?(RACK_ENV) 8 | 9 | module Grape 10 | module RackBuilder 11 | module LoggingStub 12 | class << self 13 | [:error, :debug, :exception, :info, :devel].each do |level| 14 | define_method(level){|*args| 15 | # Silence all reloader output by default with stub 16 | } 17 | end 18 | end 19 | end 20 | 21 | class MountConfig 22 | attr_accessor :app_class, :options, :mount_root 23 | def initialize(options) 24 | options.each_pair{|k,v| send(:"#{k}=", v) } 25 | end 26 | end 27 | 28 | class Config 29 | attr_accessor :mounts, :sources, :options, :force_reloading 30 | 31 | {environment: RACK_ENV, reload_threshold: 1, logger: LoggingStub, force_reloading: false}.each_pair do |attr, default| 32 | attr_accessor attr 33 | define_method(attr) { |value = nil| 34 | @options ||= {} 35 | @options[attr] = value if value 36 | @options[attr] || default 37 | } 38 | end 39 | 40 | def add_source_path(glob) 41 | (@sources ||= []) << glob 42 | end 43 | 44 | def use(*args, &block) 45 | middleware << [args, block] 46 | end 47 | 48 | def mount(app_class, options) 49 | mounts << MountConfig.new( 50 | app_class: app_class, 51 | mount_root: options.delete(:to) || '/', 52 | options: options 53 | ) 54 | end 55 | 56 | def mounts 57 | @mounts ||= [] 58 | end 59 | 60 | def middleware 61 | @middleware ||= [] 62 | end 63 | end 64 | 65 | module ClassMethods 66 | def setup(&block) 67 | config.instance_eval(&block) 68 | self 69 | end 70 | 71 | def boot! 72 | @rack_app = nil 73 | Grape::Reload::Watcher.setup(sources: Grape::Reload::Sources.new(config.sources)) 74 | self 75 | end 76 | 77 | def application 78 | return @rack_app if @rack_app 79 | mounts = config.mounts 80 | middleware = config.middleware 81 | force_reloading = config.force_reloading 82 | environment = config.environment 83 | reload_threshold = config.reload_threshold 84 | @rack_app = ::Rack::Builder.new do 85 | middleware.each do |parameters| 86 | parameters.length == 1 ? use(*parameters.first) : use(*parameters.first, ¶meters.last) 87 | end 88 | 89 | mounts.each_with_index do |m| 90 | if (environment == 'development') || force_reloading 91 | r = Rack::Builder.new 92 | r.use Grape::ReloadMiddleware[reload_threshold] 93 | r.run m.app_class 94 | map(m.mount_root) { run r } 95 | else 96 | app_klass = m.app_class.constantize 97 | map(m.mount_root) { run app_klass } 98 | end 99 | end 100 | end 101 | end 102 | 103 | def mounted_apps_of(file) 104 | config.mounts.select { |mount| File.identical?(file, Grape::Reloader.root(mount.app_file)) } 105 | end 106 | 107 | def reloadable_apps 108 | config.mounts 109 | end 110 | 111 | def logger 112 | config.logger 113 | end 114 | 115 | private 116 | def config 117 | @config ||= Config.new 118 | end 119 | end 120 | class << self 121 | include Grape::RackBuilder::ClassMethods 122 | end 123 | end 124 | end -------------------------------------------------------------------------------- /lib/grape/reload/storage.rb: -------------------------------------------------------------------------------- 1 | 2 | 3 | module Grape 4 | module Reload 5 | module Storage 6 | class << self 7 | def clear! 8 | files.each_key do |file| 9 | remove(file) 10 | Watcher.remove_feature(file) 11 | end 12 | @files = {} 13 | end 14 | 15 | def remove(name) 16 | file = files[name] || return 17 | file[:constants].each{ |constant| Watcher.remove_constant(constant) } 18 | file[:features].each{ |feature| Watcher.remove_feature(feature) } 19 | files.delete(name) 20 | end 21 | 22 | def prepare(name) 23 | file = remove(name) 24 | @old_entries ||= {} 25 | @old_entries[name] = { 26 | :constants => ObjectSpace.classes, 27 | :features => old_features = Set.new($LOADED_FEATURES.dup) 28 | } 29 | features = file && file[:features] || [] 30 | features.each{ |feature| Watcher.safe_load(feature, :force => true) unless Watcher.feature_excluded?(feature)} 31 | Watcher.remove_feature(name) if old_features.include?(name) 32 | end 33 | 34 | def commit(name) 35 | entry = { 36 | :constants => ObjectSpace.new_classes(@old_entries[name][:constants]), 37 | :features => Set.new($LOADED_FEATURES) - @old_entries[name][:features] - [name] 38 | } 39 | files[name] = entry 40 | @old_entries.delete(name) 41 | end 42 | 43 | def rollback(name) 44 | new_constants = ObjectSpace.new_classes(@old_entries[name][:constants]) 45 | new_constants.each{ |klass| Watcher.remove_constant(klass) } 46 | @old_entries.delete(name) 47 | end 48 | 49 | private 50 | 51 | def files 52 | @files ||= {} 53 | end 54 | end 55 | end 56 | end 57 | end -------------------------------------------------------------------------------- /lib/grape/reload/version.rb: -------------------------------------------------------------------------------- 1 | module Grape 2 | module Reload 3 | VERSION = "0.1.0" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/grape/reload/watcher.rb: -------------------------------------------------------------------------------- 1 | require 'ripper' 2 | require_relative 'rack_builder' 3 | require_relative 'storage' 4 | 5 | module Grape 6 | module Reload 7 | module Watcher 8 | class << self 9 | MTIMES = {} 10 | 11 | attr_reader :sources 12 | def rack_builder; Grape::RackBuilder end 13 | 14 | def logger; Grape::RackBuilder.logger end 15 | 16 | def safe_load(file, options={}) 17 | return unless options[:force] || file_changed?(file) 18 | 19 | Storage.prepare(file) # might call #safe_load recursively 20 | logger.debug((file_new?(file) ? "loading" : "reloading") + " #{file}" ) 21 | begin 22 | with_silence{ require(file) } 23 | Storage.commit(file) 24 | update_modification_time(file) 25 | rescue Exception => exception 26 | unless options[:cyclic] 27 | logger.error exception 28 | logger.error "Failed to load #{file}; removing partially defined constants" 29 | end 30 | Storage.rollback(file) 31 | raise 32 | end 33 | end 34 | 35 | ## 36 | # Tells if a feature should be excluded from Reloader tracking. 37 | # 38 | def remove_feature(file) 39 | $LOADED_FEATURES.delete(file) unless feature_excluded?(file) 40 | end 41 | 42 | ## 43 | # Tells if a feature should be excluded from Reloader tracking. 44 | # 45 | def feature_excluded?(file) 46 | @sources.file_excluded?(file) 47 | end 48 | 49 | def constant_excluded?(const) 50 | @sources.class_file(const).nil? 51 | end 52 | 53 | def files_for_rotation 54 | files = Set.new 55 | files += @sources.sorted_files.map{|p| Dir[p]}.flatten.uniq 56 | end 57 | 58 | def setup(options) 59 | @sources = options[:sources] 60 | load_files! 61 | end 62 | 63 | ### 64 | # Macro for mtime update. 65 | # 66 | def update_modification_time(file) 67 | MTIMES[file] = File.mtime(file) 68 | end 69 | 70 | 71 | def clear 72 | MTIMES.each_key{|f| Storage.remove(f)} 73 | MTIMES.clear 74 | end 75 | 76 | def load_files! 77 | files_to_load = files_for_rotation.to_a 78 | tries = {} 79 | while files_to_load.any? 80 | f = files_to_load.shift 81 | tries[f] = 1 unless tries[f] 82 | begin 83 | safe_load(f, cyclic: true, force: true) 84 | rescue 85 | logger.error $! 86 | tries[f] += 1 87 | if tries[f] < 3 88 | files_to_load << f 89 | else 90 | raise 91 | end 92 | end 93 | end 94 | end 95 | 96 | def reload! 97 | files = @sources.fs_changes{|file| 98 | File.mtime(file) > MTIMES[file] 99 | } 100 | changed_files_sorted = @sources.sorted_files.select{|f| files[:changed].include?(f)} 101 | @sources.files_reloading do 102 | changed_files_sorted.each{|f| safe_load(f)} 103 | end 104 | changed_files_sorted.map{|f| @sources.dependent_classes(f) }.flatten.uniq.each {|class_name| 105 | next unless (klass = class_name.constantize).kind_of? Class 106 | klass.reinit! if klass.respond_to?('reinit!') 107 | } 108 | end 109 | 110 | ## 111 | # Removes the specified class and constant. 112 | # 113 | def remove_constant(const) 114 | return if constant_excluded?(const) 115 | base, _, object = const.to_s.rpartition('::') 116 | base = base.empty? ? Object : base.constantize 117 | base.send :remove_const, object 118 | logger.devel "Removed constant #{const} from #{base}" 119 | rescue NameError 120 | end 121 | 122 | ### 123 | # Returns true if the file is new or it's modification time changed. 124 | # 125 | def file_changed?(file) 126 | file_new?(file) || File.mtime(file) > MTIMES[file] 127 | end 128 | 129 | def file_new?(file) 130 | MTIMES[file].nil? 131 | end 132 | 133 | private 134 | def with_silence 135 | verbosity_level, $-v = $-v, nil 136 | yield 137 | ensure 138 | $-v = verbosity_level 139 | end 140 | end 141 | end 142 | end 143 | end -------------------------------------------------------------------------------- /lib/ripper/extract_constants.rb: -------------------------------------------------------------------------------- 1 | require 'ripper' 2 | require 'forwardable' 3 | 4 | class TraversingContext 5 | extend Forwardable 6 | attr_reader :module, :options 7 | def_instance_delegators :'@options', :'[]', :'[]=' 8 | 9 | def initialize(mod = [], options = {}) 10 | @module = mod 11 | @options = options 12 | end 13 | 14 | def push_modules(*modules) 15 | @module = @module.concat(modules) 16 | end 17 | 18 | def module_name 19 | @module.join('::') 20 | end 21 | 22 | def full_class_name(class_name) 23 | module_name + '::' + class_name 24 | end 25 | end 26 | 27 | class TraversingResult 28 | attr_reader :namespace, :declared, :used, :parent, :children 29 | def initialize(namespace = nil, parent = nil) 30 | @declared = [] 31 | @used = [] 32 | @parent = parent 33 | @namespace = namespace 34 | @children = [] 35 | end 36 | 37 | def declare_const(const) 38 | @declared << const 39 | end 40 | 41 | def use_const(const, analyze = true) 42 | if analyze 43 | return if @used.map{|a| a.last }.include?(const) 44 | if const.start_with?('::') 45 | @used << [const] 46 | else 47 | const_ary = const.split('::') 48 | variants = [] 49 | if const_ary.first == namespace 50 | (variants << const_ary.dup).last.shift 51 | else 52 | (variants << const_ary.dup).last.unshift(@namespace) unless @namespace.nil? 53 | end 54 | 55 | variants << [ const_ary ] 56 | @used << (variants.map{|v| v.join('::')} << '::' + const) 57 | end 58 | else 59 | @used << const 60 | end 61 | end 62 | 63 | def nest(namespace) 64 | r = TraversingResult.new(namespace, self) 65 | @children << r 66 | r 67 | end 68 | 69 | def used; @used end 70 | def declared; @declared end 71 | 72 | def full_namespace 73 | p = self 74 | namespace_parts = [] 75 | namespace_parts << p.namespace if p.namespace 76 | unless p.parent.nil? 77 | p = p.parent 78 | namespace_parts.unshift(p.namespace) if p.namespace 79 | end 80 | 81 | namespace_parts 82 | end 83 | 84 | def extract_consts 85 | result = { 86 | declared: declared.map{|d| (namespace || '') + '::' + d }, 87 | used: [] 88 | } 89 | 90 | @children.map(&:extract_consts).each{|c| 91 | result[:declared] = result[:declared].concat(c[:declared].map{|d| (namespace || '') + '::' + d }) 92 | result[:used] = result[:used].concat(c[:used].map!{|_| _.map!{|v| 93 | if v.start_with?('::') || (namespace && v.start_with?(namespace + '::')) 94 | v 95 | else 96 | (namespace || '') + '::' + v 97 | end 98 | } }) 99 | } 100 | 101 | result[:used] = result[:used].reject {|variants| 102 | !variants.find{|v| result[:declared].include?(v) }.nil? 103 | } 104 | 105 | used = self.used.reject {|variants| !variants.find{|v| result[:declared].include?(v) }.nil? } 106 | if namespace.nil? 107 | used = used.map {|variants| variants.map{|v| (v.start_with?('::') ? '' : (namespace || '') + '::') + v }} 108 | end 109 | 110 | result[:used] = result[:used].concat(used).uniq 111 | 112 | result 113 | end 114 | end 115 | 116 | class ASTEntity 117 | class << self 118 | def ripper_id; raise 'Override ripper_id method with ripper id value' end 119 | def inherited(subclass) 120 | node_classes << subclass 121 | end 122 | def node_classes 123 | @node_classes ||= [] 124 | end 125 | def node_classes_cache 126 | return @node_classes_cache if @node_classes_cache 127 | @node_classes_cache = Hash[node_classes.map(&:ripper_id).zip(node_classes)] 128 | end 129 | def node_for(node_ary) 130 | if node_classes_cache[node_ary.first] 131 | node_classes_cache[node_ary.first].new(*node_ary[1..-1]) 132 | else 133 | if node_ary.first.kind_of?(Symbol) 134 | load(node_ary) 135 | else 136 | # Code position for identifier 137 | return if node_ary.kind_of?(Array) and (node_ary.size == 2) and node_ary[0].kind_of?(Integer) and node_ary[1].kind_of?(Integer) 138 | node_ary.map{|n| node_for(n) } 139 | end 140 | end 141 | end 142 | def load(node) 143 | new(*node[1..-1]) 144 | end 145 | end 146 | 147 | def initialize(*args) 148 | @body = args.map{ |node_ary| 149 | ASTEntity.node_for(node_ary) if node_ary.kind_of?(Array) 150 | } 151 | end 152 | 153 | def collect_constants(result, context = nil) 154 | result ||= TraversingResult.new 155 | @body.each{|e| 156 | case e 157 | when ASTEntity 158 | e.collect_constants(result, context || (TraversingContext.new)) unless e.nil? 159 | when Array 160 | e.flatten.map{|e| e.collect_constants(result, context || (TraversingContext.new)) unless e.nil? } 161 | else 162 | end 163 | } unless @body.nil? 164 | result 165 | end 166 | end 167 | 168 | class ASTProgramDecl < ASTEntity 169 | def self.ripper_id; :program end 170 | def initialize(*args) 171 | @body = args.first.map{|a| ASTEntity.node_for(a)} 172 | end 173 | end 174 | 175 | class ASTDef < ASTEntity 176 | def self.ripper_id; :def end 177 | def collect_constants(result, context) 178 | result 179 | end 180 | end 181 | 182 | class ASTCommand < ASTEntity 183 | def self.ripper_id; :command end 184 | def initialize(*args) 185 | @command = args.first[1] 186 | super(*args) 187 | end 188 | def collect_constants(result, context) 189 | @old_stop_collect_constants = context[:stop_collect_constants] 190 | context[:stop_collect_constants] = nil unless %w{desc mount params}.index(@command).nil? 191 | ret = super(result, context) 192 | context[:stop_collect_constants] = @old_stop_collect_constants 193 | ret 194 | end 195 | end 196 | 197 | class ASTBody < ASTEntity 198 | def self.ripper_id; :bodystmt end 199 | def initialize(*args) 200 | @body = args.reject(&:nil?).map{ |node| ASTEntity.node_for(node) } 201 | end 202 | def collect_constants(result, context) 203 | context[:variable_assignment] = false 204 | super(result, context) 205 | end 206 | end 207 | 208 | class ASTClass < ASTEntity 209 | def self.ripper_id; :class end 210 | def collect_constants(result, context) 211 | context[:variable_assignment] = true 212 | super(result, context) 213 | end 214 | end 215 | 216 | class ASTConstRef < ASTEntity 217 | def self.ripper_id; :const_ref end 218 | def initialize(*args) 219 | @const_name = args[0][1] 220 | end 221 | def collect_constants(result, context) 222 | result.declare_const(@const_name) 223 | super(result, context) 224 | end 225 | end 226 | 227 | class ASTTopConstRef < ASTEntity 228 | def self.ripper_id; :top_const_ref end 229 | def collect_constants(result, context) 230 | context[:top] = true 231 | ret = super(result, context) 232 | context[:top] = false 233 | ret 234 | end 235 | end 236 | 237 | class ASTArgsAddBlock < ASTEntity 238 | def self.ripper_id; :args_add_block end 239 | def initialize(*args) 240 | super(*args.flatten(1)) 241 | end 242 | end 243 | 244 | class ASTBareAssocHash < ASTEntity 245 | def self.ripper_id; :bare_assoc_hash end 246 | def initialize(*args) 247 | super(*args.flatten(2)) 248 | end 249 | end 250 | 251 | class ASTArray < ASTEntity 252 | def self.ripper_id; :array end 253 | def initialize(*args) 254 | super(*args.flatten(1)) 255 | end 256 | end 257 | 258 | class ASTConst < ASTEntity 259 | def self.ripper_id; :'@const' end 260 | def initialize(*args) 261 | @const_name = args[0] 262 | end 263 | def collect_constants(result, context) 264 | return super(result, context) if context[:stop_collect_constants] 265 | if context[:variable_assignment] 266 | result.declare_const(@const_name) 267 | else 268 | analyze_const = context[:analyze_const].nil? ? true : context[:analyze_const] 269 | if context[:top] 270 | result.use_const('::'+@const_name) 271 | else 272 | result.use_const(@const_name, analyze_const) 273 | end 274 | 275 | end 276 | 277 | super(result, context) 278 | end 279 | end 280 | 281 | class ASTConstPathRef < ASTEntity 282 | def self.ripper_id; :const_path_ref end 283 | def initialize(*args) 284 | @path = ASTEntity.node_for(args.first) 285 | @const = ASTEntity.node_for(args.last) 286 | end 287 | def collect_constants(result, context) 288 | return super(result, context) if context[:stop_collect_constants] 289 | if context[:const_path_ref] || context[:method_add_arg] 290 | r = TraversingResult.new 291 | c = context.dup 292 | c[:analyze_const] = false 293 | path_consts = @path.collect_constants(r, context) 294 | const = @const.collect_constants(r, context) 295 | result.use_const(path_consts.used.join('::'), false) 296 | else 297 | r = TraversingResult.new 298 | new_context = TraversingContext.new([], {const_path_ref: true, analyze_const: false}) 299 | path_consts = @path.collect_constants(r, new_context) 300 | const = @const.collect_constants(r, new_context) 301 | result.use_const(path_consts.used.join('::')) 302 | end 303 | result 304 | end 305 | end 306 | 307 | class ASTMethodAddArg < ASTEntity 308 | def self.ripper_id; :method_add_arg end 309 | def initialize(*args) 310 | @path = ASTEntity.node_for(args.first) 311 | end 312 | 313 | def collect_constants(result, context) 314 | return super(result, context) if context[:stop_collect_constants] 315 | if context[:method_add_arg] 316 | r = TraversingResult.new 317 | c = context.dup 318 | c[:analyze_const] = false 319 | path_consts = @path.collect_constants(r, context) 320 | result.use_const(path_consts.used.join('::'), false) 321 | else 322 | r = TraversingResult.new 323 | new_context = TraversingContext.new([], {method_add_arg: true, analyze_const: false}) 324 | path_consts = @path.collect_constants(r, new_context) 325 | result.use_const(path_consts.used.join('::')) 326 | end 327 | result 328 | end 329 | end 330 | 331 | class ASTDefs < ASTEntity 332 | def self.ripper_id; :defs end 333 | def collect_constants(result, context) 334 | result 335 | end 336 | end 337 | 338 | class ASTMethodAddBlock < ASTEntity 339 | def self.ripper_id; :method_add_block end 340 | 341 | def collect_constants(result, context) 342 | context[:stop_collect_constants] = true 343 | ret = super(result, context) 344 | context[:stop_collect_constants] = nil 345 | ret 346 | end 347 | end 348 | 349 | class ASTModule < ASTEntity 350 | def self.ripper_id; :module end 351 | def initialize(*args) 352 | @module_name = args.find{|a| a.first == :const_ref}.last[1] 353 | @body = [ASTEntity.node_for(args.find{|a| a.first == :bodystmt})] 354 | end 355 | def collect_constants(result, context) 356 | result.declare_const(@module_name) 357 | result = result.nest(@module_name) 358 | context.module << @module_name 359 | super(result, context) 360 | end 361 | end 362 | 363 | class ASTVarField < ASTEntity 364 | def self.ripper_id; :var_field end 365 | def collect_constants(result, context) 366 | context[:variable_assignment] = true 367 | ret = super(result, context) 368 | context[:variable_assignment] = false 369 | ret 370 | end 371 | end 372 | 373 | class ASTRef < ASTEntity 374 | def self.ripper_id; :var_ref end 375 | def collect_constants(result, context) 376 | context[:variable_assignment] = false 377 | super(result, context) 378 | end 379 | end 380 | 381 | class ASTLambda < ASTEntity 382 | def self.ripper_id; :lambda end 383 | def initialize(*args) 384 | super(*(args[0..-2])) 385 | end 386 | end 387 | 388 | class ASTStatementsAdd < ASTEntity 389 | def self.ripper_id; :stmts_add end 390 | def initialize(*args) 391 | super(*args) 392 | end 393 | end 394 | 395 | class ASTStatementsNew < ASTEntity 396 | def self.ripper_id; :stmts_new end 397 | def initialize(*args) 398 | super(*args) 399 | end 400 | end 401 | 402 | class ASTStatementsProgram < ASTEntity 403 | def self.ripper_id; :program end 404 | def initialize(*args) 405 | super(args.first) 406 | end 407 | end 408 | 409 | class Ripper 410 | def self.extract_constants(code) 411 | ast = Ripper.sexp_raw(code) 412 | result = ASTEntity.node_for(ast).collect_constants(TraversingResult.new) 413 | consts = result.extract_consts 414 | consts[:declared].flatten! 415 | consts[:declared].uniq! 416 | consts 417 | end 418 | end 419 | -------------------------------------------------------------------------------- /spec/fixtures/app1/mounts/lib.rb: -------------------------------------------------------------------------------- 1 | module Test 2 | class LibMount1 < Grape::API 3 | desc 'Some test description', 4 | entity: [Test::Lib1] 5 | get :lib_string do 6 | Test::Lib1.get_lib_string 7 | end 8 | end 9 | end -------------------------------------------------------------------------------- /spec/fixtures/app1/mounts/mount.rb: -------------------------------------------------------------------------------- 1 | module Test 2 | class Mount1 < Grape::API 3 | get :test1 do 4 | 'mounted test1' #changed: 'mounted test1 changed' 5 | end 6 | end 7 | end -------------------------------------------------------------------------------- /spec/fixtures/app1/test1.rb: -------------------------------------------------------------------------------- 1 | module Test 2 | class App1 < Grape::API 3 | format :txt 4 | mount Test::Mount1 => '/mounted' 5 | #changed: mount Test::LibMount1 => '/lib_mounted' 6 | get :test do 7 | 'test1 response' #changed: 'test1 response changed' 8 | end 9 | end 10 | end -------------------------------------------------------------------------------- /spec/fixtures/app2/mounts/lib.rb: -------------------------------------------------------------------------------- 1 | module Test 2 | class LibMount2 < Grape::API 3 | get :lib_string do 4 | Test::Lib2.get_lib_string 5 | end 6 | end 7 | end -------------------------------------------------------------------------------- /spec/fixtures/app2/mounts/mount.rb: -------------------------------------------------------------------------------- 1 | module Test 2 | class Mount2 < Grape::API 3 | get :test do 4 | 'test' 5 | end 6 | end 7 | end -------------------------------------------------------------------------------- /spec/fixtures/app2/test2.rb: -------------------------------------------------------------------------------- 1 | module Test 2 | class App2 < Grape::API 3 | format :txt 4 | mount Test::Mount2 => '/mounted' #changed: mount Test::Mount2 => '/mounted2' 5 | get :test do 6 | 'test2 response changed' 7 | end 8 | end 9 | end -------------------------------------------------------------------------------- /spec/fixtures/lib/lib1.rb: -------------------------------------------------------------------------------- 1 | module Test 2 | class Lib1 3 | def self.get_lib_string 4 | 'lib string 1' # changed: 'lib string 1 changed' 5 | end 6 | end 7 | end -------------------------------------------------------------------------------- /spec/fixtures/lib/lib2.rb: -------------------------------------------------------------------------------- 1 | module Test 2 | class Lib2 3 | def self.get_lib_string 4 | 'lib string 2' # changed: lib string 2 changed 5 | end 6 | end 7 | end -------------------------------------------------------------------------------- /spec/grape/reload/autoreload_interceptor_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require_relative '../../../lib/grape/reload/grape_api' 3 | describe Grape::Reload::AutoreloadInterceptor do 4 | let!(:api_class) { 5 | nested_class = Class.new(Grape::API) do 6 | namespace :nested do 7 | get :route do 8 | 'nested route' 9 | end 10 | end 11 | end 12 | 13 | versioned_class = Class.new(Grape::API) do 14 | version :v1 15 | get 'versioned_route' do 16 | 'versioned route' 17 | end 18 | end 19 | 20 | Class.new(Grape::API) do 21 | format :txt 22 | get :test_route do 23 | 'test' 24 | end 25 | mount nested_class => '/nested' 26 | mount versioned_class 27 | end 28 | } 29 | 30 | describe '.reinit!' do 31 | let!(:app) { 32 | app = Rack::Builder.new 33 | app.run api_class 34 | app 35 | } 36 | it 'exists' do 37 | expect(api_class).to respond_to('reinit!') 38 | end 39 | 40 | it 'reinit Grape API declaration' do 41 | get '/test_route' 42 | expect(last_response).to succeed 43 | expect(last_response.body).to eq('test') 44 | get '/nested/nested/route' 45 | expect(last_response).to succeed 46 | expect(last_response.body).to eq('nested route') 47 | api_class.reinit! 48 | get '/test_route' 49 | expect(last_response).to succeed 50 | expect(last_response.body).to eq('test') 51 | get '/nested/nested/route' 52 | expect(last_response).to succeed 53 | expect(last_response.body).to eq('nested route') 54 | end 55 | 56 | it 'preserves the API version' do 57 | get '/v1/versioned_route' 58 | expect(last_response).to succeed 59 | expect(last_response.body).to eq('versioned route') 60 | api_class.reinit! 61 | get '/v1/versioned_route' 62 | expect(last_response).to succeed 63 | expect(last_response.body).to eq('versioned route') 64 | end 65 | 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/grape/reload/dependency_map_spec.rb: -------------------------------------------------------------------------------- 1 | require 'grape' 2 | require 'spec_helper' 3 | 4 | describe Grape::Reload::DependencyMap do 5 | let!(:file_class_map) { 6 | { 7 | 'file1' => { 8 | declared: ['::Class1'], 9 | used: [], 10 | }, 11 | 'file2' => { 12 | declared: ['::Class2'], 13 | used: [['::Class1'],['::Class3']], 14 | }, 15 | 'file3' => { 16 | declared: ['::Class3'], 17 | used: [['::Class1']], 18 | }, 19 | } 20 | } 21 | let!(:wrong_class_map) { 22 | { 23 | 'file1' => { 24 | declared: ['::Class1'], 25 | used: [], 26 | }, 27 | 'file2' => { 28 | declared: ['::Class2'], 29 | used: [['::Class1'],['::Class3']], 30 | }, 31 | 'file3' => { 32 | declared: ['::Class3'], 33 | used: [['::Class5']], 34 | }, 35 | } 36 | } 37 | let!(:dm) { Grape::Reload::DependencyMap.new([]) } 38 | 39 | it 'resolves dependent classes properly' do 40 | allow(dm).to receive(:map).and_return(file_class_map) 41 | dm.resolve_dependencies! 42 | 43 | expect(dm.dependent_classes('file1')).to include('::Class2','::Class3') 44 | end 45 | end -------------------------------------------------------------------------------- /spec/grape/reload/rack_builder_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Grape::RackBuilder do 4 | let(:builder) { 5 | Module.new do 6 | class << self 7 | include Grape::RackBuilder::ClassMethods 8 | def get_config 9 | config 10 | end 11 | end 12 | end 13 | } 14 | let(:middleware) { 15 | Class.new do 16 | def initialize(app) 17 | @app = app 18 | end 19 | def call(env) 20 | @app.call(env) 21 | end 22 | end 23 | } 24 | 25 | 26 | before do 27 | builder.setup do 28 | environment 'development' 29 | add_source_path File.expand_path('**/*.rb', APP_ROOT) 30 | end 31 | end 32 | before :each do 33 | builder.get_config.mounts.clear 34 | end 35 | 36 | describe '.setup' do 37 | subject(:config){ builder.get_config } 38 | 39 | it 'configures builder with options' do 40 | expect(config.sources).to include(File.expand_path('**/*.rb', APP_ROOT)) 41 | expect(config.environment).to eq('development') 42 | end 43 | 44 | it 'allows to mount bunch of grape apps to different roots' do 45 | builder.setup do 46 | mount 'TestClass1', to: '/test1' 47 | mount 'TestClass2', to: '/test2' 48 | end 49 | expect(config.mounts.size).to eq(2) 50 | end 51 | 52 | it 'allows to add middleware' do 53 | builder.setup do 54 | use middleware do 55 | end 56 | end 57 | expect(config.middleware.size).to eq(1) 58 | end 59 | end 60 | 61 | describe '.boot!' do 62 | before(:each) do 63 | builder.setup do 64 | mount 'Test::App1', to: '/test1' 65 | mount 'Test::App2', to: '/test2' 66 | end 67 | end 68 | 69 | it 'autoloads mounted apps files' do 70 | expect{ builder.boot! }.to_not raise_error 71 | expect(defined?(Test::App1)).not_to be_nil 72 | expect(defined?(Test::App2)).not_to be_nil 73 | end 74 | 75 | it 'autoloads apps dependencies, too' do 76 | expect{ builder.boot! }.to_not raise_error 77 | expect(defined?(Test::Mount1)).not_to be_nil 78 | expect(defined?(Test::Mount2)).not_to be_nil 79 | end 80 | end 81 | 82 | describe '.application' do 83 | before(:each) do 84 | builder.setup do 85 | use middleware 86 | mount 'Test::App1', to: '/test1' 87 | mount 'Test::App2', to: '/test2' 88 | end 89 | builder.boot! 90 | end 91 | it 'creates Rack::Builder application' do 92 | expect{ @app = builder.application }.not_to raise_error 93 | expect(@app).to be_an_instance_of(Rack::Builder) 94 | def @app.get_map; @map end 95 | def @app.get_use; @use end 96 | expect(@app.get_use.size).to eq(1) 97 | expect(@app.get_map.keys).to include('/test1','/test2') 98 | end 99 | end 100 | end -------------------------------------------------------------------------------- /spec/grape/reload/watcher_spec.rb: -------------------------------------------------------------------------------- 1 | require 'grape' 2 | require 'spec_helper' 3 | 4 | describe Grape::Reload::Watcher do 5 | def app 6 | @app 7 | end 8 | before(:each) do 9 | @app = 10 | Grape::RackBuilder.setup do 11 | add_source_path File.expand_path('**.rb', APP_ROOT) 12 | add_source_path File.expand_path('**/*.rb', APP_ROOT) 13 | environment 'development' 14 | reload_threshold 0 15 | mount 'Test::App1', to: '/test1' 16 | mount 'Test::App2', to: '/test2' 17 | end.boot!.application 18 | end 19 | 20 | after(:example) do 21 | Grape::Reload::Watcher.clear 22 | end 23 | 24 | it 'reloads changed root app file' do 25 | get '/test1/test' 26 | expect(last_response).to succeed 27 | expect(last_response.body).to eq('test1 response') 28 | 29 | with_changed_fixture 'app1/test1.rb' do 30 | get '/test1/test' 31 | expect(last_response).to succeed 32 | expect(last_response.body).to eq('test1 response changed') 33 | end 34 | end 35 | 36 | describe 'force_reloading' do 37 | before(:each) do 38 | @app = 39 | Grape::RackBuilder.setup do 40 | add_source_path File.expand_path('**.rb', APP_ROOT) 41 | add_source_path File.expand_path('**/*.rb', APP_ROOT) 42 | environment 'test' 43 | force_reloading true 44 | reload_threshold 0 45 | mount 'Test::App1', to: '/test1' 46 | mount 'Test::App2', to: '/test2' 47 | end.boot!.application 48 | end 49 | 50 | it 'reloads files within any environment with force_reloading options set' do 51 | get '/test1/test' 52 | expect(last_response).to succeed 53 | expect(last_response.body).to eq('test1 response') 54 | 55 | with_changed_fixture 'app1/test1.rb' do 56 | get '/test1/test' 57 | expect(last_response).to succeed 58 | expect(last_response.body).to eq('test1 response changed') 59 | end 60 | end 61 | end 62 | 63 | 64 | 65 | it 'reloads mounted app file' do 66 | get '/test1/mounted/test1' 67 | expect(last_response).to succeed 68 | expect(last_response.body).to eq('mounted test1') 69 | 70 | with_changed_fixture 'app1/mounts/mount.rb' do 71 | get '/test1/mounted/test1' 72 | expect(last_response).to succeed 73 | expect(last_response.body).to eq('mounted test1 changed') 74 | end 75 | end 76 | 77 | it 'remounts class on different root' do 78 | get '/test2/mounted/test' 79 | expect(last_response).to succeed 80 | expect(last_response.body).to eq('test') 81 | 82 | with_changed_fixture 'app2/test2.rb' do 83 | get '/test2/mounted/test' 84 | expect(last_response).to_not succeed 85 | 86 | get '/test2/mounted2/test' 87 | expect(last_response).to succeed 88 | end 89 | end 90 | 91 | it 'reloads library file and reinits all affected APIs' do 92 | with_changed_fixture 'app1/test1.rb' do 93 | get '/test1/lib_mounted/lib_string' 94 | expect(last_response).to succeed 95 | expect(last_response.body).to eq('lib string 1') 96 | 97 | with_changed_fixture 'lib/lib1.rb' do 98 | get '/test1/lib_mounted/lib_string' 99 | expect(last_response).to succeed 100 | expect(last_response.body).to eq('lib string 1 changed') 101 | 102 | expect(Test::LibMount1.endpoints.first.options[:route_options][:entity].first.get_lib_string).to eq('lib string 1 changed') 103 | end 104 | end 105 | end 106 | end -------------------------------------------------------------------------------- /spec/ripper/extract_constants_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'ripper/extract_constants' 3 | 4 | describe 'Ripper.extract_consts' do 5 | let!(:code1) { 6 | < '/mounted' 48 | mount Test::Mount10 => '/mounted2' 49 | desc 'Blablabla', 50 | entity: [Test::SomeAnotherEntity] 51 | get :test do 52 | SomeClass.usage 53 | 'test2 response' 54 | end 55 | end 56 | module EmptyModule 57 | end 58 | end 59 | class WithoutModule 60 | def use_top_level 61 | TopLevel.new 62 | end 63 | def self.method 64 | SomeModule::ShouldntUse.call 65 | end 66 | end 67 | CODE 68 | } 69 | 70 | let!(:deeply_nested) { 71 | <(arg) { 123 | ModuleName::ClassName.call(arg) 124 | } 125 | CODE 126 | } 127 | 128 | it 'extract consts from code1 correctly' do 129 | consts = Ripper.extract_constants(code1) 130 | expect(consts[:declared].flatten).to include( 131 | '::TopClass', 132 | '::Test::Test1', 133 | '::Test::Test2::Test4', 134 | '::Test::Test2::InClassUsage', 135 | '::Test::CONST_DEF1', 136 | '::Test::CONST_DEF2', 137 | '::Test::CONST_DEF3', 138 | '::Test::Test' 139 | ) 140 | 141 | expect(consts[:used].flatten).to include( 142 | '::Test3::AnotherClass', 143 | '::Test::NotExists::Test1', 144 | '::SomeExternalClass', 145 | '::Superclass' 146 | ) 147 | 148 | expect(consts[:used].flatten).not_to include( 149 | '::SomeClass1', 150 | '::SomeClass2' 151 | ) 152 | 153 | 154 | end 155 | it 'extract consts from code2 correctly' do 156 | consts = Ripper.extract_constants(code2) 157 | expect(consts[:declared].flatten).to include( 158 | '::Test::App2', 159 | '::Test::EmptyModule' 160 | ) 161 | 162 | expect(consts[:used].flatten).to include( 163 | '::Test::Mount2', 164 | '::Test::Mount10', 165 | '::Test::SomeAnotherEntity', 166 | ) 167 | 168 | expect(consts[:used].flatten).not_to include( 169 | '::SomeClass', 170 | '::TopLevel' 171 | ) 172 | 173 | end 174 | 175 | it 'extracts consts used in deeply nested modules up to root namespace' do 176 | consts = Ripper.extract_constants(deeply_nested) 177 | expect(consts[:used].flatten).to include('::Grape::API') 178 | end 179 | 180 | it 'extracts const with call (sequel-related)' do 181 | consts = Ripper.extract_constants(class_reference_with_call) 182 | expect(consts[:used].flatten).to include('::Grape::API') 183 | end 184 | 185 | it 'extracts consts from desc method args' do 186 | consts = Ripper.extract_constants(grape_desc_args) 187 | expect(consts[:used].flatten).to include('::Test::SomeAnotherEntity') 188 | end 189 | 190 | it 'does not mess up class name when class level method called with argument' do 191 | consts = Ripper.extract_constants(class_level_call_with_args) 192 | expect(consts[:used].flatten).to include('::UseModule::UseClass') 193 | end 194 | 195 | it 'does not include classes used in lambdas' do 196 | consts = Ripper.extract_constants(lambda_class_usage) 197 | expect(consts[:used].flatten).not_to include('::ModuleName::ClassName') 198 | end 199 | end -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'pry' 2 | require 'grape/reload' 3 | require 'rack' 4 | require 'rack/test' 5 | require 'grape' 6 | require 'rspec/mocks' 7 | 8 | APP_ROOT = File.expand_path('spec/fixtures') 9 | 10 | module FileHelpers 11 | class FixtureChangesStorage 12 | def self.fixture_modified(file_name, original_content) 13 | modified_fixtures[file_name] = original_content 14 | end 15 | 16 | def self.revert_fixtures! 17 | modified_fixtures.each_pair do |file_name, content| 18 | revert_fixture(file_name) 19 | end 20 | @modified_fixtures = {} 21 | end 22 | 23 | def self.revert_fixture(file_name) 24 | return unless modified_fixtures[file_name] 25 | data = modified_fixtures.delete(file_name) 26 | File.write(file_name, data.last) 27 | File.utime(File.atime(file_name), data.first, file_name) 28 | end 29 | 30 | def self.modified_fixtures 31 | @modified_fixtures ||= {} 32 | end 33 | end 34 | 35 | def with_changed_fixture(file_name, &block) 36 | file_name = File.expand_path(file_name, APP_ROOT) 37 | lines = File.read(file_name).split("\n") 38 | new_lines = [] 39 | lines.each do |l| 40 | if (/^(?\s*).+\#\s?changed:\s?(?.+)/ =~ l).nil? 41 | new_lines << l 42 | else 43 | new_lines << prepend + changes 44 | end 45 | end 46 | File.write(file_name, new_lines.join("\n")) 47 | mtime = File.mtime(file_name) 48 | File.utime(File.atime(file_name), 1.day.from_now, file_name) 49 | FixtureChangesStorage.fixture_modified(file_name, [mtime, lines.join("\n")]) 50 | yield if block_given? 51 | FixtureChangesStorage.revert_fixture(file_name) 52 | end 53 | end 54 | 55 | RSpec::Matchers.define :succeed do 56 | match do |actual| 57 | (actual.status == 200) || (actual.status == 201) 58 | end 59 | 60 | failure_message do |actual| 61 | "expected that #{actual} succeed, but got #{actual.status} error:\n#{actual.body}" 62 | end 63 | 64 | failure_message_when_negated do |actual| 65 | "expected that #{actual} fails, but got #{actual.status}" 66 | end 67 | 68 | description do 69 | 'respond with 200 or 201 status code' 70 | end 71 | end 72 | 73 | RSpec.configure do |c| 74 | c.include FileHelpers 75 | c.include Rack::Test::Methods 76 | c.after(:suite) do |*args| 77 | FileHelpers::FixtureChangesStorage.revert_fixtures! 78 | end 79 | end --------------------------------------------------------------------------------