graph_weaver 0.4.6 → 0.5.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1442 -0
  3. data/Gemfile.lock +23 -23
  4. data/README.md +115 -96
  5. data/docs/cassettes.md +93 -46
  6. data/docs/editors.md +82 -0
  7. data/docs/errors.md +34 -30
  8. data/docs/federation.md +521 -48
  9. data/docs/generated_modules.md +352 -137
  10. data/docs/getting_started.md +237 -67
  11. data/docs/logging.md +35 -6
  12. data/docs/real_world.md +21 -15
  13. data/docs/scalars.md +49 -154
  14. data/docs/testing.md +300 -52
  15. data/docs/transports.md +129 -30
  16. data/docs/upgrading.md +134 -0
  17. data/graph_weaver.gemspec +19 -3
  18. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  19. data/lib/graph_weaver/client.rb +118 -111
  20. data/lib/graph_weaver/codegen/aliases.rb +223 -0
  21. data/lib/graph_weaver/codegen/emit.rb +283 -261
  22. data/lib/graph_weaver/codegen/enum_type.rb +25 -124
  23. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  24. data/lib/graph_weaver/codegen/scalar_type.rb +69 -66
  25. data/lib/graph_weaver/codegen/type_helpers.rb +140 -0
  26. data/lib/graph_weaver/codegen.rb +672 -336
  27. data/lib/graph_weaver/errors.rb +154 -16
  28. data/lib/graph_weaver/federation.rb +259 -0
  29. data/lib/graph_weaver/hints.rb +9 -1
  30. data/lib/graph_weaver/in_process.rb +90 -0
  31. data/lib/graph_weaver/input_struct.rb +14 -2
  32. data/lib/graph_weaver/logging.rb +29 -0
  33. data/lib/graph_weaver/parsing.rb +59 -0
  34. data/lib/graph_weaver/query_module.rb +55 -0
  35. data/lib/graph_weaver/railtie.rb +23 -1
  36. data/lib/graph_weaver/representation.rb +74 -0
  37. data/lib/graph_weaver/response.rb +7 -0
  38. data/lib/graph_weaver/retry.rb +29 -8
  39. data/lib/graph_weaver/rspec.rb +220 -16
  40. data/lib/graph_weaver/schema_loader.rb +819 -60
  41. data/lib/graph_weaver/schemas.rb +48 -0
  42. data/lib/graph_weaver/selection.rb +43 -8
  43. data/lib/graph_weaver/tasks.rb +220 -22
  44. data/lib/graph_weaver/testing/cassette.rb +249 -81
  45. data/lib/graph_weaver/testing/coverage.rb +160 -0
  46. data/lib/graph_weaver/testing/failure.rb +14 -25
  47. data/lib/graph_weaver/testing/fake_client.rb +182 -22
  48. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  49. data/lib/graph_weaver/testing/router.rb +1452 -0
  50. data/lib/graph_weaver/testing/subgraphs.rb +134 -0
  51. data/lib/graph_weaver/testing.rb +209 -13
  52. data/lib/graph_weaver/transport/faraday.rb +28 -10
  53. data/lib/graph_weaver/transport/http.rb +99 -36
  54. data/lib/graph_weaver/transport.rb +67 -14
  55. data/lib/graph_weaver/version.rb +1 -1
  56. data/lib/graph_weaver.rb +416 -172
  57. metadata +25 -9
  58. data/CLAUDE.md +0 -69
  59. data/Makefile +0 -23
  60. data/NOTES.md +0 -182
  61. data/PLAN.md +0 -144
