Skip to content

Fix S3/SFTP job contracts, the JSON content type check and dead bulk_root_url - #29

Merged
franciscbalint merged 1 commit into
developfrom
fix/74-ops-audit-s3-ipt-json-content-type
Oct 25, 2026
Merged

franciscbalint merged 1 commit into
developfrom
fix/74-ops-audit-s3-ipt-json-content-type

Conversation

@franciscbalint

Copy link
Copy Markdown
Collaborator

An audit against the 74 documented ZeroBounce operations turned up several places where the SDK sends something the live API rejects, or reads a failure as a success. Each was re-verified against the code before changing.

Wrong URL

  • op 30 list_s3_files went through bulk_get, emitting GET bulkapi/v2/listS3files. Like every other S3 and SFTP/FTP call it lives on the bulkapi root, so the request 404ed. It now uses bulk_root_get. spec/priority3_spec.rb had a WebMock stub asserting the /v2 URL; that stub is fixed in the same commit.

Correctness

  • ops 31/32 validate_s3_file / score_s3_file took return_url through **options and never required it, so the live API rejected the job with "This field is required". They now go through bulk_root_file_job — the helper ops 33/34/36-39 already share — instead of rebuilding the parameter hash twice. has_header_row is now a named keyword (default false); arbitrary extra options are no longer forwarded, matching the documented field set.
  • op 35 export_s3_file raised ArgumentError when export_bucket was absent. It is optional: omitted, the export goes to the bucket in the account's settings, the way export_sftp_file's export_path already did.
  • op 45 list_inbox_placement_tests sent pageSize, which the API accepts and then ignores (page_size=1 paginates; pageSize=1 returns the full list unchanged). It now sends page_size, bounds it to 1-100 before the request, and accepts created_after / created_before (sent as createdAfter / createdBefore), which were absent entirely. The spec asserting pageSize is updated.
  • op 7 find_email required first_name as a positional. /guessformat only requires a domain or a company name, so it is now optional. It stays the first positional argument, so existing calls are unchanged, and find_domain and the deprecated guessformat keep their own signatures.
  • op 20 send_email_finder_file now enforces "first_name_column OR full_name_column required" — with neither, the finder has no name to build a local part from.
  • op 43 create_inbox_placement_test enforces name ≤ 64 characters, test_type in 1|2|3, and sender_email when test_type is 1. These are rejected by the API anyway, and the last one would otherwise spend inbox-test credits to be told so.

Config / robustness

  • Configuration#bulk_root_url / ZEROBOUNCE_BULK_ROOT_URL was dead config — written, never read — so a documented-looking environment variable silently did nothing. It is now wired through BaseRequest.bulk_api_root_without_v2 and is the root the S3/SFTP jobs use when set. It defaults to nil rather than the live constant, so leaving it alone still derives the root from bulk_api_root_url with the /v2 stripped: nobody pointing bulk_api_root_url at a mock server loses that derivation.
  • The content type was compared with == 'application/json', so a response typed application/json; charset=utf-8 skipped JSON parsing and the raw body came back as a String. An {"error": ...} payload then read as a success instead of raising. BaseRequest.__json_content_type__ matches the media type and ignores the parameters after the semicolon. Applied in both Request and MockRequest; affects ops 12/14/16-19/24-27/30.

Tests

spec/priority3_spec.rb is expanded from 3 examples to a full offline suite covering request method, full URL, parameters, auth placement (query string vs. form body vs. multipart) and response parsing including error bodies, for every changed method. spec/bulk_jobs_spec.rb gains the Inbox Placement Tester bounds, and spec/base_request_spec.rb the content-type helper. The live suite's return_url check now covers validate_s3_file and score_s3_file too. No API key is written to any file, fixture or commit.

$ bundle exec rspec
..............................................................................

Finished in 1.1 seconds (files took 0.3676 seconds to load)
200 examples, 0 failures

Coverage report generated for RSpec to .../coverage. 888 / 1016 LOC (87.4%) covered.

$ rubocop lib spec
Inspecting 27 files
...........................

27 files inspected, no offenses detected

Baseline before the change was 173 examples, 0 failures.

Note: RuboCop had to be run outside bundle exec — RuboCop 1.49 cannot boot under this machine's Ruby 4.0 (cannot load such file -- benchmark). The new specs are folded into the existing spec/priority3_spec.rb rather than a new file, so nothing had to be added to .rubocop_todo.yml, which .rubocop.yml asks us not to grow.

The live suite (ruby -Ilib live/live_api.rb) was not run — it needs real API keys, which this environment does not have. AGENTS.md requires it by hand before release.

🤖 Generated with Claude Code

…root_url

An audit against the 74 documented operations found several places where the
SDK sends something the live API rejects, or reads a failure as a success.

listS3files was going through bulk_get, which puts it under /v2. Every other
S3 and SFTP/FTP call lives on the bulkapi root, so the request 404ed. It now
uses bulk_root_get, and the spec stub that asserted the wrong URL is fixed
with it.

validate_s3_file and score_s3_file took return_url through **options and never
required it, so the live API rejected the job with "This field is required".
They now go through bulk_root_file_job, the helper the other six jobs already
share, rather than rebuilding the parameter hash twice. export_s3_file demanded
export_bucket, but it is optional: omitted, the export goes to the bucket in
the account's settings, the way export_sftp_file's export_path already did.

list_inbox_placement_tests sent pageSize. The API accepts it and ignores it:
page_size=1 paginates, pageSize=1 returns the whole list. It now sends
page_size, bounds it to 1-100, and accepts createdAfter/createdBefore, which
were missing entirely.

The remaining argument checks catch what the API rejects anyway, before the
request: a bulk email finder file needs first_name_column or full_name_column
to have a name to work from, and a new inbox placement test needs a name of at
most 64 characters, a testType of 1, 2 or 3, and a senderEmail when the type
is 1 - that one would otherwise spend inbox-test credits to be told so.
find_email's first_name goes the other way: /guessformat only requires a
domain or a company name, so it is now optional. It stays the first positional
argument, and find_domain and the deprecated guessformat are untouched.

Two things underneath the endpoints:

The content type was compared with == 'application/json', so a response typed
'application/json; charset=utf-8' skipped JSON parsing and came back as a raw
String. An {"error": ...} body then read as a success instead of raising.
__json_content_type__ matches the media type and ignores the parameters.

bulk_root_url / ZEROBOUNCE_BULK_ROOT_URL was written in Configuration and
never read, so a documented-looking environment variable silently did nothing.
It is now the root the S3/SFTP jobs use when it is set. It defaults to nil
instead of the live constant, so leaving it alone still derives the root from
bulk_api_root_url with the /v2 stripped and nobody pointing that at a mock
server loses the derivation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@franciscbalint
franciscbalint merged commit 6dde424 into develop Oct 25, 2026
6 checks passed
@franciscbalint
franciscbalint deleted the fix/74-ops-audit-s3-ipt-json-content-type branch October 25, 2026 21:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant