@mmerterden/multi-agent-pipeline 20.7.0 → 20.8.1

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 (284) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +0 -10
  3. package/docs/facts.json +1 -1
  4. package/manifest.json +285 -285
  5. package/package.json +3 -3
  6. package/pipeline/lib/redact.mjs +3 -2
  7. package/pipeline/scripts/_notices.mjs +1 -1
  8. package/pipeline/scripts/gen-skills-index.mjs +13 -1
  9. package/pipeline/scripts/pre-commit-check.sh +4 -0
  10. package/pipeline/skills/.skill-manifest.json +69 -69
  11. package/pipeline/skills/shared/README.md +70 -70
  12. package/pipeline/skills/shared/external/alarmkit/SKILL.md +373 -381
  13. package/pipeline/skills/shared/external/alarmkit/evals/evals.json +23 -18
  14. package/pipeline/skills/shared/external/alarmkit/references/alarmkit-patterns.md +328 -378
  15. package/pipeline/skills/shared/external/app-clips/SKILL.md +260 -160
  16. package/pipeline/skills/shared/external/app-clips/evals/evals.json +27 -27
  17. package/pipeline/skills/shared/external/app-clips/references/data-handoff-notifications-location.md +150 -83
  18. package/pipeline/skills/shared/external/app-clips/references/routing-and-experiences.md +135 -83
  19. package/pipeline/skills/shared/external/app-clips/references/size-capabilities-and-promotion.md +143 -85
  20. package/pipeline/skills/shared/external/app-intents/SKILL.md +302 -304
  21. package/pipeline/skills/shared/external/app-intents/evals/evals.json +21 -21
  22. package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +594 -894
  23. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +345 -277
  24. package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +27 -23
  25. package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +107 -121
  26. package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +145 -165
  27. package/pipeline/skills/shared/external/app-store-review/SKILL.md +306 -326
  28. package/pipeline/skills/shared/external/app-store-review/evals/evals.json +21 -21
  29. package/pipeline/skills/shared/external/app-store-review/references/privacy-manifest.md +105 -67
  30. package/pipeline/skills/shared/external/app-store-review/references/review-checklists.md +114 -101
  31. package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +335 -360
  32. package/pipeline/skills/shared/external/apple-on-device-ai/evals/evals.json +24 -27
  33. package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-conversion.md +215 -322
  34. package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-optimization.md +161 -256
  35. package/pipeline/skills/shared/external/apple-on-device-ai/references/foundation-models.md +277 -387
  36. package/pipeline/skills/shared/external/apple-on-device-ai/references/mlx-swift.md +196 -210
  37. package/pipeline/skills/shared/external/authentication/SKILL.md +277 -381
  38. package/pipeline/skills/shared/external/authentication/evals/evals.json +25 -25
  39. package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +135 -178
  40. package/pipeline/skills/shared/external/authentication/references/passkeys.md +111 -147
  41. package/pipeline/skills/shared/external/avkit/SKILL.md +267 -364
  42. package/pipeline/skills/shared/external/avkit/evals/evals.json +26 -26
  43. package/pipeline/skills/shared/external/avkit/references/avkit-patterns.md +375 -493
  44. package/pipeline/skills/shared/external/background-processing/SKILL.md +274 -384
  45. package/pipeline/skills/shared/external/background-processing/evals/evals.json +22 -22
  46. package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +173 -321
  47. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +290 -371
  48. package/pipeline/skills/shared/external/callkit-voip/evals/evals.json +24 -24
  49. package/pipeline/skills/shared/external/callkit-voip/references/callkit-patterns.md +175 -343
  50. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +292 -381
  51. package/pipeline/skills/shared/external/cloudkit-sync/evals/evals.json +33 -30
  52. package/pipeline/skills/shared/external/cloudkit-sync/references/cloudkit-patterns.md +227 -355
  53. package/pipeline/skills/shared/external/contacts-framework/SKILL.md +197 -346
  54. package/pipeline/skills/shared/external/contacts-framework/evals/evals.json +19 -21
  55. package/pipeline/skills/shared/external/contacts-framework/references/contacts-patterns.md +169 -308
  56. package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +228 -376
  57. package/pipeline/skills/shared/external/core-bluetooth/evals/evals.json +25 -22
  58. package/pipeline/skills/shared/external/core-bluetooth/references/ble-patterns.md +257 -337
  59. package/pipeline/skills/shared/external/core-data/SKILL.md +302 -368
  60. package/pipeline/skills/shared/external/core-data/evals/evals.json +30 -27
  61. package/pipeline/skills/shared/external/core-motion/SKILL.md +235 -324
  62. package/pipeline/skills/shared/external/core-motion/evals/evals.json +31 -27
  63. package/pipeline/skills/shared/external/core-motion/references/motion-patterns.md +210 -310
  64. package/pipeline/skills/shared/external/core-nfc/SKILL.md +292 -366
  65. package/pipeline/skills/shared/external/core-nfc/evals/evals.json +22 -24
  66. package/pipeline/skills/shared/external/core-nfc/references/nfc-patterns.md +233 -329
  67. package/pipeline/skills/shared/external/coreml/SKILL.md +323 -367
  68. package/pipeline/skills/shared/external/coreml/evals/evals.json +24 -21
  69. package/pipeline/skills/shared/external/coreml/references/coreml-swift-integration.md +562 -565
  70. package/pipeline/skills/shared/external/cryptokit/SKILL.md +253 -394
  71. package/pipeline/skills/shared/external/cryptokit/evals/evals.json +20 -18
  72. package/pipeline/skills/shared/external/cryptokit/references/cryptokit-patterns.md +299 -488
  73. package/pipeline/skills/shared/external/debugging-instruments/SKILL.md +270 -323
  74. package/pipeline/skills/shared/external/debugging-instruments/evals/evals.json +27 -30
  75. package/pipeline/skills/shared/external/debugging-instruments/references/instruments-guide.md +167 -315
  76. package/pipeline/skills/shared/external/debugging-instruments/references/lldb-patterns.md +140 -193
  77. package/pipeline/skills/shared/external/device-integrity/SKILL.md +236 -353
  78. package/pipeline/skills/shared/external/device-integrity/evals/evals.json +25 -21
  79. package/pipeline/skills/shared/external/device-integrity/references/device-integrity-patterns.md +159 -197
  80. package/pipeline/skills/shared/external/energykit/SKILL.md +225 -392
  81. package/pipeline/skills/shared/external/energykit/evals/evals.json +29 -28
  82. package/pipeline/skills/shared/external/energykit/references/energykit-patterns.md +174 -470
  83. package/pipeline/skills/shared/external/eventkit-calendar/SKILL.md +261 -383
  84. package/pipeline/skills/shared/external/eventkit-calendar/evals/evals.json +25 -22
  85. package/pipeline/skills/shared/external/eventkit-calendar/references/eventkit-patterns.md +165 -268
  86. package/pipeline/skills/shared/external/healthkit/SKILL.md +252 -303
  87. package/pipeline/skills/shared/external/healthkit/evals/evals.json +24 -23
  88. package/pipeline/skills/shared/external/healthkit/references/healthkit-patterns.md +369 -523
  89. package/pipeline/skills/shared/external/homekit-matter/SKILL.md +233 -348
  90. package/pipeline/skills/shared/external/homekit-matter/evals/evals.json +27 -22
  91. package/pipeline/skills/shared/external/homekit-matter/references/matter-commissioning.md +199 -305
  92. package/pipeline/skills/shared/external/ios-accessibility/SKILL.md +368 -340
  93. package/pipeline/skills/shared/external/ios-accessibility/evals/evals.json +28 -27
  94. package/pipeline/skills/shared/external/ios-accessibility/references/a11y-patterns.md +314 -260
  95. package/pipeline/skills/shared/external/ios-accessibility/references/media-accessibility.md +97 -67
  96. package/pipeline/skills/shared/external/ios-accessibility/references/nutrition-labels.md +165 -101
  97. package/pipeline/skills/shared/external/ios-localization/SKILL.md +258 -371
  98. package/pipeline/skills/shared/external/ios-localization/evals/evals.json +23 -23
  99. package/pipeline/skills/shared/external/ios-localization/references/formatstyle-locale.md +283 -491
  100. package/pipeline/skills/shared/external/ios-localization/references/string-catalogs.md +313 -440
  101. package/pipeline/skills/shared/external/ios-networking/SKILL.md +265 -341
  102. package/pipeline/skills/shared/external/ios-networking/evals/evals.json +24 -24
  103. package/pipeline/skills/shared/external/ios-networking/references/background-websocket.md +425 -652
  104. package/pipeline/skills/shared/external/ios-networking/references/file-storage-patterns.md +143 -285
  105. package/pipeline/skills/shared/external/ios-networking/references/lightweight-clients.md +93 -53
  106. package/pipeline/skills/shared/external/ios-networking/references/network-framework.md +231 -456
  107. package/pipeline/skills/shared/external/ios-networking/references/urlsession-patterns.md +517 -784
  108. package/pipeline/skills/shared/external/ios-simulator/SKILL.md +265 -393
  109. package/pipeline/skills/shared/external/ios-simulator/evals/evals.json +21 -21
  110. package/pipeline/skills/shared/external/ios-simulator/references/simctl-commands.md +177 -270
  111. package/pipeline/skills/shared/external/live-activities/SKILL.md +318 -360
  112. package/pipeline/skills/shared/external/live-activities/evals/evals.json +21 -21
  113. package/pipeline/skills/shared/external/live-activities/references/activitykit-patterns.md +478 -710
  114. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +3 -3
  115. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +1 -1
  116. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +8 -7
  117. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +2 -2
  118. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +5 -2
  119. package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +100 -0
  120. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +45 -26
  121. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +14 -16
  122. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +12 -5
  123. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +2 -1
  124. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +6 -5
  125. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +44 -18
  126. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +5 -2
  127. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +10 -11
  128. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +4 -33
  129. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +12 -59
  130. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +297 -267
  131. package/pipeline/skills/shared/external/mapkit-location/evals/evals.json +28 -24
  132. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-corelocation-patterns.md +378 -532
  133. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +397 -499
  134. package/pipeline/skills/shared/external/metrickit-diagnostics/SKILL.md +165 -348
  135. package/pipeline/skills/shared/external/metrickit-diagnostics/evals/evals.json +26 -23
  136. package/pipeline/skills/shared/external/metrickit-diagnostics/references/metrickit-patterns.md +123 -130
  137. package/pipeline/skills/shared/external/musickit-audio/SKILL.md +189 -315
  138. package/pipeline/skills/shared/external/musickit-audio/evals/evals.json +22 -21
  139. package/pipeline/skills/shared/external/musickit-audio/references/musickit-patterns.md +181 -270
  140. package/pipeline/skills/shared/external/natural-language/SKILL.md +188 -340
  141. package/pipeline/skills/shared/external/natural-language/evals/evals.json +21 -21
  142. package/pipeline/skills/shared/external/natural-language/references/translation-patterns.md +171 -225
  143. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +258 -392
  144. package/pipeline/skills/shared/external/passkit-wallet/evals/evals.json +30 -29
  145. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +164 -231
  146. package/pipeline/skills/shared/external/pdfkit/SKILL.md +312 -344
  147. package/pipeline/skills/shared/external/pdfkit/evals/evals.json +19 -19
  148. package/pipeline/skills/shared/external/pdfkit/references/pdfkit-patterns.md +413 -624
  149. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +242 -358
  150. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +25 -21
  151. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +161 -226
  152. package/pipeline/skills/shared/external/permissionkit/SKILL.md +282 -400
  153. package/pipeline/skills/shared/external/permissionkit/evals/evals.json +27 -30
  154. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +237 -350
  155. package/pipeline/skills/shared/external/photos-camera-media/SKILL.md +276 -325
  156. package/pipeline/skills/shared/external/photos-camera-media/references/av-playback.md +299 -545
  157. package/pipeline/skills/shared/external/photos-camera-media/references/camera-capture.md +344 -588
  158. package/pipeline/skills/shared/external/photos-camera-media/references/image-loading-caching.md +316 -660
  159. package/pipeline/skills/shared/external/photos-camera-media/references/photokit-patterns.md +270 -416
  160. package/pipeline/skills/shared/external/push-notifications/SKILL.md +312 -340
  161. package/pipeline/skills/shared/external/push-notifications/evals/evals.json +27 -26
  162. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +328 -485
  163. package/pipeline/skills/shared/external/push-notifications/references/rich-notifications.md +327 -560
  164. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +218 -410
  165. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +24 -27
  166. package/pipeline/skills/shared/external/realitykit-ar/references/realitykit-patterns.md +221 -348
  167. package/pipeline/skills/shared/external/shareplay-activities/SKILL.md +222 -393
  168. package/pipeline/skills/shared/external/shareplay-activities/evals/evals.json +23 -24
  169. package/pipeline/skills/shared/external/shareplay-activities/references/shareplay-patterns.md +280 -420
  170. package/pipeline/skills/shared/external/skill-creator/template.md +7 -1
  171. package/pipeline/skills/shared/external/speech-recognition/SKILL.md +217 -421
  172. package/pipeline/skills/shared/external/speech-recognition/evals/evals.json +23 -26
  173. package/pipeline/skills/shared/external/speech-recognition/references/speechanalyzer-patterns.md +133 -125
  174. package/pipeline/skills/shared/external/storekit/SKILL.md +228 -204
  175. package/pipeline/skills/shared/external/storekit/evals/evals.json +27 -24
  176. package/pipeline/skills/shared/external/storekit/references/app-review-guidelines.md +98 -109
  177. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +302 -241
  178. package/pipeline/skills/shared/external/storekit/references/storekit-advanced.md +356 -649
  179. package/pipeline/skills/shared/external/swift-api-design-guidelines/SKILL.md +274 -399
  180. package/pipeline/skills/shared/external/swift-api-design-guidelines/evals/evals.json +22 -24
  181. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/argument-labels-and-parameters.md +107 -108
  182. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/conventions-and-special-rules.md +93 -165
  183. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/naming-and-clarity.md +99 -137
  184. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/side-effects-and-mutating-pairs.md +77 -120
  185. package/pipeline/skills/shared/external/swift-architecture/SKILL.md +334 -350
  186. package/pipeline/skills/shared/external/swift-architecture/evals/evals.json +22 -22
  187. package/pipeline/skills/shared/external/swift-charts/SKILL.md +208 -394
  188. package/pipeline/skills/shared/external/swift-charts/evals/evals.json +27 -30
  189. package/pipeline/skills/shared/external/swift-charts/references/charts-patterns.md +351 -762
  190. package/pipeline/skills/shared/external/swift-codable/SKILL.md +339 -343
  191. package/pipeline/skills/shared/external/swift-codable/evals/evals.json +20 -20
  192. package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +304 -351
  193. package/pipeline/skills/shared/external/swift-concurrency/evals/evals.json +27 -24
  194. package/pipeline/skills/shared/external/swift-concurrency/references/approachable-concurrency.md +65 -80
  195. package/pipeline/skills/shared/external/swift-concurrency/references/async-algorithms.md +48 -84
  196. package/pipeline/skills/shared/external/swift-concurrency/references/bridging-interop.md +134 -79
  197. package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +145 -167
  198. package/pipeline/skills/shared/external/swift-concurrency/references/diagnostics.md +62 -50
  199. package/pipeline/skills/shared/external/swift-concurrency/references/swiftui-concurrency.md +92 -121
  200. package/pipeline/skills/shared/external/swift-concurrency/references/synchronization-primitives.md +177 -241
  201. package/pipeline/skills/shared/external/swift-formatstyle/SKILL.md +258 -234
  202. package/pipeline/skills/shared/external/swift-language/SKILL.md +342 -382
  203. package/pipeline/skills/shared/external/swift-language/evals/evals.json +24 -27
  204. package/pipeline/skills/shared/external/swift-language/references/swift-attributes-interop.md +79 -56
  205. package/pipeline/skills/shared/external/swift-language/references/swift-patterns-extended.md +297 -340
  206. package/pipeline/skills/shared/external/swift-security/SKILL.md +183 -162
  207. package/pipeline/skills/shared/external/swift-security/evals/evals.json +25 -25
  208. package/pipeline/skills/shared/external/swift-security/references/biometric-authentication.md +314 -469
  209. package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +411 -476
  210. package/pipeline/skills/shared/external/swift-security/references/common-anti-patterns.md +260 -530
  211. package/pipeline/skills/shared/external/swift-security/references/compliance-owasp-mapping.md +270 -477
  212. package/pipeline/skills/shared/external/swift-security/references/credential-storage-patterns.md +573 -571
  213. package/pipeline/skills/shared/external/swift-security/references/cryptokit-public-key.md +370 -441
  214. package/pipeline/skills/shared/external/swift-security/references/cryptokit-symmetric.md +332 -433
  215. package/pipeline/skills/shared/external/swift-security/references/keychain-access-control.md +346 -468
  216. package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +375 -491
  217. package/pipeline/skills/shared/external/swift-security/references/keychain-item-classes.md +431 -432
  218. package/pipeline/skills/shared/external/swift-security/references/keychain-sharing.md +328 -425
  219. package/pipeline/skills/shared/external/swift-security/references/migration-legacy-stores.md +341 -579
  220. package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +397 -457
  221. package/pipeline/skills/shared/external/swift-security/references/testing-security-code.md +354 -614
  222. package/pipeline/skills/shared/external/swift-testing/SKILL.md +191 -175
  223. package/pipeline/skills/shared/external/swift-testing/evals/evals.json +26 -24
  224. package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +81 -84
  225. package/pipeline/skills/shared/external/swift-testing/references/testing-patterns.md +317 -433
  226. package/pipeline/skills/shared/external/swiftdata/SKILL.md +394 -256
  227. package/pipeline/skills/shared/external/swiftdata/evals/evals.json +24 -24
  228. package/pipeline/skills/shared/external/swiftdata/references/core-data-coexistence.md +206 -402
  229. package/pipeline/skills/shared/external/swiftdata/references/indexing.md +59 -52
  230. package/pipeline/skills/shared/external/swiftdata/references/predicate-pitfalls.md +57 -33
  231. package/pipeline/skills/shared/external/swiftdata/references/swiftdata-advanced.md +354 -747
  232. package/pipeline/skills/shared/external/swiftdata/references/swiftdata-queries.md +300 -508
  233. package/pipeline/skills/shared/external/swiftlint/SKILL.md +175 -226
  234. package/pipeline/skills/shared/external/swiftlint/references/adoption-and-configuration.md +141 -208
  235. package/pipeline/skills/shared/external/swiftlint/references/custom-rules-and-analyze.md +100 -109
  236. package/pipeline/skills/shared/external/swiftlint/references/plugins-run-scripts-and-integrations.md +159 -179
  237. package/pipeline/skills/shared/external/swiftlint/references/rule-reference.md +383 -18
  238. package/pipeline/skills/shared/external/swiftlint/references/rules-suppressions-and-baselines.md +143 -229
  239. package/pipeline/skills/shared/external/swiftui-animation/SKILL.md +283 -366
  240. package/pipeline/skills/shared/external/swiftui-animation/references/animation-advanced.md +396 -608
  241. package/pipeline/skills/shared/external/swiftui-animation/references/core-animation-bridge.md +336 -385
  242. package/pipeline/skills/shared/external/swiftui-gestures/SKILL.md +239 -349
  243. package/pipeline/skills/shared/external/swiftui-gestures/references/gesture-patterns.md +228 -310
  244. package/pipeline/skills/shared/external/swiftui-layout-components/SKILL.md +260 -249
  245. package/pipeline/skills/shared/external/swiftui-layout-components/references/form.md +92 -74
  246. package/pipeline/skills/shared/external/swiftui-layout-components/references/grids.md +112 -177
  247. package/pipeline/skills/shared/external/swiftui-layout-components/references/list.md +61 -64
  248. package/pipeline/skills/shared/external/swiftui-layout-components/references/scrollview.md +94 -134
  249. package/pipeline/skills/shared/external/swiftui-liquid-glass/SKILL.md +193 -225
  250. package/pipeline/skills/shared/external/swiftui-liquid-glass/references/liquid-glass.md +173 -327
  251. package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +201 -168
  252. package/pipeline/skills/shared/external/swiftui-navigation/references/deeplinks.md +127 -150
  253. package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +134 -133
  254. package/pipeline/skills/shared/external/swiftui-navigation/references/sheets.md +152 -117
  255. package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +111 -138
  256. package/pipeline/skills/shared/external/swiftui-patterns/SKILL.md +316 -252
  257. package/pipeline/skills/shared/external/swiftui-patterns/references/architecture-patterns.md +341 -332
  258. package/pipeline/skills/shared/external/swiftui-patterns/references/deprecated-migration.md +547 -854
  259. package/pipeline/skills/shared/external/swiftui-patterns/references/design-polish.md +485 -537
  260. package/pipeline/skills/shared/external/swiftui-patterns/references/platform-and-sharing.md +417 -499
  261. package/pipeline/skills/shared/external/swiftui-performance/SKILL.md +213 -376
  262. package/pipeline/skills/shared/external/swiftui-performance/references/demystify-swiftui-performance-wwdc23.md +86 -175
  263. package/pipeline/skills/shared/external/swiftui-performance/references/optimizing-swiftui-performance-instruments.md +89 -195
  264. package/pipeline/skills/shared/external/swiftui-performance/references/understanding-hangs-in-your-app.md +95 -182
  265. package/pipeline/skills/shared/external/swiftui-performance/references/understanding-improving-swiftui-performance.md +71 -149
  266. package/pipeline/skills/shared/external/swiftui-performance/references/wwdc-session-sources.md +21 -27
  267. package/pipeline/skills/shared/external/swiftui-uikit-interop/SKILL.md +303 -295
  268. package/pipeline/skills/shared/external/swiftui-uikit-interop/references/hosting-migration.md +204 -387
  269. package/pipeline/skills/shared/external/swiftui-uikit-interop/references/representable-recipes.md +469 -683
  270. package/pipeline/skills/shared/external/swiftui-webkit/SKILL.md +140 -186
  271. package/pipeline/skills/shared/external/swiftui-webkit/references/loading-and-observation.md +75 -86
  272. package/pipeline/skills/shared/external/swiftui-webkit/references/local-content-and-custom-schemes.md +63 -60
  273. package/pipeline/skills/shared/external/swiftui-webkit/references/migration-and-fallbacks.md +69 -137
  274. package/pipeline/skills/shared/external/swiftui-webkit/references/navigation-and-javascript.md +95 -67
  275. package/pipeline/skills/shared/external/tipkit/SKILL.md +220 -335
  276. package/pipeline/skills/shared/external/tipkit/references/tipkit-patterns.md +356 -494
  277. package/pipeline/skills/shared/external/vision-framework/SKILL.md +260 -375
  278. package/pipeline/skills/shared/external/vision-framework/references/vision-requests.md +393 -515
  279. package/pipeline/skills/shared/external/vision-framework/references/visionkit-scanner.md +363 -539
  280. package/pipeline/skills/shared/external/weatherkit/SKILL.md +160 -315
  281. package/pipeline/skills/shared/external/weatherkit/references/weatherkit-patterns.md +288 -407
  282. package/pipeline/skills/shared/external/widgetkit/SKILL.md +224 -288
  283. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +416 -719
  284. package/pipeline/skills/shared/external/NOTICE-swift-ios-skills.md +0 -39