data/docs/upgrading.md ADDED
@@ -0,0 +1,134 @@
1
+ # Upgrading to 0.5.0
2
+
3
+ 0.5.0 is one large breaking release. Almost all of it is caught mechanically,
4
+ in this order:
5
+
6
+ ```sh
7
+ # 1. rename the path settings first — generate won't load without them
8
+ # (queries_path -> queries_paths, generated_path -> generated_paths,
9
+ # fragments_path -> fragments_paths; see "One plurality rule" below)
10
+
11
+ bundle exec tapioca gem graph_weaver # 2. regenerate the RBI
12
+ rake graph_weaver:generate # 3. the emitted call shape changed
13
+ srb tc # 4. every call site that moved is an error
14
+ rake graph_weaver:verify # 5. fails until the tree is regenerated
15
+ ```
16
+
17
+ **Step 2 is not optional.** Against the 0.4.6 RBI, `srb tc` reports errors
18
+ pointing into your `generated/` directory — `QueryModule`, `client_for`,
19
+ `check_envelope!` — which read as though codegen emitted broken Ruby. It
20
+ didn't; sorbet is checking new generated code against the old gem's types.
21
+ Regenerate the RBI and what remains is only your own call sites.
22
+
23
+ Generated code is `# typed: strict`, so step 4 finds those for you. The rest of
24
+ this page is what a typechecker can't see.
25
+
26
+ ## `execute` means one thing now
27
+
28
+ Every client answers the same call — `execute(query, variables:, operation_name:)`,
29
+ returning the raw response hash. `Client` used to spell something else under
30
+ that name, which is why `Retry.new(client)` and `Sequence.new(client, fake)`
31
+ raised `ArgumentError`. They work now.
32
+
33
+ The one-shot sugar moved to `run`:
34
+
35
+ ```ruby
36
+ client.execute!("query { … }", id: "1") # before
37
+ client.run!("query { … }", id: "1") # after (and #run for the envelope)
38
+
39
+ GraphWeaver.execute(source, query, **vars) # before
40
+ GraphWeaver.run(source, query, **vars) # after
41
+ ```
42
+
43
+ **This one is worth grepping for.** `Client#execute` still exists, so a stale
44
+ call fails at runtime rather than at typecheck — as do `GraphWeaver.execute`
45
+ and `GraphWeaver.reset_scalars!`, which are simply gone and will not be flagged
46
+ until the RBI is regenerated (step 2): `rg '\.execute!?\(' --type ruby`
47
+ and check each hit is passing `variables:` rather than loose kwargs.
48
+
49
+ A generated module takes its per-call client as a **keyword**:
50
+
51
+ ```ruby
52
+ PersonQuery.execute(some_client, id: "1") # before
53
+ PersonQuery.execute(client: some_client, id: "1") # after
54
+ ```
55
+
56
+ `GraphWeaver.resolve_transport` is gone; nothing needs unwrapping any more.
57
+
58
+ ## Path settings are lists
59
+
60
+ `queries_paths`, `generated_paths`, `fragments_paths` — every entry is read.
61
+ Assigning a String still works, so the change is the name:
62
+
63
+ ```ruby
64
+ GraphWeaver.queries_path = "app/graphql/queries" # before
65
+ GraphWeaver.queries_paths = "app/graphql/queries" # after
66
+ ```
67
+
68
+ `schema_path` stays singular: one run reads one schema.
69
+
70
+ ## One reset
71
+
72
+ `GraphWeaver.reset_registrations!` is the clean slate between tests. The four
73
+ narrow ones moved to where they live:
74
+
75
+ ```ruby
76
+ GraphWeaver.reset_scalars! # before
77
+ GraphWeaver::Codegen.reset_scalars! # after (also reset_enums!, clear_scalars!,
78
+ # reset_type_helpers!)
79
+ ```
80
+
81
+ ## Smaller renames
82
+
83
+ | before | after |
84
+ |---|---|
85
+ | `Testing.config.auto_fake = true` | `Testing.config.default_mode = :fake` |
86
+ | `register_scalar(…, coerce: :to_s)` | `coerce: true`, or a `cast:`/`serialize:` pair |
87
+ | a mutation's `…Query` module | `…Mutation` |
88
+ | `graphql: :none` (rspec tag) | `graphql: false` |
89
+
90
+ **The shared types module was three, and is now one.** `GraphQLInputs`,
91
+ `GraphQLEnums` and `GraphQLUnions` are all `GraphQLTypes`, and the files move
92
+ with them — `generated/inputs/` becomes `generated/types/`. The three settings
93
+ that named them (`inputs_module=`, `enums_module=`, `unions_module=`) are one
94
+ `types_module=`. Regenerating writes the new tree; delete the old directory,
95
+ which pruning leaves behind empty.
96
+
97
+ If your specs run one schema class in-process while your client points at a
98
+ different API, name it — per example, since a federated suite runs more than
99
+ one:
100
+
101
+ ```ruby
102
+ graphql_in_process(MySchema) # in the example
103
+ GraphWeaver::Testing.config.schema = MySchema # or once, for the whole suite
104
+ ```
105
+
106
+ ## Registering from Rails
107
+
108
+ A registration naming one of your own constants belongs in a `to_prepare` block
109
+ — the same place the in-process client goes, and for the same reason:
110
+ autoloading is set up after `config/initializers` run.
111
+
112
+ ```ruby
113
+ Rails.application.config.to_prepare do
114
+ GraphWeaver.register_enum("Species", PetKind, fallback: PetKind::Unknown)
115
+ GraphWeaver.extend_type("Pet", PetHelpers)
116
+ end
117
+ ```
118
+
119
+ Generation depends on `:environment`, which runs `to_prepare` too, so the
120
+ registration is in place before it emits.
121
+
122
+ ## If you use the federation router
123
+
124
+ Detection only sees *loaded* schema classes, and Rails does not eager load for
125
+ rake or in the default test environment. Both are one line:
126
+
127
+ ```ruby
128
+ config.eager_load = true # config/environments/test.rb
129
+ config.rake_eager_load = true # config/application.rb
130
+ ```
131
+
132
+ Without them the `federation:*` tasks silently see nothing — and
133
+ `federation:diff` now **fails** rather than reporting a green "matches" over
134
+ zero subgraphs.
data/graph_weaver.gemspec CHANGED
@@ -2,16 +2,32 @@ require_relative "lib/graph_weaver/version"
2
2
 
