lighthouse-graphql 0.5.0 → 0.7.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -1
  3. data/README.md +38 -22
  4. data/docs/best-practices.md +6 -4
  5. data/docs/caching.md +4 -3
  6. data/docs/configuration.md +20 -26
  7. data/docs/custom-directives.md +2 -1
  8. data/docs/directives.md +79 -14
  9. data/docs/federation.md +1 -1
  10. data/docs/filtering-and-ordering.md +330 -30
  11. data/docs/generators.md +42 -40
  12. data/docs/getting-started.md +128 -58
  13. data/docs/motivation.md +3 -2
  14. data/docs/performance.md +5 -4
  15. data/docs/relationships.md +7 -5
  16. data/docs/relay.md +63 -0
  17. data/docs/resolvers.md +92 -39
  18. data/docs/schema-organisation.md +177 -0
  19. data/docs/security.md +64 -16
  20. data/docs/subscriptions.md +16 -9
  21. data/docs/types.md +16 -17
  22. data/docs/uploads.md +41 -14
  23. data/lib/generators/lighthouse/base_classes.rb +9 -14
  24. data/lib/generators/lighthouse/install/graphql_ruby_install.rb +113 -0
  25. data/lib/generators/lighthouse/install/install_generator.rb +125 -99
  26. data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +2 -1
  27. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +14 -13
  28. data/lib/lighthouse/ast_builder.rb +124 -0
  29. data/lib/lighthouse/caching.rb +9 -3
  30. data/lib/lighthouse/config.rb +27 -25
  31. data/lib/lighthouse/contracts/arg_manipulator.rb +9 -5
  32. data/lib/lighthouse/contracts/field_builder.rb +13 -0
  33. data/lib/lighthouse/controller.rb +28 -5
  34. data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +1 -2
  35. data/lib/lighthouse/directive_resolvers/all_resolver.rb +0 -2
  36. data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +18 -18
  37. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +9 -6
  38. data/lib/lighthouse/directive_resolvers/base.rb +6 -37
  39. data/lib/lighthouse/directive_resolvers/builder_applier.rb +21 -6
  40. data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +1 -1
  41. data/lib/lighthouse/directive_resolvers/count_resolver.rb +0 -1
  42. data/lib/lighthouse/directive_resolvers/first_resolver.rb +0 -2
  43. data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +3 -1
  44. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +2 -3
  45. data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +85 -26
  46. data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +12 -6
  47. data/lib/lighthouse/directives/arguments/more_filters.rb +1 -1
  48. data/lib/lighthouse/directives/arguments/order_by.rb +190 -26
  49. data/lib/lighthouse/directives/arguments/where_conditions.rb +25 -0
  50. data/lib/lighthouse/directives/arguments/where_conditions_base.rb +84 -0
  51. data/lib/lighthouse/directives/arguments/where_has_conditions.rb +56 -0
  52. data/lib/lighthouse/directives/definitions.rb +0 -1
  53. data/lib/lighthouse/directives/generated_types.rb +103 -0
  54. data/lib/lighthouse/directives/registry.rb +39 -10
  55. data/lib/lighthouse/document_ast.rb +111 -0
  56. data/lib/lighthouse/field_resolver_stack.rb +9 -6
  57. data/lib/lighthouse/global_id.rb +29 -3
  58. data/lib/lighthouse/graphql/version.rb +1 -1
  59. data/lib/lighthouse/graphql_ruby_patches.rb +45 -0
  60. data/lib/lighthouse/ide_helper.rb +80 -23
  61. data/lib/lighthouse/namespaces.rb +1 -0
  62. data/lib/lighthouse/order_by/types.rb +137 -0
  63. data/lib/lighthouse/railtie.rb +9 -2
  64. data/lib/lighthouse/rb_lighthouse.rb +16 -5
  65. data/lib/lighthouse/resolve_info.rb +5 -5
  66. data/lib/lighthouse/resolver_bases.rb +18 -4
  67. data/lib/lighthouse/resolver_dispatch.rb +16 -1
  68. data/lib/lighthouse/root_types.rb +117 -0
  69. data/lib/lighthouse/schema_cache.rb +8 -4
  70. data/lib/lighthouse/schema_factory.rb +6 -9
  71. data/lib/lighthouse/schema_generator.rb +8 -18
  72. data/lib/lighthouse/schema_stitcher.rb +64 -0
  73. data/lib/lighthouse/support/naming.rb +0 -12
  74. data/lib/lighthouse/upload.rb +4 -2
  75. data/lib/lighthouse/upload_store.rb +39 -14
  76. data/lib/lighthouse/where_conditions/columns.rb +36 -0
  77. data/lib/lighthouse/where_conditions/handler.rb +152 -0
  78. data/lib/lighthouse/where_conditions/json_column.rb +99 -0
  79. data/lib/lighthouse/where_conditions/operator_map.rb +1 -4
  80. data/lib/lighthouse/where_conditions/types.rb +110 -0
  81. data/sig/lighthouse/directives.rbs +16 -3
  82. data/sig/lighthouse/graphql.rbs +48 -4
  83. data/sig/lighthouse.rbs +8 -12
  84. metadata +17 -7
  85. data/lib/generators/lighthouse/install/templates/schema.rb.tt +0 -29
  86. data/lib/generators/lighthouse/mutation_generator.rb +0 -31
  87. data/lib/generators/lighthouse/query_generator.rb +0 -30
  88. data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +0 -170
  89. data/lib/lighthouse/directives/where_conditions_directive.rb +0 -244
  90. data/lib/lighthouse/sdl_loader.rb +0 -22
