fopost 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/CHANGELOG.md +18 -0
- data/LICENSE +21 -0
- data/README.md +250 -0
- data/lib/fopost/client.rb +63 -0
- data/lib/fopost/errors.rb +105 -0
- data/lib/fopost/http/client.rb +149 -0
- data/lib/fopost/http/net_http_transport.rb +55 -0
- data/lib/fopost/http/response.rb +24 -0
- data/lib/fopost/http/transport.rb +21 -0
- data/lib/fopost/model.rb +101 -0
- data/lib/fopost/models.rb +208 -0
- data/lib/fopost/platforms.rb +49 -0
- data/lib/fopost/resources/accounts.rb +23 -0
- data/lib/fopost/resources/ai.rb +52 -0
- data/lib/fopost/resources/base.rb +49 -0
- data/lib/fopost/resources/labels.rb +12 -0
- data/lib/fopost/resources/posts.rb +185 -0
- data/lib/fopost/resources/workspaces.rb +16 -0
- data/lib/fopost/unset.rb +10 -0
- data/lib/fopost/version.rb +5 -0
- data/lib/fopost.rb +36 -0
- metadata +67 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1845a9113bbfdb41688fd430794d43749707b835d82e2f0a4df826df43b4f1da
|
|
4
|
+
data.tar.gz: '08c64a3e1385d13789b22b5924f8672bcffbcf89dd524da03e8f3b94806fefc3'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d151dc7b437cfeb9e3369c3455f5534c8a2a599a17084eb5b6cc5ba77d5bc2d53ffdf887abba1eb33836e04b34d9d60b04f9b7958c9263e2882056137d07d1d9
|
|
7
|
+
data.tar.gz: 05f02730b6ab206ae44dbcaee5910253e10a28f8f6fd2eaa2f9f0d6ecae7c44e598716a4eff08e9f77565c528070dc9a8f68ea4075b59a7f5da93cf3af09487c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this gem are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the gem follows
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-08-30
|
|
8
|
+
|
|
9
|
+
Initial release.
|
|
10
|
+
|
|
11
|
+
- `Fopost::Client` with `posts`, `accounts`, `workspaces`, `labels`, and `ai` resources.
|
|
12
|
+
- Defaults to the documented `https://api.fopost.com/v1` base URL.
|
|
13
|
+
- Automatic retry on `429`, honouring `Retry-After`.
|
|
14
|
+
- Typed error classes per status, all rescuable as `Fopost::Error`.
|
|
15
|
+
- Response models that accept either wire casing and keep unknown fields on `#raw`.
|
|
16
|
+
- A pluggable transport, so the HTTP stack can be swapped or stubbed.
|
|
17
|
+
|
|
18
|
+
[0.1.0]: https://github.com/fopost/fopost-ruby/releases/tag/v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Porter Bridge, LLC
|
|
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,250 @@
|
|
|
1
|
+
# FoPost Ruby SDK
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/fopost)
|
|
4
|
+
[](https://rubygems.org/gems/fopost)
|
|
5
|
+
[](https://github.com/fopost/fopost-ruby/actions)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
The official Ruby SDK for the [FoPost](https://fopost.com) API. Connect social accounts once, then compose, schedule, and publish to +30 platforms from your own application.
|
|
9
|
+
|
|
10
|
+
Requires Ruby 3.1 or newer. No runtime dependencies: the gem talks over `net/http` from the standard library, so it drops into any app without a version conflict.
|
|
11
|
+
|
|
12
|
+
> **0.x release.** The public API is still settling and minor versions may contain breaking changes. Pin an exact version if that matters to you.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bundle add fopost
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or without Bundler:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
gem install fopost
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Get an API key
|
|
27
|
+
|
|
28
|
+
Create a key at [app.fopost.com/api-keys](https://app.fopost.com/api-keys). The full API reference lives at [fopost.com/docs](https://fopost.com/docs).
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
require 'fopost'
|
|
34
|
+
|
|
35
|
+
client = Fopost.new(api_key: 'fp_...') # or set FOPOST_API_KEY
|
|
36
|
+
|
|
37
|
+
workspace = client.workspaces.list.first
|
|
38
|
+
accounts = client.accounts.list(workspace_id: workspace.id)
|
|
39
|
+
|
|
40
|
+
post = client.posts.create(
|
|
41
|
+
workspace_id: workspace.id,
|
|
42
|
+
content: 'Hello from Ruby',
|
|
43
|
+
accounts: accounts.map(&:id)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
client.posts.publish(post.id)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`content` takes a string for a single block, or an array for a thread:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
client.posts.create(
|
|
53
|
+
workspace_id: workspace.id,
|
|
54
|
+
content: [
|
|
55
|
+
'First post in the thread',
|
|
56
|
+
{
|
|
57
|
+
'text' => 'Second one, with an image',
|
|
58
|
+
'media' => [{ 'type' => 'image', 'name' => 'chart.png', 'url' => 'https://.../chart.png' }]
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
accounts: accounts.map(&:id)
|
|
62
|
+
)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`accounts` takes account ids, the `Fopost::SocialAccount` objects themselves, or hashes with an `id`.
|
|
66
|
+
|
|
67
|
+
## Scheduling
|
|
68
|
+
|
|
69
|
+
`status` is `"draft"` or `"scheduled"`; a scheduled post needs `schedule_at`. To send something out now, create it and call `publish`.
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
client.posts.create(
|
|
73
|
+
workspace_id: workspace.id,
|
|
74
|
+
status: 'scheduled',
|
|
75
|
+
schedule_at: Time.utc(2026, 9, 1, 10, 0),
|
|
76
|
+
content: 'Scheduled with the SDK',
|
|
77
|
+
accounts: [accounts.first.id]
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`schedule_at` accepts a `Time`, a `DateTime`, or an ISO 8601 string.
|
|
82
|
+
|
|
83
|
+
## Posts
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
client.posts.get(post_id)
|
|
87
|
+
client.posts.update(post_id, title: 'Renamed') # partial: only what you pass is sent
|
|
88
|
+
client.posts.delete(post_id)
|
|
89
|
+
|
|
90
|
+
client.posts.publish(post_id) # queue delivery to every targeted account
|
|
91
|
+
client.posts.cancel(post_id) # cancel what has not gone out yet
|
|
92
|
+
client.posts.retry(post_id) # retry only the deliveries that failed
|
|
93
|
+
client.posts.preflight(post_id) # per-account blockers, without publishing
|
|
94
|
+
|
|
95
|
+
client.posts.deliveries(post_id).each { |d| puts "#{d.platform}: #{d.status}" }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`update` is a partial update, so passing `nil` clears a field and leaving an argument out leaves it alone:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
client.posts.update(post_id, schedule_at: nil) # sends {"schedule_at": null}
|
|
102
|
+
client.posts.update(post_id, title: 'Renamed') # sends {"title": "Renamed"}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Pagination
|
|
106
|
+
|
|
107
|
+
`posts.list` returns one page, which is `Enumerable` over its items. `posts.each` walks every page for you.
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
page = client.posts.list(workspace_id: workspace.id, status: 'published', per_page: 50)
|
|
111
|
+
puts "#{page.meta.total} published posts"
|
|
112
|
+
page.each { |post| puts "#{post.id} #{post.status}" }
|
|
113
|
+
|
|
114
|
+
# Every post, one page fetched at a time
|
|
115
|
+
client.posts.each(workspace_id: workspace.id) { |post| puts post.id }
|
|
116
|
+
|
|
117
|
+
# Or page by page, when you want the meta
|
|
118
|
+
client.posts.each_page(workspace_id: workspace.id) do |p|
|
|
119
|
+
puts "#{p.meta.current_page} (#{p.size} posts)"
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Both walkers return an `Enumerator` when called without a block, so `client.posts.each(...).lazy.first(10)` works.
|
|
124
|
+
|
|
125
|
+
## AI features
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
balance = client.ai.credits
|
|
129
|
+
puts "#{balance.credits_remaining} of #{balance.credits_total} credits left"
|
|
130
|
+
|
|
131
|
+
result = client.ai.generate_caption(
|
|
132
|
+
current_caption: 'shipping a new feature',
|
|
133
|
+
platforms: %w[twitter linkedin]
|
|
134
|
+
)
|
|
135
|
+
puts result.caption
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`rewrite` and `repurpose_url` are wired the same way:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
rewrites = client.ai.rewrite(
|
|
142
|
+
content: 'Long article-style draft...',
|
|
143
|
+
platforms: %w[twitter linkedin bluesky]
|
|
144
|
+
)
|
|
145
|
+
rewrites.results.each { |variant| puts "#{variant.platform}: #{variant.content}" }
|
|
146
|
+
|
|
147
|
+
repurposed = client.ai.repurpose_url(
|
|
148
|
+
url: 'https://example.com/blog/post',
|
|
149
|
+
platforms: %w[twitter linkedin bluesky threads]
|
|
150
|
+
)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
> **API keys reach `credits` and `generate_caption`.** `rewrite` and `repurpose_url` currently require a signed-in dashboard session and answer `401` to an API key. They are here so the surface is complete once the server opens them up.
|
|
154
|
+
|
|
155
|
+
## Errors
|
|
156
|
+
|
|
157
|
+
Every non-2xx response raises. All of them are rescuable as `Fopost::Error`.
|
|
158
|
+
|
|
159
|
+
| Status | Class |
|
|
160
|
+
| ------- | -------------------------------- |
|
|
161
|
+
| 400/422 | `Fopost::ValidationError` |
|
|
162
|
+
| 401 | `Fopost::AuthenticationError` |
|
|
163
|
+
| 402 | `Fopost::PaymentRequiredError` |
|
|
164
|
+
| 403 | `Fopost::PermissionDeniedError` |
|
|
165
|
+
| 404 | `Fopost::NotFoundError` |
|
|
166
|
+
| 429 | `Fopost::RateLimitError` |
|
|
167
|
+
| other | `Fopost::Error` |
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
begin
|
|
171
|
+
client.posts.publish(post_id)
|
|
172
|
+
rescue Fopost::PaymentRequiredError => e
|
|
173
|
+
warn "Out of credits — upgrade at #{e.upgrade_url}"
|
|
174
|
+
rescue Fopost::Error => e
|
|
175
|
+
warn "#{e.status} #{e.code}: #{e.message}"
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`message` is what the API said; `to_s` adds the status and code, so an uncaught error still reports both. `e.body` holds the decoded response, and `Fopost::ValidationError#errors` carries per-field messages when the API sends them.
|
|
180
|
+
|
|
181
|
+
## Configuration
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
Fopost.new(
|
|
185
|
+
api_key: 'fp_...', # or FOPOST_API_KEY
|
|
186
|
+
base_url: 'https://api.fopost.com/v1', # override for a dev server
|
|
187
|
+
timeout: 30.0, # seconds
|
|
188
|
+
max_retries: 3, # total attempts on a 429
|
|
189
|
+
transport: MyTransport.new # bring your own HTTP stack
|
|
190
|
+
)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
| Env var | Used for |
|
|
194
|
+
| ---------------- | ------------------------------------------- |
|
|
195
|
+
| `FOPOST_API_KEY` | API key, when not passed to the constructor |
|
|
196
|
+
|
|
197
|
+
A `429` is retried automatically, waiting for the interval the API asks for in `Retry-After` (delta-seconds or an HTTP date, capped at 60s). `max_retries` counts total attempts, so the default of 3 means two retries.
|
|
198
|
+
|
|
199
|
+
## Endpoints the SDK does not wrap yet
|
|
200
|
+
|
|
201
|
+
`client.request` reaches anything in the API, with the same auth, retries, and error handling:
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
client.request(:get, '/analytics/overview', params: { 'workspace_id' => workspace.id })
|
|
205
|
+
client.request(:post, '/webhooks', json: { 'url' => 'https://example.com/hook', 'events' => ['post.published'] })
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Swapping the HTTP stack
|
|
209
|
+
|
|
210
|
+
The default transport is `net/http`. Anything that responds to `call(method:, url:, headers:, body:)` and returns a `Fopost::HTTP::Response` can replace it — useful for a shared connection pool, custom instrumentation, or stubbing the network in tests.
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
class LoggingTransport
|
|
214
|
+
include Fopost::HTTP::Transport
|
|
215
|
+
|
|
216
|
+
def initialize(inner) = @inner = inner
|
|
217
|
+
|
|
218
|
+
def call(method:, url:, headers:, body:)
|
|
219
|
+
warn "#{method} #{url}"
|
|
220
|
+
@inner.call(method: method, url: url, headers: headers, body: body)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
client = Fopost.new(transport: LoggingTransport.new(Fopost::HTTP::NetHTTPTransport.new))
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Models
|
|
228
|
+
|
|
229
|
+
Responses come back as small model objects with snake_case readers. The API is not consistent about its wire casing — posts come back snake_case, accounts camelCase — so both spellings parse. Fields the SDK does not model yet stay reachable:
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
post.raw['someNewField'] # the decoded body, exactly as sent
|
|
233
|
+
post['some_new_field'] # by either spelling
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
That means a field added server-side never breaks an older client.
|
|
237
|
+
|
|
238
|
+
## Development
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
bundle install
|
|
242
|
+
bundle exec rake test
|
|
243
|
+
bundle exec rubocop
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Tests run against a stubbed transport, so nothing touches the network.
|
|
247
|
+
|
|
248
|
+
## License
|
|
249
|
+
|
|
250
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fopost/http/client'
|
|
4
|
+
require 'fopost/models'
|
|
5
|
+
require 'fopost/resources/base'
|
|
6
|
+
require 'fopost/resources/accounts'
|
|
7
|
+
require 'fopost/resources/ai'
|
|
8
|
+
require 'fopost/resources/labels'
|
|
9
|
+
require 'fopost/resources/posts'
|
|
10
|
+
require 'fopost/resources/workspaces'
|
|
11
|
+
|
|
12
|
+
module Fopost
|
|
13
|
+
# Client for the FoPost API.
|
|
14
|
+
#
|
|
15
|
+
# client = Fopost::Client.new(api_key: 'fp_...')
|
|
16
|
+
# accounts = client.accounts.list(workspace_id: '9b2f6c1e-...')
|
|
17
|
+
#
|
|
18
|
+
# The key falls back to the `FOPOST_API_KEY` environment variable. Requests
|
|
19
|
+
# that come back 429 are retried up to `max_retries` attempts, waiting for the
|
|
20
|
+
# interval the API asks for in `Retry-After`.
|
|
21
|
+
class Client
|
|
22
|
+
DEFAULT_BASE_URL = HTTP::Client::DEFAULT_BASE_URL
|
|
23
|
+
|
|
24
|
+
attr_reader :posts, :accounts, :workspaces, :labels, :ai
|
|
25
|
+
|
|
26
|
+
def initialize(api_key: nil, base_url: DEFAULT_BASE_URL, timeout: HTTP::Client::DEFAULT_TIMEOUT,
|
|
27
|
+
max_retries: HTTP::Client::DEFAULT_MAX_RETRIES, transport: nil, sleeper: nil)
|
|
28
|
+
key = api_key || ENV['FOPOST_API_KEY']
|
|
29
|
+
if key.nil? || key.to_s.empty?
|
|
30
|
+
raise ConfigurationError,
|
|
31
|
+
'fopost: an api key is required — pass api_key: or set FOPOST_API_KEY'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@http = HTTP::Client.new(
|
|
35
|
+
api_key: key,
|
|
36
|
+
base_url: base_url,
|
|
37
|
+
timeout: timeout,
|
|
38
|
+
max_retries: max_retries,
|
|
39
|
+
transport: transport,
|
|
40
|
+
sleeper: sleeper
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@posts = Resources::Posts.new(@http)
|
|
44
|
+
@accounts = Resources::Accounts.new(@http)
|
|
45
|
+
@workspaces = Resources::Workspaces.new(@http)
|
|
46
|
+
@labels = Resources::Labels.new(@http)
|
|
47
|
+
@ai = Resources::Ai.new(@http)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def base_url
|
|
51
|
+
@http.base_url
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Call an endpoint the SDK does not wrap yet. Returns the decoded body.
|
|
55
|
+
def request(method, path, json: nil, params: nil)
|
|
56
|
+
@http.request(method, path, json: json, params: params)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def inspect
|
|
60
|
+
"#<Fopost::Client base_url=#{base_url.inspect}>"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
# Base class for every error the FoPost API returns.
|
|
5
|
+
#
|
|
6
|
+
# The API answers failures with an `{"error": "<code>", "message": "<human
|
|
7
|
+
# readable>"}` envelope, which maps onto {#code} and {#message}.
|
|
8
|
+
class Error < StandardError
|
|
9
|
+
attr_reader :status, :code, :body
|
|
10
|
+
|
|
11
|
+
# The message the API sent, undecorated. `to_s` adds the status and code,
|
|
12
|
+
# so an uncaught error still reports both.
|
|
13
|
+
attr_reader :message
|
|
14
|
+
|
|
15
|
+
def initialize(message, status:, code: nil, body: nil)
|
|
16
|
+
super(message)
|
|
17
|
+
@message = message
|
|
18
|
+
@status = status
|
|
19
|
+
@code = code
|
|
20
|
+
@body = body
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_s
|
|
24
|
+
suffix = code ? " (#{code})" : ''
|
|
25
|
+
"[#{status}#{suffix}] #{@message}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Raised before a request goes out, when the client is misconfigured.
|
|
30
|
+
class ConfigurationError < StandardError; end
|
|
31
|
+
|
|
32
|
+
# 400 or 422 — the request body did not pass validation.
|
|
33
|
+
class ValidationError < Error
|
|
34
|
+
# Per-field messages, when the API sends them.
|
|
35
|
+
def errors
|
|
36
|
+
value = body.is_a?(Hash) ? (body['errors'] || body[:errors]) : nil
|
|
37
|
+
value.is_a?(Hash) || value.is_a?(Array) ? value : nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# 401 — missing, invalid, or expired API key.
|
|
42
|
+
class AuthenticationError < Error; end
|
|
43
|
+
|
|
44
|
+
# 402 — no active subscription, or AI credits exhausted.
|
|
45
|
+
class PaymentRequiredError < Error
|
|
46
|
+
# The page the API suggests sending the user to.
|
|
47
|
+
def upgrade_url
|
|
48
|
+
value = body.is_a?(Hash) ? (body['upgrade_url'] || body[:upgrade_url]) : nil
|
|
49
|
+
value.is_a?(String) ? value : nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# 403 — the key is valid but lacks the scope or the workspace.
|
|
54
|
+
class PermissionDeniedError < Error; end
|
|
55
|
+
|
|
56
|
+
# 404 — no such resource, or it is outside the key's reach.
|
|
57
|
+
class NotFoundError < Error; end
|
|
58
|
+
|
|
59
|
+
# 429 — rate limit exceeded. {#retry_after} is in seconds when the API sends it.
|
|
60
|
+
class RateLimitError < Error
|
|
61
|
+
attr_reader :retry_after
|
|
62
|
+
|
|
63
|
+
def initialize(message, status:, code: nil, body: nil, retry_after: nil)
|
|
64
|
+
super(message, status: status, code: code, body: body)
|
|
65
|
+
@retry_after = retry_after
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Maps an HTTP status plus a decoded body onto the most specific error class.
|
|
70
|
+
module ErrorFactory
|
|
71
|
+
BY_STATUS = {
|
|
72
|
+
400 => ValidationError,
|
|
73
|
+
401 => AuthenticationError,
|
|
74
|
+
402 => PaymentRequiredError,
|
|
75
|
+
403 => PermissionDeniedError,
|
|
76
|
+
404 => NotFoundError,
|
|
77
|
+
422 => ValidationError,
|
|
78
|
+
429 => RateLimitError
|
|
79
|
+
}.freeze
|
|
80
|
+
|
|
81
|
+
def self.build(status, body, retry_after: nil)
|
|
82
|
+
code = nil
|
|
83
|
+
message = "HTTP #{status}"
|
|
84
|
+
|
|
85
|
+
if body.is_a?(Hash)
|
|
86
|
+
code = body['error'] if body['error'].is_a?(String)
|
|
87
|
+
if body['message'].is_a?(String) && !body['message'].empty?
|
|
88
|
+
message = body['message']
|
|
89
|
+
elsif code
|
|
90
|
+
message = code
|
|
91
|
+
end
|
|
92
|
+
elsif body.is_a?(String) && !body.strip.empty?
|
|
93
|
+
message = body.strip
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
klass = BY_STATUS.fetch(status, Error)
|
|
97
|
+
if klass == RateLimitError
|
|
98
|
+
return RateLimitError.new(message, status: status, code: code, body: body,
|
|
99
|
+
retry_after: retry_after)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
klass.new(message, status: status, code: code, body: body)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
require 'fopost/errors'
|
|
8
|
+
require 'fopost/version'
|
|
9
|
+
require 'fopost/http/net_http_transport'
|
|
10
|
+
|
|
11
|
+
module Fopost
|
|
12
|
+
module HTTP
|
|
13
|
+
# Internal transport wrapper: auth headers, JSON coding, envelope unwrap,
|
|
14
|
+
# and the 429 retry. One per {Fopost::Client}.
|
|
15
|
+
class Client
|
|
16
|
+
DEFAULT_BASE_URL = 'https://api.fopost.com/v1'
|
|
17
|
+
DEFAULT_TIMEOUT = 30.0
|
|
18
|
+
DEFAULT_MAX_RETRIES = 3
|
|
19
|
+
MAX_RETRY_WAIT = 60.0
|
|
20
|
+
|
|
21
|
+
USER_AGENT = "fopost-ruby/#{Fopost::VERSION}".freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :base_url, :max_retries
|
|
24
|
+
|
|
25
|
+
def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
|
|
26
|
+
max_retries: DEFAULT_MAX_RETRIES, transport: nil, sleeper: nil)
|
|
27
|
+
raise ConfigurationError, 'fopost: api_key is required' if api_key.nil? || api_key.to_s.empty?
|
|
28
|
+
raise ConfigurationError, 'fopost: max_retries must be at least 1' if max_retries < 1
|
|
29
|
+
|
|
30
|
+
@api_key = api_key
|
|
31
|
+
@base_url = base_url.to_s.sub(%r{/+\z}, '')
|
|
32
|
+
@max_retries = max_retries
|
|
33
|
+
@transport = transport || NetHTTPTransport.new(timeout: timeout)
|
|
34
|
+
# Indirected so tests can replace the wait without touching the real clock.
|
|
35
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def headers
|
|
39
|
+
{
|
|
40
|
+
'Accept' => 'application/json',
|
|
41
|
+
'Content-Type' => 'application/json',
|
|
42
|
+
'X-API-Key' => @api_key,
|
|
43
|
+
'User-Agent' => USER_AGENT
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Send a request, retrying on 429, and return the decoded body.
|
|
48
|
+
def request(method, path, json: nil, params: nil)
|
|
49
|
+
url = build_url(path, params)
|
|
50
|
+
body = json.nil? ? nil : JSON.generate(json)
|
|
51
|
+
|
|
52
|
+
attempt = 0
|
|
53
|
+
loop do
|
|
54
|
+
attempt += 1
|
|
55
|
+
response = @transport.call(method: method.to_s.upcase, url: url, headers: headers, body: body)
|
|
56
|
+
|
|
57
|
+
if response.status == 429 && attempt < @max_retries
|
|
58
|
+
wait = retry_after_seconds(response)
|
|
59
|
+
@sleeper.call([wait || 1.0, MAX_RETRY_WAIT].min)
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
return decode(response)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def get(path, params = nil)
|
|
68
|
+
request(:get, path, params: params)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def post(path, json = nil)
|
|
72
|
+
request(:post, path, json: json)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def put(path, json = nil)
|
|
76
|
+
request(:put, path, json: json)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def delete(path, json = nil)
|
|
80
|
+
request(:delete, path, json: json)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Peel the `{"data": ...}` envelope the API wraps most responses in. Some
|
|
84
|
+
# endpoints (POST /posts, GET /posts/:id) return the resource bare, so the
|
|
85
|
+
# envelope comes off only when it is actually there.
|
|
86
|
+
def self.unwrap(body)
|
|
87
|
+
body.is_a?(Hash) && body.key?('data') ? body['data'] : body
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def build_url(path, params)
|
|
93
|
+
uri = URI.parse(path.to_s)
|
|
94
|
+
uri = URI.parse("#{@base_url}/#{path.to_s.sub(%r{\A/+}, '')}") if uri.scheme.nil?
|
|
95
|
+
|
|
96
|
+
query = (params || {}).compact
|
|
97
|
+
unless query.empty?
|
|
98
|
+
existing = uri.query.to_s
|
|
99
|
+
encoded = URI.encode_www_form(query.map { |k, v| [k.to_s, v.to_s] })
|
|
100
|
+
uri.query = existing.empty? ? encoded : "#{existing}&#{encoded}"
|
|
101
|
+
end
|
|
102
|
+
uri
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def decode(response)
|
|
106
|
+
body = decode_body(response)
|
|
107
|
+
|
|
108
|
+
if response.success?
|
|
109
|
+
if body.is_a?(String)
|
|
110
|
+
content_type = response.header('content-type')
|
|
111
|
+
raise Error.new(
|
|
112
|
+
"Expected a JSON response, got #{content_type || 'no content type'}",
|
|
113
|
+
status: response.status,
|
|
114
|
+
body: body
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
return body
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
raise ErrorFactory.build(response.status, body, retry_after: retry_after_seconds(response))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def decode_body(response)
|
|
124
|
+
return nil if response.status == 204 || response.body.nil? || response.body.empty?
|
|
125
|
+
|
|
126
|
+
JSON.parse(response.body)
|
|
127
|
+
rescue JSON::ParserError
|
|
128
|
+
response.body
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Retry-After is either delta-seconds or an HTTP date.
|
|
132
|
+
def retry_after_seconds(response)
|
|
133
|
+
raw = response.header('retry-after')
|
|
134
|
+
return nil if raw.nil?
|
|
135
|
+
|
|
136
|
+
raw = raw.to_s.strip
|
|
137
|
+
return nil if raw.empty?
|
|
138
|
+
|
|
139
|
+
seconds = Float(raw, exception: false)
|
|
140
|
+
return [0.0, seconds].max if seconds
|
|
141
|
+
|
|
142
|
+
target = Time.httpdate(raw) rescue (Time.parse(raw) rescue nil)
|
|
143
|
+
return nil unless target
|
|
144
|
+
|
|
145
|
+
[0.0, target.to_f - Time.now.to_f].max
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'fopost/http/response'
|
|
6
|
+
require 'fopost/http/transport'
|
|
7
|
+
|
|
8
|
+
module Fopost
|
|
9
|
+
module HTTP
|
|
10
|
+
# The default transport: Ruby's own net/http, so the gem needs no
|
|
11
|
+
# dependency of its own.
|
|
12
|
+
class NetHTTPTransport
|
|
13
|
+
include Transport
|
|
14
|
+
|
|
15
|
+
METHODS = {
|
|
16
|
+
'GET' => Net::HTTP::Get,
|
|
17
|
+
'POST' => Net::HTTP::Post,
|
|
18
|
+
'PUT' => Net::HTTP::Put,
|
|
19
|
+
'PATCH' => Net::HTTP::Patch,
|
|
20
|
+
'DELETE' => Net::HTTP::Delete
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def initialize(timeout: 30.0, open_timeout: nil)
|
|
24
|
+
@timeout = timeout
|
|
25
|
+
@open_timeout = open_timeout || timeout
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(method:, url:, headers:, body:)
|
|
29
|
+
uri = url.is_a?(URI) ? url : URI.parse(url)
|
|
30
|
+
klass = METHODS.fetch(method.to_s.upcase) do
|
|
31
|
+
raise ArgumentError, "fopost: unsupported HTTP method #{method}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
request = klass.new(uri)
|
|
35
|
+
headers.each { |name, value| request[name] = value }
|
|
36
|
+
request.body = body if body
|
|
37
|
+
|
|
38
|
+
response = http_for(uri).request(request)
|
|
39
|
+
Response.new(status: response.code.to_i, body: response.body || '',
|
|
40
|
+
headers: response.to_hash.transform_values(&:first))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def http_for(uri)
|
|
46
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
47
|
+
http.use_ssl = uri.scheme == 'https'
|
|
48
|
+
http.read_timeout = @timeout
|
|
49
|
+
http.write_timeout = @timeout if http.respond_to?(:write_timeout=)
|
|
50
|
+
http.open_timeout = @open_timeout
|
|
51
|
+
http
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
module HTTP
|
|
5
|
+
# What a {Transport} hands back: the raw status, headers, and body.
|
|
6
|
+
class Response
|
|
7
|
+
attr_reader :status, :headers, :body
|
|
8
|
+
|
|
9
|
+
def initialize(status:, body: '', headers: {})
|
|
10
|
+
@status = status
|
|
11
|
+
@body = body.to_s
|
|
12
|
+
@headers = headers.each_with_object({}) { |(k, v), out| out[k.to_s.downcase] = v }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def success?
|
|
16
|
+
status >= 200 && status < 300
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def header(name)
|
|
20
|
+
headers[name.to_s.downcase]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|