3
3
  Gem::Specification.new do |s|
4
4
  s.authors = ["Daniel Pepper"]
5
- s.description = "A typed GraphQL client for Ruby generate Sorbet T::Structs from queries, with federation, extensibility, and testing in mind"
5
+ # the README tagline, verbatimthe two pitches drifted apart once already,
6
+ # so spec/gemspec_spec.rb pins them together
7
+ s.description = "Your .graphql files, compiled into Sorbet types — and the fakes to test them."
6
8
  # ".yardopts" explicitly: `git ls-files *` skips dotfiles, and
7
9
  # rubydoc.info needs it shipped to render docstrings as markdown
8
- s.files = `git ls-files * ':!:spec' ':!:sorbet' ':!:bin' ':!:examples'`.split("\n") + [".yardopts"]
10
+ # CLAUDE.md/PLAN.md/REVIEW.md/NOTES.md/DECISIONS.md are written for whoever
11
+ # works on the gem, not whoever installs it — and REVIEW.md carries examples
12
+ # from before the API it describes was rewritten
13
+ s.files = `git ls-files * ':!:spec' ':!:sorbet' ':!:bin' ':!:examples' \
14
+ ':!:CLAUDE.md' ':!:PLAN.md' ':!:REVIEW.md' ':!:NOTES.md' \
15
+ ':!:DECISIONS.md' ':!:Makefile' ':!:design'`.split("\n") + [".yardopts"]
9
16
  s.homepage = "https://github.com/dpep/graph_weaver"
10
17
  s.license = "MIT"
11
18
  s.name = "graph_weaver"
12
- s.summary = "GraphWeaver"
19
+ # rubygems.org shows summary as the headline, description below it
20
+ s.summary = "A typed GraphQL client for Ruby"
13
21
  s.version = GraphWeaver::VERSION
14
22
 
