@mmerterden/multi-agent-pipeline 20.6.0 → 20.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +0 -10
  3. package/docs/facts.json +1 -1
  4. package/manifest.json +328 -329
  5. package/package.json +2 -2
  6. package/pipeline/multi-agent-refs/features/design-conformance.md +62 -64
  7. package/pipeline/scripts/_notices.mjs +12 -1
  8. package/pipeline/scripts/gen-skills-index.mjs +1 -1
  9. package/pipeline/skills/.skill-manifest.json +106 -106
  10. package/pipeline/skills/shared/README.md +71 -71
  11. package/pipeline/skills/shared/external/agent-introspection-debugging/SKILL.md +1 -0
  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/android-architecture/SKILL.md +2 -0
  16. package/pipeline/skills/shared/external/android-performance/SKILL.md +2 -0
  17. package/pipeline/skills/shared/external/android-security/SKILL.md +2 -0
  18. package/pipeline/skills/shared/external/app-clips/SKILL.md +260 -160
  19. package/pipeline/skills/shared/external/app-clips/evals/evals.json +27 -27
  20. package/pipeline/skills/shared/external/app-clips/references/data-handoff-notifications-location.md +150 -83
  21. package/pipeline/skills/shared/external/app-clips/references/routing-and-experiences.md +135 -83
  22. package/pipeline/skills/shared/external/app-clips/references/size-capabilities-and-promotion.md +143 -85
  23. package/pipeline/skills/shared/external/app-intents/SKILL.md +302 -304
  24. package/pipeline/skills/shared/external/app-intents/evals/evals.json +21 -21
  25. package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +594 -894
  26. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +339 -277
  27. package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +27 -23
  28. package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +105 -122
  29. package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +143 -166
  30. package/pipeline/skills/shared/external/app-store-review/SKILL.md +307 -326
  31. package/pipeline/skills/shared/external/app-store-review/evals/evals.json +21 -21
  32. package/pipeline/skills/shared/external/app-store-review/references/privacy-manifest.md +105 -67
  33. package/pipeline/skills/shared/external/app-store-review/references/review-checklists.md +114 -101
  34. package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +333 -360
  35. package/pipeline/skills/shared/external/apple-on-device-ai/evals/evals.json +24 -27
  36. package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-conversion.md +215 -322
  37. package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-optimization.md +161 -256
  38. package/pipeline/skills/shared/external/apple-on-device-ai/references/foundation-models.md +277 -387
  39. package/pipeline/skills/shared/external/apple-on-device-ai/references/mlx-swift.md +196 -210
  40. package/pipeline/skills/shared/external/authentication/SKILL.md +265 -381
  41. package/pipeline/skills/shared/external/authentication/evals/evals.json +25 -25
  42. package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +133 -178
  43. package/pipeline/skills/shared/external/authentication/references/passkeys.md +111 -147
  44. package/pipeline/skills/shared/external/avkit/SKILL.md +267 -364
  45. package/pipeline/skills/shared/external/avkit/evals/evals.json +26 -26
  46. package/pipeline/skills/shared/external/avkit/references/avkit-patterns.md +375 -493
  47. package/pipeline/skills/shared/external/background-processing/SKILL.md +270 -382
  48. package/pipeline/skills/shared/external/background-processing/evals/evals.json +22 -22
  49. package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +169 -317
  50. package/pipeline/skills/shared/external/backlog/BACKLOG.md +1 -1
  51. package/pipeline/skills/shared/external/backlog/SKILL.md +56 -33
  52. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +290 -371
  53. package/pipeline/skills/shared/external/callkit-voip/evals/evals.json +24 -24
  54. package/pipeline/skills/shared/external/callkit-voip/references/callkit-patterns.md +175 -343
  55. package/pipeline/skills/shared/external/ci-cd-pipelines/SKILL.md +1 -0
  56. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +292 -381
  57. package/pipeline/skills/shared/external/cloudkit-sync/evals/evals.json +33 -30
  58. package/pipeline/skills/shared/external/cloudkit-sync/references/cloudkit-patterns.md +227 -355
  59. package/pipeline/skills/shared/external/compose-components/SKILL.md +2 -0
  60. package/pipeline/skills/shared/external/compose-navigation/SKILL.md +3 -2
  61. package/pipeline/skills/shared/external/compose-testing/SKILL.md +2 -0
  62. package/pipeline/skills/shared/external/contacts-framework/SKILL.md +197 -346
  63. package/pipeline/skills/shared/external/contacts-framework/evals/evals.json +19 -21
  64. package/pipeline/skills/shared/external/contacts-framework/references/contacts-patterns.md +169 -308
  65. package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +226 -376
  66. package/pipeline/skills/shared/external/core-bluetooth/evals/evals.json +25 -22
  67. package/pipeline/skills/shared/external/core-bluetooth/references/ble-patterns.md +257 -337
  68. package/pipeline/skills/shared/external/core-data/SKILL.md +292 -368
  69. package/pipeline/skills/shared/external/core-data/evals/evals.json +30 -27
  70. package/pipeline/skills/shared/external/core-motion/SKILL.md +235 -324
  71. package/pipeline/skills/shared/external/core-motion/evals/evals.json +31 -27
  72. package/pipeline/skills/shared/external/core-motion/references/motion-patterns.md +210 -310
  73. package/pipeline/skills/shared/external/core-nfc/SKILL.md +292 -366
  74. package/pipeline/skills/shared/external/core-nfc/evals/evals.json +22 -24
  75. package/pipeline/skills/shared/external/core-nfc/references/nfc-patterns.md +233 -329
  76. package/pipeline/skills/shared/external/coreml/SKILL.md +323 -367
  77. package/pipeline/skills/shared/external/coreml/evals/evals.json +24 -21
  78. package/pipeline/skills/shared/external/coreml/references/coreml-swift-integration.md +562 -565
  79. package/pipeline/skills/shared/external/council/SKILL.md +1 -0
  80. package/pipeline/skills/shared/external/cryptokit/SKILL.md +253 -394
  81. package/pipeline/skills/shared/external/cryptokit/evals/evals.json +20 -18
  82. package/pipeline/skills/shared/external/cryptokit/references/cryptokit-patterns.md +299 -488
  83. package/pipeline/skills/shared/external/css-modern/SKILL.md +1 -0
  84. package/pipeline/skills/shared/external/database-patterns/SKILL.md +1 -0
  85. package/pipeline/skills/shared/external/debugging-instruments/SKILL.md +270 -323
  86. package/pipeline/skills/shared/external/debugging-instruments/evals/evals.json +27 -30
  87. package/pipeline/skills/shared/external/debugging-instruments/references/instruments-guide.md +167 -315
  88. package/pipeline/skills/shared/external/debugging-instruments/references/lldb-patterns.md +140 -193
  89. package/pipeline/skills/shared/external/device-integrity/SKILL.md +230 -353
  90. package/pipeline/skills/shared/external/device-integrity/evals/evals.json +25 -21
  91. package/pipeline/skills/shared/external/device-integrity/references/device-integrity-patterns.md +159 -197
  92. package/pipeline/skills/shared/external/energykit/SKILL.md +225 -392
  93. package/pipeline/skills/shared/external/energykit/evals/evals.json +29 -28
  94. package/pipeline/skills/shared/external/energykit/references/energykit-patterns.md +174 -470
  95. package/pipeline/skills/shared/external/eventkit-calendar/SKILL.md +261 -383
  96. package/pipeline/skills/shared/external/eventkit-calendar/evals/evals.json +25 -22
  97. package/pipeline/skills/shared/external/eventkit-calendar/references/eventkit-patterns.md +165 -268
  98. package/pipeline/skills/shared/external/evidence-github/SKILL.md +2 -0
  99. package/pipeline/skills/shared/external/evidence-registry/SKILL.md +2 -0
  100. package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +2 -0
  101. package/pipeline/skills/shared/external/healthkit/SKILL.md +252 -303
  102. package/pipeline/skills/shared/external/healthkit/evals/evals.json +24 -23
  103. package/pipeline/skills/shared/external/healthkit/references/healthkit-patterns.md +369 -523
  104. package/pipeline/skills/shared/external/homekit-matter/SKILL.md +233 -348
  105. package/pipeline/skills/shared/external/homekit-matter/evals/evals.json +27 -22
  106. package/pipeline/skills/shared/external/homekit-matter/references/matter-commissioning.md +199 -305
  107. package/pipeline/skills/shared/external/html-semantic/SKILL.md +1 -0
  108. package/pipeline/skills/shared/external/humanizer/SKILL.md +1 -0
  109. package/pipeline/skills/shared/external/ios-accessibility/SKILL.md +368 -340
  110. package/pipeline/skills/shared/external/ios-accessibility/evals/evals.json +28 -27
  111. package/pipeline/skills/shared/external/ios-accessibility/references/a11y-patterns.md +314 -260
  112. package/pipeline/skills/shared/external/ios-accessibility/references/media-accessibility.md +97 -67
  113. package/pipeline/skills/shared/external/ios-accessibility/references/nutrition-labels.md +165 -101
  114. package/pipeline/skills/shared/external/ios-coding-standard/SKILL.md +1 -0
  115. package/pipeline/skills/shared/external/ios-localization/SKILL.md +258 -371
  116. package/pipeline/skills/shared/external/ios-localization/evals/evals.json +23 -23
  117. package/pipeline/skills/shared/external/ios-localization/references/formatstyle-locale.md +283 -491
  118. package/pipeline/skills/shared/external/ios-localization/references/string-catalogs.md +313 -440
  119. package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +1 -0
  120. package/pipeline/skills/shared/external/ios-networking/SKILL.md +265 -341
  121. package/pipeline/skills/shared/external/ios-networking/evals/evals.json +24 -24
  122. package/pipeline/skills/shared/external/ios-networking/references/background-websocket.md +425 -652
  123. package/pipeline/skills/shared/external/ios-networking/references/file-storage-patterns.md +143 -285
  124. package/pipeline/skills/shared/external/ios-networking/references/lightweight-clients.md +93 -53
  125. package/pipeline/skills/shared/external/ios-networking/references/network-framework.md +231 -456
  126. package/pipeline/skills/shared/external/ios-networking/references/urlsession-patterns.md +517 -784
  127. package/pipeline/skills/shared/external/ios-security/SKILL.md +2 -0
  128. package/pipeline/skills/shared/external/ios-simulator/SKILL.md +265 -393
  129. package/pipeline/skills/shared/external/ios-simulator/evals/evals.json +21 -21
  130. package/pipeline/skills/shared/external/ios-simulator/references/simctl-commands.md +177 -270
  131. package/pipeline/skills/shared/external/live-activities/SKILL.md +318 -360
  132. package/pipeline/skills/shared/external/live-activities/evals/evals.json +21 -21
  133. package/pipeline/skills/shared/external/live-activities/references/activitykit-patterns.md +478 -710
  134. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +91 -283
  135. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +119 -151
  136. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +60 -90
  137. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +119 -156
  138. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +726 -787
  139. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +253 -288
  140. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +243 -304
  141. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +88 -104
  142. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +181 -235
  143. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +198 -263
  144. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +461 -466
  145. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +145 -151
  146. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +123 -141
  147. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +146 -157
  148. package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +22 -19
  149. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +156 -140
  150. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +295 -267
  151. package/pipeline/skills/shared/external/mapkit-location/evals/evals.json +28 -24
  152. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-corelocation-patterns.md +378 -532
  153. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +397 -499
  154. package/pipeline/skills/shared/external/metrickit-diagnostics/SKILL.md +165 -348
  155. package/pipeline/skills/shared/external/metrickit-diagnostics/evals/evals.json +26 -23
  156. package/pipeline/skills/shared/external/metrickit-diagnostics/references/metrickit-patterns.md +123 -130
  157. package/pipeline/skills/shared/external/musickit-audio/SKILL.md +189 -315
  158. package/pipeline/skills/shared/external/musickit-audio/evals/evals.json +22 -21
  159. package/pipeline/skills/shared/external/musickit-audio/references/musickit-patterns.md +181 -270
  160. package/pipeline/skills/shared/external/natural-language/SKILL.md +188 -340
  161. package/pipeline/skills/shared/external/natural-language/evals/evals.json +21 -21
  162. package/pipeline/skills/shared/external/natural-language/references/translation-patterns.md +171 -225
  163. package/pipeline/skills/shared/external/nextjs-app-router/SKILL.md +1 -0
  164. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +258 -392
  165. package/pipeline/skills/shared/external/passkit-wallet/evals/evals.json +30 -29
  166. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +164 -231
  167. package/pipeline/skills/shared/external/pdfkit/SKILL.md +312 -344
  168. package/pipeline/skills/shared/external/pdfkit/evals/evals.json +19 -19
  169. package/pipeline/skills/shared/external/pdfkit/references/pdfkit-patterns.md +413 -624
  170. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +242 -358
  171. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +25 -21
  172. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +161 -226
  173. package/pipeline/skills/shared/external/permissionkit/SKILL.md +282 -400
  174. package/pipeline/skills/shared/external/permissionkit/evals/evals.json +27 -30
  175. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +237 -350
  176. package/pipeline/skills/shared/external/photos-camera-media/SKILL.md +276 -325
  177. package/pipeline/skills/shared/external/photos-camera-media/references/av-playback.md +299 -545
  178. package/pipeline/skills/shared/external/photos-camera-media/references/camera-capture.md +344 -588
  179. package/pipeline/skills/shared/external/photos-camera-media/references/image-loading-caching.md +316 -660
  180. package/pipeline/skills/shared/external/photos-camera-media/references/photokit-patterns.md +270 -416
  181. package/pipeline/skills/shared/external/play-store-review/SKILL.md +2 -0
  182. package/pipeline/skills/shared/external/push-notifications/SKILL.md +312 -340
  183. package/pipeline/skills/shared/external/push-notifications/evals/evals.json +27 -26
  184. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +328 -485
  185. package/pipeline/skills/shared/external/push-notifications/references/rich-notifications.md +327 -560
  186. package/pipeline/skills/shared/external/python-patterns/SKILL.md +1 -0
  187. package/pipeline/skills/shared/external/react-best-practices/SKILL.md +1 -0
  188. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +218 -410
  189. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +24 -27
  190. package/pipeline/skills/shared/external/realitykit-ar/references/realitykit-patterns.md +221 -348
  191. package/pipeline/skills/shared/external/rest-api-design/SKILL.md +1 -0
  192. package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +2 -0
  193. package/pipeline/skills/shared/external/room-database/SKILL.md +2 -0
  194. package/pipeline/skills/shared/external/search-first/SKILL.md +1 -0
  195. package/pipeline/skills/shared/external/shareplay-activities/SKILL.md +222 -393
  196. package/pipeline/skills/shared/external/shareplay-activities/evals/evals.json +23 -24
  197. package/pipeline/skills/shared/external/shareplay-activities/references/shareplay-patterns.md +280 -420
  198. package/pipeline/skills/shared/external/signal-community/SKILL.md +2 -0
  199. package/pipeline/skills/shared/external/skill-creator/SKILL.md +80 -41
  200. package/pipeline/skills/shared/external/skill-creator/audit.md +63 -59
  201. package/pipeline/skills/shared/external/skill-creator/checklist.md +28 -20
  202. package/pipeline/skills/shared/external/skill-creator/examples.md +40 -40
  203. package/pipeline/skills/shared/external/skill-creator/label-check.md +48 -36
  204. package/pipeline/skills/shared/external/skill-creator/scripts/audit-panel.js +91 -100
  205. package/pipeline/skills/shared/external/skill-creator/template.md +51 -39
  206. package/pipeline/skills/shared/external/speech-recognition/SKILL.md +217 -421
  207. package/pipeline/skills/shared/external/speech-recognition/evals/evals.json +23 -26
  208. package/pipeline/skills/shared/external/speech-recognition/references/speechanalyzer-patterns.md +133 -125
  209. package/pipeline/skills/shared/external/storekit/SKILL.md +228 -204
  210. package/pipeline/skills/shared/external/storekit/evals/evals.json +27 -24
  211. package/pipeline/skills/shared/external/storekit/references/app-review-guidelines.md +98 -109
  212. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +298 -242
  213. package/pipeline/skills/shared/external/storekit/references/storekit-advanced.md +356 -649
  214. package/pipeline/skills/shared/external/swift-api-design-guidelines/SKILL.md +274 -399
  215. package/pipeline/skills/shared/external/swift-api-design-guidelines/evals/evals.json +22 -24
  216. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/argument-labels-and-parameters.md +107 -108
  217. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/conventions-and-special-rules.md +93 -165
  218. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/naming-and-clarity.md +99 -137
  219. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/side-effects-and-mutating-pairs.md +77 -120
  220. package/pipeline/skills/shared/external/swift-architecture/SKILL.md +334 -350
  221. package/pipeline/skills/shared/external/swift-architecture/evals/evals.json +22 -22
  222. package/pipeline/skills/shared/external/swift-charts/SKILL.md +208 -394
  223. package/pipeline/skills/shared/external/swift-charts/evals/evals.json +27 -30
  224. package/pipeline/skills/shared/external/swift-charts/references/charts-patterns.md +351 -762
  225. package/pipeline/skills/shared/external/swift-codable/SKILL.md +339 -343
  226. package/pipeline/skills/shared/external/swift-codable/evals/evals.json +20 -20
  227. package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +303 -351
  228. package/pipeline/skills/shared/external/swift-concurrency/evals/evals.json +27 -24
  229. package/pipeline/skills/shared/external/swift-concurrency/references/approachable-concurrency.md +65 -80
  230. package/pipeline/skills/shared/external/swift-concurrency/references/async-algorithms.md +48 -84
  231. package/pipeline/skills/shared/external/swift-concurrency/references/bridging-interop.md +134 -79
  232. package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +145 -167
  233. package/pipeline/skills/shared/external/swift-concurrency/references/diagnostics.md +62 -50
  234. package/pipeline/skills/shared/external/swift-concurrency/references/swiftui-concurrency.md +92 -121
  235. package/pipeline/skills/shared/external/swift-concurrency/references/synchronization-primitives.md +177 -241
  236. package/pipeline/skills/shared/external/swift-formatstyle/SKILL.md +258 -234
  237. package/pipeline/skills/shared/external/swift-language/SKILL.md +342 -382
  238. package/pipeline/skills/shared/external/swift-language/evals/evals.json +24 -27
  239. package/pipeline/skills/shared/external/swift-language/references/swift-attributes-interop.md +79 -56
  240. package/pipeline/skills/shared/external/swift-language/references/swift-patterns-extended.md +297 -340
  241. package/pipeline/skills/shared/external/swift-security/SKILL.md +180 -161
  242. package/pipeline/skills/shared/external/swift-security/evals/evals.json +25 -25
  243. package/pipeline/skills/shared/external/swift-security/references/biometric-authentication.md +314 -469
  244. package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +408 -476
  245. package/pipeline/skills/shared/external/swift-security/references/common-anti-patterns.md +260 -530
  246. package/pipeline/skills/shared/external/swift-security/references/compliance-owasp-mapping.md +270 -477
  247. package/pipeline/skills/shared/external/swift-security/references/credential-storage-patterns.md +573 -571
  248. package/pipeline/skills/shared/external/swift-security/references/cryptokit-public-key.md +370 -441
  249. package/pipeline/skills/shared/external/swift-security/references/cryptokit-symmetric.md +332 -433
  250. package/pipeline/skills/shared/external/swift-security/references/keychain-access-control.md +346 -468
  251. package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +352 -472
  252. package/pipeline/skills/shared/external/swift-security/references/keychain-item-classes.md +431 -432
  253. package/pipeline/skills/shared/external/swift-security/references/keychain-sharing.md +328 -425
  254. package/pipeline/skills/shared/external/swift-security/references/migration-legacy-stores.md +341 -579
  255. package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +396 -457
  256. package/pipeline/skills/shared/external/swift-security/references/testing-security-code.md +354 -614
  257. package/pipeline/skills/shared/external/swift-testing/SKILL.md +188 -175
  258. package/pipeline/skills/shared/external/swift-testing/evals/evals.json +26 -24
  259. package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +80 -84
  260. package/pipeline/skills/shared/external/swift-testing/references/testing-patterns.md +317 -433
  261. package/pipeline/skills/shared/external/swiftdata/SKILL.md +392 -256
  262. package/pipeline/skills/shared/external/swiftdata/evals/evals.json +24 -24
  263. package/pipeline/skills/shared/external/swiftdata/references/core-data-coexistence.md +206 -402
  264. package/pipeline/skills/shared/external/swiftdata/references/indexing.md +59 -52
  265. package/pipeline/skills/shared/external/swiftdata/references/predicate-pitfalls.md +57 -33
  266. package/pipeline/skills/shared/external/swiftdata/references/swiftdata-advanced.md +354 -747
  267. package/pipeline/skills/shared/external/swiftdata/references/swiftdata-queries.md +300 -508
  268. package/pipeline/skills/shared/external/swiftlint/SKILL.md +175 -226
  269. package/pipeline/skills/shared/external/swiftlint/references/adoption-and-configuration.md +141 -208
  270. package/pipeline/skills/shared/external/swiftlint/references/custom-rules-and-analyze.md +100 -109
  271. package/pipeline/skills/shared/external/swiftlint/references/plugins-run-scripts-and-integrations.md +159 -179
  272. package/pipeline/skills/shared/external/swiftlint/references/rule-reference.md +383 -18
  273. package/pipeline/skills/shared/external/swiftlint/references/rules-suppressions-and-baselines.md +143 -229
  274. package/pipeline/skills/shared/external/swiftui-animation/SKILL.md +283 -366
  275. package/pipeline/skills/shared/external/swiftui-animation/references/animation-advanced.md +396 -608
  276. package/pipeline/skills/shared/external/swiftui-animation/references/core-animation-bridge.md +336 -385
  277. package/pipeline/skills/shared/external/swiftui-gestures/SKILL.md +239 -349
  278. package/pipeline/skills/shared/external/swiftui-gestures/references/gesture-patterns.md +228 -310
  279. package/pipeline/skills/shared/external/swiftui-layout-components/SKILL.md +260 -249
  280. package/pipeline/skills/shared/external/swiftui-layout-components/references/form.md +92 -74
  281. package/pipeline/skills/shared/external/swiftui-layout-components/references/grids.md +112 -177
  282. package/pipeline/skills/shared/external/swiftui-layout-components/references/list.md +61 -64
  283. package/pipeline/skills/shared/external/swiftui-layout-components/references/scrollview.md +94 -134
  284. package/pipeline/skills/shared/external/swiftui-liquid-glass/SKILL.md +193 -225
  285. package/pipeline/skills/shared/external/swiftui-liquid-glass/references/liquid-glass.md +173 -327
  286. package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +193 -168
  287. package/pipeline/skills/shared/external/swiftui-navigation/references/deeplinks.md +127 -150
  288. package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +132 -133
  289. package/pipeline/skills/shared/external/swiftui-navigation/references/sheets.md +152 -117
  290. package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +106 -140
  291. package/pipeline/skills/shared/external/swiftui-patterns/SKILL.md +316 -252
  292. package/pipeline/skills/shared/external/swiftui-patterns/references/architecture-patterns.md +341 -332
  293. package/pipeline/skills/shared/external/swiftui-patterns/references/deprecated-migration.md +547 -854
  294. package/pipeline/skills/shared/external/swiftui-patterns/references/design-polish.md +485 -537
  295. package/pipeline/skills/shared/external/swiftui-patterns/references/platform-and-sharing.md +417 -499
  296. package/pipeline/skills/shared/external/swiftui-performance/SKILL.md +213 -376
  297. package/pipeline/skills/shared/external/swiftui-performance/references/demystify-swiftui-performance-wwdc23.md +86 -175
  298. package/pipeline/skills/shared/external/swiftui-performance/references/optimizing-swiftui-performance-instruments.md +89 -195
  299. package/pipeline/skills/shared/external/swiftui-performance/references/understanding-hangs-in-your-app.md +95 -182
  300. package/pipeline/skills/shared/external/swiftui-performance/references/understanding-improving-swiftui-performance.md +71 -149
  301. package/pipeline/skills/shared/external/swiftui-performance/references/wwdc-session-sources.md +21 -27
  302. package/pipeline/skills/shared/external/swiftui-uikit-interop/SKILL.md +303 -295
  303. package/pipeline/skills/shared/external/swiftui-uikit-interop/references/hosting-migration.md +204 -387
  304. package/pipeline/skills/shared/external/swiftui-uikit-interop/references/representable-recipes.md +469 -683
  305. package/pipeline/skills/shared/external/swiftui-webkit/SKILL.md +140 -186
  306. package/pipeline/skills/shared/external/swiftui-webkit/references/loading-and-observation.md +75 -86
  307. package/pipeline/skills/shared/external/swiftui-webkit/references/local-content-and-custom-schemes.md +63 -60
  308. package/pipeline/skills/shared/external/swiftui-webkit/references/migration-and-fallbacks.md +69 -137
  309. package/pipeline/skills/shared/external/swiftui-webkit/references/navigation-and-javascript.md +95 -67
  310. package/pipeline/skills/shared/external/tailwind-css/SKILL.md +1 -0
  311. package/pipeline/skills/shared/external/testing-backend/SKILL.md +1 -0
  312. package/pipeline/skills/shared/external/tipkit/SKILL.md +220 -335
  313. package/pipeline/skills/shared/external/tipkit/references/tipkit-patterns.md +356 -494
  314. package/pipeline/skills/shared/external/typescript-patterns/SKILL.md +1 -0
  315. package/pipeline/skills/shared/external/vision-framework/SKILL.md +260 -375
  316. package/pipeline/skills/shared/external/vision-framework/references/vision-requests.md +393 -515
  317. package/pipeline/skills/shared/external/vision-framework/references/visionkit-scanner.md +363 -539
  318. package/pipeline/skills/shared/external/vue-composition/SKILL.md +1 -0
  319. package/pipeline/skills/shared/external/weatherkit/SKILL.md +152 -310
  320. package/pipeline/skills/shared/external/weatherkit/references/weatherkit-patterns.md +288 -407
  321. package/pipeline/skills/shared/external/web-accessibility/SKILL.md +1 -0
  322. package/pipeline/skills/shared/external/web-performance/SKILL.md +1 -0
  323. package/pipeline/skills/shared/external/web-testing/SKILL.md +1 -0
  324. package/pipeline/skills/shared/external/widgetkit/SKILL.md +216 -288
  325. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +414 -719
  326. 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
  ```