whoosh 1.2.2 → 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 +4 -4
- data/lib/whoosh/ai/llm.rb +139 -0
- data/lib/whoosh/ai/structured_output.rb +31 -0
- data/lib/whoosh/ai.rb +18 -0
- data/lib/whoosh/app.rb +10 -1
- data/lib/whoosh/cli/main.rb +170 -6
- data/lib/whoosh/cli/project_generator.rb +175 -0
- data/lib/whoosh/version.rb +1 -1
- data/lib/whoosh.rb +1 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 589d3cd63bfe7cb2a7ae32d8de43c064eb0e9dadfae68ecb1907bea9a099cd7d
|
|
4
|
+
data.tar.gz: a18f684c03fa83b5e7e86febd6eca0bc79d01d5c3cf719b729118eea91510e9c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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])
|
data/lib/whoosh/cli/main.rb
CHANGED
|
@@ -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 "
|
|
163
|
+
puts "✗ No app.rb found"
|
|
98
164
|
exit 1
|
|
99
165
|
end
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -306,6 +455,21 @@ module Whoosh
|
|
|
306
455
|
|
|
307
456
|
private
|
|
308
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
|
+
|
|
309
473
|
def run_secret_scan
|
|
310
474
|
patterns = [
|
|
311
475
|
/(?:api[_-]?key|secret|password|token)\s*[:=]\s*["'][A-Za-z0-9+\/=]{8,}["']/i,
|
|
@@ -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"))
|
|
@@ -406,6 +407,180 @@ module Whoosh
|
|
|
406
407
|
IGNORE
|
|
407
408
|
end
|
|
408
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
|
+
|
|
409
584
|
def readme(name)
|
|
410
585
|
title = name.gsub(/[-_]/, " ").split.map(&:capitalize).join(" ")
|
|
411
586
|
<<~MD
|
data/lib/whoosh/version.rb
CHANGED
data/lib/whoosh.rb
CHANGED
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.
|
|
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
|