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.
Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +413 -0
  4. data/exe/whoosh +6 -0
  5. data/lib/whoosh/app.rb +655 -0
  6. data/lib/whoosh/auth/access_control.rb +26 -0
  7. data/lib/whoosh/auth/api_key.rb +30 -0
  8. data/lib/whoosh/auth/jwt.rb +88 -0
  9. data/lib/whoosh/auth/oauth2.rb +33 -0
  10. data/lib/whoosh/auth/rate_limiter.rb +86 -0
  11. data/lib/whoosh/auth/token_tracker.rb +40 -0
  12. data/lib/whoosh/cache/memory_store.rb +57 -0
  13. data/lib/whoosh/cache/redis_store.rb +72 -0
  14. data/lib/whoosh/cache.rb +26 -0
  15. data/lib/whoosh/cli/generators.rb +133 -0
  16. data/lib/whoosh/cli/main.rb +277 -0
  17. data/lib/whoosh/cli/project_generator.rb +172 -0
  18. data/lib/whoosh/config.rb +160 -0
  19. data/lib/whoosh/database.rb +47 -0
  20. data/lib/whoosh/dependency_injection.rb +103 -0
  21. data/lib/whoosh/endpoint.rb +79 -0
  22. data/lib/whoosh/env_loader.rb +46 -0
  23. data/lib/whoosh/errors.rb +68 -0
  24. data/lib/whoosh/http/response.rb +26 -0
  25. data/lib/whoosh/http.rb +73 -0
  26. data/lib/whoosh/instrumentation.rb +22 -0
  27. data/lib/whoosh/job.rb +24 -0
  28. data/lib/whoosh/jobs/memory_backend.rb +45 -0
  29. data/lib/whoosh/jobs/worker.rb +73 -0
  30. data/lib/whoosh/jobs.rb +50 -0
  31. data/lib/whoosh/logger.rb +62 -0
  32. data/lib/whoosh/mcp/client.rb +71 -0
  33. data/lib/whoosh/mcp/client_manager.rb +73 -0
  34. data/lib/whoosh/mcp/protocol.rb +39 -0
  35. data/lib/whoosh/mcp/server.rb +66 -0
  36. data/lib/whoosh/mcp/transport/sse.rb +26 -0
  37. data/lib/whoosh/mcp/transport/stdio.rb +33 -0
  38. data/lib/whoosh/metrics.rb +84 -0
  39. data/lib/whoosh/middleware/cors.rb +61 -0
  40. data/lib/whoosh/middleware/plugin_hooks.rb +27 -0
  41. data/lib/whoosh/middleware/request_limit.rb +28 -0
  42. data/lib/whoosh/middleware/request_logger.rb +39 -0
  43. data/lib/whoosh/middleware/security_headers.rb +28 -0
  44. data/lib/whoosh/middleware/stack.rb +25 -0
  45. data/lib/whoosh/openapi/generator.rb +50 -0
  46. data/lib/whoosh/openapi/schema_converter.rb +48 -0
  47. data/lib/whoosh/openapi/ui.rb +62 -0
  48. data/lib/whoosh/paginate.rb +64 -0
  49. data/lib/whoosh/performance.rb +20 -0
  50. data/lib/whoosh/plugins/base.rb +42 -0
  51. data/lib/whoosh/plugins/registry.rb +139 -0
  52. data/lib/whoosh/request.rb +93 -0
  53. data/lib/whoosh/response.rb +39 -0
  54. data/lib/whoosh/router.rb +112 -0
  55. data/lib/whoosh/schema.rb +194 -0
  56. data/lib/whoosh/serialization/json.rb +73 -0
  57. data/lib/whoosh/serialization/msgpack.rb +51 -0
  58. data/lib/whoosh/serialization/negotiator.rb +37 -0
  59. data/lib/whoosh/serialization/protobuf.rb +43 -0
  60. data/lib/whoosh/shutdown.rb +30 -0
  61. data/lib/whoosh/storage/local.rb +24 -0
  62. data/lib/whoosh/storage/s3.rb +31 -0
  63. data/lib/whoosh/storage.rb +20 -0
  64. data/lib/whoosh/streaming/llm_stream.rb +51 -0
  65. data/lib/whoosh/streaming/sse.rb +61 -0
  66. data/lib/whoosh/streaming/stream_body.rb +59 -0
  67. data/lib/whoosh/streaming/websocket.rb +51 -0
  68. data/lib/whoosh/test.rb +70 -0
  69. data/lib/whoosh/types.rb +11 -0
  70. data/lib/whoosh/uploaded_file.rb +47 -0
  71. data/lib/whoosh/version.rb +5 -0
  72. data/lib/whoosh.rb +86 -0
  73. 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
@@ -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