Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions lib/hyperline/collection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
17 changes: 16 additions & 1 deletion lib/hyperline/resources/subscriptions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -92,7 +106,8 @@ def list_templates(**params)
data: response['data'],
meta: response['meta'],
resource: self,
params: params
params: params,
method: :list_templates
)
end

Expand Down
74 changes: 74 additions & 0 deletions spec/hyperline/collection_spec.rb
Original file line number Diff line number Diff line change
@@ -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
18 changes: 15 additions & 3 deletions spec/hyperline/resources/subscriptions_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading