graphql_tag_rbs 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 91736967e60bb08acadda5c3394bcd2f572358030fc1e44a831c0223cd600203
4
+ data.tar.gz: 854c435820049688a2b720277ffadda627d653d0481b0c52ed563ce189016f57
5
+ SHA512:
6
+ metadata.gz: 8dc02cfe6152943af815460d4c55e364ef4d22f35fd2c5529cc4935dc29f5d17013359ebe399cd05a1b81377aa490e7cf8db9a172b67d2f609f915eea8c9f59e
7
+ data.tar.gz: 388c4aff5e4e83ca00a2d21a56c3d9bb98cd0e168ad9e50817cd911110d3f23a85058214650bd4e126b30cf1203a3e1344b0b2abf877b4d925d6e87e5f6afc2e
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 4.0.0
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-11-03
11
+
12
+ ### Added
13
+
14
+ - Generate RBS `variables`, `data`, and `response` types for each operation, and
15
+ a `data` type for each fragment, from a graphql_tag_pluck (0.3.0 or later)
16
+ operation list and a schema in SDL or introspection JSON.
17
+ - One `.rbs` file per source file, plus a shared file with the GraphQL error
18
+ type and the enums and input objects the operations use.
19
+ - Validation of operations against the schema, with errors naming the
20
+ operation's location.
21
+ - Options for the namespace, string or symbol keys, and custom scalar types.
22
+ - The `graphql_tag_rbs:generate` rake task, for Rails and other projects.
data/Gemfile ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in graphql_tag_rbs.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
9
+ gem "rspec", "~> 3.0"
10
+
11
+ # Used by the specs to check that generated signatures are valid and usable.
12
+ gem "rbs", ">= 3.4"
13
+ gem "steep", ">= 1.9"
14
+
15
+ # Used by the end-to-end spec that plucks operations from Ruby files.
16
+ gem "graphql_tag_pluck", ">= 0.3.0"
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 indigolain
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # GraphqlTagRbs
2
+
3
+ Generates [RBS](https://github.com/ruby/rbs) types for the GraphQL operations
4
+ in your Ruby code, so [Steep](https://github.com/soutaro/steep) can check how
5
+ you build variables and read responses.
6
+
7
+ It reads the operation list exported by
8
+ [graphql_tag_pluck](https://github.com/indigolain/graphql_tag_pluck) (0.3.0 or
9
+ later) and your GraphQL schema, and writes one `.rbs` file per Ruby source file.
10
+
11
+ ## Usage
12
+
13
+ Given a heredoc in `app/clients/user_client.rb`:
14
+
15
+ ```ruby
16
+ module Clients
17
+ class UserClient
18
+ USER_QUERY = <<~GRAPHQL
19
+ query UserQuery($id: ID!) {
20
+ user(id: $id) {
21
+ id
22
+ name
23
+ role
24
+ }
25
+ }
26
+ GRAPHQL
27
+ end
28
+ end
29
+ ```
30
+
31
+ run
32
+
33
+ ```sh
34
+ bundle exec rake graphql_tag_pluck:generate_graphql_operation_list graphql_tag_rbs:generate
35
+ ```
36
+
37
+ to get `sig/graphql/app/clients/user_client.rbs`:
38
+
39
+ ```rbs
40
+ # Generated by graphql_tag_rbs from app/clients/user_client.rb. Do not edit.
41
+
42
+ module GraphQLTypes
43
+ # query UserQuery at app/clients/user_client.rb:4 (Clients::UserClient::USER_QUERY)
44
+ module UserQuery
45
+ type variables = {
46
+ "id" => ::String | ::Integer
47
+ }
48
+
49
+ type data = {
50
+ "user" => {
51
+ "id" => ::String,
52
+ "name" => ::String?,
53
+ "role" => ::GraphQLTypes::Enums::role
54
+ }?
55
+ }
56
+
57
+ type response = {
58
+ ?"data" => data?,
59
+ ?"errors" => ::Array[::GraphQLTypes::error],
60
+ ?"extensions" => ::Hash[::String, untyped]
61
+ }
62
+ end
63
+ end
64
+ ```
65
+
66
+ and `sig/graphql/_shared.rbs` with the error type, enums (`type role = "ADMIN" | "MEMBER"`)
67
+ and input objects the operations use.
68
+
69
+ The types describe the parsed JSON response, so annotate where you parse it:
70
+
71
+ ```ruby
72
+ # @type var response: GraphQLTypes::UserQuery::response
73
+ response = JSON.parse(http_response.body)
74
+ ```
75
+
76
+ or give your client method a signature that takes `GraphQLTypes::UserQuery::variables`
77
+ and returns `GraphQLTypes::UserQuery::response`.
78
+
79
+ ### Loading the rake task
80
+
81
+ In a Rails app the task is available automatically. In any other project, add this to your `Rakefile`:
82
+
83
+ ```ruby
84
+ require "graphql_tag_rbs/rake_tasks"
85
+ ```
86
+
87
+ ## How GraphQL maps to RBS
88
+
89
+ | GraphQL | RBS |
90
+ |---|---|
91
+ | `String`, `ID` | `::String` |
92
+ | `Int` | `::Integer` |
93
+ | `Float` | `::Float` |
94
+ | `Boolean` | `bool` |
95
+ | custom scalar | `untyped`, unless mapped with the `scalars` option |
96
+ | enum | a literal union in `Enums`, e.g. `"ADMIN" \| "MEMBER"` |
97
+ | nullable field `T` | `T?` |
98
+ | list `[T!]!` | `::Array[T]` |
99
+ | field under `@include` / `@skip` | optional key, `?"field" => T` |
100
+ | union / interface | a record per distinct shape, with `"__typename"` as a literal type |
101
+ | fragment | inlined into each operation, plus a `data` type of its own |
102
+
103
+ Variables accept what GraphQL input coercion accepts: `ID` also takes `::Integer`,
104
+ `Float` also takes `::Integer`. Nullable variables and variables with a default value
105
+ are optional keys. Input objects become records in `Inputs`; `@oneOf` input objects
106
+ become a union of single-key records.
107
+
108
+ When a selection on a union or interface picks different fields for different
109
+ possible types, select `__typename` there. Generation fails otherwise, since
110
+ the result could not be told apart.
111
+
112
+ Each definition gets a module named after it (`query getUser` becomes `GetUser`).
113
+ Unnamed operations are skipped with a warning.
114
+
115
+ Operations are validated against the schema first, and errors name the operation
116
+ and where it is defined:
117
+
118
+ ```
119
+ GraphQL validation failed:
120
+ - UserQuery (app/clients/user_client.rb:4): Field 'nmae' doesn't exist on type 'User'
121
+ ```
122
+
123
+ ### Steep caveat
124
+
125
+ Steep reports calling methods on nullable fields and missing or mistyped variables,
126
+ but it does not report reading a key a record does not have: `user["nmae"]` is typed
127
+ as the union of the record's value types instead of an error.
128
+
129
+ ## Configuration
130
+
131
+ Create `.graphqltagrbsconfig.yaml` to change any of these options:
132
+
133
+ - schema_path
134
+ - The GraphQL schema, as SDL (`.graphql`, `.graphqls`) or an introspection result (`.json`)
135
+ - default: `"schema.graphql"`
136
+ - input_path
137
+ - The operation list exported by graphql_tag_pluck
138
+ - default: `"__generated__/graphql_operation_list.json"`
139
+ - output_dir
140
+ - Where to write the `.rbs` files. Generated files that are no longer produced are removed; files without the generated header are left alone.
141
+ - default: `"sig/graphql"`
142
+ - namespace
143
+ - The module the types are defined in. May be nested, e.g. `"MyApp::GraphQL"`.
144
+ - default: `"GraphQLTypes"`
145
+ - key_style
146
+ - `"string"` for `JSON.parse(body)`, `"symbol"` for `JSON.parse(body, symbolize_names: true)`
147
+ - default: `"string"`
148
+ - scalars
149
+ - RBS types for custom scalars, as they arrive in JSON
150
+ - default: `{}`, e.g. `{ DateTime: "::String", JSON: "untyped" }`
151
+
152
+ Add the output directory to your `Steepfile`:
153
+
154
+ ```ruby
155
+ target :app do
156
+ signature "sig"
157
+ check "app"
158
+ end
159
+ ```
160
+
161
+ ## Installation
162
+
163
+ ```sh
164
+ bundle add graphql_tag_pluck graphql_tag_rbs --group development
165
+ ```
166
+
167
+ ## Releasing
168
+
169
+ Releases are automated by the [Release workflow](.github/workflows/release.yml):
170
+
171
+ 1. Bump the version in the `VERSION` file at the repository root in a pull request.
172
+ 2. Merge it into `main`.
173
+
174
+ The workflow then tags the merge commit as `vX.Y.Z`, runs the specs and publishes the gem to RubyGems.org. If publishing fails, re-run it from **Actions → Release → Run workflow**; the existing tag is reused.
175
+
176
+ ## License
177
+
178
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphqlTagRbs
4
+ # Loads options from .graphqltagrbsconfig.yaml in the current directory.
5
+ class Config
6
+ CONFIG_FILE = File.expand_path(".graphqltagrbsconfig.yaml", Dir.pwd)
7
+
8
+ def self.load(path = CONFIG_FILE)
9
+ return {} unless File.exist?(path)
10
+
11
+ (YAML.load_file(path) || {}).to_h { |key, value| [key.to_sym, value] }
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphqlTagRbs
4
+ # Turns a graphql_tag_pluck operation list into RBS files.
5
+ #
6
+ # Generator.new(schema: schema, operations: operations).generate
7
+ # # => { "app/clients/user_client.rbs" => "...", "_shared.rbs" => "..." }
8
+ #
9
+ # Every operation gets a module with `variables`, `data`, and `response`
10
+ # type aliases, and every fragment a module with a `data` alias. Enums,
11
+ # input objects, and the GraphQL error type go into the shared file.
12
+ class Generator
13
+ include Types
14
+
15
+ HEADER = "# Generated by graphql_tag_rbs"
16
+ SHARED_FILE = "_shared.rbs"
17
+ KEY_STYLES = %i[string symbol].freeze
18
+
19
+ Nodes = GraphQL::Language::Nodes
20
+
21
+ Definition = Struct.new(:name, :module_name, :kind, :node, :file, :line, :constant, keyword_init: true) do
22
+ def label = "#{name} (#{file}:#{line})"
23
+ end
24
+
25
+ # All occurrences of one response key within a selection set.
26
+ FieldEntry = Struct.new(:name, :nodes, :conditional)
27
+
28
+ def initialize(schema:, operations:, namespace: "GraphQLTypes", key_style: :string, scalars: {})
29
+ @schema = schema
30
+ @namespace = namespace.to_s.split("::")
31
+ @key_style = key_style.to_sym
32
+ raise Error, "key_style must be one of #{KEY_STYLES.join(", ")}, got #{key_style.inspect}" unless KEY_STYLES.include?(@key_style)
33
+
34
+ @scalars = scalars.to_h { |name, type| [name.to_s, type.to_s] }
35
+ @definitions = build_definitions(operations)
36
+ @fragments = @definitions.select { |d| d.kind == "fragment" }.to_h { |d| [d.name, d.node] }
37
+ @enums = Set.new
38
+ @inputs = Set.new
39
+ end
40
+
41
+ def generate
42
+ validate!
43
+
44
+ files = @definitions.group_by(&:file).sort.to_h do |file, definitions|
45
+ [output_path(file), render_file(file, definitions.sort_by(&:line))]
46
+ end
47
+ raise Error, "#{SHARED_FILE} clashes with a file generated for a source file; rename that file" if files.key?(SHARED_FILE)
48
+
49
+ # Rendered last: rendering the operations collects the enums and inputs it needs.
50
+ files[SHARED_FILE] = render_shared
51
+ files
52
+ end
53
+
54
+ private
55
+
56
+ # --- Input ------------------------------------------------------------
57
+
58
+ def build_definitions(operations)
59
+ definitions = operations.each_value.filter_map do |entry|
60
+ entry = entry.to_h { |key, value| [key.to_s, value] }
61
+ unless entry.key?("file") && entry.key?("line")
62
+ raise Error, "#{entry["name"] || "An operation"} has no file / line. " \
63
+ "Regenerate the operation list with graphql_tag_pluck 0.3.0 or later."
64
+ end
65
+
66
+ if entry["name"].nil?
67
+ warn "graphql_tag_rbs: skipping unnamed operation at #{entry["file"]}:#{entry["line"]}; name it to generate types"
68
+ next
69
+ end
70
+
71
+ Definition.new(
72
+ name: entry["name"],
73
+ module_name: module_name(entry["name"]),
74
+ kind: entry["type"],
75
+ node: GraphQL.parse(entry["source"]).definitions.first,
76
+ file: entry["file"],
77
+ line: entry["line"],
78
+ constant: entry["constant"]
79
+ )
80
+ end
81
+
82
+ definitions.group_by(&:module_name).each do |module_name, same|
83
+ next if same.size == 1
84
+
85
+ raise Error, "#{same.map(&:label).join(" and ")} would both generate module #{module_name}"
86
+ end
87
+ definitions
88
+ end
89
+
90
+ def validate!
91
+ document = Nodes::Document.new(definitions: @definitions.map(&:node))
92
+ errors = @schema.validate(document).reject do |error|
93
+ # Fragments get their own types, so a fragment no operation spreads is fine.
94
+ error.is_a?(GraphQL::StaticValidation::FragmentsAreUsedError) && error.message.end_with?("was defined, but not used")
95
+ end
96
+ return if errors.empty?
97
+
98
+ by_name = @definitions.to_h { |d| [d.name, d] }
99
+ messages = errors.map do |error|
100
+ definition = by_name[error.path&.first.to_s.split(" ").last]
101
+ definition ? "#{definition.label}: #{error.message}" : error.message
102
+ end
103
+ raise Error, "GraphQL validation failed:\n#{messages.map { |m| " - #{m}" }.join("\n")}"
104
+ end
105
+
106
+ # --- Files ------------------------------------------------------------
107
+
108
+ def output_path(file)
109
+ "#{file.delete_prefix("/").delete_suffix(".rb")}.rbs"
110
+ end
111
+
112
+ def render_file(file, definitions)
113
+ body = definitions.flat_map.with_index do |definition, index|
114
+ (index.zero? ? [] : [""]) + render_definition(definition)
115
+ end
116
+ "#{HEADER} from #{file}. Do not edit.\n\n#{wrap_in_namespace(body)}"
117
+ end
118
+
119
+ def render_definition(definition)
120
+ @current = definition
121
+ comment = "# #{definition.kind} #{definition.name} at #{definition.file}:#{definition.line}"
122
+ comment += " (#{definition.constant})" if definition.constant
123
+
124
+ aliases =
125
+ if definition.kind == "fragment"
126
+ { "data" => fragment_type(definition.node) }
127
+ else
128
+ { "variables" => variables_type(definition.node), "data" => data_type(definition.node), "response" => response_type }
129
+ end
130
+
131
+ lines = [comment, "module #{definition.module_name}"]
132
+ aliases.each_with_index do |(name, type), index|
133
+ lines << "" unless index.zero?
134
+ lines.concat(indent_lines("type #{name} = #{type.render("")}"))
135
+ end
136
+ lines << "end"
137
+ end
138
+
139
+ def render_shared
140
+ inputs = {}
141
+ # Input objects can reference further input objects and enums.
142
+ until (pending = @inputs.to_a.sort - inputs.keys).empty?
143
+ pending.each { |name| inputs[name] = input_object_type(@schema.get_type(name)) }
144
+ end
145
+
146
+ body = ["type error = #{error_type.render("")}"].flat_map { |line| line.lines(chomp: true) }
147
+ body += shared_module("Enums", @enums.to_a.sort.to_h { |name| [name, enum_type(@schema.get_type(name))] })
148
+ body += shared_module("Inputs", inputs.sort.to_h)
149
+
150
+ "#{HEADER}. Do not edit.\n\n#{wrap_in_namespace(body)}"
151
+ end
152
+
153
+ def shared_module(module_name, types)
154
+ return [] if types.empty?
155
+
156
+ lines = ["", "module #{module_name}"]
157
+ types.each_with_index do |(name, type), index|
158
+ lines << "" unless index.zero?
159
+ lines.concat(indent_lines("type #{alias_name(name)} = #{type.render("")}"))
160
+ end
161
+ lines << "end"
162
+ end
163
+
164
+ def wrap_in_namespace(lines)
165
+ @namespace.reverse_each do |name|
166
+ lines = ["module #{name}", *indent_lines(lines), "end"]
167
+ end
168
+ "#{lines.join("\n")}\n"
169
+ end
170
+
171
+ def indent_lines(text)
172
+ Array(text)
173
+ .flat_map { |chunk| chunk.empty? ? [""] : chunk.lines(chomp: true) }
174
+ .map { |line| line.empty? ? line : INDENT + line }
175
+ end
176
+
177
+ # --- Operation types ----------------------------------------------------
178
+
179
+ def data_type(operation)
180
+ root = @schema.root_type_for_operation(operation.operation_type)
181
+ raise Error, "#{@current.label}: the schema has no #{operation.operation_type} type" if root.nil?
182
+
183
+ selection_set_type(root, operation.selections, [])
184
+ end
185
+
186
+ def fragment_type(fragment)
187
+ selection_set_type(@schema.get_type(fragment.type.name), fragment.selections, [])
188
+ end
189
+
190
+ def variables_type(operation)
191
+ fields = operation.variables.map do |variable|
192
+ required = variable.type.is_a?(Nodes::NonNullType) && variable.default_value.nil?
193
+ Field.new(variable.name, input_ast_type(variable.type), !required)
194
+ end
195
+ Record.new(fields, @key_style)
196
+ end
197
+
198
+ def response_type
199
+ Record.new([
200
+ Field.new("data", Optional.new(Named.new("data")), true),
201
+ Field.new("errors", ArrayOf.new(Named.new("#{namespace_ref}::error")), true),
202
+ Field.new("extensions", hash_type, true)
203
+ ], @key_style)
204
+ end
205
+
206
+ def error_type
207
+ location = Record.new([Field.new("line", Named.new("::Integer"), false), Field.new("column", Named.new("::Integer"), false)], @key_style)
208
+ Record.new([
209
+ Field.new("message", Named.new("::String"), false),
210
+ Field.new("locations", ArrayOf.new(location), true),
211
+ Field.new("path", ArrayOf.new(Union.of([Named.new("::String"), Named.new("::Integer")])), true),
212
+ Field.new("extensions", hash_type, true)
213
+ ], @key_style)
214
+ end
215
+
216
+ def hash_type
217
+ Named.new(@key_style == :symbol ? "::Hash[::Symbol, untyped]" : "::Hash[::String, untyped]")
218
+ end
219
+
220
+ # --- Selections ---------------------------------------------------------
221
+
222
+ # The type of a selection set on `parent_type`. For abstract types, possible
223
+ # types that select the same fields share one record, distinguished by a
224
+ # `__typename` literal union; differing shapes become a union of records.
225
+ def selection_set_type(parent_type, selections, path)
226
+ possible = @schema.possible_types(parent_type).sort_by(&:graphql_name)
227
+ return UNTYPED if possible.empty?
228
+
229
+ shapes = possible.map do |type|
230
+ entries = {}
231
+ collect_fields(type, selections, false, entries)
232
+ [type, entries.map { |key, entry| field_for(type, key, entry, path) }]
233
+ end
234
+
235
+ groups = shapes.group_by { |_type, fields| fields }.transform_values { |members| members.map(&:first) }
236
+ if groups.size > 1 && !groups.keys.all? { |fields| fields.any? { |field| field.type == :typename } }
237
+ location = path.empty? ? "the root selection" : "`#{path.join(".")}`"
238
+ raise Error, "#{@current.label}: #{location} selects different fields depending on whether the " \
239
+ "#{parent_type.graphql_name} is a #{possible.map(&:graphql_name).join(", ")}; " \
240
+ "select `__typename` there so the result can be told apart"
241
+ end
242
+
243
+ Union.of(groups.map do |fields, types|
244
+ typename = Union.of(types.map { |type| Literal.new(type.graphql_name) })
245
+ Record.new(fields.map { |field| field.type == :typename ? Field.new(field.key, typename, field.optional) : field }, @key_style)
246
+ end)
247
+ end
248
+
249
+ # Collects the fields `type` gets from `selections`, following inline
250
+ # fragments and fragment spreads that apply to it. Fields under
251
+ # @include / @skip may be missing from the response, so they are marked
252
+ # conditional unless the same key is also selected unconditionally.
253
+ def collect_fields(type, selections, conditional, entries)
254
+ selections.each do |selection|
255
+ selection_conditional = conditional || selection.directives.any? { |d| %w[include skip].include?(d.name) }
256
+
257
+ case selection
258
+ when Nodes::Field
259
+ key = selection.alias || selection.name
260
+ if (entry = entries[key])
261
+ entry.nodes << selection
262
+ entry.conditional &&= selection_conditional
263
+ else
264
+ entries[key] = FieldEntry.new(selection.name, [selection], selection_conditional)
265
+ end
266
+ when Nodes::InlineFragment
267
+ next if selection.type && !applies_to?(type, selection.type.name)
268
+
269
+ collect_fields(type, selection.selections, selection_conditional, entries)
270
+ when Nodes::FragmentSpread
271
+ fragment = @fragments.fetch(selection.name)
272
+ next unless applies_to?(type, fragment.type.name)
273
+
274
+ collect_fields(type, fragment.selections, selection_conditional, entries)
275
+ end
276
+ end
277
+ end
278
+
279
+ def applies_to?(type, type_condition)
280
+ condition = @schema.get_type(type_condition)
281
+ condition == type || (condition.kind.abstract? && @schema.possible_types(condition).include?(type))
282
+ end
283
+
284
+ def field_for(type, key, entry, path)
285
+ # A placeholder, replaced with the possible type names once types are grouped.
286
+ return Field.new(key, :typename, entry.conditional) if entry.name == "__typename"
287
+
288
+ definition = @schema.get_field(type, entry.name)
289
+ field_type = output_type(definition.type, entry.nodes.flat_map(&:selections), path + [key])
290
+ Field.new(key, field_type, entry.conditional)
291
+ end
292
+
293
+ def output_type(type, selections, path)
294
+ return bare_output_type(type.of_type, selections, path) if type.non_null?
295
+
296
+ Optional.new(bare_output_type(type, selections, path))
297
+ end
298
+
299
+ def bare_output_type(type, selections, path)
300
+ return ArrayOf.new(output_type(type.of_type, selections, path)) if type.list?
301
+
302
+ case type.kind.name
303
+ when "SCALAR" then scalar_type(type.graphql_name, :output)
304
+ when "ENUM" then enum_ref(type)
305
+ else selection_set_type(type, selections, path)
306
+ end
307
+ end
308
+
309
+ # --- Inputs ---------------------------------------------------------------
310
+
311
+ def input_ast_type(ast_type)
312
+ return bare_input_ast_type(ast_type.of_type) if ast_type.is_a?(Nodes::NonNullType)
313
+
314
+ Optional.new(bare_input_ast_type(ast_type))
315
+ end
316
+
317
+ def bare_input_ast_type(ast_type)
318
+ return ArrayOf.new(input_ast_type(ast_type.of_type)) if ast_type.is_a?(Nodes::ListType)
319
+
320
+ named_input_type(@schema.get_type(ast_type.name))
321
+ end
322
+
323
+ def input_type(type)
324
+ return bare_input_type(type.of_type) if type.non_null?
325
+
326
+ Optional.new(bare_input_type(type))
327
+ end
328
+
329
+ def bare_input_type(type)
330
+ return ArrayOf.new(input_type(type.of_type)) if type.list?
331
+
332
+ named_input_type(type)
333
+ end
334
+
335
+ def named_input_type(type)
336
+ case type.kind.name
337
+ when "SCALAR" then scalar_type(type.graphql_name, :input)
338
+ when "ENUM" then enum_ref(type)
339
+ else
340
+ @inputs << type.graphql_name
341
+ Named.new("#{namespace_ref}::Inputs::#{alias_name(type.graphql_name)}")
342
+ end
343
+ end
344
+
345
+ def input_object_type(type)
346
+ arguments = type.arguments.values
347
+
348
+ # @oneOf input objects take exactly one of their fields, and it must not be null.
349
+ if type.respond_to?(:one_of?) && type.one_of?
350
+ return Union.of(arguments.map do |argument|
351
+ value_type = argument.type.non_null? ? argument.type.of_type : argument.type
352
+ Record.new([Field.new(argument.graphql_name, bare_input_type(value_type), false)], @key_style)
353
+ end)
354
+ end
355
+
356
+ Record.new(arguments.map do |argument|
357
+ required = argument.type.non_null? && !argument.default_value?
358
+ Field.new(argument.graphql_name, input_type(argument.type), !required)
359
+ end, @key_style)
360
+ end
361
+
362
+ # --- Leaves ---------------------------------------------------------------
363
+
364
+ BUILT_IN_SCALARS = {
365
+ output: { "ID" => "::String", "String" => "::String", "Int" => "::Integer", "Float" => "::Float", "Boolean" => "bool" },
366
+ # Input coercion also accepts integers for ID and Float.
367
+ input: { "ID" => "::String | ::Integer", "String" => "::String", "Int" => "::Integer", "Float" => "::Float | ::Integer", "Boolean" => "bool" }
368
+ }.freeze
369
+
370
+ def scalar_type(name, direction)
371
+ Named.new(@scalars.fetch(name) { BUILT_IN_SCALARS[direction].fetch(name, "untyped") })
372
+ end
373
+
374
+ def enum_ref(type)
375
+ @enums << type.graphql_name
376
+ Named.new("#{namespace_ref}::Enums::#{alias_name(type.graphql_name)}")
377
+ end
378
+
379
+ def enum_type(type)
380
+ Union.of(type.values.keys.map { |value| Literal.new(value) })
381
+ end
382
+
383
+ # --- Naming ---------------------------------------------------------------
384
+
385
+ def namespace_ref = "::#{@namespace.join("::")}"
386
+
387
+ # GraphQL names may start with a lowercase letter or underscores; RBS module names may not.
388
+ def module_name(name)
389
+ stripped = name.sub(/\A_+/, "")
390
+ module_name = stripped[0].to_s.upcase + stripped[1..].to_s
391
+ raise Error, "Cannot turn GraphQL name #{name.inspect} into an RBS module name" unless module_name.match?(/\A[A-Z]\w*\z/)
392
+ raise Error, "#{name} clashes with the generated #{module_name} module; rename it" if %w[Enums Inputs].include?(module_name)
393
+
394
+ module_name
395
+ end
396
+
397
+ # RBS type alias names start with a lowercase letter: UserRole => user_role.
398
+ def alias_name(name)
399
+ name.sub(/\A_+/, "").gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
400
+ end
401
+ end
402
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+
5
+ module GraphqlTagRbs
6
+ class Railtie < Rails::Railtie
7
+ railtie_name :graphql_tag_rbs
8
+
9
+ rake_tasks do
10
+ Dir.glob(File.expand_path("tasks/**/*.rake", __dir__)).each { |f| load f }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Defines the graphql_tag_rbs rake tasks outside Rails. Add this to your
4
+ # Rakefile:
5
+ #
6
+ # require "graphql_tag_rbs/rake_tasks"
7
+ #
8
+ # Rails apps get the tasks through GraphqlTagRbs::Railtie and should not
9
+ # require this file, or the tasks will run twice.
10
+ require "graphql_tag_rbs"
11
+
12
+ Dir.glob(File.expand_path("tasks/**/*.rake", __dir__)).each { |f| load f }
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphqlTagRbs
4
+ # Loads a schema from SDL (.graphql / .graphqls) or an introspection result (.json).
5
+ module SchemaLoader
6
+ def self.load(path)
7
+ raise Error, "GraphQL schema not found at #{path}" unless File.exist?(path)
8
+
9
+ if File.extname(path) == ".json"
10
+ introspection = JSON.parse(File.read(path))
11
+ # Accept both a raw `{ "__schema": ... }` and a full `{ "data": { "__schema": ... } }` response.
12
+ introspection = { "data" => introspection } if introspection.key?("__schema")
13
+ GraphQL::Schema.from_introspection(introspection)
14
+ else
15
+ GraphQL::Schema.from_definition(File.read(path))
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :graphql_tag_rbs do
4
+ desc "Generate RBS types for the GraphQL operations plucked by graphql_tag_pluck"
5
+ task :generate do
6
+ result = GraphqlTagRbs.generate
7
+ puts "graphql_tag_rbs: wrote #{result.written.size} file(s) to #{GraphqlTagRbs.options[:output_dir]}"
8
+ result.deleted.each { |path| puts "graphql_tag_rbs: removed #{path}" }
9
+ end
10
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphqlTagRbs
4
+ # A small RBS type AST. Every node renders itself given the indentation of
5
+ # the line it starts on; records span several lines.
6
+ module Types
7
+ INDENT = " "
8
+
9
+ # A type written as-is, e.g. `::String`, `bool`, `untyped`, or a type alias reference.
10
+ Named = Struct.new(:name) do
11
+ def render(_indent) = name
12
+
13
+ def compound? = name.match?(/[|&]/)
14
+ end
15
+
16
+ # A string literal type, e.g. `"User"`.
17
+ Literal = Struct.new(:value) do
18
+ def render(_indent) = value.dump
19
+ end
20
+
21
+ Optional = Struct.new(:type) do
22
+ def render(indent)
23
+ return type.render(indent) if type == UNTYPED
24
+
25
+ inner = type.render(indent)
26
+ needs_parens = type.is_a?(Union) || (type.is_a?(Named) && type.compound?)
27
+ needs_parens ? "(#{inner})?" : "#{inner}?"
28
+ end
29
+ end
30
+
31
+ ArrayOf = Struct.new(:type) do
32
+ def render(indent) = "::Array[#{type.render(indent)}]"
33
+ end
34
+
35
+ Union = Struct.new(:types) do
36
+ def self.of(types)
37
+ unique = types.uniq
38
+ unique.size == 1 ? unique.first : new(unique)
39
+ end
40
+
41
+ def render(indent) = types.map { |type| type.render(indent) }.join(" | ")
42
+ end
43
+
44
+ Field = Struct.new(:key, :type, :optional)
45
+
46
+ Record = Struct.new(:fields, :key_style) do
47
+ def render(indent)
48
+ return "{}" if fields.empty?
49
+
50
+ inner = indent + INDENT
51
+ lines = fields.map { |field| "#{inner}#{key(field)}#{field.type.render(inner)}" }
52
+ "{\n#{lines.join(",\n")}\n#{indent}}"
53
+ end
54
+
55
+ private
56
+
57
+ def key(field)
58
+ prefix = field.optional ? "?" : ""
59
+ key_style == :symbol ? "#{prefix}#{field.key}: " : "#{prefix}#{field.key.dump} => "
60
+ end
61
+ end
62
+
63
+ UNTYPED = Named.new("untyped")
64
+ end
65
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphqlTagRbs
4
+ # The version lives in the plain-text VERSION file at the gem root.
5
+ VERSION = File.read(File.expand_path("../../VERSION", __dir__)).strip.freeze
6
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module GraphqlTagRbs
6
+ # Writes generated files into the output directory and removes generated
7
+ # files that are no longer produced, such as those of a deleted source file.
8
+ # Files without the generated header are never touched.
9
+ class Writer
10
+ Result = Struct.new(:written, :deleted, keyword_init: true)
11
+
12
+ def initialize(output_dir)
13
+ @output_dir = output_dir
14
+ end
15
+
16
+ def write(files)
17
+ written = files.map do |relative_path, content|
18
+ path = File.join(@output_dir, relative_path)
19
+ FileUtils.mkdir_p(File.dirname(path))
20
+ File.write(path, content) unless File.exist?(path) && File.read(path) == content
21
+ path
22
+ end
23
+
24
+ deleted = (Dir.glob(File.join(@output_dir, "**/*.rbs")) - written).select do |path|
25
+ next false unless File.foreach(path).first.to_s.start_with?(Generator::HEADER)
26
+
27
+ File.delete(path)
28
+ true
29
+ end
30
+
31
+ Result.new(written: written, deleted: deleted)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "graphql"
4
+ require "json"
5
+ require "set"
6
+ require "yaml"
7
+
8
+ require_relative "graphql_tag_rbs/version"
9
+
10
+ module GraphqlTagRbs
11
+ class Error < StandardError; end
12
+ end
13
+
14
+ require_relative "graphql_tag_rbs/config"
15
+ require_relative "graphql_tag_rbs/types"
16
+ require_relative "graphql_tag_rbs/schema_loader"
17
+ require_relative "graphql_tag_rbs/generator"
18
+ require_relative "graphql_tag_rbs/writer"
19
+ require_relative "graphql_tag_rbs/railtie" if defined?(Rails::Railtie)
20
+
21
+ module GraphqlTagRbs
22
+ class << self
23
+ attr_accessor :options
24
+
25
+ def default_options
26
+ {
27
+ schema_path: "schema.graphql",
28
+ input_path: "__generated__/graphql_operation_list.json",
29
+ output_dir: "sig/graphql",
30
+ namespace: "GraphQLTypes",
31
+ key_style: "string",
32
+ scalars: {}
33
+ }
34
+ end
35
+
36
+ # Reads the schema and the graphql_tag_pluck operation list, and writes
37
+ # RBS files into output_dir. Returns the Writer::Result.
38
+ def generate(options = self.options)
39
+ input_path = options[:input_path]
40
+ unless File.exist?(input_path)
41
+ raise Error, "Operation list not found at #{input_path}. " \
42
+ "Run `rake graphql_tag_pluck:generate_graphql_operation_list` first."
43
+ end
44
+
45
+ files = Generator.new(
46
+ schema: SchemaLoader.load(options[:schema_path]),
47
+ operations: JSON.parse(File.read(input_path)),
48
+ namespace: options[:namespace],
49
+ key_style: options[:key_style],
50
+ scalars: options[:scalars] || {}
51
+ ).generate
52
+
53
+ Writer.new(options[:output_dir]).write(files)
54
+ end
55
+ end
56
+
57
+ self.options = default_options.merge(Config.load)
58
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: graphql_tag_rbs
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - indigolain
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: graphql
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ description: Reads the operation list exported by graphql_tag_pluck and a GraphQL
27
+ schema, and writes RBS type aliases for each operation's variables, data, and response.
28
+ email:
29
+ - indigo.lain@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - ".rspec"
35
+ - ".ruby-version"
36
+ - CHANGELOG.md
37
+ - Gemfile
38
+ - LICENSE.txt
39
+ - README.md
40
+ - Rakefile
41
+ - VERSION
42
+ - lib/graphql_tag_rbs.rb
43
+ - lib/graphql_tag_rbs/config.rb
44
+ - lib/graphql_tag_rbs/generator.rb
45
+ - lib/graphql_tag_rbs/railtie.rb
46
+ - lib/graphql_tag_rbs/rake_tasks.rb
47
+ - lib/graphql_tag_rbs/schema_loader.rb
48
+ - lib/graphql_tag_rbs/tasks/generate.rake
49
+ - lib/graphql_tag_rbs/types.rb
50
+ - lib/graphql_tag_rbs/version.rb
51
+ - lib/graphql_tag_rbs/writer.rb
52
+ homepage: https://github.com/indigolain/graphql_tag_rbs
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ homepage_uri: https://github.com/indigolain/graphql_tag_rbs
57
+ source_code_uri: https://github.com/indigolain/graphql_tag_rbs
58
+ changelog_uri: https://github.com/indigolain/graphql_tag_rbs/blob/main/CHANGELOG.md
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: 3.2.0
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 4.0.3
74
+ specification_version: 4
75
+ summary: Generates RBS types for GraphQL operations plucked by graphql_tag_pluck
76
+ test_files: []