activeadmin-graphql 0.2.0 → 0.3.0

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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/CODE_OF_CONDUCT.md +14 -19
  4. data/CONTRIBUTING.md +12 -16
  5. data/README.md +19 -2
  6. data/activeadmin-graphql.gemspec +11 -7
  7. data/app/controllers/active_admin/graphql_controller.rb +50 -8
  8. data/docs/graphql-api.md +36 -11
  9. data/lib/active_admin/graphql/integration.rb +22 -23
  10. data/lib/active_admin/graphql/mutation_execution_error.rb +16 -0
  11. data/lib/active_admin/graphql/mutation_input_config.rb +11 -0
  12. data/lib/active_admin/graphql/mutation_input_definition_dsl.rb +22 -0
  13. data/lib/active_admin/graphql/policy_set_cache.rb +64 -0
  14. data/lib/active_admin/graphql/resource_config.rb +21 -2
  15. data/lib/active_admin/graphql/resource_definition_dsl.rb +19 -0
  16. data/lib/active_admin/graphql/resource_methods.rb +55 -0
  17. data/lib/active_admin/graphql/resource_query_proxy.rb +20 -0
  18. data/lib/active_admin/graphql/run_action_payload.rb +2 -1
  19. data/lib/active_admin/graphql/schema_builder/graph_params.rb +2 -2
  20. data/lib/active_admin/graphql/schema_builder/mutation_create.rb +6 -2
  21. data/lib/active_admin/graphql/schema_builder/mutation_update_destroy.rb +19 -9
  22. data/lib/active_admin/graphql/schema_builder/query_type_policies.rb +17 -5
  23. data/lib/active_admin/graphql/schema_builder/types_inputs.rb +24 -4
  24. data/lib/active_admin/graphql/schema_builder/types_object.rb +5 -2
  25. data/lib/active_admin/graphql/schema_builder/wire.rb +11 -1
  26. data/lib/active_admin/graphql/version.rb +1 -1
  27. data/lib/active_admin/graphql.rb +9 -1
  28. data/sig/active_admin/graphql.rbs +7 -0
  29. metadata +89 -35
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "run_action_mutation_config"
4
+ require_relative "mutation_input_config"
4
5
 
5
6
  module ActiveAdmin
6
7
  module GraphQL
@@ -10,14 +11,16 @@ module ActiveAdmin
10
11
  attr_accessor :graphql_type_name
11
12
  attr_accessor :only_attributes
12
13
  attr_accessor :exclude_attributes
14
+ attr_accessor :permit_params_attributes
15
+ attr_accessor :html_permit_params_attributes
13
16
  attr_accessor :extension_block
14
17
 
15
18
  # Optional resolver overrides (set from +graphql do+). SchemaBuilder still owns field names,
16
19
  # arguments, and types; procs replace only the Ruby resolution body.
17
20
  attr_accessor :resolve_index_proc
18
21
  attr_accessor :resolve_show_proc
19
- attr_accessor :resolve_create_proc
20
- attr_accessor :resolve_update_proc
22
+ attr_writer :resolve_create_proc
23
+ attr_writer :resolve_update_proc
21
24
  attr_accessor :resolve_destroy_proc
22
25
 
23
26
  # Default return type for run-action mutations (+batch+, +member+, +collection+) when a kind-specific
@@ -33,6 +36,22 @@ module ActiveAdmin
33
36
  !enabled
34
37
  end
35
38
 
39
+ def create_input
40
+ @create_input ||= MutationInputConfig.new
41
+ end
42
+
43
+ def update_input
44
+ @update_input ||= MutationInputConfig.new
45
+ end
46
+
47
+ def resolve_create_proc
48
+ @resolve_create_proc || create_input.resolve_proc
49
+ end
50
+
51
+ def resolve_update_proc
52
+ @resolve_update_proc || update_input.resolve_proc
53
+ end
54
+
36
55
  def batch_run_action
37
56
  @batch_run_action ||= RunActionMutationConfig.new
38
57
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "run_action_mutation_config"
4
4
  require_relative "run_action_mutation_dsl"
5
+ require_relative "mutation_input_definition_dsl"
5
6
 
6
7
  module ActiveAdmin
7
8
  module GraphQL
@@ -18,6 +19,7 @@ module ActiveAdmin
18
19
  def type_name(name)
19
20
  @config.graphql_type_name = name.to_s
20
21
  end
22
+ alias_method :graphql_name, :type_name
21
23
 
22
24
  def only(*attrs)
23
25
  @config.only_attributes = attrs.flatten.map(&:to_sym)
@@ -28,6 +30,23 @@ module ActiveAdmin
28
30
  end
29
31
  alias_method :exclude, :except
30
32
 
