whoosh 1.2.1 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f647a91b22fcee8322c6af7bf0ab0ac0108aa3113e2d4db89a3f187bb6a1caa3
4
- data.tar.gz: e46c07b2f4fbaf1233bb52a321ff161952989313adbbfebdb5b7bf1c08675f08
3
+ metadata.gz: 589d3cd63bfe7cb2a7ae32d8de43c064eb0e9dadfae68ecb1907bea9a099cd7d
4
+ data.tar.gz: a18f684c03fa83b5e7e86febd6eca0bc79d01d5c3cf719b729118eea91510e9c
5
5
  SHA512:
6
- metadata.gz: 1ca2dbc63ab481d5d61260cfbfca37768a9498fa74fe90d68f039884f05ca2a79f0fa08da07fdca04ba175e95ab0997d5cc00acf0e8b2bbceef5ca576f8fef91
7
- data.tar.gz: b0361d6a812b80e093125f7be7d5681977ab615b6ebccacbe87753352223e6058e0076dd2eace8e3092a60a57e3387b9ebe0baed4b2f623befeb8f1c38d9435e
6
+ metadata.gz: 3ffb5d9e6d905e3914d9cf54738966c5d3ba625a89f33c8c2b3c98698f9a04ba1b6710584efa69917e24ef02b52b870f616a4ad6e6a7d631c4707319d1db7e47
7
+ data.tar.gz: e57044ee4ec0c6a1b872d85cd0abeb274ee3c260c46dcc38b925994a6b88c7b004188314c7b60e8e3a7ce5aef4198581c817fbd8870ceaca6ddbe20fce4abd75
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whoosh
4
+ module AI
5
+ class LLM
6
+ attr_reader :provider, :model
7
+
8
+ def initialize(provider: "auto", model: nil, cache_enabled: true)
9
+ @provider = provider
10
+ @model = model
11
+ @cache_enabled = cache_enabled
12
+ @cache = cache_enabled ? {} : nil
13
+ @mutex = Mutex.new
14
+ @ruby_llm = nil
15
+ end
16
+
17
+ # Chat with an LLM — returns response text
18
+ def chat(message, model: nil, system: nil, max_tokens: nil, temperature: nil, cache: nil)
19
+ use_cache = cache.nil? ? @cache_enabled : cache
20
+ cache_key = "chat:#{model || @model}:#{message}" if use_cache
21
+
22
+ # Check cache
23
+ if use_cache && @cache && (cached = @cache[cache_key])
24
+ return cached
25
+ end
26
+
27
+ result = call_llm(
28
+ messages: [{ role: "user", content: message }],
29
+ model: model || @model,
30
+ system: system,
31
+ max_tokens: max_tokens,
32
+ temperature: temperature
33
+ )
34
+
35
+ # Cache result
36
+ if use_cache && @cache
37
+ @mutex.synchronize { @cache[cache_key] = result }
38
+ end
39
+
40
+ result
41
+ end
42
+
43
+ # Extract structured data — returns validated hash
44
+ def extract(text, schema:, model: nil, prompt: nil)
45
+ schema_desc = describe_schema(schema)
46
+ system_prompt = prompt || "Extract structured data from the text. Return ONLY valid JSON matching this schema:\n#{schema_desc}"
47
+
48
+ response = chat(text, model: model, system: system_prompt, cache: false)
49
+
50
+ # Parse JSON from LLM response
51
+ json_str = extract_json(response)
52
+ parsed = Serialization::Json.decode(json_str)
53
+
54
+ # Validate against schema
55
+ result = schema.validate(parsed)
56
+ if result.success?
57
+ result.data
58
+ else
59
+ raise Errors::ValidationError.new(result.errors)
60
+ end
61
+ end
62
+
63
+ # Stream LLM response — yields chunks
64
+ def stream(message, model: nil, system: nil, &block)
65
+ ensure_ruby_llm!
66
+
67
+ messages = [{ role: "user", content: message }]
68
+ # Delegate to ruby_llm's streaming interface
69
+ if @ruby_llm
70
+ # ruby_llm streaming would go here
71
+ # For now, fall back to non-streaming
72
+ result = chat(message, model: model, system: system, cache: false)
73
+ yield result if block_given?
74
+ result
75
+ end
76
+ end
77
+
78
+ # Check if LLM is available
79
+ def available?
80
+ ruby_llm_available?
81
+ end
82
+
83
+ private
84
+
85
+ def call_llm(messages:, model:, system: nil, max_tokens: nil, temperature: nil)
86
+ ensure_ruby_llm!
87
+
88
+ if @ruby_llm
89
+ # Use ruby_llm gem
90
+ chat = RubyLLM.chat(model: model || "claude-sonnet-4-20250514")
91
+ chat.with_instructions(system) if system
92
+ response = chat.ask(messages.last[:content])
93
+ response.content
94
+ else
95
+ raise Errors::DependencyError, "No LLM provider available. Add 'ruby_llm' to your Gemfile."
96
+ end
97
+ end
98
+
99
+ def ensure_ruby_llm!
100
+ return if @ruby_llm == false # already checked, not available
101
+
102
+ if ruby_llm_available?
103
+ @ruby_llm = true
104
+ else
105
+ @ruby_llm = false
106
+ end
107
+ end
108
+
109
+ def ruby_llm_available?
110
+ require "ruby_llm"
111
+ true
112
+ rescue LoadError
113
+ false
114
+ end
115
+
116
+ def describe_schema(schema)
117
+ return "{}" unless schema.respond_to?(:fields)
118
+ fields = schema.fields.map do |name, opts|
119
+ type = OpenAPI::SchemaConverter.type_for(opts[:type])
120
+ desc = opts[:desc] ? " — #{opts[:desc]}" : ""
121
+ required = opts[:required] ? " (required)" : ""
122
+ " #{name}: #{type}#{required}#{desc}"
123
+ end
124
+ "{\n#{fields.join(",\n")}\n}"
125
+ end
126
+
127
+ def extract_json(text)
128
+ # Try to find JSON in LLM response (may be wrapped in markdown code blocks)
129
+ if text =~ /```(?:json)?\s*\n?(.*?)\n?```/m
130
+ $1.strip
131
+ elsif text.strip.start_with?("{") || text.strip.start_with?("[")
132
+ text.strip
133
+ else
134
+ text.strip
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whoosh
4
+ module AI
5
+ # Validates LLM output against a Whoosh::Schema
6
+ module StructuredOutput
7
+ def self.validate(data, schema:)
8
+ result = schema.validate(data)
9
+ return result.data if result.success?
10
+ raise Errors::ValidationError.new(result.errors)
11
+ end
12
+
13
+ def self.prompt_for(schema)
14
+ return "" unless schema.respond_to?(:fields)
15
+
16
+ lines = schema.fields.map do |name, opts|
17
+ type = OpenAPI::SchemaConverter.type_for(opts[:type])
18
+ parts = ["#{name}: #{type}"]
19
+ parts << "(required)" if opts[:required]
20
+ parts << "— #{opts[:desc]}" if opts[:desc]
21
+ parts << "[min: #{opts[:min]}]" if opts[:min]
22
+ parts << "[max: #{opts[:max]}]" if opts[:max]
23
+ parts << "[default: #{opts[:default]}]" if opts.key?(:default)
24
+ " #{parts.join(" ")}"
25
+ end
26
+
27
+ "Return ONLY valid JSON matching this schema:\n{\n#{lines.join(",\n")}\n}"
28
+ end
29
+ end
30
+ end
31
+ end
data/lib/whoosh/ai.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whoosh
4
+ module AI
5
+ autoload :LLM, "whoosh/ai/llm"
6
+ autoload :StructuredOutput, "whoosh/ai/structured_output"
7
+
8
+ # Build an AI client from config
9
+ def self.build(config_data = {})
10
+ ai_config = config_data["ai"] || {}
11
+ LLM.new(
12
+ provider: ai_config["provider"] || "auto",
13
+ model: ai_config["model"],
14
+ cache_enabled: ai_config["cache"] != false
15
+ )
16
+ end
17
+ end
18
+ end
data/lib/whoosh/app.rb CHANGED
@@ -30,6 +30,7 @@ module Whoosh
30
30
  auto_register_storage
