tina4ruby 3.11.13 → 3.11.15

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +935 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -110
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -106
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2025 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +696 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/public/css/tina4.css +2463 -2463
  63. data/lib/tina4/public/css/tina4.min.css +1 -1
  64. data/lib/tina4/public/images/logo.svg +5 -5
  65. data/lib/tina4/public/js/frond.min.js +2 -2
  66. data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
  67. data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
  68. data/lib/tina4/public/js/tina4.min.js +92 -92
  69. data/lib/tina4/public/js/tina4js.min.js +48 -48
  70. data/lib/tina4/public/swagger/index.html +90 -90
  71. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  72. data/lib/tina4/query_builder.rb +380 -380
  73. data/lib/tina4/queue.rb +366 -366
  74. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  75. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  76. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  77. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  78. data/lib/tina4/rack_app.rb +817 -817
  79. data/lib/tina4/rate_limiter.rb +130 -130
  80. data/lib/tina4/request.rb +268 -255
  81. data/lib/tina4/response.rb +346 -346
  82. data/lib/tina4/response_cache.rb +551 -551
  83. data/lib/tina4/router.rb +406 -406
  84. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  85. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  86. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  87. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  88. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  89. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  90. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  91. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  92. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  93. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  94. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  95. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  96. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  97. data/lib/tina4/scss/tina4css/base.scss +1 -1
  98. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  99. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  100. data/lib/tina4/scss_compiler.rb +178 -178
  101. data/lib/tina4/seeder.rb +567 -567
  102. data/lib/tina4/service_runner.rb +303 -303
  103. data/lib/tina4/session.rb +297 -297
  104. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  105. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  106. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  107. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  108. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  109. data/lib/tina4/shutdown.rb +84 -84
  110. data/lib/tina4/sql_translation.rb +158 -158
  111. data/lib/tina4/swagger.rb +124 -124
  112. data/lib/tina4/template.rb +894 -894
  113. data/lib/tina4/templates/base.twig +26 -26
  114. data/lib/tina4/templates/errors/302.twig +14 -14
  115. data/lib/tina4/templates/errors/401.twig +9 -9
  116. data/lib/tina4/templates/errors/403.twig +29 -29
  117. data/lib/tina4/templates/errors/404.twig +29 -29
  118. data/lib/tina4/templates/errors/500.twig +38 -38
  119. data/lib/tina4/templates/errors/502.twig +9 -9
  120. data/lib/tina4/templates/errors/503.twig +12 -12
  121. data/lib/tina4/templates/errors/base.twig +37 -37
  122. data/lib/tina4/test_client.rb +159 -159
  123. data/lib/tina4/testing.rb +340 -340
  124. data/lib/tina4/validator.rb +174 -174
  125. data/lib/tina4/version.rb +1 -1
  126. data/lib/tina4/webserver.rb +312 -312
  127. data/lib/tina4/websocket.rb +343 -343
  128. data/lib/tina4/websocket_backplane.rb +190 -190
  129. data/lib/tina4/wsdl.rb +564 -564
  130. data/lib/tina4.rb +458 -458
  131. data/lib/tina4ruby.rb +4 -4
  132. metadata +3 -3
