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 +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