whoosh 1.0.1
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +413 -0
- data/exe/whoosh +6 -0
- data/lib/whoosh/app.rb +655 -0
- data/lib/whoosh/auth/access_control.rb +26 -0
- data/lib/whoosh/auth/api_key.rb +30 -0
- data/lib/whoosh/auth/jwt.rb +88 -0
- data/lib/whoosh/auth/oauth2.rb +33 -0
- data/lib/whoosh/auth/rate_limiter.rb +86 -0
- data/lib/whoosh/auth/token_tracker.rb +40 -0
- data/lib/whoosh/cache/memory_store.rb +57 -0
- data/lib/whoosh/cache/redis_store.rb +72 -0
- data/lib/whoosh/cache.rb +26 -0
- data/lib/whoosh/cli/generators.rb +133 -0
- data/lib/whoosh/cli/main.rb +277 -0
- data/lib/whoosh/cli/project_generator.rb +172 -0
- data/lib/whoosh/config.rb +160 -0
- data/lib/whoosh/database.rb +47 -0
- data/lib/whoosh/dependency_injection.rb +103 -0
- data/lib/whoosh/endpoint.rb +79 -0
- data/lib/whoosh/env_loader.rb +46 -0
- data/lib/whoosh/errors.rb +68 -0
- data/lib/whoosh/http/response.rb +26 -0
- data/lib/whoosh/http.rb +73 -0
- data/lib/whoosh/instrumentation.rb +22 -0
- data/lib/whoosh/job.rb +24 -0
- data/lib/whoosh/jobs/memory_backend.rb +45 -0
- data/lib/whoosh/jobs/worker.rb +73 -0
- data/lib/whoosh/jobs.rb +50 -0
- data/lib/whoosh/logger.rb +62 -0
- data/lib/whoosh/mcp/client.rb +71 -0
- data/lib/whoosh/mcp/client_manager.rb +73 -0
- data/lib/whoosh/mcp/protocol.rb +39 -0
- data/lib/whoosh/mcp/server.rb +66 -0
- data/lib/whoosh/mcp/transport/sse.rb +26 -0
- data/lib/whoosh/mcp/transport/stdio.rb +33 -0
- data/lib/whoosh/metrics.rb +84 -0
- data/lib/whoosh/middleware/cors.rb +61 -0
- data/lib/whoosh/middleware/plugin_hooks.rb +27 -0
- data/lib/whoosh/middleware/request_limit.rb +28 -0
- data/lib/whoosh/middleware/request_logger.rb +39 -0
- data/lib/whoosh/middleware/security_headers.rb +28 -0
- data/lib/whoosh/middleware/stack.rb +25 -0
- data/lib/whoosh/openapi/generator.rb +50 -0
- data/lib/whoosh/openapi/schema_converter.rb +48 -0
- data/lib/whoosh/openapi/ui.rb +62 -0
- data/lib/whoosh/paginate.rb +64 -0
- data/lib/whoosh/performance.rb +20 -0
- data/lib/whoosh/plugins/base.rb +42 -0
- data/lib/whoosh/plugins/registry.rb +139 -0
- data/lib/whoosh/request.rb +93 -0
- data/lib/whoosh/response.rb +39 -0
- data/lib/whoosh/router.rb +112 -0
- data/lib/whoosh/schema.rb +194 -0
- data/lib/whoosh/serialization/json.rb +73 -0
- data/lib/whoosh/serialization/msgpack.rb +51 -0
- data/lib/whoosh/serialization/negotiator.rb +37 -0
- data/lib/whoosh/serialization/protobuf.rb +43 -0
- data/lib/whoosh/shutdown.rb +30 -0
- data/lib/whoosh/storage/local.rb +24 -0
- data/lib/whoosh/storage/s3.rb +31 -0
- data/lib/whoosh/storage.rb +20 -0
- data/lib/whoosh/streaming/llm_stream.rb +51 -0
- data/lib/whoosh/streaming/sse.rb +61 -0
- data/lib/whoosh/streaming/stream_body.rb +59 -0
- data/lib/whoosh/streaming/websocket.rb +51 -0
- data/lib/whoosh/test.rb +70 -0
- data/lib/whoosh/types.rb +11 -0
- data/lib/whoosh/uploaded_file.rb +47 -0
- data/lib/whoosh/version.rb +5 -0
- data/lib/whoosh.rb +86 -0
- metadata +265 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Whoosh
|
|
8
|
+
module Auth
|
|
9
|
+
class Jwt
|
|
10
|
+
def initialize(secret:, algorithm: :hs256, expiry: 3600)
|
|
11
|
+
@secret = secret
|
|
12
|
+
@algorithm = algorithm
|
|
13
|
+
@expiry = expiry
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def generate(sub:, **claims)
|
|
17
|
+
header = { alg: "HS256", typ: "JWT" }
|
|
18
|
+
now = Time.now.to_i
|
|
19
|
+
payload = { sub: sub, iat: now, exp: now + @expiry }.merge(claims)
|
|
20
|
+
|
|
21
|
+
header_b64 = base64url_encode(JSON.generate(header))
|
|
22
|
+
payload_b64 = base64url_encode(JSON.generate(payload))
|
|
23
|
+
signature = sign("#{header_b64}.#{payload_b64}")
|
|
24
|
+
|
|
25
|
+
"#{header_b64}.#{payload_b64}.#{signature}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def authenticate(request)
|
|
29
|
+
auth_header = request.headers["Authorization"]
|
|
30
|
+
raise Errors::UnauthorizedError, "Missing authorization header" unless auth_header
|
|
31
|
+
token = auth_header.sub(/\ABearer\s+/i, "")
|
|
32
|
+
decode(token)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def decode(token)
|
|
38
|
+
parts = token.split(".")
|
|
39
|
+
raise Errors::UnauthorizedError, "Invalid token format" unless parts.length == 3
|
|
40
|
+
|
|
41
|
+
header_b64, payload_b64, signature = parts
|
|
42
|
+
|
|
43
|
+
expected_sig = sign("#{header_b64}.#{payload_b64}")
|
|
44
|
+
unless secure_compare(signature, expected_sig)
|
|
45
|
+
raise Errors::UnauthorizedError, "Invalid token signature"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
payload = JSON.parse(base64url_decode(payload_b64))
|
|
49
|
+
|
|
50
|
+
if payload["exp"] && payload["exp"] < Time.now.to_i
|
|
51
|
+
raise Errors::UnauthorizedError, "Token expired"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
payload.transform_keys(&:to_sym)
|
|
55
|
+
rescue JSON::ParserError
|
|
56
|
+
raise Errors::UnauthorizedError, "Invalid token payload"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def sign(data)
|
|
60
|
+
digest = OpenSSL::Digest.new("SHA256")
|
|
61
|
+
signature_bytes = OpenSSL::HMAC.digest(digest, @secret, data)
|
|
62
|
+
Base64.urlsafe_encode64(signature_bytes, padding: false)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def base64url_encode(data)
|
|
66
|
+
Base64.urlsafe_encode64(data, padding: false)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def base64url_decode(str)
|
|
70
|
+
Base64.urlsafe_decode64(str)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def secure_compare(a, b)
|
|
74
|
+
# Use HMAC-based comparison to prevent length oracle attacks.
|
|
75
|
+
# Comparing raw strings leaks whether lengths differ; comparing their
|
|
76
|
+
# HMAC digests normalises to a fixed size before the constant-time XOR.
|
|
77
|
+
digest = OpenSSL::Digest.new("SHA256")
|
|
78
|
+
hmac_a = OpenSSL::HMAC.digest(digest, @secret, a)
|
|
79
|
+
hmac_b = OpenSSL::HMAC.digest(digest, @secret, b)
|
|
80
|
+
l = hmac_a.unpack("C*")
|
|
81
|
+
r = hmac_b.unpack("C*")
|
|
82
|
+
result = 0
|
|
83
|
+
l.zip(r) { |x, y| result |= x ^ y }
|
|
84
|
+
result.zero?
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# lib/whoosh/auth/oauth2.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Whoosh
|
|
5
|
+
module Auth
|
|
6
|
+
class OAuth2
|
|
7
|
+
def initialize(token_url: "/oauth/token", validator: nil)
|
|
8
|
+
@token_url = token_url
|
|
9
|
+
@validator = validator
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def authenticate(request)
|
|
13
|
+
auth_header = request.headers["Authorization"]
|
|
14
|
+
raise Errors::UnauthorizedError, "Missing authorization header" unless auth_header
|
|
15
|
+
|
|
16
|
+
token = auth_header.sub(/\ABearer\s+/i, "")
|
|
17
|
+
raise Errors::UnauthorizedError, "Missing token" if token.empty?
|
|
18
|
+
|
|
19
|
+
if @validator
|
|
20
|
+
result = @validator.call(token)
|
|
21
|
+
raise Errors::UnauthorizedError, "Invalid token" unless result
|
|
22
|
+
result
|
|
23
|
+
else
|
|
24
|
+
{ token: token }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def token_url
|
|
29
|
+
@token_url
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whoosh
|
|
4
|
+
module Auth
|
|
5
|
+
class RateLimiter
|
|
6
|
+
def initialize(default_limit: 60, default_period: 60, on_store_failure: :fail_open)
|
|
7
|
+
@default_limit = default_limit
|
|
8
|
+
@default_period = default_period
|
|
9
|
+
@on_store_failure = on_store_failure
|
|
10
|
+
@rules = {}
|
|
11
|
+
@tiers = {}
|
|
12
|
+
@store = {}
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def rule(path, limit:, period:)
|
|
17
|
+
@rules[path] = { limit: limit, period: period }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def tier(name, limit: nil, period: nil, unlimited: false)
|
|
21
|
+
@tiers[name] = { limit: limit, period: period, unlimited: unlimited }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def check!(key, path, tier: nil)
|
|
25
|
+
limits = resolve_limits(path, tier)
|
|
26
|
+
return if limits[:unlimited]
|
|
27
|
+
|
|
28
|
+
bucket_key = "#{key}:#{path}"
|
|
29
|
+
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
record = @store[bucket_key]
|
|
32
|
+
now = Time.now.to_f
|
|
33
|
+
|
|
34
|
+
if record.nil? || (now - record[:window_start]) >= limits[:period]
|
|
35
|
+
@store[bucket_key] = { count: 1, window_start: now }
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if record[:count] >= limits[:limit]
|
|
40
|
+
retry_after = (limits[:period] - (now - record[:window_start])).ceil
|
|
41
|
+
raise Errors::RateLimitExceeded.new(retry_after: retry_after)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
record[:count] += 1
|
|
45
|
+
end
|
|
46
|
+
rescue NoMethodError, TypeError
|
|
47
|
+
if @on_store_failure == :fail_closed
|
|
48
|
+
raise Errors::RateLimitExceeded.new("Rate limit store unavailable", retry_after: 60)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def remaining(key, path, tier: nil)
|
|
53
|
+
limits = resolve_limits(path, tier)
|
|
54
|
+
return Float::INFINITY if limits[:unlimited]
|
|
55
|
+
|
|
56
|
+
bucket_key = "#{key}:#{path}"
|
|
57
|
+
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
record = @store[bucket_key]
|
|
60
|
+
return limits[:limit] unless record
|
|
61
|
+
|
|
62
|
+
now = Time.now.to_f
|
|
63
|
+
return limits[:limit] if (now - record[:window_start]) >= limits[:period]
|
|
64
|
+
|
|
65
|
+
[limits[:limit] - record[:count], 0].max
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def resolve_limits(path, tier)
|
|
72
|
+
if tier && @tiers[tier]
|
|
73
|
+
tier_config = @tiers[tier]
|
|
74
|
+
return { unlimited: true } if tier_config[:unlimited]
|
|
75
|
+
return { limit: tier_config[:limit], period: tier_config[:period], unlimited: false }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if @rules[path]
|
|
79
|
+
return { limit: @rules[path][:limit], period: @rules[path][:period], unlimited: false }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
{ limit: @default_limit, period: @default_period, unlimited: false }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whoosh
|
|
4
|
+
module Auth
|
|
5
|
+
class TokenTracker
|
|
6
|
+
def initialize
|
|
7
|
+
@usage = {}
|
|
8
|
+
@callbacks = []
|
|
9
|
+
@mutex = Mutex.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def on_usage(&block)
|
|
13
|
+
@callbacks << block
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def record(key:, endpoint:, tokens:)
|
|
17
|
+
total = tokens.values.sum
|
|
18
|
+
@mutex.synchronize do
|
|
19
|
+
@usage[key] ||= { total_tokens: 0, endpoints: {} }
|
|
20
|
+
@usage[key][:total_tokens] += total
|
|
21
|
+
@usage[key][:endpoints][endpoint] ||= 0
|
|
22
|
+
@usage[key][:endpoints][endpoint] += total
|
|
23
|
+
end
|
|
24
|
+
@callbacks.each { |cb| cb.call(key, endpoint, tokens) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def usage_for(key)
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
data = @usage[key]
|
|
30
|
+
return { total_tokens: 0, endpoints: {} } unless data
|
|
31
|
+
{ total_tokens: data[:total_tokens], endpoints: data[:endpoints].dup }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def reset(key)
|
|
36
|
+
@mutex.synchronize { @usage.delete(key) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# lib/whoosh/cache/memory_store.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Whoosh
|
|
5
|
+
module Cache
|
|
6
|
+
class MemoryStore
|
|
7
|
+
def initialize(default_ttl: 300)
|
|
8
|
+
@store = {}
|
|
9
|
+
@default_ttl = default_ttl
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def get(key)
|
|
14
|
+
@mutex.synchronize do
|
|
15
|
+
entry = @store[key]
|
|
16
|
+
return nil unless entry
|
|
17
|
+
if entry[:expires_at] && Time.now.to_f > entry[:expires_at]
|
|
18
|
+
@store.delete(key)
|
|
19
|
+
return nil
|
|
20
|
+
end
|
|
21
|
+
entry[:value]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def set(key, value, ttl: nil)
|
|
26
|
+
ttl ||= @default_ttl
|
|
27
|
+
serialized = Serialization::Json.decode(Serialization::Json.encode(value))
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
@store[key] = { value: serialized, expires_at: Time.now.to_f + ttl }
|
|
30
|
+
end
|
|
31
|
+
true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def fetch(key, ttl: nil)
|
|
35
|
+
existing = get(key)
|
|
36
|
+
return existing unless existing.nil?
|
|
37
|
+
value = yield
|
|
38
|
+
set(key, value, ttl: ttl)
|
|
39
|
+
Serialization::Json.decode(Serialization::Json.encode(value))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def delete(key)
|
|
43
|
+
@mutex.synchronize { @store.delete(key) }
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def clear
|
|
48
|
+
@mutex.synchronize { @store.clear }
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def close
|
|
53
|
+
# No-op
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# lib/whoosh/cache/redis_store.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Whoosh
|
|
5
|
+
module Cache
|
|
6
|
+
class RedisStore
|
|
7
|
+
@redis_available = nil
|
|
8
|
+
|
|
9
|
+
def self.available?
|
|
10
|
+
if @redis_available.nil?
|
|
11
|
+
@redis_available = begin
|
|
12
|
+
require "redis"
|
|
13
|
+
true
|
|
14
|
+
rescue LoadError
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
@redis_available
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(url:, default_ttl: 300, pool_size: 5)
|
|
22
|
+
unless self.class.available?
|
|
23
|
+
raise Errors::DependencyError, "Cache Redis store requires the 'redis' gem"
|
|
24
|
+
end
|
|
25
|
+
@redis = Redis.new(url: url)
|
|
26
|
+
@default_ttl = default_ttl
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def get(key)
|
|
30
|
+
raw = @redis.get(key)
|
|
31
|
+
return nil unless raw
|
|
32
|
+
Serialization::Json.decode(raw)
|
|
33
|
+
rescue => e
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def set(key, value, ttl: nil)
|
|
38
|
+
ttl ||= @default_ttl
|
|
39
|
+
@redis.set(key, Serialization::Json.encode(value), ex: ttl)
|
|
40
|
+
true
|
|
41
|
+
rescue => e
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fetch(key, ttl: nil)
|
|
46
|
+
existing = get(key)
|
|
47
|
+
return existing unless existing.nil?
|
|
48
|
+
value = yield
|
|
49
|
+
set(key, value, ttl: ttl)
|
|
50
|
+
Serialization::Json.decode(Serialization::Json.encode(value))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def delete(key)
|
|
54
|
+
@redis.del(key) > 0
|
|
55
|
+
rescue => e
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def clear
|
|
60
|
+
@redis.flushdb
|
|
61
|
+
true
|
|
62
|
+
rescue => e
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def close
|
|
67
|
+
@redis.close
|
|
68
|
+
rescue => e
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/whoosh/cache.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# lib/whoosh/cache.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Whoosh
|
|
5
|
+
module Cache
|
|
6
|
+
autoload :MemoryStore, "whoosh/cache/memory_store"
|
|
7
|
+
autoload :RedisStore, "whoosh/cache/redis_store"
|
|
8
|
+
|
|
9
|
+
def self.build(config_data = {})
|
|
10
|
+
cache_config = config_data["cache"] || {}
|
|
11
|
+
store = cache_config["store"] || "memory"
|
|
12
|
+
default_ttl = cache_config["default_ttl"] || 300
|
|
13
|
+
|
|
14
|
+
case store
|
|
15
|
+
when "memory"
|
|
16
|
+
MemoryStore.new(default_ttl: default_ttl)
|
|
17
|
+
when "redis"
|
|
18
|
+
url = cache_config["url"] || "redis://localhost:6379"
|
|
19
|
+
pool_size = cache_config["pool_size"] || 5
|
|
20
|
+
RedisStore.new(url: url, default_ttl: default_ttl, pool_size: pool_size)
|
|
21
|
+
else
|
|
22
|
+
raise ArgumentError, "Unknown cache store: #{store}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# lib/whoosh/cli/generators.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Whoosh
|
|
7
|
+
module CLI
|
|
8
|
+
class Generators
|
|
9
|
+
TYPE_MAP = {
|
|
10
|
+
"string" => "String", "integer" => "Integer", "float" => "Float",
|
|
11
|
+
"boolean" => "Whoosh::Types::Bool", "text" => "String",
|
|
12
|
+
"datetime" => "Time"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def self.endpoint(name, fields = [], root: Dir.pwd)
|
|
16
|
+
cn = classify(name)
|
|
17
|
+
FileUtils.mkdir_p(File.join(root, "endpoints"))
|
|
18
|
+
FileUtils.mkdir_p(File.join(root, "schemas"))
|
|
19
|
+
FileUtils.mkdir_p(File.join(root, "test", "endpoints"))
|
|
20
|
+
|
|
21
|
+
# Generate schema with fields
|
|
22
|
+
schema(name, fields, root: root) unless fields.empty?
|
|
23
|
+
|
|
24
|
+
unless fields.empty?
|
|
25
|
+
# schema already generated above, skip the blank one
|
|
26
|
+
else
|
|
27
|
+
File.write(File.join(root, "schemas", "#{name}.rb"),
|
|
28
|
+
"# frozen_string_literal: true\n\nclass #{cn}Request < Whoosh::Schema\n # Add fields here\nend\n\nclass #{cn}Response < Whoosh::Schema\n # Add fields here\nend\n")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
File.write(File.join(root, "endpoints", "#{name}.rb"),
|
|
32
|
+
"# frozen_string_literal: true\n\nclass #{cn}Endpoint < Whoosh::Endpoint\n post \"/#{name}\", request: #{cn}Request\n\n def call(req)\n { message: \"#{cn} endpoint\" }\n end\nend\n")
|
|
33
|
+
|
|
34
|
+
File.write(File.join(root, "test", "endpoints", "#{name}_test.rb"),
|
|
35
|
+
"# frozen_string_literal: true\n\nrequire \"test_helper\"\n\nRSpec.describe #{cn}Endpoint do\n it \"responds to POST /#{name}\" do\n post \"/#{name}\"\n expect(last_response.status).to eq(200)\n end\nend\n")
|
|
36
|
+
|
|
37
|
+
puts "Created endpoints/#{name}.rb, schemas/#{name}.rb, test/endpoints/#{name}_test.rb"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.schema(name, fields = [], root: Dir.pwd)
|
|
41
|
+
cn = classify(name)
|
|
42
|
+
FileUtils.mkdir_p(File.join(root, "schemas"))
|
|
43
|
+
|
|
44
|
+
field_lines = fields.map { |f|
|
|
45
|
+
col, type = f.split(":")
|
|
46
|
+
ruby_type = TYPE_MAP[type] || "String"
|
|
47
|
+
" field :#{col}, #{ruby_type}, required: true"
|
|
48
|
+
}.join("\n")
|
|
49
|
+
field_lines = " # field :name, String, required: true, desc: \"Description\"" if field_lines.empty?
|
|
50
|
+
|
|
51
|
+
File.write(File.join(root, "schemas", "#{name}.rb"),
|
|
52
|
+
"# frozen_string_literal: true\n\nclass #{cn}Schema < Whoosh::Schema\n#{field_lines}\nend\n")
|
|
53
|
+
puts "Created schemas/#{name}.rb"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.model(name, fields = [], root: Dir.pwd)
|
|
57
|
+
cn = classify(name)
|
|
58
|
+
table = "#{name.downcase}s"
|
|
59
|
+
ts = Time.now.strftime("%Y%m%d%H%M%S")
|
|
60
|
+
|
|
61
|
+
FileUtils.mkdir_p(File.join(root, "models"))
|
|
62
|
+
FileUtils.mkdir_p(File.join(root, "db", "migrations"))
|
|
63
|
+
|
|
64
|
+
File.write(File.join(root, "models", "#{name.downcase}.rb"),
|
|
65
|
+
"# frozen_string_literal: true\n\nclass #{cn} < Sequel::Model(:#{table})\nend\n")
|
|
66
|
+
|
|
67
|
+
cols = fields.map { |f|
|
|
68
|
+
col, type = f.split(":")
|
|
69
|
+
st = { "string" => "String", "integer" => "Integer", "float" => "Float", "boolean" => "TrueClass", "text" => "String, text: true", "datetime" => "DateTime" }[type] || "String"
|
|
70
|
+
" #{st} :#{col}, null: false"
|
|
71
|
+
}.join("\n")
|
|
72
|
+
|
|
73
|
+
File.write(File.join(root, "db", "migrations", "#{ts}_create_#{table}.rb"),
|
|
74
|
+
"Sequel.migration do\n change do\n create_table(:#{table}) do\n primary_key :id\n#{cols}\n DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP\n DateTime :updated_at\n end\n end\nend\n")
|
|
75
|
+
|
|
76
|
+
FileUtils.mkdir_p(File.join(root, "test", "models"))
|
|
77
|
+
File.write(File.join(root, "test", "models", "#{name.downcase}_test.rb"),
|
|
78
|
+
"# frozen_string_literal: true\n\nrequire \"test_helper\"\n\nRSpec.describe #{cn} do\n it \"exists\" do\n expect(#{cn}).to be_a(Class)\n end\nend\n")
|
|
79
|
+
|
|
80
|
+
puts "Created models/#{name.downcase}.rb, db/migrations/#{ts}_create_#{table}.rb"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.migration(name, root: Dir.pwd)
|
|
84
|
+
ts = Time.now.strftime("%Y%m%d%H%M%S")
|
|
85
|
+
FileUtils.mkdir_p(File.join(root, "db", "migrations"))
|
|
86
|
+
File.write(File.join(root, "db", "migrations", "#{ts}_#{name}.rb"),
|
|
87
|
+
"Sequel.migration do\n change do\n # Add migration code here\n end\nend\n")
|
|
88
|
+
puts "Created db/migrations/#{ts}_#{name}.rb"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.plugin(name, root: Dir.pwd)
|
|
92
|
+
cn = classify(name)
|
|
93
|
+
FileUtils.mkdir_p(File.join(root, "lib"))
|
|
94
|
+
|
|
95
|
+
File.write(File.join(root, "lib", "#{name}_plugin.rb"), <<~RUBY)
|
|
96
|
+
# frozen_string_literal: true
|
|
97
|
+
|
|
98
|
+
class #{cn}Plugin < Whoosh::Plugins::Base
|
|
99
|
+
gem_name "#{name}"
|
|
100
|
+
accessor_name :#{name.tr("-", "_")}
|
|
101
|
+
|
|
102
|
+
def self.initialize_plugin(config)
|
|
103
|
+
# Initialize and return plugin instance
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
RUBY
|
|
108
|
+
puts "Created lib/#{name}_plugin.rb"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.proto(name, root: Dir.pwd)
|
|
112
|
+
FileUtils.mkdir_p(File.join(root, "protos"))
|
|
113
|
+
msg_name = name.match?(/[_-]/) ? classify(name) : name
|
|
114
|
+
|
|
115
|
+
File.write(File.join(root, "protos", "#{name.downcase}.proto"), <<~PROTO)
|
|
116
|
+
syntax = "proto3";
|
|
117
|
+
|
|
118
|
+
package whoosh;
|
|
119
|
+
|
|
120
|
+
message #{msg_name} {
|
|
121
|
+
// Add fields here
|
|
122
|
+
// string name = 1;
|
|
123
|
+
}
|
|
124
|
+
PROTO
|
|
125
|
+
puts "Created protos/#{name.downcase}.proto"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def self.classify(name)
|
|
129
|
+
name.split(/[-_]/).map(&:capitalize).join
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|