reve_ai 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 280bf1673c22b411163ea4dad09eae4a7ca07a06dceefee1d10b54cc2a07a097
4
- data.tar.gz: 288d3493fcd73b1827003b785c29fd0c7ce53a76a302769e03054686508d2dc4
3
+ metadata.gz: a51cf5f511364f9b5420edaba5d26f020c533354706dc0777fcd50a73ac8b99e
4
+ data.tar.gz: 15d846e877511007e0cf82ab1d447bf1915482dfa31210e8c4e7180aa75c6032
5
5
  SHA512:
6
- metadata.gz: 604e38817990a60d470e0c24b96033c88b27774012e6dac07e6392c7f413d02fd8c123a92978868eb972790dea4ad23f11ce73226c11db88f0c3a5482aff7e52
7
- data.tar.gz: 202c65bafdcd96b002cff7a083f10b7efd4c05e187b0add59831833e8d6dfd88e7aa0fac49037b986a5aedb4bcf19eceb3b6d4a679741a2acb3bd7577bc5a27f
6
+ metadata.gz: b6af6528d75a2ab2fb007cf4e2e6277c722cc34d9ac396643e75bf3d36e09db4ff450764fc3f3c8d0cffa27f0a7e6cabc65f189a36c92d8b4ee8e313e257d4f2
7
+ data.tar.gz: 99194027f16869e1a2ecea152a030bb1a54f7d7e60b91824e608b7e9e41c5b82da12d5fdcdc633e54f5dd498a883e133ea3420725df8f9250593688bcb3728d4
data/CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.0] - Unreleased
9
+
10
+ ### Added
11
+
12
+ - API v2 image creation via `client.v2.images.create` (`POST /v2/image/create`): ordered `references`
13
+ (`{ data: }` / `{ ref: }` objects addressed from the prompt as `<frame>N</frame>`), the full
14
+ 18-value aspect-ratio set including `auto`, prompts up to 4000 characters, and up to 8 references
15
+ - Experimental layout endpoints under `client.v2.layouts`: `extract` (`POST /v2/image/extract_layout`),
16
+ `create` (`POST /v2/image/create_layout`), and `render` (`POST /v2/image/render_layout`)
17
+ - Effects listing via `client.effects.list` (`GET /v1/image/effect`) with an optional `source` filter
18
+ - `postprocessing:`, `test_time_scaling:`, `accept:`, and `breadcrumb:` options on the v1
19
+ `create`/`edit`/`remix` and v2 endpoints
20
+ - Binary image responses: `accept: "image/png"`, `"image/jpeg"`, or `"image/webp"` returns raw image
21
+ bytes with metadata in `X-Reve-*` headers
22
+ - `ImageResponse#layout` and `ReveAI::LayoutResponse` for the structured layouts returned by v2 endpoints
23
+ - `APIError#params` for the error-specific `params` object returned by the API, and `APIError#error_code`
24
+ now falls back to the `X-Reve-Error-Code` header (binary error responses)
25
+ - GET support in the HTTP layer; retries now cover GET requests as well as POST
26
+
27
+ ### Fixed
28
+
29
+ - Remix `<img>N</img>` YARD documentation corrected to 0-based indexing, matching the API
30
+
31
+ ## [0.1.0] - 2026-01-04
32
+
33
+ ### Added
34
+
35
+ - Initial release
36
+ - `ReveAI::Client` for API authentication and configuration
37
+ - Image generation via `client.images.generate`
38
+ - Image editing via `client.images.edit`
39
+ - Image remix via `client.images.remix`
40
+ - Automatic retry with exponential backoff via Faraday
41
+ - Configurable timeouts and base URL
data/README.md CHANGED
@@ -37,6 +37,18 @@ response.credits_used # => 18
37
37
  response.credits_remaining # => 982
38
38
  ```
39
39
 
40
+ Save the image to a file:
41
+
42
+ ```ruby
43
+ require "base64"
44
+
45
+ image_data = Base64.decode64(response.image)
46
+
47
+ File.open("output.png", "wb") do |f|
48
+ f.write(image_data)
49
+ end
50
+ ```
51
+
40
52
  With aspect ratio:
41
53
 
42
54
  ```ruby
@@ -76,7 +88,7 @@ response.image # => "base64editeddata..."
76
88
  response.version # => "reve-edit@20250915"
77
89
  ```
78
90
 
79
- Available versions for edit: `latest`, `latest-fast`, `reve-edit@20250915`, `reve-edit-fast@20251030`
91
+ Available versions for edit: `latest`, `latest-fast`, `reve-edit@20250915`, `reve-edit-fast@20251030`, `reve-edit-passthrough@20260625`
80
92
 
81
93
  ### Remix Images
82
94
 
@@ -104,6 +116,227 @@ response.version # => "reve-remix@20250915"
104
116
 
105
117
  Available versions for remix: `latest`, `latest-fast`, `reve-remix@20250915`, `reve-remix-fast@20251030`
106
118
 
119
+ ### Postprocessing and Effects
120
+
121
+ The v1 `create`/`edit`/`remix` methods and `client.v2.images.create` accept these optional keyword arguments (`client.v2.layouts.render` also supports `postprocessing:` and `accept:`, and every endpoint supports `breadcrumb:`):
122
+
123
+ | Option | Values | Description |
124
+ |--------|--------|-------------|
125
+ | `postprocessing:` | Array of operation Hashes | Operations applied to the generated image (see below) |
126
+ | `test_time_scaling:` | `1`-`15` | Spend more effort (and credits) on the request; clamped server-side. Not recommended for v2 models |
127
+ | `accept:` | `"image/png"`, `"image/jpeg"`, `"image/webp"` | Return raw image bytes instead of JSON; metadata moves to `X-Reve-*` headers |
128
+ | `breadcrumb:` | String | Request-tracking tag, searchable on the Reve Usage page; ignored by the API |
129
+
130
+ Supported postprocessing operations:
131
+
132
+ | Operation | Parameters | Notes |
133
+ |-----------|------------|-------|
134
+ | `upscale` | `upscale_factor` (integer, 1-4) | Adds credits cost; a 4x upscale is large |
135
+ | `remove_background` | none | Adds credits cost; works best with a clear subject |
136
+ | `fit_image` | `max_dim`, `max_width`, and/or `max_height` (max 4096) | Free; scales down preserving aspect ratio |
137
+ | `effect` | `effect_name`, optional `effect_parameters` | Applies an effect saved in your project |
138
+
139
+ ```ruby
140
+ response = client.images.create(
141
+ prompt: "A beautiful sunset over mountains",
142
+ postprocessing: [
143
+ { process: "upscale", upscale_factor: 2 },
144
+ { process: "fit_image", max_dim: 2048 }
145
+ ],
146
+ test_time_scaling: 3,
147
+ breadcrumb: "homepage-hero"
148
+ )
149
+ ```
150
+
151
+ Effect parameter overrides use a nested `{ filterId => { uniformId => value } }` format; omitted parameters fall back to the effect's saved defaults:
152
+
153
+ ```ruby
154
+ response = client.images.create(
155
+ prompt: "A high-quality photo of a wine bottle",
156
+ postprocessing: [
157
+ {
158
+ process: "effect",
159
+ effect_name: "adjustments",
160
+ effect_parameters: {
161
+ "adjustments" => {
162
+ "u_exposure" => 42,
163
+ "u_contrast" => -38,
164
+ "u_vibrance" => 64
165
+ }
166
+ }
167
+ }
168
+ ]
169
+ )
170
+ ```
171
+
172
+ List the effects available to your project — the returned `name` values are valid `effect_name` arguments:
173
+
174
+ ```ruby
175
+ response = client.effects.list
176
+
177
+ response.body[:effects].each do |effect|
178
+ puts "#{effect[:name]} (#{effect[:source]})" # source: "saved" (project) or "builtin" (preset)
179
+ end
180
+
181
+ # Optional source filter: "all" (default), "project" (saved only), "preset" (builtin only)
182
+ presets = client.effects.list(source: "preset")
183
+ ```
184
+
185
+ ### API v2
186
+
187
+ The v2 API collapses the three v1 workflows into a single endpoint, `client.v2.images.create`, which takes ordered reference images and returns a structured `layout` alongside the image. The v1 endpoints above remain live and unchanged.
188
+
189
+ - Prompts up to 4000 characters, and up to 8 reference images
190
+ - The full 18-value aspect-ratio set, including `auto` (the default) — see [Validation Constraints](#validation-constraints)
191
+ - References are objects with exactly one of `data:` (base64-encoded image) or `ref:` (`"id:<uuid>"` for a stored image or generation, `"reference:@<name>"` for a named project reference)
192
+ - Address a reference from the prompt as `<frame>N</frame>` (0-based: the first reference is `<frame>0</frame>`)
193
+ - v2 images are significantly larger than v1 — cap the output with a free `fit_image` step: `postprocessing: [{ process: "fit_image", max_dim: 2048 }]`
194
+
195
+ Generate an image from text:
196
+
197
+ ```ruby
198
+ response = client.v2.images.create(prompt: "A beautiful sunset over mountains")
199
+
200
+ response.image # => "base64encodeddata..."
201
+ response.version # => "latest" (v2 responses currently always report "latest")
202
+ ```
203
+
204
+ Edit an image (pass it as the first reference and address it as `<frame>0</frame>`):
205
+
206
+ ```ruby
207
+ require "base64"
208
+
209
+ image_data = Base64.strict_encode64(File.read("my-image.png"))
210
+
211
+ response = client.v2.images.create(
212
+ prompt: "Add dramatic clouds to the sky of <frame>0</frame>",
213
+ references: [{ data: image_data }]
214
+ )
215
+ ```
216
+
217
+ Combine multiple references:
218
+
219
+ ```ruby
220
+ person = Base64.strict_encode64(File.read("person.png"))
221
+ background = Base64.strict_encode64(File.read("background.png"))
222
+
223
+ response = client.v2.images.create(
224
+ prompt: "The person from <frame>0</frame> standing in the scene from <frame>1</frame>",
225
+ references: [{ data: person }, { data: background }],
226
+ aspect_ratio: "21:9"
227
+ )
228
+ ```
229
+
230
+ Every v2 JSON response includes the layout the model generated for the image:
231
+
232
+ ```ruby
233
+ response = client.v2.images.create(prompt: "A serene mountain landscape at sunset")
234
+
235
+ response.layout # => { prompt: "...", regions: [...], width: 4672, height: 3520 }
236
+
237
+ response.layout[:regions].each do |region|
238
+ puts "#{region[:label]}: #{region[:prompt]}"
239
+ end
240
+ ```
241
+
242
+ Request a binary image instead of JSON with `accept:` — `response.image` then holds the raw bytes, and the metadata arrives via `X-Reve-*` headers:
243
+
244
+ ```ruby
245
+ response = client.v2.images.create(
246
+ prompt: "A beautiful sunset over mountains",
247
+ accept: "image/webp"
248
+ )
249
+
250
+ File.binwrite("mountains.webp", response.image) # no Base64 decoding needed
251
+
252
+ response.credits_used # => 150
253
+ ```
254
+
255
+ Pass `version:` to pin a model alias — v2 aliases observed: `latest` (default), `reve-v2-create@260601`.
256
+
257
+ #### Migrating from v1 to v2
258
+
259
+ All three v1 workflows map to `client.v2.images.create`:
260
+
261
+ Create — keep `prompt` and `aspect_ratio`:
262
+
263
+ ```ruby
264
+ # v1
265
+ client.images.create(prompt: "A serene mountain landscape at sunset",
266
+ aspect_ratio: "16:9", version: "latest")
267
+
268
+ # v2
269
+ client.v2.images.create(prompt: "A serene mountain landscape at sunset",
270
+ aspect_ratio: "16:9")
271
+ ```
272
+
273
+ Edit — the edited image becomes `references[0]`, addressed as `<frame>0</frame>`:
274
+
275
+ ```ruby
276
+ # v1
277
+ client.images.edit(edit_instruction: "Remove the people in the background.",
278
+ reference_image: image_b64)
279
+
280
+ # v2
281
+ client.v2.images.create(prompt: "Remove the people in the background of <frame>0</frame>.",
282
+ references: [{ data: image_b64 }])
283
+ ```
284
+
285
+ Remix — `<img>N</img>` tags become `<frame>N</frame>` (both 0-based) and bare base64 strings become `{ data: }` objects:
286
+
287
+ ```ruby
288
+ # v1
289
+ client.images.remix(prompt: "The woman from <img>0</img> driving the car from <img>1</img>.",
290
+ reference_images: [woman_b64, car_b64], aspect_ratio: "1:1")
291
+
292
+ # v2
293
+ client.v2.images.create(prompt: "The woman from <frame>0</frame> driving the car from <frame>1</frame>.",
294
+ references: [{ data: woman_b64 }, { data: car_b64 }], aspect_ratio: "1:1")
295
+ ```
296
+
297
+ #### Experimental Layout Endpoints
298
+
299
+ The layout endpoints are **experimental**: they require care and experimentation to achieve good results, are best suited for agents and custom tooling, and may change. For simple generation and prompt-based editing, use `client.v2.images.create`. They return JSON only (`render` also supports `accept:`) and commonly take 10-40 seconds (`render`: 40-80 seconds) — keep timeouts at 120 seconds or more.
300
+
301
+ A layout is a Hash with an optional overall `prompt`, optional `width`/`height`, and a `regions` array; each region carries a `label`, a regional `prompt`, and a normalized `bbox` (`x0`, `y0`, `x1`, `y1` in 0.0-1.0, top-left origin).
302
+
303
+ Extract a layout from an image:
304
+
305
+ ```ruby
306
+ image = { data: Base64.strict_encode64(File.read("photo.jpg")) }
307
+
308
+ response = client.v2.layouts.extract(image: image)
309
+ response.layout # => { prompt: "...", regions: [...], width: 4672, height: 3520 }
310
+ ```
311
+
312
+ Create a layout from a prompt, without rendering an image:
313
+
314
+ ```ruby
315
+ response = client.v2.layouts.create(prompt: "a person at a cafe", aspect_ratio: "3:2")
316
+ layout = response.layout
317
+ ```
318
+
319
+ Render an image from a layout:
320
+
321
+ ```ruby
322
+ response = client.v2.layouts.render(layout: layout)
323
+ response.image # => "base64encodeddata..."
324
+ ```
325
+
326
+ Unlike v2 create, the layout endpoints take compound references — each entry may contain any subset of `image:`, `layout:`, and `prompt:`:
327
+
328
+ ```ruby
329
+ response = client.v2.layouts.create(
330
+ prompt: "Put the bottle on a wooden table",
331
+ references: [
332
+ { image: { data: bottle_b64 }, prompt: "the product to feature" },
333
+ { layout: saved_layout }
334
+ ]
335
+ )
336
+ ```
337
+
338
+ `create` also accepts `commands:` — ordered imperative edits (`add`, `place`, `shift`, `remove`, `keep`, `change`) with normalized positions; see the [API docs](https://api.reve.com/console/docs) for the command shapes.
339
+
107
340
  ### Rails
108
341
 
109
342
  Create `config/initializers/reve_ai.rb`:
@@ -155,6 +388,19 @@ rescue ReveAI::ConnectionError => e
155
388
  end
156
389
  ```