data/docs/types.md CHANGED
@@ -69,17 +69,15 @@ Most schemas need nothing else.
69
69
 
70
70
  When the default is not enough, say when the values are Hashes from a search
71
71
  service rather than records, write the union's class as graphql-ruby does
72
- (`rails g graphql:union SearchResult`),
73
- `Types::<Name>Type` including `Lighthouse::GraphQL::Type`, with `resolve_type`.
74
- It returns a type name, or a type class, which stands for the SDL type of its
75
- `graphql_name`:
72
+ (`rails g graphql:union SearchResult`): `Types::<Name>Type`, with `resolve_type`.
73
+ Its base, `Types::BaseUnion`, includes `Lighthouse::GraphQL::Type` after
74
+ `lighthouse:install` (see [opting in](#opting-in-and-base-classes)). It returns a
75
+ type name, or a type class, which stands for the SDL type of its `graphql_name`:
76
76
 
77
77
  ```ruby
78
78
  # app/graphql/types/search_result_type.rb
79
79
  module Types
80
80
  class SearchResultType < BaseUnion
81
- include Lighthouse::GraphQL::Type
82
-
83
81
  def self.resolve_type(object, context)
84
82
  return unless object.is_a?(Hash)
85
83
 
@@ -96,7 +94,6 @@ An interface's goes in graphql-ruby's `definition_methods`:
96
94
  module Types
97
95
  module AuthoredType
98
96
  include BaseInterface
99
- include Lighthouse::GraphQL::Type
100
97
 
101
98
  definition_methods do
102
99
  def resolve_type(object, context)
@@ -154,15 +151,14 @@ type Query {
154
151
  }
155
152
  ```
156
153
 
157
- Write the scalar's class with graphql-ruby's `rails g graphql:scalar DateTime`,
158
- `Types::<Name>Type` including `Lighthouse::GraphQL::Type`:
154
+ Write the scalar's class with graphql-ruby's `rails g graphql:scalar DateTime`:
155
+ `Types::<Name>Type`, a `Types::BaseScalar`, which includes `Lighthouse::GraphQL::Type`
156
+ after `lighthouse:install`:
159
157
 
160
158
  ```ruby
161
159
  # app/graphql/types/date_time_type.rb
162
160
  module Types
163
161
  class DateTimeType < BaseScalar
164
- include Lighthouse::GraphQL::Type
165
-
166
162
  def self.coerce_input(value, context)
167
163
  Time.iso8601(value.to_s)
168
164
  rescue ArgumentError
@@ -194,10 +190,12 @@ class with `coerce_input` and `coerce_result` fails when the schema is built.
194
190
  ## Opting in, and base classes
195
191
 
196
192
  Only a class including `Lighthouse::GraphQL::Type` counts: any other
197
- `Types::SearchResultType` or `Types::DateTimeType`, such as the ones a graphql-ruby
198
- app already has, is left alone. The concern can be included in a subclass of
199
- `GraphQL::Schema::Object`, `Union` or `Scalar`, or in a module including
200
- `GraphQL::Schema::Interface`.
193
+ `Types::SearchResultType` or `Types::DateTimeType` is left alone. `lighthouse:install`
194
+ includes the concern in graphql-ruby's `Types::BaseObject`, `BaseInterface`,
195
+ `BaseUnion` and `BaseScalar`, so every type class inheriting from them counts. In an
196
+ app that did not run it, include the concern by hand, in those bases or in each
197
+ class: a subclass of `GraphQL::Schema::Object`, `Union` or `Scalar`, or a module
198
+ including `GraphQL::Schema::Interface`.
201
199
 
202
200
  The classes are looked up in [`namespaces[:types]`](configuration.md#namespaces)
203
201
  (default `['Types']`). When the schema's `base_types` name a base for a kind,
@@ -205,15 +203,16 @@ graphql-ruby builds the SDL's types of that kind from it, and a type class of th
205
203
  kind must inherit from it, or the schema does not build:
206
204
 
207
205
  ```ruby
208
- AppSchema = Lighthouse::GraphQL::RbLightHouse.get_schema(
206
+ class AppSchema < Lighthouse::GraphQL::RbLightHouse.get_schema(
209
207
  options: {
210
208
  base_types: {
211
209
  object: Types::BaseObject,
212
210
  union: Types::BaseUnion,
213
211
  interface: Types::BaseInterface,
214
212
  scalar: Types::BaseScalar
215
- # resolver:, mutation:, subscription: (resolvers.md#handing-the-bases-to-the-schema)
213
+ # query_type:, node:, resolver:, mutation:, ... (resolvers.md#handing-the-bases-to-the-schema)
216
214
  }
217
215
  }
218
216
  )
217
+ end
219
218
  ```
data/docs/uploads.md CHANGED
@@ -27,7 +27,9 @@ on `User`, the mutation above attaches it.
27
27
 
28
28
  ## `@upload`
29
29
 
30
- `@upload` stores the file first, and passes along what was stored instead:
30
+ `@upload` stores the file first, and passes along what was stored instead, as PHP
31
+ Lighthouse's `@upload` does on a Laravel disk. The file goes to your app's
32
+ ActiveStorage, the way the rest of the app stores files:
31
33
 
32
34
  ```graphql
33
35
  type Mutation {
@@ -38,23 +40,42 @@ type Mutation {
38
40
  }
39
41
  ```
40
42
 
41
- By default the file becomes an ActiveStorage blob: on the service `disk:` names (the
42
- default service without it), with its key under `path:`. The argument then holds the
43
- blob's **signed id**, which an attachment attribute attaches (`user.avatar = signed_id`)
44
- and `ActiveStorage::Blob.find_signed` finds. Lists of files are stored one by one.
43
+ | | PHP Lighthouse (Laravel) | lighthouse-graphql (ActiveStorage) |
44
+ | --- | --- | --- |
45
+ | `disk` | a disk of `config/filesystems.php`; `filesystems.default` without it | a service of `config/storage.yml`; `config.active_storage.service` without it |
46
+ | `path` (`"/"`) | the directory the file is stored in | the directory the blob's key starts with (`avatars/<token>`); at `"/"`, ActiveStorage's own key |
47
+ | `public` (`false`) | the file's visibility | must agree with the service's (see below) |
48
+ | the argument holds | the stored path | the blob's **signed id** |
49
+
50
+ The signed id is what an attachment attribute attaches (`user.avatar = signed_id`) and
51
+ `ActiveStorage::Blob.find_signed` finds; the blob keeps the file's name and content
52
+ type. Lists of files are stored one by one, and `null` stays `null`.
53
+
54
+ ActiveStorage makes a whole service public (`public: true` in `config/storage.yml`)
55
+ or private, never one file. So `public:` must agree with the service's, and the schema
56
+ never says a file is private that anyone can read: `@upload(public: true)` on a private
57
+ service fails, and so does `@upload` (`public: false`) on a public one. A service
58
+ `config/storage.yml` does not declare fails too.
59
+
60
+ To store files somewhere else (S3, GCS, Azure or a service of your own), declare an
61
+ ActiveStorage service for it and name it with `disk:`, as a Laravel app declares a disk:
62
+
63
+ ```yaml
64
+ # config/storage.yml
65
+ avatars:
66
+ service: S3
67
+ bucket: my-app-avatars
68
+ region: us-east-1
69
+ public: true
70
+ ```
71
+
72
+ ```graphql
73
+ updateAvatar(avatar: Upload! @upload(disk: "avatars", path: "users", public: true)): User!
74
+ ```
45
75
 
46
76
  `@upload` runs once `@guard`, the `@can*` family and `@validator` have let the call
47
77
  through, so a refused call stores nothing.
48
78
 
49
- To store files elsewhere, give [`upload_store`](configuration.md#upload_store) an
50
- object with `call(file, disk:, path:, public:)`; the argument holds what it returns:
51
-
52
- ```ruby
53
- Lighthouse.configure do |config|
54
- config.upload_store = ->(file, path: '/', **) { S3Uploader.put(file, prefix: path) } # returns the key
55
- end
56
- ```
57
-
58
79
  ## The request
59
80
 
60
81
  ```shell
@@ -68,6 +89,12 @@ curl localhost:3000/graphql \
68
89
  A batch sends an array as `operations`, and maps files to `"0.variables.avatar"`,
69
90
  `"1.variables.files.0"`, and so on.
70
91
 
92
+ A multipart request is also what an HTML form on another site can send. If the GraphQL
93
+ controller authenticates with the session cookie, keep Rails' CSRF protection on it
94
+ (`protect_from_forgery`, on by default in `ActionController::Base`) and have clients send
95
+ the `X-CSRF-Token` header, as PHP Lighthouse has them send `X-Requested-With` for its
96
+ `EnsureXHR` middleware. A controller that authenticates with a token header is not exposed.
97
+
71
98
  In a request spec:
72
99
 
73
100
  ```ruby
@@ -4,19 +4,18 @@ require 'rails/generators'
4
4
  require 'lighthouse-graphql'
5
5
 
6
6
  # The base class graphql-ruby's convention puts in each namespace, which the
7
- # classes Lighthouse generates inherit from: Resolvers::BaseResolver,
8
- # Mutations::BaseMutation and Subscriptions::BaseSubscription.
7
+ # classes Lighthouse generates inherit from.
9
8
  #
10
- # graphql-ruby's `graphql:install` writes the first two (and every Types::Base*),
11
- # and `lighthouse:install` runs it: Lighthouse does not write copies of them. It
12
- # writes only the base graphql-ruby has none of, BaseSubscription, which
13
- # includes Lighthouse's subscription concern. A generator that finds a base
14
- # missing says how to get it.
9
+ # graphql-ruby's `graphql:install` writes Resolvers::BaseResolver,
10
+ # Mutations::BaseMutation and every Types::Base*, and `lighthouse:install`
11
+ # builds on them: Lighthouse does not write copies of them. It writes only the
12
+ # base graphql-ruby has none of, Subscriptions::BaseSubscription, which
13
+ # includes Lighthouse's subscription concern.
15
14
  #
16
15
  # A base that already exists is never replaced, not even with --force: it holds
17
16
  # the app's own code.
18
17
  module Lighthouse::Generators::BaseClasses
19
- NAMES = { queries: 'BaseResolver', mutations: 'BaseMutation', subscriptions: 'BaseSubscription' }.freeze
18
+ NAMES = { subscriptions: 'BaseSubscription' }.freeze
20
19
 
21
20
  # The bases Lighthouse writes itself, graphql-ruby having none.
22
21
  SOURCES = {
@@ -39,19 +38,15 @@ module Lighthouse::Generators::BaseClasses
39
38
  NAMES[kind]
40
39
  end
41
40
 
42
- # Write the base class of `kind` into `namespace` when Lighthouse owns it and
43
- # it is missing; say how to get one graphql-ruby owns.
41
+ # Write the base class of `kind` into `namespace` when it is missing.
44
42
  def write_base_class(kind, namespace)
45
43
  return unless base_class_name(kind)
46
44
 
47
45
  path = base_class_path(kind, namespace)
48
46
  if File.exist?(File.join(destination_root, path))
49
47
  say_status :skip, "#{path} already exists", :yellow
50
- elsif SOURCES[kind]
51
- create_file path, ruby_source(namespace.split('::'), SOURCES[kind])
52
48
  else
53
- say_status :missing, "#{path}: graphql-ruby writes it — run `bin/rails g lighthouse:install` (which runs graphql:install), " \
54
- "or write #{namespace}::#{base_class_name(kind)} yourself", :red
49
+ create_file path, ruby_source(namespace.split('::'), SOURCES.fetch(kind))
55
50
  end
56
51
  end
57
52
 
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ # What graphql-ruby's `graphql:install` wrote in an app, as lighthouse:install
4
+ # reads it: where its schema class is (under app/, beside its types: --directory,
5
+ # --schema), which of its classes exist (--relay, --skip-mutation-root-type), and
6
+ # the parts of its templates Lighthouse takes out.
7
+ module Lighthouse::Generators::GraphqlRubyInstall
8
+ # graphql-ruby's code-first root types and Node interface, by base_types key (RootTypes).
9
+ ROOT_TYPES = { query_type: 'QueryType', mutation_type: 'MutationType', subscription_type: 'SubscriptionType', node: 'NodeType' }.freeze
10
+
11
+ # graphql-ruby's base types, by base_types key.
12
+ TYPE_BASES = {
13
+ object: 'BaseObject', interface: 'BaseInterface', union: 'BaseUnion', scalar: 'BaseScalar',
14
+ enum: 'BaseEnum', input_object: 'BaseInputObject'
15
+ }.freeze
16
+
17
+ # Where Lighthouse::GraphQL::Type goes in graphql-ruby's base types: after the
18
+ # class line, or the interface's own include.
19
+ TYPE_CONCERN_ANCHORS = {
20
+ 'base_object' => /^([ \t]*)class BaseObject < GraphQL::Schema::Object\n/,
21
+ 'base_union' => /^([ \t]*)class BaseUnion < GraphQL::Schema::Union\n/,
22
+ 'base_scalar' => /^([ \t]*)class BaseScalar < GraphQL::Schema::Scalar\n/,
23
+ 'base_interface' => /^([ \t]*)include GraphQL::Schema::Interface\n/
24
+ }.freeze
25
+
26
+ # The example field graphql-ruby's install puts in each root type.
27
+ TEST_FIELD = /\n+[ \t]*# TODO: remove me\n[ \t]*field :test_field,.*?\n[ \t]*def test_field\n.*?\n[ \t]*end\n/m
28
+
29
+ # The `resolve_type` stub graphql-ruby's install puts in the schema class.
30
+ RESOLVE_TYPE_STUB = /
31
+ \n+[ \t]*\#\ Union\ and\ Interface\ Resolution\n
32
+ [ \t]*def\ self\.resolve_type\(.*?\)\n
33
+ (?:[ \t]*\#.*\n)*
34
+ [ \t]*raise\(GraphQL::RequiredImplementationMissingError\)\n
35
+ [ \t]*end\n
36
+ /x
37
+
38
+ private
39
+
40
+ # The file of graphql-ruby's schema class under app/ (--schema picks one by name), or nil.
41
+ def schema_path
42
+ @schema_path ||= Dir[File.join(destination_root, 'app', '**', '*.rb')].filter_map do |file|
43
+ name = schema_class_name(File.read(file))
44
+ file.delete_prefix("#{destination_root}/") if name && (options[:schema].nil? || name.demodulize == options[:schema].camelize)
45
+ end.first
46
+ end
47
+
48
+ def schema_class_name(source)
49
+ source[/^[ \t]*class (\S+) < (?:GraphQL::Schema|Lighthouse::GraphQL::RbLightHouse\.get_schema)\b/, 1] ||
50
+ source[/^[ \t]*(\S+) = Lighthouse::GraphQL::RbLightHouse\.get_schema\b/, 1]
51
+ end
52
+
53
+ def schema_name
54
+ schema_class_name(read(schema_path)).demodulize
55
+ end
56
+
57
+ def lighthouse?(source)
58
+ source.include?('Lighthouse::GraphQL::RbLightHouse.get_schema')
59
+ end
60
+
61
+ # graphql-ruby's install: its defaults, an API app's smaller stack, the name --schema gives.
62
+ def graphql_install_options
63
+ { schema: options[:schema], api: api_only? || nil }.compact
64
+ end
65
+
66
+ def api_only?
67
+ exist?('config/application.rb') && read('config/application.rb').match?(/^\s*config\.api_only\s*=\s*true/)
68
+ end
69
+
70
+ # graphql-ruby writes its types beside the schema class (--directory).
71
+ def directory
72
+ File.dirname(schema_path)
73
+ end
74
+
75
+ def types_path(file)
76
+ File.join(directory, 'types', file)
77
+ end
78
+
79
+ # Lighthouse's base_types: every class graphql-ruby's install wrote, found
80
+ # where it writes it, as [key, constant] pairs.
81
+ def base_types
82
+ @base_types ||= type_classes(ROOT_TYPES) + resolver_classes + type_classes(TYPE_BASES)
83
+ end
84
+
85
+ def type_classes(names)
86
+ names.filter_map { |key, name| [key, "Types::#{name}"] if exist?(types_path("#{name.underscore}.rb")) }
87
+ end
88
+
89
+ def resolver_classes
90
+ classes = { resolver: %w[resolvers BaseResolver], mutation: %w[mutations BaseMutation] }
91
+ classes[:subscription] = %w[subscriptions BaseSubscription] if subscription_base?
92
+ classes.filter_map do |key, (folder, name)|
93
+ [key, "#{folder.camelize}::#{name}"] if exist?(File.join(directory, folder, "#{name.underscore}.rb"))
94
+ end
95
+ end
96
+
97
+ def subscription_base?
98
+ options[:subscriptions] || exist?(File.join(directory, 'subscriptions', 'base_subscription.rb'))
99
+ end
100
+
101
+ def graphql_ruby_controller?(source)
102
+ source.include?('def prepare_variables(variables_param)') && source.include?('def handle_error_in_development(e)') &&
103
+ source.include?("#{schema_name}.execute(query, variables: variables, context: context, operation_name: operation_name)")
104
+ end
105
+
106
+ def read(path)
107
+ File.read(File.join(destination_root, path))
108
+ end
109
+
110
+ def exist?(path)
111
+ File.exist?(File.join(destination_root, path))
112
+ end
113
+ end
@@ -4,50 +4,66 @@ require 'rails/generators'
4
4
  require 'generators/graphql/install_generator'
5
5
  require 'lighthouse-graphql'
6
6
  require_relative '../base_classes'
7
+ require_relative 'graphql_ruby_install'
7
8
 
8
- # `rails generate lighthouse:install` — sets a Rails app up for Lighthouse, on
9
- # top of graphql-ruby's own install, which it runs first (`graphql:install
10
- # --no-relay --skip-graphiql --skip-query-logs`, keeping the files the app has):
11
- # graphql-ruby writes the base classes every graphql-ruby app has (Types::Base*,
12
- # Resolvers::BaseResolver, Mutations::BaseMutation), its controller and route.
13
- # Lighthouse then puts its own over what graphql-ruby wrote in this run:
9
+ # `rails generate lighthouse:install` — puts Lighthouse on top of graphql-ruby's
10
+ # own install (`rails g graphql:install`, with whichever of its options the app
11
+ # chose: --relay, --batch, --skip-mutation-root-type, --schema, --directory,
12
+ # ...). When the app has none, it runs graphql:install first, with its
13
+ # defaults. It then adapts what graphql-ruby wrote, instead of writing its own:
14
14
  #
15
- # - the schema class, built from the SDL (graphql-ruby's is code-first);
16
- # - the controller, with Lighthouse::GraphQL::Controller;
17
- # - no Types::QueryType / MutationType: the SDL declares the root types;
18
- # - Mutations::BaseMutation a GraphQL::Schema::Mutation, as the SDL declares a
19
- # mutation's arguments (graphql-ruby's is a RelayClassicMutation, one `input:`);
15
+ # - the schema class (`class AppSchema < GraphQL::Schema`) becomes Lighthouse's,
16
+ # built from the SDL on everything graphql-ruby wrote: its base types, base
17
+ # resolver and base mutation, and its code-first Types::QueryType /
18
+ # MutationType, which the SDL's root types extend, so their `field`s (Relay's
19
+ # `node` / `nodes`, what `graphql:mutation` adds) stay. Its Relay
20
+ # Types::NodeType is the SDL's Node. Everything else in the class is
21
+ # graphql-ruby's and stays, but the `resolve_type` stub (Lighthouse resolves
22
+ # types) and the root types it is built on;
23
+ # - the example `test_field` goes from the root types;
24
+ # - graphql-ruby's base object, interface, union and scalar include
25
+ # Lighthouse::GraphQL::Type, so every type class (Types::QueryType,
26
+ # Types::NodeType, what `graphql:object` writes) is Lighthouse's;
27
+ # - with --batch, the schema uses GraphQL::Dataloader too, which Lighthouse's
28
+ # directives batch through;
29
+ # - graphql-ruby's controller, as its install wrote it, becomes one with
30
+ # Lighthouse::GraphQL::Controller.
20
31
  #
21
- # and adds the initializer, a starter SDL file, Subscriptions::BaseSubscription
22
- # and the graphql-config file IDEs read. Rerunning it skips what is in place. A
23
- # schema class or controller the app had before is kept (pass --force to replace
24
- # it), so an app already serving a graphql-ruby schema keeps doing so until it
25
- # is pointed at ours.
32
+ # It adds the initializer, a starter SDL file and the graphql-config file IDEs
33
+ # read, and with --subscriptions, Subscriptions::BaseSubscription. Rerunning it
34
+ # skips what is in place.
26
35
  class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
27
36
  include Lighthouse::Generators::BaseClasses
37
+ include Lighthouse::Generators::GraphqlRubyInstall
28
38
 
29
39
  CONTROLLER = 'app/controllers/graphql_controller.rb'
30
- ROOT_TYPES = %w[app/graphql/types/query_type.rb app/graphql/types/mutation_type.rb].freeze
31
- BASE_MUTATION = 'app/graphql/mutations/base_mutation.rb'
32
40
 
33
41
  source_root File.expand_path('templates', __dir__)
34
42
 
35
- desc 'Install Lighthouse: initializer, starter schema, schema class, base classes, controller, route and IDE config.'
43
+ desc "Install Lighthouse on graphql-ruby's install: schema class, base classes, controller, initializer, starter SDL and IDE config."
36
44
 
37
- class_option :schema, type: :string, default: 'AppSchema',
38
- desc: 'Name of the schema constant to generate'
39
- class_option :sdl_folder, type: :string, default: 'graphql',
40
- desc: 'Folder, relative to the app root, holding the *.graphql files'
45
+ class_option :schema, type: :string,
46
+ desc: "The schema class to adopt; for graphql:install, when it runs, its name (default: graphql-ruby's)"
47
+ class_option :schema_path, type: :string, default: 'graphql/schema.graphql',
48
+ desc: "The schema's entrypoint, relative to the app root; it #imports the other .graphql files"
49
+ class_option :subscriptions, type: :boolean, default: false,
50
+ desc: 'Add Subscriptions::BaseSubscription, the base of the classes Subscription fields resolve to'
41
51
  class_option :skip_controller, type: :boolean, default: false,
42
- desc: 'Skip the GraphqlController and its route'
52
+ desc: "Leave graphql-ruby's GraphqlController as it is"
43
53
  class_option :skip_ide, type: :boolean, default: false,
44
54
  desc: 'Skip graphql.config.yml and the .gitignore entries for the IDE helper files'
45
55
 
46
- # graphql-ruby's install, for the base classes. What it writes that Lighthouse
47
- # replaces is noted, so a file the app had before is never touched.
56
+ # graphql-ruby's install: the app's, or one now, with graphql-ruby's defaults.
48
57
  def install_graphql_ruby
49
- @before = ([schema_path, CONTROLLER, BASE_MUTATION] + ROOT_TYPES).select { |path| exist?(path) }
50
- invoke 'graphql:install', [], schema: schema_name, relay: false, skip_graphiql: true, skip_query_logs: true, skip: true
58
+ return if schema_path
59
+
60
+ say_status :invoke, 'graphql:install (no graphql-ruby schema found)', :blue
61
+ invoke 'graphql:install', [], graphql_install_options
62
+ @schema_path = nil
63
+ return if schema_path
64
+
65
+ raise Thor::Error, "No graphql-ruby schema class (class #{options[:schema] || 'AppSchema'} < GraphQL::Schema) under app/: " \
66
+ 'run `bin/rails g graphql:install` first.'
51
67
  end
52
68
 
53
69
  def create_initializer
@@ -55,46 +71,68 @@ class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
55
71
  end
56
72
 
57
73
  def create_starter_schema
58
- if Dir[File.join(destination_root, sdl_folder, '**', '*.graphql')].any?
59
- say_status :skip, "#{sdl_folder}/ already has .graphql files", :yellow
74
+ if File.exist?(File.join(destination_root, sdl_schema_path))
75
+ say_status :skip, "#{sdl_schema_path} exists", :yellow
60
76
  return
61
77
  end
62
78
 
63
- template 'schema.graphql.tt', File.join(sdl_folder, 'schema.graphql')
79
+ template 'schema.graphql.tt', sdl_schema_path
64
80
  end
65
81
 
66
- def create_schema_class
67
- return if keep_existing?(schema_path)
68
-
69
- template 'schema.rb.tt', schema_path, force: true
82
+ # Subscriptions::BaseSubscription, which graphql-ruby has none of (see BaseClasses).
83
+ def create_base_subscription
84
+ write_base_class(:subscriptions, Lighthouse::Config.namespaces[:subscriptions].first) if options[:subscriptions]
70
85
  end
71
86
 
72
- # The SDL declares the root types: graphql-ruby's code-first ones go. Not when
73
- # the app keeps a code-first schema of its own, which graphql-ruby's install
74
- # pointed at them.
75
- def remove_root_types
76
- return unless exist?(schema_path) && File.read(File.join(destination_root, schema_path)).include?('RbLightHouse.get_schema')
87
+ # graphql-ruby's schema class, built by Lighthouse from the SDL on its classes.
88
+ def adopt_schema_class
89
+ source = read(schema_path)
90
+ if lighthouse?(source)
91
+ say_status :skip, "#{schema_path} is Lighthouse's already", :yellow
92
+ return
93
+ end
77
94
 
78
- ROOT_TYPES.each { |path| remove_file path unless before?(path) }
95
+ declaration = /^([ \t]*)class (\S+) < GraphQL::Schema[ \t]*\n/
96
+ indent, name = source.match(declaration).captures
97
+ gsub_file schema_path, declaration, "#{indent}#{schema_comment(indent)}#{indent}class #{name} < #{schema_superclass(indent)}\n"
98
+ gsub_file schema_path, /^[ \t]*(query|mutation|subscription)\(Types::(Query|Mutation|Subscription)Type\)\n/, ''
99
+ gsub_file schema_path, /^(#{indent}\)\n)\n+/, '\\1'
100
+ gsub_file schema_path, RESOLVE_TYPE_STUB, "\n"
101
+ add_dataloader if source.include?('use GraphQL::Batch') && source.exclude?('use GraphQL::Dataloader')
79
102
  end
80
103
 
81
- # The SDL declares a mutation's arguments, so its class is a plain
82
- # GraphQL::Schema::Mutation, not graphql-ruby's RelayClassicMutation.
83
- def adapt_base_mutation
84
- return if before?(BASE_MUTATION) || !exist?(BASE_MUTATION)
85
-
86
- gsub_file BASE_MUTATION, 'GraphQL::Schema::RelayClassicMutation', 'GraphQL::Schema::Mutation'
87
- gsub_file BASE_MUTATION, /^\s*input_object_class .*\n/, ''
104
+ # The example field goes; the root types' own fields stay.
105
+ def adapt_root_types
106
+ %w[query_type mutation_type subscription_type].each do |name|
107
+ path = types_path("#{name}.rb")
108
+ gsub_file path, TEST_FIELD, "\n" if exist?(path) && read(path).match?(TEST_FIELD)
109
+ end
88
110
  end
89
111
 
90
- # Subscriptions::BaseSubscription, which graphql-ruby has none of (see BaseClasses).
91
- def create_base_subscription
92
- write_base_class(:subscriptions, Lighthouse::Config.namespaces[:subscriptions].first)
112
+ # Every type class is Lighthouse's: graphql-ruby's base object, interface,
113
+ # union and scalar include Lighthouse::GraphQL::Type, so the classes that
114
+ # inherit from them (Types::QueryType, Types::NodeType, what `graphql:object`
115
+ # writes) lend their methods to the SDL's types of their name.
116
+ def adapt_type_bases
117
+ TYPE_CONCERN_ANCHORS.each do |name, anchor|
118
+ path = types_path("#{name}.rb")
119
+ next if !exist?(path) || read(path).include?('Lighthouse::GraphQL::Type')
120
+
121
+ indent = read(path)[anchor, 1]
122
+ next unless indent
123
+
124
+ indent += ' ' unless name == 'base_interface'
125
+ inject_into_file path, "#{indent}# Lighthouse's: the class of the SDL type of its name lends that type its methods.\n" \
126
+ "#{indent}include Lighthouse::GraphQL::Type\n", after: anchor
127
+ end
93
128
  end
94
129
 
95
- def create_controller
96
- return remove_graphql_ruby_controller if options[:skip_controller]
97
- return if (@kept_controller = keep_existing?(CONTROLLER))
130
+ # graphql-ruby's controller, as its install wrote it, serves the schema with
131
+ # Lighthouse's concern. One the app has changed is left alone.
132
+ def adapt_controller
133
+ return if options[:skip_controller] || !exist?(CONTROLLER)
134
+ return if read(CONTROLLER).include?('Lighthouse::GraphQL::Controller')
135
+ return @kept_controller = true unless graphql_ruby_controller?(read(CONTROLLER))
98
136
 
99
137
  template 'graphql_controller.rb.tt', CONTROLLER, force: true
100
138
  end
@@ -106,8 +144,7 @@ class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
106
144
  return unless File.exist?(routes)
107
145
  return if File.read(routes).include?('graphql#execute')
108
146
 
109
- # Byte-identical to graphql-ruby's install route, so whichever generator
110
- # runs second finds it in place and does not add it again.
147
+ # Byte-identical to graphql-ruby's install route.
111
148
  route 'post "/graphql", to: "graphql#execute"'
112
149
  end
113
150
 
@@ -128,73 +165,62 @@ class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
128
165
  def show_next_steps
129
166
  serve =
130
167
  if @kept_controller
131
- "Serve it: #{CONTROLLER} was kept — have it call #{schema_name}.execute(...) to serve Lighthouse's schema."
168
+ "Serve it: #{CONTROLLER} was changed from graphql-ruby's, so it was kept — " \
169
+ "include Lighthouse::GraphQL::Controller in it, or keep calling #{schema_name}.execute(...)."
132
170
  else
133
171
  'Query it: POST /graphql {"query": "{ users { data { id name } paginatorInfo { total } } }"}'
134
172
  end
135
173
 
136
174
  say <<~MSG
137
175
 
138
- Lighthouse is installed. Next steps:
139
- 1. Describe your API in #{sdl_folder}/*.graphql (the starter schema assumes a User model).
140
- 2. Resolve a field in Ruby when a directive is not enough: bin/rails g lighthouse:query LatestPosts
141
- (it inherits from Resolvers::BaseResolver, which graphql-ruby's install wrote; lighthouse:mutation and
142
- lighthouse:subscription write the others)
176
+ Lighthouse is installed on graphql-ruby's install (#{schema_path}). Next steps:
177
+ 1. Describe your API in #{sdl_schema_path} (the starter assumes a User model); #import more files, which `extend type Query`.
178
+ 2. Resolve a field in Ruby when a directive is not enough, as in graphql-ruby: a method of
179
+ Types::QueryType (Query.latestPosts -> #latest_posts), a `field` of its own there,
180
+ `bin/rails g graphql:mutation`, or a Resolvers::BaseResolver subclass named after the field.
143
181
  3. Give a type Ruby methods, a union or interface its resolve_type, a scalar its coercion:
144
- bin/rails g graphql:object Post (or graphql:union, graphql:interface, graphql:scalar),
145
- then include Lighthouse::GraphQL::Type in the class
146
- 4. Generate the IDE helper files: bin/rails lighthouse:ide_helper
182
+ bin/rails g graphql:object Post (or graphql:union, graphql:interface, graphql:scalar);
183
+ the class is Lighthouse's through its base (Lighthouse::GraphQL::Type)
184
+ 4. The IDE helper files are rewritten in development; or: bin/rails lighthouse:ide_helper
147
185
  5. #{serve}
148
186
  MSG
149
187
  end
150
188
 
151
189
  private
152
190
 
153
- # A file the app had before this run is left alone unless --force is given;
154
- # one graphql-ruby's install wrote in this run is Lighthouse's to replace.
155
- def keep_existing?(path)
156
- return false if options[:force] || !before?(path)
157
-
158
- say_status :skip, "#{path} already exists (pass --force to replace it)", :yellow
159
- true
160
- end
161
-
162
- def before?(path)
163
- @before.include?(path)
191
+ def schema_superclass(indent)
192
+ lines = base_types.map { |key, name| "#{indent} #{key}: #{name}" }
193
+ "Lighthouse::GraphQL::RbLightHouse.get_schema(\n#{indent} options: {\n#{indent} base_types: {\n" \
194
+ "#{lines.join(",\n")}\n#{indent} }\n#{indent} }\n#{indent})"
164
195
  end
165
196
 
166
- def exist?(path)
167
- File.exist?(File.join(destination_root, path))
168
- end
169
-
170
- def schema_path
171
- File.join('app/graphql', "#{schema_name.underscore}.rb")
172
- end
173
-
174
- # --skip-controller: the app serves the schema itself, so graphql-ruby's
175
- # controller and route, if its install just wrote them, go.
176
- def remove_graphql_ruby_controller
177
- return if before?(CONTROLLER)
178
-
179
- remove_file CONTROLLER
180
- gsub_file 'config/routes.rb', %r{^\s*post "/graphql", to: "graphql#execute"\n}, '' if exist?('config/routes.rb')
197
+ def schema_comment(indent)
198
+ <<~RUBY.gsub(/^/, indent).delete_prefix(indent)
199
+ # Lighthouse builds the schema from the SDL in #{sdl_schema_path}, on the classes graphql-ruby's
200
+ # install wrote (base_types): the SDL's root types extend the code-first ones, whose
201
+ # `field`s stay the schema's, and every type, resolver and mutation class inherits from
202
+ # its kind's base. The rest of the class is graphql-ruby's.
203
+ RUBY
181
204
  end
182
205
 
183
- def schema_name
184
- options[:schema].camelize
206
+ def add_dataloader
207
+ anchor = /^([ \t]*)use GraphQL::Batch\n/
208
+ indent = read(schema_path)[anchor, 1]
209
+ inject_into_file schema_path, "#{indent}# Lighthouse's directives batch through GraphQL::Dataloader, beside GraphQL::Batch.\n" \
210
+ "#{indent}use GraphQL::Dataloader\n", after: anchor
185
211
  end
186
212
 
187
- # `Resolvers::BaseResolver`, for the schema class template.
188
- def base_class_constant(kind)
189
- "#{Lighthouse::Config.namespaces[kind].first}::#{base_class_name(kind)}"
213
+ # The SDL's entrypoint (Config.schema_path); `schema_path` is graphql-ruby's schema class.
214
+ def sdl_schema_path
215
+ options[:schema_path].delete_prefix('/')
190
216
  end
191
217
 
192
218
  def sdl_folder
193
- options[:sdl_folder].delete_suffix('/')
219
+ File.dirname(sdl_schema_path)
194
220
  end
195
221
 
196
- def default_sdl_folder?
197
- sdl_folder == 'graphql'
222
+ def default_sdl_schema_path?
223
+ sdl_schema_path == 'graphql/schema.graphql'
198
224
  end
199
225
 
200
226
  def ide_helper_files
@@ -1,6 +1,7 @@
1
1
  # graphql-config, read by the GraphQL plugins for JetBrains IDEs and VS Code.
2
2
  # The two generated files describe Lighthouse's directives and the types it
3
- # generates; refresh them after changing the schema:
3
+ # generates. In development they are rewritten on boot and on every reload
4
+ # (config.ide_helper); elsewhere, refresh them with:
4
5
  # bin/rails lighthouse:ide_helper
5
6
  schema:
6
7
  - <%= sdl_folder %>/**/*.graphql