gr_api_manager 0.1.0 → 0.3.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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +645 -163
  3. data/lib/gr_api_manager.rb +448 -52
  4. metadata +21 -4
@@ -1,34 +1,334 @@
1
1
  require 'sinatra/base'
2
2
  require 'json'
3
3
  require 'dotenv/load'
4
+ require 'base64'
5
+ require 'tempfile'
4
6
 
5
7
  module GRApiManager
8
+ # Convenience size helpers — use in max_body_size:
9
+ # GRApiManager.mb(50) => 50 MB in bytes
10
+ # GRApiManager.gb(2) => 2 GB in bytes
11
+ def self.mb(n) = n * 1_024 * 1_024
12
+ def self.gb(n) = n * 1_024 * 1_024 * 1_024
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # RateLimiter — thread-safe sliding-window rate limiter per IP address.
16
+ #
17
+ # Prevents any single client from flooding the server. Uses a mutex-protected
18
+ # in-memory store with automatic cleanup to avoid unbounded memory growth.
19
+ # ---------------------------------------------------------------------------
20
+ class RateLimiter
21
+ attr_reader :max_requests, :window_seconds
22
+
23
+ def initialize(max_requests:, window_seconds:)
24
+ @max = max_requests
25
+ @window = window_seconds
26
+ @store = Hash.new { |h, k| h[k] = [] }
27
+ @mutex = Mutex.new
28
+ end
29
+
30
+ # Returns true if the request from *ip* is within the allowed rate.
31
+ # Increments the counter for that IP on each allowed request.
32
+ def allow?(ip)
33
+ @mutex.synchronize do
34
+ now = Time.now.to_f
35
+ cutoff = now - @window
36
+ @store[ip].reject! { |t| t < cutoff }
37
+ return false if @store[ip].size >= @max
38
+ @store[ip] << now
39
+ true
40
+ end
41
+ end
42
+
43
+ # Remaining requests allowed for *ip* in the current window.
44
+ def remaining(ip)
45
+ @mutex.synchronize do
46
+ cutoff = Time.now.to_f - @window
47
+ active = @store[ip].count { |t| t >= cutoff }
48
+ [@max - active, 0].max
49
+ end
50
+ end
51
+
52
+ # Removes stale entries — call periodically to prevent memory growth.
53
+ def cleanup!
54
+ @mutex.synchronize do
55
+ cutoff = Time.now.to_f - @window
56
+ @store.each_value { |times| times.reject! { |t| t < cutoff } }
57
+ @store.delete_if { |_, times| times.empty? }
58
+ end
59
+ end
60
+
61
+ # Summary hash — safe to log or expose on a diagnostic endpoint.
62
+ def stats
63
+ @mutex.synchronize do
64
+ { tracked_ips: @store.size, max_requests: @max, window_seconds: @window }
65
+ end
66
+ end
67
+ end
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # FilePayload — wraps an uploaded file (multipart or raw) with a clean API.
71
+ # ---------------------------------------------------------------------------
72
+ class FilePayload
73
+ attr_reader :filename, :content_type, :size, :tempfile
74
+
75
+ def initialize(tempfile:, filename:, content_type:)
76
+ @tempfile = tempfile
77
+ @filename = filename.to_s
78
+ @content_type = content_type.to_s
79
+ @size = tempfile.respond_to?(:size) ? tempfile.size : tempfile.length
80
+ end
81
+
82
+ # Returns the raw binary content of the file as a String (encoding: BINARY).
83
+ def read
84
+ if @tempfile.respond_to?(:read)
85
+ @tempfile.rewind
86
+ @tempfile.read
87
+ else
88
+ @tempfile.to_s.force_encoding(Encoding::BINARY)
89
+ end
90
+ end
91
+
92
+ # Returns the file content encoded as a Base64 string (no newlines).
93
+ def to_base64
94
+ Base64.strict_encode64(read)
95
+ end
96
+
97
+ # Returns the file content encoded as a lowercase hexadecimal string.
98
+ def to_hex
99
+ read.unpack1('H*')
100
+ end
101
+
102
+ # Saves the uploaded content to *dest_path* on disk. Returns dest_path.
103
+ def save_to(dest_path)
104
+ File.open(dest_path, 'wb') { |f| f.write(read) }
105
+ dest_path
106
+ end
107
+
108
+ # Convenience: the file extension derived from the original filename.
109
+ def extension
110
+ File.extname(@filename).downcase
111
+ end
112
+
113
+ # Human-friendly summary — safe to include in JSON responses.
114
+ def to_h
115
+ {
116
+ filename: @filename,
117
+ content_type: @content_type,
118
+ size: @size,
119
+ extension: extension
120
+ }
121
+ end
122
+
123
+ def inspect
124
+ "#<GRApiManager::FilePayload filename=#{@filename.inspect} " \
125
+ "content_type=#{@content_type.inspect} size=#{@size}>"
126
+ end
127
+ end
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # BodyParser — detects Content-Type and returns an appropriate parsed result.
131
+ #
132
+ # Supported formats:
133
+ # application/json -> Hash (symbolized keys)
134
+ # multipart/form-data -> Hash + :_files key (FilePayload objects)
135
+ # application/octet-stream -> :_raw_binary (FilePayload)
136
+ # image/* / application/pdf etc. -> :_raw_binary (FilePayload)
137
+ # text/plain -> :_raw_text (String)
138
+ # application/x-www-form-urlencoded-> Hash (Sinatra already handles this)
139
+ # ---------------------------------------------------------------------------
140
+ module BodyParser
141
+
142
+ BINARY_MIME_PREFIXES = %w[
143
+ image/ video/ audio/ application/pdf application/msword
144
+ application/vnd. application/zip application/x-tar
145
+ application/x-rar application/octet-stream
146
+ ].freeze
147
+
148
+ # Returns a Hash of parsed body values.
149
+ # Special keys injected into the result hash:
150
+ # :_files => { field_name => FilePayload } (multipart)
151
+ # :_raw_binary => FilePayload (raw binary body)
152
+ # :_raw_text => String (plain-text body)
153
+ def self.parse(request)
154
+ content_type = (request.content_type || '').split(';').first.strip.downcase
155
+
156
+ case content_type
157
+ when 'application/json'
158
+ parse_json(request)
159
+
160
+ when 'multipart/form-data'
161
+ parse_multipart(request)
162
+
163
+ when 'application/x-www-form-urlencoded'
164
+ # Sinatra already exposes these in `params` — nothing extra to do.
165
+ {}
166
+
167
+ when 'text/plain'
168
+ text = request.body.read.to_s.force_encoding(Encoding::UTF_8)
169
+ { _raw_text: text }
170
+
171
+ else
172
+ # Treat anything else that looks binary as a raw binary upload.
173
+ if binary_content_type?(content_type)
174
+ parse_raw_binary(request, content_type)
175
+ else
176
+ # Last resort: try JSON, silently fall back to empty hash.
177
+ parse_json(request) rescue {}
178
+ end
179
+ end
180
+ end
181
+
182
+ # -------------------------------------------------------------------------
183
+ private_class_method
184
+
185
+ def self.parse_json(request)
186
+ body = request.body.read.to_s
187
+ return {} if body.strip.empty?
188
+ JSON.parse(body, symbolize_names: true)
189
+ rescue JSON::ParserError => e
190
+ raise ArgumentError, "Invalid JSON body: #{e.message}"
191
+ end
192
+
193
+ def self.parse_multipart(request)
194
+ result = {}
195
+ files = {}
196
+
197
+ request.params.each do |key, value|
198
+ sym = key.to_sym
199
+
200
+ if value.is_a?(Hash) && value.key?(:tempfile)
201
+ # Rack multipart file upload hash
202
+ files[sym] = FilePayload.new(
203
+ tempfile: value[:tempfile],
204
+ filename: value[:filename] || key,
205
+ content_type: value[:type] || 'application/octet-stream'
206
+ )
207
+ elsif value.is_a?(Array)
208
+ # Multiple file inputs with the same name
209
+ files[sym] = value.map do |v|
210
+ if v.is_a?(Hash) && v.key?(:tempfile)
211
+ FilePayload.new(
212
+ tempfile: v[:tempfile],
213
+ filename: v[:filename] || key,
214
+ content_type: v[:type] || 'application/octet-stream'
215
+ )
216
+ else
217
+ v
218
+ end
219
+ end
220
+ else
221
+ result[sym] = value
222
+ end
223
+ end
224
+
225
+ result[:_files] = files unless files.empty?
226
+ result
227
+ end
228
+
229
+ def self.parse_raw_binary(request, content_type)
230
+ raw = request.body.read
231
+ return {} if raw.nil? || raw.empty?
232
+
233
+ # Try to derive a filename from the Content-Disposition header, if any.
234
+ disposition = request.env['HTTP_CONTENT_DISPOSITION'] || ''
235
+ filename = disposition[/filename="?([^";]+)"?/, 1] || "upload#{ext_for(content_type)}"
236
+
237
+ # Wrap the raw bytes in a StringIO so FilePayload can rewind/read it.
238
+ io = StringIO.new(raw.force_encoding(Encoding::BINARY))
239
+ io.define_singleton_method(:size) { raw.bytesize }
240
+
241
+ payload = FilePayload.new(
242
+ tempfile: io,
243
+ filename: filename,
244
+ content_type: content_type
245
+ )
246
+
247
+ { _raw_binary: payload }
248
+ end
249
+
250
+ def self.binary_content_type?(ct)
251
+ BINARY_MIME_PREFIXES.any? { |prefix| ct.start_with?(prefix) }
252
+ end
253
+
254
+ # Maps common MIME types to file extensions for unnamed raw uploads.
255
+ def self.ext_for(content_type)
256
+ {
257
+ 'image/jpeg' => '.jpg',
258
+ 'image/png' => '.png',
259
+ 'image/gif' => '.gif',
260
+ 'image/webp' => '.webp',
261
+ 'image/svg+xml' => '.svg',
262
+ 'image/bmp' => '.bmp',
263
+ 'video/mp4' => '.mp4',
264
+ 'video/webm' => '.webm',
265
+ 'audio/mpeg' => '.mp3',
266
+ 'audio/wav' => '.wav',
267
+ 'application/pdf' => '.pdf',
268
+ 'application/msword' => '.doc',
269
+ 'application/zip' => '.zip',
270
+ 'application/x-tar' => '.tar',
271
+ 'application/octet-stream' => '.bin'
272
+ }.fetch(content_type, '.bin')
273
+ end
274
+ end
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # Server — the public-facing DSL.
278
+ # ---------------------------------------------------------------------------
6
279
  class Server
7
280
  attr_reader :app_class
8
281
 
9
282
  # Initializes the server configuration.
10
- def initialize(port: nil, bearer_token: nil, permitted_hosts: [], prefix: '')
11
- @port = port || ENV['PORT'] || 4000
12
- @token = bearer_token || ENV['API_TOKEN']
283
+ #
284
+ # Options:
285
+ # port: Integer – listening port (default: ENV['PORT'] || 4000)
286
+ # bearer_token: String – Bearer token for auth (default: ENV['API_TOKEN'])
287
+ # permitted_hosts: Array – host allowlist; empty = allow all
288
+ # prefix: String – route prefix, e.g. '/api/v1'
289
+ # max_body_size: Integer – maximum accepted body in bytes (default: 50 MB)
290
+ # dev_mode: Boolean – show full stack traces on 500 (default: false)
291
+ # rate_limit: Integer – max requests per IP per window (nil = disabled)
292
+ # rate_limit_window: Integer – sliding window in seconds (default: 60)
293
+ def initialize(
294
+ port: nil,
295
+ bearer_token: nil,
296
+ permitted_hosts: [],
297
+ prefix: '',
298
+ max_body_size: GRApiManager.mb(50),
299
+ dev_mode: false,
300
+ rate_limit: nil,
301
+ rate_limit_window: 60
302
+ )
303
+ @port = port || ENV['PORT'] || 4000
304
+ @token = bearer_token || ENV['API_TOKEN']
13
305
  @permitted_hosts = permitted_hosts.empty? ? [] : permitted_hosts
14
- @prefix = prefix
15
-
306
+ @prefix = prefix
307
+ @max_body_size = max_body_size
308
+ @dev_mode = dev_mode
309
+ @rate_limiter = rate_limit ? GRApiManager::RateLimiter.new(
310
+ max_requests: rate_limit,
311
+ window_seconds: rate_limit_window
312
+ ) : nil
313
+
16
314
  @app_class = Class.new(Sinatra::Base) do
17
-
315
+
18
316
  # Logs HTTP requests with status-based color coding.
19
- def log_request(method, path, params, status_code)
317
+ def log_request(method, path, status_code)
20
318
  color = status_code.between?(200, 299) ? "\e[32m" : "\e[31m"
21
319
  puts "[#{Time.now.strftime('%H:%M:%S')}] #{color}#{method} #{path} - #{status_code}\e[0m"
22
320
  end
23
321
 
24
322
  # Casts string URL parameters to native Ruby types (Integer, Float, Boolean).
323
+ # Leaves values untouched if they are already non-String (e.g. FilePayload).
25
324
  def smart_parse(hash)
26
325
  hash.transform_values do |val|
326
+ next val unless val.is_a?(String)
27
327
  case val
28
- when 'true' then true
29
- when 'false' then false
30
- when /^[0-9]+$/ then val.to_i
31
- when /^[0-9]+\.[0-9]+$/ then val.to_f
328
+ when 'true' then true
329
+ when 'false' then false
330
+ when /^\d+$/ then val.to_i
331
+ when /^\d+\.\d+$/ then val.to_f
32
332
  else val
33
333
  end
34
334
  end
@@ -40,20 +340,49 @@ module GRApiManager
40
340
 
41
341
  private
42
342
 
43
- # Sets up Sinatra environment, CORS policies, and global error handlers.
343
+ # Sets up Sinatra environment, CORS policies, body size limit, rate limiting, and global error handlers.
44
344
  def configure_app
45
- app = @app_class
46
- app.set :port, @port
47
- app.set :bind, '0.0.0.0'
48
- app.set :token, @token
49
- app.set :show_exceptions, false
345
+ app = @app_class
346
+ max_body = @max_body_size
347
+ rate_limiter = @rate_limiter
348
+
349
+ app.set :port, @port
350
+ app.set :bind, '0.0.0.0'
351
+ app.set :token, @token
352
+ app.set :dev_mode, @dev_mode
353
+ app.set :show_exceptions, @dev_mode
50
354
  app.set :host_authorization, { permitted_hosts: @permitted_hosts }
355
+ app.enable :static
51
356
 
52
- # Enable broad CORS and handle preflight requests.
53
357
  app.before do
54
- headers 'Access-Control-Allow-Origin' => '*',
55
- 'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
56
- 'Access-Control-Allow-Headers' => 'Content-Type, Authorization'
358
+ headers 'Access-Control-Allow-Origin' => '*',
359
+ 'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
360
+ 'Access-Control-Allow-Headers' => 'Content-Type, Authorization, Content-Disposition'
361
+
362
+ # Rate limiting — checked before anything else.
363
+ if rate_limiter
364
+ ip = request.ip
365
+ unless rate_limiter.allow?(ip)
366
+ remaining_reset = rate_limiter.window_seconds
367
+ headers 'Retry-After' => remaining_reset.to_s,
368
+ 'X-RateLimit-Limit' => rate_limiter.max_requests.to_s,
369
+ 'X-RateLimit-Remaining' => '0',
370
+ 'X-RateLimit-Reset' => (Time.now.to_i + remaining_reset).to_s
371
+ halt 429, { error: "Too many requests", retry_after_seconds: remaining_reset }.to_json
372
+ end
373
+ # Add rate-limit headers on allowed requests too.
374
+ headers 'X-RateLimit-Limit' => rate_limiter.max_requests.to_s,
375
+ 'X-RateLimit-Remaining' => rate_limiter.remaining(request.ip).to_s
376
+ end
377
+
378
+ # Body size limit (skip for read-only / headerless verbs).
379
+ unless %w[GET DELETE OPTIONS HEAD].include?(request.request_method)
380
+ content_length = request.content_length.to_i
381
+ if content_length > max_body
382
+ halt 413, { error: "Payload too large", max_bytes: max_body }.to_json
383
+ end
384
+ end
385
+
57
386
  content_type :json
58
387
  end
59
388
 
@@ -61,40 +390,59 @@ module GRApiManager
61
390
  halt 200
62
391
  end
63
392
 
64
- # JSON formatted 404 response.
65
393
  app.not_found do
66
394
  status 404
67
395
  { error: "Endpoint not found", path: request.path_info }.to_json
68
396
  end
69
397
 
70
- # JSON formatted 500 response.
71
398
  app.error do
72
399
  e = env['sinatra.error']
73
400
  status 500
74
- { error: "Internal Server Error", details: e.message }.to_json
401
+ if settings.dev_mode
402
+ { error: "Internal Server Error", details: e.message,
403
+ class: e.class.to_s, backtrace: e.backtrace&.first(15) }.to_json
404
+ else
405
+ { error: "Internal Server Error", details: e.message }.to_json
406
+ end
75
407
  end
76
408
  end
77
409
 
78
410
  public
79
411
 
80
- # Dynamically generate routing methods (get, post, put, delete).
81
- %w[get post put delete].each do |verb|
412
+ # Dynamically generate routing methods (get, post, put, patch, delete).
413
+ %w[get post put patch delete].each do |verb|
82
414
  define_method(verb) do |path, options = {}, &block|
83
415
  register_route(verb, path, options, &block)
84
416
  end
85
417
  end
86
418
 
87
- # Core routing logic: auth validation, param parsing, and block execution.
419
+ # Core routing logic: auth validation, body parsing, param merging, validation, execution.
420
+ #
421
+ # Supported body formats (auto-detected via Content-Type):
422
+ # application/json – standard JSON body
423
+ # multipart/form-data – form fields + file uploads
424
+ # application/octet-stream – raw binary stream
425
+ # image/*, video/*, audio/* – raw binary media
426
+ # application/pdf, etc. – raw binary document
427
+ # text/plain – plain text body
428
+ #
429
+ # Inside your block, params will contain:
430
+ # :_files => { field: FilePayload } – for multipart uploads
431
+ # :_raw_binary => FilePayload – for raw binary/media bodies
432
+ # :_raw_text => String – for text/plain bodies
433
+ #
434
+ # Route options:
435
+ # auth: Boolean – require Bearer Token (default: true)
436
+ # requires: Array – required parameter keys [:name, :email, ...]
88
437
  def register_route(verb, path, options = {}, &block)
89
- verb = verb.to_s.upcase
90
- require_auth = options.fetch(:auth, true)
438
+ verb_up = verb.to_s.upcase
439
+ require_auth = options.fetch(:auth, true)
91
440
  required_params = options.fetch(:requires, [])
92
-
441
+
93
442
  # Construct the full path with the optional prefix.
94
443
  full_path = File.join('/', @prefix.to_s, path.to_s).gsub(%r{/+}, '/')
95
444
 
96
445
  handler = proc do
97
-
98
446
  # 1. Authentication check
99
447
  if require_auth
100
448
  auth_header = request.env["HTTP_AUTHORIZATION"]
@@ -102,46 +450,94 @@ module GRApiManager
102
450
  halt 403, { error: "Invalid token" }.to_json if auth_header.split(" ").last != settings.token
103
451
  end
104
452
 
105
- # 2. Body parsing (for POST/PUT requests)
453
+ # 2. Body parsing smart detection based on Content-Type
106
454
  parsed_body = {}
107
- if ['POST', 'PUT'].include?(verb)
108
- body_data = request.body.read.to_s
109
- unless body_data.empty?
110
- begin
111
- parsed_body = JSON.parse(body_data, symbolize_names: true)
112
- rescue JSON::ParserError
113
- halt 400, { error: "Invalid JSON body" }.to_json
114
- end
455
+ if %w[POST PUT PATCH].include?(verb_up)
456
+ begin
457
+ parsed_body = GRApiManager::BodyParser.parse(request)
458
+ rescue ArgumentError => e
459
+ halt 400, { error: e.message }.to_json
115
460
  end
116
461
  end
117
462
 
118
- # 3. Merge query parameters with parsed JSON body
119
- all_params = smart_parse(params).merge(parsed_body)
463
+ # 3. Merge query/path parameters with parsed body.
464
+ # URL params go through smart_parse; body values are left as-is
465
+ # (so FilePayload objects, arrays, etc. are preserved).
466
+ url_params = smart_parse(params.reject { |_, v| v.is_a?(Hash) && v.key?(:tempfile) })
467
+ all_params = url_params.merge(parsed_body)
468
+
469
+ # 4. Declarative parameter validation (skips special _ keys and FilePayload values).
470
+ missing = required_params.select do |p|
471
+ val = all_params[p.to_sym]
472
+ val.nil? || (val.is_a?(String) && val.strip.empty?)
473
+ end
120
474
 
121
- # 4. Declarative parameter validation
122
- missing = required_params.select { |p| all_params[p.to_sym].nil? || all_params[p.to_sym].to_s.strip.empty? }
123
475
  if missing.any?
124
476
  status 400
125
- log_request(verb, full_path, all_params, 400)
477
+ log_request(verb_up, full_path, 400)
126
478
  next { error: "Missing required parameters", required: missing }.to_json
127
479
  end
128
480
 
129
481
  # 5. Execute user-defined block
130
482
  result = instance_exec(all_params, &block)
131
- log_request(verb, full_path, all_params, response.status)
132
- result.to_json
483
+ log_request(verb_up, full_path, response.status)
484
+
485
+ # If the block returned a String (e.g. already rendered binary data),
486
+ # pass it through unchanged. Otherwise serialize to JSON.
487
+ result.is_a?(String) ? result : result.to_json
133
488
  end
134
489
 
135
490
  @app_class.send(verb.downcase, full_path, &handler)
136
491
  end
137
492
 
138
- # Starts the Sinatra server with a custom GR banner.
139
- def run!
493
+ # Starts the Sinatra server.
494
+ #
495
+ # Options:
496
+ # workers: Integer – Puma worker processes (default: ENV['WEB_CONCURRENCY'] || 2)
497
+ # threads: String – min:max thread count per worker (default: '2:8')
498
+ #
499
+ # For sustained high traffic, run behind Nginx as a reverse proxy.
500
+ # See the README section "High Traffic & Concurrency" for production tuning.
501
+ def run!(workers: nil, threads: '2:8')
502
+ w = (workers || ENV.fetch('WEB_CONCURRENCY', 2)).to_i
503
+ min_t, max_t = threads.to_s.split(':').map(&:to_i)
504
+ max_t ||= min_t
505
+ mb = (@max_body_size.to_f / 1_048_576).round(1)
506
+
507
+ # Use Puma as the application server for concurrency.
508
+ @app_class.set :server, :puma
509
+ @app_class.set :server_settings, {
510
+ workers: w,
511
+ min_threads: min_t,
512
+ max_threads: max_t
513
+ }
514
+
515
+ # Background thread to purge stale rate-limit entries (prevents memory growth).
516
+ if @rate_limiter
517
+ rl = @rate_limiter
518
+ Thread.new do
519
+ loop do
520
+ sleep rl.window_seconds * 2
521
+ rl.cleanup!
522
+ end
523
+ end
524
+ end
525
+
526
+ rl_info = if @rate_limiter
527
+ "#{@rate_limiter.max_requests} req / #{@rate_limiter.window_seconds}s per IP"
528
+ else
529
+ 'Disabled'
530
+ end
531
+
140
532
  puts "============================================="
141
533
  puts " GR API MANAGER STARTED"
142
- puts " Port : #{@port}"
143
- puts " Auth : #{@token ? 'Enabled' : 'Public'}"
144
- puts " Prefix : #{@prefix.empty? ? '/' : @prefix}"
534
+ puts " Port : #{@port}"
535
+ puts " Auth : #{@token ? 'Enabled' : 'Public (no token)'}"
536
+ puts " Prefix : #{@prefix.empty? ? '/' : @prefix}"
537
+ puts " Max Body : #{mb} MB"
538
+ puts " Workers : #{w} | Threads: #{min_t}:#{max_t}"
539
+ puts " Rate Limit: #{rl_info}"
540
+ puts " Dev Mode : #{@dev_mode ? 'ON ⚠️ (disable in production)' : 'Off'}"
145
541
  puts "============================================="
146
542
  @app_class.run!
147
543
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gr_api_manager
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Razo
@@ -37,8 +37,24 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '2.8'
40
- description: Eliminates boilerplate from REST API development. Handles Auth, CORS,
41
- parameter validation, and type casting.
40
+ - !ruby/object:Gem::Dependency
41
+ name: puma
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.0'
54
+ description: Eliminates boilerplate from REST API development. Handles auth, CORS,
55
+ param validation, type casting, multipart file uploads, raw binary/image/document
56
+ bodies, Base64, hexadecimal, rate limiting (429), Puma multi-worker concurrency,
57
+ and dev mode stack traces.
42
58
  email:
43
59
  - garabatoangelopolis@gmail.com
44
60
  executables: []
@@ -71,5 +87,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
71
87
  requirements: []
72
88
  rubygems_version: 3.6.7
73
89
  specification_version: 4
74
- summary: A minimal, opinionated Ruby wrapper around Sinatra.
90
+ summary: A minimal Ruby wrapper around Sinatra with auth, file/binary support, rate
91
+ limiting, and Puma concurrency.
75
92
  test_files: []