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 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
+ [![Gem Version](https://img.shields.io/gem/v/fopost.svg)](https://rubygems.org/gems/fopost)
4
+ [![Downloads](https://img.shields.io/gem/dt/fopost.svg)](https://rubygems.org/gems/fopost)
5
+ [![CI](https://img.shields.io/github/actions/workflow/status/fopost/fopost-ruby/ci.yml?branch=main&label=ci)](https://github.com/fopost/fopost-ruby/actions)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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