lighthouse-graphql 0.1.1 → 0.2.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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +154 -1
  3. data/README.md +42 -2
  4. data/docs/authorization.md +116 -57
  5. data/docs/best-practices.md +16 -3
  6. data/docs/caching.md +161 -0
  7. data/docs/configuration.md +83 -4
  8. data/docs/custom-directives.md +5 -1
  9. data/docs/directives.md +117 -38
  10. data/docs/getting-started.md +84 -0
  11. data/docs/mutations.md +260 -0
  12. data/docs/performance.md +138 -0
  13. data/docs/relationships.md +80 -33
  14. data/docs/resolvers.md +179 -0
  15. data/docs/validation.md +132 -0
  16. data/lib/generators/lighthouse/builder_generator.rb +30 -0
  17. data/lib/generators/lighthouse/class_generator.rb +61 -0
  18. data/lib/generators/lighthouse/install/install_generator.rb +127 -0
  19. data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +8 -0
  20. data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +57 -0
  21. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +67 -0
  22. data/lib/generators/lighthouse/install/templates/schema.graphql.tt +19 -0
  23. data/lib/generators/lighthouse/install/templates/schema.rb.tt +10 -0
  24. data/lib/generators/lighthouse/mutation_generator.rb +30 -0
  25. data/lib/generators/lighthouse/query_generator.rb +29 -0
  26. data/lib/generators/lighthouse/validator_generator.rb +36 -0
  27. data/lib/lighthouse/cache_control.rb +126 -0
  28. data/lib/lighthouse/caching.rb +68 -0
  29. data/lib/lighthouse/config.rb +85 -19
  30. data/lib/lighthouse/contracts/arg_builder.rb +8 -12
  31. data/lib/lighthouse/contracts/arg_manipulator.rb +8 -12
  32. data/lib/lighthouse/contracts/directive.rb +10 -14
  33. data/lib/lighthouse/contracts/field_manipulator.rb +8 -12
  34. data/lib/lighthouse/contracts/field_middleware.rb +7 -11
  35. data/lib/lighthouse/contracts/field_resolver.rb +9 -13
  36. data/lib/lighthouse/dataloader_sources/association_loader.rb +28 -19
  37. data/lib/lighthouse/dataloader_sources/model_loader.rb +10 -14
  38. data/lib/lighthouse/dataloader_sources/relation_count_loader.rb +16 -0
  39. data/lib/lighthouse/dataloader_sources/relation_page_loader.rb +18 -0
  40. data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +57 -69
  41. data/lib/lighthouse/directive_resolvers/all_resolver.rb +26 -57
  42. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +161 -0
  43. data/lib/lighthouse/directive_resolvers/auth_resolver.rb +41 -38
  44. data/lib/lighthouse/directive_resolvers/base.rb +142 -86
  45. data/lib/lighthouse/directive_resolvers/builder_applier.rb +58 -56
  46. data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +133 -0
  47. data/lib/lighthouse/directive_resolvers/can_resolvers.rb +177 -0
  48. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +70 -0
  49. data/lib/lighthouse/directive_resolvers/count_resolver.rb +42 -48
  50. data/lib/lighthouse/directive_resolvers/field_resolver.rb +27 -68
  51. data/lib/lighthouse/directive_resolvers/find_resolver.rb +42 -52
  52. data/lib/lighthouse/directive_resolvers/first_resolver.rb +39 -45
  53. data/lib/lighthouse/directive_resolvers/guard_resolver.rb +16 -22
  54. data/lib/lighthouse/directive_resolvers/method_resolver.rb +19 -25
  55. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +219 -0
  56. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +70 -128
  57. data/lib/lighthouse/directive_resolvers/rename_resolver.rb +18 -24
  58. data/lib/lighthouse/directive_resolvers/resolution.rb +31 -27
  59. data/lib/lighthouse/directive_resolvers/validator_resolver.rb +193 -0
  60. data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +164 -163
  61. data/lib/lighthouse/directives/arguments/base.rb +23 -31
  62. data/lib/lighthouse/directives/arguments/filters.rb +113 -93
  63. data/lib/lighthouse/directives/arguments/order_by.rb +41 -41
  64. data/lib/lighthouse/directives/pagination_info_types.rb +69 -0
  65. data/lib/lighthouse/directives/pagination_manipulator.rb +176 -0
  66. data/lib/lighthouse/directives/registry.rb +210 -141
  67. data/lib/lighthouse/directives/where_conditions_directive.rb +236 -200
  68. data/lib/lighthouse/errors.rb +27 -0
  69. data/lib/lighthouse/federation_directives.rb +53 -0
  70. data/lib/lighthouse/field_resolver_stack.rb +56 -0
  71. data/lib/lighthouse/generated_resolvers.rb +82 -0
  72. data/lib/lighthouse/graphql/version.rb +1 -1
  73. data/lib/lighthouse/graphql_ruby_patches.rb +18 -0
  74. data/lib/lighthouse/ide_helper.rb +98 -0
  75. data/lib/lighthouse/namespaces.rb +21 -0
  76. data/lib/lighthouse/nested_mutations/belongs_to_operations.rb +50 -0
  77. data/lib/lighthouse/nested_mutations/executor.rb +149 -0
  78. data/lib/lighthouse/nested_mutations/has_operations.rb +66 -0
  79. data/lib/lighthouse/nested_mutations/many_to_many_operations.rb +44 -0
  80. data/lib/lighthouse/nested_mutations/partitioner.rb +125 -0
  81. data/lib/lighthouse/nested_mutations.rb +210 -0
  82. data/lib/lighthouse/pagination.rb +221 -0
  83. data/lib/lighthouse/persisted_queries.rb +167 -0
  84. data/lib/lighthouse/railtie.rb +26 -0
  85. data/lib/lighthouse/rb_lighthouse.rb +50 -29
  86. data/lib/lighthouse/reference_resolver.rb +13 -15
  87. data/lib/lighthouse/relation_batch.rb +156 -0
  88. data/lib/lighthouse/resolver_dispatch.rb +26 -0
  89. data/lib/lighthouse/schema_cache.rb +120 -0
  90. data/lib/lighthouse/schema_factory.rb +27 -37
  91. data/lib/lighthouse/schema_generator.rb +196 -239
  92. data/lib/lighthouse/schema_implementation.rb +51 -48
  93. data/lib/lighthouse/sdl_loader.rb +17 -20
  94. data/lib/lighthouse/support/arguments.rb +119 -0
  95. data/lib/lighthouse/support/authorization.rb +18 -19
  96. data/lib/lighthouse/support/batching.rb +16 -0
  97. data/lib/lighthouse/support/directive_args.rb +26 -24
  98. data/lib/lighthouse/support/model_resolver.rb +56 -56
  99. data/lib/lighthouse/support/naming.rb +55 -59
  100. data/lib/lighthouse/tasks/lighthouse.rake +33 -0
  101. data/lib/lighthouse/where_conditions/operator_map.rb +25 -29
  102. metadata +51 -63
  103. data/lib/lighthouse/directive_resolvers/belongs_to_many_resolver.rb +0 -56
  104. data/lib/lighthouse/directive_resolvers/belongs_to_resolver.rb +0 -47
  105. data/lib/lighthouse/directive_resolvers/can_resolver.rb +0 -86
  106. data/lib/lighthouse/directive_resolvers/has_many_resolver.rb +0 -109
  107. data/lib/lighthouse/directive_resolvers/has_one_resolver.rb +0 -43
  108. data/lib/lighthouse/directives/relation_directive.rb +0 -332
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 44c0b7f96cdd9ba27384cff96408e10839eab552d11371d3d6fa977e646adcc7
4
- data.tar.gz: c68872ac75f3233cec0bbefda727e50074fb1b81e7c39d7e11de782bf473f089
3
+ metadata.gz: e3be850a4eb57c4d4fc6bd41103d5bb783482530f3fc0f3584aa00314de4e936
4
+ data.tar.gz: 75ae5604d85d1549f159dc17a98932d7c4274074ea26b9a9ade38b4f8d8d6af6
5
5
  SHA512:
6
- metadata.gz: 613a4c6a0692f228bee57d8ab160c5c523a6b1660d99b1ad37ccbd38dcb51d7cdc30585642d9f40687183940e660349e14125a773a0b793579ba623e082299ac
7
- data.tar.gz: 5fad77a3975ee0ed94da5db38258585f99227eecb8834e1264275801052e03073098c2b441ce53a38c3324f8044be46b0f79af095b0d65cffbac6cc3ddd6e122
6
+ metadata.gz: 62cafecf9c4deae7b974747a9de82c422ea1d58d50fa752d6c583fba20f6ff16b99db56496a9963d0740826803b89e730c3ede19fed937baf22fac7f954bd826
7
+ data.tar.gz: fcd638c332e4f055afb9b28e7ac211162582878e578e5cb45c02a7d24dcf2ae70f7dd5342cdd477bc1f8a16b3cc1e6dd78e487791ee396044fefbe7aa95bea49
data/CHANGELOG.md CHANGED
@@ -6,6 +6,158 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-10-17
10
+
11
+ ### Added
12
+
13
+ - `rails generate lighthouse:install` sets a Rails app up: the initializer (every option,
14
+ commented out at its default), a starter schema, the schema class with
15
+ `GraphQL::Dataloader`, a `GraphqlController` and its route, and a `graphql.config.yml`
16
+ for IDEs. `--schema`, `--sdl-folder`, `--skip-controller` and `--skip-ide` adjust it;
17
+ rerunning it adds the route and `.gitignore` entries only once. It coexists with
18
+ graphql-ruby's `graphql:install`: an existing controller or schema class is kept unless
19
+ `--force` is given, and the route is written exactly as graphql-ruby writes it, so it is
20
+ never added twice in either order.
21
+ - `bin/rails lighthouse:ide_helper` writes `schema-directives.graphql` (every registered
22
+ directive the SDL does not declare) and `programmatic-types.graphql` (the types the
23
+ manipulation phase generates) so IDEs can validate and autocomplete a Lighthouse
24
+ schema, as PHP Lighthouse's `lighthouse:ide-helper` does.
25
+ - `DirectiveRegistry#register_definition` contributes the SDL definition of a directive
26
+ that has no class of its own; `register_type_definition` / a class's `type_definitions`
27
+ contribute the types such definitions refer to, injected alongside them.
28
+ - **Queries, mutations and builders as separate kinds**, each with its own namespace
29
+ (`Config.namespaces`) and contract. A `Query` or `Mutation` field without a resolving
30
+ directive resolves by convention to `Queries::<FieldName>` (a `GraphQL::Schema::Resolver`)
31
+ or `Mutations::<FieldName>` (a `GraphQL::Schema::Mutation`, `RelayClassicMutation`
32
+ included) — graphql-ruby's contract: `resolve(**args)`, `object`, `context`, `ready?`,
33
+ `authorized?`. `@field(resolver:)` on those roots looks in the same namespaces. `@builder`
34
+ classes live in `Builders::`. Generators: `lighthouse:query`, `lighthouse:mutation`,
35
+ `lighthouse:builder`, `lighthouse:validator`.
36
+ - `@create`, `@createMany`, `@update`, `@updateMany`: save the returned model through
37
+ ActiveRecord, in a transaction (`Config.transactional_mutations`), reporting model
38
+ validation errors by argument path (`extensions.validation`, as in PHP Lighthouse).
39
+ `@spread` flattens an input into the arguments, `@inject(context:, name:)` sets
40
+ server-side values, `@rename` now also applies to arguments.
41
+ - `@validator` (field or input type) runs a contract before the field resolves:
42
+ a `Dry::Validation::Contract` (optional dependency) or any `call(values)` returning
43
+ errors. Input-type validators apply wherever the type is used, nested and in lists.
44
+ - The `@can*` family, Pundit by default: `@canFind`, `@canModel`, `@canQuery`,
45
+ `@canResolved`, `@canRoot`, with `injectArgs`, `args`, `action`
46
+ (`EXCEPTION_PASS` / `EXCEPTION_NOT_AUTHORIZED` / `RETURN_VALUE`) and `returnValue`. The
47
+ authorizer receives those arguments as a fourth parameter, only when there are any.
48
+ - `@cache(maxAge:, private:)`, `@cacheKey` and `@clearCache(type:, idSource:, field:)` over
49
+ `Config.cache_store` (`Rails.cache`), invalidating through versioned keys so it works on
50
+ any store, including ones without `delete_matched`.
51
+ - **Nested mutations**, PHP Lighthouse's API over ActiveRecord: an input field named after
52
+ an association carries `connect` / `create` / `update` / `upsert` / `disconnect` /
53
+ `delete` (belongs_to, polymorphic belongs_to, has_one, has_many) and `sync` /
54
+ `syncWithoutDetaching` (has_many :through, has_and_belongs_to_many — with pivot columns on a
55
+ `:through` join model), to any depth. Each record is saved once; errors are keyed by the full
56
+ input path. `@create` / `@update` / `@upsert` / `@delete(relation:)` on an argument run one
57
+ operation on an association.
58
+ - `@upsert` / `@upsertMany` (by primary key or `identifyingColumns`) and `@delete` (by the
59
+ field's filter arguments; refuses to delete without one), and the `@whereKey` filter.
60
+ - `@paginate(type: PAGINATOR | SIMPLE | CONNECTION, scopes:, defaultCount:, maxCount:)` and
61
+ `type:` on `@hasMany` / `@belongsToMany` / `@morphMany` / `@morphToMany`, generating PHP
62
+ Lighthouse's types and arguments (`first`, `page`, `after`; `PaginatorInfo`,
63
+ `SimplePaginatorInfo`, `PageInfo` with `total` / `count` / `currentPage` / `lastPage`). The total
64
+ is only counted when selected. `Config.pagination_default_count` / `pagination_max_count`.
65
+ - `@morphTo`, `@morphOne`, `@morphMany`, `@morphToMany` (ActiveRecord polymorphic
66
+ associations), and `scopes:` on every relationship directive.
67
+ - The integration suites of PHP Lighthouse for nested mutations (belongsTo, hasOne, hasMany,
68
+ belongsToMany, morphTo, morphOne, morphMany, morphToMany), the model mutation directives,
69
+ `@all` and `@paginate`, ported to RSpec against the same models (spec/integration).
70
+ - **Opt-in options for large schemas and high traffic**, all off by default (docs/performance.md):
71
+ - `config.schema_cache` caches the built schema document in `tmp/cache/lighthouse`, Bootsnap
72
+ style: content-addressed (SDL, versions, registered directives, manipulator source, config),
73
+ so it invalidates itself; atomic writes; `bin/rails lighthouse:cache` / `lighthouse:clear_cache`.
74
+ About 1.8x faster schema builds on large schemas.
75
+ - `config.watch_sdl` adds the SDL folder to Rails' watched dirs, so editing a `.graphql` file
76
+ reloads the schema in development (it previously needed a server restart).
77
+ - `config.persisted_queries`: Apollo-compatible Automatic Persisted Queries
78
+ (`PersistedQueries.execute`, used by the generated controller), with a TTL, a store, a
79
+ manifest of trusted operations (Apollo's format or `{ sha256 => query }`) served from memory,
80
+ `persisted_queries_only` as an allowlist, and `bin/rails lighthouse:persisted_queries:verify`
81
+ to check the manifest against the schema in CI.
82
+ - `@cacheControl(maxAge:, scope:, inheritMaxAge:)` computes the response's `Cache-Control`
83
+ header from the selected fields (PHP Lighthouse's / Apollo's rules);
84
+ `Lighthouse::GraphQL::CacheControl.apply(response, result)` sets it, and the generated
85
+ controller calls it.
86
+ - `example/`: a Rails 8.1 blog API built with Lighthouse, which is both an example to
87
+ read and the end-to-end suite. Its specs go through the real `/graphql` endpoint:
88
+ queries, pagination, relationships without N+1, `@guard` and the `@can*` family with
89
+ Pundit, mutations (nested ones included), validation, `@cache`/`@cacheControl`,
90
+ persisted queries over `GET`, and the rake tasks. CI runs it.
91
+ - RuboCop (with rubocop-rails, -rspec, -performance and -factory_bot) lints the gem and
92
+ the example app; CI runs it, and `rake` runs it after the suite.
93
+ - **Paginated relations no longer query once per parent.** `@hasMany`,
94
+ `@belongsToMany`, `@morphMany` and `@morphToMany` with `type:`, and `@paginate` on a
95
+ nested field, load every parent's page in one query (a `ROW_NUMBER()` window
96
+ partitioned by parent) and every total in one `GROUP BY`, only when a total is
97
+ selected. Databases without window functions keep loading one parent at a time. See
98
+ docs/relationships.md.
99
+
100
+ ### Fixed
101
+
102
+ - `@whereConditions`' `AND`, `OR` and `HAS` were ignored when they came from a query.
103
+ graphql-ruby hands input objects over with Ruby-style keys (`:and`, `:or`, `:has`), and
104
+ only the upper-case keys were read, so a nested condition matched every record.
105
+ - `scopes:` was declared but ignored by `@count` and `@first`; `@all` now accepts it too.
106
+ - The default resolver discarded the result of a generated three-argument resolver
107
+ (`resolve_field_<name>(obj, args, ctx)`) and fell through to reading the attribute.
108
+ - Used standalone (without Rails), the gem missed ActiveSupport's `Enumerable` and
109
+ `String#exclude?` extensions it relies on (`index_by`, `pluck`, `exclude?`).
110
+
111
+ - Every built-in directive now contributes its SDL definition. `@all`, `@find`,
112
+ `@paginate`, `@hasMany`, `@belongsTo`, `@belongsToMany`, `@aggregate`, `@field`,
113
+ `@auth`, `@builder`, `@whereConditions` and `@whereHasConditions` had none, so a schema
114
+ that used one without declaring it failed to build — contrary to the docs.
115
+ - `@find` without `model:` found nothing: it inferred the model from the return type's
116
+ Ruby class name, which is `nil` for SDL-built types, instead of its GraphQL name.
117
+ - `@find` returned `null` for existing records when batched through the Dataloader: `ID`
118
+ arguments arrive as strings and were looked up against integer primary keys.
119
+ - Wrapping directives (`@guard`, ...) on a field without a resolving directive called
120
+ graphql-ruby's internal resolver with the wrong arity instead of the default reader.
121
+ - The gem now requires `graphql` itself instead of relying on the app to load it first.
122
+ - `@all` on a list field returned `nodes: null`: the field was rewritten to a connection the
123
+ resolver never built. `@all` now returns the list, as in PHP Lighthouse; it also resolved
124
+ `model:` ignoring `resolver_namespaces` and stripping the namespace (`Admin::User` → `User`).
125
+ - `@paginate` ignored the field's argument filters (`@eq`, `@like`, ...), took no `first` /
126
+ `page` arguments as a paginator, and returned `nodes: null` as a connection.
127
+ - `@find` ignored every argument but `id`; it now applies the argument filters and errors on
128
+ more than one match.
129
+ - The relationship directives' Dataloader source loaded each parent's association separately
130
+ (N+1); it now batches with ActiveRecord's preloader.
131
+ - The relationship directives and `@find` raised "Dataloader is not running" on schemas without
132
+ `GraphQL::Dataloader`; they now load directly there.
133
+ - Generated pagination wrappers made every paginated field `no-cache` under `@cacheControl`;
134
+ they now inherit the parent's maxAge, as in PHP Lighthouse.
135
+ - An explicit `null` directive argument (`@paginate(maxCount: null)`) failed to build the
136
+ schema: graphql-ruby passes it on as a NullValue node. Lighthouse maps it to nil.
137
+
138
+ ### Removed
139
+
140
+ - **`@can`** — replaced by the `@can*` family. `@can(ability:)` becomes `@canResolved`,
141
+ `root: true` becomes `@canRoot`, `find:` / `model:` become `@canFind`; see the migration
142
+ table in docs/authorization.md. Schemas still using `@can` fail to build.
143
+
144
+ ### Changed
145
+
146
+ - **`@hasMany` and `@belongsToMany` return plain lists**, as in PHP Lighthouse, instead of
147
+ connections. Pass `type: CONNECTION` (or `PAGINATOR` / `SIMPLE`) to paginate; clients that
148
+ selected `edges` / `nodes` on them need the `type:`.
149
+ - Paginated fields follow PHP Lighthouse: a paginator takes `first: Int!` (defaulted by
150
+ `defaultCount`) and `page`, a connection `first: Int!` and `after`, and returns non-null.
151
+ - `@paginate`'s `type:` is the `PaginateType` enum (`PAGINATOR`, `SIMPLE`, `CONNECTION`); the
152
+ string form still works at runtime.
153
+ - `@builder` classes are looked up in `Config.namespaces[:builders]` (`Builders::`) instead of
154
+ `resolver_namespaces`. Fully-qualified names work as before.
155
+ - `@field(resolver:)` on `Query` / `Mutation` looks in the queries / mutations namespaces;
156
+ elsewhere still in `resolver_namespaces`.
157
+ - `@aggregate`'s `function:` is declared as `String`. Enum
158
+ literals (`function: SUM`) still work at runtime, but IDEs expect strings
159
+ (`function: "SUM"`).
160
+
9
161
  ## [0.1.1] - 2026-09-23
10
162
 
11
163
  Release tooling only: the library is unchanged from `0.1.0`. The packaged gem differs because
@@ -56,6 +208,7 @@ had been running inside a private monorepo as a path gem before being extracted.
56
208
  - Config-driven extension seams: `Lighthouse.configure`, `resolver_namespaces`,
57
209
  `on_error`, and `DirectiveRegistry.register` for app-defined directives.
58
210
 
59
- [Unreleased]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.1.1...HEAD
211
+ [Unreleased]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.2.0...HEAD
212
+ [0.2.0]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.1.1...v0.2.0
60
213
  [0.1.1]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.1.0...v0.1.1
61
214
  [0.1.0]: https://github.com/Vaz-Innovation/lighthouse-graphql/releases/tag/v0.1.0
data/README.md CHANGED
@@ -63,8 +63,36 @@ gem 'lighthouse-graphql'
63
63
  It depends on `graphql ~> 2.5` and `activesupport`. For federation you also need
64
64
  `apollo-federation` in your app (it is an optional, app-provided dependency).
65
65
 
66
+ Then let the generator set the app up:
67
+
68
+ ```shell
69
+ bin/rails generate lighthouse:install
70
+ ```
71
+
72
+ It creates the initializer, a starter schema in `graphql/schema.graphql`, the
73
+ `AppSchema` class, a `GraphqlController` with its `POST /graphql` route, and a
74
+ `graphql.config.yml` for your IDE. Run `bin/rails generate lighthouse:install --help`
75
+ for its options (`--schema`, `--sdl-folder`, `--skip-controller`, `--skip-ide`).
76
+
77
+ ### IDE support
78
+
79
+ Lighthouse's directives and the types it generates (Paginator/Connection types,
80
+ `whereConditions` inputs, …) are defined in Ruby, so your IDE can't see them in
81
+ your `.graphql` files. Write them out for it:
82
+
83
+ ```shell
84
+ bin/rails lighthouse:ide_helper
85
+ ```
86
+
87
+ This writes `schema-directives.graphql` and `programmatic-types.graphql` to the app
88
+ root; the generated `graphql.config.yml` already points the GraphQL plugins for
89
+ JetBrains IDEs and VS Code at them. Rerun it after changing your schema or
90
+ registering a directive. See [docs/getting-started.md](docs/getting-started.md#ide-support).
91
+
66
92
  ## Quick start
67
93
 
94
+ Prefer to wire it by hand? These are the steps the generator performs.
95
+
68
96
  1. **Put your SDL** in `app/graphql` or any folder (default: `RAILS_ROOT/graphql`),
69
97
  split across as many `.graphql` files as you like:
70
98
 
@@ -103,6 +131,13 @@ It depends on `graphql ~> 2.5` and `activesupport`. For federation you also need
103
131
  See **[docs/getting-started.md](docs/getting-started.md)** for the full setup,
104
132
  including base types and configuration.
105
133
 
134
+ ### Example app
135
+
136
+ [`example/`](example) is a small blog API built with Lighthouse on Rails 8.1. It shows
137
+ the schema, the generated `Queries::`/`Mutations::`/`Builders::`/`Validators::` classes,
138
+ Pundit policies and the opt-in performance features working together, and its specs
139
+ are the library's end-to-end suite. See [example/README.md](example/README.md).
140
+
106
141
  ---
107
142
 
108
143
  ## Documentation
@@ -111,9 +146,14 @@ including base types and configuration.
111
146
  | --- | --- |
112
147
  | [Getting started](docs/getting-started.md) | Install, base types, building & serving the schema |
113
148
  | [Directives reference](docs/directives.md) | Every built-in directive, grouped by purpose |
114
- | [Relationships](docs/relationships.md) | `@hasMany`, `@belongsTo`, `@belongsToMany`, `@hasOne` and batching |
149
+ | [Relationships](docs/relationships.md) | `@hasMany`, `@belongsTo`, `@belongsToMany`, `@hasOne`, the `@morph*` variants, batching and `type:` pagination |
115
150
  | [Filtering & ordering](docs/filtering-and-ordering.md) | `@eq`/`@where`/`@in`/`@like`, `@whereConditions`, `@orderBy` |
116
- | [Authentication & authorization](docs/authorization.md) | `@guard`, `@can` (policy-based, Pundit by default) |
151
+ | [Queries, mutations & builders](docs/resolvers.md) | `Queries::` / `Mutations::` classes (graphql-ruby's contract), builders, generators |
152
+ | [Mutations](docs/mutations.md) | `@create`, `@update`, `@upsert`, `@delete` (and `*Many`), nested mutations on every kind of association, `@spread`, `@inject` |
153
+ | [Validation](docs/validation.md) | Model validations, `@validator` with Dry::Validation contracts |
154
+ | [Authentication & authorization](docs/authorization.md) | `@guard`, `@canFind` / `@canModel` / `@canQuery` / `@canResolved` / `@canRoot` (Pundit by default) |
155
+ | [Performance at scale](docs/performance.md) | Opt-in schema cache, SDL reloading in development, persisted queries (APQ) and trusted-document allowlists |
156
+ | [Caching](docs/caching.md) | `@cache`, `@cacheKey`, `@clearCache` (Rails.cache), `@cacheControl` (HTTP) |
117
157
  | [Apollo Federation](docs/federation.md) | `@key`, `resolve_reference`, the `ReferenceResolver` registry |
118
158
  | [Custom directives](docs/custom-directives.md) | The contracts, the registry, writing your own directive |
119
159
  | [Configuration](docs/configuration.md) | `Lighthouse.configure`, namespaces, error handling |
@@ -1,109 +1,168 @@
1
1
  # Authentication & Authorization
2
2
 
3
- Lighthouse lets you enforce **authentication** and **authorization** declaratively
4
- in your schema with the `@guard` and `@can` directives — instead of hand-writing
5
- a resolver whose only job is a policy check. This mirrors PHP Lighthouse's
6
- `@guard` / `@can*` family, adapted to Ruby and your authorization library (Pundit
7
- by default).
3
+ Enforce **authentication** and **authorization** declaratively in your schema,
4
+ instead of in resolvers whose only job is a policy check. `@guard` requires a
5
+ user. The `@can*` family checks a policy ability, Pundit by default. This is PHP
6
+ Lighthouse's v6 design, which replaced its single `@can` with one directive per
7
+ *thing being authorized*.
8
8
 
9
9
  ## Configuration
10
10
 
11
- The library is framework-agnostic: how it reads the current user and how it
12
- checks an ability are both configurable. The defaults work with a Rails app that
13
- puts `current_user` / `pundit_user` in the GraphQL context and uses Pundit, so
14
- **typically no configuration is needed**:
11
+ The defaults work with a Rails app that puts `current_user` (or `pundit_user`)
12
+ in the GraphQL context and uses Pundit, so **typically no configuration is
13
+ needed**:
15
14
 
16
15
  ```ruby
17
16
  Lighthouse.configure do |config|
18
17
  # The authenticated user (nil if none). Default:
19
18
  config.authenticated_user = ->(ctx) { ctx[:current_user] }
20
19
 
21
- # The "user" passed to the authorizer. Default: ctx[:pundit_user], falling back
22
- # to the authenticated user.
20
+ # The "user" handed to policies. Default: ctx[:pundit_user], else the authenticated user.
23
21
  config.policy_user = ->(ctx) { ctx[:pundit_user] }
24
22
 
25
- # How an ability is checked. Default uses Pundit:
26
- config.authorizer = ->(user, record, ability) { Pundit.policy!(user, record).public_send("#{ability}?") }
23
+ # How an ability is checked. Default (Pundit):
24
+ config.authorizer = ->(user, record, ability, args = []) do
25
+ Pundit.policy!(user, record).public_send("#{ability}?", *args)
26
+ end
27
27
  end
28
28
  ```
29
29
 
30
- Point `authorizer` at any system (CanCanCan, ActionPolicy, a plain lambda) to use
31
- something other than Pundit.
30
+ Point `authorizer` at any system (CanCanCan, ActionPolicy, a lambda) to use
31
+ something else. It receives `args` only when a directive passes some, so a
32
+ three-argument authorizer keeps working for checks without them.
32
33
 
33
34
  ## `@guard` — require authentication
34
35
 
35
- Require an authenticated user before a field resolves. Raises `Unauthenticated.`
36
- otherwise.
36
+ Raises `Unauthenticated.` when there is no user. It runs before everything else
37
+ on the field.
37
38
 
38
39
  ```graphql
39
40
  type Query {
40
- me: User @field(resolver: "Resolvers::CurrentUser") @guard
41
+ me: User @guard
41
42
  }
42
43
  ```
43
44
 
44
- `@guard` runs **outermost** — before any other directive on the field.
45
+ ## The `@can*` family
45
46
 
46
- ## `@can` — authorize with a policy
47
+ Each directive checks `ability` with the policy, e.g. `PostPolicy#update?` for
48
+ `ability: "update"`. They differ in what they authorize:
47
49
 
48
- Check a policy ability. By default it authorizes the **resolved record**: the
49
- field resolves first, then `@can` checks the ability against the result (and
50
- raises if denied). `nil` results pass through; collections are left to scoping.
50
+ | Directive | Authorizes | Typical use |
51
+ | --- | --- | --- |
52
+ | `@canModel` | the model **class** | `create?` before `@create` |
53
+ | `@canFind` | the models whose keys an argument holds | `update?` before `@update` |
54
+ | `@canQuery` | the models the field's argument filters select | bulk reads or writes |
55
+ | `@canResolved` | what the field resolved to (each item of a list) | read-only fields |
56
+ | `@canRoot` | the parent object | a sensitive field on a type |
51
57
 
52
58
  ```graphql
53
59
  type Query {
54
- # Resolve the (account-scoped) contact, then authorize ContactPolicy#show?
55
- contact(id: ID!): Contact
56
- @field(resolver: "Resolvers::ContactResolver")
57
- @can(ability: "show")
60
+ post(id: ID! @eq): Post @find @canResolved(ability: "show")
61
+ draftCount(authorId: ID @eq): Int @count(model: "Post") @canQuery(ability: "show")
62
+ }
63
+
64
+ type Mutation {
65
+ createPost(input: CreatePostInput! @spread): Post @create @canModel(ability: "create")
66
+ updatePost(input: UpdatePostInput! @spread): Post @update @canFind(ability: "update", find: "input.id")
67
+ }
68
+
69
+ type User {
70
+ email: String @canRoot(ability: "view_email")
58
71
  }
59
72
  ```
60
73
 
74
+ With Pundit, `@canFind(ability: "update", find: "input.id")` loads the post and
75
+ calls `PostPolicy.new(pundit_user, post).update?`. `@canModel(ability: "create")`
76
+ calls `PostPolicy.new(pundit_user, Post).create?`.
77
+
78
+ All of them run **before** the field resolves, except `@canResolved`. That one
79
+ needs the result, so use it only on fields that do not change data.
80
+
81
+ ### Arguments
82
+
83
+ Every `@can*` directive takes:
84
+
85
+ | Argument | Meaning |
86
+ | --- | --- |
87
+ | `ability: String!` | The policy method, without `?` |
88
+ | `injectArgs: Boolean = false` | Pass the field's arguments (a Hash) to the policy method |
89
+ | `args: CanArgs` | Static values passed after them, e.g. `args: 2` or `args: ["a", "b"]` |
90
+ | `action: CanAction = EXCEPTION_PASS` | What a denial does (below) |
91
+ | `returnValue: CanArgs` | The value returned for `action: RETURN_VALUE` |
92
+
61
93
  ```ruby
62
- # The resolver is now just a scoped finder — no authorization boilerplate.
63
- module Resolvers
64
- class ContactResolver
65
- def self.call(_obj, args, ctx)
66
- account = ctx[:current_account]
67
- account.contacts.find_by(id: args[:id]) || raise(GraphQL::ExecutionError, 'Contact not found.')
68
- end
94
+ class PostPolicy < ApplicationPolicy
95
+ def publish?(args) # @canFind(ability: "publish", find: "id", injectArgs: true)
96
+ user.admin? && args[:title] != record.title
97
+ end
98
+
99
+ def edit?(level) # @canFind(ability: "edit", find: "id", args: 2)
100
+ user.level >= level
69
101
  end
70
102
  end
71
103
  ```
72
104
 
73
- With Pundit, `@can(ability: "show")` calls `ContactPolicy.new(pundit_user, contact).show?`.
105
+ Plus, per directive:
74
106
 
75
- ### Other modes
107
+ - `@canFind(find: String!, model: String, findOrFail: Boolean = true, scopes: [String!])`.
108
+ `find` is the argument holding the primary key or keys, and may be a dot path
109
+ (`"input.id"`). A key that matches no record fails with
110
+ `Couldn't find Post with 'id'=…`, unless `findOrFail: false`. `scopes` are model
111
+ scopes applied to the lookup.
112
+ - `@canModel(model: String)`.
113
+ - `@canQuery(model: String, scopes: [String!])`. `scopes` narrow the records
114
+ being authorized, not the field's result.
76
115
 
77
- ```graphql
78
- # Authorize against the parent object (e.g. restrict a field on a type):
79
- type User {
80
- email: String! @can(ability: "view_email", root: true)
81
- }
116
+ The model defaults to the `model:` of another directive on the field (as in
117
+ `@count(model: "Post") @canQuery`), then to the field's return type.
82
118
 
83
- # Find a model by an argument and authorize it before resolving:
84
- type Mutation {
85
- archivePost(id: ID!): Post @can(ability: "update", find: "id", model: "Post") @field(resolver: "...")
86
- }
119
+ ### `action`: what a denial does
120
+
121
+ | `action` | Result |
122
+ | --- | --- |
123
+ | `EXCEPTION_PASS` (default) | The error reaches the client: `This action is unauthorized.`, or `@canFind`'s not-found message |
124
+ | `EXCEPTION_NOT_AUTHORIZED` | Always `This action is unauthorized.`, which hides whether a record exists |
125
+ | `RETURN_VALUE` | The field returns `returnValue` instead of resolving |
126
+
127
+ ```graphql
128
+ post(id: ID!): Post @canFind(ability: "show", find: "id", action: EXCEPTION_NOT_AUTHORIZED)
87
129
  ```
88
130
 
89
- `@can` is `repeatable`, so you can require multiple abilities.
131
+ `action` applies to the directive's own checks only. Errors from the resolver
132
+ itself are untouched.
133
+
134
+ ## Ordering
135
+
136
+ The order you write directives in does not matter. They run as:
137
+
138
+ 1. `@guard` / `@auth` (authentication)
139
+ 2. `@canFind`, `@canModel`, `@canQuery`, `@canRoot` (and the check of `@canResolved`, after the resolver)
140
+ 3. `@validator` (see [validation.md](validation.md))
141
+ 4. `@cache`, then the resolver
142
+
143
+ So a cached value is still authorized on every request.
90
144
 
91
145
  ## Multi-tenancy vs. authorization
92
146
 
93
147
  Keep the two layers distinct:
94
148
 
95
- - **Tenancy / scoping** (which account's records are visible) belongs in your
96
- resolver or an `@builder` — e.g. `account.contacts`. This is application-specific.
97
- - **Authorization** (may this user perform this action on this record) belongs in
98
- `@can` + your policies.
149
+ - **Tenancy / scoping** decides which account's records are visible. It belongs in
150
+ a [builder](resolvers.md#builders) or query class, e.g. `account.posts`.
151
+ - **Authorization** decides whether this user may do this to this record. It
152
+ belongs in `@can*` and your policies.
99
153
 
100
- For list fields, filter with a policy **scope** in a builder (e.g. Pundit's
101
- `policy_scope`) rather than authorizing item-by-item.
154
+ For lists, scope with a builder (e.g. Pundit's `policy_scope`) rather than
155
+ authorizing item by item.
102
156
 
103
- ## Ordering
157
+ ## Migrating from `@can`
158
+
159
+ `@can` is gone. Its modes map one to one:
104
160
 
105
- When several wrapping directives are on one field, they run in this order:
161
+ | Before | After |
162
+ | --- | --- |
163
+ | `@can(ability: "show")` | `@canResolved(ability: "show")` |
164
+ | `@can(ability: "view_email", root: true)` | `@canRoot(ability: "view_email")` |
165
+ | `@can(ability: "update", find: "id", model: "Post")` | `@canFind(ability: "update", find: "id", model: "Post")` |
106
166
 
107
- 1. `@guard` / `@auth` (authentication) — outermost
108
- 2. `@can` (authorization of the resolved value)
109
- 3. the field's data resolver (`@field`, `@all`, `@hasMany`, …) — innermost
167
+ One difference: `@canResolved` authorizes **each item** of a list. `@can` skipped
168
+ lists.
@@ -40,8 +40,21 @@ Without it, nested relationships re-introduce N+1 queries.
40
40
 
41
41
  ## Authorize at the field, early
42
42
 
43
- Use `@auth` (and your own authorization directives) on fields that require it.
44
- Because `@auth` wraps the resolver, the check runs before any data is loaded.
43
+ Use `@guard` and the `@can*` directives on fields that require them. They wrap the
44
+ resolver, so the check runs before any data is loaded (only `@canResolved`, which
45
+ needs the result, runs after — keep it to fields that don't write).
46
+
47
+ ## Keep queries, mutations and builders apart
48
+
49
+ A custom `Query` field is a `GraphQL::Schema::Resolver` in `app/graphql/queries`,
50
+ a `Mutation` field a `GraphQL::Schema::Mutation` in `app/graphql/mutations`, and a
51
+ query refinement a builder in `app/graphql/builders`. They have different jobs and
52
+ contracts; don't fold them into one "resolvers" folder. See [resolvers.md](resolvers.md).
53
+
54
+ ## Validate in the model first
55
+
56
+ Model validations already cover `@create` / `@update`. Reach for `@validator` only
57
+ for rules that aren't the model's. See [validation.md](validation.md).
45
58
 
46
59
  ## Mutations: prefer throwable errors
47
60
 
@@ -62,7 +75,7 @@ end
62
75
 
63
76
  ## One configuration seam
64
77
 
65
- Keep all Lighthouse wiring — `resolver_namespaces`, custom directive
78
+ Keep all Lighthouse wiring — `namespaces`, `resolver_namespaces`, custom directive
66
79
  registration, federation reference resolvers, `on_error` — in initializers, not
67
80
  scattered through the app. This keeps the library a clean dependency and makes the
68
81
  app's GraphQL setup easy to find.