157
390
 
391
+ Every API error also exposes the details returned by the API:
392
+
393
+ ```ruby
394
+ rescue ReveAI::APIError => e
395
+ e.error_code # => "PROMPT_TOO_LONG"
396
+ e.params # => Hash of error-specific parameters, or nil
397
+ e.status # => 400
398
+ e.request_id # => "rsid-..."
399
+ end
400
+ ```
401
+
402
+ For binary image requests (`accept: "image/*"`), error responses carry a small grey image and the error code arrives in the `X-Reve-Error-Code` header — `#error_code` reads it from either the response body or the header.
403
+
158
404
  ### Content Moderation
159
405
 
160
406
  The API may flag content violations:
@@ -173,20 +419,38 @@ end
173
419
  |--------|---------|-------------|
174
420
  | `api_key` | `ENV["REVE_AI_API_KEY"]` | Your Reve API key |
175
421
  | `base_url` | `https://api.reve.com` | API base URL |
176
- | `timeout` | `120` | Request timeout in seconds |
422
+ | `timeout` | `120` | Request timeout in seconds (the API requires at least 120) |
177
423
  | `open_timeout` | `30` | Connection timeout in seconds |
178
424
  | `max_retries` | `2` | Number of retries for failed requests |
179
425
  | `logger` | `nil` | Logger instance for debugging |
