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
2 changes: 1 addition & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1338,7 +1338,7 @@ Three response shapes exist across the API:
| **Keyed array** | `{"events": [item, ...]}` | Extract items from named key |
| **Wrapped response** | `{"wrapper_field": ..., "events": [item, ...]}` | Return wrapper fields + paginated items from named key |

The variant is determined at code-generation time from the OpenAPI response schema and encoded in the generated service method (via `x-basecamp-pagination` extension or response schema analysis).
The variant is determined at code-generation time from the OpenAPI response schema and encoded in the generated service method (via `x-basecamp-pagination` extension or response schema analysis). The extension's `style` decides whether a walk is generated at all: `link` produces the Link-following, page-flattening variants above, and `cursor` produces none — one call answers one page carrying its own opaque position, which is the only thing a consumer may persist, and flattening would swallow every one. Every service generator refuses any other value rather than reading it as unpaginated.

**Wrapped response pagination:** For endpoints that return a wrapper object with a paginated array inside (e.g., `personProgress` returns `{person, events: [...]}`), the generated service method paginates the embedded array while preserving the wrapper fields from the first page. The `paginate` algorithm above handles item extraction; the wrapping/unwrapping is a code-generation concern, not a transport concern. See `typescript/src/generated/services/reports.ts` and `go/pkg/basecamp/timeline.go` for reference implementations.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,38 @@ class OperationParser(private val api: OpenApiParser) {
val returnsVoid = isVoidResponse(responses)
val isMutation = httpMethod != "GET"
val resourceType = extractResourceType(operationId)
val hasPagination = operation.containsKey("x-basecamp-pagination")
val paginationKey = operation["x-basecamp-pagination"]?.jsonObject?.get("key")?.jsonPrimitive?.content
// Auto-pagination is the "link" style alone. The generated method follows
// Link: rel="next" and flattens the whole walk into one array, which is right
// when the only thing a page carries is more items.
//
// The "cursor" style (the event feed's PollEvents/PollInbox) is declared so
// the catalogue and behavior model describe the operation honestly, and
// deliberately generates no auto-pagination: each call returns ONE page
// carrying its own opaque `position`, which is the durable checkpoint a
// consumer persists after accepting that page. Flattening the walk would
// swallow every intermediate position and leave a crashed consumer with
// nothing to resume from.
// `?.jsonObject` throws on JsonNull, which is not Kotlin null -- a literal
// "x-basecamp-pagination": null would crash here where the other five
// generators read it as unpaginated. Narrow to a real object first.
val paginationExtension = operation["x-basecamp-pagination"] as? JsonObject
val paginationStyle = paginationExtension?.get("style")?.jsonPrimitive?.content
// Only "link" and "cursor" are implemented. Anything else -- a typo, or
// the "page" style the trait used to advertise -- must fail loudly: read
// as "not paginated" it would silently ship a method that never walks.
require(paginationExtension == null || paginationStyle in setOf("link", "cursor")) {
"$operationId: unsupported pagination style $paginationStyle (expected \"link\" or \"cursor\")"
}
Comment thread
jorgemanrubia marked this conversation as resolved.
val hasPagination = paginationStyle == "link"
Comment thread
jorgemanrubia marked this conversation as resolved.
// Link-style only. findUnderlyingEntitySchema unwraps an envelope whenever
// the key is set, and OperationParser:315 calls it without checking
// hasPagination -- so a cursor operation would be typed as the item under
// its key instead of the envelope the wire actually sends.
val paginationKey = if (paginationStyle == "link") {
paginationExtension?.get("key")?.jsonPrimitive?.content
} else {
null
}

// Note: wrapped pagination (paginationKey != null) does NOT force returnsArray.
// The response is an object with a paginated array inside — handled separately
Expand Down
26 changes: 24 additions & 2 deletions python/scripts/generate_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -499,9 +499,31 @@ def parse_operation(
returns_array = (resolved_response or {}).get("type") == "array"

# Pagination
# Auto-pagination is the "link" style alone. The generated method follows
# Link: rel="next" and flattens the whole walk into one array, which is right
# when the only thing a page carries is more items.
#
# The "cursor" style (the event feed's PollEvents/PollInbox) is declared so
# the catalogue and behavior model describe the operation honestly, and
# deliberately generates no auto-pagination: each call returns ONE page
# carrying its own opaque `position`, which is the durable checkpoint a
# consumer persists after accepting that page. Flattening the walk would
# swallow every intermediate position and leave a crashed consumer with
# nothing to resume from.
pagination = operation.get("x-basecamp-pagination")
has_pagination = pagination is not None
pagination_key = (pagination or {}).get("key")
pagination_style = (pagination or {}).get("style")
# Only "link" and "cursor" are implemented. Anything else -- a typo, or the
# "page" style the trait used to advertise -- must fail loudly: read as "not
# paginated" it would silently ship a method that never walks.
if pagination is not None and pagination_style not in ("link", "cursor"):
raise ValueError(
f"{operation_id}: unsupported pagination style {pagination_style!r} (expected 'link' or 'cursor')"
)
has_pagination = pagination_style == "link"
# Link-style only: the key drives envelope unwrapping in type resolution, and
# a cursor operation must be typed as its envelope, not as the item under the
# key. Withheld at the source rather than gated at each consumer.
pagination_key = (pagination or {}).get("key") if pagination_style == "link" else None

return {
"operation_id": operation_id,
Expand Down
68 changes: 68 additions & 0 deletions python/tests/test_generate_services_pagination_style.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""The pagination trait's `style` is load-bearing in this generator.

`has_pagination` decides whether the emitted method follows `Link: rel="next"`
and flattens the walk. It used to key off the trait's mere presence, so the
`cursor` style the trait documents would have produced exactly the walk it
exists to avoid. It now keys off `style`, which makes an unrecognised value
dangerous in a new way: read as "not paginated" it would silently ship a method
that never walks. So the parse refuses anything that is not `link` or `cursor`,
and these cases pin both halves.
"""

from __future__ import annotations

import sys
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from generate_services import parse_operation # noqa: E402


def _operation(style):
operation = {
"operationId": "PollWidgets",
"responses": {"200": {"content": {"application/json": {"schema": {"type": "object"}}}}},
}
if style is not None:
operation["x-basecamp-pagination"] = {"style": style, "key": "events"}
return operation


def _parse(style):
return parse_operation("/{accountId}/widgets.json", "get", _operation(style), {})


def test_link_style_auto_paginates():
assert _parse("link")["has_pagination"] is True


def test_cursor_style_does_not_auto_paginate():
# One call is one page, carrying its own position; flattening would swallow
# every position a consumer could resume from.
assert _parse("cursor")["has_pagination"] is False


def test_no_trait_at_all_is_simply_unpaginated():
assert _parse(None)["has_pagination"] is False


def test_the_key_is_withheld_from_a_cursor_operation():
# The key drives envelope unwrapping in type resolution: set, it turns
# `{events: [...], position}` into the item type. A cursor operation is
# typed as its envelope, so the key must not reach those consumers at all.
assert _parse("link")["pagination_key"] == "events"
assert _parse("cursor")["pagination_key"] is None


@pytest.mark.parametrize("style", ["page", "linkk", "Link", "", None])
def test_an_unrecognised_style_is_refused_rather_than_read_as_unpaginated(style):
operation = _operation("link")
if style is None:
del operation["x-basecamp-pagination"]["style"]
else:
operation["x-basecamp-pagination"]["style"] = style

with pytest.raises(ValueError, match="unsupported pagination style"):
parse_operation("/{accountId}/widgets.json", "get", operation, {})
2 changes: 1 addition & 1 deletion ruby/lib/basecamp/generated/metadata.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://basecamp.com/schemas/sdk-metadata.json",
"version": "1.0.0",
"generated": "2026-09-16T08:42:38Z",
"generated": "2026-09-16T11:03:30Z",
"operations": {
"GetAccount": {
"retry": {
Expand Down
2 changes: 1 addition & 1 deletion ruby/lib/basecamp/generated/types.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

# Auto-generated from OpenAPI spec. Do not edit manually.
# Generated: 2026-09-16T08:42:38Z
# Generated: 2026-09-16T11:03:30Z

require "json"
require "time"
Expand Down
1 change: 0 additions & 1 deletion ruby/scripts/generate-metadata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ def extract_operation_metadata(operation)
if (pagination = operation['x-basecamp-pagination'])
metadata['pagination'] = {
'style' => pagination['style'],
'pageParam' => pagination['pageParam'],
'totalCountHeader' => pagination['totalCountHeader'],
'maxPageSize' => pagination['maxPageSize']
}.compact
Expand Down
27 changes: 24 additions & 3 deletions ruby/scripts/generate-services.rb
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,14 @@ def parse_operation(path, method, operation)
# Extract body parameters from schema
body_params = extract_body_params(body_schema_ref)

# Only "link" and "cursor" are implemented. Anything else -- a typo, or the
# "page" style the trait used to advertise -- must fail loudly: read as "not
# paginated" it would silently ship a method that never walks.
pagination_style = operation.dig('x-basecamp-pagination', 'style')
if operation['x-basecamp-pagination'] && !%w[ link cursor ].include?(pagination_style)
raise "#{operation_id}: unsupported pagination style #{pagination_style.inspect} (expected \"link\" or \"cursor\")"
end

# Check response
success_response = operation.dig('responses', '200') || operation.dig('responses', '201')
response_schema = success_response&.dig('content', 'application/json', 'schema')
Expand Down Expand Up @@ -471,8 +479,18 @@ def parse_operation(path, method, operation)
returns_array: returns_array,
returns_bare_array: returns_bare_array,
is_mutation: http_method != 'GET',
has_pagination: !!operation['x-basecamp-pagination'],
pagination_key: operation.dig('x-basecamp-pagination', 'key')
# Auto-pagination is the "link" style alone: the generated method follows
# Link: rel="next" and flattens the walk into one array. The "cursor"
# style is declared for the catalogue and generates none -- each call
# returns one page carrying its own opaque position, and flattening would
# swallow every intermediate one.
has_pagination: pagination_style == 'link',
# Carried separately because has_pagination is false for cursor, and the
# bare-array gate below would otherwise walk it anyway.
cursor_pagination: pagination_style == 'cursor',
# Link-style only: the key drives envelope unwrapping, and a cursor
# operation must be typed as its envelope rather than the item under it.
pagination_key: (pagination_style == 'link' ? operation.dig('x-basecamp-pagination', 'key') : nil)
}
end

Expand Down Expand Up @@ -658,7 +676,10 @@ def generate_service(service)
def generate_method(op, service_name:)
lines = []

is_paginated = (op[:returns_array] || op[:has_pagination]) && !op[:pagination_key]
# A cursor operation never walks, whatever its response shape: has_pagination
# is already false for it, but a bare-array response would otherwise send it
# down the Link-following path through returns_array.
is_paginated = !op[:cursor_pagination] && (op[:returns_array] || op[:has_pagination]) && !op[:pagination_key]
is_wrapped_paginated = op[:has_pagination] && op[:pagination_key]

# Method signature (paginated operations gain a trailing max_items: kwarg)
Expand Down
96 changes: 96 additions & 0 deletions ruby/test/scripts/generate_services_pagination_style_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# frozen_string_literal: true

# The pagination trait's `style` is load-bearing in this generator, and Ruby is
# the one place where it is not the only gate.
#
# `has_pagination` decides whether the emitted method follows `Link: rel="next"`
# and flattens the walk. It used to key off the trait's mere presence, so the
# `cursor` style the trait documents would have produced exactly the walk it
# exists to avoid. But `generate_method` asks `(returns_array || has_pagination)`
# — so a cursor operation answering a bare array would still have walked, by the
# other half of that disjunction. `cursor_pagination` is carried for that gate
# alone, and `test_a_cursor_operation_with_a_bare_array_response_still_does_not_walk`
# is the case that would otherwise pass unnoticed.

require "test_helper"
require_relative "../../scripts/generate-services"

class GenerateServicesPaginationStyleTest < Minitest::Test
def setup
@generator = ServiceGenerator.allocate
end

def test_link_style_auto_paginates
assert parse(style: "link")[:has_pagination]
end

def test_cursor_style_does_not_auto_paginate
assert_not parse(style: "cursor")[:has_pagination]
assert parse(style: "cursor")[:cursor_pagination]
end

def test_no_trait_at_all_is_simply_unpaginated
assert_not parse(style: :none)[:has_pagination]
assert_not parse(style: :none)[:cursor_pagination]
end

# The key drives envelope unwrapping in type resolution: set, it turns
# `{events: [...], position}` into the item type. A cursor operation is typed
# as its envelope, so the key must not reach those consumers at all.
def test_the_key_is_withheld_from_a_cursor_operation
assert_equal "events", parse(style: "link")[:pagination_key]
assert_nil parse(style: "cursor")[:pagination_key]
end

# The disjunction Ruby alone carries: a bare-array response sets returns_array,
# which would send a cursor operation down the Link-following path even though
# has_pagination is false for it.
#
# Asserted against the emitted method rather than against a copy of the
# predicate. A copy is worth nothing here: delete the cursor guard from
# generate_method and a test that re-implements it stays green while the
# generator ships the walk.
def test_a_cursor_operation_with_a_bare_array_response_still_does_not_walk
operation = parse(style: "cursor", response_schema: { "type" => "array" })
assert operation[:returns_array], "the fixture must exercise the returns_array half"

emitted = Array(@generator.send(:generate_method, operation, service_name: "Widgets")).join("\n")

assert_no_match(/wrap_paginated|paginate\(/, emitted,
"a cursor operation must not be emitted as a Link-following walk")
end

# The control: the same response shape under the link style DOES walk, so the
# assertion above is discriminating rather than matching nothing.
def test_the_same_shape_under_link_style_does_walk
operation = parse(style: "link", response_schema: { "type" => "array" }, key: nil)

emitted = Array(@generator.send(:generate_method, operation, service_name: "Widgets")).join("\n")

assert_match(/wrap_paginated|paginate\(/, emitted,
"a link operation over a bare array is exactly what the walk is for")
end

def test_an_unrecognised_style_is_refused_rather_than_read_as_unpaginated
[ "page", "linkk", "Link", "", nil ].each do |style|
error = assert_raises(RuntimeError) { parse(style: style) }
assert_match(/unsupported pagination style/, error.message, "style #{style.inspect}")
end
end

private
def parse(style:, response_schema: { "type" => "object" }, key: "events")
operation = {
"operationId" => "PollWidgets",
"description" => "Poll the widgets.",
"responses" => { "200" => { "content" => { "application/json" => { "schema" => response_schema } } } }
}
unless style == :none
operation["x-basecamp-pagination"] = {}
operation["x-basecamp-pagination"]["key"] = key if key
operation["x-basecamp-pagination"]["style"] = style unless style.nil?
end

@generator.send(:parse_operation, "/{accountId}/widgets.json", "get", operation)
end
end
8 changes: 8 additions & 0 deletions rust/basecamp-sdk/src/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,14 @@ pub enum Pagination {
/// The header carrying the collection's total size.
total_count_header: Option<&'static str>,
},
/// Cursor pages: one call answers one page, which carries its own opaque
/// position. The SDK never walks these — flattening the pages would swallow
/// every position, and a position is the only thing a consumer can resume
/// from. Follow the continuation the body names.
Cursor {
/// The response member holding the page's items.
key: Option<&'static str>,
},
}

/// SPEC §18 write semantics for a replace-style operation.
Expand Down
Loading
Loading