basecamp-sdk 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 333309f03879b5b93ba66a1b6da556fefa44e67608544dadfdb27cac0ade7a6e
4
- data.tar.gz: a2601804437fb844bb148ea53d458c1003dc2e96faaabda5f47abcd489b9feea
3
+ metadata.gz: 77ea8d483dab96eeb6721a83f11100e5b46ffd1901d4607f4c1dc1b882fb34c9
4
+ data.tar.gz: f2e1946f8a21e6620f051cb2ea6c74f6374cd8d6ba1074ab7264960acd8528c5
5
5
  SHA512:
6
- metadata.gz: 7308e4342b71c0f4e577f7010d3f64963705a2b42e638174a79689a01af17faf9f49ab58de1eb983f3071d3f94201cad17906ce616495e886c29dbda2389677d
7
- data.tar.gz: 6ebf54b146a9fa683832100267fadb3f7f6b04cc7582e59ad763640d6a885817aac0d227f1c65240e022b9c0cdbd62e5d4712a2de5a094a5163e0414edb269b5
6
+ metadata.gz: 919b6403677541f344409a96208f1e4e3a20a7cee39ba078b72847d7fb8359af418a3959901cbe6e09835a51b361a5c0d1f48aaa0c18aa4e3e68cec7e2b7051b
7
+ data.tar.gz: 504c56f9a01cdcc29876969e485f78fbc19d8d6b74849f871ca01406ad1078d58f52caac079ef5778f52f39a6694b63a47bd1264968212c4c21bfd9ad6f1743c
@@ -208,6 +208,15 @@ module Basecamp
208
208
  @parent.http.paginate_key(account_path(path), key: key, params: params, &)
209
209
  end
210
210
 
211
+ # Fetches a wrapped paginated resource, returning wrapper fields + lazy paginated items.
212
+ # @param path [String] URL path (without account prefix)
213
+ # @param key [String] the key containing the array of paginated items
214
+ # @param params [Hash] query parameters
215
+ # @return [Hash] wrapper fields merged with key => Enumerator of all items
216
+ def paginate_wrapped(path, key:, params: {})
217
+ @parent.http.paginate_wrapped(account_path(path), key: key, params: params)
218
+ end
219
+
211
220
  # @!group Services
212
221
 
213
222
  # @return [Services::ProjectsService]
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://basecamp.com/schemas/sdk-metadata.json",
3
3
  "version": "1.0.0",
4
- "generated": "2026-03-09T21:06:11Z",
4
+ "generated": "2026-03-11T02:58:58Z",
5
5
  "operations": {
6
6
  "CreateAttachment": {
7
7
  "retry": {
@@ -1107,6 +1107,11 @@
1107
1107
  429,
1108
1108
  503
1109
1109
  ]
1110
+ },
1111
+ "pagination": {
1112
+ "style": "link",
1113
+ "totalCountHeader": "X-Total-Count",
1114
+ "maxPageSize": 50
1110
1115
  }
1111
1116
  },
1112
1117
  "GetAnswer": {
@@ -1544,6 +1549,11 @@
1544
1549
  429,
1545
1550
  503
1546
1551
  ]
1552
+ },
1553
+ "pagination": {
1554
+ "style": "link",
1555
+ "totalCountHeader": "X-Total-Count",
1556
+ "maxPageSize": 50
1547
1557
  }
1548
1558
  },
1549
1559
  "CreateTimesheetEntry": {
@@ -1676,6 +1686,11 @@
1676
1686
  429,
1677
1687
  503
1678
1688
  ]
1689
+ },
1690
+ "pagination": {
1691
+ "style": "link",
1692
+ "totalCountHeader": "X-Total-Count",
1693
+ "maxPageSize": 50
1679
1694
  }
1680
1695
  },
1681
1696
  "GetScheduleEntry": {
@@ -47,8 +47,8 @@ module Basecamp
47
47
  # @param person_id [Integer] person id ID
48
48
  # @return [Hash] response data
49
49
  def person_progress(person_id:)
50
- with_operation(service: "reports", operation: "person_progress", is_mutation: false, resource_id: person_id) do
51
- http_get("/reports/users/progress/#{person_id}").json
50
+ wrap_paginated_wrapped(key: "events", service: "reports", operation: "person_progress", is_mutation: false, resource_id: person_id) do
51
+ paginate_wrapped("/reports/users/progress/#{person_id}.json", key: "events")
52
52
  end
53
53
  end
54
54
  end
@@ -12,10 +12,11 @@ module Basecamp
12
12
  # @param from [String, nil] from
13
13
  # @param to [String, nil] to
14
14
  # @param person_id [Integer, nil] person id
15
- # @return [Hash] response data
15
+ # @return [Enumerator<Hash>] paginated results
16
16
  def for_project(project_id:, from: nil, to: nil, person_id: nil)
17
- with_operation(service: "timesheets", operation: "for_project", is_mutation: false, project_id: project_id) do
18
- http_get("/projects/#{project_id}/timesheet.json", params: compact_params(from: from, to: to, person_id: person_id)).json
17
+ wrap_paginated(service: "timesheets", operation: "for_project", is_mutation: false, project_id: project_id) do
18
+ params = compact_params(from: from, to: to, person_id: person_id)
19
+ paginate("/projects/#{project_id}/timesheet.json", params: params)
19
20
  end
20
21
  end
21
22
 
@@ -24,10 +25,11 @@ module Basecamp
24
25
  # @param from [String, nil] from
25
26
  # @param to [String, nil] to
26
27
  # @param person_id [Integer, nil] person id
27
- # @return [Hash] response data
28
+ # @return [Enumerator<Hash>] paginated results
28
29
  def for_recording(recording_id:, from: nil, to: nil, person_id: nil)
29
- with_operation(service: "timesheets", operation: "for_recording", is_mutation: false, resource_id: recording_id) do
30
- http_get("/recordings/#{recording_id}/timesheet.json", params: compact_params(from: from, to: to, person_id: person_id)).json
30
+ wrap_paginated(service: "timesheets", operation: "for_recording", is_mutation: false, resource_id: recording_id) do
31
+ params = compact_params(from: from, to: to, person_id: person_id)
32
+ paginate("/recordings/#{recording_id}/timesheet.json", params: params)
31
33
  end
32
34
  end
33
35
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Auto-generated from OpenAPI spec. Do not edit manually.
4
- # Generated: 2026-03-09T21:06:11Z
4
+ # Generated: 2026-03-11T02:58:58Z
5
5
 
6
6
  require "json"
7
7
  require "time"
data/lib/basecamp/http.rb CHANGED
@@ -172,6 +172,72 @@ module Basecamp
172
172
  end
173
173
  end
174
174
 
175
+ # Fetches a wrapped paginated resource, returning wrapper fields + lazy paginated items.
176
+ # Use this for endpoints that return {wrapper_field: ..., key: [items]} on every page.
177
+ # @param path [String] initial URL path
178
+ # @param key [String] the key containing the array of paginated items
179
+ # @param params [Hash] query parameters
180
+ # @return [Hash] wrapper fields merged with key => Enumerator of all items
181
+ def paginate_wrapped(path, key:, params: {})
182
+ base_url = build_url(path)
183
+
184
+ @hooks.on_paginate(base_url, 1)
185
+ first_response = get(base_url, params: params)
186
+ Security.check_body_size!(first_response.body, Security::MAX_RESPONSE_BODY_BYTES)
187
+
188
+ begin
189
+ first_data = JSON.parse(first_response.body)
190
+ rescue JSON::ParserError => e
191
+ raise Basecamp::APIError.new(
192
+ "Failed to parse paginated response (page 1): #{Security.truncate(e.message)}"
193
+ )
194
+ end
195
+
196
+ wrapper = first_data.reject { |k, _| k == key }
197
+ first_items = first_data[key] || []
198
+
199
+ events = Enumerator.new do |yielder|
200
+ first_items.each { |item| yielder << item }
201
+
202
+ next_link = parse_next_link(first_response.headers["Link"])
203
+ url = base_url
204
+ page = 1
205
+
206
+ while next_link && page < @config.max_pages
207
+ page += 1
208
+ next_url = Security.resolve_url(url, next_link)
209
+
210
+ unless Security.same_origin?(next_url, base_url)
211
+ raise Basecamp::APIError.new(
212
+ "Pagination Link header points to different origin: " \
213
+ "#{Security.truncate(next_url)}"
214
+ )
215
+ end
216
+
217
+ @hooks.on_paginate(next_url, page)
218
+ response = get(next_url)
219
+ Security.check_body_size!(response.body, Security::MAX_RESPONSE_BODY_BYTES)
220
+
221
+ begin
222
+ data = JSON.parse(response.body)
223
+ rescue JSON::ParserError => e
224
+ raise Basecamp::APIError.new(
225
+ "Failed to parse paginated response (page #{page}): " \
226
+ "#{Security.truncate(e.message)}"
227
+ )
228
+ end
229
+
230
+ items = data[key] || []
231
+ items.each { |item| yielder << item }
232
+
233
+ next_link = parse_next_link(response.headers["Link"])
234
+ url = next_url
235
+ end
236
+ end
237
+
238
+ wrapper.merge(key => events)
239
+ end
240
+
175
241
  private
176
242
 
177
243
  def build_faraday_client
@@ -85,6 +85,43 @@ module Basecamp
85
85
  end
86
86
  end
87
87
 
88
+ # Wraps a wrapped-paginated operation with hooks.
89
+ # Fires on_operation_start eagerly (before page 1 fetch),
90
+ # on_operation_end when the events Enumerator completes/errors/breaks.
91
+ def wrap_paginated_wrapped(key:, service:, operation:, is_mutation: false, project_id: nil, resource_id: nil)
92
+ info = OperationInfo.new(
93
+ service: service, operation: operation,
94
+ is_mutation: is_mutation, project_id: project_id, resource_id: resource_id
95
+ )
96
+ hooks = @hooks
97
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
98
+ safe_hook { hooks.on_operation_start(info) }
99
+
100
+ begin
101
+ result = yield # paginate_wrapped fetches page 1 here
102
+ rescue => e
103
+ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
104
+ safe_hook { hooks.on_operation_end(info, OperationResult.new(duration_ms: duration, error: e)) }
105
+ raise
106
+ end
107
+
108
+ inner_enum = result[key]
109
+ wrapped_enum = Enumerator.new do |yielder|
110
+ error = nil
111
+ begin
112
+ inner_enum.each { |item| yielder.yield(item) }
113
+ rescue => e
114
+ error = e
115
+ raise
116
+ ensure
117
+ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
118
+ safe_hook { hooks.on_operation_end(info, OperationResult.new(duration_ms: duration, error: error)) }
119
+ end
120
+ end
121
+
122
+ result.merge(key => wrapped_enum)
123
+ end
124
+
88
125
  # Invoke a hook callback, swallowing exceptions so hooks never break SDK behavior.
89
126
  def safe_hook
90
127
  yield
@@ -141,6 +178,11 @@ module Basecamp
141
178
  def paginate_key(...)
142
179
  @client.paginate_key(...)
143
180
  end
181
+
182
+ # Paginate a wrapped response extracting items from a specific key
183
+ def paginate_wrapped(...)
184
+ @client.paginate_wrapped(...)
185
+ end
144
186
  end
145
187
  end
146
188
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basecamp
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  API_VERSION = "2026-01-26"
6
6
  end
@@ -387,7 +387,8 @@ class ServiceGenerator
387
387
  returns_void: returns_void,
388
388
  returns_array: returns_array,
389
389
  is_mutation: http_method != 'GET',
390
- has_pagination: !!operation['x-basecamp-pagination']
390
+ has_pagination: !!operation['x-basecamp-pagination'],
391
+ pagination_key: operation.dig('x-basecamp-pagination', 'key')
391
392
  }
