@devo-bmad-custom/agent-orchestration 1.0.2 → 1.0.4
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/lib/installer.js +33 -0
- package/package.json +1 -1
- package/src/.agents/skills/audit-website/README.md +20 -20
- package/src/.agents/skills/audit-website/SKILL.md +470 -470
- package/src/.agents/skills/audit-website/agents/openai.yaml +6 -6
- package/src/.agents/skills/audit-website/assets/icon-small.svg +41 -41
- package/src/.agents/skills/audit-website/references/OUTPUT-FORMAT.md +250 -250
- package/src/.agents/skills/clean-code-standards/SKILL.md +104 -104
- package/src/.agents/skills/excalidraw-dark-standard/SKILL.md +281 -281
- package/src/.agents/skills/frontend-responsive-design-standards/SKILL.md +434 -434
- package/src/.agents/skills/java-fundamentals/SKILL.md +116 -116
- package/src/.agents/skills/java-performance/SKILL.md +119 -119
- package/src/.agents/skills/next-best-practices/SKILL.md +153 -153
- package/src/.agents/skills/next-best-practices/async-patterns.md +87 -87
- package/src/.agents/skills/next-best-practices/bundling.md +180 -180
- package/src/.agents/skills/next-best-practices/data-patterns.md +297 -297
- package/src/.agents/skills/next-best-practices/debug-tricks.md +105 -105
- package/src/.agents/skills/next-best-practices/directives.md +73 -73
- package/src/.agents/skills/next-best-practices/error-handling.md +227 -227
- package/src/.agents/skills/next-best-practices/file-conventions.md +140 -140
- package/src/.agents/skills/next-best-practices/font.md +245 -245
- package/src/.agents/skills/next-best-practices/functions.md +108 -108
- package/src/.agents/skills/next-best-practices/hydration-error.md +91 -91
- package/src/.agents/skills/next-best-practices/image.md +173 -173
- package/src/.agents/skills/next-best-practices/metadata.md +301 -301
- package/src/.agents/skills/next-best-practices/parallel-routes.md +287 -287
- package/src/.agents/skills/next-best-practices/route-handlers.md +146 -146
- package/src/.agents/skills/next-best-practices/rsc-boundaries.md +159 -159
- package/src/.agents/skills/next-best-practices/runtime-selection.md +39 -39
- package/src/.agents/skills/next-best-practices/scripts.md +141 -141
- package/src/.agents/skills/next-best-practices/self-hosting.md +371 -371
- package/src/.agents/skills/next-best-practices/suspense-boundaries.md +67 -67
- package/src/.agents/skills/nextjs-app-router-patterns/SKILL.md +537 -537
- package/src/.agents/skills/postgresql-optimization/SKILL.md +404 -404
- package/src/.agents/skills/python-backend/SKILL.md +153 -153
- package/src/.agents/skills/python-fundamentals/SKILL.md +234 -234
- package/src/.agents/skills/python-performance/SKILL.md +404 -404
- package/src/.agents/skills/react-expert/SKILL.md +335 -335
- package/src/.agents/skills/redis-best-practices/SKILL.md +438 -438
- package/src/.agents/skills/security-best-practices/SKILL.md +288 -288
- package/src/.agents/skills/security-review/LICENSE +22 -22
- package/src/.agents/skills/security-review/SKILL.md +312 -312
- package/src/.agents/skills/security-review/infrastructure/docker.md +432 -432
- package/src/.agents/skills/security-review/languages/javascript.md +388 -388
- package/src/.agents/skills/security-review/languages/python.md +363 -363
- package/src/.agents/skills/security-review/references/api-security.md +519 -519
- package/src/.agents/skills/security-review/references/authentication.md +353 -353
- package/src/.agents/skills/security-review/references/authorization.md +372 -372
- package/src/.agents/skills/security-review/references/business-logic.md +443 -443
- package/src/.agents/skills/security-review/references/cryptography.md +329 -329
- package/src/.agents/skills/security-review/references/csrf.md +398 -398
- package/src/.agents/skills/security-review/references/data-protection.md +378 -378
- package/src/.agents/skills/security-review/references/deserialization.md +410 -410
- package/src/.agents/skills/security-review/references/error-handling.md +436 -436
- package/src/.agents/skills/security-review/references/file-security.md +457 -457
- package/src/.agents/skills/security-review/references/injection.md +259 -259
- package/src/.agents/skills/security-review/references/logging.md +433 -433
- package/src/.agents/skills/security-review/references/misconfiguration.md +435 -435
- package/src/.agents/skills/security-review/references/modern-threats.md +475 -475
- package/src/.agents/skills/security-review/references/ssrf.md +415 -415
- package/src/.agents/skills/security-review/references/supply-chain.md +405 -405
- package/src/.agents/skills/security-review/references/xss.md +336 -336
- package/src/.agents/skills/subagent-driven-development/SKILL.md +275 -275
- package/src/.agents/skills/subagent-driven-development/code-quality-reviewer-prompt.md +26 -26
- package/src/.agents/skills/subagent-driven-development/implementer-prompt.md +113 -113
- package/src/.agents/skills/subagent-driven-development/spec-reviewer-prompt.md +61 -61
- package/src/.agents/skills/systematic-debugging/CREATION-LOG.md +119 -119
- package/src/.agents/skills/systematic-debugging/SKILL.md +296 -296
- package/src/.agents/skills/systematic-debugging/condition-based-waiting-example.ts +158 -158
- package/src/.agents/skills/systematic-debugging/condition-based-waiting.md +115 -115
- package/src/.agents/skills/systematic-debugging/defense-in-depth.md +122 -122
- package/src/.agents/skills/systematic-debugging/root-cause-tracing.md +169 -169
- package/src/.agents/skills/systematic-debugging/test-academic.md +14 -14
- package/src/.agents/skills/systematic-debugging/test-pressure-1.md +58 -58
- package/src/.agents/skills/systematic-debugging/test-pressure-2.md +68 -68
- package/src/.agents/skills/systematic-debugging/test-pressure-3.md +69 -69
- package/src/.agents/skills/typescript-best-practices/SKILL.md +373 -373
- package/src/.agents/skills/ui-ux-pro-custom/SKILL.md +348 -348
- package/src/.agents/skills/ui-ux-pro-custom/data/charts.csv +26 -26
- package/src/.agents/skills/ui-ux-pro-custom/data/colors.csv +97 -97
- package/src/.agents/skills/ui-ux-pro-custom/data/icons.csv +101 -101
- package/src/.agents/skills/ui-ux-pro-custom/data/jetpack-compose-expert-skill/SKILL.md +106 -106
- package/src/.agents/skills/ui-ux-pro-custom/data/jetpack-compose-expert-skill/references/accessibility.md +475 -475
- package/src/.agents/skills/ui-ux-pro-custom/data/jetpack-compose-expert-skill/references/animation.md +466 -466
- package/src/.agents/skills/ui-ux-pro-custom/data/jetpack-compose-expert-skill/references/composition-locals.md +231 -231
- package/src/.agents/skills/ui-ux-pro-custom/data/jetpack-compose-expert-skill/references/deprecated-patterns.md +323 -323
- package/src/.agents/skills/ui-ux-pro-custom/data/jetpack-compose-expert-skill/references/lists-scrolling.md +400 -400
- package/src/.agents/skills/ui-ux-pro-custom/data/jetpack-compose-expert-skill/references/modifiers.md +331 -331
- package/src/.agents/skills/ui-ux-pro-custom/data/jetpack-compose-expert-skill/references/navigation.md +416 -416
- package/src/.agents/skills/ui-ux-pro-custom/data/jetpack-compose-expert-skill/references/performance.md +446 -446
- package/src/.agents/skills/ui-ux-pro-custom/data/jetpack-compose-expert-skill/references/side-effects.md +516 -516
- package/src/.agents/skills/ui-ux-pro-custom/data/jetpack-compose-expert-skill/references/source-code/foundation-source.md +13327 -13327
- package/src/.agents/skills/ui-ux-pro-custom/data/jetpack-compose-expert-skill/references/source-code/material3-source.md +19097 -19097
- package/src/.agents/skills/ui-ux-pro-custom/data/jetpack-compose-expert-skill/references/source-code/navigation-source.md +2947 -2947
- package/src/.agents/skills/ui-ux-pro-custom/data/jetpack-compose-expert-skill/references/source-code/runtime-source.md +11316 -11316
- package/src/.agents/skills/ui-ux-pro-custom/data/jetpack-compose-expert-skill/references/source-code/ui-source.md +7896 -7896
- package/src/.agents/skills/ui-ux-pro-custom/data/jetpack-compose-expert-skill/references/state-management.md +377 -377
- package/src/.agents/skills/ui-ux-pro-custom/data/jetpack-compose-expert-skill/references/styles-experimental.md +470 -470
- package/src/.agents/skills/ui-ux-pro-custom/data/jetpack-compose-expert-skill/references/theming-material3.md +349 -349
- package/src/.agents/skills/ui-ux-pro-custom/data/jetpack-compose-expert-skill/references/view-composition.md +595 -595
- package/src/.agents/skills/ui-ux-pro-custom/data/landing.csv +31 -31
- package/src/.agents/skills/ui-ux-pro-custom/data/mobile-ui-layout.md +654 -654
- package/src/.agents/skills/ui-ux-pro-custom/data/products.csv +96 -96
- package/src/.agents/skills/ui-ux-pro-custom/data/react-performance.csv +45 -45
- package/src/.agents/skills/ui-ux-pro-custom/data/stacks/astro.csv +54 -54
- package/src/.agents/skills/ui-ux-pro-custom/data/stacks/flutter.csv +53 -53
- package/src/.agents/skills/ui-ux-pro-custom/data/stacks/html-tailwind.csv +56 -56
- package/src/.agents/skills/ui-ux-pro-custom/data/stacks/jetpack-compose.csv +53 -53
- package/src/.agents/skills/ui-ux-pro-custom/data/stacks/nextjs.csv +53 -53
- package/src/.agents/skills/ui-ux-pro-custom/data/stacks/nuxt-ui.csv +51 -51
- package/src/.agents/skills/ui-ux-pro-custom/data/stacks/nuxtjs.csv +59 -59
- package/src/.agents/skills/ui-ux-pro-custom/data/stacks/react-native.csv +56 -56
- package/src/.agents/skills/ui-ux-pro-custom/data/stacks/react.csv +54 -54
- package/src/.agents/skills/ui-ux-pro-custom/data/stacks/shadcn.csv +61 -61
- package/src/.agents/skills/ui-ux-pro-custom/data/stacks/svelte.csv +54 -54
- package/src/.agents/skills/ui-ux-pro-custom/data/stacks/swiftui.csv +51 -51
- package/src/.agents/skills/ui-ux-pro-custom/data/stacks/vue.csv +50 -50
- package/src/.agents/skills/ui-ux-pro-custom/data/styles.csv +68 -68
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/alarmkit/SKILL.md +438 -438
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/alarmkit/references/alarmkit-patterns.md +584 -584
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/app-clips/SKILL.md +436 -436
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/app-intents/SKILL.md +489 -489
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/app-intents/references/appintents-advanced.md +1076 -1076
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/app-store-review/SKILL.md +340 -340
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/app-store-review/references/privacy-manifest.md +90 -90
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/app-store-review/references/review-checklists.md +106 -106
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/apple-on-device-ai/SKILL.md +500 -500
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/apple-on-device-ai/references/coreml-conversion.md +425 -425
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/apple-on-device-ai/references/coreml-optimization.md +344 -344
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/apple-on-device-ai/references/foundation-models.md +508 -508
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/apple-on-device-ai/references/mlx-swift.md +285 -285
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/authentication/SKILL.md +496 -496
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/authentication/references/keychain-biometric.md +211 -211
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/background-processing/SKILL.md +499 -499
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/background-processing/references/background-task-patterns.md +390 -390
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/callkit-voip/SKILL.md +461 -461
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/callkit-voip/references/callkit-patterns.md +425 -425
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/cloudkit-sync/SKILL.md +492 -492
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/cloudkit-sync/references/cloudkit-patterns.md +461 -461
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/codable-patterns/SKILL.md +467 -467
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/contacts-framework/SKILL.md +425 -425
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/contacts-framework/references/contacts-patterns.md +409 -409
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/core-bluetooth/SKILL.md +491 -491
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/core-bluetooth/references/ble-patterns.md +435 -435
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/core-motion/SKILL.md +388 -388
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/core-motion/references/motion-patterns.md +405 -405
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/core-nfc/SKILL.md +495 -495
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/core-nfc/references/nfc-patterns.md +420 -420
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/coreml/SKILL.md +459 -459
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/coreml/references/coreml-swift-integration.md +765 -765
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/debugging-instruments/SKILL.md +422 -422
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/debugging-instruments/references/instruments-guide.md +387 -387
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/debugging-instruments/references/lldb-patterns.md +298 -298
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/device-integrity/SKILL.md +477 -477
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/energykit/SKILL.md +460 -460
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/energykit/references/energykit-patterns.md +541 -541
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/eventkit-calendar/SKILL.md +483 -483
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/eventkit-calendar/references/eventkit-patterns.md +326 -326
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/healthkit/SKILL.md +498 -498
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/healthkit/references/healthkit-patterns.md +602 -602
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/homekit-matter/SKILL.md +496 -496
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/homekit-matter/references/matter-commissioning.md +455 -455
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/ios-accessibility/SKILL.md +301 -301
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/ios-accessibility/references/a11y-patterns.md +140 -140
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/ios-localization/SKILL.md +418 -418
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/ios-localization/references/formatstyle-locale.md +627 -627
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/ios-localization/references/string-catalogs.md +462 -462
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/ios-networking/SKILL.md +441 -441
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/ios-networking/references/background-websocket.md +862 -862
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/ios-networking/references/lightweight-clients.md +93 -93
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/ios-networking/references/network-framework.md +563 -563
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/ios-networking/references/urlsession-patterns.md +1116 -1116
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/ios-security/SKILL.md +496 -496
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/ios-security/references/app-review-guidelines.md +174 -174
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/ios-security/references/cryptokit-advanced.md +296 -296
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/ios-security/references/file-storage-patterns.md +354 -354
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/ios-security/references/privacy-manifest.md +117 -117
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/live-activities/SKILL.md +500 -500
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/live-activities/references/live-activity-patterns.md +868 -868
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/mapkit-location/SKILL.md +485 -485
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/mapkit-location/references/corelocation-patterns.md +730 -730
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/mapkit-location/references/mapkit-patterns.md +748 -748
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/metrickit-diagnostics/SKILL.md +479 -479
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/musickit-audio/SKILL.md +395 -395
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/musickit-audio/references/musickit-patterns.md +363 -363
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/natural-language/SKILL.md +412 -412
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/natural-language/references/translation-patterns.md +311 -311
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/passkit-wallet/SKILL.md +398 -398
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/passkit-wallet/references/wallet-passes.md +254 -254
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/pencilkit-drawing/SKILL.md +387 -387
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/pencilkit-drawing/references/paperkit-integration.md +376 -376
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/pencilkit-drawing/references/pencilkit-patterns.md +302 -302
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/permissionkit/SKILL.md +446 -446
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/permissionkit/references/permissionkit-patterns.md +435 -435
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/photos-camera-media/SKILL.md +500 -500
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/photos-camera-media/references/av-playback.md +701 -701
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/photos-camera-media/references/camera-capture.md +774 -774
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/photos-camera-media/references/image-loading-caching.md +869 -869
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/photos-camera-media/references/photospicker-patterns.md +597 -597
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/push-notifications/SKILL.md +500 -500
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/push-notifications/references/notification-patterns.md +677 -677
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/push-notifications/references/rich-notifications.md +745 -745
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/realitykit-ar/SKILL.md +479 -479
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/realitykit-ar/references/realitykit-patterns.md +480 -480
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/shareplay-activities/SKILL.md +483 -483
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/shareplay-activities/references/shareplay-patterns.md +544 -544
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/speech-recognition/SKILL.md +485 -485
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/storekit/SKILL.md +478 -478
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/storekit/references/app-review-guidelines.md +58 -58
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/storekit/references/storekit-advanced.md +755 -755
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swift-charts/SKILL.md +487 -487
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swift-charts/references/charts-patterns.md +895 -895
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swift-concurrency/SKILL.md +408 -408
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swift-concurrency/references/approachable-concurrency.md +80 -80
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swift-concurrency/references/swift-6-2-concurrency.md +233 -233
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swift-concurrency/references/swiftui-concurrency.md +187 -187
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swift-concurrency/references/synchronization-primitives.md +341 -341
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swift-language/SKILL.md +498 -498
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swift-language/references/swift-patterns-extended.md +505 -505
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swift-testing/SKILL.md +467 -467
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swift-testing/references/testing-patterns.md +504 -504
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftdata/SKILL.md +334 -334
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftdata/references/core-data-coexistence.md +504 -504
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftdata/references/swiftdata-advanced.md +975 -975
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftdata/references/swiftdata-queries.md +675 -675
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-animation/SKILL.md +481 -481
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-animation/references/animation-advanced.md +804 -804
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-animation/references/core-animation-bridge.md +553 -553
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-gestures/SKILL.md +450 -450
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-gestures/references/gesture-patterns.md +425 -425
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-layout-components/SKILL.md +336 -336
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-layout-components/references/form.md +97 -97
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-layout-components/references/grids.md +69 -69
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-layout-components/references/list.md +99 -99
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-layout-components/references/scrollview.md +147 -147
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-liquid-glass/SKILL.md +325 -325
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-liquid-glass/references/liquid-glass.md +387 -387
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-navigation/SKILL.md +262 -262
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-navigation/references/deeplinks.md +207 -207
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-navigation/references/navigationstack.md +177 -177
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-navigation/references/sheets.md +169 -169
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-navigation/references/tabview.md +178 -178
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-patterns/SKILL.md +381 -381
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-patterns/references/architecture-patterns.md +486 -486
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-patterns/references/deprecated-migration.md +1097 -1097
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-patterns/references/design-polish.md +780 -780
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-patterns/references/platform-and-sharing.md +696 -696
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-performance/SKILL.md +491 -491
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-performance/references/demystify-swiftui-performance-wwdc23.md +46 -46
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-performance/references/optimizing-swiftui-performance-instruments.md +29 -29
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-performance/references/understanding-hangs-in-your-app.md +33 -33
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-performance/references/understanding-improving-swiftui-performance.md +52 -52
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-uikit-interop/SKILL.md +428 -428
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-uikit-interop/references/hosting-migration.md +534 -534
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/swiftui-uikit-interop/references/representable-recipes.md +1133 -1133
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/tipkit/SKILL.md +494 -494
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/tipkit/references/tipkit-patterns.md +782 -782
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/vision-framework/SKILL.md +475 -475
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/vision-framework/references/vision-requests.md +736 -736
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/vision-framework/references/visionkit-scanner.md +738 -738
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/weatherkit/SKILL.md +410 -410
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/weatherkit/references/weatherkit-patterns.md +567 -567
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/widgetkit/SKILL.md +497 -497
- package/src/.agents/skills/ui-ux-pro-custom/data/swift-ios-skills/widgetkit/references/widgetkit-advanced.md +871 -871
- package/src/.agents/skills/ui-ux-pro-custom/data/typography.csv +57 -57
- package/src/.agents/skills/ui-ux-pro-custom/data/ui-reasoning.csv +101 -101
- package/src/.agents/skills/ui-ux-pro-custom/data/ux-guidelines.csv +99 -99
- package/src/.agents/skills/ui-ux-pro-custom/data/web-interface.csv +31 -31
- package/src/.agents/skills/ui-ux-pro-custom/scripts/core.py +253 -253
- package/src/.agents/skills/ui-ux-pro-custom/scripts/design_system.py +1067 -1067
- package/src/.agents/skills/ui-ux-pro-custom/scripts/search.py +114 -114
- package/src/.agents/skills/ux-audit/SKILL.md +150 -150
- package/src/.agents/skills/websocket-engineer/SKILL.md +168 -168
- package/src/.agents/skills/websocket-engineer/references/alternatives.md +391 -391
- package/src/.agents/skills/websocket-engineer/references/patterns.md +400 -400
- package/src/.agents/skills/websocket-engineer/references/protocol.md +195 -195
- package/src/.agents/skills/websocket-engineer/references/scaling.md +333 -333
- package/src/.agents/skills/websocket-engineer/references/security.md +474 -474
- package/src/.agents/skills/writing-skills/SKILL.md +655 -655
- package/src/.agents/skills/writing-skills/anthropic-best-practices.md +1150 -1150
- package/src/.agents/skills/writing-skills/examples/CLAUDE_MD_TESTING.md +189 -189
- package/src/.agents/skills/writing-skills/graphviz-conventions.dot +171 -171
- package/src/.agents/skills/writing-skills/persuasion-principles.md +187 -187
- package/src/.agents/skills/writing-skills/render-graphs.js +168 -168
- package/src/.agents/skills/writing-skills/testing-skills-with-subagents.md +384 -384
- package/src/.claude/commands/master-orchestrator.md +15 -0
- package/src/_memory/config.yaml +11 -11
- package/src/_memory/master-orchestrator-sidecar/instructions.md +97 -47
- package/src/_memory/skills/nimbalyst-tracking/SKILL.md +103 -103
- package/src/_memory/skills/writing-skills/SKILL.md +655 -655
- package/src/bmb/agents/agent-builder.md +59 -59
- package/src/bmb/agents/module-builder.md +60 -60
- package/src/bmb/agents/workflow-builder.md +61 -61
- package/src/bmb/config.yaml +12 -12
- package/src/bmb/module-help.csv +13 -13
- package/src/bmb/workflows/agent/data/agent-architecture.md +258 -258
- package/src/bmb/workflows/agent/data/agent-compilation.md +185 -185
- package/src/bmb/workflows/agent/data/agent-menu-patterns.md +189 -189
- package/src/bmb/workflows/agent/data/agent-metadata.md +133 -133
- package/src/bmb/workflows/agent/data/agent-validation.md +111 -111
- package/src/bmb/workflows/agent/data/brainstorm-context.md +96 -96
- package/src/bmb/workflows/agent/data/communication-presets.csv +61 -61
- package/src/bmb/workflows/agent/data/critical-actions.md +75 -75
- package/src/bmb/workflows/agent/data/persona-properties.md +252 -252
- package/src/bmb/workflows/agent/data/principles-crafting.md +142 -142
- package/src/bmb/workflows/agent/data/reference/module-examples/architect.md +68 -68
- package/src/bmb/workflows/agent/data/reference/with-sidecar/journal-keeper/journal-keeper-sidecar/entries/yy-mm-dd-entry-template.md +16 -16
- package/src/bmb/workflows/agent/data/understanding-agent-types.md +126 -126
- package/src/bmb/workflows/agent/steps-c/step-01-brainstorm.md +129 -129
- package/src/bmb/workflows/agent/steps-c/step-02-discovery.md +170 -170
- package/src/bmb/workflows/agent/steps-c/step-03-sidecar-metadata.md +309 -309
- package/src/bmb/workflows/agent/steps-c/step-04-persona.md +213 -213
- package/src/bmb/workflows/agent/steps-c/step-05-commands-menu.md +179 -179
- package/src/bmb/workflows/agent/steps-c/step-06-activation.md +278 -278
- package/src/bmb/workflows/agent/steps-c/step-07-build-agent.md +316 -316
- package/src/bmb/workflows/agent/steps-c/step-08-celebrate.md +247 -247
- package/src/bmb/workflows/agent/steps-e/e-01-load-existing.md +221 -221
- package/src/bmb/workflows/agent/steps-e/e-02-discover-edits.md +195 -195
- package/src/bmb/workflows/agent/steps-e/e-04-sidecar-metadata.md +126 -126
- package/src/bmb/workflows/agent/steps-e/e-05-persona.md +135 -135
- package/src/bmb/workflows/agent/steps-e/e-06-commands-menu.md +123 -123
- package/src/bmb/workflows/agent/steps-e/e-07-activation.md +124 -124
- package/src/bmb/workflows/agent/steps-e/e-08-edit-agent.md +197 -197
- package/src/bmb/workflows/agent/steps-e/e-09-celebrate.md +155 -155
- package/src/bmb/workflows/agent/steps-v/v-01-load-review.md +137 -137
- package/src/bmb/workflows/agent/steps-v/v-02a-validate-metadata.md +116 -116
- package/src/bmb/workflows/agent/steps-v/v-02b-validate-persona.md +124 -124
- package/src/bmb/workflows/agent/steps-v/v-02c-validate-menu.md +127 -127
- package/src/bmb/workflows/agent/steps-v/v-02d-validate-structure.md +134 -134
- package/src/bmb/workflows/agent/steps-v/v-02e-validate-sidecar.md +134 -134
- package/src/bmb/workflows/agent/steps-v/v-03-summary.md +104 -104
- package/src/bmb/workflows/agent/templates/agent-plan.template.md +5 -5
- package/src/bmb/workflows/agent/templates/agent-template.md +89 -89
- package/src/bmb/workflows/agent/workflow-create-agent.md +72 -72
- package/src/bmb/workflows/agent/workflow-edit-agent.md +75 -75
- package/src/bmb/workflows/agent/workflow-validate-agent.md +73 -73
- package/src/bmb/workflows/module/data/agent-architecture.md +179 -179
- package/src/bmb/workflows/module/data/agent-spec-template.md +79 -79
- package/src/bmb/workflows/module/data/module-standards.md +263 -263
- package/src/bmb/workflows/module/data/module-yaml-conventions.md +392 -392
- package/src/bmb/workflows/module/module-help-generate.md +254 -254
- package/src/bmb/workflows/module/steps-b/step-01-welcome.md +148 -148
- package/src/bmb/workflows/module/steps-b/step-02-spark.md +141 -141
- package/src/bmb/workflows/module/steps-b/step-03-module-type.md +149 -149
- package/src/bmb/workflows/module/steps-b/step-04-vision.md +83 -83
- package/src/bmb/workflows/module/steps-b/step-05-identity.md +97 -97
- package/src/bmb/workflows/module/steps-b/step-06-users.md +86 -86
- package/src/bmb/workflows/module/steps-b/step-07-value.md +76 -76
- package/src/bmb/workflows/module/steps-b/step-08-agents.md +97 -97
- package/src/bmb/workflows/module/steps-b/step-09-workflows.md +83 -83
- package/src/bmb/workflows/module/steps-b/step-10-tools.md +91 -91
- package/src/bmb/workflows/module/steps-b/step-11-scenarios.md +84 -84
- package/src/bmb/workflows/module/steps-b/step-12-creative.md +95 -95
- package/src/bmb/workflows/module/steps-b/step-13-review.md +105 -105
- package/src/bmb/workflows/module/steps-b/step-14-finalize.md +117 -117
- package/src/bmb/workflows/module/steps-c/step-01-load-brief.md +179 -179
- package/src/bmb/workflows/module/steps-c/step-01b-continue.md +82 -82
- package/src/bmb/workflows/module/steps-c/step-02-structure.md +105 -105
- package/src/bmb/workflows/module/steps-c/step-03-config.md +119 -119
- package/src/bmb/workflows/module/steps-c/step-04-agents.md +168 -168
- package/src/bmb/workflows/module/steps-c/step-05-workflows.md +184 -184
- package/src/bmb/workflows/module/steps-c/step-06-docs.md +401 -401
- package/src/bmb/workflows/module/steps-c/step-07-complete.md +152 -152
- package/src/bmb/workflows/module/steps-e/step-01-load-target.md +81 -81
- package/src/bmb/workflows/module/steps-e/step-02-select-edit.md +77 -77
- package/src/bmb/workflows/module/steps-e/step-03-apply-edit.md +77 -77
- package/src/bmb/workflows/module/steps-e/step-04-review.md +80 -80
- package/src/bmb/workflows/module/steps-e/step-05-confirm.md +75 -75
- package/src/bmb/workflows/module/steps-v/step-01-load-target.md +96 -96
- package/src/bmb/workflows/module/steps-v/step-02-file-structure.md +93 -93
- package/src/bmb/workflows/module/steps-v/step-03-module-yaml.md +99 -99
- package/src/bmb/workflows/module/steps-v/step-04-agent-specs.md +152 -152
- package/src/bmb/workflows/module/steps-v/step-05-workflow-specs.md +152 -152
- package/src/bmb/workflows/module/steps-v/step-06-documentation.md +143 -143
- package/src/bmb/workflows/module/steps-v/step-07-installation.md +102 -102
- package/src/bmb/workflows/module/steps-v/step-08-report.md +197 -197
- package/src/bmb/workflows/module/templates/brief-template.md +154 -154
- package/src/bmb/workflows/module/templates/workflow-spec-template.md +96 -96
- package/src/bmb/workflows/module/workflow-create-module-brief.md +71 -71
- package/src/bmb/workflows/module/workflow-create-module.md +86 -86
- package/src/bmb/workflows/module/workflow-edit-module.md +66 -66
- package/src/bmb/workflows/module/workflow-validate-module.md +66 -66
- package/src/bmb/workflows/workflow/data/architecture.md +150 -150
- package/src/bmb/workflows/workflow/data/common-workflow-tools.csv +19 -19
- package/src/bmb/workflows/workflow/data/csv-data-file-standards.md +53 -53
- package/src/bmb/workflows/workflow/data/frontmatter-standards.md +184 -184
- package/src/bmb/workflows/workflow/data/input-discovery-standards.md +191 -191
- package/src/bmb/workflows/workflow/data/intent-vs-prescriptive-spectrum.md +44 -44
- package/src/bmb/workflows/workflow/data/menu-handling-standards.md +133 -133
- package/src/bmb/workflows/workflow/data/output-format-standards.md +135 -135
- package/src/bmb/workflows/workflow/data/step-file-rules.md +235 -235
- package/src/bmb/workflows/workflow/data/step-type-patterns.md +257 -257
- package/src/bmb/workflows/workflow/data/subprocess-optimization-patterns.md +188 -188
- package/src/bmb/workflows/workflow/data/trimodal-workflow-structure.md +164 -164
- package/src/bmb/workflows/workflow/data/workflow-chaining-standards.md +222 -222
- package/src/bmb/workflows/workflow/data/workflow-examples.md +232 -232
- package/src/bmb/workflows/workflow/data/workflow-type-criteria.md +134 -134
- package/src/bmb/workflows/workflow/steps-c/step-00-conversion.md +263 -263
- package/src/bmb/workflows/workflow/steps-c/step-01-discovery.md +194 -194
- package/src/bmb/workflows/workflow/steps-c/step-01b-continuation.md +3 -3
- package/src/bmb/workflows/workflow/steps-c/step-02-classification.md +270 -270
- package/src/bmb/workflows/workflow/steps-c/step-03-requirements.md +283 -283
- package/src/bmb/workflows/workflow/steps-c/step-04-tools.md +282 -282
- package/src/bmb/workflows/workflow/steps-c/step-05-plan-review.md +243 -243
- package/src/bmb/workflows/workflow/steps-c/step-06-design.md +330 -330
- package/src/bmb/workflows/workflow/steps-c/step-07-foundation.md +239 -239
- package/src/bmb/workflows/workflow/steps-c/step-08-build-step-01.md +379 -379
- package/src/bmb/workflows/workflow/steps-c/step-09-build-next-step.md +350 -350
- package/src/bmb/workflows/workflow/steps-c/step-10-confirmation.md +322 -322
- package/src/bmb/workflows/workflow/steps-c/step-11-completion.md +191 -191
- package/src/bmb/workflows/workflow/steps-e/step-e-01-assess-workflow.md +237 -237
- package/src/bmb/workflows/workflow/steps-e/step-e-02-discover-edits.md +251 -251
- package/src/bmb/workflows/workflow/steps-e/step-e-03-fix-validation.md +254 -254
- package/src/bmb/workflows/workflow/steps-e/step-e-04-direct-edit.md +277 -277
- package/src/bmb/workflows/workflow/steps-e/step-e-05-apply-edit.md +154 -154
- package/src/bmb/workflows/workflow/steps-e/step-e-06-validate-after.md +190 -190
- package/src/bmb/workflows/workflow/steps-e/step-e-07-complete.md +206 -206
- package/src/bmb/workflows/workflow/steps-v/step-01-validate-max-mode.md +109 -109
- package/src/bmb/workflows/workflow/steps-v/step-01-validate.md +221 -221
- package/src/bmb/workflows/workflow/steps-v/step-01b-structure.md +152 -152
- package/src/bmb/workflows/workflow/steps-v/step-02-frontmatter-validation.md +199 -199
- package/src/bmb/workflows/workflow/steps-v/step-02b-path-violations.md +265 -265
- package/src/bmb/workflows/workflow/steps-v/step-03-menu-validation.md +164 -164
- package/src/bmb/workflows/workflow/steps-v/step-04-step-type-validation.md +211 -211
- package/src/bmb/workflows/workflow/steps-v/step-05-output-format-validation.md +200 -200
- package/src/bmb/workflows/workflow/steps-v/step-06-validation-design-check.md +195 -195
- package/src/bmb/workflows/workflow/steps-v/step-07-instruction-style-check.md +209 -209
- package/src/bmb/workflows/workflow/steps-v/step-08-collaborative-experience-check.md +199 -199
- package/src/bmb/workflows/workflow/steps-v/step-08b-subprocess-optimization.md +179 -179
- package/src/bmb/workflows/workflow/steps-v/step-09-cohesive-review.md +186 -186
- package/src/bmb/workflows/workflow/steps-v/step-10-report-complete.md +154 -154
- package/src/bmb/workflows/workflow/steps-v/step-11-plan-validation.md +237 -237
- package/src/bmb/workflows/workflow/templates/minimal-output-template.md +11 -11
- package/src/bmb/workflows/workflow/templates/step-01-init-continuable-template.md +241 -241
- package/src/bmb/workflows/workflow/templates/step-1b-template.md +224 -224
- package/src/bmb/workflows/workflow/templates/step-template.md +294 -294
- package/src/bmb/workflows/workflow/templates/workflow-template.md +102 -102
- package/src/bmb/workflows/workflow/workflow-create-workflow.md +79 -79
- package/src/bmb/workflows/workflow/workflow-edit-workflow.md +65 -65
- package/src/bmb/workflows/workflow/workflow-rework-workflow.md +65 -65
- package/src/bmb/workflows/workflow/workflow-validate-max-parallel-workflow.md +66 -66
- package/src/bmb/workflows/workflow/workflow-validate-workflow.md +65 -65
- package/src/bmm/agents/analyst.md +104 -104
- package/src/bmm/agents/dev.md +100 -100
- package/src/bmm/agents/qa.md +100 -90
- package/src/bmm/agents/tech-writer/tech-writer.md +94 -94
- package/src/bmm/module-help.csv +31 -31
- package/src/bmm/workflows/1-analysis/create-product-brief/steps/step-01-init.md +115 -115
- package/src/bmm/workflows/1-analysis/create-product-brief/steps/step-01b-continue.md +107 -107
- package/src/bmm/workflows/1-analysis/create-product-brief/steps/step-02-vision.md +141 -141
- package/src/bmm/workflows/1-analysis/create-product-brief/steps/step-03-users.md +144 -144
- package/src/bmm/workflows/1-analysis/create-product-brief/steps/step-04-metrics.md +147 -147
- package/src/bmm/workflows/1-analysis/create-product-brief/steps/step-05-scope.md +161 -161
- package/src/bmm/workflows/1-analysis/create-product-brief/steps/step-06-complete.md +99 -99
- package/src/bmm/workflows/1-analysis/create-product-brief/workflow.md +57 -57
- package/src/bmm/workflows/1-analysis/research/domain-steps/step-01-init.md +87 -87
- package/src/bmm/workflows/1-analysis/research/domain-steps/step-02-domain-analysis.md +156 -156
- package/src/bmm/workflows/1-analysis/research/domain-steps/step-03-competitive-landscape.md +165 -165
- package/src/bmm/workflows/1-analysis/research/domain-steps/step-04-regulatory-focus.md +140 -140
- package/src/bmm/workflows/1-analysis/research/domain-steps/step-05-technical-trends.md +152 -152
- package/src/bmm/workflows/1-analysis/research/domain-steps/step-06-research-synthesis.md +345 -345
- package/src/bmm/workflows/1-analysis/research/market-steps/step-01-init.md +92 -92
- package/src/bmm/workflows/1-analysis/research/market-steps/step-02-customer-behavior.md +164 -164
- package/src/bmm/workflows/1-analysis/research/market-steps/step-03-customer-pain-points.md +174 -174
- package/src/bmm/workflows/1-analysis/research/market-steps/step-04-customer-decisions.md +184 -184
- package/src/bmm/workflows/1-analysis/research/market-steps/step-05-competitive-analysis.md +105 -105
- package/src/bmm/workflows/1-analysis/research/market-steps/step-06-research-completion.md +360 -360
- package/src/bmm/workflows/1-analysis/research/technical-steps/step-01-init.md +87 -87
- package/src/bmm/workflows/1-analysis/research/technical-steps/step-02-technical-overview.md +165 -165
- package/src/bmm/workflows/1-analysis/research/technical-steps/step-03-integration-patterns.md +174 -174
- package/src/bmm/workflows/1-analysis/research/technical-steps/step-04-architectural-patterns.md +141 -141
- package/src/bmm/workflows/1-analysis/research/technical-steps/step-05-implementation-research.md +159 -159
- package/src/bmm/workflows/1-analysis/research/technical-steps/step-06-research-synthesis.md +387 -387
- package/src/bmm/workflows/1-analysis/research/workflow-domain-research.md +54 -54
- package/src/bmm/workflows/1-analysis/research/workflow-market-research.md +54 -54
- package/src/bmm/workflows/1-analysis/research/workflow-technical-research.md +54 -54
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-c/step-01b-continue.md +100 -100
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-c/step-02-discovery.md +160 -160
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-c/step-02b-vision.md +88 -88
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-c/step-02c-executive-summary.md +99 -99
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-c/step-03-success.md +169 -169
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-c/step-04-journeys.md +156 -156
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-c/step-05-domain.md +136 -136
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-c/step-06-innovation.md +176 -176
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-c/step-07-project-type.md +184 -184
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-c/step-08-scoping.md +174 -174
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-c/step-09-functional.md +175 -175
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-c/step-10-nonfunctional.md +189 -189
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-c/step-11-polish.md +162 -162
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-c/step-12-complete.md +79 -79
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-e/step-e-01-discovery.md +183 -183
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-e/step-e-01b-legacy-conversion.md +149 -149
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-e/step-e-02-review.md +187 -187
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-e/step-e-03-edit.md +192 -192
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-e/step-e-04-complete.md +108 -108
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-v/step-v-01-discovery.md +166 -166
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-v/step-v-02-format-detection.md +131 -131
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-v/step-v-02b-parity-check.md +150 -150
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-v/step-v-03-density-validation.md +118 -118
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-v/step-v-04-brief-coverage-validation.md +155 -155
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-v/step-v-05-measurability-validation.md +170 -170
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-v/step-v-06-traceability-validation.md +158 -158
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-v/step-v-07-implementation-leakage-validation.md +147 -147
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-v/step-v-08-domain-compliance-validation.md +182 -182
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-v/step-v-09-project-type-validation.md +202 -202
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-v/step-v-10-smart-validation.md +148 -148
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-v/step-v-11-holistic-quality-validation.md +201 -201
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-v/step-v-12-completeness-validation.md +179 -179
- package/src/bmm/workflows/2-plan-workflows/create-prd/steps-v/step-v-13-report-complete.md +164 -164
- package/src/bmm/workflows/2-plan-workflows/create-prd/workflow-create-prd.md +65 -65
- package/src/bmm/workflows/2-plan-workflows/create-prd/workflow-edit-prd.md +65 -65
- package/src/bmm/workflows/2-plan-workflows/create-prd/workflow-validate-prd.md +63 -63
- package/src/bmm/workflows/2-plan-workflows/create-ux-design/steps/step-01b-continue.md +63 -63
- package/src/bmm/workflows/2-plan-workflows/create-ux-design/steps/step-02-discovery.md +106 -106
- package/src/bmm/workflows/2-plan-workflows/create-ux-design/steps/step-03-core-experience.md +111 -111
- package/src/bmm/workflows/2-plan-workflows/create-ux-design/steps/step-04-emotional-response.md +115 -115
- package/src/bmm/workflows/2-plan-workflows/create-ux-design/steps/step-05-inspiration.md +127 -127
- package/src/bmm/workflows/2-plan-workflows/create-ux-design/steps/step-06-design-system.md +167 -167
- package/src/bmm/workflows/2-plan-workflows/create-ux-design/steps/step-07-defining-experience.md +143 -143
- package/src/bmm/workflows/2-plan-workflows/create-ux-design/steps/step-08-visual-foundation.md +118 -118
- package/src/bmm/workflows/2-plan-workflows/create-ux-design/steps/step-09-design-directions.md +154 -154
- package/src/bmm/workflows/2-plan-workflows/create-ux-design/steps/step-10-user-journeys.md +136 -136
- package/src/bmm/workflows/2-plan-workflows/create-ux-design/steps/step-11-component-strategy.md +165 -165
- package/src/bmm/workflows/2-plan-workflows/create-ux-design/steps/step-12-ux-patterns.md +135 -135
- package/src/bmm/workflows/2-plan-workflows/create-ux-design/steps/step-13-responsive-accessibility.md +192 -192
- package/src/bmm/workflows/2-plan-workflows/create-ux-design/steps/step-14-complete.md +101 -101
- package/src/bmm/workflows/2-plan-workflows/create-ux-design/workflow.md +45 -45
- package/src/bmm/workflows/3-solutioning/check-implementation-readiness/steps/step-01-document-discovery.md +185 -185
- package/src/bmm/workflows/3-solutioning/check-implementation-readiness/steps/step-02-prd-analysis.md +129 -129
- package/src/bmm/workflows/3-solutioning/check-implementation-readiness/steps/step-03-epic-coverage-validation.md +130 -130
- package/src/bmm/workflows/3-solutioning/check-implementation-readiness/steps/step-04-ux-alignment.md +93 -93
- package/src/bmm/workflows/3-solutioning/check-implementation-readiness/steps/step-05-epic-quality-review.md +196 -196
- package/src/bmm/workflows/3-solutioning/check-implementation-readiness/steps/step-06-final-assessment.md +129 -129
- package/src/bmm/workflows/3-solutioning/check-implementation-readiness/workflow.md +54 -54
- package/src/bmm/workflows/3-solutioning/create-architecture/steps/step-01b-continue.md +82 -82
- package/src/bmm/workflows/3-solutioning/create-architecture/steps/step-02-context.md +106 -106
- package/src/bmm/workflows/3-solutioning/create-architecture/steps/step-03-starter.md +138 -138
- package/src/bmm/workflows/3-solutioning/create-architecture/steps/step-04-decisions.md +129 -129
- package/src/bmm/workflows/3-solutioning/create-architecture/steps/step-05-patterns.md +166 -166
- package/src/bmm/workflows/3-solutioning/create-architecture/steps/step-06-structure.md +186 -186
- package/src/bmm/workflows/3-solutioning/create-architecture/steps/step-07-validation.md +163 -163
- package/src/bmm/workflows/3-solutioning/create-architecture/steps/step-08-complete.md +38 -38
- package/src/bmm/workflows/3-solutioning/create-architecture/workflow.md +49 -49
- package/src/bmm/workflows/3-solutioning/create-epics-and-stories/steps/step-02-design-epics.md +124 -124
- package/src/bmm/workflows/3-solutioning/create-epics-and-stories/steps/step-03-create-stories.md +122 -122
- package/src/bmm/workflows/3-solutioning/create-epics-and-stories/steps/step-04-final-validation.md +84 -84
- package/src/bmm/workflows/3-solutioning/create-epics-and-stories/workflow.md +58 -58
- package/src/bmm/workflows/4-implementation/code-review/workflow.yaml +43 -43
- package/src/bmm/workflows/4-implementation/correct-course/workflow.yaml +53 -53
- package/src/bmm/workflows/4-implementation/create-story/checklist.md +159 -159
- package/src/bmm/workflows/4-implementation/create-story/template.md +79 -79
- package/src/bmm/workflows/4-implementation/create-story/workflow.yaml +52 -52
- package/src/bmm/workflows/4-implementation/dev-story/workflow.yaml +20 -20
- package/src/bmm/workflows/4-implementation/retrospective/workflow.yaml +52 -52
- package/src/bmm/workflows/4-implementation/sprint-planning/workflow.yaml +52 -52
- package/src/bmm/workflows/4-implementation/sprint-status/workflow.yaml +25 -25
- package/src/bmm/workflows/bmad-quick-flow/quick-dev/steps/step-01-mode-detection.md +158 -158
- package/src/bmm/workflows/bmad-quick-flow/quick-dev/steps/step-02-context-gathering.md +122 -122
- package/src/bmm/workflows/bmad-quick-flow/quick-dev/steps/step-03-execute.md +93 -93
- package/src/bmm/workflows/bmad-quick-flow/quick-dev/steps/step-04-self-check.md +93 -93
- package/src/bmm/workflows/bmad-quick-flow/quick-dev/steps/step-05-adversarial-review.md +87 -87
- package/src/bmm/workflows/bmad-quick-flow/quick-dev/steps/step-06-resolve-findings.md +146 -146
- package/src/bmm/workflows/bmad-quick-flow/quick-dev/workflow.md +50 -50
- package/src/bmm/workflows/bmad-quick-flow/quick-spec/steps/step-02-investigate.md +152 -152
- package/src/bmm/workflows/bmad-quick-flow/quick-spec/steps/step-03-generate.md +123 -123
- package/src/bmm/workflows/bmad-quick-flow/quick-spec/steps/step-04-review.md +201 -201
- package/src/bmm/workflows/bmad-quick-flow/quick-spec/workflow.md +79 -79
- package/src/bmm/workflows/document-project/workflow.yaml +22 -22
- package/src/bmm/workflows/generate-project-context/steps/step-01-discover.md +184 -184
- package/src/bmm/workflows/generate-project-context/steps/step-02-generate.md +322 -322
- package/src/bmm/workflows/generate-project-context/steps/step-03-complete.md +235 -235
- package/src/bmm/workflows/generate-project-context/workflow.md +49 -49
- package/src/bmm/workflows/qa/automate/workflow.yaml +233 -233
- package/src/bmm/workflows/qa-generate-e2e-tests/workflow.yaml +42 -42
- package/src/core/config.yaml +9 -9
- package/src/core/module-help.csv +10 -10
- package/src/core/scripts/generate-loop-report.py +72 -72
- package/src/core/tasks/editorial-review-prose.xml +101 -101
- package/src/core/tasks/editorial-review-structure.xml +207 -207
- package/src/core/tasks/help.md +86 -86
- package/src/core/tasks/index-docs.xml +64 -64
- package/src/core/tasks/review-adversarial-general.xml +66 -66
- package/src/core/tasks/review-adversarial-loop.xml +46 -46
- package/src/core/tasks/review-edge-case-hunter.xml +63 -63
- package/src/core/tasks/review-party-loop.xml +46 -46
- package/src/core/tasks/shard-doc.xml +107 -107
- package/src/core/tasks/workflow.xml +235 -235
- package/src/core/templates/review-loop-report.html +88 -88
- package/src/core/templates/review-loop-report.md +5 -5
- package/src/core/workflows/advanced-elicitation/workflow.xml +117 -117
- package/src/core/workflows/brainstorming/steps/step-01-session-setup.md +212 -212
- package/src/core/workflows/brainstorming/steps/step-01b-continue.md +122 -122
- package/src/core/workflows/brainstorming/steps/step-02a-user-selected.md +225 -225
- package/src/core/workflows/brainstorming/steps/step-02b-ai-recommended.md +237 -237
- package/src/core/workflows/brainstorming/steps/step-02c-random-selection.md +209 -209
- package/src/core/workflows/brainstorming/steps/step-02d-progressive-flow.md +264 -264
- package/src/core/workflows/brainstorming/steps/step-02e-deep-dive.md +68 -68
- package/src/core/workflows/brainstorming/steps/step-03-technique-execution.md +403 -403
- package/src/core/workflows/brainstorming/steps/step-04-idea-organization.md +303 -303
- package/src/core/workflows/brainstorming/workflow.md +60 -60
- package/src/core/workflows/extract-trackers/workflow.md +45 -45
- package/src/core/workflows/party-mode/steps/step-01-agent-loading.md +142 -142
- package/src/core/workflows/party-mode/workflow.md +194 -194
- package/src/docs/dev/tmux/actions_popup.py +291 -291
- package/src/docs/dev/tmux/tmux-setup.md +62 -1
|
@@ -1,1116 +1,1116 @@
|
|
|
1
|
-
# URLSession Patterns Reference
|
|
2
|
-
|
|
3
|
-
Complete implementation patterns for URLSession-based networking. Each
|
|
4
|
-
section is self-contained with production-ready code.
|
|
5
|
-
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
## Contents
|
|
9
|
-
|
|
10
|
-
- [Complete API Client with Protocol](#complete-api-client-with-protocol)
|
|
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.
|
|
28
|
-
|
|
29
|
-
### Protocol
|
|
30
|
-
|
|
31
|
-
```swift
|
|
32
|
-
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
|
|
45
|
-
}
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
### Endpoint Definition
|
|
49
|
-
|
|
50
|
-
```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"
|
|
66
|
-
}
|
|
67
|
-
|
|
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
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
### Client Implementation
|
|
90
|
-
|
|
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
|
|
121
|
-
}
|
|
122
|
-
|
|
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)
|
|
131
|
-
}
|
|
132
|
-
|
|
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)
|
|
137
|
-
}
|
|
138
|
-
|
|
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)
|
|
149
|
-
}
|
|
150
|
-
|
|
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
|
-
))
|
|
163
|
-
}
|
|
164
|
-
|
|
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
|
-
))
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
func delete(path: String) async throws {
|
|
180
|
-
try await request(endpoint: Endpoint(path: path, method: .delete))
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// MARK: - Internal
|
|
184
|
-
|
|
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
|
|
191
|
-
}
|
|
192
|
-
|
|
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
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
### Error Types
|
|
210
|
-
|
|
211
|
-
```swift
|
|
212
|
-
enum NetworkError: Error, Sendable, LocalizedError {
|
|
213
|
-
case invalidResponse
|
|
214
|
-
case httpError(statusCode: Int, data: Data, message: String? = nil)
|
|
215
|
-
case noConnection
|
|
216
|
-
case timedOut
|
|
217
|
-
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
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
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
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
struct APIErrorBody: Decodable, Sendable {
|
|
249
|
-
let code: String?
|
|
250
|
-
let message: String?
|
|
251
|
-
}
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
---
|
|
255
|
-
|
|
256
|
-
## Request Builder Pattern
|
|
257
|
-
|
|
258
|
-
For complex request construction, a builder provides a fluent API that
|
|
259
|
-
reduces errors.
|
|
260
|
-
|
|
261
|
-
```swift
|
|
262
|
-
struct RequestBuilder: Sendable {
|
|
263
|
-
private var method: String = "GET"
|
|
264
|
-
private var path: String
|
|
265
|
-
private var baseURL: URL
|
|
266
|
-
private var queryItems: [URLQueryItem] = []
|
|
267
|
-
private var headers: [String: String] = [:]
|
|
268
|
-
private var body: Data?
|
|
269
|
-
private var cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy
|
|
270
|
-
private var timeout: TimeInterval = 30
|
|
271
|
-
|
|
272
|
-
init(baseURL: URL, path: String) {
|
|
273
|
-
self.baseURL = baseURL
|
|
274
|
-
self.path = path
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
func method(_ method: String) -> RequestBuilder {
|
|
278
|
-
var copy = self
|
|
279
|
-
copy.method = method
|
|
280
|
-
return copy
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
func query(_ name: String, _ value: String?) -> RequestBuilder {
|
|
284
|
-
guard let value else { return self }
|
|
285
|
-
var copy = self
|
|
286
|
-
copy.queryItems.append(URLQueryItem(name: name, value: value))
|
|
287
|
-
return copy
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
func header(_ name: String, _ value: String) -> RequestBuilder {
|
|
291
|
-
var copy = self
|
|
292
|
-
copy.headers[name] = value
|
|
293
|
-
return copy
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
func jsonBody<T: Encodable>(_ value: T) throws -> RequestBuilder {
|
|
297
|
-
var copy = self
|
|
298
|
-
copy.body = try JSONEncoder().encode(value)
|
|
299
|
-
copy.headers["Content-Type"] = "application/json"
|
|
300
|
-
return copy
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
func timeout(_ interval: TimeInterval) -> RequestBuilder {
|
|
304
|
-
var copy = self
|
|
305
|
-
copy.timeout = interval
|
|
306
|
-
return copy
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
func cachePolicy(_ policy: URLRequest.CachePolicy) -> RequestBuilder {
|
|
310
|
-
var copy = self
|
|
311
|
-
copy.cachePolicy = policy
|
|
312
|
-
return copy
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
func build() -> URLRequest {
|
|
316
|
-
var components = URLComponents(
|
|
317
|
-
url: baseURL.appendingPathComponent(path),
|
|
318
|
-
resolvingAgainstBaseURL: true
|
|
319
|
-
)!
|
|
320
|
-
if !queryItems.isEmpty {
|
|
321
|
-
components.queryItems = queryItems
|
|
322
|
-
}
|
|
323
|
-
var request = URLRequest(url: components.url!)
|
|
324
|
-
request.httpMethod = method
|
|
325
|
-
request.httpBody = body
|
|
326
|
-
request.cachePolicy = cachePolicy
|
|
327
|
-
request.timeoutInterval = timeout
|
|
328
|
-
for (key, value) in headers {
|
|
329
|
-
request.setValue(value, forHTTPHeaderField: key)
|
|
330
|
-
}
|
|
331
|
-
return request
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// Usage
|
|
336
|
-
let request = try RequestBuilder(baseURL: apiURL, path: "users")
|
|
337
|
-
.method("POST")
|
|
338
|
-
.header("X-Request-ID", UUID().uuidString)
|
|
339
|
-
.jsonBody(CreateUserRequest(name: "Alice", email: "alice@example.com"))
|
|
340
|
-
.timeout(15)
|
|
341
|
-
.build()
|
|
342
|
-
```
|
|
343
|
-
|
|
344
|
-
---
|
|
345
|
-
|
|
346
|
-
## Multipart Form Upload
|
|
347
|
-
|
|
348
|
-
Multipart/form-data uploads are common for file attachments. Build the
|
|
349
|
-
body manually -- no third-party library needed.
|
|
350
|
-
|
|
351
|
-
```swift
|
|
352
|
-
struct MultipartFormData: Sendable {
|
|
353
|
-
private let boundary: String
|
|
354
|
-
private var parts: [Part] = []
|
|
355
|
-
|
|
356
|
-
init(boundary: String = UUID().uuidString) {
|
|
357
|
-
self.boundary = boundary
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
var contentType: String {
|
|
361
|
-
"multipart/form-data; boundary=\(boundary)"
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
mutating func addField(name: String, value: String) {
|
|
365
|
-
parts.append(Part(
|
|
366
|
-
headers: "Content-Disposition: form-data; name=\"\(name)\"",
|
|
367
|
-
body: Data(value.utf8)
|
|
368
|
-
))
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
mutating func addFile(
|
|
372
|
-
name: String,
|
|
373
|
-
filename: String,
|
|
374
|
-
mimeType: String,
|
|
375
|
-
data: Data
|
|
376
|
-
) {
|
|
377
|
-
parts.append(Part(
|
|
378
|
-
headers: """
|
|
379
|
-
Content-Disposition: form-data; name="\(name)"; filename="\(filename)"\r
|
|
380
|
-
Content-Type: \(mimeType)
|
|
381
|
-
""",
|
|
382
|
-
body: data
|
|
383
|
-
))
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
func encode() -> Data {
|
|
387
|
-
var data = Data()
|
|
388
|
-
let crlf = "\r\n"
|
|
389
|
-
for part in parts {
|
|
390
|
-
data.append("--\(boundary)\(crlf)")
|
|
391
|
-
data.append("\(part.headers)\(crlf)\(crlf)")
|
|
392
|
-
data.append(part.body)
|
|
393
|
-
data.append(crlf)
|
|
394
|
-
}
|
|
395
|
-
data.append("--\(boundary)--\(crlf)")
|
|
396
|
-
return data
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
private struct Part: Sendable {
|
|
400
|
-
let headers: String
|
|
401
|
-
let body: Data
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
extension Data {
|
|
406
|
-
mutating func append(_ string: String) {
|
|
407
|
-
append(Data(string.utf8))
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Usage
|
|
412
|
-
var form = MultipartFormData()
|
|
413
|
-
form.addField(name: "title", value: "Profile Photo")
|
|
414
|
-
form.addFile(
|
|
415
|
-
name: "image",
|
|
416
|
-
filename: "photo.jpg",
|
|
417
|
-
mimeType: "image/jpeg",
|
|
418
|
-
data: imageData
|
|
419
|
-
)
|
|
420
|
-
|
|
421
|
-
var request = URLRequest(url: uploadURL)
|
|
422
|
-
request.httpMethod = "POST"
|
|
423
|
-
request.setValue(form.contentType, forHTTPHeaderField: "Content-Type")
|
|
424
|
-
request.httpBody = form.encode()
|
|
425
|
-
|
|
426
|
-
let (data, response) = try await URLSession.shared.upload(
|
|
427
|
-
for: request,
|
|
428
|
-
from: form.encode()
|
|
429
|
-
)
|
|
430
|
-
```
|
|
431
|
-
|
|
432
|
-
---
|
|
433
|
-
|
|
434
|
-
## Download with Progress Tracking
|
|
435
|
-
|
|
436
|
-
Use `bytes(for:)` for real-time progress. The response includes
|
|
437
|
-
`expectedContentLength` for calculating percentage.
|
|
438
|
-
|
|
439
|
-
```swift
|
|
440
|
-
@available(iOS 15.0, *)
|
|
441
|
-
func downloadWithProgress(
|
|
442
|
-
from url: URL,
|
|
443
|
-
progressHandler: @Sendable (Double) -> Void
|
|
444
|
-
) async throws -> Data {
|
|
445
|
-
let (bytes, response) = try await URLSession.shared.bytes(from: url)
|
|
446
|
-
|
|
447
|
-
let expectedLength = response.expectedContentLength
|
|
448
|
-
var receivedData = Data()
|
|
449
|
-
if expectedLength > 0 {
|
|
450
|
-
receivedData.reserveCapacity(Int(expectedLength))
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
var receivedLength: Int64 = 0
|
|
454
|
-
for try await byte in bytes {
|
|
455
|
-
receivedData.append(byte)
|
|
456
|
-
receivedLength += 1
|
|
457
|
-
if expectedLength > 0 {
|
|
458
|
-
let progress = Double(receivedLength) / Double(expectedLength)
|
|
459
|
-
progressHandler(progress)
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
return receivedData
|
|
464
|
-
}
|
|
465
|
-
```
|
|
466
|
-
|
|
467
|
-
For large files, prefer `URLSessionDownloadTask` with a delegate for
|
|
468
|
-
better memory efficiency and background support. See
|
|
469
|
-
`references/background-websocket.md`.
|
|
470
|
-
|
|
471
|
-
### Download to File with Progress (Delegate-Based)
|
|
472
|
-
|
|
473
|
-
```swift
|
|
474
|
-
@available(iOS 15.0, *)
|
|
475
|
-
final class DownloadManager: NSObject, URLSessionDownloadDelegate, Sendable {
|
|
476
|
-
private let continuation: AsyncStream<DownloadEvent>.Continuation
|
|
477
|
-
|
|
478
|
-
enum DownloadEvent: Sendable {
|
|
479
|
-
case progress(Double)
|
|
480
|
-
case completed(URL)
|
|
481
|
-
case failed(Error)
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
static func download(from url: URL) -> AsyncStream<DownloadEvent> {
|
|
485
|
-
AsyncStream { continuation in
|
|
486
|
-
let manager = DownloadManager(continuation: continuation)
|
|
487
|
-
let session = URLSession(
|
|
488
|
-
configuration: .default,
|
|
489
|
-
delegate: manager,
|
|
490
|
-
delegateQueue: nil
|
|
491
|
-
)
|
|
492
|
-
session.downloadTask(with: url).resume()
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
private init(continuation: AsyncStream<DownloadEvent>.Continuation) {
|
|
497
|
-
self.continuation = continuation
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
nonisolated func urlSession(
|
|
501
|
-
_ session: URLSession,
|
|
502
|
-
downloadTask: URLSessionDownloadTask,
|
|
503
|
-
didFinishDownloadingTo location: URL
|
|
504
|
-
) {
|
|
505
|
-
// Move file to permanent location before this method returns
|
|
506
|
-
let destination = FileManager.default.temporaryDirectory
|
|
507
|
-
.appendingPathComponent(UUID().uuidString)
|
|
508
|
-
do {
|
|
509
|
-
try FileManager.default.moveItem(at: location, to: destination)
|
|
510
|
-
continuation.yield(.completed(destination))
|
|
511
|
-
} catch {
|
|
512
|
-
continuation.yield(.failed(error))
|
|
513
|
-
}
|
|
514
|
-
continuation.finish()
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
nonisolated func urlSession(
|
|
518
|
-
_ session: URLSession,
|
|
519
|
-
downloadTask: URLSessionDownloadTask,
|
|
520
|
-
didWriteData bytesWritten: Int64,
|
|
521
|
-
totalBytesWritten: Int64,
|
|
522
|
-
totalBytesExpectedToWrite: Int64
|
|
523
|
-
) {
|
|
524
|
-
guard totalBytesExpectedToWrite > 0 else { return }
|
|
525
|
-
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
|
526
|
-
continuation.yield(.progress(progress))
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
nonisolated func urlSession(
|
|
530
|
-
_ session: URLSession,
|
|
531
|
-
task: URLSessionTask,
|
|
532
|
-
didCompleteWithError error: (any Error)?
|
|
533
|
-
) {
|
|
534
|
-
if let error {
|
|
535
|
-
continuation.yield(.failed(error))
|
|
536
|
-
continuation.finish()
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
```
|
|
541
|
-
|
|
542
|
-
---
|
|
543
|
-
|
|
544
|
-
## Cursor-Based Pagination
|
|
545
|
-
|
|
546
|
-
A reusable paginator that conforms to `AsyncSequence`, yielding pages
|
|
547
|
-
of results until the server indicates no more data.
|
|
548
|
-
|
|
549
|
-
```swift
|
|
550
|
-
struct PageResponse<T: Decodable & Sendable>: Decodable, Sendable {
|
|
551
|
-
let data: [T]
|
|
552
|
-
let pagination: PaginationInfo
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
struct PaginationInfo: Decodable, Sendable {
|
|
556
|
-
let nextCursor: String?
|
|
557
|
-
let hasMore: Bool
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
struct CursorPaginator<T: Decodable & Sendable>: AsyncSequence {
|
|
561
|
-
typealias Element = [T]
|
|
562
|
-
|
|
563
|
-
private let fetchPage: @Sendable (String?) async throws -> PageResponse<T>
|
|
564
|
-
|
|
565
|
-
init(fetchPage: @escaping @Sendable (String?) async throws -> PageResponse<T>) {
|
|
566
|
-
self.fetchPage = fetchPage
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
func makeAsyncIterator() -> Iterator {
|
|
570
|
-
Iterator(fetchPage: fetchPage)
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
struct Iterator: AsyncIteratorProtocol {
|
|
574
|
-
private let fetchPage: @Sendable (String?) async throws -> PageResponse<T>
|
|
575
|
-
private var cursor: String?
|
|
576
|
-
private var exhausted = false
|
|
577
|
-
|
|
578
|
-
init(fetchPage: @escaping @Sendable (String?) async throws -> PageResponse<T>) {
|
|
579
|
-
self.fetchPage = fetchPage
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
mutating func next() async throws -> [T]? {
|
|
583
|
-
guard !exhausted else { return nil }
|
|
584
|
-
try Task.checkCancellation()
|
|
585
|
-
|
|
586
|
-
let response = try await fetchPage(cursor)
|
|
587
|
-
cursor = response.pagination.nextCursor
|
|
588
|
-
exhausted = !response.pagination.hasMore
|
|
589
|
-
|
|
590
|
-
return response.data.isEmpty ? nil : response.data
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
// Usage
|
|
596
|
-
let paginator = CursorPaginator<User> { cursor in
|
|
597
|
-
var queryItems = [URLQueryItem(name: "limit", value: "50")]
|
|
598
|
-
if let cursor {
|
|
599
|
-
queryItems.append(URLQueryItem(name: "cursor", value: cursor))
|
|
600
|
-
}
|
|
601
|
-
return try await client.get(
|
|
602
|
-
PageResponse<User>.self,
|
|
603
|
-
path: "users",
|
|
604
|
-
queryItems: queryItems
|
|
605
|
-
)
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
var allUsers: [User] = []
|
|
609
|
-
for try await batch in paginator {
|
|
610
|
-
allUsers.append(contentsOf: batch)
|
|
611
|
-
}
|
|
612
|
-
```
|
|
613
|
-
|
|
614
|
-
---
|
|
615
|
-
|
|
616
|
-
## Offset-Based Pagination
|
|
617
|
-
|
|
618
|
-
```swift
|
|
619
|
-
struct OffsetPaginator<T: Decodable & Sendable>: AsyncSequence {
|
|
620
|
-
typealias Element = [T]
|
|
621
|
-
|
|
622
|
-
private let pageSize: Int
|
|
623
|
-
private let fetchPage: @Sendable (Int, Int) async throws -> [T]
|
|
624
|
-
|
|
625
|
-
init(
|
|
626
|
-
pageSize: Int = 20,
|
|
627
|
-
fetchPage: @escaping @Sendable (_ offset: Int, _ limit: Int) async throws -> [T]
|
|
628
|
-
) {
|
|
629
|
-
self.pageSize = pageSize
|
|
630
|
-
self.fetchPage = fetchPage
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
func makeAsyncIterator() -> Iterator {
|
|
634
|
-
Iterator(pageSize: pageSize, fetchPage: fetchPage)
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
struct Iterator: AsyncIteratorProtocol {
|
|
638
|
-
private let pageSize: Int
|
|
639
|
-
private let fetchPage: @Sendable (Int, Int) async throws -> [T]
|
|
640
|
-
private var offset = 0
|
|
641
|
-
private var exhausted = false
|
|
642
|
-
|
|
643
|
-
init(
|
|
644
|
-
pageSize: Int,
|
|
645
|
-
fetchPage: @escaping @Sendable (Int, Int) async throws -> [T]
|
|
646
|
-
) {
|
|
647
|
-
self.pageSize = pageSize
|
|
648
|
-
self.fetchPage = fetchPage
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
mutating func next() async throws -> [T]? {
|
|
652
|
-
guard !exhausted else { return nil }
|
|
653
|
-
try Task.checkCancellation()
|
|
654
|
-
|
|
655
|
-
let items = try await fetchPage(offset, pageSize)
|
|
656
|
-
offset += items.count
|
|
657
|
-
if items.count < pageSize { exhausted = true }
|
|
658
|
-
|
|
659
|
-
return items.isEmpty ? nil : items
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
```
|
|
664
|
-
|
|
665
|
-
---
|
|
666
|
-
|
|
667
|
-
## URLProtocol Mock for Testing
|
|
668
|
-
|
|
669
|
-
`URLProtocol` is the correct way to mock network responses at the
|
|
670
|
-
transport level. It works with any URLSession configuration and does
|
|
671
|
-
not require changing production code.
|
|
672
|
-
|
|
673
|
-
```swift
|
|
674
|
-
final class MockURLProtocol: URLProtocol {
|
|
675
|
-
nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
|
|
676
|
-
|
|
677
|
-
override class func canInit(with request: URLRequest) -> Bool { true }
|
|
678
|
-
|
|
679
|
-
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
|
680
|
-
|
|
681
|
-
override func startLoading() {
|
|
682
|
-
guard let handler = Self.requestHandler else {
|
|
683
|
-
fatalError("MockURLProtocol.requestHandler is not set")
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
do {
|
|
687
|
-
let (response, data) = try handler(request)
|
|
688
|
-
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
|
689
|
-
client?.urlProtocol(self, didLoad: data)
|
|
690
|
-
client?.urlProtocolDidFinishLoading(self)
|
|
691
|
-
} catch {
|
|
692
|
-
client?.urlProtocol(self, didFailWithError: error)
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
override func stopLoading() {}
|
|
697
|
-
}
|
|
698
|
-
```
|
|
699
|
-
|
|
700
|
-
### Test Setup
|
|
701
|
-
|
|
702
|
-
```swift
|
|
703
|
-
import Testing
|
|
704
|
-
|
|
705
|
-
@Suite struct APIClientTests {
|
|
706
|
-
let client: APIClient
|
|
707
|
-
let session: URLSession
|
|
708
|
-
|
|
709
|
-
init() {
|
|
710
|
-
let config = URLSessionConfiguration.ephemeral
|
|
711
|
-
config.protocolClasses = [MockURLProtocol.self]
|
|
712
|
-
session = URLSession(configuration: config)
|
|
713
|
-
client = APIClient(
|
|
714
|
-
baseURL: URL(string: "https://api.example.com")!,
|
|
715
|
-
session: session
|
|
716
|
-
)
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
@Test func fetchUsersDecodesCorrectly() async throws {
|
|
720
|
-
let usersJSON = """
|
|
721
|
-
[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
|
|
722
|
-
"""
|
|
723
|
-
MockURLProtocol.requestHandler = { request in
|
|
724
|
-
#expect(request.url?.path == "/users")
|
|
725
|
-
let response = HTTPURLResponse(
|
|
726
|
-
url: request.url!,
|
|
727
|
-
statusCode: 200,
|
|
728
|
-
httpVersion: nil,
|
|
729
|
-
headerFields: ["Content-Type": "application/json"]
|
|
730
|
-
)!
|
|
731
|
-
return (response, Data(usersJSON.utf8))
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
let users: [User] = try await client.get([User].self, path: "users")
|
|
735
|
-
#expect(users.count == 2)
|
|
736
|
-
#expect(users[0].name == "Alice")
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
@Test func fetchReturnsHTTPError() async throws {
|
|
740
|
-
MockURLProtocol.requestHandler = { request in
|
|
741
|
-
let response = HTTPURLResponse(
|
|
742
|
-
url: request.url!,
|
|
743
|
-
statusCode: 404,
|
|
744
|
-
httpVersion: nil,
|
|
745
|
-
headerFields: nil
|
|
746
|
-
)!
|
|
747
|
-
return (response, Data())
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
await #expect(throws: NetworkError.self) {
|
|
751
|
-
let _: [User] = try await client.get([User].self, path: "missing")
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
@Test func requestIncludesAuthHeader() async throws {
|
|
756
|
-
let authClient = APIClient(
|
|
757
|
-
baseURL: URL(string: "https://api.example.com")!,
|
|
758
|
-
session: session,
|
|
759
|
-
middlewares: [AuthMiddleware { "test-token" }]
|
|
760
|
-
)
|
|
761
|
-
|
|
762
|
-
MockURLProtocol.requestHandler = { request in
|
|
763
|
-
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token")
|
|
764
|
-
let response = HTTPURLResponse(
|
|
765
|
-
url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil
|
|
766
|
-
)!
|
|
767
|
-
return (response, Data("{}".utf8))
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
let _: EmptyResponse = try await authClient.get(EmptyResponse.self, path: "me")
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
struct EmptyResponse: Decodable, Sendable {}
|
|
775
|
-
```
|
|
776
|
-
|
|
777
|
-
---
|
|
778
|
-
|
|
779
|
-
## Retry with Exponential Backoff
|
|
780
|
-
|
|
781
|
-
Respect cancellation. Do not retry client errors (4xx except 429 rate
|
|
782
|
-
limiting). Include jitter to prevent thundering herd.
|
|
783
|
-
|
|
784
|
-
```swift
|
|
785
|
-
func withRetry<T: Sendable>(
|
|
786
|
-
maxAttempts: Int = 3,
|
|
787
|
-
initialDelay: Duration = .seconds(1),
|
|
788
|
-
maxDelay: Duration = .seconds(30),
|
|
789
|
-
shouldRetry: @Sendable (Error) -> Bool = { error in
|
|
790
|
-
if error is CancellationError { return false }
|
|
791
|
-
if case NetworkError.httpError(let code, _, _) = error {
|
|
792
|
-
return code >= 500 || code == 429
|
|
793
|
-
}
|
|
794
|
-
if let urlError = error as? URLError {
|
|
795
|
-
return [.timedOut, .networkConnectionLost, .notConnectedToInternet]
|
|
796
|
-
.contains(urlError.code)
|
|
797
|
-
}
|
|
798
|
-
return false
|
|
799
|
-
},
|
|
800
|
-
operation: @Sendable () async throws -> T
|
|
801
|
-
) async throws -> T {
|
|
802
|
-
var lastError: Error?
|
|
803
|
-
|
|
804
|
-
for attempt in 0..<maxAttempts {
|
|
805
|
-
try Task.checkCancellation()
|
|
806
|
-
do {
|
|
807
|
-
return try await operation()
|
|
808
|
-
} catch {
|
|
809
|
-
lastError = error
|
|
810
|
-
guard shouldRetry(error), attempt < maxAttempts - 1 else {
|
|
811
|
-
throw error
|
|
812
|
-
}
|
|
813
|
-
// Exponential backoff with jitter
|
|
814
|
-
let base = Double(initialDelay.components.seconds) * pow(2.0, Double(attempt))
|
|
815
|
-
let capped = min(base, Double(maxDelay.components.seconds))
|
|
816
|
-
let jitter = Double.random(in: 0...(capped * 0.1))
|
|
817
|
-
let delay = Duration.seconds(capped + jitter)
|
|
818
|
-
try await Task.sleep(for: delay)
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
throw lastError!
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
// Usage
|
|
826
|
-
let users = try await withRetry {
|
|
827
|
-
try await client.get([User].self, path: "users")
|
|
828
|
-
}
|
|
829
|
-
```
|
|
830
|
-
|
|
831
|
-
---
|
|
832
|
-
|
|
833
|
-
## Certificate Pinning (URLSessionDelegate)
|
|
834
|
-
|
|
835
|
-
Pin the server's public key hash rather than the certificate itself.
|
|
836
|
-
Certificates rotate; public keys are more stable. Always include a
|
|
837
|
-
backup pin.
|
|
838
|
-
|
|
839
|
-
```swift
|
|
840
|
-
import CryptoKit
|
|
841
|
-
|
|
842
|
-
final class PinningDelegate: NSObject, URLSessionDelegate, Sendable {
|
|
843
|
-
/// SHA-256 hashes of Subject Public Key Info (SPKI) in base64
|
|
844
|
-
private let pinnedKeyHashes: Set<String>
|
|
845
|
-
|
|
846
|
-
init(pinnedKeyHashes: Set<String>) {
|
|
847
|
-
self.pinnedKeyHashes = pinnedKeyHashes
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
nonisolated func urlSession(
|
|
851
|
-
_ session: URLSession,
|
|
852
|
-
didReceive challenge: URLAuthenticationChallenge
|
|
853
|
-
) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
|
|
854
|
-
guard challenge.protectionSpace.authenticationMethod
|
|
855
|
-
== NSURLAuthenticationMethodServerTrust,
|
|
856
|
-
let trust = challenge.protectionSpace.serverTrust else {
|
|
857
|
-
return (.performDefaultHandling, nil)
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
// Evaluate the trust chain
|
|
861
|
-
var error: CFError?
|
|
862
|
-
guard SecTrustEvaluateWithError(trust, &error) else {
|
|
863
|
-
return (.cancelAuthenticationChallenge, nil)
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
// Extract the leaf certificate's public key
|
|
867
|
-
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
|
|
868
|
-
let leafCert = chain.first,
|
|
869
|
-
let publicKey = SecCertificateCopyKey(leafCert),
|
|
870
|
-
let publicKeyData = SecKeyCopyExternalRepresentation(
|
|
871
|
-
publicKey, nil
|
|
872
|
-
) as Data? else {
|
|
873
|
-
return (.cancelAuthenticationChallenge, nil)
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
let keyHash = SHA256.hash(data: publicKeyData)
|
|
877
|
-
let hashString = Data(keyHash).base64EncodedString()
|
|
878
|
-
|
|
879
|
-
if pinnedKeyHashes.contains(hashString) {
|
|
880
|
-
return (.useCredential, URLCredential(trust: trust))
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
return (.cancelAuthenticationChallenge, nil)
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
// Usage
|
|
888
|
-
let delegate = PinningDelegate(pinnedKeyHashes: [
|
|
889
|
-
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=", // Primary
|
|
890
|
-
"CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=", // Backup
|
|
891
|
-
])
|
|
892
|
-
|
|
893
|
-
let session = URLSession(
|
|
894
|
-
configuration: .default,
|
|
895
|
-
delegate: delegate,
|
|
896
|
-
delegateQueue: nil
|
|
897
|
-
)
|
|
898
|
-
```
|
|
899
|
-
|
|
900
|
-
**Important considerations:**
|
|
901
|
-
- Pin at least two keys (primary + backup) to avoid lockout during rotation.
|
|
902
|
-
- Have a remote kill switch (feature flag) to disable pinning in emergencies.
|
|
903
|
-
- Test certificate rotation in staging before deploying to production.
|
|
904
|
-
- Do not pin intermediate CA certificates -- they rotate more frequently.
|
|
905
|
-
|
|
906
|
-
---
|
|
907
|
-
|
|
908
|
-
## Request Logging / Debugging Middleware
|
|
909
|
-
|
|
910
|
-
Log outgoing requests and incoming responses for debugging. Disable or
|
|
911
|
-
reduce verbosity in release builds.
|
|
912
|
-
|
|
913
|
-
```swift
|
|
914
|
-
struct LoggingMiddleware: RequestMiddleware {
|
|
915
|
-
let logger: Logger
|
|
916
|
-
|
|
917
|
-
func prepare(_ request: URLRequest) async throws -> URLRequest {
|
|
918
|
-
#if DEBUG
|
|
919
|
-
let method = request.httpMethod ?? "GET"
|
|
920
|
-
let url = request.url?.absoluteString ?? "unknown"
|
|
921
|
-
logger.debug("[\(method)] \(url)")
|
|
922
|
-
if let headers = request.allHTTPHeaderFields {
|
|
923
|
-
for (key, value) in headers where key != "Authorization" {
|
|
924
|
-
logger.debug(" \(key): \(value)")
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
if let body = request.httpBody, body.count < 10_000 {
|
|
928
|
-
logger.debug(" Body: \(String(data: body, encoding: .utf8) ?? "<binary>")")
|
|
929
|
-
}
|
|
930
|
-
#endif
|
|
931
|
-
return request
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
```
|
|
935
|
-
|
|
936
|
-
### Response Logging
|
|
937
|
-
|
|
938
|
-
To log responses, wrap the transport call rather than using middleware:
|
|
939
|
-
|
|
940
|
-
```swift
|
|
941
|
-
func loggedRequest<T: Decodable & Sendable>(
|
|
942
|
-
_ type: T.Type,
|
|
943
|
-
endpoint: Endpoint,
|
|
944
|
-
logger: Logger
|
|
945
|
-
) async throws -> T {
|
|
946
|
-
let start = ContinuousClock().now
|
|
947
|
-
do {
|
|
948
|
-
let result: T = try await request(type, endpoint: endpoint)
|
|
949
|
-
let elapsed = ContinuousClock().now - start
|
|
950
|
-
logger.debug("[\(endpoint.method.rawValue)] \(endpoint.path) -> 200 (\(elapsed))")
|
|
951
|
-
return result
|
|
952
|
-
} catch {
|
|
953
|
-
let elapsed = ContinuousClock().now - start
|
|
954
|
-
logger.error("[\(endpoint.method.rawValue)] \(endpoint.path) -> ERROR (\(elapsed)): \(error)")
|
|
955
|
-
throw error
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
```
|
|
959
|
-
|
|
960
|
-
---
|
|
961
|
-
|
|
962
|
-
## Request Caching Strategies
|
|
963
|
-
|
|
964
|
-
### URLCache Configuration
|
|
965
|
-
|
|
966
|
-
```swift
|
|
967
|
-
// 50 MB memory / 200 MB disk cache
|
|
968
|
-
let cache = URLCache(
|
|
969
|
-
memoryCapacity: 50 * 1024 * 1024,
|
|
970
|
-
diskCapacity: 200 * 1024 * 1024,
|
|
971
|
-
directory: FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
|
|
972
|
-
.first?.appendingPathComponent("URLCache")
|
|
973
|
-
)
|
|
974
|
-
|
|
975
|
-
let config = URLSessionConfiguration.default
|
|
976
|
-
config.urlCache = cache
|
|
977
|
-
config.requestCachePolicy = .returnCacheDataElseLoad
|
|
978
|
-
|
|
979
|
-
let session = URLSession(configuration: config)
|
|
980
|
-
```
|
|
981
|
-
|
|
982
|
-
### Per-Request Cache Control
|
|
983
|
-
|
|
984
|
-
```swift
|
|
985
|
-
// Force fresh data
|
|
986
|
-
var request = URLRequest(url: url)
|
|
987
|
-
request.cachePolicy = .reloadIgnoringLocalCacheData
|
|
988
|
-
|
|
989
|
-
// Use cached if available
|
|
990
|
-
request.cachePolicy = .returnCacheDataElseLoad
|
|
991
|
-
|
|
992
|
-
// Cache only (offline mode)
|
|
993
|
-
request.cachePolicy = .returnCacheDataDontLoad
|
|
994
|
-
```
|
|
995
|
-
|
|
996
|
-
### ETag / If-None-Match
|
|
997
|
-
|
|
998
|
-
```swift
|
|
999
|
-
func fetchWithETag<T: Decodable & Sendable>(
|
|
1000
|
-
_ type: T.Type,
|
|
1001
|
-
url: URL,
|
|
1002
|
-
cachedETag: String?,
|
|
1003
|
-
cachedData: Data?
|
|
1004
|
-
) async throws -> (T, String?) {
|
|
1005
|
-
var request = URLRequest(url: url)
|
|
1006
|
-
if let etag = cachedETag {
|
|
1007
|
-
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
let (data, response) = try await URLSession.shared.data(for: request)
|
|
1011
|
-
guard let http = response as? HTTPURLResponse else {
|
|
1012
|
-
throw NetworkError.invalidResponse
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
if http.statusCode == 304, let cachedData {
|
|
1016
|
-
// Not modified -- use cached data
|
|
1017
|
-
let decoded = try JSONDecoder().decode(T.self, from: cachedData)
|
|
1018
|
-
return (decoded, cachedETag)
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
let newETag = http.value(forHTTPHeaderField: "ETag")
|
|
1022
|
-
let decoded = try JSONDecoder().decode(T.self, from: data)
|
|
1023
|
-
return (decoded, newETag)
|
|
1024
|
-
}
|
|
1025
|
-
```
|
|
1026
|
-
|
|
1027
|
-
---
|
|
1028
|
-
|
|
1029
|
-
## Server-Sent Events (SSE) Parsing
|
|
1030
|
-
|
|
1031
|
-
Use `bytes(for:)` to consume a streaming SSE endpoint.
|
|
1032
|
-
|
|
1033
|
-
```swift
|
|
1034
|
-
struct ServerSentEvent: Sendable {
|
|
1035
|
-
var event: String?
|
|
1036
|
-
var data: String
|
|
1037
|
-
var id: String?
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
func sseStream(from url: URL) -> AsyncThrowingStream<ServerSentEvent, Error> {
|
|
1041
|
-
AsyncThrowingStream { continuation in
|
|
1042
|
-
let task = Task {
|
|
1043
|
-
do {
|
|
1044
|
-
var request = URLRequest(url: url)
|
|
1045
|
-
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
|
1046
|
-
|
|
1047
|
-
let (bytes, _) = try await URLSession.shared.bytes(for: request)
|
|
1048
|
-
|
|
1049
|
-
var currentEvent: String?
|
|
1050
|
-
var currentData = ""
|
|
1051
|
-
var currentId: String?
|
|
1052
|
-
|
|
1053
|
-
for try await line in bytes.lines {
|
|
1054
|
-
if line.isEmpty {
|
|
1055
|
-
// Empty line = dispatch event
|
|
1056
|
-
if !currentData.isEmpty {
|
|
1057
|
-
continuation.yield(ServerSentEvent(
|
|
1058
|
-
event: currentEvent,
|
|
1059
|
-
data: currentData.trimmingCharacters(in: .newlines),
|
|
1060
|
-
id: currentId
|
|
1061
|
-
))
|
|
1062
|
-
}
|
|
1063
|
-
currentEvent = nil
|
|
1064
|
-
currentData = ""
|
|
1065
|
-
currentId = nil
|
|
1066
|
-
} else if line.hasPrefix("event:") {
|
|
1067
|
-
currentEvent = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces)
|
|
1068
|
-
} else if line.hasPrefix("data:") {
|
|
1069
|
-
let value = String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces)
|
|
1070
|
-
currentData += currentData.isEmpty ? value : "\n" + value
|
|
1071
|
-
} else if line.hasPrefix("id:") {
|
|
1072
|
-
currentId = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
continuation.finish()
|
|
1076
|
-
} catch {
|
|
1077
|
-
continuation.finish(throwing: error)
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
continuation.onTermination = { _ in task.cancel() }
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
```
|
|
1084
|
-
|
|
1085
|
-
---
|
|
1086
|
-
|
|
1087
|
-
## Configured URLSession for Production
|
|
1088
|
-
|
|
1089
|
-
```swift
|
|
1090
|
-
enum SessionFactory {
|
|
1091
|
-
static func makeDefault(delegate: (any URLSessionDelegate)? = nil) -> URLSession {
|
|
1092
|
-
let config = URLSessionConfiguration.default
|
|
1093
|
-
config.timeoutIntervalForRequest = 30
|
|
1094
|
-
config.timeoutIntervalForResource = 300
|
|
1095
|
-
config.waitsForConnectivity = true
|
|
1096
|
-
config.httpMaximumConnectionsPerHost = 6
|
|
1097
|
-
config.requestCachePolicy = .useProtocolCachePolicy
|
|
1098
|
-
config.httpAdditionalHeaders = [
|
|
1099
|
-
"Accept": "application/json",
|
|
1100
|
-
"Accept-Encoding": "gzip, deflate, br",
|
|
1101
|
-
]
|
|
1102
|
-
|
|
1103
|
-
let cache = URLCache(
|
|
1104
|
-
memoryCapacity: 25 * 1024 * 1024,
|
|
1105
|
-
diskCapacity: 100 * 1024 * 1024
|
|
1106
|
-
)
|
|
1107
|
-
config.urlCache = cache
|
|
1108
|
-
|
|
1109
|
-
return URLSession(
|
|
1110
|
-
configuration: config,
|
|
1111
|
-
delegate: delegate,
|
|
1112
|
-
delegateQueue: nil
|
|
1113
|
-
)
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
```
|
|
1
|
+
# URLSession Patterns Reference
|
|
2
|
+
|
|
3
|
+
Complete implementation patterns for URLSession-based networking. Each
|
|
4
|
+
section is self-contained with production-ready code.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Contents
|
|
9
|
+
|
|
10
|
+
- [Complete API Client with Protocol](#complete-api-client-with-protocol)
|
|
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.
|
|
28
|
+
|
|
29
|
+
### Protocol
|
|
30
|
+
|
|
31
|
+
```swift
|
|
32
|
+
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
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Endpoint Definition
|
|
49
|
+
|
|
50
|
+
```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"
|
|
66
|
+
}
|
|
67
|
+
|
|
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
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Client Implementation
|
|
90
|
+
|
|
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
|
|
121
|
+
}
|
|
122
|
+
|
|
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)
|
|
131
|
+
}
|
|
132
|
+
|
|
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)
|
|
137
|
+
}
|
|
138
|
+
|
|
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)
|
|
149
|
+
}
|
|
150
|
+
|
|
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
|
+
))
|
|
163
|
+
}
|
|
164
|
+
|
|
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
|
+
))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func delete(path: String) async throws {
|
|
180
|
+
try await request(endpoint: Endpoint(path: path, method: .delete))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// MARK: - Internal
|
|
184
|
+
|
|
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
|
|
191
|
+
}
|
|
192
|
+
|
|
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
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Error Types
|
|
210
|
+
|
|
211
|
+
```swift
|
|
212
|
+
enum NetworkError: Error, Sendable, LocalizedError {
|
|
213
|
+
case invalidResponse
|
|
214
|
+
case httpError(statusCode: Int, data: Data, message: String? = nil)
|
|
215
|
+
case noConnection
|
|
216
|
+
case timedOut
|
|
217
|
+
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
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
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
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
struct APIErrorBody: Decodable, Sendable {
|
|
249
|
+
let code: String?
|
|
250
|
+
let message: String?
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Request Builder Pattern
|
|
257
|
+
|
|
258
|
+
For complex request construction, a builder provides a fluent API that
|
|
259
|
+
reduces errors.
|
|
260
|
+
|
|
261
|
+
```swift
|
|
262
|
+
struct RequestBuilder: Sendable {
|
|
263
|
+
private var method: String = "GET"
|
|
264
|
+
private var path: String
|
|
265
|
+
private var baseURL: URL
|
|
266
|
+
private var queryItems: [URLQueryItem] = []
|
|
267
|
+
private var headers: [String: String] = [:]
|
|
268
|
+
private var body: Data?
|
|
269
|
+
private var cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy
|
|
270
|
+
private var timeout: TimeInterval = 30
|
|
271
|
+
|
|
272
|
+
init(baseURL: URL, path: String) {
|
|
273
|
+
self.baseURL = baseURL
|
|
274
|
+
self.path = path
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
func method(_ method: String) -> RequestBuilder {
|
|
278
|
+
var copy = self
|
|
279
|
+
copy.method = method
|
|
280
|
+
return copy
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
func query(_ name: String, _ value: String?) -> RequestBuilder {
|
|
284
|
+
guard let value else { return self }
|
|
285
|
+
var copy = self
|
|
286
|
+
copy.queryItems.append(URLQueryItem(name: name, value: value))
|
|
287
|
+
return copy
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
func header(_ name: String, _ value: String) -> RequestBuilder {
|
|
291
|
+
var copy = self
|
|
292
|
+
copy.headers[name] = value
|
|
293
|
+
return copy
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
func jsonBody<T: Encodable>(_ value: T) throws -> RequestBuilder {
|
|
297
|
+
var copy = self
|
|
298
|
+
copy.body = try JSONEncoder().encode(value)
|
|
299
|
+
copy.headers["Content-Type"] = "application/json"
|
|
300
|
+
return copy
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
func timeout(_ interval: TimeInterval) -> RequestBuilder {
|
|
304
|
+
var copy = self
|
|
305
|
+
copy.timeout = interval
|
|
306
|
+
return copy
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
func cachePolicy(_ policy: URLRequest.CachePolicy) -> RequestBuilder {
|
|
310
|
+
var copy = self
|
|
311
|
+
copy.cachePolicy = policy
|
|
312
|
+
return copy
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
func build() -> URLRequest {
|
|
316
|
+
var components = URLComponents(
|
|
317
|
+
url: baseURL.appendingPathComponent(path),
|
|
318
|
+
resolvingAgainstBaseURL: true
|
|
319
|
+
)!
|
|
320
|
+
if !queryItems.isEmpty {
|
|
321
|
+
components.queryItems = queryItems
|
|
322
|
+
}
|
|
323
|
+
var request = URLRequest(url: components.url!)
|
|
324
|
+
request.httpMethod = method
|
|
325
|
+
request.httpBody = body
|
|
326
|
+
request.cachePolicy = cachePolicy
|
|
327
|
+
request.timeoutInterval = timeout
|
|
328
|
+
for (key, value) in headers {
|
|
329
|
+
request.setValue(value, forHTTPHeaderField: key)
|
|
330
|
+
}
|
|
331
|
+
return request
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Usage
|
|
336
|
+
let request = try RequestBuilder(baseURL: apiURL, path: "users")
|
|
337
|
+
.method("POST")
|
|
338
|
+
.header("X-Request-ID", UUID().uuidString)
|
|
339
|
+
.jsonBody(CreateUserRequest(name: "Alice", email: "alice@example.com"))
|
|
340
|
+
.timeout(15)
|
|
341
|
+
.build()
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
## Multipart Form Upload
|
|
347
|
+
|
|
348
|
+
Multipart/form-data uploads are common for file attachments. Build the
|
|
349
|
+
body manually -- no third-party library needed.
|
|
350
|
+
|
|
351
|
+
```swift
|
|
352
|
+
struct MultipartFormData: Sendable {
|
|
353
|
+
private let boundary: String
|
|
354
|
+
private var parts: [Part] = []
|
|
355
|
+
|
|
356
|
+
init(boundary: String = UUID().uuidString) {
|
|
357
|
+
self.boundary = boundary
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
var contentType: String {
|
|
361
|
+
"multipart/form-data; boundary=\(boundary)"
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
mutating func addField(name: String, value: String) {
|
|
365
|
+
parts.append(Part(
|
|
366
|
+
headers: "Content-Disposition: form-data; name=\"\(name)\"",
|
|
367
|
+
body: Data(value.utf8)
|
|
368
|
+
))
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
mutating func addFile(
|
|
372
|
+
name: String,
|
|
373
|
+
filename: String,
|
|
374
|
+
mimeType: String,
|
|
375
|
+
data: Data
|
|
376
|
+
) {
|
|
377
|
+
parts.append(Part(
|
|
378
|
+
headers: """
|
|
379
|
+
Content-Disposition: form-data; name="\(name)"; filename="\(filename)"\r
|
|
380
|
+
Content-Type: \(mimeType)
|
|
381
|
+
""",
|
|
382
|
+
body: data
|
|
383
|
+
))
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
func encode() -> Data {
|
|
387
|
+
var data = Data()
|
|
388
|
+
let crlf = "\r\n"
|
|
389
|
+
for part in parts {
|
|
390
|
+
data.append("--\(boundary)\(crlf)")
|
|
391
|
+
data.append("\(part.headers)\(crlf)\(crlf)")
|
|
392
|
+
data.append(part.body)
|
|
393
|
+
data.append(crlf)
|
|
394
|
+
}
|
|
395
|
+
data.append("--\(boundary)--\(crlf)")
|
|
396
|
+
return data
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private struct Part: Sendable {
|
|
400
|
+
let headers: String
|
|
401
|
+
let body: Data
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
extension Data {
|
|
406
|
+
mutating func append(_ string: String) {
|
|
407
|
+
append(Data(string.utf8))
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Usage
|
|
412
|
+
var form = MultipartFormData()
|
|
413
|
+
form.addField(name: "title", value: "Profile Photo")
|
|
414
|
+
form.addFile(
|
|
415
|
+
name: "image",
|
|
416
|
+
filename: "photo.jpg",
|
|
417
|
+
mimeType: "image/jpeg",
|
|
418
|
+
data: imageData
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
var request = URLRequest(url: uploadURL)
|
|
422
|
+
request.httpMethod = "POST"
|
|
423
|
+
request.setValue(form.contentType, forHTTPHeaderField: "Content-Type")
|
|
424
|
+
request.httpBody = form.encode()
|
|
425
|
+
|
|
426
|
+
let (data, response) = try await URLSession.shared.upload(
|
|
427
|
+
for: request,
|
|
428
|
+
from: form.encode()
|
|
429
|
+
)
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
## Download with Progress Tracking
|
|
435
|
+
|
|
436
|
+
Use `bytes(for:)` for real-time progress. The response includes
|
|
437
|
+
`expectedContentLength` for calculating percentage.
|
|
438
|
+
|
|
439
|
+
```swift
|
|
440
|
+
@available(iOS 15.0, *)
|
|
441
|
+
func downloadWithProgress(
|
|
442
|
+
from url: URL,
|
|
443
|
+
progressHandler: @Sendable (Double) -> Void
|
|
444
|
+
) async throws -> Data {
|
|
445
|
+
let (bytes, response) = try await URLSession.shared.bytes(from: url)
|
|
446
|
+
|
|
447
|
+
let expectedLength = response.expectedContentLength
|
|
448
|
+
var receivedData = Data()
|
|
449
|
+
if expectedLength > 0 {
|
|
450
|
+
receivedData.reserveCapacity(Int(expectedLength))
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
var receivedLength: Int64 = 0
|
|
454
|
+
for try await byte in bytes {
|
|
455
|
+
receivedData.append(byte)
|
|
456
|
+
receivedLength += 1
|
|
457
|
+
if expectedLength > 0 {
|
|
458
|
+
let progress = Double(receivedLength) / Double(expectedLength)
|
|
459
|
+
progressHandler(progress)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return receivedData
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
For large files, prefer `URLSessionDownloadTask` with a delegate for
|
|
468
|
+
better memory efficiency and background support. See
|
|
469
|
+
`references/background-websocket.md`.
|
|
470
|
+
|
|
471
|
+
### Download to File with Progress (Delegate-Based)
|
|
472
|
+
|
|
473
|
+
```swift
|
|
474
|
+
@available(iOS 15.0, *)
|
|
475
|
+
final class DownloadManager: NSObject, URLSessionDownloadDelegate, Sendable {
|
|
476
|
+
private let continuation: AsyncStream<DownloadEvent>.Continuation
|
|
477
|
+
|
|
478
|
+
enum DownloadEvent: Sendable {
|
|
479
|
+
case progress(Double)
|
|
480
|
+
case completed(URL)
|
|
481
|
+
case failed(Error)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
static func download(from url: URL) -> AsyncStream<DownloadEvent> {
|
|
485
|
+
AsyncStream { continuation in
|
|
486
|
+
let manager = DownloadManager(continuation: continuation)
|
|
487
|
+
let session = URLSession(
|
|
488
|
+
configuration: .default,
|
|
489
|
+
delegate: manager,
|
|
490
|
+
delegateQueue: nil
|
|
491
|
+
)
|
|
492
|
+
session.downloadTask(with: url).resume()
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private init(continuation: AsyncStream<DownloadEvent>.Continuation) {
|
|
497
|
+
self.continuation = continuation
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
nonisolated func urlSession(
|
|
501
|
+
_ session: URLSession,
|
|
502
|
+
downloadTask: URLSessionDownloadTask,
|
|
503
|
+
didFinishDownloadingTo location: URL
|
|
504
|
+
) {
|
|
505
|
+
// Move file to permanent location before this method returns
|
|
506
|
+
let destination = FileManager.default.temporaryDirectory
|
|
507
|
+
.appendingPathComponent(UUID().uuidString)
|
|
508
|
+
do {
|
|
509
|
+
try FileManager.default.moveItem(at: location, to: destination)
|
|
510
|
+
continuation.yield(.completed(destination))
|
|
511
|
+
} catch {
|
|
512
|
+
continuation.yield(.failed(error))
|
|
513
|
+
}
|
|
514
|
+
continuation.finish()
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
nonisolated func urlSession(
|
|
518
|
+
_ session: URLSession,
|
|
519
|
+
downloadTask: URLSessionDownloadTask,
|
|
520
|
+
didWriteData bytesWritten: Int64,
|
|
521
|
+
totalBytesWritten: Int64,
|
|
522
|
+
totalBytesExpectedToWrite: Int64
|
|
523
|
+
) {
|
|
524
|
+
guard totalBytesExpectedToWrite > 0 else { return }
|
|
525
|
+
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
|
526
|
+
continuation.yield(.progress(progress))
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
nonisolated func urlSession(
|
|
530
|
+
_ session: URLSession,
|
|
531
|
+
task: URLSessionTask,
|
|
532
|
+
didCompleteWithError error: (any Error)?
|
|
533
|
+
) {
|
|
534
|
+
if let error {
|
|
535
|
+
continuation.yield(.failed(error))
|
|
536
|
+
continuation.finish()
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
---
|
|
543
|
+
|
|
544
|
+
## Cursor-Based Pagination
|
|
545
|
+
|
|
546
|
+
A reusable paginator that conforms to `AsyncSequence`, yielding pages
|
|
547
|
+
of results until the server indicates no more data.
|
|
548
|
+
|
|
549
|
+
```swift
|
|
550
|
+
struct PageResponse<T: Decodable & Sendable>: Decodable, Sendable {
|
|
551
|
+
let data: [T]
|
|
552
|
+
let pagination: PaginationInfo
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
struct PaginationInfo: Decodable, Sendable {
|
|
556
|
+
let nextCursor: String?
|
|
557
|
+
let hasMore: Bool
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
struct CursorPaginator<T: Decodable & Sendable>: AsyncSequence {
|
|
561
|
+
typealias Element = [T]
|
|
562
|
+
|
|
563
|
+
private let fetchPage: @Sendable (String?) async throws -> PageResponse<T>
|
|
564
|
+
|
|
565
|
+
init(fetchPage: @escaping @Sendable (String?) async throws -> PageResponse<T>) {
|
|
566
|
+
self.fetchPage = fetchPage
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
func makeAsyncIterator() -> Iterator {
|
|
570
|
+
Iterator(fetchPage: fetchPage)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
struct Iterator: AsyncIteratorProtocol {
|
|
574
|
+
private let fetchPage: @Sendable (String?) async throws -> PageResponse<T>
|
|
575
|
+
private var cursor: String?
|
|
576
|
+
private var exhausted = false
|
|
577
|
+
|
|
578
|
+
init(fetchPage: @escaping @Sendable (String?) async throws -> PageResponse<T>) {
|
|
579
|
+
self.fetchPage = fetchPage
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
mutating func next() async throws -> [T]? {
|
|
583
|
+
guard !exhausted else { return nil }
|
|
584
|
+
try Task.checkCancellation()
|
|
585
|
+
|
|
586
|
+
let response = try await fetchPage(cursor)
|
|
587
|
+
cursor = response.pagination.nextCursor
|
|
588
|
+
exhausted = !response.pagination.hasMore
|
|
589
|
+
|
|
590
|
+
return response.data.isEmpty ? nil : response.data
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Usage
|
|
596
|
+
let paginator = CursorPaginator<User> { cursor in
|
|
597
|
+
var queryItems = [URLQueryItem(name: "limit", value: "50")]
|
|
598
|
+
if let cursor {
|
|
599
|
+
queryItems.append(URLQueryItem(name: "cursor", value: cursor))
|
|
600
|
+
}
|
|
601
|
+
return try await client.get(
|
|
602
|
+
PageResponse<User>.self,
|
|
603
|
+
path: "users",
|
|
604
|
+
queryItems: queryItems
|
|
605
|
+
)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
var allUsers: [User] = []
|
|
609
|
+
for try await batch in paginator {
|
|
610
|
+
allUsers.append(contentsOf: batch)
|
|
611
|
+
}
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
---
|
|
615
|
+
|
|
616
|
+
## Offset-Based Pagination
|
|
617
|
+
|
|
618
|
+
```swift
|
|
619
|
+
struct OffsetPaginator<T: Decodable & Sendable>: AsyncSequence {
|
|
620
|
+
typealias Element = [T]
|
|
621
|
+
|
|
622
|
+
private let pageSize: Int
|
|
623
|
+
private let fetchPage: @Sendable (Int, Int) async throws -> [T]
|
|
624
|
+
|
|
625
|
+
init(
|
|
626
|
+
pageSize: Int = 20,
|
|
627
|
+
fetchPage: @escaping @Sendable (_ offset: Int, _ limit: Int) async throws -> [T]
|
|
628
|
+
) {
|
|
629
|
+
self.pageSize = pageSize
|
|
630
|
+
self.fetchPage = fetchPage
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
func makeAsyncIterator() -> Iterator {
|
|
634
|
+
Iterator(pageSize: pageSize, fetchPage: fetchPage)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
struct Iterator: AsyncIteratorProtocol {
|
|
638
|
+
private let pageSize: Int
|
|
639
|
+
private let fetchPage: @Sendable (Int, Int) async throws -> [T]
|
|
640
|
+
private var offset = 0
|
|
641
|
+
private var exhausted = false
|
|
642
|
+
|
|
643
|
+
init(
|
|
644
|
+
pageSize: Int,
|
|
645
|
+
fetchPage: @escaping @Sendable (Int, Int) async throws -> [T]
|
|
646
|
+
) {
|
|
647
|
+
self.pageSize = pageSize
|
|
648
|
+
self.fetchPage = fetchPage
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
mutating func next() async throws -> [T]? {
|
|
652
|
+
guard !exhausted else { return nil }
|
|
653
|
+
try Task.checkCancellation()
|
|
654
|
+
|
|
655
|
+
let items = try await fetchPage(offset, pageSize)
|
|
656
|
+
offset += items.count
|
|
657
|
+
if items.count < pageSize { exhausted = true }
|
|
658
|
+
|
|
659
|
+
return items.isEmpty ? nil : items
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
---
|
|
666
|
+
|
|
667
|
+
## URLProtocol Mock for Testing
|
|
668
|
+
|
|
669
|
+
`URLProtocol` is the correct way to mock network responses at the
|
|
670
|
+
transport level. It works with any URLSession configuration and does
|
|
671
|
+
not require changing production code.
|
|
672
|
+
|
|
673
|
+
```swift
|
|
674
|
+
final class MockURLProtocol: URLProtocol {
|
|
675
|
+
nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
|
|
676
|
+
|
|
677
|
+
override class func canInit(with request: URLRequest) -> Bool { true }
|
|
678
|
+
|
|
679
|
+
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
|
680
|
+
|
|
681
|
+
override func startLoading() {
|
|
682
|
+
guard let handler = Self.requestHandler else {
|
|
683
|
+
fatalError("MockURLProtocol.requestHandler is not set")
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
do {
|
|
687
|
+
let (response, data) = try handler(request)
|
|
688
|
+
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
|
689
|
+
client?.urlProtocol(self, didLoad: data)
|
|
690
|
+
client?.urlProtocolDidFinishLoading(self)
|
|
691
|
+
} catch {
|
|
692
|
+
client?.urlProtocol(self, didFailWithError: error)
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
override func stopLoading() {}
|
|
697
|
+
}
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
### Test Setup
|
|
701
|
+
|
|
702
|
+
```swift
|
|
703
|
+
import Testing
|
|
704
|
+
|
|
705
|
+
@Suite struct APIClientTests {
|
|
706
|
+
let client: APIClient
|
|
707
|
+
let session: URLSession
|
|
708
|
+
|
|
709
|
+
init() {
|
|
710
|
+
let config = URLSessionConfiguration.ephemeral
|
|
711
|
+
config.protocolClasses = [MockURLProtocol.self]
|
|
712
|
+
session = URLSession(configuration: config)
|
|
713
|
+
client = APIClient(
|
|
714
|
+
baseURL: URL(string: "https://api.example.com")!,
|
|
715
|
+
session: session
|
|
716
|
+
)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
@Test func fetchUsersDecodesCorrectly() async throws {
|
|
720
|
+
let usersJSON = """
|
|
721
|
+
[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
|
|
722
|
+
"""
|
|
723
|
+
MockURLProtocol.requestHandler = { request in
|
|
724
|
+
#expect(request.url?.path == "/users")
|
|
725
|
+
let response = HTTPURLResponse(
|
|
726
|
+
url: request.url!,
|
|
727
|
+
statusCode: 200,
|
|
728
|
+
httpVersion: nil,
|
|
729
|
+
headerFields: ["Content-Type": "application/json"]
|
|
730
|
+
)!
|
|
731
|
+
return (response, Data(usersJSON.utf8))
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
let users: [User] = try await client.get([User].self, path: "users")
|
|
735
|
+
#expect(users.count == 2)
|
|
736
|
+
#expect(users[0].name == "Alice")
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
@Test func fetchReturnsHTTPError() async throws {
|
|
740
|
+
MockURLProtocol.requestHandler = { request in
|
|
741
|
+
let response = HTTPURLResponse(
|
|
742
|
+
url: request.url!,
|
|
743
|
+
statusCode: 404,
|
|
744
|
+
httpVersion: nil,
|
|
745
|
+
headerFields: nil
|
|
746
|
+
)!
|
|
747
|
+
return (response, Data())
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
await #expect(throws: NetworkError.self) {
|
|
751
|
+
let _: [User] = try await client.get([User].self, path: "missing")
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
@Test func requestIncludesAuthHeader() async throws {
|
|
756
|
+
let authClient = APIClient(
|
|
757
|
+
baseURL: URL(string: "https://api.example.com")!,
|
|
758
|
+
session: session,
|
|
759
|
+
middlewares: [AuthMiddleware { "test-token" }]
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
MockURLProtocol.requestHandler = { request in
|
|
763
|
+
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token")
|
|
764
|
+
let response = HTTPURLResponse(
|
|
765
|
+
url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil
|
|
766
|
+
)!
|
|
767
|
+
return (response, Data("{}".utf8))
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
let _: EmptyResponse = try await authClient.get(EmptyResponse.self, path: "me")
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
struct EmptyResponse: Decodable, Sendable {}
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
---
|
|
778
|
+
|
|
779
|
+
## Retry with Exponential Backoff
|
|
780
|
+
|
|
781
|
+
Respect cancellation. Do not retry client errors (4xx except 429 rate
|
|
782
|
+
limiting). Include jitter to prevent thundering herd.
|
|
783
|
+
|
|
784
|
+
```swift
|
|
785
|
+
func withRetry<T: Sendable>(
|
|
786
|
+
maxAttempts: Int = 3,
|
|
787
|
+
initialDelay: Duration = .seconds(1),
|
|
788
|
+
maxDelay: Duration = .seconds(30),
|
|
789
|
+
shouldRetry: @Sendable (Error) -> Bool = { error in
|
|
790
|
+
if error is CancellationError { return false }
|
|
791
|
+
if case NetworkError.httpError(let code, _, _) = error {
|
|
792
|
+
return code >= 500 || code == 429
|
|
793
|
+
}
|
|
794
|
+
if let urlError = error as? URLError {
|
|
795
|
+
return [.timedOut, .networkConnectionLost, .notConnectedToInternet]
|
|
796
|
+
.contains(urlError.code)
|
|
797
|
+
}
|
|
798
|
+
return false
|
|
799
|
+
},
|
|
800
|
+
operation: @Sendable () async throws -> T
|
|
801
|
+
) async throws -> T {
|
|
802
|
+
var lastError: Error?
|
|
803
|
+
|
|
804
|
+
for attempt in 0..<maxAttempts {
|
|
805
|
+
try Task.checkCancellation()
|
|
806
|
+
do {
|
|
807
|
+
return try await operation()
|
|
808
|
+
} catch {
|
|
809
|
+
lastError = error
|
|
810
|
+
guard shouldRetry(error), attempt < maxAttempts - 1 else {
|
|
811
|
+
throw error
|
|
812
|
+
}
|
|
813
|
+
// Exponential backoff with jitter
|
|
814
|
+
let base = Double(initialDelay.components.seconds) * pow(2.0, Double(attempt))
|
|
815
|
+
let capped = min(base, Double(maxDelay.components.seconds))
|
|
816
|
+
let jitter = Double.random(in: 0...(capped * 0.1))
|
|
817
|
+
let delay = Duration.seconds(capped + jitter)
|
|
818
|
+
try await Task.sleep(for: delay)
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
throw lastError!
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Usage
|
|
826
|
+
let users = try await withRetry {
|
|
827
|
+
try await client.get([User].self, path: "users")
|
|
828
|
+
}
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
---
|
|
832
|
+
|
|
833
|
+
## Certificate Pinning (URLSessionDelegate)
|
|
834
|
+
|
|
835
|
+
Pin the server's public key hash rather than the certificate itself.
|
|
836
|
+
Certificates rotate; public keys are more stable. Always include a
|
|
837
|
+
backup pin.
|
|
838
|
+
|
|
839
|
+
```swift
|
|
840
|
+
import CryptoKit
|
|
841
|
+
|
|
842
|
+
final class PinningDelegate: NSObject, URLSessionDelegate, Sendable {
|
|
843
|
+
/// SHA-256 hashes of Subject Public Key Info (SPKI) in base64
|
|
844
|
+
private let pinnedKeyHashes: Set<String>
|
|
845
|
+
|
|
846
|
+
init(pinnedKeyHashes: Set<String>) {
|
|
847
|
+
self.pinnedKeyHashes = pinnedKeyHashes
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
nonisolated func urlSession(
|
|
851
|
+
_ session: URLSession,
|
|
852
|
+
didReceive challenge: URLAuthenticationChallenge
|
|
853
|
+
) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
|
|
854
|
+
guard challenge.protectionSpace.authenticationMethod
|
|
855
|
+
== NSURLAuthenticationMethodServerTrust,
|
|
856
|
+
let trust = challenge.protectionSpace.serverTrust else {
|
|
857
|
+
return (.performDefaultHandling, nil)
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Evaluate the trust chain
|
|
861
|
+
var error: CFError?
|
|
862
|
+
guard SecTrustEvaluateWithError(trust, &error) else {
|
|
863
|
+
return (.cancelAuthenticationChallenge, nil)
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Extract the leaf certificate's public key
|
|
867
|
+
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
|
|
868
|
+
let leafCert = chain.first,
|
|
869
|
+
let publicKey = SecCertificateCopyKey(leafCert),
|
|
870
|
+
let publicKeyData = SecKeyCopyExternalRepresentation(
|
|
871
|
+
publicKey, nil
|
|
872
|
+
) as Data? else {
|
|
873
|
+
return (.cancelAuthenticationChallenge, nil)
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
let keyHash = SHA256.hash(data: publicKeyData)
|
|
877
|
+
let hashString = Data(keyHash).base64EncodedString()
|
|
878
|
+
|
|
879
|
+
if pinnedKeyHashes.contains(hashString) {
|
|
880
|
+
return (.useCredential, URLCredential(trust: trust))
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
return (.cancelAuthenticationChallenge, nil)
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Usage
|
|
888
|
+
let delegate = PinningDelegate(pinnedKeyHashes: [
|
|
889
|
+
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=", // Primary
|
|
890
|
+
"CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=", // Backup
|
|
891
|
+
])
|
|
892
|
+
|
|
893
|
+
let session = URLSession(
|
|
894
|
+
configuration: .default,
|
|
895
|
+
delegate: delegate,
|
|
896
|
+
delegateQueue: nil
|
|
897
|
+
)
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
**Important considerations:**
|
|
901
|
+
- Pin at least two keys (primary + backup) to avoid lockout during rotation.
|
|
902
|
+
- Have a remote kill switch (feature flag) to disable pinning in emergencies.
|
|
903
|
+
- Test certificate rotation in staging before deploying to production.
|
|
904
|
+
- Do not pin intermediate CA certificates -- they rotate more frequently.
|
|
905
|
+
|
|
906
|
+
---
|
|
907
|
+
|
|
908
|
+
## Request Logging / Debugging Middleware
|
|
909
|
+
|
|
910
|
+
Log outgoing requests and incoming responses for debugging. Disable or
|
|
911
|
+
reduce verbosity in release builds.
|
|
912
|
+
|
|
913
|
+
```swift
|
|
914
|
+
struct LoggingMiddleware: RequestMiddleware {
|
|
915
|
+
let logger: Logger
|
|
916
|
+
|
|
917
|
+
func prepare(_ request: URLRequest) async throws -> URLRequest {
|
|
918
|
+
#if DEBUG
|
|
919
|
+
let method = request.httpMethod ?? "GET"
|
|
920
|
+
let url = request.url?.absoluteString ?? "unknown"
|
|
921
|
+
logger.debug("[\(method)] \(url)")
|
|
922
|
+
if let headers = request.allHTTPHeaderFields {
|
|
923
|
+
for (key, value) in headers where key != "Authorization" {
|
|
924
|
+
logger.debug(" \(key): \(value)")
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
if let body = request.httpBody, body.count < 10_000 {
|
|
928
|
+
logger.debug(" Body: \(String(data: body, encoding: .utf8) ?? "<binary>")")
|
|
929
|
+
}
|
|
930
|
+
#endif
|
|
931
|
+
return request
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
### Response Logging
|
|
937
|
+
|
|
938
|
+
To log responses, wrap the transport call rather than using middleware:
|
|
939
|
+
|
|
940
|
+
```swift
|
|
941
|
+
func loggedRequest<T: Decodable & Sendable>(
|
|
942
|
+
_ type: T.Type,
|
|
943
|
+
endpoint: Endpoint,
|
|
944
|
+
logger: Logger
|
|
945
|
+
) async throws -> T {
|
|
946
|
+
let start = ContinuousClock().now
|
|
947
|
+
do {
|
|
948
|
+
let result: T = try await request(type, endpoint: endpoint)
|
|
949
|
+
let elapsed = ContinuousClock().now - start
|
|
950
|
+
logger.debug("[\(endpoint.method.rawValue)] \(endpoint.path) -> 200 (\(elapsed))")
|
|
951
|
+
return result
|
|
952
|
+
} catch {
|
|
953
|
+
let elapsed = ContinuousClock().now - start
|
|
954
|
+
logger.error("[\(endpoint.method.rawValue)] \(endpoint.path) -> ERROR (\(elapsed)): \(error)")
|
|
955
|
+
throw error
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
---
|
|
961
|
+
|
|
962
|
+
## Request Caching Strategies
|
|
963
|
+
|
|
964
|
+
### URLCache Configuration
|
|
965
|
+
|
|
966
|
+
```swift
|
|
967
|
+
// 50 MB memory / 200 MB disk cache
|
|
968
|
+
let cache = URLCache(
|
|
969
|
+
memoryCapacity: 50 * 1024 * 1024,
|
|
970
|
+
diskCapacity: 200 * 1024 * 1024,
|
|
971
|
+
directory: FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
|
|
972
|
+
.first?.appendingPathComponent("URLCache")
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
let config = URLSessionConfiguration.default
|
|
976
|
+
config.urlCache = cache
|
|
977
|
+
config.requestCachePolicy = .returnCacheDataElseLoad
|
|
978
|
+
|
|
979
|
+
let session = URLSession(configuration: config)
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
### Per-Request Cache Control
|
|
983
|
+
|
|
984
|
+
```swift
|
|
985
|
+
// Force fresh data
|
|
986
|
+
var request = URLRequest(url: url)
|
|
987
|
+
request.cachePolicy = .reloadIgnoringLocalCacheData
|
|
988
|
+
|
|
989
|
+
// Use cached if available
|
|
990
|
+
request.cachePolicy = .returnCacheDataElseLoad
|
|
991
|
+
|
|
992
|
+
// Cache only (offline mode)
|
|
993
|
+
request.cachePolicy = .returnCacheDataDontLoad
|
|
994
|
+
```
|
|
995
|
+
|
|
996
|
+
### ETag / If-None-Match
|
|
997
|
+
|
|
998
|
+
```swift
|
|
999
|
+
func fetchWithETag<T: Decodable & Sendable>(
|
|
1000
|
+
_ type: T.Type,
|
|
1001
|
+
url: URL,
|
|
1002
|
+
cachedETag: String?,
|
|
1003
|
+
cachedData: Data?
|
|
1004
|
+
) async throws -> (T, String?) {
|
|
1005
|
+
var request = URLRequest(url: url)
|
|
1006
|
+
if let etag = cachedETag {
|
|
1007
|
+
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
let (data, response) = try await URLSession.shared.data(for: request)
|
|
1011
|
+
guard let http = response as? HTTPURLResponse else {
|
|
1012
|
+
throw NetworkError.invalidResponse
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if http.statusCode == 304, let cachedData {
|
|
1016
|
+
// Not modified -- use cached data
|
|
1017
|
+
let decoded = try JSONDecoder().decode(T.self, from: cachedData)
|
|
1018
|
+
return (decoded, cachedETag)
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
let newETag = http.value(forHTTPHeaderField: "ETag")
|
|
1022
|
+
let decoded = try JSONDecoder().decode(T.self, from: data)
|
|
1023
|
+
return (decoded, newETag)
|
|
1024
|
+
}
|
|
1025
|
+
```
|
|
1026
|
+
|
|
1027
|
+
---
|
|
1028
|
+
|
|
1029
|
+
## Server-Sent Events (SSE) Parsing
|
|
1030
|
+
|
|
1031
|
+
Use `bytes(for:)` to consume a streaming SSE endpoint.
|
|
1032
|
+
|
|
1033
|
+
```swift
|
|
1034
|
+
struct ServerSentEvent: Sendable {
|
|
1035
|
+
var event: String?
|
|
1036
|
+
var data: String
|
|
1037
|
+
var id: String?
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
func sseStream(from url: URL) -> AsyncThrowingStream<ServerSentEvent, Error> {
|
|
1041
|
+
AsyncThrowingStream { continuation in
|
|
1042
|
+
let task = Task {
|
|
1043
|
+
do {
|
|
1044
|
+
var request = URLRequest(url: url)
|
|
1045
|
+
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
|
1046
|
+
|
|
1047
|
+
let (bytes, _) = try await URLSession.shared.bytes(for: request)
|
|
1048
|
+
|
|
1049
|
+
var currentEvent: String?
|
|
1050
|
+
var currentData = ""
|
|
1051
|
+
var currentId: String?
|
|
1052
|
+
|
|
1053
|
+
for try await line in bytes.lines {
|
|
1054
|
+
if line.isEmpty {
|
|
1055
|
+
// Empty line = dispatch event
|
|
1056
|
+
if !currentData.isEmpty {
|
|
1057
|
+
continuation.yield(ServerSentEvent(
|
|
1058
|
+
event: currentEvent,
|
|
1059
|
+
data: currentData.trimmingCharacters(in: .newlines),
|
|
1060
|
+
id: currentId
|
|
1061
|
+
))
|
|
1062
|
+
}
|
|
1063
|
+
currentEvent = nil
|
|
1064
|
+
currentData = ""
|
|
1065
|
+
currentId = nil
|
|
1066
|
+
} else if line.hasPrefix("event:") {
|
|
1067
|
+
currentEvent = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces)
|
|
1068
|
+
} else if line.hasPrefix("data:") {
|
|
1069
|
+
let value = String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces)
|
|
1070
|
+
currentData += currentData.isEmpty ? value : "\n" + value
|
|
1071
|
+
} else if line.hasPrefix("id:") {
|
|
1072
|
+
currentId = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
continuation.finish()
|
|
1076
|
+
} catch {
|
|
1077
|
+
continuation.finish(throwing: error)
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
continuation.onTermination = { _ in task.cancel() }
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
```
|
|
1084
|
+
|
|
1085
|
+
---
|
|
1086
|
+
|
|
1087
|
+
## Configured URLSession for Production
|
|
1088
|
+
|
|
1089
|
+
```swift
|
|
1090
|
+
enum SessionFactory {
|
|
1091
|
+
static func makeDefault(delegate: (any URLSessionDelegate)? = nil) -> URLSession {
|
|
1092
|
+
let config = URLSessionConfiguration.default
|
|
1093
|
+
config.timeoutIntervalForRequest = 30
|
|
1094
|
+
config.timeoutIntervalForResource = 300
|
|
1095
|
+
config.waitsForConnectivity = true
|
|
1096
|
+
config.httpMaximumConnectionsPerHost = 6
|
|
1097
|
+
config.requestCachePolicy = .useProtocolCachePolicy
|
|
1098
|
+
config.httpAdditionalHeaders = [
|
|
1099
|
+
"Accept": "application/json",
|
|
1100
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
1101
|
+
]
|
|
1102
|
+
|
|
1103
|
+
let cache = URLCache(
|
|
1104
|
+
memoryCapacity: 25 * 1024 * 1024,
|
|
1105
|
+
diskCapacity: 100 * 1024 * 1024
|
|
1106
|
+
)
|
|
1107
|
+
config.urlCache = cache
|
|
1108
|
+
|
|
1109
|
+
return URLSession(
|
|
1110
|
+
configuration: config,
|
|
1111
|
+
delegate: delegate,
|
|
1112
|
+
delegateQueue: nil
|
|
1113
|
+
)
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
```
|