activeadmin-graphql 0.1.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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +6 -0
  3. data/CODE_OF_CONDUCT.md +31 -0
  4. data/CONTRIBUTING.md +27 -0
  5. data/LICENSE.md +21 -0
  6. data/README.md +49 -0
  7. data/activeadmin-graphql.gemspec +66 -0
  8. data/app/controllers/active_admin/graphql_controller.rb +168 -0
  9. data/docs/graphql-api.md +486 -0
  10. data/lib/active_admin/graphql/auth_context.rb +35 -0
  11. data/lib/active_admin/graphql/engine.rb +9 -0
  12. data/lib/active_admin/graphql/integration.rb +135 -0
  13. data/lib/active_admin/graphql/key_value_pair_input.rb +48 -0
  14. data/lib/active_admin/graphql/railtie.rb +10 -0
  15. data/lib/active_admin/graphql/record_source.rb +30 -0
  16. data/lib/active_admin/graphql/resource_config.rb +68 -0
  17. data/lib/active_admin/graphql/resource_definition_dsl.rb +117 -0
  18. data/lib/active_admin/graphql/resource_interface.rb +25 -0
  19. data/lib/active_admin/graphql/resource_query_proxy/controller.rb +149 -0
  20. data/lib/active_admin/graphql/resource_query_proxy.rb +112 -0
  21. data/lib/active_admin/graphql/run_action_mutation_config.rb +23 -0
  22. data/lib/active_admin/graphql/run_action_mutation_dsl.rb +32 -0
  23. data/lib/active_admin/graphql/run_action_payload.rb +27 -0
  24. data/lib/active_admin/graphql/schema_builder/build.rb +84 -0
  25. data/lib/active_admin/graphql/schema_builder/graph_params.rb +75 -0
  26. data/lib/active_admin/graphql/schema_builder/mutation_action_types.rb +52 -0
  27. data/lib/active_admin/graphql/schema_builder/mutation_batch.rb +61 -0
  28. data/lib/active_admin/graphql/schema_builder/mutation_collection.rb +118 -0
  29. data/lib/active_admin/graphql/schema_builder/mutation_create.rb +65 -0
  30. data/lib/active_admin/graphql/schema_builder/mutation_member.rb +122 -0
  31. data/lib/active_admin/graphql/schema_builder/mutation_type_builder.rb +52 -0
  32. data/lib/active_admin/graphql/schema_builder/mutation_update_destroy.rb +120 -0
  33. data/lib/active_admin/graphql/schema_builder/query_type.rb +53 -0
  34. data/lib/active_admin/graphql/schema_builder/query_type_collection.rb +84 -0
  35. data/lib/active_admin/graphql/schema_builder/query_type_member.rb +91 -0
  36. data/lib/active_admin/graphql/schema_builder/query_type_pages.rb +44 -0
  37. data/lib/active_admin/graphql/schema_builder/query_type_registered.rb +57 -0
  38. data/lib/active_admin/graphql/schema_builder/resolvers.rb +116 -0
  39. data/lib/active_admin/graphql/schema_builder/resources.rb +48 -0
  40. data/lib/active_admin/graphql/schema_builder/types_inputs.rb +119 -0
  41. data/lib/active_admin/graphql/schema_builder/types_object.rb +96 -0
  42. data/lib/active_admin/graphql/schema_builder/visibility.rb +58 -0
  43. data/lib/active_admin/graphql/schema_builder/wire.rb +36 -0
  44. data/lib/active_admin/graphql/schema_builder.rb +62 -0
  45. data/lib/active_admin/graphql/schema_field.rb +29 -0
  46. data/lib/active_admin/graphql/version.rb +7 -0
  47. data/lib/active_admin/graphql.rb +68 -0
  48. data/lib/active_admin/primary_key.rb +117 -0
  49. data/lib/activeadmin/graphql.rb +5 -0
  50. metadata +389 -0
@@ -0,0 +1,486 @@
1
+ # GraphQL API
2
+
3
+ This guide documents the [activeadmin-graphql](https://github.com/amkisko/activeadmin-graphql) gem: an optional GraphQL HTTP API for each ActiveAdmin namespace, built with [graphql-ruby](https://graphql-ruby.org/). The API mirrors the same resources, scoping, and controller behavior as the HTML and JSON admin endpoints: Ransack `q`, menu `scope`, sort `order`, nested `belongs_to` parents, CRUD, batch actions, and custom member and collection actions.
4
+
5
+ The HTTP feature is off until you set `graphql = true` on a namespace. That adds a `POST` endpoint only (for example `POST /admin/graphql` for the default `admin` namespace); it does not replace the existing admin UI.
6
+
7
+ ## Gem and loading
8
+
9
+ Add the extension to your application (it pulls in `activeadmin` and `graphql` as runtime dependencies):
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ gem "activeadmin"
14
+ gem "activeadmin-graphql"
15
+ ```
16
+
17
+ Bundling loads `activeadmin/graphql`, which installs the Router/Resource integration and calls `ActiveAdmin::GraphQL.load!`, so graphql-ruby is loaded at boot whenever the gem is in your bundle. The schema class for a namespace is built on demand (first request or introspection) via `ActiveAdmin::GraphQL.schema_for(namespace)`.
18
+
19
+ If `graphql = true` is set for a namespace but Bundler cannot satisfy the `graphql` gem, boot fails with an ActiveAdmin dependency error until you run `bundle install`.
20
+
21
+ Custom pages that declare `graphql_field` with types such as `GraphQL::Types::Int` need graphql-ruby loaded before `app/admin` is evaluated; with `activeadmin-graphql` in the Gemfile that is already true.
22
+
23
+ ## Enabling the endpoint
24
+
25
+ In `config/initializers/active_admin.rb`, turn on GraphQL for the namespace you
26
+ want:
27
+
28
+ ```ruby
29
+ ActiveAdmin.setup do |config|
30
+ config.namespace :admin do |admin|
31
+ admin.graphql = true
32
+ # Optional — default is "graphql", i.e. POST /admin/graphql
33
+ # admin.graphql_path = "graphql"
34
+ end
35
+ end
36
+ ```
37
+
38
+ Run `bin/rails active_admin:install` or reload routes if needed so
39
+ `ActiveAdmin.routes(self)` is applied.
40
+
41
+ ### Namespace settings
42
+
43
+ Each namespace inherits these options (defaults in parentheses):
44
+
45
+ * `graphql` — set to `true` to mount the endpoint (`false` by default).
46
+ * `graphql_path` — path segment under the namespace mount (`"graphql"`).
47
+ * `graphql_multiplex_max` — maximum operations per multiplexed JSON array
48
+ request (`20`).
49
+ * `graphql_max_complexity` — passed to graphql-ruby `max_complexity` when set.
50
+ * `graphql_max_depth` — passed to `max_depth` when set.
51
+ * `graphql_default_page_size` / `graphql_default_max_page_size` — connection
52
+ defaults when set.
53
+ * `graphql_dataloader` — graphql-ruby dataloader plugin (`nil` = use
54
+ `GraphQL::Dataloader`). Set to `GraphQL::Dataloader::AsyncDataloader` when
55
+ the [`async` gem](https://github.com/socketry/async) is loaded if you want
56
+ parallel I/O from custom `GraphQL::Dataloader::Source` `#fetch` methods.
57
+ * `graphql_visibility` — `nil` by default (no graphql-ruby visibility plugin).
58
+ Set to `true` to use [`GraphQL::Schema::Visibility`](https://graphql-ruby.org/schema/visibility.html)
59
+ with default options, or pass a `Hash` of visibility options (`profiles:`,
60
+ `dynamic:`, `preload:`, `migration_errors:`, …).
61
+ * `graphql_visibility_profile` — when using named visibility profiles, optional
62
+ default `context[:visibility_profile]` for each HTTP request (symbols accepted).
63
+ * `graphql_schema_visible` — optional `Proc` `(context, meta) -> Boolean` when
64
+ `graphql_visibility` is enabled; see Schema visibility below.
65
+ * `graphql_configure_schema` — `Proc` receiving the schema class after build
66
+ (extensions, extra types, and so on).
67
+
68
+ ### GraphQL::Dataloader
69
+
70
+ The namespace schema calls `schema.use` with the configured dataloader (see
71
+ `graphql_dataloader` above). [graphql-ruby’s
72
+ dataloader](https://graphql-ruby.org/dataloader/overview.html) batches external
73
+ work across sibling fields using Fibers; each HTTP request gets its own
74
+ dataloader instance (available as `context.dataloader` inside resolvers).
75
+
76
+ Active Admin registers `ActiveAdmin::GraphQL::RecordSource` for
77
+ non-polymorphic `belongs_to` columns on resource types: resolving `author { id }` on
78
+ many `Post` nodes issues one batched `WHERE id IN (...)` for the related model
79
+ instead of one query per node. Custom `graphql` `configure` fields and
80
+ `register_page` `graphql_field` blocks run as normal GraphQL field methods, so
81
+ you can use `dataloader` (or `context.dataloader`) with your own
82
+ `GraphQL::Dataloader::Source` subclasses the same way as in standalone
83
+ graphql-ruby apps.
84
+
85
+ To implement `Schema.object_from_id` with batching (for example for `loads:` on
86
+ mutation arguments), use `context.dataloader.with(ActiveAdmin::GraphQL::RecordSource, model_class).load(database_id)` inside your `graphql_configure_schema`
87
+ hook after assigning `schema.define_singleton_method(:object_from_id, ...)`.
88
+ Global / Relay-style IDs are not generated by Active Admin itself.
89
+
90
+ ### Schema visibility (`GraphQL::Schema::Visibility`)
91
+
92
+ When `graphql_visibility` is set, the namespace schema calls
93
+ `schema.use(GraphQL::Schema::Visibility, …)` so introspection and validation
94
+ respect [visibility](https://graphql-ruby.org/schema/visibility.html).
95
+
96
+ Set `graphql_schema_visible` to a proc that receives the GraphQL `context`
97
+ and a `meta` hash and returns truthy to keep the member visible. The hash
98
+ always includes `:kind`, and often `:graphql_type_name` (the resource’s GraphQL
99
+ basename), `:resource` (`ActiveAdmin::Resource`), and other keys:
100
+
101
+ | `kind` | Meaning |
102
+ |--------|---------|
103
+ | `:resource_interface` | `ActiveAdminResource` interface |
104
+ | `:resource_object` | Resource object type |
105
+ | `:resource_enum` | Rails enum type; `meta` includes `:column` |
106
+ | `:input_object` | Create/update/where/list-filter input; `meta` includes `:role` (`:create_input`, …) |
107
+ | `:registered_resource_union` | `ActiveAdminRegisteredResource` union |
108
+ | `:run_action_payload` | Batch/member/collection action payload type |
109
+ | `:query_registered_resource` | `registered_resource` query field |
110
+ | `:query_collection_field` / `:query_member_field` | Resource list/detail fields; `meta` includes `:field_name` |
111
+ | `:query_page_field` | Custom `register_page` field; `meta` includes `:page` |
112
+ | `:mutation_create`, `:mutation_update`, `:mutation_delete`, … | CRUD and action mutations |
113
+
114
+ Root `Query` / `Mutation` fields are defined with
115
+ `ActiveAdmin::GraphQL::SchemaField`, which supports a `visibility:` keyword:
116
+ a metadata `Hash` (same shape as the `meta` argument above) for
117
+ `graphql_schema_visible`, not a graphql-ruby core option. Resource `graphql`
118
+ `configure` blocks use that field class too, so custom fields may pass
119
+ `visibility: { … }` when you want the hook to run.
120
+
121
+ You can also subclass your own `GraphQL::Schema::Object` / `Field` types in
122
+ `graphql_configure_schema` and implement `visible?` there; Active Admin’s
123
+ helpers call `super` first so those implementations compose.
124
+
125
+ Interfaces and unions: graphql-ruby may keep an interface or union visible
126
+ when all members are hidden unless you return `false` from
127
+ `graphql_schema_visible` for `:resource_interface` or `:registered_resource_union`
128
+ (or implement `.visible?` on custom types). See graphql-ruby’s visibility migration
129
+ notes for edge cases.
130
+
131
+ ## Making requests
132
+
133
+ The endpoint accepts `POST` at `{namespace}/{graphql_path}` (for example
134
+ `/admin/graphql`).
135
+
136
+ Send JSON with `query` and optional `variables`. For
137
+ [multiplexing](https://graphql-ruby.org/queries/multiplex.html), the body may
138
+ be a JSON array of such objects; the size is limited by `graphql_multiplex_max`.
139
+
140
+ Requests are handled by `ActiveAdmin::GraphqlController` (an engine controller,
141
+ not a generated `Admin::…` subclass).
142
+
143
+ ### CSRF and cookie sessions
144
+
145
+ `GraphqlController` uses Rails’ default CSRF protection
146
+ (`protect_from_forgery with: :exception`). Clients that rely on session cookies
147
+ (for example a typical Devise session) must submit a valid CSRF token with each
148
+ `POST`, such as the `authenticity_token` used by the HTML admin. API-style
149
+ clients often use header-based authentication without cookie sessions, or a
150
+ separate route with CSRF disabled. Treat this endpoint like any other
151
+ `ActionController::Base` `POST` from the browser.
152
+
153
+ ### Authentication and introspection
154
+
155
+ The namespace `authentication_method` runs before any GraphQL work, including
156
+ schema introspection. Unauthenticated clients should not receive a schema.
157
+
158
+ The GraphQL context exposes the same user as `current_active_admin_user` (from
159
+ the namespace `current_user_method`), wrapped for authorization checks.
160
+
161
+ ## Authorization
162
+
163
+ Field resolvers call the namespace `authorization_adapter` (`authorized?` and
164
+ `scope_collection`) for standard read and CRUD operations. Data loading reuses
165
+ `ResourceController` (`find_collection`, `find_resource`,
166
+ `apply_authorization_scope`, and related methods), so collection scoping matches
167
+ REST.
168
+
169
+ Batch, member, and collection mutations first require read access to the
170
+ resource model (`authorized?` with the model class, in the same way as
171
+ collection queries). They then run the same controller actions and blocks as the
172
+ UI, including any `authorize!` or `authorize_resource!` calls.
173
+
174
+ See the ActiveAdmin guide [Authorization adapter](https://activeadmin.info/13-authorization-adapter) for adapter setup; the same rules apply to GraphQL.
175
+
176
+ ## Schema overview
177
+
178
+ The `Query` type includes one Relay-style connection field per resource route
179
+ (for example `posts`), plus a singular field for a single record (for example
180
+ `post`). Optional `graphql_field` entries on `register_page` appear here too,
181
+ plus `registered_resource` (returning the `ActiveAdminRegisteredResource` union)
182
+ when at least one resource is exposed.
183
+
184
+ Every resource object type implements the `ActiveAdminResource` interface
185
+ (`id: ID!`) so clients can share fragments or rely on a common shape.
186
+
187
+ The `Mutation` type is present when at least one operation exists: per-resource
188
+ `create_*`, `update_*`, and `delete_*` when those controller actions exist, plus
189
+ `*_batch_action`, `*_member_action`, and `*_collection_action` fields when the
190
+ resource defines the corresponding batch, member, or collection actions.
191
+
192
+ Object type names default to the model name (for example `Post`). Typed input
193
+ objects mirror `attributes_for_graphql` (see the per-resource section below):
194
+ `PostCreateInput`, `PostUpdateInput`, `PostListFilterInput`, and `PostWhereInput`
195
+ when the GraphQL name is `Post`.
196
+
197
+ ### Composite primary keys (Rails 7.1+)
198
+
199
+ For models with `self.primary_key = [:book_code, :seq]` (and no separate `id`
200
+ column), Active Admin treats GraphQL as follows:
201
+
202
+ * `id` on the object type — JSON object string with all key columns in
203
+ model order (same format Rails uses when casting composite ids), for example
204
+ `{"book_code":"CPK","seq":7}`.
205
+ * Readable fields — each primary-key column is also exposed as its own field
206
+ (in addition to `id`) so clients can load scalars without parsing JSON.
207
+ * `LibraryEditionWhereInput` (and similar) — either optional `id` (that JSON
208
+ string) or every primary-key column; `id` remains required when there is
209
+ only one key column.
210
+ * Singular query field — pass the JSON `id`, pass `where`, or pass one
211
+ GraphQL argument per primary-key column.
212
+ * Create mutations — composite key attributes stay included in create
213
+ inputs (unlike single `id` tables, where the key is omitted from assignable
214
+ attributes).
215
+
216
+ For REST/HTML member routes, `ResourceController#find_resource` must resolve composite ids from `params[:id]` (JSON string) or from individual primary-key params. Stock ActiveAdmin 3.x only passes a single `params[:id]` into `find`; an ActiveAdmin fork (or patch) that implements composite-aware `find_resource` keeps the admin UI aligned with GraphQL. The GraphQL layer also works with `ActiveAdmin::GraphQL::ResourceQueryProxy` using Rails’ tuple id convention for `Relation#find`.
217
+
218
+ The gem ships `ActiveAdmin::PrimaryKey` when the core constant is missing (upstream). A fork may define `PrimaryKey` and extended `find_resource` in `ResourceController` instead.
219
+
220
+ `ActiveAdmin::GraphQL::RecordSource` batch-loads composite associations using
221
+ Rails `where(primary_key => [tuple, …])`.
222
+
223
+ ### Collection query arguments
224
+
225
+ These align with REST index parameters where possible:
226
+
227
+ * `filter` — optional `PostListFilterInput` with `scope`, `order`, `q` (Ransack
228
+ predicates as JSON — kept as JSON because predicate keys are open-ended), and the nested parent id when `belongs_to` applies.
229
+ * `scope` — menu scope id (string); still accepted on the field for backward
230
+ compatibility.
231
+ * `q` — Ransack predicates (JSON object).
232
+ * `order` — index sort string (for example `id_desc`).
233
+ * Parent foreign key — when the resource uses `belongs_to`, the parent param
234
+ (for example `user_id`) stays available on the connection field as today.
235
+
236
+ ### Singular query arguments
237
+
238
+ * `where` — `PostWhereInput` with required `id` (single-key tables) or,
239
+ for composite keys, `id` (JSON) and/or each key column; optional parent ids for
240
+ nested resources.
241
+ * Legacy `id` / parent arguments behave as before (composite models also accept
242
+ one argument per key column on the field itself).
243
+
244
+ ### CRUD mutations
245
+
246
+ Typical names for a `Post` resource:
247
+
248
+ * `create_post(input: PostCreateInput!)` — attribute fields match assignable
249
+ columns (and the nested parent param when `belongs_to` is configured).
250
+ * `update_post(where: PostWhereInput!, input: PostUpdateInput!)`
251
+ * `delete_post(where: PostWhereInput!)`
252
+
253
+ Nested resources require the parent id on `where`, `input`, or list `filter`
254
+ when the association is required.
255
+
256
+ ### Migrating GraphQL clients
257
+
258
+ If you are updating clients that targeted an older embedded GraphQL integration (or regenerated types against a previous schema), check the following:
259
+
260
+ * Rails enum GraphQL names use an `Enum` infix (for example `PostEnumStatus`, not `PostStatus`). Update persisted operations, codegen, and fragments to match; see Rails enum columns below.
261
+
262
+ * CRUD mutations use typed `input` and `where` arguments (`PostCreateInput`, `PostWhereInput`, …), not a single loose `attributes` JSON object and bare `id` on update/delete. List/detail fields may use `where` / `filter` input objects; legacy scalar arguments on fields are unchanged where still exposed.
263
+
264
+ * `registered_resource(path: …)`, batch `inputs`, and member/collection `params` use `[ActiveAdminKeyValuePair!]` (a list of `{ key, value }` strings), not arbitrary JSON objects. Express maps as list items; duplicate keys keep the last value. Ransack `q` (on list `filter` / field args) stays a JSON object.
265
+
266
+ These rules match the current schema described in this guide. The core `activeadmin` gem’s `UPGRADING.md` may only mention them if you use a fork that references this extension.
267
+
268
+ ### `registered_resource` query
269
+
270
+ `registered_resource(type_name: String!, id: ID!, path: [ActiveAdminKeyValuePair!])`
271
+ returns `ActiveAdminRegisteredResource`, the union of all resource object types in
272
+ the namespace. Pass nested parent route params as ordered `path: [{ key: "user_id", value: "1" }, …]`
273
+ (same keys as REST path segments). Resolve with inline fragments on concrete types.
274
+
275
+ `ActiveAdminKeyValuePair` is `{ key: String!, value: String! }` — flat strings only, like query/form fields.
276
+
277
+ ### Batch, member, and collection actions
278
+
279
+ For a route key of `posts`, fields look like:
280
+
281
+ * `posts_batch_action(batch_action: String!, ids: [ID!]!, inputs: [ActiveAdminKeyValuePair!], …)` —
282
+ runs the registered batch action; `inputs` is converted to `batch_action_inputs` (flat key/value map)
283
+ like the HTML form. Omitted when batch actions are disabled or none are registered.
284
+ * `posts_member_action(action: String!, id: ID!, params: [ActiveAdminKeyValuePair!], …)` —
285
+ aggregate field: pick the `member_action` by `action` string (same as today).
286
+ * One GraphQL field per registered action — `{plural}_member_{action_name}` with
287
+ underscores (e.g. `posts_member_publish`, `posts_member_append_title_bang`): same
288
+ `id`, `params`, and parent ids as the aggregate field, without an `action:` argument.
289
+ Use `graphql { member_action_mutation(:publish) { … } }` to set return type,
290
+ `resolve`, and extra `arguments { … }` for that action only.
291
+ * `posts_collection_action(action: String!, params: [ActiveAdminKeyValuePair!], …)` —
292
+ aggregate collection actions by name.
293
+ * Per collection action: `{plural}_collection_{action_name}` (e.g.
294
+ `posts_collection_posts_count`). Configure with `collection_action_mutation(:posts_count) { … }`.
295
+
296
+ Action names are validated against registered definitions; arbitrary controller
297
+ methods are not exposed.
298
+
299
+ The aggregate fields stay available for clients that prefer a single entry point;
300
+ per-action fields give distinct GraphQL input and output types per `member_action` /
301
+ `collection_action` when you need them.
302
+
303
+ By default, run-action mutations return `ActiveAdminRunActionPayload`: `ok`,
304
+ `status`, `location` (redirects), and `body` (rendered output when present). You
305
+ can replace that object type per resource (see Run action return types
306
+ below). Resolvers still build `ActiveAdmin::GraphQL::RunActionPayload::Result`;
307
+ custom types should usually subclass `RunActionPayload` so those fields keep
308
+ working and `graphql_schema_visible` `:run_action_payload` continues to apply.
309
+
310
+ ## Per-resource `graphql` block
311
+
312
+ Inside `ActiveAdmin.register` you can narrow the GraphQL surface:
313
+
314
+ ```ruby
315
+ ActiveAdmin.register Post do
316
+ graphql do
317
+ disable! # omit this resource from the schema
318
+ type_name "BlogPost" # GraphQL object / mutation type basename
319
+ only :title, :body, :published_at # expose only these attributes
320
+ exclude :internal_score # or `except` / `exclude`
321
+ configure do
322
+ # graphql-ruby field DSL on the object type class
323
+ field :computed, GraphQL::Types::String, null: true
324
+ define_method(:computed) { object.title&.reverse }
325
+ end
326
+ end
327
+ end
328
+ ```
329
+
330
+ * `disable!` — resource is not included in Query or Mutation.
331
+ * `type_name` — overrides the default GraphQL type name derived from the model
332
+ (object types, mutations, and Rails enum GraphQL types use this basename).
333
+ * `only` / `except` (or `exclude`) — restrict columns on object types and
334
+ assignable mutation keys (the primary key remains readable as `id`).
335
+ * `configure` — `field` and resolver methods are evaluated on the generated
336
+ object type class.
337
+
338
+ ### Run action return types (batch / member / collection mutations)
339
+
340
+ Return type and optional resolver for each kind live together on
341
+ `ActiveAdmin::GraphQL::RunActionMutationConfig` (`payload_type`, `resolve_proc`),
342
+ exposed through the DSL in a graphql-ruby-like way.
343
+
344
+ Use a `GraphQL::Schema::Object` subclass — typically subclass
345
+ `ActiveAdmin::GraphQL::RunActionPayload` — then add fields and resolver methods.
346
+ The mutation object passed to field methods is always
347
+ `RunActionPayload::Result` (`ok`, `status`, `location`, `body`).
348
+
349
+ Grouped (recommended):
350
+
351
+ ```ruby
352
+ graphql do
353
+ run_action_payload_type(AppDefaultRunPayload) # optional default for all three kinds
354
+
355
+ batch_action_mutation do
356
+ type(Batches::BatchRunPayload)
357
+ resolve do |proxy:, batch_action:, ids:, inputs:, **kw|
358
+ # optional; same keyword args as resolve_batch_action
359
+ proxy.run_batch_action(batch_action, ids, inputs: inputs)
360
+ end
361
+ end
362
+
363
+ member_action_mutation do
364
+ type(Members::MemberRunPayload)
365
+ end
366
+
367
+ member_action_mutation(:send_invoice) do
368
+ type(Members::SendInvoicePayload)
369
+ arguments do
370
+ argument :note, GraphQL::Types::String, required: false, camelize: false
371
+ end
372
+ end
373
+ end
374
+ ```
375
+
376
+ Call the named field as `posts_member_send_invoice(id: "...", note: "…")`. Extra
377
+ arguments are merged with `params` for the controller and passed as keyword args
378
+ to a custom `resolve` block.
379
+
380
+ With no symbol argument, `member_action_mutation { … }` configures only the
381
+ aggregate `posts_member_action` field. With a symbol, it configures the
382
+ per-action field `posts_member_<name>`.
383
+
384
+ Inside each `*_mutation` block, `type` (alias `payload_type`) sets the GraphQL
385
+ object type and `resolve` replaces the Ruby body for that field only. You can
386
+ set just `type`, just `resolve`, or both; order inside the block does not matter.
387
+
388
+ One-shot aliases (same underlying config):
389
+
390
+ | DSL method | Effect |
391
+ |------------|--------|
392
+ | `run_action_payload_type(MyType)` | Default return type when a kind-specific `type` is not set. |
393
+ | `batch_action_run_action_payload_type(MyType)` | Same as `batch_action_mutation { type(MyType) }`. |
394
+ | `member_action_run_action_payload_type(MyType)` | Same as `member_action_mutation { type(MyType) }`. |
395
+ | `collection_action_run_action_payload_type(MyType)` | Same as `collection_action_mutation { type(MyType) }`. |
396
+ | `resolve_batch_action { … }` | Same as `batch_action_mutation { resolve { … } }` (and likewise for member/collection). |
397
+
398
+ Per-kind `type` wins over `run_action_payload_type`; any unset kind falls back
399
+ to `run_action_payload_type`, then `ActiveAdminRunActionPayload`. Set
400
+ `graphql_name` on your subclass so introspection names stay stable.
401
+
402
+ Per action name: use `member_action_mutation(:action_name)` /
403
+ `collection_action_mutation(:action_name)` so each Registered action gets its
404
+ own mutation field, return type, resolver, and optional `arguments` block. The
405
+ aggregate `posts_member_action` / `posts_collection_action` fields remain for
406
+ backwards compatibility and for resources that do not need per-action typing.
407
+
408
+ ### Resolver overrides (`resolve_*`)
409
+
410
+ Field names and GraphQL types stay defined by Active Admin; these procs only
411
+ replace the Ruby body that loads data or runs a mutation (similar in spirit to
412
+ overriding `find_resource` / `find_collection` rather than inventing new field
413
+ names):
414
+
415
+ | DSL method | Replaces default … |
416
+ |------------|-------------------|
417
+ | `resolve_index` (alias `resolve_collection`) | List connection resolver; must return an `ActiveRecord::Relation` (or compatible). Receives `proxy`, `context`, `auth`, `aa_resource`, `graph_params`, and the list field arguments (`filter`, `scope`, `q`, `order`, parent ids, …). |
418
+ | `resolve_show` (alias `resolve_member`) | Singular field and `registered_resource` for this resource; must return a record or `nil`. Receives `proxy`, `id`, `graph_params`, and for the generated `post` field also `where` / `id_argument` when present. |
419
+ | `resolve_create` | After class-level create authorization: build and persist. Receives `proxy`, `input`, `attributes` (assignable slice), `context`, `auth`, `aa_resource`. Must return the created record; instance-level `authorized?` runs afterward. |
420
+ | `resolve_update` | After load and update authorization: apply changes. Receives `record`, `input`, `attributes`, plus `proxy`, `context`, `auth`, `aa_resource`. Must return the updated record. |
421
+ | `resolve_destroy` | After destroy authorization: delete or archive. Receives `record`, `proxy`, `context`, `auth`, `aa_resource`. Default is `record.destroy!`. |
422
+ | `resolve_batch_action` | Batch mutation; receives `batch_action`, `ids`, `inputs` (string-key `Hash` after converting GraphQL key/value list), `proxy`, … Default calls `ResourceQueryProxy#run_batch_action`. |
423
+ | `resolve_member_action` | Member-action mutation; receives `action`, `id`, `params` (string-key `Hash`), `proxy`, … |
424
+ | `resolve_collection_action` | Collection-action mutation; receives `action`, `params` (string-key `Hash`), `proxy`, … |
425
+
426
+ Standard authorization checks around each field or mutation still run; hooks
427
+ are for customizing how data is fetched or saved, not for skipping policy.
428
+
429
+ ### Rails enum columns
430
+
431
+ Attributes backed by `enum` (integer or string columns with `defined_enums`) are
432
+ exposed as GraphQL enums. Each enum type is named
433
+
434
+ `{GraphQLTypeBasename}Enum{ColumnNameInPascalCase}`
435
+
436
+ where `GraphQLTypeBasename` is the resource’s GraphQL type name (from `type_name`
437
+ or the model name), and `ColumnNameInPascalCase` is the database attribute in
438
+ PascalCase (for example `notification_channel` → `NotificationChannel`).
439
+
440
+ Examples:
441
+
442
+ * `User` + column `notification_channel` → `UserEnumNotificationChannel`
443
+ * `UserNotification` + column `channel` → `UserNotificationEnumChannel`
444
+
445
+ Think of `PostEnumStatus` as the enum for `Post`’s `status` attribute, not as a
446
+ single merged word like `PostStatus`. Putting `Enum` immediately after the
447
+ resource basename (instead of a suffix such as `PostStatusEnum`) keeps the
448
+ basename and column distinct when both are camelized. That avoids the duplicates
449
+ graphql-ruby rejects when unrelated pairs would otherwise share one type name
450
+ (for example joining basename and column with a single underscore and camelizing
451
+ the whole string, or suffixing `Enum` after a fused `basename` + `column`
452
+ string).
453
+
454
+ If you still need to disambiguate or narrow the surface, you can:
455
+
456
+ * set `type_name` on a resource so its enums get a different prefix;
457
+ * use `only` / `except` to omit an enum attribute from GraphQL;
458
+ * define a custom field in `configure` that uses your own shared enum type.
459
+
460
+ ## Custom pages
461
+
462
+ `register_page` can add query fields:
463
+
464
+ ```ruby
465
+ ActiveAdmin.register_page "Dashboard" do
466
+ graphql_field :open_tickets_count, GraphQL::Types::Int, null: false,
467
+ description: "Tickets still open" do |_user, _ctx|
468
+ Ticket.open.count
469
+ end
470
+ end
471
+ ```
472
+
473
+ The block receives `(current_user, context)`. Authorization uses the page as the
474
+ subject for read access.
475
+
476
+ ## Custom schema class hook
477
+
478
+ ```ruby
479
+ config.namespace :admin do |admin|
480
+ admin.graphql = true
481
+ admin.graphql_configure_schema = lambda do |schema|
482
+ # schema.max_complexity(...) etc. if not using namespace settings
483
+ # schema.use MyGraphQLExtension
484
+ end
485
+ end
486
+ ```
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module GraphQL
5
+ # Authorization adapter wrapper for GraphQL +context+.
6
+ class AuthContext
7
+ attr_reader :user, :namespace
8
+
9
+ def initialize(user:, namespace:)
10
+ @user = user
11
+ @namespace = namespace
12
+ @adapter_by_resource_id = {}
13
+ end
14
+
15
+ def adapter_for(aa_resource)
16
+ @adapter_by_resource_id[aa_resource.object_id] ||= begin
17
+ klass = namespace.authorization_adapter
18
+ klass = klass.constantize if klass.is_a?(String)
19
+ klass.new(aa_resource, user)
20
+ end
21
+ end
22
+
23
+ def authorized?(aa_resource, action, subject = nil)
24
+ adapter_for(aa_resource).authorized?(action, subject)
25
+ end
26
+
27
+ # Delegates to the namespace authorization adapter. Built-in resolvers scope
28
+ # collections through +ResourceController+ instead of calling this; it remains
29
+ # available for custom schema extensions or host-app glue code.
30
+ def scope_collection(aa_resource, relation, action = ActiveAdmin::Authorization::READ)
31
+ adapter_for(aa_resource).scope_collection(relation, action)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module GraphQL
5
+ class Engine < ::Rails::Engine
6
+ engine_name "activeadmin_graphql"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource_definition_dsl"
4
+
5
+ module ActiveAdmin
6
+ module GraphQL
7
+ # Hooks graphql configuration and routing into ActiveAdmin when this gem is loaded.
8
+ module Integration
9
+ module_function
10
+
11
+ def install!
12
+ return if @installed
13
+
14
+ @installed = true
15
+ ActiveAdmin::Application.prepend(ApplicationUnloadClearsGraphQLSchema)
16
+ register_namespace_settings!
17
+ ActiveAdmin::Resource.prepend(ResourceMethods)
18
+ ActiveAdmin::ResourceDSL.prepend(ResourceDSLMethods)
19
+ ActiveAdmin::Page.prepend(PageMethods)
20
+ ActiveAdmin::PageDSL.prepend(PageDSLMethods)
21
+ ActiveAdmin::Router.prepend(RouterMethods)
22
+ register_railtie!
23
+ end
24
+
25
+ def register_namespace_settings!
26
+ ns = ActiveAdmin::NamespaceSettings
27
+ ns.register :graphql, false
28
+ ns.register :graphql_path, "graphql"
29
+ ns.register :graphql_multiplex_max, 20
30
+ ns.register :graphql_dataloader, nil
31
+ ns.register :graphql_visibility, nil
32
+ ns.register :graphql_visibility_profile, nil
33
+ ns.register :graphql_schema_visible, nil
34
+ ns.register :graphql_max_complexity, nil
35
+ ns.register :graphql_max_depth, nil
36
+ ns.register :graphql_default_page_size, nil
37
+ ns.register :graphql_default_max_page_size, nil
38
+ ns.register :graphql_configure_schema, nil
39
+ end
40
+
41
+ def register_railtie!
42
+ return unless defined?(Rails::Railtie)
43
+
44
+ require_relative "railtie"
45
+ end
46
+
47
+ # Stale cached schemas keep references to resources/controllers cleared by +unload!+
48
+ # (e.g. specs that reload registrations). Always drop cached schema when AA unloads.
49
+ module ApplicationUnloadClearsGraphQLSchema
50
+ def unload!
51
+ super
52
+ ActiveAdmin::GraphQL.clear_schema_cache!
53
+ end
54
+ end
55
+
56
+ module ResourceMethods
57
+ def graphql_config
58
+ @graphql_config ||= ActiveAdmin::GraphQL::ResourceConfig.new
59
+ end
60
+
61
+ def attributes_for_graphql
62
+ keys = resource_attributes.keys
63
+ cfg = graphql_config
64
+ if cfg.only_attributes
65
+ keys &= cfg.only_attributes
66
+ end
67
+ keys -= cfg.exclude_attributes
68
+ keys
69
+ end
70
+
71
+ def graphql_assignable_attribute_names
72
+ names = attributes_for_graphql.map(&:to_s)
73
+ pk_cols = ActiveAdmin::PrimaryKey.columns(resource_class)
74
+ return names if pk_cols.size > 1
75
+
76
+ names - pk_cols
77
+ end
78
+ end
79
+
80
+ module ResourceDSLMethods
81
+ def graphql(&block)
82
+ if block
83
+ ActiveAdmin::GraphQL::ResourceDefinitionDSL.new(config.graphql_config).instance_exec(&block)
84
+ end
85
+ config.graphql_config
86
+ end
87
+ end
88
+
89
+ module PageMethods
90
+ def graphql_fields
91
+ @graphql_fields ||= []
92
+ end
93
+ end
94
+
95
+ module PageDSLMethods
96
+ def graphql_field(field_name, graphql_type, null: true, description: nil, &block)
97
+ config.graphql_fields << {
98
+ name: field_name.to_s,
99
+ type: graphql_type,
100
+ null: null,
101
+ description: description,
102
+ resolver: block
103
+ }
104
+ end
105
+ end
106
+
107
+ module RouterMethods
108
+ def apply
109
+ define_root_routes
110
+ define_graphql_routes
111
+ define_resources_routes
112
+ end
113
+
114
+ private
115
+
116
+ def define_graphql_routes
117
+ namespaces.each do |namespace|
118
+ next unless namespace.graphql
119
+
120
+ segment = namespace.graphql_path.to_s.delete_prefix("/").presence || "graphql"
121
+ defaults = {active_admin_namespace: namespace.name}
122
+
123
+ if namespace.root?
124
+ router.post segment, controller: "/active_admin/graphql", action: "execute", defaults: defaults
125
+ else
126
+ router.namespace namespace.name, **namespace.route_options.dup do
127
+ router.post segment, controller: "/active_admin/graphql", action: "execute", defaults: defaults
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end