23
+ s.metadata = {
24
+ "bug_tracker_uri" => "#{s.homepage}/issues",
25
+ "changelog_uri" => "#{s.homepage}/blob/main/CHANGELOG.md",
26
+ "documentation_uri" => "#{s.homepage}/tree/main/docs",
27
+ "rubygems_mfa_required" => "true",
28
+ "source_code_uri" => s.homepage,
29
+ }
30
+
15
31
  s.required_ruby_version = ">= 3.3"
16
32
 
17
33
  # 2.6.7 fills defaulted directive arguments when building from SDL
@@ -0,0 +1,259 @@
1
+ # typed: ignore — Rails::Generators DSL, only loaded by `rails g`
2
+ # frozen_string_literal: true
3
+
4
+ require "graph_weaver"
5
+
6
+ # rails g graph_weaver:install https://api.example.com/graphql
7
+ # rails g graph_weaver:install MyApp::Schema
8
+ # rails g graph_weaver:install db/schema.graphql
9
+ #
10
+ # Scaffolds the conventional layout — initializer, query/generated
11
+ # directories, editor config — and bootstraps the schema dump, so the
12
+ # setup in docs/getting_started.md is one command.
13
+ #
14
+ # The argument is what you'd pass to GraphWeaver.new, and the same three
15
+ # source forms are accepted; the initializer it writes reflects the one
16
+ # you chose. The source arrives on the command line rather than being read
17
+ # from config: at install time the initializer doesn't exist yet.
18
+ module GraphWeaver
19
+ module Generators
20
+ class InstallGenerator < Rails::Generators::Base
21
+ desc <<~TEXT
22
+ Wire up GraphWeaver: initializer, app/graphql layout, editor config, schema dump.
23
+
24
+ SOURCE is what you'd pass to GraphWeaver.new:
25
+
26
+ rails g graph_weaver:install https://api.example.com/graphql # an endpoint
27
+ rails g graph_weaver:install MyApp::Schema # a graphql-ruby schema, in-process
28
+ rails g graph_weaver:install db/schema.graphql # a schema dump you already have
29
+ TEXT
30
+
31
+ argument :source, type: :string, banner: "SOURCE",
32
+ desc: "what you'd pass to GraphWeaver.new: an endpoint url, a graphql-ruby schema class, or a schema dump path"
33
+
34
+ # the default is SchemaLoader's, not one restated here — an --auth the
35
+ # generator omits from the dump is one the schema tasks then can't find
36
+ class_option :auth, type: :string,
37
+ desc: "name of the ENV var holding the auth token (url only) — " \
38
+ "default #{GraphWeaver::SchemaLoader::DEFAULT_AUTH_ENV}"
39
+ class_option :schema, type: :boolean, default: true,
40
+ desc: "write the schema dump codegen reads"
41
+
42
+ # Before anything is written: a mistyped source or a flag that doesn't
43
+ # apply to it is a mistake in the command just typed, so say so there
44
+ # rather than at boot, three files later.
45
+ def check_source
46
+ if options[:auth] && form != :url
47
+ raise Thor::Error, "--auth applies to a url — #{source} is a #{form == :schema_class ? "schema class" : "schema dump"}"
48
+ end
49
+
50
+ schema_class if form == :schema_class
51
+ end
52
+
53
+ # Every write goes through create_file, so a re-run prompts with a
54
+ # diff rather than overwriting an initializer you've edited.
55
+ def create_initializer
56
+ create_file "config/initializers/graph_weaver.rb", initializer
57
+ end
58
+
59
+ def create_layout
60
+ create_file File.join(GraphWeaver.queries_paths.first, ".keep"), ""
61
+ create_file File.join(GraphWeaver.generated_paths.first, ".keep"), ""
62
+ end
63
+
64
+ # editor autocomplete + validation for .graphql files (docs/editors.md)
65
+ def create_editor_config
66
+ create_file "graphql.config.yml", editor_config
67
+ end
68
+
69
+ # A url is introspected and a schema class dumped; a dump the app
70
+ # already has is left where it is (schema_path points at it instead).
71
+ def fetch_schema
72
+ return unless options[:schema] && form != :path
73
+
74
+ if form == :url
75
+ # pass the var name, not just the token — it lands in the dump's
76
+ # provenance so schema:refresh/:diff read the same one the
77
+ # initializer does, instead of defaulting to GRAPHWEAVER_AUTH
78
+ GraphWeaver::SchemaLoader.refresh!(url: source, auth_env: auth_var)
79
+ else
80
+ # a schema class is its own introspection source; ttl: 0 so an
81
+ # existing dump never counts as fresh
82
+ GraphWeaver::SchemaLoader.introspect(schema_class, cache: schema_path, ttl: 0)
83
+ end
84
+ say_status :introspect, "#{schema_path} from #{source}"
85
+ rescue StandardError => e
86
+ # the files above are the valuable part — don't lose them to a bad
87
+ # token or an unreachable host
88
+ say_status :failed, "#{e.message} — retry with `#{refresh_command}`", :red
89
+ end
90
+
91
+ def next_steps
92
+ say <<~TEXT
93
+
94
+ Write a query in #{GraphWeaver.queries_paths.first}, then:
95
+
96
+ rake graph_weaver:generate
97
+
98
+ Docs: https://github.com/dpep/graph_weaver/blob/main/docs/getting_started.md
99
+ TEXT
100
+
101
+ say federated_steps if subgraphs
102
+ end
103
+
104
+ private
105
+
106
+ # This install run is the one moment the user is guaranteed to be
107
+ # reading, and a composed supergraph changes what the next steps are:
108
+ # the test client is the interesting one, and there's a CI gate to add.
109
+ def federated_steps
110
+ <<~TEXT
111
+
112
+ #{source} is a composed supergraph (#{subgraphs.size} subgraphs: #{subgraphs.join(", ")}), so:
113
+
114
+ rake graph_weaver:federation:diff # CI gate: a subgraph changed, nobody recomposed
115
+ rake graph_weaver:federation:subgraphs # which schema here serves which subgraph
116
+
117
+ and specs run against your real resolvers across all of them, in-process:
118
+
119
+ describe "checkout", graphql: :router do ... end # require "graph_weaver/rspec"
120
+
121
+ Docs: https://github.com/dpep/graph_weaver/blob/main/docs/federation.md
122
+ TEXT
123
+ end
124
+
125
+ # The subgraph names this source composes, or nil when it isn't a
126
+ # composed supergraph. Read off the routing table rather than guessed —
127
+ # the same reader Testing::Router and federation:diff use.
128
+ def subgraphs
129
+ return @subgraphs if defined?(@subgraphs)
130
+
131
+ @subgraphs =
132
+ begin
133
+ (GraphWeaver::SchemaLoader.routing_table(source).subgraphs if form == :path)
134
+ rescue StandardError
135
+ # not a supergraph, or not readable — nothing to say either way
136
+ nil
137
+ end
138
+ end
139
+
140
+ # Which of GraphWeaver.new's source forms this is. Neither test is its
141
+ # own — a url is whatever the client calls one, a constant path whatever
142
+ # codegen will spell — so the generator can't disagree with either about
143
+ # what it just wrote an initializer for. Anything that is neither is
144
+ # taken as a path to a dump.
145
+ def form
146
+ @form ||=
147
+ if source.match?(GraphWeaver::Client::URL)
148
+ :url
149
+ elsif source.match?(GraphWeaver::Codegen::CONSTANT_NAME)
150
+ :schema_class
151
+ else
152
+ :path
153
+ end
154
+ end
155
+
156
+ # The named schema class, resolved now: `rails g` boots the app, so a
157
+ # typo is catchable here rather than as a NameError at the next boot.
158
+ def schema_class
159
+ @schema_class ||= begin
160
+ klass = Object.const_get(source)
161
+ unless klass.respond_to?(:execute)
162
+ raise Thor::Error, "#{source} isn't a graphql-ruby schema (no .execute) — pass the class that inherits GraphQL::Schema"
163
+ end
164
+
165
+ klass
166
+ rescue NameError
167
+ raise Thor::Error, "uninitialized constant #{source} — pass your graphql-ruby schema class " \
168
+ "(rails g graphql:install writes app/graphql/<app>_schema.rb), an endpoint url, or a path to a schema dump"
169
+ end
170
+ end
171
+
172
+ # A dump the app already has stays where it is; every other form
173
+ # writes the conventional one.
174
+ def schema_path = (form == :path) ? source : GraphWeaver.schema_path
175
+
176
+ def auth_var = options[:auth] || GraphWeaver::SchemaLoader::DEFAULT_AUTH_ENV
177
+
178
+ def refresh_command
179
+ (form == :url) ? "rake graph_weaver:schema:refresh" : "rails g graph_weaver:install #{source}"
180
+ end
181
+
182
+ def initializer
183
+ <<~RUBY
184
+ # frozen_string_literal: true
185
+
186
+ #{client_setup}
187
+ # Custom scalars, enums and type mixins go here — `rake graph_weaver:generate`
188
+ # bakes them into the generated source, so they must be registered first:
189
+ #
190
+ # GraphWeaver.register_scalar("DateTime", Time, serialize: :iso8601, requires: "time")
191
+ # GraphWeaver.extend_type("Person", Greetable)
192
+ RUBY
193
+ end
194
+
195
+ # The load-bearing lines, per source form: what generated modules
196
+ # resolve to at execute time, and where codegen reads the schema.
197
+ def client_setup
198
+ case form
199
+ when :url
200
+ <<~RUBY
201
+ GraphWeaver.client = GraphWeaver.new(
202
+ "#{source}",
203
+ auth: ENV["#{auth_var}"],
204
+ cache: true, # reuse the committed dump; delete it to re-introspect
205
+ )
206
+ RUBY
207
+ when :schema_class
208
+ # to_prepare, not a bare assignment: the schema class is autoloaded,
209
+ # so it isn't resolvable this early, and a dev reload replaces it
210
+ # with a new class object the client would otherwise still hold.
211
+ <<~RUBY
212
+ Rails.application.config.to_prepare do
213
+ # queries run in-process against the app's own schema — no socket
214
+ GraphWeaver.client = GraphWeaver.new(#{source})
215
+ end
216
+ RUBY
217
+ when :path
218
+ if subgraphs
219
+ <<~RUBY
220
+ GraphWeaver.schema_path = "#{source}"
221
+
222
+ # A composed supergraph: your queries are generated against the whole
223
+ # graph, and a router serves it. Point the app default at the gateway:
224
+ #
225
+ # GraphWeaver.client = GraphWeaver.new("https://gateway.example.com/graphql")
226
+ #
227
+ # Specs don't need one — `graphql: :router` plans against this
228
+ # supergraph and runs your own subgraph resolvers in-process
229
+ # (docs/federation.md).
230
+ RUBY
231
+ else
232
+ <<~RUBY
233
+ GraphWeaver.schema_path = "#{source}"
234
+
235
+ # A dump is type information only — it has no resolvers, so it can't
236
+ # execute. Point the app default at whatever serves this API:
237
+ #
238
+ # GraphWeaver.client = GraphWeaver.new("https://api.example.com/graphql")
239
+ RUBY
240
+ end
241
+ end
242
+ end
243
+
244
+ # fragments are in documents: too — without them an editor reports
245
+ # `Unknown fragment` on any query that spreads a shared one. The glob is
246
+ # codegen's, so the editor validates exactly the files codegen reads.
247
+ def editor_config
248
+ <<~YAML
249
+ # Autocomplete and validation for .graphql files in VS Code / RubyMine.
250
+ # https://github.com/dpep/graph_weaver/blob/main/docs/editors.md
251
+ schema: #{schema_path}
252
+ documents:
253
+ - #{File.join(GraphWeaver.queries_paths.first, GraphWeaver::Codegen::DOCUMENT_GLOB)}
254
+ - #{File.join(GraphWeaver.fragments_paths.first, GraphWeaver::Codegen::DOCUMENT_GLOB)}
255
+ YAML
256
+ end
257
+ end
258
+ end
259
+ end