wowsql-sdk 1.2.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.
@@ -0,0 +1,250 @@
1
+ require 'faraday'
2
+ require 'json'
3
+ require_relative 'exceptions'
4
+
5
+ module WOWSQL
6
+ # Schema management client for PostgreSQL.
7
+ #
8
+ # Requires a SERVICE ROLE key (wowsql_service_...), not an anonymous key.
9
+ #
10
+ # @example
11
+ # schema = WOWSQL::WOWSQLSchema.new(
12
+ # "myproject",
13
+ # "wowsql_service_..."
14
+ # )
15
+ # schema.create_table("users", [
16
+ # { "name" => "id", "type" => "SERIAL", "auto_increment" => true },
17
+ # { "name" => "email", "type" => "VARCHAR(255)", "unique" => true, "nullable" => false },
18
+ # { "name" => "name", "type" => "VARCHAR(255)" },
19
+ # { "name" => "metadata", "type" => "JSONB", "default" => "'{}'" },
20
+ # { "name" => "created_at", "type" => "TIMESTAMPTZ", "default" => "CURRENT_TIMESTAMP" },
21
+ # ], primary_key: "id", indexes: ["email"])
22
+ class WOWSQLSchema
23
+ attr_reader :base_url
24
+
25
+ # @param project_url [String] Project subdomain or full URL
26
+ # @param service_key [String] Service role key (wowsql_service_...)
27
+ # @param base_domain [String] Base domain (default: "wowsql.com")
28
+ # @param secure [Boolean] Use HTTPS (default: true)
29
+ # @param timeout [Integer] Request timeout in seconds (default: 30)
30
+ # @param verify_ssl [Boolean] Verify SSL certificates (default: true)
31
+ def initialize(project_url, service_key, base_domain: 'wowsql.com', secure: true,
32
+ timeout: 30, verify_ssl: true)
33
+ if project_url.start_with?('http://') || project_url.start_with?('https://')
34
+ base = project_url.chomp('/')
35
+ base = base.split('/api').first if base.include?('/api')
36
+ @base_url = base
37
+ else
38
+ protocol = secure ? 'https' : 'http'
39
+ if project_url.include?(".#{base_domain}") || project_url.end_with?(base_domain)
40
+ @base_url = "#{protocol}://#{project_url}"
41
+ else
42
+ @base_url = "#{protocol}://#{project_url}.#{base_domain}"
43
+ end
44
+ end
45
+
46
+ @timeout = timeout
47
+
48
+ ssl_options = verify_ssl ? {} : { verify: false }
49
+
50
+ @conn = Faraday.new(url: @base_url, ssl: ssl_options) do |f|
51
+ f.request :json
52
+ f.response :json
53
+ f.adapter Faraday.default_adapter
54
+ f.options.timeout = timeout
55
+ end
56
+
57
+ @conn.headers['Authorization'] = "Bearer #{service_key}"
58
+ @conn.headers['Content-Type'] = 'application/json'
59
+ end
60
+
61
+ # ── Table operations ─────────────────────────────────────────
62
+
63
+ # Create a new table.
64
+ #
65
+ # @param table_name [String] Name of the table
66
+ # @param columns [Array<Hash>] Column definitions (name, type, auto_increment, unique, nullable, default)
67
+ # @param primary_key [String, nil] Primary key column name
68
+ # @param indexes [Array<String>, nil] Columns to create indexes on
69
+ # @return [Hash]
70
+ def create_table(table_name, columns, primary_key: nil, indexes: nil)
71
+ request('POST', '/api/v2/schema/tables', nil, {
72
+ table_name: table_name,
73
+ columns: columns,
74
+ primary_key: primary_key,
75
+ indexes: indexes
76
+ })
77
+ end
78
+
79
+ # Alter an existing table.
80
+ #
81
+ # Operations: add_column, drop_column, modify_column, rename_column
82
+ #
83
+ # @param table_name [String] Table name
84
+ # @param operation [String] Operation type
85
+ # @param column_name [String, nil] Column name
86
+ # @param column_type [String, nil] Column type
87
+ # @param new_column_name [String, nil] New column name (for rename)
88
+ # @param nullable [Boolean] Whether column is nullable
89
+ # @param default [String, nil] Default value
90
+ # @return [Hash]
91
+ def alter_table(table_name, operation, column_name: nil, column_type: nil,
92
+ new_column_name: nil, nullable: true, default: nil)
93
+ request('PATCH', "/api/v2/schema/tables/#{table_name}", nil, {
94
+ table_name: table_name,
95
+ operation: operation,
96
+ column_name: column_name,
97
+ column_type: column_type,
98
+ new_column_name: new_column_name,
99
+ nullable: nullable,
100
+ default: default
101
+ })
102
+ end
103
+
104
+ # Drop a table. WARNING: This cannot be undone!
105
+ #
106
+ # @param table_name [String] Table to drop
107
+ # @param cascade [Boolean] Also drop dependent objects
108
+ # @return [Hash]
109
+ def drop_table(table_name, cascade: false)
110
+ request('DELETE', "/api/v2/schema/tables/#{table_name}", { 'cascade' => cascade }, nil)
111
+ end
112
+
113
+ # Execute raw DDL SQL.
114
+ #
115
+ # Only schema statements are allowed: CREATE TABLE, ALTER TABLE,
116
+ # DROP TABLE, CREATE INDEX, DROP INDEX, etc.
117
+ #
118
+ # @param sql [String] DDL SQL statement
119
+ # @return [Hash]
120
+ def execute_sql(sql)
121
+ request('POST', '/api/v2/schema/execute', nil, { sql: sql })
122
+ end
123
+
124
+ # ── Convenience methods ──────────────────────────────────────
125
+
126
+ # Add a column to an existing table.
127
+ #
128
+ # @param table_name [String] Table name
129
+ # @param column_name [String] Column name
130
+ # @param column_type [String] Column type
131
+ # @param nullable [Boolean] Whether column is nullable
132
+ # @param default [String, nil] Default value
133
+ # @return [Hash]
134
+ def add_column(table_name, column_name, column_type, nullable: true, default: nil)
135
+ alter_table(
136
+ table_name, 'add_column',
137
+ column_name: column_name,
138
+ column_type: column_type,
139
+ nullable: nullable,
140
+ default: default
141
+ )
142
+ end
143
+
144
+ # Drop a column from a table.
145
+ #
146
+ # @param table_name [String] Table name
147
+ # @param column_name [String] Column name
148
+ # @return [Hash]
149
+ def drop_column(table_name, column_name)
150
+ alter_table(table_name, 'drop_column', column_name: column_name)
151
+ end
152
+
153
+ # Rename a column.
154
+ #
155
+ # @param table_name [String] Table name
156
+ # @param old_name [String] Current column name
157
+ # @param new_name [String] New column name
158
+ # @return [Hash]
159
+ def rename_column(table_name, old_name, new_name)
160
+ alter_table(
161
+ table_name, 'rename_column',
162
+ column_name: old_name,
163
+ new_column_name: new_name
164
+ )
165
+ end
166
+
167
+ # Change column type, nullability, or default value.
168
+ #
169
+ # @param table_name [String] Table name
170
+ # @param column_name [String] Column name
171
+ # @param column_type [String, nil] New column type
172
+ # @param nullable [Boolean, nil] New nullability
173
+ # @param default [String, nil] New default value
174
+ # @return [Hash]
175
+ def modify_column(table_name, column_name, column_type: nil, nullable: nil, default: nil)
176
+ kwargs = { column_name: column_name }
177
+ kwargs[:column_type] = column_type unless column_type.nil?
178
+ kwargs[:nullable] = nullable unless nullable.nil?
179
+ kwargs[:default] = default unless default.nil?
180
+ alter_table(table_name, 'modify_column', **kwargs)
181
+ end
182
+
183
+ # Create an index.
184
+ #
185
+ # @param table_name [String] Table to index
186
+ # @param columns [String, Array<String>] Column(s)
187
+ # @param unique [Boolean] Create a UNIQUE index
188
+ # @param name [String, nil] Custom index name
189
+ # @param using [String, nil] Index method (btree, hash, gin, gist)
190
+ # @return [Hash]
191
+ def create_index(table_name, columns, unique: false, name: nil, using: nil)
192
+ cols = columns.is_a?(Array) ? columns : [columns]
193
+ idx_name = name || "idx_#{table_name}_#{cols.join('_')}"
194
+ unique_kw = unique ? 'UNIQUE ' : ''
195
+ using_kw = using ? " USING #{using}" : ''
196
+ col_list = cols.map { |c| "\"#{c}\"" }.join(', ')
197
+ sql = "CREATE #{unique_kw}INDEX IF NOT EXISTS \"#{idx_name}\" ON \"#{table_name}\"#{using_kw} (#{col_list})"
198
+ execute_sql(sql)
199
+ end
200
+
201
+ # List all tables via the v2 REST API.
202
+ #
203
+ # @return [Array<String>]
204
+ def list_tables
205
+ resp = request('GET', '/api/v2/tables', nil, nil)
206
+ resp['tables'] || []
207
+ end
208
+
209
+ # Get column-level schema information for a table.
210
+ #
211
+ # @param table_name [String] Table name
212
+ # @return [Hash]
213
+ def get_table_schema(table_name)
214
+ request('GET', "/api/v2/tables/#{table_name}/schema", nil, nil)
215
+ end
216
+
217
+ # Close the HTTP connection.
218
+ def close
219
+ @conn.close if @conn.respond_to?(:close)
220
+ end
221
+
222
+ private
223
+
224
+ def request(method, path, params = nil, json = nil)
225
+ response = @conn.public_send(method.downcase, path) do |req|
226
+ req.params = params if params
227
+ req.body = json if json
228
+ end
229
+
230
+ if response.status == 403
231
+ raise SchemaPermissionError.new(
232
+ 'Schema operations require a SERVICE ROLE key. ' \
233
+ 'You are using an anonymous key which cannot modify database schema.',
234
+ 403
235
+ )
236
+ end
237
+
238
+ if response.status >= 400
239
+ error_data = response.body.is_a?(Hash) ? response.body : {}
240
+ error_msg = error_data['detail'] || error_data['message'] ||
241
+ "Request failed with status #{response.status}"
242
+ raise WOWSQLError.new(error_msg, response.status, error_data)
243
+ end
244
+
245
+ response.body
246
+ rescue Faraday::Error => e
247
+ raise WOWSQLError.new("Request failed: #{e.message}")
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,380 @@
1
+ require 'faraday'
2
+ require 'faraday/multipart'
3
+ require 'json'
4
+ require 'stringio'
5
+ require 'uri'
6
+ require 'fileutils'
7
+ require_relative 'exceptions'
8
+
9
+ module WOWSQL
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")
94
+ class WOWSQLStorage
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
+
130
+ @timeout = timeout
131
+ @verify_ssl = verify_ssl
132
+
133
+ ssl_options = verify_ssl ? {} : { verify: false }
134
+
135
+ @conn = Faraday.new(url: @base_url, ssl: ssl_options) do |f|
136
+ f.request :multipart
137
+ f.request :json
138
+ f.response :json
139
+ f.adapter Faraday.default_adapter
140
+ f.options.timeout = timeout
141
+ end
142
+
143
+ @conn.headers['Authorization'] = "Bearer #{api_key}"
144
+ end
145
+
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
172
+
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)
180
+ end
181
+
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
217
+ end
218
+
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 }
231
+ end
232
+
233
+ if response.status >= 400
234
+ handle_error(response)
235
+ end
236
+
237
+ StorageFile.new(response.body)
238
+ rescue Faraday::Error => e
239
+ raise StorageError.new("Upload failed: #{e.message}")
240
+ end
241
+
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)
254
+ end
255
+ end
256
+
257
+ # List files in a bucket.
258
+ #
259
+ # @param bucket_name [String] Bucket name
260
+ # @param prefix [String, nil] Filter by prefix/folder
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)
344
+ end
345
+
346
+ def to_s
347
+ "WOWSQLStorage(project=#{@project_slug.inspect})"
348
+ end
349
+ alias inspect to_s
350
+
351
+ private
352
+
353
+ def request(method, path, params = nil, json = nil)
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
359
+
360
+ if response.status >= 400
361
+ handle_error(response)
362
+ end
363
+
364
+ response.body
365
+ rescue Faraday::Error => e
366
+ raise StorageError.new("Request failed: #{e.message}")
367
+ end
368
+
369
+ def handle_error(response)
370
+ error_data = response.body.is_a?(Hash) ? response.body : {}
371
+ error_msg = error_data['detail'] || error_data['message'] || "Request failed with status #{response.status}"
372
+
373
+ if response.status == 413
374
+ raise StorageLimitExceededError.new(error_msg, 413, error_data)
375
+ end
376
+
377
+ raise StorageError.new(error_msg, response.status, error_data)
378
+ end
379
+ end
380
+ end