31
31
  auto_register_http
32
32
  auto_register_vectors
33
+ auto_register_ai
33
34
  auto_configure_jobs
34
35
  @metrics = Metrics.new
35
36
  auto_register_metrics
@@ -307,6 +308,10 @@ module Whoosh
307
308
  @di.provide(:vectors) { VectorStore.build(@config.data) }
308
309
  end
309
310
 
311
+ def auto_register_ai
312
+ @di.provide(:llm) { AI.build(@config.data) }
313
+ end
314
+
310
315
  def auto_configure_jobs
311
316
  backend = Jobs.build_backend(@config.data)
312
317
  Jobs.configure(backend: backend, di: @di)
@@ -448,8 +453,12 @@ module Whoosh
448
453
  end
449
454
 
450
455
  def register_mcp_tools
456
+ internal_paths = %w[/openapi.json /docs /redoc /metrics /healthz]
457
+
451
458
  @router.routes.each do |route|
452
- next unless route[:metadata] && route[:metadata][:mcp]
459
+ # Auto-expose all routes as MCP tools (opt-out with mcp: false)
460
+ next if route[:metadata] && route[:metadata][:mcp] == false
461
+ next if internal_paths.include?(route[:path])
453
462
 
454
463
  tool_name = "#{route[:method]} #{route[:path]}"
