basecamp-sdk 0.12.0 → 0.14.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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +128 -8
  3. data/lib/basecamp/client.rb +35 -11
  4. data/lib/basecamp/config.rb +69 -0
  5. data/lib/basecamp/error.rb +1 -0
  6. data/lib/basecamp/error_code.rb +1 -0
  7. data/lib/basecamp/exit_code.rb +1 -0
  8. data/lib/basecamp/generated/metadata.json +291 -141
  9. data/lib/basecamp/generated/services/base_service.rb +37 -16
  10. data/lib/basecamp/generated/services/bookmarks_service.rb +5 -4
  11. data/lib/basecamp/generated/services/boosts_service.rb +12 -6
  12. data/lib/basecamp/generated/services/campfires_service.rb +54 -41
  13. data/lib/basecamp/generated/services/cards_service.rb +6 -3
  14. data/lib/basecamp/generated/services/checkins_service.rb +28 -15
  15. data/lib/basecamp/generated/services/client_approvals_service.rb +8 -5
  16. data/lib/basecamp/generated/services/client_correspondences_service.rb +8 -5
  17. data/lib/basecamp/generated/services/client_replies_service.rb +12 -7
  18. data/lib/basecamp/generated/services/cloud_files_service.rb +57 -0
  19. data/lib/basecamp/generated/services/comments_service.rb +6 -3
  20. data/lib/basecamp/generated/services/documents_service.rb +9 -6
  21. data/lib/basecamp/generated/services/drafts_service.rb +5 -4
  22. data/lib/basecamp/generated/services/events_service.rb +6 -3
  23. data/lib/basecamp/generated/services/everything_service.rb +70 -56
  24. data/lib/basecamp/generated/services/folders_service.rb +62 -0
  25. data/lib/basecamp/generated/services/forwards_service.rb +12 -17
  26. data/lib/basecamp/generated/services/gauges_service.rb +12 -7
  27. data/lib/basecamp/generated/services/google_documents_service.rb +61 -0
  28. data/lib/basecamp/generated/services/message_types_service.rb +4 -3
  29. data/lib/basecamp/generated/services/messages_service.rb +6 -4
  30. data/lib/basecamp/generated/services/my_notes_service.rb +1 -1
  31. data/lib/basecamp/generated/services/my_notifications_service.rb +8 -5
  32. data/lib/basecamp/generated/services/people_service.rb +17 -10
  33. data/lib/basecamp/generated/services/projects_service.rb +26 -4
  34. data/lib/basecamp/generated/services/recordings_service.rb +6 -13
  35. data/lib/basecamp/generated/services/reports_service.rb +15 -9
  36. data/lib/basecamp/generated/services/schedules_service.rb +90 -16
  37. data/lib/basecamp/generated/services/search_service.rb +6 -4
  38. data/lib/basecamp/generated/services/templates_service.rb +6 -4
  39. data/lib/basecamp/generated/services/timeline_service.rb +6 -3
  40. data/lib/basecamp/generated/services/timesheets_service.rb +22 -8
  41. data/lib/basecamp/generated/services/todolist_groups_service.rb +7 -4
  42. data/lib/basecamp/generated/services/todolists_service.rb +11 -9
  43. data/lib/basecamp/generated/services/todos_service.rb +6 -14
  44. data/lib/basecamp/generated/services/uploads_service.rb +28 -6
  45. data/lib/basecamp/generated/services/vaults_service.rb +6 -3
  46. data/lib/basecamp/generated/services/webhooks_service.rb +4 -3
  47. data/lib/basecamp/generated/types.rb +603 -139
  48. data/lib/basecamp/http.rb +352 -163
  49. data/lib/basecamp/limit_exceeded_error.rb +22 -0
  50. data/lib/basecamp/list_enumerator.rb +29 -0
  51. data/lib/basecamp/list_meta.rb +44 -0
  52. data/lib/basecamp/services/authorization_service.rb +11 -2
  53. data/lib/basecamp/services/cards_extensions.rb +35 -27
  54. data/lib/basecamp/services/documents_extensions.rb +136 -0
  55. data/lib/basecamp/services/merge_safe.rb +255 -0
  56. data/lib/basecamp/services/schedules_extensions.rb +354 -0
  57. data/lib/basecamp/services/todolists_extensions.rb +274 -0
  58. data/lib/basecamp/services/todos_extensions.rb +22 -6
  59. data/lib/basecamp/validation_error.rb +11 -1
  60. data/lib/basecamp/version.rb +2 -2
  61. data/lib/basecamp.rb +98 -4
  62. data/scripts/generate-metadata.rb +3 -1
  63. data/scripts/generate-services.rb +78 -27
  64. data/scripts/generate-types.rb +4 -2
  65. data/scripts/go_type_spellings.rb +26 -0
  66. metadata +13 -2
@@ -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-07-31T15:59:31Z",
4
+ "generated": "2026-08-07T11:12:00Z",
5
5
  "operations": {
6
6
  "GetAccount": {
7
7
  "retry": {
@@ -239,6 +239,131 @@
239
239
  "natural": true
240
240
  }
241
241
  },
242
+ "ListChatbots": {
243
+ "retry": {
244
+ "maxAttempts": 3,
245
+ "baseDelayMs": 1000,
246
+ "backoff": "exponential",
247
+ "retryOn": [
248
+ 429,
249
+ 503
250
+ ]
251
+ },
252
+ "pagination": {
253
+ "style": "link",
254
+ "totalCountHeader": "X-Total-Count",
255
+ "maxPageSize": 50
256
+ }
257
+ },
258
+ "CreateChatbot": {
259
+ "retry": {
260
+ "maxAttempts": 2,
261
+ "baseDelayMs": 1000,
262
+ "backoff": "exponential",
263
+ "retryOn": [
264
+ 429,
265
+ 503
266
+ ]
267
+ }
268
+ },
269
+ "GetChatbot": {
270
+ "retry": {
271
+ "maxAttempts": 3,
272
+ "baseDelayMs": 1000,
273
+ "backoff": "exponential",
274
+ "retryOn": [
275
+ 429,
276
+ 503
277
+ ]
278
+ }
279
+ },
280
+ "UpdateChatbot": {
281
+ "retry": {
282
+ "maxAttempts": 3,
283
+ "baseDelayMs": 1000,
284
+ "backoff": "exponential",
285
+ "retryOn": [
286
+ 429,
287
+ 503
288
+ ]
289
+ },
290
+ "idempotent": {
291
+ "natural": true
292
+ }
293
+ },
294
+ "DeleteChatbot": {
295
+ "retry": {
296
+ "maxAttempts": 3,
297
+ "baseDelayMs": 1000,
298
+ "backoff": "exponential",
299
+ "retryOn": [
300
+ 429,
301
+ 503
302
+ ]
303
+ },
304
+ "idempotent": {
305
+ "natural": true
306
+ }
307
+ },
308
+ "ListClientApprovals": {
309
+ "retry": {
310
+ "maxAttempts": 3,
311
+ "baseDelayMs": 1000,
312
+ "backoff": "exponential",
313
+ "retryOn": [
314
+ 429,
315
+ 503
316
+ ]
317
+ },
318
+ "pagination": {
319
+ "style": "link",
320
+ "totalCountHeader": "X-Total-Count",
321
+ "maxPageSize": 50
322
+ }
323
+ },
324
+ "ListClientCorrespondences": {
325
+ "retry": {
326
+ "maxAttempts": 3,
327
+ "baseDelayMs": 1000,
328
+ "backoff": "exponential",
329
+ "retryOn": [
330
+ 429,
331
+ 503
332
+ ]
333
+ },
334
+ "pagination": {
335
+ "style": "link",
336
+ "totalCountHeader": "X-Total-Count",
337
+ "maxPageSize": 50
338
+ }
339
+ },
340
+ "ListClientReplies": {
341
+ "retry": {
342
+ "maxAttempts": 3,
343
+ "baseDelayMs": 1000,
344
+ "backoff": "exponential",
345
+ "retryOn": [
346
+ 429,
347
+ 503
348
+ ]
349
+ },
350
+ "pagination": {
351
+ "style": "link",
352
+ "totalCountHeader": "X-Total-Count",
353
+ "maxPageSize": 50
354
+ }
355
+ },
356
+ "GetClientReply": {
357
+ "retry": {
358
+ "maxAttempts": 3,
359
+ "baseDelayMs": 1000,
360
+ "backoff": "exponential",
361
+ "retryOn": [
362
+ 429,
363
+ 503
364
+ ]
365
+ }
366
+ },
242
367
  "CreateTool": {
243
368
  "retry": {
244
369
  "maxAttempts": 2,
@@ -261,6 +386,28 @@
261
386
  ]
262
387
  }
263
388
  },
389
+ "CreateCloudFile": {
390
+ "retry": {
391
+ "maxAttempts": 2,
392
+ "baseDelayMs": 1000,
393
+ "backoff": "exponential",
394
+ "retryOn": [
395
+ 429,
396
+ 503
397
+ ]
398
+ }
399
+ },
400
+ "CreateGoogleDocument": {
401
+ "retry": {
402
+ "maxAttempts": 2,
403
+ "baseDelayMs": 1000,
404
+ "backoff": "exponential",
405
+ "retryOn": [
406
+ 429,
407
+ 503
408
+ ]
409
+ }
410
+ },
264
411
  "ListWebhooks": {
265
412
  "retry": {
266
413
  "maxAttempts": 3,
@@ -641,72 +788,6 @@
641
788
  ]
642
789
  }
643
790
  },
644
- "ListChatbots": {
645
- "retry": {
646
- "maxAttempts": 3,
647
- "baseDelayMs": 1000,
648
- "backoff": "exponential",
649
- "retryOn": [
650
- 429,
651
- 503
652
- ]
653
- },
654
- "pagination": {
655
- "style": "link",
656
- "totalCountHeader": "X-Total-Count",
657
- "maxPageSize": 50
658
- }
659
- },
660
- "CreateChatbot": {
661
- "retry": {
662
- "maxAttempts": 2,
663
- "baseDelayMs": 1000,
664
- "backoff": "exponential",
665
- "retryOn": [
666
- 429,
667
- 503
668
- ]
669
- }
670
- },
671
- "GetChatbot": {
672
- "retry": {
673
- "maxAttempts": 3,
674
- "baseDelayMs": 1000,
675
- "backoff": "exponential",
676
- "retryOn": [
677
- 429,
678
- 503
679
- ]
680
- }
681
- },
682
- "UpdateChatbot": {
683
- "retry": {
684
- "maxAttempts": 3,
685
- "baseDelayMs": 1000,
686
- "backoff": "exponential",
687
- "retryOn": [
688
- 429,
689
- 503
690
- ]
691
- },
692
- "idempotent": {
693
- "natural": true
694
- }
695
- },
696
- "DeleteChatbot": {
697
- "retry": {
698
- "maxAttempts": 3,
699
- "baseDelayMs": 1000,
700
- "backoff": "exponential",
701
- "retryOn": [
702
- 429,
703
- 503
704
- ]
705
- },
706
- "idempotent": {
707
- "natural": true
708
- }
709
- },
710
791
  "ListCampfireLines": {
711
792
  "retry": {
712
793
  "maxAttempts": 3,
@@ -832,22 +913,6 @@
832
913
  "maxPageSize": 50
833
914
  }
834
915
  },
835
- "ListClientApprovals": {
836
- "retry": {
837
- "maxAttempts": 3,
838
- "baseDelayMs": 1000,
839
- "backoff": "exponential",
840
- "retryOn": [
841
- 429,
842
- 503
843
- ]
844
- },
845
- "pagination": {
846
- "style": "link",
847
- "totalCountHeader": "X-Total-Count",
848
- "maxPageSize": 50
849
- }
850
- },
851
916
  "GetClientApproval": {
852
917
  "retry": {
853
918
  "maxAttempts": 3,
@@ -859,22 +924,6 @@
859
924
  ]
860
925
  }
861
926
  },
862
- "ListClientCorrespondences": {
863
- "retry": {
864
- "maxAttempts": 3,
865
- "baseDelayMs": 1000,
866
- "backoff": "exponential",
867
- "retryOn": [
868
- 429,
869
- 503
870
- ]
871
- },
872
- "pagination": {
873
- "style": "link",
874
- "totalCountHeader": "X-Total-Count",
875
- "maxPageSize": 50
876
- }
877
- },
878
927
  "GetClientCorrespondence": {
879
928
  "retry": {
880
929
  "maxAttempts": 3,
@@ -886,7 +935,7 @@
886
935
  ]
887
936
  }
888
937
  },
889
- "ListClientReplies": {
938
+ "GetCloudFile": {
890
939
  "retry": {
891
940
  "maxAttempts": 3,
892
941
  "baseDelayMs": 1000,
@@ -895,14 +944,9 @@
895
944
  429,
896
945
  503
897
946
  ]
898
- },
899
- "pagination": {
900
- "style": "link",
901
- "totalCountHeader": "X-Total-Count",
902
- "maxPageSize": 50
903
947
  }
904
948
  },
905
- "GetClientReply": {
949
+ "UpdateCloudFile": {
906
950
  "retry": {
907
951
  "maxAttempts": 3,
908
952
  "baseDelayMs": 1000,
@@ -911,6 +955,9 @@
911
955
  429,
912
956
  503
913
957
  ]
958
+ },
959
+ "idempotent": {
960
+ "natural": true
914
961
  }
915
962
  },
916
963
  "GetEverythingComments": {
@@ -1004,7 +1051,7 @@
1004
1051
  ]
1005
1052
  }
1006
1053
  },
1007
- "UpdateDocument": {
1054
+ "ReplaceDocument": {
1008
1055
  "retry": {
1009
1056
  "maxAttempts": 3,
1010
1057
  "baseDelayMs": 1000,
@@ -1089,7 +1136,7 @@
1089
1136
  "natural": true
1090
1137
  }
1091
1138
  },
1092
- "GetForward": {
1139
+ "GetGoogleDocument": {
1093
1140
  "retry": {
1094
1141
  "maxAttempts": 3,
1095
1142
  "baseDelayMs": 1000,
@@ -1100,7 +1147,7 @@
1100
1147
  ]
1101
1148
  }
1102
1149
  },
1103
- "ListForwardReplies": {
1150
+ "UpdateGoogleDocument": {
1104
1151
  "retry": {
1105
1152
  "maxAttempts": 3,
1106
1153
  "baseDelayMs": 1000,
@@ -1110,21 +1157,35 @@
1110
1157
  503
1111
1158
  ]
1112
1159
  },
1113
- "pagination": {
1114
- "style": "link",
1115
- "totalCountHeader": "X-Total-Count",
1116
- "maxPageSize": 50
1160
+ "idempotent": {
1161
+ "natural": true
1117
1162
  }
1118
1163
  },
1119
- "CreateForwardReply": {
1164
+ "GetForward": {
1120
1165
  "retry": {
1121
- "maxAttempts": 2,
1166
+ "maxAttempts": 3,
1167
+ "baseDelayMs": 1000,
1168
+ "backoff": "exponential",
1169
+ "retryOn": [
1170
+ 429,
1171
+ 503
1172
+ ]
1173
+ }
1174
+ },
1175
+ "ListForwardReplies": {
1176
+ "retry": {
1177
+ "maxAttempts": 3,
1122
1178
  "baseDelayMs": 1000,
1123
1179
  "backoff": "exponential",
1124
1180
  "retryOn": [
1125
1181
  429,
1126
1182
  503
1127
1183
  ]
1184
+ },
1185
+ "pagination": {
1186
+ "style": "link",
1187
+ "totalCountHeader": "X-Total-Count",
1188
+ "maxPageSize": 50
1128
1189
  }
1129
1190
  },
1130
1191
  "GetForwardReply": {
@@ -1745,6 +1806,34 @@
1745
1806
  "natural": true
1746
1807
  }
1747
1808
  },
1809
+ "UnarchiveProject": {
1810
+ "retry": {
1811
+ "maxAttempts": 3,
1812
+ "baseDelayMs": 1000,
1813
+ "backoff": "exponential",
1814
+ "retryOn": [
1815
+ 429,
1816
+ 503
1817
+ ]
1818
+ },
1819
+ "idempotent": {
1820
+ "natural": true
1821
+ }
1822
+ },
1823
+ "ArchiveProject": {
1824
+ "retry": {
1825
+ "maxAttempts": 3,
1826
+ "baseDelayMs": 1000,
1827
+ "backoff": "exponential",
1828
+ "retryOn": [
1829
+ 429,
1830
+ 503
1831
+ ]
1832
+ },
1833
+ "idempotent": {
1834
+ "natural": true
1835
+ }
1836
+ },
1748
1837
  "GetProjectTimeline": {
1749
1838
  "retry": {
1750
1839
  "maxAttempts": 3,
@@ -1989,17 +2078,6 @@
1989
2078
  "natural": true
1990
2079
  }
1991
2080
  },
1992
- "GetRecording": {
1993
- "retry": {
1994
- "maxAttempts": 3,
1995
- "baseDelayMs": 1000,
1996
- "backoff": "exponential",
1997
- "retryOn": [
1998
- 429,
1999
- 503
2000
- ]
2001
- }
2002
- },
2003
2081
  "GetBookmark": {
2004
2082
  "retry": {
2005
2083
  "maxAttempts": 3,
@@ -2425,7 +2503,7 @@
2425
2503
  ]
2426
2504
  }
2427
2505
  },
2428
- "UpdateScheduleEntry": {
2506
+ "ReplaceScheduleEntry": {
2429
2507
  "retry": {
2430
2508
  "maxAttempts": 3,
2431
2509
  "baseDelayMs": 1000,
@@ -2529,6 +2607,67 @@
2529
2607
  ]
2530
2608
  }
2531
2609
  },
2610
+ "ListFolders": {
2611
+ "retry": {
2612
+ "maxAttempts": 3,
2613
+ "baseDelayMs": 1000,
2614
+ "backoff": "exponential",
2615
+ "retryOn": [
2616
+ 429,
2617
+ 503
2618
+ ]
2619
+ }
2620
+ },
2621
+ "CreateFolder": {
2622
+ "retry": {
2623
+ "maxAttempts": 3,
2624
+ "baseDelayMs": 1000,
2625
+ "backoff": "exponential",
2626
+ "retryOn": [
2627
+ 429,
2628
+ 503
2629
+ ]
2630
+ }
2631
+ },
2632
+ "GetFolder": {
2633
+ "retry": {
2634
+ "maxAttempts": 3,
2635
+ "baseDelayMs": 1000,
2636
+ "backoff": "exponential",
2637
+ "retryOn": [
2638
+ 429,
2639
+ 503
2640
+ ]
2641
+ }
2642
+ },
2643
+ "UpdateFolder": {
2644
+ "retry": {
2645
+ "maxAttempts": 3,
2646
+ "baseDelayMs": 1000,
2647
+ "backoff": "exponential",
2648
+ "retryOn": [
2649
+ 429,
2650
+ 503
2651
+ ]
2652
+ },
2653
+ "idempotent": {
2654
+ "natural": true
2655
+ }
2656
+ },
2657
+ "DeleteFolder": {
2658
+ "retry": {
2659
+ "maxAttempts": 3,
2660
+ "baseDelayMs": 1000,
2661
+ "backoff": "exponential",
2662
+ "retryOn": [
2663
+ 429,
2664
+ 503
2665
+ ]
2666
+ },
2667
+ "idempotent": {
2668
+ "natural": true
2669
+ }
2670
+ },
2532
2671
  "ListTemplates": {
2533
2672
  "retry": {
2534
2673
  "maxAttempts": 3,
@@ -2642,6 +2781,20 @@
2642
2781
  "natural": true
2643
2782
  }
2644
2783
  },
2784
+ "DestroyTimesheetEntry": {
2785
+ "retry": {
2786
+ "maxAttempts": 3,
2787
+ "baseDelayMs": 1000,
2788
+ "backoff": "exponential",
2789
+ "retryOn": [
2790
+ 429,
2791
+ 503
2792
+ ]
2793
+ },
2794
+ "idempotent": {
2795
+ "natural": true
2796
+ }
2797
+ },
2645
2798
  "RepositionTodolistGroup": {
2646
2799
  "retry": {
2647
2800
  "maxAttempts": 3,
@@ -2835,20 +2988,6 @@
2835
2988
  "natural": true
2836
2989
  }
2837
2990
  },
2838
- "TrashTodo": {
2839
- "retry": {
2840
- "maxAttempts": 3,
2841
- "baseDelayMs": 1000,
2842
- "backoff": "exponential",
2843
- "retryOn": [
2844
- 429,
2845
- 503
2846
- ]
2847
- },
2848
- "idempotent": {
2849
- "natural": true
2850
- }
2851
- },
2852
2991
  "CompleteTodo": {
2853
2992
  "retry": {
2854
2993
  "maxAttempts": 3,
@@ -3009,6 +3148,17 @@
3009
3148
  "maxPageSize": 50
3010
3149
  }
3011
3150
  },
3151
+ "CreateUploadVersion": {
3152
+ "retry": {
3153
+ "maxAttempts": 2,
3154
+ "baseDelayMs": 1000,
3155
+ "backoff": "exponential",
3156
+ "retryOn": [
3157
+ 429,
3158
+ 503
3159
+ ]
3160
+ }
3161
+ },
3012
3162
  "GetVault": {
3013
3163
  "retry": {
3014
3164
  "maxAttempts": 3,
@@ -57,37 +57,52 @@ module Basecamp
57
57
  raise
58
58
  end
59
59
 
60
- # Wraps a lazy Enumerator so operation hooks fire around actual iteration,
61
- # not at enumerator creation time. Hooks fire when the consumer begins
62
- # iterating (.each, .to_a, .first, etc.) and end fires via ensure when
63
- # iteration completes, errors, or is cut short by break/take.
60
+ # Wraps a paginated operation with hooks, preserving the pagination
61
+ # metadata carried by the inner ListEnumerator.
62
+ # Fires on_operation_start eagerly (paginate fetches page 1 inside the
63
+ # yield, so the start hook must precede it); on_operation_end fires via
64
+ # ensure when the first traversal completes, errors, or is cut short by
65
+ # break/take — exactly once, so re-enumerating never emits an
66
+ # on_operation_end without a matching start.
64
67
  def wrap_paginated(service:, operation:, is_mutation: false, project_id: nil, resource_id: nil)
65
68
  info = OperationInfo.new(
66
69
  service: service, operation: operation,
67
70
  is_mutation: is_mutation, project_id: project_id, resource_id: resource_id
68
71
  )
69
- enum = yield
70
-
71
72
  hooks = @hooks
72
- Enumerator.new do |yielder|
73
- start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
73
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
74
+ safe_hook { hooks.on_operation_start(info) }
75
+
76
+ begin
77
+ enum = yield # paginate fetches page 1 here
78
+ rescue => e
79
+ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
80
+ safe_hook { hooks.on_operation_end(info, OperationResult.new(duration_ms: duration, error: e)) }
81
+ raise
82
+ end
83
+
84
+ ended = false
85
+ ListEnumerator.new(enum.meta) do |yielder|
74
86
  error = nil
75
87
  begin
76
- safe_hook { hooks.on_operation_start(info) }
77
88
  enum.each { |item| yielder.yield(item) }
78
89
  rescue => e
79
90
  error = e
80
91
  raise
81
92
  ensure
82
- duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
83
- safe_hook { hooks.on_operation_end(info, OperationResult.new(duration_ms: duration, error: error)) }
93
+ unless ended
94
+ ended = true
95
+ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
96
+ safe_hook { hooks.on_operation_end(info, OperationResult.new(duration_ms: duration, error: error)) }
97
+ end
84
98
  end
85
99
  end
86
100
  end
87
101
 
88
102
  # 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.
103
+ # Fires on_operation_start eagerly (before page 1 fetch), and
104
+ # on_operation_end exactly once, when the first traversal of the events
105
+ # Enumerator completes/errors/breaks.
91
106
  def wrap_paginated_wrapped(key:, service:, operation:, is_mutation: false, project_id: nil, resource_id: nil)
92
107
  info = OperationInfo.new(
93
108
  service: service, operation: operation,
@@ -106,7 +121,8 @@ module Basecamp
106
121
  end
107
122
 
108
123
  inner_enum = result[key]
109
- wrapped_enum = Enumerator.new do |yielder|
124
+ ended = false
125
+ wrapped_enum = ListEnumerator.new(inner_enum.meta) do |yielder|
110
126
  error = nil
111
127
  begin
112
128
  inner_enum.each { |item| yielder.yield(item) }
@@ -114,8 +130,13 @@ module Basecamp
114
130
  error = e
115
131
  raise
116
132
  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)) }
133
+ # Exactly once: on_operation_start fired once at call time, so
134
+ # re-enumerating must not emit unmatched on_operation_end events.
135
+ unless ended
136
+ ended = true
137
+ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
138
+ safe_hook { hooks.on_operation_end(info, OperationResult.new(duration_ms: duration, error: error)) }
139
+ end
119
140
  end
120
141
  end
121
142
 
@@ -8,12 +8,13 @@ module Basecamp
8
8
  class BookmarksService < BaseService
9
9
 
10
10
  # List the current user's bookmarks, most recently bookmarked first (paginated).
11
- # @param page [Integer, nil] Page number for paginating through results. Defaults to 1.
12
- # @return [Enumerator<Hash>] paginated results
13
- def list_my_bookmarks(page: nil)
11
+ # @param page [Integer, nil] Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8.
12
+ # @param max_items [Integer, nil] cap on items yielded across pages; nil or non-positive means no cap
13
+ # @return [ListEnumerator<Hash>] lazily paginated results (#meta carries pagination metadata)
14
+ def list_my_bookmarks(page: nil, max_items: nil)
14
15
  wrap_paginated(service: "bookmarks", operation: "list_my_bookmarks", is_mutation: false) do
15
16
  params = compact_query_params(page: page)
16
- paginate("/my/bookmarks.json", params: params, operation: "ListMyBookmarks")
17
+ paginate("/my/bookmarks.json", params: params, operation: "ListMyBookmarks", max_items: max_items)
17
18
  end
18
19
  end
19
20