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
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'lighthouse-graphql'
5
+
6
+ # `rails generate lighthouse:install` — sets a Rails app up for Lighthouse:
7
+ # the initializer, a starter SDL file, the schema class, a controller and
8
+ # its route, plus the graphql-config file IDEs read. Rerunning it skips the
9
+ # route, SDL and .gitignore entries that are already in place.
10
+ #
11
+ # It coexists with graphql-ruby's `graphql:install`, which writes the same
12
+ # controller path and route: an existing schema class or controller is kept
13
+ # (pass --force to replace it) instead of overwritten, so an app already
14
+ # serving a graphql-ruby schema keeps doing so until it is pointed at ours.
15
+ class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
16
+ CONTROLLER = 'app/controllers/graphql_controller.rb'
17
+
18
+ source_root File.expand_path('templates', __dir__)
19
+
20
+ desc 'Install Lighthouse: initializer, starter schema, schema class, controller, route and IDE config.'
21
+
22
+ class_option :schema, type: :string, default: 'AppSchema',
23
+ desc: 'Name of the schema constant to generate'
24
+ class_option :sdl_folder, type: :string, default: 'graphql',
25
+ desc: 'Folder, relative to the app root, holding the *.graphql files'
26
+ class_option :skip_controller, type: :boolean, default: false,
27
+ desc: 'Skip the GraphqlController and its route'
28
+ class_option :skip_ide, type: :boolean, default: false,
29
+ desc: 'Skip graphql.config.yml and the .gitignore entries for the IDE helper files'
30
+
31
+ def create_initializer
32
+ template 'initializer.rb.tt', 'config/initializers/lighthouse.rb'
33
+ end
34
+
35
+ def create_starter_schema
36
+ if Dir[File.join(destination_root, sdl_folder, '**', '*.graphql')].any?
37
+ say_status :skip, "#{sdl_folder}/ already has .graphql files", :yellow
38
+ return
39
+ end
40
+
41
+ template 'schema.graphql.tt', File.join(sdl_folder, 'schema.graphql')
42
+ end
43
+
44
+ def create_schema_class
45
+ path = File.join('app/graphql', "#{schema_name.underscore}.rb")
46
+ return if keep_existing?(path)
47
+
48
+ template 'schema.rb.tt', path
49
+ end
50
+
51
+ def create_controller
52
+ return if options[:skip_controller]
53
+ return if (@kept_controller = keep_existing?(CONTROLLER))
54
+
55
+ template 'graphql_controller.rb.tt', CONTROLLER
56
+ end
57
+
58
+ def add_route
59
+ return if options[:skip_controller]
60
+
61
+ routes = File.join(destination_root, 'config/routes.rb')
62
+ return unless File.exist?(routes)
63
+ return if File.read(routes).include?('graphql#execute')
64
+
65
+ # Byte-identical to graphql-ruby's install route, so whichever generator
66
+ # runs second finds it in place and does not add it again.
67
+ route 'post "/graphql", to: "graphql#execute"'
68
+ end
69
+
70
+ def create_ide_config
71
+ return if options[:skip_ide]
72
+
73
+ template 'graphql.config.yml.tt', 'graphql.config.yml'
74
+
75
+ gitignore = File.join(destination_root, '.gitignore')
76
+ return unless File.exist?(gitignore)
77
+
78
+ entries = ide_helper_files.map { |file| "/#{file}" } - File.read(gitignore).lines.map(&:strip)
79
+ return if entries.empty?
80
+
81
+ append_to_file '.gitignore', "\n# Lighthouse IDE helper output (bin/rails lighthouse:ide_helper)\n#{entries.join("\n")}\n"
82
+ end
83
+
84
+ def show_next_steps
85
+ serve =
86
+ if @kept_controller
87
+ "Serve it: #{CONTROLLER} was kept — have it call #{schema_name}.execute(...) to serve Lighthouse's schema."
88
+ else
89
+ 'Query it: POST /graphql {"query": "{ users { data { id name } paginatorInfo { total } } }"}'
90
+ end
91
+
92
+ say <<~MSG
93
+
94
+ Lighthouse is installed. Next steps:
95
+ 1. Describe your API in #{sdl_folder}/*.graphql (the starter schema assumes a User model).
96
+ 2. Generate the IDE helper files: bin/rails lighthouse:ide_helper
97
+ 3. #{serve}
98
+ MSG
99
+ end
100
+
101
+ private
102
+
103
+ # Files another generator may own (graphql-ruby's install writes the same
104
+ # controller) are left alone unless --force is given.
105
+ def keep_existing?(path)
106
+ return false if options[:force] || !File.exist?(File.join(destination_root, path))
107
+
108
+ say_status :skip, "#{path} already exists (pass --force to replace it)", :yellow
109
+ true
110
+ end
111
+
112
+ def schema_name
113
+ options[:schema].camelize
114
+ end
115
+
116
+ def sdl_folder
117
+ options[:sdl_folder].delete_suffix('/')
118
+ end
119
+
120
+ def default_sdl_folder?
121
+ sdl_folder == 'graphql'
122
+ end
123
+
124
+ def ide_helper_files
125
+ [Lighthouse::IdeHelper::DIRECTIVES_FILE, Lighthouse::IdeHelper::PROGRAMMATIC_TYPES_FILE]
126
+ end
127
+ end
@@ -0,0 +1,8 @@
1
+ # graphql-config, read by the GraphQL plugins for JetBrains IDEs and VS Code.
2
+ # The two generated files describe Lighthouse's directives and the types it
3
+ # generates; refresh them after changing the schema:
4
+ # bin/rails lighthouse:ide_helper
5
+ schema:
6
+ - <%= sdl_folder %>/**/*.graphql
7
+ - <%= Lighthouse::IdeHelper::DIRECTIVES_FILE %>
8
+ - <%= Lighthouse::IdeHelper::PROGRAMMATIC_TYPES_FILE %>
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ class GraphqlController < ApplicationController
4
+ # If this endpoint is called from outside this domain, null the session
5
+ # instead of failing CSRF verification:
6
+ # protect_from_forgery with: :null_session
7
+
8
+ def execute
9
+ # PersistedQueries.execute resolves Apollo-style persisted queries when
10
+ # config.persisted_queries is on, and otherwise runs params[:query] as is.
11
+ result = Lighthouse::GraphQL::PersistedQueries.execute(
12
+ <%= schema_name %>,
13
+ query: params[:query],
14
+ extensions: params[:extensions],
15
+ variables: prepare_variables(params[:variables]),
16
+ operation_name: params[:operationName],
17
+ context: {
18
+ # Read by @guard and the @can* directives — see config/initializers/lighthouse.rb.
19
+ # current_user: current_user
20
+ }
21
+ )
22
+ # The Cache-Control header the selected fields' @cacheControl hints allow
23
+ # (a no-op when the schema has none).
24
+ Lighthouse::GraphQL::CacheControl.apply(response, result)
25
+ render json: result
26
+ rescue StandardError => e
27
+ raise e unless Rails.env.development?
28
+
29
+ handle_error_in_development(e)
30
+ end
31
+
32
+ private
33
+
34
+ # Variables arrive as a JSON string (GET or form posts) or as parameters.
35
+ def prepare_variables(variables_param)
36
+ case variables_param
37
+ when String
38
+ variables_param.present? ? JSON.parse(variables_param) || {} : {}
39
+ when Hash
40
+ variables_param
41
+ when ActionController::Parameters
42
+ variables_param.to_unsafe_hash
43
+ when nil
44
+ {}
45
+ else
46
+ raise ArgumentError, "Unexpected parameter: #{variables_param}"
47
+ end
48
+ end
49
+
50
+ def handle_error_in_development(error)
51
+ logger.error(error.message)
52
+ logger.error(error.backtrace.join("\n"))
53
+
54
+ render json: { errors: [{ message: error.message, backtrace: error.backtrace }], data: {} },
55
+ status: :internal_server_error
56
+ end
57
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Lighthouse configuration. Every option is optional; the defaults are shown.
4
+ # Reference: https://github.com/Vaz-Innovation/lighthouse-graphql/blob/main/docs/configuration.md
5
+ Lighthouse.configure do |config|
6
+ # Folder the *.graphql schema files are loaded from.
7
+ <% if default_sdl_folder? -%>
8
+ # config.sdl_folder = Rails.root.join('graphql').to_s
9
+ <% else -%>
10
+ config.sdl_folder = Rails.root.join('<%= sdl_folder %>').to_s
11
+ <% end -%>
12
+
13
+ # Where classes named by the schema are looked up, by kind. A Query or Mutation
14
+ # field without a resolving directive resolves by convention: Query.latestPosts
15
+ # to Queries::LatestPosts (a GraphQL::Schema::Resolver), Mutation.publishPost
16
+ # to Mutations::PublishPost (a GraphQL::Schema::Mutation). @builder classes
17
+ # come from :builders and @validator contracts from :validators. Generate them
18
+ # with `rails g lighthouse:query|mutation|builder|validator Name`.
19
+ # config.namespaces = {
20
+ # queries: ['Queries'], mutations: ['Mutations'], builders: ['Builders'], validators: ['Validators']
21
+ # }
22
+
23
+ # Namespaces tried, in order, for models (@all(model: "User")) and for
24
+ # @field(resolver:) on types other than Query and Mutation. Fully-qualified
25
+ # names (containing `::`) are used as-is.
26
+ # config.resolver_namespaces = ['App::Graphql']
27
+
28
+ # Store for @cache and @clearCache.
29
+ # config.cache_store = Rails.cache
30
+
31
+ # Run @create/@update/@createMany/@updateMany in a database transaction.
32
+ # config.transactional_mutations = true
33
+
34
+ # --- For large schemas and high traffic (all off by default) ---------------
35
+ # Cache the built schema document in tmp/cache, keyed by the SDL and
36
+ # everything that shapes it; warm it at deploy with `bin/rails lighthouse:cache`.
37
+ # config.schema_cache = Rails.env.production?
38
+
39
+ # Reload the app (and so the schema) when a .graphql file changes.
40
+ # config.watch_sdl = Rails.env.development?
41
+
42
+ # Automatic Persisted Queries (Apollo's protocol). Add a GET route too, so
43
+ # hashed queries can be cached by CDNs: get "/graphql", to: "graphql#execute"
44
+ # config.persisted_queries = true
45
+ # config.persisted_queries_ttl = 1.week.to_i
46
+ # A manifest of trusted operations from your client build, served by hash;
47
+ # with persisted_queries_only, nothing else may run.
48
+ # config.persisted_queries_manifest = Rails.root.join('persisted-queries.json')
49
+ # config.persisted_queries_only = Rails.env.production?
50
+
51
+ # Called when a directive or resolver swallows an error. The default raises in
52
+ # test and logs otherwise.
53
+ # config.on_error = ->(error, _context) { Rails.error.report(error) }
54
+
55
+ # Authorization seams used by @guard and the @can* directives (Pundit by default).
56
+ # config.authenticated_user = ->(ctx) { ctx[:current_user] }
57
+ # config.policy_user = ->(ctx) { ctx[:pundit_user] || ctx[:current_user] }
58
+ # config.authorizer = ->(user, record, ability, args = []) { Pundit.policy!(user, record).public_send("#{ability}?", *args) }
59
+ end
60
+
61
+ # Register app-defined directives and federation reference resolvers inside
62
+ # to_prepare, so they reference reloadable constants and run before the schema
63
+ # is built. See docs/custom-directives.md and docs/federation.md.
64
+ # Rails.application.config.to_prepare do
65
+ # Lighthouse::DirectiveRegistry.register(Directives::Upcase)
66
+ # Lighthouse::ReferenceResolver.register('User') { |ref, _ctx| User.find_by(id: ref['id']) }
67
+ # end
@@ -0,0 +1,19 @@
1
+ # Starter schema generated by `rails generate lighthouse:install`. It assumes a
2
+ # User model with `name` and `email` columns — replace it with your own types.
3
+ # Directives reference: https://github.com/Vaz-Innovation/lighthouse-graphql/blob/main/docs/directives.md
4
+
5
+ "Indicates what fields are available at the top level of a query operation."
6
+ type Query {
7
+ "Find a single user by id."
8
+ user(id: ID! @eq): User @find
9
+
10
+ "List users, a page at a time (10 by default; pass first: and page:)."
11
+ users: [User!]! @paginate(defaultCount: 10)
12
+ }
13
+
14
+ "Account of a person who uses this application."
15
+ type User {
16
+ id: ID!
17
+ name: String
18
+ email: String
19
+ }
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Executable schema built by Lighthouse from the SDL in <%= sdl_folder %>/. The
4
+ # Dataloader batches relationship loads (@hasMany, @belongsTo, ...) to avoid N+1.
5
+ <%= schema_name %> = Lighthouse::GraphQL::RbLightHouse.get_schema(
6
+ options: {
7
+ using: { GraphQL::Dataloader => {} }
8
+ # base_types: { object: Types::BaseObject } # your graphql-ruby base classes
9
+ }
10
+ )
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'class_generator'
4
+
5
+ # `rails generate lighthouse:mutation PublishPost` — a GraphQL::Schema::Mutation
6
+ # that `Mutation.publishPost` resolves to by convention.
7
+ class Lighthouse::Generators::MutationGenerator < Lighthouse::Generators::ClassGenerator
8
+ desc 'Generate a mutation class (GraphQL::Schema::Mutation) in app/graphql/mutations.'
9
+
10
+ private
11
+
12
+ def kind = :mutations
13
+
14
+ def body
15
+ <<~RUBY
16
+ # Resolves Mutation.#{class_basename.camelize(:lower)}. Keyword arguments are the field's
17
+ # arguments in snake_case; `object` and `context` are available. Raise
18
+ # GraphQL::ExecutionError for failures the client should see.
19
+ class #{class_basename} < GraphQL::Schema::Mutation
20
+ def resolve(**args)
21
+ raise NotImplementedError
22
+ end
23
+ end
24
+ RUBY
25
+ end
26
+
27
+ def usage
28
+ "Mutation.#{class_basename.camelize(:lower)} resolves to #{full_class_name} — add the field to your SDL without a resolving directive."
29
+ end
30
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'class_generator'
4
+
5
+ # `rails generate lighthouse:query LatestPosts` — a GraphQL::Schema::Resolver
6
+ # that `Query.latestPosts` resolves to by convention.
7
+ class Lighthouse::Generators::QueryGenerator < Lighthouse::Generators::ClassGenerator
8
+ desc 'Generate a query class (GraphQL::Schema::Resolver) in app/graphql/queries.'
9
+
10
+ private
11
+
12
+ def kind = :queries
13
+
14
+ def body
15
+ <<~RUBY
16
+ # Resolves Query.#{class_basename.camelize(:lower)}. Keyword arguments are the field's
17
+ # arguments in snake_case; `object` and `context` are available.
18
+ class #{class_basename} < GraphQL::Schema::Resolver
19
+ def resolve(**args)
20
+ raise NotImplementedError
21
+ end
22
+ end
23
+ RUBY
24
+ end
25
+
26
+ def usage
27
+ "Query.#{class_basename.camelize(:lower)} resolves to #{full_class_name} — add the field to your SDL without a resolving directive."
28
+ end
29
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'class_generator'
4
+
5
+ # `rails generate lighthouse:validator CreatePostInput` — a
6
+ # Dry::Validation::Contract for @validator. Name it after an input type
7
+ # (CreatePostInputValidator) or a field (Mutation::CreatePostValidator).
8
+ class Lighthouse::Generators::ValidatorGenerator < Lighthouse::Generators::ClassGenerator
9
+ desc 'Generate a validator (Dry::Validation::Contract) for @validator in app/graphql/validators.'
10
+
11
+ private
12
+
13
+ def kind = :validators
14
+
15
+ def class_basename
16
+ base = super
17
+ base.end_with?('Validator') ? base : "#{base}Validator"
18
+ end
19
+
20
+ def body
21
+ <<~RUBY
22
+ # Arguments arrive with snake_case keys. Remove `option :graphql_context`
23
+ # if the rules don't need the GraphQL context.
24
+ class #{class_basename} < Dry::Validation::Contract
25
+ option :graphql_context, optional: true
26
+
27
+ params do
28
+ end
29
+ end
30
+ RUBY
31
+ end
32
+
33
+ def usage
34
+ 'Needs the dry-validation gem. Add @validator to the input type or field it is named after.'
35
+ end
36
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @cacheControl — compute the HTTP Cache-Control header a response may carry
4
+ # from the fields a query selects, as PHP Lighthouse (after Apollo) does:
5
+ #
6
+ # - max-age is the lowest maxAge among the contributing fields; 0 means
7
+ # `no-cache`.
8
+ # - The response is `private` if any selected field's scope is PRIVATE.
9
+ # - Defaults: a root field is maxAge 0 and PRIVATE; any other field returning
10
+ # an object, interface or union is maxAge 0 unless its type carries
11
+ # @cacheControl; scalar fields inherit their parent and do not contribute.
12
+ # A directive on the field wins over one on its type, and its omitted
13
+ # arguments take the directive defaults (maxAge 0, PUBLIC).
14
+ # - `inheritMaxAge: true` uses the parent field's maxAge.
15
+ # - A mutation is never cacheable.
16
+ #
17
+ # The schema runs Analyzer on every query when its SDL uses @cacheControl;
18
+ # read the result with CacheControl.header(result) and set it on the
19
+ # response (see `apply`).
20
+ module Lighthouse::GraphQL::CacheControl
21
+ DEFINITION = 'directive @cacheControl(maxAge: Int! = 0, scope: CacheControlScope! = PUBLIC, ' \
22
+ 'inheritMaxAge: Boolean! = false) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION'
23
+
24
+ TYPE_DEFINITIONS = {
25
+ 'CacheControlScope' => <<~GRAPHQL.chomp
26
+ "Who a cached response may be shared with."
27
+ enum CacheControlScope {
28
+ "The same for every user: shared caches (CDNs, proxies) may store it."
29
+ PUBLIC
30
+ "Specific to the user: only the user's own client may store it."
31
+ PRIVATE
32
+ }
33
+ GRAPHQL
34
+ }.freeze
35
+
36
+ CONTEXT_KEY = :lighthouse_cache_control
37
+
38
+ module_function
39
+
40
+ # The Cache-Control value for an executed query (a GraphQL::Query::Result
41
+ # or GraphQL::Query), or nil when the schema does not use @cacheControl.
42
+ def header(result)
43
+ query = result.respond_to?(:query) ? result.query : result
44
+ query.respond_to?(:context) ? query.context[CONTEXT_KEY] : nil
45
+ end
46
+
47
+ # Set the header on a Rails (or Rack) response; a no-op without one.
48
+ def apply(response, result)
49
+ value = header(result)
50
+ response.headers['Cache-Control'] = value if value
51
+ value
52
+ end
53
+
54
+ def to_header(max_age, private_scope)
55
+ visibility = private_scope ? 'private' : 'public'
56
+ max_age.positive? ? "max-age=#{max_age}, #{visibility}" : "no-cache#{', private' if private_scope}"
57
+ end
58
+
59
+ class Analyzer < ::GraphQL::Analysis::Analyzer
60
+ def initialize(subject)
61
+ super
62
+ @parents = []
63
+ @max_age = nil
64
+ @private = false
65
+ end
66
+
67
+ def on_enter_field(node, _parent, visitor)
68
+ field = visitor.field_definition
69
+ return @parents.push(@parents.last) if field.nil? || visitor.skipping?
70
+
71
+ max_age, scope = hint_for(field)
72
+ unless max_age.nil?
73
+ @max_age = [@max_age, max_age].compact.min
74
+ @private ||= scope == 'PRIVATE'
75
+ end
76
+ @parents.push(max_age.nil? ? @parents.last : max_age)
77
+ node
78
+ end
79
+
80
+ def on_leave_field(_node, _parent, _visitor)
81
+ @parents.pop
82
+ end
83
+
84
+ def result
85
+ value =
86
+ if query.mutation?
87
+ 'no-cache'
88
+ else
89
+ Lighthouse::GraphQL::CacheControl.to_header(@max_age || 0, @private)
90
+ end
91
+ query.context[CONTEXT_KEY] = value
92
+ end
93
+
94
+ private
95
+
96
+ def root?
97
+ @parents.empty?
98
+ end
99
+
100
+ # [maxAge, scope] the field contributes, or [nil, nil] to inherit.
101
+ def hint_for(field)
102
+ directive = cache_control(field.ast_node)
103
+ return from_directive(directive) if directive
104
+ return [0, 'PRIVATE'] if root?
105
+
106
+ return_type = field.type.unwrap
107
+ return [nil, nil] if return_type.kind.leaf?
108
+
109
+ type_directive = cache_control(return_type.ast_node)
110
+ type_directive ? from_directive(type_directive) : [0, 'PUBLIC']
111
+ end
112
+
113
+ def from_directive(directive)
114
+ scope = Lighthouse::Support::DirectiveArgs.literal(directive, 'scope') || 'PUBLIC'
115
+ if Lighthouse::Support::DirectiveArgs.literal(directive, 'inheritMaxAge')
116
+ [@parents.last || 0, scope]
117
+ else
118
+ [Lighthouse::Support::DirectiveArgs.literal(directive, 'maxAge') || 0, scope]
119
+ end
120
+ end
121
+
122
+ def cache_control(ast_node)
123
+ ast_node&.directives&.find { |d| d.name == 'cacheControl' }
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require 'securerandom'
6
+
7
+ # Keys and invalidation for @cache / @clearCache over Config.cache_store
8
+ # (Rails.cache by default).
9
+ #
10
+ # PHP Lighthouse invalidates with cache tags, which Rails cache stores do not
11
+ # have (and `delete_matched` is not universal). Instead every entry key embeds
12
+ # four version tokens — the type, the type + parent id, the type + field, and
13
+ # the type + parent id + field — and @clearCache replaces the matching token.
14
+ # Old entries are never read again and expire on their own. A token that
15
+ # was evicted is re-created with a fresh value, so eviction only ever causes
16
+ # misses, never stale reads.
17
+ module Lighthouse::GraphQL::Caching
18
+ PREFIX = 'lighthouse'
19
+
20
+ module_function
21
+
22
+ def store
23
+ Lighthouse::Config.cache_store
24
+ end
25
+
26
+ def entry_key(type, id, field, args, user_key)
27
+ tokens = versions(version_keys(type, id, field))
28
+ [PREFIX, type, id, field, digest(args), user_key, *tokens].join(':')
29
+ end
30
+
31
+ # @clearCache: invalidate everything cached for `type`, narrowed to one
32
+ # parent `id` and/or one `field` when given.
33
+ def clear(type, id: nil, field: nil)
34
+ key = version_key(type, id, field)
35
+ store.write(key, SecureRandom.hex(8))
36
+ end
37
+
38
+ def version_keys(type, id, field)
39
+ [version_key(type, nil, nil), version_key(type, id, nil), version_key(type, nil, field), version_key(type, id, field)]
40
+ .uniq
41
+ end
42
+
43
+ def version_key(type, id, field)
44
+ [PREFIX, 'version', type, id, field].join(':')
45
+ end
46
+
47
+ def versions(keys)
48
+ found = store.read_multi(*keys)
49
+ keys.map do |key|
50
+ found[key] || begin
51
+ store.write(key, SecureRandom.hex(8), unless_exist: true)
52
+ store.read(key)
53
+ end
54
+ end
55
+ end
56
+
57
+ def digest(args)
58
+ Digest::SHA256.hexdigest(JSON.generate(canonical(Lighthouse::Support::Arguments.plain(args))))[0, 32]
59
+ end
60
+
61
+ def canonical(value)
62
+ case value
63
+ when Hash then value.map { |k, v| [k.to_s, canonical(v)] }.sort_by(&:first)
64
+ when Array then value.map { |v| canonical(v) }
65
+ else value
66
+ end
67
+ end
68
+ end