@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.
- package/CHANGELOG.md +27 -0
- package/LICENSE +0 -10
- package/docs/facts.json +1 -1
- package/manifest.json +328 -329
- package/package.json +2 -2
- package/pipeline/multi-agent-refs/features/design-conformance.md +62 -64
- package/pipeline/scripts/_notices.mjs +12 -1
- package/pipeline/scripts/gen-skills-index.mjs +1 -1
- package/pipeline/skills/.skill-manifest.json +106 -106
- package/pipeline/skills/shared/README.md +71 -71
- package/pipeline/skills/shared/external/agent-introspection-debugging/SKILL.md +1 -0
- package/pipeline/skills/shared/external/alarmkit/SKILL.md +373 -381
- package/pipeline/skills/shared/external/alarmkit/evals/evals.json +23 -18
- package/pipeline/skills/shared/external/alarmkit/references/alarmkit-patterns.md +328 -378
- package/pipeline/skills/shared/external/android-architecture/SKILL.md +2 -0
- package/pipeline/skills/shared/external/android-performance/SKILL.md +2 -0
- package/pipeline/skills/shared/external/android-security/SKILL.md +2 -0
- package/pipeline/skills/shared/external/app-clips/SKILL.md +260 -160
- package/pipeline/skills/shared/external/app-clips/evals/evals.json +27 -27
- package/pipeline/skills/shared/external/app-clips/references/data-handoff-notifications-location.md +150 -83
- package/pipeline/skills/shared/external/app-clips/references/routing-and-experiences.md +135 -83
- package/pipeline/skills/shared/external/app-clips/references/size-capabilities-and-promotion.md +143 -85
- package/pipeline/skills/shared/external/app-intents/SKILL.md +302 -304
- package/pipeline/skills/shared/external/app-intents/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +594 -894
- package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +339 -277
- package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +27 -23
- package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +105 -122
- package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +143 -166
- package/pipeline/skills/shared/external/app-store-review/SKILL.md +307 -326
- package/pipeline/skills/shared/external/app-store-review/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/app-store-review/references/privacy-manifest.md +105 -67
- package/pipeline/skills/shared/external/app-store-review/references/review-checklists.md +114 -101
- package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +333 -360
- package/pipeline/skills/shared/external/apple-on-device-ai/evals/evals.json +24 -27
- package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-conversion.md +215 -322
- package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-optimization.md +161 -256
- package/pipeline/skills/shared/external/apple-on-device-ai/references/foundation-models.md +277 -387
- package/pipeline/skills/shared/external/apple-on-device-ai/references/mlx-swift.md +196 -210
- package/pipeline/skills/shared/external/authentication/SKILL.md +265 -381
- package/pipeline/skills/shared/external/authentication/evals/evals.json +25 -25
- package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +133 -178
- package/pipeline/skills/shared/external/authentication/references/passkeys.md +111 -147
- package/pipeline/skills/shared/external/avkit/SKILL.md +267 -364
- package/pipeline/skills/shared/external/avkit/evals/evals.json +26 -26
- package/pipeline/skills/shared/external/avkit/references/avkit-patterns.md +375 -493
- package/pipeline/skills/shared/external/background-processing/SKILL.md +270 -382
- package/pipeline/skills/shared/external/background-processing/evals/evals.json +22 -22
- package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +169 -317
- package/pipeline/skills/shared/external/backlog/BACKLOG.md +1 -1
- package/pipeline/skills/shared/external/backlog/SKILL.md +56 -33
- package/pipeline/skills/shared/external/callkit-voip/SKILL.md +290 -371
- package/pipeline/skills/shared/external/callkit-voip/evals/evals.json +24 -24
- package/pipeline/skills/shared/external/callkit-voip/references/callkit-patterns.md +175 -343
- package/pipeline/skills/shared/external/ci-cd-pipelines/SKILL.md +1 -0
- package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +292 -381
- package/pipeline/skills/shared/external/cloudkit-sync/evals/evals.json +33 -30
- package/pipeline/skills/shared/external/cloudkit-sync/references/cloudkit-patterns.md +227 -355
- package/pipeline/skills/shared/external/compose-components/SKILL.md +2 -0
- package/pipeline/skills/shared/external/compose-navigation/SKILL.md +3 -2
- package/pipeline/skills/shared/external/compose-testing/SKILL.md +2 -0
- package/pipeline/skills/shared/external/contacts-framework/SKILL.md +197 -346
- package/pipeline/skills/shared/external/contacts-framework/evals/evals.json +19 -21
- package/pipeline/skills/shared/external/contacts-framework/references/contacts-patterns.md +169 -308
- package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +226 -376
- package/pipeline/skills/shared/external/core-bluetooth/evals/evals.json +25 -22
- package/pipeline/skills/shared/external/core-bluetooth/references/ble-patterns.md +257 -337
- package/pipeline/skills/shared/external/core-data/SKILL.md +292 -368
- package/pipeline/skills/shared/external/core-data/evals/evals.json +30 -27
- package/pipeline/skills/shared/external/core-motion/SKILL.md +235 -324
- package/pipeline/skills/shared/external/core-motion/evals/evals.json +31 -27
- package/pipeline/skills/shared/external/core-motion/references/motion-patterns.md +210 -310
- package/pipeline/skills/shared/external/core-nfc/SKILL.md +292 -366
- package/pipeline/skills/shared/external/core-nfc/evals/evals.json +22 -24
- package/pipeline/skills/shared/external/core-nfc/references/nfc-patterns.md +233 -329
- package/pipeline/skills/shared/external/coreml/SKILL.md +323 -367
- package/pipeline/skills/shared/external/coreml/evals/evals.json +24 -21
- package/pipeline/skills/shared/external/coreml/references/coreml-swift-integration.md +562 -565
- package/pipeline/skills/shared/external/council/SKILL.md +1 -0
- package/pipeline/skills/shared/external/cryptokit/SKILL.md +253 -394
- package/pipeline/skills/shared/external/cryptokit/evals/evals.json +20 -18
- package/pipeline/skills/shared/external/cryptokit/references/cryptokit-patterns.md +299 -488
- package/pipeline/skills/shared/external/css-modern/SKILL.md +1 -0
- package/pipeline/skills/shared/external/database-patterns/SKILL.md +1 -0
- package/pipeline/skills/shared/external/debugging-instruments/SKILL.md +270 -323
- package/pipeline/skills/shared/external/debugging-instruments/evals/evals.json +27 -30
- package/pipeline/skills/shared/external/debugging-instruments/references/instruments-guide.md +167 -315
- package/pipeline/skills/shared/external/debugging-instruments/references/lldb-patterns.md +140 -193
- package/pipeline/skills/shared/external/device-integrity/SKILL.md +230 -353
- package/pipeline/skills/shared/external/device-integrity/evals/evals.json +25 -21
- package/pipeline/skills/shared/external/device-integrity/references/device-integrity-patterns.md +159 -197
- package/pipeline/skills/shared/external/energykit/SKILL.md +225 -392
- package/pipeline/skills/shared/external/energykit/evals/evals.json +29 -28
- package/pipeline/skills/shared/external/energykit/references/energykit-patterns.md +174 -470
- package/pipeline/skills/shared/external/eventkit-calendar/SKILL.md +261 -383
- package/pipeline/skills/shared/external/eventkit-calendar/evals/evals.json +25 -22
- package/pipeline/skills/shared/external/eventkit-calendar/references/eventkit-patterns.md +165 -268
- package/pipeline/skills/shared/external/evidence-github/SKILL.md +2 -0
- package/pipeline/skills/shared/external/evidence-registry/SKILL.md +2 -0
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +2 -0
- package/pipeline/skills/shared/external/healthkit/SKILL.md +252 -303
- package/pipeline/skills/shared/external/healthkit/evals/evals.json +24 -23
- package/pipeline/skills/shared/external/healthkit/references/healthkit-patterns.md +369 -523
- package/pipeline/skills/shared/external/homekit-matter/SKILL.md +233 -348
- package/pipeline/skills/shared/external/homekit-matter/evals/evals.json +27 -22
- package/pipeline/skills/shared/external/homekit-matter/references/matter-commissioning.md +199 -305
- package/pipeline/skills/shared/external/html-semantic/SKILL.md +1 -0
- package/pipeline/skills/shared/external/humanizer/SKILL.md +1 -0
- package/pipeline/skills/shared/external/ios-accessibility/SKILL.md +368 -340
- package/pipeline/skills/shared/external/ios-accessibility/evals/evals.json +28 -27
- package/pipeline/skills/shared/external/ios-accessibility/references/a11y-patterns.md +314 -260
- package/pipeline/skills/shared/external/ios-accessibility/references/media-accessibility.md +97 -67
- package/pipeline/skills/shared/external/ios-accessibility/references/nutrition-labels.md +165 -101
- package/pipeline/skills/shared/external/ios-coding-standard/SKILL.md +1 -0
- package/pipeline/skills/shared/external/ios-localization/SKILL.md +258 -371
- package/pipeline/skills/shared/external/ios-localization/evals/evals.json +23 -23
- package/pipeline/skills/shared/external/ios-localization/references/formatstyle-locale.md +283 -491
- package/pipeline/skills/shared/external/ios-localization/references/string-catalogs.md +313 -440
- package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +1 -0
- package/pipeline/skills/shared/external/ios-networking/SKILL.md +265 -341
- package/pipeline/skills/shared/external/ios-networking/evals/evals.json +24 -24
- package/pipeline/skills/shared/external/ios-networking/references/background-websocket.md +425 -652
- package/pipeline/skills/shared/external/ios-networking/references/file-storage-patterns.md +143 -285
- package/pipeline/skills/shared/external/ios-networking/references/lightweight-clients.md +93 -53
- package/pipeline/skills/shared/external/ios-networking/references/network-framework.md +231 -456
- package/pipeline/skills/shared/external/ios-networking/references/urlsession-patterns.md +517 -784
- package/pipeline/skills/shared/external/ios-security/SKILL.md +2 -0
- package/pipeline/skills/shared/external/ios-simulator/SKILL.md +265 -393
- package/pipeline/skills/shared/external/ios-simulator/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/ios-simulator/references/simctl-commands.md +177 -270
- package/pipeline/skills/shared/external/live-activities/SKILL.md +318 -360
- package/pipeline/skills/shared/external/live-activities/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/live-activities/references/activitykit-patterns.md +478 -710
- package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +91 -283
- package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +119 -151
- package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +60 -90
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +119 -156
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +726 -787
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +253 -288
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +243 -304
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +88 -104
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +181 -235
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +198 -263
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +461 -466
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +145 -151
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +123 -141
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +146 -157
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +22 -19
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +156 -140
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +295 -267
- package/pipeline/skills/shared/external/mapkit-location/evals/evals.json +28 -24
- package/pipeline/skills/shared/external/mapkit-location/references/mapkit-corelocation-patterns.md +378 -532
- package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +397 -499
- package/pipeline/skills/shared/external/metrickit-diagnostics/SKILL.md +165 -348
- package/pipeline/skills/shared/external/metrickit-diagnostics/evals/evals.json +26 -23
- package/pipeline/skills/shared/external/metrickit-diagnostics/references/metrickit-patterns.md +123 -130
- package/pipeline/skills/shared/external/musickit-audio/SKILL.md +189 -315
- package/pipeline/skills/shared/external/musickit-audio/evals/evals.json +22 -21
- package/pipeline/skills/shared/external/musickit-audio/references/musickit-patterns.md +181 -270
- package/pipeline/skills/shared/external/natural-language/SKILL.md +188 -340
- package/pipeline/skills/shared/external/natural-language/evals/evals.json +21 -21
- package/pipeline/skills/shared/external/natural-language/references/translation-patterns.md +171 -225
- package/pipeline/skills/shared/external/nextjs-app-router/SKILL.md +1 -0
- package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +258 -392
- package/pipeline/skills/shared/external/passkit-wallet/evals/evals.json +30 -29
- package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +164 -231
- package/pipeline/skills/shared/external/pdfkit/SKILL.md +312 -344
- package/pipeline/skills/shared/external/pdfkit/evals/evals.json +19 -19
- package/pipeline/skills/shared/external/pdfkit/references/pdfkit-patterns.md +413 -624
- package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +242 -358
- package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +25 -21
- package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +161 -226
- package/pipeline/skills/shared/external/permissionkit/SKILL.md +282 -400
- package/pipeline/skills/shared/external/permissionkit/evals/evals.json +27 -30
- package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +237 -350
- package/pipeline/skills/shared/external/photos-camera-media/SKILL.md +276 -325
- package/pipeline/skills/shared/external/photos-camera-media/references/av-playback.md +299 -545
- package/pipeline/skills/shared/external/photos-camera-media/references/camera-capture.md +344 -588
- package/pipeline/skills/shared/external/photos-camera-media/references/image-loading-caching.md +316 -660
- package/pipeline/skills/shared/external/photos-camera-media/references/photokit-patterns.md +270 -416
- package/pipeline/skills/shared/external/play-store-review/SKILL.md +2 -0
- package/pipeline/skills/shared/external/push-notifications/SKILL.md +312 -340
- package/pipeline/skills/shared/external/push-notifications/evals/evals.json +27 -26
- package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +328 -485
- package/pipeline/skills/shared/external/push-notifications/references/rich-notifications.md +327 -560
- package/pipeline/skills/shared/external/python-patterns/SKILL.md +1 -0
- package/pipeline/skills/shared/external/react-best-practices/SKILL.md +1 -0
- package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +218 -410
- package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +24 -27
- package/pipeline/skills/shared/external/realitykit-ar/references/realitykit-patterns.md +221 -348
- package/pipeline/skills/shared/external/rest-api-design/SKILL.md +1 -0
- package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +2 -0
- package/pipeline/skills/shared/external/room-database/SKILL.md +2 -0
- package/pipeline/skills/shared/external/search-first/SKILL.md +1 -0
- package/pipeline/skills/shared/external/shareplay-activities/SKILL.md +222 -393
- package/pipeline/skills/shared/external/shareplay-activities/evals/evals.json +23 -24
- package/pipeline/skills/shared/external/shareplay-activities/references/shareplay-patterns.md +280 -420
- package/pipeline/skills/shared/external/signal-community/SKILL.md +2 -0
- package/pipeline/skills/shared/external/skill-creator/SKILL.md +80 -41
- package/pipeline/skills/shared/external/skill-creator/audit.md +63 -59
- package/pipeline/skills/shared/external/skill-creator/checklist.md +28 -20
- package/pipeline/skills/shared/external/skill-creator/examples.md +40 -40
- package/pipeline/skills/shared/external/skill-creator/label-check.md +48 -36
- package/pipeline/skills/shared/external/skill-creator/scripts/audit-panel.js +91 -100
- package/pipeline/skills/shared/external/skill-creator/template.md +51 -39
- package/pipeline/skills/shared/external/speech-recognition/SKILL.md +217 -421
- package/pipeline/skills/shared/external/speech-recognition/evals/evals.json +23 -26
- package/pipeline/skills/shared/external/speech-recognition/references/speechanalyzer-patterns.md +133 -125
- package/pipeline/skills/shared/external/storekit/SKILL.md +228 -204
- package/pipeline/skills/shared/external/storekit/evals/evals.json +27 -24
- package/pipeline/skills/shared/external/storekit/references/app-review-guidelines.md +98 -109
- package/pipeline/skills/shared/external/storekit/references/core-patterns.md +298 -242
- package/pipeline/skills/shared/external/storekit/references/storekit-advanced.md +356 -649
- package/pipeline/skills/shared/external/swift-api-design-guidelines/SKILL.md +274 -399
- package/pipeline/skills/shared/external/swift-api-design-guidelines/evals/evals.json +22 -24
- package/pipeline/skills/shared/external/swift-api-design-guidelines/references/argument-labels-and-parameters.md +107 -108
- package/pipeline/skills/shared/external/swift-api-design-guidelines/references/conventions-and-special-rules.md +93 -165
- package/pipeline/skills/shared/external/swift-api-design-guidelines/references/naming-and-clarity.md +99 -137
- package/pipeline/skills/shared/external/swift-api-design-guidelines/references/side-effects-and-mutating-pairs.md +77 -120
- package/pipeline/skills/shared/external/swift-architecture/SKILL.md +334 -350
- package/pipeline/skills/shared/external/swift-architecture/evals/evals.json +22 -22
- package/pipeline/skills/shared/external/swift-charts/SKILL.md +208 -394
- package/pipeline/skills/shared/external/swift-charts/evals/evals.json +27 -30
- package/pipeline/skills/shared/external/swift-charts/references/charts-patterns.md +351 -762
- package/pipeline/skills/shared/external/swift-codable/SKILL.md +339 -343
- package/pipeline/skills/shared/external/swift-codable/evals/evals.json +20 -20
- package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +303 -351
- package/pipeline/skills/shared/external/swift-concurrency/evals/evals.json +27 -24
- package/pipeline/skills/shared/external/swift-concurrency/references/approachable-concurrency.md +65 -80
- package/pipeline/skills/shared/external/swift-concurrency/references/async-algorithms.md +48 -84
- package/pipeline/skills/shared/external/swift-concurrency/references/bridging-interop.md +134 -79
- package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +145 -167
- package/pipeline/skills/shared/external/swift-concurrency/references/diagnostics.md +62 -50
- package/pipeline/skills/shared/external/swift-concurrency/references/swiftui-concurrency.md +92 -121
- package/pipeline/skills/shared/external/swift-concurrency/references/synchronization-primitives.md +177 -241
- package/pipeline/skills/shared/external/swift-formatstyle/SKILL.md +258 -234
- package/pipeline/skills/shared/external/swift-language/SKILL.md +342 -382
- package/pipeline/skills/shared/external/swift-language/evals/evals.json +24 -27
- package/pipeline/skills/shared/external/swift-language/references/swift-attributes-interop.md +79 -56
- package/pipeline/skills/shared/external/swift-language/references/swift-patterns-extended.md +297 -340
- package/pipeline/skills/shared/external/swift-security/SKILL.md +180 -161
- package/pipeline/skills/shared/external/swift-security/evals/evals.json +25 -25
- package/pipeline/skills/shared/external/swift-security/references/biometric-authentication.md +314 -469
- package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +408 -476
- package/pipeline/skills/shared/external/swift-security/references/common-anti-patterns.md +260 -530
- package/pipeline/skills/shared/external/swift-security/references/compliance-owasp-mapping.md +270 -477
- package/pipeline/skills/shared/external/swift-security/references/credential-storage-patterns.md +573 -571
- package/pipeline/skills/shared/external/swift-security/references/cryptokit-public-key.md +370 -441
- package/pipeline/skills/shared/external/swift-security/references/cryptokit-symmetric.md +332 -433
- package/pipeline/skills/shared/external/swift-security/references/keychain-access-control.md +346 -468
- package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +352 -472
- package/pipeline/skills/shared/external/swift-security/references/keychain-item-classes.md +431 -432
- package/pipeline/skills/shared/external/swift-security/references/keychain-sharing.md +328 -425
- package/pipeline/skills/shared/external/swift-security/references/migration-legacy-stores.md +341 -579
- package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +396 -457
- package/pipeline/skills/shared/external/swift-security/references/testing-security-code.md +354 -614
- package/pipeline/skills/shared/external/swift-testing/SKILL.md +188 -175
- package/pipeline/skills/shared/external/swift-testing/evals/evals.json +26 -24
- package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +80 -84
- package/pipeline/skills/shared/external/swift-testing/references/testing-patterns.md +317 -433
- package/pipeline/skills/shared/external/swiftdata/SKILL.md +392 -256
- package/pipeline/skills/shared/external/swiftdata/evals/evals.json +24 -24
- package/pipeline/skills/shared/external/swiftdata/references/core-data-coexistence.md +206 -402
- package/pipeline/skills/shared/external/swiftdata/references/indexing.md +59 -52
- package/pipeline/skills/shared/external/swiftdata/references/predicate-pitfalls.md +57 -33
- package/pipeline/skills/shared/external/swiftdata/references/swiftdata-advanced.md +354 -747
- package/pipeline/skills/shared/external/swiftdata/references/swiftdata-queries.md +300 -508
- package/pipeline/skills/shared/external/swiftlint/SKILL.md +175 -226
- package/pipeline/skills/shared/external/swiftlint/references/adoption-and-configuration.md +141 -208
- package/pipeline/skills/shared/external/swiftlint/references/custom-rules-and-analyze.md +100 -109
- package/pipeline/skills/shared/external/swiftlint/references/plugins-run-scripts-and-integrations.md +159 -179
- package/pipeline/skills/shared/external/swiftlint/references/rule-reference.md +383 -18
- package/pipeline/skills/shared/external/swiftlint/references/rules-suppressions-and-baselines.md +143 -229
- package/pipeline/skills/shared/external/swiftui-animation/SKILL.md +283 -366
- package/pipeline/skills/shared/external/swiftui-animation/references/animation-advanced.md +396 -608
- package/pipeline/skills/shared/external/swiftui-animation/references/core-animation-bridge.md +336 -385
- package/pipeline/skills/shared/external/swiftui-gestures/SKILL.md +239 -349
- package/pipeline/skills/shared/external/swiftui-gestures/references/gesture-patterns.md +228 -310
- package/pipeline/skills/shared/external/swiftui-layout-components/SKILL.md +260 -249
- package/pipeline/skills/shared/external/swiftui-layout-components/references/form.md +92 -74
- package/pipeline/skills/shared/external/swiftui-layout-components/references/grids.md +112 -177
- package/pipeline/skills/shared/external/swiftui-layout-components/references/list.md +61 -64
- package/pipeline/skills/shared/external/swiftui-layout-components/references/scrollview.md +94 -134
- package/pipeline/skills/shared/external/swiftui-liquid-glass/SKILL.md +193 -225
- package/pipeline/skills/shared/external/swiftui-liquid-glass/references/liquid-glass.md +173 -327
- package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +193 -168
- package/pipeline/skills/shared/external/swiftui-navigation/references/deeplinks.md +127 -150
- package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +132 -133
- package/pipeline/skills/shared/external/swiftui-navigation/references/sheets.md +152 -117
- package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +106 -140
- package/pipeline/skills/shared/external/swiftui-patterns/SKILL.md +316 -252
- package/pipeline/skills/shared/external/swiftui-patterns/references/architecture-patterns.md +341 -332
- package/pipeline/skills/shared/external/swiftui-patterns/references/deprecated-migration.md +547 -854
- package/pipeline/skills/shared/external/swiftui-patterns/references/design-polish.md +485 -537
- package/pipeline/skills/shared/external/swiftui-patterns/references/platform-and-sharing.md +417 -499
- package/pipeline/skills/shared/external/swiftui-performance/SKILL.md +213 -376
- package/pipeline/skills/shared/external/swiftui-performance/references/demystify-swiftui-performance-wwdc23.md +86 -175
- package/pipeline/skills/shared/external/swiftui-performance/references/optimizing-swiftui-performance-instruments.md +89 -195
- package/pipeline/skills/shared/external/swiftui-performance/references/understanding-hangs-in-your-app.md +95 -182
- package/pipeline/skills/shared/external/swiftui-performance/references/understanding-improving-swiftui-performance.md +71 -149
- package/pipeline/skills/shared/external/swiftui-performance/references/wwdc-session-sources.md +21 -27
- package/pipeline/skills/shared/external/swiftui-uikit-interop/SKILL.md +303 -295
- package/pipeline/skills/shared/external/swiftui-uikit-interop/references/hosting-migration.md +204 -387
- package/pipeline/skills/shared/external/swiftui-uikit-interop/references/representable-recipes.md +469 -683
- package/pipeline/skills/shared/external/swiftui-webkit/SKILL.md +140 -186
- package/pipeline/skills/shared/external/swiftui-webkit/references/loading-and-observation.md +75 -86
- package/pipeline/skills/shared/external/swiftui-webkit/references/local-content-and-custom-schemes.md +63 -60
- package/pipeline/skills/shared/external/swiftui-webkit/references/migration-and-fallbacks.md +69 -137
- package/pipeline/skills/shared/external/swiftui-webkit/references/navigation-and-javascript.md +95 -67
- package/pipeline/skills/shared/external/tailwind-css/SKILL.md +1 -0
- package/pipeline/skills/shared/external/testing-backend/SKILL.md +1 -0
- package/pipeline/skills/shared/external/tipkit/SKILL.md +220 -335
- package/pipeline/skills/shared/external/tipkit/references/tipkit-patterns.md +356 -494
- package/pipeline/skills/shared/external/typescript-patterns/SKILL.md +1 -0
- package/pipeline/skills/shared/external/vision-framework/SKILL.md +260 -375
- package/pipeline/skills/shared/external/vision-framework/references/vision-requests.md +393 -515
- package/pipeline/skills/shared/external/vision-framework/references/visionkit-scanner.md +363 -539
- package/pipeline/skills/shared/external/vue-composition/SKILL.md +1 -0
- package/pipeline/skills/shared/external/weatherkit/SKILL.md +152 -310
- package/pipeline/skills/shared/external/weatherkit/references/weatherkit-patterns.md +288 -407
- package/pipeline/skills/shared/external/web-accessibility/SKILL.md +1 -0
- package/pipeline/skills/shared/external/web-performance/SKILL.md +1 -0
- package/pipeline/skills/shared/external/web-testing/SKILL.md +1 -0
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +216 -288
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +414 -719
- package/pipeline/skills/shared/external/NOTICE-swift-ios-skills.md +0 -39
|
@@ -1,207 +1,163 @@
|
|
|
1
|
-
# URLSession Patterns
|
|
1
|
+
# URLSession Patterns
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
- [
|
|
10
|
-
- [
|
|
11
|
-
- [
|
|
12
|
-
- [
|
|
13
|
-
- [
|
|
14
|
-
- [
|
|
15
|
-
- [
|
|
16
|
-
- [
|
|
17
|
-
- [
|
|
18
|
-
- [
|
|
19
|
-
- [
|
|
20
|
-
- [
|
|
21
|
-
- [
|
|
22
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
) async throws
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
52
|
-
let
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
124
|
-
|
|
125
|
-
endpoint
|
|
126
|
-
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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,
|
|
168
|
+
enum NetworkError: Error, LocalizedError {
|
|
169
|
+
case invalidURL
|
|
213
170
|
case invalidResponse
|
|
214
|
-
case
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
case .
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
235
|
-
switch
|
|
236
|
-
case .
|
|
237
|
-
|
|
238
|
-
case .
|
|
239
|
-
|
|
240
|
-
case .
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
265
|
-
|
|
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
|
-
##
|
|
220
|
+
## Building requests step by step
|
|
276
221
|
|
|
277
|
-
For
|
|
278
|
-
|
|
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
|
|
283
|
-
private var
|
|
284
|
-
private var
|
|
285
|
-
private var
|
|
286
|
-
private var
|
|
287
|
-
private var
|
|
288
|
-
private var
|
|
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
|
-
|
|
293
|
-
self.path = path
|
|
236
|
+
target = baseURL.appending(path: path)
|
|
294
237
|
}
|
|
295
238
|
|
|
296
|
-
func
|
|
297
|
-
var
|
|
298
|
-
|
|
299
|
-
return
|
|
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
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
)
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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-
|
|
358
|
-
.jsonBody(
|
|
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
|
-
##
|
|
285
|
+
## Uploading multipart form data
|
|
366
286
|
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
372
|
-
private let
|
|
373
|
-
private var parts: [Part] = []
|
|
291
|
+
struct MultipartForm: Sendable {
|
|
292
|
+
private struct Section: Sendable { let lines: [String]; let payload: Data }
|
|
374
293
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
294
|
+
let separator = "Boundary-\(UUID().uuidString)"
|
|
295
|
+
private var sections: [Section] = []
|
|
378
296
|
|
|
379
|
-
var
|
|
380
|
-
"multipart/form-data; boundary=\(boundary)"
|
|
381
|
-
}
|
|
297
|
+
var headerValue: String { "multipart/form-data; boundary=\(separator)" }
|
|
382
298
|
|
|
383
|
-
mutating func
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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
|
|
406
|
-
var
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
415
|
-
return
|
|
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(
|
|
426
|
-
append(Data(string.utf8))
|
|
427
|
-
}
|
|
324
|
+
mutating func append(line: String) { append(Data((line + "\r\n").utf8)) }
|
|
428
325
|
}
|
|
429
326
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
form.
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
##
|
|
340
|
+
## Downloads that report progress
|
|
454
341
|
|
|
455
|
-
|
|
456
|
-
`expectedContentLength`
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
|
487
|
-
|
|
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
|
-
###
|
|
365
|
+
### Delegate-Based File Download with Progress
|
|
366
|
+
|
|
367
|
+
A delegate object can turn callbacks into an `AsyncStream`:
|
|
490
368
|
|
|
491
369
|
```swift
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
503
|
-
|
|
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<
|
|
379
|
+
private init(continuation: AsyncStream<TransferEvent>.Continuation) {
|
|
515
380
|
self.continuation = continuation
|
|
516
381
|
}
|
|
517
382
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
528
|
-
|
|
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
|
-
|
|
537
|
-
|
|
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
|
-
|
|
544
|
-
continuation.yield(.progress(progress))
|
|
409
|
+
continuation.yield(.progress(Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)))
|
|
545
410
|
}
|
|
546
411
|
|
|
547
|
-
nonisolated func urlSession(
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
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
|
-
|
|
565
|
-
of results until the server indicates no more data.
|
|
425
|
+
## Paging by cursor
|
|
566
426
|
|
|
567
427
|
```swift
|
|
568
|
-
struct
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
597
|
-
|
|
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 -> [
|
|
601
|
-
guard !
|
|
446
|
+
mutating func next() async throws -> [Item]? {
|
|
447
|
+
guard !finished else { return nil }
|
|
602
448
|
try Task.checkCancellation()
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
|
638
|
-
typealias Element = [
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
500
|
+
## Stubbing the network with URLProtocol
|
|
686
501
|
|
|
687
|
-
`URLProtocol`
|
|
688
|
-
|
|
689
|
-
|
|
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
|
|
693
|
-
nonisolated(unsafe) static var
|
|
507
|
+
final class StubURLProtocol: URLProtocol {
|
|
508
|
+
nonisolated(unsafe) static var respond: ((URLRequest) throws -> (HTTPURLResponse, Data))?
|
|
694
509
|
|
|
695
|
-
override class func canInit(with
|
|
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
|
|
701
|
-
|
|
514
|
+
guard let respond = Self.respond else {
|
|
515
|
+
client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
|
|
516
|
+
return
|
|
702
517
|
}
|
|
703
|
-
|
|
704
518
|
do {
|
|
705
|
-
let (
|
|
706
|
-
client?.urlProtocol(self, didReceive:
|
|
707
|
-
client?.urlProtocol(self, didLoad:
|
|
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
|
|
724
|
-
|
|
725
|
-
|
|
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 = [
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
738
|
-
let
|
|
739
|
-
|
|
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
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
568
|
+
try await client.perform(Endpoint(path: "projects/99"))
|
|
770
569
|
}
|
|
771
570
|
}
|
|
772
571
|
|
|
773
|
-
@Test func
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
##
|
|
589
|
+
## Retries that back off exponentially
|
|
798
590
|
|
|
799
|
-
|
|
800
|
-
|
|
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
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
819
|
-
)
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
844
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
644
|
+
## Middleware that logs requests
|
|
869
645
|
|
|
870
|
-
Log
|
|
871
|
-
|
|
646
|
+
Log traffic while developing, and compile it out or turn it down for
|
|
647
|
+
release.
|
|
872
648
|
|
|
873
649
|
```swift
|
|
874
|
-
|
|
875
|
-
let logger: Logger
|
|
650
|
+
import OSLog
|
|
876
651
|
|
|
877
|
-
|
|
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
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
|
|
674
|
+
Middleware sees only the outgoing request. To log outcomes and timing, wrap
|
|
675
|
+
the transport call:
|
|
899
676
|
|
|
900
677
|
```swift
|
|
901
|
-
func
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
|
909
|
-
let
|
|
910
|
-
|
|
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
|
-
|
|
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
|
-
|
|
928
|
-
let
|
|
929
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
|
960
|
-
|
|
961
|
-
url:
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
-
##
|
|
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
|
|
995
|
-
var
|
|
737
|
+
struct StreamEvent: Sendable {
|
|
738
|
+
var name: String?
|
|
996
739
|
var data: String
|
|
997
740
|
var id: String?
|
|
998
741
|
}
|
|
999
742
|
|
|
1000
|
-
func
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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
|
|
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
|
-
##
|
|
792
|
+
## A production-ready session setup
|
|
1048
793
|
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
|
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
|
-
|
|
1070
|
-
|
|
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
|
```
|