wowsql-sdk 1.3.0 → 3.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.
@@ -3,20 +3,136 @@ require 'faraday/multipart'
3
3
  require 'json'
4
4
  require 'stringio'
5
5
  require 'uri'
6
+ require 'fileutils'
6
7
  require_relative 'exceptions'
7
8
 
8
9
  module WOWSQL
9
- # WOWSQL Storage Client - Manage S3 storage with automatic quota validation.
10
+ # Bucket information.
11
+ class StorageBucket
12
+ attr_reader :id, :name, :public, :file_size_limit,
13
+ :allowed_mime_types, :created_at, :object_count, :total_size
14
+
15
+ def initialize(data)
16
+ @id = data['id'] || data[:id] || ''
17
+ @name = data['name'] || data[:name] || ''
18
+ @public = !!(data['public'] || data[:public])
19
+ @file_size_limit = data['file_size_limit'] || data[:file_size_limit]
20
+ @allowed_mime_types = data['allowed_mime_types'] || data[:allowed_mime_types]
21
+ @created_at = data['created_at'] || data[:created_at]
22
+ @object_count = data['object_count'] || data[:object_count] || 0
23
+ @total_size = data['total_size'] || data[:total_size] || 0
24
+ end
25
+
26
+ def to_s
27
+ "StorageBucket(name=#{@name.inspect}, public=#{@public})"
28
+ end
29
+ alias inspect to_s
30
+ end
31
+
32
+ # Storage file/object information.
33
+ class StorageFile
34
+ attr_reader :id, :bucket_id, :name, :path, :mime_type,
35
+ :size, :metadata, :created_at, :public_url
36
+
37
+ def initialize(data)
38
+ @id = data['id'] || data[:id] || ''
39
+ @bucket_id = data['bucket_id'] || data[:bucket_id] || ''
40
+ @name = data['name'] || data[:name] || ''
41
+ @path = data['path'] || data[:path] || ''
42
+ @mime_type = data['mime_type'] || data[:mime_type]
43
+ @size = data['size'] || data[:size] || 0
44
+ @metadata = data['metadata'] || data[:metadata] || {}
45
+ @created_at = data['created_at'] || data[:created_at]
46
+ @public_url = data['public_url'] || data[:public_url]
47
+ end
48
+
49
+ def size_mb
50
+ @size / (1024.0 * 1024.0)
51
+ end
52
+
53
+ def size_gb
54
+ @size / (1024.0 ** 3)
55
+ end
56
+
57
+ def to_s
58
+ "StorageFile(path=#{@path.inspect}, size=#{'%.2f' % size_mb}MB)"
59
+ end
60
+ alias inspect to_s
61
+ end
62
+
63
+ # Storage quota / statistics information.
64
+ class StorageQuota
65
+ attr_reader :total_files, :total_size_bytes, :total_size_gb, :file_types
66
+
67
+ def initialize(data)
68
+ @total_files = data['total_files'] || data[:total_files] || 0
69
+ @total_size_bytes = data['total_size_bytes'] || data[:total_size_bytes] || 0
70
+ @total_size_gb = data['total_size_gb'] || data[:total_size_gb] || 0
71
+ @file_types = data['file_types'] || data[:file_types] || {}
72
+ end
73
+
74
+ def to_s
75
+ "StorageQuota(files=#{@total_files}, size=#{'%.4f' % @total_size_gb}GB)"
76
+ end
77
+ alias inspect to_s
78
+ end
79
+
80
+ # WowSQL Storage Client — PostgreSQL-native file storage.
81
+ #
82
+ # Files are stored as BYTEA inside each project's storage schema.
83
+ # No external S3 dependency — everything lives in PostgreSQL.
84
+ #
85
+ # @example
86
+ # storage = WOWSQL::WOWSQLStorage.new(
87
+ # "https://myproject.wowsql.com",
88
+ # "wowsql_anon_..."
89
+ # )
90
+ # bucket = storage.create_bucket("avatars", public: true)
91
+ # File.open("photo.jpg", "rb") { |f| storage.upload("avatars", f, path: "users/profile.jpg") }
92
+ # files = storage.list_files("avatars", prefix: "users/")
93
+ # url = storage.get_public_url("avatars", "users/profile.jpg")
10
94
  class WOWSQLStorage
11
- def initialize(project_slug, api_key, base_url = 'https://api.wowsql.com', timeout = 60, auto_check_quota = true)
12
- @project_slug = project_slug
13
- @api_key = api_key
14
- @base_url = base_url.chomp('/')
95
+ attr_reader :base_url, :project_slug
96
+
97
+ # @param project_url [String] Project subdomain or full URL
98
+ # @param api_key [String] API key for authentication
99
+ # @param project_slug [String] Explicit slug (used with base_url)
100
+ # @param base_url [String] Explicit base URL (used with project_slug)
101
+ # @param base_domain [String] Base domain (default: "wowsql.com")
102
+ # @param secure [Boolean] Use HTTPS (default: true)
103
+ # @param timeout [Integer] Request timeout in seconds (default: 60)
104
+ # @param verify_ssl [Boolean] Verify SSL certificates (default: true)
105
+ def initialize(project_url = '', api_key = '', project_slug: '', base_url: '',
106
+ base_domain: 'wowsql.com', secure: true, timeout: 60, verify_ssl: true)
107
+ if !project_slug.empty? && !base_url.empty?
108
+ @base_url = base_url.chomp('/')
109
+ @project_slug = project_slug
110
+ elsif !project_url.empty?
111
+ url = project_url.strip
112
+ if url.start_with?('http://') || url.start_with?('https://')
113
+ @base_url = url.chomp('/')
114
+ @base_url = @base_url.split('/api').first if @base_url.include?('/api')
115
+ else
116
+ protocol = secure ? 'https' : 'http'
117
+ if url.include?(".#{base_domain}") || url.end_with?(base_domain)
118
+ @base_url = "#{protocol}://#{url}"
119
+ else
120
+ @base_url = "#{protocol}://#{url}.#{base_domain}"
121
+ end
122
+ end
123
+ @project_slug = url.split('.').first
124
+ .sub('https://', '')
125
+ .sub('http://', '')
126
+ else
127
+ raise ArgumentError, 'Either project_url or (project_slug + base_url) must be provided'
128
+ end
129
+
15
130
  @timeout = timeout
16
- @auto_check_quota = auto_check_quota
17
- @quota_cache = nil
131
+ @verify_ssl = verify_ssl
18
132
 
19
- @conn = Faraday.new(url: @base_url) do |f|
133
+ ssl_options = verify_ssl ? {} : { verify: false }
134
+
135
+ @conn = Faraday.new(url: @base_url, ssl: ssl_options) do |f|
20
136
  f.request :multipart
21
137
  f.request :json
22
138
  f.response :json
@@ -27,203 +143,238 @@ module WOWSQL
27
143
  @conn.headers['Authorization'] = "Bearer #{api_key}"
28
144
  end
29
145
 
30
- # Get storage quota information.
31
- #
32
- # @param force_refresh [Boolean] Force refresh quota from server
33
- # @return [Hash] Storage quota details
34
- def get_quota(force_refresh = false)
35
- return @quota_cache if @quota_cache && !force_refresh
146
+ # ── Buckets ──────────────────────────────────────────────────
147
+
148
+ # Create a new storage bucket.
149
+ #
150
+ # @param name [String] Bucket name
151
+ # @param public [Boolean] Whether the bucket is public
152
+ # @param file_size_limit [Integer, nil] Max file size in bytes
153
+ # @param allowed_mime_types [Array<String>, nil] Allowed MIME types
154
+ # @return [StorageBucket]
155
+ def create_bucket(name, public: false, file_size_limit: nil, allowed_mime_types: nil)
156
+ data = request('POST', "/api/v1/storage/projects/#{@project_slug}/buckets", nil, {
157
+ name: name,
158
+ public: public,
159
+ file_size_limit: file_size_limit,
160
+ allowed_mime_types: allowed_mime_types
161
+ })
162
+ StorageBucket.new(data)
163
+ end
164
+
165
+ # List all buckets in the project.
166
+ #
167
+ # @return [Array<StorageBucket>]
168
+ def list_buckets
169
+ data = request('GET', "/api/v1/storage/projects/#{@project_slug}/buckets", nil, nil)
170
+ Array(data).map { |b| StorageBucket.new(b) }
171
+ end
36
172
 
37
- response = request('GET', "/api/v1/storage/s3/projects/#{@project_slug}/quota", nil, nil)
38
- @quota_cache = response
39
- response
173
+ # Get a specific bucket by name.
174
+ #
175
+ # @param name [String] Bucket name
176
+ # @return [StorageBucket]
177
+ def get_bucket(name)
178
+ data = request('GET', "/api/v1/storage/projects/#{@project_slug}/buckets/#{name}", nil, nil)
179
+ StorageBucket.new(data)
40
180
  end
41
181
 
42
- # Upload file from local filesystem path.
43
- #
44
- # @param file_path [String] Path to local file
45
- # @param file_key [String] File name or path in bucket
46
- # @param folder [String, nil] Optional folder path
47
- # @param content_type [String, nil] Optional content type
48
- # @param check_quota [Boolean, nil] Override auto quota checking
49
- # @return [Hash] Upload result
50
- def upload_from_path(file_path, file_key = nil, folder = nil, content_type = nil, check_quota = nil)
51
- unless File.exist?(file_path)
52
- raise StorageException.new("File not found: #{file_path}")
182
+ # Update bucket settings.
183
+ #
184
+ # @param name [String] Bucket name
185
+ # @param options [Hash] Settings to update
186
+ # @return [StorageBucket]
187
+ def update_bucket(name, **options)
188
+ data = request('PATCH', "/api/v1/storage/projects/#{@project_slug}/buckets/#{name}", nil, options)
189
+ StorageBucket.new(data)
190
+ end
191
+
192
+ # Delete a bucket and all its files.
193
+ #
194
+ # @param name [String] Bucket name
195
+ # @return [Hash]
196
+ def delete_bucket(name)
197
+ request('DELETE', "/api/v1/storage/projects/#{@project_slug}/buckets/#{name}", nil, nil)
198
+ end
199
+
200
+ # ── Files ────────────────────────────────────────────────────
201
+
202
+ # Upload a file to a bucket.
203
+ #
204
+ # @param bucket_name [String] Target bucket
205
+ # @param file_data [IO] File-like object (opened in binary mode)
206
+ # @param path [String, nil] File path within bucket
207
+ # @param file_name [String, nil] Override filename
208
+ # @return [StorageFile]
209
+ def upload(bucket_name, file_data, path: nil, file_name: nil)
210
+ content = file_data.respond_to?(:read) ? file_data.read : file_data
211
+ name = file_name || (file_data.respond_to?(:path) ? File.basename(file_data.path) : nil) || path || 'file'
212
+ name = name.split('/').last if name.include?('/')
213
+
214
+ folder = ''
215
+ if path && path.include?('/')
216
+ folder = path.rpartition('/').first
53
217
  end
54
218
 
