whoosh 1.5.0 → 1.7.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/README.md +101 -47
- data/lib/whoosh/app.rb +3 -2
- data/lib/whoosh/cli/client_generator.rb +237 -0
- data/lib/whoosh/cli/main.rb +10 -0
- data/lib/whoosh/client_gen/base_generator.rb +84 -0
- data/lib/whoosh/client_gen/dependency_checker.rb +49 -0
- data/lib/whoosh/client_gen/fallback_backend.rb +292 -0
- data/lib/whoosh/client_gen/generators/expo.rb +1038 -0
- data/lib/whoosh/client_gen/generators/flutter.rb +915 -0
- data/lib/whoosh/client_gen/generators/htmx.rb +498 -0
- data/lib/whoosh/client_gen/generators/ios.rb +832 -0
- data/lib/whoosh/client_gen/generators/react_spa.rb +932 -0
- data/lib/whoosh/client_gen/generators/telegram_bot.rb +624 -0
- data/lib/whoosh/client_gen/generators/telegram_mini_app.rb +844 -0
- data/lib/whoosh/client_gen/introspector.rb +178 -0
- data/lib/whoosh/client_gen/ir.rb +37 -0
- data/lib/whoosh/version.rb +1 -1
- metadata +14 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7276060a2fd0b8ef3edc620c087767b92de6684e6ef54a130f5c3b45f5468a52
|
|
4
|
+
data.tar.gz: 98f9f219bbc2e01af9ba63803a525d100fcd70adf397248065db796b28319ae4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a12f5a8f431ff694382192e046072596beeaced996cf47e19633ea3974ebd7bae17bc079118b28cc08b019282219d0ff4cf263291eed418a38d2f32a4021d38a
|
|
7
|
+
data.tar.gz: 76c4e804ea925a037ecd60fcc02834800fb0d2ec000d9fdfb48a7a142625c32e37dd3f8bdbf56bae934f1a6ee46a3731a1f0db06cddac660630c4f14bdccd579
|
data/README.md
CHANGED
|
@@ -5,27 +5,38 @@
|
|
|
5
5
|
<h1 align="center">Whoosh</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
<strong>
|
|
9
|
-
|
|
8
|
+
<strong>The fastest way to ship a production MCP server in Ruby.</strong><br>
|
|
9
|
+
A FastAPI-style framework with MCP, schema validation, auth, streaming, and OpenAPI — built in.
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
12
|
<p align="center">
|
|
13
13
|
<img src="https://img.shields.io/badge/ruby-%3E%3D%203.4.0-red" alt="Ruby">
|
|
14
14
|
<img src="https://img.shields.io/badge/rack-3.0-blue" alt="Rack">
|
|
15
15
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
|
16
|
-
<img src="https://img.shields.io/badge/tests-
|
|
17
|
-
<img src="https://img.shields.io/badge/
|
|
16
|
+
<img src="https://img.shields.io/badge/tests-659%20passing-brightgreen" alt="Tests">
|
|
17
|
+
<img src="https://img.shields.io/badge/stability-evolving-yellow" alt="Stability">
|
|
18
18
|
</p>
|
|
19
19
|
|
|
20
20
|
---
|
|
21
21
|
|
|
22
22
|
## Why Whoosh?
|
|
23
23
|
|
|
24
|
-
- **
|
|
25
|
-
- **
|
|
26
|
-
- **Batteries included** —
|
|
27
|
-
- **
|
|
28
|
-
- **
|
|
24
|
+
- **MCP-native** — opt routes into MCP with `mcp: true` and they become typed tools over stdio/SSE. No glue code, no separate server.
|
|
25
|
+
- **FastAPI-style DSL in Ruby** — declarative schemas, typed request/response, auto-generated OpenAPI + Swagger UI, dependency injection.
|
|
26
|
+
- **Batteries included** — auth (JWT, API key, OAuth), rate limiting, caching, background jobs, file uploads, vector search, streaming, pagination.
|
|
27
|
+
- **Agent-friendly** — `whoosh describe` emits a JSON snapshot of your app; generated `CLAUDE.md` so coding agents understand it; `whoosh check` validates config before runtime.
|
|
28
|
+
- **Competitive Ruby performance** — YJIT + Falcon fibers + Oj, ~2.5µs framework overhead. See [Performance](#performance) for honest, per-core comparisons.
|
|
29
|
+
|
|
30
|
+
## When NOT to use Whoosh
|
|
31
|
+
|
|
32
|
+
Whoosh is on 1.x but still evolving — solo-maintained, without a production track record yet, and breaking changes ship occasionally (always called out in `CHANGELOG.md`). Reach for something else when:
|
|
33
|
+
|
|
34
|
+
- **You need a managed backend.** Supabase, PocketBase, or Firebase give you DB + auth + realtime without hosting a framework. Whoosh is the app layer — use it *with* a managed DB if that fits.
|
|
35
|
+
- **You want maximum ecosystem depth.** Rails has more gems; FastAPI has the Python ML/AI library ecosystem (PyTorch, transformers, LangChain). If your core workload lives in those libraries, stay where they are.
|
|
36
|
+
- **You need a frozen API surface.** Being on 1.x doesn't mean the API is locked — breaking changes still ship when the design calls for it. If you need strict stability contracts today, wait a few releases.
|
|
37
|
+
- **Your team has no Ruby experience** and the project isn't specifically about AI/MCP. Hiring and ecosystem gravity usually beat framework features.
|
|
38
|
+
|
|
39
|
+
Whoosh's sweet spot: Ruby shops (or Ruby-curious teams) building AI / LLM / MCP-backed APIs who want typed schemas, OpenAPI, and MCP without wiring three libraries together.
|
|
29
40
|
|
|
30
41
|
## Install
|
|
31
42
|
|
|
@@ -192,21 +203,24 @@ end
|
|
|
192
203
|
|
|
193
204
|
### MCP (Model Context Protocol)
|
|
194
205
|
|
|
195
|
-
**
|
|
206
|
+
**Routes are exposed as MCP tools only when you opt in with `mcp: true`.** This prevents internal or admin endpoints from being callable as tools by accident.
|
|
196
207
|
|
|
197
208
|
```ruby
|
|
198
|
-
#
|
|
199
|
-
app.post "/summarize", request: SummarizeRequest do |req|
|
|
209
|
+
# Opt in per route:
|
|
210
|
+
app.post "/summarize", request: SummarizeRequest, mcp: true do |req|
|
|
200
211
|
{ summary: llm.summarize(req.body[:text]) }
|
|
201
212
|
end
|
|
202
213
|
|
|
203
|
-
|
|
204
|
-
|
|
214
|
+
# Or opt in a whole group:
|
|
215
|
+
app.group "/tools", mcp: true do
|
|
216
|
+
post "/translate" do |req|
|
|
217
|
+
{ result: translate(req.body["text"]) }
|
|
218
|
+
end
|
|
205
219
|
end
|
|
206
220
|
|
|
207
|
-
#
|
|
208
|
-
app.get "/internal"
|
|
209
|
-
{ debug: "not exposed
|
|
221
|
+
# Default: not exposed as an MCP tool.
|
|
222
|
+
app.get "/internal" do
|
|
223
|
+
{ debug: "not exposed" }
|
|
210
224
|
end
|
|
211
225
|
```
|
|
212
226
|
|
|
@@ -346,6 +360,43 @@ app.docs enabled: true, redoc: true
|
|
|
346
360
|
- `/redoc` — ReDoc
|
|
347
361
|
- `/openapi.json` — Machine-readable spec
|
|
348
362
|
|
|
363
|
+
### Client Generator
|
|
364
|
+
|
|
365
|
+
Generate complete, typed, ready-to-run client apps from your Whoosh API — one command.
|
|
366
|
+
|
|
367
|
+
```sh
|
|
368
|
+
whoosh generate client react_spa # React + Vite + TypeScript
|
|
369
|
+
whoosh generate client expo # Expo + React Native
|
|
370
|
+
whoosh generate client ios # SwiftUI + MVVM
|
|
371
|
+
whoosh generate client flutter # Dart + Riverpod + GoRouter
|
|
372
|
+
whoosh generate client htmx # Plain HTML + htmx, no build step
|
|
373
|
+
whoosh generate client telegram_bot # Ruby Telegram bot
|
|
374
|
+
whoosh generate client telegram_mini_app # React + Telegram WebApp SDK
|
|
375
|
+
|
|
376
|
+
whoosh generate client react_spa --oauth # Add Google/GitHub/Apple login
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
The generator **introspects your Whoosh app** via OpenAPI — it reads your routes, schemas, and auth config, then produces a typed client with:
|
|
380
|
+
|
|
381
|
+
- API client with auth headers and automatic token refresh
|
|
382
|
+
- Model types matching your schemas
|
|
383
|
+
- Auth screens (login, register, logout)
|
|
384
|
+
- CRUD screens for every resource
|
|
385
|
+
- Navigation and routing
|
|
386
|
+
- Starter tests
|
|
387
|
+
|
|
388
|
+
If no Whoosh app exists yet, it scaffolds a standard backend (JWT auth + tasks CRUD) alongside the client.
|
|
389
|
+
|
|
390
|
+
| Client | Stack | Token Storage |
|
|
391
|
+
|--------|-------|---------------|
|
|
392
|
+
| `react_spa` | React 19, Vite, TypeScript, React Router | localStorage |
|
|
393
|
+
| `expo` | Expo SDK 52, Expo Router, TypeScript | SecureStore |
|
|
394
|
+
| `ios` | SwiftUI, async/await, MVVM | Keychain |
|
|
395
|
+
| `flutter` | Dart, Dio, Riverpod, GoRouter | flutter_secure_storage |
|
|
396
|
+
| `htmx` | HTML, htmx 2.x, vanilla JS | localStorage |
|
|
397
|
+
| `telegram_bot` | Ruby, telegram-bot-ruby | In-memory session |
|
|
398
|
+
| `telegram_mini_app` | React, Telegram WebApp SDK | Telegram initData |
|
|
399
|
+
|
|
349
400
|
### Health Checks
|
|
350
401
|
|
|
351
402
|
```ruby
|
|
@@ -377,6 +428,8 @@ whoosh generate model User name:string email:string
|
|
|
377
428
|
whoosh generate migration add_email_to_users
|
|
378
429
|
whoosh generate plugin my_tool # plugin boilerplate
|
|
379
430
|
whoosh generate proto ChatRequest # .proto file
|
|
431
|
+
whoosh generate client react_spa # full client app (7 types)
|
|
432
|
+
whoosh generate client expo --oauth # with OAuth2 social login
|
|
380
433
|
|
|
381
434
|
whoosh db migrate # run migrations
|
|
382
435
|
whoosh db rollback # rollback
|
|
@@ -401,55 +454,56 @@ whoosh check # validates config, auth, dependencies
|
|
|
401
454
|
|
|
402
455
|
## Performance
|
|
403
456
|
|
|
404
|
-
|
|
457
|
+
> Apple Silicon arm64, 12 cores. Ruby 3.4 + YJIT. [Full benchmark suite & reproduction steps](benchmarks/comparison/)
|
|
458
|
+
>
|
|
459
|
+
> **How to read these numbers.** Benchmarks are selective by nature. A `GET /health` returning `{"status":"ok"}` tests the router + serializer, not your real app. A Postgres read tests one query pattern. We show single-process (per-core) numbers first because that's the fair cross-language comparison. Multi-worker numbers are included for deployment sizing, but scaling strategies differ per runtime (Node uses `cluster`, Python uses multiple workers, Ruby uses workers × threads or fibers) and mixing them isn't apples-to-apples.
|
|
405
460
|
|
|
406
|
-
|
|
461
|
+
### HTTP micro-benchmark — `GET /health`
|
|
407
462
|
|
|
408
|
-
**Single process
|
|
463
|
+
**Single process (per-core, fair comparison):**
|
|
409
464
|
|
|
410
465
|
| Framework | Language | Server | Req/sec |
|
|
411
466
|
|-----------|----------|--------|---------|
|
|
412
467
|
| Fastify | Node.js 22 | built-in | 69,200 |
|
|
413
|
-
| **Whoosh** | Ruby 3.4 +YJIT |
|
|
468
|
+
| **Whoosh** | Ruby 3.4 +YJIT | Falcon | **24,400** |
|
|
414
469
|
| **Whoosh** | Ruby 3.4 +YJIT | Puma (5 threads) | **15,500** |
|
|
415
470
|
| FastAPI | Python 3.13 | uvicorn | 8,900 |
|
|
416
471
|
| Sinatra | Ruby 3.4 | Puma (5 threads) | 7,100 |
|
|
417
|
-
| PHP (raw) | PHP 8.5 | built-in | 2,000 |
|
|
418
472
|
|
|
419
|
-
|
|
473
|
+
On this microbenchmark, Fastify is ~2.8× Whoosh+Falcon per-core; that's the honest picture for trivial JSON. Against other Ruby frameworks and against FastAPI on CPython, Whoosh is competitive.
|
|
420
474
|
|
|
421
|
-
**Multi-worker
|
|
475
|
+
**Multi-worker (sizing reference, not apples-to-apples):**
|
|
422
476
|
|
|
423
|
-
| Framework |
|
|
424
|
-
|
|
425
|
-
|
|
|
426
|
-
| Fastify |
|
|
427
|
-
|
|
|
428
|
-
| Roda |
|
|
477
|
+
| Framework | Server | Req/sec |
|
|
478
|
+
|-----------|--------|---------|
|
|
479
|
+
| Whoosh | Falcon (4 workers) | 87,400 |
|
|
480
|
+
| Fastify | built-in (single thread, no cluster) | 69,200 |
|
|
481
|
+
| Whoosh | Puma (4w × 4t) | 52,500 |
|
|
482
|
+
| Roda | Puma (4w × 4t) | 14,700 |
|
|
429
483
|
|
|
430
|
-
|
|
484
|
+
Fastify was not run under `cluster`; don't read this table as "Whoosh beats Fastify." Read it as "Whoosh on 4 cores handles ~87K req/s on trivial JSON."
|
|
431
485
|
|
|
432
|
-
### Real-
|
|
486
|
+
### Real-world benchmark — `GET /users/:id` from PostgreSQL (1000-row table)
|
|
433
487
|
|
|
434
|
-
**Single process:**
|
|
488
|
+
**Single process (per-core):**
|
|
435
489
|
|
|
436
|
-
| Framework |
|
|
437
|
-
|
|
438
|
-
| Fastify + pg |
|
|
439
|
-
| **Whoosh + Falcon (fiber PG pool)** |
|
|
440
|
-
|
|
|
441
|
-
| Roda + Puma |
|
|
442
|
-
| Sinatra + Puma |
|
|
443
|
-
| FastAPI + uvicorn |
|
|
490
|
+
| Framework | Req/sec |
|
|
491
|
+
|-----------|---------|
|
|
492
|
+
| Fastify + pg | 36,900 |
|
|
493
|
+
| **Whoosh + Falcon (fiber PG pool)** | **13,400** |
|
|
494
|
+
| Whoosh + Puma (Sequel) | 8,600 |
|
|
495
|
+
| Roda + Puma | 6,700 |
|
|
496
|
+
| Sinatra + Puma | 4,400 |
|
|
497
|
+
| FastAPI + uvicorn | 2,400 |
|
|
444
498
|
|
|
445
|
-
|
|
499
|
+
On realistic DB-bound work, Whoosh's fiber-aware PG pool closes a lot of the gap vs Fastify (~2.75×) and has a wide lead over FastAPI on CPython and over other Ruby frameworks.
|
|
446
500
|
|
|
447
|
-
|
|
448
|
-
|-----------|----------|---------|
|
|
449
|
-
| **Whoosh + Falcon (4 workers, fiber PG pool)** | Ruby 3.4 +YJIT | **45,900** |
|
|
450
|
-
| Fastify (single thread) | Node.js 22 | 36,900 |
|
|
501
|
+
**Multi-worker (sizing reference):**
|
|
451
502
|
|
|
452
|
-
|
|
503
|
+
| Framework | Req/sec |
|
|
504
|
+
|-----------|---------|
|
|
505
|
+
| Whoosh + Falcon (4 workers, fiber PG pool) | 45,900 |
|
|
506
|
+
| Fastify (single thread) | 36,900 |
|
|
453
507
|
|
|
454
508
|
### Micro-benchmarks
|
|
455
509
|
|
data/lib/whoosh/app.rb
CHANGED
|
@@ -493,8 +493,9 @@ module Whoosh
|
|
|
493
493
|
internal_paths = %w[/openapi.json /docs /redoc /metrics /healthz]
|
|
494
494
|
|
|
495
495
|
@router.routes.each do |route|
|
|
496
|
-
#
|
|
497
|
-
|
|
496
|
+
# Routes are exposed as MCP tools only when opted in with mcp: true.
|
|
497
|
+
# Why: default-expose leaks internal/admin endpoints as callable tools.
|
|
498
|
+
next unless route[:metadata] && route[:metadata][:mcp] == true
|
|
498
499
|
next if internal_paths.include?(route[:path])
|
|
499
500
|
|
|
500
501
|
tool_name = "#{route[:method]} #{route[:path]}"
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "whoosh/client_gen/ir"
|
|
4
|
+
require "whoosh/client_gen/introspector"
|
|
5
|
+
require "whoosh/client_gen/base_generator"
|
|
6
|
+
require "whoosh/client_gen/dependency_checker"
|
|
7
|
+
require "whoosh/client_gen/fallback_backend"
|
|
8
|
+
|
|
9
|
+
module Whoosh
|
|
10
|
+
module ClientGen
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module CLI
|
|
15
|
+
class ClientGenerator
|
|
16
|
+
CLIENT_TYPES = %i[react_spa expo ios flutter htmx telegram_bot telegram_mini_app].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :type, :oauth, :output_dir
|
|
19
|
+
|
|
20
|
+
def self.client_types
|
|
21
|
+
CLIENT_TYPES
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(type:, oauth:, dir:, root: Dir.pwd)
|
|
25
|
+
@type = type.to_sym
|
|
26
|
+
@oauth = oauth
|
|
27
|
+
@output_dir = dir || default_output_dir
|
|
28
|
+
@root = root
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run
|
|
32
|
+
validate!
|
|
33
|
+
check_dependencies!
|
|
34
|
+
result = introspect_or_fallback
|
|
35
|
+
|
|
36
|
+
case result[:mode]
|
|
37
|
+
when :introspected
|
|
38
|
+
display_found(result[:ir])
|
|
39
|
+
ir = confirm_selection(result[:ir])
|
|
40
|
+
generate_client(ir)
|
|
41
|
+
when :fallback
|
|
42
|
+
display_fallback_prompt
|
|
43
|
+
generate_fallback_backend
|
|
44
|
+
ir = build_fallback_ir
|
|
45
|
+
generate_client(ir)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
display_success
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate!
|
|
52
|
+
unless CLIENT_TYPES.include?(@type)
|
|
53
|
+
raise ClientGen::Error, "Unknown client type: #{@type}. Supported: #{CLIENT_TYPES.join(", ")}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def default_output_dir
|
|
58
|
+
"clients/#{@type}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def check_dependencies!
|
|
62
|
+
result = ClientGen::DependencyChecker.check(@type)
|
|
63
|
+
return if result[:ok]
|
|
64
|
+
|
|
65
|
+
puts "\n⚠️ Missing dependencies for #{@type}:"
|
|
66
|
+
result[:missing].each do |dep|
|
|
67
|
+
msg = " - #{dep[:cmd]} (check: #{dep[:check]})"
|
|
68
|
+
msg += " — found v#{dep[:found_version]}, need v#{dep[:min_version]}+" if dep[:found_version]
|
|
69
|
+
puts msg
|
|
70
|
+
end
|
|
71
|
+
puts "\nInstall the missing dependencies and try again."
|
|
72
|
+
exit 1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def introspect_or_fallback
|
|
76
|
+
app = load_app
|
|
77
|
+
if app
|
|
78
|
+
introspector = ClientGen::Introspector.new(app, base_url: detect_base_url(app))
|
|
79
|
+
ir = introspector.introspect
|
|
80
|
+
if ir.has_resources? || ir.has_auth?
|
|
81
|
+
{ mode: :introspected, ir: ir }
|
|
82
|
+
else
|
|
83
|
+
{ mode: :fallback }
|
|
84
|
+
end
|
|
85
|
+
else
|
|
86
|
+
{ mode: :fallback }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def load_app
|
|
93
|
+
app_file = File.join(@root, "app.rb")
|
|
94
|
+
return nil unless File.exist?(app_file)
|
|
95
|
+
|
|
96
|
+
require app_file
|
|
97
|
+
ObjectSpace.each_object(Whoosh::App).first
|
|
98
|
+
rescue => e
|
|
99
|
+
puts "⚠️ Failed to load app: #{e.message}"
|
|
100
|
+
puts "Run `whoosh check` to debug."
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def detect_base_url(app)
|
|
105
|
+
config = app.instance_variable_get(:@config)
|
|
106
|
+
port = config&.respond_to?(:port) ? config.port : 9292
|
|
107
|
+
host = config&.respond_to?(:host) ? config.host : "localhost"
|
|
108
|
+
"http://#{host}:#{port}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def display_found(ir)
|
|
112
|
+
puts "\n🔍 Inspecting Whoosh app...\n\n"
|
|
113
|
+
puts "Found:"
|
|
114
|
+
puts " Auth: #{ir.auth&.type || "none"}"
|
|
115
|
+
ir.resources.each do |r|
|
|
116
|
+
puts " Resource: #{r.name} (#{r.endpoints.length} endpoints)"
|
|
117
|
+
end
|
|
118
|
+
ir.streaming.each do |s|
|
|
119
|
+
puts " Streaming: #{s[:type]} on #{s[:path]}"
|
|
120
|
+
end
|
|
121
|
+
puts
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def confirm_selection(ir)
|
|
125
|
+
ir
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def display_fallback_prompt
|
|
129
|
+
puts "\n⚠️ No Whoosh app found (or no routes defined).\n\n"
|
|
130
|
+
puts "Generating standard starter with:"
|
|
131
|
+
puts " - JWT auth (email/password login + register)"
|
|
132
|
+
puts " - Tasks CRUD (title, description, status, due_date)"
|
|
133
|
+
puts " - Matching backend endpoints"
|
|
134
|
+
if @oauth
|
|
135
|
+
puts " - OAuth2 (Google, GitHub, Apple)"
|
|
136
|
+
end
|
|
137
|
+
puts
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def generate_fallback_backend
|
|
141
|
+
ClientGen::FallbackBackend.generate(root: @root, oauth: @oauth)
|
|
142
|
+
puts "✅ Backend endpoints generated"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def build_fallback_ir
|
|
146
|
+
ClientGen::IR::AppSpec.new(
|
|
147
|
+
auth: ClientGen::IR::Auth.new(
|
|
148
|
+
type: :jwt,
|
|
149
|
+
endpoints: {
|
|
150
|
+
login: { method: :post, path: "/auth/login" },
|
|
151
|
+
register: { method: :post, path: "/auth/register" },
|
|
152
|
+
refresh: { method: :post, path: "/auth/refresh" },
|
|
153
|
+
logout: { method: :delete, path: "/auth/logout" },
|
|
154
|
+
me: { method: :get, path: "/auth/me" }
|
|
155
|
+
},
|
|
156
|
+
oauth_providers: @oauth ? %i[google github apple] : []
|
|
157
|
+
),
|
|
158
|
+
resources: [
|
|
159
|
+
ClientGen::IR::Resource.new(
|
|
160
|
+
name: :tasks,
|
|
161
|
+
endpoints: [
|
|
162
|
+
ClientGen::IR::Endpoint.new(method: :get, path: "/tasks", action: :index, pagination: true),
|
|
163
|
+
ClientGen::IR::Endpoint.new(method: :get, path: "/tasks/:id", action: :show),
|
|
164
|
+
ClientGen::IR::Endpoint.new(method: :post, path: "/tasks", action: :create),
|
|
165
|
+
ClientGen::IR::Endpoint.new(method: :put, path: "/tasks/:id", action: :update),
|
|
166
|
+
ClientGen::IR::Endpoint.new(method: :delete, path: "/tasks/:id", action: :destroy)
|
|
167
|
+
],
|
|
168
|
+
fields: [
|
|
169
|
+
{ name: :title, type: :string, required: true },
|
|
170
|
+
{ name: :description, type: :string, required: false },
|
|
171
|
+
{ name: :status, type: :string, required: false, enum: %w[pending in_progress done], default: "pending" },
|
|
172
|
+
{ name: :due_date, type: :string, required: false }
|
|
173
|
+
]
|
|
174
|
+
)
|
|
175
|
+
],
|
|
176
|
+
streaming: [],
|
|
177
|
+
base_url: "http://localhost:9292"
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def generate_client(ir)
|
|
182
|
+
generator_class = load_generator_class
|
|
183
|
+
output = File.join(@root, @output_dir)
|
|
184
|
+
|
|
185
|
+
if Dir.exist?(output) && !Dir.empty?(output)
|
|
186
|
+
puts "⚠️ #{@output_dir}/ already exists."
|
|
187
|
+
print "Overwrite? [y/N] "
|
|
188
|
+
answer = $stdin.gets&.strip&.downcase
|
|
189
|
+
unless answer == "y"
|
|
190
|
+
puts "Aborted."
|
|
191
|
+
exit 0
|
|
192
|
+
end
|
|
193
|
+
FileUtils.rm_rf(output)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
generator_class.new(ir: ir, output_dir: output, platform: platform_for_type).generate
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def load_generator_class
|
|
200
|
+
require "whoosh/client_gen/generators/#{@type}"
|
|
201
|
+
Whoosh::ClientGen::Generators.const_get(camelize(@type.to_s))
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def platform_for_type
|
|
205
|
+
case @type
|
|
206
|
+
when :react_spa, :expo, :telegram_mini_app then :typescript
|
|
207
|
+
when :ios then :swift
|
|
208
|
+
when :flutter then :dart
|
|
209
|
+
when :htmx then :html
|
|
210
|
+
when :telegram_bot then :ruby
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def camelize(str)
|
|
215
|
+
str.split("_").map(&:capitalize).join
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def display_success
|
|
219
|
+
puts "\n✅ Generated #{@type} client in #{@output_dir}/"
|
|
220
|
+
case @type
|
|
221
|
+
when :react_spa, :telegram_mini_app
|
|
222
|
+
puts " Run: cd #{@output_dir} && npm install && npm run dev"
|
|
223
|
+
when :expo
|
|
224
|
+
puts " Run: cd #{@output_dir} && npm install && npx expo start"
|
|
225
|
+
when :ios
|
|
226
|
+
puts " Run: open #{@output_dir}/WhooshApp.xcodeproj"
|
|
227
|
+
when :flutter
|
|
228
|
+
puts " Run: cd #{@output_dir} && flutter pub get && flutter run"
|
|
229
|
+
when :htmx
|
|
230
|
+
puts " Run: cd #{@output_dir} && open index.html (or any static server)"
|
|
231
|
+
when :telegram_bot
|
|
232
|
+
puts " Run: cd #{@output_dir} && bundle install && ruby bot.rb"
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
data/lib/whoosh/cli/main.rb
CHANGED
|
@@ -600,6 +600,16 @@ module Whoosh
|
|
|
600
600
|
require "whoosh/cli/generators"
|
|
601
601
|
Whoosh::CLI::Generators.proto(name)
|
|
602
602
|
end
|
|
603
|
+
|
|
604
|
+
desc "client TYPE", "Generate a client app (react_spa, expo, ios, flutter, htmx, telegram_bot, telegram_mini_app)"
|
|
605
|
+
option :oauth, type: :boolean, default: false, desc: "Include OAuth2 social login"
|
|
606
|
+
option :dir, type: :string, desc: "Output directory (default: clients/<type>)"
|
|
607
|
+
def client(type)
|
|
608
|
+
require "whoosh/cli/client_generator"
|
|
609
|
+
Whoosh::CLI::ClientGenerator.new(
|
|
610
|
+
type: type, oauth: options[:oauth], dir: options[:dir]
|
|
611
|
+
).run
|
|
612
|
+
end
|
|
603
613
|
}
|
|
604
614
|
end
|
|
605
615
|
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# lib/whoosh/client_gen/base_generator.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "whoosh/client_gen/ir"
|
|
6
|
+
|
|
7
|
+
module Whoosh
|
|
8
|
+
module ClientGen
|
|
9
|
+
class BaseGenerator
|
|
10
|
+
TYPE_MAPS = {
|
|
11
|
+
typescript: {
|
|
12
|
+
string: "string", integer: "number", number: "number",
|
|
13
|
+
boolean: "boolean", array: "any[]", object: "Record<string, any>"
|
|
14
|
+
},
|
|
15
|
+
swift: {
|
|
16
|
+
string: "String", integer: "Int", number: "Double",
|
|
17
|
+
boolean: "Bool", array: "[Any]", object: "[String: Any]"
|
|
18
|
+
},
|
|
19
|
+
dart: {
|
|
20
|
+
string: "String", integer: "int", number: "double",
|
|
21
|
+
boolean: "bool", array: "List<dynamic>", object: "Map<String, dynamic>"
|
|
22
|
+
},
|
|
23
|
+
ruby: {
|
|
24
|
+
string: "String", integer: "Integer", number: "Float",
|
|
25
|
+
boolean: "Boolean", array: "Array", object: "Hash"
|
|
26
|
+
},
|
|
27
|
+
html: {
|
|
28
|
+
string: "text", integer: "number", number: "number",
|
|
29
|
+
boolean: "checkbox", array: "text", object: "text"
|
|
30
|
+
}
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
attr_reader :ir, :output_dir, :platform
|
|
34
|
+
|
|
35
|
+
def initialize(ir:, output_dir:, platform:)
|
|
36
|
+
@ir = ir
|
|
37
|
+
@output_dir = output_dir
|
|
38
|
+
@platform = platform
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def generate
|
|
42
|
+
raise NotImplementedError, "Subclasses must implement #generate"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def type_for(ir_type)
|
|
46
|
+
TYPE_MAPS.dig(@platform, ir_type.to_sym) || "string"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def write_file(relative_path, content)
|
|
50
|
+
full_path = File.join(@output_dir, relative_path)
|
|
51
|
+
FileUtils.mkdir_p(File.dirname(full_path))
|
|
52
|
+
File.write(full_path, content)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def classify(name)
|
|
56
|
+
singular = singularize(name.to_s)
|
|
57
|
+
singular.split(/[-_]/).map(&:capitalize).join
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def singularize(word)
|
|
61
|
+
w = word.to_s
|
|
62
|
+
if w.end_with?("ies")
|
|
63
|
+
w[0..-4] + "y"
|
|
64
|
+
elsif w.end_with?("ses") || w.end_with?("xes") || w.end_with?("zes") || w.end_with?("ches") || w.end_with?("shes")
|
|
65
|
+
w[0..-3]
|
|
66
|
+
elsif w.end_with?("sses")
|
|
67
|
+
w[0..-3]
|
|
68
|
+
elsif w.end_with?("s") && !w.end_with?("ss") && !w.end_with?("us")
|
|
69
|
+
w[0..-2]
|
|
70
|
+
else
|
|
71
|
+
w
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def camelize(name)
|
|
76
|
+
name.to_s.split(/[-_]/).map(&:capitalize).join
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def snake_case(name)
|
|
80
|
+
name.to_s.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, "")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# lib/whoosh/client_gen/dependency_checker.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Whoosh
|
|
5
|
+
module ClientGen
|
|
6
|
+
class DependencyChecker
|
|
7
|
+
DEPENDENCIES = {
|
|
8
|
+
react_spa: [{ cmd: "node", check: "node --version", min_version: "18" }],
|
|
9
|
+
expo: [
|
|
10
|
+
{ cmd: "node", check: "node --version", min_version: "18" },
|
|
11
|
+
{ cmd: "npx", check: "npx expo --version", min_version: nil }
|
|
12
|
+
],
|
|
13
|
+
ios: [{ cmd: "xcodebuild", check: "xcodebuild -version", min_version: "15" }],
|
|
14
|
+
flutter: [{ cmd: "flutter", check: "flutter --version", min_version: "3" }],
|
|
15
|
+
htmx: [],
|
|
16
|
+
telegram_bot: [{ cmd: "ruby", check: "ruby --version", min_version: "3.2" }],
|
|
17
|
+
telegram_mini_app: [{ cmd: "node", check: "node --version", min_version: "18" }]
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def self.check(client_type)
|
|
21
|
+
deps = DEPENDENCIES[client_type.to_sym] || []
|
|
22
|
+
return { ok: true, dependencies: [], missing: [] } if deps.empty?
|
|
23
|
+
|
|
24
|
+
missing = []
|
|
25
|
+
deps.each do |dep|
|
|
26
|
+
output = `#{dep[:check]} 2>/dev/null`.strip
|
|
27
|
+
if output.empty?
|
|
28
|
+
missing << dep
|
|
29
|
+
elsif dep[:min_version]
|
|
30
|
+
version = output.scan(/(\d+)\./)[0]&.first
|
|
31
|
+
if version && version.to_i < dep[:min_version].to_i
|
|
32
|
+
missing << dep.merge(found_version: version)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
ok: missing.empty?,
|
|
39
|
+
dependencies: deps.map { |d| d[:cmd] },
|
|
40
|
+
missing: missing
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.dependency_for(client_type)
|
|
45
|
+
DEPENDENCIES[client_type.to_sym] || []
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|