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.
Files changed (112) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +175 -1
  3. data/README.md +47 -3
  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/motivation.md +184 -0
  12. data/docs/mutations.md +260 -0
  13. data/docs/performance.md +138 -0
  14. data/docs/relationships.md +80 -33
  15. data/docs/resolvers.md +189 -0
  16. data/docs/validation.md +132 -0
  17. data/lib/generators/lighthouse/builder_generator.rb +30 -0
  18. data/lib/generators/lighthouse/class_generator.rb +61 -0
  19. data/lib/generators/lighthouse/install/install_generator.rb +127 -0
  20. data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +8 -0
  21. data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +57 -0
  22. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +67 -0
  23. data/lib/generators/lighthouse/install/templates/schema.graphql.tt +19 -0
  24. data/lib/generators/lighthouse/install/templates/schema.rb.tt +10 -0
  25. data/lib/generators/lighthouse/mutation_generator.rb +30 -0
  26. data/lib/generators/lighthouse/query_generator.rb +29 -0
  27. data/lib/generators/lighthouse/validator_generator.rb +36 -0
  28. data/lib/lighthouse/cache_control.rb +126 -0
  29. data/lib/lighthouse/caching.rb +68 -0
  30. data/lib/lighthouse/config.rb +85 -19
  31. data/lib/lighthouse/contracts/arg_builder.rb +8 -12
  32. data/lib/lighthouse/contracts/arg_manipulator.rb +8 -12
  33. data/lib/lighthouse/contracts/directive.rb +10 -14
  34. data/lib/lighthouse/contracts/field_manipulator.rb +8 -12
  35. data/lib/lighthouse/contracts/field_middleware.rb +7 -11
  36. data/lib/lighthouse/contracts/field_resolver.rb +9 -13
  37. data/lib/lighthouse/dataloader_sources/association_loader.rb +28 -19
  38. data/lib/lighthouse/dataloader_sources/model_loader.rb +10 -14
  39. data/lib/lighthouse/dataloader_sources/relation_count_loader.rb +16 -0
  40. data/lib/lighthouse/dataloader_sources/relation_page_loader.rb +18 -0
  41. data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +57 -69
  42. data/lib/lighthouse/directive_resolvers/all_resolver.rb +26 -57
  43. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +161 -0
  44. data/lib/lighthouse/directive_resolvers/auth_resolver.rb +41 -38
  45. data/lib/lighthouse/directive_resolvers/base.rb +142 -86
  46. data/lib/lighthouse/directive_resolvers/builder_applier.rb +58 -56
  47. data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +133 -0
  48. data/lib/lighthouse/directive_resolvers/can_resolvers.rb +177 -0
  49. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +70 -0
  50. data/lib/lighthouse/directive_resolvers/count_resolver.rb +42 -48
  51. data/lib/lighthouse/directive_resolvers/field_resolver.rb +27 -68
  52. data/lib/lighthouse/directive_resolvers/find_resolver.rb +42 -52
  53. data/lib/lighthouse/directive_resolvers/first_resolver.rb +39 -45
  54. data/lib/lighthouse/directive_resolvers/guard_resolver.rb +16 -22
  55. data/lib/lighthouse/directive_resolvers/method_resolver.rb +19 -25
  56. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +219 -0
  57. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +70 -128
  58. data/lib/lighthouse/directive_resolvers/rename_resolver.rb +18 -24
  59. data/lib/lighthouse/directive_resolvers/resolution.rb +31 -27
  60. data/lib/lighthouse/directive_resolvers/validator_resolver.rb +193 -0
  61. data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +164 -163
  62. data/lib/lighthouse/directives/arguments/base.rb +23 -31
  63. data/lib/lighthouse/directives/arguments/filters.rb +113 -93
  64. data/lib/lighthouse/directives/arguments/order_by.rb +41 -41
  65. data/lib/lighthouse/directives/pagination_info_types.rb +69 -0
  66. data/lib/lighthouse/directives/pagination_manipulator.rb +176 -0
  67. data/lib/lighthouse/directives/registry.rb +210 -141
  68. data/lib/lighthouse/directives/where_conditions_directive.rb +236 -200
  69. data/lib/lighthouse/errors.rb +27 -0
  70. data/lib/lighthouse/federation_directives.rb +53 -0
  71. data/lib/lighthouse/field_resolver_stack.rb +56 -0
  72. data/lib/lighthouse/generated_resolvers.rb +82 -0
  73. data/lib/lighthouse/graphql/version.rb +1 -1
  74. data/lib/lighthouse/graphql_ruby_patches.rb +18 -0
  75. data/lib/lighthouse/ide_helper.rb +98 -0
  76. data/lib/lighthouse/namespaces.rb +21 -0
  77. data/lib/lighthouse/nested_mutations/belongs_to_operations.rb +50 -0
  78. data/lib/lighthouse/nested_mutations/executor.rb +149 -0
  79. data/lib/lighthouse/nested_mutations/has_operations.rb +66 -0
  80. data/lib/lighthouse/nested_mutations/many_to_many_operations.rb +44 -0
  81. data/lib/lighthouse/nested_mutations/partitioner.rb +125 -0
  82. data/lib/lighthouse/nested_mutations.rb +210 -0
  83. data/lib/lighthouse/pagination.rb +221 -0
  84. data/lib/lighthouse/persisted_queries.rb +167 -0
  85. data/lib/lighthouse/railtie.rb +26 -0
  86. data/lib/lighthouse/rb_lighthouse.rb +50 -29
  87. data/lib/lighthouse/reference_resolver.rb +13 -15
  88. data/lib/lighthouse/relation_batch.rb +156 -0
  89. data/lib/lighthouse/resolver_dispatch.rb +26 -0
  90. data/lib/lighthouse/schema_cache.rb +120 -0
  91. data/lib/lighthouse/schema_factory.rb +27 -37
  92. data/lib/lighthouse/schema_generator.rb +196 -239
  93. data/lib/lighthouse/schema_implementation.rb +51 -48
  94. data/lib/lighthouse/sdl_loader.rb +17 -20
  95. data/lib/lighthouse/support/arguments.rb +119 -0
  96. data/lib/lighthouse/support/authorization.rb +18 -19
  97. data/lib/lighthouse/support/batching.rb +16 -0
  98. data/lib/lighthouse/support/directive_args.rb +26 -24
  99. data/lib/lighthouse/support/model_resolver.rb +56 -56
  100. data/lib/lighthouse/support/naming.rb +55 -59
  101. data/lib/lighthouse/tasks/lighthouse.rake +33 -0
  102. data/lib/lighthouse/where_conditions/operator_map.rb +25 -29
  103. data/sig/lighthouse/directives.rbs +158 -0
  104. data/sig/lighthouse/graphql.rbs +78 -0
  105. data/sig/lighthouse.rbs +142 -0
  106. metadata +56 -64
  107. data/lib/lighthouse/directive_resolvers/belongs_to_many_resolver.rb +0 -56
  108. data/lib/lighthouse/directive_resolvers/belongs_to_resolver.rb +0 -47
  109. data/lib/lighthouse/directive_resolvers/can_resolver.rb +0 -86
  110. data/lib/lighthouse/directive_resolvers/has_many_resolver.rb +0 -109
  111. data/lib/lighthouse/directive_resolvers/has_one_resolver.rb +0 -43
  112. 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: 844e3fc4942a637b4353427932e031c210ad52d4d425161a58f45e2e36f1d931
4
+ data.tar.gz: 9b0b05109d1fcac2f330b7266fe91eb502d521dabce399adfd98828137119468
5
5
  SHA512:
6
- metadata.gz: 613a4c6a0692f228bee57d8ab160c5c523a6b1660d99b1ad37ccbd38dcb51d7cdc30585642d9f40687183940e660349e14125a773a0b793579ba623e082299ac
7
- data.tar.gz: 5fad77a3975ee0ed94da5db38258585f99227eecb8834e1264275801052e03073098c2b441ce53a38c3324f8044be46b0f79af095b0d65cffbac6cc3ddd6e122
6
+ metadata.gz: ef2d22dd33355e36e3b40725cea55ebf5923a06ea2c94660228d8cc6b3a6e222bd7da6f42f796d52275a2130c1ebd9e54844f85febf31d66a7a4c759d310723e
7
+ data.tar.gz: 7334b3e2b4ecb745fea9a4f9c08860d8e5c6245654323b0161a0b97c367d8ea022a3b23f7bd7e5bbd8c4e8614939b16e82fa737e9e133e4c5cba555199088c1d
data/CHANGELOG.md CHANGED
@@ -6,6 +6,178 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.1] - 2026-10-17
10
+
11
+ ### Added
12
+
13
+ - RBS signatures in `sig/`, shipped with the gem, for the parts an app implements or calls:
14
+ `Lighthouse::_Builder` (what a `@builder` class answers) and `_Validator`, every
15
+ `Lighthouse::Config` setting with the callables it takes (`_ErrorHandler`, `_UserReader`,
16
+ `_Authorizer`), the directive extension points (`DirectiveRegistry`, the contracts,
17
+ `BaseDirectiveResolver`, `Directives::Arguments::Base`), the entrypoints (`get_schema`,
18
+ `SchemaFactory`, `PersistedQueries`, `CacheControl`, `SchemaCache`, `IdeHelper`) and the
19
+ errors. `rbs collection` (and so Steep) loads them from the installed gem, as it does for any
20
+ gem that ships `sig/`; nothing changes at runtime.
21
+ CI validates them and runs the suite with every Lighthouse method checked against its
22
+ signature, so they cannot drift from the code.
23
+ - A documentation site, built from `docs/` with VuePress as PHP Lighthouse builds its own,
24
+ and published to GitHub Pages on every push to `main`:
25
+ <https://vaz-innovation.github.io/lighthouse-graphql/>.
26
+ - `docs/motivation.md`: where Lighthouse comes from, why the schema comes first, and why
27
+ directives work so well on Active Record — and when something else fits better.
28
+
29
+ ## [0.2.0] - 2026-10-17
30
+
31
+ ### Added
32
+
33
+ - `rails generate lighthouse:install` sets a Rails app up: the initializer (every option,
34
+ commented out at its default), a starter schema, the schema class with
35
+ `GraphQL::Dataloader`, a `GraphqlController` and its route, and a `graphql.config.yml`
36
+ for IDEs. `--schema`, `--sdl-folder`, `--skip-controller` and `--skip-ide` adjust it;
37
+ rerunning it adds the route and `.gitignore` entries only once. It coexists with
38
+ graphql-ruby's `graphql:install`: an existing controller or schema class is kept unless
39
+ `--force` is given, and the route is written exactly as graphql-ruby writes it, so it is
40
+ never added twice in either order.
41
+ - `bin/rails lighthouse:ide_helper` writes `schema-directives.graphql` (every registered
42
+ directive the SDL does not declare) and `programmatic-types.graphql` (the types the
43
+ manipulation phase generates) so IDEs can validate and autocomplete a Lighthouse
44
+ schema, as PHP Lighthouse's `lighthouse:ide-helper` does.
45
+ - `DirectiveRegistry#register_definition` contributes the SDL definition of a directive
46
+ that has no class of its own; `register_type_definition` / a class's `type_definitions`
47
+ contribute the types such definitions refer to, injected alongside them.
48
+ - **Queries, mutations and builders as separate kinds**, each with its own namespace
49
+ (`Config.namespaces`) and contract. A `Query` or `Mutation` field without a resolving
50
+ directive resolves by convention to `Queries::<FieldName>` (a `GraphQL::Schema::Resolver`)
51
+ or `Mutations::<FieldName>` (a `GraphQL::Schema::Mutation`, `RelayClassicMutation`
52
+ included) — graphql-ruby's contract: `resolve(**args)`, `object`, `context`, `ready?`,
53
+ `authorized?`. `@field(resolver:)` on those roots looks in the same namespaces. `@builder`
54
+ classes live in `Builders::`. Generators: `lighthouse:query`, `lighthouse:mutation`,
55
+ `lighthouse:builder`, `lighthouse:validator`.
56
+ - `@create`, `@createMany`, `@update`, `@updateMany`: save the returned model through
57
+ ActiveRecord, in a transaction (`Config.transactional_mutations`), reporting model
58
+ validation errors by argument path (`extensions.validation`, as in PHP Lighthouse).
59
+ `@spread` flattens an input into the arguments, `@inject(context:, name:)` sets
60
+ server-side values, `@rename` now also applies to arguments.
61
+ - `@validator` (field or input type) runs a contract before the field resolves:
62
+ a `Dry::Validation::Contract` (optional dependency) or any `call(values)` returning
63
+ errors. Input-type validators apply wherever the type is used, nested and in lists.
64
+ - The `@can*` family, Pundit by default: `@canFind`, `@canModel`, `@canQuery`,
65
+ `@canResolved`, `@canRoot`, with `injectArgs`, `args`, `action`
66
+ (`EXCEPTION_PASS` / `EXCEPTION_NOT_AUTHORIZED` / `RETURN_VALUE`) and `returnValue`. The
67
+ authorizer receives those arguments as a fourth parameter, only when there are any.
68
+ - `@cache(maxAge:, private:)`, `@cacheKey` and `@clearCache(type:, idSource:, field:)` over
69
+ `Config.cache_store` (`Rails.cache`), invalidating through versioned keys so it works on
70
+ any store, including ones without `delete_matched`.
71
+ - **Nested mutations**, PHP Lighthouse's API over ActiveRecord: an input field named after
72
+ an association carries `connect` / `create` / `update` / `upsert` / `disconnect` /
73
+ `delete` (belongs_to, polymorphic belongs_to, has_one, has_many) and `sync` /
74
+ `syncWithoutDetaching` (has_many :through, has_and_belongs_to_many — with pivot columns on a
75
+ `:through` join model), to any depth. Each record is saved once; errors are keyed by the full
76
+ input path. `@create` / `@update` / `@upsert` / `@delete(relation:)` on an argument run one
77
+ operation on an association.
78
+ - `@upsert` / `@upsertMany` (by primary key or `identifyingColumns`) and `@delete` (by the
79
+ field's filter arguments; refuses to delete without one), and the `@whereKey` filter.
80
+ - `@paginate(type: PAGINATOR | SIMPLE | CONNECTION, scopes:, defaultCount:, maxCount:)` and
81
+ `type:` on `@hasMany` / `@belongsToMany` / `@morphMany` / `@morphToMany`, generating PHP
82
+ Lighthouse's types and arguments (`first`, `page`, `after`; `PaginatorInfo`,
83
+ `SimplePaginatorInfo`, `PageInfo` with `total` / `count` / `currentPage` / `lastPage`). The total
84
+ is only counted when selected. `Config.pagination_default_count` / `pagination_max_count`.
85
+ - `@morphTo`, `@morphOne`, `@morphMany`, `@morphToMany` (ActiveRecord polymorphic
86
+ associations), and `scopes:` on every relationship directive.
87
+ - The integration suites of PHP Lighthouse for nested mutations (belongsTo, hasOne, hasMany,
88
+ belongsToMany, morphTo, morphOne, morphMany, morphToMany), the model mutation directives,
89
+ `@all` and `@paginate`, ported to RSpec against the same models (spec/integration).
90
+ - **Opt-in options for large schemas and high traffic**, all off by default (docs/performance.md):
91
+ - `config.schema_cache` caches the built schema document in `tmp/cache/lighthouse`, Bootsnap
92
+ style: content-addressed (SDL, versions, registered directives, manipulator source, config),
93
+ so it invalidates itself; atomic writes; `bin/rails lighthouse:cache` / `lighthouse:clear_cache`.
94
+ About 1.8x faster schema builds on large schemas.
95
+ - `config.watch_sdl` adds the SDL folder to Rails' watched dirs, so editing a `.graphql` file
96
+ reloads the schema in development (it previously needed a server restart).
97
+ - `config.persisted_queries`: Apollo-compatible Automatic Persisted Queries
98
+ (`PersistedQueries.execute`, used by the generated controller), with a TTL, a store, a
99
+ manifest of trusted operations (Apollo's format or `{ sha256 => query }`) served from memory,
100
+ `persisted_queries_only` as an allowlist, and `bin/rails lighthouse:persisted_queries:verify`
101
+ to check the manifest against the schema in CI.
102
+ - `@cacheControl(maxAge:, scope:, inheritMaxAge:)` computes the response's `Cache-Control`
103
+ header from the selected fields (PHP Lighthouse's / Apollo's rules);
104
+ `Lighthouse::GraphQL::CacheControl.apply(response, result)` sets it, and the generated
105
+ controller calls it.
106
+ - `example/`: a Rails 8.1 blog API built with Lighthouse, which is both an example to
107
+ read and the end-to-end suite. Its specs go through the real `/graphql` endpoint:
108
+ queries, pagination, relationships without N+1, `@guard` and the `@can*` family with
109
+ Pundit, mutations (nested ones included), validation, `@cache`/`@cacheControl`,
110
+ persisted queries over `GET`, and the rake tasks. CI runs it.
111
+ - RuboCop (with rubocop-rails, -rspec, -performance and -factory_bot) lints the gem and
112
+ the example app; CI runs it, and `rake` runs it after the suite.
113
+ - **Paginated relations no longer query once per parent.** `@hasMany`,
114
+ `@belongsToMany`, `@morphMany` and `@morphToMany` with `type:`, and `@paginate` on a
115
+ nested field, load every parent's page in one query (a `ROW_NUMBER()` window
116
+ partitioned by parent) and every total in one `GROUP BY`, only when a total is
117
+ selected. Databases without window functions keep loading one parent at a time. See
118
+ docs/relationships.md.
119
+
120
+ ### Fixed
121
+
122
+ - `@whereConditions`' `AND`, `OR` and `HAS` were ignored when they came from a query.
123
+ graphql-ruby hands input objects over with Ruby-style keys (`:and`, `:or`, `:has`), and
124
+ only the upper-case keys were read, so a nested condition matched every record.
125
+ - `scopes:` was declared but ignored by `@count` and `@first`; `@all` now accepts it too.
126
+ - The default resolver discarded the result of a generated three-argument resolver
127
+ (`resolve_field_<name>(obj, args, ctx)`) and fell through to reading the attribute.
128
+ - Used standalone (without Rails), the gem missed ActiveSupport's `Enumerable` and
129
+ `String#exclude?` extensions it relies on (`index_by`, `pluck`, `exclude?`).
130
+
131
+ - Every built-in directive now contributes its SDL definition. `@all`, `@find`,
132
+ `@paginate`, `@hasMany`, `@belongsTo`, `@belongsToMany`, `@aggregate`, `@field`,
133
+ `@auth`, `@builder`, `@whereConditions` and `@whereHasConditions` had none, so a schema
134
+ that used one without declaring it failed to build — contrary to the docs.
135
+ - `@find` without `model:` found nothing: it inferred the model from the return type's
136
+ Ruby class name, which is `nil` for SDL-built types, instead of its GraphQL name.
137
+ - `@find` returned `null` for existing records when batched through the Dataloader: `ID`
138
+ arguments arrive as strings and were looked up against integer primary keys.
139
+ - Wrapping directives (`@guard`, ...) on a field without a resolving directive called
140
+ graphql-ruby's internal resolver with the wrong arity instead of the default reader.
141
+ - The gem now requires `graphql` itself instead of relying on the app to load it first.
142
+ - `@all` on a list field returned `nodes: null`: the field was rewritten to a connection the
143
+ resolver never built. `@all` now returns the list, as in PHP Lighthouse; it also resolved
144
+ `model:` ignoring `resolver_namespaces` and stripping the namespace (`Admin::User` → `User`).
145
+ - `@paginate` ignored the field's argument filters (`@eq`, `@like`, ...), took no `first` /
146
+ `page` arguments as a paginator, and returned `nodes: null` as a connection.
147
+ - `@find` ignored every argument but `id`; it now applies the argument filters and errors on
148
+ more than one match.
149
+ - The relationship directives' Dataloader source loaded each parent's association separately
150
+ (N+1); it now batches with ActiveRecord's preloader.
151
+ - The relationship directives and `@find` raised "Dataloader is not running" on schemas without
152
+ `GraphQL::Dataloader`; they now load directly there.
153
+ - Generated pagination wrappers made every paginated field `no-cache` under `@cacheControl`;
154
+ they now inherit the parent's maxAge, as in PHP Lighthouse.
155
+ - An explicit `null` directive argument (`@paginate(maxCount: null)`) failed to build the
156
+ schema: graphql-ruby passes it on as a NullValue node. Lighthouse maps it to nil.
157
+
158
+ ### Removed
159
+
160
+ - **`@can`** — replaced by the `@can*` family. `@can(ability:)` becomes `@canResolved`,
161
+ `root: true` becomes `@canRoot`, `find:` / `model:` become `@canFind`; see the migration
162
+ table in docs/authorization.md. Schemas still using `@can` fail to build.
163
+
164
+ ### Changed
165
+
166
+ - **`@hasMany` and `@belongsToMany` return plain lists**, as in PHP Lighthouse, instead of
167
+ connections. Pass `type: CONNECTION` (or `PAGINATOR` / `SIMPLE`) to paginate; clients that
168
+ selected `edges` / `nodes` on them need the `type:`.
169
+ - Paginated fields follow PHP Lighthouse: a paginator takes `first: Int!` (defaulted by
170
+ `defaultCount`) and `page`, a connection `first: Int!` and `after`, and returns non-null.
171
+ - `@paginate`'s `type:` is the `PaginateType` enum (`PAGINATOR`, `SIMPLE`, `CONNECTION`); the
172
+ string form still works at runtime.
173
+ - `@builder` classes are looked up in `Config.namespaces[:builders]` (`Builders::`) instead of
174
+ `resolver_namespaces`. Fully-qualified names work as before.
175
+ - `@field(resolver:)` on `Query` / `Mutation` looks in the queries / mutations namespaces;
176
+ elsewhere still in `resolver_namespaces`.
177
+ - `@aggregate`'s `function:` is declared as `String`. Enum
178
+ literals (`function: SUM`) still work at runtime, but IDEs expect strings
179
+ (`function: "SUM"`).
180
+
9
181
  ## [0.1.1] - 2026-09-23
10
182
 
11
183
  Release tooling only: the library is unchanged from `0.1.0`. The packaged gem differs because
@@ -56,6 +228,8 @@ had been running inside a private monorepo as a path gem before being extracted.
56
228
  - Config-driven extension seams: `Lighthouse.configure`, `resolver_namespaces`,
57
229
  `on_error`, and `DirectiveRegistry.register` for app-defined directives.
58
230
 
59
- [Unreleased]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.1.1...HEAD
231
+ [Unreleased]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.2.1...HEAD
232
+ [0.2.1]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.2.0...v0.2.1
233
+ [0.2.0]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.1.1...v0.2.0
60
234
  [0.1.1]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.1.0...v0.1.1
61
235
  [0.1.0]: https://github.com/Vaz-Innovation/lighthouse-graphql/releases/tag/v0.1.0
data/README.md CHANGED
@@ -39,7 +39,7 @@ paginate, walk a relationship, or read an attribute. Lighthouse — like its PHP
39
39
  inspiration — captures those patterns as **directives** you attach in the schema,
40
40
  and falls back to a sensible default reader for plain fields. You write Ruby only
41
41
  for the genuinely custom parts (`@field(resolver: "...")`), and your schema stays
42
- the single source of truth.
42
+ the single source of truth. [Motivation](docs/motivation.md) explains the idea in depth.
43
43
 
