graph_weaver 0.0.1 → 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.
@@ -0,0 +1,119 @@
1
+ # Generated modules
2
+
3
+ `GraphWeaver::Codegen` turns one GraphQL operation into one `# typed: strict`
4
+ Ruby module. Everything `srb tc` knows about your query results comes from this
5
+ file — there is no runtime schema, no lazy wrapper, no reflection.
6
+
7
+ ## Anatomy
8
+
9
+ ```ruby
10
+ module PersonQuery
11
+ QUERY = "..." # the operation, verbatim
12
+
13
+ class Result < T::Struct # the response shape, exactly as selected
14
+ class Person < T::Struct
15
+ const :name, String # non-null in the schema
16
+ const :birthday, T.nilable(Date)
17
+
18
+ def self.from_h(data) ... # generated casting — no reflection
19
+ end
20
+
21
+ const :person, T.nilable(Person)
22
+ end
23
+
24
+ def self.executor ... # default transport (see below)
25
+ def self.execute(id:, executor: self.executor) # -> GraphWeaver::Response[Result]
26
+ def self.execute!(id:, executor: self.executor) # -> Result, or raises QueryError
27
+ end
28
+ ```
29
+
30
+ - `execute` returns the **envelope** — `GraphWeaver::Response[Result]` with
31
+ `#data`, `#data!`, `#errors`, `#extensions` — so partial data and
32
+ cost/throttle metadata survive.
33
+ - `execute!` is the shortcut: the typed result or a raised
34
+ `GraphWeaver::QueryError`.
35
+
36
+ ## Variables become typed kwargs
37
+
38
+ ```graphql
39
+ mutation($name: String!, $species: Species!, $note: String) { ... }
40
+ ```
41
+
42
+ ```ruby
43
+ AddPetQuery.execute!(name: "Rex", species: AddPetQuery::Species::Dog)
44
+ ```
45
+
46
+ - required vs optional falls out of nullability and defaults: nullable or
47
+ defaulted variables become optional kwargs (nil is omitted from the wire,
48
+ so server-side defaults apply)
49
+ - enum variables generate module-level `T::Enum`s and serialize themselves
50
+ - custom scalars serialize through the [scalar registry](scalars.md)
51
+
52
+ **Input objects** generate module-level `T::Struct`s with `serialize`
53
+ (aliased as `to_h`) producing the wire hash — optional fields default nil and
54
+ stay off the wire:
55
+
56
+ ```ruby
57
+ input = AdoptQuery::AdoptionInput.new(name: "Rex", species: AdoptQuery::Species::Dog)
58
+ AdoptQuery.execute!(input:)
59
+
60
+ # or pass a plain hash straight to execute — .coerce normalizes and type-checks it at
61
+ # the boundary (underscored Symbol/String keys; enums accept wire values;
62
+ # nested inputs accept hashes)
63
+ AdoptQuery.execute!(input: { name: "Rex", species: "DOG" })
64
+ ```
65
+
66
+ Nested inputs work (dependencies emit first); recursive input types are not
67
+ yet supported.
68
+
69
+ ## Selections
70
+
71
+ - **Fragments** — inline fragments and named spreads flatten into the
72
+ selection; type conditions match exact names or interfaces/unions the type
73
+ belongs to.
74
+ - **Unions and interfaces** — each abstract selection site emits a module:
75
+ one member struct per possible type, `Type = T.type_alias { T.any(...) }`,
76
+ and a `from_h` dispatching on `__typename`. Generation *requires*
77
+ `__typename` in the selection so dispatch is possible.
78
+ - **`@skip` / `@include`** — a directive-conditional field may be absent from
79
+ the response regardless of schema nullability, so its generated type is
80
+ always nilable.
81
+ - **Aliases** — result keys follow aliases; props are the underscored alias.
82
+
83
+ ## Naming
84
+
85
+ Module names derive from the operation name (`query GetPerson` → `GetPerson`);
86
+ `GraphWeaver.parse` on a `.graphql` file derives from the file name
87
+ (`person.graphql` → `PersonQuery`). Pass `module_name:`/`name:` to override.
88
+ `Codegen.generate` requires a deliberate name for anonymous operations; dynamic
89
+ `parse` defaults to `Query` (its constants are container-scoped, so collisions
90
+ are impossible).
91
+
92
+ Nested struct names come from GraphQL type names, disambiguated one level by
93
+ field name on collision.
94
+
95
+ ## Executors
96
+
97
+ An executor is anything with `execute(query, variables:)` whose result `to_h`s
98
+ into `{"data" => ..., "errors" => ...}`. Resolution order:
99
+
100
+ 1. per call: `execute(..., executor: something)`
101
+ 2. per module: `PersonQuery.executor = something`
102
+ 3. baked constant: `Codegen.generate(..., executor: MyApi::Executor)`
103
+ 4. global: `GraphWeaver.executor` (raises helpfully when unconfigured)
104
+
105
+ Generate *without* a baked constant when you want modules to follow the
106
+ global — that's also what lets [testing's auto_fake](testing.md) swap in a
107
+ fake per example.
108
+
109
+ ## Dynamic mode
110
+
111
+ `GraphWeaver.parse` generates + evals in one step (no build artifact, evaled
112
+ into an anonymous container — no global constants leak). Same runtime
113
+ semantics; invisible to `srb tc`, so prefer the build step where static
114
+ checking matters. `GraphWeaver.execute(schema:, query:, variables: {})` is
115
+ the one-shot form.
116
+
117
+ Generated source is eval'd, so inputs are validated: module names must be
118
+ constant names, and query heredocs can't be terminated early. Still: queries
119
+ are code — don't feed untrusted strings to parse.
@@ -0,0 +1,46 @@
1
+ # Against a real API
2
+
3
+ Everything composes for a remote endpoint — introspect the schema
4
+ straight off the wire, then query with typed results. GitHub's API,
5
+ end to end:
6
+
7
+ ```ruby
8
+ require "graph_weaver"
9
+
10
+ executor = GraphWeaver::HttpExecutor.new(
11
+ "https://api.github.com/graphql",
12
+ headers: { "Authorization" => "Bearer #{`gh auth token`.strip}" },
13
+ )
14
+
15
+ # introspecting a big API takes seconds — cache: stores the introspection
16
+ # JSON in a file and reuses it for ttl: seconds. For Rails.cache/redis,
17
+ # cache SchemaLoader.introspection_result(executor) (a plain Hash) instead.
18
+ schema = GraphWeaver::SchemaLoader.introspect(
19
+ executor,
20
+ cache: "tmp/github-schema.json",
21
+ ttl: 24 * 60 * 60,
22
+ )
23
+
24
+ # map GitHub's DateTime scalar onto Time (cast inferred from Time.parse)
25
+ GraphWeaver.register_scalar("DateTime", type: Time, serialize: :iso8601, requires: "time")
26
+
27
+ RepoQuery = GraphWeaver.parse(schema:, executor:, query: <<~GRAPHQL)
28
+ query($owner: String!, $name: String!) {
29
+ repository(owner: $owner, name: $name) {
30
+ nameWithOwner
31
+ createdAt
32
+ stargazerCount
33
+ }
34
+ }
35
+ GRAPHQL
36
+
37
+ repo = RepoQuery.execute!(owner: "dpep", name: "graph_weaver").repository
38
+ repo&.name_with_owner # => "dpep/graph_weaver"
39
+ repo&.created_at # => 2026-07-07 ... (a real Time)
40
+ repo&.stargazer_count # => Integer
41
+ ```
42
+
43
+ The same flow runs as one-off integration specs against the live GitHub
44
+ and Countries APIs — `make integration` (network; GitHub auth via
45
+ `gh auth token` or `GITHUB_TOKEN`).
46
+
data/docs/scalars.md ADDED
@@ -0,0 +1,69 @@
1
+ # Custom scalars
2
+
3
+ Teach the generator how a GraphQL custom scalar deserializes into a rich
4
+ Ruby object (and serializes back when used as a variable). A field typed
5
+ `Money` then generates `const :price, T.nilable(Money)` and casts with
6
+ `Money.parse(...)` inline — no runtime reflection:
7
+
8
+ ```ruby
9
+ GraphWeaver.register_scalar("Money", type: Money, requires: "bigdecimal")
10
+ ```
11
+
12
+ Pass a real class as `type:` and the cast/serialize are **inferred** from it by
13
+ probing the deserialize side and pairing its serializer:
14
+
15
+ | the class defines | cast | serialize |
16
+ |-------------------|---------------|----------------|
17
+ | `.parse` | `Type.parse(v)` | `v.to_s` |
18
+ | `.load` | `Type.load(v)` | `Type.dump(v)` |
19
+
20
+ so the common case needs nothing more. Probing the *deserialize* side is
21
+ deliberate — every object has `#to_s`, so inferring off it would wrongly wrap
22
+ plain types like `String`/`Integer`; requiring a `.parse`/`.load` the type
23
+ actually defines avoids that (and is why the built-ins can be registered with
24
+ their real class constants). Override explicitly when you need to:
25
+
26
+ - a `Symbol` method name, nothing to misspell: `cast: :load` → `Money.load(expr)`,
27
+ `serialize: :to_json` → `expr.to_json`
28
+ - a `Proc` for anything a method name can't express: `cast: ->(expr) { "Money.new(#{expr})" }`
29
+ - `:itself` to force pass-through, opting out of inference (rare)
30
+
31
+ `type:` also accepts a plain string (`"BigDecimal"`) when you'd rather not
32
+ reference the class. `requires:` (a string or array) names files emitted as
33
+ `require`s atop the generated source so the cast/type resolve. When `type:` is
34
+ a real class (so the runtime is loaded), each path is also `require`d at
35
+ registration — a typo fails now, not in the generated file.
36
+
37
+ Pass `coerce: true` to let a variable of this scalar accept **either** the value
38
+ object **or** its raw input, normalizing the latter through the cast:
39
+
40
+ ```ruby
41
+ GraphWeaver.register_scalar("Money", type: Money, coerce: true)
42
+ # generated execute now takes T.any(Money, String); "12.00" is parsed
43
+ StoreQuery.execute(budget: "12.00") # Money.parse("12.00") under the hood
44
+ StoreQuery.execute(budget: Money.new(1200)) # passed straight through
45
+ ```
46
+
47
+ Bad input still explodes (the cast raises), so some safety survives; coercion
48
+ needs both a cast and a serialize. Off by default — the strict typed kwarg is the norm.
49
+
50
+ `coerce:` also takes a **Symbol** naming a conversion method, for built-ins where
51
+ a plain method is the whole story — `coerce: :to_f` makes a variable accept
52
+ `5`/`"5"` and `.to_f` it, sending a native number (not `"5.0"`) on the wire. The
53
+ convertible built-ins already know theirs (`Float`→`:to_f`, `Int`→`:to_i`,
54
+ `ID`/`String`→`:to_s`), so rather than re-registering each, flip them all on at
55
+ once:
56
+
57
+ ```ruby
58
+ GraphWeaver.reset_scalars!(coerce: true) # reload built-ins as coercible
59
+ GraphWeaver.register_scalar("Money", ...) # then add your own
60
+ ```
61
+
62
+ `Boolean` and `Date` have no lossless one-method conversion, so they stay strict.
63
+
64
+ The built-in scalars (`Date`, `ID`, `Int`, …) are pre-registered through the
65
+ same path (`Date` even carries its own `require "date"`), so a later
66
+ `register_scalar` overrides them; `GraphWeaver.reset_scalars!` restores the
67
+ defaults (`reset_scalars!(coerce: true)` restores them coercible) and
68
+ `clear_scalars!` empties the registry. Register before generating — it's a
69
+ codegen-time concern, baked into the emitted source.
data/docs/testing.md ADDED
@@ -0,0 +1,93 @@
1
+ # Testing
2
+
3
+ `require "graph_weaver/rspec"` from your spec helper (or
4
+ `graph_weaver/testing` outside rspec — never production code) for a
5
+ zero-setup fake backend. `FakeExecutor` fabricates
6
+ schema-correct responses for whatever query arrives: real enum values,
7
+ valid `__typename` members, iso8601 date scalars — every fake casts
8
+ cleanly through your generated structs.
9
+
10
+ ```ruby
11
+ fake = GraphWeaver::Testing::FakeExecutor.new(schema:)
12
+
13
+ person = PersonQuery.execute!(id: "1", executor: fake).person
14
+ person.name # => "Eliza Kertzmann" (faker-matched on field name, when faker is loaded)
15
+ person.birthday # => a real Date
16
+ ```
17
+
18
+ Pin what matters, keyed by GraphQL names (schema vocabulary — keys
19
+ survive query refactors); `"Type.field"` beats `"field"`:
20
+
21
+ ```ruby
22
+ GraphWeaver::Testing::FakeExecutor.new(schema:, overrides: {
23
+ "Person.name" => "Daniel",
24
+ "email" => -> { "test@example.com" },
25
+ })
26
+ ```
27
+
28
+ Or configure once, initializer-style (e.g. in `spec/support/graph_weaver.rb`):
29
+
30
+ ```ruby
31
+ require "graph_weaver/rspec" # rspec: seed follows --seed
32
+
33
+ GraphWeaver::Testing.configure do |config|
34
+ config.schema = MySchema
35
+ config.auto_fake = true # every example runs against a fresh FakeExecutor
36
+ config.mode = :faker # or :literal (plain typed values); nil = auto
37
+ config.overrides = { "Person.name" => "Daniel" }
38
+ config.list_size = 1..3
39
+ config.null_chance = 0.1 # nullable fields go nil sometimes
40
+ end
41
+ ```
42
+
43
+ With the rspec integration, `rspec --seed 1234` reproduces fake data
44
+ along with test order, and `auto_fake` installs a seeded executor per
45
+ example (generate modules *without* a baked `executor:` so they consult
46
+ `GraphWeaver.executor`). `mode:` picks value fabrication: `:faker`
47
+ (semantic, field-name matched — raises if the gem is missing),
48
+ `:literal` (plain type-derived), or nil to auto-detect faker.
49
+
50
+ **Simulating failures** — every failure mode is just an executor, so
51
+ error-handling paths are testable without a server that misbehaves on cue:
52
+
53
+ ```ruby
54
+ Failure = GraphWeaver::Testing::Failure
55
+
56
+ PersonQuery.execute(id: "1", executor: Failure.transport) # TransportError (cause preserved)
57
+ PersonQuery.execute(id: "1", executor: Failure.server(status: 502)) # ServerError
58
+ PersonQuery.execute(id: "1", executor: Failure.throttled) # QueryError, code THROTTLED
59
+ PersonQuery.execute(id: "1", executor: Failure.stale_schema) # schema_stale? => true
60
+ PersonQuery.execute(id: "1", executor: Failure.graphql("boom", data: {...})) # partial failure
61
+
62
+ # retries: fail twice, then succeed
63
+ GraphWeaver::Testing::SequenceExecutor.new(Failure.transport, Failure.transport, fake)
64
+
65
+ # type mismatch: corrupt: derives a wrong-typed wire value for the field —
66
+ # casting raises GraphWeaver::TypeError (overrides remain the manual escape hatch)
67
+ GraphWeaver::Testing::FakeExecutor.new(schema:, corrupt: "Person.birthday")
68
+
69
+ # stale schema naming a real (sampled) field
70
+ Failure.stale_schema(schema: MySchema)
71
+
72
+ # field-level partial failure with real GraphQL null propagation: the error
73
+ # lands with its concrete path and nulls bubble to the nearest nullable spot
74
+ GraphWeaver::Testing::FakeExecutor.new(schema:, fail_at: { path: "person.email", code: "PRIVATE" })
75
+ ```
76
+
77
+ **Capture and replay** — cassettes live above the transport (no HTTP
78
+ interception), so they work identically for HTTP, Faraday, or in-process
79
+ executors:
80
+
81
+ ```ruby
82
+ # records against the live executor when the file is missing, replays after
83
+ executor = GraphWeaver::Testing::Cassette.use("github", executor: live)
84
+ ```
85
+
86
+ Cassettes hold real responses. Anonymize before committing — shape,
87
+ nulls, list lengths, enums, and id relationships survive; values are
88
+ replaced with (semantically matched) fakes:
89
+
90
+ ```ruby
91
+ GraphWeaver::Testing::Cassette.new("spec/cassettes/github.yml").anonymize!(schema:)
92
+ ```
93
+
data/graph_weaver.gemspec CHANGED
@@ -15,7 +15,10 @@ Gem::Specification.new do |s|
15
15
  s.add_dependency "graphql", ">= 2"
