wcc-jsonapi 0.1.0 → 0.2.0

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: 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'