44
44
  ### Inspirations
45
45
 
@@ -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,17 +131,33 @@ 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
109
144
 
145
+ **Read it at [vaz-innovation.github.io/lighthouse-graphql](https://vaz-innovation.github.io/lighthouse-graphql/)**,
146
+ or as Markdown in [`docs/`](docs):
147
+
110
148
  | Guide | What it covers |
111
149
  | --- | --- |
150
+ | [Motivation](docs/motivation.md) | Where Lighthouse comes from, why the schema comes first, and why directives fit Active Record |
112
151
  | [Getting started](docs/getting-started.md) | Install, base types, building & serving the schema |
113
152
  | [Directives reference](docs/directives.md) | Every built-in directive, grouped by purpose |
114
- | [Relationships](docs/relationships.md) | `@hasMany`, `@belongsTo`, `@belongsToMany`, `@hasOne` and batching |
153
+ | [Relationships](docs/relationships.md) | `@hasMany`, `@belongsTo`, `@belongsToMany`, `@hasOne`, the `@morph*` variants, batching and `type:` pagination |
115
154
  | [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) |
155
+ | [Queries, mutations & builders](docs/resolvers.md) | `Queries::` / `Mutations::` classes (graphql-ruby's contract), builders, generators |
156
+ | [Mutations](docs/mutations.md) | `@create`, `@update`, `@upsert`, `@delete` (and `*Many`), nested mutations on every kind of association, `@spread`, `@inject` |
157
+ | [Validation](docs/validation.md) | Model validations, `@validator` with Dry::Validation contracts |
158
+ | [Authentication & authorization](docs/authorization.md) | `@guard`, `@canFind` / `@canModel` / `@canQuery` / `@canResolved` / `@canRoot` (Pundit by default) |
159
+ | [Performance at scale](docs/performance.md) | Opt-in schema cache, SDL reloading in development, persisted queries (APQ) and trusted-document allowlists |
160
+ | [Caching](docs/caching.md) | `@cache`, `@cacheKey`, `@clearCache` (Rails.cache), `@cacheControl` (HTTP) |
117
161
  | [Apollo Federation](docs/federation.md) | `@key`, `resolve_reference`, the `ReferenceResolver` registry |
118
162
  | [Custom directives](docs/custom-directives.md) | The contracts, the registry, writing your own directive |
119
163
  | [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.