33
+ def permit_params(*attrs)
34
+ @config.permit_params_attributes = attrs.flatten.map(&:to_sym)
35
+ end
36
+ alias_method :permit, :permit_params
37
+
38
+ def create(&block)
39
+ return unless block
40
+
41
+ MutationInputDefinitionDSL.new(@config.create_input).instance_exec(&block)
42
+ end
43
+
44
+ def update(&block)
45
+ return unless block
46
+
47
+ MutationInputDefinitionDSL.new(@config.update_input).instance_exec(&block)
48
+ end
49
+
31
50
  def configure(&block)
32
51
  @config.extension_block = block
33
52
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module GraphQL
5
+ module Integration
6
+ module ResourceMethods
7
+ SERVER_OWNED_TIMESTAMP_COLUMNS = %w[created_at updated_at].freeze
8
+
9
+ def graphql_config
10
+ @graphql_config ||= ActiveAdmin::GraphQL::ResourceConfig.new
11
+ end
12
+
13
+ def attributes_for_graphql
14
+ keys = resource_attributes.keys
15
+ cfg = graphql_config
16
+ if cfg.only_attributes
17
+ keys &= cfg.only_attributes
18
+ end
19
+ keys -= cfg.exclude_attributes
20
+ keys
21
+ end
22
+
23
+ def graphql_assignable_attribute_names
24
+ names = attributes_for_graphql.map(&:to_s)
25
+ pk_cols = ActiveAdmin::PrimaryKey.columns(resource_class)
26
+ return names if pk_cols.size > 1
27
+
28
+ names - pk_cols
29
+ end
30
+
31
+ def graphql_create_attribute_names
32
+ mutation_permit_params_names(
33
+ graphql_config.create_input.permit_params_attributes || graphql_config.permit_params_attributes
34
+ )
35
+ end
36
+
37
+ def graphql_update_attribute_names
38
+ mutation_permit_params_names(
39
+ graphql_config.update_input.permit_params_attributes || graphql_config.permit_params_attributes
40
+ )
41
+ end
42
+
43
+ private
44
+
45
+ def mutation_permit_params_names(permit_list)
46
+ names = graphql_assignable_attribute_names.map(&:to_s)
47
+ permit_list = graphql_config.html_permit_params_attributes if permit_list.nil?
48
+ return names - SERVER_OWNED_TIMESTAMP_COLUMNS if permit_list.nil?
49
+
50
+ names & permit_list.map(&:to_s)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ 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
@@ -28,9 +28,9 @@ module ActiveAdmin
28
28
  h
29
29
  end
30
30
 
31
- def assignable_slice_from_input(aa_res, input)
31
+ def assignable_slice_from_input(aa_res, input, attribute_names: nil)
32
32
  blob = input.to_h.stringify_keys
33
- names = aa_res.graphql_assignable_attribute_names.map(&:to_s)
33
+ names = (attribute_names || aa_res.graphql_assignable_attribute_names).map(&:to_s)
34
34
  if (btc = aa_res.belongs_to_config)
35
35
  names -= [btc.to_param.to_s]
36
36
  end
@@ -29,7 +29,11 @@ module ActiveAdmin
29
29
  namespace: ns,
30
30
  graph_params: sb.graph_params_from_input(aa_res, input)
31
31
  )
32
- attrs = sb.assignable_slice_from_input(aa_res, input)
32
+ attrs = sb.assignable_slice_from_input(
33
+ aa_res,
34
+ input,
35
+ attribute_names: aa_res.graphql_create_attribute_names
36
+ )
33
37
  record = if (hook = aa_res.graphql_config.resolve_create_proc)
