rubyn-code 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- metadata +397 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
# Gem: graphql-ruby
|
|
2
|
+
|
|
3
|
+
## What It Is
|
|
4
|
+
|
|
5
|
+
The `graphql` gem (graphql-ruby) is the standard Ruby implementation of GraphQL. It provides a type system, schema definition, query execution, mutations, subscriptions, and tooling for building GraphQL APIs in Rails. It's powerful but has a steep learning curve and many subtle gotchas.
|
|
6
|
+
|
|
7
|
+
## Setup Done Right
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# Gemfile
|
|
11
|
+
gem 'graphql'
|
|
12
|
+
|
|
13
|
+
# Generate the schema
|
|
14
|
+
rails generate graphql:install
|
|
15
|
+
|
|
16
|
+
# This creates:
|
|
17
|
+
# app/graphql/your_app_schema.rb
|
|
18
|
+
# app/graphql/types/query_type.rb
|
|
19
|
+
# app/graphql/types/mutation_type.rb
|
|
20
|
+
# app/graphql/types/base_*.rb
|
|
21
|
+
# app/controllers/graphql_controller.rb
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# app/graphql/rubyn_schema.rb
|
|
26
|
+
class RubynSchema < GraphQL::Schema
|
|
27
|
+
query Types::QueryType
|
|
28
|
+
mutation Types::MutationType
|
|
29
|
+
|
|
30
|
+
# IMPORTANT: Set max complexity and depth to prevent abuse
|
|
31
|
+
max_complexity 300
|
|
32
|
+
max_depth 15
|
|
33
|
+
default_max_page_size 25
|
|
34
|
+
|
|
35
|
+
# Use dataloader for N+1 prevention (replaces batch-loader gems)
|
|
36
|
+
use GraphQL::Dataloader
|
|
37
|
+
|
|
38
|
+
# Error handling
|
|
39
|
+
rescue_from(ActiveRecord::RecordNotFound) do |err, obj, args, ctx, field|
|
|
40
|
+
raise GraphQL::ExecutionError, "#{field.type.unwrap.graphql_name} not found"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
rescue_from(Pundit::NotAuthorizedError) do |err, obj, args, ctx, field|
|
|
44
|
+
raise GraphQL::ExecutionError, "Not authorized"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Gotcha #1: N+1 Queries Are Silent and Devastating
|
|
50
|
+
|
|
51
|
+
GraphQL's nested structure naturally creates N+1 queries. A query for 25 orders with their users and line items can generate 75+ queries if you're not careful.
|
|
52
|
+
|
|
53
|
+
```graphql
|
|
54
|
+
# This innocent query causes N+1 hell
|
|
55
|
+
{
|
|
56
|
+
orders(first: 25) {
|
|
57
|
+
nodes {
|
|
58
|
+
id
|
|
59
|
+
total
|
|
60
|
+
user { # N queries for users
|
|
61
|
+
name
|
|
62
|
+
email
|
|
63
|
+
}
|
|
64
|
+
lineItems { # N queries for line items
|
|
65
|
+
product { # N*M queries for products
|
|
66
|
+
name
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**WRONG: Direct association access in type resolvers**
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
# app/graphql/types/order_type.rb
|
|
78
|
+
class Types::OrderType < Types::BaseObject
|
|
79
|
+
field :user, Types::UserType, null: false
|
|
80
|
+
field :line_items, [Types::LineItemType], null: false
|
|
81
|
+
|
|
82
|
+
# These default resolvers call order.user and order.line_items
|
|
83
|
+
# Each call is a separate query — N+1!
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**RIGHT: Use GraphQL::Dataloader (built-in since graphql-ruby 2.0)**
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
# app/graphql/sources/record_source.rb
|
|
91
|
+
class Sources::RecordSource < GraphQL::Dataloader::Source
|
|
92
|
+
def initialize(model_class, column: :id)
|
|
93
|
+
@model_class = model_class
|
|
94
|
+
@column = column
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def fetch(ids)
|
|
98
|
+
records = @model_class.where(@column => ids)
|
|
99
|
+
ids.map { |id| records.find { |r| r.public_send(@column) == id } }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# app/graphql/sources/association_source.rb
|
|
104
|
+
class Sources::AssociationSource < GraphQL::Dataloader::Source
|
|
105
|
+
def initialize(model_class, association_name)
|
|
106
|
+
@model_class = model_class
|
|
107
|
+
@association_name = association_name
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def fetch(records)
|
|
111
|
+
ActiveRecord::Associations::Preloader.new(
|
|
112
|
+
records: records,
|
|
113
|
+
associations: @association_name
|
|
114
|
+
).call
|
|
115
|
+
|
|
116
|
+
records.map { |record| record.public_send(@association_name) }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# app/graphql/types/order_type.rb
|
|
121
|
+
class Types::OrderType < Types::BaseObject
|
|
122
|
+
field :user, Types::UserType, null: false
|
|
123
|
+
field :line_items, [Types::LineItemType], null: false
|
|
124
|
+
|
|
125
|
+
def user
|
|
126
|
+
dataloader.with(Sources::RecordSource, User).load(object.user_id)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def line_items
|
|
130
|
+
dataloader.with(Sources::AssociationSource, Order, :line_items).load(object)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**The trap:** Everything works in development with 5 records. In production with 25 records per page, the query takes 3 seconds and makes 200 database calls. Always check your query count with `bullet` or query logs.
|
|
136
|
+
|
|
137
|
+
## Gotcha #2: Authorization — Don't Trust the Client
|
|
138
|
+
|
|
139
|
+
GraphQL clients can query any field in the schema. Authorization must happen at the field/type level, not just at the query root.
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
# WRONG: Only checking auth at the query root
|
|
143
|
+
class Types::QueryType < Types::BaseObject
|
|
144
|
+
field :orders, Types::OrderType.connection_type, null: false
|
|
145
|
+
|
|
146
|
+
def orders
|
|
147
|
+
# Checks that user is logged in... but returns ALL orders
|
|
148
|
+
raise GraphQL::ExecutionError, "Not authenticated" unless context[:current_user]
|
|
149
|
+
Order.all # SECURITY HOLE: User sees everyone's orders
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# RIGHT: Scope queries AND authorize individual records
|
|
154
|
+
class Types::QueryType < Types::BaseObject
|
|
155
|
+
field :orders, Types::OrderType.connection_type, null: false
|
|
156
|
+
|
|
157
|
+
def orders
|
|
158
|
+
raise GraphQL::ExecutionError, "Not authenticated" unless context[:current_user]
|
|
159
|
+
OrderPolicy::Scope.new(context[:current_user], Order).resolve.order(created_at: :desc)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# RIGHT: Authorize field-level access for sensitive fields
|
|
164
|
+
class Types::UserType < Types::BaseObject
|
|
165
|
+
field :email, String, null: false
|
|
166
|
+
field :credit_balance, Integer, null: false
|
|
167
|
+
|
|
168
|
+
# Only show email to the user themselves or admins
|
|
169
|
+
def email
|
|
170
|
+
if context[:current_user] == object || context[:current_user]&.admin?
|
|
171
|
+
object.email
|
|
172
|
+
else
|
|
173
|
+
raise GraphQL::ExecutionError, "Not authorized to view email"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Only show credit balance to the user themselves
|
|
178
|
+
def credit_balance
|
|
179
|
+
raise GraphQL::ExecutionError, "Not authorized" unless context[:current_user] == object
|
|
180
|
+
object.credit_balance
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**The trap:** A user queries `{ users { email creditBalance } }` and sees everyone's email and credit balance. Field-level auth is essential because clients control which fields they request.
|
|
186
|
+
|
|
187
|
+
## Gotcha #3: Context Setup in the Controller
|
|
188
|
+
|
|
189
|
+
The `context` hash is your bridge between Rails and GraphQL. Set it up once, use it everywhere.
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
# app/controllers/graphql_controller.rb
|
|
193
|
+
class GraphqlController < ApplicationController
|
|
194
|
+
skip_before_action :verify_authenticity_token # API endpoint, not a form
|
|
195
|
+
|
|
196
|
+
def execute
|
|
197
|
+
result = RubynSchema.execute(
|
|
198
|
+
params[:query],
|
|
199
|
+
variables: prepare_variables(params[:variables]),
|
|
200
|
+
context: {
|
|
201
|
+
current_user: current_user, # From Devise or your auth
|
|
202
|
+
request: request, # For IP, user agent
|
|
203
|
+
pundit_user: current_user # If using Pundit integration
|
|
204
|
+
},
|
|
205
|
+
operation_name: params[:operationName]
|
|
206
|
+
)
|
|
207
|
+
render json: result
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
handle_error(e)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def prepare_variables(variables_param)
|
|
215
|
+
case variables_param
|
|
216
|
+
when String then variables_param.present? ? JSON.parse(variables_param) : {}
|
|
217
|
+
when Hash then variables_param
|
|
218
|
+
when ActionController::Parameters then variables_param.to_unsafe_hash
|
|
219
|
+
when nil then {}
|
|
220
|
+
else raise ArgumentError, "Unexpected variables: #{variables_param}"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def handle_error(e)
|
|
225
|
+
Rails.logger.error("GraphQL Error: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
|
226
|
+
render json: { errors: [{ message: "Internal server error" }] }, status: :internal_server_error
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
**The trap:** `params[:variables]` can be a String (from a POST body), a Hash (from a JSON body), or ActionController::Parameters (from Rails). If you don't handle all three, queries with variables break intermittently depending on the client library.
|
|
232
|
+
|
|
233
|
+
## Gotcha #4: Mutations — Input Types and Error Handling
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
# WRONG: Returning generic error strings
|
|
237
|
+
class Mutations::CreateOrder < Mutations::BaseMutation
|
|
238
|
+
argument :shipping_address, String, required: true
|
|
239
|
+
|
|
240
|
+
field :order, Types::OrderType, null: true
|
|
241
|
+
|
|
242
|
+
def resolve(shipping_address:)
|
|
243
|
+
order = context[:current_user].orders.create!(shipping_address: shipping_address)
|
|
244
|
+
{ order: order }
|
|
245
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
246
|
+
raise GraphQL::ExecutionError, e.message # Lumps all errors into one string
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
```ruby
|
|
252
|
+
# RIGHT: Structured error responses with field-level errors
|
|
253
|
+
class Mutations::CreateOrder < Mutations::BaseMutation
|
|
254
|
+
argument :shipping_address, String, required: true
|
|
255
|
+
argument :note, String, required: false
|
|
256
|
+
|
|
257
|
+
field :order, Types::OrderType, null: true
|
|
258
|
+
field :errors, [Types::UserErrorType], null: false
|
|
259
|
+
|
|
260
|
+
def resolve(shipping_address:, note: nil)
|
|
261
|
+
authorize_action!
|
|
262
|
+
|
|
263
|
+
order = context[:current_user].orders.build(
|
|
264
|
+
shipping_address: shipping_address,
|
|
265
|
+
note: note
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if order.save
|
|
269
|
+
{ order: order, errors: [] }
|
|
270
|
+
else
|
|
271
|
+
{
|
|
272
|
+
order: nil,
|
|
273
|
+
errors: order.errors.map { |e|
|
|
274
|
+
{ field: e.attribute.to_s.camelize(:lower), message: e.full_message }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
private
|
|
281
|
+
|
|
282
|
+
def authorize_action!
|
|
283
|
+
raise GraphQL::ExecutionError, "Not authenticated" unless context[:current_user]
|
|
284
|
+
raise GraphQL::ExecutionError, "Insufficient credits" unless context[:current_user].credit_balance > 0
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# app/graphql/types/user_error_type.rb
|
|
289
|
+
class Types::UserErrorType < Types::BaseObject
|
|
290
|
+
field :field, String, null: true, description: "Which input field the error relates to"
|
|
291
|
+
field :message, String, null: false, description: "Human-readable error message"
|
|
292
|
+
end
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
**The trap:** `GraphQL::ExecutionError` puts errors in the top-level `errors` array, which clients typically treat as unexpected failures. Validation errors should be in the `data` response as structured fields, so the client can display them next to form fields.
|
|
296
|
+
|
|
297
|
+
## Gotcha #5: Connection Types and Pagination
|
|
298
|
+
|
|
299
|
+
```ruby
|
|
300
|
+
# WRONG: Returning a plain array — no pagination
|
|
301
|
+
field :orders, [Types::OrderType], null: false
|
|
302
|
+
def orders
|
|
303
|
+
Order.all # Loads every order into memory
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# RIGHT: Use connection types for automatic cursor-based pagination
|
|
307
|
+
field :orders, Types::OrderType.connection_type, null: false
|
|
308
|
+
|
|
309
|
+
def orders
|
|
310
|
+
policy_scope(Order).order(created_at: :desc)
|
|
311
|
+
# GraphQL handles first/last/before/after automatically
|
|
312
|
+
end
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
```graphql
|
|
316
|
+
# Client gets pagination for free
|
|
317
|
+
{
|
|
318
|
+
orders(first: 10, after: "MjA=") {
|
|
319
|
+
edges {
|
|
320
|
+
node {
|
|
321
|
+
id
|
|
322
|
+
total
|
|
323
|
+
}
|
|
324
|
+
cursor
|
|
325
|
+
}
|
|
326
|
+
pageInfo {
|
|
327
|
+
hasNextPage
|
|
328
|
+
endCursor
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
**The trap:** Without connection types, requesting 10,000 orders loads them all. Connection types enforce pagination and provide cursor-based navigation. Set `default_max_page_size` in your schema.
|
|
335
|
+
|
|
336
|
+
## Gotcha #6: Enum Types Must Match Exactly
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
# WRONG: String values that don't match DB enum
|
|
340
|
+
class Types::OrderStatusEnum < Types::BaseEnum
|
|
341
|
+
value "PENDING" # GraphQL convention is SCREAMING_SNAKE
|
|
342
|
+
value "CONFIRMED"
|
|
343
|
+
value "SHIPPED"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# But the DB stores "pending", "confirmed", "shipped"
|
|
347
|
+
# This silently doesn't match — filters return no results
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
```ruby
|
|
351
|
+
# RIGHT: Map GraphQL values to DB values
|
|
352
|
+
class Types::OrderStatusEnum < Types::BaseEnum
|
|
353
|
+
value "PENDING", value: "pending"
|
|
354
|
+
value "CONFIRMED", value: "confirmed"
|
|
355
|
+
value "SHIPPED", value: "shipped"
|
|
356
|
+
value "DELIVERED", value: "delivered"
|
|
357
|
+
value "CANCELLED", value: "cancelled"
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Usage in a query argument
|
|
361
|
+
field :orders, Types::OrderType.connection_type, null: false do
|
|
362
|
+
argument :status, Types::OrderStatusEnum, required: false
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def orders(status: nil)
|
|
366
|
+
scope = policy_scope(Order)
|
|
367
|
+
scope = scope.where(status: status) if status # status is now "pending", not "PENDING"
|
|
368
|
+
scope.order(created_at: :desc)
|
|
369
|
+
end
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
## Gotcha #7: Circular Type References
|
|
373
|
+
|
|
374
|
+
Types that reference each other (User has Orders, Order has User) can cause load-order issues.
|
|
375
|
+
|
|
376
|
+
```ruby
|
|
377
|
+
# This can cause "uninitialized constant Types::OrderType" if load order is wrong
|
|
378
|
+
class Types::UserType < Types::BaseObject
|
|
379
|
+
field :orders, [Types::OrderType], null: false # OrderType may not be loaded yet
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# FIX: Use a string or lambda for lazy resolution
|
|
383
|
+
class Types::UserType < Types::BaseObject
|
|
384
|
+
field :orders, [Types::OrderType], null: false
|
|
385
|
+
# graphql-ruby handles circular references automatically in modern versions
|
|
386
|
+
# But if you get load errors, use:
|
|
387
|
+
field :orders, ["Types::OrderType"], null: false # String reference, resolved lazily
|
|
388
|
+
end
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
## Gotcha #8: Testing GraphQL
|
|
392
|
+
|
|
393
|
+
```ruby
|
|
394
|
+
# spec/support/graphql_helpers.rb
|
|
395
|
+
module GraphqlHelpers
|
|
396
|
+
def execute_query(query, variables: {}, user: nil)
|
|
397
|
+
RubynSchema.execute(
|
|
398
|
+
query,
|
|
399
|
+
variables: variables,
|
|
400
|
+
context: { current_user: user }
|
|
401
|
+
)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def graphql_data(result)
|
|
405
|
+
result["data"]
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def graphql_errors(result)
|
|
409
|
+
result["errors"]
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
RSpec.configure do |config|
|
|
414
|
+
config.include GraphqlHelpers, type: :graphql
|
|
415
|
+
end
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
```ruby
|
|
419
|
+
# spec/graphql/queries/orders_query_spec.rb
|
|
420
|
+
RSpec.describe "orders query", type: :graphql do
|
|
421
|
+
let(:user) { create(:user) }
|
|
422
|
+
let!(:orders) { create_list(:order, 3, user: user) }
|
|
423
|
+
let!(:other_order) { create(:order) }
|
|
424
|
+
|
|
425
|
+
let(:query) do
|
|
426
|
+
<<~GQL
|
|
427
|
+
query($first: Int) {
|
|
428
|
+
orders(first: $first) {
|
|
429
|
+
nodes {
|
|
430
|
+
id
|
|
431
|
+
total
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
GQL
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
it "returns only the user's orders" do
|
|
439
|
+
result = execute_query(query, variables: { first: 10 }, user: user)
|
|
440
|
+
|
|
441
|
+
expect(graphql_errors(result)).to be_nil
|
|
442
|
+
nodes = graphql_data(result).dig("orders", "nodes")
|
|
443
|
+
expect(nodes.length).to eq(3)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
it "requires authentication" do
|
|
447
|
+
result = execute_query(query, user: nil)
|
|
448
|
+
expect(graphql_errors(result).first["message"]).to include("Not authenticated")
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
```ruby
|
|
454
|
+
# spec/graphql/mutations/create_order_mutation_spec.rb
|
|
455
|
+
RSpec.describe "createOrder mutation", type: :graphql do
|
|
456
|
+
let(:user) { create(:user, credit_balance: 100) }
|
|
457
|
+
|
|
458
|
+
let(:mutation) do
|
|
459
|
+
<<~GQL
|
|
460
|
+
mutation($input: CreateOrderInput!) {
|
|
461
|
+
createOrder(input: $input) {
|
|
462
|
+
order {
|
|
463
|
+
id
|
|
464
|
+
shippingAddress
|
|
465
|
+
}
|
|
466
|
+
errors {
|
|
467
|
+
field
|
|
468
|
+
message
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
GQL
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
it "creates an order" do
|
|
476
|
+
result = execute_query(mutation, variables: {
|
|
477
|
+
input: { shippingAddress: "123 Main St" }
|
|
478
|
+
}, user: user)
|
|
479
|
+
|
|
480
|
+
data = graphql_data(result)["createOrder"]
|
|
481
|
+
expect(data["errors"]).to be_empty
|
|
482
|
+
expect(data["order"]["shippingAddress"]).to eq("123 Main St")
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
it "returns validation errors" do
|
|
486
|
+
result = execute_query(mutation, variables: {
|
|
487
|
+
input: { shippingAddress: "" }
|
|
488
|
+
}, user: user)
|
|
489
|
+
|
|
490
|
+
data = graphql_data(result)["createOrder"]
|
|
491
|
+
expect(data["order"]).to be_nil
|
|
492
|
+
expect(data["errors"].first["field"]).to eq("shippingAddress")
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
## Do's and Don'ts Summary
|
|
498
|
+
|
|
499
|
+
**DO:**
|
|
500
|
+
- Use `GraphQL::Dataloader` for every association — N+1 is the default without it
|
|
501
|
+
- Set `max_complexity`, `max_depth`, and `default_max_page_size` to prevent abuse
|
|
502
|
+
- Use connection types for all list fields
|
|
503
|
+
- Return structured errors from mutations (not just `GraphQL::ExecutionError`)
|
|
504
|
+
- Authorize at field level, not just query level
|
|
505
|
+
- Handle all `variables` formats in the controller (String, Hash, ActionController::Parameters)
|
|
506
|
+
|
|
507
|
+
**DON'T:**
|
|
508
|
+
- Don't return plain arrays — use connection types for pagination
|
|
509
|
+
- Don't trust that clients only request authorized fields — enforce it server-side
|
|
510
|
+
- Don't use `GraphQL::ExecutionError` for validation errors — use structured error fields
|
|
511
|
+
- Don't forget `skip_before_action :verify_authenticity_token` on the GraphQL controller
|
|
512
|
+
- Don't define enum values without mapping to DB values
|
|
513
|
+
- Don't load records without scoping to the current user first
|
|
514
|
+
- Don't put business logic in resolvers — delegate to service objects
|