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,221 @@
|
|
|
1
|
+
# Sinatra: Middleware, Configuration, and Deployment
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Sinatra apps are Rack apps. Use Rack middleware for cross-cutting concerns, environment-specific configuration for different stages, and standard deployment patterns for production.
|
|
6
|
+
|
|
7
|
+
### Middleware Stack
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# app/api.rb
|
|
11
|
+
module MyApp
|
|
12
|
+
class Api < Sinatra::Base
|
|
13
|
+
# Request/Response middleware
|
|
14
|
+
use Rack::JSONBodyParser # Parse JSON bodies into params
|
|
15
|
+
use Rack::Cors do # CORS for API clients
|
|
16
|
+
allow do
|
|
17
|
+
origins "*"
|
|
18
|
+
resource "/api/*", headers: :any, methods: [:get, :post, :put, :delete]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Custom middleware
|
|
23
|
+
use RequestLogger # Log every request
|
|
24
|
+
use RateLimiter, limit: 100, period: 60 # 100 req/min
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# app/middleware/request_logger.rb
|
|
31
|
+
class RequestLogger
|
|
32
|
+
def initialize(app)
|
|
33
|
+
@app = app
|
|
34
|
+
@logger = Logger.new($stdout)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def call(env)
|
|
38
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
39
|
+
status, headers, body = @app.call(env)
|
|
40
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
41
|
+
|
|
42
|
+
@logger.info("#{env['REQUEST_METHOD']} #{env['PATH_INFO']} → #{status} (#{(elapsed * 1000).round}ms)")
|
|
43
|
+
|
|
44
|
+
[status, headers, body]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# app/middleware/rate_limiter.rb
|
|
51
|
+
class RateLimiter
|
|
52
|
+
def initialize(app, limit: 60, period: 60)
|
|
53
|
+
@app = app
|
|
54
|
+
@limit = limit
|
|
55
|
+
@period = period
|
|
56
|
+
@store = {} # Use Redis in production
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def call(env)
|
|
60
|
+
key = client_key(env)
|
|
61
|
+
count = increment(key)
|
|
62
|
+
|
|
63
|
+
if count > @limit
|
|
64
|
+
[429, { "Content-Type" => "application/json", "Retry-After" => @period.to_s },
|
|
65
|
+
['{"error":"Rate limited"}']]
|
|
66
|
+
else
|
|
67
|
+
@app.call(env)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def client_key(env)
|
|
74
|
+
ip = env["HTTP_X_FORWARDED_FOR"]&.split(",")&.first || env["REMOTE_ADDR"]
|
|
75
|
+
token = env["HTTP_AUTHORIZATION"]&.split(" ")&.last
|
|
76
|
+
"rate:#{token || ip}:#{(Time.now.to_i / @period)}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def increment(key)
|
|
80
|
+
@store[key] = (@store[key] || 0) + 1
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Environment Configuration
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
# app/api.rb
|
|
89
|
+
module MyApp
|
|
90
|
+
class Api < Sinatra::Base
|
|
91
|
+
configure do
|
|
92
|
+
set :root, File.dirname(__FILE__)
|
|
93
|
+
set :views, File.join(root, "views")
|
|
94
|
+
set :public_folder, File.join(root, "..", "public")
|
|
95
|
+
|
|
96
|
+
# Don't show raw errors to users
|
|
97
|
+
set :show_exceptions, false
|
|
98
|
+
set :raise_errors, false
|
|
99
|
+
|
|
100
|
+
enable :logging
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
configure :development do
|
|
104
|
+
set :show_exceptions, :after_handler
|
|
105
|
+
enable :reloader # Reloads code on changes (with sinatra-contrib)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
configure :test do
|
|
109
|
+
set :raise_errors, true # Let errors propagate to tests
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
configure :production do
|
|
113
|
+
enable :logging
|
|
114
|
+
|
|
115
|
+
# Force SSL
|
|
116
|
+
use Rack::SslEnforcer if ENV["FORCE_SSL"]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Database Setup (ActiveRecord or Sequel)
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
# With sinatra-activerecord gem
|
|
126
|
+
# Gemfile
|
|
127
|
+
gem "sinatra-activerecord"
|
|
128
|
+
gem "pg"
|
|
129
|
+
|
|
130
|
+
# config/database.yml
|
|
131
|
+
development:
|
|
132
|
+
adapter: postgresql
|
|
133
|
+
database: my_app_development
|
|
134
|
+
|
|
135
|
+
test:
|
|
136
|
+
adapter: postgresql
|
|
137
|
+
database: my_app_test
|
|
138
|
+
|
|
139
|
+
production:
|
|
140
|
+
url: <%= ENV["DATABASE_URL"] %>
|
|
141
|
+
|
|
142
|
+
# Rakefile
|
|
143
|
+
require_relative "config/environment"
|
|
144
|
+
require "sinatra/activerecord/rake"
|
|
145
|
+
|
|
146
|
+
# Now you get: rake db:create, db:migrate, db:seed, etc.
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
# With Sequel (lightweight alternative)
|
|
151
|
+
# Gemfile
|
|
152
|
+
gem "sequel"
|
|
153
|
+
gem "pg"
|
|
154
|
+
|
|
155
|
+
# config/environment.rb
|
|
156
|
+
DB = Sequel.connect(ENV.fetch("DATABASE_URL", "postgres://localhost/my_app_dev"))
|
|
157
|
+
Sequel.extension :migration
|
|
158
|
+
|
|
159
|
+
# db/migrate/001_create_orders.rb
|
|
160
|
+
Sequel.migration do
|
|
161
|
+
change do
|
|
162
|
+
create_table(:orders) do
|
|
163
|
+
primary_key :id
|
|
164
|
+
String :reference, null: false, unique: true
|
|
165
|
+
Integer :total, null: false
|
|
166
|
+
String :status, default: "pending"
|
|
167
|
+
DateTime :created_at
|
|
168
|
+
DateTime :updated_at
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Deployment (Puma)
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
# config/puma.rb
|
|
178
|
+
workers ENV.fetch("WEB_CONCURRENCY", 2).to_i
|
|
179
|
+
threads_count = ENV.fetch("MAX_THREADS", 5).to_i
|
|
180
|
+
threads threads_count, threads_count
|
|
181
|
+
|
|
182
|
+
port ENV.fetch("PORT", 3000)
|
|
183
|
+
environment ENV.fetch("RACK_ENV", "development")
|
|
184
|
+
|
|
185
|
+
preload_app!
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
# Procfile (for Heroku/DigitalOcean App Platform)
|
|
190
|
+
web: bundle exec puma -C config/puma.rb
|
|
191
|
+
|
|
192
|
+
# Docker
|
|
193
|
+
FROM ruby:3.3-slim
|
|
194
|
+
WORKDIR /app
|
|
195
|
+
COPY Gemfile Gemfile.lock ./
|
|
196
|
+
RUN bundle install --without development test
|
|
197
|
+
COPY . .
|
|
198
|
+
EXPOSE 3000
|
|
199
|
+
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Why This Is Good
|
|
203
|
+
|
|
204
|
+
- **Middleware is composable.** Each middleware handles one concern: logging, CORS, rate limiting, SSL. Stack them in any order.
|
|
205
|
+
- **Same Rack ecosystem as Rails.** Rack::Cors, Rack::Attack, and other middleware work identically in Sinatra and Rails.
|
|
206
|
+
- **Lightweight deployment.** A Sinatra app starts in milliseconds and uses ~30MB of RAM. Perfect for microservices and sidecar APIs.
|
|
207
|
+
- **Standard tooling.** Puma, Procfile, Docker — the same deployment stack as Rails. No special Sinatra knowledge needed.
|
|
208
|
+
|
|
209
|
+
## When To Choose Sinatra
|
|
210
|
+
|
|
211
|
+
- **Focused API services** — webhook receivers, proxy APIs, embedding service wrappers
|
|
212
|
+
- **Microservices** — small, single-purpose services with 5-15 endpoints
|
|
213
|
+
- **Internal tools** — health dashboards, admin APIs, CLI backend services
|
|
214
|
+
- **When boot time matters** — Lambda functions, short-lived containers
|
|
215
|
+
|
|
216
|
+
## When To Choose Rails Instead
|
|
217
|
+
|
|
218
|
+
- **Full-stack web apps** — forms, views, sessions, asset pipeline, mailers
|
|
219
|
+
- **Complex data models** — 20+ tables with associations, migrations, seeds
|
|
220
|
+
- **Team projects** — Rails conventions mean less decision-making and easier onboarding
|
|
221
|
+
- **Rapid prototyping** — Rails generators and scaffolds are faster for CRUD
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# Sinatra: Testing with Rack::Test
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Test Sinatra apps using `rack-test`, which provides HTTP method helpers that hit your app directly (no real HTTP server needed). Tests are fast, isolated, and exercise the full Rack middleware stack.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Gemfile
|
|
9
|
+
group :test do
|
|
10
|
+
gem "rack-test"
|
|
11
|
+
gem "minitest"
|
|
12
|
+
gem "database_cleaner-active_record"
|
|
13
|
+
end
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# test/test_helper.rb
|
|
18
|
+
ENV["RACK_ENV"] = "test"
|
|
19
|
+
|
|
20
|
+
require_relative "../config/environment"
|
|
21
|
+
require "minitest/autorun"
|
|
22
|
+
require "minitest/pride"
|
|
23
|
+
require "rack/test"
|
|
24
|
+
|
|
25
|
+
class Minitest::Test
|
|
26
|
+
include Rack::Test::Methods
|
|
27
|
+
|
|
28
|
+
def app
|
|
29
|
+
MyApp::Api
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def json_body
|
|
33
|
+
JSON.parse(last_response.body, symbolize_names: true)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def auth_header(user)
|
|
37
|
+
{ "HTTP_AUTHORIZATION" => "Bearer #{user.api_token}" }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def post_json(path, body, headers = {})
|
|
41
|
+
post path, body.to_json, headers.merge("CONTENT_TYPE" => "application/json")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
# test/routes/health_test.rb
|
|
48
|
+
require "test_helper"
|
|
49
|
+
|
|
50
|
+
class HealthTest < Minitest::Test
|
|
51
|
+
def test_returns_ok
|
|
52
|
+
get "/health"
|
|
53
|
+
|
|
54
|
+
assert_equal 200, last_response.status
|
|
55
|
+
assert_equal "ok", json_body[:status]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
# test/routes/orders_test.rb
|
|
62
|
+
require "test_helper"
|
|
63
|
+
|
|
64
|
+
class OrdersTest < Minitest::Test
|
|
65
|
+
def setup
|
|
66
|
+
@user = User.create!(email: "alice@example.com", name: "Alice", api_token: "test-token-123")
|
|
67
|
+
@order = Order.create!(user: @user, reference: "ORD-001", shipping_address: "123 Main", status: "pending", total: 50_00)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def teardown
|
|
71
|
+
DatabaseCleaner.clean
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# INDEX
|
|
75
|
+
def test_index_returns_orders
|
|
76
|
+
get "/orders", {}, auth_header(@user)
|
|
77
|
+
|
|
78
|
+
assert_equal 200, last_response.status
|
|
79
|
+
assert_equal 1, json_body[:orders].length
|
|
80
|
+
assert_equal "ORD-001", json_body[:orders].first[:reference]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def test_index_requires_auth
|
|
84
|
+
get "/orders"
|
|
85
|
+
|
|
86
|
+
assert_equal 401, last_response.status
|
|
87
|
+
assert_equal "Unauthorized", json_body[:error]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def test_index_only_returns_current_users_orders
|
|
91
|
+
other_user = User.create!(email: "bob@example.com", name: "Bob", api_token: "bob-token")
|
|
92
|
+
Order.create!(user: other_user, reference: "ORD-002", shipping_address: "456 Oak", status: "pending", total: 25_00)
|
|
93
|
+
|
|
94
|
+
get "/orders", {}, auth_header(@user)
|
|
95
|
+
|
|
96
|
+
references = json_body[:orders].map { |o| o[:reference] }
|
|
97
|
+
assert_includes references, "ORD-001"
|
|
98
|
+
refute_includes references, "ORD-002"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# SHOW
|
|
102
|
+
def test_show_returns_order
|
|
103
|
+
get "/orders/#{@order.id}", {}, auth_header(@user)
|
|
104
|
+
|
|
105
|
+
assert_equal 200, last_response.status
|
|
106
|
+
assert_equal "ORD-001", json_body[:order][:reference]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def test_show_returns_404_for_missing_order
|
|
110
|
+
get "/orders/999999", {}, auth_header(@user)
|
|
111
|
+
|
|
112
|
+
assert_equal 404, last_response.status
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# CREATE
|
|
116
|
+
def test_create_with_valid_params
|
|
117
|
+
post_json "/orders", {
|
|
118
|
+
shipping_address: "789 Elm St",
|
|
119
|
+
line_items: [{ product_id: 1, quantity: 2 }]
|
|
120
|
+
}, auth_header(@user)
|
|
121
|
+
|
|
122
|
+
assert_equal 201, last_response.status
|
|
123
|
+
assert json_body[:order][:id].present?
|
|
124
|
+
assert_equal "pending", json_body[:order][:status]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def test_create_with_invalid_params
|
|
128
|
+
post_json "/orders", { shipping_address: "" }, auth_header(@user)
|
|
129
|
+
|
|
130
|
+
assert_equal 422, last_response.status
|
|
131
|
+
assert json_body[:details].any?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def test_create_requires_auth
|
|
135
|
+
post_json "/orders", { shipping_address: "123 Main" }
|
|
136
|
+
|
|
137
|
+
assert_equal 401, last_response.status
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# DELETE
|
|
141
|
+
def test_delete_removes_order
|
|
142
|
+
count_before = Order.count
|
|
143
|
+
|
|
144
|
+
delete "/orders/#{@order.id}", {}, auth_header(@user)
|
|
145
|
+
|
|
146
|
+
assert_equal 200, last_response.status
|
|
147
|
+
assert_equal count_before - 1, Order.count
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def test_delete_cannot_remove_other_users_order
|
|
151
|
+
other_user = User.create!(email: "bob@example.com", name: "Bob", api_token: "bob-token")
|
|
152
|
+
bobs_order = Order.create!(user: other_user, reference: "ORD-BOB", shipping_address: "456", status: "pending", total: 10_00)
|
|
153
|
+
|
|
154
|
+
delete "/orders/#{bobs_order.id}", {}, auth_header(@user)
|
|
155
|
+
|
|
156
|
+
assert_equal 404, last_response.status
|
|
157
|
+
assert Order.exists?(bobs_order.id) # Still exists
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Testing services (framework-independent):
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
# test/services/orders/create_service_test.rb
|
|
166
|
+
require "test_helper"
|
|
167
|
+
|
|
168
|
+
class Orders::CreateServiceTest < Minitest::Test
|
|
169
|
+
def setup
|
|
170
|
+
@user = User.create!(email: "alice@example.com", name: "Alice", api_token: "token")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def test_creates_order_with_valid_params
|
|
174
|
+
result = Orders::CreateService.call({ shipping_address: "123 Main" }, @user)
|
|
175
|
+
|
|
176
|
+
assert result.success?
|
|
177
|
+
assert_instance_of Order, result.order
|
|
178
|
+
assert result.order.persisted?
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def test_returns_failure_for_invalid_params
|
|
182
|
+
result = Orders::CreateService.call({ shipping_address: "" }, @user)
|
|
183
|
+
|
|
184
|
+
refute result.success?
|
|
185
|
+
assert result.order.errors[:shipping_address].any?
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def teardown
|
|
189
|
+
DatabaseCleaner.clean
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Why This Is Good
|
|
195
|
+
|
|
196
|
+
- **No HTTP server needed.** `rack-test` calls the app directly through Rack. Tests run in milliseconds, not seconds.
|
|
197
|
+
- **Full middleware stack.** Authentication middleware, JSON parsing, error handling — all exercised just like production.
|
|
198
|
+
- **`last_response` gives you everything.** Status code, body, headers, content type. Assert on any of them.
|
|
199
|
+
- **Service tests are framework-agnostic.** `Orders::CreateService.call(params, user)` is tested identically whether it's used in Sinatra, Rails, or a CLI tool.
|
|
200
|
+
|
|
201
|
+
## Key Methods
|
|
202
|
+
|
|
203
|
+
| Method | Purpose |
|
|
204
|
+
|---|---|
|
|
205
|
+
| `get "/path"` | GET request |
|
|
206
|
+
| `post "/path", body, headers` | POST request |
|
|
207
|
+
| `put "/path", body, headers` | PUT request |
|
|
208
|
+
| `delete "/path"` | DELETE request |
|
|
209
|
+
| `last_response.status` | HTTP status code |
|
|
210
|
+
| `last_response.body` | Response body string |
|
|
211
|
+
| `last_response.headers` | Response headers hash |
|
|
212
|
+
| `last_response.ok?` | Status is 200? |
|
|
213
|
+
| `last_response.redirect?` | Status is 3xx? |
|
|
214
|
+
| `follow_redirect!` | Follow a redirect |
|
|
215
|
+
|
|
216
|
+
## Anti-Pattern
|
|
217
|
+
|
|
218
|
+
Testing by starting a real HTTP server:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
# BAD: Starting a server for tests
|
|
222
|
+
def setup
|
|
223
|
+
@server = Thread.new { MyApp::Api.run! port: 4567 }
|
|
224
|
+
sleep 1 # Wait for server to start
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def test_health
|
|
228
|
+
response = Net::HTTP.get(URI("http://localhost:4567/health"))
|
|
229
|
+
# Slow, flaky, port conflicts
|
|
230
|
+
end
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Use `rack-test` — it's faster, more reliable, and doesn't need network ports.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# SOLID: Dependency Inversion Principle (DIP)
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
High-level modules should not depend on low-level modules. Both should depend on abstractions. In Ruby, this means: depend on duck-typed interfaces (what an object *does*), not on concrete classes (what an object *is*). Inject dependencies rather than hardcoding them.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# GOOD: High-level service depends on an injected abstraction, not a concrete class
|
|
9
|
+
|
|
10
|
+
class Ai::CompletionService
|
|
11
|
+
# Depends on: any object that responds to .complete(messages, model:, max_tokens:)
|
|
12
|
+
# Does NOT depend on: Anthropic::Client specifically
|
|
13
|
+
def initialize(client:)
|
|
14
|
+
@client = client
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(prompt, context:)
|
|
18
|
+
messages = build_messages(prompt, context)
|
|
19
|
+
response = @client.complete(messages, model: "claude-haiku-4-5-20251001", max_tokens: 4096)
|
|
20
|
+
|
|
21
|
+
Result.new(
|
|
22
|
+
content: response.content,
|
|
23
|
+
input_tokens: response.input_tokens,
|
|
24
|
+
output_tokens: response.output_tokens
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_messages(prompt, context)
|
|
31
|
+
[
|
|
32
|
+
{ role: "system", content: context },
|
|
33
|
+
{ role: "user", content: prompt }
|
|
34
|
+
]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Production: real Anthropic client
|
|
39
|
+
client = Anthropic::Client.new(api_key: ENV["ANTHROPIC_API_KEY"])
|
|
40
|
+
service = Ai::CompletionService.new(client: client)
|
|
41
|
+
|
|
42
|
+
# Tests: fake client — no HTTP, no API key needed
|
|
43
|
+
fake_client = FakeCompletionClient.new(response: "Here is your refactored code...")
|
|
44
|
+
service = Ai::CompletionService.new(client: fake_client)
|
|
45
|
+
|
|
46
|
+
# Future: OpenAI, Ollama, or any LLM that implements .complete
|
|
47
|
+
ollama_client = Ollama::Client.new(base_url: "http://localhost:11434")
|
|
48
|
+
service = Ai::CompletionService.new(client: ollama_client)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Configuring dependencies at the application level:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# config/initializers/dependencies.rb
|
|
55
|
+
Rails.application.config.after_initialize do
|
|
56
|
+
# Wire up production dependencies
|
|
57
|
+
embedding_client = Embeddings::HttpClient.new(
|
|
58
|
+
base_url: ENV.fetch("EMBEDDING_SERVICE_URL")
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
Rails.application.config.x.embedding_client = embedding_client
|
|
62
|
+
Rails.application.config.x.ai_client = Anthropic::Client.new(
|
|
63
|
+
api_key: ENV.fetch("ANTHROPIC_API_KEY")
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Services pull from config or accept injection
|
|
68
|
+
class Embeddings::IndexService
|
|
69
|
+
def initialize(client: Rails.application.config.x.embedding_client)
|
|
70
|
+
@client = client
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def call(project, files)
|
|
74
|
+
files.each do |path, content|
|
|
75
|
+
vectors = @client.embed([content])
|
|
76
|
+
project.code_embeddings.create!(file_path: path, embedding: vectors.first)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
DIP with Ruby blocks — the lightest-weight dependency injection:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
class Orders::ExportService
|
|
86
|
+
# The formatter is an injected dependency via block
|
|
87
|
+
def call(orders, &formatter)
|
|
88
|
+
formatter ||= method(:default_format)
|
|
89
|
+
orders.map { |order| formatter.call(order) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def default_format(order)
|
|
95
|
+
"#{order.reference}: $#{order.total}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Different formats without modifying ExportService
|
|
100
|
+
Orders::ExportService.new.call(orders) { |o| o.to_json }
|
|
101
|
+
Orders::ExportService.new.call(orders) { |o| [o.reference, o.total].join(",") }
|
|
102
|
+
Orders::ExportService.new.call(orders) # Uses default
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Why This Is Good
|
|
106
|
+
|
|
107
|
+
- **Swappable dependencies.** Production uses Anthropic, tests use a fake, future uses Ollama — `CompletionService` never changes. The high-level business logic is isolated from low-level API details.
|
|
108
|
+
- **Testable without infrastructure.** Tests inject fakes or doubles. No HTTP calls, no API keys, no external services. Tests run in milliseconds.
|
|
109
|
+
- **Framework-independent business logic.** `CompletionService` doesn't know about Rails, HTTP, or JSON parsing. It knows about messages and responses. The concrete client handles the transport.
|
|
110
|
+
- **Default injection balances convenience and flexibility.** `client: Rails.application.config.x.embedding_client` provides a sensible default while allowing test overrides. Production code doesn't need to specify the client every time.
|
|
111
|
+
|
|
112
|
+
## Anti-Pattern
|
|
113
|
+
|
|
114
|
+
Hardcoded dependencies — high-level logic directly instantiates low-level classes:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
class Ai::CompletionService
|
|
118
|
+
def call(prompt, context:)
|
|
119
|
+
# HARDCODED: directly creates the concrete client
|
|
120
|
+
client = Anthropic::Client.new(api_key: ENV["ANTHROPIC_API_KEY"])
|
|
121
|
+
|
|
122
|
+
messages = build_messages(prompt, context)
|
|
123
|
+
response = client.messages.create(
|
|
124
|
+
model: "claude-haiku-4-5-20251001",
|
|
125
|
+
max_tokens: 4096,
|
|
126
|
+
messages: messages
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
Result.new(
|
|
130
|
+
content: response.content.first.text,
|
|
131
|
+
input_tokens: response.usage.input_tokens,
|
|
132
|
+
output_tokens: response.usage.output_tokens
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
# Another violation: service directly calls a specific external API
|
|
140
|
+
class Embeddings::IndexService
|
|
141
|
+
def call(project, files)
|
|
142
|
+
files.each do |path, content|
|
|
143
|
+
# HARDCODED: knows the exact URL, HTTP method, headers, and response format
|
|
144
|
+
response = Faraday.post(
|
|
145
|
+
"http://embedding-service:8000/embed",
|
|
146
|
+
{ texts: [content] }.to_json,
|
|
147
|
+
"Content-Type" => "application/json"
|
|
148
|
+
)
|
|
149
|
+
vector = JSON.parse(response.body)["embeddings"].first
|
|
150
|
+
project.code_embeddings.create!(file_path: path, embedding: vector)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Why This Is Bad
|
|
157
|
+
|
|
158
|
+
- **Can't swap the provider.** Moving from Anthropic to OpenAI requires rewriting `CompletionService`. The business logic (building messages, processing responses) is tangled with the transport (HTTP client, API format).
|
|
159
|
+
- **Can't test without the real service.** Testing `CompletionService` requires either a running Anthropic API (slow, expensive, flaky) or complex WebMock stubs that mirror the exact API format. A fake client is simpler.
|
|
160
|
+
- **URL, headers, and JSON parsing inside business logic.** `IndexService` knows about Faraday, URLs, JSON parsing, and response structure. These are transport concerns that belong in a client class, not in the indexing logic.
|
|
161
|
+
- **Environment coupling.** `ENV["ANTHROPIC_API_KEY"]` is read every time the service is called. In tests, you must set the environment variable or the service breaks. With injection, tests pass a fake and never touch ENV.
|
|
162
|
+
|
|
163
|
+
## When To Apply
|
|
164
|
+
|
|
165
|
+
- **External services.** API clients, email services, payment gateways, embedding services — always inject these. They're the most common source of hard-to-test, hard-to-swap dependencies.
|
|
166
|
+
- **Cross-cutting concerns.** Logging, caching, metrics — inject them so you can swap implementations (stdout logger vs CloudWatch vs null logger for tests).
|
|
167
|
+
- **Strategy selection.** When behavior varies at runtime (different AI models, different export formats, different notification channels), inject the strategy.
|
|
168
|
+
- **Configuration that varies by environment.** Database connections, API URLs, feature flags — inject via Rails config or environment, not hardcoded values.
|
|
169
|
+
|
|
170
|
+
## When NOT To Apply
|
|
171
|
+
|
|
172
|
+
- **Don't inject Ruby standard library classes.** `Array.new`, `Hash.new`, `Time.current` — these are stable, universal dependencies. Injecting them adds ceremony with no benefit.
|
|
173
|
+
- **Don't inject ActiveRecord models.** `User.find(id)` is fine. You don't need to inject a "UserRepository" in Rails — that's Java-style over-abstraction.
|
|
174
|
+
- **Don't inject everything.** Inject *boundaries* — the edges where your code meets external systems. Internal collaborators (one service calling another within your app) can be directly referenced if they're stable.
|
|
175
|
+
|
|
176
|
+
## Edge Cases
|
|
177
|
+
|
|
178
|
+
**Circular dependencies:**
|
|
179
|
+
If ServiceA depends on ServiceB and ServiceB depends on ServiceA, you have a design problem. Extract the shared logic into a third class that both depend on.
|
|
180
|
+
|
|
181
|
+
**Default values vs mandatory injection:**
|
|
182
|
+
Use default values for production dependencies, mandatory injection for things that should always be explicit:
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
# Default for convenience — production always uses the real client
|
|
186
|
+
def initialize(client: Anthropic::Client.new(api_key: ENV["ANTHROPIC_API_KEY"]))
|
|
187
|
+
|
|
188
|
+
# Mandatory — caller must choose a strategy
|
|
189
|
+
def initialize(processor:)
|
|
190
|
+
raise ArgumentError, "processor is required" unless processor
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Rails' built-in DIP mechanisms:**
|
|
195
|
+
Rails already uses DIP in many places: `config.active_job.queue_adapter`, `config.cache_store`, `config.active_storage.service`. These are configuration-based dependency injection. Follow the same pattern for your own services.
|