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 +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 +260 -19
- data/lib/whoosh/cli/project_generator.rb +184 -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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
396
|
+
skipped << "Brakeman (add brakeman to Gemfile)"
|
|
247
397
|
end
|
|
248
398
|
|
|
249
|
-
# Step 3:
|
|
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
|
-
|
|
415
|
+
skipped << "RSpec (add rspec to Gemfile)"
|
|
254
416
|
end
|
|
255
417
|
|
|
256
|
-
# Step
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
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
|