fragmentary 0.2.2 → 0.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 2747ef223962cc8eaa11b9d929febf77a5746bca
4
- data.tar.gz: 8eb6ca9f3d487102cf23bb8944f46a856230371c
2
+ SHA256:
3
+ metadata.gz: 0005ce07a2de70b95fc9b105b3ab12a7d763fb2ededa0423845bbfc3c0b45e8d
4
+ data.tar.gz: e2202f4d38a1189ba956e7b88150178e962581ed93e00f3d74fb8144b193055e
5
5
  SHA512:
6
- metadata.gz: dcf5246ca4dd67f075f74b7d951e6e69d57dce8269ed49e6896cb92d45e2079500317345e95bb63208d7035e9988f6c03ff653eab2d88c410bfe6de3a9509b99
7
- data.tar.gz: f0498b8f900c03dc05d0469fbf5cde755b91b7a941da85cfc9394c5d730cc40ab9f097a4e2e1c38bc73e62700cc0bf83d0360233fb6e5d580dd9984b45b2a527
6
+ metadata.gz: 1665f0be866246efc7a1ba157d1216f63a39162261a680e6e8542884461c2285137ce350efdd4ed233ee6cb4b88484664ab5b7436a0f84e5fbb80c892ccab7ae
7
+ data.tar.gz: 0e4d6a8bd99ae54ff6c3715e203cde760382ef9c48a5ee241ae645ce58dd07dcb1d17b819f10771edcf5d5d113016d0d115fd5a08b1218e3060fef0bf57553de
data/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ### 0.3.0
2
+ - Updates gem to support Rails 5.x (Rails 4.x and earlier are no longer supported due to Rails API changes).
3
+ - Adds support for multiple application instances, allowing pre-release application code to be staged for testing.
4
+ - Fixes a bug in Fragment.set_record_type affecting some application data handlers for fragment classes that are subclassed from another.
5
+
1
6
  ### 0.2.2
2
7
  - Removes validation of the fragment's root_id (only relevant to child fragments); ignore v0.2.1
3
8
 
data/README.md CHANGED
@@ -5,11 +5,12 @@ Fragmentary augments the fragment caching capabilities of Ruby on Rails to suppo
5
5
  * multiple versions of individual fragments for different groups of users, e.g. admin vs regular users
6
6
  * post-cache insertion of user-specific content
7
7
  * automatic refreshing of cached content when application data changes, without an external client request
8
+ * multiple application instances running concurrently with shared application data
8
9
 
