activeadmin-graphql 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15355b8548053d83cb751fc238ff2f9d4c910be902cf3aab25c2c91741a33fa9
4
- data.tar.gz: 8d76e29dcfe43079b171d19b817d52f13327c286a234688dd32c7e04efcc1522
3
+ metadata.gz: '00998f8445b9a5f9a7d4a939029d6e6460527432d658711b713df1ad293abc9e'
4
+ data.tar.gz: fd5f948edd2277b99e68ee384ccf7d31c8ffa8c890bf2f277837106213498652
5
5
  SHA512:
6
- metadata.gz: a8a5472cfd32ac45a662733ee46fe1b0f918ea96e71db5bdf25ff9191a3c808c7b9ec047b4e4e2e0b52269a2a3aba20ff7d5cce39607bdf28448f85139beff39
7
- data.tar.gz: 873a5814296d27af34ac8a6742ada804d61e4f855c33d0b5a092793ec7260625c68c54d8831977b88e59a7d7299a5cb22f77b502ab81b504e2d523b01e505cae
6
+ metadata.gz: fb9a627765be0dfb70171b6c2b9be70828e35a86fb0be2c79df237b0dbadc0559376e176b04ab6a2de49d22404c9773a4f9bee1427d1007b139dc71114afb991
7
+ data.tar.gz: 7c2d075e817702fd05648cf0f6dee228938d7d09400be066f99d4d50b23eb4d301f8ee0ac1632974ea57317f8a2ee7bcee81f5282481e9c477c9e8fd312e5db1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.2.1 (2026-07-14)
6
+
7
+ - Add RBS type signatures to the published gem for Steep and other Ruby type checkers
8
+ - Add Ruby 4.0 to the supported compatibility matrix
9
+ - Return HTTP 400 with a clear error when the GraphQL request body or variables contain invalid JSON
10
+ - Return `null` for member queries when the record id does not exist
11
+ - Enforce read authorization on `belongs_to` association fields so denied parent records resolve as `null`
12
+ - Build GraphQL schemas safely when `schema_for` runs concurrently
13
+
3
14
  ## 0.2.0 (2026-04-29)
4
15
 
5
16
  - Add `activeadmin_policies` GraphQL policy surfaces:
@@ -36,7 +36,8 @@ Gem::Specification.new do |spec|
36
36
  ].select { |f| File.file?(f) } +
37
37
  Dir["docs/**/*.md"] +
38
38
  Dir["app/**/*.rb"] +
39
- Dir["lib/**/*.rb"]
39
+ Dir["lib/**/*.rb"] +
40
+ Dir["sig/**/*"]
40
41
  end
41
42
 
42
43
  spec.require_paths = ["lib"]
@@ -51,11 +52,12 @@ Gem::Specification.new do |spec|
51
52
  spec.add_development_dependency "parallel_tests", "~> 4.7"
52
53
  spec.add_development_dependency "rails", ">= 6.1"
53
54
  spec.add_development_dependency "rspec", "~> 3"
55
+ spec.add_development_dependency "polyrun", ">= 2.2.0"
56
+ spec.add_development_dependency "prosopite", "~> 2.0"
54
57
  spec.add_development_dependency "rspec-rails", ">= 6"
55
58
  spec.add_development_dependency "rubocop-rails", "~> 2.34"
56
59
  spec.add_development_dependency "rubocop-rspec", "~> 3.8"
57
60
  spec.add_development_dependency "rubocop-thread_safety", "~> 0.7"
58
- spec.add_development_dependency "simplecov", "~> 0.22"
59
61
  spec.add_development_dependency "sprockets-rails", ">= 3.4"
60
62
  spec.add_development_dependency "sqlite3", ">= 1"
61
63
  spec.add_development_dependency "standard", "~> 1.52"
@@ -63,4 +65,5 @@ Gem::Specification.new do |spec|
63
65
  spec.add_development_dependency "standard-performance", "~> 1.8"
64
66
  spec.add_development_dependency "standard-rails", "~> 1.5"
65
67
  spec.add_development_dependency "standard-rspec", "~> 0.3"
68
+ spec.add_development_dependency "rbs", "~> 3"
66
69
  end
@@ -9,6 +9,10 @@ module ActiveAdmin
9
9
  class GraphqlController < ApplicationController
10
10
  protect_from_forgery with: :exception
11
11
 
12
+ rescue_from ActionDispatch::Http::Parameters::ParseError do
13
+ render json: {errors: [{message: "Invalid JSON"}]}, status: :bad_request
14
+ end
15
+
12
16
  before_action :ensure_graphql_enabled!
13
17
  before_action :authenticate_graphql!
14
18
 
@@ -104,6 +108,8 @@ module ActiveAdmin
104
108
 
105
109
  def request_body_hash
106
110
  json = request_body_json
111
+ return nil if json.nil? || json == :invalid_json
112
+
107
113
  json if json.is_a?(Hash)
108
114
  end
109
115
 
@@ -119,13 +125,21 @@ module ActiveAdmin
119
125
  body.present? ? JSON.parse(body) : nil
120
126
  end
121
127
  rescue JSON::ParserError
122
- @request_body_json = nil
128
+ @request_body_json = :invalid_json
129
+ end
130
+
131
+ def invalid_request_json?
132
+ request_body_json == :invalid_json
123
133
  end
124
134
 
125
135
  def ensure_variables(raw)
126
136
  case raw
127
137
  when String
128
- raw.present? ? JSON.parse(raw) : {}
138
+ if raw.blank?
139
+ {}
140
+ else
141
+ JSON.parse(raw)
142
+ end
129
143
  when Hash
130
144
  raw
131
145
  when ActionController::Parameters
@@ -136,22 +150,35 @@ module ActiveAdmin
136
150
  {}
137
151
  end
138
152
  rescue JSON::ParserError
139
- {}
153
+ :invalid_json
154
+ end
155
+
156
+ def render_invalid_json!
157
+ render json: {errors: [{message: "Invalid JSON"}]}, status: :bad_request
140
158
  end
141
159
 
142
160
  def render_multiplex(schema)
161
+ return render_invalid_json! if invalid_request_json?
162
+
143
163
  operations = multiplex_operations
144
164
  return render_multiplex_limit_error!(operations.size) if exceeds_multiplex_limit?(operations)
145
165
 
146
166
  payloads = build_multiplex_payloads(operations)
167
+ return if performed?
168
+
147
169
  results = schema.multiplex(payloads)
148
170
  render json: results.map(&:to_h), status: :ok
149
171
  end
150
172
 
151
173
  def render_single(schema)
174
+ return render_invalid_json! if invalid_request_json?
175
+
176
+ variables = ensure_variables(variables_hash)
177
+ return render_invalid_json! if variables == :invalid_json
178
+
152
179
  result = schema.execute(
153
180
  query: query_string,
154
- variables: ensure_variables(variables_hash),
181
+ variables: variables,
155
182
  operation_name: operation_name,
156
183
  context: graphql_context
157
184
  )
@@ -174,11 +201,17 @@ module ActiveAdmin
174
201
 
175
202
  def build_multiplex_payloads(operations)
176
203
  context = graphql_context
177
- operations.map do |op|
204
+ operations.map do |operation|
205
+ variables = ensure_variables(operation[:variables])
206
+ if variables == :invalid_json
207
+ render_invalid_json!
208
+ return []
209
+ end
210
+
178
211
  {
179
- query: op[:query],
180
- variables: ensure_variables(op[:variables]),
181
- operation_name: op[:operation_name],
212
+ query: operation[:query],
213
+ variables: variables,
214
+ operation_name: operation[:operation_name],
182
215
  context: context
183
216
  }
184
217
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module GraphQL
5
+ # Request-scoped memoization for ActiveAdmin policy sets on GraphQL objects.
6
+ class PolicySetCache
7
+ class << self
8
+ def fetch(context, subject_owner:, subject:)
9
+ namespace = context[:namespace]
10
+ unless namespace
11
+ return policy_builder(context).send(
12
+ :build_policy_set,
13
+ auth: context[:auth],
14
+ subject_owner: subject_owner,
15
+ subject: subject,
16
+ context: context
17
+ )
18
+ end
19
+
20
+ cache = context[:policy_set_cache] ||= {}
21
+ key = cache_key(subject_owner, subject)
22
+ cache[key] ||= policy_builder(context).send(
23
+ :build_policy_set,
24
+ auth: context[:auth],
25
+ subject_owner: subject_owner,
26
+ subject: subject,
27
+ context: context
28
+ )
29
+ end
30
+
31
+ def policy_builder(context)
32
+ context[:policy_schema_builder] ||= SchemaBuilder.new(context[:namespace])
33
+ end
34
+
35
+ def cache_key(subject_owner, subject)
36
+ owner_key = subject_owner_key(subject_owner)
37
+ "#{owner_key}:#{subject_key(subject)}"
38
+ end
39
+
40
+ private
41
+
42
+ def subject_owner_key(subject_owner)
43
+ if subject_owner.is_a?(ActiveAdmin::Page)
44
+ "page-owner:#{subject_owner.name}"
45
+ else
46
+ "resource-owner:#{subject_owner.resource_class.name}"
47
+ end
48
+ end
49
+
50
+ def subject_key(subject)
51
+ if subject.is_a?(Class)
52
+ "class:#{subject.name}"
53
+ elsif subject.is_a?(ActiveAdmin::Page)
54
+ "page:#{subject.name}"
55
+ elsif subject.is_a?(ActiveRecord::Base)
56
+ "record:#{subject.class.name}:#{PrimaryKey.graphql_id_value(subject)}"
57
+ else
58
+ "object:#{subject.object_id}"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -27,6 +27,26 @@ module ActiveAdmin
27
27
  def find_member(id)
28
28
  extra = member_route_params_for_find(id)
29
29
  controller_for("show", extra).send(:find_resource)
30
+ rescue ActiveRecord::RecordNotFound
31
+ nil
32
+ end
33
+
34
+ def find_members(ids)
35
+ string_ids = Array(ids).map(&:to_s).uniq
36
+ return {} if string_ids.empty?
37
+
38
+ model = @aa_resource.resource_class
39
+ if ActiveAdmin::PrimaryKey.composite?(model)
40
+ return string_ids.index_with { |identifier| find_member(identifier) }
41
+ end
42
+
43
+ controller = controller_for("index")
44
+ relation = controller.send(:apply_authorization_scope, controller.send(:scoped_collection))
45
+ primary_key = model.primary_key.to_sym
46
+ indexed = relation.where(primary_key => string_ids).index_by do |record|
47
+ record.public_send(primary_key).to_s
48
+ end
49
+ string_ids.index_with { |identifier| indexed[identifier] }
30
50
  end
31
51
 
32
52
  def build_new(attributes)
@@ -21,7 +21,8 @@ module ActiveAdmin
21
21
  field :body, ::GraphQL::Types::String, null: true,
22
22
  description: "Response body text when the action rendered (e.g. JSON)."
23
23
 
24
- Result = Struct.new(:ok, :status, :location, :body, keyword_init: true)
24
+ # keyword_init required for Ruby 3.2 consumers; cop targets 3.4 default Struct behavior
25
+ Result = Struct.new(:ok, :status, :location, :body, keyword_init: true) # rubocop:disable Style/RedundantStructKeywordInit
25
26
  end
26
27
  end
27
28
  end
@@ -48,17 +48,24 @@ module ActiveAdmin
48
48
  end
49
49
 
50
50
  define_method(:activeadmin_policies) do
51
- auth = context[:auth]
52
51
  resources = builder.send(:active_resources).map do |aa_res|
53
52
  {
54
53
  type_name: builder.send(:graphql_type_name_for, aa_res),
55
- activeadmin_policies: builder.send(:build_policy_set, auth: auth, subject_owner: aa_res, subject: aa_res.resource_class, context: context)
54
+ activeadmin_policies: ActiveAdmin::GraphQL::PolicySetCache.fetch(
55
+ context,
56
+ subject_owner: aa_res,
57
+ subject: aa_res.resource_class
58
+ )
56
59
  }
57
60
  end
58
61
  pages = ns.resources.select { |r| r.is_a?(ActiveAdmin::Page) }.map do |page|
59
62
  {
60
63
  name: page.name,
61
- activeadmin_policies: builder.send(:build_policy_set, auth: auth, subject_owner: page, subject: page, context: context)
64
+ activeadmin_policies: ActiveAdmin::GraphQL::PolicySetCache.fetch(
65
+ context,
66
+ subject_owner: page,
67
+ subject: page
68
+ )
62
69
  }
63
70
  end
64
71
  {resources: resources, pages: pages}
@@ -75,13 +82,18 @@ module ActiveAdmin
75
82
  graph_params: KeyValuePairs.to_hash(path)
76
83
  )
77
84
 
85
+ records_by_id = proxy.find_members(ids.map(&:to_s))
78
86
  ids.map do |id|
79
- record = proxy.find_member(id.to_s)
87
+ record = records_by_id[id.to_s]
80
88
  raise ::GraphQL::ExecutionError, "not found" unless record
81
89
 
82
90
  {
83
91
  id: id.to_s,
84
- activeadmin_policies: builder.send(:build_policy_set, auth: context[:auth], subject_owner: aa_res, subject: record, context: context)
92
+ activeadmin_policies: ActiveAdmin::GraphQL::PolicySetCache.fetch(
93
+ context,
94
+ subject_owner: aa_res,
95
+ subject: record
96
+ )
85
97
  }
86
98
  end
87
99
  end
@@ -101,8 +101,11 @@ module ActiveAdmin
101
101
 
102
102
  type_class.field :activeadmin_policies, policy_set_type, null: false, camelize: false, authorize: false
103
103
  type_class.define_method(:activeadmin_policies) do
104
- builder = context[:namespace] && ActiveAdmin::GraphQL::SchemaBuilder.new(context[:namespace])
105
- builder.send(:build_policy_set, auth: context[:auth], subject_owner: aa_res, subject: object, context: context)
104
+ ActiveAdmin::GraphQL::PolicySetCache.fetch(
105
+ context,
106
+ subject_owner: aa_res,
107
+ subject: object
108
+ )
106
109
  end
107
110
 
108
111
  if (ext = aa_res.graphql_config.extension_block)
@@ -14,6 +14,7 @@ module ActiveAdmin
14
14
  model.reflect_on_all_associations(:belongs_to).each do |ref|
15
15
  next if ref.polymorphic?
16
16
  target = ref.klass
17
+ target_aa_res = @aa_by_model[target]
17
18
  next unless @object_types[target]
18
19
 
19
20
  field_name = ref.name.to_sym
@@ -25,7 +26,16 @@ module ActiveAdmin
25
26
  fk_val = object.public_send(fk)
26
27
  next nil if fk_val.nil?
27
28
 
28
- dataloader.with(ActiveAdmin::GraphQL::RecordSource, target).load(fk_val)
29
+ record = dataloader.with(ActiveAdmin::GraphQL::RecordSource, target).load(fk_val)
30
+ next nil unless record
31
+
32
+ auth = context[:auth]
33
+ if target_aa_res && auth &&
34
+ !auth.authorized?(target_aa_res, ActiveAdmin::Authorization::READ, record)
35
+ next nil
36
+ end
37
+
38
+ record
29
39
  end
30
40
  end
31
41
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveAdmin
4
4
  module GraphQL
5
- VERSION = "0.2.0"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end
@@ -14,6 +14,7 @@ module ActiveAdmin
14
14
  #
15
15
  module GraphQL
16
16
  SCHEMA_CACHE = {}
17
+ SCHEMA_CACHE_MUTEX = Mutex.new
17
18
  end
18
19
  end
19
20
 
@@ -41,7 +42,12 @@ module ActiveAdmin
41
42
  # when admin registrations change without a full unload.
42
43
  def schema_for(namespace)
43
44
  cache_key = namespace.name
44
- SCHEMA_CACHE[cache_key] ||= SchemaBuilder.new(namespace).build
45
+ cached = SCHEMA_CACHE[cache_key]
46
+ return cached if cached
47
+
48
+ SCHEMA_CACHE_MUTEX.synchronize do
49
+ SCHEMA_CACHE[cache_key] ||= SchemaBuilder.new(namespace).build
50
+ end
45
51
  end
46
52
 
47
53
  def clear_schema_cache!
@@ -70,6 +76,7 @@ module ActiveAdmin
70
76
  graphql/run_action_mutation_config
71
77
  graphql/run_action_mutation_dsl
72
78
  graphql/key_value_pair_input
79
+ graphql/policy_set_cache
73
80
  graphql/schema_builder
74
81
  ].each { |path| require_relative path }
75
82
  end
@@ -0,0 +1,7 @@
1
+ module ActiveAdmin
2
+ module GraphQL
3
+ VERSION: String
4
+
5
+ def self.load!: () -> void
6
+ end
7
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeadmin-graphql
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -135,6 +135,34 @@ dependencies:
135
135
  - - "~>"
136
136
  - !ruby/object:Gem::Version
137
137
  version: '3'
138
+ - !ruby/object:Gem::Dependency
139
+ name: polyrun
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: 2.2.0
145
+ type: :development
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: 2.2.0
152
+ - !ruby/object:Gem::Dependency
153
+ name: prosopite
154
+ requirement: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - "~>"
157
+ - !ruby/object:Gem::Version
158
+ version: '2.0'
159
+ type: :development
160
+ prerelease: false
161
+ version_requirements: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - "~>"
164
+ - !ruby/object:Gem::Version
165
+ version: '2.0'
138
166
  - !ruby/object:Gem::Dependency
139
167
  name: rspec-rails
140
168
  requirement: !ruby/object:Gem::Requirement
@@ -191,20 +219,6 @@ dependencies:
191
219
  - - "~>"
192
220
  - !ruby/object:Gem::Version
193
221
  version: '0.7'
194
- - !ruby/object:Gem::Dependency
195
- name: simplecov
196
- requirement: !ruby/object:Gem::Requirement
197
- requirements:
198
- - - "~>"
199
- - !ruby/object:Gem::Version
200
- version: '0.22'
201
- type: :development
202
- prerelease: false
203
- version_requirements: !ruby/object:Gem::Requirement
204
- requirements:
205
- - - "~>"
206
- - !ruby/object:Gem::Version
207
- version: '0.22'
208
222
  - !ruby/object:Gem::Dependency
209
223
  name: sprockets-rails
210
224
  requirement: !ruby/object:Gem::Requirement
@@ -303,6 +317,20 @@ dependencies:
303
317
  - - "~>"
304
318
  - !ruby/object:Gem::Version
305
319
  version: '0.3'
320
+ - !ruby/object:Gem::Dependency
321
+ name: rbs
322
+ requirement: !ruby/object:Gem::Requirement
323
+ requirements:
324
+ - - "~>"
325
+ - !ruby/object:Gem::Version
326
+ version: '3'
327
+ type: :development
328
+ prerelease: false
329
+ version_requirements: !ruby/object:Gem::Requirement
330
+ requirements:
331
+ - - "~>"
332
+ - !ruby/object:Gem::Version
333
+ version: '3'
306
334
  description: Exposes ActiveAdmin resources and pages as a graphql-ruby schema with
307
335
  an HTTP endpoint per namespace.
308
336
  email:
@@ -324,6 +352,7 @@ files:
324
352
  - lib/active_admin/graphql/engine.rb
325
353
  - lib/active_admin/graphql/integration.rb
326
354
  - lib/active_admin/graphql/key_value_pair_input.rb
355
+ - lib/active_admin/graphql/policy_set_cache.rb
327
356
  - lib/active_admin/graphql/railtie.rb
328
357
  - lib/active_admin/graphql/record_source.rb
329
358
  - lib/active_admin/graphql/resource_config.rb
@@ -360,6 +389,7 @@ files:
360
389
  - lib/active_admin/graphql/version.rb
361
390
  - lib/active_admin/primary_key.rb
362
391
  - lib/activeadmin/graphql.rb
392
+ - sig/active_admin/graphql.rbs
363
393
  homepage: https://github.com/amkisko/activeadmin-graphql.rb
364
394
  licenses:
365
395
  - MIT
@@ -384,7 +414,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
384
414
  - !ruby/object:Gem::Version
385
415
  version: '0'
386
416
  requirements: []
387
- rubygems_version: 4.0.4
417
+ rubygems_version: 4.0.6
388
418
  specification_version: 4
389
419
  summary: GraphQL API extension for ActiveAdmin (graphql-ruby).
390
420
  test_files: []