34
38
  hook.call(
35
39
  proxy: proxy,
@@ -46,7 +50,7 @@ module ActiveAdmin
46
50
  end
47
51
 
48
52
  unless r.save
49
- raise ::GraphQL::ExecutionError, r.errors.full_messages.to_sentence
53
+ raise MutationExecutionError.validation(r)
50
54
  end
51
55
  r
52
56
  end
@@ -42,7 +42,11 @@ module ActiveAdmin
42
42
  raise ::GraphQL::ExecutionError, "not authorized to update this record"
43
43
  end
44
44
 
45
- attrs = sb.assignable_slice_from_input(aa_res, input)
45
+ attrs = sb.assignable_slice_from_input(
46
+ aa_res,
47
+ input,
48
+ attribute_names: aa_res.graphql_update_attribute_names
49
+ )
46
50
  record = if (hook = aa_res.graphql_config.resolve_update_proc)
47
51
  hook.call(
48
52
  proxy: proxy,
@@ -54,9 +58,9 @@ module ActiveAdmin
54
58
  aa_resource: aa_res
55
59
  )
56
60
  else
57
- attrs = attrs.stringify_keys.slice(*aa_res.graphql_assignable_attribute_names)
61
+ attrs = attrs.stringify_keys.slice(*aa_res.graphql_update_attribute_names)
58
62
  unless record.update(attrs)
59
- raise ::GraphQL::ExecutionError, record.errors.full_messages.to_sentence
63
+ raise MutationExecutionError.validation(record)
60
64
  end
61
65
  record
62
66
  end
@@ -66,17 +70,22 @@ module ActiveAdmin
66
70
  end
67
71
 
68
72
  def mutation_destroy_field(sb, ns, aa_res, model)
69
- fname = "delete_#{aa_res.resource_name.route_key.singularize.tr("-", "_")}"
73
+ stem = aa_res.resource_name.route_key.singularize.tr("-", "_")
74
+ delete_name = "delete_#{stem}"
75
+ destroy_name = "destroy_#{stem}"
70
76
  find_type = @find_input_types[model]
77
+ type_basename = sb.send(:graphql_type_name_for, aa_res)
71
78
 
72
79
  proc do
73
- field fname.to_sym, ::GraphQL::Types::Boolean, null: false, camelize: false,
74
- visibility: {kind: :mutation_delete, graphql_type_name: sb.send(:graphql_type_name_for, aa_res), resource: aa_res},
75
- description: "Delete #{model.name}" do
76
- argument :where, find_type, required: true, camelize: false
80
+ [delete_name, destroy_name].each do |fname|
81
+ field fname.to_sym, ::GraphQL::Types::Boolean, null: false, camelize: false,
82
+ visibility: {kind: :mutation_delete, graphql_type_name: type_basename, resource: aa_res},
83
+ description: "Destroy #{model.name}" do
84
+ argument :where, find_type, required: true, camelize: false
85
+ end
77
86
  end
78
87
 
79
- define_method(fname.to_sym) do |where:, **|
88
+ define_method(delete_name.to_sym) do |where:, **|
80
89
  auth = context[:auth]
81
90
  blob = where.to_h.stringify_keys
82
91
  graph = sb.graph_params_from_find_blob(aa_res, blob)
@@ -112,6 +121,7 @@ module ActiveAdmin
112
121
  end
113
122
  true
114
123
  end
124
+ alias_method destroy_name.to_sym, delete_name.to_sym
115
125
  end
116
126
  end
117
127
  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
@@ -6,10 +6,16 @@ module ActiveAdmin
6
6
  module TypesInputs
7
7
  private
8
8
 
9
- def define_input_assignable_arguments!(input_class, aa_res, required:)
9
+ def mark_empty_input_object!(input_class)
10
+ return if input_class.any_arguments?
11
+
12
+ input_class.has_no_arguments(true)
13
+ end
14
+
15
+ def define_input_assignable_arguments!(input_class, aa_res, required:, attribute_names:)
10
16
  model = aa_res.resource_class
11
17
  cols_by_name = model.columns.index_by(&:name)
12
- gassign = aa_res.graphql_assignable_attribute_names.map(&:to_s)
18
+ gassign = attribute_names.map(&:to_s)
13
19
  if (btc = aa_res.belongs_to_config)
14
20
  gassign -= [btc.to_param.to_s]
15
21
  end
@@ -33,12 +39,19 @@ module ActiveAdmin
33
39
  graphql_name "#{gname}CreateInput"
34
40
  description "Attributes (and nested route params) for creating #{model.name}"
35
41
 
36
- builder.send(:define_input_assignable_arguments!, self, aa_res, required: false)
42
+ builder.send(
43
+ :define_input_assignable_arguments!,
44
+ self,
45
+ aa_res,
46
+ required: false,
47
+ attribute_names: aa_res.graphql_create_attribute_names
48
+ )
37
49
 
38
50
  if btc
39
51
  argument btc.to_param.to_sym, ::GraphQL::Types::ID, required: btc.required?, camelize: false
40
52
  end
41
53
  end
54
+ mark_empty_input_object!(klass)
42
55
  attach_input_object_visibility!(klass, "#{gname}CreateInput", aa_res, :create_input)
43
56
  klass
44
57
  end
@@ -53,12 +66,19 @@ module ActiveAdmin
53
66
  graphql_name "#{gname}UpdateInput"
54
67
  description "Partial attributes (and nested route params) for updating #{model.name}"
55
68
 
56
- builder.send(:define_input_assignable_arguments!, self, aa_res, required: false)
69
+ builder.send(
70
+ :define_input_assignable_arguments!,
71
+ self,
72
+ aa_res,
73
+ required: false,
74
+ attribute_names: aa_res.graphql_update_attribute_names
75
+ )
57
76
 
58
77
  if btc
59
78
  argument btc.to_param.to_sym, ::GraphQL::Types::ID, required: false, camelize: false
60
79
  end
61
80
  end
81
+ mark_empty_input_object!(klass)
62
82
  attach_input_object_visibility!(klass, "#{gname}UpdateInput", aa_res, :update_input)
63
83
  klass
64
84
  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.3.0"
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,8 @@ 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
80
+ graphql/mutation_execution_error
73
81
  graphql/schema_builder
74
82
  ].each { |path| require_relative path }
75
83
  end
@@ -0,0 +1,7 @@
1
+ module ActiveAdmin
2
+ module GraphQL
3
+ VERSION: String
4
+
5
+ def self.load!: () -> void
6
+ end
7
+ end