data/lib/tina4/graphql.rb CHANGED
@@ -1,966 +1,966 @@
1
- # frozen_string_literal: false
2
-
3
- require "json"
4
-
5
- module Tina4
6
- # Lightweight, zero-dependency GraphQL implementation for Tina4 Ruby.
7
- # Mirrors the tina4php-graphql approach: custom recursive-descent parser,
8
- # depth-first executor, programmatic schema, and ORM auto-schema generation.
9
-
10
- # ─── Type System ──────────────────────────────────────────────────────
11
-
12
- class GraphQLType
13
- SCALARS = %w[String Int Float Boolean ID].freeze
14
-
15
- attr_reader :name, :kind, :fields, :description
16
-
17
- # kind: :scalar, :object, :list, :non_null, :input_object, :enum
18
- def initialize(name, kind = :object, fields: {}, of_type: nil, description: nil)
19
- @name = name
20
- @kind = kind.to_sym
21
- @fields = fields # { field_name => { type:, args:, resolve:, description: } }
22
- @of_type = of_type # wrapped type for list / non_null
23
- @description = description
24
- end
25
-
26
- def scalar?
27
- @kind == :scalar || SCALARS.include?(@name)
28
- end
29
-
30
- def list?
31
- @kind == :list
32
- end
33
-
34
- def non_null?
35
- @kind == :non_null
36
- end
37
-
38
- def of_type
39
- @of_type
40
- end
41
-
42
- # Parse a type string like "String", "String!", "[String]", "[Int!]!"
43
- def self.parse(type_str)
44
- type_str = type_str.to_s.strip
45
- if type_str.end_with?("!")
46
- inner = parse(type_str[0..-2])
47
- new(type_str, :non_null, of_type: inner)
48
- elsif type_str.start_with?("[") && type_str.end_with?("]")
49
- inner = parse(type_str[1..-2])
50
- new(type_str, :list, of_type: inner)
51
- elsif SCALARS.include?(type_str)
52
- new(type_str, :scalar)
53
- else
54
- new(type_str, :object)
55
- end
56
- end
57
- end
58
-
59
- # ─── Schema ───────────────────────────────────────────────────────────
60
-
61
- class GraphQLSchema
62
- attr_reader :types, :queries, :mutations
63
-
64
- def initialize
65
- @types = {}
66
- @queries = {} # name => { type:, args:, resolve:, description: }
67
- @mutations = {}
68
- register_scalars
69
- end
70
-
71
- # add_type(name, fields) — parity with PHP/Python/Node
72
- # add_type(type_object) — legacy Ruby form (type_object responds to .name)
73
- def add_type(name_or_type, fields = nil)
74
- if fields
75
- # New form: add_type("User", { "id" => "ID", "name" => "String" })
76
- @types[name_or_type] = fields
77
- else
78
- # Legacy form: add_type(GraphQLType.new(...))
79
- @types[name_or_type.name] = name_or_type
80
- end
81
- end
82
-
83
- def get_type(name)
84
- @types[name]
85
- end
86
-
87
- # Register a query field.
88
- # Cross-framework form: add_query(name, args, return_type, resolver)
89
- # Block form also accepted: add_query(name, args, return_type) { |root, args, ctx| ... }
90
- def add_query(name, args = {}, return_type = nil, resolver = nil, &block)
91
- resolve = resolver || block
92
- @queries[name] = { type: return_type, args: args, resolve: resolve }
93
- end
94
-
95
- # Register a mutation field.
96
- # Cross-framework form: add_mutation(name, args, return_type, resolver)
97
- # Block form also accepted: add_mutation(name, args, return_type) { |root, args, ctx| ... }
98
- def add_mutation(name, args = {}, return_type = nil, resolver = nil, &block)
99
- resolve = resolver || block
100
- @mutations[name] = { type: return_type, args: args, resolve: resolve }
101
- end
102
-
103
- # ── ORM Auto-Schema ──────────────────────────────────────────────────
104
- # Generates GraphQL types + CRUD queries/mutations from a Tina4::ORM subclass.
105
- #
106
- # schema.from_orm(User)
107
- #
108
- # Creates:
109
- # Query: user(id), users(limit, offset)
110
- # Mutation: createUser(input), updateUser(id, input), deleteUser(id)
111
- def from_orm(klass)
112
- model_name = klass.name.split("::").last
113
- type_name = model_name
114
- table_lower = model_name.gsub(/([A-Z])/, '_\1').sub(/\A_/, "").downcase
115
- plural = "#{table_lower}s"
116
-
117
- # Build GraphQL object type from ORM field definitions
118
- gql_fields = {}
119
- pk_field = nil
120
-
121
- if klass.respond_to?(:field_definitions)
122
- klass.field_definitions.each do |fname, fdef|
123
- gql_type = ruby_field_to_gql(fdef[:type] || :string)
124
- gql_fields[fname.to_s] = { type: gql_type }
125
- pk_field = fname.to_s if fdef[:primary_key]
126
- end
127
- end
128
-
129
- pk_field ||= "id"
130
- gql_fields[pk_field] ||= { type: "ID" }
131
-
132
- obj_type = GraphQLType.new(type_name, :object, fields: gql_fields)
133
- add_type(obj_type)
134
-
135
- # Input type for create/update
136
- input_fields = gql_fields.reject { |k, _| k == pk_field }
137
- input_type = GraphQLType.new("#{type_name}Input", :input_object, fields: input_fields)
138
- add_type(input_type)
139
-
140
- # ── Queries ──
141
-
142
- # Single record: user(id: ID!): User
143
- add_query(table_lower, { pk_field => { type: "ID!" } }, type_name) do |_root, args, _ctx|
144
- record = klass.find_by_id(args[pk_field])
145
- record&.to_hash
146
- end
147
-
148
- # List: users(limit: Int, offset: Int): [User]
149
- add_query(plural, { "limit" => { type: "Int" }, "offset" => { type: "Int" } }, "[#{type_name}]") do |_root, args, _ctx|
150
- limit = args["limit"] || 100
151
- offset = args["offset"] || 0
152
- result = klass.all(limit: limit, offset: offset)
153
- result.respond_to?(:to_array) ? result.to_array : Array(result).map { |r| r.respond_to?(:to_hash) ? r.to_hash : r }
154
- end
155
-
156
- # ── Mutations ──
157
-
158
- # Create
159
- add_mutation("create#{model_name}", { "input" => { type: "#{type_name}Input!" } }, type_name) do |_root, args, _ctx|
160
- record = klass.create(args["input"] || {})
161
- record.respond_to?(:to_hash) ? record.to_hash : record
162
- end
163
-
164
- # Update
165
- add_mutation("update#{model_name}", { pk_field => { type: "ID!" }, "input" => { type: "#{type_name}Input!" } }, type_name) do |_root, args, _ctx|
166
- record = klass.find_by_id(args[pk_field])
167
- return nil unless record
168
- (args["input"] || {}).each { |k, v| record.send(:"#{k}=", v) if record.respond_to?(:"#{k}=") }
169
- record.save
170
- record.to_hash
171
- end
172
-
173
- # Delete
174
- add_mutation("delete#{model_name}", { pk_field => { type: "ID!" } }, "Boolean") do |_root, args, _ctx|
175
- record = klass.find_by_id(args[pk_field])
176
- return false unless record
177
- record.delete
178
- true
179
- end
180
- end
181
-
182
- private
183
-
184
- def register_scalars
185
- GraphQLType::SCALARS.each do |s|
186
- @types[s] = GraphQLType.new(s, :scalar)
187
- end
188
- end
189
-
190
- def ruby_field_to_gql(field_type)
191
- case field_type.to_s.downcase
192
- when "integer", "int" then "Int"
193
- when "float", "double", "decimal", "numeric" then "Float"
194
- when "boolean", "bool" then "Boolean"
195
- when "string", "text", "varchar" then "String"
196
- when "datetime", "date", "timestamp" then "String"
197
- when "blob", "binary" then "String"
198
- when "json", "jsonb" then "String"
199
- else "String"
200
- end
201
- end
202
- end
203
-
204
- # ─── Parser (recursive descent) ──────────────────────────────────────
205
-
206
- class GraphQLParser
207
- Token = Struct.new(:type, :value, :pos)
208
-
209
- KEYWORDS = %w[query mutation fragment on true false null].freeze
210
-
211
- def initialize(source)
212
- @source = source
213
- @tokens = tokenize(source)
214
- @pos = 0
215
- end
216
-
217
- def parse
218
- document = { kind: :document, definitions: [] }
219
- while current
220
- skip(:comma)
221
- break unless current
222
- document[:definitions] << parse_definition
223
- end
224
- document
225
- end
226
-
227
- private
228
-
229
- # ── Tokenizer ──
230
-
231
- def tokenize(src)
232
- tokens = []
233
- i = 0
234
- while i < src.length
235
- ch = src[i]
236
-
237
- # Skip whitespace
238
- if ch =~ /\s/
239
- i += 1
240
- next
241
- end
242
-
243
- # Skip comments
244
- if ch == "#"
245
- i += 1 while i < src.length && src[i] != "\n"
246
- next
247
- end
248
-
249
- # Punctuation
250
- if "{}()[]!:=@$,".include?(ch)
251
- tokens << Token.new(:punct, ch, i)
252
- i += 1
253
- next
254
- end
255
-
256
- # Spread operator
257
- if ch == "." && src[i + 1] == "." && src[i + 2] == "."
258
- tokens << Token.new(:spread, "...", i)
259
- i += 3
260
- next
261
- end
262
-
263
- # String
264
- if ch == '"'
265
- str, i = read_string(src, i)
266
- tokens << Token.new(:string, str, i)
267
- next
268
- end
269
-
270
- # Number
271
- if ch =~ /[\d\-]/
272
- num, i = read_number(src, i)
273
- tokens << Token.new(:number, num, i)
274
- next
275
- end
276
-
277
- # Name / keyword
278
- if ch =~ /[a-zA-Z_]/
279
- name = ""
280
- while i < src.length && src[i] =~ /[a-zA-Z0-9_]/
281
- name << src[i]
282
- i += 1
283
- end
284
- type = KEYWORDS.include?(name) ? :keyword : :name
285
- tokens << Token.new(type, name, i - name.length)
286
- next
287
- end
288
-
289
- i += 1 # skip unknown
290
- end
291
- tokens
292
- end
293
-
294
- def read_string(src, i)
295
- i += 1 # skip opening quote
296
- str = ""
297
- while i < src.length && src[i] != '"'
298
- if src[i] == "\\"
299
- i += 1
300
- case src[i]
301
- when "n" then str << "\n"
302
- when "t" then str << "\t"
303
- when '"' then str << '"'
304
- when "\\" then str << "\\"
305
- else str << src[i].to_s
306
- end
307
- else
308
- str << src[i]
309
- end
310
- i += 1
311
- end
312
- i += 1 # skip closing quote
313
- [str, i]
314
- end
315
-
316
- def read_number(src, i)
317
- start = i
318
- i += 1 if src[i] == "-"
319
- i += 1 while i < src.length && src[i] =~ /[\d.eE+\-]/
320
- [src[start...i], i]
321
- end
322
-
323
- # ── Token helpers ──
324
-
325
- def current
326
- @tokens[@pos]
327
- end
328
-
329
- def peek(offset = 0)
330
- @tokens[@pos + offset]
331
- end
332
-
333
- def advance
334
- tok = @tokens[@pos]
335
- @pos += 1
336
- tok
337
- end
338
-
339
- def expect(type, value = nil)
340
- tok = current
341
- if tok.nil?
342
- raise GraphQLError, "Unexpected end of query, expected #{type} #{value}"
343
- end
344
- if tok.type != type || (value && tok.value != value)
345
- raise GraphQLError, "Expected #{type} '#{value}' at position #{tok.pos}, got #{tok.type} '#{tok.value}'"
346
- end
347
- advance
348
- end
349
-
350
- def match(type, value = nil)
351
- tok = current
352
- return nil unless tok
353
- return nil unless tok.type == type
354
- return nil if value && tok.value != value
355
- advance
356
- end
357
-
358
- def skip(type, value = nil)
359
- match(type, value) while current && current.type == type && (value.nil? || current.value == value)
360
- end
361
-
362
- # ── Parse rules ──
363
-
364
- def parse_definition
365
- tok = current
366
- if tok.nil?
367
- raise GraphQLError, "Unexpected end of input"
368
- end
369
-
370
- if tok.type == :keyword && tok.value == "fragment"
371
- return parse_fragment
372
- end
373
-
374
- if tok.type == :keyword && (tok.value == "query" || tok.value == "mutation")
375
- return parse_operation
376
- end
377
-
378
- # Shorthand query (just a selection set)
379
- if tok.type == :punct && tok.value == "{"
380
- return { kind: :operation, operation: :query, name: nil, variables: [], selection_set: parse_selection_set }
381
- end
382
-
383
- raise GraphQLError, "Unexpected token '#{tok.value}' at position #{tok.pos}"
384
- end
385
-
386
- def parse_operation
387
- op = advance.value.to_sym # :query or :mutation
388
- name = match(:name)&.value
389
-
390
- variables = []
391
- if current&.value == "("
392
- variables = parse_variable_definitions
393
- end
394
-
395
- selection_set = parse_selection_set
396
-
397
- { kind: :operation, operation: op, name: name, variables: variables, selection_set: selection_set }
398
- end
399
-
400
- def parse_variable_definitions
401
- expect(:punct, "(")
402
- vars = []
403
- until current&.value == ")"
404
- skip(:comma)
405
- break if current&.value == ")"
406
- expect(:punct, "$")
407
- vname = expect(:name).value
408
- expect(:punct, ":")
409
- vtype = parse_type_ref
410
- default = nil
411
- if match(:punct, "=")
412
- default = parse_value
413
- end
414
- vars << { name: vname, type: vtype, default: default }
415
- end
416
- expect(:punct, ")")
417
- vars
418
- end
419
-
420
- def parse_type_ref
421
- if match(:punct, "[")
422
- inner = parse_type_ref
423
- expect(:punct, "]")
424
- type_str = "[#{inner}]"
425
- else
426
- type_str = expect(:name).value
427
- end
428
- type_str += "!" if match(:punct, "!")
429
- type_str
430
- end
431
-
432
- def parse_selection_set
433
- expect(:punct, "{")
434
- selections = []
435
- until current&.value == "}"
436
- skip(:comma)
437
- break if current&.value == "}"
438
-
439
- if current&.type == :spread
440
- selections << parse_fragment_spread
441
- else
442
- selections << parse_field
443
- end
444
- end
445
- expect(:punct, "}")
446
- selections
447
- end
448
-
449
- def parse_field
450
- name_tok = expect(:name)
451
- field_name = name_tok.value
452
- alias_name = nil
453
-
454
- # Check for alias: alias: fieldName
455
- if current&.value == ":"
456
- advance
457
- alias_name = field_name
458
- field_name = expect(:name).value
459
- end
460
-
461
- arguments = {}
462
- if current&.value == "("
463
- arguments = parse_arguments
464
- end
465
-
466
- selection_set = nil
467
- if current&.value == "{"
468
- selection_set = parse_selection_set
469
- end
470
-
471
- { kind: :field, name: field_name, alias: alias_name, arguments: arguments, selection_set: selection_set }
472
- end
473
-
474
- def parse_arguments
475
- expect(:punct, "(")
476
- args = {}
477
- until current&.value == ")"
478
- skip(:comma)
479
- break if current&.value == ")"
480
- arg_name = expect(:name).value
481
- expect(:punct, ":")
482
- args[arg_name] = parse_value
483
- end
484
- expect(:punct, ")")
485
- args
486
- end
487
-
488
- def parse_value
489
- tok = current
490
- case tok.type
491
- when :string
492
- advance
493
- tok.value
494
- when :number
495
- advance
496
- tok.value.include?(".") ? tok.value.to_f : tok.value.to_i
497
- when :keyword
498
- advance
499
- case tok.value
500
- when "true" then true
501
- when "false" then false
502
- when "null" then nil
503
- else tok.value
504
- end
505
- when :name
506
- # Enum value
507
- advance
508
- tok.value
509
- when :punct
510
- if tok.value == "["
511
- parse_list_value
512
- elsif tok.value == "{"
513
- parse_object_value
514
- elsif tok.value == "$"
515
- advance
516
- { kind: :variable, name: expect(:name).value }
517
- else
518
- raise GraphQLError, "Unexpected '#{tok.value}' in value at position #{tok.pos}"
519
- end
520
- else
521
- raise GraphQLError, "Unexpected token type #{tok.type} at position #{tok.pos}"
522
- end
523
- end
524
-
525
- def parse_list_value
526
- expect(:punct, "[")
527
- items = []
528
- until current&.value == "]"
529
- skip(:comma)
530
- break if current&.value == "]"
531
- items << parse_value
532
- end
533
- expect(:punct, "]")
534
- items
535
- end
536
-
537
- def parse_object_value
538
- expect(:punct, "{")
539
- obj = {}
540
- until current&.value == "}"
541
- skip(:comma)
542
- break if current&.value == "}"
543
- key = expect(:name).value
544
- expect(:punct, ":")
545
- obj[key] = parse_value
546
- end
547
- expect(:punct, "}")
548
- obj
549
- end
550
-
551
- def parse_fragment_spread
552
- expect(:spread)
553
- if current&.type == :keyword && current&.value == "on"
554
- # Inline fragment
555
- advance
556
- type_name = expect(:name).value
557
- selection_set = parse_selection_set
558
- { kind: :inline_fragment, on: type_name, selection_set: selection_set }
559
- else
560
- name = expect(:name).value
561
- { kind: :fragment_spread, name: name }
562
- end
563
- end
564
-
565
- def parse_fragment
566
- expect(:keyword, "fragment")
567
- name = expect(:name).value
568
- expect(:keyword, "on")
569
- type_name = expect(:name).value
570
- selection_set = parse_selection_set
571
- { kind: :fragment, name: name, on: type_name, selection_set: selection_set }
572
- end
573
- end
574
-
575
- # ─── Executor ─────────────────────────────────────────────────────────
576
-
577
- class GraphQLExecutor
578
- def initialize(schema)
579
- @schema = schema
580
- end
581
-
582
- def execute(document, variables: {}, context: {}, operation_name: nil)
583
- # Collect fragments
584
- fragments = {}
585
- operations = []
586
-
587
- document[:definitions].each do |defn|
588
- case defn[:kind]
589
- when :fragment
590
- fragments[defn[:name]] = defn
591
- when :operation
592
- operations << defn
593
- end
594
- end
595
-
596
- # Pick the operation
597
- operation = if operation_name
598
- operations.find { |op| op[:name] == operation_name }
599
- elsif operations.length == 1
600
- operations.first
601
- else
602
- raise GraphQLError, "Must provide operation name when multiple operations exist"
603
- end
604
-
605
- raise GraphQLError, "Unknown operation: #{operation_name}" unless operation
606
-
607
- # Resolve variables
608
- resolved_vars = resolve_variables(operation[:variables], variables)
609
-
610
- # Choose root fields
611
- root_fields = case operation[:operation]
612
- when :query then @schema.queries
613
- when :mutation then @schema.mutations
614
- else raise GraphQLError, "Unsupported operation: #{operation[:operation]}"
615
- end
616
-
617
- # Execute selection set
618
- data = {}
619
- errors = []
620
-
621
- operation[:selection_set].each do |selection|
622
- resolve_selection(selection, root_fields, nil, resolved_vars, context, fragments, data, errors)
623
- end
624
-
625
- result = { "data" => data }
626
- result["errors"] = errors unless errors.empty?
627
- result
628
- end
629
-
630
- private
631
-
632
- def resolve_selection(selection, fields, parent, variables, context, fragments, data, errors)
633
- case selection[:kind]
634
- when :field
635
- resolve_field(selection, fields, parent, variables, context, fragments, data, errors)
636
- when :fragment_spread
637
- frag = fragments[selection[:name]]
638
- if frag
639
- frag[:selection_set].each do |sel|
640
- resolve_selection(sel, fields, parent, variables, context, fragments, data, errors)
641
- end
642
- end
643
- when :inline_fragment
644
- selection[:selection_set].each do |sel|
645
- resolve_selection(sel, fields, parent, variables, context, fragments, data, errors)
646
- end
647
- end
648
- end
649
-
650
- def resolve_field(selection, fields, parent, variables, context, fragments, data, errors)
651
- field_name = selection[:name]
652
- output_name = selection[:alias] || field_name
653
-
654
- # Check directives (@skip, @include, @auth, @role, @guest)
655
- return unless check_directives(selection[:directives] || [], variables, context)
656
-
657
- # Resolve arguments (substitute variables)
658
- args = resolve_args(selection[:arguments], variables)
659
-
660
- field_def = fields[field_name]
661
-
662
- # Input validation
663
- if field_def && field_def[:args]
664
- validation_errors = validate_args(args, field_def[:args], field_name)
665
- if validation_errors.any?
666
- errors.concat(validation_errors)
667
- data[output_name] = nil
668
- return
669
- end
670
- end
671
-
672
- begin
673
- if field_def && field_def[:resolve]
674
- # Inject sub-selections into context for DataLoader/eager-loading
675
- ctx = context.merge("__selections" => (selection[:selection_set] || []))
676
- value = field_def[:resolve].call(parent, args, ctx)
677
- elsif parent.is_a?(Hash)
678
- value = parent[field_name] || parent[field_name.to_sym]
679
- else
680
- value = nil
681
- end
682
-
683
- # Recurse into nested selections
684
- if selection[:selection_set] && value
685
- if value.is_a?(Array)
686
- data[output_name] = value.map do |item|
687
- nested = {}
688
- sub_fields = item.is_a?(Hash) ? item_fields(item) : {}
689
- selection[:selection_set].each do |sel|
690
- resolve_selection(sel, sub_fields, item, variables, context, fragments, nested, errors)
691
- end
692
- nested
693
- end
694
- elsif value.is_a?(Hash)
695
- nested = {}
696
- sub_fields = item_fields(value)
697
- selection[:selection_set].each do |sel|
698
- resolve_selection(sel, sub_fields, value, variables, context, fragments, nested, errors)
699
- end
700
- data[output_name] = nested
701
- else
702
- data[output_name] = value
703
- end
704
- else
705
- data[output_name] = coerce_value(value)
706
- end
707
- rescue => e
708
- errors << { "message" => e.message, "path" => [output_name] }
709
- data[output_name] = nil
710
- end
711
- end
712
-
713
- # Build field resolvers from a hash (for nested object access)
714
- def item_fields(hash)
715
- result = {}
716
- hash.each do |k, _v|
717
- ks = k.to_s
718
- result[ks] = { resolve: ->(_p, _a, _c) { hash[k] || hash[k.to_s] || hash[k.to_sym] } }
719
- end
720
- result
721
- end
722
-
723
- # Check directives: @skip, @include, @auth, @role, @guest.
724
- # Returns true if the field should be included.
725
- def check_directives(directives, variables, context = {})
726
- directives.each do |d|
727
- val = d[:arguments]&.dig("if")
728
- val = variables[val[:name]] if val.is_a?(Hash) && val[:kind] == :variable
729
-
730
- return false if d[:name] == "skip" && val
731
- return false if d[:name] == "include" && !val
732
-
733
- # Auth: @auth — requires authenticated user
734
- return false if d[:name] == "auth" && !context["user"]
735
-
736
- # Auth: @role(role: "admin") — requires specific role
737
- if d[:name] == "role"
738
- required = d[:arguments]&.dig("role")
739
- user = context["user"]
740
- actual = user.is_a?(Hash) ? (user["role"] || user[:role]) : nil
741
- actual ||= context["role"]
742
- return false if required.nil? || actual != required
743
- end
744
-
745
- # Auth: @guest — only for unauthenticated
746
- return false if d[:name] == "guest" && context["user"]
747
- end
748
- true
749
- end
750
-
751
- # Validate resolved args against declared types.
752
- def validate_args(args, arg_configs, field_name)
753
- errors = []
754
- arg_configs.each do |arg_name, declared_type|
755
- value = args[arg_name]
756
- is_non_null = declared_type.to_s.end_with?("!")
757
- base_type = declared_type.to_s.gsub(/[!\[\]]/, "").strip
758
-
759
- if is_non_null && (value.nil? || value == "")
760
- errors << {
761
- "message" => "Argument '#{arg_name}' on field '#{field_name}' is required (type: #{declared_type})",
762
- "path" => [field_name]
763
- }
764
- next
765
- end
766
-
767
- next if value.nil?
768
-
769
- if %w[Int Float Boolean String ID].include?(base_type)
770
- unless coerce_check(value, base_type)
771
- errors << {
772
- "message" => "Argument '#{arg_name}' on field '#{field_name}' expected type #{base_type}, got #{value.class}",
773
- "path" => [field_name]
774
- }
775
- end
776
- end
777
- end
778
- errors
779
- end
780
-
781
- def coerce_check(value, type_name)
782
- case type_name
783
- when "String", "ID"
784
- value.is_a?(String) || value.is_a?(Numeric) || value.is_a?(Symbol)
785
- when "Int"
786
- return false if value.is_a?(TrueClass) || value.is_a?(FalseClass)
787
- value.is_a?(Integer) || (value.is_a?(String) && value.match?(/\A-?\d+\z/))
788
- when "Float"
789
- return false if value.is_a?(TrueClass) || value.is_a?(FalseClass)
790
- value.is_a?(Numeric) || (value.is_a?(String) && value.match?(/\A-?\d+(\.\d+)?\z/))
791
- when "Boolean"
792
- value.is_a?(TrueClass) || value.is_a?(FalseClass) || [0, 1, "true", "false"].include?(value)
793
- else
794
- true
795
- end
796
- end
797
-
798
- def resolve_args(args, variables)
799
- return {} unless args
800
- resolved = {}
801
- args.each do |key, val|
802
- resolved[key] = resolve_value(val, variables)
803
- end
804
- resolved
805
- end
806
-
807
- def resolve_value(val, variables)
808
- if val.is_a?(Hash) && val[:kind] == :variable
809
- variables[val[:name]]
810
- elsif val.is_a?(Hash)
811
- val.transform_values { |v| resolve_value(v, variables) }
812
- elsif val.is_a?(Array)
813
- val.map { |v| resolve_value(v, variables) }
814
- else
815
- val
816
- end
817
- end
818
-
819
- def resolve_variables(var_defs, provided)
820
- result = {}
821
- (var_defs || []).each do |vd|
822
- name = vd[:name]
823
- result[name] = provided.key?(name) ? provided[name] : vd[:default]
824
- end
825
- # Also include any extra provided variables
826
- provided.each { |k, v| result[k.to_s] = v unless result.key?(k.to_s) }
827
- result
828
- end
829
-
830
- def coerce_value(val)
831
- case val
832
- when Time, Date then val.iso8601
833
- when Symbol then val.to_s
834
- else val
835
- end
836
- end
837
- end
838
-
839
- # ─── Error class ──────────────────────────────────────────────────────
840
-
841
- class GraphQLError < StandardError; end
842
-
843
- # ─── Main GraphQL class ──────────────────────────────────────────────
844
-
845
- class GraphQL
846
- attr_reader :schema
847
-
848
- def initialize(schema = nil)
849
- @schema = schema || GraphQLSchema.new
850
- @executor = GraphQLExecutor.new(@schema)
851
- end
852
-
853
- # Execute a query string directly
854
- def execute(query, variables: {}, context: {}, operation_name: nil)
855
- parser = GraphQLParser.new(query)
856
- document = parser.parse
857
- @executor.execute(document, variables: variables, context: context, operation_name: operation_name)
858
- rescue GraphQLError => e
859
- { "data" => nil, "errors" => [{ "message" => e.message }] }
860
- rescue => e
861
- { "data" => nil, "errors" => [{ "message" => "Internal error: #{e.message}" }] }
862
- end
863
-
864
- # Return schema as GraphQL SDL string.
865
- def schema_sdl
866
- sdl = ""
867
- @schema.types.each do |name, type_obj|
868
- sdl += "type #{name} {\n"
869
- type_obj.fields.each { |f| sdl += " #{f[:name]}: #{f[:type]}\n" }
870
- sdl += "}\n\n"
871
- end
872
- unless @schema.queries.empty?
873
- sdl += "type Query {\n"
874
- @schema.queries.each do |name, config|
875
- args = (config[:args] || {}).map { |k, v| "#{k}: #{v}" }.join(", ")
876
- arg_str = args.empty? ? "" : "(#{args})"
877
- sdl += " #{name}#{arg_str}: #{config[:type]}\n"
878
- end
879
- sdl += "}\n\n"
880
- end
881
- unless @schema.mutations.empty?
882
- sdl += "type Mutation {\n"
883
- @schema.mutations.each do |name, config|
884
- args = (config[:args] || {}).map { |k, v| "#{k}: #{v}" }.join(", ")
885
- arg_str = args.empty? ? "" : "(#{args})"
886
- sdl += " #{name}#{arg_str}: #{config[:type]}\n"
887
- end
888
- sdl += "}\n\n"
889
- end
890
- sdl
891
- end
892
-
893
- # Return schema metadata for debugging.
894
- def introspect
895
- queries = @schema.queries.transform_values { |v| { type: v[:type], args: v[:args] || {} } }
896
- mutations = @schema.mutations.transform_values { |v| { type: v[:type], args: v[:args] || {} } }
897
- { types: @schema.types.keys, queries: queries, mutations: mutations }
898
- end
899
-
900
- # Handle an HTTP request body (JSON string)
901
- def handle_request(body, context: {})
902
- payload = JSON.parse(body)
903
- query = payload["query"] || ""
904
- variables = payload["variables"] || {}
905
- op_name = payload["operationName"]
906
-
907
- execute(query, variables: variables, context: context, operation_name: op_name)
908
- rescue JSON::ParserError
909
- { "data" => nil, "errors" => [{ "message" => "Invalid JSON in request body" }] }
910
- end
911
-
912
- # ── Route Registration ─────────────────────────────────────────────
913
- # Register a POST /graphql route in the Tina4 router.
914
- #
915
- # gql = Tina4::GraphQL.new(schema)
916
- # gql.register_route # POST /graphql
917
- # gql.register_route("/api/graphql") # custom path
918
- #
919
- def register_route(path = "/graphql")
920
- graphql = self
921
- Tina4.post path, auth: false do |request, response|
922
- body = request.body
923
- result = graphql.handle_request(body, context: { request: request })
924
- response.json(result)
925
- end
926
-
927
- # Optional: GET for GraphiQL/introspection
928
- Tina4.get path, auth: false do |request, response|
929
- query = request.params["query"]
930
- if query
931
- variables = request.params["variables"]
932
- variables = JSON.parse(variables) if variables.is_a?(String) && !variables.empty?
933
- result = graphql.execute(query, variables: variables || {}, context: { request: request })
934
- response.json(result)
935
- else
936
- response.html(graphiql_html(path))
937
- end
938
- end
939
- end
940
-
941
- private
942
-
943
- def graphiql_html(endpoint)
944
- <<~HTML
945
- <!DOCTYPE html>
946
- <html>
947
- <head>
948
- <title>GraphiQL — Tina4 Ruby</title>
949
- <link rel="stylesheet" href="https://unpkg.com/graphiql@3/graphiql.min.css" />
950
- </head>
951
- <body style="margin:0;height:100vh;">
952
- <div id="graphiql" style="height:100vh;"></div>
953
- <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
954
- <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
955
- <script crossorigin src="https://unpkg.com/graphiql@3/graphiql.min.js"></script>
956
- <script>
957
- const fetcher = GraphiQL.createFetcher({ url: '#{endpoint}' });
958
- ReactDOM.createRoot(document.getElementById('graphiql'))
959
- .render(React.createElement(GraphiQL, { fetcher }));
960
- </script>
961
- </body>
962
- </html>
963
- HTML
964
- end
965
- end
966
- end
1
+ # frozen_string_literal: false
2
+
3
+ require "json"
4
+
5
+ module Tina4
6
+ # Lightweight, zero-dependency GraphQL implementation for Tina4 Ruby.
7
+ # Mirrors the tina4php-graphql approach: custom recursive-descent parser,
8
+ # depth-first executor, programmatic schema, and ORM auto-schema generation.
9
+
10
+ # ─── Type System ──────────────────────────────────────────────────────
11
+
12
+ class GraphQLType
13
+ SCALARS = %w[String Int Float Boolean ID].freeze
14
+
15
+ attr_reader :name, :kind, :fields, :description
16
+
17
+ # kind: :scalar, :object, :list, :non_null, :input_object, :enum
18
+ def initialize(name, kind = :object, fields: {}, of_type: nil, description: nil)
19
+ @name = name
20
+ @kind = kind.to_sym
21
+ @fields = fields # { field_name => { type:, args:, resolve:, description: } }
22
+ @of_type = of_type # wrapped type for list / non_null
23
+ @description = description
24
+ end
25
+
26
+ def scalar?
27
+ @kind == :scalar || SCALARS.include?(@name)
28
+ end
29
+
30
+ def list?
31
+ @kind == :list
32
+ end
33
+
34
+ def non_null?
35
+ @kind == :non_null
36
+ end
37
+
38
+ def of_type
39
+ @of_type
40
+ end
41
+
42
+ # Parse a type string like "String", "String!", "[String]", "[Int!]!"
43
+ def self.parse(type_str)
44
+ type_str = type_str.to_s.strip
45
+ if type_str.end_with?("!")
46
+ inner = parse(type_str[0..-2])
47
+ new(type_str, :non_null, of_type: inner)
48
+ elsif type_str.start_with?("[") && type_str.end_with?("]")
49
+ inner = parse(type_str[1..-2])
50
+ new(type_str, :list, of_type: inner)
51
+ elsif SCALARS.include?(type_str)
52
+ new(type_str, :scalar)
53
+ else
54
+ new(type_str, :object)
55
+ end
56
+ end
57
+ end
58
+
59
+ # ─── Schema ───────────────────────────────────────────────────────────
60
+
61
+ class GraphQLSchema
62
+ attr_reader :types, :queries, :mutations
63
+
64
+ def initialize
65
+ @types = {}
66
+ @queries = {} # name => { type:, args:, resolve:, description: }
67
+ @mutations = {}
68
+ register_scalars
69
+ end
70
+
71
+ # add_type(name, fields) — parity with PHP/Python/Node
72
+ # add_type(type_object) — legacy Ruby form (type_object responds to .name)
73
+ def add_type(name_or_type, fields = nil)
74
+ if fields
75
+ # New form: add_type("User", { "id" => "ID", "name" => "String" })
76
+ @types[name_or_type] = fields
77
+ else
78
+ # Legacy form: add_type(GraphQLType.new(...))
79
+ @types[name_or_type.name] = name_or_type
80
+ end
81
+ end
82
+
83
+ def get_type(name)
84
+ @types[name]
85
+ end
86
+
87
+ # Register a query field.
88
+ # Cross-framework form: add_query(name, args, return_type, resolver)
89
+ # Block form also accepted: add_query(name, args, return_type) { |root, args, ctx| ... }
90
+ def add_query(name, args = {}, return_type = nil, resolver = nil, &block)
91
+ resolve = resolver || block
92
+ @queries[name] = { type: return_type, args: args, resolve: resolve }
93
+ end
94
+
95
+ # Register a mutation field.
96
+ # Cross-framework form: add_mutation(name, args, return_type, resolver)
97
+ # Block form also accepted: add_mutation(name, args, return_type) { |root, args, ctx| ... }
98
+ def add_mutation(name, args = {}, return_type = nil, resolver = nil, &block)
99
+ resolve = resolver || block
100
+ @mutations[name] = { type: return_type, args: args, resolve: resolve }
101
+ end
102
+
103
+ # ── ORM Auto-Schema ──────────────────────────────────────────────────
104
+ # Generates GraphQL types + CRUD queries/mutations from a Tina4::ORM subclass.
105
+ #
106
+ # schema.from_orm(User)
107
+ #
108
+ # Creates:
109
+ # Query: user(id), users(limit, offset)
110
+ # Mutation: createUser(input), updateUser(id, input), deleteUser(id)
111
+ def from_orm(klass)
112
+ model_name = klass.name.split("::").last
113
+ type_name = model_name
114
+ table_lower = model_name.gsub(/([A-Z])/, '_\1').sub(/\A_/, "").downcase
115
+ plural = "#{table_lower}s"
116
+
117
+ # Build GraphQL object type from ORM field definitions
118
+ gql_fields = {}
119
+ pk_field = nil
120
+
121
+ if klass.respond_to?(:field_definitions)
122
+ klass.field_definitions.each do |fname, fdef|
123
+ gql_type = ruby_field_to_gql(fdef[:type] || :string)
124
+ gql_fields[fname.to_s] = { type: gql_type }
125
+ pk_field = fname.to_s if fdef[:primary_key]
126
+ end
127
+ end
128
+
129
+ pk_field ||= "id"
130
+ gql_fields[pk_field] ||= { type: "ID" }
131
+
132
+ obj_type = GraphQLType.new(type_name, :object, fields: gql_fields)
133
+ add_type(obj_type)
134
+
135
+ # Input type for create/update
136
+ input_fields = gql_fields.reject { |k, _| k == pk_field }
137
+ input_type = GraphQLType.new("#{type_name}Input", :input_object, fields: input_fields)
138
+ add_type(input_type)
139
+
140
+ # ── Queries ──
141
+
142
+ # Single record: user(id: ID!): User
143
+ add_query(table_lower, { pk_field => { type: "ID!" } }, type_name) do |_root, args, _ctx|
144
+ record = klass.find_by_id(args[pk_field])
145
+ record&.to_hash
146
+ end
147
+
148
+ # List: users(limit: Int, offset: Int): [User]
149
+ add_query(plural, { "limit" => { type: "Int" }, "offset" => { type: "Int" } }, "[#{type_name}]") do |_root, args, _ctx|
150
+ limit = args["limit"] || 100
151
+ offset = args["offset"] || 0
152
+ result = klass.all(limit: limit, offset: offset)
153
+ result.respond_to?(:to_array) ? result.to_array : Array(result).map { |r| r.respond_to?(:to_hash) ? r.to_hash : r }
154
+ end
155
+
156
+ # ── Mutations ──
157
+
158
+ # Create
159
+ add_mutation("create#{model_name}", { "input" => { type: "#{type_name}Input!" } }, type_name) do |_root, args, _ctx|
160
+ record = klass.create(args["input"] || {})
161
+ record.respond_to?(:to_hash) ? record.to_hash : record
162
+ end
163
+
164
+ # Update
165
+ add_mutation("update#{model_name}", { pk_field => { type: "ID!" }, "input" => { type: "#{type_name}Input!" } }, type_name) do |_root, args, _ctx|
166
+ record = klass.find_by_id(args[pk_field])
167
+ return nil unless record
168
+ (args["input"] || {}).each { |k, v| record.send(:"#{k}=", v) if record.respond_to?(:"#{k}=") }
169
+ record.save
170
+ record.to_hash
171
+ end
172
+
173
+ # Delete
174
+ add_mutation("delete#{model_name}", { pk_field => { type: "ID!" } }, "Boolean") do |_root, args, _ctx|
175
+ record = klass.find_by_id(args[pk_field])
176
+ return false unless record
177
+ record.delete
178
+ true
179
+ end
180
+ end
181
+
182
+ private
183
+
184
+ def register_scalars
185
+ GraphQLType::SCALARS.each do |s|
186
+ @types[s] = GraphQLType.new(s, :scalar)
187
+ end
188
+ end
189
+
190
+ def ruby_field_to_gql(field_type)
191
+ case field_type.to_s.downcase
192
+ when "integer", "int" then "Int"
193
+ when "float", "double", "decimal", "numeric" then "Float"
194
+ when "boolean", "bool" then "Boolean"
195
+ when "string", "text", "varchar" then "String"
196
+ when "datetime", "date", "timestamp" then "String"
197
+ when "blob", "binary" then "String"
198
+ when "json", "jsonb" then "String"
199
+ else "String"
200
+ end
201
+ end
202
+ end
203
+
204
+ # ─── Parser (recursive descent) ──────────────────────────────────────
205
+
206
+ class GraphQLParser
207
+ Token = Struct.new(:type, :value, :pos)
208
+
209
+ KEYWORDS = %w[query mutation fragment on true false null].freeze
210
+
211
+ def initialize(source)
212
+ @source = source
213
+ @tokens = tokenize(source)
214
+ @pos = 0
215
+ end
216
+
217
+ def parse
218
+ document = { kind: :document, definitions: [] }
219
+ while current
220
+ skip(:comma)
221
+ break unless current
222
+ document[:definitions] << parse_definition
223
+ end
224
+ document
225
+ end
226
+
227
+ private
228
+
229
+ # ── Tokenizer ──
230
+
231
+ def tokenize(src)
232
+ tokens = []
233
+ i = 0
234
+ while i < src.length
235
+ ch = src[i]
236
+
237
+ # Skip whitespace
238
+ if ch =~ /\s/
239
+ i += 1
240
+ next
241
+ end
242
+
243
+ # Skip comments
244
+ if ch == "#"
245
+ i += 1 while i < src.length && src[i] != "\n"
246
+ next
247
+ end
248
+
249
+ # Punctuation
250
+ if "{}()[]!:=@$,".include?(ch)
251
+ tokens << Token.new(:punct, ch, i)
252
+ i += 1
253
+ next
254
+ end
255
+
256
+ # Spread operator
257
+ if ch == "." && src[i + 1] == "." && src[i + 2] == "."
258
+ tokens << Token.new(:spread, "...", i)
259
+ i += 3
260
+ next
261
+ end
262
+
263
+ # String
264
+ if ch == '"'
265
+ str, i = read_string(src, i)
266
+ tokens << Token.new(:string, str, i)
267
+ next
268
+ end
269
+
270
+ # Number
271
+ if ch =~ /[\d\-]/
272
+ num, i = read_number(src, i)
273
+ tokens << Token.new(:number, num, i)
274
+ next
275
+ end
276
+
277
+ # Name / keyword
278
+ if ch =~ /[a-zA-Z_]/
279
+ name = ""
280
+ while i < src.length && src[i] =~ /[a-zA-Z0-9_]/
281
+ name << src[i]
282
+ i += 1
283
+ end
284
+ type = KEYWORDS.include?(name) ? :keyword : :name
285
+ tokens << Token.new(type, name, i - name.length)
286
+ next
287
+ end
288
+
289
+ i += 1 # skip unknown
290
+ end
291
+ tokens
292
+ end
293
+
294
+ def read_string(src, i)
295
+ i += 1 # skip opening quote
296
+ str = ""
297
+ while i < src.length && src[i] != '"'
298
+ if src[i] == "\\"
299
+ i += 1
300
+ case src[i]
301
+ when "n" then str << "\n"
302
+ when "t" then str << "\t"
303
+ when '"' then str << '"'
304
+ when "\\" then str << "\\"
305
+ else str << src[i].to_s
306
+ end
307
+ else
308
+ str << src[i]
309
+ end
310
+ i += 1
311
+ end
312
+ i += 1 # skip closing quote
313
+ [str, i]
314
+ end
315
+
316
+ def read_number(src, i)
317
+ start = i
318
+ i += 1 if src[i] == "-"
319
+ i += 1 while i < src.length && src[i] =~ /[\d.eE+\-]/
320
+ [src[start...i], i]
321
+ end
322
+
323
+ # ── Token helpers ──
324
+
325
+ def current
326
+ @tokens[@pos]
327
+ end
328
+
329
+ def peek(offset = 0)
330
+ @tokens[@pos + offset]
331
+ end
332
+
333
+ def advance
334
+ tok = @tokens[@pos]
335
+ @pos += 1
336
+ tok
337
+ end
338
+
339
+ def expect(type, value = nil)
340
+ tok = current
341
+ if tok.nil?
342
+ raise GraphQLError, "Unexpected end of query, expected #{type} #{value}"
343
+ end
344
+ if tok.type != type || (value && tok.value != value)
345
+ raise GraphQLError, "Expected #{type} '#{value}' at position #{tok.pos}, got #{tok.type} '#{tok.value}'"
346
+ end
347
+ advance
348
+ end
349
+
350
+ def match(type, value = nil)
351
+ tok = current
352
+ return nil unless tok
353
+ return nil unless tok.type == type
354
+ return nil if value && tok.value != value
355
+ advance
356
+ end
357
+
358
+ def skip(type, value = nil)
359
+ match(type, value) while current && current.type == type && (value.nil? || current.value == value)
360
+ end
361
+
362
+ # ── Parse rules ──
363
+
364
+ def parse_definition
365
+ tok = current
366
+ if tok.nil?
367
+ raise GraphQLError, "Unexpected end of input"
368
+ end
369
+
370
+ if tok.type == :keyword && tok.value == "fragment"
371
+ return parse_fragment
372
+ end
373
+
374
+ if tok.type == :keyword && (tok.value == "query" || tok.value == "mutation")
375
+ return parse_operation
376
+ end
377
+
378
+ # Shorthand query (just a selection set)
379
+ if tok.type == :punct && tok.value == "{"
380
+ return { kind: :operation, operation: :query, name: nil, variables: [], selection_set: parse_selection_set }
381
+ end
382
+
383
+ raise GraphQLError, "Unexpected token '#{tok.value}' at position #{tok.pos}"
384
+ end
385
+
386
+ def parse_operation
387
+ op = advance.value.to_sym # :query or :mutation
388
+ name = match(:name)&.value
389
+
390
+ variables = []
391
+ if current&.value == "("
392
+ variables = parse_variable_definitions
393
+ end
394
+
395
+ selection_set = parse_selection_set
396
+
397
+ { kind: :operation, operation: op, name: name, variables: variables, selection_set: selection_set }
398
+ end
399
+
400
+ def parse_variable_definitions
401
+ expect(:punct, "(")
402
+ vars = []
403
+ until current&.value == ")"
404
+ skip(:comma)
405
+ break if current&.value == ")"
406
+ expect(:punct, "$")
407
+ vname = expect(:name).value
408
+ expect(:punct, ":")
409
+ vtype = parse_type_ref
410
+ default = nil
411
+ if match(:punct, "=")
412
+ default = parse_value
413
+ end
414
+ vars << { name: vname, type: vtype, default: default }
415
+ end
416
+ expect(:punct, ")")
417
+ vars
418
+ end
419
+
420
+ def parse_type_ref
421
+ if match(:punct, "[")
422
+ inner = parse_type_ref
423
+ expect(:punct, "]")
424
+ type_str = "[#{inner}]"
425
+ else
426
+ type_str = expect(:name).value
427
+ end
428
+ type_str += "!" if match(:punct, "!")
429
+ type_str
430
+ end
431
+
432
+ def parse_selection_set
433
+ expect(:punct, "{")
434
+ selections = []
435
+ until current&.value == "}"
436
+ skip(:comma)
437
+ break if current&.value == "}"
438
+
439
+ if current&.type == :spread
440
+ selections << parse_fragment_spread
441
+ else
442
+ selections << parse_field
443
+ end
444
+ end
445
+ expect(:punct, "}")
446
+ selections
447
+ end
448
+
449
+ def parse_field
450
+ name_tok = expect(:name)
451
+ field_name = name_tok.value
452
+ alias_name = nil
453
+
454
+ # Check for alias: alias: fieldName
455
+ if current&.value == ":"
456
+ advance
457
+ alias_name = field_name
458
+ field_name = expect(:name).value
459
+ end
460
+
461
+ arguments = {}
462
+ if current&.value == "("
463
+ arguments = parse_arguments
464
+ end
465
+
466
+ selection_set = nil
467
+ if current&.value == "{"
468
+ selection_set = parse_selection_set
469
+ end
470
+
471
+ { kind: :field, name: field_name, alias: alias_name, arguments: arguments, selection_set: selection_set }
472
+ end
473
+
474
+ def parse_arguments
475
+ expect(:punct, "(")
476
+ args = {}
477
+ until current&.value == ")"
478
+ skip(:comma)
479
+ break if current&.value == ")"
480
+ arg_name = expect(:name).value
481
+ expect(:punct, ":")
482
+ args[arg_name] = parse_value
483
+ end
484
+ expect(:punct, ")")
485
+ args
486
+ end
487
+
488
+ def parse_value
489
+ tok = current
490
+ case tok.type
491
+ when :string
492
+ advance
493
+ tok.value
494
+ when :number
495
+ advance
496
+ tok.value.include?(".") ? tok.value.to_f : tok.value.to_i
497
+ when :keyword
498
+ advance
499
+ case tok.value
500
+ when "true" then true
501
+ when "false" then false
502
+ when "null" then nil
503
+ else tok.value
504
+ end
505
+ when :name
506
+ # Enum value
507
+ advance
508
+ tok.value
509
+ when :punct
510
+ if tok.value == "["
511
+ parse_list_value
512
+ elsif tok.value == "{"
513
+ parse_object_value
514
+ elsif tok.value == "$"
515
+ advance
516
+ { kind: :variable, name: expect(:name).value }
517
+ else
518
+ raise GraphQLError, "Unexpected '#{tok.value}' in value at position #{tok.pos}"
519
+ end
520
+ else
521
+ raise GraphQLError, "Unexpected token type #{tok.type} at position #{tok.pos}"
522
+ end
523
+ end
524
+
525
+ def parse_list_value
526
+ expect(:punct, "[")
527
+ items = []
528
+ until current&.value == "]"
529
+ skip(:comma)
530
+ break if current&.value == "]"
531
+ items << parse_value
532
+ end
533
+ expect(:punct, "]")
534
+ items
535
+ end
536
+
537
+ def parse_object_value
538
+ expect(:punct, "{")
539
+ obj = {}
540
+ until current&.value == "}"
541
+ skip(:comma)
542
+ break if current&.value == "}"
543
+ key = expect(:name).value
544
+ expect(:punct, ":")
545
+ obj[key] = parse_value
546
+ end
547
+ expect(:punct, "}")
548
+ obj
549
+ end
550
+
551
+ def parse_fragment_spread
552
+ expect(:spread)
553
+ if current&.type == :keyword && current&.value == "on"
554
+ # Inline fragment
555
+ advance
556
+ type_name = expect(:name).value
557
+ selection_set = parse_selection_set
558
+ { kind: :inline_fragment, on: type_name, selection_set: selection_set }
559
+ else
560
+ name = expect(:name).value
561
+ { kind: :fragment_spread, name: name }
562
+ end
563
+ end
564
+
565
+ def parse_fragment
566
+ expect(:keyword, "fragment")
567
+ name = expect(:name).value
568
+ expect(:keyword, "on")
569
+ type_name = expect(:name).value
570
+ selection_set = parse_selection_set
571
+ { kind: :fragment, name: name, on: type_name, selection_set: selection_set }
572
+ end
573
+ end
574
+
575
+ # ─── Executor ─────────────────────────────────────────────────────────
576
+
577
+ class GraphQLExecutor
578
+ def initialize(schema)
579
+ @schema = schema
580
+ end
581
+
582
+ def execute(document, variables: {}, context: {}, operation_name: nil)
583
+ # Collect fragments
584
+ fragments = {}
585
+ operations = []
586
+
587
+ document[:definitions].each do |defn|
588
+ case defn[:kind]
589
+ when :fragment
590
+ fragments[defn[:name]] = defn
591
+ when :operation
592
+ operations << defn
593
+ end
594
+ end
595
+
596
+ # Pick the operation
597
+ operation = if operation_name
598
+ operations.find { |op| op[:name] == operation_name }
599
+ elsif operations.length == 1
600
+ operations.first
601
+ else
602
+ raise GraphQLError, "Must provide operation name when multiple operations exist"
603
+ end
604
+
605
+ raise GraphQLError, "Unknown operation: #{operation_name}" unless operation
606
+
607
+ # Resolve variables
608
+ resolved_vars = resolve_variables(operation[:variables], variables)
609
+
610
+ # Choose root fields
611
+ root_fields = case operation[:operation]
612
+ when :query then @schema.queries
613
+ when :mutation then @schema.mutations
614
+ else raise GraphQLError, "Unsupported operation: #{operation[:operation]}"
615
+ end
616
+
617
+ # Execute selection set
618
+ data = {}
619
+ errors = []
620
+
621
+ operation[:selection_set].each do |selection|
622
+ resolve_selection(selection, root_fields, nil, resolved_vars, context, fragments, data, errors)
623
+ end
624
+
625
+ result = { "data" => data }
626
+ result["errors"] = errors unless errors.empty?
627
+ result
628
+ end
629
+
630
+ private
631
+
632
+ def resolve_selection(selection, fields, parent, variables, context, fragments, data, errors)
633
+ case selection[:kind]
634
+ when :field
635
+ resolve_field(selection, fields, parent, variables, context, fragments, data, errors)
636
+ when :fragment_spread
637
+ frag = fragments[selection[:name]]
638
+ if frag
639
+ frag[:selection_set].each do |sel|
640
+ resolve_selection(sel, fields, parent, variables, context, fragments, data, errors)
641
+ end
642
+ end
643
+ when :inline_fragment
644
+ selection[:selection_set].each do |sel|
645
+ resolve_selection(sel, fields, parent, variables, context, fragments, data, errors)
646
+ end
647
+ end
648
+ end
649
+
650
+ def resolve_field(selection, fields, parent, variables, context, fragments, data, errors)
651
+ field_name = selection[:name]
652
+ output_name = selection[:alias] || field_name
653
+
654
+ # Check directives (@skip, @include, @auth, @role, @guest)
655
+ return unless check_directives(selection[:directives] || [], variables, context)
656
+
657
+ # Resolve arguments (substitute variables)
658
+ args = resolve_args(selection[:arguments], variables)
659
+
660
+ field_def = fields[field_name]
661
+
662
+ # Input validation
663
+ if field_def && field_def[:args]
664
+ validation_errors = validate_args(args, field_def[:args], field_name)
665
+ if validation_errors.any?
666
+ errors.concat(validation_errors)
667
+ data[output_name] = nil
668
+ return
669
+ end
670
+ end
671
+
672
+ begin
673
+ if field_def && field_def[:resolve]
674
+ # Inject sub-selections into context for DataLoader/eager-loading
675
+ ctx = context.merge("__selections" => (selection[:selection_set] || []))
676
+ value = field_def[:resolve].call(parent, args, ctx)
677
+ elsif parent.is_a?(Hash)
678
+ value = parent[field_name] || parent[field_name.to_sym]
679
+ else
680
+ value = nil
681
+ end
682
+
683
+ # Recurse into nested selections
684
+ if selection[:selection_set] && value
685
+ if value.is_a?(Array)
686
+ data[output_name] = value.map do |item|
687
+ nested = {}
688
+ sub_fields = item.is_a?(Hash) ? item_fields(item) : {}
689
+ selection[:selection_set].each do |sel|
690
+ resolve_selection(sel, sub_fields, item, variables, context, fragments, nested, errors)
691
+ end
692
+ nested
693
+ end
694
+ elsif value.is_a?(Hash)
695
+ nested = {}
696
+ sub_fields = item_fields(value)
697
+ selection[:selection_set].each do |sel|
698
+ resolve_selection(sel, sub_fields, value, variables, context, fragments, nested, errors)
699
+ end
700
+ data[output_name] = nested
701
+ else
702
+ data[output_name] = value
703
+ end
704
+ else
705
+ data[output_name] = coerce_value(value)
706
+ end
707
+ rescue => e
708
+ errors << { "message" => e.message, "path" => [output_name] }
709
+ data[output_name] = nil
710
+ end
711
+ end
712
+
713
+ # Build field resolvers from a hash (for nested object access)
714
+ def item_fields(hash)
715
+ result = {}
716
+ hash.each do |k, _v|
717
+ ks = k.to_s
718
+ result[ks] = { resolve: ->(_p, _a, _c) { hash[k] || hash[k.to_s] || hash[k.to_sym] } }
719
+ end
720
+ result
721
+ end
722
+
723
+ # Check directives: @skip, @include, @auth, @role, @guest.
724
+ # Returns true if the field should be included.
725
+ def check_directives(directives, variables, context = {})
726
+ directives.each do |d|
727
+ val = d[:arguments]&.dig("if")
728
+ val = variables[val[:name]] if val.is_a?(Hash) && val[:kind] == :variable
729
+
730
+ return false if d[:name] == "skip" && val
731
+ return false if d[:name] == "include" && !val
732
+
733
+ # Auth: @auth — requires authenticated user
734
+ return false if d[:name] == "auth" && !context["user"]
735
+
736
+ # Auth: @role(role: "admin") — requires specific role
737
+ if d[:name] == "role"
738
+ required = d[:arguments]&.dig("role")
739
+ user = context["user"]
740
+ actual = user.is_a?(Hash) ? (user["role"] || user[:role]) : nil
741
+ actual ||= context["role"]
742
+ return false if required.nil? || actual != required
743
+ end
744
+
745
+ # Auth: @guest — only for unauthenticated
746
+ return false if d[:name] == "guest" && context["user"]
747
+ end
748
+ true
749
+ end
750
+
751
+ # Validate resolved args against declared types.
752
+ def validate_args(args, arg_configs, field_name)
753
+ errors = []
754
+ arg_configs.each do |arg_name, declared_type|
755
+ value = args[arg_name]
756
+ is_non_null = declared_type.to_s.end_with?("!")
757
+ base_type = declared_type.to_s.gsub(/[!\[\]]/, "").strip
758
+
759
+ if is_non_null && (value.nil? || value == "")
760
+ errors << {
761
+ "message" => "Argument '#{arg_name}' on field '#{field_name}' is required (type: #{declared_type})",
762
+ "path" => [field_name]
763
+ }
764
+ next
765
+ end
766
+
767
+ next if value.nil?
768
+
769
+ if %w[Int Float Boolean String ID].include?(base_type)
770
+ unless coerce_check(value, base_type)
771
+ errors << {
772
+ "message" => "Argument '#{arg_name}' on field '#{field_name}' expected type #{base_type}, got #{value.class}",
773
+ "path" => [field_name]
774
+ }
775
+ end
776
+ end
777
+ end
778
+ errors
779
+ end
780
+
781
+ def coerce_check(value, type_name)
782
+ case type_name
783
+ when "String", "ID"
784
+ value.is_a?(String) || value.is_a?(Numeric) || value.is_a?(Symbol)
785
+ when "Int"
786
+ return false if value.is_a?(TrueClass) || value.is_a?(FalseClass)
787
+ value.is_a?(Integer) || (value.is_a?(String) && value.match?(/\A-?\d+\z/))
788
+ when "Float"
789
+ return false if value.is_a?(TrueClass) || value.is_a?(FalseClass)
790
+ value.is_a?(Numeric) || (value.is_a?(String) && value.match?(/\A-?\d+(\.\d+)?\z/))
791
+ when "Boolean"
792
+ value.is_a?(TrueClass) || value.is_a?(FalseClass) || [0, 1, "true", "false"].include?(value)
793
+ else
794
+ true
795
+ end
796
+ end
797
+
798
+ def resolve_args(args, variables)
799
+ return {} unless args
800
+ resolved = {}
801
+ args.each do |key, val|
802
+ resolved[key] = resolve_value(val, variables)
803
+ end
804
+ resolved
805
+ end
806
+
807
+ def resolve_value(val, variables)
808
+ if val.is_a?(Hash) && val[:kind] == :variable
809
+ variables[val[:name]]
810
+ elsif val.is_a?(Hash)
811
+ val.transform_values { |v| resolve_value(v, variables) }
812
+ elsif val.is_a?(Array)
813
+ val.map { |v| resolve_value(v, variables) }
814
+ else
815
+ val
816
+ end
817
+ end
818
+
819
+ def resolve_variables(var_defs, provided)
820
+ result = {}
821
+ (var_defs || []).each do |vd|
822
+ name = vd[:name]
823
+ result[name] = provided.key?(name) ? provided[name] : vd[:default]
824
+ end
825
+ # Also include any extra provided variables
826
+ provided.each { |k, v| result[k.to_s] = v unless result.key?(k.to_s) }
827
+ result
828
+ end
829
+
830
+ def coerce_value(val)
831
+ case val
832
+ when Time, Date then val.iso8601
833
+ when Symbol then val.to_s
834
+ else val
835
+ end
836
+ end
837
+ end
838
+
839
+ # ─── Error class ──────────────────────────────────────────────────────
840
+
841
+ class GraphQLError < StandardError; end
842
+
843
+ # ─── Main GraphQL class ──────────────────────────────────────────────
844
+
845
+ class GraphQL
846
+ attr_reader :schema
847
+
848
+ def initialize(schema = nil)
849
+ @schema = schema || GraphQLSchema.new
850
+ @executor = GraphQLExecutor.new(@schema)
851
+ end
852
+
853
+ # Execute a query string directly
854
+ def execute(query, variables: {}, context: {}, operation_name: nil)
855
+ parser = GraphQLParser.new(query)
856
+ document = parser.parse
857
+ @executor.execute(document, variables: variables, context: context, operation_name: operation_name)
858
+ rescue GraphQLError => e
859
+ { "data" => nil, "errors" => [{ "message" => e.message }] }
860
+ rescue => e
861
+ { "data" => nil, "errors" => [{ "message" => "Internal error: #{e.message}" }] }
862
+ end
863
+
864
+ # Return schema as GraphQL SDL string.
865
+ def schema_sdl
866
+ sdl = ""
867
+ @schema.types.each do |name, type_obj|
868
+ sdl += "type #{name} {\n"
869
+ type_obj.fields.each { |f| sdl += " #{f[:name]}: #{f[:type]}\n" }
870
+ sdl += "}\n\n"
871
+ end
872
+ unless @schema.queries.empty?
873
+ sdl += "type Query {\n"
874
+ @schema.queries.each do |name, config|
875
+ args = (config[:args] || {}).map { |k, v| "#{k}: #{v}" }.join(", ")
876
+ arg_str = args.empty? ? "" : "(#{args})"
877
+ sdl += " #{name}#{arg_str}: #{config[:type]}\n"
878
+ end
879
+ sdl += "}\n\n"
880
+ end
881
+ unless @schema.mutations.empty?
882
+ sdl += "type Mutation {\n"
883
+ @schema.mutations.each do |name, config|
884
+ args = (config[:args] || {}).map { |k, v| "#{k}: #{v}" }.join(", ")
885
+ arg_str = args.empty? ? "" : "(#{args})"
886
+ sdl += " #{name}#{arg_str}: #{config[:type]}\n"
887
+ end
888
+ sdl += "}\n\n"
889
+ end
890
+ sdl
891
+ end
892
+
893
+ # Return schema metadata for debugging.
894
+ def introspect
895
+ queries = @schema.queries.transform_values { |v| { type: v[:type], args: v[:args] || {} } }
896
+ mutations = @schema.mutations.transform_values { |v| { type: v[:type], args: v[:args] || {} } }
897
+ { types: @schema.types.keys, queries: queries, mutations: mutations }
898
+ end
899
+
900
+ # Handle an HTTP request body (JSON string)
901
+ def handle_request(body, context: {})
902
+ payload = JSON.parse(body)
903
+ query = payload["query"] || ""
904
+ variables = payload["variables"] || {}
905
+ op_name = payload["operationName"]
906
+
907
+ execute(query, variables: variables, context: context, operation_name: op_name)
908
+ rescue JSON::ParserError
909
+ { "data" => nil, "errors" => [{ "message" => "Invalid JSON in request body" }] }
910
+ end
911
+
912
+ # ── Route Registration ─────────────────────────────────────────────
913
+ # Register a POST /graphql route in the Tina4 router.
914
+ #
915
+ # gql = Tina4::GraphQL.new(schema)
916
+ # gql.register_route # POST /graphql
917
+ # gql.register_route("/api/graphql") # custom path
918
+ #
919
+ def register_route(path = "/graphql")
920
+ graphql = self
921
+ Tina4.post path, auth: false do |request, response|
922
+ body = request.body
923
+ result = graphql.handle_request(body, context: { request: request })
924
+ response.json(result)
925
+ end
926
+
927
+ # Optional: GET for GraphiQL/introspection
928
+ Tina4.get path, auth: false do |request, response|
929
+ query = request.params["query"]
930
+ if query
931
+ variables = request.params["variables"]
932
+ variables = JSON.parse(variables) if variables.is_a?(String) && !variables.empty?
933
+ result = graphql.execute(query, variables: variables || {}, context: { request: request })
934
+ response.json(result)
935
+ else
936
+ response.html(graphiql_html(path))
937
+ end
938
+ end
939
+ end
940
+
941
+ private
942
+
943
+ def graphiql_html(endpoint)
944
+ <<~HTML
945
+ <!DOCTYPE html>
946
+ <html>
947
+ <head>
948
+ <title>GraphiQL — Tina4 Ruby</title>
949
+ <link rel="stylesheet" href="https://unpkg.com/graphiql@3/graphiql.min.css" />
950
+ </head>
951
+ <body style="margin:0;height:100vh;">
952
+ <div id="graphiql" style="height:100vh;"></div>
953
+ <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
954
+ <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
955
+ <script crossorigin src="https://unpkg.com/graphiql@3/graphiql.min.js"></script>
956
+ <script>
957
+ const fetcher = GraphiQL.createFetcher({ url: '#{endpoint}' });
958
+ ReactDOM.createRoot(document.getElementById('graphiql'))
959
+ .render(React.createElement(GraphiQL, { fetcher }));
960
+ </script>
961
+ </body>
962
+ </html>
963
+ HTML
964
+ end
965
+ end
966
+ end