whoosh 1.0.1 → 1.0.2

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: dcdd58c23ddbf6deaa47e53584d661eaad24ac247734aa7bf9cb3a3b3222aae3
4
- data.tar.gz: cb40914a6bcc3ed0bd53108320278b8cb339d00593ec5a8c312024aacc78c13f
3
+ metadata.gz: 6d739583e7066d3f04675dbd6eb4e17809226c909f5c7d4d226b7672957e4a88
4
+ data.tar.gz: 5f12610ddf11479be668f3b0dc88de6f6c3f5640620299ca3512f48ffaf14922
5
5
  SHA512:
6
- metadata.gz: 0f14ef114d78e7b220ad6afb8cbe90e2a5b9ff9493f1e549cb016c29f80c87b7e76bd281332f0244df04f79c7f0a63fc547e3892e00331f952a387fd008ca1db
7
- data.tar.gz: 55b556be2c0d39472350653331af872f16afd3f34fa1535d252f5cb033fffea60a57542a88dfb6cfd49f13933f77b6c9c7dda3c500878dbe8a0d32ff596fbc13
6
+ metadata.gz: 305d2ea3aac7ba3a22be62c52c996aca50956cce5a0983a8d38cc0f76a55a817c25bdb3fe8877127752a0f3d1e3d81d894c80dc395ae4da39d562d2fad58098f
7
+ data.tar.gz: 2cd02f1ab2374bd1334d6dc6bab05fc460e108081eadb8119c9082af41f522c7fb5a8e7f63781b9451aeb319cd001522b7d6998290c662b3c8dbf0706ac03308
data/README.md CHANGED
@@ -337,14 +337,65 @@ whoosh db status # migration status
337
337
 
338
338
  ## Performance
339
339
 
340
- | Benchmark | Result |
341
- |-----------|--------|
342
- | Simple JSON endpoint | **406K req/s** |
343
- | Schema-validated endpoint | **115K req/s** |
344
- | Router lookup (static) | **6.1M lookups/s** |
340
+ ### HTTP Benchmark: `GET /health → {"status":"ok"}`
341
+
342
+ > Apple Silicon arm64, 12 cores. [Full benchmark suite](benchmarks/comparison/)
343
+
344
+ **Single process** (fair 1:1 comparison):
345
+
346
+ | Framework | Language | Server | Req/sec |
347
+ |-----------|----------|--------|---------|
348
+ | Fastify | Node.js 22 | built-in | 69,200 |
349
+ | **Whoosh** | Ruby 3.4 +YJIT | **Falcon** | **24,400** |
350
+ | **Whoosh** | Ruby 3.4 +YJIT | Puma (5 threads) | **15,500** |
351
+ | FastAPI | Python 3.13 | uvicorn | 8,900 |
352
+ | Sinatra | Ruby 3.4 | Puma (5 threads) | 7,100 |
353
+ | PHP (raw) | PHP 8.5 | built-in | 2,000 |
354
+
355
+ > Whoosh + Falcon is **2.7x faster** than FastAPI single-core. Whoosh + Puma is **1.7x faster** than FastAPI. Use Falcon (recommended) for best performance.
356
+
357
+ **Multi-worker** (production deployment):
358
+
359
+ | Framework | Language | Server | Req/sec |
360
+ |-----------|----------|--------|---------|
361
+ | **Whoosh** | Ruby 3.4 +YJIT | **Falcon (4 workers)** | **87,400** |
362
+ | Fastify | Node.js 22 | built-in (single thread) | 69,200 |
363
+ | **Whoosh** | Ruby 3.4 +YJIT | Puma (4w×4t) | **52,500** |
364
+ | Roda | Ruby 3.4 | Puma (4w×4t) | 14,700 |
365
+
366
+ > **Note:** Fastify is single-threaded by design (Node.js event loop). It can scale via `cluster` module but was not tested in that mode. Whoosh + Falcon with 4 workers uses 4 cores.
367
+
368
+ ### Real-World Benchmark: `GET /users/:id` from PostgreSQL (1000 rows)
369
+
370
+ **Single process:**
371
+
372
+ | Framework | Language | Req/sec |
373
+ |-----------|----------|---------|
374
+ | Fastify + pg | Node.js 22 | 36,900 |
375
+ | **Whoosh + Falcon (fiber PG pool)** | Ruby 3.4 +YJIT | **13,400** |
376
+ | **Whoosh + Puma (Sequel)** | Ruby 3.4 +YJIT | **8,600** |
377
+ | Roda + Puma | Ruby 3.4 | 6,700 |
378
+ | Sinatra + Puma | Ruby 3.4 | 4,400 |
379
+ | FastAPI + uvicorn | Python 3.13 | 2,400 |
380
+
381
+ **Multi-worker (PostgreSQL):**
382
+
383
+ | Framework | Language | Req/sec |
384
+ |-----------|----------|---------|
385
+ | **Whoosh + Falcon (4 workers, fiber PG pool)** | Ruby 3.4 +YJIT | **45,900** |
386
+ | Fastify (single thread) | Node.js 22 | 36,900 |
387
+
388
+ > Whoosh + Falcon with fiber-aware PG pool is **5.6x faster** than FastAPI. Multi-worker Falcon **beats Fastify by 24%** on real PostgreSQL workloads.
389
+
390
+ ### Micro-benchmarks
391
+
392
+ | Component | Throughput |
393
+ |-----------|-----------|
394
+ | Router lookup (static, cached) | **6.1M ops/s** |
395
+ | JSON encode (Oj) | **5.4M ops/s** |
345
396
  | Framework overhead | **~2.5µs per request** |
346
397
 
347
- Optimizations: YJIT auto-enabled, Oj JSON auto-detected (5-10x faster), O(1) static route cache, pre-frozen headers, compiled middleware chain.
398
+ Optimizations: YJIT auto-enabled, Oj JSON auto-detected, O(1) static route cache, compiled middleware chain, pre-frozen headers.
348
399
 
349
400
  ## Configuration
350
401
 
data/lib/whoosh/app.rb CHANGED
@@ -276,8 +276,10 @@ module Whoosh
276
276
  register_doc_routes if @config.docs_enabled?
277
277
  register_metrics_route
278
278
  @router.freeze!
279
- inner = method(:handle_request)
280
- app = @middleware_stack.build(inner)
279
+
280
+ # Compile the entire middleware + handler into a single lambda
281
+ # This eliminates 4x nested method calls per request
282
+ app = build_compiled_handler
281
283
  start_job_workers
282
284
  @shutdown.register { @di.close_all }
283
285
  @shutdown.register { @mcp_manager.shutdown_all }
@@ -368,6 +370,78 @@ module Whoosh
368
370
  @middleware_stack.use(Middleware::RequestLogger, logger: @logger, metrics: @metrics)
369
371
  end
370
372
 
373
+ # Compiled handler inlines all default middleware into a single lambda
374
+ # Eliminates nested method calls — ~3-4x faster than middleware stack
375
+ def build_compiled_handler
376
+ logger = @logger
377
+ metrics = @metrics
378
+ max_bytes = 1_048_576
379
+ security_headers = Middleware::SecurityHeaders::HEADERS
380
+
381
+ -> (env) {
382
+ # 1. RequestLimit — check content length
383
+ content_length = env["CONTENT_LENGTH"]&.to_i || 0
384
+ if content_length > max_bytes
385
+ return [413, { "content-type" => "application/json" },
386
+ [JSON.generate({ error: "request_too_large", max_bytes: max_bytes })]]
387
+ end
388
+
389
+ # 2. Request ID (from RequestLogger)
390
+ request_id = env["HTTP_X_REQUEST_ID"] || SecureRandom.uuid
391
+ env["whoosh.request_id"] = request_id
392
+
393
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
394
+
395
+ # 3. CORS preflight
396
+ origin = env["HTTP_ORIGIN"]
397
+ if env["REQUEST_METHOD"] == "OPTIONS" && origin
398
+ cors_headers = {
399
+ "access-control-allow-methods" => "GET, POST, PUT, PATCH, DELETE, OPTIONS",
400
+ "access-control-allow-headers" => "Content-Type, Authorization, X-API-Key, X-Request-ID",
401
+ "access-control-max-age" => "86400",
402
+ "access-control-allow-origin" => "*",
403
+ "access-control-expose-headers" => "X-Request-ID",
404
+ "vary" => "Origin"
405
+ }
406
+ return [204, cors_headers, []]
407
+ end
408
+
409
+ # 4. Handle request (core)
410
+ status, headers, body = handle_request(env)
411
+
412
+ # Ensure headers are mutable (streaming returns frozen headers)
413
+ headers = headers.dup if headers.frozen?
414
+
415
+ # 5. Security headers (inline, no allocation)
416
+ security_headers.each { |k, v| headers[k] ||= v }
417
+
418
+ # 6. CORS headers
419
+ if origin
420
+ headers["access-control-allow-origin"] = "*"
421
+ headers["access-control-expose-headers"] = "X-Request-ID"
422
+ headers["vary"] = "Origin"
423
+ end
424
+
425
+ # 7. Request ID in response
426
+ headers["x-request-id"] = request_id
427
+
428
+ # 8. Logging + metrics
429
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
430
+ logger.info("request_complete",
431
+ method: env["REQUEST_METHOD"], path: env["PATH_INFO"],
432
+ status: status, duration_ms: duration_ms, request_id: request_id)
433
+
434
+ if metrics
435
+ metrics.increment("whoosh_requests_total",
436
+ labels: { method: env["REQUEST_METHOD"], path: env["PATH_INFO"], status: status.to_s })
437
+ metrics.observe("whoosh_request_duration_seconds",
438
+ duration_ms / 1000.0, labels: { path: env["PATH_INFO"] })
439
+ end
440
+
441
+ [status, headers, body]
442
+ }
443
+ end
444
+
371
445
  def register_mcp_tools
372
446
  @router.routes.each do |route|
373
447
  next unless route[:metadata] && route[:metadata][:mcp]
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "fileutils"
5
+ require "securerandom"
5
6
 
6
7
  module Whoosh
7
8
  module CLI
@@ -13,19 +14,47 @@ module Whoosh
13
14
  FileUtils.mkdir_p(File.join(dir, d))
14
15
  end
15
16
 
17
+ jwt_secret = SecureRandom.hex(32)
18
+
16
19
  write(dir, "app.rb", app_rb(name))
17
20
  write(dir, "config.ru", config_ru)
18
- write(dir, "Gemfile", gemfile(full: full))
19
- write(dir, "Rakefile", "require \"rspec/core/rake_task\"\nRSpec::Core::RakeTask.new(:spec)\ntask default: :spec\n")
21
+ write(dir, "Gemfile", gemfile(minimal: minimal, full: full))
22
+ write(dir, "Rakefile", rakefile)
20
23
  write(dir, "config/app.yml", app_yml(name))
21
- write(dir, "config/plugins.yml", plugins_yml_template)
24
+ write(dir, "config/plugins.yml", plugins_yml)
22
25
  write(dir, "endpoints/health.rb", health_endpoint)
23
26
  write(dir, "schemas/health.rb", health_schema)
24
27
  write(dir, "test/test_helper.rb", test_helper)
25
- write(dir, ".env.example", env_example_template)
26
- write(dir, "Dockerfile", dockerfile_template)
27
- write(dir, ".dockerignore", dockerignore_template)
28
- puts "Created #{name}/ — run `cd #{name} && bundle install && whoosh server`"
28
+ write(dir, ".env", env_file(jwt_secret))
29
+ write(dir, ".env.example", env_example)
30
+ write(dir, ".gitignore", gitignore)
31
+ write(dir, ".rspec", rspec_config)
32
+ write(dir, "Dockerfile", dockerfile)
33
+ write(dir, ".dockerignore", dockerignore)
34
+ write(dir, "README.md", readme(name))
35
+
36
+ # Create empty SQLite DB directory
37
+ FileUtils.mkdir_p(File.join(dir, "db"))
38
+
39
+ # Auto-run bundle install
40
+ puts "Created #{name}/"
41
+ puts ""
42
+ Dir.chdir(dir) do
43
+ puts "Installing dependencies..."
44
+ system("bundle install --quiet")
45
+ end
46
+ puts ""
47
+ puts " #{name}/ is ready!"
48
+ puts ""
49
+ puts " cd #{name}"
50
+ puts " whoosh s # start server at http://localhost:9292"
51
+ puts " whoosh s --reload # start with hot reload"
52
+ puts ""
53
+ puts " http://localhost:9292/health # health check"
54
+ puts " http://localhost:9292/healthz # health probes"
55
+ puts " http://localhost:9292/docs # Swagger UI"
56
+ puts " http://localhost:9292/metrics # Prometheus metrics"
57
+ puts ""
29
58
  end
30
59
 
31
60
  class << self
@@ -41,58 +70,147 @@ module Whoosh
41
70
 
42
71
  require "whoosh"
43
72
 
73
+ # Auto-enable YJIT + Oj for best performance
74
+ Whoosh::Performance.optimize!
75
+
44
76
  App = Whoosh::App.new
45
77
 
78
+ # --- API Documentation ---
46
79
  App.openapi do
47
- title "#{name.capitalize} API"
80
+ title "#{name.gsub(/[-_]/, " ").split.map(&:capitalize).join(" ")} API"
48
81
  version "0.1.0"
49
82
  end
50
83
 
84
+ App.docs enabled: true, redoc: true
85
+
86
+ # --- Security ---
87
+ App.auth do
88
+ jwt secret: ENV["JWT_SECRET"], algorithm: :hs256, expiry: 3600
89
+ end
90
+
91
+ App.rate_limit do
92
+ default limit: 60, period: 60
93
+ end
94
+
95
+ # --- Health Check ---
96
+ App.health_check do
97
+ probe(:api) { true }
98
+ end
99
+
100
+ # --- Load Endpoints ---
51
101
  App.load_endpoints(File.join(__dir__, "endpoints"))
52
102
  RUBY
53
103
  end
54
104
 
55
105
  def config_ru
56
- "# frozen_string_literal: true\n\nrequire_relative \"app\"\n\nrun App.to_rack\n"
106
+ <<~RUBY
107
+ # frozen_string_literal: true
108
+
109
+ require_relative "app"
110
+
111
+ run App.to_rack
112
+ RUBY
57
113
  end
58
114
 
59
- def gemfile(full: false)
60
- g = "source \"https://rubygems.org\"\n\ngem \"whoosh\"\n"
115
+ def gemfile(minimal: false, full: false)
116
+ g = <<~GEM
117
+ source "https://rubygems.org"
118
+
119
+ gem "whoosh"
120
+
121
+ # Server (Falcon recommended for best performance)
122
+ gem "falcon"
123
+
124
+ # Fast JSON (5-10x faster than stdlib)
125
+ gem "oj"
126
+
127
+ # Database
128
+ gem "sequel"
129
+ gem "sqlite3"
130
+ GEM
131
+
61
132
  if full
62
- g += "\n# AI & NLP\ngem \"ruby_llm\"\ngem \"lingua-ruby\"\ngem \"ner-ruby\"\n\n# Database\ngem \"sequel\"\ngem \"sqlite3\"\n"
133
+ g += <<~GEM
134
+
135
+ # AI & NLP
136
+ gem "ruby_llm"
137
+ gem "lingua-ruby"
138
+ gem "ner-ruby"
139
+ gem "guardrails-ruby"
140
+ GEM
63
141
  end
64
- g += "\ngroup :development, :test do\n gem \"rspec\"\n gem \"rack-test\"\nend\n"
65
- end
66
142
 
67
- def app_yml(name)
68
- app_yml_template(name)
143
+ g += <<~GEM
144
+
145
+ group :development, :test do
146
+ gem "rspec"
147
+ gem "rack-test"
148
+ end
149
+ GEM
150
+
151
+ g
69
152
  end
70
153
 
71
- def env_example_template
72
- "# WHOOSH_PORT=9292\n# WHOOSH_ENV=development\n# DATABASE_URL=sqlite://db/development.sqlite3\n# REDIS_URL=redis://localhost:6379\n"
154
+ def rakefile
155
+ <<~RUBY
156
+ require "rspec/core/rake_task"
157
+ RSpec::Core::RakeTask.new(:spec)
158
+ task default: :spec
159
+ RUBY
73
160
  end
74
161
 
75
- def app_yml_template(name)
162
+ def app_yml(name)
76
163
  <<~YAML
77
164
  app:
78
- name: "#{name.capitalize} API"
165
+ name: "#{name.gsub(/[-_]/, " ").split.map(&:capitalize).join(" ")} API"
79
166
  port: 9292
80
167
  host: localhost
81
168
 
169
+ server:
170
+ type: falcon
171
+ workers: auto
172
+
82
173
  database:
83
174
  url: <%= ENV.fetch("DATABASE_URL", "sqlite://db/development.sqlite3") %>
84
175
  max_connections: 10
176
+ log_level: debug
85
177
 
86
178
  cache:
87
179
  store: memory
88
180
  default_ttl: 300
89
181
 
182
+ jobs:
183
+ backend: memory
184
+ workers: 2
185
+ retry: 3
186
+
90
187
  logging:
91
188
  level: info
92
189
  format: json
93
190
 
94
191
  docs:
95
192
  enabled: true
193
+
194
+ performance:
195
+ yjit: true
196
+ YAML
197
+ end
198
+
199
+ def plugins_yml
200
+ <<~YAML
201
+ # Plugin configuration
202
+ # Gems are auto-discovered from Gemfile.lock
203
+ # Configure or disable here:
204
+ #
205
+ # lingua:
206
+ # languages: [en, id, ms]
207
+ #
208
+ # guardrails:
209
+ # language_check:
210
+ # enabled: true
211
+ #
212
+ # ner:
213
+ # enabled: false
96
214
  YAML
97
215
  end
98
216
 
@@ -111,61 +229,176 @@ module Whoosh
111
229
  end
112
230
 
113
231
  def health_schema
114
- "# frozen_string_literal: true\n\nclass HealthResponse < Whoosh::Schema\n field :status, String, required: true, desc: \"Health status\"\n field :version, String, desc: \"API version\"\nend\n"
232
+ <<~RUBY
233
+ # frozen_string_literal: true
234
+
235
+ class HealthResponse < Whoosh::Schema
236
+ field :status, String, required: true, desc: "Health status"
237
+ field :version, String, desc: "API version"
238
+ end
239
+ RUBY
115
240
  end
116
241
 
117
242
  def test_helper
118
- "# frozen_string_literal: true\n\nrequire \"rack/test\"\nrequire_relative \"../app\"\n\nRSpec.configure do |config|\n config.include Rack::Test::Methods\nend\n"
243
+ <<~RUBY
244
+ # frozen_string_literal: true
245
+
246
+ require "whoosh/test"
247
+ require_relative "../app"
248
+
249
+ RSpec.configure do |config|
250
+ config.include Whoosh::Test
251
+
252
+ def app
253
+ App.to_rack
254
+ end
255
+ end
256
+ RUBY
119
257
  end
120
258
 
121
- def plugins_yml_template
122
- <<~YAML
123
- # Plugin configuration
124
- # Uncomment and configure as needed:
125
- #
126
- # lingua:
127
- # languages: [en, id, ms]
128
- #
129
- # guardrails:
130
- # language_check:
131
- # enabled: true
132
- #
133
- # ner:
134
- # enabled: false
135
- YAML
259
+ def env_file(jwt_secret)
260
+ <<~ENV
261
+ # Generated by whoosh new — do NOT commit this file
262
+ JWT_SECRET=#{jwt_secret}
263
+ WHOOSH_ENV=development
264
+ DATABASE_URL=sqlite://db/development.sqlite3
265
+ ENV
266
+ end
267
+
268
+ def env_example
269
+ <<~ENV
270
+ # Copy to .env and fill in values
271
+ JWT_SECRET=change_me_to_a_random_64_char_hex
272
+ WHOOSH_ENV=development
273
+ DATABASE_URL=sqlite://db/development.sqlite3
274
+ # REDIS_URL=redis://localhost:6379
275
+ ENV
276
+ end
277
+
278
+ def gitignore
279
+ <<~GIT
280
+ # Dependencies
281
+ /vendor/bundle
282
+ /.bundle
283
+
284
+ # Environment
285
+ .env
286
+ .env.local
287
+ .env.production
288
+
289
+ # Database
290
+ db/*.sqlite3
291
+
292
+ # Logs
293
+ /log/*
294
+ /tmp/*
295
+
296
+ # OS
297
+ .DS_Store
298
+ *.swp
299
+ *~
300
+
301
+ # IDE
302
+ .idea/
303
+ .vscode/
304
+ *.code-workspace
305
+
306
+ # Gems
307
+ *.gem
308
+ Gemfile.lock
309
+ GIT
136
310
  end
137
311
 
138
- def dockerfile_template
312
+ def rspec_config
313
+ <<~RSPEC
314
+ --require spec_helper
315
+ --format documentation
316
+ --color
317
+ --order random
318
+ RSPEC
319
+ end
320
+
321
+ def dockerfile
139
322
  <<~DOCKERFILE
140
323
  FROM ruby:3.4-slim
141
324
 
142
325
  WORKDIR /app
143
326
 
144
- # Install dependencies
327
+ # Install system dependencies
328
+ RUN apt-get update -qq && apt-get install -y build-essential libsqlite3-dev
329
+
330
+ # Install gems
145
331
  COPY Gemfile Gemfile.lock ./
146
332
  RUN bundle install --without development test
147
333
 
148
334
  # Copy app
149
335
  COPY . .
150
336
 
151
- # Expose port
152
337
  EXPOSE 9292
153
338
 
154
- # Start server
339
+ # Start with Falcon for best performance
155
340
  CMD ["bundle", "exec", "whoosh", "s", "-p", "9292", "--host", "0.0.0.0"]
156
341
  DOCKERFILE
157
342
  end
158
343
 
159
- def dockerignore_template
344
+ def dockerignore
160
345
  <<~IGNORE
161
346
  .git
162
347
  .env
348
+ .env.*
163
349
  node_modules
164
350
  tmp
165
351
  log
166
352
  db/*.sqlite3
353
+ *.gem
354
+ .DS_Store
167
355
  IGNORE
168
356
  end
357
+
358
+ def readme(name)
359
+ title = name.gsub(/[-_]/, " ").split.map(&:capitalize).join(" ")
360
+ <<~MD
361
+ # #{title} API
362
+
363
+ Built with [Whoosh](https://github.com/johannesdwicahyo/whoosh) — AI-first Ruby API framework.
364
+
365
+ ## Quick Start
366
+
367
+ ```bash
368
+ whoosh s # http://localhost:9292
369
+ whoosh s --reload # hot reload
370
+ ```
371
+
372
+ ## Endpoints
373
+
374
+ | Method | Path | Description |
375
+ |--------|------|-------------|
376
+ | GET | /health | Health check |
377
+ | GET | /healthz | Health probes |
378
+ | GET | /docs | Swagger UI |
379
+ | GET | /redoc | ReDoc |
380
+ | GET | /openapi.json | OpenAPI spec |
381
+ | GET | /metrics | Prometheus metrics |
382
+
383
+ ## Development
384
+
385
+ ```bash
386
+ whoosh generate endpoint users # create endpoint + schema + test
387
+ whoosh generate schema CreateUser # create schema
388
+ whoosh generate model User name:string email:string
389
+ whoosh routes # list all routes
390
+ whoosh console # IRB with app loaded
391
+ bundle exec rspec # run tests
392
+ ```
393
+
394
+ ## Deploy
395
+
396
+ ```bash
397
+ docker build -t #{name} .
398
+ docker run -p 9292:9292 -e JWT_SECRET=your_secret #{name}
399
+ ```
400
+ MD
401
+ end
169
402
  end
170
403
  end
171
404
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Whoosh
4
- VERSION = "1.0.1"
4
+ VERSION = "1.0.2"
5
5
  end
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.0.1
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Johannes Dwi Cahyo