unisender-ruby 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -0
- data/CONTRIBUTING.md +10 -1
- data/README.md +55 -112
- data/docs/api.md +395 -210
- data/docs/execution.md +89 -0
- data/docs/import-recovery.md +41 -0
- data/docs/live-contracts.md +34 -0
- data/docs/migration-0.2.md +129 -0
- data/docs/rails.md +248 -0
- data/docs/releases.md +4 -4
- data/docs/testing.md +83 -0
- data/docs/usage.md +144 -0
- data/docs/webhooks.md +41 -0
- data/examples/campaign.rb +5 -6
- data/examples/campaign_lifecycle.rb +28 -0
- data/examples/check_connection.rb +1 -1
- data/examples/events.rb +1 -1
- data/examples/export.rb +20 -6
- data/examples/import.rb +3 -2
- data/examples/import_recovery.rb +70 -0
- data/examples/quickstart.rb +1 -1
- data/examples/subscribe.rb +2 -2
- data/lib/unisender/acceptance.rb +104 -0
- data/lib/unisender/attachment.rb +47 -0
- data/lib/unisender/batch_report.rb +115 -0
- data/lib/unisender/batch_summary.rb +87 -0
- data/lib/unisender/batches.rb +41 -12
- data/lib/unisender/campaign_workflow.rb +72 -0
- data/lib/unisender/client.rb +65 -33
- data/lib/unisender/client_configuration.rb +50 -0
- data/lib/unisender/client_execution.rb +43 -0
- data/lib/unisender/client_state.rb +31 -0
- data/lib/unisender/client_validation.rb +97 -0
- data/lib/unisender/configuration.rb +151 -0
- data/lib/unisender/contact_records.rb +251 -0
- data/lib/unisender/diagnostic_urls.rb +68 -0
- data/lib/unisender/diagnostics.rb +83 -0
- data/lib/unisender/download_target.rb +61 -0
- data/lib/unisender/download_transfer.rb +43 -0
- data/lib/unisender/downloader.rb +99 -0
- data/lib/unisender/error_diagnostics.rb +83 -0
- data/lib/unisender/errors.rb +125 -8
- data/lib/unisender/event_batches.rb +40 -0
- data/lib/unisender/event_payload.rb +50 -22
- data/lib/unisender/events.rb +21 -5
- data/lib/unisender/execution_context.rb +114 -0
- data/lib/unisender/form.rb +20 -12
- data/lib/unisender/helpers.rb +70 -10
- data/lib/unisender/hooks.rb +79 -0
- data/lib/unisender/import_cells.rb +62 -0
- data/lib/unisender/input_types.rb +30 -0
- data/lib/unisender/inputs.rb +262 -0
- data/lib/unisender/iteration.rb +15 -0
- data/lib/unisender/iterators.rb +60 -0
- data/lib/unisender/mail_inputs.rb +37 -0
- data/lib/unisender/named_inputs.rb +52 -0
- data/lib/unisender/observation.rb +30 -0
- data/lib/unisender/operation_contracts.rb +106 -0
- data/lib/unisender/operations/contacts_1.rb +4 -3
- data/lib/unisender/operations/contacts_2.rb +23 -4
- data/lib/unisender/operations/messages_1.rb +7 -4
- data/lib/unisender/operations/messages_2.rb +8 -3
- data/lib/unisender/operations/messages_3.rb +1 -1
- data/lib/unisender/operations/notes_1.rb +1 -1
- data/lib/unisender/operations/statistics_1.rb +3 -3
- data/lib/unisender/operations/templates_1.rb +2 -2
- data/lib/unisender/outcomes.rb +320 -0
- data/lib/unisender/page_response.rb +48 -0
- data/lib/unisender/paginated_operations.rb +43 -2
- data/lib/unisender/pagination.rb +53 -20
- data/lib/unisender/persistent_transport.rb +104 -0
- data/lib/unisender/rate_limiter.rb +72 -0
- data/lib/unisender/record.rb +210 -0
- data/lib/unisender/record_rows.rb +69 -0
- data/lib/unisender/records.rb +614 -0
- data/lib/unisender/request_execution.rb +108 -0
- data/lib/unisender/response.rb +13 -10
- data/lib/unisender/result_registry.rb +72 -0
- data/lib/unisender/results.rb +105 -0
- data/lib/unisender/retry_policy.rb +10 -16
- data/lib/unisender/serialization.rb +54 -0
- data/lib/unisender/statistic_records.rb +129 -0
- data/lib/unisender/task_response.rb +85 -0
- data/lib/unisender/task_waiter.rb +25 -11
- data/lib/unisender/testing/assertions.rb +48 -0
- data/lib/unisender/testing/fixtures.rb +24 -0
- data/lib/unisender/testing.rb +91 -0
- data/lib/unisender/transport.rb +17 -9
- data/lib/unisender/validation.rb +11 -9
- data/lib/unisender/version.rb +1 -1
- data/lib/unisender/webhooks/tokens.rb +38 -0
- data/lib/unisender/webhooks.rb +230 -0
- data/lib/unisender.rb +42 -0
- data/sig/acceptance.rbs +30 -0
- data/sig/attachment.rbs +15 -0
- data/sig/batch_summary.rbs +38 -0
- data/sig/campaign_workflow.rbs +20 -0
- data/sig/configuration.rbs +62 -0
- data/sig/connection_controls.rbs +55 -0
- data/sig/diagnostics.rbs +37 -0
- data/sig/domain_records.rbs +96 -0
- data/sig/downloads.rbs +53 -0
- data/sig/execution.rbs +52 -0
- data/sig/execution_context.rbs +46 -0
- data/sig/hooks.rbs +18 -0
- data/sig/inputs.rbs +84 -0
- data/sig/iterators.rbs +11 -0
- data/sig/mail_inputs.rbs +8 -0
- data/sig/manifest.yaml +11 -0
- data/sig/operation_contracts.rbs +28 -0
- data/sig/operations.rbs +73 -69
- data/sig/outcomes.rbs +90 -0
- data/sig/record.rbs +77 -0
- data/sig/records.rbs +182 -0
- data/sig/serialization.rbs +13 -0
- data/sig/tasks.rbs +26 -0
- data/sig/testing.rbs +54 -0
- data/sig/unisender.rbs +128 -39
- data/sig/webhooks.rbs +55 -0
- metadata +84 -3
data/docs/execution.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Consumption, ownership, and recovery
|
|
2
|
+
|
|
3
|
+
`client.get_lists.each` iterates the fetched response without a network request. An all-page iterator explicitly requests more pages:
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
client.each_message(date_from: "2026-01-01", date_to: "2026-01-02")
|
|
7
|
+
.lazy.map(&:id).take(10).force
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
`map` alone consumes an entire Enumerable immediately. Use `.lazy.map` for early stopping, or `.to_a` when you intend to retain every item. Each new enumeration of a page iterator starts another read sequence. Page offsets do not promise a stable server snapshot.
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
client.each_page(:get_messages, date_from: "2026-01-01", date_to: "2026-01-02") do |page|
|
|
14
|
+
process_page(page)
|
|
15
|
+
save_next_offset(page.next_offset) # Save only after processing succeeds.
|
|
16
|
+
end
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Batch commands such as `import_contacts_in_batches` execute immediately. `each_import_batch` and `each_event_batch` execute when consumed. Re-enumerating them repeats mutations if the source can be replayed. A non-rewindable source may instead be exhausted. Do not use enumeration as a retry mechanism.
|
|
20
|
+
|
|
21
|
+
The [import example](../examples/import.rb) uses record hashes and reports failed source ranges. Persist the source and reconcile accepted rows before retrying an ambiguous failure. Row indices are zero-based. Do not interpret an HTTP success as complete acceptance or as recipient delivery.
|
|
22
|
+
|
|
23
|
+
The [export example](../examples/export.rb) saves the task UUID, resumes the saved job, bounds the download, and renames a completed `.part` file. Use private paths on the same filesystem for atomic renames. State persistence and starting a remote job cannot be one atomic transaction: if the process stops before the UUID is stored, reconcile the job before starting another. A partial file contains private contact data and needs the same access controls as the final file.
|
|
24
|
+
|
|
25
|
+
## Shared request budgets
|
|
26
|
+
|
|
27
|
+
One `RateLimiter` coordinates clients in one Ruby process. It cannot coordinate worker processes, other applications using the same key, or callers sharing an egress IP. Route those calls through one application-owned request worker or gateway that serializes acquisition against a shared store and accounts for every request. Keep its policy aligned with the [upstream limits](https://www.unisender.com/ru/support/api/common/api-limits/).
|
|
28
|
+
|
|
29
|
+
Give each worker an explicit client and close it when the worker stops. Inject the client into application services. Do not construct persistent clients before forking. Keep contact data and raw error details out of logs; log the operation, category, and request ID instead.
|
|
30
|
+
|
|
31
|
+
### Client ownership
|
|
32
|
+
|
|
33
|
+
| Client or resource | Lifetime and sharing |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| Default client | Creates a separate connection for each request; close it when its application owner stops. |
|
|
36
|
+
| Persistent client | Serializes requests on its built-in transport. Sharing one client does not increase request concurrency. |
|
|
37
|
+
| `client.with(...)` | Creates an independent client lifetime. An injected transport remains shared and caller-owned. |
|
|
38
|
+
| `with_execution` client | Borrows its parent's transport and expires when the block ends. Close the parent at its normal lifetime boundary. |
|
|
39
|
+
| Injected transports and callbacks | The application supplies any synchronization their implementation needs. |
|
|
40
|
+
| Forked worker | Construct clients after forking. Built-in persistent transports discard inherited connections on a PID change. |
|
|
41
|
+
|
|
42
|
+
Both API protocols send a User-Agent containing the gem and Ruby versions, without account or host information.
|
|
43
|
+
|
|
44
|
+
## Cooperative execution scopes
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
token = Unisender::CancellationToken.new
|
|
48
|
+
client.with_execution(timeout: 30, cancellation: token,
|
|
49
|
+
instrumentation: ->(event) { record_timing(event.kind, event.duration) }) do |scoped|
|
|
50
|
+
scoped.each_import_batch(records: records) { |response| process_result(response) }
|
|
51
|
+
end
|
|
52
|
+
# Another thread can call token.cancel.
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The timeout is one monotonic budget for the scope. Requests, retry delays, pacing, task polling, and batch boundaries use that budget. Nested scopes also obey their parent's deadline and cancellation. The scoped client borrows the parent's transport; closing it does not close the parent. Scoped clients and derived clients cannot be used after the block ends.
|
|
56
|
+
|
|
57
|
+
Cancellation and deadlines are cooperative. They do not forcibly interrupt an in-flight request, callback, or custom transport. Built-in downloads check between streamed chunks and preserve confirmed bytes in the error. Successful in-flight mutations return their result; the next boundary stops further work. Built-in socket timeouts are reduced to the remaining budget before IO. Do not use this scope as a hard wall-clock timeout. `CancelledError` and `DeadlineExceededError` have separate categories. Batch failures retain completed work, including when cancellation occurs before another batch.
|
|
58
|
+
|
|
59
|
+
The scope's instrumentation emits payload-free `ExecutionEvent` values for `workflow`, `retry_wait`, `rate_limit_wait`, and `poll_wait`. Existing request instrumentation measures individual network attempts and includes the upstream request ID when available. Observer exceptions do not change the operation's result. Progress callbacks are application work, so their exceptions stop the job with completed-work context.
|
|
60
|
+
|
|
61
|
+
## Acceptance metrics
|
|
62
|
+
|
|
63
|
+
Pass `acceptance_instrumentation: ->(event) { record_acceptance(event) }` when constructing a client.
|
|
64
|
+
The callback receives an immutable `AcceptanceEvent` for each attempted named send, import, or Event Tracking request.
|
|
65
|
+
Reads and offline validation do not emit acceptance events.
|
|
66
|
+
|
|
67
|
+
Events contain `operation`, `outcome`, `input_count`, `accepted_count`, `rejected_count`, `unknown_count`, `row_warning_count`, and `request_id`.
|
|
68
|
+
Counts unavailable from the response are `nil`. They are not inferred from HTTP success.
|
|
69
|
+
An Event Tracking HTTP 400 response can mean partial acceptance, with unknown counts.
|
|
70
|
+
Transport failures also leave acceptance counts unknown. HTTP 401 rejects the event batch.
|
|
71
|
+
Outcomes are `:complete`, `:partial`, `:rejected`, or `:uncertain`.
|
|
72
|
+
|
|
73
|
+
Acceptance describes provider acknowledgement, not delivery to a recipient.
|
|
74
|
+
Request `instrumentation:` separately reports network attempts and can report success when acceptance is rejected.
|
|
75
|
+
Callbacks receive no payload, contact address, credential, or response body. Callback exceptions do not change operation results.
|
|
76
|
+
|
|
77
|
+
## Retaining large jobs
|
|
78
|
+
|
|
79
|
+
Full `BatchReport` history remains the default. For large imports or event streams, select `retention: :summary` on either an immediate or lazy batch helper. Immediate calls return a non-enumerable `BatchSummary`; lazy calls still yield each response but retain no successful response bodies internally.
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
summary = client.import_contacts_in_batches(
|
|
83
|
+
records: records, retention: :summary,
|
|
84
|
+
on_progress: ->(progress) { save_checkpoint(progress.next_index) }
|
|
85
|
+
)
|
|
86
|
+
puts summary.completed_items
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`BatchProgress` snapshots contain completed batch and input counts, next source index, invalid count, warning count, and the current `response`. Counts describe completed batches. Summary failures retain the failed response and its row details. Successful warning details and response payloads are discarded, so persist anything you need in the response or progress callback. Retaining progress snapshots also retains their responses. Applications can still retain unbounded data themselves, for example by calling `to_a` on a lazy batch helper.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Recovering imports
|
|
2
|
+
|
|
3
|
+
Keep source rows and application IDs stable for the whole job. Store them in private application storage.
|
|
4
|
+
The [RecoverableImport example](../examples/import_recovery.rb) accepts rows with separate application IDs and API fields.
|
|
5
|
+
It persists the source checksum, next completed index, and IDs of the in-flight rows.
|
|
6
|
+
|
|
7
|
+
Run it from an application file with an injected client:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
require "unisender"
|
|
11
|
+
require_relative "import_recovery"
|
|
12
|
+
|
|
13
|
+
rows = [
|
|
14
|
+
{ "id" => "customer-42", "fields" => { "email" => "person@example.test", "Name" => "Example" } }
|
|
15
|
+
]
|
|
16
|
+
client = Unisender::Client.from_env
|
|
17
|
+
completed = RecoverableImport.new(client, "/private/job-42.json").run(rows)
|
|
18
|
+
puts "Completed rows: #{completed}"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Use a writable private directory on a filesystem that supports atomic rename. Only one worker may own the checkpoint.
|
|
22
|
+
For concurrent workers or transactional guarantees, use a database job row and lock, as in the [Rails integration](rails.md).
|
|
23
|
+
Every row must contain the same API field keys. Application IDs are not sent to UniSender.
|
|
24
|
+
|
|
25
|
+
The example writes intent before sending each batch. It advances the completed index only after complete acceptance.
|
|
26
|
+
The response must report zero invalid rows and a total equal to the submitted batch size.
|
|
27
|
+
Missing or inconsistent totals preserve the in-flight marker for reconciliation.
|
|
28
|
+
A restart with an in-flight marker refuses to send. This covers a timeout, partial response, callback failure, or process crash after sending.
|
|
29
|
+
|
|
30
|
+
1. Read the persisted application IDs and the original source. Keep the source order unchanged.
|
|
31
|
+
2. Inspect `PartialFailure#response` privately for known acceptance and failure details, when available. Row positions are zero-based within that request.
|
|
32
|
+
3. Reconcile every in-flight row against the account. A missing response does not prove rejection.
|
|
33
|
+
4. Record the reconciliation decision in application storage. Build a separate job for confirmed rejected or unconsumed rows.
|
|
34
|
+
|
|
35
|
+
Never clear the marker solely because a request failed. A completed batch can already have changed the account.
|
|
36
|
+
Source checksums detect accidental edits; they do not provide remote idempotency.
|
|
37
|
+
For `BatchError`, `failed_range` identifies source positions, not safe retry input. `error.diagnostics` is safe for logs; raw reports contain contact data.
|
|
38
|
+
|
|
39
|
+
Application job IDs provide correlation across logs, checkpoints, and manual reconciliation.
|
|
40
|
+
For direct email, carry an application reference in documented metadata and retain returned message IDs.
|
|
41
|
+
Do not treat `ref_key` as an idempotency guarantee.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Staging contract checks
|
|
2
|
+
|
|
3
|
+
Offline fixtures establish deterministic behavior. They do not establish the response shapes or permissions of a live account. The repository has not run this harness against an authorized account.
|
|
4
|
+
|
|
5
|
+
Obtain explicit permission and use a designated test account before running either command. Set `UNISENDER_API_KEY` privately in your environment. Do not save it in a fixture or command transcript.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
UNISENDER_LIVE=1 UNISENDER_LIVE_MODE=read bundle exec ruby -r ./scripts/live_contracts -e 'LiveContracts.run(ENV, $stdout)'
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Read mode requests lists, fields, and tags. Mutation mode creates one randomly named empty list, reads it back, and deletes that exact list in `ensure`:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
UNISENDER_LIVE=1 UNISENDER_LIVE_MODE=mutation bundle exec ruby -r ./scripts/live_contracts -e 'LiveContracts.run(ENV, $stdout)'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Record the date, Ruby version, phase, service environment, and observed contract failures. Keep response bodies private. An interrupted process can leave its created list behind; reconcile that list before rerunning. The harness does not send messages, change contacts, register webhooks, or validate webhook delivery. Those need a separately approved account scenario and receiver.
|
|
18
|
+
|
|
19
|
+
## Follow-up evidence gates
|
|
20
|
+
|
|
21
|
+
These scenarios remain pending explicit authorization and a designated test account.
|
|
22
|
+
Approve the exact sender, recipients, lists, and receiver before any send or contact mutation.
|
|
23
|
+
|
|
24
|
+
| Case | Approved action needed | Evidence to retain |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| L1 | Run the read-only harness | Successful typed results, HTTP status, tested account environment |
|
|
27
|
+
| L2 | Create and delete one empty test list | Exact created ID and confirmed cleanup |
|
|
28
|
+
| L3 | Schedule one message with an offset Time | Stored UTC minute, campaign ID, account timezone, final cancellation or completion |
|
|
29
|
+
| L4 | Send SMS to one and multiple owned test numbers | Object/array wire cardinality and receipt IDs |
|
|
30
|
+
| L5 | Submit a controlled partially invalid email/import batch | Accepted/rejected/unknown counts, warning paths, zero automatic replay |
|
|
31
|
+
| L6 | Register a temporary HTTPS receiver and trigger an owned test delivery | Authentication, duplicate handling, status projection, registration cleanup |
|
|
32
|
+
|
|
33
|
+
Record timestamps and library/Ruby versions. Retain only sanitized findings in this repository.
|
|
34
|
+
Keep raw provider responses in private test storage. A green offline suite does not close these gates.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Migrating to 0.2
|
|
2
|
+
|
|
3
|
+
All changes on this branch remain unreleased. Execution scopes, summary retention, and webhooks are opt-in. `RequestEvent` adds a `request_id` field. Full batch history remains the default; summary mode discards successful payloads and successful row-warning details. See [execution](execution.md) and [webhooks](webhooks.md).
|
|
4
|
+
|
|
5
|
+
Named operations return typed records and collections. `request` retains raw JSON results.
|
|
6
|
+
|
|
7
|
+
## Result access
|
|
8
|
+
|
|
9
|
+
`send_sms` always returns a collection. Replace `response.sms_id` with `response.fetch(0).sms_id` for a single recipient.
|
|
10
|
+
Phone arrays are accepted; the raw upstream object or array remains available through `response.data`.
|
|
11
|
+
|
|
12
|
+
`JSON.generate(response)` serializes the typed payload. Use `response.data` when you need the upstream envelope.
|
|
13
|
+
Collections support slices and fetch defaults; object responses provide `to_h`.
|
|
14
|
+
`as_json` and `to_json` honor top-level `only:` and `except:` filters and reject unsupported options.
|
|
15
|
+
Contact address strings are available through `email_address` and `phone_number`, independent of nested detail shapes.
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# Before
|
|
19
|
+
client.get_lists.result.each { |list| puts list.fetch("title") }
|
|
20
|
+
client.create_list(title: "News").result.fetch("id")
|
|
21
|
+
|
|
22
|
+
# After
|
|
23
|
+
client.get_lists.each { |list| puts list.title }
|
|
24
|
+
client.create_list(title: "News").id
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Use `response.result` for the typed result and `response.data` for the original JSON envelope.
|
|
28
|
+
Records retain string-key lookup and unknown fields. `to_h` returns a detached mutable copy.
|
|
29
|
+
Record equality requires the same concrete class. Use `to_h` when comparing a record with a Hash.
|
|
30
|
+
Equivalent records work as Hash keys and in Sets. Object responses also expose `key?`, `keys`, and `values`.
|
|
31
|
+
|
|
32
|
+
## HTTP metadata
|
|
33
|
+
|
|
34
|
+
Replace `response.status` with `response.http_status` when reading the HTTP code.
|
|
35
|
+
`response.status` exposes a documented result status when the operation supplies one.
|
|
36
|
+
Headers and warnings retain their existing names.
|
|
37
|
+
|
|
38
|
+
## Predicates and singular results
|
|
39
|
+
|
|
40
|
+
Use `contact_in_lists?` in conditionals. It returns a boolean and accepts `condition: :any` or `:all`.
|
|
41
|
+
The response-returning `is_contact_in_lists` method remains available.
|
|
42
|
+
|
|
43
|
+
`get_message(id:)` returns one message. Empty results raise `NotFoundError`; multiple results raise `InvalidResponseError`.
|
|
44
|
+
Count fields use integers. Tabular visited-link results become enumerable records keyed by the returned column names.
|
|
45
|
+
|
|
46
|
+
## Execution and pagination
|
|
47
|
+
|
|
48
|
+
Page iterators now execute supplied blocks. Avoid passing a block unless you intend to consume the iterator immediately.
|
|
49
|
+
Successful pages expose checkpoint metadata through `offset`, `limit`, and `next_offset`.
|
|
50
|
+
Completed task handles return immediately from `wait`; a supplied callback receives the existing handle once.
|
|
51
|
+
Failed handles also invoke the callback once, then raise `TaskFailedError` without another request.
|
|
52
|
+
|
|
53
|
+
`import_contacts_in_batches` and `track_events_in_batches` execute immediately and return a `BatchReport`.
|
|
54
|
+
Use `each_import_batch` and `each_event_batch` for lazy execution. Each accepts a block or returns an Enumerator.
|
|
55
|
+
Reading a report repeatedly does not repeat requests.
|
|
56
|
+
|
|
57
|
+
Named paginated reads request 100 items unless you supply `limit`.
|
|
58
|
+
Use a fetched page's `each_page` or `auto_each` to continue from that page without refetching it.
|
|
59
|
+
Named iterators such as `each_message` yield typed items across pages and accept a block.
|
|
60
|
+
Invalid pagination arguments raise when constructing the iterator. HTTP requests remain lazy.
|
|
61
|
+
Pagination errors expose `next_offset` and `completed_pages` for recovery. Direct paginated reads now reject invalid limits and offsets before making requests.
|
|
62
|
+
|
|
63
|
+
## Inputs and tasks
|
|
64
|
+
|
|
65
|
+
Named methods reject wrong scalar and collection types before a request. Use arrays or comma-separated strings for list IDs and field IDs.
|
|
66
|
+
`client.validate!(operation, **arguments)` checks a named request, Event Tracking, or a campaign workflow without networking.
|
|
67
|
+
It applies the same defaults and structural rules as execution. Import validation consumes its source; recreate an enumerable before sending.
|
|
68
|
+
|
|
69
|
+
Campaign `Time` inputs use UTC with `timezone: "UTC"`. Seconds are truncated to the API's minute precision.
|
|
70
|
+
Conflicting timezone arguments raise `ValidationError`. String inputs retain their explicit or account-default timezone.
|
|
71
|
+
|
|
72
|
+
Named flags reject values outside booleans and 0/1 before networking. Known closed choices reject unsupported values.
|
|
73
|
+
Library validation failures expose structured fields through `ValidationError`, while remaining rescuable as `ArgumentError`.
|
|
74
|
+
`error.diagnostics` provides safe structured context for validation, transport, API, task, and batch failures.
|
|
75
|
+
Paths identify documented fields and row positions. Dynamic field names are masked; raw payload access remains explicit.
|
|
76
|
+
`send_email` defaults `error_checking` to true. Use recipient results for IDs; explicit false retains the legacy format.
|
|
77
|
+
`accepted_ids` works with both formats. Recipient views retain rejected and unknown outcomes separately.
|
|
78
|
+
Named `reply_to:` and `priority:` headers reject conflicts. Unsupported `images_as` values fail locally.
|
|
79
|
+
List/tag arrays in import records become comma-separated scalar cells. Subscription-time arrays retain their list-ID order.
|
|
80
|
+
|
|
81
|
+
Supply `email:` directly to `subscribe`, or continue using `fields:`.
|
|
82
|
+
Subscription modes accept `:request_confirmation`, `:already_confirmed`, and `:confirm_if_needed`.
|
|
83
|
+
Overwrite modes accept `:preserve`, `:replace`, and `:update`. Numeric modes retain their meanings.
|
|
84
|
+
|
|
85
|
+
Imports accept `records:` with consistent keys, including `email`. This input is mutually exclusive with `field_names:` and `data:`.
|
|
86
|
+
Record key order may differ; the first record determines column order. Missing columns must be supplied explicitly.
|
|
87
|
+
Arrays validate before sending. Streaming input validates as consumed.
|
|
88
|
+
|
|
89
|
+
Timestamp arguments accept `Time` and convert it to the operation's UTC format. String inputs pass through unchanged.
|
|
90
|
+
`ValidationError` provides `field`, `code`, and optional `operation` without including private values.
|
|
91
|
+
|
|
92
|
+
Export and delivery-report responses are task handles. Call `job.wait` explicitly to poll.
|
|
93
|
+
A block passed to `wait` receives each observed state. Polling deadlines retain the last response and task UUID. Request failures during polling preserve their original exception class and add `task_uuid` and `last_response`.
|
|
94
|
+
|
|
95
|
+
## Partial success
|
|
96
|
+
|
|
97
|
+
Import, send, and event responses expose `complete?`, `partial?`, `uncertain?`, and `failures`.
|
|
98
|
+
These describe request acceptance, not final message delivery. Call `raise_for_partial_failure!` to reject incomplete acceptance explicitly.
|
|
99
|
+
|
|
100
|
+
`BatchError#report` retains completed responses, known import totals, and failures with global input indices.
|
|
101
|
+
Unknown acceptance remains unknown; the client does not count an uncertain request as rejected.
|
|
102
|
+
Event batches respect the encoded payload limit as well as the event-count limit.
|
|
103
|
+
Streaming input failures retain completed batches. No failed request or input chunk is replayed automatically.
|
|
104
|
+
|
|
105
|
+
## Configuration and diagnostics
|
|
106
|
+
|
|
107
|
+
Pass timeout and retry options directly to `Client.new`, or retain explicit transport and policy objects.
|
|
108
|
+
Combining an explicit object with its construction options raises `ArgumentError`.
|
|
109
|
+
`Client.from_env` reads the API key without loading files. `client.with(...)` leaves the original unchanged.
|
|
110
|
+
|
|
111
|
+
`message_defaults` supplies sender name, sender email, and message language for message creation and direct email sends.
|
|
112
|
+
Explicit arguments override defaults; explicit `nil` suppresses a default.
|
|
113
|
+
|
|
114
|
+
Use `error.category`, `error.recovery_hint`, and `error.request_id` for diagnosis.
|
|
115
|
+
`error.transient?` describes the failure. `error.safe_to_retry?` also checks the operation.
|
|
116
|
+
Neither enables retries automatically.
|
|
117
|
+
|
|
118
|
+
The optional instrumentation callback receives safe metadata for each attempt.
|
|
119
|
+
See [application testing](testing.md) for deterministic responses and failure scenarios.
|
|
120
|
+
|
|
121
|
+
## Optional helpers
|
|
122
|
+
|
|
123
|
+
Use `persistent: true` for connection reuse and `client.close` when the client is no longer needed.
|
|
124
|
+
An injected transport remains caller-owned. Request pacing is opt-in through `rate_limiter:` and is independent of retry settings.
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
Attachments accept `Attachment.from_path`, `Attachment.from_io`, one attachment, or an array, alongside existing hashes.
|
|
128
|
+
`send_campaign(message:, campaign:)` returns both responses and retains a created message if the campaign step fails.
|
|
129
|
+
Completed tasks expose `download_to(io, max_bytes:)`. Downloads never happen automatically; errors retain confirmed partial byte counts.
|
data/docs/rails.md
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# Rails integration
|
|
2
|
+
|
|
3
|
+
This example connects a background import, durable recovery state, and an authenticated delivery receiver.
|
|
4
|
+
The gem has no Rails dependency. Add it to your application as described in the [README](../README.md).
|
|
5
|
+
The example uses Rails 8.1 and a database with transactions and unique indexes.
|
|
6
|
+
|
|
7
|
+
## Store recovery and delivery state
|
|
8
|
+
|
|
9
|
+
Add `db/migrate/20261026000000_create_unisender_state.rb`, then run `bin/rails db:migrate`:
|
|
10
|
+
|
|
11
|
+
<!-- file: db/migrate/20261026000000_create_unisender_state.rb -->
|
|
12
|
+
```ruby
|
|
13
|
+
class CreateUnisenderState < ActiveRecord::Migration[8.1]
|
|
14
|
+
def change
|
|
15
|
+
create_table :import_runs do |t|
|
|
16
|
+
t.json :source, null: false
|
|
17
|
+
t.integer :next_index, null: false, default: 0
|
|
18
|
+
t.json :in_flight
|
|
19
|
+
t.string :status, null: false, default: "ready"
|
|
20
|
+
t.json :diagnostics
|
|
21
|
+
end
|
|
22
|
+
create_table :delivery_events do |t|
|
|
23
|
+
t.string :fingerprint, null: false
|
|
24
|
+
t.string :account, null: false
|
|
25
|
+
t.json :payload, null: false
|
|
26
|
+
t.datetime :processed_at
|
|
27
|
+
end
|
|
28
|
+
add_index :delivery_events, :fingerprint, unique: true
|
|
29
|
+
create_table :email_deliveries do |t|
|
|
30
|
+
t.string :account, null: false
|
|
31
|
+
t.string :email_id, null: false
|
|
32
|
+
t.string :status
|
|
33
|
+
t.datetime :observed_at
|
|
34
|
+
end
|
|
35
|
+
add_index :email_deliveries, [:account, :email_id], unique: true
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Add these models to `app/models`:
|
|
41
|
+
|
|
42
|
+
<!-- file: app/models/import_run.rb -->
|
|
43
|
+
```ruby
|
|
44
|
+
class ImportRun < ApplicationRecord
|
|
45
|
+
attr_readonly :source
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
<!-- file: app/models/delivery_event.rb -->
|
|
50
|
+
```ruby
|
|
51
|
+
class DeliveryEvent < ApplicationRecord
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
<!-- file: app/models/email_delivery.rb -->
|
|
56
|
+
```ruby
|
|
57
|
+
class EmailDelivery < ApplicationRecord
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Store source rows as `{"id" => "customer-42", "fields" => {"email" => "person@example.test"}}`.
|
|
62
|
+
Keep all row fields consistent. Application IDs remain local; only `fields` are sent.
|
|
63
|
+
Do not edit an import's source after creating it.
|
|
64
|
+
|
|
65
|
+
## Import through an injected client
|
|
66
|
+
|
|
67
|
+
Add `app/services/import_processor.rb`:
|
|
68
|
+
|
|
69
|
+
<!-- file: app/services/import_processor.rb -->
|
|
70
|
+
```ruby
|
|
71
|
+
class ImportProcessor
|
|
72
|
+
def initialize(run, client:)
|
|
73
|
+
@run, @client = run, client
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def call
|
|
77
|
+
while (work = claim)
|
|
78
|
+
batch, offset = work
|
|
79
|
+
response = @client.import_contacts(records: batch.map { |row| row.fetch("fields") }).raise_for_partial_failure!
|
|
80
|
+
raise Unisender::PartialFailure, response unless response.total == batch.size
|
|
81
|
+
@run.with_lock do
|
|
82
|
+
@run.update!(next_index: offset + batch.size, in_flight: nil, status: "ready")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
rescue Unisender::Error => error
|
|
86
|
+
@run.update!(status: "needs_review", diagnostics: error.diagnostics)
|
|
87
|
+
raise
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def claim
|
|
93
|
+
@run.with_lock do
|
|
94
|
+
raise "Reconcile in-flight rows before resuming" if @run.in_flight
|
|
95
|
+
batch = @run.source.slice(@run.next_index, 500)
|
|
96
|
+
if batch.empty?
|
|
97
|
+
@run.update!(status: "completed")
|
|
98
|
+
nil
|
|
99
|
+
else
|
|
100
|
+
@run.update!(status: "running", in_flight: batch.map { |row| row.fetch("id") })
|
|
101
|
+
[batch, @run.next_index]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The claim transaction commits before the network call. A crash leaves `in_flight` intact.
|
|
109
|
+
A duplicate job cannot send that batch again. A completed batch advances the checkpoint in another transaction.
|
|
110
|
+
Never wrap the whole import in a database transaction, because rollback would erase the recovery marker.
|
|
111
|
+
Keep each job's source and checkpoints on the primary database, not an eventually consistent replica.
|
|
112
|
+
|
|
113
|
+
Add `app/jobs/import_contacts_job.rb`:
|
|
114
|
+
|
|
115
|
+
<!-- file: app/jobs/import_contacts_job.rb -->
|
|
116
|
+
```ruby
|
|
117
|
+
class ImportContactsJob < ApplicationJob
|
|
118
|
+
def perform(run_id)
|
|
119
|
+
Unisender::Client.open(api_key: Rails.application.credentials.fetch(:unisender_api_key)) do |client|
|
|
120
|
+
ImportProcessor.new(ImportRun.find(run_id), client: client).call
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Start the job from application code with `ImportContactsJob.perform_later(run.id)`.
|
|
127
|
+
Use your application's durable Active Job backend. Do not configure automatic mutation retries.
|
|
128
|
+
If a backend redelivers a crashed job, the persisted marker stops the remote call.
|
|
129
|
+
Reconcile marked rows using the [import recovery procedure](import-recovery.md), then create a separate job for safe remaining work.
|
|
130
|
+
`diagnostics` contains safe metadata; `source`, `in_flight`, and response payloads are private application data.
|
|
131
|
+
|
|
132
|
+
## Authenticate and persist webhook events
|
|
133
|
+
|
|
134
|
+
Add this route inside `Rails.application.routes.draw` in `config/routes.rb`:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
match "/unisender", to: "unisender_webhooks#receive", via: [:get, :post]
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Add `app/controllers/unisender_webhooks_controller.rb`:
|
|
141
|
+
|
|
142
|
+
<!-- file: app/controllers/unisender_webhooks_controller.rb -->
|
|
143
|
+
```ruby
|
|
144
|
+
require "unisender/webhooks"
|
|
145
|
+
|
|
146
|
+
class UnisenderWebhooksController < ActionController::API
|
|
147
|
+
def receive
|
|
148
|
+
return head :ok if request.get?
|
|
149
|
+
notification = Unisender::Webhooks.verify(
|
|
150
|
+
body: request.body.read(1_048_577),
|
|
151
|
+
api_key: Rails.application.credentials.fetch(:unisender_api_key),
|
|
152
|
+
content_encoding: request.get_header("HTTP_CONTENT_ENCODING")
|
|
153
|
+
)
|
|
154
|
+
DeliveryEvent.transaction do
|
|
155
|
+
notification.users.each do |user|
|
|
156
|
+
user.events.each do |event|
|
|
157
|
+
value = [user.login, canonical(event.to_h)]
|
|
158
|
+
fingerprint = Digest::SHA256.hexdigest(JSON.generate(value))
|
|
159
|
+
DeliveryEvent.create_or_find_by!(fingerprint: fingerprint) do |record|
|
|
160
|
+
record.account = user.login
|
|
161
|
+
record.payload = event.to_h
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
head :ok
|
|
167
|
+
rescue Unisender::WebhookError
|
|
168
|
+
head :bad_request
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def canonical(value)
|
|
174
|
+
case value
|
|
175
|
+
when Hash then value.sort.to_h.transform_values { |child| canonical(child) }
|
|
176
|
+
when Array then value.map { |child| canonical(child) }
|
|
177
|
+
else value
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
GET acknowledges registration only. POST verifies the original body before parsing it into application records.
|
|
184
|
+
Set a matching request-body limit in the web server. Return HTTP 200 only after the event transaction commits.
|
|
185
|
+
The unique fingerprint suppresses repeated event content, even when a batch is regrouped or object keys change order.
|
|
186
|
+
This is an application deduplication rule, not a provider event identifier. Retain the fingerprint for your full replay window.
|
|
187
|
+
Serve this endpoint over HTTPS. Exclude webhook bodies from request logging and error-reporting attachments.
|
|
188
|
+
|
|
189
|
+
Add the payload keys to `config/initializers/filter_parameter_logging.rb`:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
Rails.application.config.filter_parameters += [:auth, :api_key, :events_by_user]
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Configure external error and HTTP logging tools separately; Rails parameter filters do not control those tools.
|
|
196
|
+
|
|
197
|
+
## Process persisted events separately
|
|
198
|
+
|
|
199
|
+
Add `app/jobs/process_delivery_events_job.rb`:
|
|
200
|
+
|
|
201
|
+
<!-- file: app/jobs/process_delivery_events_job.rb -->
|
|
202
|
+
```ruby
|
|
203
|
+
class ProcessDeliveryEventsJob < ApplicationJob
|
|
204
|
+
def perform
|
|
205
|
+
DeliveryEvent.where(processed_at: nil).find_each do |event|
|
|
206
|
+
event.with_lock do
|
|
207
|
+
next if event.processed_at
|
|
208
|
+
apply_status(event)
|
|
209
|
+
event.update!(processed_at: Time.current)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
def apply_status(event)
|
|
217
|
+
payload = event.payload
|
|
218
|
+
return unless payload["event_name"] == "email_status"
|
|
219
|
+
data = payload.fetch("event_data")
|
|
220
|
+
return unless data["email_id"]
|
|
221
|
+
delivery = EmailDelivery.create_or_find_by!(account: event.account, email_id: data.fetch("email_id").to_s)
|
|
222
|
+
observed = Time.find_zone!("UTC").parse(payload.fetch("event_time"))
|
|
223
|
+
delivery.with_lock do
|
|
224
|
+
if delivery.observed_at.nil? || observed > delivery.observed_at
|
|
225
|
+
delivery.update!(status: data.fetch("status"), observed_at: observed)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Schedule this job periodically through your job backend. It also recovers events persisted before a process crash.
|
|
233
|
+
Event processing and the processed marker commit together. Repeated processing does not repeat the state change.
|
|
234
|
+
The timestamp guard prevents older notifications from replacing newer status.
|
|
235
|
+
This example projects direct-email delivery, using IDs returned by `send_email.accepted_ids`.
|
|
236
|
+
Campaign notifications use `campaign_id` and recipient email; add a separate projection when your application needs campaign delivery.
|
|
237
|
+
Equal timestamps keep the first observed status. Define a domain-specific precedence if your application needs finer ordering.
|
|
238
|
+
|
|
239
|
+
Receipt and processing are separate: the receiver persists, then the worker updates application state.
|
|
240
|
+
Database failures produce a non-200 response and allow provider redelivery. Authentication failures never create event records.
|
|
241
|
+
Signature details and provider limitations are in the [webhook guide](webhooks.md).
|
|
242
|
+
|
|
243
|
+
## Test without an account
|
|
244
|
+
|
|
245
|
+
Inject `Unisender::Testing::Transport` into `ImportProcessor` and queue `Fixtures.import_result` responses.
|
|
246
|
+
Test complete acceptance, partial acceptance, transport failure, duplicate jobs, and restart from an in-flight marker.
|
|
247
|
+
For the receiver, sign synthetic payloads with a fake key. Test invalid auth, duplicate delivery, reordered JSON keys, and older events.
|
|
248
|
+
See [testing support](testing.md) for request assertions and [serialization](usage.md#responses-and-failures) for `only:` and `except:`.
|
data/docs/releases.md
CHANGED
|
@@ -9,11 +9,11 @@ Starting with 0.1.1, GitHub releases include the gem, an SPDX inventory of its p
|
|
|
9
9
|
Download and verify a release in your terminal:
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
gh release download v0.
|
|
12
|
+
gh release download v0.2.0 --repo svyatov/unisender-ruby --dir release
|
|
13
13
|
cd release
|
|
14
|
-
gh attestation verify unisender-ruby.gem --repo svyatov/unisender-ruby --signer-workflow svyatov/unisender-ruby/.github/workflows/release.yml --source-ref refs/tags/v0.
|
|
15
|
-
gh attestation verify sbom.spdx.json --repo svyatov/unisender-ruby --signer-workflow svyatov/unisender-ruby/.github/workflows/release.yml --source-ref refs/tags/v0.
|
|
16
|
-
gh attestation verify SHA256SUMS --repo svyatov/unisender-ruby --signer-workflow svyatov/unisender-ruby/.github/workflows/release.yml --source-ref refs/tags/v0.
|
|
14
|
+
gh attestation verify unisender-ruby.gem --repo svyatov/unisender-ruby --signer-workflow svyatov/unisender-ruby/.github/workflows/release.yml --source-ref refs/tags/v0.2.0
|
|
15
|
+
gh attestation verify sbom.spdx.json --repo svyatov/unisender-ruby --signer-workflow svyatov/unisender-ruby/.github/workflows/release.yml --source-ref refs/tags/v0.2.0
|
|
16
|
+
gh attestation verify SHA256SUMS --repo svyatov/unisender-ruby --signer-workflow svyatov/unisender-ruby/.github/workflows/release.yml --source-ref refs/tags/v0.2.0
|
|
17
17
|
shasum -a 256 -c SHA256SUMS
|
|
18
18
|
```
|
|
19
19
|
|
data/docs/testing.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Testing application integrations
|
|
2
|
+
|
|
3
|
+
Load the optional fake transport explicitly. It has no runtime dependencies and never opens a socket.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
require "unisender/testing"
|
|
7
|
+
|
|
8
|
+
transport = Unisender::Testing::Transport.new
|
|
9
|
+
transport.enqueue(operation: "getLists", data: {
|
|
10
|
+
"result" => [{ "id" => 42, "title" => "News" }]
|
|
11
|
+
})
|
|
12
|
+
client = Unisender::Client.new(api_key: "test-key", transport:)
|
|
13
|
+
|
|
14
|
+
raise "Unexpected lists" unless client.get_lists.map(&:id) == [42]
|
|
15
|
+
raise "Missing format" unless transport.requests.first.form["format"] == "json"
|
|
16
|
+
transport.verify!
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Each request consumes one queued response. Missing responses and operation mismatches raise immediately.
|
|
20
|
+
Call `verify!` at the end of a test to reject unused responses. Mismatch messages contain only recognized operation names.
|
|
21
|
+
Use `enqueue(data:, http_status:, headers:)` for API errors, HTTP failures, warnings, and partial results.
|
|
22
|
+
Use `enqueue_response` with an `HTTPResponse` for malformed JSON or an exception for transport failures.
|
|
23
|
+
|
|
24
|
+
Request inspection omits payloads. `form` excludes the API key.
|
|
25
|
+
Explicit `body`, `headers`, and `uri` access can expose credentials or contact data. Keep those values out of test failure output.
|
|
26
|
+
Queued fixtures should use fake addresses and keys.
|
|
27
|
+
|
|
28
|
+
## Fixtures and request expectations
|
|
29
|
+
|
|
30
|
+
`Fixtures.list`, `Fixtures.import_result`, `Fixtures.task`, and `Fixtures.recipient` return result values without the outer `result` envelope.
|
|
31
|
+
Required keywords describe the minimum usable fixture; optional keywords add failures, IDs, and download URLs.
|
|
32
|
+
|
|
33
|
+
Add this expectation to an application test:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
transport.enqueue(
|
|
37
|
+
operation: "importContacts",
|
|
38
|
+
expected_form: { "field_names[0]" => "email", "data[0][0]" => "person@example.test" },
|
|
39
|
+
data: { "result" => Unisender::Testing::Fixtures.import_result(total: 1) }
|
|
40
|
+
)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`expected_form:` checks selected encoded fields. `expected_json:` checks nested object subsets and positional array prefixes.
|
|
44
|
+
Choose one per reply. Additional request fields are allowed; missing or different expected fields fail immediately.
|
|
45
|
+
Error messages contain a redacted field path, without expected or actual payload values. API keys cannot be form expectations.
|
|
46
|
+
Use exact request counts and `verify!` when order and extra requests matter.
|
|
47
|
+
|
|
48
|
+
For Event Tracking, use `transport.requests.last.json` to inspect the decoded JSON payload with its `events` array.
|
|
49
|
+
Like `body`, this reader contains contact data. Compare selected fields instead of printing the full payload.
|
|
50
|
+
|
|
51
|
+
## Type checking in an application
|
|
52
|
+
|
|
53
|
+
The gem packages RBS under `sig/`, including `sig/manifest.yaml`. With Steep installed in your application's development bundle, use this target in `Steepfile`:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
target :app do
|
|
57
|
+
check "app"
|
|
58
|
+
library "unisender-ruby"
|
|
59
|
+
library "net-http", "json", "uri", "openssl", "time", "ipaddr", "zlib", "stringio"
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Run `bundle exec steep check`. The gem includes its signatures; copying the library's source into your application is unnecessary.
|
|
64
|
+
|
|
65
|
+
### Cursor setup
|
|
66
|
+
|
|
67
|
+
Use [Steep](https://github.com/soutaro/steep) in your development bundle and the publisher's [Steep extension](https://marketplace.visualstudio.com/items?itemName=soutaro.steep-vscode).
|
|
68
|
+
The extension repository is [soutaro/steep-vscode](https://github.com/soutaro/steep-vscode).
|
|
69
|
+
If Cursor's registry does not list it, download the publisher's VSIX from Visual Studio Marketplace and use **Extensions: Install from VSIX**.
|
|
70
|
+
|
|
71
|
+
1. Add `gem "steep", group: :development` to your application's Gemfile, then run `bundle install`.
|
|
72
|
+
2. Add the `Steepfile` target above, changing `check "app"` to your source directory.
|
|
73
|
+
3. Open the project folder in Cursor and run **Steep: Restart** from the command palette.
|
|
74
|
+
4. Use **Trigger Suggest** for methods, **Show or Focus Hover** for keywords and return types, and the Problems panel for invalid calls.
|
|
75
|
+
|
|
76
|
+
The extension launches `bin/steep langserver` when a binstub exists, otherwise `bundle exec steep langserver`.
|
|
77
|
+
Ensure Cursor uses the same Ruby and bundle as your terminal. A version-manager shim must resolve Ruby in the project directory.
|
|
78
|
+
On macOS, open the canonical folder path, such as `/private/tmp/project` rather than its `/tmp/project` symlink.
|
|
79
|
+
Mismatched document paths can hide diagnostics even while the language server runs.
|
|
80
|
+
|
|
81
|
+
Verified with Cursor 3.27.14, Steep extension 0.10.0, Steep 2.1.0, RBS 4.2.0, and Ruby 4.0.9.
|
|
82
|
+
The consumer used an installed 0.2.0 gem outside this checkout.
|
|
83
|
+
Observed in the editor: `get_*` completion, `CollectionResponse[List]` hover, required `title: String` keyword help, and rejection of `title: false`.
|