lighthouse-graphql 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +175 -1
- data/README.md +47 -3
- data/docs/authorization.md +116 -57
- data/docs/best-practices.md +16 -3
- data/docs/caching.md +161 -0
- data/docs/configuration.md +83 -4
- data/docs/custom-directives.md +5 -1
- data/docs/directives.md +117 -38
- data/docs/getting-started.md +84 -0
- data/docs/motivation.md +184 -0
- data/docs/mutations.md +260 -0
- data/docs/performance.md +138 -0
- data/docs/relationships.md +80 -33
- data/docs/resolvers.md +189 -0
- data/docs/validation.md +132 -0
- data/lib/generators/lighthouse/builder_generator.rb +30 -0
- data/lib/generators/lighthouse/class_generator.rb +61 -0
- data/lib/generators/lighthouse/install/install_generator.rb +127 -0
- data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +8 -0
- data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +57 -0
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +67 -0
- data/lib/generators/lighthouse/install/templates/schema.graphql.tt +19 -0
- data/lib/generators/lighthouse/install/templates/schema.rb.tt +10 -0
- data/lib/generators/lighthouse/mutation_generator.rb +30 -0
- data/lib/generators/lighthouse/query_generator.rb +29 -0
- data/lib/generators/lighthouse/validator_generator.rb +36 -0
- data/lib/lighthouse/cache_control.rb +126 -0
- data/lib/lighthouse/caching.rb +68 -0
- data/lib/lighthouse/config.rb +85 -19
- data/lib/lighthouse/contracts/arg_builder.rb +8 -12
- data/lib/lighthouse/contracts/arg_manipulator.rb +8 -12
- data/lib/lighthouse/contracts/directive.rb +10 -14
- data/lib/lighthouse/contracts/field_manipulator.rb +8 -12
- data/lib/lighthouse/contracts/field_middleware.rb +7 -11
- data/lib/lighthouse/contracts/field_resolver.rb +9 -13
- data/lib/lighthouse/dataloader_sources/association_loader.rb +28 -19
- data/lib/lighthouse/dataloader_sources/model_loader.rb +10 -14
- data/lib/lighthouse/dataloader_sources/relation_count_loader.rb +16 -0
- data/lib/lighthouse/dataloader_sources/relation_page_loader.rb +18 -0
- data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +57 -69
- data/lib/lighthouse/directive_resolvers/all_resolver.rb +26 -57
- data/lib/lighthouse/directive_resolvers/association_resolvers.rb +161 -0
- data/lib/lighthouse/directive_resolvers/auth_resolver.rb +41 -38
- data/lib/lighthouse/directive_resolvers/base.rb +142 -86
- data/lib/lighthouse/directive_resolvers/builder_applier.rb +58 -56
- data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +133 -0
- data/lib/lighthouse/directive_resolvers/can_resolvers.rb +177 -0
- data/lib/lighthouse/directive_resolvers/convention_resolver.rb +70 -0
- data/lib/lighthouse/directive_resolvers/count_resolver.rb +42 -48
- data/lib/lighthouse/directive_resolvers/field_resolver.rb +27 -68
- data/lib/lighthouse/directive_resolvers/find_resolver.rb +42 -52
- data/lib/lighthouse/directive_resolvers/first_resolver.rb +39 -45
- data/lib/lighthouse/directive_resolvers/guard_resolver.rb +16 -22
- data/lib/lighthouse/directive_resolvers/method_resolver.rb +19 -25
- data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +219 -0
- data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +70 -128
- data/lib/lighthouse/directive_resolvers/rename_resolver.rb +18 -24
- data/lib/lighthouse/directive_resolvers/resolution.rb +31 -27
- data/lib/lighthouse/directive_resolvers/validator_resolver.rb +193 -0
- data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +164 -163
- data/lib/lighthouse/directives/arguments/base.rb +23 -31
- data/lib/lighthouse/directives/arguments/filters.rb +113 -93
- data/lib/lighthouse/directives/arguments/order_by.rb +41 -41
- data/lib/lighthouse/directives/pagination_info_types.rb +69 -0
- data/lib/lighthouse/directives/pagination_manipulator.rb +176 -0
- data/lib/lighthouse/directives/registry.rb +210 -141
- data/lib/lighthouse/directives/where_conditions_directive.rb +236 -200
- data/lib/lighthouse/errors.rb +27 -0
- data/lib/lighthouse/federation_directives.rb +53 -0
- data/lib/lighthouse/field_resolver_stack.rb +56 -0
- data/lib/lighthouse/generated_resolvers.rb +82 -0
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/graphql_ruby_patches.rb +18 -0
- data/lib/lighthouse/ide_helper.rb +98 -0
- data/lib/lighthouse/namespaces.rb +21 -0
- data/lib/lighthouse/nested_mutations/belongs_to_operations.rb +50 -0
- data/lib/lighthouse/nested_mutations/executor.rb +149 -0
- data/lib/lighthouse/nested_mutations/has_operations.rb +66 -0
- data/lib/lighthouse/nested_mutations/many_to_many_operations.rb +44 -0
- data/lib/lighthouse/nested_mutations/partitioner.rb +125 -0
- data/lib/lighthouse/nested_mutations.rb +210 -0
- data/lib/lighthouse/pagination.rb +221 -0
- data/lib/lighthouse/persisted_queries.rb +167 -0
- data/lib/lighthouse/railtie.rb +26 -0
- data/lib/lighthouse/rb_lighthouse.rb +50 -29
- data/lib/lighthouse/reference_resolver.rb +13 -15
- data/lib/lighthouse/relation_batch.rb +156 -0
- data/lib/lighthouse/resolver_dispatch.rb +26 -0
- data/lib/lighthouse/schema_cache.rb +120 -0
- data/lib/lighthouse/schema_factory.rb +27 -37
- data/lib/lighthouse/schema_generator.rb +196 -239
- data/lib/lighthouse/schema_implementation.rb +51 -48
- data/lib/lighthouse/sdl_loader.rb +17 -20
- data/lib/lighthouse/support/arguments.rb +119 -0
- data/lib/lighthouse/support/authorization.rb +18 -19
- data/lib/lighthouse/support/batching.rb +16 -0
- data/lib/lighthouse/support/directive_args.rb +26 -24
- data/lib/lighthouse/support/model_resolver.rb +56 -56
- data/lib/lighthouse/support/naming.rb +55 -59
- data/lib/lighthouse/tasks/lighthouse.rake +33 -0
- data/lib/lighthouse/where_conditions/operator_map.rb +25 -29
- data/sig/lighthouse/directives.rbs +158 -0
- data/sig/lighthouse/graphql.rbs +78 -0
- data/sig/lighthouse.rbs +142 -0
- metadata +56 -64
- data/lib/lighthouse/directive_resolvers/belongs_to_many_resolver.rb +0 -56
- data/lib/lighthouse/directive_resolvers/belongs_to_resolver.rb +0 -47
- data/lib/lighthouse/directive_resolvers/can_resolver.rb +0 -86
- data/lib/lighthouse/directive_resolvers/has_many_resolver.rb +0 -109
- data/lib/lighthouse/directive_resolvers/has_one_resolver.rb +0 -43
- data/lib/lighthouse/directives/relation_directive.rb +0 -332
data/docs/relationships.md
CHANGED
|
@@ -12,27 +12,47 @@ end
|
|
|
12
12
|
|
|
13
13
|
```graphql
|
|
14
14
|
type Post {
|
|
15
|
-
author: User! @belongsTo
|
|
15
|
+
author: User! @belongsTo
|
|
16
16
|
comments: [Comment!]! @hasMany
|
|
17
17
|
}
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
+
| Directive | ActiveRecord association |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `@belongsTo` | `belongs_to` |
|
|
23
|
+
| `@hasOne` | `has_one` |
|
|
24
|
+
| `@hasMany` | `has_many` |
|
|
25
|
+
| `@belongsToMany` | `has_many :through`, `has_and_belongs_to_many` |
|
|
26
|
+
| `@morphTo` | `belongs_to …, polymorphic: true` |
|
|
27
|
+
| `@morphOne` / `@morphMany` | `has_one` / `has_many …, as:` |
|
|
28
|
+
| `@morphToMany` | `has_many :through` a polymorphic join |
|
|
29
|
+
|
|
30
|
+
The `morph*` names are PHP Lighthouse's. ActiveRecord resolves polymorphic
|
|
31
|
+
associations the same way as any other, so they behave exactly like their
|
|
32
|
+
non-polymorphic counterparts. Use whichever reads better.
|
|
33
|
+
|
|
20
34
|
## Avoiding the N+1 problem
|
|
21
35
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
36
|
+
The relationship directives load through graphql-ruby's **Dataloader** and
|
|
37
|
+
ActiveRecord's preloader, one query per association per batch. Enable the
|
|
38
|
+
Dataloader on your schema (the install generator does):
|
|
25
39
|
|
|
26
40
|
```ruby
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
41
|
+
AppSchema = Lighthouse::GraphQL::RbLightHouse.get_schema(
|
|
42
|
+
options: { using: { GraphQL::Dataloader => {} } }
|
|
43
|
+
)
|
|
30
44
|
```
|
|
31
45
|
|
|
32
|
-
|
|
33
|
-
|
|
46
|
+
A query for 100 posts and their authors then issues two queries, not 101. A
|
|
47
|
+
many-to-many or `:through` association adds one query for the join table. Without
|
|
48
|
+
the Dataloader, the directives still work, but they load one parent at a time.
|
|
49
|
+
|
|
50
|
+
A field with `scopes:` or a filter argument the client supplied runs its own query
|
|
51
|
+
per parent, because each parent's query differs.
|
|
52
|
+
|
|
53
|
+
## Lists, and paginating them
|
|
34
54
|
|
|
35
|
-
|
|
55
|
+
Collection directives return a **plain list**, as in PHP Lighthouse:
|
|
36
56
|
|
|
37
57
|
```graphql
|
|
38
58
|
type User {
|
|
@@ -40,38 +60,59 @@ type User {
|
|
|
40
60
|
}
|
|
41
61
|
```
|
|
42
62
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
63
|
+
```graphql
|
|
64
|
+
{ user(id: 1) { posts { title } } }
|
|
65
|
+
```
|
|
46
66
|
|
|
47
|
-
|
|
67
|
+
To paginate, pass `type:`, which takes the same values as
|
|
68
|
+
[`@paginate`](directives.md#paginate): `PAGINATOR`, `SIMPLE` or `CONNECTION`.
|
|
69
|
+
Lighthouse rewrites the field and adds the pagination arguments:
|
|
48
70
|
|
|
49
71
|
```graphql
|
|
50
72
|
type User {
|
|
51
|
-
|
|
73
|
+
posts: [Post!]! @hasMany(type: PAGINATOR, defaultCount: 10)
|
|
74
|
+
feed: [Post!]! @hasMany(relation: "posts", type: CONNECTION)
|
|
52
75
|
}
|
|
53
76
|
```
|
|
54
77
|
|
|
55
|
-
## Inverse — `@belongsTo`
|
|
56
|
-
|
|
57
78
|
```graphql
|
|
58
|
-
|
|
59
|
-
|
|
79
|
+
{
|
|
80
|
+
user(id: 1) {
|
|
81
|
+
posts(first: 5, page: 2) { paginatorInfo { total lastPage } data { title } }
|
|
82
|
+
feed(first: 5, after: "NQ==") { pageInfo { hasNextPage endCursor } edges { node { title } } }
|
|
83
|
+
}
|
|
60
84
|
}
|
|
61
85
|
```
|
|
62
86
|
|
|
63
|
-
|
|
87
|
+
`defaultCount` and `maxCount` work as on `@paginate`.
|
|
64
88
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
89
|
+
### Paginating many parents at once
|
|
90
|
+
|
|
91
|
+
With the Dataloader, a paginated relation loads the pages of **every parent** of
|
|
92
|
+
a level together, as PHP Lighthouse does: `{ users { posts(first: 5) { … } } }`
|
|
93
|
+
costs one query for the pages, plus one for the totals if the client selects
|
|
94
|
+
`paginatorInfo` or `pageInfo` — however many users there are. The same goes for
|
|
95
|
+
`@paginate` on a nested field.
|
|
96
|
+
|
|
97
|
+
- **Pages.** A window function numbers each parent's rows in the field's order
|
|
98
|
+
(`@orderBy`, else the primary key), and one query keeps the rows of the
|
|
99
|
+
requested page: `ROW_NUMBER() OVER (PARTITION BY posts.user_id ORDER BY …)`.
|
|
100
|
+
PHP Lighthouse uses a `UNION ALL` of one query per parent instead; a window
|
|
101
|
+
function keeps the SQL the same size for 3 parents or 3,000.
|
|
102
|
+
- **Totals.** One `GROUP BY` counts every parent's related records, with the same
|
|
103
|
+
scopes and filters as the page. The count only runs when a total is asked for,
|
|
104
|
+
and never for `SIMPLE`, which fetches one extra row instead.
|
|
105
|
+
|
|
106
|
+
This needs window functions: PostgreSQL, MySQL 8+, MariaDB 10.2+ or SQLite 3.25+.
|
|
107
|
+
A relation falls back to one query per parent on other databases, without the
|
|
108
|
+
Dataloader, and when its rows can't be numbered per parent: a composite key, an
|
|
109
|
+
association scope that reads the owner (`has_many :x, ->(owner) { … }`), or
|
|
110
|
+
`DISTINCT`. The pages are the same either way.
|
|
70
111
|
|
|
71
112
|
## Renaming relations
|
|
72
113
|
|
|
73
|
-
By default the relation name
|
|
74
|
-
|
|
114
|
+
By default the relation name is the field name, snake_cased. When they differ,
|
|
115
|
+
pass `relation:`:
|
|
75
116
|
|
|
76
117
|
```graphql
|
|
77
118
|
type Post {
|
|
@@ -79,17 +120,23 @@ type Post {
|
|
|
79
120
|
}
|
|
80
121
|
```
|
|
81
122
|
|
|
82
|
-
##
|
|
123
|
+
## Scopes and filters
|
|
83
124
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
125
|
+
`scopes:` applies model scopes to the association. Relationship fields also
|
|
126
|
+
accept the same [argument filters](filtering-and-ordering.md) and
|
|
127
|
+
[`@whereConditions`](filtering-and-ordering.md#complex-where-conditions) as root
|
|
128
|
+
queries:
|
|
87
129
|
|
|
88
130
|
```graphql
|
|
89
131
|
type User {
|
|
90
132
|
publishedPosts(
|
|
91
|
-
|
|
133
|
+
title: String @like
|
|
92
134
|
orderBy: [OrderByClause!] @orderBy
|
|
93
|
-
): [Post!]! @hasMany(relation: "posts")
|
|
135
|
+
): [Post!]! @hasMany(relation: "posts", scopes: ["published"])
|
|
94
136
|
}
|
|
95
137
|
```
|
|
138
|
+
|
|
139
|
+
## Writing relationships
|
|
140
|
+
|
|
141
|
+
Creating, connecting, syncing or deleting related records from a mutation's input
|
|
142
|
+
is covered by [nested mutations](mutations.md#nested-mutations).
|
data/docs/resolvers.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# Queries, Mutations and Builders
|
|
2
|
+
|
|
3
|
+
Most fields need no Ruby: a directive (`@all`, `@find`, `@paginate`, `@create`,
|
|
4
|
+
…) or the default attribute reader resolves them. For the rest, Lighthouse looks
|
|
5
|
+
up a class by name. There are three kinds of class, and they are not
|
|
6
|
+
interchangeable. Each kind has its own contract and its own folder:
|
|
7
|
+
|
|
8
|
+
| Kind | Folder / namespace | Contract | Used by |
|
|
9
|
+
| --- | --- | --- | --- |
|
|
10
|
+
| Query | `app/graphql/queries` · `Queries::` | `GraphQL::Schema::Resolver` | `Query` fields |
|
|
11
|
+
| Mutation | `app/graphql/mutations` · `Mutations::` | `GraphQL::Schema::Mutation` | `Mutation` fields |
|
|
12
|
+
| Builder | `app/graphql/builders` · `Builders::` | `call(relation, args, context)` → relation | `@builder` |
|
|
13
|
+
|
|
14
|
+
Validators (`app/graphql/validators`) are the fourth kind. They are covered in
|
|
15
|
+
[validation.md](validation.md).
|
|
16
|
+
|
|
17
|
+
## Queries and mutations: graphql-ruby's contract
|
|
18
|
+
|
|
19
|
+
A query or mutation class is a plain graphql-ruby
|
|
20
|
+
[resolver](https://graphql-ruby.org/fields/resolvers.html) or
|
|
21
|
+
[mutation](https://graphql-ruby.org/mutations/mutation_classes.html), so everything
|
|
22
|
+
you know from graphql-ruby applies:
|
|
23
|
+
|
|
24
|
+
- the field's arguments arrive as `snake_case` keyword arguments to `resolve`;
|
|
25
|
+
- `object` and `context` are available;
|
|
26
|
+
- `ready?` and `authorized?` run before `resolve`.
|
|
27
|
+
|
|
28
|
+
The SDL already declares the field's arguments and return type, so the class
|
|
29
|
+
declares neither.
|
|
30
|
+
|
|
31
|
+
```graphql
|
|
32
|
+
type Query {
|
|
33
|
+
latestPosts(limit: Int): [Post!]!
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Mutation {
|
|
37
|
+
publishPost(id: ID!): Post
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# app/graphql/queries/latest_posts.rb
|
|
43
|
+
module Queries
|
|
44
|
+
class LatestPosts < GraphQL::Schema::Resolver
|
|
45
|
+
def resolve(limit: 10)
|
|
46
|
+
Post.order(created_at: :desc).limit(limit)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# app/graphql/mutations/publish_post.rb
|
|
52
|
+
module Mutations
|
|
53
|
+
class PublishPost < GraphQL::Schema::Mutation
|
|
54
|
+
def authorized?(id:)
|
|
55
|
+
context[:current_user]&.admin? || raise(GraphQL::ExecutionError, 'Admins only.')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resolve(id:)
|
|
59
|
+
Post.find(id).tap { |post| post.update!(published: true) }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Resolution by convention
|
|
66
|
+
|
|
67
|
+
A field on `Query` or `Mutation` with **no resolving directive** resolves to the
|
|
68
|
+
class named after it, as in PHP Lighthouse:
|
|
69
|
+
|
|
70
|
+
- `Query.latestPosts` resolves to `Queries::LatestPosts`;
|
|
71
|
+
- `Mutation.publishPost` resolves to `Mutations::PublishPost`.
|
|
72
|
+
|
|
73
|
+
The class is looked up on each call, so code reloading works. Classes found by
|
|
74
|
+
convention must inherit from `GraphQL::Schema::Resolver` (or `Mutation`, which
|
|
75
|
+
is one). Otherwise Lighthouse raises a `Lighthouse::DefinitionError`. A root
|
|
76
|
+
field with no class is reported through [`on_error`](configuration.md#on_error):
|
|
77
|
+
it raises in test and logs elsewhere. Lighthouse never falls back to a top-level
|
|
78
|
+
constant, so a `post` field is never mistaken for the `Post` model.
|
|
79
|
+
|
|
80
|
+
graphql-ruby's `RelayClassicMutation` (what `rails g graphql:install` generates
|
|
81
|
+
`Mutations::BaseMutation` from) works too, as long as the SDL field takes the
|
|
82
|
+
`input:` argument it expects.
|
|
83
|
+
|
|
84
|
+
Wrapping directives (`@guard`, the `@can*` family, `@validator`, `@cache`,
|
|
85
|
+
`@clearCache`) are not resolvers, so a field carrying only those still resolves by
|
|
86
|
+
convention:
|
|
87
|
+
|
|
88
|
+
```graphql
|
|
89
|
+
type Mutation {
|
|
90
|
+
publishPost(id: ID!): Post @guard @canFind(ability: "publish", find: "id")
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Naming a class explicitly
|
|
95
|
+
|
|
96
|
+
`@field(resolver:)` points a field at a class by name. On `Query` and `Mutation`
|
|
97
|
+
the name is looked up in the queries and mutations namespaces; on any other type,
|
|
98
|
+
in [`resolver_namespaces`](configuration.md#resolver_namespaces).
|
|
99
|
+
|
|
100
|
+
```graphql
|
|
101
|
+
type Query {
|
|
102
|
+
feed: [Post!]! @field(resolver: "Feeds::Home") # Queries::Feeds::Home
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Besides graphql-ruby resolver classes, `@field` also accepts a plain callable:
|
|
107
|
+
anything with `call(object, args, context)`.
|
|
108
|
+
|
|
109
|
+
### `@spread` and `@inject`
|
|
110
|
+
|
|
111
|
+
Both shape the keyword arguments a class receives. They do the same for
|
|
112
|
+
[`@create` / `@update`](mutations.md).
|
|
113
|
+
|
|
114
|
+
```graphql
|
|
115
|
+
type Mutation {
|
|
116
|
+
createDraft(input: CreateDraftInput! @spread): Post
|
|
117
|
+
@inject(context: "current_user.id", name: "author_id")
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`resolve(title:, body:, author_id:)` receives the input's fields at the top level.
|
|
122
|
+
`author_id` comes from `context[:current_user].id`, so clients cannot set it.
|
|
123
|
+
`@inject` is repeatable, and `name:` may be a dot path into an input
|
|
124
|
+
(`"input.author_id"`).
|
|
125
|
+
|
|
126
|
+
## Builders
|
|
127
|
+
|
|
128
|
+
A builder is not a resolver: it does not produce the field's value. It refines the
|
|
129
|
+
query of a field that a model directive resolves. It receives the relation, the
|
|
130
|
+
field's arguments and the context, and returns a relation:
|
|
131
|
+
|
|
132
|
+
```graphql
|
|
133
|
+
type Query {
|
|
134
|
+
posts: [Post!]! @paginate @builder(class: "Published")
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
# app/graphql/builders/published.rb
|
|
140
|
+
module Builders
|
|
141
|
+
class Published
|
|
142
|
+
def self.call(relation, args, context)
|
|
143
|
+
relation.where(published: true)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`@builder` works with `@all`, `@paginate`, `@first`, `@count` and `@aggregate`.
|
|
150
|
+
It is repeatable, and `method:` names a method other than `call`. Builder classes
|
|
151
|
+
are looked up in the builders namespace only. Raising a `GraphQL::ExecutionError`
|
|
152
|
+
from a builder (e.g. a tenancy check) always reaches the client; it never falls
|
|
153
|
+
back to the unscoped relation.
|
|
154
|
+
|
|
155
|
+
The gem ships RBS signatures in `sig/`, so with Steep (or anything that reads
|
|
156
|
+
`rbs collection`) a builder can be checked against `Lighthouse::_Builder`:
|
|
157
|
+
|
|
158
|
+
```rbs
|
|
159
|
+
# sig/builders/published.rbs
|
|
160
|
+
class Builders::Published
|
|
161
|
+
extend Lighthouse::_Builder
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Generators
|
|
166
|
+
|
|
167
|
+
```shell
|
|
168
|
+
bin/rails generate lighthouse:query LatestPosts # app/graphql/queries/latest_posts.rb
|
|
169
|
+
bin/rails generate lighthouse:mutation PublishPost # app/graphql/mutations/publish_post.rb
|
|
170
|
+
bin/rails generate lighthouse:builder Published # app/graphql/builders/published.rb
|
|
171
|
+
bin/rails generate lighthouse:validator CreatePostInput # app/graphql/validators/create_post_input_validator.rb
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Each one writes the class with its contract, into the first namespace configured
|
|
175
|
+
for its kind (or `--namespace=…`). Namespaced names nest modules:
|
|
176
|
+
`lighthouse:mutation Blog::Publish` writes `Mutations::Blog::Publish`. These
|
|
177
|
+
generators do not collide with graphql-ruby's `graphql:*` generators. A
|
|
178
|
+
`lighthouse:mutation` and a `graphql:mutation` of the same name target the same
|
|
179
|
+
file, and Rails asks before overwriting it.
|
|
180
|
+
|
|
181
|
+
## Changing the namespaces
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
Lighthouse.configure do |config|
|
|
185
|
+
config.namespaces = { queries: ['Api::Queries'], mutations: ['Api::Mutations'] }
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Kinds you leave out keep their defaults. Each kind takes a list, tried in order.
|
data/docs/validation.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Validation
|
|
2
|
+
|
|
3
|
+
Rails puts validation in the model, and so does Lighthouse: the
|
|
4
|
+
[mutation directives](mutations.md) save through ActiveRecord, so `validates`
|
|
5
|
+
rules apply with no configuration. Not every rule belongs to a model, though:
|
|
6
|
+
a password confirmation, a rule across two arguments, the arguments of a query, or
|
|
7
|
+
input that never reaches a model at all. For those, `@validator` runs a contract
|
|
8
|
+
class before the field resolves.
|
|
9
|
+
|
|
10
|
+
Both report errors the same way, in PHP Lighthouse's format: messages keyed by
|
|
11
|
+
argument path under `extensions.validation`.
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"data": { "signUp": null },
|
|
16
|
+
"errors": [{
|
|
17
|
+
"message": "Validation failed for the field [signUp].",
|
|
18
|
+
"extensions": { "validation": { "passwordConfirmation": ["must match password"] } }
|
|
19
|
+
}]
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Model validations
|
|
24
|
+
|
|
25
|
+
Nothing to do. A failed `save` in `@create`, `@update`, `@createMany` or
|
|
26
|
+
`@updateMany` reports the model's `full_message`s, keyed by the argument each
|
|
27
|
+
attribute came from (see [mutations.md](mutations.md#validation-errors)).
|
|
28
|
+
|
|
29
|
+
## `@validator`
|
|
30
|
+
|
|
31
|
+
```graphql
|
|
32
|
+
directive @validator(class: String) on FIELD_DEFINITION | INPUT_OBJECT
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### On a field
|
|
36
|
+
|
|
37
|
+
The contract receives all of the field's arguments:
|
|
38
|
+
|
|
39
|
+
```graphql
|
|
40
|
+
type Mutation {
|
|
41
|
+
signUp(email: String!, password: String!, passwordConfirmation: String): User @validator
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
# app/graphql/validators/mutation/sign_up_validator.rb
|
|
47
|
+
module Validators
|
|
48
|
+
module Mutation
|
|
49
|
+
class SignUpValidator < Dry::Validation::Contract
|
|
50
|
+
params do
|
|
51
|
+
required(:email).filled(:string)
|
|
52
|
+
required(:password).filled(:string, min_size?: 12)
|
|
53
|
+
optional(:password_confirmation).maybe(:string)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
rule(:password_confirmation) do
|
|
57
|
+
key.failure('must match password') if value && value != values[:password]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The class defaults to `Validators::<ParentType>::<FieldName>Validator`. Name
|
|
65
|
+
another with `@validator(class: "SignUpRules")`; it is looked up in the validators
|
|
66
|
+
namespace.
|
|
67
|
+
|
|
68
|
+
### On an input type
|
|
69
|
+
|
|
70
|
+
The contract receives that input's values. It runs **wherever the type is used**:
|
|
71
|
+
as an argument, nested inside another input, or as each item of a list. The
|
|
72
|
+
fields that take the input need no directive.
|
|
73
|
+
|
|
74
|
+
```graphql
|
|
75
|
+
input AddressInput @validator {
|
|
76
|
+
street: String
|
|
77
|
+
zipCode: String
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type Mutation {
|
|
81
|
+
ship(to: AddressInput!, billing: BillingInput): Shipment
|
|
82
|
+
shipMany(to: [AddressInput!]!): [Shipment!]!
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The class defaults to `Validators::<InputType>Validator`, here
|
|
87
|
+
`Validators::AddressInputValidator`. Errors are prefixed with the path to the
|
|
88
|
+
input: `to.zipCode`, `billing.address.street`, `to.1.street`.
|
|
89
|
+
|
|
90
|
+
### Contracts
|
|
91
|
+
|
|
92
|
+
A contract is a [Dry::Validation](https://dry-rb.org/gems/dry-validation/)
|
|
93
|
+
contract:
|
|
94
|
+
|
|
95
|
+
- Values arrive with `snake_case` symbol keys, and the error keys are mapped back
|
|
96
|
+
to the GraphQL argument names.
|
|
97
|
+
- Declare `option :graphql_context, optional: true` to read the GraphQL context in
|
|
98
|
+
rules (e.g. `graphql_context[:current_user]`).
|
|
99
|
+
- `dry-validation` is an optional dependency. Add it to your Gemfile to use these
|
|
100
|
+
contracts.
|
|
101
|
+
|
|
102
|
+
Any other object works if its `call(values)` returns a Dry-style result, or a
|
|
103
|
+
`{ key => [messages] }` Hash (empty when valid):
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
module Validators
|
|
107
|
+
class NoSpam
|
|
108
|
+
def call(values)
|
|
109
|
+
values[:title].to_s.include?('spam') ? { title: ['looks like spam'] } : {}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
A missing class raises `Lighthouse::DefinitionError`. Lighthouse never silently
|
|
116
|
+
skips validation.
|
|
117
|
+
|
|
118
|
+
### Order
|
|
119
|
+
|
|
120
|
+
Validation runs after authentication and authorization (`@guard`, `@can*`), so
|
|
121
|
+
anonymous clients cannot probe your rules, and before `@cache` and the resolver.
|
|
122
|
+
All contracts on a field run, and their errors are reported together.
|
|
123
|
+
|
|
124
|
+
Generate a contract with `bin/rails generate lighthouse:validator AddressInput`.
|
|
125
|
+
|
|
126
|
+
## Why not `@rules`?
|
|
127
|
+
|
|
128
|
+
PHP Lighthouse's `@rules(apply: ["email", "max:280"])` embeds Laravel's
|
|
129
|
+
string-based rule language in the schema. Rails has no equivalent to port: its
|
|
130
|
+
validations live in models, and Dry contracts express the rest as Ruby with
|
|
131
|
+
proper types. So Lighthouse for Rails supports those two, and does not invent a
|
|
132
|
+
third rule syntax.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'class_generator'
|
|
4
|
+
|
|
5
|
+
# `rails generate lighthouse:builder PublishedPosts` — a query builder for
|
|
6
|
+
# `@builder(class: "PublishedPosts")`.
|
|
7
|
+
class Lighthouse::Generators::BuilderGenerator < Lighthouse::Generators::ClassGenerator
|
|
8
|
+
desc 'Generate a query builder for @builder in app/graphql/builders.'
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def kind = :builders
|
|
13
|
+
|
|
14
|
+
def body
|
|
15
|
+
<<~RUBY
|
|
16
|
+
# Applied by @builder(class: "#{class_name}") before the field resolves:
|
|
17
|
+
# receives the relation, the field's arguments and the GraphQL context, and
|
|
18
|
+
# returns the relation to use. Its type is Lighthouse::_Builder.
|
|
19
|
+
class #{class_basename}
|
|
20
|
+
def self.call(relation, args, context)
|
|
21
|
+
relation
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
RUBY
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def usage
|
|
28
|
+
"Use it with @builder(class: \"#{class_name}\") on a field resolved by @all, @paginate, @first, @count or @aggregate."
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'lighthouse-graphql'
|
|
5
|
+
|
|
6
|
+
# Base for the generators that scaffold the classes a schema refers to by
|
|
7
|
+
# name — queries, mutations, builders and validators — each in its own
|
|
8
|
+
# namespace (Config.namespaces) and with its own contract.
|
|
9
|
+
class Lighthouse::Generators::ClassGenerator < Rails::Generators::NamedBase
|
|
10
|
+
hide!
|
|
11
|
+
|
|
12
|
+
class_option :namespace, type: :string,
|
|
13
|
+
desc: 'Namespace to generate into (default: the first of Config.namespaces for this kind)'
|
|
14
|
+
|
|
15
|
+
def create_class
|
|
16
|
+
create_file File.join('app/graphql', *modules.map(&:underscore), "#{class_basename.underscore}.rb"), source
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def show_usage
|
|
20
|
+
say_status :usage, usage, :blue if usage
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# :queries, :mutations, :builders or :validators.
|
|
26
|
+
def kind
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def body
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def usage
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def class_basename
|
|
39
|
+
class_name.demodulize
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def namespace_name
|
|
43
|
+
options[:namespace] || Lighthouse::Config.namespaces[kind].first
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def modules
|
|
47
|
+
namespace_name.split('::') + class_name.split('::')[0...-1]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def full_class_name
|
|
51
|
+
(modules + [class_basename]).join('::')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def source
|
|
55
|
+
lines = ['# frozen_string_literal: true', '']
|
|
56
|
+
modules.each_with_index { |mod, depth| lines << "#{' ' * depth}module #{mod}" }
|
|
57
|
+
body.each_line { |line| lines << (line.strip.empty? ? '' : "#{' ' * modules.size}#{line.chomp}") }
|
|
58
|
+
modules.size.times.reverse_each { |depth| lines << "#{' ' * depth}end" }
|
|
59
|
+
"#{lines.join("\n")}\n"
|
|
60
|
+
end
|
|
61
|
+
end
|