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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -1
- data/README.md +22 -3
- data/docs/best-practices.md +16 -6
- data/docs/caching.md +1 -1
- data/docs/configuration.md +74 -5
- data/docs/custom-directives.md +11 -1
- data/docs/directives.md +333 -2
- data/docs/events.md +203 -0
- data/docs/federation.md +3 -2
- data/docs/getting-started.md +90 -23
- data/docs/motivation.md +3 -2
- data/docs/mutations.md +15 -0
- data/docs/resolvers.md +95 -19
- data/docs/security.md +210 -0
- data/docs/subscriptions.md +446 -0
- data/lib/generators/lighthouse/base_classes.rb +87 -0
- data/lib/generators/lighthouse/class_generator.rb +19 -8
- data/lib/generators/lighthouse/install/install_generator.rb +23 -6
- data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +7 -45
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +34 -5
- data/lib/generators/lighthouse/install/templates/schema.rb.tt +14 -2
- data/lib/generators/lighthouse/mutation_generator.rb +5 -4
- data/lib/generators/lighthouse/query_generator.rb +5 -4
- data/lib/generators/lighthouse/subscription_generator.rb +100 -0
- data/lib/lighthouse/broadcast_job.rb +14 -0
- data/lib/lighthouse/broadcasting.rb +143 -0
- data/lib/lighthouse/cache_control.rb +5 -2
- data/lib/lighthouse/complexity.rb +174 -0
- data/lib/lighthouse/complexity_analyzer.rb +56 -0
- data/lib/lighthouse/config.rb +56 -14
- data/lib/lighthouse/controller.rb +131 -0
- data/lib/lighthouse/directive_resolvers/broadcast_resolver.rb +31 -0
- data/lib/lighthouse/directive_resolvers/convention_resolver.rb +14 -4
- data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +134 -0
- data/lib/lighthouse/directive_resolvers/event_resolver.rb +37 -0
- data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +28 -13
- data/lib/lighthouse/directive_resolvers/soft_delete_resolvers.rb +66 -0
- data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +76 -0
- data/lib/lighthouse/directives/arguments/soft_deletes.rb +112 -0
- data/lib/lighthouse/directives/registry.rb +15 -6
- data/lib/lighthouse/directives/soft_deletes_directive.rb +123 -0
- data/lib/lighthouse/events/execution_trace.rb +52 -0
- data/lib/lighthouse/events/lifecycle.rb +148 -0
- data/lib/lighthouse/events/schema_build.rb +44 -0
- data/lib/lighthouse/events.rb +76 -0
- data/lib/lighthouse/feature_directive.rb +137 -0
- data/lib/lighthouse/field_resolver_stack.rb +28 -7
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/namespaces.rb +1 -0
- data/lib/lighthouse/persisted_queries/batch.rb +34 -0
- data/lib/lighthouse/persisted_queries.rb +17 -4
- data/lib/lighthouse/rb_lighthouse.rb +23 -0
- data/lib/lighthouse/resolver_bases.rb +61 -0
- data/lib/lighthouse/schema_cache.rb +12 -5
- data/lib/lighthouse/schema_factory.rb +0 -1
- data/lib/lighthouse/schema_generator.rb +42 -9
- data/lib/lighthouse/sdl_loader.rb +0 -2
- data/lib/lighthouse/subscription.rb +120 -0
- data/lib/lighthouse/support/model_resolver.rb +1 -1
- data/lib/lighthouse/support/soft_deletes.rb +164 -0
- data/lib/lighthouse/tasks/lighthouse.rake +10 -0
- data/sig/lighthouse/controller.rbs +30 -0
- data/sig/lighthouse/events.rbs +122 -0
- data/sig/lighthouse/graphql.rbs +62 -0
- data/sig/lighthouse.rbs +52 -1
- 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 `
|
|
154
|
-
`Mutations::<FieldName>`, graphql-ruby resolver and mutation classes
|
|
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
|
-
|
|
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
|