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
data/docs/js/writes.md
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Writes'
|
|
3
|
+
sidebar_position: 5
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Similar to `ActiveRecord`, you can call `#save()` on a model instance. Spraypaint will [create](http://jsonapi.org/format/#crud-creating) (`POST`) or [update](http://jsonapi.org/format/#crud-updating) (`PATCH`) as needed.
|
|
7
|
+
|
|
8
|
+
`#save()` returns a `Promise` that will resolve a `boolean` - `true` when the server returns a 200-ish response code, `false` when the server returns a `422` response code (see [validations](/js/writes#validations)). As always, anything else will reject the promise.
|
|
9
|
+
|
|
10
|
+
```typescript
|
|
11
|
+
let blog = new Blog({ title: "My Blog" })
|
|
12
|
+
let success = await blog.save() // POST /blogs
|
|
13
|
+
console.log(success) // true/false
|
|
14
|
+
|
|
15
|
+
blog.title = "Updated Title"
|
|
16
|
+
success = await blog.save() // PUT /blogs/:id
|
|
17
|
+
console.log(success) // true/false
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```javascript
|
|
21
|
+
var blog = new Blog({ title: "My Blog" });
|
|
22
|
+
// POST /blogs
|
|
23
|
+
blog.save().then(function(success) {
|
|
24
|
+
console.log(success); // true/false
|
|
25
|
+
|
|
26
|
+
blog.title = "Updated Title":
|
|
27
|
+
// PUT /blogs/:id
|
|
28
|
+
blog.save().then(function(success) {
|
|
29
|
+
console.log(success) // true/false
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
After saving, the instance will automatically pick up any server-assigned attributes:
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
let post = new Post()
|
|
38
|
+
await post.save()
|
|
39
|
+
post.id // server-assigned value
|
|
40
|
+
post.createdAt // server-assigned value
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```javascript
|
|
44
|
+
var post = new Post();
|
|
45
|
+
post.save().then(function(success) {
|
|
46
|
+
post.id // server-assigned value
|
|
47
|
+
post.createdAt // server-assigned value
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
If a `Model` was instantiated with data from the server, `isPersisted` will return `true`. This means that we can assign IDs on the client without any adverse behavior. We can also manually mark objects as persisted for testing purposes:
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
let blog = new Blog({ id: 123 })
|
|
55
|
+
blog.isPersisted // false
|
|
56
|
+
await blog.save() // POST /blogs
|
|
57
|
+
blog.isPersisted // true
|
|
58
|
+
blog.id // 123
|
|
59
|
+
|
|
60
|
+
// Manually mark an instance as persisted
|
|
61
|
+
blog = new Blog({ id: 123 })
|
|
62
|
+
blog.isPersisted = true
|
|
63
|
+
await blog.save() // PUT /blogs/123
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```javascript
|
|
67
|
+
var blog = new Blog({ id: 123 });
|
|
68
|
+
blog.isPersisted // false
|
|
69
|
+
// POST /blogs
|
|
70
|
+
blog.save().then(function(response) {
|
|
71
|
+
blog.isPersisted // true
|
|
72
|
+
blog.id // 123
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Manually mark an instance as persisted
|
|
76
|
+
var blog = new Blog({ id: 123 });
|
|
77
|
+
blog.isPersisted = true
|
|
78
|
+
blog.save() // PUT /blogs/123
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Notably, **only dirty (changed) attributes will be sent to the server**. This prevents race conditions and unexpected side-effects. In the following example, `Post` has attributes `title`, `description`, and `createdAt`:
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
let post = (await Post.first())
|
|
85
|
+
post.title = "updated"
|
|
86
|
+
// ONLY title sent to the server
|
|
87
|
+
await post.save()
|
|
88
|
+
// Title is now synced with the server
|
|
89
|
+
post.description = "updated"
|
|
90
|
+
// ONLY description sent to the server
|
|
91
|
+
await post.save()
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```javascript
|
|
95
|
+
Post.first().then(function(response) {
|
|
96
|
+
var post = response.data;
|
|
97
|
+
post.title = "updated";
|
|
98
|
+
// ONLY title sent to the server
|
|
99
|
+
post.save().then(function(response) {
|
|
100
|
+
// Title is now synced with the server
|
|
101
|
+
post.description = "updated";
|
|
102
|
+
// ONLY description sent to the server
|
|
103
|
+
post.save();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Validations
|
|
109
|
+
|
|
110
|
+
JSONAPI Suite is already set up to return validation errors with a `422` response code and JSONAPI-compliant [errors payload](http://jsonapi.org/format/#errors). Those errors will be automatically assigned, and removed on subsequent requests:
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
let success = await post.save()
|
|
114
|
+
console.log(success) // false
|
|
115
|
+
post.errors.title // { message: "Can't be blank", ... }
|
|
116
|
+
post.title = "no longer blank"
|
|
117
|
+
success = await post.save()
|
|
118
|
+
console.log(success) // true
|
|
119
|
+
post.errors // {}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```javascript
|
|
123
|
+
post.save().then(function(success) {
|
|
124
|
+
console.log(success) // false
|
|
125
|
+
post.errors.title // { message: "Can't be blank", ... }
|
|
126
|
+
post.title = "no longer blank"
|
|
127
|
+
post.save().then(function(success) {
|
|
128
|
+
console.log(success); // true
|
|
129
|
+
post.errors // {}
|
|
130
|
+
});
|
|
131
|
+
})
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Dirty Tracking
|
|
135
|
+
|
|
136
|
+
When an attribute has been modified, but has not yet been saved to the server, it is considered "dirty". Use `#isDirty()` to see if any attribute is dirty, use the `#changes()` method to see all dirty attributes.
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
let post = await Post.first()
|
|
140
|
+
post.title // "original"
|
|
141
|
+
post.isDirty() // false
|
|
142
|
+
post.changes() // {}
|
|
143
|
+
|
|
144
|
+
post.title = "changed"
|
|
145
|
+
post.isDirty() // true
|
|
146
|
+
post.changes() // { title: ["original", "changed"] }
|
|
147
|
+
|
|
148
|
+
await post.save()
|
|
149
|
+
post.isDirty() // false
|
|
150
|
+
post.changes() // {}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```javascript
|
|
154
|
+
Post.first().then(function(response) {
|
|
155
|
+
var post = response.data;
|
|
156
|
+
|
|
157
|
+
post.title; // "original"
|
|
158
|
+
post.isDirty(); // false
|
|
159
|
+
post.changes(); // {}
|
|
160
|
+
|
|
161
|
+
post.title = "changed";
|
|
162
|
+
post.isDirty(); // true
|
|
163
|
+
post.changes(); // { title: ["original", "changed"] }
|
|
164
|
+
|
|
165
|
+
post.save().then(function(success) { // true
|
|
166
|
+
post.isDirty(); // false
|
|
167
|
+
post.changes(); // {}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
> Remember, only dirty attributes are sent to the server when `#save()`
|
|
173
|
+
> is called.
|
|
174
|
+
|
|
175
|
+
`#isDirty()` *can* take into account relationships - just pass a string, array, or object or relationship names. A relationship is considered dirty if:
|
|
176
|
+
|
|
177
|
+
* Any objects in the relationship have dirty attributes
|
|
178
|
+
* An object was removed from a `hasMany` relationship
|
|
179
|
+
* An object was added to a `hasMany` relationship
|
|
180
|
+
* Any object within the relationship was replaced with a different
|
|
181
|
+
object.
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
let post = await Post.first()
|
|
185
|
+
post.comments[0].text = "my comment"
|
|
186
|
+
post.isDirty("comments") // true
|
|
187
|
+
|
|
188
|
+
post = await Post.first()
|
|
189
|
+
post.comments.push(new Comment())
|
|
190
|
+
post.isDirty("comments") // true
|
|
191
|
+
|
|
192
|
+
post = await Post.first()
|
|
193
|
+
post.comments.splice(1, 1)
|
|
194
|
+
post.isDirty("comments") // true
|
|
195
|
+
|
|
196
|
+
post = await Post.first()
|
|
197
|
+
post.blog // an existing Blog instance
|
|
198
|
+
post.blog = (await Blog.first()).data
|
|
199
|
+
post.isDirty("blog") // true
|
|
200
|
+
|
|
201
|
+
// check nested relationships
|
|
202
|
+
post.isDirty(["blog", { comments: "author" }])
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
```javascript
|
|
206
|
+
Post.first().then(function(response) {
|
|
207
|
+
var post = response.data;
|
|
208
|
+
post.comments[0].text = "my comment";
|
|
209
|
+
post.isDirty("comments"); // true
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
Post.first().then(function(response) {
|
|
213
|
+
var post = response.data;
|
|
214
|
+
post.comments.push(new Comment());
|
|
215
|
+
post.isDirty("comments"); // true
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
Post.first().then(function(response) {
|
|
219
|
+
var post = response.data;
|
|
220
|
+
post.comments.splice(1, 1);
|
|
221
|
+
post.isDirty("comments"); // true
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
Post.first().then(function(response) {
|
|
225
|
+
var post = response.data;
|
|
226
|
+
post.blog; // an existing Blog instance
|
|
227
|
+
|
|
228
|
+
Blog.first().then(function(blog) {
|
|
229
|
+
post.blog = (await Blog.first()).data
|
|
230
|
+
post.isDirty("blog") // true
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// check nested relationships
|
|
235
|
+
post.isDirty(["blog", { comments: "author" }])
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
If you need to reset dirty tracking, call `#reset()`
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
let post = await Post.first()
|
|
242
|
+
post.title // "original"
|
|
243
|
+
post.title = "changed"
|
|
244
|
+
post.isDirty() // true
|
|
245
|
+
post.reset()
|
|
246
|
+
post.title // "changed"
|
|
247
|
+
post.isDirty() // false
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
```javascript
|
|
251
|
+
Post.first().then(function(post) {
|
|
252
|
+
post.title; // "original"
|
|
253
|
+
post.title = "changed";
|
|
254
|
+
post.isDirty() // true
|
|
255
|
+
post.reset();
|
|
256
|
+
post.title; // "original"
|
|
257
|
+
post.isDirty() // false
|
|
258
|
+
});
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Nested Writes
|
|
262
|
+
|
|
263
|
+
You can write a `Model` and all of its relationships in a single request. Keep in mind normal dirty tracking rules still apply - nothing is sent to the server unless it is dirty.
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
let author = new Author()
|
|
267
|
+
let comment = new Comment({ author })
|
|
268
|
+
let post = new Post({ comments: [comment] })
|
|
269
|
+
|
|
270
|
+
// post.save({ with: "comments" })
|
|
271
|
+
// post.save({ with: ["comments", "blog"] })
|
|
272
|
+
post.save({ with: { comments: 'author' }})
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
```javascript
|
|
276
|
+
var author = new Author();
|
|
277
|
+
var comment = new Comment({ author: author });
|
|
278
|
+
var post = new Post({ comments: [comment] });
|
|
279
|
+
|
|
280
|
+
// post.save({ with: "comments" })
|
|
281
|
+
// post.save({ with: ["comments", "blog"] })
|
|
282
|
+
post.save({ with: { comments: "author" }});
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Use `model.isMarkedForDestruction = true` to delete the associated object. Use `model.isMarkedForDisassociation = true` to remove the association without deleting the underlying object:
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
let post = (await Post.includes("comments").first()).data
|
|
289
|
+
post.comments[0].isMarkedForDestruction = true
|
|
290
|
+
post.comments[1].isMarkedForDisassociation = true
|
|
291
|
+
|
|
292
|
+
// destroys the first comment
|
|
293
|
+
// disassociates the second comment
|
|
294
|
+
await post.save({ with: "comments" })
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
```javascript
|
|
298
|
+
Post.includes("comments").first().then(function(response) {
|
|
299
|
+
var post = response.data;
|
|
300
|
+
post.comments[0].isMarkedForDestruction = true;
|
|
301
|
+
post.comments[1].isMarkedForDisassociation = true;
|
|
302
|
+
|
|
303
|
+
// destroys the first comment
|
|
304
|
+
// disassociates the second comment
|
|
305
|
+
post.save({ with: "comments" })
|
|
306
|
+
});
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
You may want to send *only* the `id` of the related object to the server - ensuring the models are associated without updating attributes by accident. Just add `.id` to the relationship name:
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
post.save({ with: "comments.id" })
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
```javascript
|
|
316
|
+
post.save({ with: "comments.id" })
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## Deferred Action
|
|
320
|
+
|
|
321
|
+
If your update or destroy action takes a long time then the server can respond with status code `202 Accepted` and include background job object in the payload.
|
|
322
|
+
|
|
323
|
+
Example response:
|
|
324
|
+
```http
|
|
325
|
+
HTTP/1.1 202 Accepted
|
|
326
|
+
Content-Type: application/vnd.api+json
|
|
327
|
+
|
|
328
|
+
{
|
|
329
|
+
"data": {
|
|
330
|
+
"type": "background_jobs",
|
|
331
|
+
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
332
|
+
"attributes": {
|
|
333
|
+
"status": "pending"
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
You will need to give the model object a callback called `onDeferredDestroy` or `onDeferredUpdate`. Spraypaint will then call your callback with the deserialized object included in the payload.
|
|
340
|
+
|
|
341
|
+
```typescript
|
|
342
|
+
let person = new Person({ firstName: 'Jane' })
|
|
343
|
+
person.onDeferredUpdate = (job: any) => {
|
|
344
|
+
handleBackgroundJob(job);
|
|
345
|
+
}
|
|
346
|
+
person.save()
|
|
347
|
+
|
|
348
|
+
person.onDeferredDestroy = (job: any) => {
|
|
349
|
+
handleBackgroundJob(job);
|
|
350
|
+
}
|
|
351
|
+
person.destroy()
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
```javascript
|
|
355
|
+
const person = new Person({ firstName: 'Jane' });
|
|
356
|
+
person.onDeferredUpdate = (job) => {
|
|
357
|
+
handleBackgroundJob(job);
|
|
358
|
+
};
|
|
359
|
+
person.save();
|
|
360
|
+
|
|
361
|
+
person.onDeferredDestroy = (job) => {
|
|
362
|
+
handleBackgroundJob(job);
|
|
363
|
+
};
|
|
364
|
+
person.destroy();
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
<h2 id="next">
|
|
368
|
+
<a href="/js/middleware">
|
|
369
|
+
NEXT:
|
|
370
|
+
<small>Middleware</small>
|
|
371
|
+
»
|
|
372
|
+
</a>
|
|
373
|
+
</h2>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Vandal'
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Vandal
|
|
6
|
+
Vandal is the Graphiti UI. It's helpful for exploring data, testing and
|
|
7
|
+
generating URLs. To take Vandal for a spin, [view our sample app](https://jsonapi-employee-directory.herokuapp.com/vandal) (*initial load may take a second*).
|
|
8
|
+
|
|
9
|
+
<br />
|
|
10
|
+
<img width="100%" src="/assets/img/legacy/legacy-07aa104495.png" />
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## Installation {#installation}
|
|
15
|
+
|
|
16
|
+
### Installing via Template {#installing-via-template}
|
|
17
|
+
|
|
18
|
+
If you ran our [application template](/getting-started/installation),
|
|
19
|
+
you already have Vandal installed. Check your routes to see it mounted.
|
|
20
|
+
|
|
21
|
+
### Installing via Gem {#installing-via-gem}
|
|
22
|
+
|
|
23
|
+
* Add the `vandal_ui` gem.
|
|
24
|
+
* Run `rake vandal:install`
|
|
25
|
+
* Mount the engine:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# config/routes.rb
|
|
29
|
+
scope path: ApplicationResource.endpoint_namespace, defaults: { format: :jsonapi } do
|
|
30
|
+
# ... routes ...
|
|
31
|
+
mount VandalUi::Engine, at: '/vandal'
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
That's it! Vandal will dynamically generate a schema at `<api_namespace>/vandal/schema.json`, and you can view the UI at `<api_namespace>/vandal`.
|
|
36
|
+
|
|
37
|
+
### Manual Installation {#manual-installation}
|
|
38
|
+
|
|
39
|
+
[Vandal](https://github.com/graphiti-api/vandal) is a VueJS
|
|
40
|
+
application. Grab the [dist files](https://github.com/graphiti-api/vandal/tree/master/dist) and put them anywhere you'd like.
|
|
41
|
+
|
|
42
|
+
`index.html` has a placeholder, `__SCHEMA_PATH__`. Replace
|
|
43
|
+
this with a URL hosting your schema, and you'll be good to go.
|
|
44
|
+
|
|
45
|
+
## Usage {#usage}
|
|
46
|
+
|
|
47
|
+
First, make sure your schema is being correctly generated. You should
|
|
48
|
+
see Vandal make a request something like `/vandal/schema.json` - make
|
|
49
|
+
sure that looks correct. If it doesn't, you may need to bounce your
|
|
50
|
+
server.
|
|
51
|
+
|
|
52
|
+
After selecting an endpoint, use the left rail to configure your
|
|
53
|
+
request. Click a relationship once to include it in the response.
|
|
54
|
+
If a relationship is included, you can click any row in the table to
|
|
55
|
+
view related data.
|
|
56
|
+
|
|
57
|
+
Click a relationship twice and you can configure the deep query of
|
|
58
|
+
the associated Resource. In other words, if you're fetching Posts and
|
|
59
|
+
Comments, click `comments` twice to say things like "only active
|
|
60
|
+
comments should be returned".
|
|
61
|
+
|
|
62
|
+
When you hit 'submit', the top URL bar will change to reflect your query
|
|
63
|
+
and results will show in the center table.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Why REST?'
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Why REST?
|
|
6
|
+
|
|
7
|
+
Graphiti builds on REST rather than replacing it, which is worth a short explanation if you're weighing it against GraphQL.
|
|
8
|
+
|
|
9
|
+
The complaints that motivated GraphQL are real. REST APIs often make clients do several round trips and still hand back the wrong shape of data. But those are complaints about how REST APIs are usually built, not about REST. Add eager-loading and a schema to REST and the complaints go away, and you keep the parts of REST that are hard to get back once you've left: addressable URLs, HTTP caching, and Links that let the server change how a relationship resolves without breaking clients.
|
|
10
|
+
|
|
11
|
+
The other half is conventions. A GraphQL schema is hand-written per type, so filtering and sorting get reinvented on every team. One API spells it `name_contains`, another `name_LIKE`, another exposes no multisort at all. JSON:API already answers those questions, so `?filter[name][prefix]=Ja&sort=-created_at&page[size]=10` means the same thing on every endpoint of every Graphiti API. You define the Resource. The query interface follows from the attribute types.
|
|
12
|
+
|
|
13
|
+
That's the whole tradeoff: fewer decisions per endpoint, at the cost of a fixed request and response format.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Authorization'
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Graphiti authorization happens at three independent layers: which records a query can ever see (`base_scope`), which attributes are readable/writable on those records, and which relationships can be sideloaded or sideposted. Each layer is enforced separately, so a guard on one doesn't imply anything about the others.
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
Guards need to know who's asking. Every Resource has access to `Graphiti.context` via the `#context` method (`lib/graphiti/resource.rb`). In a Rails app, including `Graphiti::Rails::Context` in your controller wraps every action in `Graphiti.with_context(graphiti_context, action_name.to_sym)`, and `graphiti_context` defaults to the controller instance itself (`lib/graphiti/rails/context.rb`):
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
class ApplicationController < ActionController::Base
|
|
13
|
+
include Graphiti::Rails::Context
|
|
14
|
+
end
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That means `context` is your controller, and `context.current_user` (or whatever helper method your controller exposes) is available inside any Resource. Outside of Rails, set context manually:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
Graphiti.with_context(OpenStruct.new(current_user: user)) do
|
|
21
|
+
PostResource.all
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Scoping records
|
|
26
|
+
|
|
27
|
+
Override `#base_scope` to limit which records a Resource can ever return, regardless of filters:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
class PostResource < ApplicationResource
|
|
31
|
+
def base_scope
|
|
32
|
+
Post.where(account_id: context.current_user.account_id)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This runs before filtering, sorting, and pagination, so it can't be bypassed by query params. It applies to sideloads too: a `has_many`/`belongs_to`/`has_one` on the related Resource inherits that Resource's `base_scope` unless the relationship itself passes an explicit `base_scope:` option (`lib/graphiti/sideload.rb#base_scope`), so a scoped Resource stays scoped no matter which relationship it's reached through. See [Composing with Scopes](/concepts/resources#composing-with-scopes) for how `base_scope` fits into the rest of query building.
|
|
38
|
+
|
|
39
|
+
### Knowing which action you're in
|
|
40
|
+
|
|
41
|
+
`base_scope` runs for every action, and sometimes you want it to behave differently for a collection than for a single record. Use `current_action`, which is the action name as a symbol. Rails sets it from `action_name` when wrapping the request (`lib/graphiti/rails/context.rb#wrap_graphiti_context`):
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
def base_scope
|
|
45
|
+
return Post.all if current_action == :show
|
|
46
|
+
Post.where(account_id: context.current_user.account_id)
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Reach for this rather than digging through the query object's internals. `current_action` is public and stable. The params inside `Graphiti::Query` are neither. It is `nil` when nothing set it, which includes a Resource spec that calls `Graphiti.with_context` without a second argument.
|
|
51
|
+
|
|
52
|
+
## Integrating with Pundit
|
|
53
|
+
|
|
54
|
+
Graphiti has no built-in Pundit integration, but the two compose cleanly: let Pundit's policy scope decide which records exist, and let Graphiti guards decide which fields and relationships are exposed.
|
|
55
|
+
|
|
56
|
+
Merge the policy scope in `base_scope`, so it can't be bypassed by query params:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
class ApplicationResource < Graphiti::Resource
|
|
60
|
+
def base_scope
|
|
61
|
+
Pundit.policy_scope!(context.current_user, model)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def current_user
|
|
65
|
+
context.current_user
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Because `context` is the controller in Rails, per-record authorization stays where it always was: in the action, on the model the proxy hands you:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
def show
|
|
74
|
+
post = PostResource.find(params)
|
|
75
|
+
authorize post.data
|
|
76
|
+
respond_with(post)
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Note the split: the policy scope answers "which records may appear at all", `authorize` answers "may this user see this specific record", and [attribute guards](#attribute-guards) answer "which fields of it". Reaching for a policy inside an attribute guard works too, since guards can receive the model:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
attribute :salary, :integer, readable: :salary_visible?
|
|
84
|
+
|
|
85
|
+
def salary_visible?(model)
|
|
86
|
+
Pundit.policy(current_user, model).salary?
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Attribute guards
|
|
91
|
+
|
|
92
|
+
Pass a symbol, string, or proc to `readable:`/`writable:` on an attribute to gate it per-request. The guard method can optionally accept the model instance and the attribute name as arguments. Arity decides what it receives, and the model is only resolved if a guard actually declares a parameter for it (`lib/graphiti/util/attribute_check.rb`, `lib/graphiti/resource.rb#guard_model`):
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
class EmployeeResource < ApplicationResource
|
|
96
|
+
attribute :salary, :integer, writable: :salary_writable?
|
|
97
|
+
|
|
98
|
+
def salary_writable?(model_instance, attribute_name)
|
|
99
|
+
context.current_user.admin? || context.current_user == model_instance.manager
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
On create the model is a new unsaved instance. On update it's the persisted record. A failed `writable` guard on a request rejects the write with an `unwritable_attribute` validation error before anything is persisted (`lib/graphiti/request_validators/validator.rb`). A failed `readable` guard omits the attribute from the response (`lib/graphiti/util/serializer_attributes.rb`).
|
|
105
|
+
|
|
106
|
+
You can set the same guard for every attribute on a Resource with `attributes_readable_by_default`/`attributes_writable_by_default`, which also accept a symbol (`lib/graphiti/resource/configuration.rb`):
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
class ApplicationResource < Graphiti::Resource
|
|
110
|
+
self.attributes_writable_by_default = :writable_by_default?
|
|
111
|
+
|
|
112
|
+
def writable_by_default?(model_instance, attribute_name)
|
|
113
|
+
PolicyChecker.new(context.current_user).writable?(model_instance, attribute_name)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Full option details, including `only`/`except` shorthand, live under [Limiting Behavior](/concepts/resources#limiting-behavior).
|
|
119
|
+
|
|
120
|
+
## Relationship guards
|
|
121
|
+
|
|
122
|
+
`has_many`, `belongs_to`, `has_one`, and `many_to_many` accept the same `readable:`/`writable:` guard shape, but unlike attribute guards, relationship guards take no arguments. Base the decision on `context` alone (`lib/graphiti/sideload.rb#evaluate_flag`):
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
class EmployeeResource < ApplicationResource
|
|
126
|
+
has_many :salary_histories, readable: :admin?, writable: :admin?
|
|
127
|
+
|
|
128
|
+
def admin?
|
|
129
|
+
context.current_user.admin?
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
A failed `readable` guard silently scrubs the relationship from `?include=` before any records are fetched, and omits it from serialized output. A failed `writable` guard rejects a sidepost to that relationship with an `unwritable_relationship` validation error (`lib/graphiti/request_validators/validator.rb`).
|
|
135
|
+
|
|
136
|
+
The guard method can live on either side of the relationship: Graphiti first looks for it on the resource declaring the relationship, falling back to the related resource if it isn't defined there (`lib/graphiti/sideload.rb#guard_resource`). That lets you define the guard once on the related resource and cover every relationship that points at it.
|
|
137
|
+
|
|
138
|
+
To audit every guarded relationship across your app (useful before deploying a new guard), call `Graphiti.guarded_relationships`, which returns strings like `"EmployeeResource.salary_histories"` for every relationship whose `readable` or `writable` flag is a symbol, string, or proc (`lib/graphiti.rb#guarded_relationships`).
|
|
139
|
+
|
|
140
|
+
See [Customizing Relationships](/concepts/relationships#customizing-relationships) for the rest of the relationship option surface.
|
|
141
|
+
|
|
142
|
+
## Testing authorization
|
|
143
|
+
|
|
144
|
+
Set context in a spec with `Graphiti.with_context`:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
let(:ctx) { OpenStruct.new(current_user: double(admin?: true)) }
|
|
148
|
+
|
|
149
|
+
it 'exposes salary to admins' do
|
|
150
|
+
Graphiti.with_context(ctx) { render }
|
|
151
|
+
expect(d[0].salary).to eq(100_000)
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
See [Context](/topics/testing#context) in the testing guide for more on setting context in Resource and API specs.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Caching'
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Caching
|
|
6
|
+
|
|
7
|
+
Graphiti can cache the rendered JSON response for a request, keyed off the underlying data and the parts of the query that affect rendering. This is opt-in at two levels: a global switch that enables cache-backed rendering, and a per-resource declaration that says which resources actually participate.
|
|
8
|
+
|
|
9
|
+
## Enabling it
|
|
10
|
+
|
|
11
|
+
First, tell Graphiti which cache store to use. Any object that responds to `fetch` works, so `Rails.cache` is the usual choice:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
Graphiti.cache = Rails.cache
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then turn on cache-backed rendering globally:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
Graphiti.configure do |config|
|
|
21
|
+
config.cache_rendering = true
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
If `cache_rendering` is `true` but `Graphiti.cache` isn't set to something that responds to `fetch`, Graphiti raises `"You must configure a cache store in order to use cache_rendering. Set Graphiti.cache = Rails.cache, for example."` the first time `Graphiti.config.cache_rendering?` is checked.
|
|
26
|
+
|
|
27
|
+
`cache_rendering` alone doesn't cache anything, though. Each resource has to opt in with `cache_resource`:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
class EmployeeResource < ApplicationResource
|
|
31
|
+
cache_resource expires_in: 5.minutes, tag: :cache_tag
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`expires_in` defaults to `false` (no expiry) and `tag` defaults to `nil`. Calling `cache_resource` sets a resource-level flag that flows through every `all`/`find` call on that resource, so caching applies to both index and show-style requests.
|
|
36
|
+
|
|
37
|
+
## What actually gets cached
|
|
38
|
+
|
|
39
|
+
Only the rendered JSON is cached, not the database query. On render, if the resource proxy is cacheable and `Graphiti.config.cache_rendering?` is true, the renderer wraps the render call in `Graphiti.cache.fetch`, keyed by `"graphiti:render/#{proxy.cache_key}"`, versioned by `proxy.updated_at`, and expiring after `proxy.cache_expires_in`. If either condition is false, rendering happens normally with no cache involved.
|
|
40
|
+
|
|
41
|
+
## Cache key composition
|
|
42
|
+
|
|
43
|
+
`proxy.cache_key` combines three pieces, joined into a single expanded cache key:
|
|
44
|
+
|
|
45
|
+
- **Scope cache key**: the underlying object's own `cache_key` (typically an ActiveRecord relation's `cache_key`, so it reflects the resolved records), combined with the `cache_key` of every sideloaded resource proxy. Sideloading `positions` or `department` folds their cache keys into the parent's.
|
|
46
|
+
- **Query cache key**: a SHA1 digest over the parts of the query that affect *rendering*: `extra_fields`, `fields`, whether links are requested, whether pagination links are requested, and `format`. Filters, sorts, and pagination page/size are deliberately not part of this digest. Two requests that select the same rendering options produce the same query cache key even if they filter different data. This is why the key is always combined with the scope key, which does vary with the resolved records.
|
|
47
|
+
- **Resource cache tag**: if `cache_resource` was given a `tag:`, and the resource responds to that method, its value is appended as a third segment (e.g. `cache_resource tag: :cache_tag` calls `resource.cache_tag` and appends the result).
|
|
48
|
+
|
|
49
|
+
## Versioning and expiry
|
|
50
|
+
|
|
51
|
+
`proxy.updated_at` is the max `updated_at` across the resolved records (`@object.maximum(:updated_at)`) and every sideloaded proxy's `updated_at`, recursively. If that calculation raises, Graphiti logs the error and falls back to `Time.now`, so a broken `updated_at` calculation degrades to "always fresh" rather than raising into the request. This value is passed as the cache store's `version:` option, so it participates in the effective cache entry the same way `ActiveSupport::Cache::Store#fetch` normally handles versioning. `expires_in` is passed straight through to the cache store as-is from `cache_resource`.
|
|
52
|
+
|
|
53
|
+
## Debugging cache behavior
|
|
54
|
+
|
|
55
|
+
When the [debugger](/topics/debugging) is enabled and a request's rendering is actually cached (`proxy.cached?` and `cache_rendering?` both true), the debug output includes a cache section showing the cache key's name, whether it's "stable" or "volatile" (based on how often the key changes across requests), and, when the key does change, which cache-key segments were added or removed. This is built on `Graphiti::Util::CacheDebug`, which persists hit/miss counts in `Graphiti.cache` between requests to compute those stats. See [ETags](/topics/etags) for the related per-response version identifier this same infrastructure computes.
|