fizzy-sdk 0.2.2 → 0.3.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: 4148bf948dbe4f116778c655d10b149a8eead481f24e2675dee2fbca8a1fb76d
4
- data.tar.gz: 37d11604378a170e05525485628132940890259d4e486f91fcf1b78e04807d78
3
+ metadata.gz: 5cb258ecc0bb6152a3736dd03883662cf809277bc85444ed698c386ae641360a
4
+ data.tar.gz: 3da686f5810a68724d669fb3c4f07cac37799be99c0e1d5cc07ed235330b3679
5
5
  SHA512:
6
- metadata.gz: a5fc5660d84ce0a0dd47ea03d9b57ff8d86399b8a44680f0a44264dcbf040f8f29cd45aced7fcff393adb4c01a293b47745928f7c11f1f5833aec422960c4dbd
7
- data.tar.gz: d12fee37e5bad0d5acac1bc0995d1098a0e96bc397eca1784e6f0bcf3fc755abf8e3f1585e57823ef4719c16d912a3fbcbbad258847c463d935f7667c2e07322
6
+ metadata.gz: fd2608ea08ec97413c39d9f22ed1fca328fe917b4f3cb7fdde4a1da1d0b47725eb75cd2a2f8786b8df3a1bc929cf95c1fd20bf658e340b90372c34e5fe530f61
7
+ data.tar.gz: 3e32b0a281961f7c7c779b0bb0a70fcff3550aa04f732e4779ed33ce15826134034d3d56deaff1a8ad7820f6b0813177456c8a6e2ae941f8c993209a8917e13f
data/fizzy-sdk.gemspec CHANGED
@@ -33,13 +33,13 @@ Gem::Specification.new do |spec|
33
33
  spec.add_dependency 'zeitwerk', '~> 2.6'
34
34
 
35
35
  # Development dependencies
36
+ spec.add_development_dependency 'bundler-audit', '~> 0.9.3'
36
37
  spec.add_development_dependency 'minitest', '~> 6.0'
37
38
  spec.add_development_dependency 'rake', '~> 13.0'
38
39
  spec.add_development_dependency 'rubocop-37signals'
39
- spec.add_development_dependency 'simplecov', '~> 0.22'
40
+ spec.add_development_dependency 'simplecov', '~> 1.0'
40
41
  spec.add_development_dependency 'webmock', '~> 3.24'
41
42
  spec.add_development_dependency 'irb', '~> 1.15'
42
- spec.add_development_dependency 'rdoc', '~> 7.1'
43
- spec.add_development_dependency 'webrick', '~> 1.9'
43
+ spec.add_development_dependency 'rdoc', '~> 8.0'
44
44
  spec.add_development_dependency 'yard', '~> 0.9'
45
45
  end
data/lib/fizzy/config.rb CHANGED
@@ -48,7 +48,7 @@ module Fizzy
48
48
  #
49
49
  # @param base_url [String] API base URL
50
50
  # @param timeout [Integer] request timeout in seconds
51
- # @param max_retries [Integer] maximum retry attempts
51
+ # @param max_retries [Integer] total attempts per retryable request, floored at one
52
52
  # @param base_delay [Float] initial backoff delay
53
53
  # @param max_jitter [Float] maximum jitter
54
54
  # @param max_pages [Integer] maximum pages to fetch
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://fizzy.do/schemas/sdk-metadata.json",
3
3
  "version": "1.0.0",
