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 +5 -5
- data/README.md +14 -0
- data/Rakefile +9 -2
- data/lib/wcc/jsonapi/concerns/api_action_caching.rb +128 -0
- data/lib/wcc/jsonapi/concerns/pagination_helper.rb +49 -0
- data/lib/wcc/jsonapi/concerns/serializer_with_caching.rb +171 -0
- data/lib/wcc/jsonapi/rspec/api_cache_control_examples.rb +97 -0
- data/lib/wcc/jsonapi/rspec/snapshot_helper.rb +158 -0
- data/lib/wcc/jsonapi/rspec.rb +11 -0
- data/lib/wcc/{json_api → jsonapi}/version.rb +1 -1
- data/lib/wcc/jsonapi.rb +9 -0
- data/lib/wcc-jsonapi.rb +3 -0
- metadata +27 -206
- data/.gitignore +0 -14
- data/Gemfile +0 -6
- data/LICENSE.txt +0 -0
- data/lib/generators/wcc/json_api/client_generator.rb +0 -9
- data/lib/wcc/json_api/active_record_shim.rb +0 -54
- data/lib/wcc/json_api/base_client/api_error.rb +0 -26
- data/lib/wcc/json_api/base_client/base_response.rb +0 -127
- data/lib/wcc/json_api/base_client/http_adapter.rb +0 -19
- data/lib/wcc/json_api/base_client/typhoeus_adapter.rb +0 -63
- data/lib/wcc/json_api/base_client.rb +0 -142
- data/wcc-jsonapi.gemspec +0 -38
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 8ab4f51035684c2eefb583873f49cf69d673db91aafce6e79ba1f43c63c7610c
|
4
|
+
data.tar.gz: ba7ba67c456819a916e83385521c4a3a7d8114ce905618c42a1b78f0528ec2ce
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
@@ -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
|
data/lib/wcc/jsonapi.rb
ADDED
@@ -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
|
data/lib/wcc-jsonapi.rb
ADDED