From 8e30a850f2332925fbb44bae2dfa19621677ac3d Mon Sep 17 00:00:00 2001 From: Gaston Rey Date: Fri, 21 Aug 2026 14:27:41 +0000 Subject: [PATCH 1/2] fix: list subscriptions through v2 and paginate the method that was called Two bugs the 0.2.1 changelog listed as known issues, one of which was worse than recorded. `#list` used base_path, which is `/v1/subscriptions` -- a route that does not exist. It answers 404 "Route not found", so the method always raised and the "inconsistent with get/update" note undersold it. base_path stays on v1 because the action sub-paths genuinely live there: POST /v1/subscriptions/{id}/update is what the API accepts, and so are cancel, pause, activate and the rest. Only the collection GET moves. The existing example stubbed `/v1/subscriptions`, so it asserted the bug rather than the behaviour and passed either way. `Collection#next_page` re-issued the resource's default `#list` no matter which method had produced the page, so a page from a custom one -- Subscriptions #list_templates is the only current case -- paged into the wrong endpoint. For subscriptions that endpoint was the 404 above. Collection now remembers the method it came from. Verified against the sandbox: list returns 29 subscriptions, next_page returns a disjoint page, auto_paginate walks all 29, and list_templates answers 0 rows with next_page nil rather than reaching for another endpoint. --- lib/hyperline/collection.rb | 8 +- lib/hyperline/resources/subscriptions.rb | 17 ++++- spec/hyperline/collection_spec.rb | 74 +++++++++++++++++++ .../hyperline/resources/subscriptions_spec.rb | 18 ++++- 4 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 spec/hyperline/collection_spec.rb diff --git a/lib/hyperline/collection.rb b/lib/hyperline/collection.rb index 855c0f1..c809004 100644 --- a/lib/hyperline/collection.rb +++ b/lib/hyperline/collection.rb @@ -6,11 +6,15 @@ class Collection attr_reader :data, :meta - def initialize(data:, meta:, resource:, params:) + # `method` is the resource method that produced this page. It matters because next_page has to + # re-issue the *same* call: defaulting to #list meant a page from a custom list method, such as + # Subscriptions#list_templates, paged into the resource's default endpoint instead. + def initialize(data:, meta:, resource:, params:, method: :list) @data = data @meta = meta @resource = resource @params = params + @method = method end def each(&block) @@ -36,7 +40,7 @@ def next_page? def next_page return nil unless next_page? - @resource.list(**@params, skip: skipped + taken) + @resource.public_send(@method, **@params, skip: skipped + taken) end def each_page diff --git a/lib/hyperline/resources/subscriptions.rb b/lib/hyperline/resources/subscriptions.rb index c4802ac..5f8bf39 100644 --- a/lib/hyperline/resources/subscriptions.rb +++ b/lib/hyperline/resources/subscriptions.rb @@ -3,6 +3,20 @@ module Hyperline module Resources class Subscriptions < BaseResource + # /v1/subscriptions does not exist -- it answers 404 "Route not found" -- so listing goes + # through the v2 path that search already uses. base_path stays on v1 because the action + # sub-paths below genuinely live there: POST /v1/subscriptions/{id}/update is what the API + # accepts, and so are cancel, pause and the rest. + def list(**params) + response = request(:get, search_path, params) + Collection.new( + data: response['data'], + meta: response['meta'], + resource: self, + params: params + ) + end + def get(id) request(:get, "/v2/subscriptions/#{id}") end @@ -92,7 +106,8 @@ def list_templates(**params) data: response['data'], meta: response['meta'], resource: self, - params: params + params: params, + method: :list_templates ) end diff --git a/spec/hyperline/collection_spec.rb b/spec/hyperline/collection_spec.rb new file mode 100644 index 0000000..67071f7 --- /dev/null +++ b/spec/hyperline/collection_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Hyperline::Collection do + let(:client) { build_client } + + def page_body(skipped:, taken:, total:, ids:) + { meta: { total: total, taken: taken, skipped: skipped }, data: ids.map { |i| { id: i } } } + end + + describe '#next_page' do + it 're-issues the default list for a page that came from it' do + first = stub_request(:get, 'https://api.hyperline.co/v1/features') + .with(query: { take: 1 }) + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: page_body(skipped: 0, taken: 1, total: 2, ids: %w[a]).to_json) + second = stub_request(:get, 'https://api.hyperline.co/v1/features') + .with(query: { take: 1, skip: 1 }) + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: page_body(skipped: 1, taken: 1, total: 2, ids: %w[b]).to_json) + + page = client.features.list(take: 1) + + expect(page.next_page.data.first['id']).to eq('b') + expect(first).to have_been_requested + expect(second).to have_been_requested + end + + # The regression this exists for: next_page used to call the resource's default #list whatever + # method had produced the page, so a custom one paged into the wrong endpoint -- and for + # subscriptions that endpoint does not even exist. + it 'stays on the custom list method that produced the page' do + stub_request(:get, 'https://api.hyperline.co/v1/subscriptions/templates') + .with(query: { take: 1 }) + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: page_body(skipped: 0, taken: 1, total: 2, ids: %w[tpl_a]).to_json) + second = stub_request(:get, 'https://api.hyperline.co/v1/subscriptions/templates') + .with(query: { take: 1, skip: 1 }) + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: page_body(skipped: 1, taken: 1, total: 2, ids: %w[tpl_b]).to_json) + + page = client.subscriptions.list_templates(take: 1) + + expect(page.next_page.data.first['id']).to eq('tpl_b') + expect(second).to have_been_requested + end + + it 'is nil on the last page' do + stub_api(:get, '/v1/features', query: { take: 1 }, + body: page_body(skipped: 0, taken: 1, total: 1, ids: %w[a])) + + expect(client.features.list(take: 1).next_page).to be_nil + end + end + + describe '#auto_paginate' do + it 'walks every page of a custom list method' do + stub_request(:get, 'https://api.hyperline.co/v1/subscriptions/templates') + .with(query: { take: 1 }) + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: page_body(skipped: 0, taken: 1, total: 2, ids: %w[tpl_a]).to_json) + stub_request(:get, 'https://api.hyperline.co/v1/subscriptions/templates') + .with(query: { take: 1, skip: 1 }) + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: page_body(skipped: 1, taken: 1, total: 2, ids: %w[tpl_b]).to_json) + + seen = [] + client.subscriptions.list_templates(take: 1).auto_paginate { |row| seen << row['id'] } + + expect(seen).to eq(%w[tpl_a tpl_b]) + end + end +end diff --git a/spec/hyperline/resources/subscriptions_spec.rb b/spec/hyperline/resources/subscriptions_spec.rb index c0f1ef0..9e47102 100644 --- a/spec/hyperline/resources/subscriptions_spec.rb +++ b/spec/hyperline/resources/subscriptions_spec.rb @@ -7,15 +7,27 @@ let(:subscriptions) { client.subscriptions } describe '#list' do - it 'returns a collection of subscriptions' do - stub_api(:get, '/v1/subscriptions', body: fixture('subscriptions_list')) + # This stubbed /v1/subscriptions, which does not exist -- the real endpoint answers 404 "Route + # not found", so the example asserted the bug rather than the behaviour. + it 'lists through the v2 path' do + stub = stub_api(:get, '/v2/subscriptions', body: fixture('subscriptions_list')) result = subscriptions.list + expect(stub).to have_been_requested expect(result).to be_a(Hyperline::Collection) - expect(result.data.length).to eq(1) expect(result.data.first['id']).to eq('sub_001') end + + # base_path stays on v1 because the action sub-paths genuinely live there. + it 'leaves the action paths on v1' do + stub = stub_request(:post, 'https://api.hyperline.co/v1/subscriptions/sub_001/cancel') + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, body: '{}') + + subscriptions.cancel('sub_001') + + expect(stub).to have_been_requested + end end describe '#get' do From 1fb79c1a654da2a871193cc290ab58625e4e7879 Mon Sep 17 00:00:00 2001 From: Gaston Rey Date: Fri, 21 Aug 2026 14:32:01 +0000 Subject: [PATCH 2/2] docs: record the unreleased changes and retire the fixed known issues Two PRs have landed since 0.2.1 without a changelog entry, and the "Known issues" list under 0.2.1 documents the two bugs the previous commit fixes -- so as it stands the file tells the next reader that a method which now works still cannot return. 0.2.1 keeps its list unchanged: it was accurate for that release. The Unreleased section says which of them are fixed, and carries the API contracts that were established against the sandbox rather than inferred -- archive-before-delete, PUT rather than POST for archiving, and the bare array from the product-features endpoint -- because none of them are in Hyperline's docs. The remaining known issue is the one this run did not solve: retry policy is a constant here and configuration there, which is why a consumer needing a hard timeout or a retry on 404 still wraps #request itself. --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b426cef..7535096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,45 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Added + +- Features resource (list, get, create, update, archive, delete), keyed by the feature's `code` + rather than an opaque id +- `Products#features`, `#link_feature`, `#unlink_feature` and `#archive` +- `ConflictError` (409) and `UnprocessableEntityError` (422); both previously arrived as the + generic `ApiError`, indistinguishable from an unrecognised status +- `idempotency_key:` on every mutating method. Hyperline honours the standard `Idempotency-Key` + header — the same key with the same body replays the first response, while the same body with + no key applies twice — so a write is retried (429 and 5xx, bounded) only when a caller supplies + one. `X-Idempotency-Key` is ignored by the API. + +### Fixed + +- Both issues listed as known under 0.2.1 below. `Subscriptions#list` used `/v1/subscriptions`, + which answers 404 `Route not found`, so the method could never return; listing now goes through + the v2 path. `base_path` deliberately stays on v1, because the action sub-paths genuinely live + there. And `Collection#next_page` re-issued the resource's default `#list` whatever method had + produced the page, so a page from a custom list method paged into the wrong endpoint. +- `Subscriptions#update_operation`'s documented payload omitted the required `payment_schedule`, + so the example in the comment and in the spec returned 400. The verified shape and its enum + values are now recorded on the method. + +### Notes + +- A feature must be archived before it can be deleted: `DELETE` on an active one answers 400 + `Cannot delete a feature that is not archived`. +- Archiving is a `PUT` for both products and features; `POST` answers 404 `Route not found`. +- `GET /v1/products/{id}/features` answers with a bare array, with no `meta`/`data` envelope. + +### Known issues + +- The write retry budget is a hardcoded constant while `Configuration#max_retries` governs the + read retry, so there are two budgets and only one is reachable. Retry policy is not yet + configurable per condition, which is why consumers that need a hard timeout or retries on 404 + still wrap `#request` themselves. + ## [0.2.1] - 2026-07-29 ### Fixed