4
- "generated": "2026-06-01T02:39:56Z",
4
+ "generated": "2026-10-17T23:45:49Z",
5
5
  "operations": {
6
6
  "ListAccessTokens": {
7
7
  "retry": {
@@ -154,17 +154,10 @@
154
154
  },
155
155
  "ResetJoinCode": {
156
156
  "retry": {
157
- "maxAttempts": 3,
158
- "baseDelayMs": 1000,
159
- "backoff": "exponential",
160
- "retryOn": [
161
- 429,
162
- 500,
163
- 503
164
- ]
165
- },
166
- "idempotent": {
167
- "natural": true
157
+ "maxAttempts": 1,
158
+ "baseDelayMs": null,
159
+ "backoff": null,
160
+ "retryOn": null
168
161
  }
169
162
  },
170
163
  "GetAccountSettings": {
@@ -834,17 +827,10 @@
834
827
  },
835
828
  "PostponeCard": {
836
829
  "retry": {
837
- "maxAttempts": 3,
838
- "baseDelayMs": 1000,
839
- "backoff": "exponential",
840
- "retryOn": [
841
- 429,
842
- 500,
843
- 503
844
- ]
845
- },
846
- "idempotent": {
847
- "natural": true
830
+ "maxAttempts": 1,
831
+ "baseDelayMs": null,
832
+ "backoff": null,
833
+ "retryOn": null
848
834
  }
849
835
  },
850
836
  "PinCard": {
@@ -864,17 +850,10 @@
864
850
  },
865
851
  "UnpinCard": {
866
852
  "retry": {
867
- "maxAttempts": 3,
868
- "baseDelayMs": 1000,
869
- "backoff": "exponential",
870
- "retryOn": [
871
- 429,
872
- 500,
873
- 503
874
- ]
875
- },
876
- "idempotent": {
877
- "natural": true
853
+ "maxAttempts": 1,
854
+ "baseDelayMs": null,
855
+ "backoff": null,
856
+ "retryOn": null
878
857
  }
879
858
  },
880
859
  "PublishCard": {
@@ -1030,32 +1009,18 @@
1030
1009
  },
1031
1010
  "TriageCard": {
1032
1011
  "retry": {
1033
- "maxAttempts": 3,
1034
- "baseDelayMs": 1000,
1035
- "backoff": "exponential",
1036
- "retryOn": [
1037
- 429,
1038
- 500,
1039
- 503
1040
- ]
1041
- },
1042
- "idempotent": {
1043
- "natural": true
1012
+ "maxAttempts": 1,
1013
+ "baseDelayMs": null,
1014
+ "backoff": null,
1015
+ "retryOn": null
1044
1016
  }
1045
1017
  },
1046
1018
  "UnTriageCard": {
1047
1019
  "retry": {
1048
- "maxAttempts": 3,
1049
- "baseDelayMs": 1000,
1050
- "backoff": "exponential",
1051
- "retryOn": [
1052
- 429,
1053
- 500,
1054
- 503
1055
- ]
1056
- },
1057
- "idempotent": {
1058
- "natural": true
1020
+ "maxAttempts": 1,
1021
+ "baseDelayMs": null,
1022
+ "backoff": null,
1023
+ "retryOn": null
1059
1024
  }
1060
1025
  },
1061
1026
  "WatchCard": {
@@ -1090,32 +1055,18 @@
1090
1055
  },
1091
1056
  "MoveColumnLeft": {
1092
1057
  "retry": {
1093
- "maxAttempts": 3,
1094
- "baseDelayMs": 1000,
1095
- "backoff": "exponential",
1096
- "retryOn": [
1097
- 429,
1098
- 500,
1099
- 503
1100
- ]
1101
- },
1102
- "idempotent": {
1103
- "natural": true
1058
+ "maxAttempts": 1,
1059
+ "baseDelayMs": null,
1060
+ "backoff": null,
1061
+ "retryOn": null
1104
1062
  }
1105
1063
  },
1106
1064
  "MoveColumnRight": {
1107
1065
  "retry": {
1108
- "maxAttempts": 3,
1109
- "baseDelayMs": 1000,
1110
- "backoff": "exponential",
1111
- "retryOn": [
1112
- 429,
1113
- 500,
1114
- 503
1115
- ]
1116
- },
1117
- "idempotent": {
1118
- "natural": true
1066
+ "maxAttempts": 1,
1067
+ "baseDelayMs": null,
1068
+ "backoff": null,
1069
+ "retryOn": null
1119
1070
  }
1120
1071
  },
1121
1072
  "RegisterDevice": {
@@ -20,7 +20,7 @@ module Fizzy
20
20
  # @param account_id [String] account id ID
21
21
  # @param name [String] name
22
22
  # @param all_access [Boolean, nil] all access
23
- # @param auto_postpone_period_in_days [Integer, nil] auto postpone period in days
23
+ # @param auto_postpone_period_in_days [Integer, nil] Days of inactivity before a card is auto-postponed. One of 3, 7, 11, 30, 90 or 365 (Entropy::AUTO_POSTPONE_PERIODS_IN_DAYS); any other value is rejected with 422.
24
24
  # @param public_description [String, nil] public description
25
25
  # @return [Hash] response data
26
26
  def create(account_id:, name:, all_access: nil, auto_postpone_period_in_days: nil, public_description: nil)
@@ -39,14 +39,14 @@ module Fizzy
39
39
  end
40
40
  end
41
41
 
42
- # update operation
42
+ # Updates a board. Send at least one of name, all_access, auto_postpone_period_in_days or public_description: the controller expects a `board` object, so a body without any of them (including one carrying only user_ids) is a 400.
43
43
  # @param account_id [String] account id ID
44
44
  # @param board_id [String] board id ID
45
45
  # @param name [String, nil] name
46
46
  # @param all_access [Boolean, nil] all access
47
- # @param auto_postpone_period_in_days [Integer, nil] auto postpone period in days
47
+ # @param auto_postpone_period_in_days [Integer, nil] Days of inactivity before a card is auto-postponed. One of 3, 7, 11, 30, 90 or 365 (Entropy::AUTO_POSTPONE_PERIODS_IN_DAYS); any other value is rejected with 422.
48
48
  # @param public_description [String, nil] public description
49
- # @param user_ids [Array, nil] user ids
49
+ # @param user_ids [Array, nil] Replaces the board's membership: users listed are granted access and every current member not listed loses it. Omit it to leave access unchanged. It can't be sent alone (see the operation).
50
50
  # @return [Hash] response data
51
51
  def update(account_id:, board_id:, name: nil, all_access: nil, auto_postpone_period_in_days: nil, public_description: nil, user_ids: nil)
52
52
  with_operation(service: "boards", operation: "UpdateBoard", is_mutation: true, resource_id: board_id) do
@@ -69,11 +69,11 @@ module Fizzy
69
69
  # @param closer_ids [Array, nil] closer ids
70
70
  # @param card_ids [Array, nil] card ids
71
71
  # @param column_ids [Array, nil] column ids
72
- # @param indexed_by [String, nil] indexed by
73
- # @param sorted_by [String, nil] sorted by
74
- # @param assignment_status [String, nil] assignment status
75
- # @param creation [String, nil] creation
76
- # @param closure [String, nil] closure
72
+ # @param indexed_by [String, nil] Which cards to list: "all" (default, open cards), "maybe" (awaiting triage), "closed", "not_now", "stalled", "postponing_soon" or "golden". Any other value lists the same cards as "all".
73
+ # @param sorted_by [String, nil] "latest" (default, most recently active first), "newest" or "oldest" (by creation). Any other value sorts as "latest".
74
+ # @param assignment_status [String, nil] "unassigned" lists only cards with no assignee; any other value is ignored.
75
+ # @param creation [String, nil] Created within: "today", "yesterday", "thisweek", "lastweek", "thismonth", "lastmonth", "thisyear" or "lastyear" (TimeWindowParser); any other value is ignored.
76
+ # @param closure [String, nil] Closed within: "today", "yesterday", "thisweek", "lastweek", "thismonth", "lastmonth", "thisyear" or "lastyear" (TimeWindowParser); any other value is ignored.
77
77
  # @param terms [Array, nil] terms
78
78
  # @return [Enumerator<Hash>] paginated results
79
79
  def list(account_id:, board_ids: nil, tag_ids: nil, assignee_ids: nil, creator_ids: nil, closer_ids: nil, card_ids: nil, column_ids: nil, indexed_by: nil, sorted_by: nil, assignment_status: nil, creation: nil, closure: nil, terms: nil)
@@ -86,18 +86,15 @@ module Fizzy
86
86
  # create operation
87
87
  # @param account_id [String] account id ID
88
88
  # @param title [String] title
89
- # @param board_id [String, nil] board id
90
- # @param column_id [String, nil] column id
89
+ # @param board_id [String] The board to create the card on. Required: CardsController finds it unconditionally.
91
90
  # @param description [String, nil] description
92
- # @param assignee_ids [Array, nil] assignee ids
93
- # @param tag_names [Array, nil] tag names
94
- # @param image [String, nil] image
91
+ # @param image [String, nil] The signed blob id of an image uploaded with CreateDirectUpload. A caller that can't make the upload has no value to send here.
95
92
  # @param created_at [String, nil] created at
96
93
  # @param last_active_at [String, nil] last active at
97
94
  # @return [Hash] response data
98
- def create(account_id:, title:, board_id: nil, column_id: nil, description: nil, assignee_ids: nil, tag_names: nil, image: nil, created_at: nil, last_active_at: nil)
95
+ def create(account_id:, title:, board_id:, description: nil, image: nil, created_at: nil, last_active_at: nil)
99
96
  with_operation(service: "cards", operation: "CreateCard", is_mutation: true, resource_id: account_id) do
100
- http_post("/#{account_id}/cards.json", body: compact_params(title: title, board_id: board_id, column_id: column_id, description: description, assignee_ids: assignee_ids, tag_names: tag_names, image: image, created_at: created_at, last_active_at: last_active_at)).json
97
+ http_post("/#{account_id}/cards.json", body: compact_params(title: title, board_id: board_id, description: description, image: image, created_at: created_at, last_active_at: last_active_at)).json
101
98
  end
102
99
  end
103
100
 
@@ -111,18 +108,17 @@ module Fizzy
111
108
  end
112
109
  end
113
110
 
114
- # update operation
111
+ # Updates a card. Send at least one of title, description, image or created_at: the controller expects a `card` object, so an empty body is a 400. Moving a card between columns is TriageCard, and between boards MoveCard.
115
112
  # @param account_id [String] account id ID
116
113
  # @param card_number [Integer] card number ID
117
114
  # @param title [String, nil] title
118
115
  # @param description [String, nil] description
119
- # @param column_id [String, nil] column id
120
- # @param image [String, nil] image
116
+ # @param image [String, nil] The signed blob id of an image uploaded with CreateDirectUpload. A caller that can't make the upload has no value to send here.
121
117
  # @param created_at [String, nil] created at
122
118
  # @return [Hash] response data
123
- def update(account_id:, card_number:, title: nil, description: nil, column_id: nil, image: nil, created_at: nil)
119
+ def update(account_id:, card_number:, title: nil, description: nil, image: nil, created_at: nil)
124
120
  with_operation(service: "cards", operation: "UpdateCard", is_mutation: true, resource_id: card_number) do
125
- http_patch("/#{account_id}/cards/#{card_number}", body: compact_params(title: title, description: description, column_id: column_id, image: image, created_at: created_at)).json
121
+ http_patch("/#{account_id}/cards/#{card_number}", body: compact_params(title: title, description: description, image: image, created_at: created_at)).json
126
122
  end
127
123
  end
128
124
 
@@ -137,7 +133,7 @@ module Fizzy
137
133
  end
138
134
  end
139
135
 
140
- # assign operation
136
+ # Assigns the user to the card, or unassigns them if they are already assigned. This is a toggle: calling it again undoes it, and both outcomes return 204, so a retry or a repeat reverses the first call. Check the card first when the outcome matters.
141
137
  # @param account_id [String] account id ID
142
138
  # @param card_number [Integer] card number ID
143
139
  # @param assignee_id [String] assignee id
@@ -152,12 +148,11 @@ module Fizzy
152
148
  # move operation
153
149
  # @param account_id [String] account id ID
154
150
  # @param card_number [Integer] card number ID
155
- # @param board_id [String] board id
156
- # @param column_id [String, nil] column id
151
+ # @param board_id [String] The destination board. Cards::BoardsController reads no column.
157
152
  # @return [Hash] response data
158
- def move(account_id:, card_number:, board_id:, column_id: nil)
153
+ def move(account_id:, card_number:, board_id:)
159
154
  with_operation(service: "cards", operation: "MoveCard", is_mutation: true, resource_id: card_number) do
160
- http_patch("/#{account_id}/cards/#{card_number}/board.json", body: compact_params(board_id: board_id, column_id: column_id)).json
155
+ http_patch("/#{account_id}/cards/#{card_number}/board.json", body: compact_params(board_id: board_id)).json
161
156
  end
162
157
  end
163
158
 
@@ -216,13 +211,13 @@ module Fizzy
216
211
  end
217
212
  end
218
213
 
219
- # postpone operation
214
+ # Moves the card to Not Now. Each call records an event, which notifies webhooks and adds a system comment to the card, even when the card's state doesn't change, so it is not safe to retry.
220
215
  # @param account_id [String] account id ID
221
216
  # @param card_number [Integer] card number ID
222
217
  # @return [void]
223
218
  def postpone(account_id:, card_number:)
224
219
  with_operation(service: "cards", operation: "PostponeCard", is_mutation: true, resource_id: card_number) do
225
- http_post("/#{account_id}/cards/#{card_number}/not_now.json", retryable: true)
220
+ http_post("/#{account_id}/cards/#{card_number}/not_now.json")
226
221
  nil
227
222
  end
228
223
  end
@@ -238,13 +233,13 @@ module Fizzy
238
233
  end
239
234
  end
240
235
 
241
- # unpin operation
236
+ # Unpins the card for the caller. Unpinning a card the caller hasn't pinned currently fails with a 500 (Card::Pinnable#unpin_by destroys a pin it didn't find), so a retry after a success fails: not idempotent.
242
237
  # @param account_id [String] account id ID
243
238
  # @param card_number [Integer] card number ID
244
239
  # @return [void]
245
240
  def unpin(account_id:, card_number:)
246
241
  with_operation(service: "cards", operation: "UnpinCard", is_mutation: true, resource_id: card_number) do
247
- http_delete("/#{account_id}/cards/#{card_number}/pin.json")
242
+ http_delete("/#{account_id}/cards/#{card_number}/pin.json", retryable: false)
248
243
  nil
249
244
  end
250
245
  end
@@ -260,7 +255,7 @@ module Fizzy
260
255
  end
261
256
  end
262
257
 
263
- # self_assign operation
258
+ # Assigns the caller to the card, or unassigns them if they are already assigned. This is a toggle: calling it again undoes it, and both outcomes return 204, so a retry or a repeat reverses the first call. Check the card first when the outcome matters.
264
259
  # @param account_id [String] account id ID
265
260
  # @param card_number [Integer] card number ID
266
261
  # @return [void]
@@ -271,10 +266,10 @@ module Fizzy
271
266
  end
272
267
  end
273
268
 
274
- # tag operation
269
+ # Tags the card, or untags it if it already has the tag. The tag is created when the account has none with that title. This is a toggle: calling it again undoes it, and both outcomes return 204, so a retry or a repeat reverses the first call. Check the card first when the outcome matters.
275
270
  # @param account_id [String] account id ID
276
271
  # @param card_number [Integer] card number ID
277
- # @param tag_title [String] tag title
272
+ # @param tag_title [String] The tag's title; surrounding whitespace and a leading "#" are stripped.
278
273
  # @return [void]
279
274
  def tag(account_id:, card_number:, tag_title:)
280
275
  with_operation(service: "cards", operation: "TagCard", is_mutation: true, resource_id: card_number) do
@@ -283,25 +278,25 @@ module Fizzy
283
278
  end
284
279
  end
285
280
 
286
- # triage operation
281
+ # Triages the card into a column of its board. Each call records an event, which notifies webhooks and adds a system comment to the card, even when the card's state doesn't change, so it is not safe to retry.
287
282
  # @param account_id [String] account id ID
288
283
  # @param card_number [Integer] card number ID
289
- # @param column_id [String, nil] column id
284
+ # @param column_id [String] A column on the card's board. Required: the controller finds it unconditionally.
290
285
  # @return [void]
291
- def triage(account_id:, card_number:, column_id: nil)
286
+ def triage(account_id:, card_number:, column_id:)
292
287
  with_operation(service: "cards", operation: "TriageCard", is_mutation: true, resource_id: card_number) do
293
- http_post("/#{account_id}/cards/#{card_number}/triage.json", body: compact_params(column_id: column_id), retryable: true)
288
+ http_post("/#{account_id}/cards/#{card_number}/triage.json", body: compact_params(column_id: column_id))
294
289
  nil
295
290
  end
296
291
  end
297
292
 
298
- # untriage operation
293
+ # Sends the card back to triage (Maybe?). Each call records an event, which notifies webhooks and adds a system comment to the card, even when the card's state doesn't change, so it is not safe to retry.
299
294
  # @param account_id [String] account id ID
300
295
  # @param card_number [Integer] card number ID
301
296
  # @return [void]
302
297
  def untriage(account_id:, card_number:)
303
298
  with_operation(service: "cards", operation: "UnTriageCard", is_mutation: true, resource_id: card_number) do
304
- http_delete("/#{account_id}/cards/#{card_number}/triage.json")
299
+ http_delete("/#{account_id}/cards/#{card_number}/triage.json", retryable: false)
305
300
  nil
306
301
  end
307
302
  end
@@ -21,7 +21,7 @@ module Fizzy
21
21
  # @param account_id [String] account id ID
22
22
  # @param board_id [String] board id ID
23
23
  # @param name [String] name
24
- # @param color [String, nil] color
24
+ # @param color [String, nil] The column color, as its CSS value: "var(--color-card-default)" (Blue), "var(--color-card-1)" (Gray), "var(--color-card-2)" (Tan), "var(--color-card-3)" (Yellow), "var(--color-card-4)" (Lime), "var(--color-card-5)" (Aqua), "var(--color-card-6)" (Violet), "var(--color-card-7)" (Purple) or "var(--color-card-8)" (Pink) (Color::COLORS). Any other value is stored but reads back as Blue.
25
25
  # @return [Hash] response data
26
26
  def create(account_id:, board_id:, name:, color: nil)
27
27
  with_operation(service: "columns", operation: "CreateColumn", is_mutation: true, resource_id: board_id) do
@@ -40,12 +40,12 @@ module Fizzy
40
40
  end
41
41
  end
42
42
 
43
- # update operation
43
+ # Updates a column. Send at least one of name or color: the controller expects a `column` object, so an empty body is a 400.
44
44
  # @param account_id [String] account id ID
45
45
  # @param board_id [String] board id ID
46
46
  # @param column_id [String] column id ID
47
47
  # @param name [String, nil] name
48
- # @param color [String, nil] color
48
+ # @param color [String, nil] The column color, as its CSS value: "var(--color-card-default)" (Blue), "var(--color-card-1)" (Gray), "var(--color-card-2)" (Tan), "var(--color-card-3)" (Yellow), "var(--color-card-4)" (Lime), "var(--color-card-5)" (Aqua), "var(--color-card-6)" (Violet), "var(--color-card-7)" (Purple) or "var(--color-card-8)" (Pink) (Color::COLORS). Any other value is stored but reads back as Blue.
49
49
  # @return [Hash] response data
50
50
  def update(account_id:, board_id:, column_id:, name: nil, color: nil)
51
51
  with_operation(service: "columns", operation: "UpdateColumn", is_mutation: true, resource_id: column_id) do
@@ -15,9 +15,9 @@ module Fizzy
15
15
  end
16
16
  end
17
17
 
18
- # update_timezone operation
18
+ # Sets the caller's time zone. The name isn't validated: one that ActiveSupport::TimeZone doesn't recognize is stored, succeeds with 204, and reads back as UTC.
19
19
  # @param account_id [String] account id ID
20
- # @param timezone_name [String] timezone name
20
+ # @param timezone_name [String] An IANA or Rails time zone name, such as "America/Chicago" or "Central Time (US & Canada)".
21
21
  # @return [void]
22
22
  def update_timezone(account_id:, timezone_name:)
23
23
  with_operation(service: "identity", operation: "UpdateMyTimezone", is_mutation: true, resource_id: account_id) do
@@ -37,9 +37,9 @@ module Fizzy
37
37
 
38
38
  # update_account_entropy operation
39
39
  # @param account_id [String] account id ID
40
- # @param auto_postpone_period_in_days [Integer, nil] auto postpone period in days
40
+ # @param auto_postpone_period_in_days [Integer] Days of inactivity before a card is auto-postponed. One of 3, 7, 11, 30, 90 or 365 (Entropy::AUTO_POSTPONE_PERIODS_IN_DAYS); any other value is rejected with 422. Required: an empty body is a 400.
41
41
  # @return [Hash] response data
42
- def update_account_entropy(account_id:, auto_postpone_period_in_days: nil)
42
+ def update_account_entropy(account_id:, auto_postpone_period_in_days:)
43
43
  with_operation(service: "miscellaneous", operation: "UpdateAccountEntropy", is_mutation: true, resource_id: account_id) do
44
44
  http_put("/#{account_id}/account/entropy.json", body: compact_params(auto_postpone_period_in_days: auto_postpone_period_in_days)).json
45
45
  end
@@ -75,21 +75,21 @@ module Fizzy
75
75
 
76
76
  # update_join_code operation
77
77
  # @param account_id [String] account id ID
78
- # @param usage_limit [Integer, nil] usage limit
78
+ # @param usage_limit [Integer] How many times the join code can be redeemed. Fizzy accepts up to 10,000,000,000, but this 32-bit field holds at most 2,147,483,647. Required: an empty body is a 400.
79
79
  # @return [void]
80
- def update_join_code(account_id:, usage_limit: nil)
80
+ def update_join_code(account_id:, usage_limit:)
81
81
  with_operation(service: "miscellaneous", operation: "UpdateJoinCode", is_mutation: true, resource_id: account_id) do
82
82
  http_patch("/#{account_id}/account/join_code.json", body: compact_params(usage_limit: usage_limit))
83
83
  nil
84
84
  end
85
85
  end
86
86
 
87
- # reset_join_code operation
87
+ # Replaces the join code with a newly generated one and resets its usage count. Each call mints a different code and invalidates the previous one, so a retry invalidates the code the first call issued: not idempotent.
88
88
  # @param account_id [String] account id ID
89
89
  # @return [void]
90
90
  def reset_join_code(account_id:)
91
91
  with_operation(service: "miscellaneous", operation: "ResetJoinCode", is_mutation: true, resource_id: account_id) do
92
- http_delete("/#{account_id}/account/join_code.json")
92
+ http_delete("/#{account_id}/account/join_code.json", retryable: false)
93
93
  nil
94
94
  end
95
95
  end
@@ -105,9 +105,9 @@ module Fizzy
105
105
 
106
106
  # update_account_settings operation
107
107
  # @param account_id [String] account id ID
108
- # @param name [String, nil] name
108
+ # @param name [String] The account name. Required: the controller expects an `account` object, so an empty body is a 400.
109
109
  # @return [void]
110
- def update_account_settings(account_id:, name: nil)
110
+ def update_account_settings(account_id:, name:)
111
111
  with_operation(service: "miscellaneous", operation: "UpdateAccountSettings", is_mutation: true, resource_id: account_id) do
112
112
  http_patch("/#{account_id}/account/settings.json", body: compact_params(name: name))
113
113
  nil
@@ -117,9 +117,9 @@ module Fizzy
117
117
  # update_board_entropy operation
118
118
  # @param account_id [String] account id ID
119
119
  # @param board_id [String] board id ID
120
- # @param auto_postpone_period_in_days [Integer, nil] auto postpone period in days
120
+ # @param auto_postpone_period_in_days [Integer] Days of inactivity before a card is auto-postponed. One of 3, 7, 11, 30, 90 or 365 (Entropy::AUTO_POSTPONE_PERIODS_IN_DAYS); any other value is rejected with 422. Required: an empty body is a 400.
121
121
  # @return [Hash] response data
122
- def update_board_entropy(account_id:, board_id:, auto_postpone_period_in_days: nil)
122
+ def update_board_entropy(account_id:, board_id:, auto_postpone_period_in_days:)
123
123
  with_operation(service: "miscellaneous", operation: "UpdateBoardEntropy", is_mutation: true, resource_id: board_id) do
124
124
  http_put("/#{account_id}/boards/#{board_id}/entropy.json", body: compact_params(auto_postpone_period_in_days: auto_postpone_period_in_days)).json
125
125
  end
@@ -128,16 +128,16 @@ module Fizzy
128
128
  # update_board_involvement operation
129
129
  # @param account_id [String] account id ID
130
130
  # @param board_id [String] board id ID
131
- # @param involvement [String, nil] involvement
131
+ # @param involvement [String] The caller's involvement with the board: "access_only" or "watching" (Access#involvement). Required: the controller assigns it unconditionally.
132
132
  # @return [void]
133
- def update_board_involvement(account_id:, board_id:, involvement: nil)
133
+ def update_board_involvement(account_id:, board_id:, involvement:)
134
134
  with_operation(service: "miscellaneous", operation: "UpdateBoardInvolvement", is_mutation: true, resource_id: board_id) do
135
135
  http_patch("/#{account_id}/boards/#{board_id}/involvement.json", body: compact_params(involvement: involvement))
136
136
  nil
137
137
  end
138
138
  end
139
139
 
140
- # mark_card_read operation
140
+ # Marks the caller's notification for the card read. When the caller has no notification for the card, the call succeeds without changing anything.
141
141
  # @param account_id [String] account id ID
142
142
  # @param card_number [Integer] card number ID
143
143
  # @return [void]
@@ -148,7 +148,7 @@ module Fizzy
148
148
  end
149
149
  end
150
150
 
151
- # mark_card_unread operation
151
+ # Marks the caller's notification for the card unread. When the caller has no notification for the card, the call succeeds without changing anything.
152
152
  # @param account_id [String] account id ID
153
153
  # @param card_number [Integer] card number ID
154
154
  # @return [void]
@@ -159,24 +159,24 @@ module Fizzy
159
159
  end
160
160
  end
161
161
 
162
- # move_column_left operation
162
+ # Swaps the column with its left neighbor. Each call moves it again, so it is not idempotent; at the leftmost edge there is no neighbor and the call succeeds without changing anything.
163
163
  # @param account_id [String] account id ID
164
164
  # @param column_id [String] column id ID
165
165
  # @return [void]
166
166
  def move_column_left(account_id:, column_id:)
167
167
  with_operation(service: "miscellaneous", operation: "MoveColumnLeft", is_mutation: true, resource_id: column_id) do
168
- http_post("/#{account_id}/columns/#{column_id}/left_position.json", retryable: true)
168
+ http_post("/#{account_id}/columns/#{column_id}/left_position.json")
169
169
  nil
170
170
  end
171
171
  end
172
172
 
173
- # move_column_right operation
173
+ # Swaps the column with its right neighbor. Each call moves it again, so it is not idempotent; at the rightmost edge there is no neighbor and the call succeeds without changing anything.
174
174
  # @param account_id [String] account id ID
175
175
  # @param column_id [String] column id ID
176
176
  # @return [void]
177
177
  def move_column_right(account_id:, column_id:)
178
178
  with_operation(service: "miscellaneous", operation: "MoveColumnRight", is_mutation: true, resource_id: column_id) do
179
- http_post("/#{account_id}/columns/#{column_id}/right_position.json", retryable: true)
179
+ http_post("/#{account_id}/columns/#{column_id}/right_position.json")
180
180
  nil
181
181
  end
182
182
  end
@@ -192,9 +192,9 @@ module Fizzy
192
192
 
193
193
  # update_notification_settings operation
194
194
  # @param account_id [String] account id ID
195
- # @param bundle_email_frequency [String, nil] bundle email frequency
195
+ # @param bundle_email_frequency [String] "never", "every_few_hours", "daily" or "weekly" (User::Settings#bundle_email_frequency). Required: an empty body is a 400.
196
196
  # @return [void]
197
- def update_notification_settings(account_id:, bundle_email_frequency: nil)
197
+ def update_notification_settings(account_id:, bundle_email_frequency:)
198
198
  with_operation(service: "miscellaneous", operation: "UpdateNotificationSettings", is_mutation: true, resource_id: account_id) do
199
199
  http_patch("/#{account_id}/notifications/settings.json", body: compact_params(bundle_email_frequency: bundle_email_frequency))
200
200
  nil
@@ -241,7 +241,7 @@ module Fizzy
241
241
  # update_user_role operation
242
242
  # @param account_id [String] account id ID
243
243
  # @param user_id [String] user id ID
244
- # @param role [String] role
244
+ # @param role [String] "member" or "admin". Any other value, including "owner", sets the role to "member" (Users::RolesController#role_params), so a typo demotes.
245
245
  # @return [void]
246
246
  def update_user_role(account_id:, user_id:, role:)
247
247
  with_operation(service: "miscellaneous", operation: "UpdateUserRole", is_mutation: true, resource_id: user_id) do
@@ -7,20 +7,18 @@ module Fizzy
7
7
  # @generated from OpenAPI spec
8
8
  class NotificationsService < BaseService
9
9
 
10
- # list operation
10
+ # Lists the caller's notifications. It takes no read/unread filter: the first page holds up to 100 unread notifications followed by a page of read ones, and later pages hold read ones only.
11
11
  # @param account_id [String] account id ID
12
- # @param read [Boolean, nil] read
13
12
  # @return [Enumerator<Hash>] paginated results
14
- def list(account_id:, read: nil)
13
+ def list(account_id:)
15
14
  wrap_paginated(service: "notifications", operation: "ListNotifications", is_mutation: false, resource_id: account_id) do
16
- params = compact_params(read: read)
17
- paginate("/#{account_id}/notifications.json", params: params)
15
+ paginate("/#{account_id}/notifications.json")
18
16
  end
19
17
  end
20
18
 
21
- # bulk_read operation
19
+ # Marks every unread notification of the caller read. It takes no selection.
22
20
  # @param account_id [String] account id ID
23
- # @param notification_ids [Array, nil] notification ids
21
+ # @param notification_ids [Array, nil] Ignored: Notifications::BulkReadingsController reads no ids and marks every unread notification read whatever this holds.
24
22
  # @return [void]
25
23
  def bulk_read(account_id:, notification_ids: nil)
26
24
  with_operation(service: "notifications", operation: "BulkReadNotifications", is_mutation: true, resource_id: account_id) do
@@ -7,7 +7,7 @@ module Fizzy
7
7
  # @generated from OpenAPI spec
8
8
  class SessionsService < BaseService
9
9
 
10
- # create operation
10
+ # Starts a magic-link sign-in and answers the pending authentication token.
11
11
  # @param email_address [String] email address
12
12
  # @return [Hash] response data
13
13
  def create(email_address:)
@@ -25,12 +25,12 @@ module Fizzy
25
25
  end
26
26
  end
27
27
 
28
- # redeem_magic_link operation
29
- # @param token [String] token
28
+ # Redeems the code from a magic-link email for a session token.
29
+ # @param code [String] The code from the magic-link email.
30
30
  # @return [Hash] response data
31
- def redeem_magic_link(token:)
31
+ def redeem_magic_link(code:)
32
32
  with_operation(service: "sessions", operation: "RedeemMagicLink", is_mutation: true) do
33
- http_post("/session/magic_link.json", body: compact_params(token: token)).json
33
+ http_post("/session/magic_link.json", body: compact_params(code: code)).json
34
34
  end
35
35
  end
36
36
 
@@ -40,7 +40,7 @@ module Fizzy
40
40
  end
41
41
  end
42
42
 
43
- # update operation
43
+ # Updates a step. Send at least one of content or completed: the controller expects a `step` object, so an empty body is a 400.
44
44
  # @param account_id [String] account id ID
45
45
  # @param card_number [Integer] card number ID
46
46
  # @param step_id [String] step id ID
@@ -29,9 +29,9 @@ module Fizzy
29
29
  # update operation
30
30
  # @param account_id [String] account id ID
31
31
  # @param user_id [String] user id ID
32
- # @param name [String, nil] name
32
+ # @param name [String] Required: the controller expects a `user` object, so an empty body is a 400.
33
33
  # @return [Hash] response data
34
- def update(account_id:, user_id:, name: nil)
34
+ def update(account_id:, user_id:, name:)
35
35
  with_operation(service: "users", operation: "UpdateUser", is_mutation: true, resource_id: user_id) do
36
36
  http_patch("/#{account_id}/users/#{user_id}", body: compact_params(name: name)).json
37
37
  end
@@ -7,7 +7,7 @@ module Fizzy
7
7
  # @generated from OpenAPI spec
8
8
  class WebhooksService < BaseService
9
9
 
10
- # list operation
10
+ # Lists the board's webhooks. Fizzy pages this list (a Link header with rel="next"); this operation returns the first page.
11
11
  # @param account_id [String] account id ID
12
12
  # @param board_id [String] board id ID
13
13
  # @return [Hash] response data
@@ -22,7 +22,7 @@ module Fizzy
22
22
  # @param board_id [String] board id ID
23
23
  # @param name [String] name
24
24
  # @param url [String] url
25
- # @param subscribed_actions [Array, nil] subscribed actions
25
+ # @param subscribed_actions [Array, nil] The events to deliver, from: "card_assigned", "card_closed", "card_postponed", "card_auto_postponed", "card_board_changed", "card_published", "card_reopened", "card_sent_back_to_triage", "card_triaged", "card_unassigned" and "comment_created" (Webhook::PERMITTED_ACTIONS). Unknown actions are dropped silently. A webhook created without any receives no deliveries.
26
26
  # @return [Hash] response data
27
27
  def create(account_id:, board_id:, name:, url:, subscribed_actions: nil)
28
28
  with_operation(service: "webhooks", operation: "CreateWebhook", is_mutation: true, resource_id: board_id) do
@@ -41,17 +41,16 @@ module Fizzy
41
41
  end
42
42
  end
43
43
 
44
- # update operation
44
+ # Updates a webhook's name or subscribed actions. Send at least one: the controller expects a `webhook` object, so an empty body is a 400. The URL can't change after creation; delete the webhook and create a new one.
45
45
  # @param account_id [String] account id ID
46
46
  # @param board_id [String] board id ID
47
47
  # @param webhook_id [String] webhook id ID
48
48
  # @param name [String, nil] name
49
- # @param url [String, nil] url
50
- # @param subscribed_actions [Array, nil] subscribed actions
49
+ # @param subscribed_actions [Array, nil] The events to deliver, from: "card_assigned", "card_closed", "card_postponed", "card_auto_postponed", "card_board_changed", "card_published", "card_reopened", "card_sent_back_to_triage", "card_triaged", "card_unassigned" and "comment_created" (Webhook::PERMITTED_ACTIONS). Unknown actions are dropped silently. The list replaces the current subscription.
51
50
  # @return [Hash] response data
52
- def update(account_id:, board_id:, webhook_id:, name: nil, url: nil, subscribed_actions: nil)
51
+ def update(account_id:, board_id:, webhook_id:, name: nil, subscribed_actions: nil)
53
52
  with_operation(service: "webhooks", operation: "UpdateWebhook", is_mutation: true, resource_id: webhook_id) do
54
- http_patch("/#{account_id}/boards/#{board_id}/webhooks/#{webhook_id}", body: compact_params(name: name, url: url, subscribed_actions: subscribed_actions)).json
53
+ http_patch("/#{account_id}/boards/#{board_id}/webhooks/#{webhook_id}", body: compact_params(name: name, subscribed_actions: subscribed_actions)).json
55
54
  end
56
55
  end
57
56
 
@@ -365,16 +365,13 @@ module Fizzy
365
365
  CreateCardReactionResponseContent = Data.define
366
366
 
367
367
  # @generated
368
- CreateCardRequestContent = Data.define(:title, :board_id, :column_id, :description, :assignee_ids, :tag_names, :image, :created_at, :last_active_at) do
368
+ CreateCardRequestContent = Data.define(:title, :board_id, :description, :image, :created_at, :last_active_at) do
369
369
  # @param data [Hash] raw JSON response
370
370
  def self.from_json(data)
371
371
  new(
372
372
  title: data["title"],
373
373
  board_id: data["board_id"],
374
- column_id: data["column_id"],
375
374
  description: data["description"],
376
- assignee_ids: data["assignee_ids"],
377
- tag_names: data["tag_names"],
378
375
  image: data["image"],
379
376
  created_at: data["created_at"],
380
377
  last_active_at: data["last_active_at"]
@@ -700,12 +697,11 @@ module Fizzy
700
697
  ListWebhooksResponseContent = Data.define
701
698
 
702
699
  # @generated
703
- MoveCardRequestContent = Data.define(:board_id, :column_id) do
700
+ MoveCardRequestContent = Data.define(:board_id) do
704
701
  # @param data [Hash] raw JSON response
705
702
  def self.from_json(data)
706
703
  new(
707
- board_id: data["board_id"],
708
- column_id: data["column_id"]
704
+ board_id: data["board_id"]
709
705
  )
710
706
  end
711
707
  end
@@ -805,11 +801,11 @@ module Fizzy
805
801
  end
806
802
 
807
803
  # @generated
808
- RedeemMagicLinkRequestContent = Data.define(:token) do
804
+ RedeemMagicLinkRequestContent = Data.define(:code) do
809
805
  # @param data [Hash] raw JSON response
810
806
  def self.from_json(data)
811
807
  new(
812
- token: data["token"]
808
+ code: data["code"]
813
809
  )
814
810
  end
815
811
  end
@@ -986,13 +982,12 @@ module Fizzy
986
982
  UpdateBoardResponseContent = Data.define
987
983
 
988
984
  # @generated
989
- UpdateCardRequestContent = Data.define(:title, :description, :column_id, :image, :created_at) do
985
+ UpdateCardRequestContent = Data.define(:title, :description, :image, :created_at) do
990
986
  # @param data [Hash] raw JSON response
991
987
  def self.from_json(data)
992
988
  new(
993
989
  title: data["title"],
994
990
  description: data["description"],
995
- column_id: data["column_id"],
996
991
  image: data["image"],
997
992
  created_at: data["created_at"]
998
993
  )
@@ -1097,12 +1092,11 @@ module Fizzy
1097
1092
  end
1098
1093
 
1099
1094
  # @generated
1100
- UpdateWebhookRequestContent = Data.define(:name, :url, :subscribed_actions) do
1095
+ UpdateWebhookRequestContent = Data.define(:name, :subscribed_actions) do
1101
1096
  # @param data [Hash] raw JSON response
1102
1097
  def self.from_json(data)
1103
1098
  new(
1104
1099
  name: data["name"],
1105
- url: data["url"],
1106
1100
  subscribed_actions: data["subscribed_actions"]
1107
1101
  )
1108
1102
  end
data/lib/fizzy/http.rb CHANGED
@@ -160,12 +160,15 @@ module Fizzy
160
160
  end
161
161
 
162
162
  def request_with_retry(method, url, params: {}, body: nil)
163
+ # max_retries counts total attempts and is floored at one: whether a
164
+ # request reaches the wire at all must not depend on the retry budget.
165
+ max_attempts = [ @config.max_retries, 1 ].max
163
166
  attempt = 0
164
167
  last_error = nil
165
168
 
166
169
  loop do
167
170
  attempt += 1
168
- break if attempt > @config.max_retries
171
+ break if attempt > max_attempts
169
172
 
170
173
  begin
171
174
  return single_request(method, url, params: params, body: body, attempt: attempt)
@@ -175,7 +178,7 @@ module Fizzy
175
178
  last_error = e
176
179
 
177
180
  # Don't sleep if this was the last attempt
178
- break if attempt >= @config.max_retries
181
+ break if attempt >= max_attempts
179
182
 
180
183
  delay = calculate_delay(attempt, e.retry_after)
181
184
 
@@ -187,7 +190,8 @@ module Fizzy
187
190
  end
188
191
  end
189
192
 
190
- raise last_error || Fizzy::APIError.new("Request failed after #{@config.max_retries} retries")
193
+ noun = max_attempts == 1 ? "attempt" : "attempts"
194
+ raise last_error || Fizzy::APIError.new("Request failed after #{max_attempts} #{noun}")
191
195
  end
192
196
 
193
197
  def single_request(method, url, params:, body:, attempt:, retry_count: 0)
@@ -210,6 +214,10 @@ module Fizzy
210
214
  status: response.status,
211
215
  headers: response.headers
212
216
  )
217
+ rescue Faraday::TimeoutError => e
218
+ # Faraday::TimeoutError < Faraday::ServerError: named before the status
219
+ # clause, or a stalled read classifies as a status-less api_error.
220
+ transport_failure(e, info: info, start_time: start_time)
213
221
  rescue Faraday::ServerError, Faraday::ClientError => e
214
222
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
215
223
  error = handle_error(e)
@@ -229,11 +237,7 @@ module Fizzy
229
237
 
230
238
  raise error
231
239
  rescue Faraday::Error => e
232
- duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
233
- error = Fizzy::NetworkError.new("Connection failed", cause: e)
234
- result = RequestResult.new(duration: duration, error: error)
235
- @hooks.on_request_end(info, result)
236
- raise error
240
+ transport_failure(e, info: info, start_time: start_time)
237
241
  end
238
242
  end
239
243
 
@@ -265,6 +269,8 @@ module Fizzy
265
269
  status: response.status,
266
270
  headers: response.headers
267
271
  )
272
+ rescue Faraday::TimeoutError => e
273
+ transport_failure(e, info: info, start_time: start_time)
268
274
  rescue Faraday::ServerError, Faraday::ClientError => e
269
275
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
270
276
  error = handle_error(e)
@@ -277,14 +283,21 @@ module Fizzy
277
283
  @hooks.on_request_end(info, result)
278
284
  raise error
279
285
  rescue Faraday::Error => e
280
- duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
281
- error = Fizzy::NetworkError.new("Connection failed", cause: e)
282
- result = RequestResult.new(duration: duration, error: error)
283
- @hooks.on_request_end(info, result)
284
- raise error
286
+ transport_failure(e, info: info, start_time: start_time)
285
287
  end
286
288
  end
287
289
 
290
+ # A transport failure — no HTTP response, so no status to map — as the
291
+ # network error the caller and on_request_end both see: a timeout by name,
292
+ # anything else as a connection failure, both retryable.
293
+ def transport_failure(exception, info:, start_time:)
294
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
295
+ message = exception.is_a?(Faraday::TimeoutError) ? "Request timed out" : "Connection failed"
296
+ error = Fizzy::NetworkError.new(message, cause: exception)
297
+ @hooks.on_request_end(info, RequestResult.new(duration: duration, error: error))
298
+ raise error
299
+ end
300
+
288
301
  def handle_error(error)
289
302
  status = error.response&.dig(:status)
290
303
  body = error.response&.dig(:body)
@@ -1,56 +1,120 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fizzy
4
- # Orchestrates the passwordless magic link authentication flow.
4
+ # Signs in without a password. Fizzy emails a code to an address and, once the person types
5
+ # it back, hands out a session token for {CookieAuth}.
5
6
  #
6
- # The flow works in two steps:
7
- # 1. Call CreateSession with an email address - this sends a magic link email
8
- # 2. Call RedeemMagicLink with the token from the magic link URL
7
+ # The two steps are one exchange as far as Fizzy is concerned: creating the session sets a
8
+ # +pending_authentication_token+ cookie naming the address the code went to, and redeeming
9
+ # the code has to send that cookie back or the code is refused. The flow keeps the token
10
+ # between the calls, and hands it out for a caller that finishes in another process (a CLI
11
+ # that prompts for the code on its next run), which picks it up again with +pending_token:+.
9
12
  #
10
- # After redemption, the response contains a session token that can be used
11
- # with CookieAuth or BearerAuth for subsequent requests.
13
+ # Both requests go through the generated {Services::SessionsService}, so their paths and
14
+ # bodies are the model's: +POST /session.json+ with +email_address+, then
15
+ # +POST /session/magic_link.json+ with +code+.
12
16
  #
13
17
  # @example
14
- # flow = Fizzy::MagicLinkFlow.new(base_url: "https://fizzy.do")
15
- # flow.request_magic_link(email: "user@example.com")
16
- # # User clicks magic link in email, your app extracts the token
17
- # session = flow.redeem(token: "magic-link-token-from-url")
18
- # # Use session token for authenticated requests
18
+ # flow = Fizzy::MagicLinkFlow.new
19
+ # flow.create_session(email_address: "jane@example.com")
20
+ # # ... the person reads the code out of their email ...
21
+ # session = flow.redeem(code: "ABC123")
19
22
  # client = Fizzy.client(auth: Fizzy::CookieAuth.new(session["session_token"]))
23
+ #
24
+ # @example Finishing in another process
25
+ # pending = Fizzy::MagicLinkFlow.new.tap { _1.create_session(email_address: email) }.pending_token
26
+ # # ... later ...
27
+ # Fizzy::MagicLinkFlow.new(pending_token: pending).redeem(code: code)
20
28
  class MagicLinkFlow
29
+ # The cookie a pending sign-in is carried in between creating the session and redeeming
30
+ # the code.
31
+ PENDING_COOKIE = "pending_authentication_token"
32
+
33
+ # The token a pending sign-in is waiting on, for a caller that finishes the flow in
34
+ # another process.
35
+ # @return [String, nil]
36
+
21
37
  # @param base_url [String] Fizzy API base URL
22
38
  # @param hooks [Hooks, nil] observability hooks
23
- def initialize(base_url: Config::DEFAULT_BASE_URL, hooks: nil)
39
+ # @param pending_token [String, nil] the pending token of a sign-in another flow started
40
+ def initialize(base_url: Config::DEFAULT_BASE_URL, hooks: nil, pending_token: nil)
41
+ @pending_token = pending_token
42
+ @lock = Mutex.new
24
43
  @config = Config.new(base_url: base_url)
25
- @hooks = hooks || NoopHooks.new
26
- @http = Http.new(config: @config, auth_strategy: NullAuth.new, hooks: @hooks)
44
+ @hooks = hooks
45
+ @client = client_with(PendingCookieAuth.new(nil))
46
+ end
47
+
48
+ # The pending authentication token of the sign-in under way, or nil before one starts.
49
+ # @return [String, nil]
50
+ def pending_token
51
+ @lock.synchronize { @pending_token }
27
52
  end
28
53
 
29
- # Step 1: Request a magic link email.
54
+ # Asks Fizzy to email a sign-in code to the address, and keeps the pending token the
55
+ # answer carries for {#redeem}.
30
56
  #
31
- # @param email [String] the user's email address
32
- # @return [Hash] response from the API
33
- def request_magic_link(email:)
34
- response = @http.post("/sessions", body: { email: email })
35
- response.json
57
+ # @param email_address [String] the address to send the code to
58
+ # @return [String] the pending authentication token
59
+ # @raise [APIError] when Fizzy answers without a pending token
60
+ def create_session(email_address:)
61
+ answer = @client.sessions.create(email_address: email_address)
62
+ token = answer.is_a?(Hash) ? answer["pending_authentication_token"] : nil
63
+ raise APIError.new("Fizzy answered no pending authentication token") \
64
+ unless token.is_a?(String) && !token.empty?
65
+
66
+ @lock.synchronize { @pending_token = token }
36
67
  end
37
68
 
38
- # Step 2: Redeem a magic link token to get a session.
69
+ # Redeems the code from the email, sending back the pending token as the cookie Fizzy
70
+ # reads it from.
39
71
  #
40
- # @param token [String] the magic link token from the URL
41
- # @return [Hash] response containing session_token and user info
42
- def redeem(token:)
43
- response = @http.post("/sessions/redeem", body: { token: token })
44
- response.json
72
+ # @param code [String] the code from the magic-link email
73
+ # @return [Hash] +session_token+ and +requires_signup_completion+
74
+ # @raise [UsageError] when no sign-in is pending
75
+ # @raise [AuthError] when Fizzy refuses the code or the pending token
76
+ def redeem(code:)
77
+ pending = pending_token
78
+ raise UsageError.new("no sign-in is pending; call create_session first, or pass pending_token:") \
79
+ if pending.nil? || pending.empty?
80
+
81
+ # The cookie is bound to the token read here, so a sign-in that starts while this one is
82
+ # under way (a hook can yield before the request is signed) cannot pair this code with
83
+ # another's pending token.
84
+ answer = client_with(PendingCookieAuth.new(pending)).sessions.redeem_magic_link(code: code)
85
+ raise APIError.new("Fizzy answered no session token") \
86
+ unless answer.is_a?(Hash) && answer["session_token"].is_a?(String)
87
+
88
+ # The sign-in redeemed is consumed; one a create_session started meanwhile is not.
89
+ @lock.synchronize { @pending_token = nil if @pending_token.equal?(pending) }
90
+ answer.slice("session_token", "requires_signup_completion")
45
91
  end
46
92
 
47
- # Null authentication strategy for unauthenticated requests.
93
+ # A +Cookie+ header value carrying one cookie, its value percent-encoded as Rack decodes it.
48
94
  # @api private
49
- class NullAuth
95
+ def self.cookie(name, value)
96
+ encoded = value.to_s.b.gsub(/[^A-Za-z0-9\-_.~]/n) { |byte| format("%%%02X", byte.ord) }
97
+ "#{name}=#{encoded}"
98
+ end
99
+
100
+ private
101
+
102
+ def client_with(auth_strategy)
103
+ Client.new(config: @config, auth_strategy: auth_strategy, hooks: @hooks)
104
+ end
105
+
106
+ # Sends one pending token, when given one, as the cookie Fizzy reads the pending sign-in
107
+ # from, and no other credentials: there are none yet.
108
+ # @api private
109
+ class PendingCookieAuth
50
110
  include AuthStrategy
51
111
 
112
+ def initialize(token)
113
+ @token = token
114
+ end
115
+
52
116
  def authenticate(headers)
53
- # No authentication needed for magic link flow initiation
117
+ headers["Cookie"] = MagicLinkFlow.cookie(PENDING_COOKIE, @token) unless @token.nil? || @token.empty?
54
118
  end
55
119
  end
56
120
  end
data/lib/fizzy/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fizzy
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.0"
5
5
  API_VERSION = "2026-03-01"
6
6
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fizzy-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.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-06-03 00:00:00.000000000 Z
11
+ date: 2026-10-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '2.6'
41
+ - !ruby/object:Gem::Dependency
42
+ name: bundler-audit
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 0.9.3
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 0.9.3
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: minitest
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -86,14 +100,14 @@ dependencies:
86
100
  requirements:
87
101
  - - "~>"
88
102
  - !ruby/object:Gem::Version
89
- version: '0.22'
103
+ version: '1.0'
90
104
  type: :development
91
105
  prerelease: false
92
106
  version_requirements: !ruby/object:Gem::Requirement
93
107
  requirements:
94
108
  - - "~>"
95
109
  - !ruby/object:Gem::Version
96
- version: '0.22'
110
+ version: '1.0'
97
111
  - !ruby/object:Gem::Dependency
98
112
  name: webmock
99
113
  requirement: !ruby/object:Gem::Requirement
@@ -128,28 +142,14 @@ dependencies:
128
142
  requirements:
129
143
  - - "~>"
130
144
  - !ruby/object:Gem::Version
131
- version: '7.1'
132
- type: :development
133
- prerelease: false
134
- version_requirements: !ruby/object:Gem::Requirement
135
- requirements:
136
- - - "~>"
137
- - !ruby/object:Gem::Version
138
- version: '7.1'
139
- - !ruby/object:Gem::Dependency
140
- name: webrick
141
- requirement: !ruby/object:Gem::Requirement
142
- requirements:
143
- - - "~>"
144
- - !ruby/object:Gem::Version
145
- version: '1.9'
145
+ version: '8.0'
146
146
  type: :development
147
147
  prerelease: false
148
148
  version_requirements: !ruby/object:Gem::Requirement
149
149
  requirements:
150
150
  - - "~>"
151
151
  - !ruby/object:Gem::Version
152
- version: '1.9'
152
+ version: '8.0'
153
153
  - !ruby/object:Gem::Dependency
154
154
  name: yard
155
155
  requirement: !ruby/object:Gem::Requirement