lighthouse-graphql 0.2.1 → 0.4.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/README.md +22 -3
  4. data/docs/best-practices.md +16 -6
  5. data/docs/caching.md +1 -1
  6. data/docs/configuration.md +74 -5
  7. data/docs/custom-directives.md +11 -1
  8. data/docs/directives.md +333 -2
  9. data/docs/events.md +203 -0
  10. data/docs/federation.md +3 -2
  11. data/docs/getting-started.md +90 -23
  12. data/docs/motivation.md +3 -2
  13. data/docs/mutations.md +15 -0
  14. data/docs/resolvers.md +95 -19
  15. data/docs/security.md +210 -0
  16. data/docs/subscriptions.md +446 -0
  17. data/lib/generators/lighthouse/base_classes.rb +87 -0
  18. data/lib/generators/lighthouse/class_generator.rb +19 -8
  19. data/lib/generators/lighthouse/install/install_generator.rb +23 -6
  20. data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +7 -45
  21. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +34 -5
  22. data/lib/generators/lighthouse/install/templates/schema.rb.tt +14 -2
  23. data/lib/generators/lighthouse/mutation_generator.rb +5 -4
  24. data/lib/generators/lighthouse/query_generator.rb +5 -4
  25. data/lib/generators/lighthouse/subscription_generator.rb +100 -0
  26. data/lib/lighthouse/broadcast_job.rb +14 -0
  27. data/lib/lighthouse/broadcasting.rb +143 -0
  28. data/lib/lighthouse/cache_control.rb +5 -2
  29. data/lib/lighthouse/complexity.rb +174 -0
  30. data/lib/lighthouse/complexity_analyzer.rb +56 -0
  31. data/lib/lighthouse/config.rb +56 -14
  32. data/lib/lighthouse/controller.rb +131 -0
  33. data/lib/lighthouse/directive_resolvers/broadcast_resolver.rb +31 -0
  34. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +14 -4
  35. data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +134 -0
  36. data/lib/lighthouse/directive_resolvers/event_resolver.rb +37 -0
  37. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +28 -13
  38. data/lib/lighthouse/directive_resolvers/soft_delete_resolvers.rb +66 -0
  39. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +76 -0
  40. data/lib/lighthouse/directives/arguments/soft_deletes.rb +112 -0
  41. data/lib/lighthouse/directives/registry.rb +15 -6
  42. data/lib/lighthouse/directives/soft_deletes_directive.rb +123 -0
  43. data/lib/lighthouse/events/execution_trace.rb +52 -0
  44. data/lib/lighthouse/events/lifecycle.rb +148 -0
  45. data/lib/lighthouse/events/schema_build.rb +44 -0
  46. data/lib/lighthouse/events.rb +76 -0
  47. data/lib/lighthouse/feature_directive.rb +137 -0
  48. data/lib/lighthouse/field_resolver_stack.rb +28 -7
  49. data/lib/lighthouse/graphql/version.rb +1 -1
  50. data/lib/lighthouse/namespaces.rb +1 -0
  51. data/lib/lighthouse/persisted_queries/batch.rb +34 -0
  52. data/lib/lighthouse/persisted_queries.rb +17 -4
  53. data/lib/lighthouse/rb_lighthouse.rb +23 -0
  54. data/lib/lighthouse/resolver_bases.rb +61 -0
  55. data/lib/lighthouse/schema_cache.rb +12 -5
  56. data/lib/lighthouse/schema_factory.rb +0 -1
  57. data/lib/lighthouse/schema_generator.rb +42 -9
  58. data/lib/lighthouse/sdl_loader.rb +0 -2
  59. data/lib/lighthouse/subscription.rb +120 -0
  60. data/lib/lighthouse/support/model_resolver.rb +1 -1
  61. data/lib/lighthouse/support/soft_deletes.rb +164 -0
  62. data/lib/lighthouse/tasks/lighthouse.rake +10 -0
  63. data/sig/lighthouse/controller.rbs +30 -0
  64. data/sig/lighthouse/events.rbs +122 -0
  65. data/sig/lighthouse/graphql.rbs +62 -0
  66. data/sig/lighthouse.rbs +52 -1
  67. metadata +29 -1
data/docs/directives.md CHANGED
@@ -150,8 +150,9 @@ The relation name defaults to the field name (snake_cased). Override with
150
150
 
151
151
  Delegate a field to a class — your escape hatch for anything the built-in
152
152
  directives don't cover. On `Query` and `Mutation` you usually don't need it:
153
- a field without a resolving directive resolves to `Queries::<FieldName>` /
154
- `Mutations::<FieldName>`, graphql-ruby resolver and mutation classes. See
153
+ a field without a resolving directive resolves to `Resolvers::<FieldName>` /
154
+ `Mutations::<FieldName>`, graphql-ruby resolver and mutation classes under
155
+ `Resolvers::BaseResolver` / `Mutations::BaseMutation`. See
155
156
  [resolvers.md](resolvers.md).
156
157
 
157
158
  ```graphql
@@ -318,6 +319,33 @@ type Query {
318
319
  }
319
320
  ```
320
321
 
322
+ ### `@convertEmptyStringsToNull`
323
+
324
+ Replaces incoming empty strings `""` with `null`, before any resolver sees them —
325
+ directive resolvers (`@create`, `@all` filters, …), `@field`, `Resolvers::` /
326
+ `Mutations::` classes and `@validator` alike. On an argument or an input field it
327
+ converts that one input; on a field it converts every input of the field, nested
328
+ input objects and lists included. Non-null inputs (`String!`) pass unchanged when
329
+ the directive is only on the field: placing it on the non-null argument or input
330
+ field itself forces the conversion.
331
+
332
+ ```graphql
333
+ type Mutation {
334
+ createPost(title: String @convertEmptyStringsToNull, body: String): Post! @create
335
+ signUp(input: SignUpInput!): User! @create @convertEmptyStringsToNull
336
+ }
337
+
338
+ input SignUpInput {
339
+ name: String # "" becomes null on signUp, but not where no directive applies
340
+ nickname: String @convertEmptyStringsToNull # on every field taking SignUpInput
341
+ email: String! # unchanged; add the directive here to force it
342
+ }
343
+ ```
344
+
345
+ A directive on an input field applies wherever the input type is used; one on a
346
+ field affects only that field. To enable it for every field, set
347
+ [`convert_empty_strings_to_null`](configuration.md#convert_empty_strings_to_null).
348
+
321
349
  ---
322
350
 
323
351
  ## Mutation directives
@@ -342,6 +370,197 @@ See [mutations.md](mutations.md).
342
370
 
343
371
  ---
344
372
 
373
+ ## Soft deletes & global scopes
374
+
375
+ Rails has no built-in soft deletes or named global scopes, so these directives
376
+ work with what Rails apps use. Lighthouse detects, per model, in this order:
377
+
378
+ 1. **[paranoia](https://github.com/rubysherpas/paranoia)** (`acts_as_paranoid`):
379
+ a default scope hides trashed records.
380
+ 2. **[discard](https://github.com/jhawthorn/discard)** (`include Discard::Model`):
381
+ nothing is hidden, unless the model adds `default_scope -> { kept }`.
382
+ 3. **A plain `deleted_at` column**, with no gem (a default scope is optional).
383
+
384
+ An app with its own scheme, or another column, sets
385
+ [`Config.soft_deletes`](configuration.md#soft_deletes). A model that matches none
386
+ of them is an error when a client uses one of these directives on it
387
+ (`Model Widget does not support soft deletes …`).
388
+
389
+ ### `@softDeletes`
390
+
391
+ Lets clients filter trashed records. It adds the argument
392
+ `trashed: Trashed @trashed` to the field and defines the `Trashed` enum once
393
+ (unless your schema declares its own).
394
+
395
+ ```graphql
396
+ type Query {
397
+ tasks: [Task!]! @all @softDeletes
398
+ }
399
+ ```
400
+
401
+ becomes
402
+
403
+ ```graphql
404
+ type Query {
405
+ tasks(trashed: Trashed @trashed): [Task!]! @all
406
+ }
407
+
408
+ "Specify if you want to include or exclude trashed results from a query."
409
+ enum Trashed {
410
+ "Only return trashed results."
411
+ ONLY
412
+ "Return both trashed and non-trashed results."
413
+ WITH
414
+ "Only return non-trashed results."
415
+ WITHOUT
416
+ }
417
+ ```
418
+
419
+ ### `@trashed`
420
+
421
+ The argument builder `@softDeletes` adds; use it by hand to name the argument
422
+ yourself. It works wherever argument filters do: `@all`, `@paginate`, `@find`,
423
+ `@first`, `@count` and the relationship directives (`@hasMany`, …, including
424
+ paginated ones, which keep batching through the Dataloader).
425
+
426
+ ```graphql
427
+ type Query {
428
+ flights(trashed: Trashed @trashed, destination: String @eq): [Flight!]! @all
429
+ }
430
+ type Post {
431
+ comments(trashed: Trashed @trashed): [Comment!]! @hasMany
432
+ }
433
+ ```
434
+
435
+ | Value | Result |
436
+ | --- | --- |
437
+ | `ONLY` | only trashed records |
438
+ | `WITH` | trashed and non-trashed records |
439
+ | `WITHOUT` | only non-trashed records |
440
+ | *(omitted)* | **the model's own behaviour, untouched**: paranoia and a default scope hide trashed records, a bare `deleted_at` column or discard without a default scope show everything |
441
+
442
+ `ONLY`, `WITH` and `WITHOUT` lift the model's default scope on the soft-delete
443
+ column first, so they behave the same whatever the model does by default. That
444
+ is `unscope(where: column)`, which removes every condition on that column from
445
+ the query, including one added by an argument declared before `trashed`. Avoid a
446
+ separate filter on the soft-delete column itself.
447
+
448
+ ### `@forceDelete`
449
+
450
+ Permanently removes one or more soft deleted models. It works like
451
+ [`@delete`](mutations.md#delete): the filter arguments (`@whereKey`, `@eq`, …) are
452
+ required (`Would modify all models, use an argument to filter.`), the removed
453
+ models are returned (a list for a list field, else the first or `null`), and
454
+ `model:` and `scopes:` are honoured. It finds trashed models even when the model
455
+ hides them by default, and it removes live ones too. It runs in a transaction
456
+ (`Config.transactional_mutations`).
457
+
458
+ ```graphql
459
+ type Mutation {
460
+ forceDeletePost(id: ID! @whereKey): Post @forceDelete
461
+ forceDeletePosts(ids: [ID!]! @whereKey): [Post!]! @forceDelete
462
+ }
463
+ ```
464
+
465
+ paranoia models are removed with `really_destroy!`, discard and `deleted_at`
466
+ models with `destroy`. A model whose callbacks refuse is reported as
467
+ `Could not modify model Post with ID 1.`
468
+
469
+ ### `@restore`
470
+
471
+ Un-deletes one or more soft deleted models, with the same arguments, guard and
472
+ result as `@forceDelete`: paranoia's `restore`, discard's `undiscard`, or setting
473
+ the `deleted_at` column to `NULL`.
474
+
475
+ ```graphql
476
+ type Mutation {
477
+ restorePost(id: ID! @whereKey): Post @restore
478
+ }
479
+ ```
480
+
481
+ ### `@withoutGlobalScopes`
482
+
483
+ Omits global scopes from the query when the client passes `true`. Use it on a
484
+ `Boolean` argument; `false` and an omitted argument leave the scopes in place.
485
+
486
+ ```graphql
487
+ type Query {
488
+ posts(includeUnscheduled: Boolean @withoutGlobalScopes(names: ["scheduled"])): [Post!]! @all
489
+ }
490
+ ```
491
+
492
+ ActiveRecord has one anonymous `default_scope`, not scopes with names, so each
493
+ name is resolved like this (snake_cased; a trailing `Scope` is optional):
494
+
495
+ 1. A model scope **`without_<name>`**, if the model has one. You write it:
496
+ `scope :without_scheduled, -> { unscope(where: :schedule_at) }`.
497
+ 2. Else a **column** of that name: `unscope(where: column)`. Like `@trashed`, this
498
+ removes every condition on that column from the query, including ones added by
499
+ earlier arguments.
500
+ 3. Else a soft-delete name (`softDeletes`, `SoftDeletingScope`): trashed records
501
+ are included, as with `trashed: WITH`.
502
+
503
+ Any other name is an error: `Cannot omit the global scope …`.
504
+
505
+ ---
506
+
507
+ ## Subscriptions
508
+
509
+ Fields of the `Subscription` type resolve to a `Subscriptions::` class by
510
+ convention, like queries and mutations: a plain graphql-ruby
511
+ `GraphQL::Schema::Subscription` (`authorized?`, `subscribe`, `update`, ...).
512
+ `@subscription` names another one, and `@broadcast` triggers a subscription from
513
+ a mutation. The backend (ActionCable, ...) is the schema's; see
514
+ [subscriptions.md](subscriptions.md).
515
+
516
+ ### `@subscription`
517
+
518
+ ```graphql
519
+ directive @subscription(class: String!) on FIELD_DEFINITION
520
+ ```
521
+
522
+ Names the class a `Subscription` field resolves to, when it does not follow the
523
+ convention (`postUpdated` → `Subscriptions::PostUpdated`). The class inherits from
524
+ `Subscriptions::BaseSubscription`, a `GraphQL::Schema::Subscription` that includes
525
+ `Lighthouse::GraphQL::Subscription` (PHP Lighthouse's `filter?` hooks), and is looked
526
+ up in `Config.namespaces[:subscriptions]`; a name with `::` is used as it is.
527
+
528
+ ```graphql
529
+ type Subscription {
530
+ postUpdated(author: ID!): Post @subscription(class: "Blog::PostUpdatedSubscription")
531
+ }
532
+ ```
533
+
534
+ ### `@broadcast`
535
+
536
+ ```graphql
537
+ directive @broadcast(subscription: String!, shouldQueue: Boolean) repeatable on FIELD_DEFINITION
538
+ ```
539
+
540
+ Once the field has resolved, triggers the `subscription` field of the
541
+ `Subscription` type with the result as its root, so its subscribers are updated,
542
+ each passing through the class's `update` (or `filter?`). The class's
543
+ `self.trigger_arguments(root, context)` says which subscribers' arguments the
544
+ trigger must equal: none are needed for a field without arguments, and a plain
545
+ class with a required argument and no `trigger_arguments` is reported as a
546
+ `Lighthouse::DefinitionError`. A failed mutation, or a `nil` result, broadcasts
547
+ nothing. An unknown subscription name is a
548
+ `Lighthouse::DefinitionError` when the schema is built. `shouldQueue` performs the
549
+ trigger in an ActiveJob; without it, `Config.queue_broadcasts` (default `false`)
550
+ decides.
551
+
552
+ ```graphql
553
+ type Mutation {
554
+ createPost(input: CreatePostInput! @spread): Post! @create @broadcast(subscription: "postCreated")
555
+ updatePost(input: UpdatePostInput! @spread): Post!
556
+ @update
557
+ @broadcast(subscription: "postUpdated", shouldQueue: false)
558
+ @broadcast(subscription: "postChanged")
559
+ }
560
+ ```
561
+
562
+ ---
563
+
345
564
  ## Validation
346
565
 
347
566
  `@validator` runs a contract (Dry::Validation by default) before a field
@@ -369,6 +588,118 @@ type Query {
369
588
 
370
589
  ---
371
590
 
591
+ ## Feature flags
592
+
593
+ ### `@feature`
594
+
595
+ Include a field or an object type in the schema depending on a feature flag,
596
+ backed by [Flipper](https://www.flippercloud.io/docs) (PHP Lighthouse's
597
+ `@feature` is backed by Laravel Pennant).
598
+
599
+ ```graphql
600
+ type Query {
601
+ newCheckout: Checkout @feature(name: "new_checkout")
602
+ legacyCheckout: Checkout @feature(name: "new_checkout", when: INACTIVE)
603
+ }
604
+
605
+ type Beta @feature(name: "beta") {
606
+ id: ID!
607
+ }
608
+ ```
609
+
610
+ | Argument | Meaning |
611
+ | --- | --- |
612
+ | `name: String!` | The feature to check. |
613
+ | `when: FeatureState = ACTIVE` | The state the feature must be in for the element to be included: `ACTIVE` or `INACTIVE`. |
614
+
615
+ Feature flags depend on who asks, while the schema is built once, so the element
616
+ stays in the schema and is hidden **per query**, through graphql-ruby's
617
+ [visibility](https://graphql-ruby.org/authorization/visibility.html). A hidden
618
+ field or type disappears from introspection, and a query selecting it fails
619
+ validation (`Field 'newCheckout' doesn't exist on type 'Query'`). The fields
620
+ returning a hidden type are hidden with it. `to_definition` prints the schema as
621
+ the features stand for no actor.
622
+
623
+ Each feature is checked at most once per query. By default Flipper is asked
624
+ whether the feature is enabled for the authenticated user (`Flipper.enabled?(:new_checkout, user)`;
625
+ the user answers `flipper_id`, which Flipper's ActiveRecord integration adds to
626
+ models); with no user, the global gate decides. Flipper is optional: add
627
+ `gem "flipper"` to your Gemfile, or configure your own check with
628
+ `feature_checker` and `feature_actor` (see [configuration.md](configuration.md#feature_checker-feature_actor)).
629
+
630
+ ```ruby
631
+ Flipper.enable_actor(:new_checkout, User.find(1))
632
+
633
+ # schema.execute(query, context: { current_user: user })
634
+ ```
635
+
636
+ ---
637
+
638
+ ## Query complexity
639
+
640
+ ### `@complexity`
641
+
642
+ Customize what a field costs in the query's complexity score, which graphql-ruby
643
+ checks against the schema's `max_complexity` before it runs the query.
644
+
645
+ ```graphql
646
+ type Query {
647
+ search(first: Int, term: String!): [Post!]! @complexity
648
+ posts(includeFullText: Boolean): [Post!]! @all @complexity(resolver: "PostsComplexity")
649
+ }
650
+ ```
651
+
652
+ | Argument | Meaning |
653
+ | --- | --- |
654
+ | `resolver: String` | A `GraphQL::Analysis::Analyzer` subclass in `namespaces[:analyzers]` (`Analyzers::PostsComplexity`), installed on the schema. With `Lighthouse::GraphQL::ComplexityAnalyzer` included, its `complexity` (a number, or a proc of `context, args, child_complexity`) is the field's cost. Without it, or without `resolver:`: `1 + child complexity × first`. |
655
+
656
+ Paginated fields cost `1 + child complexity × first` without the directive, and a
657
+ field resolved by a `GraphQL::Schema::Resolver` class costs what that class
658
+ declares. See [security.md](security.md).
659
+
660
+ ---
661
+
662
+ ## Events
663
+
664
+ `@event` dispatches an event once the field has resolved. The class is built with
665
+ the resolved value and handed to `Lighthouse::Events.dispatch`; the value is
666
+ returned unchanged, and nothing is dispatched when the field raises. It is
667
+ repeatable: each `@event` dispatches its own. See [events.md](events.md) for
668
+ listening, and for the events Lighthouse itself dispatches.
669
+
670
+ ```graphql
671
+ directive @event(dispatch: String!) repeatable on FIELD_DEFINITION
672
+ ```
673
+
674
+ ```graphql
675
+ type Mutation {
676
+ shipOrder(id: ID!): Order!
677
+ @update
678
+ @event(dispatch: "OrderShipped")
679
+ @event(dispatch: "Billing::InvoiceDue")
680
+ }
681
+ ```
682
+
683
+ ```ruby
684
+ class OrderShipped
685
+ attr_reader :order
686
+
687
+ def initialize(order) = @order = order
688
+ end
689
+
690
+ Lighthouse::Events.listen(OrderShipped) { |event| Mailer.shipped(event.order).deliver_later }
691
+ ```
692
+
693
+ The class is looked up in `Config.namespaces[:events]` (default `['Events']`,
694
+ so `Events::OrderShipped`, then `::OrderShipped`); a name with `::` is used
695
+ as-is. A name that finds no class fails the schema build with
696
+ `Lighthouse::DefinitionError`. `@event` runs inside `@guard`, `@can*`,
697
+ `@validator` and `@cache`: a rejected request, or a cache hit, dispatches
698
+ nothing. (`@canResolved` checks the resolved value, so there the event is
699
+ dispatched before the check.)
700
+
701
+ ---
702
+
372
703
  ## Adding your own
373
704
 
374
705
  Every directive above is a small class registered with
data/docs/events.md ADDED
@@ -0,0 +1,203 @@
1
+ # Events
2
+
3
+ Lighthouse dispatches events as a request goes by and as the schema is built, so
4
+ an app can hook into them without patching either. They mirror
5
+ [PHP Lighthouse's events](https://lighthouse-php.com/master/api-reference/events.html),
6
+ adapted to Ruby: a listener is a block or anything that responds to `call(event)`.
7
+
8
+ ```ruby
9
+ # config/initializers/lighthouse.rb
10
+ Lighthouse::Events.listen(Lighthouse::Events::StartExecution) do |event|
11
+ Rails.logger.info("GraphQL #{event.operation_name} at #{event.moment}")
12
+ end
13
+ ```
14
+
15
+ ## Listening
16
+
17
+ ```ruby
18
+ listener = Lighthouse::Events.listen(Lighthouse::Events::EndExecution) { |event| Metrics.record(event.result) }
19
+ Lighthouse::Events.listen(Lighthouse::Events::EndRequest, AuditLog) # anything with #call
20
+ Lighthouse::Events.forget(listener) # stop listening
21
+ ```
22
+
23
+ - Listeners run in registration order. A listener on an event class also hears
24
+ its subclasses, so one on `Lighthouse::Events::Event` hears everything.
25
+ - `Lighthouse::Events.dispatch(event)` returns the non-nil values the listeners
26
+ returned. Some events give them a meaning (below); for the others they are
27
+ ignored, so a listener's last expression does no harm.
28
+ - Register in an initializer. Specs can call `Lighthouse::Events.reset!` to drop
29
+ every listener.
30
+
31
+ ### ActiveSupport::Notifications
32
+
33
+ Every dispatch is also instrumented as `<event name>.lighthouse`, with the event
34
+ as the `:event` payload, so Rails tooling observes them with no listener of ours:
35
+
36
+ ```ruby
37
+ ActiveSupport::Notifications.subscribe('end_execution.lighthouse') do |*, payload|
38
+ Rails.logger.info(payload[:event].result['errors'])
39
+ end
40
+ ```
41
+
42
+ The name is the class name underscored: `start_request.lighthouse`,
43
+ `build_schema_string.lighthouse`, ...
44
+
45
+ ## Request lifecycle
46
+
47
+ In the order they happen. Events with a `moment` carry the `Time` they were
48
+ created at (UTC).
49
+
50
+ | Event | When | Readers |
51
+ | --- | --- | --- |
52
+ | `StartRequest` | The controller action begins | `request`, `moment` |
53
+ | `StartOperationOrOperations` | The operation(s) were read, before they run | `operation_or_operations` |
54
+ | `StartExecution` | A query is about to run (once per operation of a batch) | `schema`, `query`, `variables`, `operation_name`, `context`, `moment` |
55
+ | `BuildExtensionsResponse` | A query ran | `result`, `query_complexity` |
56
+ | `ManipulateResult` | Before the result is returned | `result` (+ writer) |
57
+ | `EndExecution` | The result is final | `result`, `moment` |
58
+ | `EndOperationOrOperations` | The operation(s) ran | `result_or_results` |
59
+ | `EndRequest` | The response is rendered | `response`, `moment` |
60
+
61
+ `StartRequest` and `EndRequest` are dispatched by `execute`, the action of the
62
+ `Lighthouse::GraphQL::Controller` concern that `rails g lighthouse:install`
63
+ generates a controller with ([getting-started.md](getting-started.md)):
64
+ the first thing it does, and right after it renders. An action of your own gets
65
+ them by wrapping its body in `lighthouse_request`, which dispatches them once
66
+ however it nests:
67
+
68
+ ```ruby
69
+ def search
70
+ lighthouse_request do
71
+ render json: Lighthouse::GraphQL::PersistedQueries.execute(AppSchema, query: params[:q])
72
+ end
73
+ end
74
+ ```
75
+
76
+ Without the concern, dispatch them yourself:
77
+ `Lighthouse::Events.dispatch(Lighthouse::Events::StartRequest.new(request))`, and
78
+ `EndRequest.new(response)` after rendering.
79
+
80
+ `StartOperationOrOperations` and `EndOperationOrOperations` are dispatched by
81
+ `Lighthouse::GraphQL::PersistedQueries.execute`, which the generated controller
82
+ calls. The first carries a Hash (`query`, `variables`, `operation_name`,
83
+ `extensions`), as the request named them. For a batch, run the operations through
84
+ `PersistedQueries.multiplex(schema, operations, context: ...)` instead (the concern does for a JSON array body): it takes
85
+ an Array of such Hashes (each may also carry its own `context`), dispatches the
86
+ Arrays, and answers one result per operation. Operations that run straight on
87
+ `schema.execute` or `schema.multiplex` fire only the execution events below.
88
+
89
+ ### Execution events
90
+
91
+ `StartExecution`, `BuildExtensionsResponse`, `ManipulateResult` and
92
+ `EndExecution` come from a graphql-ruby trace Lighthouse installs on every schema
93
+ it builds, so they fire however the query was started: the controller,
94
+ `schema.execute` in a job, or a spec. A multiplex fires them once per query.
95
+ With no listener, and no `ActiveSupport::Notifications` subscriber, they cost
96
+ one lookup per request.
97
+
98
+ `StartExecution#query` is the parsed `GraphQL::Language::Nodes::Document`.
99
+
100
+ **`BuildExtensionsResponse`**: return an `ExtensionsResponse` (or an Array of
101
+ them) to add to the result's `extensions`:
102
+
103
+ ```ruby
104
+ Lighthouse::Events.listen(Lighthouse::Events::BuildExtensionsResponse) do |event|
105
+ Lighthouse::Events::ExtensionsResponse.new('queries', Query.count_for(event.result))
106
+ end
107
+ # => { "data" => {...}, "extensions" => { "queries" => 3 } }
108
+ ```
109
+
110
+ `query_complexity` is the query's complexity score, the number graphql-ruby
111
+ checks against `max_complexity` (see [security.md](security.md#reading-the-score)).
112
+ It is `nil` for a query that failed validation.
113
+
114
+ **`ManipulateResult`**: change `event.result` (the Hash with `data`, `errors` and
115
+ `extensions`) in place, or assign a new Hash to `event.result=`:
116
+
117
+ ```ruby
118
+ Lighthouse::Events.listen(Lighthouse::Events::ManipulateResult) do |event|
119
+ event.result['extensions'] = (event.result['extensions'] || {}).merge('served_by' => Socket.gethostname)
120
+ nil
121
+ end
122
+ ```
123
+
124
+ Both also fire for a query that fails validation; the result then holds `errors`
125
+ and no `data`.
126
+
127
+ ## Schema build events
128
+
129
+ These fire while the schema is built, once per build. Listen from an initializer
130
+ that runs before the schema is first used (the schema constant autoloads when
131
+ something first refers to it).
132
+
133
+ **`RegisterDirectiveNamespaces`**: return a namespace (a `Module`, or its name)
134
+ or an Array of them. Every directive class under them (a constant that responds
135
+ to `directive_name`: a field resolver or an argument builder) is registered, as
136
+ if with `Lighthouse::DirectiveRegistry.register`:
137
+
138
+ ```ruby
139
+ Lighthouse::Events.listen(Lighthouse::Events::RegisterDirectiveNamespaces) do
140
+ %w[App::Graphql::Directives MyEngine::Directives]
141
+ end
142
+ ```
143
+
144
+ A name that is no constant fails the build with `Lighthouse::DefinitionError`.
145
+ It fires before the directive definitions are injected, on every build,
146
+ schema cache or not.
147
+
148
+ **`BuildSchemaString`**: return SDL to append to the app's schema, before the
149
+ manipulation phase (so it can use any directive):
150
+
151
+ ```ruby
152
+ Lighthouse::Events.listen(Lighthouse::Events::BuildSchemaString) do |event|
153
+ 'extend type Query { version: String @field(resolver: "Version") }'
154
+ end
155
+ ```
156
+
157
+ **`ManipulateAST`**: the document after manipulation, right before the schema
158
+ is built from it. graphql-ruby's nodes are immutable, so replace the document:
159
+ `event.document_ast = GraphQL.parse(...)`.
160
+
161
+ With [`schema_cache`](performance.md#schema-cache) on, these two fire only when the document
162
+ is actually built, on a cache miss, and the cache holds what they made. The cache
163
+ key does not cover them: after you change what a listener adds, run
164
+ `bin/rails lighthouse:clear_cache`.
165
+
166
+ ## `ValidateSchema`
167
+
168
+ `bin/rails lighthouse:validate_schema` (`SCHEMA=AppSchema` names another schema)
169
+ dispatches `ValidateSchema` with the schema, then has graphql-ruby build and print
170
+ every type. A listener that raises, or a schema graphql-ruby rejects, aborts the
171
+ task with a message, so it can run in CI:
172
+
173
+ ```ruby
174
+ Lighthouse::Events.listen(Lighthouse::Events::ValidateSchema) do |event|
175
+ raise 'The schema has no mutations' unless event.schema.mutation
176
+ end
177
+ ```
178
+
179
+ ## Dispatching your own: `@event`
180
+
181
+ The [`@event`](directives.md#event) directive dispatches an event of yours once a
182
+ field has resolved:
183
+
184
+ ```graphql
185
+ type Mutation {
186
+ publishPost(id: ID!): Post! @update @event(dispatch: "PostPublished")
187
+ }
188
+ ```
189
+
190
+ ```ruby
191
+ # app/events/post_published.rb
192
+ class PostPublished
193
+ attr_reader :post
194
+
195
+ def initialize(post) = @post = post
196
+ end
197
+
198
+ Lighthouse::Events.listen(PostPublished) { |event| Mailer.published(event.post).deliver_later }
199
+ ```
200
+
201
+ Any class works as an event. Lighthouse looks the name up in
202
+ `Config.namespaces[:events]` (default `['Events']`, so `Events::PostPublished`,
203
+ then `::PostPublished`).
data/docs/federation.md CHANGED
@@ -13,7 +13,8 @@ Include the federation mixins in the base types you pass to Lighthouse, and make
13
13
  your schema a federation schema:
14
14
 
15
15
  ```ruby
16
- class BaseObject < GraphQL::Schema::Object
16
+ # app/graphql/types/base_object.rb
17
+ class Types::BaseObject < GraphQL::Schema::Object
17
18
  include ApolloFederation::Object
18
19
 
19
20
  # Default entity resolution: look the record up by id on the model that shares
@@ -29,7 +30,7 @@ end
29
30
 
30
31
  BaseAppSchema = Lighthouse::GraphQL::RbLightHouse.get_schema(
31
32
  sdl_folder: Rails.root.join('graphql').to_s,
32
- options: { federation: true, base_types: { object: BaseObject } }
33
+ options: { federation: true, base_types: { object: Types::BaseObject, resolver: Resolvers::BaseResolver } }
33
34
  )
34
35
 
35
36
  class AppSchema < BaseAppSchema