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,677 @@
1
+ ---
2
+ title: 'Resources'
3
+ ---
4
+
5
+ # Resources
6
+
7
+ A Resource is an abstraction around an API endpoint, the way a Model is an abstraction around a database table. It holds the logic for **querying**, **persisting**, and **serializing** one kind of thing.
8
+
9
+ ```ruby
10
+ class EmployeeResource < ApplicationResource
11
+ attribute :first_name, :string
12
+ attribute :age, :integer
13
+
14
+ has_many :positions
15
+ end
16
+ ```
17
+
18
+ This page is the full reference. For the whole API on one screen, see the [cheatsheet on the home page](/). For how a request flows through a Resource, see [Lifecycle of a Request](/concepts/overview).
19
+
20
+ Resources connect to each other. That's covered separately in [Relationships](/concepts/relationships), and writes in [Persisting](/concepts/persisting).
21
+
22
+ ## Attributes {#attributes}
23
+
24
+ ```ruby
25
+ attribute :first_name, :string
26
+ ```
27
+
28
+ A **name** (`first_name`) maps to a JSON key. A **Type** (`string`) maps to a JSON value and its coercion rules.
29
+
30
+ ### Limiting Behavior {#limiting-behavior}
31
+
32
+ ```ruby
33
+ attribute :name, :string,
34
+ readable: true, # renders in responses
35
+ writable: true, # accepted on create/update
36
+ sortable: true, # ?sort=name works
37
+ filterable: true, # ?filter[name]=... works
38
+ schema: true # exported to schema.json, not affected by only/except
39
+ ```
40
+
41
+ Turn any flag off directly, or with `only`/`except` shorthand:
42
+
43
+ ```ruby
44
+ attribute :name, :string, sortable: false
45
+ attribute :name, :string, only: [:sortable]
46
+ attribute :name, :string, except: [:writable]
47
+ ```
48
+
49
+ **Guards.** `readable` and `writable` also accept a symbol, string, or proc. The behavior applies only when the guard returns `true`, and the guard's arity decides what it receives:
50
+
51
+ ```ruby
52
+ attribute :name, :string, writable: :admin?
53
+ attribute :salary, :integer, readable: :visible?, writable: :salary_writable?
54
+
55
+ def admin? # no arguments
56
+ context.current_user.admin?
57
+ end
58
+
59
+ def visible?(model) # the model
60
+ model.internal == false
61
+ end
62
+
63
+ def salary_writable?(model, attribute_name) # the model and the attribute name
64
+ PolicyChecker.new(model).attribute_writable?(attribute_name)
65
+ end
66
+ ```
67
+
68
+ The model is only looked up when a guard declares a parameter for it, so zero-argument guards cost nothing. On an update it's the persisted record. On a create, it's a new unsaved instance.
69
+
70
+ | Guard returns `false` on | Result |
71
+ | --- | --- |
72
+ | `readable` | The attribute is omitted from the response. |
73
+ | `writable` | The request is rejected with an `unwritable_attribute` validation error, before anything is persisted. |
74
+
75
+ ### Default Behavior {#default-behavior}
76
+
77
+ ```ruby
78
+ # On ApplicationResource, affects every subclass
79
+ self.attributes_readable_by_default = false # default true
80
+ self.attributes_writable_by_default = false # default true
81
+ self.attributes_filterable_by_default = false # default true
82
+ self.attributes_sortable_by_default = false # default true
83
+ self.attributes_schema_by_default = false # default true
84
+ ```
85
+
86
+ Each `*_by_default` setting can also be a guard symbol, delegating the check to a method. Useful for wiring every attribute through one authorization system:
87
+
88
+ ```ruby
89
+ self.attributes_readable_by_default = :attribute_readable?
90
+
91
+ def attribute_readable?(model_instance, attribute_name)
92
+ PolicyChecker.new(model_instance).attribute_readable?(attribute_name)
93
+ end
94
+ ```
95
+
96
+ ### Customizing Display {#customizing-display}
97
+
98
+ ```ruby
99
+ attribute :name, :string do
100
+ @object.name.upcase # @object is the model instance
101
+ end
102
+ ```
103
+
104
+ ### Types {#types}
105
+
106
+ | Type | Notes |
107
+ | --- | --- |
108
+ | `string` | |
109
+ | `integer` | |
110
+ | `integer_id` | Renders as a string, queries/persists as an integer. Default type for `id`. |
111
+ | `uuid` | Like `string`, but only `eq`/`not_eq`, case-sensitive by default. |
112
+ | `string_enum` | Like `string`, but only `eq`/`not_eq`/`eql`/`not_eql`, and requires `allow:`. |
113
+ | `integer_enum` | Like `integer`, but only `eq`/`not_eq`, and requires `allow:`. |
114
+ | `big_decimal` | |
115
+ | `float` | |
116
+ | `boolean` | |
117
+ | `date` | |
118
+ | `datetime` | |
119
+ | `hash` | |
120
+ | `array` | |
121
+
122
+ Every type except `boolean`, `hash`, and `array` also has an `array_of_*` variant: `array_of_integers`, `array_of_dates`, `array_of_uuids`, and so on.
123
+
124
+ Each Type governs reading, writing, and filtering by wrapping a [Dry Type](https://dry-rb.org/gems/dry-types). Inspect one to see its parts:
125
+
126
+ ```ruby
127
+ Graphiti::Types[:integer_id]
128
+
129
+ # {
130
+ # params: Dry::Types['coercible.integer'],
131
+ # read: Dry::Types['coercible.string'],
132
+ # write: Dry::Types['coercible.integer'],
133
+ # ...
134
+ # }
135
+ ```
136
+
137
+ Edit an implementation in place. Here, `:string` is made to render as an integer:
138
+
139
+ ```ruby
140
+ Graphiti::Types[:string][:read] = Dry::Types['coercible.integer']
141
+ ```
142
+
143
+ #### Enum Types {#enum-types}
144
+
145
+ `string_enum` and `integer_enum` behave like `string` and `integer`, except declaring one (as an attribute or a filter) requires the `allow:` option, the list of acceptable values:
146
+
147
+ ```ruby
148
+ attribute :status, :string_enum, allow: ['draft', 'published']
149
+ ```
150
+
151
+ If your attribute is backed by an ActiveRecord enum, reference the values directly:
152
+
153
+ ```ruby
154
+ # app/models/post.rb
155
+ class Post < ApplicationRecord
156
+ enum status: {
157
+ draft: 0,
158
+ published: 1
159
+ }
160
+ end
161
+
162
+ # app/resources/post_resource.rb
163
+ class PostResource < ApplicationResource
164
+ attribute :status, :string_enum, allow: Post.statuses.keys
165
+ end
166
+ ```
167
+
168
+ See [Filter Options](#filter-options) for more on `allow`.
169
+
170
+ Graphiti does not validate enum values on write. Your model layer is still expected to validate incoming data.
171
+
172
+ #### Custom Types {#custom-types}
173
+
174
+ [Dry Types supports custom types](https://dry-rb.org/gems/dry-types/main/custom-types/):
175
+
176
+ ```ruby
177
+ # Define the Type
178
+ definition = Dry::Types::Nominal.new(String)
179
+ type = definition.constructor do |input|
180
+ input.upcase
181
+ end
182
+
183
+ # Register it with Graphiti
184
+ Graphiti::Types[:caps_lock] = {
185
+ params: type,
186
+ read: type,
187
+ write: type,
188
+ kind: 'scalar',
189
+ canonical_name: :caps_lock,
190
+ description: 'All capital letters'
191
+ }
192
+
193
+ # Use in a Resource
194
+ attribute :name, :caps_lock
195
+ ```
196
+
197
+ ## Querying {#querying}
198
+
199
+ ```ruby
200
+ class PostResource < ApplicationResource
201
+ # Applies to every query: start with a base scope, alter it based on
202
+ # the incoming request. Called just like ActiveRecord's Post.all.
203
+ def base_scope
204
+ Post.all
205
+ end
206
+
207
+ # Must execute the query and return an array of Model instances.
208
+ def resolve(scope)
209
+ scope.to_a
210
+ end
211
+ end
212
+ ```
213
+
214
+ ### Query Interface {#query-interface}
215
+
216
+ Resources can query and persist without an API request or response. Pass a [JSONAPI-compliant](http://jsonapi.org) query hash directly:
217
+
218
+ ```ruby
219
+ EmployeeResource.all({
220
+ filter: { first_name: 'Jane' },
221
+ sort: '-created_at',
222
+ page: { size: 10, number: 2 }
223
+ })
224
+ ```
225
+
226
+ The return value from `.all` is a **proxy** object, similar to `ActiveRecord::Relation`. No query fires until you call `.map`, `.data`, or a render method:
227
+
228
+ ```ruby
229
+ employees = EmployeeResource.all
230
+ employees.class # Graphiti::ResourceProxy
231
+ employees.map(&:first_name) # => ["Jane", "Joe", ...]
232
+ employees.data # => [#<Employee>, #<Employee>, ...]
233
+
234
+ employees.to_jsonapi
235
+ employees.to_json
236
+ employees.to_xml
237
+ ```
238
+
239
+ `.find` returns a single record's proxy by id, raising `Graphiti::Errors::RecordNotFound` if none are returned:
240
+
241
+ ```ruby
242
+ employee = EmployeeResource.find(id: 123)
243
+ employee.data.first_name # => "Jane"
244
+ ```
245
+
246
+ ### Composing with Scopes {#composing-with-scopes}
247
+
248
+ #### #base_scope {#base-scope}
249
+
250
+ ```ruby
251
+ def base_scope
252
+ Position.where(active: true)
253
+ end
254
+ ```
255
+
256
+ Override `#base_scope` for logic that should apply to every query. Here, it only ever returns active Positions.
257
+
258
+ Pass a second argument to `.all` to override the base scope for a single call:
259
+
260
+ ```ruby
261
+ class InactivePostsController < PostsController
262
+ def index
263
+ posts = PostResource.all(params, Post.where(active: false))
264
+ respond_with(posts)
265
+ end
266
+ end
267
+ ```
268
+
269
+ ### Sort {#sort}
270
+
271
+ ```ruby
272
+ sort :name, :string do |scope, direction|
273
+ scope.order(first_name: direction, last_name: direction)
274
+ end
275
+ ```
276
+
277
+ Omit the type if a matching `attribute` is already defined. This overrides its default sort behavior:
278
+
279
+ ```ruby
280
+ attribute :name, :string
281
+
282
+ sort :name do |scope, direction|
283
+ # ... code ...
284
+ end
285
+ ```
286
+
287
+ `sort` on its own defines a sort-only attribute. Define the `attribute` first if you also need filtering or other behavior.
288
+
289
+ #### Sort Options {#sort-options}
290
+
291
+ | Option | Description |
292
+ | --- | --- |
293
+ | `only` | Restrict to a single direction, e.g. `sort :name, only: [:desc]` |
294
+
295
+ ### Filter {#filter}
296
+
297
+ ```ruby
298
+ filter :name, :string do
299
+ eq do |scope, value|
300
+ scope.where(first_name: value)
301
+ end
302
+
303
+ # prefix do ... end
304
+ # suffix do ... end
305
+ # etc
306
+ end
307
+ ```
308
+
309
+ Omit the type if a matching `attribute` is already defined. This overrides its default filter behavior. `filter` on its own defines a filter-only attribute. Define the `attribute` first if you also need sorting or other behavior.
310
+
311
+ Every operator below also has a `not_` counterpart (`not_eq`, `not_prefix`, ...). Values arrive as an array unless the filter is `single: true`. Comma-delimit multiple values in a query string (`/employees?filter[name]=Jane,John`).
312
+
313
+ | Type | Default operators |
314
+ | --- | --- |
315
+ | `string` | `eq`, `eql`, `prefix`, `suffix`, `match` |
316
+ | `uuid` | `eq` |
317
+ | `string_enum`, `integer_enum` | `eq`, `eql` |
318
+ | `integer_id`, `integer`, `big_decimal`, `float`, `date`, `datetime` | `eq`, `gt`, `gte`, `lt`, `lte` |
319
+ | `boolean` | `eq` (always `single: true`) |
320
+ | `hash` | `eq` |
321
+ | `array` | `eq` |
322
+
323
+ Define custom operators on the fly:
324
+
325
+ ```ruby
326
+ filter :name do
327
+ fuzzy_match do |scope, value|
328
+ # ... code ...
329
+ end
330
+ end
331
+ ```
332
+
333
+ This supports `filter[name][fuzzy_match]=foo`.
334
+
335
+ #### Filter Options {#filter-options}
336
+
337
+ | Option | Description |
338
+ | --- | --- |
339
+ | `only`, `except` | Limit the operators generated from the type's defaults, e.g. `filter :name, :string, only: [:eq, :suffix]` |
340
+ | `allow` | Only permit these values, e.g. `filter :size, :string, allow: ['Big', 'Medium', 'Small']` |
341
+ | `deny` | Reject these values, e.g. `filter :size, :string, deny: ['X-Large']` |
342
+ | `single` | Accept one value instead of an array. `boolean` filters are `single: true` by default. |
343
+ | `required` | Reject the request if the filter is absent, e.g. `filter :customer_id, :string, required: true` (equivalently, `attribute :customer_id, :integer, filterable: :required`) |
344
+ | `dependent` | Require other filters alongside this one, e.g. `filter :customer_id, :integer, dependent: [:customer_type]` paired with `filter :customer_type, :string, dependent: [:customer_id]`, so querying by id requires type, and vice versa |
345
+ | `allow_nil` | Coerce an incoming `null` to Ruby `nil` instead of the string `"null"`. Default `false`. Set `self.filters_accept_nil_by_default = true` on a Resource to flip it for all of that Resource's filters. |
346
+
347
+ ```ruby
348
+ # Default behavior
349
+ filter :name, :string do
350
+ eq do |scope, value|
351
+ value # => ["Jane"]
352
+ end
353
+ end
354
+
355
+ # With single: true
356
+ filter :name, :string, single: true do
357
+ eq do |scope, value|
358
+ value # => "Jane"
359
+ end
360
+ end
361
+ ```
362
+
363
+ #### Boolean Filter {#boolean-filter}
364
+
365
+ Filters with type `boolean` are `single: true` by default. A boolean filter accepting multiple values doesn't make sense.
366
+
367
+ #### Hash Filter {#hash-filter}
368
+
369
+ Filters with type `hash` parse JSON automatically when passed in a URL query string:
370
+
371
+ ```ruby
372
+ # GET /employees?filter[metadata]={ "foo": 100 }
373
+
374
+ filter :metadata, :hash do
375
+ eq do |scope, value|
376
+ value # => [{ "foo" => 100 }]
377
+ end
378
+ end
379
+ ```
380
+
381
+ #### Escaping Values {#escaping-values}
382
+
383
+ By default, Graphiti parses a comma-delimited string as an array. Wrap a value in `{{curlies}}` to keep it intact, for a "keyword search" field that could itself contain a comma:
384
+
385
+ ```ruby
386
+ # GET /employees?filter[keywords]={{some,value}}
387
+
388
+ filter :keywords, :string do
389
+ eq do |scope, value|
390
+ value # => "some,value"
391
+ end
392
+ end
393
+ ```
394
+
395
+ Or define an array explicitly instead of relying on comma-splitting:
396
+
397
+ ```ruby
398
+ # GET /employees?filter[keywords]=[some,value]
399
+
400
+ filter :keywords, :string do
401
+ eq do |scope, value|
402
+ value # => ["some", "value"]
403
+ end
404
+ end
405
+ ```
406
+
407
+ A `single: true` filter skips array parsing entirely and escapes the value for you, filtering on the string as given.
408
+
409
+ ### Statistics {#statistics}
410
+
411
+ ```ruby
412
+ stat total: [:count]
413
+ stat rating: [:average]
414
+ stat likes: [:sum]
415
+ stat score: [:maximum]
416
+
417
+ stat rating: [:average] do
418
+ standard_deviation do |scope, attr|
419
+ # your standard deviation code here
420
+ end
421
+ end
422
+ ```
423
+
424
+ Every Resource has a `total: :count` statistic by default. Statistics respect filtering but not pagination, so you can show a "Total Posts" count above a paginated grid without a second request:
425
+
426
+ ```ruby
427
+ PostResource.all({
428
+ stats: { total: 'count' }
429
+ })
430
+ # GET /posts?stats[total]=count
431
+ ```
432
+
433
+ ```ruby
434
+ {
435
+ meta: {
436
+ stats: {
437
+ total: {
438
+ count: 100
439
+ }
440
+ }
441
+ }
442
+ }
443
+ ```
444
+
445
+ ### Extra Fields {#extra-fields}
446
+
447
+ ```ruby
448
+ extra_attribute :net_worth
449
+ ```
450
+
451
+ Works like `attribute`, except the field is read-only and only returned when explicitly requested: `?extra_fields[employees]=net_worth`.
452
+
453
+ Adjust the scope (e.g. to eager-load) only when the extra field is requested:
454
+
455
+ ```ruby
456
+ resource.on_extra_attribute :net_worth do |scope|
457
+ scope.includes(:assets)
458
+ end
459
+ ```
460
+
461
+ ### #resolve {#resolve}
462
+
463
+ `#resolve` must execute the query and return an array of `Model` instances. Override it to add behavior around the default:
464
+
465
+ ```ruby
466
+ def resolve(scope)
467
+ Rails.logger.info "begin resolving scope..."
468
+ result = super
469
+ Rails.logger.info "resolved!"
470
+ result
471
+ end
472
+ ```
473
+
474
+ ## Configuration {#configuration}
475
+
476
+ ```ruby
477
+ class PostResource < ApplicationResource
478
+ self.model = Post
479
+ self.type = 'posts'
480
+
481
+ # Only used if you care about Links
482
+ primary_endpoint '/posts', [:index, :show, :create, :update, :destroy]
483
+
484
+ self.default_sort = [{ title: :asc }] # default nil
485
+ self.default_page_size = 10 # default 20
486
+ end
487
+ ```
488
+
489
+ Typically inherited from `ApplicationResource`, where cross-cutting settings live:
490
+
491
+ ```ruby
492
+ class ApplicationResource < Graphiti::Resource
493
+ # Required when there's no corresponding model
494
+ self.abstract_class = true
495
+
496
+ # Subclasses override as needed
497
+ self.adapter = Graphiti::Adapters::ActiveRecord
498
+
499
+ # Default attribute flags. See #limiting-behavior
500
+ self.attributes_readable_by_default = true
501
+ self.attributes_writable_by_default = true
502
+ self.attributes_sortable_by_default = true
503
+ self.attributes_filterable_by_default = true
504
+
505
+ # Used for link generation
506
+ self.base_url = Rails.application.routes.default_url_options[:host]
507
+ # Suggest referencing this in config/routes.rb:
508
+ # scope path: ApplicationResource.endpoint_namespace do
509
+ # resources :posts
510
+ # end
511
+ self.endpoint_namespace = '/api/v1'
512
+
513
+ # Raise if a Resource is accessed from a URL it isn't allowlisted for
514
+ self.validate_endpoints = false
515
+
516
+ # Automatically generate JSONAPI links?
517
+ self.autolink = true
518
+ end
519
+ ```
520
+
521
+ ### Polymorphic Resources {#polymorphic-resources}
522
+
523
+ Polymorphic Resources are similar to [ActiveRecord STI](https://api.rubyonrails.org/classes/ActiveRecord/Inheritance.html): a single query returns multiple Resource types. Querying `/tasks` can return `bugs`, `features`, and `epics`.
524
+
525
+ ```ruby
526
+ class Employee < ApplicationRecord
527
+ has_many :tasks
528
+ end
529
+
530
+ # tasks table has a 'type' column
531
+ class Task < ApplicationRecord
532
+ belongs_to :employee
533
+ end
534
+
535
+ class Bug < Task
536
+ end
537
+
538
+ # ONLY Feature has #points
539
+ class Feature < Task
540
+ def points
541
+ 5
542
+ end
543
+ end
544
+
545
+ # ONLY Epic has the milestones relationship
546
+ class Epic < Task
547
+ has_many :milestones
548
+ end
549
+
550
+ class Milestone < ApplicationRecord
551
+ belongs_to :epic
552
+ end
553
+ ```
554
+
555
+ ```ruby
556
+ class TaskResource < ApplicationResource
557
+ # Reference child classes
558
+ self.polymorphic = [
559
+ 'BugResource',
560
+ 'FeatureResource',
561
+ 'EpicResource'
562
+ ]
563
+
564
+ attribute :title, :string
565
+ end
566
+
567
+ class BugResource < TaskResource
568
+ end
569
+
570
+ class FeatureResource < TaskResource
571
+ attribute :points, :integer
572
+ end
573
+
574
+ class EpicResource < TaskResource
575
+ has_many :milestones
576
+ end
577
+
578
+ class MilestoneResource < TaskResource
579
+ belongs_to :epic
580
+ end
581
+ ```
582
+
583
+ `/tasks` returns [JSONAPI types](http://jsonapi.org/format/#document-resource-identifier-objects) of `bugs`, `features`, and `epics`. Only `features` render `points`. Only `epics` render the `milestones` relationship. `/tasks?include=milestones` correctly only queries and renders Milestones for Epics.
584
+
585
+ Resources connect to each other through relationships. See [Relationships](/concepts/relationships).
586
+
587
+ ## Generators {#generators}
588
+
589
+ ```bash
590
+ $ rails generate graphiti:resource NAME [attribute:type] [options]
591
+ ```
592
+
593
+ ```bash
594
+ $ rails generate graphiti:resource Employee first_name:string age:integer
595
+ ```
596
+
597
+ Adds a route, controller, resource, and tests.
598
+
599
+ Limit the actions the resource supports with `-a`:
600
+
601
+ ```bash
602
+ $ rails generate graphiti:resource Employee -a index show
603
+ ```
604
+
605
+ Writing data (creating, updating, and destroying resources, including a graph of them in a single request) is covered in [Persisting](/concepts/persisting).
606
+
607
+ ## Context {#context}
608
+
609
+ ```ruby
610
+ # app/resources/post_resource.rb
611
+ attribute :active, :boolean, writable: :admin?
612
+
613
+ def admin?
614
+ context.current_user.admin?
615
+ end
616
+ ```
617
+
618
+ Every Resource has access to `#context`. Under Rails, `context` is the controller instance processing the request.
619
+
620
+ Put common helpers like `current_user` on `ApplicationResource`, so every Resource can call them:
621
+
622
+ ```ruby
623
+ # app/resources/application_resource.rb
624
+ class ApplicationResource < Graphiti::Resource
625
+ # ... code ...
626
+ def current_user
627
+ context.current_user
628
+ end
629
+ end
630
+
631
+ # app/resources/post_resource.rb
632
+ class PostResource < ApplicationResource
633
+ # ... code ...
634
+ def admin?
635
+ current_user.admin?
636
+ end
637
+ end
638
+ ```
639
+
640
+ Set context manually with `with_context`:
641
+
642
+ ```ruby
643
+ ctx = OpenStruct.new(current_user: User.first)
644
+ Graphiti.with_context(ctx) do
645
+ # current_user == ctx.current_user
646
+ PostResource.all
647
+ end
648
+ ```
649
+
650
+ ## Concurrency {#concurrency}
651
+
652
+ ```ruby
653
+ # config/initializers/graphiti.rb
654
+ Graphiti.configure do |c|
655
+ c.concurrency = false
656
+ end
657
+ ```
658
+
659
+ Under Rails, concurrency turns on by default when `::Rails.application.config.cache_classes` is `true` (the default for staging and production). Sibling sideloads then load concurrently, so a `Post` sideloading `Comments` and `Author` loads both at the same time.
660
+
661
+ Concurrency runs sideloads in new Threads, so thread locals are dropped. Use `Graphiti.context` instead of `Thread.current` for anything that needs to survive a sideload:
662
+
663
+ ```ruby
664
+ # BAD:
665
+ Thread.current[:foo] = "bar"
666
+ Thread.current[:foo] # => will be nil when sideloading!
667
+
668
+ # GOOD:
669
+ Graphiti.context[:foo] = "bar"
670
+ Graphiti.context[:foo] # => "bar", even when sideloading
671
+ ```
672
+
673
+ ## Adapters {#adapters}
674
+
675
+ Common resource overrides can be packaged into an Adapter for code re-use, most commonly to use a different client/datastore than ActiveRecord/RelationalDB.
676
+
677
+ [Adapters are best explained in the 'Without ActiveRecord' recipe](/topics/without-activerecord).