455
464
  match = @router.match(route[:method], route[:path])
@@ -92,17 +92,166 @@ module Whoosh
92
92
 
93
93
  desc "routes", "List all registered routes"
94
94
  def routes
95
+ app = load_app
96
+ return unless app
97
+ app.routes.each { |r| puts " #{r[:method].ljust(8)} #{r[:path]}" }
98
+ end
99
+
100
+ desc "describe", "Dump app structure as JSON (AI-friendly introspection)"
101
+ option :routes, type: :boolean, default: false, desc: "Routes only"
102
+ option :schemas, type: :boolean, default: false, desc: "Schemas only"
103
+ def describe
104
+ app = load_app
105
+ return unless app
106
+ app.to_rack # ensure everything is built
107
+
108
+ output = {}
109
+
110
+ unless options[:schemas]
111
+ output[:routes] = app.routes.map do |r|
112
+ match = app.instance_variable_get(:@router).match(r[:method], r[:path])
113
+ handler = match[:handler] if match
114
+ route_info = {
115
+ method: r[:method],
116
+ path: r[:path],
117
+ auth: r[:metadata]&.dig(:auth),
118
+ mcp: r[:metadata]&.dig(:mcp) || false
119
+ }
120
+ if handler && handler[:request_schema]
121
+ route_info[:request_schema] = OpenAPI::SchemaConverter.convert(handler[:request_schema])
122
+ end
123
+ if handler && handler[:response_schema]
124
+ route_info[:response_schema] = OpenAPI::SchemaConverter.convert(handler[:response_schema])
125
+ end
126
+ route_info
127
+ end
128
+ end
129
+
130
+ unless options[:routes]
131
+ # Collect all Schema subclasses
132
+ schemas = {}
133
+ ObjectSpace.each_object(Class).select { |k| k < Schema && k != Schema }.each do |klass|
134
+ schemas[klass.name] = OpenAPI::SchemaConverter.convert(klass) if klass.name
135
+ end
136
+ output[:schemas] = schemas unless schemas.empty?
137
+ end
138
+
139
+ output[:config] = {
140
+ app_name: app.config.app_name,
141
+ port: app.config.port,
142
+ env: app.config.env,
143
+ docs_enabled: app.config.docs_enabled?,
144
+ auth_configured: !!app.authenticator,
145
+ rate_limit_configured: !!app.rate_limiter_instance,
146
+ mcp_tools: app.mcp_server.list_tools.map { |t| t[:name] }
147
+ }
148
+
149
+ output[:framework] = {
150
+ version: Whoosh::VERSION,
151
+ ruby: RUBY_VERSION,
152
+ yjit: Performance.yjit_enabled?,
153
+ json_engine: Serialization::Json.engine
154
+ }
155
+
156
+ puts JSON.pretty_generate(output)
157
+ end
158
+
159
+ desc "check", "Validate app configuration and catch common mistakes"
160
+ def check
95
161
  app_file = File.join(Dir.pwd, "app.rb")
