graphiti 2.0.0.beta.2 → 2.0.0.beta.4
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 +4 -4
- data/.github/workflows/ci.yml +30 -86
- data/.github/workflows/docs.yml +60 -0
- data/.github/workflows/release.yml +8 -8
- data/.gitignore +7 -0
- data/.npmrc +9 -0
- data/.standard.yml +4 -1
- data/Appraisals +33 -32
- data/CHANGELOG.md +41 -0
- data/README.md +13 -2
- data/UPGRADING.md +2 -68
- data/docs/concepts/backends-and-models.md +122 -0
- data/docs/concepts/endpoints.md +183 -0
- data/docs/concepts/links.md +212 -0
- data/docs/concepts/overview.md +80 -0
- data/docs/concepts/persisting.md +376 -0
- data/docs/concepts/relationships.md +527 -0
- data/docs/concepts/resources.md +677 -0
- data/docs/getting-started/first-api.md +289 -0
- data/docs/getting-started/installation.md +185 -0
- data/docs/intro.md +307 -0
- data/docs/js/authentication.md +63 -0
- data/docs/js/ddau.md +20 -0
- data/docs/js/extra-params.md +41 -0
- data/docs/js/index.md +112 -0
- data/docs/js/installation.md +120 -0
- data/docs/js/middleware.md +72 -0
- data/docs/js/models.md +202 -0
- data/docs/js/reads.md +494 -0
- data/docs/js/state-syncing.md +100 -0
- data/docs/js/writes.md +373 -0
- data/docs/reference/vandal.md +63 -0
- data/docs/reference/why.md +13 -0
- data/docs/topics/authorization.md +155 -0
- data/docs/topics/caching.md +55 -0
- data/docs/topics/customizing-sideloads.md +156 -0
- data/docs/topics/debugging.md +216 -0
- data/docs/topics/error-handling.md +210 -0
- data/docs/topics/etags.md +46 -0
- data/docs/topics/hopping-relationships.md +149 -0
- data/docs/topics/json-attributes.md +77 -0
- data/docs/topics/openstruct-models.md +50 -0
- data/docs/topics/remote-resources.md +291 -0
- data/docs/topics/testing.md +894 -0
- data/docs/topics/without-activerecord.md +324 -0
- data/docs/tutorial/index.md +58 -0
- data/docs/tutorial/step_0.md +107 -0
- data/docs/tutorial/step_1.md +199 -0
- data/docs/tutorial/step_2.md +312 -0
- data/docs/tutorial/step_3.md +142 -0
- data/docs/tutorial/step_4.md +135 -0
- data/docs/tutorial/step_5.md +69 -0
- data/docs/tutorial/step_6.md +82 -0
- data/docs/tutorial/step_7.md +205 -0
- data/docs/tutorial/step_8.md +128 -0
- data/docs/tutorial/step_9.md +171 -0
- data/docs/upgrading.md +265 -0
- data/gemfiles/rails_7_1.gemfile +4 -3
- data/gemfiles/{rails_7_2_graphiti_rails.gemfile → rails_7_2.gemfile} +3 -3
- data/gemfiles/{rails_8_1_graphiti_rails.gemfile → rails_8_0.gemfile} +3 -3
- data/gemfiles/{rails_8_0_graphiti_rails.gemfile → rails_8_1.gemfile} +3 -3
- data/graphiti.gemspec +7 -5
- data/{deprecated_generators → lib/generators}/graphiti/api_test_generator.rb +7 -1
- data/{deprecated_generators → lib/generators}/graphiti/generator_mixin.rb +14 -1
- data/{deprecated_generators → lib/generators}/graphiti/install_generator.rb +19 -13
- data/{deprecated_generators → lib/generators}/graphiti/resource_generator.rb +43 -6
- data/{deprecated_generators → lib/generators}/graphiti/templates/index_request_spec.rb.erb +1 -1
- data/{deprecated_generators → lib/generators}/graphiti/templates/resource_reads_spec.rb.erb +6 -6
- data/{deprecated_generators → lib/generators}/graphiti/templates/show_request_spec.rb.erb +1 -1
- data/lib/graphiti/configuration.rb +2 -2
- data/lib/graphiti/error_serializers/conflict_request.rb +19 -0
- data/lib/graphiti/error_serializers/deprecated_constants.rb +48 -0
- data/lib/graphiti/error_serializers/invalid_request.rb +56 -0
- data/lib/graphiti/error_serializers/validation.rb +143 -0
- data/lib/graphiti/errors.rb +4 -23
- data/lib/graphiti/query.rb +1 -1
- data/lib/graphiti/rails/context.rb +33 -0
- data/lib/graphiti/rails/controller.rb +41 -0
- data/lib/graphiti/rails/debugging.rb +18 -0
- data/lib/graphiti/rails/exception_handlers.rb +77 -0
- data/lib/graphiti/rails/railtie.rb +139 -0
- data/lib/graphiti/rails/responders.rb +21 -0
- data/lib/graphiti/rails/test_helpers.rb +22 -0
- data/lib/graphiti/rails.rb +47 -29
- data/lib/graphiti/resource/configuration.rb +1 -0
- data/lib/graphiti/resource/interface.rb +2 -2
- data/lib/graphiti/resource/persistence.rb +14 -2
- data/lib/graphiti/resource/remote.rb +2 -2
- data/lib/graphiti/resource/sideloading.rb +1 -1
- data/lib/graphiti/resource.rb +13 -1
- data/lib/graphiti/responders.rb +7 -20
- data/lib/graphiti/schema.rb +5 -1
- data/lib/graphiti/schema_diff.rb +4 -0
- data/lib/graphiti/scope.rb +45 -37
- data/lib/graphiti/serializer.rb +6 -0
- data/lib/graphiti/sideload/belongs_to.rb +38 -5
- data/lib/graphiti/sideload/polymorphic_belongs_to.rb +27 -23
- data/lib/graphiti/sideload.rb +54 -35
- data/lib/graphiti/spec_helpers/errors.rb +73 -0
- data/lib/graphiti/spec_helpers/errors_proxy.rb +75 -0
- data/lib/graphiti/spec_helpers/helpers.rb +107 -0
- data/lib/graphiti/spec_helpers/node.rb +88 -0
- data/lib/graphiti/spec_helpers/rspec.rb +147 -0
- data/lib/graphiti/spec_helpers.rb +53 -0
- data/lib/graphiti/util/include_params.rb +2 -2
- data/lib/graphiti/util/persistence.rb +10 -11
- data/lib/graphiti/util/serializer_relationships.rb +41 -5
- data/lib/graphiti/version.rb +1 -1
- data/lib/graphiti-rails.rb +11 -0
- data/lib/graphiti.rb +34 -10
- data/lib/graphiti_errors.rb +11 -0
- data/lib/graphiti_spec_helpers/rspec.rb +3 -0
- data/lib/graphiti_spec_helpers.rb +11 -0
- data/lib/{graphiti/deprecated_tasks.rb → tasks/graphiti.rake} +6 -1
- data/package-lock.json +6199 -0
- data/package.json +5 -4
- data/website/.gitignore +20 -0
- data/website/README.md +43 -0
- data/website/docusaurus.config.js +141 -0
- data/website/package-lock.json +19474 -0
- data/website/package.json +46 -0
- data/website/sidebars.js +82 -0
- data/website/src/css/custom.css +58 -0
- data/website/src/pages/markdown-page.mdx +7 -0
- data/website/static/.nojekyll +0 -0
- data/website/static/1.13/2019/03/31/graphiti-1-0.html +205 -0
- data/website/static/1.13/2019/05/08/graphiti-1-1.html +212 -0
- data/website/static/1.13/2019/05/20/graphiti-1-2.html +214 -0
- data/website/static/1.13/2019/10/14/tutorial.html +198 -0
- data/website/static/1.13/CNAME +1 -0
- data/website/static/1.13/README.md +16 -0
- data/website/static/1.13/assets/css/syntax.css +60 -0
- data/website/static/1.13/assets/favicons/android-chrome-192x192.png +0 -0
- data/website/static/1.13/assets/favicons/android-chrome-256x256.png +0 -0
- data/website/static/1.13/assets/favicons/apple-touch-icon.png +0 -0
- data/website/static/1.13/assets/favicons/browserconfig.xml +9 -0
- data/website/static/1.13/assets/favicons/favicon-16x16.png +0 -0
- data/website/static/1.13/assets/favicons/favicon-32x32.png +0 -0
- data/website/static/1.13/assets/favicons/favicon.ico +0 -0
- data/website/static/1.13/assets/favicons/mstile-150x150.png +0 -0
- data/website/static/1.13/assets/favicons/safari-pinned-tab.svg +1 -0
- data/website/static/1.13/assets/favicons/site.webmanifest +19 -0
- data/website/static/1.13/assets/img/backend.gif +0 -0
- data/website/static/1.13/assets/img/conformity.png +0 -0
- data/website/static/1.13/assets/img/error_payload.png +0 -0
- data/website/static/1.13/assets/img/gh.png +0 -0
- data/website/static/1.13/assets/img/lifecycle.gif +0 -0
- data/website/static/1.13/assets/img/logo-500.png +0 -0
- data/website/static/1.13/assets/img/logo.png +0 -0
- data/website/static/1.13/assets/img/love-graffiti.jpg +0 -0
- data/website/static/1.13/assets/img/meta_total_count.png +0 -0
- data/website/static/1.13/assets/img/persist.jpg +0 -0
- data/website/static/1.13/assets/img/resource.gif +0 -0
- data/website/static/1.13/assets/img/rest-graffiti.jpg +0 -0
- data/website/static/1.13/assets/img/rest1.gif +0 -0
- data/website/static/1.13/assets/img/rest2.gif +0 -0
- data/website/static/1.13/assets/img/rest3.gif +0 -0
- data/website/static/1.13/assets/img/rethink-rest-graffiti.jpg +0 -0
- data/website/static/1.13/assets/img/why.png +0 -0
- data/website/static/1.13/assets/js/highlight.pack.js +2 -0
- data/website/static/1.13/assets/main.css +15518 -0
- data/website/static/1.13/assets/main.css.map +1 -0
- data/website/static/1.13/bin/bundle +109 -0
- data/website/static/1.13/bin/jekyll +27 -0
- data/website/static/1.13/bin/kramdown +27 -0
- data/website/static/1.13/bin/listen +27 -0
- data/website/static/1.13/bin/rake +27 -0
- data/website/static/1.13/bin/rougify +27 -0
- data/website/static/1.13/bin/safe_yaml +27 -0
- data/website/static/1.13/bin/sass +27 -0
- data/website/static/1.13/bin/sass-convert +27 -0
- data/website/static/1.13/bin/scss +27 -0
- data/website/static/1.13/blog.html +259 -0
- data/website/static/1.13/cheatsheet.html +316 -0
- data/website/static/1.13/cookbooks/authorization.md +0 -0
- data/website/static/1.13/cookbooks/caching.md +0 -0
- data/website/static/1.13/cookbooks/customizing-sideloads.html +325 -0
- data/website/static/1.13/cookbooks/etags.md +0 -0
- data/website/static/1.13/cookbooks/hopping-relationships.html +324 -0
- data/website/static/1.13/cookbooks/json_attributes.md +0 -0
- data/website/static/1.13/cookbooks/openstruct-models.md +0 -0
- data/website/static/1.13/cookbooks/remote-resources.md +0 -0
- data/website/static/1.13/cookbooks/without-activerecord.html +510 -0
- data/website/static/1.13/features.html +249 -0
- data/website/static/1.13/feed.xml +106 -0
- data/website/static/1.13/guides/concepts/backends-and-models.html +467 -0
- data/website/static/1.13/guides/concepts/debugging.html +440 -0
- data/website/static/1.13/guides/concepts/endpoints.html +432 -0
- data/website/static/1.13/guides/concepts/error-handling.html +396 -0
- data/website/static/1.13/guides/concepts/links.html +501 -0
- data/website/static/1.13/guides/concepts/remote-resources.html +536 -0
- data/website/static/1.13/guides/concepts/resources.html +2176 -0
- data/website/static/1.13/guides/concepts/testing.html +1469 -0
- data/website/static/1.13/guides/getting-started/installation.html +420 -0
- data/website/static/1.13/guides/graphiti-rails-migration.html +242 -0
- data/website/static/1.13/guides/index.html +269 -0
- data/website/static/1.13/guides/overview.html +325 -0
- data/website/static/1.13/guides/upgrading-2-0.html +193 -0
- data/website/static/1.13/guides/upgrading.html +314 -0
- data/website/static/1.13/guides/vandal.html +282 -0
- data/website/static/1.13/guides/why.html +1121 -0
- data/website/static/1.13/index.html +72 -0
- data/website/static/1.13/js/authentication.html +295 -0
- data/website/static/1.13/js/ddau.html +238 -0
- data/website/static/1.13/js/extra-params.html +270 -0
- data/website/static/1.13/js/index.html +321 -0
- data/website/static/1.13/js/installation.html +637 -0
- data/website/static/1.13/js/introduction.html +257 -0
- data/website/static/1.13/js/middleware.html +318 -0
- data/website/static/1.13/js/reads/fieldsets.html +271 -0
- data/website/static/1.13/js/reads/filtering.html +289 -0
- data/website/static/1.13/js/reads/includes.html +260 -0
- data/website/static/1.13/js/reads/index.html +497 -0
- data/website/static/1.13/js/reads/nested-queries.html +353 -0
- data/website/static/1.13/js/reads/pagination.html +260 -0
- data/website/static/1.13/js/reads/sorting.html +265 -0
- data/website/static/1.13/js/reads/statistics.html +289 -0
- data/website/static/1.13/js/state-syncing.html +340 -0
- data/website/static/1.13/js/writes/deferred.html +296 -0
- data/website/static/1.13/js/writes/dirty-tracking.html +399 -0
- data/website/static/1.13/js/writes/index.html +391 -0
- data/website/static/1.13/js/writes/nested.html +330 -0
- data/website/static/1.13/js/writes/validations.html +272 -0
- data/website/static/1.13/quickstart.html +660 -0
- data/website/static/1.13/template +161 -0
- data/website/static/1.13/tutorial/index.html +250 -0
- data/website/static/1.13/tutorial/step_0.html +292 -0
- data/website/static/1.13/tutorial/step_1.html +517 -0
- data/website/static/1.13/tutorial/step_2.html +481 -0
- data/website/static/1.13/tutorial/step_3.html +323 -0
- data/website/static/1.13/tutorial/step_4.html +318 -0
- data/website/static/1.13/tutorial/step_5.html +265 -0
- data/website/static/1.13/tutorial/step_6.html +276 -0
- data/website/static/1.13/tutorial/step_7.html +390 -0
- data/website/static/1.13/tutorial/step_8.html +316 -0
- data/website/static/1.13/tutorial/step_9.html +365 -0
- data/website/static/assets/img/error_payload.png +0 -0
- data/website/static/assets/img/legacy/legacy-0378a3bb39.png +0 -0
- data/website/static/assets/img/legacy/legacy-05bbd3e5fd.png +0 -0
- data/website/static/assets/img/legacy/legacy-07aa104495.png +0 -0
- data/website/static/assets/img/legacy/legacy-0c75a16b3a.gif +0 -0
- data/website/static/assets/img/legacy/legacy-3076df6209.png +0 -0
- data/website/static/assets/img/legacy/legacy-7f6889bc89.png +0 -0
- data/website/static/assets/img/legacy/legacy-a2cc4363c3.png +0 -0
- data/website/static/assets/img/legacy/legacy-f67cfa89ab.png +0 -0
- data/website/static/assets/img/meta_total_count.png +0 -0
- data/website/static/img/docusaurus-social-card.jpg +0 -0
- data/website/static/img/docusaurus.png +0 -0
- data/website/static/img/favicon.ico +0 -0
- data/website/static/img/logo.png +0 -0
- data/website/static/img/logo.svg +1 -0
- data/website/static/img/undraw_docusaurus_mountain.svg +171 -0
- data/website/static/img/undraw_docusaurus_react.svg +170 -0
- data/website/static/img/undraw_docusaurus_tree.svg +40 -0
- metadata +245 -46
- data/gemfiles/rails_6.gemfile +0 -18
- data/gemfiles/rails_6_graphiti_rails.gemfile +0 -19
- data/gemfiles/rails_7.gemfile +0 -18
- data/gemfiles/rails_7_1_graphiti_rails.gemfile +0 -19
- data/gemfiles/rails_7_graphiti_rails.gemfile +0 -19
- data/lib/graphiti/railtie.rb +0 -121
- /data/{deprecated_generators → lib/generators}/graphiti/resource_test_generator.rb +0 -0
- /data/{deprecated_generators → lib/generators}/graphiti/templates/application_resource.rb.erb +0 -0
- /data/{deprecated_generators → lib/generators}/graphiti/templates/controller.rb.erb +0 -0
- /data/{deprecated_generators → lib/generators}/graphiti/templates/create_request_spec.rb.erb +0 -0
- /data/{deprecated_generators → lib/generators}/graphiti/templates/destroy_request_spec.rb.erb +0 -0
- /data/{deprecated_generators → lib/generators}/graphiti/templates/resource.rb.erb +0 -0
- /data/{deprecated_generators → lib/generators}/graphiti/templates/resource_writes_spec.rb.erb +0 -0
- /data/{deprecated_generators → lib/generators}/graphiti/templates/update_request_spec.rb.erb +0 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Hopping Relationships'
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Hopping Relationships
|
|
6
|
+
> [See the code](https://github.com/graphiti-api/employee_directory/commit/b187127d60ea67ef4c2a326721caeaad21ed7ec9)
|
|
7
|
+
|
|
8
|
+
Our [sample application](https://github.com/graphiti-api/employee_directory)
|
|
9
|
+
has the setup `Employee > Position > Department`, where one of the positions is the `current_position`. What if we wanted to change this to `Employee > Department`, hiding everything about positions
|
|
10
|
+
under-the-hood?
|
|
11
|
+
|
|
12
|
+
Let's start by saying an `Employee` has many `Department`s. Here's the
|
|
13
|
+
spec:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
describe 'sideloading' do
|
|
17
|
+
describe 'departments' do
|
|
18
|
+
let!(:employee) { create(:employee) }
|
|
19
|
+
let!(:position1) do
|
|
20
|
+
create :position,
|
|
21
|
+
historical_index: 2,
|
|
22
|
+
employee: employee,
|
|
23
|
+
department: department1
|
|
24
|
+
end
|
|
25
|
+
let!(:position2) do
|
|
26
|
+
create :position,
|
|
27
|
+
historical_index: 1,
|
|
28
|
+
employee: employee,
|
|
29
|
+
department: department2
|
|
30
|
+
end
|
|
31
|
+
let!(:department1) { create(:department) }
|
|
32
|
+
let!(:department2) { create(:department) }
|
|
33
|
+
|
|
34
|
+
before do
|
|
35
|
+
params[:include] = 'departments'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'finds the departments for all positions' do
|
|
39
|
+
render
|
|
40
|
+
sl = d[0].sideload(:departments)
|
|
41
|
+
expect(sl.map(&:id)).to eq([department1.id, department2.id])
|
|
42
|
+
expect(sl.map(&:jsonapi_type).uniq).to eq(['departments'])
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Start by defining the association:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
has_many :departments
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
And you'll get this error:
|
|
55
|
+
|
|
56
|
+
```error
|
|
57
|
+
Graphiti::Errors::AttributeError:
|
|
58
|
+
DepartmentResource: Tried to filter on attribute :employee_id, but could not find an attribute with that name.
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Which makes sense - if this is a `has_many` association, we'd expect DepartmentResource to filter by `employee_id`. Though in our case we
|
|
62
|
+
don't have that as a foreign key, we can still implement the
|
|
63
|
+
`employee_id` filter:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
filter :employee_id, :integer, only: [:eq] do
|
|
67
|
+
eq do |scope, value|
|
|
68
|
+
scope.joins(:positions).merge(Position.where(employee_id: value))
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
In order to find `Department`s by an `employee_id`, we need to join the `positions` table which has the `employee_id` column.
|
|
74
|
+
|
|
75
|
+
We now get this error:
|
|
76
|
+
|
|
77
|
+
```error
|
|
78
|
+
NoMethodError:
|
|
79
|
+
undefined method `employee_id' for #<Department:0x00007fa6f330f768>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Let's say our URL is `/employees?include=departments`. We've fetched all the `Employee`s and all the `Department`s, now we need to associate each `Department` with its relevant `Employee`. Normally we'd do that by looking at the `employee_id` foreign key on `Department`, but this
|
|
83
|
+
scenario has non-standard logic. Let's tell Graphiti how to select
|
|
84
|
+
relevant `Department`s for a given `Employee`:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
has_many :departments do
|
|
88
|
+
assign_each do |employee, departments|
|
|
89
|
+
departments.select do |d|
|
|
90
|
+
employee_ids = d.positions.map(&:employee_id).flatten
|
|
91
|
+
employee.id.in?(employee_ids)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
There's one final step - because we're assigning a department to an
|
|
98
|
+
employee, we have to make sure that accessor exists:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
# app/models/employee.rb
|
|
102
|
+
attr_accessor :department
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
And that's it! Our test now passes.
|
|
107
|
+
|
|
108
|
+
There's a little bit of sleight-of-hand above though. Our filter joins
|
|
109
|
+
to the `positions` table, and our assignment iterates over departments and calls `department.positions`. **If we don't eager load, we'll cause
|
|
110
|
+
an N+!**!
|
|
111
|
+
|
|
112
|
+
There are two solutions to this. The first is to simple change `.joins` to `.eager_load`:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
scope.eager_load(:positions).merge(Position.where(employee_id: value))
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
This ensures that not only are we joining on the `positions` table, we'll eagler load the `positions` *relationship* and avoid the N+1.
|
|
119
|
+
|
|
120
|
+
If you're a stickler, though, you may have a nitpick. For one, if
|
|
121
|
+
we're hitting `/departments?filter[employee_id]` directly there is no need to eager load `positions` because we're never associating to an `Employee`. We're paying a performance penalty when we don't have to.
|
|
122
|
+
|
|
123
|
+
OK, let's keep our filter `.joins`. We just have to tell Graphiti to switch it to `.eager_load` when sideloading through `EmployeeResource`:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
has_many :departments do
|
|
127
|
+
# ... code ...
|
|
128
|
+
|
|
129
|
+
pre_load do |proxy, employees|
|
|
130
|
+
proxy.scope.object = proxy.scope.object.eager_load(:positions)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The `pre_load` hook fires after we've built up the scope, but before we resolve it (before actually firing the query). It yields a `proxy`
|
|
136
|
+
object that we can modify - here we're modifying the scope to eager load
|
|
137
|
+
positions.
|
|
138
|
+
|
|
139
|
+
It's up to you if you care about this scenario - you may want to start
|
|
140
|
+
with `.eager_load` and only embrace to the extra work of `pre_load` when
|
|
141
|
+
you really need it.
|
|
142
|
+
|
|
143
|
+
The trick to these customizations is to think in Links. Resources
|
|
144
|
+
connect to each other with URLs - what would the query parameters of the
|
|
145
|
+
URL be? In this case, `filter?[employee_id]=123`. After that, we just
|
|
146
|
+
have to define how to associate relevant objects. Even with complex
|
|
147
|
+
associations hopping several levels, the same logic applies.
|
|
148
|
+
|
|
149
|
+
See the final code [here](https://github.com/graphiti-api/employee_directory/commit/b187127d60ea67ef4c2a326721caeaad21ed7ec9).
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'JSON Attributes'
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# JSON Attributes
|
|
6
|
+
|
|
7
|
+
Graphiti has two built-in types for structured data: `hash` and `array`. Both are useful for serving JSON/JSONB columns (or any nested data) through a Resource, without you writing custom typecasting.
|
|
8
|
+
|
|
9
|
+
For the full type table, see [Types](/concepts/resources#types). This page covers `hash` and `array` specifically.
|
|
10
|
+
|
|
11
|
+
## Declaring the attribute {#declaring}
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
class PostResource < ApplicationResource
|
|
15
|
+
attribute :metadata, :hash
|
|
16
|
+
attribute :tags, :array
|
|
17
|
+
end
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Like any attribute, this is readable, writable, sortable and filterable by default. If your model reads a `metadata` JSONB column and returns a Ruby `Hash`, `attribute :metadata, :hash` will render it as-is.
|
|
21
|
+
|
|
22
|
+
## Coercion rules {#coercion}
|
|
23
|
+
|
|
24
|
+
Each type is a [Dry::Types](https://dry-rb.org/gems/dry-types) triple of `params` (used for filtering/sorting from query strings), `read`, and `write`. Per `lib/graphiti/types.rb`:
|
|
25
|
+
|
|
26
|
+
* `hash` - `read` and `write` are `Dry::Types["strict.hash"]`. Nothing is coerced beyond requiring a real `Hash`. `params` is a custom type that runs `JSON.parse(input) if input.is_a?(String)` before validating with `Dry::Types["params.hash"]`, so a JSON string arriving in a query param gets parsed automatically.
|
|
27
|
+
* `array` - `read`, `write`, and `params` are all `Dry::Types["strict.array"]`. There is no `.of(...)` constraint, so elements are not individually coerced. Any array (including an array of hashes) passes through as-is.
|
|
28
|
+
|
|
29
|
+
Both types have `kind: "record"` (`hash`) or `kind: "array"` (`array`) rather than `"scalar"`. One consequence: unlike every other base type (`integer`, `string`, `date`, etc.), `hash` and `array` do **not** get an `array_of_*` doppelgänger generated (`lib/graphiti/types.rb` explicitly excludes `:boolean`, `:hash`, and `:array` when building `array_of_*` variants). If you need an array of hashes, just use `attribute :things, :array` - there's no `array_of_hashes` type.
|
|
30
|
+
|
|
31
|
+
On coercion failure - reading, writing, or filtering - Graphiti raises `Graphiti::Errors::TypecastFailed` with the attribute name, the offending value, and the underlying error.
|
|
32
|
+
|
|
33
|
+
## Filtering on a hash attribute {#filtering}
|
|
34
|
+
|
|
35
|
+
Declaring `attribute :metadata, :hash` makes it filterable with the `eq` operator by default (the `hash` type only supports `eq` out of the box, per the default operator map). A request like:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
GET /posts?filter[metadata]={"status":"draft"}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
parses the JSON string into a Ruby `Hash` before your filter block runs:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
filter :metadata, :hash do
|
|
45
|
+
eq do |scope, value|
|
|
46
|
+
# value => [{ "status" => "draft" }]
|
|
47
|
+
scope
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Note the value is wrapped in an array - Graphiti's filter pipeline supports passing multiple comma-separated JSON objects (`filter[metadata]={"a":1},{"b":2}`), so `eq` always receives an array of hashes unless you opt out.
|
|
53
|
+
|
|
54
|
+
Pass `single: true` to receive the hash directly instead of an array-wrapped one, and to skip the comma-splitting behavior entirely (useful once your hash values might legitimately contain commas):
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
filter :metadata, :hash, single: true do
|
|
58
|
+
eq do |scope, value|
|
|
59
|
+
# value => { "status" => "draft" }
|
|
60
|
+
scope
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A Ruby `Hash` (rather than a JSON string) passed directly as a filter param works the same way. It's validated rather than parsed.
|
|
66
|
+
|
|
67
|
+
Array attributes filter similarly: `filter[tags]=ruby,rails` splits on commas into `["ruby", "rails"]`. Wrap a value in `{{curlies}}` to prevent comma-splitting (see [Escaping Values](/concepts/resources#escaping-values)).
|
|
68
|
+
|
|
69
|
+
## Writing to a JSON column {#writing}
|
|
70
|
+
|
|
71
|
+
There's nothing Graphiti-specific to do here. On a write request, Graphiti coerces the incoming JSON attribute through the `write` type (`strict.hash` or `strict.array` - just a presence/type check) and assigns it to your model via `attributes[:metadata] = value`. Persisting that Ruby `Hash`/`Array` into an actual `jsonb`/`json` column is entirely up to your ORM (ActiveRecord serializes it automatically for `jsonb`/`json` columns) - Graphiti does not serialize to a JSON string itself, so don't do that in your own code either or you'll end up double-encoded.
|
|
72
|
+
|
|
73
|
+
## Caveats {#caveats}
|
|
74
|
+
|
|
75
|
+
* `hash` and `array` only support the `eq` filter operator by default - there's no built-in `gt`/`lt`/`prefix` for structured data. Add custom operators yourself if you need them.
|
|
76
|
+
* Non-`single` hash filters always hand your `eq` block an array, even for a single JSON object - a common source of confusion is forgetting the `value[0]` unwrap.
|
|
77
|
+
* There's no schema validation built in - `strict.hash`/`strict.array` just confirm you got a `Hash`/`Array`, not that its keys match anything in particular. For a shape check, register a [custom type](/concepts/resources#custom-types) with `Dry::Types["hash"].schema(...)`.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'OpenStruct Models'
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# OpenStruct Models
|
|
6
|
+
|
|
7
|
+
[Model Requirements](/concepts/backends-and-models#model-requirements) covers what any Model needs to respond to, and [Usage Without ActiveRecord](/topics/without-activerecord) walks through building a Resource around a PORO. `OpenStruct` satisfies those requirements with zero boilerplate - no `attr_accessor` list, no constructor - which is exactly why Graphiti uses it internally for [remote resources](/topics/remote-resources): `Resource::Remote` and the default `Sideload` model both set `self.model = OpenStruct` (`lib/graphiti/resource/remote.rb`, `lib/graphiti/sideload.rb`), since a remote resource doesn't know its shape ahead of time. That convenience comes with sharp edges if you reach for `OpenStruct` as a model in your own Resources.
|
|
8
|
+
|
|
9
|
+
## What Graphiti expects from it {#expectations}
|
|
10
|
+
|
|
11
|
+
Reads go through `@object.send(attribute_name)` (`lib/graphiti/util/serializer_attributes.rb`), and writes go through `model.send(:"#{key}=", value)`-style assignment. `OpenStruct` handles both via `method_missing`, so any attribute you construct it with - or assign later - just works, same as a PORO with `attr_accessor`.
|
|
12
|
+
|
|
13
|
+
## The gotcha: typos and reserved methods return silently, they don't raise {#gotcha}
|
|
14
|
+
|
|
15
|
+
An `attr_accessor`-based PORO raises `NoMethodError` the moment you call an undefined reader. `OpenStruct` does not - if the attribute was never set, `#send` on it just returns `nil`:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require "ostruct"
|
|
19
|
+
o = OpenStruct.new(name: "a")
|
|
20
|
+
o.send(:naem) # => nil, not NoMethodError
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Since attribute reads happen inside `@object.send(name_ref)`, a typo'd attribute name (in your `attribute` declaration, or a rename you forgot to propagate) will silently serialize as `null` instead of blowing up in your test suite. With a real PORO the same typo raises immediately and is easy to catch.
|
|
24
|
+
|
|
25
|
+
Worse, `OpenStruct` only overrides *undefined* methods - if the attribute name collides with something `Object`/`Kernel` already defines, the field is silently swallowed and you get the *original* method's return value instead of your data:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
o = OpenStruct.new(hash: 123, count: 5)
|
|
29
|
+
o.hash # => some large integer (Object#hash), NOT 123
|
|
30
|
+
o.count # => 5, fine - `count` isn't a reserved method
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`id`, `class`, `object_id`, `hash`, `send`, `freeze`, and `to_s` are all real methods on every Ruby object. Naming an attribute after one of them (a `hash` field to store a checksum is a realistic trap given Graphiti's own `:hash` type) won't error - it'll quietly return the wrong value. `id` itself is safe (`Object#id` was removed from modern Ruby in favor of `#object_id`), but don't assume the rest are.
|
|
34
|
+
|
|
35
|
+
## Validations {#validations}
|
|
36
|
+
|
|
37
|
+
`OpenStruct` doesn't include `ActiveModel::Validations`, and the [Null adapter's `#save`](/concepts/backends-and-models#model-requirements) only calls `model.valid?` if the model `respond_to?(:valid?)` - so an unvalidated `OpenStruct` model will save "successfully" with no errors payload, not raise. If you want write-request validation, subclass it:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
class Employee < OpenStruct
|
|
41
|
+
include ActiveModel::Validations
|
|
42
|
+
validates :first_name, presence: true
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This works exactly as it would on any other class - `OpenStruct` doesn't get in the way of `include`.
|
|
47
|
+
|
|
48
|
+
## When it's the right call {#when}
|
|
49
|
+
|
|
50
|
+
`OpenStruct` is a reasonable choice for throwaway resources, prototypes, and cases like remote resources where the attribute set is genuinely dynamic. For a Resource you're going to maintain, prefer a real PORO, `ActiveModel::Model`, or `Dry::Struct` (all shown in [Model Implementations](/concepts/backends-and-models#model-implementations)) - you get the same zero-ORM flexibility with a class that fails loudly on a mistake instead of quietly serializing `nil`.
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Remote Resources'
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
## Overview {#overview}
|
|
6
|
+
|
|
7
|
+
Resources have a defined query contract, and connect together with [Links](/concepts/links). That contract doesn't care whether the sideloaded Resource lives in the same application, so we can point it at a separate service instead:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
has_many :comments,
|
|
11
|
+
remote: 'http://blog-api.com/api/v1/comments'
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Splitting an application into services tends to break down at the boundary between them: no consistent query interface, no consistent error handling, no types or backwards-compatibility checks. Graphiti was built to address exactly this - a defined query contract, an errors payload, and a schema with types and backwards-compatibility checks, all organized into RESTful Resources - so cross-service communication is automated rather than hand-rolled per integration.
|
|
15
|
+
|
|
16
|
+
> Note: Remote Resources are for **read** operations only. The exception
|
|
17
|
+
> is associating to an existing `belongs_to` remote entity.
|
|
18
|
+
|
|
19
|
+
> Note: We use [Faraday](https://github.com/lostisland/faraday) to hit
|
|
20
|
+
> the remote API. You must add `faraday` to your Gemfile to enable
|
|
21
|
+
> remote resources.
|
|
22
|
+
|
|
23
|
+
### How it Works {#how-it-works}
|
|
24
|
+
|
|
25
|
+
Let's take a simple association:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
class PostResource < ApplicationResource
|
|
29
|
+
has_many :comments
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
This would generate a [Link](/concepts/links) for
|
|
34
|
+
lazy-loading comments:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
{
|
|
38
|
+
related: "http://my-api.com/api/v1/comments?filter[post_id]=123"
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Critically, **those same lazy-loading parameters are used when
|
|
43
|
+
eager-loading**:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
# under the hood
|
|
47
|
+
posts = PostResource.all.data
|
|
48
|
+
CommentResource.all(filter: { post_id: 123 })
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
OK, and we also know Resources support [any backend](/concepts/backends-and-models), and we can build an [Adapter](/topics/without-activerecord#adapters) if our backend supports common operations like filtering, sorting, and pagination.
|
|
52
|
+
|
|
53
|
+
So, that means we can build an Adapter that makes an HTTP request to another Graphiti Resource that lives in a separate API. That adapter is built into Graphiti and comes out-of-the-box: `Graphiti::Adapters::GraphitiAPI`
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
class CommentResource < ApplicationResource
|
|
57
|
+
self.remote = "http://my-api.com/api/v1/comments"
|
|
58
|
+
# under-the-hood, this sets:
|
|
59
|
+
# self.adapter = Graphiti::Adapters::GraphitiAPI
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
This Resource works as normal. We can execute queries:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
comments = CommentResource.all({
|
|
67
|
+
sort: '-id',
|
|
68
|
+
filter: { active: true }
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
# The model instances are OpenStructs
|
|
72
|
+
comments.data # => [#<OpenStruct>, #<OpenStruct>, ...]
|
|
73
|
+
|
|
74
|
+
# Those models reflect all the properties returned from the API:
|
|
75
|
+
comments.data.map(&:author) # => ["Jane Doe", "John Doe", ...]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
And we can sideload just like we always do:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
class PostResource < ApplicationResource
|
|
82
|
+
# Nothing to see here!
|
|
83
|
+
has_many :comments
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
We'll still support Deep Querying - let's fetch the Post and its
|
|
88
|
+
active comments, ordered by `created_at`:
|
|
89
|
+
|
|
90
|
+
`/posts?include=comments&sort=comments.created_at&filter[active]=true`
|
|
91
|
+
|
|
92
|
+
Let's say `CommentResource` has an association to `Author`. If `AuthorResource` is defined in the remote API, we can fetch it as normal - no special configuration needed to fetch the `Post`, `Comment`s and `Author`s in a single request.
|
|
93
|
+
|
|
94
|
+
But maybe only `CommentResource` is remote, and `Authors` are local.
|
|
95
|
+
We need only define the association locally:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
class CommentResource < ApplicationResource
|
|
99
|
+
self.remote = "http://my-api.com/api/v1/comments"
|
|
100
|
+
|
|
101
|
+
belongs_to :author
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Let's say we need to tweak the display of a property coming from the
|
|
106
|
+
remote API. Again, works just like normal:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
class CommentResource < ApplicationResource
|
|
110
|
+
self.remote = "http://my-api.com/api/v1/comments"
|
|
111
|
+
|
|
112
|
+
attribute :body, :string do
|
|
113
|
+
@object.body.truncate(100)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
You only need to define attributes when overriding this logic -
|
|
119
|
+
otherwise we'll take them directly from the API response. This means you
|
|
120
|
+
don't have to update two repos and coordinate deploys - as soon as you
|
|
121
|
+
add a property to the remote API and deploy it, it will be reflected in
|
|
122
|
+
the local API response.
|
|
123
|
+
|
|
124
|
+
For the typical use case, we don't even *need* to create this Resource
|
|
125
|
+
class. The sideload definition accepts a `remote:` option, which will
|
|
126
|
+
create a Remote Resource under-the-hood:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
class PostResource < ApplicationResource
|
|
130
|
+
has_many :comments, remote: 'http://my-api.com/api/v1/comments'
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Equivalent to:
|
|
134
|
+
#
|
|
135
|
+
# class PostResource < ApplicationResource
|
|
136
|
+
# has_many :comments
|
|
137
|
+
# end
|
|
138
|
+
#
|
|
139
|
+
# class CommentResource < ApplicationResource
|
|
140
|
+
# self.remote = 'http://my-api.com/api/v1/comments'
|
|
141
|
+
# end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
> NOTE: When sending a request to a remote API, we request page size
|
|
145
|
+
> `999` so results don't get accidentally cut off. If you need
|
|
146
|
+
> successive requests, please [submit an issue](https://github.com/graphiti-api/graphiti/issues).
|
|
147
|
+
|
|
148
|
+
### Customizing {#customizing}
|
|
149
|
+
|
|
150
|
+
We use [Faraday](https://github.com/lostisland/faraday) under-the-hood,
|
|
151
|
+
which allows for various adapters and middleware. In addition:
|
|
152
|
+
|
|
153
|
+
#### Configure Timeout {#configure-timeout}
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
class CommentResource < ApplicationResource
|
|
157
|
+
self.remote = "..."
|
|
158
|
+
|
|
159
|
+
# Customize faraday timeout
|
|
160
|
+
self.timeout = 10
|
|
161
|
+
self.open_timeout = 20
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
#### Configure Request {#configure-request}
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
class CommentResource < ApplicationResource
|
|
169
|
+
self.remote = "..."
|
|
170
|
+
|
|
171
|
+
def make_request(url)
|
|
172
|
+
# request here is from Faraday:
|
|
173
|
+
#
|
|
174
|
+
# conn.get do |req|
|
|
175
|
+
# yield req
|
|
176
|
+
# end
|
|
177
|
+
#
|
|
178
|
+
super do |request|
|
|
179
|
+
request.headers["Custom"] = "Header"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
#### Configure Headers {#configure-headers}
|
|
186
|
+
|
|
187
|
+
By default we're going to *forward* the `Authorization` header of the request to the remote API. To override the default headers sent:
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
# app/resources/comment_resource.rb
|
|
191
|
+
def request_headers
|
|
192
|
+
{ "Some-Foo" => "bar" }
|
|
193
|
+
end
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Error Handling {#error-handling}
|
|
197
|
+
|
|
198
|
+
If the remote API has an error, we want to re-raise that same error. But
|
|
199
|
+
unless you've enabled [displaying raw errors](/topics/error-handling#displaying-raw-errors), we won't be able to - the only information we have is what's returned from the API.
|
|
200
|
+
|
|
201
|
+
You're encouraged to display raw errors when an internal or privileged
|
|
202
|
+
user:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
rescue_from Exception do |e|
|
|
206
|
+
handle_exception(e, show_raw_error: current_user.developer?)
|
|
207
|
+
end
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
If you do this, we'll be able to re-raise the original error, including
|
|
211
|
+
stacktrace. If raw errors are not enabled, we'll raise whatever
|
|
212
|
+
information is given.
|
|
213
|
+
|
|
214
|
+
Both styles will be wrapped in `Graphiti::Errors::Remote`, so you can
|
|
215
|
+
differentiate between a local error and a remote one.
|
|
216
|
+
|
|
217
|
+
## Testing {#testing}
|
|
218
|
+
|
|
219
|
+
When testing a remote resource, we need to mock the API request and
|
|
220
|
+
response. Graphiti gives you a spec helper to do just that -
|
|
221
|
+
`include_context "remote api"`:
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
describe 'comments' do
|
|
225
|
+
include_context 'remote api'
|
|
226
|
+
|
|
227
|
+
let(:api_response) do
|
|
228
|
+
{
|
|
229
|
+
data: [{
|
|
230
|
+
id: '1',
|
|
231
|
+
type: 'comments',
|
|
232
|
+
attributes: { body: 'hello' }
|
|
233
|
+
}]
|
|
234
|
+
}
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
it 'does something' do
|
|
238
|
+
url = 'http://my-api.com/api/v1/comments?page[size]=999'
|
|
239
|
+
mock_api(url, api_response)
|
|
240
|
+
# ... test ...
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
This shows all the pieces needed to test remote APIs. We want to test
|
|
246
|
+
|
|
247
|
+
* The correct URL is hit
|
|
248
|
+
* When given a valid response, the rest of the flow works as expected.
|
|
249
|
+
|
|
250
|
+
> NOTE: if the remote relationship is a has_many, the API will need to
|
|
251
|
+
> return the foreign key as part of the response. Otherwise, we won't
|
|
252
|
+
> know how to associate these children to their parents.
|
|
253
|
+
|
|
254
|
+
Here's a slightly longer version, showing that `Post` can sideload `Comment`s:
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
describe 'sideloading' do
|
|
258
|
+
describe 'comments' do
|
|
259
|
+
include_context 'remote api'
|
|
260
|
+
|
|
261
|
+
let!(:post) { create(:post) }
|
|
262
|
+
|
|
263
|
+
let(:api_response) do
|
|
264
|
+
{
|
|
265
|
+
data: [{
|
|
266
|
+
id: '789',
|
|
267
|
+
type: 'comments',
|
|
268
|
+
attributes: { body: 'hello' }
|
|
269
|
+
}]
|
|
270
|
+
}
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
before do
|
|
274
|
+
params[:include] = 'comments'
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
it 'does something' do
|
|
278
|
+
url = "http://my-api.com/api/v1/comments"
|
|
279
|
+
url += "?filter[post_id]=#{post_id}"
|
|
280
|
+
mock_api(url, api_response)
|
|
281
|
+
render
|
|
282
|
+
sl = d[0].sideload(:comments)
|
|
283
|
+
expect(sl.map(&:id)).to eq(['789'])
|
|
284
|
+
expect(sl.map(&:jsonapi_type).uniq)
|
|
285
|
+
.to eq(['comments'])
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
> Make sure to include `page[size]=999` in the test URL!
|