apertur-sdk 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +195 -0
- data/lib/apertur/client.rb +75 -0
- data/lib/apertur/crypto.rb +60 -0
- data/lib/apertur/errors.rb +55 -0
- data/lib/apertur/http_client.rb +195 -0
- data/lib/apertur/resources/destinations.rb +61 -0
- data/lib/apertur/resources/encryption.rb +22 -0
- data/lib/apertur/resources/keys.rb +61 -0
- data/lib/apertur/resources/polling.rb +70 -0
- data/lib/apertur/resources/sessions.rb +112 -0
- data/lib/apertur/resources/stats.rb +20 -0
- data/lib/apertur/resources/upload.rb +98 -0
- data/lib/apertur/resources/uploads.rb +35 -0
- data/lib/apertur/resources/webhooks.rb +85 -0
- data/lib/apertur/signature.rb +79 -0
- data/lib/apertur/version.rb +5 -0
- data/lib/apertur.rb +34 -0
- metadata +68 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 43690cd24afff7146d8e06a8fa97981242ac044b9f681c594101f648f3771016
|
|
4
|
+
data.tar.gz: e9e4f5fd4d7097ae37a482fe44acf6277ea5bfe47abc730363ad86e1e4719aa2
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 242ce180cfb7061f9e232bfafb3c469fd4c7bb47b0ddf194150d97a17f734f25b224ece23e3cff1ee21b15bdc6eb04572a65b8a0e1de740748f8194a0520a4fb
|
|
7
|
+
data.tar.gz: a672905ff810166a17076d8db8df271002489399b08ed56f9b156135f7550f2d1493bf8c78ee2c49f401fd519ce30c671d6af358485bb085c9b604d1b1238388
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Apertur
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Apertur Ruby SDK
|
|
2
|
+
|
|
3
|
+
Official Ruby client for the [Apertur](https://apertur.ca) image upload and delivery API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "apertur-sdk"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or install directly:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
gem install apertur-sdk
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require "apertur"
|
|
23
|
+
|
|
24
|
+
client = Apertur::Client.new(api_key: "aptr_test_your_key_here")
|
|
25
|
+
|
|
26
|
+
# Create an upload session
|
|
27
|
+
session = client.sessions.create(max_images: 10)
|
|
28
|
+
puts session["uuid"]
|
|
29
|
+
|
|
30
|
+
# Upload an image
|
|
31
|
+
result = client.upload.image(session["uuid"], "/path/to/photo.jpg")
|
|
32
|
+
|
|
33
|
+
# Upload with encryption
|
|
34
|
+
server_key = client.encryption.get_server_key
|
|
35
|
+
client.upload.image_encrypted(
|
|
36
|
+
session["uuid"],
|
|
37
|
+
"/path/to/photo.jpg",
|
|
38
|
+
server_key["publicKey"]
|
|
39
|
+
)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Authentication
|
|
43
|
+
|
|
44
|
+
The SDK accepts an API key (prefixed `aptr_` or `aptr_test_`) or an OAuth token. The environment is auto-detected from the key prefix:
|
|
45
|
+
|
|
46
|
+
- `aptr_test_*` keys target the sandbox at `https://sandbox.api.aptr.ca`
|
|
47
|
+
- `aptr_*` keys target production at `https://api.aptr.ca`
|
|
48
|
+
|
|
49
|
+
You can override the base URL:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
client = Apertur::Client.new(api_key: "aptr_...", base_url: "http://localhost:3000")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Resources
|
|
56
|
+
|
|
57
|
+
### Sessions
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
client.sessions.create(max_images: 5, expires_in_hours: 24)
|
|
61
|
+
client.sessions.get("uuid")
|
|
62
|
+
client.sessions.update("uuid", max_images: 10)
|
|
63
|
+
client.sessions.list(page: 1, page_size: 20)
|
|
64
|
+
client.sessions.recent(limit: 5)
|
|
65
|
+
client.sessions.qr("uuid", format: "png", size: 300)
|
|
66
|
+
client.sessions.verify_password("uuid", "secret")
|
|
67
|
+
client.sessions.delivery_status("uuid")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Upload
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# Multipart upload from file path
|
|
74
|
+
client.upload.image("uuid", "/path/to/image.jpg")
|
|
75
|
+
|
|
76
|
+
# Upload from IO
|
|
77
|
+
File.open("photo.png", "rb") do |f|
|
|
78
|
+
client.upload.image("uuid", f, filename: "photo.png", mime_type: "image/png")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Encrypted upload
|
|
82
|
+
client.upload.image_encrypted("uuid", "/path/to/image.jpg", public_key_pem)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Uploads
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
client.uploads.list(page: 1, page_size: 20)
|
|
89
|
+
client.uploads.recent(limit: 10)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Polling
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
# One-shot poll
|
|
96
|
+
result = client.polling.list("uuid")
|
|
97
|
+
data = client.polling.download("uuid", image_id)
|
|
98
|
+
client.polling.ack("uuid", image_id)
|
|
99
|
+
|
|
100
|
+
# Blocking loop
|
|
101
|
+
client.polling.poll_and_process("uuid", interval: 3) do |image, data|
|
|
102
|
+
File.binwrite("downloads/#{image['id']}.jpg", data)
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Destinations
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
client.destinations.list("project_id")
|
|
110
|
+
client.destinations.create("project_id", type: "s3", bucket: "my-bucket")
|
|
111
|
+
client.destinations.update("project_id", "dest_id", bucket: "other-bucket")
|
|
112
|
+
client.destinations.delete("project_id", "dest_id")
|
|
113
|
+
client.destinations.test("project_id", "dest_id")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Keys
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
client.keys.list("project_id")
|
|
120
|
+
client.keys.create("project_id", name: "My Key")
|
|
121
|
+
client.keys.update("project_id", "key_id", name: "Renamed Key")
|
|
122
|
+
client.keys.delete("project_id", "key_id")
|
|
123
|
+
client.keys.set_destinations("key_id", ["dest_1", "dest_2"], long_polling_enabled: true)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Webhooks
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
client.webhooks.list("project_id")
|
|
130
|
+
client.webhooks.create("project_id", url: "https://example.com/hook", events: ["upload.completed"])
|
|
131
|
+
client.webhooks.update("project_id", "webhook_id", url: "https://example.com/hook2")
|
|
132
|
+
client.webhooks.delete("project_id", "webhook_id")
|
|
133
|
+
client.webhooks.test("project_id", "webhook_id")
|
|
134
|
+
client.webhooks.deliveries("project_id", "webhook_id", page: 1, limit: 20)
|
|
135
|
+
client.webhooks.retry_delivery("project_id", "webhook_id", "delivery_id")
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Encryption
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
server_key = client.encryption.get_server_key
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Stats
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
stats = client.stats.get
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Webhook Signature Verification
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
# Simple webhook
|
|
154
|
+
Apertur::Signature.verify_webhook(request_body, signature_header, secret)
|
|
155
|
+
|
|
156
|
+
# Event webhook with timestamp
|
|
157
|
+
Apertur::Signature.verify_event(request_body, timestamp_header, signature_header, secret)
|
|
158
|
+
|
|
159
|
+
# Svix-style webhook
|
|
160
|
+
Apertur::Signature.verify_svix(request_body, svix_id, svix_timestamp, svix_signature, secret)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Client-Side Encryption
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
encrypted = Apertur::Crypto.encrypt_image(raw_bytes, public_key_pem)
|
|
167
|
+
# => { "encrypted_key" => "...", "iv" => "...", "encrypted_data" => "...", "algorithm" => "RSA-OAEP+AES-256-GCM" }
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Error Handling
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
begin
|
|
174
|
+
client.sessions.get("nonexistent")
|
|
175
|
+
rescue Apertur::NotFoundError => e
|
|
176
|
+
puts "Not found: #{e.message}"
|
|
177
|
+
rescue Apertur::AuthenticationError => e
|
|
178
|
+
puts "Auth failed: #{e.message}"
|
|
179
|
+
rescue Apertur::RateLimitError => e
|
|
180
|
+
puts "Rate limited, retry after #{e.retry_after}s"
|
|
181
|
+
rescue Apertur::ValidationError => e
|
|
182
|
+
puts "Invalid request: #{e.message}"
|
|
183
|
+
rescue Apertur::Error => e
|
|
184
|
+
puts "API error #{e.status_code}: #{e.message}"
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Requirements
|
|
189
|
+
|
|
190
|
+
- Ruby >= 3.0
|
|
191
|
+
- No external dependencies (uses `net/http`, `json`, and `openssl` from stdlib)
|
|
192
|
+
|
|
193
|
+
## License
|
|
194
|
+
|
|
195
|
+
MIT
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Apertur
|
|
4
|
+
# Main client for the Apertur API.
|
|
5
|
+
#
|
|
6
|
+
# Provides access to all API resources through lazily initialized accessors.
|
|
7
|
+
# Automatically detects the environment (live vs. sandbox) from the API key
|
|
8
|
+
# prefix and selects the appropriate base URL.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# client = Apertur::Client.new(api_key: "aptr_test_abc123")
|
|
12
|
+
# session = client.sessions.create(max_images: 5)
|
|
13
|
+
# puts session["uuid"]
|
|
14
|
+
class Client
|
|
15
|
+
DEFAULT_BASE_URL = "https://api.aptr.ca"
|
|
16
|
+
SANDBOX_BASE_URL = "https://sandbox.api.aptr.ca"
|
|
17
|
+
|
|
18
|
+
# @return [String] the environment this client targets ("live" or "test")
|
|
19
|
+
attr_reader :env
|
|
20
|
+
|
|
21
|
+
# @return [Apertur::Resources::Sessions]
|
|
22
|
+
attr_reader :sessions
|
|
23
|
+
|
|
24
|
+
# @return [Apertur::Resources::Upload]
|
|
25
|
+
attr_reader :upload
|
|
26
|
+
|
|
27
|
+
# @return [Apertur::Resources::Uploads]
|
|
28
|
+
attr_reader :uploads
|
|
29
|
+
|
|
30
|
+
# @return [Apertur::Resources::Polling]
|
|
31
|
+
attr_reader :polling
|
|
32
|
+
|
|
33
|
+
# @return [Apertur::Resources::Destinations]
|
|
34
|
+
attr_reader :destinations
|
|
35
|
+
|
|
36
|
+
# @return [Apertur::Resources::Keys]
|
|
37
|
+
attr_reader :keys
|
|
38
|
+
|
|
39
|
+
# @return [Apertur::Resources::Webhooks]
|
|
40
|
+
attr_reader :webhooks
|
|
41
|
+
|
|
42
|
+
# @return [Apertur::Resources::Encryption]
|
|
43
|
+
attr_reader :encryption
|
|
44
|
+
|
|
45
|
+
# @return [Apertur::Resources::Stats]
|
|
46
|
+
attr_reader :stats
|
|
47
|
+
|
|
48
|
+
# Create a new Apertur API client.
|
|
49
|
+
#
|
|
50
|
+
# @param api_key [String, nil] an API key (prefixed +aptr_+ or +aptr_test_+)
|
|
51
|
+
# @param oauth_token [String, nil] an OAuth bearer token (alternative to api_key)
|
|
52
|
+
# @param base_url [String, nil] override the base URL; auto-detected from the
|
|
53
|
+
# key prefix when nil
|
|
54
|
+
# @raise [ArgumentError] if neither +api_key+ nor +oauth_token+ is provided
|
|
55
|
+
def initialize(api_key: nil, oauth_token: nil, base_url: nil)
|
|
56
|
+
token = api_key || oauth_token
|
|
57
|
+
raise ArgumentError, "Either api_key or oauth_token must be provided" if token.nil? || token.empty?
|
|
58
|
+
|
|
59
|
+
@env = token.start_with?("aptr_test_") ? "test" : "live"
|
|
60
|
+
|
|
61
|
+
resolved_url = base_url || (@env == "test" ? SANDBOX_BASE_URL : DEFAULT_BASE_URL)
|
|
62
|
+
http = HttpClient.new(resolved_url, token)
|
|
63
|
+
|
|
64
|
+
@sessions = Resources::Sessions.new(http)
|
|
65
|
+
@upload = Resources::Upload.new(http)
|
|
66
|
+
@uploads = Resources::Uploads.new(http)
|
|
67
|
+
@polling = Resources::Polling.new(http)
|
|
68
|
+
@destinations = Resources::Destinations.new(http)
|
|
69
|
+
@keys = Resources::Keys.new(http)
|
|
70
|
+
@webhooks = Resources::Webhooks.new(http)
|
|
71
|
+
@encryption = Resources::Encryption.new(http)
|
|
72
|
+
@stats = Resources::Stats.new(http)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Apertur
|
|
8
|
+
# Image encryption utilities for client-side encryption before upload.
|
|
9
|
+
#
|
|
10
|
+
# Uses AES-256-GCM for symmetric encryption and RSA-OAEP (SHA-256) to
|
|
11
|
+
# wrap the AES key with the server's public key.
|
|
12
|
+
module Crypto
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Encrypt image data for secure upload.
|
|
16
|
+
#
|
|
17
|
+
# Generates a random AES-256-GCM key and IV, encrypts the image data,
|
|
18
|
+
# then wraps the AES key with the provided RSA public key using OAEP
|
|
19
|
+
# padding with SHA-256.
|
|
20
|
+
#
|
|
21
|
+
# @param image_data [String] raw image bytes
|
|
22
|
+
# @param public_key_pem [String] RSA public key in PEM format
|
|
23
|
+
# @return [Hash] a Hash with the following String keys:
|
|
24
|
+
# - +"encrypted_key"+ - Base64-encoded RSA-wrapped AES key
|
|
25
|
+
# - +"iv"+ - Base64-encoded initialization vector
|
|
26
|
+
# - +"encrypted_data"+ - Base64-encoded ciphertext with appended GCM auth tag
|
|
27
|
+
# - +"algorithm"+ - the encryption algorithm identifier ("RSA-OAEP+AES-256-GCM")
|
|
28
|
+
def encrypt_image(image_data, public_key_pem)
|
|
29
|
+
# Generate random AES-256 key and 12-byte IV
|
|
30
|
+
aes_key = SecureRandom.random_bytes(32)
|
|
31
|
+
iv = SecureRandom.random_bytes(12)
|
|
32
|
+
|
|
33
|
+
# Encrypt image with AES-256-GCM
|
|
34
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
35
|
+
cipher.encrypt
|
|
36
|
+
cipher.key = aes_key
|
|
37
|
+
cipher.iv = iv
|
|
38
|
+
|
|
39
|
+
encrypted = cipher.update(image_data) + cipher.final
|
|
40
|
+
auth_tag = cipher.auth_tag
|
|
41
|
+
encrypted_with_tag = encrypted + auth_tag
|
|
42
|
+
|
|
43
|
+
# Wrap AES key with RSA-OAEP (SHA-256)
|
|
44
|
+
# Uses OpenSSL::PKey::PKey#encrypt (available in Ruby 3.0+) which
|
|
45
|
+
# allows specifying the OAEP digest algorithm.
|
|
46
|
+
pub_key = OpenSSL::PKey::RSA.new(public_key_pem)
|
|
47
|
+
wrapped_key = pub_key.encrypt(aes_key, {
|
|
48
|
+
"rsa_padding_mode" => "oaep",
|
|
49
|
+
"rsa_oaep_md" => "sha256"
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
"encrypted_key" => Base64.strict_encode64(wrapped_key),
|
|
54
|
+
"iv" => Base64.strict_encode64(iv),
|
|
55
|
+
"encrypted_data" => Base64.strict_encode64(encrypted_with_tag),
|
|
56
|
+
"algorithm" => "RSA-OAEP+AES-256-GCM"
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Apertur
|
|
4
|
+
# Base error class for all Apertur API errors.
|
|
5
|
+
#
|
|
6
|
+
# @attr_reader [Integer, nil] status_code the HTTP status code
|
|
7
|
+
# @attr_reader [String, nil] code the error code returned by the API
|
|
8
|
+
class Error < StandardError
|
|
9
|
+
attr_reader :status_code, :code
|
|
10
|
+
|
|
11
|
+
# @param message [String] the error message
|
|
12
|
+
# @param status_code [Integer, nil] the HTTP status code
|
|
13
|
+
# @param code [String, nil] the API error code
|
|
14
|
+
def initialize(message, status_code: nil, code: nil)
|
|
15
|
+
super(message)
|
|
16
|
+
@status_code = status_code
|
|
17
|
+
@code = code
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Raised when the API returns a 401 Unauthorized response.
|
|
22
|
+
class AuthenticationError < Error
|
|
23
|
+
def initialize(message = "Authentication failed")
|
|
24
|
+
super(message, status_code: 401, code: "AUTHENTICATION_FAILED")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Raised when the API returns a 404 Not Found response.
|
|
29
|
+
class NotFoundError < Error
|
|
30
|
+
def initialize(message = "Not found")
|
|
31
|
+
super(message, status_code: 404, code: "NOT_FOUND")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Raised when the API returns a 429 Too Many Requests response.
|
|
36
|
+
#
|
|
37
|
+
# @attr_reader [Integer, nil] retry_after seconds to wait before retrying
|
|
38
|
+
class RateLimitError < Error
|
|
39
|
+
attr_reader :retry_after
|
|
40
|
+
|
|
41
|
+
# @param message [String] the error message
|
|
42
|
+
# @param retry_after [Integer, nil] seconds to wait before retrying
|
|
43
|
+
def initialize(message = "Rate limit exceeded", retry_after: nil)
|
|
44
|
+
super(message, status_code: 429, code: "RATE_LIMIT")
|
|
45
|
+
@retry_after = retry_after
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Raised when the API returns a 400 Bad Request response.
|
|
50
|
+
class ValidationError < Error
|
|
51
|
+
def initialize(message = "Validation failed")
|
|
52
|
+
super(message, status_code: 400, code: "VALIDATION_ERROR")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
module Apertur
|
|
9
|
+
# Low-level HTTP wrapper around Net::HTTP for communicating with the Apertur API.
|
|
10
|
+
#
|
|
11
|
+
# Handles JSON serialization, Bearer token authentication, multipart uploads,
|
|
12
|
+
# and error mapping.
|
|
13
|
+
class HttpClient
|
|
14
|
+
# @param base_url [String] the API base URL (e.g. "https://api.aptr.ca")
|
|
15
|
+
# @param token [String] the Bearer token (API key or OAuth token)
|
|
16
|
+
def initialize(base_url, token)
|
|
17
|
+
@base_url = base_url.chomp("/")
|
|
18
|
+
@token = token
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Perform an API request and return the parsed JSON response.
|
|
22
|
+
#
|
|
23
|
+
# @param method [Symbol] HTTP method (:get, :post, :patch, :put, :delete)
|
|
24
|
+
# @param path [String] the API path (e.g. "/api/v1/stats")
|
|
25
|
+
# @param body [Hash, nil] request body to be serialized as JSON
|
|
26
|
+
# @param query [Hash, nil] query parameters
|
|
27
|
+
# @param headers [Hash] additional request headers
|
|
28
|
+
# @return [Hash, Array, nil] parsed JSON response, or nil for 204
|
|
29
|
+
# @raise [Apertur::Error] on API errors
|
|
30
|
+
def request(method, path, body: nil, query: nil, headers: {})
|
|
31
|
+
uri = build_uri(path, query)
|
|
32
|
+
req = build_request(method, uri, headers)
|
|
33
|
+
|
|
34
|
+
if body
|
|
35
|
+
req["Content-Type"] = "application/json"
|
|
36
|
+
req.body = body.is_a?(String) ? body : JSON.generate(body)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
response = execute(uri, req)
|
|
40
|
+
handle_response(response)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Perform an API request and return the raw response body as a binary String.
|
|
44
|
+
#
|
|
45
|
+
# @param method [Symbol] HTTP method
|
|
46
|
+
# @param path [String] the API path
|
|
47
|
+
# @param query [Hash, nil] query parameters
|
|
48
|
+
# @return [String] raw response body (binary)
|
|
49
|
+
# @raise [Apertur::Error] on API errors
|
|
50
|
+
def request_raw(method, path, query: nil)
|
|
51
|
+
uri = build_uri(path, query)
|
|
52
|
+
req = build_request(method, uri)
|
|
53
|
+
|
|
54
|
+
response = execute(uri, req)
|
|
55
|
+
handle_error(response) unless response.is_a?(Net::HTTPSuccess)
|
|
56
|
+
response.body
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Perform a multipart/form-data upload request.
|
|
60
|
+
#
|
|
61
|
+
# @param path [String] the API path
|
|
62
|
+
# @param file_data [String] raw file bytes
|
|
63
|
+
# @param filename [String] the filename to use in the multipart part
|
|
64
|
+
# @param mime_type [String] the MIME type of the file
|
|
65
|
+
# @param fields [Hash] additional form fields
|
|
66
|
+
# @param headers [Hash] additional request headers
|
|
67
|
+
# @return [Hash, Array, nil] parsed JSON response
|
|
68
|
+
# @raise [Apertur::Error] on API errors
|
|
69
|
+
def request_multipart(path, file_data, filename:, mime_type:, fields: {}, headers: {})
|
|
70
|
+
uri = build_uri(path)
|
|
71
|
+
boundary = "AperturRubySDK#{SecureRandom.hex(16)}"
|
|
72
|
+
|
|
73
|
+
body = build_multipart_body(boundary, file_data, filename, mime_type, fields)
|
|
74
|
+
|
|
75
|
+
req = build_request(:post, uri, headers)
|
|
76
|
+
req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
|
77
|
+
req.body = body
|
|
78
|
+
|
|
79
|
+
response = execute(uri, req)
|
|
80
|
+
handle_response(response)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# @param path [String]
|
|
86
|
+
# @param query [Hash, nil]
|
|
87
|
+
# @return [URI]
|
|
88
|
+
def build_uri(path, query = nil)
|
|
89
|
+
url = "#{@base_url}#{path}"
|
|
90
|
+
if query && !query.empty?
|
|
91
|
+
params = query.reject { |_, v| v.nil? }.map { |k, v| "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}" }
|
|
92
|
+
url += "?#{params.join("&")}" unless params.empty?
|
|
93
|
+
end
|
|
94
|
+
URI.parse(url)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @param method [Symbol]
|
|
98
|
+
# @param uri [URI]
|
|
99
|
+
# @param extra_headers [Hash]
|
|
100
|
+
# @return [Net::HTTPRequest]
|
|
101
|
+
def build_request(method, uri, extra_headers = {})
|
|
102
|
+
klass = case method
|
|
103
|
+
when :get then Net::HTTP::Get
|
|
104
|
+
when :post then Net::HTTP::Post
|
|
105
|
+
when :patch then Net::HTTP::Patch
|
|
106
|
+
when :put then Net::HTTP::Put
|
|
107
|
+
when :delete then Net::HTTP::Delete
|
|
108
|
+
else raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
req = klass.new(uri)
|
|
112
|
+
req["Authorization"] = "Bearer #{@token}" if @token && !@token.empty?
|
|
113
|
+
req["User-Agent"] = "apertur-sdk-ruby/#{Apertur::VERSION}"
|
|
114
|
+
req["Accept"] = "application/json"
|
|
115
|
+
|
|
116
|
+
extra_headers.each { |k, v| req[k] = v }
|
|
117
|
+
req
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @param uri [URI]
|
|
121
|
+
# @param req [Net::HTTPRequest]
|
|
122
|
+
# @return [Net::HTTPResponse]
|
|
123
|
+
def execute(uri, req)
|
|
124
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
125
|
+
http.use_ssl = (uri.scheme == "https")
|
|
126
|
+
http.open_timeout = 30
|
|
127
|
+
http.read_timeout = 60
|
|
128
|
+
http.request(req)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @param response [Net::HTTPResponse]
|
|
132
|
+
# @return [Hash, Array, nil]
|
|
133
|
+
def handle_response(response)
|
|
134
|
+
handle_error(response) unless response.is_a?(Net::HTTPSuccess)
|
|
135
|
+
|
|
136
|
+
return nil if response.code == "204" || response.body.nil? || response.body.empty?
|
|
137
|
+
|
|
138
|
+
JSON.parse(response.body)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @param response [Net::HTTPResponse]
|
|
142
|
+
# @raise [Apertur::Error]
|
|
143
|
+
def handle_error(response)
|
|
144
|
+
body = begin
|
|
145
|
+
JSON.parse(response.body)
|
|
146
|
+
rescue StandardError
|
|
147
|
+
{}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
message = body["message"] || "HTTP #{response.code}"
|
|
151
|
+
code = body["code"]
|
|
152
|
+
|
|
153
|
+
case response.code.to_i
|
|
154
|
+
when 401
|
|
155
|
+
raise AuthenticationError, message
|
|
156
|
+
when 404
|
|
157
|
+
raise NotFoundError, message
|
|
158
|
+
when 429
|
|
159
|
+
retry_after = response["Retry-After"]&.to_i
|
|
160
|
+
raise RateLimitError.new(message, retry_after: retry_after)
|
|
161
|
+
when 400
|
|
162
|
+
raise ValidationError, message
|
|
163
|
+
else
|
|
164
|
+
raise Error.new(message, status_code: response.code.to_i, code: code)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# @param boundary [String]
|
|
169
|
+
# @param file_data [String]
|
|
170
|
+
# @param filename [String]
|
|
171
|
+
# @param mime_type [String]
|
|
172
|
+
# @param fields [Hash]
|
|
173
|
+
# @return [String]
|
|
174
|
+
def build_multipart_body(boundary, file_data, filename, mime_type, fields)
|
|
175
|
+
parts = []
|
|
176
|
+
|
|
177
|
+
fields.each do |key, value|
|
|
178
|
+
parts << "--#{boundary}\r\n" \
|
|
179
|
+
"Content-Disposition: form-data; name=\"#{key}\"\r\n" \
|
|
180
|
+
"\r\n" \
|
|
181
|
+
"#{value}\r\n"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
parts << "--#{boundary}\r\n" \
|
|
185
|
+
"Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n" \
|
|
186
|
+
"Content-Type: #{mime_type}\r\n" \
|
|
187
|
+
"\r\n"
|
|
188
|
+
|
|
189
|
+
body = parts.join.b
|
|
190
|
+
body << file_data.b
|
|
191
|
+
body << "\r\n--#{boundary}--\r\n".b
|
|
192
|
+
body
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Apertur
|
|
4
|
+
module Resources
|
|
5
|
+
# Manage delivery destinations within a project.
|
|
6
|
+
#
|
|
7
|
+
# Destinations define where uploaded images are delivered (e.g. cloud
|
|
8
|
+
# storage buckets, webhooks, etc.).
|
|
9
|
+
class Destinations
|
|
10
|
+
# @param http [Apertur::HttpClient]
|
|
11
|
+
def initialize(http)
|
|
12
|
+
@http = http
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# List all destinations for a project.
|
|
16
|
+
#
|
|
17
|
+
# @param project_id [String] the project ID
|
|
18
|
+
# @return [Array<Hash>] list of destinations
|
|
19
|
+
def list(project_id)
|
|
20
|
+
@http.request(:get, "/api/v1/projects/#{project_id}/destinations")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Create a new destination.
|
|
24
|
+
#
|
|
25
|
+
# @param project_id [String] the project ID
|
|
26
|
+
# @param config [Hash] destination configuration
|
|
27
|
+
# @return [Hash] the created destination
|
|
28
|
+
def create(project_id, **config)
|
|
29
|
+
@http.request(:post, "/api/v1/projects/#{project_id}/destinations", body: config)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Update an existing destination.
|
|
33
|
+
#
|
|
34
|
+
# @param project_id [String] the project ID
|
|
35
|
+
# @param dest_id [String] the destination ID
|
|
36
|
+
# @param config [Hash] fields to update
|
|
37
|
+
# @return [Hash] the updated destination
|
|
38
|
+
def update(project_id, dest_id, **config)
|
|
39
|
+
@http.request(:patch, "/api/v1/projects/#{project_id}/destinations/#{dest_id}", body: config)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Delete a destination.
|
|
43
|
+
#
|
|
44
|
+
# @param project_id [String] the project ID
|
|
45
|
+
# @param dest_id [String] the destination ID
|
|
46
|
+
# @return [nil]
|
|
47
|
+
def delete(project_id, dest_id)
|
|
48
|
+
@http.request(:delete, "/api/v1/projects/#{project_id}/destinations/#{dest_id}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Send a test payload to a destination.
|
|
52
|
+
#
|
|
53
|
+
# @param project_id [String] the project ID
|
|
54
|
+
# @param dest_id [String] the destination ID
|
|
55
|
+
# @return [Hash] test result
|
|
56
|
+
def test(project_id, dest_id)
|
|
57
|
+
@http.request(:post, "/api/v1/projects/#{project_id}/destinations/#{dest_id}/test")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Apertur
|
|
4
|
+
module Resources
|
|
5
|
+
# Retrieve server-side encryption keys.
|
|
6
|
+
class Encryption
|
|
7
|
+
# @param http [Apertur::HttpClient]
|
|
8
|
+
def initialize(http)
|
|
9
|
+
@http = http
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Get the server's public encryption key.
|
|
13
|
+
#
|
|
14
|
+
# The returned key is used for client-side image encryption before upload.
|
|
15
|
+
#
|
|
16
|
+
# @return [Hash] server key details including the PEM-encoded public key
|
|
17
|
+
def get_server_key
|
|
18
|
+
@http.request(:get, "/api/v1/encryption/server-key")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|