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
@@ -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!