Skip to content

Modernize the gem and add the V2 client (2.0.0) - #18

Merged
nina-saule merged 83 commits into
masterfrom
version-upgrade
Sep 18, 2026
Merged

nina-saule merged 83 commits into
masterfrom
version-upgrade

Conversation

@nina-saule

@nina-saule nina-saule commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

TL;DR: this branch replaces the Java code generator with plain Ruby, deletes dead code, makes v1 run on Faraday 1.10 and 2, adds AmazonSpClients::V2, a per-merchant client with typed errors, and renames the gem to sp_api_clients for rubygems.org. The v1 API and the AmazonSpClients namespace are unchanged. Do not merge yet. Every consumer installs this gem from git master, so the merge is the release for them. The merge order is below.

What is in it

  • 1.8.0: a pure-Ruby generator pinned to Amazon's spec repo, rake generate:verify in CI, 17 unused API modules deleted, SigV4 signing removed (Amazon dropped it in 2023), bug fixes. CHANGELOG.md has the full list.
  • 1.9.0: Faraday 2 compatibility. faraday_middleware dropped, the HTTPClient adapter kept, CI matrix on both Faraday majors.
  • 2.0.0: the V2 namespace (require 'amazon_sp_clients/v2'), a release workflow that publishes to rubygems.org from a v* tag, and the docs. The README opens with the experimental status and points to peddler; it has a v1 to V2 migration table and a "Releasing" section.
  • The gem name: sp_api_clients. The old name started with Amazon's name, which is not ours to publish under. Only the gem name changed: the AmazonSpClients namespace, the amazon_sp_clients/... require paths, vendor/ and the generator are as before. lib/sp_api_clients.rb is a one-line shim so gem 'sp_api_clients' works with Bundler's automatic require. The repository keeps its name.

None of these versions is released yet. Only 2.0.0 will be. It contains the other two.

How to review

  • Start with CHANGELOG.md, then the README sections "V2 client", "Migrating from v1" and "Releasing".
  • The first real consumer is the active_cart change that moves amazon_spartner_experimental to V2 (separate PR). Its test file passes against this branch. A review of that cart against the gem produced several commits here: request bodies through to_json (ActiveSupport time encoding), fuller LWA and gateway error messages, an RDT preset for report documents, a clearer error on a bad document payload, and four migration-table rows.
  • CI runs rubocop, rspec on Ruby 3.3 and 3.4 times Faraday 1 and 2, yard:verify and generate:verify.

Merge order

  1. Set up the trusted publisher on rubygems.org for gem sp_api_clients and repository dropstream/amazon_sp_clients (README, "Releasing"), then tag v2.0.0 on this branch. The Release workflow publishes the gem.
  2. active_cart and active_fulfillment change their dependency from amazon_sp_clients to sp_api_clients, '~> 2.0', and release. For active_fulfillment this is a one-line change with no code change: 2.0.0 still ships the whole v1 API under the same constant and require paths.
  3. Hosts (11 beagle services), one commit each: delete the gem 'amazon_sp_clients', git: ... line and take both releases from step 2. Then bundle list | grep amazon_sp_clients must print nothing. beagle_catalog also drops its tag: 'v1.7.0'.
  4. Merge this PR. Once no lock reads master, master stops being a live wire.

Why one commit in step 3: Bundler sees amazon_sp_clients (git, 1.7.0) and sp_api_clients (2.0.0) as two different gems and installs both side by side. Both ship lib/amazon_sp_clients.rb, so load path order decides which one a require picks. RubyGems has no way to declare a conflict. With one gem name, a version mismatch failed loudly at bundle install; that safety net is gone, and the grep replaces it.

CI moves to GitHub Actions in a later commit. The .gitmodules
listed a typo'd path and a submodule that was never registered.
MIT license, real author email, and a Ruby 3.3 floor (nothing
older runs in production). Adds RuboCop as a dev dependency.
spec_helper now requires webmock/rspec and timecop once and
returns Timecop after every example (time stayed frozen at
2018-09-01 for the rest of the suite). Examples that mutate the
thread-local default config or Aws.config now reset them, which
random-order runs proved necessary.
Pins current behavior before the modernization refactors:
Configuration region/endpoint/host logic, the RaiseError
status-to-error map and message formats, ApiClient request
building and deserialization, Session auth and RDT caching
(incl. grantless), TokenExchangeAuth, the module helpers,
ApiResponse/ApiError, and the 14 used sp_* require paths with
their API class names.

The token_exchange_auth spec also fixes a bug where a
Configuration was passed as the refresh_token argument, so it
tested the wrong config.

Known bugs from the modernization plan stay unpinned on purpose;
their fixes start with failing tests. Suite grows from 21 to 191
examples and passes in random order.
NewCops enabled; vendor/, generated sp_* shims, and models repo
excluded; Metrics off for now. Remaining offenses live in
.rubocop_todo.yml to shrink over time.

Only safe (semantics-preserving) corrections were applied to the
handwritten code, verified by the characterization suite. This
also drops a dangling 'private' in uploader.rb.
RSpec on Ruby 3.3 and 3.4, plus a RuboCop job.
Regeneration needed Java plus a working swagger-codegen install, and
the Rakefile was broken in three ways (missing amzn-models checkout,
undeclared active_support, undocumented fd). The new generator under
lib/generator is plain Ruby + ERB with no extra dependencies, modeled
on the peddler gem's generator.

Reproducibility: the generator clones Amazon's official spec repo into
amzn-models/ (gitignored) and checks out the exact revision recorded
in selling-partner-api-models.sha. `rake generate` regenerates at that
pin; `rake generate:update` is the only path that advances it.

The generator emits only what actually loads: the API classes, the
vendor entry files, and the sp_*.rb require shims. It reproduces the
old swagger-codegen output byte for byte (except the header comment),
including its quirks, so the regeneration diff stays reviewable.
codegen-config.yml now lists only the 14 modules with real consumers;
the 17 unused vendored modules stay untouched until the planned
dead-code removal.

The mustache templates and the old codegen rake tasks are gone. The
generator is excluded from the shipped gem.
Output of `bundle exec rake generate` at the pinned spec revision
(3822f16, 2025-02-26, the newest commit before the previous
regeneration date). The API classes are unchanged except the header
comment. The vendor entry files lose the commented-out model requires,
and the shims lose a trailing space and gain a final newline.
CI now runs `rake generate:verify`: it regenerates at the pinned spec
revision and fails when the committed files drift from the generator
output. A nightly workflow runs `rake generate:update` and opens a PR
(via a machine-user PAT, so the PR triggers CI) whenever Amazon
changes the specs.
No consumer requires these modules. A sweep of every Dropstream repo
found only active_cart and active_fulfillment using the gem in code,
and together they use 14 modules — the ones in codegen-config.yml.

Removes the vendor/ directories and their sp_*.rb require shims for:
catalog_items_2020, catalog_items_v0, fba_inbound,
fulfillment_inbound_v0, merchant_fulfillment_v0, messaging,
notifications, product_fees_v0, product_pricing_v0, sales, sellers,
services, shipping, solicitations, uploads_2020, vdf_payments_v1,
vdf_transactions_v1.
The old swagger-codegen emitted a full standalone gem per module.
Only the entry file and the API classes were ever loaded; the new
generator emits exactly those. Delete the rest: models/ (1,037 files
with zero requires), per-vendor api_client/configuration/api_error/
version (62 of them require the undeclared typhoeus gem), and the
gem scaffolding (README, Rakefile, Gemfile, gemspec, git_push.sh,
.rspec, .rubocop.yml, .gitignore, .swagger-codegen*).

Each vendor module now holds only lib/sp_<name>.rb and
lib/sp_<name>/api/*.rb — what `rake generate` writes.
None of this was reachable:

- ServiceError: never raised; the middleware raises Faraday errors.
- Configuration: basic_auth_token (calls undefined username/password),
  the host= writer (the host reader recomputes from region, so writes
  never surfaced), and attrs only the old typhoeus client read:
  temp_folder_path, verify_ssl, verify_ssl_host, ssl_ca_cert,
  cert_file, key_file, params_encoding, inject_format,
  force_ending_format, plus the dead @refresh_token ivar.
- ApiClient: typo'd @api_req_otps, the typhoeus-era req_opts keys
  that call_api never passes to Faraday, the commented-out
  update_params_for_auth! and download_file remains, and the 'File'
  return path reading a never-assigned @tempfile.
- RaiseError: :sts in VALID_SERVICE — no caller ever passes it (STS
  goes through aws-sdk, not this middleware). Pinned by a new spec.
Amazon dropped the SigV4 requirement for SP-API in October 2023;
only the LWA access token is needed. This deletes the SigV4
middleware, the eager synchronous AssumeRole call that every
new_session made for nothing, and the aws-sdk-core / aws-sigv4
dependencies.

The Configuration attrs consumers still write (access_key,
secret_key, role_arn, credentials_provider) stay as accepted-but-
ignored, marked deprecated, so 1.8.0 is a drop-in upgrade. They go
away in 2.0.

Behavior changes:
- new_session no longer makes an STS network call.
- STS failures can no longer surface as laundered
  Faraday::ForbiddenError.
- Session#role_credentials and Session#credentials_provider are
  gone; no consumer reads them (verified in active_cart,
  active_fulfillment, beagle_event_logger).

Also: raise_error.rb and configuration.rb now require faraday and
logger themselves (aws-sdk-core and the signer file used to load
them first), and webmock is bumped because Ruby 3.4 no longer ships
base64 — it previously arrived via aws-sdk-core.
Session#refresh tested @grantles, a misspelling of @grantless that
is never assigned, so the branch never ran. An expired grantless
session fell through to the refresh-token branch, which is also a
no-op there (client_credentials responses carry no refresh token),
and kept using the stale access token until Amazon returned 403.
call_api passed query params as the second positional argument for
every verb. Faraday's get/delete accept (url, params), but post/put/
patch accept (url, body) — the params landed in the body slot and
were then overwritten by the real body. Every POST/PUT/PATCH with
query params silently lost them: 21 generated operations, including
put_listings_item and patch_listings_item, whose required
marketplaceIds is a query param.

Set params on the request object instead, which works for all verbs.
build_request merged the generated APIs' header_params (per-op
Accept, Content-Type) into req_opts[:headers], but call_api never
read it — only the connection defaults and the x-amz pair reached
the wire. Harmless for Content-Type (every operation happens to use
application/json, same as the default) but the Accept header was
never sent at all, including application/hal+json where specs ask
for it.

Merge req_opts[:headers] into the request before the x-amz pair.
RaiseError copied the full request headers and body into
Faraday::Error#response, which consumers log. That exposed the LWA
or restricted data token (x-amz-access-token), SigV4-era
authorization and x-amz-security-token headers, and — for the token
service — client_secret and refresh_token in the form body.

Replace those header values with [FILTERED] and filter the whole
request body for the token service. Response details and non-token
request bodies are unchanged; consumers only read e.message.
config.endpoint = 'zz' used to corrupt @region to nil and then die
with KeyError 'key not found: nil' from deep inside region=. Now
set_region_by_endpoint raises ArgumentError naming the bad value
before touching any state.
The envelope guard accepted string 'payload'/'errors' keys but the
branch read only symbol keys, so a string-keyed hash matched the
guard and came out as a nil payload. Envelope keys are always
symbols in practice (deserialize symbolizes), so drop the string
checks: a string-keyed hash now lands intact in payload like any
other unrecognized shape.
MARKETPLACE_ENDPOINT_MAP and set_region_by_endpoint both know the
Saudi marketplace (A17E79C6D8DWNP, 'sa'), but MARKETPLACE_IDS was
missing the key. A spec now keeps the two constants in sync.
Only ApiClient honored config.timeout. TokenExchangeAuth, Uploader,
Downloader, and download_report_document built Faraday connections
with no timeout at all, so a hung LWA or S3 endpoint blocked the
caller forever. All connections now pass the configured timeout
(default 60s).
Expiry was stored as a strftime string and re-parsed with strptime
on every check — needless work, and a wrong-zone parse would shift
the comparison silently. Store the Time directly and drop the
String branch. Also renames the consistently misspelled
@restricted_data_token_expirest_at ivar.
Changelog gains the 1.8.0 section (deletions, bug fixes, deprecated
AWS attrs). README drops the AWS IAM settings from the usage example
and explains the deprecation. Note for consumers: active_cart and
active_fulfillment pin '~> 1.7.0' in their gemspecs and must bump it
to pick this release up.
The version bump commit changed the gemspec without re-locking, so
CI's frozen-mode bundle install failed with 'the gemspecs for path
gems changed'. Re-locking also floats multi_xml to 0.9.1 (its
bigdecimal bound widened); the suite is green with it.
Follow-ups to the redaction commit, from review:

- Redact on a Faraday::Utils::Headers copy instead of converting to
  a plain Hash, so header lookup in the error payload stays
  case-insensitive as before.
- Strip the query string from uploads-service urls in the payload.
  Those are presigned S3 urls whose query string is a live,
  time-limited credential; it leaked into consumer logs on any
  S3 error. The path stays for debugging; spapi urls keep their
  query.
- Pin that non-sensitive request headers survive redaction.
set_endpoint_by_marketplace_id assigned @marketplace_id before the
fetch that validates it, so an unknown id raised KeyError but left
the config holding the bogus id. Look the endpoint up first.
active_cart and active_fulfillment set access_key, secret_key,
role_arn, and credentials_provider unconditionally at boot. Nothing
in the suite exercised the writers after the SigV4 removal, so a
future cleanup could delete them with a green build and break every
consumer. This spec keeps them writable and inert until 2.0.
The gem carried its CI config, Rakefile, bin/ scripts, lockfile,
dotfiles and the generator config. Consumers need none of it. The
built gem now holds lib/, vendor/, the gemspec and the top-level docs.
The gemspec had no allowed_push_host, so `rake release` would have
pushed this public repo's gem to rubygems.org. Set the Dropstream
registry as the only push host, name the repository the package
belongs to, and fill in the source and changelog links.

Disable the bundler `release` task. Releases run from a version tag
in CI, not from a laptop. A spec pins the push host and the list of
shipped files.
A push of a v* tag runs the suite, builds the gem, pushes it to the
Dropstream registry on GitHub Packages with the workflow token, and
opens a GitHub Release with the CHANGELOG entry for that version.

The job stops when the tag does not match AmazonSpClients::VERSION or
when CHANGELOG.md has no entry for it, so a wrong tag publishes
nothing. GitHub Packages rejects a version it already has, so a failed
release needs a new version, not a re-run.
README: install from the GitHub Packages source block, pin a tag if a
git source stays, and the release steps (version, re-lock, changelog
date, annotated tag). CLAUDE.md: the release path, and why master must
not take 2.0.0 before consumers move off git sources.
JSON.generate calls to_json with a State, and ActiveSupport's to_json
then defers to the stdlib. So a Time in a request body went out as
Time#to_s ("2026-08-31 15:50:50 UTC") instead of ISO 8601. Amazon
rejects that in createReport's dataStartTime, which active_cart's
returns import sends as a TimeWithZone. v1 called to_json; V2 does
again, so a consumer's encoders apply.
An LWA 5xx dropped its error and error_description, so the message
read "503 (no error details)" and a consumer's retry rule on the text
could not match. A 4xx from API Gateway carries {"message": ...}
instead of an errors array and read the same way. Both now show the
text the server sent.
A string-keyed or url-less Hash raised KeyError from deep inside the
helper. It is a caller error: the payload must be ApiResponse#payload,
with symbol keys. Raise ArgumentError saying so, with the keys given.
Every consumer that downloads a restricted report builds the same
GET /reports/2021-06-30/documents/<id> resource by hand, path literal
included. RDT.report_document(id) returns it. A spec checks the path
against the one the generated getReportDocument requests, so the
preset cannot drift when the module moves.
Rows the first V2 consumer needed and the table lacked: transport
failures that fell through v1's last rescue are ConnectionError now,
the message text changed and is not a contract, an empty array is
still sent as an empty value, and upload_feed_document returns nil.
The gem goes public. The tag workflow now pushes to rubygems.org
through Trusted Publishing: the job's OIDC token is traded for a
short-lived key, so no API key is stored anywhere. rubygems.org must
trust the workflow first; README, "Releasing", has the one-time setup.

Consumers install a version from rubygems.org instead of a git branch.
The push host in the gemspec changes with it; the MFA rule stays.
The README opens with the status: large changes in progress,
interfaces may move between releases, and peddler is the recommended
client for production use. The gemspec summary and description say the
same, so the rubygems.org page does too.
The gem goes public on rubygems.org, and "amazon" is Amazon's name,
not ours. Only the gem name changes: the AmazonSpClients namespace,
the require paths, vendor/ and the generator stay as they are, so a
consumer changes one Gemfile line. lib/sp_api_clients.rb makes
Bundler's automatic require work for the new name. The V2 default
User-Agent names the gem, so it follows.

The lockfiles, the release workflow and the rubocop todo follow the
gemspec file name.
The install line, the rubygems.org link and the trusted publisher
form use the new gem name. The repository keeps its old name, so the
form asks for both. The README states that the project is not
affiliated with Amazon.
examples/auth_check.rb runs live checks with nothing but the public
gem: a made-up token is rejected, a real access token is accepted
without any AWS signature, and a refresh needs the LWA client id and
secret. It prints what each result means, so someone who does not know
the gem can settle the question "what do we need in production" in a
minute.

Credentials come from environment variables only. The endpoint
defaults to na and the marketplace to amazon.com. Loaded into a
process that already runs under Bundler, the script uses the gem when
that bundle has it, falls back to Net::HTTP with the same request
otherwise, and never calls exit. Run from a shell it installs the
public gem. examples/ stays out of the built gem.
The repository and the gem are public. Comments named the applications
that consume the gem; the facts hold for any consumer.
rake release:prepare[X.Y.Z] refuses a dirty tree, a bad version, an
existing tag or a missing CHANGELOG entry, then runs the suite, sets
the version, dates the CHANGELOG heading, relocks the three lockfiles,
commits and creates the annotated tag. It stops before the push: the
tag push is the publish, and rubygems.org never accepts a version
number twice, so the suite runs before anything is tagged.

tasks/ stays out of the built gem.
README, "Releasing" lists the three steps a release takes now;
CLAUDE.md gets the command.
The Orders API v2026-01-01 model exists only upstream, so the pin
moves from 3822f16 (2025-02-26) to 3659f96 (2026-08-26) before that
module can be added.

Regeneration changes 16 files, all of it description text except one
new optional query parameter, enableContentEncodingUrlHeader, on
getFeedDocument and getReportDocument. Upstream now marks every
Orders v0 operation as deprecated.
Every generated V2 method takes rdt: because the specs do not say
which operations are restricted. The Orders API v2026-01-01 never
uses restricted data tokens, so advertising the keyword there would
be misleading.

A module sets `rdt: false` in codegen-config.yml to drop the keyword,
its doc line and the request argument. The default is unchanged, and
regeneration of the 14 modules is byte-identical.
Amazon's next Orders API replaces the seven v0 read operations with
searchOrders and getOrder, selects PII and other blocks with
includedData instead of restricted data tokens, pages with
paginationToken, and returns the body without a payload wrapper.

The module is V2 only: `client.orders_2026` gives `Orders2026` with
`search_orders` (every parameter optional) and `get_order`. Neither
takes rdt:. `ApiResponse#payload` is the whole body, so consumers
read `payload[:orders]` and `payload.dig(:pagination, :nextToken)`.
Orders v0 stays in both v1 and V2; Amazon marks it deprecated.
README gains an "Orders API v2026-01-01" section with what a caller
has to know: two operations, includedData instead of separate PII
calls, no restricted data token, paginationToken with a 24 hour life,
renamed status values and the lower default rate. The V2 example and
the migration table use the new module; the code generation section
explains `rdt: false`.

CHANGELOG gets the undated 2.1.0 entry for the release task to stamp.
@nina-saule
nina-saule marked this pull request as ready for review September 8, 2026 10:49
@nina-saule
nina-saule requested a review from wkrsz September 11, 2026 06:51
@nina-saule
nina-saule merged commit 06020bf into master Sep 18, 2026
22 checks passed
@nina-saule
nina-saule deleted the version-upgrade branch September 18, 2026 10:20
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.

2 participants