graphiti 2.0.0.beta.9 → 2.0.0.beta.11

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +2 -0
  3. data/.tool-versions +1 -1
  4. data/CHANGELOG.md +46 -0
  5. data/Rakefile +17 -12
  6. data/lib/generators/graphiti/generator_mixin.rb +24 -0
  7. data/lib/generators/graphiti/templates/application_resource.rb.erb +9 -16
  8. data/lib/graphiti/adapters/abstract.rb +4 -3
  9. data/lib/graphiti/configuration.rb +55 -24
  10. data/lib/graphiti/delegates/pagination.rb +2 -2
  11. data/lib/graphiti/errors.rb +35 -3
  12. data/lib/graphiti/query.rb +61 -13
  13. data/lib/graphiti/rails/controller.rb +26 -6
  14. data/lib/graphiti/rails/rake_helpers.rb +17 -0
  15. data/lib/graphiti/renderer.rb +4 -1
  16. data/lib/graphiti/request_validators/update_validator.rb +1 -1
  17. data/lib/graphiti/request_validators/validator.rb +9 -0
  18. data/lib/graphiti/resource/configuration.rb +139 -40
  19. data/lib/graphiti/resource/dsl.rb +21 -2
  20. data/lib/graphiti/resource/interface.rb +1 -1
  21. data/lib/graphiti/resource/links.rb +39 -5
  22. data/lib/graphiti/resource/remote.rb +2 -1
  23. data/lib/graphiti/resource/sideloading.rb +2 -2
  24. data/lib/graphiti/schema.rb +2 -2
  25. data/lib/graphiti/scope.rb +100 -26
  26. data/lib/graphiti/scoping/filter.rb +4 -4
  27. data/lib/graphiti/scoping/paginate.rb +3 -3
  28. data/lib/graphiti/serializer.rb +30 -8
  29. data/lib/graphiti/sideload/polymorphic_belongs_to.rb +9 -9
  30. data/lib/graphiti/sideload.rb +50 -33
  31. data/lib/graphiti/spec_helpers/matchers.rb +2 -2
  32. data/lib/graphiti/spec_helpers/rspec.rb +6 -3
  33. data/lib/graphiti/util/link.rb +1 -1
  34. data/lib/graphiti/util/serializer_attributes.rb +5 -3
  35. data/lib/graphiti/util/serializer_relationships.rb +16 -15
  36. data/lib/graphiti/version.rb +1 -1
  37. data/lib/graphiti.rb +5 -0
  38. data/lib/tasks/graphiti.rake +4 -0
  39. data/package.json +1 -1
  40. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a61d00c6968d2dc24afb8986d7b3b6051394ea73ac5d5a8104f1296f650fef47
4
- data.tar.gz: bf367c2e3ff33e4240a3f11e3fbae780c75b7f727863e61fc0c546ae9cdfb5fe
3
+ metadata.gz: a639e421891e5d5e650d2d8131a75905cb9ffd0fb40cdfd15239ca5caf29d12f
4
+ data.tar.gz: 95172bbff224d598c959d5acc93c30861e01c50540fdea871ea5029473bf5259
5
5
  SHA512:
6
- metadata.gz: 4bb22545b0a2b590fb07a237857407a5a50ac2423dd33a5621af164d93b4d36237f54339fb083895e6d349ae64da3cf9cc2e24c363f63478cea691622f130331
7
- data.tar.gz: 9da999b97fa15b04ca97420d6a6942c5d32b70bd0e8f27e33e01b8861cdd0206342d6afd11db15eb0c1d445beda4ce1f6a5ff5cceffcb54d5917e3a185cdc16a
6
+ metadata.gz: 24c275cfcaecdba9ced126b7970b37962998673a3a8609c885a69befc53adba646ec2c7d9517198509e358ebba5300f3b336c86f02df98af9d18a6f4a4c306c3
7
+ data.tar.gz: e7d2fd4e0d0ff74b68ea4d3673399d1b72897ad7c8647fa38835ff2b58c850d58ba95a1145a52f55893e88e36eb0aa90bece72a2e6e593173661d16100666edc
@@ -13,6 +13,8 @@ jobs:
13
13
  uses: actions/checkout@v5
14
14
  with:
15
15
  persist-credentials: false
16
+ # semantic-release reads the tag history to work out the next version
17
+ fetch-depth: 0
16
18
  - name: Set up Ruby
17
19
  uses: ruby/setup-ruby@v1
18
20
  with:
data/.tool-versions CHANGED
@@ -1 +1 @@
1
- ruby 3.3.4
1
+ ruby 4.0.6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  graphiti changelog
2
2
 
3
+ # [2.0.0-beta.11](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.10...v2.0.0-beta.11) (2026-08-27)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * always render included when the client asked to include ([8691b3f](https://github.com/graphiti-api/graphiti/commit/8691b3fd08f3cae0481ba72839911fdc71ef5634))
9
+ * autolink = true no longer clobbers an inherited :on_demand ([2d26f8c](https://github.com/graphiti-api/graphiti/commit/2d26f8cb567e82a16918345de9114b8ceef64523))
10
+ * read the links param from the query, not the context object ([3b934cb](https://github.com/graphiti-api/graphiti/commit/3b934cb25be049e1e80ba400123da944cfd23d04))
11
+ * stop assigning the same child twice when a resource repeats in the include path ([588503b](https://github.com/graphiti-api/graphiti/commit/588503b109508495d8170373d80019c6971f7d9c))
12
+
13
+
14
+ ### Features
15
+
16
+ * omit relationships that render no ids and no link ([916aa8c](https://github.com/graphiti-api/graphiti/commit/916aa8c9c596c5d9b6c802c5ab13b766a13d1e70))
17
+
18
+
19
+ ### Performance Improvements
20
+
21
+ * memoize whether a belongs_to renders ids from its foreign key ([dcb60b2](https://github.com/graphiti-api/graphiti/commit/dcb60b2096c414756ccff534773d2e45594f0144))
22
+ * only deduplicate resources that two include paths can both reach ([744f014](https://github.com/graphiti-api/graphiti/commit/744f014249382bc25d5ea43f692778ad7adc25c3))
23
+ * resolve a lone sideload inline rather than through the pool ([3415592](https://github.com/graphiti-api/graphiti/commit/3415592d75900389a8f11f53b65ab399e297e4a9))
24
+ * reuse the resolved data and proxies when building cache keys ([8a17350](https://github.com/graphiti-api/graphiti/commit/8a17350542ba0062fdcddd115afe8041b62198c7))
25
+ * skip the on-demand link strip when no relationship renders links on demand ([cf86a8c](https://github.com/graphiti-api/graphiti/commit/cf86a8c249077d9f9d5a13de49989d102bbaed04))
26
+
27
+ # [2.0.0-beta.10](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.9...v2.0.0-beta.10) (2026-08-24)
28
+
29
+
30
+ ### Bug Fixes
31
+
32
+ * apply sideloads at definition once setup! has run ([39ef393](https://github.com/graphiti-api/graphiti/commit/39ef39383520816319282854c0ae99677bcd5127)), closes [#524](https://github.com/graphiti-api/graphiti/issues/524)
33
+ * give client-caused errors real detail and stop 500ing on bad query params ([35acc3e](https://github.com/graphiti-api/graphiti/commit/35acc3e6c78f68eeeb05728ea16264b136455c30)), closes [#531](https://github.com/graphiti-api/graphiti/issues/531)
34
+ * let an abstract resource declare relationships ([6036392](https://github.com/graphiti-api/graphiti/commit/6036392ebd611c7ed492beb3fe5754433eac9ffd)), closes [#453](https://github.com/graphiti-api/graphiti/issues/453)
35
+ * reject a non-object data payload instead of raising TypeError ([51603b8](https://github.com/graphiti-api/graphiti/commit/51603b854270715b80a75d9aa05a4020e6bbdce5))
36
+ * Update some missing sections and terms after renames, register unsupported pagination error ([39cff19](https://github.com/graphiti-api/graphiti/commit/39cff19f6f1f73126514c8b18234a18ad5eb7379))
37
+
38
+
39
+ ### Features
40
+
41
+ * carry CurrentAttributes into concurrent sideloads ([7092096](https://github.com/graphiti-api/graphiti/commit/7092096f6d99ad277cc56817c616d946f655d55b))
42
+ * fold allow_nil and deny_empty into one blanks: filter option ([a108403](https://github.com/graphiti-api/graphiti/commit/a108403e5cd87fd2fdf177da30c4c4f97b9fe750))
43
+ * move link rendering onto the resource as page_links and relationship_links ([57878d1](https://github.com/graphiti-api/graphiti/commit/57878d14fdfc4741e8d418c8edbc4a8687335843))
44
+ * move typecast_reads onto the resource and let the railtie own Rails config ([236e970](https://github.com/graphiti-api/graphiti/commit/236e9706d7d8f08e9640c5c22ac596e2f7466849))
45
+ * one link mode per relationship, cascading from relationship_links ([f0e1da1](https://github.com/graphiti-api/graphiti/commit/f0e1da1e13a8d12b47458495b378779f5687ffe2))
46
+ * point connection pool timeouts at the sizing formula and add an audit task to check ([6e017c6](https://github.com/graphiti-api/graphiti/commit/6e017c60fd45338e4e6c8527adebfd46be4bfd8d))
47
+ * split validate_endpoints into validate_requests and validate_links ([100a370](https://github.com/graphiti-api/graphiti/commit/100a370deec6a0e6ae165bdf77e50b6b14d068d3))
48
+
3
49
  # [2.0.0-beta.9](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.8...v2.0.0-beta.9) (2026-08-22)
4
50
 
5
51
 
data/Rakefile CHANGED
@@ -11,31 +11,36 @@ RSpec::Core::RakeTask.new(:spec) do |t|
11
11
  end
12
12
  end
13
13
 
14
+ # Allocations mean the same anywhere and timings do not, so the history has a
15
+ # reference CPU, named on every row, and recording timings refuses to mix them.
14
16
  namespace :performance do
15
17
  # Shelling out keeps a failure to the script's own message, with no rake backtrace on top.
16
18
  def measure(*arguments)
17
19
  exit(1) unless system("bundle", "exec", "ruby", "spec/performance/measure_releases.rb", *arguments)
18
20
  end
19
21
 
20
- desc "Compare the working tree to the last release, and plot it"
21
- task :current do
22
- measure
22
+ desc "Read a change: measure the working tree, print the comparison and plot it"
23
+ task :read do
24
+ measure("--read")
23
25
  end
24
26
 
25
- desc "Plot the release history plus the working tree to tmp/performance.html and open it"
26
- task :page do
27
- exit(1) unless system("bundle", "exec", "ruby", "spec/performance/chart_page.rb")
27
+ desc "Open the recorded history as a chart. Any machine, measures nothing"
28
+ task :chart do
29
+ exit(1) unless system("bundle", "exec", "ruby", "spec/performance/chart_page.rb", "--no-current")
28
30
  end
29
31
 
30
- desc "Measure one release and record it (TAG=v2.0.0-beta.9)"
32
+ desc "Fill in the history. Missing releases by default, TAG=v2.0.0-beta.9 for one, ALL=1 for every. Reference CPU"
31
33
  task :record do
32
- tag = ENV["TAG"] or abort "pass the release to record, e.g. rake performance:record TAG=v2.0.0-beta.9"
33
- measure(tag)
34
+ measure(*(if ENV["TAG"]
35
+ [ENV["TAG"]]
36
+ else
37
+ ENV["ALL"] ? ["--all"] : ["--missing"]
38
+ end))
34
39
  end
35
40
 
36
- desc "Re-record every release for this ruby, replacing its rows"
37
- task :record_all do
38
- measure("--all")
41
+ desc "Measure the working tree for the release to name, and commit it. Reference CPU"
42
+ task :pending do
43
+ measure("--pending")
39
44
  end
40
45
  end
41
46
 
@@ -33,6 +33,30 @@ module Graphiti
33
33
  methods.any? { |m| actions.include?(m) }
34
34
  end
35
35
 
36
+ def resource_setting_groups
37
+ name_width = Graphiti::Resource::SETTINGS.keys.map(&:length).max
38
+ assignments = Graphiti::Resource::SETTINGS.to_h do |name, setting|
39
+ value = setting[:format] || setting[:default].inspect
40
+ [name, "self.#{name.to_s.ljust(name_width)} = #{value}"]
41
+ end
42
+ hint_column = assignments.values.map(&:length).max + 2
43
+
44
+ Graphiti::Resource::SETTING_GROUPS.transform_values do |settings|
45
+ settings.map do |name, setting|
46
+ hint = resource_setting_hint(setting)
47
+ hint ? "#{assignments[name].ljust(hint_column)}# #{hint}" : assignments[name]
48
+ end
49
+ end
50
+ end
51
+
52
+ def resource_setting_hint(setting)
53
+ return setting[:note] if setting[:note]
54
+ return unless setting[:values]
55
+
56
+ setting[:values].map(&:inspect)
57
+ .to_sentence(two_words_connector: " or ", last_word_connector: ", or ")
58
+ end
59
+
36
60
  def graphiti_config
37
61
  File.exist?(".graphiticfg.yml") ? YAML.load_file(".graphiticfg.yml") : {}
38
62
  end
@@ -14,21 +14,14 @@ class ApplicationResource < Graphiti::Resource
14
14
  self.endpoint_namespace = '<%= api_namespace %>'
15
15
  <%- unless omit_comments? -%>
16
16
 
17
- # Defaults every Resource inherits. These are already the values shown;
18
- # uncomment to change them API-wide, or set them on individual Resources.
19
- # See https://graphiti.dev/concepts/resources
20
- #
21
- # self.attributes_readable_by_default = true
22
- # self.attributes_writable_by_default = true
23
- # self.attributes_sortable_by_default = true
24
- # self.attributes_filterable_by_default = true
25
- # self.attributes_schema_by_default = true
26
- # self.relationships_readable_by_default = true
27
- # self.relationships_writable_by_default = true
28
- # self.belongs_to_resource_ids_by_default = :foreign_key
29
- # self.filters_accept_nil_by_default = false
30
- # self.filters_deny_empty_by_default = false
31
- # self.default_page_size = 20
32
- # self.max_page_size = 1_000
17
+ # Defaults every Resource inherits. Uncomment to change them API-wide, or set
18
+ # them on individual Resources. See https://graphiti.dev/concepts/resources
19
+ <%- resource_setting_groups.each_pair do |group, lines| -%>
20
+
21
+ # <%= group.to_s.humanize %>
22
+ <%- lines.each do |line| -%>
23
+ # <%= line %>
24
+ <%- end -%>
25
+ <%- end -%>
33
26
  <%- end -%>
34
27
  end
@@ -371,10 +371,11 @@ module Graphiti
371
371
  activerecord_adapter.associate \
372
372
  parent, child, association_name, association_type
373
373
  elsif [:has_many, :many_to_many].include?(association_type)
374
- if parent.send(:"#{association_name}").nil?
374
+ associated = parent.send(:"#{association_name}")
375
+ if associated.nil?
375
376
  parent.send(:"#{association_name}=", [child])
376
- else
377
- parent.send(:"#{association_name}") << child
377
+ elsif associated.none? { |existing| existing.equal?(child) }
378
+ associated << child
378
379
  end
379
380
  else
380
381
  parent.send(:"#{association_name}=", child)
@@ -24,10 +24,6 @@ module Graphiti
24
24
 
25
25
  attr_accessor :respond_to
26
26
  attr_accessor :context_for_endpoint
27
- attr_accessor :links_on_demand
28
- attr_accessor :pagination_links_on_demand
29
- attr_accessor :pagination_links
30
- attr_accessor :typecast_reads
31
27
  attr_accessor :raise_on_missing_sidepost
32
28
  attr_accessor :before_sideload
33
29
 
@@ -44,32 +40,12 @@ module Graphiti
44
40
  @concurrency = false
45
41
  @concurrency_max_threads = 4
46
42
  @respond_to = [:json, :jsonapi, :xml]
47
- @links_on_demand = false
48
- @pagination_links_on_demand = false
49
- @pagination_links = false
50
- @typecast_reads = true
51
43
  @raise_on_missing_sidepost = true
52
44
  @cache_rendering = false
53
45
  self.debug = ENV.fetch("GRAPHITI_DEBUG", true)
54
46
  self.debug_models = ENV.fetch("GRAPHITI_DEBUG_MODELS", false)
55
47
 
56
48
  @uri_decoder = infer_uri_decoder
57
-
58
- # FIXME: Don't duplicate the Railtie's initializer
59
- if defined?(::Rails.root) && (root = ::Rails.root)
60
- config_file = root.join(".graphiticfg.yml")
61
- if config_file.exist?
62
- cfg = YAML.load_file(config_file)
63
- @schema_path = root.join("public#{cfg["namespace"]}/schema.json")
64
- else
65
- @schema_path = root.join("public/schema.json")
66
- end
67
-
68
- if (logger = ::Rails.logger)
69
- self.debug = logger.debug? && debug
70
- Graphiti.logger = logger
71
- end
72
- end
73
49
  end
74
50
 
75
51
  def cache_rendering?
@@ -111,6 +87,48 @@ module Graphiti
111
87
  send(:"#{key}=", original)
112
88
  end
113
89
 
90
+ def typecast_reads
91
+ Resource.typecast_reads
92
+ end
93
+
94
+ def typecast_reads=(val)
95
+ Resource.typecast_reads = val
96
+ end
97
+
98
+ def links_on_demand
99
+ Resource.relationship_links == :on_demand
100
+ end
101
+
102
+ def links_on_demand=(val)
103
+ if val
104
+ Resource.relationship_links = :on_demand
105
+ elsif Resource.relationship_links == :on_demand
106
+ Resource.relationship_links = true
107
+ end
108
+ end
109
+
110
+ def pagination_links
111
+ Resource.page_links == true
112
+ end
113
+
114
+ def pagination_links=(val)
115
+ return if Resource.page_links == :on_demand
116
+
117
+ Resource.page_links = !!val
118
+ end
119
+
120
+ def pagination_links_on_demand
121
+ Resource.page_links == :on_demand
122
+ end
123
+
124
+ def pagination_links_on_demand=(val)
125
+ if val
126
+ Resource.page_links = :on_demand
127
+ elsif Resource.page_links == :on_demand
128
+ Resource.page_links = false
129
+ end
130
+ end
131
+
114
132
  def uri_decoder=(decoder)
115
133
  unless decoder.respond_to?(:call)
116
134
  raise "uri_decoder must respond to `call`."
@@ -141,4 +159,17 @@ module Graphiti
141
159
 
142
160
  msg = "Use `config.graphiti.respond_to_formats`"
143
161
  DEPRECATOR.deprecate_methods(Configuration, respond_to: msg, "respond_to=": msg)
162
+
163
+ relationship_msg = "Set `self.relationship_links` (true, false, or :on_demand) on your resource"
164
+ pagination_msg = "Set `self.page_links` (true, false, or :on_demand) on your resource"
165
+ typecast_msg = "Set `self.typecast_reads` on your resource"
166
+ DEPRECATOR.deprecate_methods(Configuration,
167
+ typecast_reads: typecast_msg,
168
+ "typecast_reads=": typecast_msg,
169
+ links_on_demand: relationship_msg,
170
+ "links_on_demand=": relationship_msg,
171
+ pagination_links: pagination_msg,
172
+ "pagination_links=": pagination_msg,
173
+ pagination_links_on_demand: pagination_msg,
174
+ "pagination_links_on_demand=": pagination_msg)
144
175
  end
@@ -6,7 +6,7 @@ module Graphiti
6
6
  end
7
7
 
8
8
  def links?
9
- @proxy.query.pagination_links? && @proxy.data.present?
9
+ @proxy.query.page_links? && @proxy.data.present?
10
10
  end
11
11
 
12
12
  def links
@@ -104,7 +104,7 @@ module Graphiti
104
104
 
105
105
  def page_size
106
106
  @page_size ||= (page_param[:size] ||
107
- @proxy.resource.default_page_size ||
107
+ @proxy.resource.page_default_size ||
108
108
  Graphiti::Scoping::Paginate::DEFAULT_PAGE_SIZE).to_i
109
109
  end
110
110
 
@@ -221,7 +221,7 @@ module Graphiti
221
221
 
222
222
  Make sure the endpoint "#{@sideload.resource.endpoint[:full_path]}" exists with action #{@action.inspect}, or customize the endpoint for #{@sideload.resource.class.name}.
223
223
 
224
- If you do not wish to generate a link, pass link: false or set self.autolink = false.
224
+ If you do not wish to generate a link, pass link: false or set self.relationship_links = false. To keep the link and stop checking it, set self.validate_links = false.
225
225
  MSG
226
226
  end
227
227
  end
@@ -255,6 +255,8 @@ module Graphiti
255
255
  Graphiti.config.context_for_endpoint must be set to enable link generation:
256
256
 
257
257
  Graphiti.config.context_for_endpoint = ->(path, action) { ... }
258
+
259
+ Or set self.validate_links = false to render links without checking them.
258
260
  MSG
259
261
  end
260
262
  end
@@ -445,9 +447,9 @@ module Graphiti
445
447
 
446
448
  secondary_endpoint '/my_url', [:index, :update]
447
449
 
448
- Or disable endpoint validation for this resource:
450
+ Or disable request validation for this resource:
449
451
 
450
- self.validate_endpoints = false
452
+ self.validate_requests = false
451
453
 
452
454
  See https://graphiti.dev/concepts/links for more information.
453
455
 
@@ -708,6 +710,36 @@ module Graphiti
708
710
  end
709
711
  end
710
712
 
713
+ class InvalidFilterBlanks < Base
714
+ def initialize(resource_class, attribute, value)
715
+ @resource_class = resource_class
716
+ @attribute = attribute
717
+ @value = value
718
+ end
719
+
720
+ def message
721
+ <<~MSG
722
+ #{@resource_class.name}: #{@attribute} must be one of :literal, :null, or :rejected. Got #{@value.inspect}.
723
+
724
+ :literal - "null" and "" reach the filter as strings (default)
725
+ :null - "null" becomes nil, so the filter can query for NULL
726
+ :rejected - a blank value raises InvalidFilterValue
727
+ MSG
728
+ end
729
+ end
730
+
731
+ class InvalidLinkRendering < Base
732
+ def initialize(resource_class, attribute, value)
733
+ @resource_class = resource_class
734
+ @attribute = attribute
735
+ @value = value
736
+ end
737
+
738
+ def message
739
+ "#{@resource_class.name}: #{@attribute} must be true, false, or :on_demand. Got #{@value.inspect}."
740
+ end
741
+ end
742
+
711
743
  class UnselectedForeignKey < Base
712
744
  def initialize(resource_class, sideload, model)
713
745
  @resource_class = resource_class
@@ -2,6 +2,9 @@ require "digest"
2
2
 
3
3
  module Graphiti
4
4
  class Query
5
+ TRUTHY_LINKS = [true, "true"].freeze
6
+ LINKLESS_FORMATS = [:json, :xml, "json", "xml"].freeze
7
+
5
8
  attr_reader :resource, :association_name, :params, :action
6
9
 
7
10
  def initialize(resource, params, *positional, association_name: nil, nested_include: nil, parents: [], action: nil)
@@ -36,29 +39,70 @@ module Graphiti
36
39
  @entity_map
37
40
  end
38
41
 
42
+ # Deduplication exists for a row two include paths both resolve, so a
43
+ # resource class sitting at one node has nothing to collide with.
44
+ def repeated_resource_classes
45
+ return root.repeated_resource_classes unless root == self
46
+
47
+ @repeated_resource_classes ||= begin
48
+ counts = Hash.new(0)
49
+ walk = [self]
50
+ while (query = walk.pop)
51
+ counts[query.resource.class] += 1
52
+ query.sideloads.each_pair do |name, sideload_query|
53
+ walk << sideload_query
54
+ sideload = query.resource.class.sideload(name)
55
+ next unless sideload.respond_to?(:children)
56
+
57
+ # A polymorphic child resolves under a class no node names.
58
+ sideload.children.each_value { |child| counts[child.resource.class] += 2 }
59
+ end
60
+ end
61
+ counts.select { |_, count| count > 1 }.keys.to_set
62
+ end
63
+ end
64
+
39
65
  def top_level?
40
66
  !association?
41
67
  end
42
68
 
43
- def links?
44
- return false if [:json, :xml, "json", "xml"].include?(params[:format])
45
- if Graphiti.config.links_on_demand
46
- [true, "true"].include?(@params[:links])
47
- else
48
- true
49
- end
69
+ def includes_requested?
70
+ @include_param.present?
71
+ end
72
+
73
+ def links_requested?
74
+ return @links_requested if defined?(@links_requested)
75
+
76
+ @links_requested = TRUTHY_LINKS.include?(@params[:links])
77
+ end
78
+
79
+ def suppress_links?
80
+ return @suppress_links if defined?(@suppress_links)
81
+
82
+ @suppress_links = LINKLESS_FORMATS.include?(params[:format])
50
83
  end
51
84
 
52
- def pagination_links?
85
+ def render_link?(mode)
86
+ return false if suppress_links?
87
+
88
+ (mode == :on_demand) ? links_requested? : mode == true
89
+ end
90
+
91
+ def page_links?
53
92
  if action == :find
54
93
  false
55
- elsif Graphiti.config.pagination_links_on_demand
56
- [true, "true"].include?(@params[:pagination_links])
94
+ elsif @resource.page_links == :on_demand
95
+ page_links_requested?
57
96
  else
58
- Graphiti.config.pagination_links
97
+ @resource.page_links
59
98
  end
60
99
  end
61
100
 
101
+ def page_links_requested?
102
+ [@params[:page_links], @params[:pagination_links]]
103
+ .any? { |value| [true, "true"].include?(value) }
104
+ end
105
+
62
106
  def debug_requested?
63
107
  !!@params[:debug]
64
108
  end
@@ -267,8 +311,8 @@ module Graphiti
267
311
  def query_cache_key
268
312
  attrs = {extra_fields: extra_fields,
269
313
  fields: fields,
270
- links: links?,
271
- pagination_links: pagination_links?,
314
+ links: suppress_links? ? :none : links_requested?,
315
+ page_links: page_links?,
272
316
  format: params[:format]}
273
317
 
274
318
  Digest::SHA1.hexdigest(attrs.to_s)
@@ -387,5 +431,9 @@ module Graphiti
387
431
  action
388
432
  end
389
433
  end
434
+
435
+ alias_method :pagination_links?, :page_links?
436
+ DEPRECATOR.deprecate_methods(self,
437
+ pagination_links?: "Use `page_links?`")
390
438
  end
391
439
  end
@@ -30,14 +30,34 @@ module Graphiti
30
30
 
31
31
  register_exception Graphiti::Errors::InvalidRequest,
32
32
  status: 400, handler: Graphiti::Rails::InvalidRequestHandler
33
- register_exception Graphiti::Errors::ConflictRequest,
34
- status: 409, handler: Graphiti::Rails::ConflictRequestHandler
35
- register_exception Graphiti::Errors::RecordNotFound,
36
- status: 404, handler: Graphiti::Rails::ExceptionHandler
37
33
  register_exception Graphiti::Errors::RemoteWrite,
38
- status: 400, handler: Graphiti::Rails::ExceptionHandler
34
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
39
35
  register_exception Graphiti::Errors::SingularSideload,
40
- status: 400, handler: Graphiti::Rails::ExceptionHandler
36
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
37
+ register_exception Graphiti::Errors::InvalidInclude,
38
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
39
+ register_exception Graphiti::Errors::UnsupportedSort,
40
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
41
+ register_exception Graphiti::Errors::UnsupportedOperator,
42
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
43
+ register_exception Graphiti::Errors::InvalidFilterValue,
44
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
45
+ register_exception Graphiti::Errors::RequiredFilter,
46
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
47
+ register_exception Graphiti::Errors::UnsupportedPageSize,
48
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
49
+ register_exception Graphiti::Errors::UnsupportedBeforeCursor,
50
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
51
+ register_exception Graphiti::Errors::UnsupportedPagination,
52
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
53
+ register_exception Graphiti::Errors::FilterGroupMissingRequiredFilters,
54
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
55
+
56
+ register_exception Graphiti::Errors::RecordNotFound,
57
+ status: 404, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
58
+
59
+ register_exception Graphiti::Errors::ConflictRequest,
60
+ status: 409, handler: Graphiti::Rails::ConflictRequestHandler
41
61
  end
42
62
  end
43
63
  end
@@ -21,6 +21,23 @@ module Graphiti
21
21
  ::Rails.application.config.action_controller.perform_caching = false
22
22
  end
23
23
 
24
+ # The pool and RAILS_MAX_THREADS are read from this environment, and production may size both differently.
25
+ def connection_pool_advisory
26
+ return unless defined?(ActiveRecord::Base)
27
+
28
+ pool = ActiveRecord::Base.connection_db_config.pool
29
+ threads = ENV.fetch("RAILS_MAX_THREADS", 5).to_i
30
+ sideload_threads = Graphiti.config.concurrency_max_threads
31
+ needed = threads + sideload_threads + 1
32
+ return if pool.nil? || pool >= needed
33
+
34
+ "WARNING database.yml pool is #{pool} in this environment. With concurrency on (the production default), " \
35
+ "each sideload holds its own connection, so pool should be at least " \
36
+ "web threads (#{threads}) + concurrency_max_threads (#{sideload_threads}) + 1 = #{needed}. " \
37
+ "If production sizes these differently, check the numbers there. " \
38
+ "See graphiti.dev/concepts/resources#concurrency-pool-sizing."
39
+ end
40
+
24
41
  def make_request(path, debug = false)
25
42
  if path.split("/").length == 2
26
43
  path = "#{ApplicationResource.endpoint_namespace}#{path}"
@@ -13,8 +13,11 @@ module Graphiti
13
13
  @records ||= @proxy.data
14
14
  end
15
15
 
16
+ # JSON:API requires the key even when the include turns up nothing.
16
17
  def to_jsonapi
17
- render(self.class.jsonapi_renderer).to_json
18
+ rendered = render(self.class.jsonapi_renderer)
19
+ rendered[:included] ||= [] if proxy.query.includes_requested?
20
+ rendered.to_json
18
21
  end
19
22
 
20
23
  def as_graphql
@@ -2,7 +2,7 @@ module Graphiti
2
2
  module RequestValidators
3
3
  class UpdateValidator < Validator
4
4
  def validate
5
- if required_payload? && payload_matches_endpoint?
5
+ if validate_data_shape && required_payload? && payload_matches_endpoint?
6
6
  super
7
7
  else
8
8
  false
@@ -17,6 +17,7 @@ module Graphiti
17
17
  # In the future these should have their own validation logic, but
18
18
  # for now we can just bypass
19
19
  return errors.blank? unless @params.has_key?(:data)
20
+ return false unless validate_data_shape
20
21
 
21
22
  resource = @root_resource
22
23
  if @params[:data].has_key?(:type)
@@ -56,6 +57,14 @@ module Graphiti
56
57
  @errors.add(:page, :invalid, message: "must be an object")
57
58
  end
58
59
 
60
+ def validate_data_shape
61
+ data = @params[:data]
62
+ return true if data.nil? || data.respond_to?(:each_pair)
63
+
64
+ @errors.add(:data, :invalid, message: "must be an object")
65
+ false
66
+ end
67
+
59
68
  def process_relationships(resource, relationships, payload_path)
60
69
  relationships.each_key do |name|
61
70
  unless resource.class.sideload(name.to_sym)