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,146 @@
|
|
|
1
|
+
# Ruby: Rake Tasks
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Rake is Ruby's task runner. Organize tasks in namespaces, document them with descriptions, and keep task bodies thin by delegating to service objects or scripts.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Rakefile
|
|
9
|
+
require_relative "config/environment"
|
|
10
|
+
|
|
11
|
+
# Import tasks from lib/tasks/
|
|
12
|
+
Dir[File.join(__dir__, "lib", "tasks", "**", "*.rake")].each { |f| load f }
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# lib/tasks/db.rake
|
|
17
|
+
namespace :db do
|
|
18
|
+
desc "Sync best practice documents to database and generate embeddings"
|
|
19
|
+
task sync_best_practices: :environment do
|
|
20
|
+
puts "Syncing best practices..."
|
|
21
|
+
result = BestPractices::SyncService.call
|
|
22
|
+
puts "Synced #{result.created} new, updated #{result.updated}, removed #{result.removed}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "Backfill embeddings for documents missing them"
|
|
26
|
+
task backfill_embeddings: :environment do
|
|
27
|
+
documents = BestPracticeDocument.where(embedding: nil)
|
|
28
|
+
puts "Backfilling #{documents.count} documents..."
|
|
29
|
+
|
|
30
|
+
documents.find_each do |doc|
|
|
31
|
+
Embeddings::DocumentEmbedder.call(doc)
|
|
32
|
+
print "."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
puts "\nDone!"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
desc "Reset all embeddings (re-embed everything)"
|
|
39
|
+
task reset_embeddings: :environment do
|
|
40
|
+
abort("This will delete all embeddings. Run with CONFIRM=true") unless ENV["CONFIRM"] == "true"
|
|
41
|
+
|
|
42
|
+
CodeEmbedding.delete_all
|
|
43
|
+
BestPracticeDocument.update_all(embedding: nil, last_embedded_at: nil)
|
|
44
|
+
puts "All embeddings cleared. Run db:backfill_embeddings to regenerate."
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# lib/tasks/credits.rake
|
|
51
|
+
namespace :credits do
|
|
52
|
+
desc "Report credit usage for the current month"
|
|
53
|
+
task monthly_report: :environment do
|
|
54
|
+
report = Credits::MonthlyReportService.call(Date.current)
|
|
55
|
+
|
|
56
|
+
puts "=== Credit Report: #{Date.current.strftime('%B %Y')} ==="
|
|
57
|
+
puts "Total users: #{report.active_users}"
|
|
58
|
+
puts "Total credits used: #{report.total_credits}"
|
|
59
|
+
puts "Total revenue: $#{format('%.2f', report.revenue / 100.0)}"
|
|
60
|
+
puts "Average credits/user: #{report.avg_per_user}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
desc "Grant credits to a user (usage: rake credits:grant USER_ID=1 AMOUNT=100)"
|
|
64
|
+
task grant: :environment do
|
|
65
|
+
user_id = ENV.fetch("USER_ID") { abort "USER_ID required" }
|
|
66
|
+
amount = ENV.fetch("AMOUNT") { abort "AMOUNT required" }.to_i
|
|
67
|
+
abort "AMOUNT must be positive" unless amount > 0
|
|
68
|
+
|
|
69
|
+
user = User.find(user_id)
|
|
70
|
+
user.credit_ledger_entries.create!(amount: amount, description: "Manual grant via rake")
|
|
71
|
+
puts "Granted #{amount} credits to #{user.email}. New balance: #{user.credit_balance}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
# lib/tasks/data.rake
|
|
78
|
+
namespace :data do
|
|
79
|
+
desc "Import orders from CSV (usage: rake data:import_orders FILE=orders.csv)"
|
|
80
|
+
task import_orders: :environment do
|
|
81
|
+
file = ENV.fetch("FILE") { abort "FILE required" }
|
|
82
|
+
abort "File not found: #{file}" unless File.exist?(file)
|
|
83
|
+
|
|
84
|
+
imported = 0
|
|
85
|
+
errors = 0
|
|
86
|
+
|
|
87
|
+
CSV.foreach(file, headers: true) do |row|
|
|
88
|
+
result = Orders::ImportService.call(row.to_h)
|
|
89
|
+
if result.success?
|
|
90
|
+
imported += 1
|
|
91
|
+
else
|
|
92
|
+
errors += 1
|
|
93
|
+
puts "Row #{$.}: #{result.error}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
puts "Imported: #{imported}, Errors: #{errors}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Default Task and Test Task
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
# Rakefile
|
|
106
|
+
require "rake/testtask"
|
|
107
|
+
|
|
108
|
+
Rake::TestTask.new(:test) do |t|
|
|
109
|
+
t.libs << "test"
|
|
110
|
+
t.libs << "lib"
|
|
111
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
task default: :test
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Why This Is Good
|
|
118
|
+
|
|
119
|
+
- **`desc` makes tasks discoverable.** `rake -T` lists all tasks with descriptions. Undocumented tasks are invisible.
|
|
120
|
+
- **Namespaces organize tasks.** `rake db:sync_best_practices`, `rake credits:grant`, `rake data:import_orders` — clear, grouped, no collisions.
|
|
121
|
+
- **ENV parameters for input.** `USER_ID=1 AMOUNT=100 rake credits:grant` is explicit and scriptable. No interactive prompts.
|
|
122
|
+
- **Task bodies are thin.** The task calls a service object. The service contains the logic, is testable, and reusable outside of rake.
|
|
123
|
+
- **Safety guards for destructive tasks.** `abort unless ENV["CONFIRM"]` prevents accidental data deletion.
|
|
124
|
+
|
|
125
|
+
## Anti-Pattern
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
# BAD: Business logic inside the rake task
|
|
129
|
+
task :process_orders do
|
|
130
|
+
Order.where(status: :pending).each do |order|
|
|
131
|
+
order.line_items.each do |item|
|
|
132
|
+
product = Product.find(item.product_id)
|
|
133
|
+
product.update!(stock: product.stock - item.quantity)
|
|
134
|
+
end
|
|
135
|
+
order.update!(status: :confirmed)
|
|
136
|
+
OrderMailer.confirmation(order).deliver_now
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
# 10 lines of untestable, unreusable business logic
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## When To Apply
|
|
143
|
+
|
|
144
|
+
- **Every operational task.** Data migrations, reporting, manual operations, maintenance scripts.
|
|
145
|
+
- **One-off tasks stay in Rake.** Don't build an admin UI for a task you'll run once.
|
|
146
|
+
- **Keep the body under 10 lines.** If it's longer, extract to a service object.
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# Ruby: Project Structure
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Whether you're building a gem, a CLI tool, or a library, follow Ruby conventions for directory layout, naming, and require paths. Use Bundler's gem skeleton as the starting point.
|
|
6
|
+
|
|
7
|
+
### Standard Ruby Gem / Library Layout
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
my_gem/
|
|
11
|
+
├── lib/
|
|
12
|
+
│ ├── my_gem.rb # Main entry point, requires sub-files
|
|
13
|
+
│ └── my_gem/
|
|
14
|
+
│ ├── version.rb
|
|
15
|
+
│ ├── configuration.rb
|
|
16
|
+
│ ├── client.rb
|
|
17
|
+
│ ├── models/
|
|
18
|
+
│ │ ├── order.rb
|
|
19
|
+
│ │ └── user.rb
|
|
20
|
+
│ └── errors.rb
|
|
21
|
+
├── test/ # or spec/
|
|
22
|
+
│ ├── test_helper.rb
|
|
23
|
+
│ ├── my_gem/
|
|
24
|
+
│ │ ├── client_test.rb
|
|
25
|
+
│ │ └── models/
|
|
26
|
+
│ │ └── order_test.rb
|
|
27
|
+
│ └── integration/
|
|
28
|
+
│ └── api_test.rb
|
|
29
|
+
├── bin/
|
|
30
|
+
│ └── my_gem # CLI executable (if applicable)
|
|
31
|
+
├── Gemfile
|
|
32
|
+
├── Rakefile
|
|
33
|
+
├── my_gem.gemspec
|
|
34
|
+
├── README.md
|
|
35
|
+
├── LICENSE.txt
|
|
36
|
+
└── CHANGELOG.md
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### The Main Entry Point
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# lib/my_gem.rb
|
|
43
|
+
require_relative "my_gem/version"
|
|
44
|
+
require_relative "my_gem/configuration"
|
|
45
|
+
require_relative "my_gem/errors"
|
|
46
|
+
require_relative "my_gem/client"
|
|
47
|
+
|
|
48
|
+
module MyGem
|
|
49
|
+
class << self
|
|
50
|
+
attr_accessor :configuration
|
|
51
|
+
|
|
52
|
+
def configure
|
|
53
|
+
self.configuration ||= Configuration.new
|
|
54
|
+
yield(configuration) if block_given?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def reset!
|
|
58
|
+
self.configuration = Configuration.new
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
# lib/my_gem/version.rb
|
|
66
|
+
module MyGem
|
|
67
|
+
VERSION = "1.0.0"
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
# lib/my_gem/configuration.rb
|
|
73
|
+
module MyGem
|
|
74
|
+
class Configuration
|
|
75
|
+
attr_accessor :api_key, :base_url, :timeout, :logger
|
|
76
|
+
|
|
77
|
+
def initialize
|
|
78
|
+
@base_url = "https://api.example.com"
|
|
79
|
+
@timeout = 30
|
|
80
|
+
@logger = Logger.new($stdout)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
# lib/my_gem/errors.rb
|
|
88
|
+
module MyGem
|
|
89
|
+
class Error < StandardError; end
|
|
90
|
+
class AuthenticationError < Error; end
|
|
91
|
+
class RateLimitError < Error; end
|
|
92
|
+
class ApiError < Error
|
|
93
|
+
attr_reader :status, :body
|
|
94
|
+
def initialize(message, status:, body: nil)
|
|
95
|
+
@status = status
|
|
96
|
+
@body = body
|
|
97
|
+
super(message)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
# lib/my_gem/client.rb
|
|
105
|
+
require "faraday"
|
|
106
|
+
require "json"
|
|
107
|
+
|
|
108
|
+
module MyGem
|
|
109
|
+
class Client
|
|
110
|
+
def initialize(api_key: nil, base_url: nil)
|
|
111
|
+
config = MyGem.configuration || Configuration.new
|
|
112
|
+
@api_key = api_key || config.api_key
|
|
113
|
+
@base_url = base_url || config.base_url
|
|
114
|
+
@conn = build_connection
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def get_order(id)
|
|
118
|
+
response = @conn.get("/orders/#{id}")
|
|
119
|
+
handle_response(response)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def create_order(params)
|
|
123
|
+
response = @conn.post("/orders", params.to_json)
|
|
124
|
+
handle_response(response)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def build_connection
|
|
130
|
+
Faraday.new(url: @base_url) do |f|
|
|
131
|
+
f.request :json
|
|
132
|
+
f.response :json
|
|
133
|
+
f.headers["Authorization"] = "Bearer #{@api_key}"
|
|
134
|
+
f.options.timeout = MyGem.configuration&.timeout || 30
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def handle_response(response)
|
|
139
|
+
case response.status
|
|
140
|
+
when 200..299 then response.body
|
|
141
|
+
when 401 then raise AuthenticationError, "Invalid API key"
|
|
142
|
+
when 429 then raise RateLimitError, "Rate limited"
|
|
143
|
+
else raise ApiError.new("API error", status: response.status, body: response.body)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Usage
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
# Configuration (once, at boot)
|
|
154
|
+
MyGem.configure do |config|
|
|
155
|
+
config.api_key = ENV["MY_GEM_API_KEY"]
|
|
156
|
+
config.timeout = 60
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Usage
|
|
160
|
+
client = MyGem::Client.new
|
|
161
|
+
order = client.get_order(123)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### The Gemspec
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
# my_gem.gemspec
|
|
168
|
+
Gem::Specification.new do |spec|
|
|
169
|
+
spec.name = "my_gem"
|
|
170
|
+
spec.version = MyGem::VERSION
|
|
171
|
+
spec.authors = ["Your Name"]
|
|
172
|
+
spec.email = ["you@example.com"]
|
|
173
|
+
spec.summary = "A Ruby client for the Example API"
|
|
174
|
+
spec.homepage = "https://github.com/you/my_gem"
|
|
175
|
+
spec.license = "MIT"
|
|
176
|
+
spec.required_ruby_version = ">= 3.1"
|
|
177
|
+
|
|
178
|
+
spec.files = Dir["lib/**/*", "LICENSE.txt", "README.md"]
|
|
179
|
+
spec.require_paths = ["lib"]
|
|
180
|
+
|
|
181
|
+
spec.add_dependency "faraday", "~> 2.0"
|
|
182
|
+
|
|
183
|
+
# Dev dependencies in Gemfile, not gemspec (modern convention)
|
|
184
|
+
end
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Testing (Minitest)
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
# test/test_helper.rb
|
|
191
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
192
|
+
|
|
193
|
+
require "my_gem"
|
|
194
|
+
require "minitest/autorun"
|
|
195
|
+
require "minitest/pride"
|
|
196
|
+
require "webmock/minitest"
|
|
197
|
+
|
|
198
|
+
# Configure for tests
|
|
199
|
+
MyGem.configure do |config|
|
|
200
|
+
config.api_key = "test-key"
|
|
201
|
+
config.base_url = "https://api.example.com"
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
# test/my_gem/client_test.rb
|
|
207
|
+
require "test_helper"
|
|
208
|
+
|
|
209
|
+
class MyGem::ClientTest < Minitest::Test
|
|
210
|
+
def setup
|
|
211
|
+
@client = MyGem::Client.new
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def test_get_order
|
|
215
|
+
stub_request(:get, "https://api.example.com/orders/123")
|
|
216
|
+
.to_return(status: 200, body: { id: 123, status: "pending" }.to_json, headers: { "Content-Type" => "application/json" })
|
|
217
|
+
|
|
218
|
+
result = @client.get_order(123)
|
|
219
|
+
|
|
220
|
+
assert_equal 123, result["id"]
|
|
221
|
+
assert_equal "pending", result["status"]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def test_raises_on_auth_failure
|
|
225
|
+
stub_request(:get, "https://api.example.com/orders/123")
|
|
226
|
+
.to_return(status: 401)
|
|
227
|
+
|
|
228
|
+
assert_raises MyGem::AuthenticationError do
|
|
229
|
+
@client.get_order(123)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def test_raises_on_rate_limit
|
|
234
|
+
stub_request(:get, "https://api.example.com/orders/123")
|
|
235
|
+
.to_return(status: 429)
|
|
236
|
+
|
|
237
|
+
assert_raises MyGem::RateLimitError do
|
|
238
|
+
@client.get_order(123)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## Why This Is Good
|
|
245
|
+
|
|
246
|
+
- **Convention over configuration.** `lib/my_gem.rb` → `require "my_gem"`. `lib/my_gem/client.rb` → `MyGem::Client`. The directory structure maps to the module structure.
|
|
247
|
+
- **Block configuration is idiomatic Ruby.** `MyGem.configure { |c| c.api_key = "..." }` is the pattern every Rubyist expects.
|
|
248
|
+
- **Custom error hierarchy.** `rescue MyGem::Error` catches all gem errors. `rescue MyGem::RateLimitError` catches specific ones. Clean, selective handling.
|
|
249
|
+
- **Dependency injection via constructor.** `Client.new(api_key: custom_key)` overrides config for testing. The default reads from global config for convenience.
|
|
250
|
+
- **Development dependencies in Gemfile.** Modern convention keeps dev deps out of the gemspec, keeping the gem lightweight for consumers.
|
|
251
|
+
|
|
252
|
+
## When To Apply
|
|
253
|
+
|
|
254
|
+
- **Every Ruby library, gem, or standalone project.** This structure scales from 3 files to 300.
|
|
255
|
+
- **CLI tools.** Add `exe/` or `bin/` directory with the executable script. Use `Thor` or `OptionParser` for argument parsing.
|
|
256
|
+
- **Internal company gems.** Same structure as public gems. Publish to a private gem server (Gemfury, GitHub Packages).
|
|
257
|
+
|
|
258
|
+
## When NOT To Apply
|
|
259
|
+
|
|
260
|
+
- **Rails apps.** Rails has its own conventions (`app/`, `config/`, `db/`). Don't fight them.
|
|
261
|
+
- **Single-file scripts.** A 50-line utility script doesn't need a gem structure. Just use `#!/usr/bin/env ruby` and run it.
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Sinatra: Application Structure
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Sinatra apps should be structured for clarity and growth. Small apps can use a single file. Anything beyond a prototype should use the modular style (`Sinatra::Base` subclass) with extracted helpers, services, and a clear directory layout.
|
|
6
|
+
|
|
7
|
+
### Single-File (Prototypes Only)
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# app.rb
|
|
11
|
+
require "sinatra"
|
|
12
|
+
require "json"
|
|
13
|
+
|
|
14
|
+
get "/health" do
|
|
15
|
+
content_type :json
|
|
16
|
+
{ status: "ok", timestamp: Time.now.iso8601 }.to_json
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
get "/orders/:id" do
|
|
20
|
+
order = Order.find(params[:id])
|
|
21
|
+
halt 404, { error: "Not found" }.to_json unless order
|
|
22
|
+
|
|
23
|
+
content_type :json
|
|
24
|
+
order.to_json
|
|
25
|
+
end
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Modular Style (Recommended)
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
my_app/
|
|
32
|
+
├── Gemfile
|
|
33
|
+
├── config.ru
|
|
34
|
+
├── config/
|
|
35
|
+
│ ├── database.yml
|
|
36
|
+
│ └── environment.rb
|
|
37
|
+
├── app/
|
|
38
|
+
│ ├── api.rb # Main Sinatra app
|
|
39
|
+
│ ├── routes/
|
|
40
|
+
│ │ ├── orders.rb
|
|
41
|
+
│ │ ├── users.rb
|
|
42
|
+
│ │ └── health.rb
|
|
43
|
+
│ ├── models/
|
|
44
|
+
│ │ ├── order.rb
|
|
45
|
+
│ │ └── user.rb
|
|
46
|
+
│ ├── services/
|
|
47
|
+
│ │ └── orders/
|
|
48
|
+
│ │ └── create_service.rb
|
|
49
|
+
│ └── helpers/
|
|
50
|
+
│ ├── auth_helper.rb
|
|
51
|
+
│ └── json_helper.rb
|
|
52
|
+
├── db/
|
|
53
|
+
│ └── migrate/
|
|
54
|
+
├── test/
|
|
55
|
+
│ ├── test_helper.rb
|
|
56
|
+
│ ├── routes/
|
|
57
|
+
│ │ └── orders_test.rb
|
|
58
|
+
│ └── services/
|
|
59
|
+
│ └── orders/
|
|
60
|
+
│ └── create_service_test.rb
|
|
61
|
+
└── Rakefile
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
# config.ru
|
|
66
|
+
require_relative "config/environment"
|
|
67
|
+
run MyApp::Api
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
# config/environment.rb
|
|
72
|
+
require "bundler/setup"
|
|
73
|
+
Bundler.require(:default, ENV.fetch("RACK_ENV", "development").to_sym)
|
|
74
|
+
|
|
75
|
+
require "sinatra/base"
|
|
76
|
+
require "sinatra/json"
|
|
77
|
+
require "sinatra/activerecord"
|
|
78
|
+
|
|
79
|
+
# Load app files
|
|
80
|
+
Dir[File.join(__dir__, "..", "app", "models", "*.rb")].each { |f| require f }
|
|
81
|
+
Dir[File.join(__dir__, "..", "app", "services", "**", "*.rb")].each { |f| require f }
|
|
82
|
+
Dir[File.join(__dir__, "..", "app", "helpers", "*.rb")].each { |f| require f }
|
|
83
|
+
|
|
84
|
+
require_relative "../app/api"
|
|
85
|
+
Dir[File.join(__dir__, "..", "app", "routes", "*.rb")].each { |f| require f }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
# app/api.rb
|
|
90
|
+
module MyApp
|
|
91
|
+
class Api < Sinatra::Base
|
|
92
|
+
register Sinatra::ActiveRecordExtension
|
|
93
|
+
|
|
94
|
+
# Configuration
|
|
95
|
+
configure do
|
|
96
|
+
set :database_file, "config/database.yml"
|
|
97
|
+
set :show_exceptions, false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
configure :development do
|
|
101
|
+
set :show_exceptions, :after_handler
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Middleware
|
|
105
|
+
use Rack::JSONBodyParser
|
|
106
|
+
|
|
107
|
+
# Global helpers
|
|
108
|
+
helpers AuthHelper
|
|
109
|
+
helpers JsonHelper
|
|
110
|
+
|
|
111
|
+
# Error handling
|
|
112
|
+
error ActiveRecord::RecordNotFound do
|
|
113
|
+
halt 404, json_error("Not found")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
error ActiveRecord::RecordInvalid do |e|
|
|
117
|
+
halt 422, json_error("Validation failed", details: e.record.errors.full_messages)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
error do |e|
|
|
121
|
+
logger.error("#{e.class}: #{e.message}")
|
|
122
|
+
halt 500, json_error("Internal server error")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
# app/helpers/auth_helper.rb
|
|
130
|
+
module AuthHelper
|
|
131
|
+
def authenticate!
|
|
132
|
+
token = request.env["HTTP_AUTHORIZATION"]&.delete_prefix("Bearer ")
|
|
133
|
+
halt 401, json_error("Unauthorized") unless token
|
|
134
|
+
|
|
135
|
+
@current_user = User.find_by_api_token(token)
|
|
136
|
+
halt 401, json_error("Invalid token") unless @current_user
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def current_user
|
|
140
|
+
@current_user
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# app/helpers/json_helper.rb
|
|
145
|
+
module JsonHelper
|
|
146
|
+
def json_response(data, status: 200)
|
|
147
|
+
content_type :json
|
|
148
|
+
halt status, data.to_json
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def json_error(message, status: nil, details: nil)
|
|
152
|
+
content_type :json
|
|
153
|
+
body = { error: message }
|
|
154
|
+
body[:details] = details if details
|
|
155
|
+
body.to_json
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
# app/routes/orders.rb
|
|
162
|
+
module MyApp
|
|
163
|
+
class Api
|
|
164
|
+
# Routes grouped by resource
|
|
165
|
+
before "/orders*" do
|
|
166
|
+
authenticate!
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
get "/orders" do
|
|
170
|
+
orders = current_user.orders.order(created_at: :desc)
|
|
171
|
+
json_response(orders: orders.map(&:as_json))
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
get "/orders/:id" do
|
|
175
|
+
order = current_user.orders.find(params[:id])
|
|
176
|
+
json_response(order: order.as_json)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
post "/orders" do
|
|
180
|
+
result = Orders::CreateService.call(parsed_body, current_user)
|
|
181
|
+
|
|
182
|
+
if result.success?
|
|
183
|
+
json_response({ order: result.order.as_json }, status: 201)
|
|
184
|
+
else
|
|
185
|
+
halt 422, json_error("Creation failed", details: result.errors)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
delete "/orders/:id" do
|
|
190
|
+
order = current_user.orders.find(params[:id])
|
|
191
|
+
order.destroy!
|
|
192
|
+
json_response({ deleted: true })
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
def parsed_body
|
|
198
|
+
JSON.parse(request.body.read, symbolize_names: true)
|
|
199
|
+
rescue JSON::ParserError
|
|
200
|
+
halt 400, json_error("Invalid JSON")
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Why This Is Good
|
|
207
|
+
|
|
208
|
+
- **Modular `Sinatra::Base` subclass.** The app is a class, not a script. It can be tested, mounted in Rack, and composed with other apps.
|
|
209
|
+
- **Routes in separate files.** Each resource has its own file. Adding a new resource means adding a new file, not editing a growing monolith.
|
|
210
|
+
- **Extracted helpers.** Auth and JSON helpers are reusable modules, not inline code in every route.
|
|
211
|
+
- **Centralized error handling.** `error ActiveRecord::RecordNotFound` handles 404s globally. No `begin/rescue` in every route.
|
|
212
|
+
- **Same service object pattern as Rails.** `Orders::CreateService.call(params, user)` works identically whether it's called from a Sinatra route or a Rails controller.
|
|
213
|
+
|
|
214
|
+
## Anti-Pattern
|
|
215
|
+
|
|
216
|
+
A single-file Sinatra app that grows into a 500-line monster:
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
# BAD: Everything in one file
|
|
220
|
+
require "sinatra"
|
|
221
|
+
require "json"
|
|
222
|
+
|
|
223
|
+
# 50 lines of config
|
|
224
|
+
# 30 lines of helpers
|
|
225
|
+
# 100 lines of order routes
|
|
226
|
+
# 100 lines of user routes
|
|
227
|
+
# 80 lines of auth routes
|
|
228
|
+
# 50 lines of error handling
|
|
229
|
+
# 90 lines of inline business logic
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## When To Apply
|
|
233
|
+
|
|
234
|
+
- **Every Sinatra app beyond a prototype.** The modular structure takes 10 minutes to set up and prevents every future headache.
|
|
235
|
+
- **API services.** Sinatra is excellent for focused, single-purpose APIs (webhook receivers, microservices, lightweight proxies).
|
|
236
|
+
- **When Rails is too heavy.** Sinatra boots in milliseconds, has minimal dependencies, and is perfect for small services.
|
|
237
|
+
|
|
238
|
+
## When NOT To Apply
|
|
239
|
+
|
|
240
|
+
- **If you need forms, views, sessions, mailers, background jobs, and admin panels.** Use Rails. Sinatra can do all of these but you'll end up rebuilding half of Rails.
|
|
241
|
+
- **If the app will grow to 50+ routes.** Sinatra's simplicity becomes a liability at scale. Consider Rails or Hanami.
|