9
- **Note**: Fragmentary has been extracted from [Persuasive Thinking](http://persuasivethinking.com) where it is currently in active use. See [Integration Issues](https://github.com/MarkMT/fragmentary/blob/master/README.md#integration-issues) for details of issues that should be considered when using it elsewhere.
10
+ Fragmentary has been extracted from [Persuasive Thinking](http://persuasivethinking.com) where it is currently in active use.
10
11
 
11
12
  ## Background
12
- In simple cases, Rails' native support for fragment caching assumes that a fragment's content is a representation of a specific application data record. The content is stored in the cache with a key value derived from the `updated_at` attribute of that record. If any attributes of the record change, the cached entry automatically expires and on the next browser request for that content the fragment is re-rendered using the current data. In the view, the `cache` helper is used to specify the record used to determine the key and define the content to be rendered within the fragment, e.g.:
13
+ In simple cases, Rails' native support for fragment caching assumes that a fragment's content is a representation of a specific application data record. The content is stored in the cache with a key value derived from the`id` and `updated_at` attributes of that record. If any attributes of the record change, the cached entry automatically expires and on the next browser request for that content the fragment is re-rendered using the current data. In the view, the `cache` helper is used to specify the record used to determine the key and define the content to be rendered within the fragment, e.g.:
13
14
  ```
14
15
  <% cache product do %>
15
16
  <%= render product %>
@@ -577,7 +578,7 @@ def send_queued_requests
577
578
  Fragmentary::RequestQueue.all.each{|q| q.send(:delay => delay += 10.seconds)}
578
579
  end
579
580
  ```
580
- The `send` method takes two optional named arguments, `delay` and `between`. If neither are present, all requests held in the queue are sent immediately. If either are present, sending of requests is off-loaded to an asynchronous process using the [Delayed::Job gem](https://github.com/collectiveidea/delayed_job) and scheduled according to the parameters provided: `delay` represents the delay before the queue begins sending requests and `between` represents the interval between individual requests in the queue being sent. In the example above, we choose to delay the sending of requests from each queue by 10 seconds each. You may customize as appropriate.
581
+ The `send` method takes two optional named arguments, `delay` and `between`. If neither are present, all requests held in the queue are sent immediately. If either are present, sending of requests is off-loaded to an asynchronous process using the [Delayed::Job gem](https://github.com/collectiveidea/delayed_job) (i.e. we are not currently using Active Job) and scheduled according to the parameters provided: `delay` represents the delay before the queue begins sending requests and `between` represents the interval between individual requests in the queue being sent. In the example above, we choose to delay the sending of requests from each queue by 10 seconds each. You may customize as appropriate.
581
582
 
582
583
  #### Queuing Requests Explicitly
583
584
 
@@ -758,13 +759,89 @@ Note that if the partial page content being generated contains several nested ch
758
759
 
759
760
  It is possible to define a fragment without actually storing its content in the cache store. This can be useful, for example if you wish to cache several sibling children within a page but don't need to store the entire root fragment that contains them. Simply include the option `:no_cache => true` in the hash passed to `cache_fragment` or `cache_child`.
760
761
 
761
- ## Integration Issues
762
+ ### Support for Multiple Application Instances
762
763
 
763
- There are some aspects of this pre-release version of Fragmentary that reflect the application context in which it was originally developed and may need adjustment before deployment elsewhere. Note in particular the following:
764
- 1. Fragmentary was created in the context of a Rails 4.x application (for perfectly sound reasons! :)). There should be only minor adjustment required for use within a Rails 5.x application, but two specific issues we are aware of are the following:
765
- - Rails 5.x changes the API for `ActionDispatch::Integration::Session` and now requires that HTTP request parameters be passed as a named parameter `:params`, rather than an unnamed hash in Rails 4.x. This affects the method `to_proc` in class `Fragmentary::Request` and the methods `sign_in` and `sign_out` in class `Fragmentary::UserSession`.
766
- - In 'lib/fragmentary/fragment.rb', we set `cache_timestamp_format = :usec` to overcome a timestamp resolution problem when using caching with Postgres under Rails 4.x. We believe that this problem has been solved in Rails 5.x, so this setting will not be necessary. See https://github.com/rails/rails/issues/21815.
767
- 1. Fragmentary uses the [Delayed::Job gem](https://github.com/collectiveidea/delayed_job) to execute background tasks asynchronously. Other alternatives exist within the Rails ecosystem, and in Rails 5.x it will probably make sense to use [Active Job](https://guides.rubyonrails.org/active_job_basics.html) as an abstraction layer.
764
+ In many practical deployment scenarios it is desirable to maintain a live pre-release version of the application online separate from the public-facing production website. This allows new software releases to be staged for either internal or beta testing prior to final deployment to the production environment. For example, if the public-facing application is accessed at a root URL of http://myapp.com/, a separate pre-release version might be deployed say to http://prerelease.myapp.com/ or http://myapp.com/prerelease/.
765
+
766
+ In the specific scenario in which Fragmentary was developed, it was important for the pre-release version of the application to share the same application database as the production site, i.e. both production and pre-release versions render exactly the same data, and any changes to application data initiated by a user of one version of the application will be reflected in the content seen by a user of the other version as well. For caching, this leads to some additional challenges which we discuss below.
767
+
768
+ #### Storing Multiple Versions of Content in the Cache
769
+
770
+ There is an important difference between the content rendered by each instance of an application: any HTML links to other pages on the site must be to URLs representing the particular version being rendered. For example, on a 'products' index page, a link to an individual product page on the production website might look like http://myapp.com/products/123, while the same link on the index page of the pre-release version might be http://myapp.com/prerelease/products/123.
771
+
772
+ In general, as long as links contained in the view are created using Rails' standard path or url helpers, e.g. `product_path(@product)` etc, they will automatically be based on the root URL of the application instance (production or pre-release) in which they are generated. However, in the context of caching, the fact that differences exist in the content generated by different application instances implies that for each fragment defined in the view by calling `cache_fragment` or `cache_child`, different versions of fragment content need to be stored in the cache. Also, each of these distinct versions needs to be associated with a unique record in the `fragments` database table identifying which application instance generated that content.
773
+
774
+ In Fragmentary we can accomplish this simply by adding a column to the `fragments` table to store the root URL of the particular application instance that created the fragment content.
775
+
776
+ ```
777
+ class AddAppUrlToFragment < ActiveRecord::Migration
778
+ def change
779
+ change_table :fragments do |t|
780
+ t.string :application_root_url
781
+ end
782
+ end
783
+ end
784
+ ```
785
+
786
+ The column name `application_root_url` shown above is the default assumed by Fragmentary. You can use a different column name if you wish, as long as you tell Fragmentary in initializers/fragmentary.rb, e.g.:
787
+
788
+ ```
789
+ Fragmentary.setup do |config|
790
+ ...
791
+ config.application_root_url_column = 'app_root'
792
+ end
793
+
794
+ ```
795
+
796
+ Once this column has been added to the `fragments` table, any fragment records subsequently created by calls to `cache_fragment` or `cache_child` will have the column populated automatically based on the particular application instance in which the code is executed, and the content stored in the cache for each fragment record will be that generated by the corresponding instance. As long as any links rendered within the content are generated using Rails' path or url helpers, the cached content will be rendered correctly both when initially created and when retrieved subsequently.
797
+
798
+ Note that it is possible to store cached content for each of the different application instances in different places. Simply set `config.cache_store` in config/environments/production.rb (or alternative environment file) as required.
799
+
800
+ #### Automatically Refreshing Multiple Versions of Cached Content
801
+
802
+ The fact that two versions of content exist in the cache for each fragment means that whenever a change in application data occurs that triggers the generation of an internal application request to refresh a piece of cached content (i.e. for any fragment that has a `request_path` method defined), _both_ (in general all) versions need to be refreshed. So for example, a change to application data caused by a user action on the production website needs to trigger internal requests to _both_ the production _and_ the pre-release instances in order to refresh their respective content. The converse is true for changes initiated from the pre-release website.
803
+
804
+ (Note: for better or worse, we've stuck with the term 'internal request' to mean any request delivered to the application programmatically in order to refresh cached content. This includes requests created by one application instance that are intended to be processed by another instance.)
805
+
806
+ Our approach to sending requests between application instances relies on requests being processed asynchronously (i.e. by passing a `delay` value to RequestQueue#send in the controller method `send_queued_requests` discussed earlier). The reason is that asynchronous tasks used to process internal requests can be directed to specific task queues associated with the application instance they are intended to be processed by. Each application instance has an associated asynchronous task process. By configuring that process to run tasks from just the queue(s) designated for it, we ensure that any application instance can direct requests to any other.
807
+
808
+ As noted earlier, our current implementation relies on [Delayed::Job](https://github.com/collectiveidea/delayed_job) for creating and processing asynchronous tasks. Queued tasks are stored in a database table, so as long as each application and [Delayed::Job](https://github.com/collectiveidea/delayed_job) instance have access to the same database, this approach will be successful.
809
+
810
+ To configure Fragmentary to automatically refresh cached content for multiple instances, first set `remote_urls` in `Fragmentary.config`. This is an array of root URLs for all _other_ instances of the application that requests should be sent to. For example, in order to allow requests to be sent from the production instance to the pre-release instance, in initializers/fragmentary.rb in the production code we would add the following configuration:
811
+
812
+ ```
813
+ Fragmentary.setup do |config|
814
+ ...
815
+ config.remote_urls << 'http://myapp.com/prerelease/'
816
+ end
817
+ ```
818
+
819
+ Our current approach to application deployment is to maintain different branches in our source repository for each application instance. This allows us to keep custom configurations like `config.remote_urls` above for each instance on their own branches. So in contrast to the case above, to allow requests to be sent from the pre-release instance to the production instance, in initializers/fragmentary.rb on the pre-release branch the `remote_urls` array would be set to `['http://myapp.com/']` .
820
+
821
+ As an alternative, it may be possible to create a separate environment for the pre-release deployment in your config/environments directory and thus maintain the configuration for all application instances in a single repository branch. We have not investigated this approach.
822
+
823
+ By specifying `remote_urls` as described above, for each internal application request Fragmentary will automatically create tasks to process the request not only on the application instance where it was created but also on all other instances corresponding to the elements in `remote_urls`. Fragmentary sends these tasks to [Delayed::Job](https://github.com/collectiveidea/delayed_job) queues that have names formed from the domain name and path of both the root URL of the current instance and the values in `remote_urls`. So in the example, a request to '/products/123', created say by editing a product name on the production website, would be sent to [Delayed::Job](https://github.com/collectiveidea/delayed_job) queues with names 'myapp.com' and 'myapp.com/prerelease/'.
824
+
825
+ The final step needed to allow internal requests to be processed by the instance they are intended for is to configure the [Delayed::Job](https://github.com/collectiveidea/delayed_job) process associated with each instance to process tasks from the correct queue.
826
+
827
+ In our case we use Capistrano 2 for deployment and use `delayed_job_args` in config/deploy.rb to configure queue names (see documentation [here](https://github.com/collectiveidea/delayed_job/blob/master/lib/delayed/recipes.rb)):
828
+
829
+ ```
830
+ queue_prefix = "myapp.com/prerelease/"
831
+ set :delayed_job_args, "--queue=#{queue_prefix},#{queue_prefix}_overnight"
832
+ after "deploy:stop", "delayed_job:stop"
833
+ after "deploy:start", "delayed_job:start"
834
+ after "deploy:restart", "delayed_job:restart"
835
+ ```
836
+
837
+ Again, this configuration will be different for each instance and so in our case each one will be stored in different source repository branches.
838
+
839
+ Note that if you configure [Delayed::Job](https://github.com/collectiveidea/delayed_job) to work only from specific queues, you'll need to make sure that _any_ asynchronous tasks created by your application are submitted to one of those queues.
840
+
841
+ ## Dependencies
842
+
843
+ - The current implementation of Fragmentary has been tested using Rails 5. It does not work with earlier versions of Rails due to a change in the API for Rails `ActionDispatch::Integration::Session` class. We do have a 'Rails.4.2' branch that uses the older API in the github repository. However this branch is no longer maintained.
844
+ - As noted, Fragmentary uses the [Delayed::Job](https://github.com/collectiveidea/delayed_job) gem to execute background tasks asynchronously.
768
845
 
769
846
  ## Contributing
770
847
 
data/fragmentary.gemspec CHANGED
@@ -22,9 +22,11 @@ Gem::Specification.new do |spec|
22
22
  spec.bindir = "exe"
23
23
  spec.require_paths = ["lib"]
24
24
 
25
- spec.add_runtime_dependency "rails", ">= 4.0.0", "< 5"
25
+ spec.add_runtime_dependency "rails", "~> 5.0"
26
26
  spec.add_runtime_dependency "delayed_job_active_record", "~> 4.1"
27
27
  spec.add_runtime_dependency "wisper-activerecord", "~> 1.0"
28
+ spec.add_runtime_dependency "http", "~> 3.0.0"
29
+ spec.add_runtime_dependency "nokogiri"
28
30
  spec.add_development_dependency "bundler", "~> 1.17"
29
31
  spec.add_development_dependency "rake", "~> 10.0"
30
32
  spec.add_development_dependency "rspec", "~> 3.0"
@@ -2,12 +2,14 @@ module Fragmentary
2
2
 
3
3
  class Config
4
4
  include Singleton
5
- attr_accessor :current_user_method, :get_sign_in_path, :post_sign_in_path,
6
- :sign_out_path, :users, :default_user_type_mapping, :session_users
5
+ attr_accessor :current_user_method, :get_sign_in_path, :post_sign_in_path, :sign_out_path,
6
+ :users, :default_user_type_mapping, :session_users, :application_root_url_column, :remote_urls
7
7
 
8
8
  def initialize
9
9
  # default
10
10
  @current_user_method = :current_user
11
+ @application_root_url_column = :application_root_url
12
+ @remote_urls = []
11
13
  end
12
14
 
13
15
  def session_users=(session_users)
@@ -15,34 +17,47 @@ module Fragmentary
15
17
  Fragmentary.parse_session_users(session_users)
16
18
  @session_users = session_users
17
19
  end
20
+
21
+ def application_root_url_column=(column_name)
22
+ @application_root_url_column = column_name.to_sym
23
+ end
18
24
  end
19
25
 
20
26
  def self.current_user_method
21
27
  self.config.current_user_method
22
28
  end
23
29
 
24
- # Parse a class-specific set of session_user options
25
- # session_users can be an array of session_user keys, a hash of session_user definitions or an array
26
- # containing a mixture of both. The method should return an array of keys. If session_users is an
27
- # array, elements representing existing SessionUser objects should be included in the returned array.
30
+ # Parse a set of session_user options, creating session_users where needed, and return a set of user_type keys.
31
+ # session_users may take several forms:
32
+ # (1) a hash whose keys are user_type strings and whose values have the form {:credentials => credentials},
33
+ # where 'credentials' is either a hash of parameters to be submitted when logging in or a proc that
34
+ # returns those parameters.
35
+ # (2) an array of hashes as described in (1) above.
36
+ # (3) an array of user_type strings corresponding to SessionUser objects already defined.
37
+ # (4) an array containing a mixture of user_type strings and hashes as described in (1) above.
28
38
  # Non-hash elements that don't represent existing SessionUser objects should raise an exception. Array
29
- # elements that are hashes should be parsed to create new SessionUser objects. Raise an exception if
39
+ # elements that are hashes should be parsed to create new SessionUser objects. Raise an exception on
30
40
  # any attempt to redefine an existing user_type.
31
41
  def self.parse_session_users(session_users = nil)
32
42
  return nil unless session_users
33
43
  if session_users.is_a?(Array)
34
- # Fun fact: can't use 'each_with_object' here because 'acc += parse_session_users(v)' assigns a
35
- # different object to 'acc', while 'each_with_object' passes the *same* object to the block on
36
- # each iteration.
44
+ # Fun fact: can't use 'each_with_object' here because 'acc += parse_session_users(v)' would assign
45
+ # a different object to 'acc' on each iteration, while 'each_with_object' passes the *same* object
46
+ # to the block on each iteration.
37
47
  session_users.inject([]) do |acc, v|
38
48
  if v.is_a?(Hash)
39
49
  acc + parse_session_users(v)
40
50
  else
51
+ # v is a user_type, e.g. :admin
52
+ raise "No SessionUser exists for user_type '#{v}'" unless SessionUser.fetch(v)
41
53
  acc << v
42
54
  end
43
55
  end
44
56
  elsif session_users.is_a?(Hash)
45
57
  session_users.each_with_object([]) do |(k,v), acc|
58
+ # k is the user_type, v is an options hash that typically looks like {:credentials => login_credentials} where
59
+ # login_credentials is either a hash of parameters to be submitted at login or a proc that returns those parameters.
60
+ # In the latter case, the proc is executed when we actually log in to create a new session for the specified user.
46
61
  acc << k if user = SessionUser.new(k,v)
47
62
  end
48
63
  end
@@ -24,8 +24,6 @@ module Fragmentary
24
24
  # redundant duplicate request.
25
25
  after_commit :touch_parent, :on => [:update, :destroy]
26
26
 
27
- attr_accessible :parent_id, :root_id, :record_id, :user_id, :user_type, :key
28
-
29
27
  attr_accessor :indexed_children
30
28
 
31
29
  # Set cache timestamp format to :usec instead of :nsec because the latter is greater precision than Postgres supports,
@@ -73,8 +71,14 @@ module Fragmentary
73
71
  # Collect the attributes to be used when searching for an existing fragment. Fragments are unique by these values.
74
72
  search_attributes = {}
75
73
 
76
- parent_id = options.delete(:parent_id)
77
- search_attributes.merge!(:parent_id => parent_id) if parent_id
74
+ if (parent_id = options.delete(:parent_id))
75
+ search_attributes.merge!(:parent_id => parent_id)
76
+ else
77
+ application_root_url_column = Fragmentary.config.application_root_url_column
78
+ if (application_root_url = options.delete(application_root_url_column)) && column_names.include?(application_root_url_column.to_s)
79
+ search_attributes.merge!(application_root_url_column => application_root_url)
80
+ end
81
+ end
78
82
 
79
83
  [:record_id, :user_id, :user_type, :key].each do |attribute_name|
80
84
  if klass.needs?(attribute_name)
@@ -117,20 +121,53 @@ module Fragmentary
117
121
  self
118
122
  end
119
123
 
124
+ # There is one queue per user_type per application instance (the current app and any external instances). The queues
125
+ # for all fragments are held in common by the Fragment base class here in @@request_queues but are also indexed on a
126
+ # subclass basis by an individual subclass's user_types (see the inherited hook below). As well as being accessible
127
+ # here as Fragment.request_queues, the queues are also available without indexation as RequestQueue.all.
120
128
  def request_queues
121
- @@request_queues ||= Hash.new do |hash, user_type|
122
- hash[user_type] = RequestQueue.new(user_type)
129
+ @@request_queues ||= Hash.new do |hsh, host_url|
130
+ # As well as acting as a hash key to index the set of request queues for a given target application instance
131
+ # (for which its uniqueness is the only requirement), host_url is also passed to the RequestQueue constructor,
132
+ # from which it is used:
133
+ # (i) by the RequestQueue::Sender to derive the name of the delayed_job queue that will be used to process the
134
+ # queued requests if the sender is invoked in asynchronous mode - see RequestQueue::Sender#schedulerequests.
135
+ # (ii) by the Fragmentary::InternalUserSession instantiated by the Sender to configure the session_host.
136
+ hsh[host_url] = Hash.new do |hsh2, user_type|
137
+ hsh2[user_type] = RequestQueue.new(user_type, host_url)
138
+ end
123
139
  end
124
- if self == base_class
125
- @@request_queues
126
- else
127
- return nil unless (requestable? or new.requestable?)
128
- user_types.each_with_object({}){|user_type, queues| queues[user_type] = @@request_queues[user_type]}
140
+ end
141
+
142
+ # Subclass-specific request_queues
143
+ def inherited(subclass)
144
+ subclass.instance_eval do
145
+
146
+ def request_queues
147
+ super # ensure that @@request_queues has been defined
148
+ @request_queues ||= begin
149
+ app_root_url = Rails.application.routes.url_helpers.root_url
150
+ remote_urls = Fragmentary.config.remote_urls
151
+ user_types.each_with_object( Hash.new {|hsh0, url| hsh0[url] = {}} ) do |user_type, hsh|
152
+ # Internal request queues
153
+ hsh[app_root_url][user_type] = @@request_queues[app_root_url][user_type]
154
+ # External request queues
155
+ if remote_urls.any?
156
+ unless Rails.application.routes.default_url_options[:host]
157
+ raise "Can't create external request queues without setting Rails.application.routes.default_url_options[:host]"
158
+ end
159
+ remote_urls.each {|remote_url| hsh[remote_url][user_type] = @@request_queues[remote_url][user_type]}
160
+ end
161
+ end
162
+ end
163
+ end
164
+
129
165
  end
166
+ super
130
167
  end
131
168
 
132
169
  def remove_queued_request(user:, request_path:)
133
- request_queues[user_type(user)].remove_path(request_path)
170
+ request_queues.each{|key, hsh| hsh[user_type(user)].remove_path(request_path)}
134
171
  end
135
172
 
136
173
  def subscriber
@@ -158,6 +195,11 @@ module Fragmentary
158
195
  # signed in. When the fragment is instantiated using FragmentsHelper methods 'cache_fragment' or 'CacheBuilder.cache_child',
159
196
  # a :user option is added to the options hash automatically from the value of 'current_user'. The user_type is extracted
160
197
  # from this option in Fragment.attributes.
198
+ #
199
+ # For each class that declares 'needs_user_type', a set of user_types is defined that determines the set of request_queues
200
+ # that will be used to send requests to the application when a fragment is touched. By default these user_types are defined
201
+ # globally using 'Fragmentary.setup' but they can alternatively be set on a class-specific basis by passing a :session_users
202
+ # option to 'needs_user_type'. See 'Fragmentary.parse_session_users' for details.
161
203
  def needs_user_type(options = {})
162
204
  self.extend NeedsUserType
163
205
  instance_eval do
@@ -218,28 +260,25 @@ module Fragmentary
218
260
  # by the fragment.
219
261
  class << record_type_subscription
220
262
  set_callback :after_destroy, :after, ->{subscriber.client.remove_fragments_for_record(record.id)}
263
+ set_callback :after_create, :after, ->{subscriber.client.try_request_for_record(record.id)}
221
264
  end
222
265
  end
223
266
 
224
- if requestable?
225
- record_class = record_type.constantize
226
- instance_eval <<-HEREDOC
227
- subscribe_to #{record_class} do
228
- def create_#{record_class.model_name.param_key}_successful(record)
229
- request = Fragmentary::Request.new(request_method, request_path(record.id),
230
- request_parameters(record.id), request_options)
231
- queue_request(request)
232
- end
233
- end
234
- HEREDOC
235
- end
236
-
267
+ self.extend RecordClassMethods
237
268
  define_method(:record){record_type.constantize.find(record_id)}
238
269
  end
239
270
  end
240
271
 
241
- def remove_fragments_for_record(record_id)
242
- where(:record_id => record_id).each(&:destroy)
272
+ module RecordClassMethods
273
+ def remove_fragments_for_record(record_id)
274
+ where(:record_id => record_id).each(&:destroy)
275
+ end
276
+
277
+ def try_request_for_record(record_id)
278
+ if requestable?
279
+ queue_request(request(record_id))
280
+ end
281
+ end
243
282
  end
244
283
 
245
284
  def needs_record_id?
@@ -288,9 +327,7 @@ module Fragmentary
288
327
  end
289
328
 
290
329
  def queue_request(request=nil)
291
- if request
292
- request_queues.each{|key, queue| queue << request}
293
- end
330
+ request_queues.each{|key, hsh| hsh.each{|key2, queue| queue << request}} if request
294
331
  end
295
332
 
296
333
  def requestable?
@@ -308,7 +345,11 @@ module Fragmentary
308
345
 
309
346
  # The instance method 'request_options' is defined in terms of this.
310
347
  def request_options
311
- nil
348
+ {}
349
+ end
350
+
351
+ def request
352
+ raise NotImplementedError
312
353
  end
313
354
 
314
355
  # This method defines the handler for the creation of new list items. The method takes:
@@ -453,7 +494,7 @@ module Fragmentary
453
494
  end
454
495
 
455
496
  def touch(*args, no_request: false)
456
- request_queues.each{|key, queue| queue << request} if request && !no_request
497
+ request_queues.each{|key, hsh| hsh.each{|key2, queue| queue << request}} if request && !no_request
457
498
  super(*args)
458
499
  end
459
500
 
@@ -3,6 +3,7 @@ module Fragmentary
3
3
  module FragmentsHelper
4
4
 
5
5
  def cache_fragment(options, &block)
6
+ options.reverse_merge!(Fragmentary.config.application_root_url_column => self.root_url.gsub(%r{https?://}, ''))
6
7
  CacheBuilder.new(self).cache_fragment(options, &block)
7
8
  end
8
9
 
@@ -10,6 +11,7 @@ module Fragmentary
10
11
  # the template option is deprecated but avoids breaking prior usage
11
12
  template = options.delete(:template) || self
12
13
  options.reverse_merge!(:user => Template.new(template).current_user)
14
+ options.reverse_merge!(Fragmentary.config.application_root_url_column => self.root_url.gsub(%r{https?://}, ''))
13
15
  CacheBuilder.new(template, Fragmentary::Fragment.base_class.existing(options))
14
16
  end
15
17
 
@@ -33,7 +33,9 @@ module Fragmentary
33
33
  end
34
34
 
35
35
  def after_update_broadcast
36
+ Rails.logger.info "\n***** #{start = Time.now} broadcasting :after_update from #{self.class.name} #{self.id}\n"
36
37
  broadcast(:after_update, self) if self.previous_changes.any?
38
+ Rails.logger.info "\n***** #{Time.now} broadcast :after_update from #{self.class.name} #{self.id} took #{(Time.now - start) * 1000} ms\n"
37
39
  end
38
40
 
39
41
  def after_destroy_broadcast
@@ -3,28 +3,13 @@ module Fragmentary
3
3
  class Request
4
4
  attr_reader :method, :path, :options, :parameters
5
5
 
6
- def initialize(method, path, parameters=nil, options=nil)
6
+ def initialize(method, path, parameters=nil, options={})
7
7
  @method, @path, @parameters, @options = method, path, parameters, options
8
8
  end
9
9
 
10
10
  def ==(other)
11
11
  method == other.method and path == other.path and parameters == other.parameters and options == other.options
12
12
  end
13
-
14
- def to_proc
15
- method = @method; path = @path; parameters = @parameters; options = @options.try :dup
16
- if @options.try(:[], :xhr)
17
- Proc.new do
18
- puts " * Sending xhr request '#{method.to_s} #{path}'" + (!parameters.nil? ? " with #{parameters.inspect}" : "")
19
- send(:xhr, method, path, parameters, options)
20
- end
21
- else
22
- Proc.new do
23
- puts " * Sending request '#{method.to_s} #{path}'" + (!parameters.nil? ? " with #{parameters.inspect}" : "")
24
- send(method, path, parameters, options)
25
- end
26
- end
27
- end
28
13
  end
29
14
 
30
15
  end
@@ -1,5 +1,3 @@
1
- require 'fragmentary/user_session'
2
-
3
1
  module Fragmentary
4
2
 
5
3
  class RequestQueue
@@ -8,12 +6,15 @@ module Fragmentary
8
6
  @@all ||= []
9
7
  end
10
8
 
11
- attr_reader :requests, :user_type, :sender
9
+ attr_reader :requests, :user_type, :host_root_url
12
10
 
13
- def initialize(user_type)
11
+ def initialize(user_type, host_root_url)
14
12
  @user_type = user_type
13
+ # host_root_url represents where the queued *requests* are to be processed. For internal sessions it also represents where
14
+ # the *queue* will be processed by delayed_job. For external requests, the queue will be processed by the host creating the
15
+ # queue and the requests will be explicitly sent to the host_root_url.
16
+ @host_root_url = host_root_url
15
17
  @requests = []
16
- @sender = Sender.new(self)
17
18
  self.class.all << self
18
19
  end
19
20
 
@@ -40,6 +41,10 @@ module Fragmentary
40
41
  requests.delete_if{|r| r.path == path}
41
42
  end
42
43
 
44
+ def sender
45
+ @sender ||= Sender.new(self)
46
+ end
47
+
43
48
  def send(**args)
44
49
  sender.start(args)
45
50
  end
@@ -49,16 +54,39 @@ module Fragmentary
49
54
  end
50
55
 
51
56
  class Sender
57
+
52
58
  class << self
53
59
  def jobs
54
60
  ::Delayed::Job.where("(handler LIKE ?) OR (handler LIKE ?)", "--- !ruby/object:#{name} %", "--- !ruby/object:#{name}\n%")
55
61
  end
56
62
  end
57
63
 
64
+ class Target
65
+
66
+ attr_reader :url
67
+
68
+ def initialize(url)
69
+ @url = url
70
+ end
71
+
72
+ def queue_name
73
+ @url.gsub(%r{https?://}, '')
74
+ end
75
+ end
76
+
58
77
  attr_reader :queue
59
78
 
60
79
  def initialize(queue)
61
80
  @queue = queue
81
+ @target = Target.new(queue.host_root_url)
82
+ end
83
+
84
+ def session_user
85
+ @session_user ||= Fragmentary::SessionUser.fetch(queue.user_type)
86
+ end
87
+
88
+ def session
89
+ @session ||= InternalUserSession.new(@target.url, session_user)
62
90
  end
63
91
 
64
92
  # Send all requests, either directly or by schedule
@@ -80,22 +108,19 @@ module Fragmentary
80
108
  @between ? send_next_request : send_all_requests
81
109
  end
82
110
 
111
+ def send_next_request
112
+ if queue.size > 0
113
+ request = queue.next_request
114
+ session.send_request(:method => request.method, :path => request.path, :parameters => request.parameters, :options => request.options)
115
+ end
116
+ end
117
+
83
118
  def success
84
119
  schedule_requests(@between) if queue.size > 0
85
120
  end
86
121
 
87
122
  private
88
123
 
89
- def next_request
90
- queue.next_request.to_proc
91
- end
92
-
93
- def send_next_request
94
- if queue.size > 0
95
- session.instance_exec(&(next_request))
96
- end
97
- end
98
-
99
124
  def send_all_requests
100
125
  while queue.size > 0
101
126
  send_next_request
@@ -107,20 +132,11 @@ module Fragmentary
107
132
  clear_session
108
133
  Delayed::Job.transaction do
109
134
  self.class.jobs.destroy_all
110
- Delayed::Job.enqueue self, :run_at => delay.from_now
135
+ Delayed::Job.enqueue self, :run_at => delay.from_now, :queue => @target.queue_name
111
136
  end
112
137
  end
113
138
  end
114
139
 
115
- def session
116
- @session ||= new_session
117
- end
118
-
119
- def new_session
120
- session_user = Fragmentary::SessionUser.fetch(queue.user_type)
121
- UserSession.new(session_user)
122
- end
123
-
124
140
  def clear_session
125
141
  @session = nil
126
142
  end
@@ -128,4 +144,5 @@ module Fragmentary
128
144
  end
129
145
 
130
146
  end
147
+
131
148
  end
@@ -0,0 +1,38 @@
1
+ module Fragmentary
2
+
3
+ class SessionUser
4
+
5
+ def self.all
6
+ @@all ||= Hash.new
7
+ end
8
+
9
+ def self.fetch(key)
10
+ all[key]
11
+ end
12
+
13
+ def initialize(user_type, options={})
14
+ if user = self.class.fetch(user_type)
15
+ if user.options != options
16
+ raise RangeError, "You can't redefine an existing SessionUser object: #{user_type.inspect}"
17
+ else
18
+ user
19
+ end
20
+ else
21
+ @user_type = user_type
22
+ @options = options
23
+ self.class.all.merge!({user_type => self})
24
+ end
25
+ end
26
+
27
+ def credentials
28
+ options[:credentials]
29
+ end
30
+
31
+ protected
32
+ def options
33
+ @options
34
+ end
35
+
36
+ end
37
+
38
+ end
@@ -44,7 +44,7 @@ module Fragmentary
44
44
  end
45
45
 
46
46
  include ActiveSupport::Callbacks
47
- define_callbacks :after_destroy
47
+ define_callbacks :after_create, :after_destroy
48
48
 
49
49
  attr_reader :subscriber
50
50
  attr_accessor :record
@@ -55,7 +55,10 @@ module Fragmentary
55
55
  end
56
56
 
57
57
  def after_create(record)
58
- call_method(:"create_#{record.class.model_name.param_key}_successful", record)
58
+ run_callbacks :after_create do
59
+ @record = record
60
+ call_method(:"create_#{record.class.model_name.param_key}_successful", record)
61
+ end
59
62
  end
60
63
 
61
64
  def after_update(record)
@@ -1,38 +1,66 @@
1
1
  require 'rails/console/app'
2
+ require 'http'
3
+ require 'nokogiri'
2
4
 
3
5
  module Fragmentary
4
6
 
5
- class UserSession
7
+ class InternalUserSession
6
8
 
7
9
  include Rails::ConsoleMethods
8
10
 
9
- def initialize(user, &block)
11
+ def initialize(target, user=nil, &block)
10
12
  # app is from Rails::ConsoleMethods. It returns an object ActionDispatch::Integration::Session.new(Rails.application)
11
13
  # with some extensions. See https://github.com/rails/rails/blob/master/railties/lib/rails/console/app.rb
12
14
  # The session object has instance methods get, post etc.
13
15
  # See https://github.com/rails/rails/blob/master/actionpack/lib/action_dispatch/testing/integration.rb
14
16
  @session = app
15
- sign_in if @credentials = session_credentials(user)
17
+ @user = user
18
+ @target = URI.parse(target)
19
+ @session.host! session_host
20
+ sign_in if session_credentials
16
21
  instance_eval(&block) if block_given?
17
22
  end
18
23
 
19
- def session_credentials(user)
20
- credentials = user.try(:credentials)
21
- credentials.is_a?(Proc) ? credentials.call : credentials
24
+ def session_host
25
+ @session_host ||= @target.host + (port=@target.port ? ":#{port}" : "")
22
26
  end
23
27
 
24
- def method_missing(method, *args)
25
- @session.send(method, *args)
28
+ def session_sign_in_path
29
+ @sign_in_path ||= Fragmentary.config.get_sign_in_path
26
30
  end
27
31
 
28
- def sign_out
29
- post Fragmentary.config.sign_out_path, {:_method => 'delete', :authenticity_token => request.session[:_csrf_token]}
32
+ def session_sign_out_path
33
+ @sign_out_path ||= Fragmentary.config.sign_out_path
34
+ end
35
+
36
+ def session_credentials
37
+ return nil unless @user
38
+ @credentials ||= begin
39
+ credentials = @user.credentials
40
+ credentials.is_a?(Proc) ? credentials.call : credentials
41
+ end
42
+ end
43
+
44
+ def relative_url_root
45
+ @relative_url_root ||= Rails.application.config.relative_url_root
46
+ end
47
+
48
+ def session_options
49
+ @session_options ||= relative_url_root ? {:env => {'SCRIPT_NAME' => relative_url_root}} : {}
50
+ end
51
+
52
+ def method_missing(method, *args)
53
+ @session.send(method, *args)
30
54
  end
31
55
 
32
56
  def sign_in
33
- get Fragmentary.config.get_sign_in_path # necessary in order to get the csrf token
57
+ raise "Can't sign in without user credentials" unless session_credentials
58
+ send_request(:method => :get, :path => session_sign_in_path, :options => session_options) # necessary in order to get the csrf token
34
59
  # NOTE: In Rails 5, params is changed to a named argument, i.e. :params => {...}. Will need to be changed.
35
- post Fragmentary.config.post_sign_in_path, @credentials.merge(:authenticity_token => request.session[:_csrf_token])
60
+ # Note that request is called on session, returning an ActionDispatch::Request; request.session is an ActionDispatch::Request::Session
61
+ puts " * Signing in as #{session_credentials.inspect}"
62
+ parameters = session_credentials.merge(:authenticity_token => request.session[:_csrf_token])
63
+ send_request(:method => :post, :path => session_sign_in_path, :parameters => parameters, :options => session_options)
36
64
  if @session.redirect?
37
65
  follow_redirect!
38
66
  else
@@ -40,39 +68,87 @@ module Fragmentary
40
68
  end
41
69
  end
42
70
 
43
- end
71
+ def follow_redirect!
72
+ raise "not a redirect! #{status} #{status_message}" unless redirect?
73
+ if (url = response.location) =~ %r{://}
74
+ destination = URI.parse(url)
75
+ path = destination.query ? "#{destination.path}?#{destination.query}" : destination.path
76
+ end
77
+ path = relative_url_root ? path.gsub(Regexp.new("^#{relative_url_root}"), "") : path
78
+ send_request(:method => :get, :path => path, :options => session_options)
79
+ status
80
+ end
44
81
 
45
- class SessionUser
82
+ def send_request(method:, path:, parameters: nil, options: {})
83
+ options.merge!({:params => parameters})
84
+ options.merge!(session_options)
85
+ if options.try(:[], :xhr)
86
+ Rails.logger.info " * Sending xhr request '#{method.to_s} #{path}'" + (!parameters.nil? ? " with #{parameters.inspect}" : "")
87
+ else
88
+ Rails.logger.info " * Sending request '#{method.to_s} #{path}'" + (!parameters.nil? ? " with #{parameters.inspect}" : "")
89
+ end
90
+ @session.send(method, path, options)
91
+ end
46
92
 
47
- def self.all
48
- @@all ||= Hash.new
93
+ def sign_out
94
+ # request is called on session, returning an ActionDispatch::Request; request.session is an ActionDispatch::Request::Session
95
+ parameters = {:_method => 'delete', :authenticity_token => request.session[:_csrf_token]}
96
+ send_request(:method => :post, :path => session_sign_out_path, :parameters => parameters, :options => session_options)
49
97
  end
50
98
 
51
- def self.fetch(key)
52
- all[key]
99
+ end
100
+
101
+ class ExternalUserSession
102
+
103
+ def initialize(target, user=nil)
104
+ @target = URI.parse(target)
105
+ @relative_url_root = @target.path
106
+ @session = HTTP.persistent(target)
107
+ @cookie = nil
108
+ @authenticity_token = nil
109
+ sign_in if @credentials = session_credentials(user)
53
110
  end
54
111
 
55
- def initialize(user_type, options={})
56
- if user = self.class.fetch(user_type)
57
- if user.options != options
58
- raise RangeError, "You can't redefine an existing SessionUser object: #{user_type.inspect}"
59
- else
60
- user
61
- end
112
+ def send_request(method:, path:, parameters: nil, options: {})
113
+ if options.try(:[], :xhr)
114
+ Rails.logger.info " * Sending xhr request '#{method.to_s} #{path}'" + (!parameters.nil? ? " with #{parameters.inspect}" : "")
62
115
  else
63
- @user_type = user_type
64
- @options = options
65
- self.class.all.merge!({user_type => self})
116
+ Rails.logger.info " * Sending request '#{method.to_s} #{path}'" + (!parameters.nil? ? " with #{parameters.inspect}" : "")
66
117
  end
118
+ unless path =~ %r{://}
119
+ path = @relative_url_root + path
120
+ end
121
+ cookies = @cookie ? {@cookie.name.to_sym => @cookie.value} : {}
122
+ headers = options.try(:delete, :headers) || {}
123
+ headers.merge!({:'X-Requested-With' => 'XMLHttpRequest'}) if options.try(:delete, :xhr)
124
+ response = @session.cookies(cookies).headers(headers).send(method, path, {:json => parameters})
125
+ @cookie = response.cookies.first
126
+ @authenticity_token = Nokogiri::HTML.parse(response.to_s).css('head meta[name="csrf-token"]').first.try(:[], 'content')
127
+ if (response.code >=300) && (response.code <=399)
128
+ location = response.headers[:location]
129
+ options = {:headers => {:accept => "text/html,application/xhtml+xml,application/xml"}}
130
+ response = send_request(:method => :get, :path => location, :parameters => nil, :options => options)
131
+ end
132
+ response
67
133
  end
68
134
 
69
- def credentials
70
- options[:credentials]
135
+ def session_credentials(user)
136
+ credentials = user.try(:credentials)
137
+ credentials.is_a?(Proc) ? credentials.call : credentials
71
138
  end
72
139
 
73
- protected
74
- def options
75
- @options
140
+ def sign_in
141
+ # The first request retrieves the authentication token
142
+ response = send_request(:method => :get, :path => Fragmentary.config.get_sign_in_path)
143
+ puts " * Signing in as #{@credentials.inspect}"
144
+ response = send_request(:method => :post, :path => Fragmentary.config.post_sign_in_path,
145
+ :parameters => @credentials.merge(:authenticity_token => @authenticity_token),
146
+ :options => {:headers => {:accept => "text/html,application/xhtml+xml,application/xml"}})
147
+ end
148
+
149
+ def sign_out
150
+ send_request(:method => :delete, :path => Fragmentary.config.sign_out_path, :parameters => {:authenticity_token => @authenticity_token})
151
+ @session.close
76
152
  end
77
153
  end
78
154
 
@@ -1,3 +1,3 @@
1
1
  module Fragmentary
2
- VERSION = "0.2.2"
2
+ VERSION = "0.3"
3
3
  end
data/lib/fragmentary.rb CHANGED
@@ -7,6 +7,7 @@ require 'fragmentary/request'
7
7
  require 'fragmentary/fragment'
8
8
  require 'fragmentary/handler'
9
9
  require 'fragmentary/user_session'
10
+ require 'fragmentary/session_user'
10
11
  require 'fragmentary/widget_parser'
11
12
  require 'fragmentary/widget'
12
13
  require 'fragmentary/publisher'
metadata CHANGED
@@ -1,35 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fragmentary
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: '0.3'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mark Thomson
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-07-02 00:00:00.000000000 Z
11
+ date: 2022-01-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ">="
18
- - !ruby/object:Gem::Version
19
- version: 4.0.0
20
- - - "<"
17
+ - - "~>"
21
18
  - !ruby/object:Gem::Version
22
- version: '5'
19
+ version: '5.0'
23
20
  type: :runtime
24
21
  prerelease: false
25
22
  version_requirements: !ruby/object:Gem::Requirement
26
23
  requirements:
27
- - - ">="
28
- - !ruby/object:Gem::Version
29
- version: 4.0.0
30
- - - "<"
24
+ - - "~>"
31
25
  - !ruby/object:Gem::Version
32
- version: '5'
26
+ version: '5.0'
33
27
  - !ruby/object:Gem::Dependency
34
28
  name: delayed_job_active_record
35
29
  requirement: !ruby/object:Gem::Requirement
@@ -58,6 +52,34 @@ dependencies:
58
52
  - - "~>"
59
53
  - !ruby/object:Gem::Version
60
54
  version: '1.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: http
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 3.0.0
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 3.0.0
69
+ - !ruby/object:Gem::Dependency
70
+ name: nokogiri
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
61
83
  - !ruby/object:Gem::Dependency
62
84
  name: bundler
63
85
  requirement: !ruby/object:Gem::Requirement
@@ -127,6 +149,7 @@ files:
127
149
  - lib/fragmentary/publisher.rb
128
150
  - lib/fragmentary/request.rb
129
151
  - lib/fragmentary/request_queue.rb
152
+ - lib/fragmentary/session_user.rb
130
153
  - lib/fragmentary/subscriber.rb
131
154
  - lib/fragmentary/subscription.rb
132
155
  - lib/fragmentary/user_session.rb
@@ -152,8 +175,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
152
175
  - !ruby/object:Gem::Version
153
176
  version: '0'
154
177
  requirements: []
155
- rubyforge_project:
156
- rubygems_version: 2.4.8
178
+ rubygems_version: 3.0.8
157
179
  signing_key:
158
180
  specification_version: 4
159
181
  summary: Fragment modeling and caching for Rails