16
16
  s.add_dependency "sorbet-runtime"
17
17
 
18
+ s.add_development_dependency "bigdecimal"
18
19
  s.add_development_dependency "debug"
20
+ s.add_development_dependency "faker"
21
+ s.add_development_dependency "faraday"
19
22
  s.add_development_dependency "rspec"
20
23
  s.add_development_dependency "simplecov"
21
24
  s.add_development_dependency "sorbet"
@@ -0,0 +1,237 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ # Source emission: turns the node tree into the generated module text.
5
+ # Mixed into Codegen — methods run with the generator instance state.
6
+ class GraphWeaver::Codegen
7
+ module Emit
8
+ include GraphWeaver::Inflect
9
+
10
+ private
11
+
12
+ def emit_nested(node, out, indent)
13
+ case node
14
+ when UnionNode then emit_union(node, out, indent)
15
+ when EnumNode then emit_enum(node, out, indent)
16
+ else emit_object(node, out, indent)
17
+ end
18
+ end
19
+
20
+ def emit_enum(node, out, indent)
21
+ pad = " " * indent
22
+
23
+ out << "#{pad}class #{node.class_name} < T::Enum"
24
+ out << "#{pad} enums do"
25
+ node.values.each do |value|
26
+ out << "#{pad} #{camelize(value.downcase)} = new(#{value.inspect})"
27
+ end
28
+ out << "#{pad} end"
29
+ out << "#{pad}end"
30
+ end
31
+
32
+ def emit_object(node, out, indent)
33
+ pad = " " * indent
34
+
35
+ out << "#{pad}class #{node.class_name} < T::Struct"
36
+ out << "#{pad} extend T::Sig"
37
+ out << ""
38
+
39
+ node.fields.filter_map { |field| field.node.nested }.each do |child|
40
+ emit_nested(child, out, indent + 1)
41
+ out << ""
42
+ end
43
+
44
+ node.fields.each do |field|
45
+ out << "#{pad} const :#{field.prop}, #{field.node.prop_type}"
46
+ end
47
+
48
+ out << ""
49
+ out << "#{pad} sig { params(data: T::Hash[String, T.untyped]).returns(#{node.class_name}) }"
50
+ out << "#{pad} def self.from_h(data)"
51
+ out << "#{pad} new("
52
+ node.fields.each do |field|
53
+ out << "#{pad} #{field.prop}: #{field_cast(field)},"
54
+ end
55
+ out << "#{pad} )"
56
+ out << "#{pad} rescue GraphWeaver::TypeError"
57
+ out << "#{pad} raise # already wrapped by a nested struct — keep the innermost context"
58
+ out << "#{pad} rescue TypeError, ArgumentError, KeyError => e"
59
+ out << "#{pad} raise GraphWeaver::TypeError.new(struct: self, error: e)"
60
+ out << "#{pad} end"
61
+ out << "#{pad}end"
62
+ end
63
+
64
+ def emit_union(node, out, indent)
65
+ pad = " " * indent
66
+
67
+ out << "#{pad}module #{node.class_name}"
68
+ out << "#{pad} extend T::Sig"
69
+ out << ""
70
+
71
+ node.members.each_value do |member|
72
+ emit_object(member, out, indent + 1)
73
+ out << ""
74
+ end
75
+
76
+ member_names = node.members.values.map(&:class_name)
77
+ type_alias = member_names.size == 1 ? member_names.first : "T.any(#{member_names.join(", ")})"
78
+ out << "#{pad} Type = T.type_alias { #{type_alias} }"
79
+ out << ""
80
+ out << "#{pad} sig { params(data: T::Hash[String, T.untyped]).returns(Type) }"
81
+ out << "#{pad} def self.from_h(data)"
82
+ out << "#{pad} case (typename = data.fetch(\"__typename\"))"
83
+ node.members.each do |graphql_name, member|
84
+ out << "#{pad} when #{graphql_name.inspect} then #{member.class_name}.from_h(data)"
85
+ end
86
+ out << "#{pad} else raise GraphWeaver::TypeError.new(struct: self, message: \"unexpected __typename: \#{typename}\")"
87
+ out << "#{pad} end"
88
+ out << "#{pad} end"
89
+ out << "#{pad}end"
90
+ end
91
+
92
+ def emit_execute(out, variables)
93
+ out << " @executor = T.let(nil, T.untyped)"
94
+ out << ""
95
+ out << " class << self"
96
+ out << " extend T::Sig"
97
+ out << ""
98
+ out << " sig { params(executor: T.untyped).void }"
99
+ out << " attr_writer :executor"
100
+ out << ""
101
+ out << " # default transport for execute"
102
+ out << " sig { returns(T.untyped) }"
103
+ out << " def executor"
104
+ out << " @executor || #{@executor_const || "GraphWeaver.executor"}"
105
+ out << " end"
106
+ out << " end"
107
+ out << ""
108
+
109
+ sig_params = variables.map do |var|
110
+ bare = var.node.coerce? ? var.node.coerce_input_type : var.node.bare_type
111
+ kwarg_type = var.required ? bare : "T.nilable(#{bare})"
112
+ "#{var.kwarg}: #{kwarg_type}"
113
+ end
114
+ sig_params << "executor: T.untyped"
115
+
116
+ kwargs = variables.map { |var| var.required ? "#{var.kwarg}:" : "#{var.kwarg}: nil" }
117
+ kwargs << "executor: self.executor"
118
+
119
+ # execute returns the full envelope; execute! is the strict shortcut for
120
+ # `execute(...).data!` — the typed result, or a raised QueryError.
121
+ forward = (variables.map { |var| "#{var.kwarg}: #{var.kwarg}" } + ["executor: executor"]).join(", ")
122
+
123
+ out << " sig { params(#{sig_params.join(", ")}).returns(GraphWeaver::Response[Result]) }"
124
+ out << " def self.execute(#{kwargs.join(", ")})"
125
+
126
+ required, optional = variables.partition(&:required)
127
+ if required.empty?
128
+ out << " variables = {}"
129
+ else
130
+ out << " variables = {"
131
+ required.each do |var|
132
+ out << " #{var.wire.inspect} => #{variable_serialize(var)},"
133
+ end
134
+ out << " }"
135
+ end
136
+ optional.each do |var|
137
+ out << " variables[#{var.wire.inspect}] = #{variable_serialize(var)} unless #{var.kwarg}.nil?"
138
+ end
139
+
140
+ out << ""
141
+ out << " raw = executor.execute(QUERY, variables: variables).to_h"
142
+ out << " GraphWeaver::Response[Result].new("
143
+ out << " data: (Result.from_h(raw[\"data\"]) if raw[\"data\"]),"
144
+ out << " errors: (raw[\"errors\"] || []).map { |e| GraphWeaver::GraphQLError.from_h(e) },"
145
+ out << " extensions: raw[\"extensions\"] || {},"
146
+ out << " )"
147
+ out << " end"
148
+ out << ""
149
+ out << " sig { params(#{sig_params.join(", ")}).returns(Result) }"
150
+ out << " def self.execute!(#{kwargs.join(", ")})"
151
+ out << " execute(#{forward}).data!"
152
+ out << " end"
153
+ end
154
+
155
+ def variable_serialize(var)
156
+ value = var.node.coerce? ? var.node.coerce(var.kwarg) : var.kwarg
157
+ var.node.serialize_identity? ? value : var.node.serialize(value, 1)
158
+ end
159
+
160
+ # Structured shape for a schema-validation error: message plus its first
161
+ # source location, so ValidationError#errors is inspectable.
162
+ def validation_detail(error)
163
+ loc = (error.to_h["locations"]&.first if error.respond_to?(:to_h))
164
+ { message: error.message, line: loc && loc["line"], column: loc && loc["column"] }
165
+ end
166
+
167
+ def field_cast(field)
168
+ node = field.node
169
+
170
+ if node.non_null?
171
+ raw = "data.fetch(#{field.key.inspect})"
172
+ node.identity? ? raw : node.cast(raw, 1)
173
+ else
174
+ raw = "data[#{field.key.inspect}]"
175
+ node.identity? ? raw : "#{raw}&.then { |v1| #{node.cast("v1", 2)} }"
176
+ end
177
+ end
178
+
179
+ # a module-level T::Struct per input type; serialize builds the wire
180
+ # hash, omitting optional fields left nil
181
+ def emit_input(node, out, indent)
182
+ pad = " " * indent
183
+
184
+ out << "#{pad}class #{node.class_name} < T::Struct"
185
+ out << "#{pad} extend T::Sig"
186
+ out << ""
187
+ node.fields.each do |field|
188
+ default = field.required ? "" : ", default: nil"
189
+ out << "#{pad} const :#{field.prop}, #{field.node.prop_type}#{default}"
190
+ end
191
+ out << ""
192
+ out << "#{pad} sig { returns(T::Hash[String, T.untyped]) }"
193
+ out << "#{pad} def serialize"
194
+ out << "#{pad} result = T.let({}, T::Hash[String, T.untyped])"
195
+ node.fields.each do |field|
196
+ value = field.node.serialize_identity? ? field.prop.to_s : field.node.serialize(field.prop.to_s, 1)
197
+ line = "result[#{field.wire.inspect}] = #{value}"
198
+ line += " unless #{field.prop}.nil?" unless field.required
199
+ out << "#{pad} #{line}"
200
+ end
201
+ out << "#{pad} result"
202
+ out << "#{pad} end"
203
+ out << ""
204
+ out << "#{pad} # serialize, under the conventional name"
205
+ out << "#{pad} sig { returns(T::Hash[String, T.untyped]) }"
206
+ out << "#{pad} def to_h = serialize"
207
+ out << ""
208
+ out << "#{pad} # Build from a plain hash (underscored keys, Symbol or String):"
209
+ out << "#{pad} # enums accept their wire values, nested inputs accept hashes;"
210
+ out << "#{pad} # the struct's types are enforced on construction."
211
+ out << "#{pad} sig { params(value: T.any(#{node.class_name}, T::Hash[T.untyped, T.untyped])).returns(#{node.class_name}) }"
212
+ out << "#{pad} def self.coerce(value)"
213
+ out << "#{pad} return value if value.is_a?(#{node.class_name})"
214
+ out << ""
215
+ out << "#{pad} new("
216
+ node.fields.each do |field|
217
+ raw = "value_at(value, :#{field.prop})"
218
+ expr = if field.node.hash_coerce_identity?
219
+ raw
220
+ elsif field.required
221
+ "#{raw}.then { |v1| #{field.node.hash_coerce("v1", 2)} }"
222
+ else
223
+ "#{raw}&.then { |v1| #{field.node.hash_coerce("v1", 2)} }"
224
+ end
225
+ out << "#{pad} #{field.prop}: #{expr},"
226
+ end
227
+ out << "#{pad} )"
228
+ out << "#{pad} end"
229
+ out << ""
230
+ out << "#{pad} sig { params(hash: T::Hash[T.untyped, T.untyped], key: Symbol).returns(T.untyped) }"
231
+ out << "#{pad} private_class_method def self.value_at(hash, key)"
232
+ out << "#{pad} hash.key?(key) ? hash[key] : hash[key.to_s]"
233
+ out << "#{pad} end"
234
+ out << "#{pad}end"
235
+ end
236
+ end
237
+ end