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.
Files changed (269) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +30 -86
  3. data/.github/workflows/docs.yml +60 -0
  4. data/.github/workflows/release.yml +8 -8
  5. data/.gitignore +7 -0
  6. data/.npmrc +9 -0
  7. data/.standard.yml +4 -1
  8. data/Appraisals +33 -32
  9. data/CHANGELOG.md +41 -0
  10. data/README.md +13 -2
  11. data/UPGRADING.md +2 -68
  12. data/docs/concepts/backends-and-models.md +122 -0
  13. data/docs/concepts/endpoints.md +183 -0
  14. data/docs/concepts/links.md +212 -0
  15. data/docs/concepts/overview.md +80 -0
  16. data/docs/concepts/persisting.md +376 -0
  17. data/docs/concepts/relationships.md +527 -0
  18. data/docs/concepts/resources.md +677 -0
  19. data/docs/getting-started/first-api.md +289 -0
  20. data/docs/getting-started/installation.md +185 -0
  21. data/docs/intro.md +307 -0
  22. data/docs/js/authentication.md +63 -0
  23. data/docs/js/ddau.md +20 -0
  24. data/docs/js/extra-params.md +41 -0
  25. data/docs/js/index.md +112 -0
  26. data/docs/js/installation.md +120 -0
  27. data/docs/js/middleware.md +72 -0
  28. data/docs/js/models.md +202 -0
  29. data/docs/js/reads.md +494 -0
  30. data/docs/js/state-syncing.md +100 -0
  31. data/docs/js/writes.md +373 -0
  32. data/docs/reference/vandal.md +63 -0
  33. data/docs/reference/why.md +13 -0
  34. data/docs/topics/authorization.md +155 -0
  35. data/docs/topics/caching.md +55 -0
  36. data/docs/topics/customizing-sideloads.md +156 -0
  37. data/docs/topics/debugging.md +216 -0
  38. data/docs/topics/error-handling.md +210 -0
  39. data/docs/topics/etags.md +46 -0
  40. data/docs/topics/hopping-relationships.md +149 -0
  41. data/docs/topics/json-attributes.md +77 -0
  42. data/docs/topics/openstruct-models.md +50 -0
  43. data/docs/topics/remote-resources.md +291 -0
  44. data/docs/topics/testing.md +894 -0
  45. data/docs/topics/without-activerecord.md +324 -0
  46. data/docs/tutorial/index.md +58 -0
  47. data/docs/tutorial/step_0.md +107 -0
  48. data/docs/tutorial/step_1.md +199 -0
  49. data/docs/tutorial/step_2.md +312 -0
  50. data/docs/tutorial/step_3.md +142 -0
  51. data/docs/tutorial/step_4.md +135 -0
  52. data/docs/tutorial/step_5.md +69 -0
  53. data/docs/tutorial/step_6.md +82 -0
  54. data/docs/tutorial/step_7.md +205 -0
  55. data/docs/tutorial/step_8.md +128 -0
  56. data/docs/tutorial/step_9.md +171 -0
  57. data/docs/upgrading.md +265 -0
  58. data/gemfiles/rails_7_1.gemfile +4 -3
  59. data/gemfiles/{rails_7_2_graphiti_rails.gemfile → rails_7_2.gemfile} +3 -3
  60. data/gemfiles/{rails_8_1_graphiti_rails.gemfile → rails_8_0.gemfile} +3 -3
  61. data/gemfiles/{rails_8_0_graphiti_rails.gemfile → rails_8_1.gemfile} +3 -3
  62. data/graphiti.gemspec +7 -5
  63. data/{deprecated_generators → lib/generators}/graphiti/api_test_generator.rb +7 -1
  64. data/{deprecated_generators → lib/generators}/graphiti/generator_mixin.rb +14 -1
  65. data/{deprecated_generators → lib/generators}/graphiti/install_generator.rb +19 -13
  66. data/{deprecated_generators → lib/generators}/graphiti/resource_generator.rb +43 -6
  67. data/{deprecated_generators → lib/generators}/graphiti/templates/index_request_spec.rb.erb +1 -1
  68. data/{deprecated_generators → lib/generators}/graphiti/templates/resource_reads_spec.rb.erb +6 -6
  69. data/{deprecated_generators → lib/generators}/graphiti/templates/show_request_spec.rb.erb +1 -1
  70. data/lib/graphiti/configuration.rb +2 -2
  71. data/lib/graphiti/error_serializers/conflict_request.rb +19 -0
  72. data/lib/graphiti/error_serializers/deprecated_constants.rb +48 -0
  73. data/lib/graphiti/error_serializers/invalid_request.rb +56 -0
  74. data/lib/graphiti/error_serializers/validation.rb +143 -0
  75. data/lib/graphiti/errors.rb +4 -23
  76. data/lib/graphiti/query.rb +1 -1
  77. data/lib/graphiti/rails/context.rb +33 -0
  78. data/lib/graphiti/rails/controller.rb +41 -0
  79. data/lib/graphiti/rails/debugging.rb +18 -0
  80. data/lib/graphiti/rails/exception_handlers.rb +77 -0
  81. data/lib/graphiti/rails/railtie.rb +139 -0
  82. data/lib/graphiti/rails/responders.rb +21 -0
  83. data/lib/graphiti/rails/test_helpers.rb +22 -0
  84. data/lib/graphiti/rails.rb +47 -29
  85. data/lib/graphiti/resource/configuration.rb +1 -0
  86. data/lib/graphiti/resource/interface.rb +2 -2
  87. data/lib/graphiti/resource/persistence.rb +14 -2
  88. data/lib/graphiti/resource/remote.rb +2 -2
  89. data/lib/graphiti/resource/sideloading.rb +1 -1
  90. data/lib/graphiti/resource.rb +13 -1
  91. data/lib/graphiti/responders.rb +7 -20
  92. data/lib/graphiti/schema.rb +5 -1
  93. data/lib/graphiti/schema_diff.rb +4 -0
  94. data/lib/graphiti/scope.rb +45 -37
  95. data/lib/graphiti/serializer.rb +6 -0
  96. data/lib/graphiti/sideload/belongs_to.rb +38 -5
  97. data/lib/graphiti/sideload/polymorphic_belongs_to.rb +27 -23
  98. data/lib/graphiti/sideload.rb +54 -35
  99. data/lib/graphiti/spec_helpers/errors.rb +73 -0
  100. data/lib/graphiti/spec_helpers/errors_proxy.rb +75 -0
  101. data/lib/graphiti/spec_helpers/helpers.rb +107 -0
  102. data/lib/graphiti/spec_helpers/node.rb +88 -0
  103. data/lib/graphiti/spec_helpers/rspec.rb +147 -0
  104. data/lib/graphiti/spec_helpers.rb +53 -0
  105. data/lib/graphiti/util/include_params.rb +2 -2
  106. data/lib/graphiti/util/persistence.rb +10 -11
  107. data/lib/graphiti/util/serializer_relationships.rb +41 -5
  108. data/lib/graphiti/version.rb +1 -1
  109. data/lib/graphiti-rails.rb +11 -0
  110. data/lib/graphiti.rb +34 -10
  111. data/lib/graphiti_errors.rb +11 -0
  112. data/lib/graphiti_spec_helpers/rspec.rb +3 -0
  113. data/lib/graphiti_spec_helpers.rb +11 -0
  114. data/lib/{graphiti/deprecated_tasks.rb → tasks/graphiti.rake} +6 -1
  115. data/package-lock.json +6199 -0
  116. data/package.json +5 -4
  117. data/website/.gitignore +20 -0
  118. data/website/README.md +43 -0
  119. data/website/docusaurus.config.js +141 -0
  120. data/website/package-lock.json +19474 -0
  121. data/website/package.json +46 -0
  122. data/website/sidebars.js +82 -0
  123. data/website/src/css/custom.css +58 -0
  124. data/website/src/pages/markdown-page.mdx +7 -0
  125. data/website/static/.nojekyll +0 -0
  126. data/website/static/1.13/2019/03/31/graphiti-1-0.html +205 -0
  127. data/website/static/1.13/2019/05/08/graphiti-1-1.html +212 -0
  128. data/website/static/1.13/2019/05/20/graphiti-1-2.html +214 -0
  129. data/website/static/1.13/2019/10/14/tutorial.html +198 -0
  130. data/website/static/1.13/CNAME +1 -0
  131. data/website/static/1.13/README.md +16 -0
  132. data/website/static/1.13/assets/css/syntax.css +60 -0
  133. data/website/static/1.13/assets/favicons/android-chrome-192x192.png +0 -0
  134. data/website/static/1.13/assets/favicons/android-chrome-256x256.png +0 -0
  135. data/website/static/1.13/assets/favicons/apple-touch-icon.png +0 -0
  136. data/website/static/1.13/assets/favicons/browserconfig.xml +9 -0
  137. data/website/static/1.13/assets/favicons/favicon-16x16.png +0 -0
  138. data/website/static/1.13/assets/favicons/favicon-32x32.png +0 -0
  139. data/website/static/1.13/assets/favicons/favicon.ico +0 -0
  140. data/website/static/1.13/assets/favicons/mstile-150x150.png +0 -0
  141. data/website/static/1.13/assets/favicons/safari-pinned-tab.svg +1 -0
  142. data/website/static/1.13/assets/favicons/site.webmanifest +19 -0
  143. data/website/static/1.13/assets/img/backend.gif +0 -0
  144. data/website/static/1.13/assets/img/conformity.png +0 -0
  145. data/website/static/1.13/assets/img/error_payload.png +0 -0
  146. data/website/static/1.13/assets/img/gh.png +0 -0
  147. data/website/static/1.13/assets/img/lifecycle.gif +0 -0
  148. data/website/static/1.13/assets/img/logo-500.png +0 -0
  149. data/website/static/1.13/assets/img/logo.png +0 -0
  150. data/website/static/1.13/assets/img/love-graffiti.jpg +0 -0
  151. data/website/static/1.13/assets/img/meta_total_count.png +0 -0
  152. data/website/static/1.13/assets/img/persist.jpg +0 -0
  153. data/website/static/1.13/assets/img/resource.gif +0 -0
  154. data/website/static/1.13/assets/img/rest-graffiti.jpg +0 -0
  155. data/website/static/1.13/assets/img/rest1.gif +0 -0
  156. data/website/static/1.13/assets/img/rest2.gif +0 -0
  157. data/website/static/1.13/assets/img/rest3.gif +0 -0
  158. data/website/static/1.13/assets/img/rethink-rest-graffiti.jpg +0 -0
  159. data/website/static/1.13/assets/img/why.png +0 -0
  160. data/website/static/1.13/assets/js/highlight.pack.js +2 -0
  161. data/website/static/1.13/assets/main.css +15518 -0
  162. data/website/static/1.13/assets/main.css.map +1 -0
  163. data/website/static/1.13/bin/bundle +109 -0
  164. data/website/static/1.13/bin/jekyll +27 -0
  165. data/website/static/1.13/bin/kramdown +27 -0
  166. data/website/static/1.13/bin/listen +27 -0
  167. data/website/static/1.13/bin/rake +27 -0
  168. data/website/static/1.13/bin/rougify +27 -0
  169. data/website/static/1.13/bin/safe_yaml +27 -0
  170. data/website/static/1.13/bin/sass +27 -0
  171. data/website/static/1.13/bin/sass-convert +27 -0
  172. data/website/static/1.13/bin/scss +27 -0
  173. data/website/static/1.13/blog.html +259 -0
  174. data/website/static/1.13/cheatsheet.html +316 -0
  175. data/website/static/1.13/cookbooks/authorization.md +0 -0
  176. data/website/static/1.13/cookbooks/caching.md +0 -0
  177. data/website/static/1.13/cookbooks/customizing-sideloads.html +325 -0
  178. data/website/static/1.13/cookbooks/etags.md +0 -0
  179. data/website/static/1.13/cookbooks/hopping-relationships.html +324 -0
  180. data/website/static/1.13/cookbooks/json_attributes.md +0 -0
  181. data/website/static/1.13/cookbooks/openstruct-models.md +0 -0
  182. data/website/static/1.13/cookbooks/remote-resources.md +0 -0
  183. data/website/static/1.13/cookbooks/without-activerecord.html +510 -0
  184. data/website/static/1.13/features.html +249 -0
  185. data/website/static/1.13/feed.xml +106 -0
  186. data/website/static/1.13/guides/concepts/backends-and-models.html +467 -0
  187. data/website/static/1.13/guides/concepts/debugging.html +440 -0
  188. data/website/static/1.13/guides/concepts/endpoints.html +432 -0
  189. data/website/static/1.13/guides/concepts/error-handling.html +396 -0
  190. data/website/static/1.13/guides/concepts/links.html +501 -0
  191. data/website/static/1.13/guides/concepts/remote-resources.html +536 -0
  192. data/website/static/1.13/guides/concepts/resources.html +2176 -0
  193. data/website/static/1.13/guides/concepts/testing.html +1469 -0
  194. data/website/static/1.13/guides/getting-started/installation.html +420 -0
  195. data/website/static/1.13/guides/graphiti-rails-migration.html +242 -0
  196. data/website/static/1.13/guides/index.html +269 -0
  197. data/website/static/1.13/guides/overview.html +325 -0
  198. data/website/static/1.13/guides/upgrading-2-0.html +193 -0
  199. data/website/static/1.13/guides/upgrading.html +314 -0
  200. data/website/static/1.13/guides/vandal.html +282 -0
  201. data/website/static/1.13/guides/why.html +1121 -0
  202. data/website/static/1.13/index.html +72 -0
  203. data/website/static/1.13/js/authentication.html +295 -0
  204. data/website/static/1.13/js/ddau.html +238 -0
  205. data/website/static/1.13/js/extra-params.html +270 -0
  206. data/website/static/1.13/js/index.html +321 -0
  207. data/website/static/1.13/js/installation.html +637 -0
  208. data/website/static/1.13/js/introduction.html +257 -0
  209. data/website/static/1.13/js/middleware.html +318 -0
  210. data/website/static/1.13/js/reads/fieldsets.html +271 -0
  211. data/website/static/1.13/js/reads/filtering.html +289 -0
  212. data/website/static/1.13/js/reads/includes.html +260 -0
  213. data/website/static/1.13/js/reads/index.html +497 -0
  214. data/website/static/1.13/js/reads/nested-queries.html +353 -0
  215. data/website/static/1.13/js/reads/pagination.html +260 -0
  216. data/website/static/1.13/js/reads/sorting.html +265 -0
  217. data/website/static/1.13/js/reads/statistics.html +289 -0
  218. data/website/static/1.13/js/state-syncing.html +340 -0
  219. data/website/static/1.13/js/writes/deferred.html +296 -0
  220. data/website/static/1.13/js/writes/dirty-tracking.html +399 -0
  221. data/website/static/1.13/js/writes/index.html +391 -0
  222. data/website/static/1.13/js/writes/nested.html +330 -0
  223. data/website/static/1.13/js/writes/validations.html +272 -0
  224. data/website/static/1.13/quickstart.html +660 -0
  225. data/website/static/1.13/template +161 -0
  226. data/website/static/1.13/tutorial/index.html +250 -0
  227. data/website/static/1.13/tutorial/step_0.html +292 -0
  228. data/website/static/1.13/tutorial/step_1.html +517 -0
  229. data/website/static/1.13/tutorial/step_2.html +481 -0
  230. data/website/static/1.13/tutorial/step_3.html +323 -0
  231. data/website/static/1.13/tutorial/step_4.html +318 -0
  232. data/website/static/1.13/tutorial/step_5.html +265 -0
  233. data/website/static/1.13/tutorial/step_6.html +276 -0
  234. data/website/static/1.13/tutorial/step_7.html +390 -0
  235. data/website/static/1.13/tutorial/step_8.html +316 -0
  236. data/website/static/1.13/tutorial/step_9.html +365 -0
  237. data/website/static/assets/img/error_payload.png +0 -0
  238. data/website/static/assets/img/legacy/legacy-0378a3bb39.png +0 -0
  239. data/website/static/assets/img/legacy/legacy-05bbd3e5fd.png +0 -0
  240. data/website/static/assets/img/legacy/legacy-07aa104495.png +0 -0
  241. data/website/static/assets/img/legacy/legacy-0c75a16b3a.gif +0 -0
  242. data/website/static/assets/img/legacy/legacy-3076df6209.png +0 -0
  243. data/website/static/assets/img/legacy/legacy-7f6889bc89.png +0 -0
  244. data/website/static/assets/img/legacy/legacy-a2cc4363c3.png +0 -0
  245. data/website/static/assets/img/legacy/legacy-f67cfa89ab.png +0 -0
  246. data/website/static/assets/img/meta_total_count.png +0 -0
  247. data/website/static/img/docusaurus-social-card.jpg +0 -0
  248. data/website/static/img/docusaurus.png +0 -0
  249. data/website/static/img/favicon.ico +0 -0
  250. data/website/static/img/logo.png +0 -0
  251. data/website/static/img/logo.svg +1 -0
  252. data/website/static/img/undraw_docusaurus_mountain.svg +171 -0
  253. data/website/static/img/undraw_docusaurus_react.svg +170 -0
  254. data/website/static/img/undraw_docusaurus_tree.svg +40 -0
  255. metadata +245 -46
  256. data/gemfiles/rails_6.gemfile +0 -18
  257. data/gemfiles/rails_6_graphiti_rails.gemfile +0 -19
  258. data/gemfiles/rails_7.gemfile +0 -18
  259. data/gemfiles/rails_7_1_graphiti_rails.gemfile +0 -19
  260. data/gemfiles/rails_7_graphiti_rails.gemfile +0 -19
  261. data/lib/graphiti/railtie.rb +0 -121
  262. /data/{deprecated_generators → lib/generators}/graphiti/resource_test_generator.rb +0 -0
  263. /data/{deprecated_generators → lib/generators}/graphiti/templates/application_resource.rb.erb +0 -0
  264. /data/{deprecated_generators → lib/generators}/graphiti/templates/controller.rb.erb +0 -0
  265. /data/{deprecated_generators → lib/generators}/graphiti/templates/create_request_spec.rb.erb +0 -0
  266. /data/{deprecated_generators → lib/generators}/graphiti/templates/destroy_request_spec.rb.erb +0 -0
  267. /data/{deprecated_generators → lib/generators}/graphiti/templates/resource.rb.erb +0 -0
  268. /data/{deprecated_generators → lib/generators}/graphiti/templates/resource_writes_spec.rb.erb +0 -0
  269. /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
+ &raquo;
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.