whoosh 1.4.1 → 1.6.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 +40 -1
- data/lib/whoosh/app.rb +89 -53
- data/lib/whoosh/auth/oauth2.rb +69 -6
- 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/http.rb +24 -0
- data/lib/whoosh/middleware/security_headers.rb +2 -1
- data/lib/whoosh/openapi/generator.rb +13 -1
- data/lib/whoosh/request.rb +10 -0
- data/lib/whoosh/response.rb +24 -0
- data/lib/whoosh/schema.rb +15 -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: f93acb5a2f85aecd9f3feafc165962aa721a762dad30caa2d2550ed500afbec8
|
|
4
|
+
data.tar.gz: e44f3b1217d9177417b21820abd6d1788969556603c2d9aff7f26d7351c703d2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 77dd95d3cefc8053ff9c183207273adc8b99e4df806ad1374b277dfde6a64ffb31f82765c502c05d81eb4d4a9b4f6d681a2ffb0ebaf75353369e5179eeb17758
|
|
7
|
+
data.tar.gz: ea988031ce4f4dd47959bd84782caa4ad019e7570f5cd2d17cba754b3851955a4520c7d5ee76815b53da7e381a9fde0a833895a7cc59386e2354dfc6f7604f74
|
data/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
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-
|
|
16
|
+
<img src="https://img.shields.io/badge/tests-659%20passing-brightgreen" alt="Tests">
|
|
17
17
|
<img src="https://img.shields.io/badge/overhead-2.5%C2%B5s-orange" alt="Performance">
|
|
18
18
|
</p>
|
|
19
19
|
|
|
@@ -346,6 +346,43 @@ app.docs enabled: true, redoc: true
|
|
|
346
346
|
- `/redoc` — ReDoc
|
|
347
347
|
- `/openapi.json` — Machine-readable spec
|
|
348
348
|
|
|
349
|
+
### Client Generator
|
|
350
|
+
|
|
351
|
+
Generate complete, typed, ready-to-run client apps from your Whoosh API — one command.
|
|
352
|
+
|
|
353
|
+
```sh
|
|
354
|
+
whoosh generate client react_spa # React + Vite + TypeScript
|
|
355
|
+
whoosh generate client expo # Expo + React Native
|
|
356
|
+
whoosh generate client ios # SwiftUI + MVVM
|
|
357
|
+
whoosh generate client flutter # Dart + Riverpod + GoRouter
|
|
358
|
+
whoosh generate client htmx # Plain HTML + htmx, no build step
|
|
359
|
+
whoosh generate client telegram_bot # Ruby Telegram bot
|
|
360
|
+
whoosh generate client telegram_mini_app # React + Telegram WebApp SDK
|
|
361
|
+
|
|
362
|
+
whoosh generate client react_spa --oauth # Add Google/GitHub/Apple login
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
The generator **introspects your Whoosh app** via OpenAPI — it reads your routes, schemas, and auth config, then produces a typed client with:
|
|
366
|
+
|
|
367
|
+
- API client with auth headers and automatic token refresh
|
|
368
|
+
- Model types matching your schemas
|
|
369
|
+
- Auth screens (login, register, logout)
|
|
370
|
+
- CRUD screens for every resource
|
|
371
|
+
- Navigation and routing
|
|
372
|
+
- Starter tests
|
|
373
|
+
|
|
374
|
+
If no Whoosh app exists yet, it scaffolds a standard backend (JWT auth + tasks CRUD) alongside the client.
|
|
375
|
+
|
|
376
|
+
| Client | Stack | Token Storage |
|
|
377
|
+
|--------|-------|---------------|
|
|
378
|
+
| `react_spa` | React 19, Vite, TypeScript, React Router | localStorage |
|
|
379
|
+
| `expo` | Expo SDK 52, Expo Router, TypeScript | SecureStore |
|
|
380
|
+
| `ios` | SwiftUI, async/await, MVVM | Keychain |
|
|
381
|
+
| `flutter` | Dart, Dio, Riverpod, GoRouter | flutter_secure_storage |
|
|
382
|
+
| `htmx` | HTML, htmx 2.x, vanilla JS | localStorage |
|
|
383
|
+
| `telegram_bot` | Ruby, telegram-bot-ruby | In-memory session |
|
|
384
|
+
| `telegram_mini_app` | React, Telegram WebApp SDK | Telegram initData |
|
|
385
|
+
|
|
349
386
|
### Health Checks
|
|
350
387
|
|
|
351
388
|
```ruby
|
|
@@ -377,6 +414,8 @@ whoosh generate model User name:string email:string
|
|
|
377
414
|
whoosh generate migration add_email_to_users
|
|
378
415
|
whoosh generate plugin my_tool # plugin boilerplate
|
|
379
416
|
whoosh generate proto ChatRequest # .proto file
|
|
417
|
+
whoosh generate client react_spa # full client app (7 types)
|
|
418
|
+
whoosh generate client expo --oauth # with OAuth2 social login
|
|
380
419
|
|
|
381
420
|
whoosh db migrate # run migrations
|
|
382
421
|
whoosh db rollback # rollback
|
data/lib/whoosh/app.rb
CHANGED
|
@@ -250,6 +250,31 @@ module Whoosh
|
|
|
250
250
|
Paginate.cursor(collection, cursor: cursor, limit: limit, column: column)
|
|
251
251
|
end
|
|
252
252
|
|
|
253
|
+
def redirect(url, status: 302)
|
|
254
|
+
Response.redirect(url, status: status)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def download(data, filename:, content_type: nil)
|
|
258
|
+
Response.download(data, filename: filename, content_type: content_type || "application/octet-stream")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def send_file(path, content_type: nil)
|
|
262
|
+
Response.file(path, content_type: content_type)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def serve_static(prefix, root:)
|
|
266
|
+
get "#{prefix}/:_static_path" do |req|
|
|
267
|
+
file_path = File.join(root, req.params[:_static_path])
|
|
268
|
+
real = File.realpath(file_path) rescue nil
|
|
269
|
+
real_root = File.realpath(root) rescue root
|
|
270
|
+
if real && real.start_with?(real_root) && File.file?(real)
|
|
271
|
+
Response.file(real)
|
|
272
|
+
else
|
|
273
|
+
Response.not_found
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
253
278
|
# --- Endpoint loading ---
|
|
254
279
|
|
|
255
280
|
def load_endpoints(dir)
|
|
@@ -396,66 +421,71 @@ module Whoosh
|
|
|
396
421
|
security_headers = Middleware::SecurityHeaders::HEADERS
|
|
397
422
|
|
|
398
423
|
-> (env) {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
[
|
|
404
|
-
|
|
424
|
+
begin
|
|
425
|
+
# 1. RequestLimit — check content length
|
|
426
|
+
content_length = env["CONTENT_LENGTH"]&.to_i || 0
|
|
427
|
+
if content_length > max_bytes
|
|
428
|
+
return [413, { "content-type" => "application/json" },
|
|
429
|
+
[JSON.generate({ error: "request_too_large", max_bytes: max_bytes })]]
|
|
430
|
+
end
|
|
405
431
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
432
|
+
# 2. Request ID (from RequestLogger)
|
|
433
|
+
request_id = env["HTTP_X_REQUEST_ID"] || SecureRandom.uuid
|
|
434
|
+
env["whoosh.request_id"] = request_id
|
|
435
|
+
|
|
436
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
437
|
+
|
|
438
|
+
# 3. CORS preflight
|
|
439
|
+
origin = env["HTTP_ORIGIN"]
|
|
440
|
+
if env["REQUEST_METHOD"] == "OPTIONS" && origin
|
|
441
|
+
cors_headers = {
|
|
442
|
+
"access-control-allow-methods" => "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
443
|
+
"access-control-allow-headers" => "Content-Type, Authorization, X-API-Key, X-Request-ID",
|
|
444
|
+
"access-control-max-age" => "86400",
|
|
445
|
+
"access-control-allow-origin" => "*",
|
|
446
|
+
"access-control-expose-headers" => "X-Request-ID",
|
|
447
|
+
"vary" => "Origin"
|
|
448
|
+
}
|
|
449
|
+
return [204, cors_headers, []]
|
|
450
|
+
end
|
|
425
451
|
|
|
426
|
-
|
|
427
|
-
|
|
452
|
+
# 4. Handle request (core)
|
|
453
|
+
status, headers, body = handle_request(env)
|
|
428
454
|
|
|
429
|
-
|
|
430
|
-
|
|
455
|
+
# Ensure headers are mutable (streaming returns frozen headers)
|
|
456
|
+
headers = headers.dup if headers.frozen?
|
|
431
457
|
|
|
432
|
-
|
|
433
|
-
|
|
458
|
+
# 5. Security headers (inline, no allocation)
|
|
459
|
+
security_headers.each { |k, v| headers[k] ||= v }
|
|
434
460
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
461
|
+
# 6. CORS headers
|
|
462
|
+
if origin
|
|
463
|
+
headers["access-control-allow-origin"] = "*"
|
|
464
|
+
headers["access-control-expose-headers"] = "X-Request-ID"
|
|
465
|
+
headers["vary"] = "Origin"
|
|
466
|
+
end
|
|
441
467
|
|
|
442
|
-
|
|
443
|
-
|
|
468
|
+
# 7. Request ID in response
|
|
469
|
+
headers["x-request-id"] = request_id
|
|
444
470
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
471
|
+
# 8. Logging + metrics
|
|
472
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
|
473
|
+
logger.info("request_complete",
|
|
474
|
+
method: env["REQUEST_METHOD"], path: env["PATH_INFO"],
|
|
475
|
+
status: status, duration_ms: duration_ms, request_id: request_id)
|
|
450
476
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
477
|
+
if metrics
|
|
478
|
+
metrics.increment("whoosh_requests_total",
|
|
479
|
+
labels: { method: env["REQUEST_METHOD"], path: env["PATH_INFO"], status: status.to_s })
|
|
480
|
+
metrics.observe("whoosh_request_duration_seconds",
|
|
481
|
+
duration_ms / 1000.0, labels: { path: env["PATH_INFO"] })
|
|
482
|
+
end
|
|
457
483
|
|
|
458
|
-
|
|
484
|
+
[status, headers, body]
|
|
485
|
+
rescue => e
|
|
486
|
+
[500, { "content-type" => "application/json" },
|
|
487
|
+
[JSON.generate({ error: "internal_error", message: e.message })]]
|
|
488
|
+
end
|
|
459
489
|
}
|
|
460
490
|
end
|
|
461
491
|
|
|
@@ -509,7 +539,8 @@ module Whoosh
|
|
|
509
539
|
generator.add_route(
|
|
510
540
|
method: route[:method], path: route[:path],
|
|
511
541
|
request_schema: handler[:request_schema],
|
|
512
|
-
response_schema: handler[:response_schema]
|
|
542
|
+
response_schema: handler[:response_schema],
|
|
543
|
+
query_schema: handler[:query_schema]
|
|
513
544
|
)
|
|
514
545
|
end
|
|
515
546
|
|
|
@@ -540,13 +571,14 @@ module Whoosh
|
|
|
540
571
|
})
|
|
541
572
|
end
|
|
542
573
|
|
|
543
|
-
def add_route(method, path, request: nil, response: nil, **metadata, &block)
|
|
574
|
+
def add_route(method, path, request: nil, response: nil, query: nil, **metadata, &block)
|
|
544
575
|
full_path = "#{@group_prefix}#{path}"
|
|
545
576
|
merged_metadata = @group_metadata.merge(metadata)
|
|
546
577
|
handler = {
|
|
547
578
|
block: block,
|
|
548
579
|
request_schema: request,
|
|
549
580
|
response_schema: response,
|
|
581
|
+
query_schema: query,
|
|
550
582
|
middleware: @group_middleware.dup
|
|
551
583
|
}
|
|
552
584
|
@router.add(method, full_path, handler, **merged_metadata)
|
|
@@ -662,6 +694,10 @@ module Whoosh
|
|
|
662
694
|
@strategies[:jwt] = Auth::Jwt.new(secret: secret, algorithm: algorithm, expiry: expiry)
|
|
663
695
|
end
|
|
664
696
|
|
|
697
|
+
def oauth2(provider: :custom, **opts)
|
|
698
|
+
@strategies[:oauth2] = Auth::OAuth2.new(provider: provider, **opts)
|
|
699
|
+
end
|
|
700
|
+
|
|
665
701
|
def build
|
|
666
702
|
@strategies.values.first
|
|
667
703
|
end
|
data/lib/whoosh/auth/oauth2.rb
CHANGED
|
@@ -1,18 +1,74 @@
|
|
|
1
|
-
# lib/whoosh/auth/oauth2.rb
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
4
5
|
module Whoosh
|
|
5
6
|
module Auth
|
|
6
7
|
class OAuth2
|
|
7
|
-
|
|
8
|
+
PROVIDERS = {
|
|
9
|
+
google: {
|
|
10
|
+
authorize_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
11
|
+
token_url: "https://oauth2.googleapis.com/token",
|
|
12
|
+
userinfo_url: "https://www.googleapis.com/oauth2/v3/userinfo"
|
|
13
|
+
},
|
|
14
|
+
github: {
|
|
15
|
+
authorize_url: "https://github.com/login/oauth/authorize",
|
|
16
|
+
token_url: "https://github.com/login/oauth/access_token",
|
|
17
|
+
userinfo_url: "https://api.github.com/user"
|
|
18
|
+
},
|
|
19
|
+
apple: {
|
|
20
|
+
authorize_url: "https://appleid.apple.com/auth/authorize",
|
|
21
|
+
token_url: "https://appleid.apple.com/auth/token",
|
|
22
|
+
userinfo_url: nil
|
|
23
|
+
}
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :provider
|
|
27
|
+
|
|
28
|
+
def initialize(provider: :custom, client_id: nil, client_secret: nil,
|
|
29
|
+
authorize_url: nil, token_url: nil, userinfo_url: nil,
|
|
30
|
+
redirect_uri: nil, scopes: [], validator: nil)
|
|
31
|
+
@provider = provider
|
|
32
|
+
@client_id = client_id
|
|
33
|
+
@client_secret = client_secret
|
|
34
|
+
@authorize_url = authorize_url
|
|
8
35
|
@token_url = token_url
|
|
36
|
+
@userinfo_url = userinfo_url
|
|
37
|
+
@redirect_uri = redirect_uri
|
|
38
|
+
@scopes = scopes
|
|
9
39
|
@validator = validator
|
|
40
|
+
apply_provider_defaults if PROVIDERS[@provider]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def authorize_url(state: SecureRandom.hex(16))
|
|
44
|
+
params = {
|
|
45
|
+
client_id: @client_id, redirect_uri: @redirect_uri,
|
|
46
|
+
response_type: "code", scope: @scopes.join(" "), state: state
|
|
47
|
+
}
|
|
48
|
+
"#{@authorize_url}?#{URI.encode_www_form(params)}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def exchange_code(code)
|
|
52
|
+
response = HTTP.post(@token_url, json: {
|
|
53
|
+
client_id: @client_id, client_secret: @client_secret,
|
|
54
|
+
code: code, redirect_uri: @redirect_uri, grant_type: "authorization_code"
|
|
55
|
+
}, headers: { "Accept" => "application/json" })
|
|
56
|
+
raise Errors::UnauthorizedError, "Token exchange failed: #{response.status}" unless response.ok?
|
|
57
|
+
response.json
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def userinfo(access_token)
|
|
61
|
+
return nil unless @userinfo_url
|
|
62
|
+
response = HTTP.get(@userinfo_url, headers: {
|
|
63
|
+
"Authorization" => "Bearer #{access_token}", "Accept" => "application/json"
|
|
64
|
+
})
|
|
65
|
+
raise Errors::UnauthorizedError, "Userinfo failed" unless response.ok?
|
|
66
|
+
response.json
|
|
10
67
|
end
|
|
11
68
|
|
|
12
69
|
def authenticate(request)
|
|
13
70
|
auth_header = request.headers["Authorization"]
|
|
14
|
-
raise Errors::UnauthorizedError, "Missing authorization
|
|
15
|
-
|
|
71
|
+
raise Errors::UnauthorizedError, "Missing authorization" unless auth_header
|
|
16
72
|
token = auth_header.sub(/\ABearer\s+/i, "")
|
|
17
73
|
raise Errors::UnauthorizedError, "Missing token" if token.empty?
|
|
18
74
|
|
|
@@ -20,13 +76,20 @@ module Whoosh
|
|
|
20
76
|
result = @validator.call(token)
|
|
21
77
|
raise Errors::UnauthorizedError, "Invalid token" unless result
|
|
22
78
|
result
|
|
79
|
+
elsif @userinfo_url
|
|
80
|
+
userinfo(token)
|
|
23
81
|
else
|
|
24
82
|
{ token: token }
|
|
25
83
|
end
|
|
26
84
|
end
|
|
27
85
|
|
|
28
|
-
|
|
29
|
-
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def apply_provider_defaults
|
|
89
|
+
defaults = PROVIDERS[@provider]
|
|
90
|
+
@authorize_url ||= defaults[:authorize_url]
|
|
91
|
+
@token_url ||= defaults[:token_url]
|
|
92
|
+
@userinfo_url ||= defaults[:userinfo_url]
|
|
30
93
|
end
|
|
31
94
|
end
|
|
32
95
|
end
|
|
@@ -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
|