180
426
  | `debug` | `false` | Enable debug logging |
181
427
 
428
+ The API requires client timeouts of at least 120 seconds: image generation and rendering commonly take 40-80 seconds, and the layout endpoints 10-40 seconds. The gem's default of 120 seconds complies. Treat shorter client-side timeouts as cancellations — the server may still finish the request after the client disconnects.
429
+
182
430
  ### Validation Constraints
183
431
 
432
+ v1 endpoints (`create`, `edit`, `remix`):
433
+
184
434
  | Constraint | Value |
185
435
  |------------|-------|
186
436
  | Max prompt length | 2560 characters |
187
437
  | Max reference images (remix) | 6 |
188
438
  | Valid aspect ratios | 16:9, 9:16, 3:2, 2:3, 4:3, 3:4, 1:1 |
189
439
 
440
+ v2 endpoints (`client.v2`):
441
+
442
+ | Constraint | Value |
443
+ |------------|-------|
444
+ | Max prompt length | 4000 characters |
445
+ | Max reference images | 8 |
446
+ | Valid aspect ratios | 4:1, 3:1, 21:9, 2:1, 17:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16, 1:2, 1:3, 1:4, auto (default: auto) |
447
+
448
+ Input image limits (any endpoint that accepts images):
449
+
450
+ - Formats: WEBP, JPEG, PNG, GIF, TIFF, AVIF — base64-encoded in JSON
451
+ - Per image: at most 40 MB and 33,554,432 pixels, with neither dimension exceeding 8192 pixels
452
+ - Per call: at most 100 MB and 50,331,648 pixels across all images
453
+
190
454
  ## Development