392
393
  end
393
394
 
@@ -574,9 +575,14 @@ class ServiceGenerator
574
575
  end
575
576
 
576
577
  # Add @return tag
578
+ is_paginated = (op[:returns_array] || op[:has_pagination]) && !op[:pagination_key]
579
+ is_wrapped_paginated = op[:has_pagination] && op[:pagination_key]
580
+
577
581
  if op[:returns_void]
578
582
  lines << ' # @return [void]'
579
- elsif op[:returns_array] || op[:has_pagination]
583
+ elsif is_wrapped_paginated
584
+ lines << ' # @return [Hash] response data'
585
+ elsif is_paginated
580
586
  lines << ' # @return [Enumerator<Hash>] paginated results'
581
587
  else
582
588
  lines << ' # @return [Hash] response data'
@@ -587,10 +593,15 @@ class ServiceGenerator
587
593
  # Build the path
588
594
  path_expr = build_path_expression(op)
589
595
 
590
- is_paginated = op[:returns_array] || op[:has_pagination]
591
596
  hook_kwargs = build_hook_kwargs(op, service_name)
592
597
 
593
- if is_paginated
598
+ if is_wrapped_paginated
599
+ pagination_key = op[:pagination_key]
600
+ lines << " wrap_paginated_wrapped(key: \"#{pagination_key}\", #{hook_kwargs}) do"
601
+ body_lines = generate_wrapped_paginated_method_body(op, path_expr, pagination_key)
602
+ body_lines.each { |l| lines << " #{l}" }
603
+ lines << ' end'
604
+ elsif is_paginated
594
605
  # wrap_paginated defers hooks to actual iteration time (lazy-safe)
595
606
  lines << " wrap_paginated(#{hook_kwargs}) do"
596
607
  body_lines = generate_list_method_body(op, path_expr)
@@ -717,6 +728,20 @@ class ServiceGenerator
717
728
  lines
718
729
  end
719
730
 
731
+ def generate_wrapped_paginated_method_body(op, path_expr, pagination_key)
732
+ lines = []
733
+
734
+ if op[:query_params].any?
735
+ param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
736
+ lines << " params = compact_params(#{param_names.join(', ')})"
737
+ lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\", params: params)"
738
+ else
739
+ lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\")"
740
+ end
741
+
742
+ lines
743
+ end
744
+
720
745
  def generate_get_method_body(op, path_expr)
721
746
  lines = []
722
747
  http_method = op[:http_method].downcase
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: basecamp-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Basecamp
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-09 00:00:00.000000000 Z
11
+ date: 2026-03-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday