wcc-jsonapi 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: a0508ec8a9f9a06c3e595e95fc0858fb223c5d5d
4
- data.tar.gz: eb6da7214a9f625b6a59e6f04a7873f46f762ce5
2
+ SHA256:
3
+ metadata.gz: 8ab4f51035684c2eefb583873f49cf69d673db91aafce6e79ba1f43c63c7610c
4
+ data.tar.gz: ba7ba67c456819a916e83385521c4a3a7d8114ce905618c42a1b78f0528ec2ce
5
5
  SHA512:
6
- metadata.gz: 7bdf2b4539b048ba97d79f462c8752dcff1974b3863e38b5569cfce071b33d9cb5e677cbf184ed973ba33d4fb2cf7d68e78d7f69af01d577f9e27ea59ec126b4
7
- data.tar.gz: 25aca96f9d68022dd7fa2e244d6fde81e4c42492e70fabfd02f5c135314870988c22df21127f764c4f99b9ea54661201b41f76105d0b58cb8a1d4791f5be9c65
6
+ metadata.gz: 33568cdb950b83cbf2c8aae18a34b1a9df0351be15ed4e363b8f383f2217385793d75f9319c349ec4e9b7d8f11e056e56c09aee1b96e830279b29f68b5bb66c6
7
+ data.tar.gz: 78a811e119d17f7b0691ba7793f22ecad55fff7d522bf4971a77494b4fffecfe1f9c9b24f7d1254a15c6f67060c932e0e5288e29269647d9c81c4b85f67850d3
data/README.md CHANGED
@@ -0,0 +1,14 @@
1
+ # wcc-jsonapi
2
+
3
+ This gem includes common utilities for writing APIs in Rails that conform to
4
+ the JsonAPI standard.
5
+
6
+ Included modules:
7
+ * PaginationHelper
8
+ A controller concern for help generating pagination links
9
+ * ApiActionCaching
10
+ A controller concern which checks the ETag/Last-Modified for freshness,
11
+ skipping the action if so.
12
+ * SerializerWithCaching
13
+ A serializer concern to generate ETag/Last-Modified metadata based on
14
+ inclusion params. Used with [JSONAPI::Serializer](https://github.com/jsonapi-serializer/jsonapi-serializer)
data/Rakefile CHANGED
@@ -1,3 +1,10 @@
1
- # frozen_string_literal: true
1
+ require "bundler/gem_tasks"
2
2
 
3
- require 'bundler/gem_tasks'
3
+ begin
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+ task default: :spec
8
+ rescue LoadError
9
+ # no rspec available
10
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_controller/action_caching'
4
+
5
+ # This concern enables ActionCaching for our JsonAPI endpoints. It improves on
6
+ # the standard actionpack-action_caching gem for our specific use case. More
7
+ # specifically, it accomplishes the following:
8
+ # * If the incoming request is a conditional GET with ETag or Last-Modified, it
9
+ # returns 304 not modified without invoking the controller or loading the resource
10
+ # * If the incoming request is stale, it will load the rendered JSON and caching
11
+ # headers from the cache store without invoking the action
12
+ # * If the incoming request is stale AND the CacheVersion for the specified key is old,
13
+ # it invokes the action and saves both the rendered JSON response and the caching headers.
14
+ #
15
+ # It is very important to use `stale?` or `fresh_when?` to set the caching headers
16
+ # inside the action, otherwise this concern cannot respond to conditional GET requests.
17
+ #
18
+ # The controller must also include ActionController::Caching
19
+ # (or extend from Api::V1::BaseController which includes it)
20
+ #
21
+ # Example:
22
+ # class Api::V1::MyController < Api::V1::BaseController
23
+ # include Api::V1::ApiActionCaching
24
+ #
25
+ # caches_api_action :show, version_key: :my_resource # see CacheVersion::KEY_MAPPING
26
+ #
27
+ # def show
28
+ # @resource = load_resource
29
+ # @page = Api::V1::MyResourceSerializer.new(@person)
30
+ #
31
+ # render json: @page if stale?(
32
+ # strong_etag: @page.etag, last_modified: @page.last_modified, public: true,
33
+ # )
34
+ # end
35
+ #
36
+ # Modeled after https://github.com/rails/actionpack-action_caching/blob/v1.2.0/lib/action_controller/caching/actions.rb
37
+ module WCC::JsonAPI::ApiActionCaching
38
+ extend ActiveSupport::Concern
39
+
40
+ class_methods do
41
+ # This applies the action-caching filter around the action
42
+ def caches_api_action(*actions)
43
+ return unless cache_configured?
44
+
45
+ options = actions.extract_options!
46
+ filter_options = options.slice!(:cache_path, :cache_version)
47
+ filter_options.merge!(only: actions) if actions.present?
48
+ around_action ApiActionCacheFilter.new(options), **filter_options
49
+ end
50
+ end
51
+
52
+ class ApiActionCacheFilter
53
+ def initialize(options)
54
+ @options = options
55
+ end
56
+
57
+ def cache_path(controller)
58
+ cp = @options[:cache_path]
59
+ case cp
60
+ when Symbol
61
+ controller.__send__(cp)
62
+ when Proc
63
+ controller.instance_exec(&cp)
64
+ else
65
+ cp
66
+ end
67
+ end
68
+
69
+ def cache_version(controller)
70
+ version = @options[:cache_version]
71
+ case version
72
+ when Symbol
73
+ controller.__send__(version)
74
+ when Proc
75
+ controller.instance_exec(&version)
76
+ else
77
+ raise ArgumentError, 'cache_version must be method name or proc'
78
+ end
79
+ end
80
+
81
+ def around(controller)
82
+ # cache path is the standardized request URL
83
+ cache_path = self.cache_path(controller) ||
84
+ controller.url_for(controller.params.permit!.merge(only_path: true))
85
+ unless cache_version = self.cache_version(controller)
86
+ raise StandardError, 'No cache_version specified'
87
+ end
88
+
89
+ # If we have run the action previously, our caching headers will be stored in the cache.
90
+ # Use those cached headers to check our incoming request for freshness
91
+ # (i.e. pretend that the action has already set ETag and Last-Modified then check freshness)
92
+ status, headers = controller.cache_store.read(
93
+ [:api_action_caching_headers, cache_path, cache_version],
94
+ )
95
+ controller.headers.merge!(headers) if headers
96
+
97
+ if controller.request.fresh?(controller.response)
98
+ # render 304 not modified
99
+ return
100
+ end
101
+
102
+ # Cache permanent redirects
103
+ if status == 301 && controller.headers['Location'].present?
104
+ controller.response.status = status
105
+ return
106
+ end
107
+
108
+ body = controller.read_fragment([cache_path, cache_version], nil)
109
+ unless body && headers
110
+ # The cache_version has been updated - rerun the action
111
+ yield
112
+
113
+ # Save the results of the action in the controller's Fragment Cache
114
+ body = controller._save_fragment([cache_path, cache_version], nil)
115
+
116
+ status, headers = controller.response.to_a
117
+ controller.cache_store.write(
118
+ [:api_action_caching_headers, cache_path, cache_version],
119
+ [status, headers],
120
+ )
121
+ end
122
+
123
+ # Assign the response body if we skipped running the action
124
+ controller.response_body = body
125
+ controller.response.status = status if status
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WCC::JsonAPI::PaginationHelper
4
+ def limit
5
+ @limit ||= [
6
+ params[:limit]&.to_i || 100,
7
+ 100,
8
+ ].min
9
+ end
10
+
11
+ def skip
12
+ @skip ||= params[:skip]&.to_i || 0
13
+ end
14
+
15
+ def index_meta(total)
16
+ {
17
+ total: total,
18
+ limit: limit,
19
+ skip: skip,
20
+ }
21
+ end
22
+
23
+ # Gets the pagination links for the current skip and limit.
24
+ # This method is inherently complex due to the math involved. Better to
25
+ # encapsulate the complexity here.
26
+ def pagination_links(total)
27
+ prev_skip = skip == 0 ? nil : [skip - limit, 0].max
28
+ next_skip = (skip + limit) > total ? nil : skip + limit
29
+ last_skip = (total / limit).floor * limit
30
+
31
+ options = permitted_params.merge(only_path: true)
32
+
33
+ {
34
+ self: url_for(options.merge(limit: limit, skip: skip)),
35
+ first: url_for(options.merge(limit: limit, skip: 0)),
36
+ prev: prev_skip && url_for(options.merge(limit: limit, skip: prev_skip)),
37
+ next: next_skip && url_for(options.merge(limit: limit, skip: next_skip)),
38
+ last: url_for(options.merge(limit: limit, skip: last_skip)),
39
+ }
40
+ end
41
+
42
+ def index_serializer_options(total)
43
+ {
44
+ is_collection: true,
45
+ meta: index_meta(total),
46
+ links: pagination_links(total),
47
+ }.merge(serializer_options)
48
+ end
49
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This is a really complex module that is trying to cover a lot of different
4
+ # edge cases related to generating cache headers. It is complex. It tries to
5
+ # generate a consistent ETag for a serialized JSONApi response. When that response
6
+ # has includes, those included models are included in the ETag. When that response
7
+ # is a collection, all collection items' cache keys are included in the ETag.
8
+ #
9
+ # To use this module, include it in a JSONAPI::Serializer definition. Then,
10
+ # in your controller, use the `stale?` or `fresh_when` methods to set ETag and
11
+ # Last-Modified HTTP headers and respond to conditional GETs.
12
+ #
13
+ # The module attempts to conform to Rails' Recyclable Cache Keys specification,
14
+ # allowing serializers to be used as cache keys in the Rails cache as well.
15
+ #
16
+ # rubocop:disable Metrics/LineLength
17
+ module WCC::JsonAPI::SerializerWithCaching
18
+ extend ActiveSupport::Concern
19
+
20
+ included do
21
+ # ensure "model_name" exists on the serializer
22
+ extend ActiveModel::Naming
23
+
24
+ meta do |record|
25
+ # intentionally don't use includes here -
26
+ # etag and lm in the meta should represent only this record
27
+ serializer = new(record)
28
+ {
29
+ 'ETag' => generate_strong_etag([serializer.etag]),
30
+ 'Last-Modified' => serializer.last_modified.httpdate,
31
+ }
32
+ end
33
+
34
+ def etag
35
+ @etag ||= cache_key_with_version
36
+ end
37
+
38
+ def last_modified
39
+ @last_modified ||= Time.at(updated_at).utc unless updated_at.nil?
40
+ end
41
+
42
+ # If the cache store is using cache versioning, then it will combine the
43
+ # cache_key with the cache_version like this:
44
+ # cache.fetch(item.cache_key, version: item.cache_version) do ...
45
+ #
46
+ # If the cace store does not support cache versioning, the cache store will
47
+ # not use the cache_version so for backwards compatibility we have to combine
48
+ # it into the cache key.
49
+ # https://www.bigbinary.com/blog/rails-adds-support-for-recyclable-cache-keys
50
+ def cache_key
51
+ return cache_key_with_version unless ActiveRecord::Base.try(:cache_versioning) == true
52
+
53
+ cache_key_without_version
54
+ end
55
+
56
+ def cache_key_with_version
57
+ "#{cache_key_without_version}-#{cache_version}"
58
+ end
59
+
60
+ # updated_at is the max of all the related objects
61
+ def updated_at
62
+ all_resources.map { |r| r&.updated_at&.to_i }.compact.max
63
+ end
64
+
65
+ # The base of a recyclable cache key. This is a unique identifier of a single
66
+ # model or a set of models in the system.
67
+ #
68
+ # If the response is a single model without includes, the cache_key_without_version
69
+ # format is "#{serializer model name}/#{resource ID}"
70
+ #
71
+ # If the response is a collection or has includes, the cache_key_without_version
72
+ # is a sha1 hexdigest representing the unique identifiers of all included models.
73
+ def cache_key_without_version
74
+ # Use a simple cache_key_without_version if possible
75
+ @cache_key_without_version ||=
76
+ if @includes.blank? && !self.class.is_collection?(@resource)
77
+ "#{self.class.model_name}/#{@resource.id}"
78
+ else
79
+ sha1 = Digest::SHA1.new
80
+ all_resources.each do |r|
81
+ sha1.update(r.cache_key_without_version)
82
+ end
83
+ "#{self.class.model_name}/#{sha1.hexdigest}"
84
+ end
85
+ end
86
+
87
+ # This is the variant part of a recyclable cache key. It is a version number
88
+ # base on the updated_at, which is the max updated_at of all the
89
+ # embedded and included resources.
90
+ def cache_version
91
+ updated_at.to_i.to_s
92
+ end
93
+
94
+ private
95
+
96
+ # An array of all the Models that go into making this serialized representation
97
+ # of a resource. If the include: param was not set, this is just the @resource
98
+ # and its embedded resources. If the include: param was set, this also includes
99
+ # all the Models that would go in the "included" section of the response.
100
+ def all_resources
101
+ @all_resources ||=
102
+ if self.class.is_collection?(@resource)
103
+ known_included_objects = Set.new
104
+ @resource.each_with_object([]) do |r, all|
105
+ # This resource
106
+ all << r
107
+
108
+ # Plus each item in the collection's included resources, deduplicated.
109
+ next if @includes.blank?
110
+
111
+ all.concat(self.class.get_included_resources(r, @includes, known_included_objects, @fieldsets, @params)) # rubocop:disable Metrics/LineLength
112
+ end
113
+ elsif @includes.present?
114
+ # Single item with includes
115
+ [@resource] +
116
+ self.class.get_included_resources(@resource, @includes, Set.new, @fieldsets, @params)
117
+ else
118
+ # No includes and not a collection, simple: only this resource.
119
+ [@resource]
120
+ end
121
+ end
122
+ end
123
+
124
+ class_methods do
125
+ # copied from https://github.com/rails/rails/blob/v5.2.3/actionpack/lib/action_dispatch/http/cache.rb
126
+ def generate_strong_etag(validators)
127
+ %("#{ActiveSupport::Digest.hexdigest(ActiveSupport::Cache.expand_cache_key(validators))}")
128
+ end
129
+
130
+ # Gets a list of resources, one for each included object.
131
+ # Used to delegate calculating ETags.
132
+ # adapted from https://github.com/jsonapi-serializer/jsonapi-serializer/blob/v2.2.0/lib/fast_jsonapi/serialization_core.rb
133
+ def get_included_resources(record, includes_list, known_included_objects, fieldsets, params = {})
134
+ return if includes_list.blank?
135
+ return [] unless relationships_to_serialize
136
+
137
+ includes_list = parse_includes_list(includes_list)
138
+
139
+ includes_list.each_with_object([]) do |include_item, included_resources|
140
+ relationship_item = relationships_to_serialize[include_item.first]
141
+
142
+ next unless relationship_item&.include_relationship?(record, params)
143
+
144
+ included_objects = Array(relationship_item.fetch_associated_object(record, params))
145
+ next if included_objects.empty?
146
+
147
+ static_serializer = relationship_item.static_serializer
148
+ static_record_type = relationship_item.static_record_type
149
+
150
+ included_objects.each do |inc_obj|
151
+ serializer = static_serializer || relationship_item.serializer_for(inc_obj, params)
152
+ record_type = static_record_type || serializer.record_type
153
+
154
+ if include_item.last.any?
155
+ resources = serializer.get_included_resources(inc_obj, include_item.last, known_included_objects,
156
+ fieldsets, params)
157
+ included_resources.concat(resources) unless resources.empty?
158
+ end
159
+
160
+ code = "#{record_type}_#{serializer.id_from_record(inc_obj, params)}"
161
+ next if known_included_objects.include?(code)
162
+
163
+ known_included_objects << code
164
+
165
+ included_resources << inc_obj
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
171
+ # rubocop:enable Metrics/LineLength
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.shared_examples 'API cache-control headers request specs' do |act, vary, options|
4
+ include ActiveSupport::Testing::TimeHelpers
5
+
6
+ time_diff = options && options[:time_diff]
7
+
8
+ before do
9
+ # Touch all our models 1 hour ago to set updated_at
10
+ travel_to((time_diff || 1.hour).ago) do
11
+ vary.each do |key|
12
+ update_action = nil
13
+ key, update_action = key if key.is_a? Array
14
+
15
+ run_update(key, update_action)
16
+ end
17
+ end
18
+ end
19
+
20
+ it 'renders the same body 2x' do
21
+ instance_exec(&act)
22
+ body1 = response.body
23
+
24
+ instance_exec(&act)
25
+ body2 = response.body
26
+
27
+ expect(body1).to eq(body2)
28
+ end
29
+
30
+ it 'renders the same etag 2x' do
31
+ instance_exec(&act)
32
+ headers1 = response.headers
33
+
34
+ instance_exec(&act)
35
+ headers2 = response.headers
36
+
37
+ expect(headers1['ETag']).not_to be_blank
38
+ expect(headers2['ETag']).to eq(headers1['ETag'])
39
+ end
40
+
41
+ it 'renders the same last-modified 2x' do
42
+ instance_exec(&act)
43
+ headers1 = response.headers
44
+
45
+ instance_exec(&act)
46
+ headers2 = response.headers
47
+
48
+ expect(headers1['Last-Modified']).not_to be_blank
49
+ expect(headers2['Last-Modified']).to eq(headers1['Last-Modified'])
50
+ end
51
+
52
+ vary.each do |key|
53
+ update_action = nil
54
+ key, update_action = key if key.is_a? Array
55
+
56
+ context "when #{key} is varied" do
57
+ it 'changes etag and last modified' do
58
+ instance_exec(&act)
59
+ headers1 = response.headers
60
+
61
+ # Act
62
+ run_update(key, update_action)
63
+
64
+ instance_exec(&act)
65
+ headers2 = response.headers
66
+
67
+ expect(headers1['Last-Modified']).not_to be_blank
68
+ expect(headers2['Last-Modified']).not_to be_blank
69
+ expect(headers2['Last-Modified']).not_to eq(headers1['Last-Modified'])
70
+
71
+ expect(headers1['ETag']).not_to be_blank
72
+ expect(headers2['ETag']).not_to be_blank
73
+ expect(headers2['ETag']).not_to eq(headers1['ETag'])
74
+ end
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def run_update(key, update_action)
81
+ return instance_exec(&update_action) if update_action.respond_to?(:call)
82
+
83
+ model = public_send(key)
84
+ raise ArgumentError, "#{key} is nil!" unless model
85
+
86
+ if model.respond_to?(:update!)
87
+ # use update! not touch b/c we want to run model callbacks
88
+ model.update!(updated_at: Time.zone.now)
89
+ elsif model.is_a?(Hash) && model['sys']
90
+ # contentful fixture
91
+ model['sys']['updatedAt'] = Time.zone.now.iso8601
92
+ else
93
+ allow(model).to receive(:updated_at)
94
+ .and_return(Time.zone.now)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/expectations'
4
+
5
+ module WCC::JsonAPI::RSpec::SnapshotHelper
6
+ extend ActiveSupport::Concern
7
+ extend RSpec::Matchers::DSL
8
+
9
+ included do
10
+ after(:all) do
11
+ # we have to do this in an after block in case there's multiple
12
+ # writes in the same file, which would mess up our detected line numbers.
13
+ write_all_inline_snapshots
14
+ end
15
+ end
16
+
17
+ matcher :match_snapshot do |file_name|
18
+ match do |actual|
19
+ @expected = load_snapshot(file_name)
20
+ @actual = WCC::JsonAPI::RSpec::SnapshotHelper.parse_actual(actual)
21
+
22
+ if @expected.blank? || update_snapshots?
23
+ write_snapshot(file_name, @actual)
24
+ next true
25
+ end
26
+
27
+ values_match?(@expected, @actual)
28
+ end
29
+
30
+ failure_message do |_actual|
31
+ [
32
+ "Expected to match snapshot in file #{file_name}",
33
+ '(run with env var UPDATE_SNAPSHOTS=true to update snapshots)',
34
+ 'Diff:' + differ.diff_as_string(@actual, @expected),
35
+ ].join("\n")
36
+ end
37
+ end
38
+
39
+ matcher :match_inline_snapshot do |inline_snapshot|
40
+ filename, line_num = WCC::JsonAPI::RSpec::SnapshotHelper
41
+ .find_inline_call_spot(caller)
42
+ raise StandardError, 'Could not find inline call spot' if line_num.blank?
43
+
44
+ match do |actual|
45
+ # we strip whitespace here because the heredoc causes leading & trailing newlines
46
+ @expected = inline_snapshot&.strip
47
+ @actual = WCC::JsonAPI::RSpec::SnapshotHelper
48
+ .parse_actual(actual)&.strip
49
+
50
+ if @expected.blank?
51
+ queue_write_inline_snapshot(filename, line_num, @actual)
52
+ next true
53
+ end
54
+
55
+ values_match?(@expected, @actual)
56
+ end
57
+
58
+ failure_message do |_actual|
59
+ [
60
+ 'Expected to match inline snapshot',
61
+ '(delete inline snapshot heredoc including <<~SNAP and SNAP tags to regenerate)',
62
+ 'Diff:' + differ.diff_as_string(@actual, @expected),
63
+ ].join("\n")
64
+ end
65
+ end
66
+
67
+ # https://stackoverflow.com/a/32479025/2192243
68
+ def differ
69
+ RSpec::Support::Differ.new(
70
+ object_preparer: ->(object) { RSpec::Matchers::Composable.surface_descriptions_in(object) },
71
+ color: RSpec::Matchers.configuration.color?,
72
+ )
73
+ end
74
+
75
+ def update_snapshots?
76
+ ActiveModel::Type::Boolean.new.cast(ENV['UPDATE_SNAPSHOTS'])
77
+ end
78
+
79
+ def snapshot_full_path(file_name)
80
+ File.join('spec/snapshots', file_name)
81
+ end
82
+
83
+ def load_snapshot(file_name)
84
+ file = snapshot_full_path(file_name)
85
+ return File.read(file) if File.exist?(file)
86
+ end
87
+
88
+ def write_snapshot(file_name, actual)
89
+ file = snapshot_full_path(file_name)
90
+ FileUtils.mkdir_p(File.dirname(file))
91
+
92
+ puts "SnapshotHelper: Writing new snapshot in #{file_name}"
93
+ File.write(file, actual)
94
+ end
95
+
96
+ MATCH_INLINE_SNAPSHOT_REGEXP = /match\_inline\_snapshot(\(([\'\"\s]+|<<~\w+)?\))?\s*$/
97
+
98
+ def queue_write_inline_snapshot(filename, line_num, actual)
99
+ to_insert = actual.split("\n")
100
+
101
+ # enqueue to the filename
102
+ (WCC::JsonAPI::RSpec::SnapshotHelper.write_queue[filename] ||= []) <<
103
+ [line_num, to_insert]
104
+ end
105
+
106
+ def write_all_inline_snapshots
107
+ WCC::JsonAPI::RSpec::SnapshotHelper.write_queue.each do |filename, queue|
108
+ # rewrite in reverse order to preserve correct line numbers
109
+ queue = queue.sort_by(&:first).reverse
110
+
111
+ lines = File.readlines(filename)
112
+ queue.each do |line_num, to_insert|
113
+ # rewrite the lines
114
+ idx = line_num - 1
115
+ lines[idx] = lines[idx].sub(MATCH_INLINE_SNAPSHOT_REGEXP, 'match_inline_snapshot <<~SNAP')
116
+ lines = lines[0..idx] + to_insert + ['SNAP'] + lines[line_num..-1]
117
+ end
118
+
119
+ puts "SnapshotHelper: Writing new inline snapshot in #{filename}"
120
+ File.open(filename, 'w') do |f|
121
+ lines.each { |line| f.puts(line) }
122
+ end
123
+ end
124
+
125
+ WCC::JsonAPI::RSpec::SnapshotHelper.clear_write_queue!
126
+ end
127
+
128
+ class << self
129
+ def write_queue
130
+ @write_queue ||= {}
131
+ end
132
+
133
+ def clear_write_queue!
134
+ @write_queue = {}
135
+ end
136
+
137
+ def find_inline_call_spot(called_from)
138
+ called_from.each do |line|
139
+ next unless /\_spec\.rb/.match?(line)
140
+
141
+ filename, line_num = line.split(':')
142
+ line_num = line_num&.to_i
143
+ next unless filename && line_num
144
+
145
+ return [filename, line_num]
146
+ end
147
+ end
148
+
149
+ def parse_actual(actual)
150
+ case actual
151
+ when String
152
+ actual
153
+ else
154
+ JSON.pretty_generate(actual).strip
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WCC
4
+ module JsonAPI
5
+ module RSpec
6
+ end
7
+ end
8
+ end
9
+
10
+ require_relative './rspec/snapshot_helper'
11
+ require_relative './rspec/api_cache_control_examples'
@@ -2,6 +2,6 @@
2
2
 
3
3
  module WCC
4
4
  module JsonAPI
5
- VERSION = '0.1.0'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './jsonapi/version'
4
+ require_relative './jsonapi/concerns/serializer_with_caching'
5
+ require_relative './jsonapi/concerns/pagination_helper'
6
+ require_relative './jsonapi/concerns/api_action_caching'
7
+
8
+ module WCC::JsonAPI
9
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './wcc/jsonapi'