191
455
 
192
456
  ```
@@ -195,6 +459,12 @@ bundle exec rake test
195
459
  bundle exec rubocop
196
460
  ```
197
461
 
462
+ ## Release
463
+
464
+ ```sh
465
+ bundle exec rake release
466
+ ```
467
+
198
468
  ## Contributing
199
469
 
200
470
  Bug reports and pull requests are welcome on GitHub at https://github.com/dpaluy/reve_ai.
@@ -64,13 +64,40 @@ module ReveAI
64
64
  #
65
65
  # @example Remix images
66
66
  # result = client.images.remix(
67
- # prompt: "Combine <img>1</img> and <img>2</img> into one scene",
67
+ # prompt: "Combine <img>0</img> and <img>1</img> into one scene",
68
68
  # reference_images: [image1_base64, image2_base64]
69
69
  # )
70
70
  def images
71
71
  @images ||= Resources::Images.new(self)
72
72
  end
73
73
 
74
+ # Returns the Effects resource for listing available effects.
75
+ #
76
+ # @return [Resources::Effects] Effects listing interface
77
+ # @see Resources::Effects
78
+ #
79
+ # @example List all effects available to the project
80
+ # result = client.effects.list
81
+ # result.body[:effects].each { |effect| puts effect[:name] }
82
+ def effects
83
+ @effects ||= Resources::Effects.new(self)
84
+ end
85
+
86
+ # Returns the v2 API namespace (image create and layout endpoints).
87
+ #
88
+ # @return [Resources::V2] v2 API operations interface
89
+ # @see Resources::V2
90
+ #
91
+ # @example Generate an image with the v2 API
92
+ # result = client.v2.images.create(
93
+ # prompt: "Remove the people in the background of <frame>0</frame>.",
94
+ # references: [{ data: base64_encoded_image }]
95
+ # )
96
+ # result.layout # => { prompt: "...", regions: [...] }
97
+ def v2
98
+ @v2 ||= Resources::V2.new(self)
99
+ end
100
+
74
101
  # Returns the HTTP client for making API requests.
75
102
  #
76
103
  # @return [HTTP::Client] HTTP client instance
@@ -34,7 +34,7 @@ module ReveAI
34
34
  # @return [Integer] Default number of retry attempts for failed requests
35
35
  DEFAULT_MAX_RETRIES = 2
36
36
 
37
- # @return [Array<String>] Valid aspect ratios for image generation
37
+ # @return [Array<String>] Valid aspect ratios for legacy v1 image generation
38
38
  VALID_ASPECT_RATIOS = %w[16:9 9:16 3:2 2:3 4:3 3:4 1:1].freeze
39
39
 
40
40
  # @return [Integer] Maximum allowed prompt length in characters
@@ -43,6 +43,15 @@ module ReveAI
43
43
  # @return [Integer] Maximum number of reference images for remix operations
44
44
  MAX_REFERENCE_IMAGES = 6
45
45
 
46
+ # @return [Array<String>] Valid aspect ratios for image generation
47
+ ASPECT_RATIOS = %w[4:1 3:1 21:9 2:1 17:9 16:9 3:2 4:3 5:4 1:1 4:5 3:4 2:3 9:16 1:2 1:3 1:4 auto].freeze
48
+
49
+ # @return [Integer] Maximum allowed prompt length in characters for v2 endpoints
50
+ V2_MAX_PROMPT_LENGTH = 4000
51
+
52
+ # @return [Integer] Maximum number of references for v2 create operations
53
+ V2_MAX_REFERENCES = 8
54
+
46
55
  # @return [String, nil] Reve API key for authentication
47
56
  attr_accessor :api_key
48
57
 
@@ -72,22 +72,27 @@ module ReveAI
72
72
  # @return [Integer, nil] HTTP status code
73
73
  attr_reader :status
74
74
 
75
- # @return [Hash] Response body parsed as Hash
75
+ # @return [Hash, String] Response body parsed as Hash, or raw String
76
+ # for binary error responses (e.g., grey image bodies)
76
77
  attr_reader :body
77
78
 
78
79
  # @return [Hash] Response headers
79
80
  attr_reader :headers
80
81
 
82
+ # @return [Hash, nil] Additional error details from the response body
83
+ attr_reader :params
84
+
81
85
  # Creates a new API error instance.
82
86
  #
83
87
  # @param message [String, nil] Error message
84
88
  # @param status [Integer, nil] HTTP status code
85
- # @param body [Hash, nil] Response body
89
+ # @param body [Hash, String, nil] Response body
86
90
  # @param headers [Hash, nil] Response headers
87
91
  def initialize(message = nil, status: nil, body: nil, headers: nil)
88
92
  @status = status
89
93
  @body = body || {}
90
94
  @headers = headers || {}
95
+ @params = @body.is_a?(Hash) ? @body[:params] : nil
91
96
  super(message)
92
97
  end
93
98
 
@@ -100,11 +105,17 @@ module ReveAI
100
105
  headers["x-reve-request-id"]
101
106
  end
102
107
 
103
- # Returns the error code from the response body.
108
+ # Returns the error code for this error.
109
+ #
110
+ # Read from the response body when present, falling back to the
111
+ # X-Reve-Error-Code header: with an image Accept header, the API answers
112
+ # errors with a small grey image body and no JSON error code.
104
113
  #
105
114
  # @return [String, nil] Error code (e.g., "PROMPT_TOO_LONG", "INVALID_API_KEY")
106
115
  def error_code
107
- body[:error_code]
116
+ return body[:error_code] if body.is_a?(Hash) && body[:error_code]
117
+
118
+ headers["x-reve-error-code"]
108
119
  end
109
120
  end
110
121
 
@@ -39,12 +39,46 @@ module ReveAI
39
39
  @configuration = configuration
40
40
  end
41
41
 
42
+ # Makes a GET request to the API.
43
+ #
44
+ # @param path [String] API endpoint path (e.g., "/v1/image/effect")
45
+ # @param params [Hash, nil] Query parameters to merge into the URL
46
+ # (e.g., { source: "project" })
47
+ #
48
+ # @return [Response] Parsed API response
49
+ #
50
+ # @raise [TimeoutError] if request times out
51
+ # @raise [ConnectionError] if connection fails
52
+ # @raise [NetworkError] for other network errors
53
+ # @raise [BadRequestError] on 400 responses
54
+ # @raise [UnauthorizedError] on 401 responses
55
+ # @raise [InsufficientCreditsError] on 402 responses
56
+ # @raise [ForbiddenError] on 403 responses
57
+ # @raise [NotFoundError] on 404 responses
58
+ # @raise [UnprocessableEntityError] on 422 responses
59
+ # @raise [RateLimitError] on 429 responses
60
+ # @raise [ServerError] on 5xx responses
61
+ #
62
+ # @api private
63
+ def get(path, params: nil)
64
+ normalized_path = path.sub(%r{^/}, "")
65
+ response = perform_request do |conn|
66
+ conn.get(normalized_path) { |req| req.params.update(params) if params }
67
+ end
68
+ handle_response(response)
69
+ end
70
+
42
71
  # Makes a POST request to the API.
43
72
  #
44
73
  # @param path [String] API endpoint path (e.g., "/v1/image/create")
45
74
  # @param body [Hash] Request body to send as JSON
75
+ # @param params [Hash, nil] Query parameters to merge into the URL
76
+ # (e.g., { breadcrumb: "my-tracking-value" })
77
+ # @param accept [String, nil] Per-request Accept header override
78
+ # (e.g., "image/webp"). The connection default stays "application/json".
46
79
  #
47
- # @return [Response] Parsed API response
80
+ # @return [Response] Parsed API response. When the response Content-Type
81
+ # is +image/*+, the body is the raw image String (bytes), not a Hash.
48
82
  #
49
83
  # @raise [TimeoutError] if request times out
50
84
  # @raise [ConnectionError] if connection fails
@@ -59,10 +93,31 @@ module ReveAI
59
93
  # @raise [ServerError] on 5xx responses
60
94
  #
61
95
  # @api private
62
- def post(path, body = {})
96
+ def post(path, body = {}, params: nil, accept: nil)
63
97
  normalized_path = path.sub(%r{^/}, "")
64
- response = connection.post(normalized_path) { |req| req.body = JSON.generate(body) }
98
+ response = perform_request do |conn|
99
+ conn.post(normalized_path) do |req|
100
+ req.params.update(params) if params
101
+ req.headers["Accept"] = accept if accept
102
+ req.body = JSON.generate(body)
103
+ end
104
+ end
65
105
  handle_response(response)
106
+ end
107
+
108
+ private
109
+
110
+ # Performs an HTTP request, mapping Faraday errors to gem errors.
111
+ #
112
+ # @yieldparam connection [Faraday::Connection] Connection to perform the request on
113
+ # @return [Faraday::Response] Raw Faraday response
114
+ #
115
+ # @raise [TimeoutError] if request times out
116
+ # @raise [ConnectionError] if connection fails
117
+ # @raise [NetworkError] for other network errors
118
+ # @api private
119
+ def perform_request
120
+ yield connection
66
121
  rescue Faraday::TimeoutError => e
67
122
  raise TimeoutError, "Request timed out: #{e.message}"
68
123
  rescue Faraday::ConnectionFailed => e
@@ -71,8 +126,6 @@ module ReveAI
71
126
  raise NetworkError, "Network error: #{e.message}"
72
127
  end
73
128
 
74
- private
75
-
76
129
  # Handles connection failed errors.
77
130
  #
78
131
  # Distinguishes between timeout errors (which may appear as connection failures)
@@ -116,7 +169,7 @@ module ReveAI
116
169
  # @api private
117
170
  def configure_retry(conn)
118
171
  conn.request :retry, max: configuration.max_retries, interval: 0.5,
119
- backoff_factor: 2, retry_statuses: RETRY_STATUSES, methods: [:post]
172
+ backoff_factor: 2, retry_statuses: RETRY_STATUSES, methods: %i[post get]
120
173
  end
121
174
 
122
175
  # Configures request headers.
@@ -162,7 +215,7 @@ module ReveAI
162
215
  # @raise [APIError] on error responses
163
216
  # @api private
164
217
  def handle_response(response)
165
- body = parse_body(response.body)
218
+ body = parse_body(response)
166
219
  return build_success_response(response, body) if response.status.between?(200, 299)
167
220
 
168
221
  raise_api_error(response.status, body, response.headers.to_h)
@@ -171,37 +224,66 @@ module ReveAI
171
224
  # Builds a successful response object.
172
225
  #
173
226
  # @param response [Faraday::Response] Raw response
174
- # @param body [Hash] Parsed body
227
+ # @param body [Hash, String] Parsed body, or raw bytes for binary responses
175
228
  # @return [Response] Response wrapper
176
229
  # @api private
177
230
  def build_success_response(response, body)
178
231
  Response.new(status: response.status, headers: response.headers.to_h, body: body)
179
232
  end
180
233
 
181
- # Parses the response body as JSON.
234
+ # Parses the response body.
235
+ #
236
+ # Bodies with an +image/*+ Content-Type are raw image bytes and are
237
+ # returned as-is; anything else is parsed as JSON.
182
238
  #
183
- # @param body [String, nil] Raw response body
184
- # @return [Hash] Parsed body, or empty hash if nil/empty
239
+ # @param response [Faraday::Response] Raw Faraday response
240
+ # @return [Hash, String] Parsed body, raw bytes String for image
241
+ # responses, or empty hash if body is nil/empty
185
242
  # @api private
186
- def parse_body(body)
187
- return {} if body.nil? || body.empty?
243
+ def parse_body(response)
244
+ raw = response.body
245
+ return {} if raw.nil? || raw.empty?
246
+ return raw if image_content?(response)
188
247
 
189
- JSON.parse(body, symbolize_names: true)
248
+ JSON.parse(raw, symbolize_names: true)
190
249
  rescue JSON::ParserError
191
- { raw: body }
250
+ { raw: raw }
251
+ end
252
+
253
+ # Checks whether the response carries raw image bytes.
254
+ #
255
+ # @param response [Faraday::Response] Raw Faraday response
256
+ # @return [Boolean] true if Content-Type starts with "image/"
257
+ # @api private
258
+ def image_content?(response)
259
+ response.headers["content-type"].to_s.start_with?("image/")
192
260
  end
193
261
 
194
262
  # Raises the appropriate API error for a status code.
195
263
  #
196
264
  # @param status [Integer] HTTP status code
197
- # @param body [Hash] Parsed response body
265
+ # @param body [Hash, String] Parsed response body
198
266
  # @param headers [Hash] Response headers
199
267
  # @raise [APIError] Appropriate error subclass
200
268
  # @api private
201
269
  def raise_api_error(status, body, headers)
202
270
  error_class = ERROR_CODE_MAP[status] || (status >= 500 ? ServerError : APIError)
203
- message = body[:message] || body[:error] || "Unknown error"
204
- raise error_class.new(message, status: status, body: body, headers: headers)
271
+ raise error_class.new(extract_error_message(body, headers), status: status, body: body, headers: headers)
272
+ end
273
+
274
+ # Extracts the error message from the response body.
275
+ #
276
+ # Falls back to the X-Reve-Error-Code header when the body carries no
277
+ # message: with an image Accept header, the API answers errors with a
278
+ # small grey image instead of a JSON error body.
279
+ #
280
+ # @param body [Hash, String] Parsed response body
281
+ # @param headers [Hash] Response headers
282
+ # @return [String] Error message
283
+ # @api private
284
+ def extract_error_message(body, headers)
285
+ from_body = body.is_a?(Hash) ? body[:message] || body[:error] : nil
286
+ from_body || headers["x-reve-error-code"] || "Unknown error"
205
287
  end
206
288
  end
207
289
  end