fopost-rails 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: 5959178d911d67f15c611ae03d778c338acb8c90e0f0d40b5b6484ba5b0608c6
4
+ data.tar.gz: 4ed0650b489d85ad122a10ee30787456eca0606846c8bfcdab4aeb42ccf8fe23
5
+ SHA512:
6
+ metadata.gz: dc18b76fcd3d73197793b2803bd7b427b86d8d9a4e194528c70009b47712841ff8317eeaecb957920506ddea4b9b7200afafe23588a178e7a4e93f997007ad34
7
+ data.tar.gz: f89fa93ca20e7f221a0e3e7fd90c18dfd9853a4e964b20bfe9ddfdc47a90831212e74232fa130976ca4c2f10610094afae7f8f27d9b76b6078b0cf695607ed8f
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
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::Rails.configure` and `config.fopost`, resolving each setting from an explicit value,
12
+ then Rails credentials under `fopost:`, then the environment.
13
+ - A memoized, thread-safe `Fopost::Rails.client`.
14
+ - `rails generate fopost:install`, writing `config/initializers/fopost.rb`.
15
+ - `PublishJob` and `CreatePostJob`, re-enqueueing a rate-limited call for the interval the API
16
+ asked for.
17
+ - A mountable engine that verifies incoming webhook signatures and republishes them as
18
+ `ActiveSupport::Notifications` events.
19
+
20
+ [0.1.0]: https://github.com/fopost/fopost-rails/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,233 @@
1
+ # FoPost for Rails
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/fopost-rails.svg)](https://rubygems.org/gems/fopost-rails)
4
+ [![Downloads](https://img.shields.io/gem/dt/fopost-rails.svg)](https://rubygems.org/gems/fopost-rails)
5
+ [![CI](https://img.shields.io/github/actions/workflow/status/fopost/fopost-rails/ci.yml?branch=main&label=ci)](https://github.com/fopost/fopost-rails/actions)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+
8
+ The official Rails integration for the [FoPost](https://fopost.com) API. Connect social accounts
9
+ once, then compose, schedule, and publish to +30 platforms from your own application.
10
+
11
+ This gem is a thin wrapper around the [`fopost`](https://github.com/fopost/fopost-ruby) gem. Every
12
+ request, retry, model, and error class lives there; what is added here is Rails wiring:
13
+
14
+ - `config.fopost` and Rails credentials, with a sensible fallback order
15
+ - a memoized, thread-safe `Fopost::Rails.client`
16
+ - `rails generate fopost:install`
17
+ - ActiveJob jobs, so publishing never blocks a request
18
+ - a mountable endpoint that verifies and republishes incoming FoPost webhooks
19
+
20
+ Requires Ruby 3.1+ and Rails 7.0+.
21
+
22
+ > **0.x release.** The public API is still settling and minor versions may contain breaking
23
+ > changes. Pin an exact version if that matters to you.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ bundle add fopost-rails
29
+ ```
30
+
31
+ Then write the initializer:
32
+
33
+ ```bash
34
+ bin/rails generate fopost:install
35
+ ```
36
+
37
+ ## Configure
38
+
39
+ Create an API key at [app.fopost.com/api-keys](https://app.fopost.com/api-keys) and put it
40
+ somewhere the app can read it:
41
+
42
+ ```bash
43
+ bin/rails credentials:edit
44
+ ```
45
+
46
+ ```yaml
47
+ fopost:
48
+ api_key: fp_your_key_here
49
+ default_workspace_id: ws_...
50
+ webhook_secret: whsec_...
51
+ ```
52
+
53
+ Or in the environment:
54
+
55
+ ```dotenv
56
+ FOPOST_API_KEY=fp_your_key_here
57
+ ```
58
+
59
+ Every setting resolves the same way — **what you set explicitly wins, then Rails credentials under
60
+ `fopost:`, then the environment, then the default**:
61
+
62
+ | Setting | Credentials key | Environment | Default |
63
+ | --- | --- | --- | --- |
64
+ | `api_key` | `fopost: api_key:` | `FOPOST_API_KEY` | none, required |
65
+ | `base_url` | `base_url` | `FOPOST_BASE_URL` | `https://api.fopost.com/v1` |
66
+ | `timeout` | `timeout` | `FOPOST_TIMEOUT` | `30.0` |
67
+ | `max_retries` | `max_retries` | `FOPOST_MAX_RETRIES` | `3` |
68
+ | `default_workspace_id` | `default_workspace_id` | `FOPOST_WORKSPACE_ID` | none |
69
+ | `webhook_secret` | `webhook_secret` | `FOPOST_WEBHOOK_SECRET` | none |
70
+ | `queue_name` | `queue_name` | `FOPOST_QUEUE` | `default` |
71
+
72
+ Set them in the initializer:
73
+
74
+ ```ruby
75
+ Fopost::Rails.configure do |config|
76
+ config.api_key = Rails.application.credentials.dig(:fopost, :api_key)
77
+ config.queue_name = 'social'
78
+ end
79
+ ```
80
+
81
+ or from `config/application.rb`:
82
+
83
+ ```ruby
84
+ config.fopost.default_workspace_id = 'ws_...'
85
+ ```
86
+
87
+ ## Publishing from a controller
88
+
89
+ `Fopost::Rails.client` is a configured `Fopost::Client`, memoized and safe to call from any
90
+ thread. The [`fopost` gem README](https://github.com/fopost/fopost-ruby) documents the full
91
+ resource surface — `posts`, `accounts`, `workspaces`, `labels`, `ai`.
92
+
93
+ ```ruby
94
+ class PostsController < ApplicationController
95
+ def index
96
+ @posts = Fopost::Rails.client.posts.list(status: 'scheduled')
97
+ end
98
+
99
+ def create
100
+ post = Fopost::Rails.client.posts.create(
101
+ workspace_id: Fopost::Rails.config.default_workspace_id,
102
+ content: params.require(:text),
103
+ accounts: params.require(:account_ids)
104
+ )
105
+
106
+ Fopost::Rails::PublishJob.perform_later(post.id)
107
+ redirect_to posts_path, notice: 'Queued for publishing.'
108
+ end
109
+ end
110
+ ```
111
+
112
+ Errors are the SDK's, so one `rescue_from` covers the lot:
113
+
114
+ ```ruby
115
+ rescue_from Fopost::PaymentRequiredError do |error|
116
+ redirect_to error.upgrade_url, alert: error.message
117
+ end
118
+
119
+ rescue_from Fopost::Error do |error|
120
+ Rails.logger.error("FoPost: #{error}") # "[404 (not_found)] Post not found"
121
+ head :bad_gateway
122
+ end
123
+ ```
124
+
125
+ ## Background jobs
126
+
127
+ Publishing reaches a third-party network, so it belongs off the request cycle.
128
+
129
+ ```ruby
130
+ # Publish something that already exists.
131
+ Fopost::Rails::PublishJob.perform_later(post.id)
132
+
133
+ # Compose and, optionally, send in one job.
134
+ Fopost::Rails::CreatePostJob.perform_later(
135
+ content: 'Shipping today.',
136
+ accounts: account_ids,
137
+ publish: true
138
+ )
139
+
140
+ # Or schedule it, and pass anything else the SDK takes through `options`.
141
+ Fopost::Rails::CreatePostJob.perform_later(
142
+ workspace_id: 'ws_...',
143
+ content: ['First post in the thread', 'And the reply'],
144
+ accounts: account_ids,
145
+ status: 'scheduled',
146
+ schedule_at: 1.hour.from_now,
147
+ options: { labels: ['launch'], title: 'Launch week' }
148
+ )
149
+ ```
150
+
151
+ `workspace_id` falls back to `config.default_workspace_id`. Both jobs run on `config.queue_name`.
152
+
153
+ When the API answers `429`, the job is re-enqueued for exactly the interval the API asked for in
154
+ `Retry-After` (capped at a minute), up to five attempts. Every other `Fopost::Error` is left to
155
+ your queue's own error handling.
156
+
157
+ Publishing returns once delivery is **queued**, not once it is live. Subscribe to
158
+ `fopost.post.published` for that.
159
+
160
+ ## Receiving webhooks
161
+
162
+ Mount the engine:
163
+
164
+ ```ruby
165
+ # config/routes.rb
166
+ mount Fopost::Rails::Engine => '/fopost'
167
+ ```
168
+
169
+ That serves `POST /fopost/webhooks`. Create a webhook pointing at it, copy the secret it shows you
170
+ once into `config.webhook_secret`, and subscribe:
171
+
172
+ ```ruby
173
+ # config/initializers/fopost_webhooks.rb
174
+ ActiveSupport::Notifications.subscribe('fopost.post.published') do |*, payload|
175
+ payload[:event] # "post.published"
176
+ payload[:data] # the event body FoPost sent
177
+ payload[:timestamp] # ISO 8601, when FoPost sent it
178
+ payload[:delivery_id] # X-FoPost-Delivery, unique per attempt
179
+ payload[:payload] # the whole parsed body
180
+ end
181
+ ```
182
+
183
+ Two notifications fire per verified delivery: `fopost.<event>` and `fopost.webhook` for a
184
+ catch-all. The events FoPost sends are `post.published`, `post.failed`, `post.partially_failed`,
185
+ `delivery.published`, `delivery.failed`, `delivery.delayed`, and `account.health_changed`.
186
+
187
+ Verification is not optional and not yours to write. FoPost signs the exact bytes of the request
188
+ body with HMAC-SHA256, keyed by the webhook secret, and sends the hex digest as
189
+ `X-FoPost-Signature: sha256=<digest>`. The controller recomputes it over the raw body and compares
190
+ in constant time; a mismatch is a `401` and publishes nothing, and an unconfigured secret is a
191
+ `503` rather than a pretended success.
192
+
193
+ To sign a request yourself — in a request spec, say:
194
+
195
+ ```ruby
196
+ body = { event: 'post.published', data: { postId: 'post_1' } }.to_json
197
+
198
+ post '/fopost/webhooks',
199
+ params: body,
200
+ headers: {
201
+ 'CONTENT_TYPE' => 'application/json',
202
+ 'X-FoPost-Signature' => Fopost::Rails::WebhookSignature.sign(body, secret)
203
+ }
204
+ ```
205
+
206
+ ## Testing your app
207
+
208
+ Swap the client for one wired to your own transport and nothing touches the network:
209
+
210
+ ```ruby
211
+ Fopost::Rails.client = Fopost::Client.new(api_key: 'fp_test', transport: my_stub)
212
+ ```
213
+
214
+ `Fopost::Rails.reset!` puts config and client back to their defaults between tests.
215
+
216
+ ## Looking for the free self-hosted toolkit?
217
+
218
+ This gem talks to the FoPost Cloud API with a FoPost API key. To publish straight to the social
219
+ platforms using your own app credentials, with no FoPost account involved, use
220
+ [`fopost-social-core`](https://github.com/fopost/fopost-social-core) instead. The two families are
221
+ separate on purpose and never depend on each other.
222
+
223
+ ## Links
224
+
225
+ - Documentation: [fopost.com/docs](https://fopost.com/docs)
226
+ - API keys: [app.fopost.com/api-keys](https://app.fopost.com/api-keys)
227
+ - The SDK this wraps: [`fopost`](https://github.com/fopost/fopost-ruby)
228
+ - Issues: [github.com/fopost/fopost-rails/issues](https://github.com/fopost/fopost-rails/issues)
229
+ - Support: [fopost.com/contact](https://fopost.com/contact)
230
+
231
+ ## License
232
+
233
+ MIT. Copyright (c) 2026 Porter Bridge, LLC. See [LICENSE](LICENSE).
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'active_support/notifications'
5
+ require 'fopost/rails/webhook_signature'
6
+
7
+ module Fopost
8
+ module Rails
9
+ # Receives FoPost webhooks and republishes them as
10
+ # ActiveSupport::Notifications events, so subscribing costs no route,
11
+ # controller, or queue of your own.
12
+ #
13
+ # ActiveSupport::Notifications.subscribe('fopost.post.published') do |*, payload|
14
+ # Rails.logger.info(payload[:data])
15
+ # end
16
+ #
17
+ # Two events fire per delivery: `fopost.<event>` (`fopost.post.published`,
18
+ # `fopost.delivery.failed`, …) and `fopost.webhook` for a catch-all.
19
+ class WebhooksController < ActionController::API
20
+ def create
21
+ secret = Fopost::Rails.config.webhook_secret
22
+ return head :service_unavailable if secret.nil? || secret.to_s.empty?
23
+
24
+ payload = request.raw_post
25
+ signature = request.headers[WebhookSignature::SIGNATURE_HEADER]
26
+ return head :unauthorized unless WebhookSignature.valid?(payload, signature, secret)
27
+
28
+ body = parse(payload)
29
+ event = body && body['event']
30
+ return head :bad_request unless event.is_a?(String) && !event.empty?
31
+
32
+ publish(event, body)
33
+ head :ok
34
+ end
35
+
36
+ private
37
+
38
+ def publish(event, body)
39
+ notification = {
40
+ event: event,
41
+ data: body['data'],
42
+ timestamp: body['timestamp'],
43
+ delivery_id: request.headers[WebhookSignature::DELIVERY_HEADER],
44
+ payload: body
45
+ }
46
+
47
+ ActiveSupport::Notifications.instrument("fopost.#{event}", notification)
48
+ ActiveSupport::Notifications.instrument('fopost.webhook', notification)
49
+ end
50
+
51
+ def parse(payload)
52
+ parsed = JSON.parse(payload.to_s)
53
+ parsed.is_a?(Hash) ? parsed : nil
54
+ rescue JSON::ParserError
55
+ nil
56
+ end
57
+ end
58
+ end
59
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ Fopost::Rails::Engine.routes.draw do
4
+ post '/webhooks', to: 'webhooks#create', as: :webhooks
5
+
6
+ # So mounting straight onto the endpoint path also works:
7
+ # mount Fopost::Rails::Engine => '/fopost/webhooks'
8
+ post '/', to: 'webhooks#create', as: :root
9
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_job'
4
+ require 'fopost/rails'
5
+
6
+ module Fopost
7
+ module Rails
8
+ # Base for the jobs this gem ships.
9
+ #
10
+ # A rate-limited call is re-enqueued for exactly as long as the API asked
11
+ # for in `Retry-After`, instead of a guessed backoff.
12
+ class ApplicationJob < ActiveJob::Base
13
+ # The API never asks for longer than a minute; ignore it if it does.
14
+ MAX_RETRY_WAIT = 60
15
+
16
+ # ActiveJob hands the `wait:` proc the attempt count and nothing else, so
17
+ # the seconds the API asked for ride along on the thread that raised.
18
+ # `rescue_from` runs on that same thread, right after `perform`.
19
+ RETRY_AFTER_KEY = :fopost_rails_retry_after
20
+
21
+ queue_as { Fopost::Rails.config.queue_name }
22
+
23
+ retry_on Fopost::RateLimitError,
24
+ attempts: 5,
25
+ wait: lambda { |executions|
26
+ asked = Thread.current[RETRY_AFTER_KEY]
27
+ Thread.current[RETRY_AFTER_KEY] = nil
28
+ seconds = asked.to_f
29
+ seconds.positive? ? [seconds, MAX_RETRY_WAIT].min.ceil : 2**executions
30
+ }
31
+
32
+ private
33
+
34
+ def client
35
+ Fopost::Rails.client
36
+ end
37
+
38
+ # Wrap every API call, so a 429 carries its interval into the retry.
39
+ def with_retry_after
40
+ yield
41
+ rescue Fopost::RateLimitError => e
42
+ Thread.current[RETRY_AFTER_KEY] = e.retry_after
43
+ raise
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fopost'
4
+
5
+ module Fopost
6
+ module Rails
7
+ # Settings for the API client, the jobs, and the webhook endpoint.
8
+ #
9
+ # Every setting resolves the same way: what you set explicitly wins, then
10
+ # Rails credentials under `fopost:`, then the environment, then a default.
11
+ #
12
+ # Fopost::Rails.configure do |config|
13
+ # config.api_key = 'fp_...'
14
+ # config.default_workspace_id = 'ws_...'
15
+ # end
16
+ class Configuration
17
+ SETTINGS = %i[
18
+ api_key base_url timeout max_retries default_workspace_id webhook_secret queue_name
19
+ ].freeze
20
+
21
+ ENV_KEYS = {
22
+ api_key: 'FOPOST_API_KEY',
23
+ base_url: 'FOPOST_BASE_URL',
24
+ timeout: 'FOPOST_TIMEOUT',
25
+ max_retries: 'FOPOST_MAX_RETRIES',
26
+ default_workspace_id: 'FOPOST_WORKSPACE_ID',
27
+ webhook_secret: 'FOPOST_WEBHOOK_SECRET',
28
+ queue_name: 'FOPOST_QUEUE'
29
+ }.freeze
30
+
31
+ DEFAULTS = {
32
+ base_url: Fopost::Client::DEFAULT_BASE_URL,
33
+ timeout: 30.0,
34
+ max_retries: 3,
35
+ queue_name: 'default'
36
+ }.freeze
37
+
38
+ attr_writer(*SETTINGS)
39
+
40
+ def api_key
41
+ resolve(:api_key)
42
+ end
43
+
44
+ def base_url
45
+ resolve(:base_url)
46
+ end
47
+
48
+ def timeout
49
+ value = resolve(:timeout)
50
+ value&.to_f
51
+ end
52
+
53
+ def max_retries
54
+ value = resolve(:max_retries)
55
+ value&.to_i
56
+ end
57
+
58
+ def default_workspace_id
59
+ resolve(:default_workspace_id)
60
+ end
61
+
62
+ # Shown once, when the webhook is created. The mounted endpoint needs it
63
+ # to verify the signature FoPost sends.
64
+ def webhook_secret
65
+ resolve(:webhook_secret)
66
+ end
67
+
68
+ def queue_name
69
+ resolve(:queue_name)
70
+ end
71
+
72
+ # The `fopost:` section of Rails credentials, or an empty hash outside a
73
+ # booted app. A missing master key is treated as "no credentials" rather
74
+ # than an error, so a machine without the key still boots.
75
+ def credentials
76
+ app = defined?(::Rails) && ::Rails.respond_to?(:application) ? ::Rails.application : nil
77
+ section = app&.credentials&.fopost
78
+ section.respond_to?(:[]) ? section : {}
79
+ rescue StandardError
80
+ {}
81
+ end
82
+
83
+ # Drop every explicit value, so the next read falls back again.
84
+ def reset!
85
+ SETTINGS.each { |key| instance_variable_set(:"@#{key}", nil) }
86
+ self
87
+ end
88
+
89
+ def to_h
90
+ SETTINGS.to_h { |key| [key, public_send(key)] }
91
+ end
92
+
93
+ # Keeps the key out of logs and consoles.
94
+ def inspect
95
+ redacted = to_h.merge(api_key: mask(api_key), webhook_secret: mask(webhook_secret))
96
+ "#<Fopost::Rails::Configuration #{redacted.map { |k, v| "#{k}=#{v.inspect}" }.join(' ')}>"
97
+ end
98
+
99
+ private
100
+
101
+ def resolve(key)
102
+ explicit = instance_variable_get(:"@#{key}")
103
+ return explicit unless explicit.nil?
104
+
105
+ from_credentials = credentials[key]
106
+ return from_credentials unless from_credentials.nil?
107
+
108
+ from_env = ENV[ENV_KEYS.fetch(key)]
109
+ return from_env unless from_env.nil? || from_env.empty?
110
+
111
+ DEFAULTS[key]
112
+ end
113
+
114
+ def mask(value)
115
+ value.nil? || value.empty? ? value : "#{value[0, 4]}…"
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fopost/rails/application_job'
4
+
5
+ module Fopost
6
+ module Rails
7
+ # Create a post — and optionally send it — off the request cycle.
8
+ #
9
+ # Fopost::Rails::CreatePostJob.perform_later(
10
+ # content: 'Shipping today.',
11
+ # accounts: account_ids,
12
+ # publish: true
13
+ # )
14
+ #
15
+ # `workspace_id` falls back to `config.default_workspace_id`. `options` is
16
+ # merged into the create call, so anything the SDK takes (`labels`,
17
+ # `title`, `settings`, …) passes straight through.
18
+ class CreatePostJob < ApplicationJob
19
+ def perform(content:, accounts:, workspace_id: nil, status: 'draft', schedule_at: nil,
20
+ publish: false, options: {})
21
+ workspace = workspace_id || Fopost::Rails.config.default_workspace_id
22
+ if workspace.nil? || workspace.to_s.empty?
23
+ raise ArgumentError,
24
+ 'fopost: pass workspace_id: or set config.default_workspace_id'
25
+ end
26
+
27
+ post = with_retry_after do
28
+ client.posts.create(
29
+ workspace_id: workspace,
30
+ content: content,
31
+ accounts: accounts,
32
+ status: status,
33
+ schedule_at: schedule_at,
34
+ **(options || {}).transform_keys(&:to_sym)
35
+ )
36
+ end
37
+
38
+ with_retry_after { client.posts.publish(post.id) } if publish
39
+
40
+ post.id
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/engine'
4
+
5
+ module Fopost
6
+ module Rails
7
+ # Mount to receive FoPost webhooks:
8
+ #
9
+ # # config/routes.rb
10
+ # mount Fopost::Rails::Engine => '/fopost'
11
+ #
12
+ # That serves `POST /fopost/webhooks`. Point a webhook at it, set
13
+ # `config.webhook_secret` to the secret FoPost showed you once, and
14
+ # subscribe to the events it publishes.
15
+ class Engine < ::Rails::Engine
16
+ isolate_namespace Fopost::Rails
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fopost/rails/application_job'
4
+
5
+ module Fopost
6
+ module Rails
7
+ # Publish an existing post without blocking the request that asked for it.
8
+ #
9
+ # Fopost::Rails::PublishJob.perform_later(post_id)
10
+ #
11
+ # Returns once the API has queued delivery, which is not the same as live:
12
+ # subscribe to `fopost.post.published` for that.
13
+ class PublishJob < ApplicationJob
14
+ def perform(post_id)
15
+ with_retry_after { client.posts.publish(post_id) }
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+
5
+ module Fopost
6
+ module Rails
7
+ # Registers `config.fopost`, so an app can set the SDK up from
8
+ # `config/application.rb`:
9
+ #
10
+ # config.fopost.api_key = Rails.application.credentials.dig(:fopost, :api_key)
11
+ #
12
+ # Nothing is loaded eagerly: the client is built on first use and the jobs
13
+ # are autoloaded, so booting this gem costs a require of the SDK.
14
+ class Railtie < ::Rails::Railtie
15
+ config.fopost = Fopost::Rails.config
16
+
17
+ # An initializer may have written settings after something already built a
18
+ # client, so drop it once boot is done.
19
+ config.after_initialize { Fopost::Rails.reset_client! }
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fopost
4
+ module Rails
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'active_support/security_utils'
5
+
6
+ module Fopost
7
+ module Rails
8
+ # FoPost signs every webhook body with HMAC-SHA256 over the exact bytes it
9
+ # sent, keyed by the webhook secret, and puts the hex digest in
10
+ # `X-FoPost-Signature` behind a `sha256=` prefix.
11
+ #
12
+ # Verify against the raw request body — a parsed-and-re-serialized hash will
13
+ # not match.
14
+ module WebhookSignature
15
+ SIGNATURE_HEADER = 'X-FoPost-Signature'
16
+ EVENT_HEADER = 'X-FoPost-Event'
17
+ DELIVERY_HEADER = 'X-FoPost-Delivery'
18
+ PREFIX = 'sha256='
19
+
20
+ # The header value FoPost would send for this body and secret.
21
+ def self.sign(payload, secret)
22
+ "#{PREFIX}#{OpenSSL::HMAC.hexdigest('SHA256', secret.to_s, payload.to_s)}"
23
+ end
24
+
25
+ # Constant-time comparison, so a wrong signature leaks no timing.
26
+ def self.valid?(payload, signature, secret)
27
+ return false if signature.nil? || secret.nil? || secret.to_s.empty?
28
+
29
+ ActiveSupport::SecurityUtils.secure_compare(sign(payload, secret), signature.to_s)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fopost'
4
+
5
+ require 'fopost/rails/version'
6
+ require 'fopost/rails/configuration'
7
+
8
+ # Official Rails integration for the FoPost API.
9
+ #
10
+ # This gem is a thin wrapper: every request, retry, model, and error class
11
+ # lives in the `fopost` gem. What is added here is Rails wiring — configuration
12
+ # and credentials, a memoized client, an install generator, ActiveJob jobs, and
13
+ # a mountable endpoint for receiving webhooks.
14
+ #
15
+ # Fopost::Rails.client.posts.list(status: 'scheduled')
16
+ # Fopost::Rails::PublishJob.perform_later(post.id)
17
+ module Fopost
18
+ module Rails
19
+ # Loaded on first use, so an app that never enqueues a job never pulls
20
+ # ActiveJob in on our account, and requiring this gem stays cheap.
21
+ autoload :ApplicationJob, 'fopost/rails/application_job'
22
+ autoload :CreatePostJob, 'fopost/rails/create_post_job'
23
+ autoload :PublishJob, 'fopost/rails/publish_job'
24
+ autoload :WebhookSignature, 'fopost/rails/webhook_signature'
25
+
26
+ @mutex = Mutex.new
27
+ @client = nil
28
+ @config = nil
29
+
30
+ class << self
31
+ # The settings object. Also reachable as `config.fopost` inside
32
+ # `config/application.rb` and any Rails initializer.
33
+ def config
34
+ @config ||= Configuration.new
35
+ end
36
+ alias configuration config
37
+
38
+ # Fopost::Rails.configure do |c|
39
+ # c.api_key = ENV['FOPOST_API_KEY']
40
+ # end
41
+ def configure
42
+ yield config
43
+ reset_client!
44
+ config
45
+ end
46
+
47
+ # A memoized, configured Fopost::Client. Safe to call from any thread.
48
+ def client
49
+ @client || @mutex.synchronize { @client ||= build_client }
50
+ end
51
+
52
+ # Swap in your own client — a stubbed transport in tests, say.
53
+ def client=(client)
54
+ @mutex.synchronize { @client = client }
55
+ end
56
+
57
+ # Forget the memoized client, so the next call rebuilds it from config.
58
+ def reset_client!
59
+ @mutex.synchronize { @client = nil }
60
+ nil
61
+ end
62
+
63
+ # Config and client back to their defaults. Meant for test suites.
64
+ def reset!
65
+ reset_client!
66
+ config.reset!
67
+ nil
68
+ end
69
+
70
+ private
71
+
72
+ def build_client
73
+ Fopost::Client.new(
74
+ api_key: config.api_key,
75
+ base_url: config.base_url,
76
+ timeout: config.timeout,
77
+ max_retries: config.max_retries
78
+ )
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ require 'fopost/rails/railtie' if defined?(::Rails::Railtie)
85
+ require 'fopost/rails/engine' if defined?(::Rails::Engine)
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Lets `require 'fopost-rails'` work, matching the gem name.
4
+ require 'fopost/rails'
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/base'
4
+
5
+ module Fopost
6
+ module Generators
7
+ # `rails generate fopost:install`
8
+ class InstallGenerator < ::Rails::Generators::Base
9
+ source_root File.expand_path('templates', __dir__)
10
+
11
+ desc 'Writes config/initializers/fopost.rb.'
12
+
13
+ def copy_initializer
14
+ template 'fopost.rb.tt', 'config/initializers/fopost.rb'
15
+ end
16
+
17
+ def print_next_steps
18
+ say <<~TEXT
19
+
20
+ Next: put your API key somewhere the app can read it.
21
+
22
+ bin/rails credentials:edit # fopost: { api_key: fp_... }
23
+ # or export FOPOST_API_KEY=fp_...
24
+
25
+ Create a key at https://app.fopost.com/api-keys.
26
+ TEXT
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # FoPost — https://fopost.com/docs
4
+ #
5
+ # Every setting below falls back on its own, in this order: what you set here,
6
+ # then Rails credentials under `fopost:`, then the environment. Leave a line
7
+ # commented to take the fallback.
8
+ Fopost::Rails.configure do |config|
9
+ # Create a key at https://app.fopost.com/api-keys.
10
+ # Falls back to credentials `fopost: api_key:`, then ENV['FOPOST_API_KEY'].
11
+ # config.api_key = nil
12
+
13
+ # config.base_url = 'https://api.fopost.com/v1'
14
+ # config.timeout = 30.0
15
+ # config.max_retries = 3
16
+
17
+ # Used by CreatePostJob when no workspace is passed.
18
+ # config.default_workspace_id = nil
19
+
20
+ # Shown once, when you create a webhook. Required by the mounted endpoint.
21
+ # config.webhook_secret = nil
22
+
23
+ # ActiveJob queue for PublishJob and CreatePostJob.
24
+ # config.queue_name = 'default'
25
+ end
26
+
27
+ # To receive webhooks, mount the engine in config/routes.rb:
28
+ #
29
+ # mount Fopost::Rails::Engine => '/fopost'
30
+ #
31
+ # then point a webhook at https://your-app.example/fopost/webhooks and subscribe:
32
+ #
33
+ # ActiveSupport::Notifications.subscribe('fopost.post.published') do |*, payload|
34
+ # Rails.logger.info("FoPost published: #{payload[:data]}")
35
+ # end
metadata ADDED
@@ -0,0 +1,118 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fopost-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - FoPost
8
+ - Porter Bridge, LLC
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 1980-01-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '7.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9'
33
+ - !ruby/object:Gem::Dependency
34
+ name: fopost
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.1'
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.1'
47
+ - !ruby/object:Gem::Dependency
48
+ name: railties
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.0'
54
+ - - "<"
55
+ - !ruby/object:Gem::Version
56
+ version: '9'
57
+ type: :runtime
58
+ prerelease: false
59
+ version_requirements: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '7.0'
64
+ - - "<"
65
+ - !ruby/object:Gem::Version
66
+ version: '9'
67
+ description: 'Rails integration for the FoPost API: configuration and credentials,
68
+ a memoized client, ActiveJob jobs, and a mountable webhook endpoint. A thin wrapper
69
+ around the fopost gem.'
70
+ executables: []
71
+ extensions: []
72
+ extra_rdoc_files: []
73
+ files:
74
+ - CHANGELOG.md
75
+ - LICENSE
76
+ - README.md
77
+ - app/controllers/fopost/rails/webhooks_controller.rb
78
+ - config/routes.rb
79
+ - lib/fopost-rails.rb
80
+ - lib/fopost/rails.rb
81
+ - lib/fopost/rails/application_job.rb
82
+ - lib/fopost/rails/configuration.rb
83
+ - lib/fopost/rails/create_post_job.rb
84
+ - lib/fopost/rails/engine.rb
85
+ - lib/fopost/rails/publish_job.rb
86
+ - lib/fopost/rails/railtie.rb
87
+ - lib/fopost/rails/version.rb
88
+ - lib/fopost/rails/webhook_signature.rb
89
+ - lib/generators/fopost/install/install_generator.rb
90
+ - lib/generators/fopost/install/templates/fopost.rb.tt
91
+ homepage: https://fopost.com
92
+ licenses:
93
+ - MIT
94
+ metadata:
95
+ homepage_uri: https://fopost.com
96
+ source_code_uri: https://github.com/fopost/fopost-rails
97
+ bug_tracker_uri: https://github.com/fopost/fopost-rails/issues
98
+ documentation_uri: https://fopost.com/docs
99
+ changelog_uri: https://github.com/fopost/fopost-rails/blob/main/CHANGELOG.md
100
+ rubygems_mfa_required: 'true'
101
+ rdoc_options: []
102
+ require_paths:
103
+ - lib
104
+ required_ruby_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '3.1'
109
+ required_rubygems_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
114
+ requirements: []
115
+ rubygems_version: 3.6.9
116
+ specification_version: 4
117
+ summary: Official Rails integration for the FoPost API.
118
+ test_files: []