@@ -1,207 +1,163 @@
1
- # URLSession Patterns Reference
1
+ # URLSession Patterns
2
2
 
3
- Complete implementation patterns for URLSession-based networking.
4
-
5
- ---
3
+ Longer implementations behind the summaries in [SKILL.md](../SKILL.md). The
4
+ types here are the same ones SKILL.md names: `APIClientProtocol` with
5
+ `fetch`, `send`, `perform` and `upload`; `Endpoint`; `NetworkError`;
6
+ `RequestMiddleware`. None of the code force-unwraps.
6
7
 
7
8
  ## Contents
8
9
 
9
- - [Complete API Client with Protocol](#complete-api-client-with-protocol)
10
- - [Request Middleware](#request-middleware)
11
- - [Request Builder Pattern](#request-builder-pattern)
12
- - [Multipart Form Upload](#multipart-form-upload)
13
- - [Download with Progress Tracking](#download-with-progress-tracking)
14
- - [Cursor-Based Pagination](#cursor-based-pagination)
15
- - [Offset-Based Pagination](#offset-based-pagination)
16
- - [URLProtocol Mock for Testing](#urlprotocol-mock-for-testing)
17
- - [Retry with Exponential Backoff](#retry-with-exponential-backoff)
18
- - [Certificate Pinning (URLSessionDelegate)](#certificate-pinning-urlsessiondelegate)
19
- - [Request Logging / Debugging Middleware](#request-logging-debugging-middleware)
20
- - [Request Caching Strategies](#request-caching-strategies)
21
- - [Server-Sent Events (SSE) Parsing](#server-sent-events-sse-parsing)
22
- - [Configured URLSession for Production](#configured-urlsession-for-production)
23
-
24
- ## Complete API Client with Protocol
25
-
26
- A full-featured client with middleware support, configurable decoding,
27
- and response validation.
10
+ - [A protocol-backed API client](#a-protocol-backed-api-client)
11
+ - [Building requests step by step](#building-requests-step-by-step)
12
+ - [Uploading multipart form data](#uploading-multipart-form-data)
13
+ - [Downloads that report progress](#downloads-that-report-progress)
14
+ - [Paging by cursor](#paging-by-cursor)
15
+ - [Paging by offset](#paging-by-offset)
16
+ - [Stubbing the network with URLProtocol](#stubbing-the-network-with-urlprotocol)
17
+ - [Retries that back off exponentially](#retries-that-back-off-exponentially)
18
+ - [Pinning certificates in a session delegate](#pinning-certificates-in-a-session-delegate)
19
+ - [Middleware that logs requests](#middleware-that-logs-requests)
20
+ - [Choosing a cache policy](#choosing-a-cache-policy)
21
+ - [Reading a server-sent event stream](#reading-a-server-sent-event-stream)
22
+ - [A production-ready session setup](#a-production-ready-session-setup)
23
+
24
+ ## A protocol-backed API client
28
25
 
29
26
  ### Protocol
30
27
 
31
28
  ```swift
32
29
  protocol APIClientProtocol: Sendable {
33
- func request<T: Decodable & Sendable>(
34
- _ type: T.Type,
35
- endpoint: Endpoint
36
- ) async throws -> T
37
-
38
- func request(endpoint: Endpoint) async throws
39
-
40
- func upload<T: Decodable & Sendable>(
41
- _ type: T.Type,
42
- endpoint: Endpoint,
43
- body: Data
44
- ) async throws -> T
30
+ func fetch<Reply: Decodable & Sendable>(_ endpoint: Endpoint, as kind: Reply.Type) async throws -> Reply
31
+ func send<Reply: Decodable & Sendable>(_ endpoint: Endpoint, json payload: some Encodable & Sendable,
32
+ as kind: Reply.Type) async throws -> Reply
33
+ func perform(_ endpoint: Endpoint) async throws
34
+ func upload<Reply: Decodable & Sendable>(_ endpoint: Endpoint, bytes: Data,
35
+ as kind: Reply.Type) async throws -> Reply
45
36
  }
46
37
  ```
47
38
 
39
+ - `fetch` decodes the response of any request that carries no Swift body.
40
+ - `send` JSON-encodes a body, then decodes the reply.
41
+ - `perform` validates the status and ignores the body (DELETE, fire-and-ack).
42
+ - `upload` sends raw bytes through `upload(for:from:)`.
43
+
48
44
  ### Endpoint Definition
49
45
 
46
+ `Endpoint` is exactly the struct shown in SKILL.md: `path`, a nested
47
+ `HTTPMethod` enum (get, post, put, patch, delete, sent upper-cased), query items,
48
+ headers, an optional `body`, a `cachePolicy` defaulting to
49
+ `.useProtocolCachePolicy` and a 30 second `timeoutInterval`. Its
50
+ `urlRequest(relativeTo:)` adds query items only when there are some, copies
51
+ method, body, cache policy, timeout and every header, and throws
52
+ `NetworkError.invalidURL` when components or the final URL cannot be formed.
53
+
54
+ ### Client Implementation
55
+
50
56
  ```swift
51
- struct Endpoint: Sendable {
52
- let path: String
53
- var method: HTTPMethod = .get
54
- var queryItems: [URLQueryItem] = []
55
- var headers: [String: String] = [:]
56
- var body: Data? = nil
57
- var cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy
58
- var timeoutInterval: TimeInterval = 30
59
-
60
- enum HTTPMethod: String, Sendable {
61
- case get = "GET"
62
- case post = "POST"
63
- case put = "PUT"
64
- case patch = "PATCH"
65
- case delete = "DELETE"
57
+ final class APIClient: APIClientProtocol {
58
+ private let root: URL
59
+ private let transport: URLSession
60
+ private let pipeline: [any RequestMiddleware]
61
+ private let decoder = JSONDecoder.snakeCaseISO8601
62
+ private let encoder = JSONEncoder.snakeCaseISO8601
63
+
64
+ init(baseURL: URL, session: URLSession, middleware: [any RequestMiddleware] = []) {
65
+ root = baseURL
66
+ transport = session
67
+ pipeline = middleware
66
68
  }
67
69
 
68
- func urlRequest(relativeTo baseURL: URL) -> URLRequest {
69
- var components = URLComponents(
70
- url: baseURL.appendingPathComponent(path),
71
- resolvingAgainstBaseURL: true
72
- )!
73
- if !queryItems.isEmpty {
74
- components.queryItems = queryItems
75
- }
76
- var request = URLRequest(url: components.url!)
77
- request.httpMethod = method.rawValue
78
- request.httpBody = body
79
- request.cachePolicy = cachePolicy
80
- request.timeoutInterval = timeoutInterval
81
- for (key, value) in headers {
82
- request.setValue(value, forHTTPHeaderField: key)
83
- }
84
- return request
70
+ func fetch<Reply: Decodable & Sendable>(_ endpoint: Endpoint, as kind: Reply.Type) async throws -> Reply {
71
+ let outgoing = try await prepared(endpoint)
72
+ return try decoded(kind, try await execute { try await self.transport.data(for: outgoing) })
85
73
  }
86
- }
87
- ```
88
74
 
89
- ### Client Implementation
75
+ func send<Reply: Decodable & Sendable>(_ endpoint: Endpoint, json payload: some Encodable & Sendable,
76
+ as kind: Reply.Type) async throws -> Reply {
77
+ var json = endpoint
78
+ json.body = try encoder.encode(payload)
79
+ json.headers["Content-Type"] = "application/json"
80
+ return try await fetch(json, as: kind)
81
+ }
90
82
 
91
- ```swift
92
- final class APIClient: APIClientProtocol {
93
- private let baseURL: URL
94
- private let session: URLSession
95
- private let decoder: JSONDecoder
96
- private let encoder: JSONEncoder
97
- private let middlewares: [any RequestMiddleware]
98
-
99
- init(
100
- baseURL: URL,
101
- session: URLSession = .shared,
102
- decoder: JSONDecoder = {
103
- let d = JSONDecoder()
104
- d.dateDecodingStrategy = .iso8601
105
- d.keyDecodingStrategy = .convertFromSnakeCase
106
- return d
107
- }(),
108
- encoder: JSONEncoder = {
109
- let e = JSONEncoder()
110
- e.dateEncodingStrategy = .iso8601
111
- e.keyEncodingStrategy = .convertToSnakeCase
112
- return e
113
- }(),
114
- middlewares: [any RequestMiddleware] = []
115
- ) {
116
- self.baseURL = baseURL
117
- self.session = session
118
- self.decoder = decoder
119
- self.encoder = encoder
120
- self.middlewares = middlewares
83
+ func perform(_ endpoint: Endpoint) async throws {
84
+ let outgoing = try await prepared(endpoint)
85
+ _ = try await execute { try await self.transport.data(for: outgoing) }
121
86
  }
122
87
 
123
- func request<T: Decodable & Sendable>(
124
- _ type: T.Type,
125
- endpoint: Endpoint
126
- ) async throws -> T {
127
- let request = try await prepareRequest(for: endpoint)
128
- let (data, response) = try await session.data(for: request)
129
- try validateResponse(response, data: data)
130
- return try decoder.decode(T.self, from: data)
88
+ func upload<Reply: Decodable & Sendable>(_ endpoint: Endpoint, bytes: Data,
89
+ as kind: Reply.Type) async throws -> Reply {
90
+ let outgoing = try await prepared(endpoint)
91
+ return try decoded(kind, try await execute { try await self.transport.upload(for: outgoing, from: bytes) })
131
92
  }
132
93
 
133
- func request(endpoint: Endpoint) async throws {
134
- let request = try await prepareRequest(for: endpoint)
135
- let (data, response) = try await session.data(for: request)
136
- try validateResponse(response, data: data)
94
+ private func prepared(_ endpoint: Endpoint) async throws -> URLRequest {
95
+ var outgoing = try endpoint.urlRequest(relativeTo: root)
96
+ for step in pipeline { try await step.adapt(&outgoing) }
97
+ return outgoing
137
98
  }
138
99
 
139
- func upload<T: Decodable & Sendable>(
140
- _ type: T.Type,
141
- endpoint: Endpoint,
142
- body: Data
143
- ) async throws -> T {
144
- var request = try await prepareRequest(for: endpoint)
145
- request.httpBody = body
146
- let (data, response) = try await session.upload(for: request, from: body)
147
- try validateResponse(response, data: data)
148
- return try decoder.decode(T.self, from: data)
100
+ private func execute(_ call: () async throws -> (Data, URLResponse)) async throws -> Data {
101
+ let data: Data
102
+ let response: URLResponse
103
+ do {
104
+ (data, response) = try await call()
105
+ } catch let error as URLError {
106
+ throw NetworkError.from(error)
107
+ }
108
+ guard let status = (response as? HTTPURLResponse)?.statusCode else {
109
+ throw NetworkError.invalidResponse
110
+ }
111
+ if !(200...299).contains(status) {
112
+ throw NetworkError.httpStatus(code: status, body: data,
113
+ message: APIErrorBody.decode(from: data)?.message)
114
+ }
115
+ return data
149
116
  }
150
117
 
151
- // MARK: - Convenience methods
152
-
153
- func get<T: Decodable & Sendable>(
154
- _ type: T.Type,
155
- path: String,
156
- queryItems: [URLQueryItem] = []
157
- ) async throws -> T {
158
- try await request(type, endpoint: Endpoint(
159
- path: path,
160
- method: .get,
161
- queryItems: queryItems
162
- ))
118
+ private func decoded<Reply: Decodable>(_ kind: Reply.Type, _ payload: Data) throws -> Reply {
119
+ do { return try decoder.decode(kind, from: payload) }
120
+ catch { throw NetworkError.decodingFailed(error) }
163
121
  }
122
+ }
164
123
 
165
- func post<T: Decodable & Sendable, B: Encodable & Sendable>(
166
- _ type: T.Type,
167
- path: String,
168
- body: B
169
- ) async throws -> T {
170
- let bodyData = try encoder.encode(body)
171
- return try await request(type, endpoint: Endpoint(
172
- path: path,
173
- method: .post,
174
- headers: ["Content-Type": "application/json"],
175
- body: bodyData
176
- ))
124
+ extension JSONDecoder {
125
+ static var snakeCaseISO8601: JSONDecoder {
126
+ let made = JSONDecoder()
127
+ made.keyDecodingStrategy = .convertFromSnakeCase
128
+ made.dateDecodingStrategy = .iso8601
129
+ return made
177
130
  }
131
+ }
178
132
 
179
- func delete(path: String) async throws {
180
- try await request(endpoint: Endpoint(path: path, method: .delete))
133
+ extension JSONEncoder {
134
+ static var snakeCaseISO8601: JSONEncoder {
135
+ let made = JSONEncoder()
136
+ made.keyEncodingStrategy = .convertToSnakeCase
137
+ made.dateEncodingStrategy = .iso8601
138
+ return made
181
139
  }
140
+ }
141
+ ```
182
142
 
183
- // MARK: - Internal
143
+ Middleware runs in array order, so put authentication before logging if the
144
+ log should show that a header was added. Thin conveniences keep call sites
145
+ short:
184
146
 
185
- private func prepareRequest(for endpoint: Endpoint) async throws -> URLRequest {
186
- var request = endpoint.urlRequest(relativeTo: baseURL)
187
- for middleware in middlewares {
188
- request = try await middleware.prepare(request)
189
- }
190
- return request
147
+ ```swift
148
+ extension APIClient {
149
+ func get<Reply: Decodable & Sendable>(_ route: String, query: [URLQueryItem] = [],
150
+ as kind: Reply.Type) async throws -> Reply {
151
+ try await fetch(Endpoint(path: route, queryItems: query), as: kind)
191
152
  }
192
153
 
193
- private func validateResponse(_ response: URLResponse, data: Data) throws {
194
- guard let http = response as? HTTPURLResponse else {
195
- throw NetworkError.invalidResponse
196
- }
197
- guard (200..<300).contains(http.statusCode) else {
198
- let apiError = try? decoder.decode(APIErrorBody.self, from: data)
199
- throw NetworkError.httpError(
200
- statusCode: http.statusCode,
201
- data: data,
202
- message: apiError?.message
203
- )
204
- }
154
+ func post<Reply: Decodable & Sendable>(_ route: String, json payload: some Encodable & Sendable,
155
+ as kind: Reply.Type) async throws -> Reply {
156
+ try await send(Endpoint(path: route, method: .post), json: payload, as: kind)
157
+ }
158
+
159
+ func delete(_ route: String) async throws {
160
+ try await perform(Endpoint(path: route, method: .delete))
205
161
  }
206
162
  }
207
163
  ```
@@ -209,502 +165,360 @@ final class APIClient: APIClientProtocol {
209
165
  ### Error Types
210
166
 
211
167
  ```swift
212
- enum NetworkError: Error, Sendable, LocalizedError {
168
+ enum NetworkError: Error, LocalizedError {
169
+ case invalidURL
213
170
  case invalidResponse
214
- case httpError(statusCode: Int, data: Data, message: String? = nil)
171
+ case httpStatus(code: Int, body: Data, message: String?)
172
+ case decodingFailed(any Error)
215
173
  case noConnection
216
174
  case timedOut
217
175
  case cancelled
218
-
219
- var errorDescription: String? {
220
- switch self {
221
- case .invalidResponse:
222
- return "Invalid server response"
223
- case .httpError(let code, _, let message):
224
- return message ?? "HTTP error \(code)"
225
- case .noConnection:
226
- return "No internet connection"
227
- case .timedOut:
228
- return "Request timed out"
229
- case .cancelled:
230
- return nil
176
+ case transport(URLError)
177
+
178
+ static func from(_ error: URLError) -> NetworkError {
179
+ switch error.code {
180
+ case .notConnectedToInternet, .networkConnectionLost: .noConnection
181
+ case .timedOut: .timedOut
182
+ case .cancelled: .cancelled
183
+ default: .transport(error)
231
184
  }
232
185
  }
233
186
 
234
- static func from(_ urlError: URLError) -> NetworkError {
235
- switch urlError.code {
236
- case .notConnectedToInternet, .networkConnectionLost:
237
- return .noConnection
238
- case .timedOut:
239
- return .timedOut
240
- case .cancelled:
241
- return .cancelled
242
- default:
243
- return .invalidResponse
187
+ var errorDescription: String? {
188
+ switch self {
189
+ case .invalidURL: "The request address is not valid."
190
+ case .invalidResponse: "The server sent a response that could not be read."
191
+ case .httpStatus(let status, _, let message): message ?? "The server returned status \(status)."
192
+ case .decodingFailed: "The server data was in an unexpected format."
193
+ case .noConnection: "You appear to be offline."
194
+ case .timedOut: "The server took too long to answer."
195
+ case .cancelled: nil
196
+ case .transport(let error): error.localizedDescription
244
197
  }
245
198
  }
246
199
  }
247
200
 
248
201
  struct APIErrorBody: Decodable, Sendable {
249
- let code: String?
250
202
  let message: String?
251
- }
252
- ```
253
-
254
- ### Request Middleware
255
-
256
- ```swift
257
- protocol RequestMiddleware: Sendable {
258
- func prepare(_ request: URLRequest) async throws -> URLRequest
259
- }
260
-
261
- struct AuthMiddleware: RequestMiddleware {
262
- let tokenProvider: @Sendable () async throws -> String
203
+ let code: String?
263
204
 
264
- func prepare(_ request: URLRequest) async throws -> URLRequest {
265
- var request = request
266
- let token = try await tokenProvider()
267
- request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
268
- return request
205
+ static func decode(from data: Data) -> APIErrorBody? {
206
+ try? JSONDecoder().decode(APIErrorBody.self, from: data)
269
207
  }
270
208
  }
271
209
  ```
272
210
 
273
- ---
211
+ `.cancelled` has no description on purpose: a cancelled request should not
212
+ produce an alert.
213
+
214
+ ### Request Middleware
215
+
216
+ `RequestMiddleware` and `BearerTokenMiddleware` are the ones in SKILL.md:
217
+ one `adapt(_:)` method that edits the request in place, and a middleware that asks an async token provider
218
+ for the current token and sets `Authorization: Bearer ...`.
274
219
 
275
- ## Request Builder Pattern
220
+ ## Building requests step by step
276
221
 
277
- For complex request construction, a builder provides a fluent API that
278
- reduces errors.
222
+ For requests with many optional parts, a value-type builder keeps call sites
223
+ readable and hard to get wrong. Each modifier returns a changed copy.
279
224
 
280
225
  ```swift
281
226
  struct RequestBuilder: Sendable {
282
- private var method: String = "GET"
283
- private var path: String
284
- private var baseURL: URL
285
- private var queryItems: [URLQueryItem] = []
286
- private var headers: [String: String] = [:]
287
- private var body: Data?
288
- private var cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy
289
- private var timeout: TimeInterval = 30
227
+ private let target: URL
228
+ private var verb = "GET"
229
+ private var caching = URLRequest.CachePolicy.useProtocolCachePolicy
230
+ private var limit: TimeInterval = 30
231
+ private var items: [URLQueryItem] = []
232
+ private var fields: [String: String] = [:]
233
+ private var payload: Data?
290
234
 
291
235
  init(baseURL: URL, path: String) {
292
- self.baseURL = baseURL
293
- self.path = path
236
+ target = baseURL.appending(path: path)
294
237
  }
295
238
 
296
- func method(_ method: String) -> RequestBuilder {
297
- var copy = self
298
- copy.method = method
299
- return copy
239
+ private func with(_ change: (inout Self) throws -> Void) rethrows -> Self {
240
+ var next = self
241
+ try change(&next)
242
+ return next
300
243
  }
301
244
 
302
- func query(_ name: String, _ value: String?) -> RequestBuilder {
303
- guard let value else { return self }
304
- var copy = self
305
- copy.queryItems.append(URLQueryItem(name: name, value: value))
306
- return copy
307
- }
308
-
309
- func header(_ name: String, _ value: String) -> RequestBuilder {
310
- var copy = self
311
- copy.headers[name] = value
312
- return copy
313
- }
245
+ func method(_ verb: String) -> Self { with { $0.verb = verb } }
246
+ func timeout(_ seconds: TimeInterval) -> Self { with { $0.limit = seconds } }
247
+ func cachePolicy(_ rule: URLRequest.CachePolicy) -> Self { with { $0.caching = rule } }
248
+ func header(_ field: String, _ text: String) -> Self { with { $0.fields[field] = text } }
314
249
 
315
- func jsonBody<T: Encodable>(_ value: T) throws -> RequestBuilder {
316
- var copy = self
317
- copy.body = try JSONEncoder().encode(value)
318
- copy.headers["Content-Type"] = "application/json"
319
- return copy
250
+ func query(_ key: String, _ text: String?) -> Self {
251
+ guard let text else { return self }
252
+ return with { $0.items.append(URLQueryItem(name: key, value: text)) }
320
253
  }
321
254
 
322
- func timeout(_ interval: TimeInterval) -> RequestBuilder {
323
- var copy = self
324
- copy.timeout = interval
325
- return copy
326
- }
327
-
328
- func cachePolicy(_ policy: URLRequest.CachePolicy) -> RequestBuilder {
329
- var copy = self
330
- copy.cachePolicy = policy
331
- return copy
255
+ func jsonBody(_ model: some Encodable) throws -> Self {
256
+ let encoded = try JSONEncoder().encode(model)
257
+ return with {
258
+ $0.payload = encoded
259
+ $0.fields["Content-Type"] = "application/json"
260
+ }
332
261
  }
333
262
 
334
- func build() -> URLRequest {
335
- var components = URLComponents(
336
- url: baseURL.appendingPathComponent(path),
337
- resolvingAgainstBaseURL: true
338
- )!
339
- if !queryItems.isEmpty {
340
- components.queryItems = queryItems
341
- }
342
- var request = URLRequest(url: components.url!)
343
- request.httpMethod = method
344
- request.httpBody = body
345
- request.cachePolicy = cachePolicy
346
- request.timeoutInterval = timeout
347
- for (key, value) in headers {
348
- request.setValue(value, forHTTPHeaderField: key)
349
- }
350
- return request
263
+ func build() throws -> URLRequest {
264
+ var parts = URLComponents(url: target, resolvingAgainstBaseURL: true)
265
+ if !items.isEmpty { parts?.queryItems = items }
266
+ guard let url = parts?.url else { throw NetworkError.invalidURL }
267
+ var built = URLRequest(url: url, cachePolicy: caching, timeoutInterval: limit)
268
+ built.httpMethod = verb
269
+ built.httpBody = payload
270
+ built.allHTTPHeaderFields = fields
271
+ return built
351
272
  }
352
273
  }
353
274
 
354
- // Usage
355
- let request = try RequestBuilder(baseURL: apiURL, path: "users")
275
+ let request = try RequestBuilder(baseURL: apiRoot, path: "tickets")
356
276
  .method("POST")
357
- .header("X-Request-ID", UUID().uuidString)
358
- .jsonBody(CreateUserRequest(name: "Alice", email: "alice@example.com"))
277
+ .header("X-Correlation-ID", UUID().uuidString)
278
+ .jsonBody(TicketDraft(subject: "Printer offline"))
359
279
  .timeout(15)
360
280
  .build()
361
281
  ```
362
282
 
363
- ---
283
+ `query(_:_:)` quietly drops a `nil` value, which keeps optional filters tidy.
364
284
 
365
- ## Multipart Form Upload
285
+ ## Uploading multipart form data
366
286
 
367
- Multipart/form-data uploads are common for file attachments. Build the
368
- body manually -- no third-party library needed.
287
+ `multipart/form-data` is plain text framing around each part; you can build
288
+ it without a library.
369
289
 
370
290
  ```swift
371
- struct MultipartFormData: Sendable {
372
- private let boundary: String
373
- private var parts: [Part] = []
291
+ struct MultipartForm: Sendable {
292
+ private struct Section: Sendable { let lines: [String]; let payload: Data }
374
293
 
375
- init(boundary: String = UUID().uuidString) {
376
- self.boundary = boundary
377
- }
294
+ let separator = "Boundary-\(UUID().uuidString)"
295
+ private var sections: [Section] = []
378
296
 
379
- var contentType: String {
380
- "multipart/form-data; boundary=\(boundary)"
381
- }
297
+ var headerValue: String { "multipart/form-data; boundary=\(separator)" }
382
298
 
383
- mutating func addField(name: String, value: String) {
384
- parts.append(Part(
385
- headers: "Content-Disposition: form-data; name=\"\(name)\"",
386
- body: Data(value.utf8)
387
- ))
299
+ mutating func addText(_ value: String, named field: String) {
300
+ let disposition = #"Content-Disposition: form-data; name="\#(field)""#
301
+ sections.append(Section(lines: [disposition], payload: Data(value.utf8)))
388
302
  }
389
303
 
390
- mutating func addFile(
391
- name: String,
392
- filename: String,
393
- mimeType: String,
394
- data: Data
395
- ) {
396
- parts.append(Part(
397
- headers: """
398
- Content-Disposition: form-data; name="\(name)"; filename="\(filename)"\r
399
- Content-Type: \(mimeType)
400
- """,
401
- body: data
402
- ))
304
+ mutating func addFile(_ data: Data, named field: String, filename file: String, type mimeType: String) {
305
+ let disposition = #"Content-Disposition: form-data; name="\#(field)"; filename="\#(file)""#
306
+ sections.append(Section(lines: [disposition, "Content-Type: " + mimeType], payload: data))
403
307
  }
404
308
 
405
- func encode() -> Data {
406
- var data = Data()
407
- let crlf = "\r\n"
408
- for part in parts {
409
- data.append("--\(boundary)\(crlf)")
410
- data.append("\(part.headers)\(crlf)\(crlf)")
411
- data.append(part.body)
412
- data.append(crlf)
309
+ func encoded() -> Data {
310
+ var out = Data()
311
+ for section in sections {
312
+ out.append(line: "--\(separator)")
313
+ section.lines.forEach { out.append(line: $0) }
314
+ out.append(line: "")
315
+ out.append(section.payload)
316
+ out.append(line: "")
413
317
  }
414
- data.append("--\(boundary)--\(crlf)")
415
- return data
416
- }
417
-
418
- private struct Part: Sendable {
419
- let headers: String
420
- let body: Data
318
+ out.append(line: "--\(separator)--")
319
+ return out
421
320
  }
422
321
  }
423
322
 
424
323
  extension Data {
425
- mutating func append(_ string: String) {
426
- append(Data(string.utf8))
427
- }
324
+ mutating func append(line: String) { append(Data((line + "\r\n").utf8)) }
428
325
  }
429
326
 
430
- // Usage
431
- var form = MultipartFormData()
432
- form.addField(name: "title", value: "Profile Photo")
433
- form.addFile(
434
- name: "image",
435
- filename: "photo.jpg",
436
- mimeType: "image/jpeg",
437
- data: imageData
438
- )
439
-
440
- var request = URLRequest(url: uploadURL)
441
- request.httpMethod = "POST"
442
- request.setValue(form.contentType, forHTTPHeaderField: "Content-Type")
443
- request.httpBody = form.encode()
444
-
445
- let (data, response) = try await URLSession.shared.upload(
446
- for: request,
447
- from: form.encode()
448
- )
327
+ var form = MultipartForm()
328
+ form.addText("Harbor at dusk", named: "caption")
329
+ form.addFile(jpegData, named: "photo", filename: "harbor.jpg", type: "image/jpeg")
330
+ var post = URLRequest(url: uploadURL)
331
+ post.httpMethod = "POST"
332
+ post.allHTTPHeaderFields = ["Content-Type": form.headerValue]
333
+ let (reply, _) = try await session.upload(for: post, from: form.encoded())
449
334
  ```
450
335
 
451
- ---
336
+ `Data(string.utf8)` never fails, so there is no optional to unwrap. For a
337
+ background upload, write `encoded()` to a file and upload the file (see
338
+ [background-websocket.md](background-websocket.md#uploads-from-a-file-on-disk)).
452
339
 
453
- ## Download with Progress Tracking
340
+ ## Downloads that report progress
454
341
 
455
- Use `bytes(for:)` for real-time progress. The response includes
456
- `expectedContentLength` for calculating percentage.
342
+ `bytes(for:)` and `bytes(from:)` (iOS 15 and later) expose the body byte by
343
+ byte, and `response.expectedContentLength` gives the total when the server
344
+ sends one:
457
345
 
458
346
  ```swift
459
- @available(iOS 15.0, *)
460
- func downloadWithProgress(
461
- from url: URL,
462
- progressHandler: @Sendable (Double) -> Void
463
- ) async throws -> Data {
464
- let (bytes, response) = try await URLSession.shared.bytes(from: url)
465
-
466
- let expectedLength = response.expectedContentLength
467
- var receivedData = Data()
468
- if expectedLength > 0 {
469
- receivedData.reserveCapacity(Int(expectedLength))
470
- }
471
-
472
- var receivedLength: Int64 = 0
473
- for try await byte in bytes {
474
- receivedData.append(byte)
475
- receivedLength += 1
476
- if expectedLength > 0 {
477
- let progress = Double(receivedLength) / Double(expectedLength)
478
- progressHandler(progress)
347
+ func downloadReportingProgress(from url: URL, onFraction: (Double) -> Void) async throws -> Data {
348
+ let (incoming, response) = try await session.bytes(from: url)
349
+ let expected = response.expectedContentLength
350
+ var buffer = Data()
351
+ if expected > 0 { buffer.reserveCapacity(Int(expected)) }
352
+ for try await octet in incoming {
353
+ buffer.append(octet)
354
+ if expected > 0, buffer.count % 65_536 == 0 {
355
+ onFraction(Double(buffer.count) / Double(expected))
479
356
  }
480
357
  }
481
-
482
- return receivedData
358
+ return buffer
483
359
  }
484
360
  ```
485
361
 
486
- For large files, prefer `URLSessionDownloadTask` with a delegate for
487
- better memory efficiency and background support.
362
+ This holds the whole body in memory. For big files use a download task with a
363
+ delegate, which writes to disk and also works in background sessions.
488
364
 
489
- ### Download to File with Progress (Delegate-Based)
365
+ ### Delegate-Based File Download with Progress
366
+
367
+ A delegate object can turn callbacks into an `AsyncStream`:
490
368
 
491
369
  ```swift
492
- @available(iOS 15.0, *)
493
- final class DownloadManager: NSObject, URLSessionDownloadDelegate, Sendable {
494
- private let continuation: AsyncStream<DownloadEvent>.Continuation
495
-
496
- enum DownloadEvent: Sendable {
497
- case progress(Double)
498
- case completed(URL)
499
- case failed(Error)
500
- }
370
+ enum TransferEvent: Sendable {
371
+ case progress(Double)
372
+ case finished(URL)
373
+ case failed(any Error)
374
+ }
501
375
 
502
- static func download(from url: URL) -> AsyncStream<DownloadEvent> {
503
- AsyncStream { continuation in
504
- let manager = DownloadManager(continuation: continuation)
505
- let session = URLSession(
506
- configuration: .default,
507
- delegate: manager,
508
- delegateQueue: nil
509
- )
510
- session.downloadTask(with: url).resume()
511
- }
512
- }
376
+ final class FileDownload: NSObject, URLSessionDownloadDelegate, Sendable {
377
+ private let continuation: AsyncStream<TransferEvent>.Continuation
513
378
 
514
- private init(continuation: AsyncStream<DownloadEvent>.Continuation) {
379
+ private init(continuation: AsyncStream<TransferEvent>.Continuation) {
515
380
  self.continuation = continuation
516
381
  }
517
382
 
518
- nonisolated func urlSession(
519
- _ session: URLSession,
520
- downloadTask: URLSessionDownloadTask,
521
- didFinishDownloadingTo location: URL
522
- ) {
523
- // Move file to permanent location before this method returns
524
- let destination = FileManager.default.temporaryDirectory
525
- .appendingPathComponent(UUID().uuidString)
383
+ static func start(from url: URL) -> AsyncStream<TransferEvent> {
384
+ let (stream, continuation) = AsyncStream.makeStream(of: TransferEvent.self)
385
+ let observer = FileDownload(continuation: continuation)
386
+ let owner = URLSession(configuration: .default, delegate: observer, delegateQueue: nil)
387
+ owner.downloadTask(with: url).resume()
388
+ continuation.onTermination = { _ in owner.invalidateAndCancel() }
389
+ return stream
390
+ }
391
+
392
+ nonisolated func urlSession(_: URLSession, downloadTask _: URLSessionDownloadTask,
393
+ didFinishDownloadingTo location: URL) {
394
+ let kept = URL.temporaryDirectory.appending(path: UUID().uuidString)
526
395
  do {
527
- try FileManager.default.moveItem(at: location, to: destination)
528
- continuation.yield(.completed(destination))
396
+ let files = FileManager.default
397
+ try files.moveItem(at: location, to: kept)
398
+ continuation.yield(.finished(kept))
529
399
  } catch {
530
400
  continuation.yield(.failed(error))
531
401
  }
532
402
  continuation.finish()
533
403
  }
534
404
 
535
- nonisolated func urlSession(
536
- _ session: URLSession,
537
- downloadTask: URLSessionDownloadTask,
538
- didWriteData bytesWritten: Int64,
539
- totalBytesWritten: Int64,
540
- totalBytesExpectedToWrite: Int64
541
- ) {
405
+ nonisolated func urlSession(_: URLSession, downloadTask _: URLSessionDownloadTask,
406
+ didWriteData bytesWritten: Int64, totalBytesWritten: Int64,
407
+ totalBytesExpectedToWrite: Int64) {
542
408
  guard totalBytesExpectedToWrite > 0 else { return }
543
- let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
544
- continuation.yield(.progress(progress))
409
+ continuation.yield(.progress(Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)))
545
410
  }
546
411
 
547
- nonisolated func urlSession(
548
- _ session: URLSession,
549
- task: URLSessionTask,
550
- didCompleteWithError error: (any Error)?
551
- ) {
552
- if let error {
553
- continuation.yield(.failed(error))
554
- continuation.finish()
555
- }
412
+ nonisolated func urlSession(_: URLSession, task _: URLSessionTask,
413
+ didCompleteWithError error: (any Error)?) {
414
+ guard let error else { return }
415
+ continuation.yield(.failed(error))
416
+ continuation.finish()
556
417
  }
557
418
  }
558
419
  ```
559
420
 
560
- ---
561
-
562
- ## Cursor-Based Pagination
421
+ The move happens inside `didFinishDownloadingTo` because the system deletes
422
+ `location` as soon as that method returns. A session retains its delegate
423
+ until it is invalidated, so the stream's termination handler invalidates it.
563
424
 
564
- A reusable paginator that conforms to `AsyncSequence`, yielding pages
565
- of results until the server indicates no more data.
425
+ ## Paging by cursor
566
426
 
567
427
  ```swift
568
- struct PageResponse<T: Decodable & Sendable>: Decodable, Sendable {
569
- let data: [T]
570
- let pagination: PaginationInfo
571
- }
572
-
573
- struct PaginationInfo: Decodable, Sendable {
574
- let nextCursor: String?
575
- let hasMore: Bool
576
- }
577
-
578
- struct CursorPaginator<T: Decodable & Sendable>: AsyncSequence {
579
- typealias Element = [T]
580
-
581
- private let fetchPage: @Sendable (String?) async throws -> PageResponse<T>
582
-
583
- init(fetchPage: @escaping @Sendable (String?) async throws -> PageResponse<T>) {
584
- self.fetchPage = fetchPage
585
- }
586
-
587
- func makeAsyncIterator() -> Iterator {
588
- Iterator(fetchPage: fetchPage)
428
+ struct CursorPage<Item: Decodable & Sendable>: Decodable, Sendable {
429
+ struct Paging: Decodable, Sendable {
430
+ let nextCursor: String?
431
+ let hasMore: Bool
589
432
  }
433
+ let data: [Item]
434
+ let pagination: Paging
435
+ }
590
436
 
591
- struct Iterator: AsyncIteratorProtocol {
592
- private let fetchPage: @Sendable (String?) async throws -> PageResponse<T>
593
- private var cursor: String?
594
- private var exhausted = false
437
+ struct CursorPager<Item: Decodable & Sendable>: AsyncSequence {
438
+ typealias Element = [Item]
439
+ let loadPage: @Sendable (_ cursor: String?) async throws -> CursorPage<Item>
595
440
 
596
- init(fetchPage: @escaping @Sendable (String?) async throws -> PageResponse<T>) {
597
- self.fetchPage = fetchPage
598
- }
441
+ struct AsyncIterator: AsyncIteratorProtocol {
442
+ let loadPage: @Sendable (String?) async throws -> CursorPage<Item>
443
+ var cursor: String?
444
+ var finished = false
599
445
 
600
- mutating func next() async throws -> [T]? {
601
- guard !exhausted else { return nil }
446
+ mutating func next() async throws -> [Item]? {
447
+ guard !finished else { return nil }
602
448
  try Task.checkCancellation()
603
-
604
- let response = try await fetchPage(cursor)
605
- cursor = response.pagination.nextCursor
606
- exhausted = !response.pagination.hasMore
607
-
608
- return response.data.isEmpty ? nil : response.data
449
+ let page = try await loadPage(cursor)
450
+ cursor = page.pagination.nextCursor
451
+ finished = !page.pagination.hasMore
452
+ return page.data.isEmpty ? nil : page.data
609
453
  }
610
454
  }
611
- }
612
455
 
613
- // Usage
614
- let paginator = CursorPaginator<User> { cursor in
615
- var queryItems = [URLQueryItem(name: "limit", value: "50")]
616
- if let cursor {
617
- queryItems.append(URLQueryItem(name: "cursor", value: cursor))
618
- }
619
- return try await client.get(
620
- PageResponse<User>.self,
621
- path: "users",
622
- queryItems: queryItems
623
- )
456
+ func makeAsyncIterator() -> AsyncIterator { AsyncIterator(loadPage: loadPage) }
624
457
  }
625
458
 
626
- var allUsers: [User] = []
627
- for try await batch in paginator {
628
- allUsers.append(contentsOf: batch)
459
+ let pager = CursorPager<Comment> { cursor in
460
+ let query = [URLQueryItem(name: "limit", value: "50")]
461
+ + (cursor.map { [URLQueryItem(name: "cursor", value: $0)] } ?? [])
462
+ return try await client.fetch(Endpoint(path: "comments", queryItems: query),
463
+ as: CursorPage<Comment>.self)
629
464
  }
465
+ var comments: [Comment] = []
466
+ for try await batch in pager { comments += batch }
630
467
  ```
631
468
 
632
- ---
633
-
634
- ## Offset-Based Pagination
469
+ ## Paging by offset
635
470
 
636
471
  ```swift
637
- struct OffsetPaginator<T: Decodable & Sendable>: AsyncSequence {
638
- typealias Element = [T]
639
-
640
- private let pageSize: Int
641
- private let fetchPage: @Sendable (Int, Int) async throws -> [T]
642
-
643
- init(
644
- pageSize: Int = 20,
645
- fetchPage: @escaping @Sendable (_ offset: Int, _ limit: Int) async throws -> [T]
646
- ) {
647
- self.pageSize = pageSize
648
- self.fetchPage = fetchPage
649
- }
650
-
651
- func makeAsyncIterator() -> Iterator {
652
- Iterator(pageSize: pageSize, fetchPage: fetchPage)
653
- }
654
-
655
- struct Iterator: AsyncIteratorProtocol {
656
- private let pageSize: Int
657
- private let fetchPage: @Sendable (Int, Int) async throws -> [T]
658
- private var offset = 0
659
- private var exhausted = false
660
-
661
- init(
662
- pageSize: Int,
663
- fetchPage: @escaping @Sendable (Int, Int) async throws -> [T]
664
- ) {
665
- self.pageSize = pageSize
666
- self.fetchPage = fetchPage
667
- }
668
-
669
- mutating func next() async throws -> [T]? {
670
- guard !exhausted else { return nil }
472
+ struct OffsetPager<Item: Sendable>: AsyncSequence {
473
+ typealias Element = [Item]
474
+ var pageSize = 20
475
+ let loadPage: @Sendable (_ skip: Int, _ take: Int) async throws -> [Item]
476
+
477
+ struct AsyncIterator: AsyncIteratorProtocol {
478
+ let pageSize: Int
479
+ let loadPage: @Sendable (Int, Int) async throws -> [Item]
480
+ var offset = 0
481
+ var finished = false
482
+
483
+ mutating func next() async throws -> [Item]? {
484
+ guard !finished else { return nil }
671
485
  try Task.checkCancellation()
672
-
673
- let items = try await fetchPage(offset, pageSize)
486
+ let items = try await loadPage(offset, pageSize)
674
487
  offset += items.count
675
- if items.count < pageSize { exhausted = true }
676
-
488
+ finished = items.count < pageSize
677
489
  return items.isEmpty ? nil : items
678
490
  }
679
491
  }
492
+
493
+ func makeAsyncIterator() -> AsyncIterator { AsyncIterator(pageSize: pageSize, loadPage: loadPage) }
680
494
  }
681
495
  ```
682
496
 
683
- ---
497
+ A short page means the server has nothing more, so the next call ends the
498
+ sequence without another request.
684
499
 
685
- ## URLProtocol Mock for Testing
500
+ ## Stubbing the network with URLProtocol
686
501
 
687
- `URLProtocol` is the correct way to mock network responses at the
688
- transport level. It works with any URLSession configuration and does
689
- not require changing production code.
502
+ A `URLProtocol` subclass intercepts requests at the transport layer. It works
503
+ with any session configuration and needs no change to production code, so
504
+ it tests the real client, middleware and validation.
690
505
 
691
506
  ```swift
692
- final class MockURLProtocol: URLProtocol {
693
- nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
507
+ final class StubURLProtocol: URLProtocol {
508
+ nonisolated(unsafe) static var respond: ((URLRequest) throws -> (HTTPURLResponse, Data))?
694
509
 
695
- override class func canInit(with request: URLRequest) -> Bool { true }
696
-
697
- override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
510
+ override class func canInit(with _: URLRequest) -> Bool { true }
511
+ override class func canonicalRequest(for original: URLRequest) -> URLRequest { original }
698
512
 
699
513
  override func startLoading() {
700
- guard let handler = Self.requestHandler else {
701
- fatalError("MockURLProtocol.requestHandler is not set")
514
+ guard let respond = Self.respond else {
515
+ client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
516
+ return
702
517
  }
703
-
704
518
  do {
705
- let (response, data) = try handler(request)
706
- client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
707
- client?.urlProtocol(self, didLoad: data)
519
+ let (head, payload) = try respond(request)
520
+ client?.urlProtocol(self, didReceive: head, cacheStoragePolicy: .notAllowed)
521
+ client?.urlProtocol(self, didLoad: payload)
708
522
  client?.urlProtocolDidFinishLoading(self)
709
523
  } catch {
710
524
  client?.urlProtocol(self, didFailWithError: error)
@@ -715,321 +529,241 @@ final class MockURLProtocol: URLProtocol {
715
529
  }
716
530
  ```
717
531
 
532
+ A missing stub fails the request instead of crashing the test run.
533
+
718
534
  ### Test Setup
719
535
 
720
536
  ```swift
721
537
  import Testing
538
+ import Foundation
722
539
 
723
- @Suite struct APIClientTests {
724
- let client: APIClient
725
- let session: URLSession
726
-
727
- init() {
540
+ @Suite(.serialized)
541
+ struct APIClientTests {
542
+ func makeClient(middleware: [any RequestMiddleware] = []) throws -> APIClient {
728
543
  let config = URLSessionConfiguration.ephemeral
729
- config.protocolClasses = [MockURLProtocol.self]
730
- session = URLSession(configuration: config)
731
- client = APIClient(
732
- baseURL: URL(string: "https://api.example.com")!,
733
- session: session
734
- )
544
+ config.protocolClasses = [StubURLProtocol.self]
545
+ return APIClient(baseURL: try #require(URL(string: "https://api.test")),
546
+ session: URLSession(configuration: config), middleware: middleware)
735
547
  }
736
548
 
737
- @Test func fetchUsersDecodesCorrectly() async throws {
738
- let usersJSON = """
739
- [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
740
- """
741
- MockURLProtocol.requestHandler = { request in
742
- #expect(request.url?.path == "/users")
743
- let response = HTTPURLResponse(
744
- url: request.url!,
745
- statusCode: 200,
746
- httpVersion: nil,
747
- headerFields: ["Content-Type": "application/json"]
748
- )!
749
- return (response, Data(usersJSON.utf8))
750
- }
751
-
752
- let users: [User] = try await client.get([User].self, path: "users")
753
- #expect(users.count == 2)
754
- #expect(users[0].name == "Alice")
549
+ func reply(_ status: Int, _ json: String, for request: URLRequest) throws -> (HTTPURLResponse, Data) {
550
+ let url = try #require(request.url)
551
+ let response = try #require(HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil))
552
+ return (response, Data(json.utf8))
755
553
  }
756
554
 
757
- @Test func fetchReturnsHTTPError() async throws {
758
- MockURLProtocol.requestHandler = { request in
759
- let response = HTTPURLResponse(
760
- url: request.url!,
761
- statusCode: 404,
762
- httpVersion: nil,
763
- headerFields: nil
764
- )!
765
- return (response, Data())
555
+ @Test func decodesProjects() async throws {
556
+ StubURLProtocol.respond = { request in
557
+ #expect(request.url?.path() == "/projects")
558
+ return try self.reply(200, #"[{"id":7,"title":"Atlas"},{"id":8,"title":"Beacon"}]"#, for: request)
766
559
  }
560
+ let projects = try await makeClient().fetch(Endpoint(path: "projects"), as: [Project].self)
561
+ #expect(projects.count == 2)
562
+ }
767
563
 
564
+ @Test func notFoundThrows() async throws {
565
+ StubURLProtocol.respond = { try self.reply(404, "{}", for: $0) }
566
+ let client = try makeClient()
768
567
  await #expect(throws: NetworkError.self) {
769
- let _: [User] = try await client.get([User].self, path: "missing")
568
+ try await client.perform(Endpoint(path: "projects/99"))
770
569
  }
771
570
  }
772
571
 
773
- @Test func requestIncludesAuthHeader() async throws {
774
- let authClient = APIClient(
775
- baseURL: URL(string: "https://api.example.com")!,
776
- session: session,
777
- middlewares: [AuthMiddleware { "test-token" }]
778
- )
779
-
780
- MockURLProtocol.requestHandler = { request in
781
- #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token")
782
- let response = HTTPURLResponse(
783
- url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil
784
- )!
785
- return (response, Data("{}".utf8))
572
+ @Test func middlewareSignsRequests() async throws {
573
+ StubURLProtocol.respond = { request in
574
+ #expect(request.allHTTPHeaderFields?["Authorization"] == "Bearer fixture-token")
575
+ return try self.reply(200, "{}", for: request)
786
576
  }
787
-
788
- let _: EmptyResponse = try await authClient.get(EmptyResponse.self, path: "me")
577
+ let client = try makeClient(middleware: [BearerTokenMiddleware { "fixture-token" }])
578
+ _ = try await client.fetch(Endpoint(path: "me"), as: Empty.self)
789
579
  }
790
580
  }
791
581
 
792
- struct EmptyResponse: Decodable, Sendable {}
582
+ struct Project: Decodable, Sendable { let id: Int; let title: String }
583
+ struct Empty: Decodable, Sendable {}
793
584
  ```
794
585
 
795
- ---
586
+ The suite is serialized because the stub is shared static state. `Empty`
587
+ decodes a `{}` body when only the status matters.
796
588
 
797
- ## Retry with Exponential Backoff
589
+ ## Retries that back off exponentially
798
590
 
799
- Respect cancellation. Do not retry client errors (4xx except 429 rate
800
- limiting). Include jitter to prevent thundering herd.
591
+ Three rules: stop on cancellation, do not retry a 4xx other than 429, and
592
+ add jitter so that many clients do not retry in lockstep after an outage.
593
+ `withRetry` in SKILL.md implements this with a `maxDelay` cap, a
594
+ `shouldRetry` predicate, `Task.checkCancellation()` before every attempt, and
595
+ up to 10% random jitter on the capped delay. It throws immediately when the
596
+ predicate says no or on the final attempt. The default predicate:
801
597
 
802
598
  ```swift
803
- func withRetry<T: Sendable>(
804
- maxAttempts: Int = 3,
805
- initialDelay: Duration = .seconds(1),
806
- maxDelay: Duration = .seconds(30),
807
- shouldRetry: @Sendable (Error) -> Bool = { error in
808
- if error is CancellationError { return false }
809
- if case NetworkError.httpError(let code, _, _) = error {
810
- return code >= 500 || code == 429
811
- }
812
- if let urlError = error as? URLError {
813
- return [.timedOut, .networkConnectionLost, .notConnectedToInternet]
814
- .contains(urlError.code)
815
- }
599
+ func isTransient(_ error: any Error) -> Bool {
600
+ switch error {
601
+ case is CancellationError:
816
602
  return false
817
- },
818
- operation: @Sendable () async throws -> T
819
- ) async throws -> T {
820
- var lastError: Error?
821
-
822
- for attempt in 0..<maxAttempts {
823
- try Task.checkCancellation()
824
- do {
825
- return try await operation()
826
- } catch {
827
- lastError = error
828
- guard shouldRetry(error), attempt < maxAttempts - 1 else {
829
- throw error
830
- }
831
- // Exponential backoff with jitter
832
- let base = Double(initialDelay.components.seconds) * pow(2.0, Double(attempt))
833
- let capped = min(base, Double(maxDelay.components.seconds))
834
- let jitter = Double.random(in: 0...(capped * 0.1))
835
- let delay = Duration.seconds(capped + jitter)
836
- try await Task.sleep(for: delay)
603
+ case let failure as NetworkError:
604
+ switch failure {
605
+ case .httpStatus(let status, _, _): return status >= 500 || status == 429
606
+ case .timedOut, .noConnection: return true
607
+ case .transport(let urlError): return urlError.code == .networkConnectionLost
608
+ default: return false
837
609
  }
610
+ case let urlError as URLError:
611
+ let transient: Set<URLError.Code> = [.timedOut, .networkConnectionLost, .notConnectedToInternet]
612
+ return transient.contains(urlError.code)
613
+ default:
614
+ return false
838
615
  }
839
-
840
- throw lastError!
841
616
  }
842
617
 
843
- // Usage
844
- let users = try await withRetry {
845
- try await client.get([User].self, path: "users")
618
+ let summary = try await withRetry(maxAttempts: 4) {
619
+ try await client.fetch(Endpoint(path: "summary"), as: Summary.self)
846
620
  }
847
621
  ```
848
622
 
849
- ---
850
-
851
- ## Certificate Pinning (URLSessionDelegate)
852
-
853
- Prefer ATS `NSPinnedDomains` for declarative certificate pinning when the
854
- pinset can ship in `Info.plist`. For manual `URLSessionDelegate` trust work,
855
- defer to the `swift-security` skill: correct SPKI pinning requires hashing
856
- the Subject Public Key Info structure, not just the raw key bytes returned by
857
- `SecKeyCopyExternalRepresentation`.
623
+ The raw `URLError` branch covers callers that talk to URLSession directly
624
+ instead of through `APIClient`.
858
625
 
859
- **Important considerations:**
860
- - Pin at least two keys (primary + backup) to avoid lockout during rotation.
861
- - Have a remote kill switch (feature flag) to disable pinning in emergencies.
862
- - Test certificate rotation in staging before deploying to production.
863
- - Always evaluate system trust before applying pins.
864
- - Keep certificate-trust implementation details in the security boundary.
626
+ ## Pinning certificates in a session delegate
865
627
 
866
- ---
628
+ - When the pins can ship in `Info.plist`, use ATS `NSPinnedDomains` instead
629
+ of writing trust code.
630
+ - Hand-written trust evaluation in `urlSession(_:didReceive:completionHandler:)`
631
+ belongs to `swift-security`. SPKI pinning hashes the DER-encoded Subject
632
+ Public Key Info; the bytes from `SecKeyCopyExternalRepresentation` alone are
633
+ not that.
634
+ - Always pin at least two keys, the live one and a backup, so a rotation
635
+ cannot lock users out.
636
+ - Keep a remote switch (a feature flag) that can turn pinning off in an
637
+ emergency.
638
+ - Try a full key rotation against a staging host first, never first in production.
639
+ - Run the system trust evaluation first; pins only narrow an already trusted
640
+ chain.
641
+ - Keep the trust implementation inside the security module rather than
642
+ scattering it through networking code.
867
643
 
868
- ## Request Logging / Debugging Middleware
644
+ ## Middleware that logs requests
869
645
 
870
- Log outgoing requests and incoming responses for debugging. Disable or
871
- reduce verbosity in release builds.
646
+ Log traffic while developing, and compile it out or turn it down for
647
+ release.
872
648
 
873
649
  ```swift
874
- struct LoggingMiddleware: RequestMiddleware {
875
- let logger: Logger
650
+ import OSLog
876
651
 
877
- func prepare(_ request: URLRequest) async throws -> URLRequest {
652
+ struct DebugLogMiddleware: RequestMiddleware {
653
+ private let log = Logger(subsystem: "app.networking", category: "http")
654
+
655
+ func adapt(_ request: inout URLRequest) async throws {
878
656
  #if DEBUG
879
- let method = request.httpMethod ?? "GET"
880
- let url = request.url?.absoluteString ?? "unknown"
881
- logger.debug("[\(method)] \(url)")
882
- if let headers = request.allHTTPHeaderFields {
883
- for (key, value) in headers where key != "Authorization" {
884
- logger.debug(" \(key): \(value)")
885
- }
886
- }
887
- if let body = request.httpBody, body.count < 10_000 {
888
- logger.debug(" Body: \(String(data: body, encoding: .utf8) ?? "<binary>")")
657
+ let verb = request.httpMethod ?? "GET"
658
+ log.debug("\(verb) \(request.url?.absoluteString ?? "-")")
659
+ let visible = (request.allHTTPHeaderFields ?? [:]).filter { $0.key != "Authorization" }
660
+ visible.forEach { log.debug(" \($0.key): \($0.value)") }
661
+ if let payload = request.httpBody, payload.count < 10_000 {
662
+ log.debug(" body: \(String(decoding: payload, as: UTF8.self))")
889
663
  }
890
664
  #endif
891
- return request
892
665
  }
893
666
  }
894
667
  ```
895
668
 
669
+ The `Authorization` header is never written out, and large bodies are
670
+ skipped.
671
+
896
672
  ### Response Logging
897
673
 
898
- To log responses, wrap the transport call rather than using middleware:
674
+ Middleware sees only the outgoing request. To log outcomes and timing, wrap
675
+ the transport call:
899
676
 
900
677
  ```swift
901
- func loggedRequest<T: Decodable & Sendable>(
902
- _ type: T.Type,
903
- endpoint: Endpoint,
904
- logger: Logger
905
- ) async throws -> T {
906
- let start = ContinuousClock().now
678
+ func timed(_ outgoing: URLRequest, on session: URLSession, log: Logger) async throws -> (Data, URLResponse) {
679
+ let clock = ContinuousClock()
680
+ let start = clock.now
681
+ let route = outgoing.url?.path() ?? "-"
907
682
  do {
908
- let result: T = try await request(type, endpoint: endpoint)
909
- let elapsed = ContinuousClock().now - start
910
- logger.debug("[\(endpoint.method.rawValue)] \(endpoint.path) -> 200 (\(elapsed))")
683
+ let result = try await session.data(for: outgoing)
684
+ let status = (result.1 as? HTTPURLResponse)?.statusCode ?? -1
685
+ log.debug("\(status) \(route) in \(clock.now - start)")
911
686
  return result
912
687
  } catch {
913
- let elapsed = ContinuousClock().now - start
914
- logger.error("[\(endpoint.method.rawValue)] \(endpoint.path) -> ERROR (\(elapsed)): \(error)")
688
+ log.error("failed \(route) after \(clock.now - start): \(error.localizedDescription)")
915
689
  throw error
916
690
  }
917
691
  }
918
692
  ```
919
693
 
920
- ---
921
-
922
- ## Request Caching Strategies
694
+ ## Choosing a cache policy
923
695
 
924
696
  ### URLCache Configuration
925
697
 
926
698
  ```swift
927
- // 50 MB memory / 200 MB disk cache
928
- let cache = URLCache(
929
- memoryCapacity: 50 * 1024 * 1024,
930
- diskCapacity: 200 * 1024 * 1024,
931
- directory: FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
932
- .first?.appendingPathComponent("URLCache")
933
- )
934
-
699
+ let cacheFolder = URL.cachesDirectory.appending(path: "HTTPCache")
700
+ let megabyte = 1_048_576
701
+ let cache = URLCache(memoryCapacity: 50 * megabyte, diskCapacity: 200 * megabyte, directory: cacheFolder)
935
702
  let config = URLSessionConfiguration.default
936
703
  config.urlCache = cache
937
704
  config.requestCachePolicy = .returnCacheDataElseLoad
938
-
939
- let session = URLSession(configuration: config)
940
705
  ```
941
706
 
942
707
  ### Per-Request Cache Control
943
708
 
944
- ```swift
945
- // Force fresh data
946
- var request = URLRequest(url: url)
947
- request.cachePolicy = .reloadIgnoringLocalCacheData
948
-
949
- // Use cached if available
950
- request.cachePolicy = .returnCacheDataElseLoad
951
-
952
- // Cache only (offline mode)
953
- request.cachePolicy = .returnCacheDataDontLoad
954
- ```
709
+ | Policy | Behaviour |
710
+ |---|---|
711
+ | `.reloadIgnoringLocalCacheData` | Always go to the network |
712
+ | `.returnCacheDataElseLoad` | Use a cached response if there is one |
713
+ | `.returnCacheDataDontLoad` | Cache only; an offline mode |
955
714
 
956
715
  ### ETag / If-None-Match
957
716
 
958
717
  ```swift
959
- func fetchWithETag<T: Decodable & Sendable>(
960
- _ type: T.Type,
961
- url: URL,
962
- cachedETag: String?,
963
- cachedData: Data?
964
- ) async throws -> (T, String?) {
965
- var request = URLRequest(url: url)
966
- if let etag = cachedETag {
967
- request.setValue(etag, forHTTPHeaderField: "If-None-Match")
968
- }
969
-
970
- let (data, response) = try await URLSession.shared.data(for: request)
971
- guard let http = response as? HTTPURLResponse else {
972
- throw NetworkError.invalidResponse
973
- }
974
-
975
- if http.statusCode == 304, let cachedData {
976
- // Not modified -- use cached data
977
- let decoded = try JSONDecoder().decode(T.self, from: cachedData)
978
- return (decoded, cachedETag)
979
- }
980
-
981
- let newETag = http.value(forHTTPHeaderField: "ETag")
982
- let decoded = try JSONDecoder().decode(T.self, from: data)
983
- return (decoded, newETag)
718
+ func fetchRevalidating<Model: Decodable>(_ kind: Model.Type, at address: URL,
719
+ stored: (etag: String, body: Data)?) async throws -> (Model, String?) {
720
+ var conditional = URLRequest(url: address)
721
+ if let stored { conditional.addValue(stored.etag, forHTTPHeaderField: "If-None-Match") }
722
+ let (fresh, reply) = try await session.data(for: conditional)
723
+ let head = reply as? HTTPURLResponse
724
+ if head?.statusCode == 304, let stored {
725
+ return (try JSONDecoder().decode(kind, from: stored.body), stored.etag)
726
+ }
727
+ return (try JSONDecoder().decode(kind, from: fresh), head?.value(forHTTPHeaderField: "ETag"))
984
728
  }
985
729
  ```
986
730
 
987
- ---
731
+ A 304 means the stored copy is still current, so decode it and keep its tag;
732
+ otherwise decode the fresh body and store the new tag.
988
733
 
989
- ## Server-Sent Events (SSE) Parsing
990
-
991
- Use `bytes(for:)` to consume a streaming SSE endpoint.
734
+ ## Reading a server-sent event stream
992
735
 
993
736
  ```swift
994
- struct ServerSentEvent: Sendable {
995
- var event: String?
737
+ struct StreamEvent: Sendable {
738
+ var name: String?
996
739
  var data: String
997
740
  var id: String?
998
741
  }
999
742
 
1000
- func sseStream(from url: URL) -> AsyncThrowingStream<ServerSentEvent, Error> {
1001
- AsyncThrowingStream { continuation in
1002
- let task = Task {
743
+ func eventStream(for request: URLRequest, on session: URLSession) -> AsyncThrowingStream<StreamEvent, any Error> {
744
+ var accepting = request
745
+ accepting.addValue("text/event-stream", forHTTPHeaderField: "Accept")
746
+ let streaming = accepting
747
+ return AsyncThrowingStream { continuation in
748
+ let reader = Task {
1003
749
  do {
1004
- var request = URLRequest(url: url)
1005
- request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
1006
-
1007
- let (bytes, _) = try await URLSession.shared.bytes(for: request)
1008
-
1009
- var currentEvent: String?
1010
- var currentData = ""
1011
- var currentId: String?
1012
-
1013
- for try await line in bytes.lines {
1014
- if line.isEmpty {
1015
- // Empty line = dispatch event
1016
- if !currentData.isEmpty {
1017
- continuation.yield(ServerSentEvent(
1018
- event: currentEvent,
1019
- data: currentData.trimmingCharacters(in: .newlines),
1020
- id: currentId
1021
- ))
750
+ let (feed, _) = try await session.bytes(for: streaming)
751
+ var pending = StreamEvent(data: "")
752
+ var dataLines: [String] = []
753
+ for try await row in feed.lines {
754
+ if row.isEmpty {
755
+ if !dataLines.isEmpty {
756
+ pending.data = dataLines.joined(separator: "\n")
757
+ continuation.yield(pending)
1022
758
  }
1023
- currentEvent = nil
1024
- currentData = ""
1025
- currentId = nil
1026
- } else if line.hasPrefix("event:") {
1027
- currentEvent = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces)
1028
- } else if line.hasPrefix("data:") {
1029
- let value = String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces)
1030
- currentData += currentData.isEmpty ? value : "\n" + value
1031
- } else if line.hasPrefix("id:") {
1032
- currentId = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
759
+ pending = StreamEvent(data: "")
760
+ dataLines = []
761
+ } else if let value = field("event", in: row) {
762
+ pending.name = value
763
+ } else if let value = field("data", in: row) {
764
+ dataLines.append(value)
765
+ } else if let value = field("id", in: row) {
766
+ pending.id = value
1033
767
  }
1034
768
  }
1035
769
  continuation.finish()
@@ -1037,24 +771,32 @@ func sseStream(from url: URL) -> AsyncThrowingStream<ServerSentEvent, Error> {
1037
771
  continuation.finish(throwing: error)
1038
772
  }
1039
773
  }
1040
- continuation.onTermination = { _ in task.cancel() }
774
+ continuation.onTermination = { _ in reader.cancel() }
1041
775
  }
1042
776
  }
777
+
778
+ private func field(_ name: String, in row: String) -> String? {
779
+ guard row.hasPrefix(name + ":") else { return nil }
780
+ return row.dropFirst(name.count + 1).trimmingCharacters(in: .whitespaces)
781
+ }
1043
782
  ```
1044
783
 
1045
- ---
784
+ The request is copied into a `let` before the reader `Task` is created: a
785
+ captured `var` would be shared with the task, and Swift 6 rejects that as a
786
+ possible data race. A blank line ends an event; several `data:` lines join
787
+ with newlines.
788
+ `AsyncLineSequence` may not hand you empty lines on every OS release, and the
789
+ parser depends on them. If events never dispatch, split `bytes` on `\n`
790
+ yourself instead of using `lines`, and test against the real endpoint.
1046
791
 
1047
- ## Configured URLSession for Production
792
+ ## A production-ready session setup
1048
793
 
1049
- Use a configured session for production clients instead of calling
1050
- `URLSession.shared` from request methods. Set explicit request/resource
1051
- timeouts, cache behavior, connectivity policy, and any delegates needed for
1052
- authentication challenges, redirects, metrics, pinning boundaries, or
1053
- background transfers before creating the `URLSession`.
794
+ Decide timeouts, caching, connectivity behaviour and delegates once, when the
795
+ session is created, and pass that session to clients.
1054
796
 
1055
797
  ```swift
1056
798
  enum SessionFactory {
1057
- static func makeDefault(delegate: (any URLSessionDelegate)? = nil) -> URLSession {
799
+ static func standard(observer: (any URLSessionDelegate)? = nil) -> URLSession {
1058
800
  let config = URLSessionConfiguration.default
1059
801
  config.timeoutIntervalForRequest = 30
1060
802
  config.timeoutIntervalForResource = 300
@@ -1065,18 +807,9 @@ enum SessionFactory {
1065
807
  "Accept": "application/json",
1066
808
  "Accept-Encoding": "gzip, deflate, br",
1067
809
  ]
1068
-
1069
- let cache = URLCache(
1070
- memoryCapacity: 25 * 1024 * 1024,
1071
- diskCapacity: 100 * 1024 * 1024
1072
- )
1073
- config.urlCache = cache
1074
-
1075
- return URLSession(
1076
- configuration: config,
1077
- delegate: delegate,
1078
- delegateQueue: nil
1079
- )
810
+ let mb = 1_048_576
811
+ config.urlCache = URLCache(memoryCapacity: 25 * mb, diskCapacity: 100 * mb)
812
+ return URLSession(configuration: config, delegate: observer, delegateQueue: nil)
1080
813
  }
1081
814
  }
1082
815
  ```