55
- file_key ||= File.basename(file_path)
56
- upload_file(File.open(file_path, 'rb'), file_key, folder, content_type, check_quota)
57
- end
58
-
59
- # Upload file from file object or IO stream.
60
- #
61
- # @param file_data [IO, String] File object or file path
62
- # @param file_key [String] File name or path in bucket
63
- # @param folder [String, nil] Optional folder path
64
- # @param content_type [String, nil] Optional content type
65
- # @param check_quota [Boolean, nil] Override auto quota checking
66
- # @return [Hash] Upload result
67
- def upload_file(file_data, file_key, folder = nil, content_type = nil, check_quota = nil)
68
- should_check = check_quota.nil? ? @auto_check_quota : check_quota
69
-
70
- # Read file data
71
- file_bytes = file_data.respond_to?(:read) ? file_data.read : File.read(file_data)
72
- file_size = file_bytes.bytesize
73
-
74
- # Check quota if enabled
75
- if should_check
76
- quota = get_quota(true)
77
- file_size_gb = file_size / (1024.0 * 1024.0 * 1024.0)
78
- if file_size_gb > quota['storage_available_gb']
79
- raise StorageLimitExceededException.new(
80
- "Storage limit exceeded! File size: #{format('%.4f', file_size_gb)} GB, " \
81
- "Available: #{format('%.4f', quota['storage_available_gb'])} GB."
82
- )
83
- end
219
+ params = {}
220
+ params['folder'] = folder unless folder.empty?
221
+
222
+ upload_io = Faraday::Multipart::FilePart.new(
223
+ StringIO.new(content.is_a?(String) ? content : content.to_s),
224
+ 'application/octet-stream',
225
+ name
226
+ )
227
+
228
+ response = @conn.post("/api/v1/storage/projects/#{@project_slug}/buckets/#{bucket_name}/files") do |req|
229
+ req.params = params unless params.empty?
230
+ req.body = { file: upload_io }
84
231
  end
85
232
 
86
- # Build URL
87
- url = "/api/v1/storage/s3/projects/#{@project_slug}/upload"
88
- url += "?folder=#{URI.encode_www_form_component(folder)}" if folder
89
-
90
- begin
91
- # Create multipart form
92
- payload = {
93
- file: Faraday::Multipart::FilePart.new(
94
- StringIO.new(file_bytes),
95
- content_type || 'application/octet-stream',
96
- file_key
97
- ),
98
- key: file_key
99
- }
100
- payload[:content_type] = content_type if content_type
101
-
102
- response = @conn.post(url) do |req|
103
- req.body = payload
104
- end
233
+ if response.status >= 400
234
+ handle_error(response)
235
+ end
105
236
 
106
- if response.status >= 400
107
- handle_error(response)
108
- end
237
+ StorageFile.new(response.body)
238
+ rescue Faraday::Error => e
239
+ raise StorageError.new("Upload failed: #{e.message}")
240
+ end
109
241
 
110
- @quota_cache = nil # Refresh quota cache
111
- response.body
112
- rescue Faraday::Error => e
113
- raise StorageException.new("Upload failed: #{e.message}")
242
+ # Upload a file from a local filesystem path.
243
+ #
244
+ # @param file_path [String] Path to local file
245
+ # @param bucket_name [String] Target bucket
246
+ # @param path [String, nil] File path within bucket
247
+ # @return [StorageFile]
248
+ def upload_from_path(file_path, bucket_name: 'default', path: nil)
249
+ raise StorageError.new("File not found: #{file_path}") unless File.exist?(file_path)
250
+
251
+ file_name = File.basename(file_path)
252
+ File.open(file_path, 'rb') do |f|
253
+ upload(bucket_name, f, path: path || file_name, file_name: file_name)
114
254
  end
115
255
  end
116
256
 
117
- # List files in S3 bucket.
118
- #
257
+ # List files in a bucket.
258
+ #
259
+ # @param bucket_name [String] Bucket name
119
260
  # @param prefix [String, nil] Filter by prefix/folder
120
- # @param max_keys [Integer] Maximum files to return
121
- # @return [Array<Hash>] List of file objects
122
- def list_files(prefix = nil, max_keys = 1000)
123
- params = { max_keys: max_keys }
124
- params[:prefix] = prefix if prefix
125
-
126
- response = request('GET', "/api/v1/storage/s3/projects/#{@project_slug}/files", params, nil)
127
- response['files'] || []
128
- end
129
-
130
- # Delete a file from S3 bucket.
131
- #
132
- # @param file_key [String] Path to file in bucket
133
- # @return [Hash] Deletion result
134
- def delete_file(file_key)
135
- response = request('DELETE', "/api/v1/storage/s3/projects/#{@project_slug}/files/#{file_key}", nil, nil)
136
- @quota_cache = nil # Refresh quota cache
137
- response
138
- end
139
-
140
- # Get presigned URL for file access with full metadata.
141
- #
142
- # @param file_key [String] Path to file in bucket
143
- # @param expires_in [Integer] URL validity in seconds (default: 3600 = 1 hour)
144
- # @return [Hash] Dict with file URL and metadata
145
- def get_file_url(file_key, expires_in = 3600)
146
- params = { expires_in: expires_in }
147
- request('GET', "/api/v1/storage/s3/projects/#{@project_slug}/files/#{file_key}/url", params, nil)
148
- end
149
-
150
- # Generate presigned URL for file operations.
151
- #
152
- # @param file_key [String] Path to file in bucket
153
- # @param expires_in [Integer] URL validity in seconds (default: 3600)
154
- # @param operation [String] 'get_object' (download) or 'put_object' (upload)
155
- # @return [String] Presigned URL string
156
- def get_presigned_url(file_key, expires_in = 3600, operation = 'get_object')
157
- payload = {
158
- file_key: file_key,
159
- expires_in: expires_in,
160
- operation: operation
161
- }
162
-
163
- response = request('POST', "/api/v1/storage/s3/projects/#{@project_slug}/presigned-url", nil, payload)
164
- response['url'] || ''
165
- end
166
-
167
- # Get S3 storage information for the project.
168
- #
169
- # @return [Hash] Dict with storage info
170
- def get_storage_info
171
- request('GET', "/api/v1/storage/s3/projects/#{@project_slug}/info", nil, nil)
172
- end
173
-
174
- # Provision S3 storage for the project.
175
- # ⚠️ IMPORTANT: Save the credentials returned! They're only shown once.
176
- #
177
- # @param region [String] AWS region (default: 'us-east-1')
178
- # @return [Hash] Dict with provisioning result including credentials
179
- def provision_storage(region = 'us-east-1')
180
- payload = { region: region }
181
- request('POST', "/api/v1/storage/s3/projects/#{@project_slug}/provision", nil, payload)
182
- end
183
-
184
- # Get list of available S3 regions with pricing.
185
- #
186
- # @return [Array<Hash>] List of region dictionaries with pricing info
187
- def get_available_regions
188
- response = request('GET', '/api/v1/storage/s3/regions', nil, nil)
189
-
190
- # Handle both list and map responses
191
- return response if response.is_a?(Array)
192
- return response['regions'] if response.is_a?(Hash) && response['regions']
193
- [response]
261
+ # @param limit [Integer] Maximum files to return
262
+ # @param offset [Integer] Offset for pagination
263
+ # @return [Array<StorageFile>]
264
+ def list_files(bucket_name, prefix: nil, limit: 100, offset: 0)
265
+ params = { 'limit' => limit, 'offset' => offset }
266
+ params['prefix'] = prefix if prefix
267
+
268
+ data = request('GET', "/api/v1/storage/projects/#{@project_slug}/buckets/#{bucket_name}/files", params, nil)
269
+ items = data.is_a?(Array) ? data : (data['files'] || data['data'] || [])
270
+ items.map { |f| StorageFile.new(f) }
271
+ end
272
+
273
+ # Download a file and return its binary contents.
274
+ #
275
+ # @param bucket_name [String] Bucket name
276
+ # @param file_path [String] File path within bucket
277
+ # @return [String] Binary file contents
278
+ def download(bucket_name, file_path)
279
+ url = "/api/v1/storage/projects/#{@project_slug}/files/#{bucket_name}/#{file_path}"
280
+
281
+ response = @conn.get(url)
282
+ if response.status >= 400
283
+ handle_error(response)
284
+ end
285
+ response.body
286
+ rescue Faraday::Error => e
287
+ raise StorageError.new("Download failed: #{e.message}")
288
+ end
289
+
290
+ # Download a file and save it to a local path.
291
+ #
292
+ # @param bucket_name [String] Bucket name
293
+ # @param file_path [String] File path within bucket
294
+ # @param local_path [String] Local destination path
295
+ # @return [String] The local path
296
+ def download_to_file(bucket_name, file_path, local_path)
297
+ content = download(bucket_name, file_path)
298
+ dir = File.dirname(local_path)
299
+ FileUtils.mkdir_p(dir) unless dir == '.'
300
+ File.open(local_path, 'wb') { |f| f.write(content) }
301
+ local_path
302
+ end
303
+
304
+ # Delete a file from a bucket.
305
+ #
306
+ # @param bucket_name [String] Bucket name
307
+ # @param file_path [String] File path within bucket
308
+ # @return [Hash]
309
+ def delete_file(bucket_name, file_path)
310
+ request('DELETE', "/api/v1/storage/projects/#{@project_slug}/files/#{bucket_name}/#{file_path}", nil, nil)
311
+ end
312
+
313
+ # ── Utilities ────────────────────────────────────────────────
314
+
315
+ # Get the public URL for a file in a public bucket (no auth required).
316
+ #
317
+ # @param bucket_name [String] Bucket name
318
+ # @param file_path [String] File path within bucket
319
+ # @return [String]
320
+ def get_public_url(bucket_name, file_path)
321
+ "#{@base_url}/api/v1/storage/projects/#{@project_slug}/files/#{bucket_name}/#{file_path}"
322
+ end
323
+
324
+ # Get storage statistics for the project.
325
+ #
326
+ # @return [StorageQuota]
327
+ def get_stats
328
+ data = request('GET', "/api/v1/storage/projects/#{@project_slug}/stats", nil, nil)
329
+ StorageQuota.new(data)
330
+ end
331
+
332
+ # Get storage quota (alias for get_stats).
333
+ #
334
+ # @return [StorageQuota]
335
+ def get_quota(force_refresh: false)
336
+ get_stats
337
+ end
338
+
339
+ # ── Lifecycle ────────────────────────────────────────────────
340
+
341
+ # Close the HTTP connection.
342
+ def close
343
+ @conn.close if @conn.respond_to?(:close)
194
344
  end
195
345
 
346
+ def to_s
347
+ "WOWSQLStorage(project=#{@project_slug.inspect})"
348
+ end
349
+ alias inspect to_s
350
+
196
351
  private
197
352
 
198
- # Make HTTP request to Storage API.
199
353
  def request(method, path, params = nil, json = nil)
200
- begin
201
- response = @conn.public_send(method.downcase, path) do |req|
202
- req.params = params if params
203
- req.body = json if json
204
- end
205
-
206
- if response.status >= 400
207
- handle_error(response)
208
- end
354
+ response = @conn.public_send(method.downcase, path) do |req|
355
+ req.params = params if params
356
+ req.body = json if json
357
+ req.headers['Content-Type'] = 'application/json' if json
358
+ end
209
359
 
210
- response.body
211
- rescue Faraday::Error => e
212
- raise StorageException.new("Request failed: #{e.message}")
360
+ if response.status >= 400
361
+ handle_error(response)
213
362
  end
363
+
364
+ response.body
365
+ rescue Faraday::Error => e
366
+ raise StorageError.new("Request failed: #{e.message}")
214
367
  end
215
368
 
216
- # Handle HTTP errors
217
369
  def handle_error(response)
218
370
  error_data = response.body.is_a?(Hash) ? response.body : {}
219
371
  error_msg = error_data['detail'] || error_data['message'] || "Request failed with status #{response.status}"
220
372
 
221
373
  if response.status == 413
222
- raise StorageLimitExceededException.new(error_msg, response.status, error_data)
374
+ raise StorageLimitExceededError.new(error_msg, 413, error_data)
223
375
  end
224
376
 
225
- raise StorageException.new(error_msg, response.status, error_data)
377
+ raise StorageError.new(error_msg, response.status, error_data)
226
378
  end
227
379
  end
228
380
  end
229
-