96
162
  unless File.exist?(app_file)
97
- puts "Error: app.rb not found in #{Dir.pwd}"
163
+ puts " No app.rb found"
98
164
  exit 1
99
165
  end
100
- require app_file
101
- app = ObjectSpace.each_object(Whoosh::App).first
102
- if app
103
- app.routes.each { |r| puts " #{r[:method].ljust(8)} #{r[:path]}" }
166
+
167
+ puts "=> Whoosh App Check"
168
+ puts ""
169
+
170
+ issues = []
171
+ warnings = []
172
+
173
+ # Check .env
174
+ if File.exist?(".env")
175
+ env_content = File.read(".env")
176
+ if env_content.include?("change_me") || env_content.include?("CHANGE_ME")
177
+ issues << "JWT_SECRET in .env still has default value — run: ruby -e \"puts SecureRandom.hex(32)\""
178
+ end
104
179
  else
105
- puts "No Whoosh::App instance found"
180
+ warnings << "No .env file — copy .env.example to .env"
181
+ end
182
+
183
+ # Check .gitignore includes .env
184
+ if File.exist?(".gitignore")
185
+ unless File.read(".gitignore").include?(".env")
186
+ issues << ".gitignore does not exclude .env — secrets may be committed"
187
+ end
188
+ else
189
+ warnings << "No .gitignore file"
190
+ end
191
+
192
+ # Load and validate the app
193
+ begin
194
+ require app_file
195
+ app = ObjectSpace.each_object(Whoosh::App).first
196
+ if app
197
+ puts " ✓ App loads successfully"
198
+
199
+ # Check auth
200
+ if app.authenticator
201
+ puts " ✓ Auth configured"
202
+ else
203
+ warnings << "No auth configured — API is open to everyone"
204
+ end
205
+
206
+ # Check rate limiting
207
+ if app.rate_limiter_instance
208
+ puts " ✓ Rate limiting configured"
209
+ else
210
+ warnings << "No rate limiting — vulnerable to abuse"
211
+ end
212
+
213
+ # Check routes
214
+ route_count = app.routes.length
215
+ puts " ✓ #{route_count} routes registered"
216
+
217
+ # Check MCP
218
+ app.to_rack
219
+ mcp_count = app.mcp_server.list_tools.length
220
+ puts " ✓ #{mcp_count} MCP tools"
221
+
222
+ else
223
+ issues << "No Whoosh::App instance found in app.rb"
224
+ end
225
+ rescue => e
226
+ issues << "App failed to load: #{e.message}"
227
+ end
228
+
229
+ # Check dependencies
230
+ puts ""
231
+ %w[falcon oj sequel].each do |gem_name|
232
+ begin
233
+ require gem_name
234
+ puts " ✓ #{gem_name} available"
235
+ rescue LoadError
236
+ label = case gem_name
237
+ when "falcon" then "(recommended server)"
238
+ when "oj" then "(5-10x faster JSON)"
239
+ when "sequel" then "(database)"
240
+ end
241
+ warnings << "#{gem_name} not installed #{label}"
242
+ end
243
+ end
244
+
245
+ # Report
246
+ puts ""
247
+ if issues.empty? && warnings.empty?
248
+ puts "=> All checks passed! ✓"
249
+ else
250
+ issues.each { |i| puts " ✗ #{i}" }
251
+ warnings.each { |w| puts " ⚠ #{w}" }
252
+ puts ""
253
+ puts issues.empty? ? "=> #{warnings.length} warning(s)" : "=> #{issues.length} issue(s), #{warnings.length} warning(s)"
254
+ exit 1 unless issues.empty?
106
255
  end
107
256
  end
108
257
 
@@ -224,45 +373,67 @@ module Whoosh
224
373
  end
225
374
  }
226
375
 
227
- desc "ci", "Run full CI pipeline (lint + security + tests)"
376
+ desc "ci", "Run full CI pipeline (lint + security + audit + tests + coverage)"
228
377
  def ci
229
378
  puts "=> Whoosh CI Pipeline"
230
379
  puts "=" * 50
231
380
  puts ""
232
381
 
233
382
  steps = []
383
+ skipped = []
234
384
 
235
385
  # Step 1: Rubocop (lint)
236
386
  if system("bundle exec rubocop --version > /dev/null 2>&1")
237
387
  steps << { name: "Rubocop (lint)", cmd: "bundle exec rubocop --format simple" }
238
388
  else
239
- puts " [skip] Rubocop not installed (add rubocop to Gemfile)"
389
+ skipped << "Rubocop (add rubocop to Gemfile)"
240
390
  end
241
391
 
242
- # Step 2: Brakeman (security)
392
+ # Step 2: Brakeman (security scan)
243
393
  if system("bundle exec brakeman --version > /dev/null 2>&1")
244
394
  steps << { name: "Brakeman (security)", cmd: "bundle exec brakeman -q --no-pager" }
245
395
  else
246
- puts " [skip] Brakeman not installed (add brakeman to Gemfile)"
396
+ skipped << "Brakeman (add brakeman to Gemfile)"
247
397
  end
248
398
 
249
- # Step 3: RSpec (tests)
399
+ # Step 3: Bundle audit (CVE check)
400
+ if system("bundle exec bundle-audit version > /dev/null 2>&1")
401
+ steps << { name: "Bundle Audit (CVE)", cmd: "bundle exec bundle-audit check --update" }
402
+ elsif system("bundle audit --help > /dev/null 2>&1")
403
+ steps << { name: "Bundle Audit (CVE)", cmd: "bundle audit" }
404
+ else
405
+ skipped << "Bundle Audit (add bundler-audit to Gemfile)"
406
+ end
407
+
408
+ # Step 4: Secret leak scan (built-in, no gem needed)
409
+ steps << { name: "Secret Scan", type: :secret_scan }
410
+
411
+ # Step 5: RSpec (tests)
250
412
  if system("bundle exec rspec --version > /dev/null 2>&1")
251
413
  steps << { name: "RSpec (tests)", cmd: "bundle exec rspec --format progress" }
252
414
  else
253
- puts " [skip] RSpec not installed"
415
+ skipped << "RSpec (add rspec to Gemfile)"
254
416
  end
255
417
 
256
- # Step 4: Gem build check
257
- gemspec = Dir.glob("*.gemspec").first
258
- if gemspec
259
- steps << { name: "Gem build", cmd: "gem build #{gemspec} --quiet" }
260
- end
418
+ # Step 6: Coverage check (built-in)
419
+ steps << { name: "Coverage Check", type: :coverage_check }
420
+
421
+ skipped.each { |s| puts " [skip] #{s}" }
422
+ puts "" unless skipped.empty?
261
423
 
262
424
  failed = []
263
425
  steps.each_with_index do |step, i|
264
426
  puts "--- [#{i + 1}/#{steps.length}] #{step[:name]} ---"
265
- success = system(step[:cmd])
427
+
428
+ success = case step[:type]
429
+ when :secret_scan
430
+ run_secret_scan
431
+ when :coverage_check
432
+ run_coverage_check
433
+ else
434
+ system(step[:cmd])
435
+ end
436
+
266
437
  if success
267
438
  puts " ✓ #{step[:name]} passed"
268
439
  else
@@ -274,7 +445,7 @@ module Whoosh
274
445
 
275
446
  puts "=" * 50
276
447
  if failed.empty?
277
- puts "=> All checks passed! ✓"
448
+ puts "=> All #{steps.length} checks passed! ✓"
278
449
  exit 0
279
450
  else
280
451
  puts "=> FAILED: #{failed.join(', ')}"
@@ -282,6 +453,76 @@ module Whoosh
282
453
  end
283
454
  end
284
455
 
456
+ private
457
+
458
+ def load_app
459
+ app_file = File.join(Dir.pwd, "app.rb")
460
+ unless File.exist?(app_file)
461
+ puts "Error: app.rb not found in #{Dir.pwd}"
462
+ return nil
463
+ end
464
+ require app_file
465
+ app = ObjectSpace.each_object(Whoosh::App).first
466
+ unless app
467
+ puts "Error: No Whoosh::App instance found"
468
+ return nil
469
+ end
470
+ app
471
+ end
472
+
473
+ def run_secret_scan
474
+ patterns = [
475
+ /(?:api[_-]?key|secret|password|token)\s*[:=]\s*["'][A-Za-z0-9+\/=]{8,}["']/i,
476
+ /(?:sk-|pk-|rk-)[a-zA-Z0-9]{20,}/,
477
+ /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/,
478
+ /AKIA[0-9A-Z]{16}/, # AWS access key
479
+ ]
480
+
481
+ leaked = []
482
+ Dir.glob("{app.rb,lib/**/*.rb,endpoints/**/*.rb,config/**/*.rb}").each do |file|
483
+ next if file.include?("spec/") || file.include?("test/")
484
+ content = File.read(file) rescue next
485
+ patterns.each do |pattern|
486
+ content.each_line.with_index do |line, i|
487
+ if line.match?(pattern) && !line.include?("ENV[") && !line.include?("ENV.fetch")
488
+ leaked << "#{file}:#{i + 1}: #{line.strip[0..80]}"
489
+ end
490
+ end
491
+ end
492
+ end
493
+
494
+ if leaked.empty?
495
+ true
496
+ else
497
+ puts " Potential secrets found:"
498
+ leaked.each { |l| puts " #{l}" }
499
+ false
500
+ end
501
+ end
502
+
503
+ def run_coverage_check
504
+ coverage_file = File.join(Dir.pwd, "coverage", ".last_run.json")
505
+ unless File.exist?(coverage_file)
506
+ puts " [info] No coverage data found (add simplecov to test_helper for tracking)"
507
+ true # Don't fail if SimpleCov not set up
508
+ else
509
+ require "json"
510
+ data = JSON.parse(File.read(coverage_file))
511
+ coverage = data.dig("result", "line") || data.dig("result", "covered_percent") || 0
512
+ threshold = 80
513
+
514
+ if coverage >= threshold
515
+ puts " Coverage: #{coverage.round(1)}% (threshold: #{threshold}%)"
516
+ true
517
+ else
518
+ puts " Coverage: #{coverage.round(1)}% — below #{threshold}% threshold"
519
+ false
520
+ end
521
+ end
522
+ end
523
+
524
+ public
525
+
285
526
  desc "new NAME", "Create a new Whoosh project"
286
527
  option :minimal, type: :boolean, default: false
287
528
  option :full, type: :boolean, default: false
@@ -33,6 +33,7 @@ module Whoosh
33
33
  write(dir, ".dockerignore", dockerignore)
34
34
  write(dir, ".rubocop.yml", rubocop_config)
35
35
  write(dir, "README.md", readme(name))
36
+ write(dir, "CLAUDE.md", claude_md(name))
36
37
 
37
38
  # Create empty SQLite DB directory
38
39
  FileUtils.mkdir_p(File.join(dir, "db"))
@@ -150,6 +151,8 @@ module Whoosh
150
151
  gem "rack-test"
151
152
  gem "rubocop", require: false
152
153
  gem "brakeman", require: false
154
+ gem "bundler-audit", require: false
155
+ gem "simplecov", require: false
153
156
  end
154
157
  GEM
155
158
 
@@ -256,6 +259,13 @@ module Whoosh
256
259
  <<~RUBY
257
260
  # frozen_string_literal: true
258
261
 
262
+ require "simplecov"
263
+ SimpleCov.start do
264
+ add_filter "/test/"
265
+ add_filter "/spec/"
266
+ minimum_coverage 80
267
+ end
268
+
259
269
  require "whoosh/test"
260
270
  require_relative "../app"
261
271
 
@@ -397,6 +407,180 @@ module Whoosh
397
407
  IGNORE
398
408
  end
399
409
 
410
+ def claude_md(name)
411
+ title = name.gsub(/[-_]/, " ").split.map(&:capitalize).join(" ")
412
+ <<~MD
413
+ # #{title}
414
+
415
+ ## Framework
416
+
417
+ Built with [Whoosh](https://github.com/johannesdwicahyo/whoosh) — AI-first Ruby API framework.
418
+
419
+ ## Commands
420
+
421
+ ```bash
422
+ whoosh s # start server (http://localhost:9292)
423
+ whoosh s --reload # hot reload on file changes
424
+ whoosh ci # run lint + security + tests
425
+ whoosh routes # list all routes
426
+ whoosh describe # dump app structure as JSON
427
+ whoosh check # validate configuration
428
+ whoosh console # IRB with app loaded
429
+ whoosh mcp --list # list MCP tools
430
+ whoosh worker # start background job worker
431
+ bundle exec rspec # run tests
432
+ ```
433
+
434
+ ## Project Structure
435
+
436
+ ```
437
+ app.rb # main app — routes, auth, config
438
+ config.ru # Rack entry point
439
+ config/app.yml # configuration (database, cache, jobs, logging)
440
+ config/plugins.yml # plugin configuration
441
+ endpoints/ # class-based endpoints (auto-loaded)
442
+ schemas/ # request/response schemas (dry-schema)
443
+ models/ # Sequel models
444
+ db/migrations/ # database migrations
445
+ middleware/ # custom middleware
446
+ test/ # RSpec tests
447
+ .env # secrets (never commit)
448
+ ```
449
+
450
+ ## Patterns
451
+
452
+ ### Adding an endpoint
453
+
454
+ ```bash
455
+ whoosh generate endpoint users name:string email:string
456
+ ```
457
+
458
+ Or manually:
459
+
460
+ ```ruby
461
+ # endpoints/users.rb
462
+ class UsersEndpoint < Whoosh::Endpoint
463
+ get "/users"
464
+ post "/users", request: CreateUserSchema
465
+
466
+ def call(req)
467
+ case req.method
468
+ when "GET" then { users: [] }
469
+ when "POST" then { created: true }
470
+ end
471
+ end
472
+ end
473
+ ```
474
+
475
+ ### Adding a schema
476
+
477
+ ```ruby
478
+ # schemas/user.rb
479
+ class CreateUserSchema < Whoosh::Schema
480
+ field :name, String, required: true, desc: "User name"
481
+ field :email, String, required: true, desc: "Email"
482
+ field :age, Integer, min: 0, max: 150
483
+ end
484
+ ```
485
+
486
+ ### Inline routes (in app.rb)
487
+
488
+ ```ruby
489
+ App.get "/health" do
490
+ { status: "ok" }
491
+ end
492
+
493
+ App.post "/items", request: ItemSchema, auth: :api_key do |req|
494
+ { created: req.body[:name] }
495
+ end
496
+ ```
497
+
498
+ ### Auth
499
+
500
+ Protected routes use `auth: :api_key` or `auth: :jwt`:
501
+ ```ruby
502
+ App.get "/protected", auth: :api_key do |req|
503
+ { user: req.env["whoosh.auth"][:key] }
504
+ end
505
+ ```
506
+
507
+ ### Background jobs
508
+
509
+ ```ruby
510
+ class MyJob < Whoosh::Job
511
+ inject :db # DI injection
512
+ queue :default
513
+ retry_limit 3
514
+ retry_backoff :exponential
515
+
516
+ def perform(user_id:)
517
+ { done: true }
518
+ end
519
+ end
520
+
521
+ MyJob.perform_async(user_id: 42)
522
+ MyJob.perform_in(3600, user_id: 42) # 1 hour delay
523
+ ```
524
+
525
+ ### Streaming (SSE/LLM)
526
+
527
+ ```ruby
528
+ App.post "/chat" do |req|
529
+ stream_llm do |out|
530
+ out << "Hello "
531
+ out << "World"
532
+ out.finish
533
+ end
534
+ end
535
+ ```
536
+
537
+ ### MCP tools
538
+
539
+ Routes with `mcp: true` are auto-exposed as MCP tools:
540
+ ```ruby
541
+ App.post "/analyze", mcp: true, request: AnalyzeSchema do |req|
542
+ { result: "analyzed" }
543
+ end
544
+ ```
545
+
546
+ ### Testing
547
+
548
+ ```ruby
549
+ require "whoosh/test"
550
+ RSpec.describe "API" do
551
+ include Whoosh::Test
552
+ def app = App.to_rack
553
+
554
+ it "works" do
555
+ post_json "/items", { name: "test" }
556
+ assert_response 200
557
+ assert_json(name: "test")
558
+ end
559
+ end
560
+ ```
561
+
562
+ ## AI Introspection
563
+
564
+ ```bash
565
+ whoosh describe # full app structure as JSON
566
+ whoosh describe --routes # routes with schemas
567
+ whoosh describe --schemas # all schema definitions
568
+ ```
569
+
570
+ ## Configuration
571
+
572
+ All config in `config/app.yml`. Environment variables override YAML.
573
+ Secrets in `.env` (auto-loaded at boot, never committed).
574
+
575
+ ## Response Format
576
+
577
+ Simple flat JSON. Not JSON:API.
578
+ - Success: `{"name": "Alice"}`
579
+ - Error: `{"error": "validation_failed", "details": [...]}`
580
+ - Pagination: `{"data": [...], "pagination": {"page": 1}}`
581
+ MD
582
+ end
583
+
400
584
  def readme(name)
401
585
  title = name.gsub(/[-_]/, " ").split.map(&:capitalize).join(" ")
402
586
  <<~MD
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Whoosh
4
- VERSION = "1.2.1"
4
+ VERSION = "1.3.0"
5
5
  end
data/lib/whoosh.rb CHANGED
@@ -28,6 +28,7 @@ module Whoosh
28
28
  autoload :Jobs, "whoosh/jobs"
29
29
  autoload :Metrics, "whoosh/metrics"
30
30
  autoload :Paginate, "whoosh/paginate"
31
+ autoload :AI, "whoosh/ai"
31
32
  autoload :VectorStore, "whoosh/vector_store"
32
33
 
33
34
  module Auth
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: whoosh
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Johannes Dwi Cahyo
@@ -174,6 +174,9 @@ files:
174
174
  - README.md
175
175
  - exe/whoosh
176
176
  - lib/whoosh.rb
177
+ - lib/whoosh/ai.rb
178
+ - lib/whoosh/ai/llm.rb
179
+ - lib/whoosh/ai/structured_output.rb
177
180
  - lib/whoosh/app.rb
178
181
  - lib/whoosh/auth/access_control.rb
179
182
  - lib/whoosh/auth/api_key.rb