whoosh 1.5.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3029bc3666ce5b81096d8248de52ac25f31e6cc11e271ad954058d9241bf48fb
4
- data.tar.gz: 4a62476bf115c24635c2833ddf0814ec49de5b7e036a138e37f197ef8fb51905
3
+ metadata.gz: f93acb5a2f85aecd9f3feafc165962aa721a762dad30caa2d2550ed500afbec8
4
+ data.tar.gz: e44f3b1217d9177417b21820abd6d1788969556603c2d9aff7f26d7351c703d2
5
5
  SHA512:
6
- metadata.gz: 1152664925e99cce8c668a87aa0e9e131bb48a59b4cb967569fa76224f4ae50ab00aa9658231eb9312ea640993cfb46f904bf9248200bf40a5f65df282a7ac71
7
- data.tar.gz: 8c32d13d07cdfe9fdf8ff5ab201ba72632c054ee9ef60540a06f00dd4d0303755ddb7c22b0e6fad702003e0c3b088c69c062d606e1dee947f08f2330faf74285
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-564%20passing-brightgreen" alt="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
@@ -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
@@ -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