├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ └── docc.yml ├── .gitignore ├── .swiftlint.yml ├── .swiftpm └── xcode │ └── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDETemplateMacros.plist ├── Demo ├── Demo.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcuserdata │ │ └── danielsaidi.xcuserdatad │ │ ├── xcdebugger │ │ └── Breakpoints_v2.xcbkptlist │ │ └── xcschemes │ │ └── xcschememanagement.plist └── Demo │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon-Vision.solidimagestack │ │ ├── Back.solidimagestacklayer │ │ │ ├── Content.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── Icon-visionOS-Back.png │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── Front.solidimagestacklayer │ │ │ ├── Content.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── Icon-visionOS-Front.png │ │ │ └── Contents.json │ │ └── Middle.solidimagestacklayer │ │ │ ├── Content.imageset │ │ │ ├── Contents.json │ │ │ └── Icon-visionOS-Middle.png │ │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-iOS-1024.png │ │ ├── Icon-macOS-1024.png │ │ ├── Icon-macOS-128.png │ │ ├── Icon-macOS-16.png │ │ ├── Icon-macOS-256.png │ │ ├── Icon-macOS-32.png │ │ ├── Icon-macOS-512.png │ │ └── Icon-macOS-64.png │ └── Contents.json │ ├── ContentView.swift │ ├── Demo.entitlements │ ├── DemoApp.swift │ ├── DemoKeys.swift │ ├── DemoScreen.swift │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ └── Screens │ └── TheMovieDbScreen.swift ├── LICENSE ├── Package.swift ├── README.md ├── RELEASE_NOTES.md ├── Resources └── Icon.png ├── Sources └── ApiKit │ ├── ApiClient.swift │ ├── ApiEnvironment.swift │ ├── ApiError.swift │ ├── ApiKit.docc │ ├── ApiKit.md │ ├── Getting-Started.md │ └── Resources │ │ ├── Icon.png │ │ ├── Logo.png │ │ └── Page.png │ ├── ApiModel.swift │ ├── ApiRequest.swift │ ├── ApiResult.swift │ ├── ApiRoute.swift │ ├── Extensions │ ├── Int+HttpStatusCodes.swift │ └── String+UrlEncode.swift │ ├── Http │ └── HttpMethod.swift │ └── Integrations │ ├── TheMovieDb │ ├── TheMovieDb+Environment.swift │ ├── TheMovieDb+Models.swift │ ├── TheMovieDb+Route.swift │ └── TheMovieDb.swift │ └── Yelp │ ├── Yelp+Environment.swift │ ├── Yelp+Models.swift │ ├── Yelp+Route.swift │ └── Yelp.swift ├── Tests └── ApiKitTests │ ├── ApiClientTests.swift │ ├── ApiEnvironmentTests.swift │ ├── ApiRequestDataTests.swift │ ├── ApiRouteTests.swift │ ├── Extensions │ └── Int+HttpStatusCodesTests.swift │ ├── HttpMethodTests.swift │ └── TestTypes.swift ├── package_version.sh └── scripts ├── build.sh ├── chmod.sh ├── docc.sh ├── framework.sh ├── git_default_branch.sh ├── package_docc.sh ├── package_framework.sh ├── package_name.sh ├── package_version.sh ├── sync_from.sh ├── test.sh ├── version.sh ├── version_bump.sh ├── version_number.sh ├── version_validate_git.sh └── version_validate_target.sh /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [danielsaidi] 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow builds and tests the project. 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Build Runner 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | pull_request: 10 | branches: ["main"] 11 | 12 | jobs: 13 | build: 14 | runs-on: macos-15 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: maxim-lobanov/setup-xcode@v1 18 | with: 19 | xcode-version: latest-stable 20 | - name: Build all platforms 21 | run: bash scripts/build.sh ${{ github.event.repository.name }} 22 | - name: Test iOS 23 | run: bash scripts/test.sh ${{ github.event.repository.name }} 24 | -------------------------------------------------------------------------------- /.github/workflows/docc.yml: -------------------------------------------------------------------------------- 1 | # This workflow builds publish DocC docs to GitHub Pages. 2 | # Source: https://maxxfrazer.medium.com/deploying-docc-with-github-actions-218c5ca6cad5 3 | # Sample: https://github.com/AgoraIO-Community/VideoUIKit-iOS/blob/main/.github/workflows/deploy_docs.yml 4 | 5 | name: DocC Runner 6 | 7 | on: 8 | push: 9 | branches: ["main"] 10 | 11 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 12 | permissions: 13 | contents: read 14 | pages: write 15 | id-token: write 16 | 17 | # Allow one concurrent deployment 18 | concurrency: 19 | group: "pages" 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | deploy: 24 | environment: 25 | name: github-pages 26 | url: ${{ steps.deployment.outputs.page_url }} 27 | runs-on: macos-15 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v3 31 | - id: pages 32 | name: Setup Pages 33 | uses: actions/configure-pages@v4 34 | - name: Select Xcode version 35 | uses: maxim-lobanov/setup-xcode@v1 36 | with: 37 | xcode-version: latest-stable 38 | - name: Build DocC 39 | run: bash scripts/docc.sh ${{ github.event.repository.name }} 40 | - name: Upload artifact 41 | uses: actions/upload-pages-artifact@v3 42 | with: 43 | path: '.build/docs-iOS' 44 | - id: deployment 45 | name: Deploy to GitHub Pages 46 | uses: actions/deploy-pages@v4 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPM defaults 2 | .DS_Store 3 | /.build 4 | /Packages 5 | .swiftpm/ 6 | xcuserdata/ 7 | DerivedData/ 8 | 9 | **/*.xcuserstate -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - identifier_name 3 | - large_tuple 4 | - line_length 5 | - nesting 6 | - todo 7 | - trailing_whitespace 8 | - type_name 9 | - vertical_whitespace 10 | 11 | included: 12 | - Sources 13 | - Tests 14 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDETemplateMacros.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FILEHEADER 6 | 7 | // ___FILENAME___ 8 | // ApiKit 9 | // 10 | // Created by Daniel Saidi on ___DATE___. 11 | // Copyright © 2023 Daniel Saidi. All rights reserved. 12 | // 13 | 14 | -------------------------------------------------------------------------------- /Demo/Demo.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 60; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | A9B2328A29D346C700B85203 /* DemoScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B2328929D346C700B85203 /* DemoScreen.swift */; }; 11 | A9B2328D29D346F700B85203 /* TheMovieDbScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B2328C29D346F700B85203 /* TheMovieDbScreen.swift */; }; 12 | A9B2328F29D348F300B85203 /* DemoKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B2328E29D348F300B85203 /* DemoKeys.swift */; }; 13 | A9C026AB29D2CEFA00371AE0 /* DemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C026AA29D2CEFA00371AE0 /* DemoApp.swift */; }; 14 | A9C026AD29D2CEFA00371AE0 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C026AC29D2CEFA00371AE0 /* ContentView.swift */; }; 15 | A9C026AF29D2CEFB00371AE0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A9C026AE29D2CEFB00371AE0 /* Assets.xcassets */; }; 16 | A9C026B329D2CEFB00371AE0 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A9C026B229D2CEFB00371AE0 /* Preview Assets.xcassets */; }; 17 | A9C026C029D3228100371AE0 /* ApiKit in Frameworks */ = {isa = PBXBuildFile; productRef = A9C026BF29D3228100371AE0 /* ApiKit */; }; 18 | A9E31BB62CB030D300D1A3AA /* ApiKit in Frameworks */ = {isa = PBXBuildFile; productRef = A9E31BB52CB030D300D1A3AA /* ApiKit */; }; 19 | A9E31BB92CB030F000D1A3AA /* ApiKit in Frameworks */ = {isa = PBXBuildFile; productRef = A9E31BB82CB030F000D1A3AA /* ApiKit */; }; 20 | A9E31BBC2CB0310600D1A3AA /* ApiKit in Frameworks */ = {isa = PBXBuildFile; productRef = A9E31BBB2CB0310600D1A3AA /* ApiKit */; }; 21 | /* End PBXBuildFile section */ 22 | 23 | /* Begin PBXFileReference section */ 24 | A9B2328929D346C700B85203 /* DemoScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoScreen.swift; sourceTree = ""; }; 25 | A9B2328C29D346F700B85203 /* TheMovieDbScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TheMovieDbScreen.swift; sourceTree = ""; }; 26 | A9B2328E29D348F300B85203 /* DemoKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoKeys.swift; sourceTree = ""; }; 27 | A9C026A729D2CEFA00371AE0 /* Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Demo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 28 | A9C026AA29D2CEFA00371AE0 /* DemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoApp.swift; sourceTree = ""; }; 29 | A9C026AC29D2CEFA00371AE0 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 30 | A9C026AE29D2CEFB00371AE0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 31 | A9C026B029D2CEFB00371AE0 /* Demo.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Demo.entitlements; sourceTree = ""; }; 32 | A9C026B229D2CEFB00371AE0 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 33 | /* End PBXFileReference section */ 34 | 35 | /* Begin PBXFrameworksBuildPhase section */ 36 | A9C026A429D2CEFA00371AE0 /* Frameworks */ = { 37 | isa = PBXFrameworksBuildPhase; 38 | buildActionMask = 2147483647; 39 | files = ( 40 | A9E31BBC2CB0310600D1A3AA /* ApiKit in Frameworks */, 41 | A9E31BB62CB030D300D1A3AA /* ApiKit in Frameworks */, 42 | A9C026C029D3228100371AE0 /* ApiKit in Frameworks */, 43 | A9E31BB92CB030F000D1A3AA /* ApiKit in Frameworks */, 44 | ); 45 | runOnlyForDeploymentPostprocessing = 0; 46 | }; 47 | /* End PBXFrameworksBuildPhase section */ 48 | 49 | /* Begin PBXGroup section */ 50 | A9B2328B29D346E900B85203 /* Screens */ = { 51 | isa = PBXGroup; 52 | children = ( 53 | A9B2328C29D346F700B85203 /* TheMovieDbScreen.swift */, 54 | ); 55 | path = Screens; 56 | sourceTree = ""; 57 | }; 58 | A9C0269E29D2CEFA00371AE0 = { 59 | isa = PBXGroup; 60 | children = ( 61 | A9C026A929D2CEFA00371AE0 /* Demo */, 62 | A9C026A829D2CEFA00371AE0 /* Products */, 63 | A9C026BE29D3228100371AE0 /* Frameworks */, 64 | ); 65 | sourceTree = ""; 66 | }; 67 | A9C026A829D2CEFA00371AE0 /* Products */ = { 68 | isa = PBXGroup; 69 | children = ( 70 | A9C026A729D2CEFA00371AE0 /* Demo.app */, 71 | ); 72 | name = Products; 73 | sourceTree = ""; 74 | }; 75 | A9C026A929D2CEFA00371AE0 /* Demo */ = { 76 | isa = PBXGroup; 77 | children = ( 78 | A9B2328B29D346E900B85203 /* Screens */, 79 | A9C026AA29D2CEFA00371AE0 /* DemoApp.swift */, 80 | A9B2328E29D348F300B85203 /* DemoKeys.swift */, 81 | A9B2328929D346C700B85203 /* DemoScreen.swift */, 82 | A9C026AC29D2CEFA00371AE0 /* ContentView.swift */, 83 | A9C026AE29D2CEFB00371AE0 /* Assets.xcassets */, 84 | A9C026B029D2CEFB00371AE0 /* Demo.entitlements */, 85 | A9C026B129D2CEFB00371AE0 /* Preview Content */, 86 | ); 87 | path = Demo; 88 | sourceTree = ""; 89 | }; 90 | A9C026B129D2CEFB00371AE0 /* Preview Content */ = { 91 | isa = PBXGroup; 92 | children = ( 93 | A9C026B229D2CEFB00371AE0 /* Preview Assets.xcassets */, 94 | ); 95 | path = "Preview Content"; 96 | sourceTree = ""; 97 | }; 98 | A9C026BE29D3228100371AE0 /* Frameworks */ = { 99 | isa = PBXGroup; 100 | children = ( 101 | ); 102 | name = Frameworks; 103 | sourceTree = ""; 104 | }; 105 | /* End PBXGroup section */ 106 | 107 | /* Begin PBXNativeTarget section */ 108 | A9C026A629D2CEFA00371AE0 /* Demo */ = { 109 | isa = PBXNativeTarget; 110 | buildConfigurationList = A9C026B629D2CEFB00371AE0 /* Build configuration list for PBXNativeTarget "Demo" */; 111 | buildPhases = ( 112 | A9C026A329D2CEFA00371AE0 /* Sources */, 113 | A9C026A429D2CEFA00371AE0 /* Frameworks */, 114 | A9C026A529D2CEFA00371AE0 /* Resources */, 115 | ); 116 | buildRules = ( 117 | ); 118 | dependencies = ( 119 | ); 120 | name = Demo; 121 | packageProductDependencies = ( 122 | A9C026BF29D3228100371AE0 /* ApiKit */, 123 | A9E31BB52CB030D300D1A3AA /* ApiKit */, 124 | A9E31BB82CB030F000D1A3AA /* ApiKit */, 125 | A9E31BBB2CB0310600D1A3AA /* ApiKit */, 126 | ); 127 | productName = Demo; 128 | productReference = A9C026A729D2CEFA00371AE0 /* Demo.app */; 129 | productType = "com.apple.product-type.application"; 130 | }; 131 | /* End PBXNativeTarget section */ 132 | 133 | /* Begin PBXProject section */ 134 | A9C0269F29D2CEFA00371AE0 /* Project object */ = { 135 | isa = PBXProject; 136 | attributes = { 137 | BuildIndependentTargetsInParallel = 1; 138 | LastSwiftUpdateCheck = 1420; 139 | LastUpgradeCheck = 1530; 140 | ORGANIZATIONNAME = "Daniel Saidi"; 141 | TargetAttributes = { 142 | A9C026A629D2CEFA00371AE0 = { 143 | CreatedOnToolsVersion = 14.2; 144 | }; 145 | }; 146 | }; 147 | buildConfigurationList = A9C026A229D2CEFA00371AE0 /* Build configuration list for PBXProject "Demo" */; 148 | compatibilityVersion = "Xcode 14.0"; 149 | developmentRegion = en; 150 | hasScannedForEncodings = 0; 151 | knownRegions = ( 152 | en, 153 | Base, 154 | ); 155 | mainGroup = A9C0269E29D2CEFA00371AE0; 156 | packageReferences = ( 157 | A9E31BBA2CB0310600D1A3AA /* XCLocalSwiftPackageReference "../../apikit" */, 158 | ); 159 | productRefGroup = A9C026A829D2CEFA00371AE0 /* Products */; 160 | projectDirPath = ""; 161 | projectRoot = ""; 162 | targets = ( 163 | A9C026A629D2CEFA00371AE0 /* Demo */, 164 | ); 165 | }; 166 | /* End PBXProject section */ 167 | 168 | /* Begin PBXResourcesBuildPhase section */ 169 | A9C026A529D2CEFA00371AE0 /* Resources */ = { 170 | isa = PBXResourcesBuildPhase; 171 | buildActionMask = 2147483647; 172 | files = ( 173 | A9C026B329D2CEFB00371AE0 /* Preview Assets.xcassets in Resources */, 174 | A9C026AF29D2CEFB00371AE0 /* Assets.xcassets in Resources */, 175 | ); 176 | runOnlyForDeploymentPostprocessing = 0; 177 | }; 178 | /* End PBXResourcesBuildPhase section */ 179 | 180 | /* Begin PBXSourcesBuildPhase section */ 181 | A9C026A329D2CEFA00371AE0 /* Sources */ = { 182 | isa = PBXSourcesBuildPhase; 183 | buildActionMask = 2147483647; 184 | files = ( 185 | A9C026AD29D2CEFA00371AE0 /* ContentView.swift in Sources */, 186 | A9B2328D29D346F700B85203 /* TheMovieDbScreen.swift in Sources */, 187 | A9B2328A29D346C700B85203 /* DemoScreen.swift in Sources */, 188 | A9C026AB29D2CEFA00371AE0 /* DemoApp.swift in Sources */, 189 | A9B2328F29D348F300B85203 /* DemoKeys.swift in Sources */, 190 | ); 191 | runOnlyForDeploymentPostprocessing = 0; 192 | }; 193 | /* End PBXSourcesBuildPhase section */ 194 | 195 | /* Begin XCBuildConfiguration section */ 196 | A9C026B429D2CEFB00371AE0 /* Debug */ = { 197 | isa = XCBuildConfiguration; 198 | buildSettings = { 199 | ALWAYS_SEARCH_USER_PATHS = NO; 200 | CLANG_ANALYZER_NONNULL = YES; 201 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 202 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 203 | CLANG_ENABLE_MODULES = YES; 204 | CLANG_ENABLE_OBJC_ARC = YES; 205 | CLANG_ENABLE_OBJC_WEAK = YES; 206 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 207 | CLANG_WARN_BOOL_CONVERSION = YES; 208 | CLANG_WARN_COMMA = YES; 209 | CLANG_WARN_CONSTANT_CONVERSION = YES; 210 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 211 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 212 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 213 | CLANG_WARN_EMPTY_BODY = YES; 214 | CLANG_WARN_ENUM_CONVERSION = YES; 215 | CLANG_WARN_INFINITE_RECURSION = YES; 216 | CLANG_WARN_INT_CONVERSION = YES; 217 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 218 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 219 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 220 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 221 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 222 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 223 | CLANG_WARN_STRICT_PROTOTYPES = YES; 224 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 225 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 226 | CLANG_WARN_UNREACHABLE_CODE = YES; 227 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 228 | COPY_PHASE_STRIP = NO; 229 | DEAD_CODE_STRIPPING = YES; 230 | DEBUG_INFORMATION_FORMAT = dwarf; 231 | ENABLE_STRICT_OBJC_MSGSEND = YES; 232 | ENABLE_TESTABILITY = YES; 233 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 234 | GCC_C_LANGUAGE_STANDARD = gnu11; 235 | GCC_DYNAMIC_NO_PIC = NO; 236 | GCC_NO_COMMON_BLOCKS = YES; 237 | GCC_OPTIMIZATION_LEVEL = 0; 238 | GCC_PREPROCESSOR_DEFINITIONS = ( 239 | "DEBUG=1", 240 | "$(inherited)", 241 | ); 242 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 243 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 244 | GCC_WARN_UNDECLARED_SELECTOR = YES; 245 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 246 | GCC_WARN_UNUSED_FUNCTION = YES; 247 | GCC_WARN_UNUSED_VARIABLE = YES; 248 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 249 | MTL_FAST_MATH = YES; 250 | ONLY_ACTIVE_ARCH = YES; 251 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 252 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 253 | }; 254 | name = Debug; 255 | }; 256 | A9C026B529D2CEFB00371AE0 /* Release */ = { 257 | isa = XCBuildConfiguration; 258 | buildSettings = { 259 | ALWAYS_SEARCH_USER_PATHS = NO; 260 | CLANG_ANALYZER_NONNULL = YES; 261 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 262 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 263 | CLANG_ENABLE_MODULES = YES; 264 | CLANG_ENABLE_OBJC_ARC = YES; 265 | CLANG_ENABLE_OBJC_WEAK = YES; 266 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 267 | CLANG_WARN_BOOL_CONVERSION = YES; 268 | CLANG_WARN_COMMA = YES; 269 | CLANG_WARN_CONSTANT_CONVERSION = YES; 270 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 271 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 272 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 273 | CLANG_WARN_EMPTY_BODY = YES; 274 | CLANG_WARN_ENUM_CONVERSION = YES; 275 | CLANG_WARN_INFINITE_RECURSION = YES; 276 | CLANG_WARN_INT_CONVERSION = YES; 277 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 278 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 279 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 280 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 281 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 282 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 283 | CLANG_WARN_STRICT_PROTOTYPES = YES; 284 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 285 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 286 | CLANG_WARN_UNREACHABLE_CODE = YES; 287 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 288 | COPY_PHASE_STRIP = NO; 289 | DEAD_CODE_STRIPPING = YES; 290 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 291 | ENABLE_NS_ASSERTIONS = NO; 292 | ENABLE_STRICT_OBJC_MSGSEND = YES; 293 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 294 | GCC_C_LANGUAGE_STANDARD = gnu11; 295 | GCC_NO_COMMON_BLOCKS = YES; 296 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 297 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 298 | GCC_WARN_UNDECLARED_SELECTOR = YES; 299 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 300 | GCC_WARN_UNUSED_FUNCTION = YES; 301 | GCC_WARN_UNUSED_VARIABLE = YES; 302 | MTL_ENABLE_DEBUG_INFO = NO; 303 | MTL_FAST_MATH = YES; 304 | SWIFT_COMPILATION_MODE = wholemodule; 305 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 306 | }; 307 | name = Release; 308 | }; 309 | A9C026B729D2CEFB00371AE0 /* Debug */ = { 310 | isa = XCBuildConfiguration; 311 | buildSettings = { 312 | ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES; 313 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 314 | "ASSETCATALOG_COMPILER_APPICON_NAME[sdk=xros*]" = "AppIcon-Vision"; 315 | "ASSETCATALOG_COMPILER_APPICON_NAME[sdk=xrsimulator*]" = "AppIcon-Vision"; 316 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 317 | CODE_SIGN_ENTITLEMENTS = Demo/Demo.entitlements; 318 | CODE_SIGN_STYLE = Automatic; 319 | CURRENT_PROJECT_VERSION = 1; 320 | DEAD_CODE_STRIPPING = YES; 321 | DEVELOPMENT_ASSET_PATHS = "\"Demo/Preview Content\""; 322 | DEVELOPMENT_TEAM = PMEDFW438U; 323 | ENABLE_HARDENED_RUNTIME = YES; 324 | ENABLE_PREVIEWS = YES; 325 | GENERATE_INFOPLIST_FILE = YES; 326 | INFOPLIST_KEY_CFBundleDisplayName = ApiKit; 327 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 328 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 329 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 330 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 331 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 332 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 333 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 334 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 335 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 336 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 337 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 338 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 339 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 340 | MACOSX_DEPLOYMENT_TARGET = 13.0; 341 | MARKETING_VERSION = 1.0; 342 | PRODUCT_BUNDLE_IDENTIFIER = com.danielsaidi.apikit.Demo; 343 | PRODUCT_NAME = "$(TARGET_NAME)"; 344 | SDKROOT = auto; 345 | SUPPORTED_PLATFORMS = "xrsimulator xros watchsimulator watchos macosx iphonesimulator iphoneos driverkit appletvsimulator appletvos"; 346 | SUPPORTS_MACCATALYST = YES; 347 | SWIFT_EMIT_LOC_STRINGS = YES; 348 | SWIFT_VERSION = 5.0; 349 | TARGETED_DEVICE_FAMILY = "1,2"; 350 | }; 351 | name = Debug; 352 | }; 353 | A9C026B829D2CEFB00371AE0 /* Release */ = { 354 | isa = XCBuildConfiguration; 355 | buildSettings = { 356 | ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES; 357 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 358 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 359 | CODE_SIGN_ENTITLEMENTS = Demo/Demo.entitlements; 360 | CODE_SIGN_STYLE = Automatic; 361 | CURRENT_PROJECT_VERSION = 1; 362 | DEAD_CODE_STRIPPING = YES; 363 | DEVELOPMENT_ASSET_PATHS = "\"Demo/Preview Content\""; 364 | DEVELOPMENT_TEAM = PMEDFW438U; 365 | ENABLE_HARDENED_RUNTIME = YES; 366 | ENABLE_PREVIEWS = YES; 367 | GENERATE_INFOPLIST_FILE = YES; 368 | INFOPLIST_KEY_CFBundleDisplayName = ApiKit; 369 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 370 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 371 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 372 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 373 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 374 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 375 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 376 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 377 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 378 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 379 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 380 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 381 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 382 | MACOSX_DEPLOYMENT_TARGET = 13.0; 383 | MARKETING_VERSION = 1.0; 384 | PRODUCT_BUNDLE_IDENTIFIER = com.danielsaidi.apikit.Demo; 385 | PRODUCT_NAME = "$(TARGET_NAME)"; 386 | SDKROOT = auto; 387 | SUPPORTED_PLATFORMS = "xrsimulator xros watchsimulator watchos macosx iphonesimulator iphoneos driverkit appletvsimulator appletvos"; 388 | SUPPORTS_MACCATALYST = YES; 389 | SWIFT_EMIT_LOC_STRINGS = YES; 390 | SWIFT_VERSION = 5.0; 391 | TARGETED_DEVICE_FAMILY = "1,2"; 392 | }; 393 | name = Release; 394 | }; 395 | /* End XCBuildConfiguration section */ 396 | 397 | /* Begin XCConfigurationList section */ 398 | A9C026A229D2CEFA00371AE0 /* Build configuration list for PBXProject "Demo" */ = { 399 | isa = XCConfigurationList; 400 | buildConfigurations = ( 401 | A9C026B429D2CEFB00371AE0 /* Debug */, 402 | A9C026B529D2CEFB00371AE0 /* Release */, 403 | ); 404 | defaultConfigurationIsVisible = 0; 405 | defaultConfigurationName = Release; 406 | }; 407 | A9C026B629D2CEFB00371AE0 /* Build configuration list for PBXNativeTarget "Demo" */ = { 408 | isa = XCConfigurationList; 409 | buildConfigurations = ( 410 | A9C026B729D2CEFB00371AE0 /* Debug */, 411 | A9C026B829D2CEFB00371AE0 /* Release */, 412 | ); 413 | defaultConfigurationIsVisible = 0; 414 | defaultConfigurationName = Release; 415 | }; 416 | /* End XCConfigurationList section */ 417 | 418 | /* Begin XCLocalSwiftPackageReference section */ 419 | A9E31BBA2CB0310600D1A3AA /* XCLocalSwiftPackageReference "../../apikit" */ = { 420 | isa = XCLocalSwiftPackageReference; 421 | relativePath = ../../apikit; 422 | }; 423 | /* End XCLocalSwiftPackageReference section */ 424 | 425 | /* Begin XCSwiftPackageProductDependency section */ 426 | A9C026BF29D3228100371AE0 /* ApiKit */ = { 427 | isa = XCSwiftPackageProductDependency; 428 | productName = ApiKit; 429 | }; 430 | A9E31BB52CB030D300D1A3AA /* ApiKit */ = { 431 | isa = XCSwiftPackageProductDependency; 432 | productName = ApiKit; 433 | }; 434 | A9E31BB82CB030F000D1A3AA /* ApiKit */ = { 435 | isa = XCSwiftPackageProductDependency; 436 | productName = ApiKit; 437 | }; 438 | A9E31BBB2CB0310600D1A3AA /* ApiKit */ = { 439 | isa = XCSwiftPackageProductDependency; 440 | productName = ApiKit; 441 | }; 442 | /* End XCSwiftPackageProductDependency section */ 443 | }; 444 | rootObject = A9C0269F29D2CEFA00371AE0 /* Project object */; 445 | } 446 | -------------------------------------------------------------------------------- /Demo/Demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Demo/Demo.xcodeproj/xcuserdata/danielsaidi.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /Demo/Demo.xcodeproj/xcuserdata/danielsaidi.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | Demo.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon-Vision.solidimagestack/Back.solidimagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon-visionOS-Back.png", 5 | "idiom" : "vision", 6 | "scale" : "2x" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon-Vision.solidimagestack/Back.solidimagestacklayer/Content.imageset/Icon-visionOS-Back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/4aba8d9826359b42efcd9e497097e0dec4c8754e/Demo/Demo/Assets.xcassets/AppIcon-Vision.solidimagestack/Back.solidimagestacklayer/Content.imageset/Icon-visionOS-Back.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon-Vision.solidimagestack/Back.solidimagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon-Vision.solidimagestack/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "layers" : [ 7 | { 8 | "filename" : "Front.solidimagestacklayer" 9 | }, 10 | { 11 | "filename" : "Middle.solidimagestacklayer" 12 | }, 13 | { 14 | "filename" : "Back.solidimagestacklayer" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon-Vision.solidimagestack/Front.solidimagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon-visionOS-Front.png", 5 | "idiom" : "vision", 6 | "scale" : "2x" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon-Vision.solidimagestack/Front.solidimagestacklayer/Content.imageset/Icon-visionOS-Front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/4aba8d9826359b42efcd9e497097e0dec4c8754e/Demo/Demo/Assets.xcassets/AppIcon-Vision.solidimagestack/Front.solidimagestacklayer/Content.imageset/Icon-visionOS-Front.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon-Vision.solidimagestack/Front.solidimagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon-Vision.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon-visionOS-Middle.png", 5 | "idiom" : "vision", 6 | "scale" : "2x" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon-Vision.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Icon-visionOS-Middle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/4aba8d9826359b42efcd9e497097e0dec4c8754e/Demo/Demo/Assets.xcassets/AppIcon-Vision.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Icon-visionOS-Middle.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon-Vision.solidimagestack/Middle.solidimagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon-iOS-1024.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "filename" : "Icon-macOS-16.png", 11 | "idiom" : "mac", 12 | "scale" : "1x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "Icon-macOS-32.png", 17 | "idiom" : "mac", 18 | "scale" : "2x", 19 | "size" : "16x16" 20 | }, 21 | { 22 | "filename" : "Icon-macOS-32.png", 23 | "idiom" : "mac", 24 | "scale" : "1x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "Icon-macOS-64.png", 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "32x32" 32 | }, 33 | { 34 | "filename" : "Icon-macOS-128.png", 35 | "idiom" : "mac", 36 | "scale" : "1x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "Icon-macOS-256.png", 41 | "idiom" : "mac", 42 | "scale" : "2x", 43 | "size" : "128x128" 44 | }, 45 | { 46 | "filename" : "Icon-macOS-256.png", 47 | "idiom" : "mac", 48 | "scale" : "1x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "Icon-macOS-512.png", 53 | "idiom" : "mac", 54 | "scale" : "2x", 55 | "size" : "256x256" 56 | }, 57 | { 58 | "filename" : "Icon-macOS-512.png", 59 | "idiom" : "mac", 60 | "scale" : "1x", 61 | "size" : "512x512" 62 | }, 63 | { 64 | "filename" : "Icon-macOS-1024.png", 65 | "idiom" : "mac", 66 | "scale" : "2x", 67 | "size" : "512x512" 68 | } 69 | ], 70 | "info" : { 71 | "author" : "xcode", 72 | "version" : 1 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/Icon-iOS-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/4aba8d9826359b42efcd9e497097e0dec4c8754e/Demo/Demo/Assets.xcassets/AppIcon.appiconset/Icon-iOS-1024.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/Icon-macOS-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/4aba8d9826359b42efcd9e497097e0dec4c8754e/Demo/Demo/Assets.xcassets/AppIcon.appiconset/Icon-macOS-1024.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/Icon-macOS-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/4aba8d9826359b42efcd9e497097e0dec4c8754e/Demo/Demo/Assets.xcassets/AppIcon.appiconset/Icon-macOS-128.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/Icon-macOS-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/4aba8d9826359b42efcd9e497097e0dec4c8754e/Demo/Demo/Assets.xcassets/AppIcon.appiconset/Icon-macOS-16.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/Icon-macOS-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/4aba8d9826359b42efcd9e497097e0dec4c8754e/Demo/Demo/Assets.xcassets/AppIcon.appiconset/Icon-macOS-256.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/Icon-macOS-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/4aba8d9826359b42efcd9e497097e0dec4c8754e/Demo/Demo/Assets.xcassets/AppIcon.appiconset/Icon-macOS-32.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/Icon-macOS-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/4aba8d9826359b42efcd9e497097e0dec4c8754e/Demo/Demo/Assets.xcassets/AppIcon.appiconset/Icon-macOS-512.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/Icon-macOS-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/4aba8d9826359b42efcd9e497097e0dec4c8754e/Demo/Demo/Assets.xcassets/AppIcon.appiconset/Icon-macOS-64.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Demo/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Demo 4 | // 5 | // Created by Daniel Saidi on 2023-03-28. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import ApiKit 10 | import SwiftUI 11 | 12 | struct ContentView: View { 13 | 14 | var body: some View { 15 | NavigationStack { 16 | List { 17 | NavigationLink("The Movie DB", value: DemoScreen.theMovieDb) 18 | } 19 | .navigationTitle("ApiKit") 20 | .navigationDestination(for: DemoScreen.self, destination: view) 21 | } 22 | } 23 | } 24 | 25 | extension ContentView { 26 | 27 | @ViewBuilder 28 | func view(for screen: DemoScreen) -> some View { 29 | switch screen { 30 | case .theMovieDb: TheMovieDbScreen() 31 | } 32 | } 33 | } 34 | 35 | #Preview { 36 | 37 | ContentView() 38 | } 39 | -------------------------------------------------------------------------------- /Demo/Demo/Demo.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Demo/Demo/DemoApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DemoApp.swift 3 | // Demo 4 | // 5 | // Created by Daniel Saidi on 2023-03-28. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | @main 12 | struct DemoApp: App { 13 | var body: some Scene { 14 | WindowGroup { 15 | ContentView() 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Demo/Demo/DemoKeys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DemoKeys.swift 3 | // Demo 4 | // 5 | // Created by Daniel Saidi on 2023-03-28. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | This struct is used to manage keys for this demo. Just type 13 | in any keys you want to use. NEVER PUSH THESE KEYS! 14 | */ 15 | struct DemoKeys { 16 | 17 | static var theMovieDb = "c9237ef2809ed01e64d7b37b0f951c7b" 18 | } 19 | -------------------------------------------------------------------------------- /Demo/Demo/DemoScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DemoScreen.swift 3 | // Demo 4 | // 5 | // Created by Daniel Saidi on 2023-03-28. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | enum DemoScreen { 12 | 13 | case theMovieDb 14 | } 15 | -------------------------------------------------------------------------------- /Demo/Demo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Demo/Screens/TheMovieDbScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TheMovieDbScreen.swift 3 | // Demo 4 | // 5 | // Created by Daniel Saidi on 2023-03-28. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import ApiKit 10 | import SwiftUI 11 | 12 | struct TheMovieDbScreen: View { 13 | 14 | static let apiKey = DemoKeys.theMovieDb 15 | 16 | let session = URLSession.shared 17 | let environment = Environment.production(apiKey: Self.apiKey) 18 | let gridColumns = [GridItem(.adaptive(minimum: 100), alignment: .top)] 19 | 20 | @StateObject 21 | private var model = ViewModel() 22 | 23 | typealias Environment = TheMovieDb.Environment 24 | typealias Route = TheMovieDb.Route 25 | typealias Movie = TheMovieDb.Movie 26 | typealias MovieResult = TheMovieDb.MoviesPaginationResult 27 | 28 | class ViewModel: ObservableObject { 29 | 30 | @Published var discoverMovies = [Movie]() 31 | @Published var searchMovies = [Movie]() 32 | @Published var searchQuery = "" 33 | } 34 | 35 | var body: some View { 36 | ScrollView(.vertical) { 37 | LazyVGrid(columns: gridColumns) { 38 | ForEach(movies) { movie in 39 | gridItem(for: movie) 40 | } 41 | }.padding() 42 | } 43 | .task { fetchDiscoverData() } 44 | .searchable(text: $model.searchQuery) 45 | .onReceive(model.$searchQuery.throttle(for: 1, scheduler: RunLoop.main, latest: true), perform: search) 46 | .navigationTitle("The Movie DB") 47 | } 48 | } 49 | 50 | extension TheMovieDbScreen { 51 | 52 | func gridItem(for movie: Movie) -> some View { 53 | VStack { 54 | AsyncImage( 55 | url: movie.posterUrl(width: 300), 56 | content: { image in 57 | image.resizable() 58 | .cornerRadius(5) 59 | .aspectRatio(contentMode: .fit) 60 | }, 61 | placeholder: { 62 | ProgressView() 63 | } 64 | ).accessibilityLabel(movie.title) 65 | } 66 | } 67 | } 68 | 69 | extension TheMovieDbScreen { 70 | 71 | var movies: [Movie] { 72 | model.searchMovies.isEmpty ? model.discoverMovies : model.searchMovies 73 | } 74 | 75 | func fetchDiscoverData() { 76 | Task { 77 | do { 78 | let result = try await fetchDiscoverData() 79 | updateDiscoverResult(with: result) 80 | } catch { 81 | print(error) 82 | } 83 | } 84 | } 85 | 86 | func fetchDiscoverData() async throws -> MovieResult { 87 | try await session.request( 88 | at: Route.discoverMovies(page: 1), 89 | in: environment, 90 | decoder: nil 91 | ) 92 | } 93 | 94 | func search(with query: String) { 95 | Task { 96 | do { 97 | let result = try await search(with: query) 98 | updateSearchResult(with: result) 99 | } catch { 100 | print(error) 101 | } 102 | } 103 | } 104 | 105 | func search(with query: String) async throws -> MovieResult { 106 | try await session.request( 107 | at: Route.searchMovies(query: query, page: 1), 108 | in: environment 109 | ) 110 | } 111 | } 112 | 113 | @MainActor 114 | extension TheMovieDbScreen { 115 | 116 | func updateDiscoverResult(with result: MovieResult) { 117 | model.discoverMovies = result.results 118 | } 119 | 120 | func updateSearchResult(with result: MovieResult) { 121 | model.searchMovies = result.results 122 | } 123 | } 124 | 125 | #Preview { 126 | 127 | TheMovieDbScreen() 128 | #if os(macOS) 129 | .frame(minWidth: 500) 130 | #endif 131 | } 132 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2025 Daniel Saidi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "ApiKit", 7 | platforms: [ 8 | .iOS(.v13), 9 | .macOS(.v11), 10 | .tvOS(.v13), 11 | .watchOS(.v6), 12 | .visionOS(.v1) 13 | ], 14 | products: [ 15 | .library( 16 | name: "ApiKit", 17 | targets: ["ApiKit"] 18 | ) 19 | ], 20 | dependencies: [], 21 | targets: [ 22 | .target( 23 | name: "ApiKit", 24 | dependencies: [] 25 | ), 26 | .testTarget( 27 | name: "ApiKitTests", 28 | dependencies: ["ApiKit"] 29 | ) 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Project Icon 3 |

4 | 5 |

6 | Version 7 | Swift 6.0 8 | Documentation 9 | MIT License 10 | Sponsor my work 11 |

12 | 13 | 14 | # ApiKit 15 | 16 | ApiKit is a Swift library that makes it easy to integrate with any REST API and map its response data to Swift types. 17 | 18 | ApiKit defines an ``ApiClient`` protocol that can be used to request raw & typed data from any REST API, as well as ``ApiEnvironment`` and ``ApiRoute`` protocols that make it easy to model environments and routes 19 | 20 | The ``ApiClient`` protocol is already implemented by ``URLSession``, so you can use ``URLSession.shared`` directly. 21 | 22 | 23 | 24 | ## Installation 25 | 26 | ApiKit can be installed with the Swift Package Manager: 27 | 28 | ``` 29 | https://github.com/danielsaidi/ApiKit.git 30 | ``` 31 | 32 | 33 | ## Support My Work 34 | 35 | You can [become a sponsor][Sponsors] to help me dedicate more time on my various [open-source tools][OpenSource]. Every contribution, no matter the size, makes a real difference in keeping these tools free and actively developed. 36 | 37 | 38 | 39 | ## Getting Started 40 | 41 | Consider that you want to integrate with the Yelp API, which can return restaurants, reviews, etc. 42 | 43 | You would first define the various API environments that you want to integrate with. In this case, let's just integrate with the `v3` environment, which requires an API header token for all requests: 44 | 45 | ```swift 46 | import ApiKit 47 | 48 | enum YelpEnvironment: ApiEnvironment { 49 | 50 | case v3(apiToken: String) 51 | 52 | var url: String { 53 | switch self { 54 | case .v3: "https://api.yelp.com/v3/" 55 | } 56 | } 57 | 58 | var headers: [String: String]? { 59 | switch self { 60 | case .v3(let token): ["Authorization": "Bearer \(token)"] 61 | } 62 | } 63 | } 64 | ``` 65 | 66 | We can then define the routes to request from the Yelp API. In this case, let's just fetch a business by unique ID: 67 | 68 | ```swift 69 | import ApiKit 70 | 71 | enum YelpRoute: ApiRoute { 72 | 73 | case business(id: String) 74 | 75 | var path: String { 76 | switch self { 77 | case .business(let id): "businesses/\(id)" 78 | } 79 | } 80 | 81 | var httpMethod: HttpMethod { .get } 82 | var headers: [String: String]? { nil } 83 | var formParams: [String: String]? { nil } 84 | var postData: Data? { nil } 85 | 86 | var queryParams: [String: String]? { 87 | switch self { 88 | case .business: nil 89 | } 90 | } 91 | } 92 | ``` 93 | 94 | With an environment and route in place, we can now fetch a `YelpBusiness` with any ``ApiClient`` or ``URLSession``: 95 | 96 | ```swift 97 | let client = URLSession.shared 98 | let environment = YelpEnvironment.v3(apiToken: "YOUR_TOKEN") 99 | let route = YelpRoute.business(id: "abc123") 100 | let business: YelpBusiness = try await client.request(route, in: environment) 101 | ``` 102 | 103 | The generic request functions will automatically map the raw response to the requested type, and throw any error that occurs. There are also non-generic variants if you want to get the raw data or use custom error handling. 104 | 105 | See the online [getting started guide][Getting-Started] for more information. 106 | 107 | 108 | 109 | ## Documentation 110 | 111 | The online [documentation][Documentation] has more information, articles, code examples, etc. 112 | 113 | 114 | 115 | ## Demo Application 116 | 117 | The `Demo` folder has a demo app that lets you explore the library and integrate with a few APIs. 118 | 119 | 120 | 121 | ## Contact 122 | 123 | Feel free to reach out if you have questions, or want to contribute in any way: 124 | 125 | * Website: [danielsaidi.com][Website] 126 | * E-mail: [daniel.saidi@gmail.com][Email] 127 | * Bluesky: [@danielsaidi@bsky.social][Bluesky] 128 | * Mastodon: [@danielsaidi@mastodon.social][Mastodon] 129 | 130 | 131 | 132 | ## License 133 | 134 | ApiKit is available under the MIT license. See the [LICENSE][License] file for more info. 135 | 136 | 137 | 138 | [Email]: mailto:daniel.saidi@gmail.com 139 | [Website]: https://danielsaidi.com 140 | [GitHub]: https://github.com/danielsaidi 141 | [OpenSource]: https://danielsaidi.com/opensource 142 | [Sponsors]: https://github.com/sponsors/danielsaidi 143 | 144 | [Bluesky]: https://bsky.app/profile/danielsaidi.bsky.social 145 | [Mastodon]: https://mastodon.social/@danielsaidi 146 | [Twitter]: https://twitter.com/danielsaidi 147 | 148 | [Documentation]: https://danielsaidi.github.io/ApiKit 149 | [Getting-Started]: https://danielsaidi.github.io/ApiKit/documentation/apikit/getting-started 150 | [License]: https://github.com/danielsaidi/ApiKit/blob/master/LICENSE 151 | -------------------------------------------------------------------------------- /RELEASE_NOTES.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | ApiKit will use semver after 1.0. 4 | 5 | Until then, breaking changes can happen in any version, and deprecated features may be removed in any minor version bump. 6 | 7 | 8 | 9 | ## 1.0.2 10 | 11 | ### ✨ Features 12 | 13 | * `ApiError` now includes the status code in some errors. 14 | 15 | 16 | 17 | ## 1.0.1 18 | 19 | ### ✨ Features 20 | 21 | * `ApiError` now returns a readable, localized description. 22 | 23 | 24 | 25 | ## 1.0 26 | 27 | This major version bump removes deprecated code. 28 | 29 | ### 💥 Breaking changes 30 | 31 | * The `ApiRequestData` protocol has been removed. 32 | * All previously deprecated code has been removed. 33 | 34 | 35 | 36 | ## 0.9.2 37 | 38 | This version adds an `ApiModel` protocol that simplifies conforming to `Codable` and `Sendable`. 39 | 40 | 41 | 42 | ## 0.9.1 43 | 44 | This version adjusts HTTP status code terminology. 45 | 46 | ### ✨ New Features 47 | 48 | * `ApiClient` lets you provide a custom decoder. 49 | * `ApiError` has a new `invalidHttpStatusCode` error. 50 | * `ApiError` has a new `unsuccessfulHttpStatusCode` error. 51 | 52 | ### 💡 Adjustments 53 | 54 | * `100-599` is valid. 55 | * `100-199` and `300-599` is unsuccessful, not invalid. 56 | * All other status codes are invalid, since they're not in the spec. 57 | 58 | 59 | 60 | ## 0.9 61 | 62 | This version removes all deprecated code and makes the SDK use Swift 6. 63 | 64 | 65 | 66 | ## 0.8 67 | 68 | This version renames client functions to use the "request" terminology for more consistent naming. 69 | 70 | ### 🗑️ Deprecations 71 | 72 | * `ApiClient` has renamed all `fetch` operations to `request`. 73 | 74 | ### 💥 Breaking changes 75 | 76 | * `ApiClient` `fetchData` is renamed to `data` to match `URLSession`. 77 | 78 | 79 | 80 | ## 0.7 81 | 82 | ### ✨ New Features 83 | 84 | * ApiKit now supports visionOS. 85 | 86 | ### 💥 Breaking changes 87 | 88 | * SystemNotification now requires Swift 5.9. 89 | 90 | 91 | 92 | ## 0.6 93 | 94 | ### ✨ New Features 95 | 96 | * `ApiClient` now validates the response status code. 97 | * `ApiClient` can perform even more fetch operations. 98 | * `ApiError` has a new `invalidResponseStatusCode` error. 99 | 100 | ### 💥 Breaking Changes 101 | 102 | * `ApiClient` now only requires a data fetch implementation. 103 | 104 | 105 | 106 | ## 0.5 107 | 108 | ### ✨ New Features 109 | 110 | * `ApiClient` has a new `fetch(_:in:)` for fetching routes. 111 | * `ApiRequest` is a new type that simplifies fetching data. 112 | 113 | ### 💥 Breaking Changes 114 | 115 | * `ApiError.noDataInResponse` has been removed. 116 | * `ApiResult` properties are no longer optional. 117 | 118 | 119 | 120 | ## 0.4 121 | 122 | This version uses Swift 5.9 and renames some integration types. 123 | 124 | 125 | 126 | ## 0.3 127 | 128 | ### ✨ New Features 129 | 130 | * `Yelp` is a new namespace with Yelp API integrations. 131 | 132 | 133 | 134 | ## 0.2.1 135 | 136 | This version makes ApiKit support PATCH requests. 137 | 138 | ### ✨ New Features 139 | 140 | * `HttpMethod` now has a new `patch` case. 141 | 142 | 143 | 144 | ## 0.2 145 | 146 | This version adds supports for headers and for the environment to define global headers and query parameters. 147 | 148 | ### ✨ New Features 149 | 150 | * `ApiRequestData` is a new protocol that is implemented by both `ApiEnvironment` and `ApiRoute`. 151 | * `ApiEnvironment` and `ApiRoute` can now define custom headers. 152 | * `TheMovieDB` is a new type that can be used to integrate with The Movie DB api. 153 | 154 | ### 💡 Behavior Changes 155 | 156 | * All request data is now optional. 157 | * URL request creation is now throwing. 158 | * URL requests will now combine data from the environment and route. 159 | 160 | ### 🐛 Bug fixes 161 | 162 | * `ApiRequestData` removes the not needed url encoding. 163 | 164 | ### 💥 Breaking Changes 165 | 166 | * `ApiEnvironment` now uses a `String` as url. 167 | * `ApiRequestData` makes the `queryParams` property optional. 168 | * `ApiRoute` makes the `formParams` property optional. 169 | 170 | 171 | 172 | ## 0.1 173 | 174 | This is the first public release of ApiKit. 175 | 176 | ### ✨ New Features 177 | 178 | * You can create `ApiEnvironment` and `ApiRoute` implementations and use them with `ApiClient`. 179 | * `URLSession` implements `ApiClient` so you don't need a custom implementation 180 | -------------------------------------------------------------------------------- /Resources/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/4aba8d9826359b42efcd9e497097e0dec4c8754e/Resources/Icon.png -------------------------------------------------------------------------------- /Sources/ApiKit/ApiClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApiClient.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-03-25. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This protocol can be implemented by any type that can be 12 | /// used to perform API requests. 13 | /// 14 | /// Use ``data(for:)`` to request raw data and ``request(_:)`` 15 | /// to request validated ``ApiResult``. You can also use the 16 | /// generic ``request(with:)`` & ``request(at:in:)`` methods 17 | /// to request generic, typed data. 18 | /// 19 | /// This protocol is implemented by `URLSession`, so you can 20 | /// use `URLSession` directly, without having to implement a 21 | /// client class. But you can do it if you want to customize 22 | /// how it performs certain operations. 23 | public protocol ApiClient: AnyObject { 24 | 25 | /// Fetch data with the provided `URLRequest`. 26 | func data( 27 | for request: URLRequest 28 | ) async throws -> (Data, URLResponse) 29 | } 30 | 31 | extension URLSession: ApiClient {} 32 | 33 | public extension ApiClient { 34 | 35 | /// Request a raw ``ApiResult`` for the provided request. 36 | func request( 37 | _ request: URLRequest 38 | ) async throws -> ApiResult { 39 | let result = try await data(for: request) 40 | let data = result.0 41 | let response = result.1 42 | try validate(request: request, response: response, data: data) 43 | return ApiResult(data: data, response: response) 44 | } 45 | 46 | /// Request a raw ``ApiResult`` for the provided route. 47 | func request( 48 | _ route: ApiRoute, 49 | in environment: ApiEnvironment 50 | ) async throws -> ApiResult { 51 | let request = try route.urlRequest(for: environment) 52 | return try await self.request(request) 53 | } 54 | 55 | /// Request a typed result for the provided request. 56 | func request( 57 | with request: URLRequest, 58 | decoder: JSONDecoder? = nil 59 | ) async throws -> T { 60 | let result = try await self.request(request) 61 | let data = result.data 62 | let decoder = decoder ?? JSONDecoder() 63 | return try decoder.decode(T.self, from: data) 64 | } 65 | 66 | /// Request a typed result for the provided route. 67 | func request( 68 | at route: ApiRoute, 69 | in environment: ApiEnvironment, 70 | decoder: JSONDecoder? = nil 71 | ) async throws -> T { 72 | let request = try route.urlRequest(for: environment) 73 | return try await self.request(with: request, decoder: decoder) 74 | } 75 | 76 | /// Validate the provided request, response and data. 77 | func validate( 78 | request: URLRequest, 79 | response: URLResponse, 80 | data: Data 81 | ) throws(ApiError) { 82 | guard let httpResponse = response as? HTTPURLResponse else { return } 83 | let statusCode = httpResponse.statusCode 84 | guard statusCode.isValidHttpStatusCode else { 85 | throw ApiError.invalidHttpStatusCode(statusCode, request, response, data) 86 | } 87 | guard statusCode.isSuccessfulHttpStatusCode else { 88 | throw ApiError.unsuccessfulHttpStatusCode(statusCode, request, response, data) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/ApiKit/ApiEnvironment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApiEnvironment.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-03-24. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This protocol can be used to define API environments, or 12 | /// specific API versions. 13 | /// 14 | /// An ``ApiEnvironment`` must define a ``url``, to which an 15 | /// environment-relative ``ApiRoute/path`` can be added. You 16 | /// can use an enum to define multiple environments. 17 | /// 18 | /// Both the ``ApiEnvironment`` and ``ApiRoute`` can specify 19 | /// headers and query parameters that they need. Environment 20 | /// specific headers and query parameters will be applied to 21 | /// all requests, while a route specific value will override 22 | /// a value that is defined by the environment. 23 | public protocol ApiEnvironment: Sendable { 24 | 25 | /// Optional header parameters to apply to all requests. 26 | var headers: [String: String]? { get } 27 | 28 | /// Optional query params to apply to all requests. 29 | var queryParams: [String: String]? { get } 30 | 31 | /// The base URL of the environment. 32 | var url: String { get } 33 | } 34 | 35 | extension ApiEnvironment { 36 | 37 | /// Convert ``queryParams`` to url encoded query items. 38 | var encodedQueryItems: [URLQueryItem]? { 39 | queryParams? 40 | .map { URLQueryItem(name: $0.key, value: $0.value) } 41 | .sorted { $0.name < $1.name } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/ApiKit/ApiError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApiError.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-03-25. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This enum defines api-specific errors that can be thrown 12 | /// when an ``ApiClient`` communicates with any external API. 13 | public enum ApiError: Error, Equatable, LocalizedError { 14 | 15 | /// This error should be thrown when an ``ApiEnvironment`` 16 | /// has a url that can't be used to generate a `URL`. 17 | case invalidEnvironmentUrl(String) 18 | 19 | /// This error should be thrown when a URL request fails 20 | /// due to an invalid status code (outside of 100-599). 21 | case invalidHttpStatusCode(Int, URLRequest, URLResponse, Data) 22 | 23 | /// This error should be thrown when a `URLRequest` will 24 | /// fail to be created due to invalid `URLComponents`. 25 | case noUrlInComponents(URLComponents) 26 | 27 | /// This error should be thrown when a `URLRequest` will 28 | /// fail to be created due to an invalid `URL`. 29 | case failedToCreateComponentsFromUrl(URL) 30 | 31 | /// This error should be thrown when a URL request fails 32 | /// due to an unsuccessful status code (100-199, as well 33 | /// as 300-599). 34 | case unsuccessfulHttpStatusCode(Int, URLRequest, URLResponse, Data) 35 | } 36 | 37 | public extension ApiError { 38 | 39 | /// A custom localized description. 40 | var localizedDescription: String { 41 | switch self { 42 | case .invalidEnvironmentUrl: "Invalid Environment Url" 43 | case .invalidHttpStatusCode(let code, _, _, _): "Invalid HTTP Status Code \(code)" 44 | case .noUrlInComponents: "No URL In Components" 45 | case .failedToCreateComponentsFromUrl: "Failed To Create Components From Url" 46 | case .unsuccessfulHttpStatusCode(let code, _, _, _): "Unsuccessful HTTP Status Code \(code)" 47 | } 48 | } 49 | } 50 | 51 | public extension ApiError { 52 | 53 | /// Whether the error is a ``ApiError/invalidHttpStatusCode(_:_:_:_:)`` 54 | var isInvalidHttpStatusCodeError: Bool { 55 | switch self { 56 | case .invalidHttpStatusCode: true 57 | default: false 58 | } 59 | } 60 | 61 | /// Whether the error is a ``ApiError/invalidHttpStatusCode(_:_:_:_:)`` 62 | var isUnsuccessfulHttpStatusCodeError: Bool { 63 | switch self { 64 | case .unsuccessfulHttpStatusCode: true 65 | default: false 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/ApiKit/ApiKit.docc/ApiKit.md: -------------------------------------------------------------------------------- 1 | # ``ApiKit`` 2 | 3 | ApiKit is a Swift SDK that helps you integrate with any REST API. 4 | 5 | 6 | ## Overview 7 | 8 | ![Library logotype](Logo.png) 9 | 10 | ApiKit is a Swift library that makes it easy to integrate with any REST API and map its response models to Swift types. It defines an ``ApiClient`` that can request data from any API, as well as ``ApiEnvironment`` & ``ApiRoute`` protocols that make it easy to model any API. 11 | 12 | The ``ApiClient`` protocol is already implemented by ``URLSession``, so you can use ``URLSession.shared`` directly, without having to create a custom client implementation. 13 | 14 | 15 | ## Installation 16 | 17 | ApiKit can be installed with the Swift Package Manager: 18 | 19 | ``` 20 | https://github.com/danielsaidi/ApiKit.git 21 | ``` 22 | 23 | 24 | ## Support My Work 25 | 26 | You can [become a sponsor][Sponsors] to help me dedicate more time on my various [open-source tools][OpenSource]. Every contribution, no matter the size, makes a real difference in keeping these tools free and actively developed. 27 | 28 | 29 | ## Getting started 30 | 31 | Once you have one or several ``ApiEnvironment`` and ``ApiRoute`` values for the API you want to integrate with, you can easily perform requests with any ``ApiClient`` or ``Foundation/URLSession``: 32 | 33 | ```swift 34 | let client = URLSession.shared 35 | let environment = MyEnvironment.production(apiToken: "TOKEN") 36 | let route = MyRoutes.user(id: "abc123") 37 | let user: ApiUser = try await client.request(at: route, in: environment) 38 | ``` 39 | 40 | The generic, typed functions will automatically map the raw response to the type you requested, and throw any raw errors that occur. There are also non-generic variants that can be used if you want to provide custom error handling. 41 | 42 | See the article for more information on how to define environments and routes. 43 | 44 | 45 | ## Repository 46 | 47 | For more information, source code, etc., visit the [project repository](https://github.com/danielsaidi/ApiKit). 48 | 49 | 50 | ## License 51 | 52 | ApiKit is available under the MIT license. 53 | 54 | 55 | ## Topics 56 | 57 | ### Articles 58 | 59 | - 60 | 61 | ### Essentials 62 | 63 | - ``ApiEnvironment`` 64 | - ``ApiRoute`` 65 | - ``ApiClient`` 66 | - ``ApiError`` 67 | - ``ApiRequest`` 68 | - ``ApiResult`` 69 | 70 | ### HTTP 71 | 72 | - ``HttpMethod`` 73 | 74 | ### Integrations 75 | 76 | - ``TheMovieDb`` 77 | - ``Yelp`` 78 | 79 | 80 | [Email]: mailto:daniel.saidi@gmail.com 81 | [Website]: https://danielsaidi.com 82 | [GitHub]: https://github.com/danielsaidi 83 | [OpenSource]: https://danielsaidi.com/opensource 84 | [Sponsors]: https://github.com/sponsors/danielsaidi 85 | -------------------------------------------------------------------------------- /Sources/ApiKit/ApiKit.docc/Getting-Started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This article explains how to get started with ApiKit. 4 | 5 | @Metadata { 6 | 7 | @PageImage( 8 | purpose: card, 9 | source: "Page" 10 | ) 11 | 12 | @PageColor(blue) 13 | } 14 | 15 | 16 | 17 | ## Overview 18 | 19 | ApiKit defines an ``ApiClient`` protocol that describes how to request raw and typed data from any REST-based API. This protocol is implemented by ``Foundation/URLSession``, so you can use the shared session without having to create a custom client. 20 | 21 | Once you have one or several ``ApiEnvironment`` and ``ApiRoute`` values for the API you want to integrate with, you can easily perform requests with any ``ApiClient`` or ``Foundation/URLSession``: 22 | 23 | ```swift 24 | let client = URLSession.shared 25 | let environment = MyEnvironment.production(apiToken: "TOKEN") 26 | let route = MyRoutes.user(id: "abc123") 27 | let user: ApiUser = try await client.request(at: route, in: environment) 28 | ``` 29 | 30 | The generic, typed functions will automatically map the raw response to the type you requested, and throw any raw errors that occur. There are also non-generic variants that can be used if you want to provide custom error handling. 31 | 32 | 33 | 34 | ## API Environments 35 | 36 | An ``ApiEnvironment`` refers to a specific API version or environment (prod, staging, etc.), and defines a URL as well as global request headers and query parameters. 37 | 38 | For instance, this is a [Yelp](https://yelp.com) v3 API environment, which requires an API token: 39 | 40 | ```swift 41 | import ApiKit 42 | 43 | enum YelpEnvironment: ApiEnvironment { 44 | 45 | case v3(apiToken: String) 46 | 47 | var url: String { 48 | switch self { 49 | case .v3: "https://api.yelp.com/v3/" 50 | } 51 | } 52 | 53 | var headers: [String: String]? { 54 | switch self { 55 | case .v3(let token): ["Authorization": "Bearer \(token)"] 56 | } 57 | } 58 | 59 | var queryParams: [String: String]? { 60 | [:] 61 | } 62 | } 63 | ``` 64 | 65 | This API requires that all requests send the API token as a custom header. Other APIs may require it to be sent as a query parameter, or have no such requirements at all. ApiKit is flexible to support all different kinds of requirements. 66 | 67 | 68 | 69 | ## API Routes 70 | 71 | An ``ApiRoute`` refers to an endpoint within an API. It defines an HTTP method, an environment-relative path, custom headers, query parameters, post data, etc. and will generate a proper URL request for a certain ``ApiEnvironment``. 72 | 73 | For instance, this is a [Yelp](https://yelp.com) v3 API route that defines how to fetch and search for restaurants: 74 | 75 | ```swift 76 | import ApiKit 77 | 78 | enum YelpRoute: ApiRoute { 79 | 80 | case restaurant(id: String) 81 | case search(params: Yelp.SearchParams) 82 | 83 | var path: String { 84 | switch self { 85 | case .restaurant(let id): "businesses/\(id)" 86 | case .search: "businesses/search" 87 | } 88 | } 89 | 90 | var httpMethod: HttpMethod { .get } 91 | var headers: [String: String]? { nil } 92 | var formParams: [String: String]? { nil } 93 | var postData: Data? { nil } 94 | 95 | var queryParams: [String: String]? { 96 | switch self { 97 | case .restaurant: nil 98 | case .search(let params): params.queryParams 99 | } 100 | } 101 | } 102 | ``` 103 | 104 | The routes above use associated values to apply a restaurant ID to the request path, and search parameters as query parameters. 105 | 106 | 107 | 108 | ## API models 109 | 110 | We can also define codable API-specific value types to let the ``ApiClient`` automatically map the raw response data to these types. 111 | 112 | For instance, this is a lightweight Yelp restaurant model: 113 | 114 | ```swift 115 | struct YelpRestaurant: Codable { 116 | 117 | public let id: String 118 | public let name: String? 119 | public let imageUrl: String? 120 | 121 | enum CodingKeys: String, CodingKey { 122 | case id 123 | case name 124 | case imageUrl = "image_url" 125 | } 126 | } 127 | ``` 128 | 129 | The `id` and `name` parameters use the same name as in the API, while `imageUrl` requires custom mapping. 130 | 131 | 132 | 133 | ## How to fetch data 134 | 135 | We can now fetch data from the Yelp API, using ``Foundation/URLSession`` or any custom ``ApiClient``: 136 | 137 | ```swift 138 | let client = URLSession.shared 139 | let environment = YelpEnvironment.v3(apiToken: "TOKEN") 140 | let route = YelpRoute.restaurant(id: "abc123") 141 | let restaurant: YelpRestaurant = try await client.request(at: route, in: environment) 142 | ``` 143 | 144 | The client will fetch the raw data and either return the mapped result, or throw an error. 145 | 146 | 147 | 148 | ## How to fetch data even easier 149 | 150 | We can define an ``ApiRequest`` to avoid having to define routes and return types every time: 151 | 152 | ```swift 153 | struct YelpRestaurantRequest: ApiRequest { 154 | 155 | typealias ResponseType = YelpRestaurant 156 | 157 | let id: String 158 | 159 | var route: ApiRoute { 160 | YelpRoute.restaurant(id: id) 161 | } 162 | } 163 | ``` 164 | 165 | We can then use `URLSession` or a custom ``ApiClient`` to fetch requests without having to specify the route or return type: 166 | 167 | ```swift 168 | let client = URLSession.shared 169 | let environment = YelpEnvironment.v3(apiToken: "TOKEN") 170 | let request = YelpRestaurantRequest(id: "abc123") 171 | let restaurant = try await client.fetch(request, from: environment) 172 | ``` 173 | 174 | This involves creating more types, but is easier to manage in larger projects. 175 | -------------------------------------------------------------------------------- /Sources/ApiKit/ApiKit.docc/Resources/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/4aba8d9826359b42efcd9e497097e0dec4c8754e/Sources/ApiKit/ApiKit.docc/Resources/Icon.png -------------------------------------------------------------------------------- /Sources/ApiKit/ApiKit.docc/Resources/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/4aba8d9826359b42efcd9e497097e0dec4c8754e/Sources/ApiKit/ApiKit.docc/Resources/Logo.png -------------------------------------------------------------------------------- /Sources/ApiKit/ApiKit.docc/Resources/Page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/4aba8d9826359b42efcd9e497097e0dec4c8754e/Sources/ApiKit/ApiKit.docc/Resources/Page.png -------------------------------------------------------------------------------- /Sources/ApiKit/ApiModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApiModel.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2024-10-04. 6 | // Copyright © 2024 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | /// This protocol can be implemented by API-specific models. 10 | /// 11 | /// This protocol makes a type conform to both `Codable` and 12 | /// `Sendable`, which simplifies defining model types. 13 | public protocol ApiModel: Codable, Sendable {} 14 | -------------------------------------------------------------------------------- /Sources/ApiKit/ApiRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApiRequest.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2024-01-17. 6 | // Copyright © 2024 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This protocol can be used to define a API route, and its 12 | /// expected return type. 13 | /// 14 | /// You can use this protocol to avoid having to specify the 15 | /// return type when fetching data for a route. Just use the 16 | /// ``ApiClient/fetch(_:from:)`` to automatically map an API 17 | /// route's response to the expected ``ResponseType``. 18 | public protocol ApiRequest: Codable { 19 | 20 | associatedtype ResponseType: Codable 21 | 22 | var route: ApiRoute { get } 23 | } 24 | 25 | public extension ApiClient { 26 | 27 | /// Try to request a certain ``ApiRequest``. 28 | func fetch( 29 | _ request: RequestType, 30 | from env: ApiEnvironment 31 | ) async throws -> RequestType.ResponseType { 32 | try await self.request(at: request.route, in: env) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/ApiKit/ApiResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApiResult.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-03-25. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This type can be returned by an ``ApiClient``. 12 | public struct ApiResult { 13 | 14 | public init( 15 | data: Data, 16 | response: URLResponse 17 | ) { 18 | self.data = data 19 | self.response = response 20 | } 21 | 22 | public var data: Data 23 | public var response: URLResponse 24 | } 25 | -------------------------------------------------------------------------------- /Sources/ApiKit/ApiRoute.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApiRoute.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-03-24. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This protocol can be used to define API-specific routes. 12 | /// 13 | /// An ``ApiRoute`` must define an ``httpMethod`` as well as 14 | /// an environment-relative ``path``, which will be appended 15 | /// to an environment ``ApiEnvironment/url``. You can use an 16 | /// enum to define multiple routes. 17 | /// 18 | /// When a route defines a ``formParams`` value, the request 19 | /// should use `application/x-www-form-urlencoded`, and omit 20 | /// the route's ``postData`` value, if set. These properties 21 | /// are mutually exclusive and ``formParams`` should be used 22 | /// when both are defined. 23 | /// 24 | /// Both the ``ApiEnvironment`` and ``ApiRoute`` can specify 25 | /// headers and query parameters that they need. Environment 26 | /// specific headers and query parameters will be applied to 27 | /// all requests, while a route specific value will override 28 | /// a value that is defined by the environment. 29 | public protocol ApiRoute: Sendable { 30 | 31 | /// Optional header parameters to apply to the route. 32 | var headers: [String: String]? { get } 33 | 34 | /// The HTTP method to use for the route. 35 | var httpMethod: HttpMethod { get } 36 | 37 | /// The route's ``ApiEnvironment`` relative path. 38 | var path: String { get } 39 | 40 | /// Optional query params to apply to the route. 41 | var queryParams: [String: String]? { get } 42 | 43 | /// Optional form data, which is sent as request body. 44 | var formParams: [String: String]? { get } 45 | 46 | /// Optional post data, which is sent as request body. 47 | var postData: Data? { get } 48 | } 49 | 50 | public extension ApiRoute { 51 | 52 | /// Convert ``encodedFormItems`` to `.utf8` encoded data. 53 | var encodedFormData: Data? { 54 | guard let formParams, !formParams.isEmpty else { return nil } 55 | var params = URLComponents() 56 | params.queryItems = encodedFormItems 57 | let paramString = params.query 58 | return paramString?.data(using: .utf8) 59 | } 60 | 61 | /// Convert ``formParams`` to form encoded query items. 62 | var encodedFormItems: [URLQueryItem]? { 63 | formParams? 64 | .map { URLQueryItem(name: $0.key, value: $0.value.formEncoded()) } 65 | .sorted { $0.name < $1.name } 66 | } 67 | 68 | /// Get a `URLRequest` for the route and its properties. 69 | func urlRequest(for env: ApiEnvironment) throws -> URLRequest { 70 | guard let envUrl = URL(string: env.url) else { throw ApiError.invalidEnvironmentUrl(env.url) } 71 | let routeUrl = envUrl.appendingPathComponent(path) 72 | guard var components = urlComponents(from: routeUrl) else { throw ApiError.failedToCreateComponentsFromUrl(routeUrl) } 73 | components.queryItems = queryItems(for: env) 74 | guard let requestUrl = components.url else { throw ApiError.noUrlInComponents(components) } 75 | var request = URLRequest(url: requestUrl) 76 | let formData = encodedFormData 77 | request.allHTTPHeaderFields = headers(for: env) 78 | request.httpBody = formData ?? postData 79 | request.httpMethod = httpMethod.method 80 | let isFormRequest = formData != nil 81 | let contentType = isFormRequest ? "application/x-www-form-urlencoded" : "application/json" 82 | request.setValue(contentType, forHTTPHeaderField: "Content-Type") 83 | return request 84 | } 85 | } 86 | 87 | public extension ApiEnvironment { 88 | 89 | /// Get a `URLRequest` for a certain ``ApiRoute``. 90 | func urlRequest(for route: ApiRoute) throws -> URLRequest { 91 | try route.urlRequest(for: self) 92 | } 93 | } 94 | 95 | extension ApiRoute { 96 | 97 | var encodedQueryItems: [URLQueryItem]? { 98 | queryParams? 99 | .map { URLQueryItem(name: $0.key, value: $0.value) } 100 | .sorted { $0.name < $1.name } 101 | } 102 | } 103 | 104 | private extension ApiRoute { 105 | 106 | func headers(for env: ApiEnvironment) -> [String: String] { 107 | var result = env.headers ?? [:] 108 | headers?.forEach { 109 | result[$0.key] = $0.value 110 | } 111 | return result 112 | } 113 | 114 | func queryItems(for env: ApiEnvironment) -> [URLQueryItem] { 115 | let routeData = encodedQueryItems ?? [] 116 | let envData = env.encodedQueryItems ?? [] 117 | return routeData + envData 118 | } 119 | 120 | func urlComponents(from url: URL) -> URLComponents? { 121 | URLComponents(url: url, resolvingAgainstBaseURL: true) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Sources/ApiKit/Extensions/Int+HttpStatusCodes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Int+HttpStatusCodes.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2024-10-04. 6 | // Copyright © 2024 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | extension Int { 10 | 11 | /// HTTP status codes are within the 100-599 range. 12 | var isValidHttpStatusCode: Bool { 13 | self > 99 && self < 600 14 | } 15 | 16 | /// HTTP status codes are only successful within the 200 range. 17 | var isSuccessfulHttpStatusCode: Bool { 18 | self > 199 && self < 300 19 | } 20 | 21 | /// HTTP status codes are only successful within the 200 range. 22 | var isUnsuccessfulHttpStatusCode: Bool { 23 | isValidHttpStatusCode && !isSuccessfulHttpStatusCode 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/ApiKit/Extensions/String+UrlEncode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+UrlEncode.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-03-24. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | // https://danielsaidi.com/blog/2020/06/04/string-urlencode 9 | // 10 | 11 | import Foundation 12 | 13 | extension String { 14 | 15 | /// Encode the string to work for `x-www-form-urlencoded`. 16 | /// 17 | /// This will first call `urlEncoded()` and replace each 18 | /// `+` with `%2B`. 19 | func formEncoded() -> String? { 20 | self.urlEncoded()? 21 | .replacingOccurrences(of: "+", with: "%2B") 22 | } 23 | 24 | /// Encode the string for quary parameters. 25 | /// 26 | /// This will first call `addingPercentEncoding` using a 27 | /// `.urlPathAllowed` character set then replace each `&` 28 | /// with `%26`. 29 | func urlEncoded() -> String? { 30 | self.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)? 31 | .replacingOccurrences(of: "&", with: "%26") 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/ApiKit/Http/HttpMethod.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HttpMethod.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-03-24. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This enum defines various HTTP methods. 12 | public enum HttpMethod: String, CaseIterable, Identifiable { 13 | 14 | case connect 15 | case delete 16 | case get 17 | case head 18 | case options 19 | case patch 20 | case post 21 | case put 22 | case trace 23 | 24 | /// The unique HTTP method identifier. 25 | public var id: String { rawValue } 26 | 27 | /// The uppercased HTTP method name. 28 | public var method: String { id.uppercased() } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/ApiKit/Integrations/TheMovieDb/TheMovieDb+Environment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TheMovieDb+Environment.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-08-17. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension TheMovieDb { 12 | 13 | /// This type defines supported TheMovieDb environments. 14 | enum Environment: ApiEnvironment { 15 | 16 | case production(apiKey: String) 17 | } 18 | } 19 | 20 | public extension TheMovieDb.Environment { 21 | 22 | var url: String { 23 | switch self { 24 | case .production: "https://api.themoviedb.org/3" 25 | } 26 | } 27 | 28 | var headers: [String: String]? { nil } 29 | 30 | var queryParams: [String: String]? { 31 | switch self { 32 | case .production(let key): ["api_key": key] 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/ApiKit/Integrations/TheMovieDb/TheMovieDb+Models.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TheMovieDb+Models.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-08-17. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension TheMovieDb { 12 | 13 | /// This type represents a TheMovieDb movie. 14 | struct Movie: ApiModel, Identifiable { 15 | 16 | public let id: Int 17 | public let imdbId: String? 18 | public let title: String 19 | public let originalTitle: String? 20 | public let originalLanguage: String? 21 | public let overview: String? 22 | public let tagline: String? 23 | public let genres: [MovieGenre]? 24 | 25 | public let releaseDate: String? 26 | public let budget: Int? 27 | public let runtime: Int? 28 | public let revenue: Int? 29 | public let popularity: Double? 30 | public let averateRating: Double? 31 | 32 | public let homepageUrl: String? 33 | public let backdropPath: String? 34 | public let posterPath: String? 35 | 36 | public let belongsToCollection: Bool? 37 | public let isAdultMovie: Bool? 38 | 39 | enum CodingKeys: String, CodingKey { 40 | case id 41 | case imdbId = "imdb_id" 42 | case title 43 | case originalTitle 44 | case originalLanguage 45 | case overview 46 | case tagline 47 | case genres 48 | 49 | case releaseDate = "release_date" 50 | case budget 51 | case runtime 52 | case revenue 53 | case popularity 54 | case averateRating = "vote_averate" 55 | 56 | case homepageUrl = "homepage" 57 | case backdropPath = "backdrop_path" 58 | case posterPath = "poster_path" 59 | 60 | case belongsToCollection = "belongs_to_collection" 61 | case isAdultMovie = "adult" 62 | } 63 | 64 | public func backdropUrl(width: Int) -> URL? { 65 | imageUrl(path: backdropPath ?? "", width: width) 66 | } 67 | 68 | public func posterUrl(width: Int) -> URL? { 69 | imageUrl(path: posterPath ?? "", width: width) 70 | } 71 | 72 | func imageUrl(path: String, width: Int) -> URL? { 73 | URL(string: "https://image.tmdb.org/t/p/w\(width)" + path) 74 | } 75 | } 76 | 77 | /// This type represents a TheMovieDb movie genre. 78 | struct MovieGenre: ApiModel, Identifiable { 79 | 80 | public let id: Int 81 | public let name: String 82 | } 83 | 84 | /// This type represents a TheMovieDb pagination result. 85 | struct MoviesPaginationResult: ApiModel { 86 | 87 | public let page: Int 88 | public let results: [Movie] 89 | public let totalPages: Int 90 | public let totalResults: Int 91 | 92 | enum CodingKeys: String, CodingKey { 93 | case page 94 | case results 95 | case totalPages = "total_pages" 96 | case totalResults = "total_results" 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/ApiKit/Integrations/TheMovieDb/TheMovieDb+Route.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TheMovieDb+Route.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-08-17. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension TheMovieDb { 12 | 13 | /// This type defines supported TheMovieDb routes. 14 | enum Route: ApiRoute { 15 | 16 | public typealias Movie = TheMovieDb.Movie 17 | public typealias MoviesPaginationResult = TheMovieDb.MoviesPaginationResult 18 | 19 | case discoverMovies(page: Int, sortBy: String = "popularity") 20 | case movie(id: Int) 21 | case searchMovies(query: String, page: Int) 22 | } 23 | } 24 | 25 | public extension TheMovieDb.Route { 26 | 27 | var path: String { 28 | switch self { 29 | case .discoverMovies: "discover/movie" 30 | case .movie(let id): "movie/\(id)" 31 | case .searchMovies: "search/movie" 32 | } 33 | } 34 | 35 | var httpMethod: HttpMethod { .get } 36 | 37 | var headers: [String: String]? { nil } 38 | 39 | var formParams: [String: String]? { nil } 40 | 41 | var postData: Data? { nil } 42 | 43 | var queryParams: [String: String]? { 44 | switch self { 45 | case .discoverMovies(let page, let sortBy): [ 46 | "language": "en-US", 47 | "sort-by": sortBy, 48 | "page": "\(page)" 49 | ] 50 | case .movie: nil 51 | case .searchMovies(let query, let page): [ 52 | "query": query, 53 | "page": "\(page)" 54 | ] 55 | } 56 | } 57 | 58 | var returnType: Any? { 59 | switch self { 60 | case .discoverMovies: [Movie].self 61 | case .movie: Movie.self 62 | case .searchMovies: MoviesPaginationResult.self 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/ApiKit/Integrations/TheMovieDb/TheMovieDb.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TheMovieDb.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-03-28. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This namespace defines an API integration for TheMovieDb. 12 | /// 13 | /// You can read more on how to set up a test API account at 14 | /// `https://themoviedb.org`. 15 | public struct TheMovieDb {} 16 | -------------------------------------------------------------------------------- /Sources/ApiKit/Integrations/Yelp/Yelp+Environment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Yelp+Environment.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-08-17. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Yelp { 12 | 13 | /// This type defines supported Yelp API environments. 14 | enum Environment: ApiEnvironment { 15 | 16 | case v3(apiToken: String) 17 | } 18 | } 19 | 20 | public extension Yelp.Environment { 21 | 22 | var url: String { 23 | switch self { 24 | case .v3: "https://api.yelp.com/v3/" 25 | } 26 | } 27 | 28 | var headers: [String: String]? { 29 | switch self { 30 | case .v3(let apiToken): ["Authorization": "Bearer \(apiToken)"] 31 | } 32 | } 33 | 34 | var queryParams: [String: String]? { [:] } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/ApiKit/Integrations/Yelp/Yelp+Models.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Yelp+Models.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-08-17. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Yelp { 12 | 13 | /// This type represents a Yelp restaurant (business). 14 | struct Restaurant: ApiModel { 15 | 16 | public let id: String 17 | public let alias: String? 18 | public let name: String? 19 | public let imageUrl: String? 20 | public let isClosed: Bool? 21 | public let url: String? 22 | public let reviewCount: Int? 23 | public let categories: [RestaurantCategory] 24 | public let rating: Double? 25 | public let location: RestaurantLocation 26 | public let coordinates: RestaurantCoordinates 27 | public let photos: [String]? 28 | public let price: String? 29 | public let hours: [RestaurantHours]? 30 | public let phone: String? 31 | public let displayPhone: String? 32 | public let distance: Double? 33 | 34 | enum CodingKeys: String, CodingKey { 35 | case id 36 | case alias 37 | case name 38 | case imageUrl = "image_url" 39 | case isClosed = "is_closed" 40 | case url 41 | case reviewCount = "review_count" 42 | case categories 43 | case rating 44 | case location 45 | case coordinates 46 | case photos 47 | case price 48 | case hours 49 | case phone 50 | case displayPhone = "display_phone" 51 | case distance 52 | } 53 | } 54 | 55 | 56 | /// This type represents a Yelp restaurant category. 57 | struct RestaurantCategory: ApiModel { 58 | 59 | public let title: String 60 | } 61 | 62 | /// This type represents Yelp restaurant coordinates. 63 | struct RestaurantCoordinates: ApiModel { 64 | 65 | public let latitude: Double? 66 | public let longitude: Double? 67 | } 68 | 69 | /// This type represents a Yelp restaurant opening hours. 70 | struct RestaurantHour: ApiModel { 71 | 72 | public let isOvernight: Bool 73 | public let start: String 74 | public let end: String 75 | public let day: Int 76 | 77 | enum CodingKeys: String, CodingKey { 78 | case isOvernight = "is_overnight" 79 | case start 80 | case end 81 | case day 82 | } 83 | } 84 | 85 | /// This type represents a Yelp restaurant opening hour. 86 | struct RestaurantHours: ApiModel { 87 | 88 | public let type: String 89 | public let isOpenNow: Bool 90 | public let open: [RestaurantHour] 91 | 92 | enum CodingKeys: String, CodingKey { 93 | case type = "hours_type" 94 | case isOpenNow = "is_open_now" 95 | case open 96 | } 97 | } 98 | 99 | /// This type represents a Yelp restaurant location. 100 | struct RestaurantLocation: ApiModel { 101 | 102 | public let displayAddress: [String] 103 | 104 | enum CodingKeys: String, CodingKey { 105 | case displayAddress = "display_address" 106 | } 107 | } 108 | 109 | /// This type represents a Yelp restaurant review. 110 | struct RestaurantReview: ApiModel { 111 | 112 | public let id: String 113 | public let url: String? 114 | public let text: String? 115 | public let rating: Double? 116 | public let user: RestaurantReviewUser 117 | } 118 | 119 | /// This type represents a Yelp restaurant review result. 120 | struct RestaurantReviewsResult: Codable { 121 | 122 | public let reviews: [RestaurantReview] 123 | } 124 | 125 | /// This type represents a Yelp restaurant review user. 126 | struct RestaurantReviewUser: ApiModel { 127 | 128 | public let id: String 129 | public let name: String? 130 | public let imageUrl: String? 131 | 132 | enum CodingKeys: String, CodingKey { 133 | case id 134 | case name 135 | case imageUrl = "image_url" 136 | } 137 | } 138 | 139 | /// This type represents Yelp search parameters. 140 | struct RestaurantSearchParams: Sendable { 141 | 142 | public init( 143 | skip: Int, 144 | take: Int, 145 | radius: Int, 146 | coordinate: (lat: Double, long: Double)? = nil, 147 | budgetLevels: [BudgetLevel] = [], 148 | openingHours: OpeningHours = .showAll 149 | ) { 150 | self.skip = skip 151 | self.take = take 152 | self.radius = radius 153 | self.coordinate = coordinate 154 | self.budgetLevels = budgetLevels 155 | self.openingHours = openingHours 156 | } 157 | 158 | public enum BudgetLevel: String, Sendable { 159 | case level1 = "1" 160 | case level2 = "2" 161 | case level3 = "3" 162 | case level4 = "4" 163 | } 164 | 165 | public enum OpeningHours: String, Sendable { 166 | case openNow 167 | case showAll 168 | } 169 | 170 | public let skip: Int 171 | public let take: Int 172 | public let radius: Int 173 | public let coordinate: (lat: Double, long: Double)? 174 | public let budgetLevels: [BudgetLevel] 175 | public let openingHours: OpeningHours 176 | 177 | public var queryParams: [String: String] { 178 | var params: [String: String] = [ 179 | "categories": "restaurants", 180 | "radius": "\(radius)", 181 | "offset": "\(skip)", 182 | "limit": "\(take)" 183 | ] 184 | 185 | if let coord = coordinate { 186 | params["latitude"] = "\(coord.lat)" 187 | params["longitude"] = "\(coord.long)" 188 | } 189 | 190 | if !budgetLevels.isEmpty { 191 | params["price"] = Set(budgetLevels) 192 | .map { $0.rawValue } 193 | .joined(separator: ",") 194 | } 195 | 196 | if openingHours == .openNow { 197 | params["open_now"] = "true" 198 | } 199 | 200 | return params 201 | } 202 | } 203 | 204 | /// This type represents a Yelp search result. 205 | struct RestaurantSearchResult: Codable { 206 | 207 | public let businesses: [Restaurant] 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /Sources/ApiKit/Integrations/Yelp/Yelp+Route.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Yelp+Route.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-08-17. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Yelp { 12 | 13 | /// This type defines supported Yelp API routes. 14 | enum Route: ApiRoute { 15 | 16 | public typealias Restaurant = Yelp.Restaurant 17 | public typealias RestaurantReviewsResult = Yelp.RestaurantReviewsResult 18 | public typealias RestaurantSearchResult = Yelp.RestaurantSearchResult 19 | 20 | case restaurant(id: String) 21 | case restaurantReviews(restaurantId: String) 22 | case search(params: Yelp.RestaurantSearchParams) 23 | } 24 | } 25 | 26 | public extension Yelp.Route { 27 | 28 | var path: String { 29 | switch self { 30 | case .restaurant(let id): "businesses/\(id)" 31 | case .restaurantReviews(let id): "businesses/\(id)/reviews" 32 | case .search: "businesses/search" 33 | } 34 | } 35 | 36 | var httpMethod: HttpMethod { .get } 37 | 38 | var headers: [String: String]? { nil } 39 | 40 | var formParams: [String: String]? { nil } 41 | 42 | var postData: Data? { nil } 43 | 44 | var queryParams: [String: String]? { 45 | switch self { 46 | case .restaurant: nil 47 | case .restaurantReviews: nil 48 | case .search(let params): params.queryParams 49 | } 50 | } 51 | 52 | var returnType: Any? { 53 | switch self { 54 | case .restaurant: Restaurant.self 55 | case .restaurantReviews: RestaurantReviewsResult.self 56 | case .search: RestaurantSearchResult.self 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/ApiKit/Integrations/Yelp/Yelp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Yelp.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-03-28. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This namespace defines an API integration for Yelp's API. 12 | /// 13 | /// You can read more on how to set up a test API account at 14 | /// `https://yelp.com/developers`. 15 | public struct Yelp {} 16 | -------------------------------------------------------------------------------- /Tests/ApiKitTests/ApiClientTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApiClientTests.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-03-24. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import ApiKit 10 | import XCTest 11 | 12 | final class ApiClientTests: XCTestCase { 13 | 14 | private let route = TestRoute.movie(id: "ABC123") 15 | private let env = TestEnvironment.production 16 | 17 | func client(withData data: Data = .init()) -> ApiClient { 18 | TestClient(data: data) 19 | } 20 | 21 | 22 | func testFetchingItemAtRouteFailsIfServiceThrowsError() async { 23 | let client = TestClient(error: TestError.baboooom) 24 | do { 25 | let _: TestMovie? = try await client.request(at: route, in: env) 26 | XCTFail("Should fail") 27 | } catch { 28 | let err = error as? TestError 29 | XCTAssertTrue(err == .baboooom) 30 | } 31 | } 32 | 33 | func testFetchingItemAtRouteFailsForInvalidData() async throws { 34 | let client = TestClient() 35 | do { 36 | let _: TestMovie? = try await client.request(at: route, in: env) 37 | XCTFail("Should fail") 38 | } catch { 39 | XCTAssertNotNil(error as? DecodingError) 40 | } 41 | } 42 | 43 | func testFetchingItemAtRouteFailsForInvalidStatusCode() async throws { 44 | let response = TestResponse.withStatusCode(-1) 45 | let client = TestClient(response: response) 46 | do { 47 | let _: TestMovie? = try await client.request(at: route, in: env) 48 | XCTFail("Should fail") 49 | } catch { 50 | let error = error as? ApiError 51 | XCTAssertTrue(error?.isInvalidHttpStatusCodeError == true) 52 | } 53 | } 54 | 55 | func testFetchingItemAtRouteFailsForUnsuccessfulStatusCode() async throws { 56 | let response = TestResponse.withStatusCode(100) 57 | let client = TestClient(response: response) 58 | do { 59 | let _: TestMovie? = try await client.request(at: route, in: env) 60 | XCTFail("Should fail") 61 | } catch { 62 | let error = error as? ApiError 63 | XCTAssertTrue(error?.isUnsuccessfulHttpStatusCodeError == true) 64 | } 65 | } 66 | 67 | func testFetchingItemAtRouteSucceedsIfServiceReturnsValidData() async throws { 68 | let movie = TestMovie(id: "", name: "Godfather") 69 | let data = try JSONEncoder().encode(movie) 70 | let client = client(withData: data) 71 | do { 72 | let movie: TestMovie = try await client.request(at: route, in: env) 73 | XCTAssertEqual(movie.name, "Godfather") 74 | } catch { 75 | XCTFail("Should fail") 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Tests/ApiKitTests/ApiEnvironmentTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApiEnvironmentTests.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-03-25. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import ApiKit 11 | 12 | final class ApiEnvironmentTests: XCTestCase { 13 | 14 | func request(for route: TestRoute) -> URLRequest? { 15 | let env = TestEnvironment.production 16 | return try? env.urlRequest(for: route) 17 | } 18 | 19 | func testUrlRequestIsCreatedWithRoute() throws { 20 | XCTAssertNotNil(request(for: .movie(id: "ABC123"))) 21 | XCTAssertNotNil(request(for: .formLogin(userName: "danielsaidi", password: "super-secret"))) 22 | XCTAssertNotNil(request(for: .postLogin(userName: "danielsaidi", password: "super-secret"))) 23 | XCTAssertNotNil(request(for: .search(query: "A nice movie", page: 1))) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/ApiKitTests/ApiRequestDataTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApiRequestDataTests.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-03-28. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | @testable import ApiKit 12 | 13 | final class ApiRequestDataTests: XCTestCase { 14 | 15 | func testQueryItemsAreSortedAndEncoded() throws { 16 | let route = TestRoute.search(query: "let's search for &", page: 1) 17 | let items = route.encodedQueryItems 18 | XCTAssertEqual(items?.count, 2) 19 | XCTAssertEqual(items?[0].name, "p") 20 | XCTAssertEqual(items?[0].value, "1") 21 | XCTAssertEqual(items?[1].name, "q") 22 | XCTAssertEqual(items?[1].value, "let's search for &") 23 | } 24 | 25 | func testArrayQueryParametersAreJoined() throws { 26 | let route = TestRoute.searchWithArrayParams(years: [2021, 2022, 2023]) 27 | let items = route.encodedQueryItems 28 | XCTAssertEqual(items?.count, 1) 29 | XCTAssertEqual(items?[0].name, "years") 30 | XCTAssertEqual(items?[0].value, "[2021,2022,2023]") 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/ApiKitTests/ApiRouteTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApiRouteTests.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-03-24. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import ApiKit 11 | 12 | final class ApiRouteTests: XCTestCase { 13 | 14 | func request(for route: TestRoute) -> URLRequest? { 15 | let env = TestEnvironment.production 16 | return try? route.urlRequest(for: env) 17 | } 18 | 19 | 20 | func testEncodedFormItemsAreSortedAndEncoded() throws { 21 | let route = TestRoute.formLogin(userName: "danielsaidi", password: "let's code, shall we? & do more stuff +") 22 | let items = route.encodedFormItems 23 | XCTAssertEqual(items?.count, 2) 24 | XCTAssertEqual(items?[0].name, "password") 25 | XCTAssertEqual(items?[0].value, "let's%20code,%20shall%20we%3F%20%26%20do%20more%20stuff%20%2B") 26 | XCTAssertEqual(items?[1].name, "username") 27 | XCTAssertEqual(items?[1].value, "danielsaidi") 28 | } 29 | 30 | 31 | func testUrlRequestIsCreatedWithEnvironment() throws { 32 | XCTAssertNotNil(request(for: .movie(id: "ABC123"))) 33 | XCTAssertNotNil(request(for: .formLogin(userName: "danielsaidi", password: "super-secret"))) 34 | XCTAssertNotNil(request(for: .postLogin(userName: "danielsaidi", password: "super-secret"))) 35 | XCTAssertNotNil(request(for: .search(query: "A nice movie", page: 1))) 36 | } 37 | 38 | func testUrlRequestIsPropertyConfiguredForGetRequestsWithQueryParameters() throws { 39 | let route = TestRoute.search(query: "movies&+", page: 1) 40 | let request = request(for: route) 41 | XCTAssertEqual(request?.allHTTPHeaderFields, [ 42 | "Content-Type": "application/json", 43 | "locale": "sv-SE", 44 | "api-secret": "APISECRET" 45 | ]) 46 | XCTAssertEqual(request?.httpMethod, "GET") 47 | XCTAssertEqual(request?.url?.absoluteString, "https://api.imdb.com/search?p=1&q=movies%26+&api-key=APIKEY") 48 | } 49 | 50 | func testUrlRequestIsPropertyConfiguredForGetRequestsWithArrayQueryParameters() throws { 51 | let route = TestRoute.searchWithArrayParams(years: [2021, 2022, 2023]) 52 | let request = request(for: route) 53 | XCTAssertEqual(request?.allHTTPHeaderFields, [ 54 | "Content-Type": "application/json", 55 | "locale": "sv-SE", 56 | "api-secret": "APISECRET" 57 | ]) 58 | XCTAssertEqual(request?.httpMethod, "GET") 59 | XCTAssertEqual(request?.url?.absoluteString, "https://api.imdb.com/search?years=%5B2021,2022,2023%5D&api-key=APIKEY") 60 | } 61 | 62 | func testUrlRequestIsPropertyConfiguredForFormRequests() throws { 63 | let route = TestRoute.formLogin(userName: "danielsaidi", password: "let's code, shall we? & do more stuff +") 64 | let request = request(for: route) 65 | guard 66 | let bodyData = request?.httpBody, 67 | let bodyString = String(data: bodyData, encoding: .utf8) 68 | else { 69 | return XCTFail("Invalid body data") 70 | } 71 | XCTAssertEqual(request?.allHTTPHeaderFields, [ 72 | "Content-Type": "application/x-www-form-urlencoded", 73 | "locale": "sv-SE", 74 | "api-secret": "APISECRET" 75 | ]) 76 | XCTAssertEqual(bodyString, "password=let's%20code,%20shall%20we%3F%20%26%20do%20more%20stuff%20%2B&username=danielsaidi") 77 | } 78 | 79 | func testUrlRequestIsPropertyConfiguredForPostRequests() throws { 80 | let route = TestRoute.postLogin(userName: "danielsaidi", password: "password+") 81 | let request = request(for: route) 82 | guard 83 | let bodyData = request?.httpBody, 84 | let loginRequest = try? JSONDecoder().decode(TestLoginRequest.self, from: bodyData) 85 | else { 86 | return XCTFail("Invalid body data") 87 | } 88 | XCTAssertEqual(request?.allHTTPHeaderFields, [ 89 | "Content-Type": "application/json", 90 | "locale": "sv-SE", 91 | "api-secret": "APISECRET" 92 | ]) 93 | XCTAssertEqual(request?.url?.absoluteString, "https://api.imdb.com/postLogin?api-key=APIKEY") 94 | XCTAssertEqual(request?.httpMethod, "POST") 95 | XCTAssertEqual(loginRequest.userName, "danielsaidi") 96 | XCTAssertEqual(loginRequest.password, "password+") 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Tests/ApiKitTests/Extensions/Int+HttpStatusCodesTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Int+HttpStatusCodesTests.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2024-10-04. 6 | // Copyright © 2024 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | @testable import ApiKit 12 | 13 | final class Int_HttpStatusCodesTests: XCTestCase { 14 | 15 | func testIntegerCanValidateStatusCode() async throws { 16 | XCTAssertFalse(0.isValidHttpStatusCode) 17 | XCTAssertFalse(99.isValidHttpStatusCode) 18 | XCTAssertTrue(100.isValidHttpStatusCode) 19 | XCTAssertTrue(599.isValidHttpStatusCode) 20 | XCTAssertFalse(600.isValidHttpStatusCode) 21 | 22 | XCTAssertFalse(199.isSuccessfulHttpStatusCode) 23 | XCTAssertTrue(200.isSuccessfulHttpStatusCode) 24 | XCTAssertTrue(299.isSuccessfulHttpStatusCode) 25 | XCTAssertFalse(300.isSuccessfulHttpStatusCode) 26 | 27 | XCTAssertTrue(199.isUnsuccessfulHttpStatusCode) 28 | XCTAssertFalse(200.isUnsuccessfulHttpStatusCode) 29 | XCTAssertFalse(299.isUnsuccessfulHttpStatusCode) 30 | XCTAssertTrue(300.isUnsuccessfulHttpStatusCode) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/ApiKitTests/HttpMethodTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HttpMethodTests.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-03-24. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import ApiKit 10 | import XCTest 11 | 12 | final class HttpMethodTests: XCTestCase { 13 | 14 | func method(for method: HttpMethod) -> String { 15 | method.method 16 | } 17 | 18 | func testMethodIsUppercasedForAllCases() throws { 19 | HttpMethod.allCases.forEach { method in 20 | XCTAssertEqual(method.method, method.rawValue.uppercased()) 21 | } 22 | } 23 | 24 | func testMethodIsUppercased() throws { 25 | XCTAssertEqual(method(for: .connect), "CONNECT") 26 | XCTAssertEqual(method(for: .delete), "DELETE") 27 | XCTAssertEqual(method(for: .get), "GET") 28 | XCTAssertEqual(method(for: .head), "HEAD") 29 | XCTAssertEqual(method(for: .options), "OPTIONS") 30 | XCTAssertEqual(method(for: .post), "POST") 31 | XCTAssertEqual(method(for: .put), "PUT") 32 | XCTAssertEqual(method(for: .trace), "TRACE") 33 | } 34 | 35 | func testMethodUsesRawNameAsId() throws { 36 | HttpMethod.allCases.forEach { method in 37 | XCTAssertEqual(method.id, method.rawValue) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Tests/ApiKitTests/TestTypes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestTypes.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2023-03-28. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import ApiKit 10 | import Foundation 11 | 12 | class TestClient: ApiClient { 13 | 14 | init( 15 | data: Data = .init(), 16 | response: HTTPURLResponse = TestResponse.withStatusCode(200), 17 | error: Error? = nil 18 | ) { 19 | self.data = data 20 | self.response = response 21 | self.error = error 22 | } 23 | 24 | let data: Data 25 | let response: HTTPURLResponse 26 | let error: Error? 27 | 28 | func data( 29 | for request: URLRequest 30 | ) async throws -> (Data, URLResponse) { 31 | if let error { throw error } 32 | return (data, response) 33 | } 34 | } 35 | 36 | class TestResponse: HTTPURLResponse, @unchecked Sendable { 37 | 38 | var testStatusCode = 200 39 | 40 | override var statusCode: Int { testStatusCode } 41 | 42 | static func withStatusCode( 43 | _ code: Int 44 | ) -> TestResponse { 45 | let response = TestResponse( 46 | url: URL(string: "https://kankoda.com")!, 47 | mimeType: nil, 48 | expectedContentLength: 0, 49 | textEncodingName: nil 50 | ) 51 | response.testStatusCode = code 52 | return response 53 | } 54 | } 55 | 56 | enum TestEnvironment: ApiEnvironment { 57 | 58 | case staging 59 | case production 60 | 61 | var url: String { 62 | switch self { 63 | case .staging: return "https://staging-api.imdb.com" 64 | case .production: return "https://api.imdb.com" 65 | } 66 | } 67 | 68 | var headers: [String: String]? { 69 | ["api-secret": "APISECRET"] 70 | } 71 | 72 | var queryParams: [String: String]? { 73 | ["api-key": "APIKEY"] 74 | } 75 | } 76 | 77 | enum TestRoute: ApiRoute { 78 | 79 | case formLogin(userName: String, password: String) 80 | case movie(id: String) 81 | case postLogin(userName: String, password: String) 82 | case search(query: String, page: Int) 83 | case searchWithArrayParams(years: [Int]) 84 | 85 | var httpMethod: HttpMethod { 86 | switch self { 87 | case .formLogin: return .post 88 | case .movie: return .get 89 | case .postLogin: return .post 90 | case .search: return .get 91 | case .searchWithArrayParams: return .get 92 | } 93 | } 94 | 95 | var path: String { 96 | switch self { 97 | case .formLogin: return "formLogin" 98 | case .movie(let id): return "movie/\(id)" 99 | case .postLogin: return "postLogin" 100 | case .search: return "search" 101 | case .searchWithArrayParams: return "search" 102 | } 103 | } 104 | 105 | var headers: [String: String]? { 106 | ["locale": "sv-SE"] 107 | } 108 | 109 | var formParams: [String: String]? { 110 | switch self { 111 | case .formLogin(let userName, let password): 112 | return ["username": userName, "password": password] 113 | default: return nil 114 | } 115 | } 116 | 117 | var postData: Data? { 118 | switch self { 119 | case .formLogin: return nil 120 | case .movie: return nil 121 | case .postLogin(let userName, let password): 122 | let request = TestLoginRequest( 123 | userName: userName, password: password 124 | ) 125 | let encoder = JSONEncoder() 126 | return try? encoder.encode(request) 127 | case .search: return nil 128 | case .searchWithArrayParams: return nil 129 | } 130 | } 131 | 132 | var queryParams: [String: String]? { 133 | switch self { 134 | case .search(let query, let page): return [ 135 | "q": query, 136 | "p": "\(page)" 137 | ] 138 | case .searchWithArrayParams(let years): return [ 139 | "years": "[\(years.map { "\($0)"}.joined(separator: ","))]" 140 | ] 141 | default: return nil 142 | } 143 | } 144 | } 145 | 146 | 147 | struct TestLoginRequest: Codable { 148 | 149 | var userName: String 150 | var password: String 151 | } 152 | 153 | enum TestError: Error, Equatable { 154 | 155 | case baboooom 156 | } 157 | 158 | struct TestMovie: Codable { 159 | 160 | var id: String 161 | var name: String 162 | } 163 | 164 | struct TestPerson: Codable { 165 | 166 | var id: String 167 | var firstName: String 168 | var lastName: String 169 | } 170 | -------------------------------------------------------------------------------- /package_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script creates a new project version for the current project. 5 | # You can customize this to fit your project when you copy these scripts. 6 | # You can pass in a custom branch if you don't want to use the default one. 7 | 8 | SCRIPT="scripts/package_version.sh" 9 | chmod +x $SCRIPT 10 | bash $SCRIPT 11 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script builds a for all provided . 5 | # This script targets iOS, macOS, tvOS, watchOS, and xrOS by default. 6 | # You can pass in a list of if you want to customize the build. 7 | 8 | # Usage: 9 | # build.sh [ default:iOS macOS tvOS watchOS xrOS] 10 | # e.g. `bash scripts/build.sh MyTarget iOS macOS` 11 | 12 | # Exit immediately if a command exits with a non-zero status 13 | set -e 14 | 15 | # Verify that all required arguments are provided 16 | if [ $# -eq 0 ]; then 17 | echo "Error: This script requires at least one argument" 18 | echo "Usage: $0 [ default:iOS macOS tvOS watchOS xrOS]" 19 | echo "For instance: $0 MyTarget iOS macOS" 20 | exit 1 21 | fi 22 | 23 | # Define argument variables 24 | TARGET=$1 25 | 26 | # Remove TARGET from arguments list 27 | shift 28 | 29 | # Define platforms variable 30 | if [ $# -eq 0 ]; then 31 | set -- iOS macOS tvOS watchOS xrOS 32 | fi 33 | PLATFORMS=$@ 34 | 35 | # A function that builds $TARGET for a specific platform 36 | build_platform() { 37 | 38 | # Define a local $PLATFORM variable 39 | local PLATFORM=$1 40 | 41 | # Build $TARGET for the $PLATFORM 42 | echo "Building $TARGET for $PLATFORM..." 43 | if ! xcodebuild -scheme $TARGET -derivedDataPath .build -destination generic/platform=$PLATFORM; then 44 | echo "Failed to build $TARGET for $PLATFORM" 45 | return 1 46 | fi 47 | 48 | # Complete successfully 49 | echo "Successfully built $TARGET for $PLATFORM" 50 | } 51 | 52 | # Start script 53 | echo "" 54 | echo "Building $TARGET for [$PLATFORMS]..." 55 | echo "" 56 | 57 | # Loop through all platforms and call the build function 58 | for PLATFORM in $PLATFORMS; do 59 | if ! build_platform "$PLATFORM"; then 60 | exit 1 61 | fi 62 | done 63 | 64 | # Complete successfully 65 | echo "" 66 | echo "Building $TARGET completed successfully!" 67 | echo "" 68 | -------------------------------------------------------------------------------- /scripts/chmod.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script makes all scripts in this folder executable. 5 | 6 | # Usage: 7 | # scripts_chmod.sh 8 | # e.g. `bash scripts/chmod.sh` 9 | 10 | # Exit immediately if a command exits with a non-zero status 11 | set -e 12 | 13 | # Use the script folder to refer to other scripts. 14 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 15 | 16 | # Find all .sh files in the FOLDER except chmod.sh 17 | find "$FOLDER" -name "*.sh" ! -name "chmod.sh" -type f | while read -r script; do 18 | chmod +x "$script" 19 | done 20 | -------------------------------------------------------------------------------- /scripts/docc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script builds DocC for a and certain . 5 | # This script targets iOS, macOS, tvOS, watchOS, and xrOS by default. 6 | # You can pass in a list of if you want to customize the build. 7 | # The documentation ends up in to .build/docs-. 8 | 9 | # Usage: 10 | # docc.sh [ default:iOS macOS tvOS watchOS xrOS] 11 | # e.g. `bash scripts/docc.sh MyTarget iOS macOS` 12 | 13 | # Exit immediately if a command exits with a non-zero status 14 | set -e 15 | 16 | # Fail if any command in a pipeline fails 17 | set -o pipefail 18 | 19 | # Verify that all required arguments are provided 20 | if [ $# -eq 0 ]; then 21 | echo "Error: This script requires at least one argument" 22 | echo "Usage: $0 [ default:iOS macOS tvOS watchOS xrOS]" 23 | echo "For instance: $0 MyTarget iOS macOS" 24 | exit 1 25 | fi 26 | 27 | # Define argument variables 28 | TARGET=$1 29 | TARGET_LOWERCASED=$(echo "$1" | tr '[:upper:]' '[:lower:]') 30 | 31 | # Remove TARGET from arguments list 32 | shift 33 | 34 | # Define platforms variable 35 | if [ $# -eq 0 ]; then 36 | set -- iOS macOS tvOS watchOS xrOS 37 | fi 38 | PLATFORMS=$@ 39 | 40 | # Prepare the package for DocC 41 | swift package resolve; 42 | 43 | # A function that builds $TARGET for a specific platform 44 | build_platform() { 45 | 46 | # Define a local $PLATFORM variable and set an exit code 47 | local PLATFORM=$1 48 | local EXIT_CODE=0 49 | 50 | # Define the build folder name, based on the $PLATFORM 51 | case $PLATFORM in 52 | "iOS") 53 | DEBUG_PATH="Debug-iphoneos" 54 | ;; 55 | "macOS") 56 | DEBUG_PATH="Debug" 57 | ;; 58 | "tvOS") 59 | DEBUG_PATH="Debug-appletvos" 60 | ;; 61 | "watchOS") 62 | DEBUG_PATH="Debug-watchos" 63 | ;; 64 | "xrOS") 65 | DEBUG_PATH="Debug-xros" 66 | ;; 67 | *) 68 | echo "Error: Unsupported platform '$PLATFORM'" 69 | exit 1 70 | ;; 71 | esac 72 | 73 | # Build $TARGET docs for the $PLATFORM 74 | echo "Building $TARGET docs for $PLATFORM..." 75 | if ! xcodebuild docbuild -scheme $TARGET -derivedDataPath .build/docbuild -destination "generic/platform=$PLATFORM"; then 76 | echo "Error: Failed to build documentation for $PLATFORM" >&2 77 | return 1 78 | fi 79 | 80 | # Transform docs for static hosting 81 | if ! $(xcrun --find docc) process-archive \ 82 | transform-for-static-hosting .build/docbuild/Build/Products/$DEBUG_PATH/$TARGET.doccarchive \ 83 | --output-path .build/docs-$PLATFORM \ 84 | --hosting-base-path "$TARGET"; then 85 | echo "Error: Failed to transform documentation for $PLATFORM" >&2 86 | return 1 87 | fi 88 | 89 | # Inject a root redirect script on the root page 90 | echo "" > .build/docs-$PLATFORM/index.html; 91 | 92 | # Complete successfully 93 | echo "Successfully built $TARGET docs for $PLATFORM" 94 | return 0 95 | } 96 | 97 | # Start script 98 | echo "" 99 | echo "Building $TARGET docs for [$PLATFORMS]..." 100 | echo "" 101 | 102 | # Loop through all platforms and call the build function 103 | for PLATFORM in $PLATFORMS; do 104 | if ! build_platform "$PLATFORM"; then 105 | exit 1 106 | fi 107 | done 108 | 109 | # Complete successfully 110 | echo "" 111 | echo "Building $TARGET docs completed successfully!" 112 | echo "" 113 | -------------------------------------------------------------------------------- /scripts/framework.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script builds DocC for a and certain . 5 | # This script targets iOS, macOS, tvOS, watchOS, and xrOS by default. 6 | # You can pass in a list of if you want to customize the build. 7 | 8 | # Important: 9 | # This script doesn't work on packages, only on .xcproj projects that generate a framework. 10 | 11 | # Usage: 12 | # framework.sh [ default:iOS macOS tvOS watchOS xrOS] 13 | # e.g. `bash scripts/framework.sh MyTarget iOS macOS` 14 | 15 | # Exit immediately if a command exits with a non-zero status 16 | set -e 17 | 18 | # Verify that all required arguments are provided 19 | if [ $# -eq 0 ]; then 20 | echo "Error: This script requires exactly one argument" 21 | echo "Usage: $0 " 22 | exit 1 23 | fi 24 | 25 | # Define argument variables 26 | TARGET=$1 27 | 28 | # Remove TARGET from arguments list 29 | shift 30 | 31 | # Define platforms variable 32 | if [ $# -eq 0 ]; then 33 | set -- iOS macOS tvOS watchOS xrOS 34 | fi 35 | PLATFORMS=$@ 36 | 37 | # Define local variables 38 | BUILD_FOLDER=.build 39 | BUILD_FOLDER_ARCHIVES=.build/framework_archives 40 | BUILD_FILE=$BUILD_FOLDER/$TARGET.xcframework 41 | BUILD_ZIP=$BUILD_FOLDER/$TARGET.zip 42 | 43 | # Start script 44 | echo "" 45 | echo "Building $TARGET XCFramework for [$PLATFORMS]..." 46 | echo "" 47 | 48 | # Delete old builds 49 | echo "Cleaning old builds..." 50 | rm -rf $BUILD_ZIP 51 | rm -rf $BUILD_FILE 52 | rm -rf $BUILD_FOLDER_ARCHIVES 53 | 54 | 55 | # Generate XCArchive files for all platforms 56 | echo "Generating XCArchives..." 57 | 58 | # Initialize the xcframework command 59 | XCFRAMEWORK_CMD="xcodebuild -create-xcframework" 60 | 61 | # Build iOS archives and append to the xcframework command 62 | if [[ " ${PLATFORMS[@]} " =~ " iOS " ]]; then 63 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=iOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-iOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 64 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=iOS Simulator" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-iOS-Sim SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 65 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-iOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 66 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-iOS-Sim.xcarchive/Products/Library/Frameworks/$TARGET.framework" 67 | fi 68 | 69 | # Build iOS archive and append to the xcframework command 70 | if [[ " ${PLATFORMS[@]} " =~ " macOS " ]]; then 71 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=macOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-macOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 72 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-macOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 73 | fi 74 | 75 | # Build tvOS archives and append to the xcframework command 76 | if [[ " ${PLATFORMS[@]} " =~ " tvOS " ]]; then 77 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=tvOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-tvOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 78 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=tvOS Simulator" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-tvOS-Sim SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 79 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-tvOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 80 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-tvOS-Sim.xcarchive/Products/Library/Frameworks/$TARGET.framework" 81 | fi 82 | 83 | # Build watchOS archives and append to the xcframework command 84 | if [[ " ${PLATFORMS[@]} " =~ " watchOS " ]]; then 85 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=watchOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-watchOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 86 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=watchOS Simulator" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-watchOS-Sim SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 87 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-watchOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 88 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-watchOS-Sim.xcarchive/Products/Library/Frameworks/$TARGET.framework" 89 | fi 90 | 91 | # Build xrOS archives and append to the xcframework command 92 | if [[ " ${PLATFORMS[@]} " =~ " xrOS " ]]; then 93 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=xrOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-xrOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 94 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=xrOS Simulator" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-xrOS-Sim SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 95 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-xrOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 96 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-xrOS-Sim.xcarchive/Products/Library/Frameworks/$TARGET.framework" 97 | fi 98 | 99 | # Genererate XCFramework 100 | echo "Generating XCFramework..." 101 | XCFRAMEWORK_CMD+=" -output $BUILD_FILE" 102 | eval "$XCFRAMEWORK_CMD" 103 | 104 | # Genererate iOS XCFramework zip 105 | echo "Generating XCFramework zip..." 106 | zip -r $BUILD_ZIP $BUILD_FILE 107 | echo "" 108 | echo "***** CHECKSUM *****" 109 | swift package compute-checksum $BUILD_ZIP 110 | echo "********************" 111 | echo "" 112 | 113 | # Complete successfully 114 | echo "" 115 | echo "$TARGET XCFramework created successfully!" 116 | echo "" 117 | -------------------------------------------------------------------------------- /scripts/git_default_branch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script echos the default git branch name. 5 | 6 | # Usage: 7 | # git_default_branch.sh 8 | # e.g. `bash scripts/git_default_branch.sh` 9 | 10 | BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@') 11 | echo $BRANCH 12 | -------------------------------------------------------------------------------- /scripts/package_docc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script builds DocC documentation for `Package.swift`. 5 | # This script targets iOS by default, but you can pass in custom . 6 | 7 | # Usage: 8 | # package_docc.sh [ default:iOS] 9 | # e.g. `bash scripts/package_docc.sh iOS macOS` 10 | 11 | # Exit immediately if a command exits with non-zero status 12 | set -e 13 | 14 | # Use the script folder to refer to other scripts. 15 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 16 | SCRIPT_PACKAGE_NAME="$FOLDER/package_name.sh" 17 | SCRIPT_DOCC="$FOLDER/docc.sh" 18 | 19 | # Define platforms variable 20 | if [ $# -eq 0 ]; then 21 | set -- iOS 22 | fi 23 | PLATFORMS=$@ 24 | 25 | # Get package name 26 | PACKAGE_NAME=$("$SCRIPT_PACKAGE_NAME") || { echo "Failed to get package name"; exit 1; } 27 | 28 | # Build package documentation 29 | bash $SCRIPT_DOCC $PACKAGE_NAME $PLATFORMS || { echo "DocC script failed"; exit 1; } 30 | -------------------------------------------------------------------------------- /scripts/package_framework.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script generates an XCFramework for `Package.swift`. 5 | # This script targets iOS by default, but you can pass in custom . 6 | 7 | # Usage: 8 | # package_framework.sh [ default:iOS] 9 | # e.g. `bash scripts/package_framework.sh iOS macOS` 10 | 11 | # Exit immediately if a command exits with non-zero status 12 | set -e 13 | 14 | # Use the script folder to refer to other scripts. 15 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 16 | SCRIPT_PACKAGE_NAME="$FOLDER/package_name.sh" 17 | SCRIPT_FRAMEWORK="$FOLDER/framework.sh" 18 | 19 | # Define platforms variable 20 | if [ $# -eq 0 ]; then 21 | set -- iOS 22 | fi 23 | PLATFORMS=$@ 24 | 25 | # Get package name 26 | PACKAGE_NAME=$("$SCRIPT_PACKAGE_NAME") || { echo "Failed to get package name"; exit 1; } 27 | 28 | # Build package framework 29 | bash $SCRIPT_FRAMEWORK $PACKAGE_NAME $PLATFORMS 30 | -------------------------------------------------------------------------------- /scripts/package_name.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script finds the main target name in `Package.swift`. 5 | 6 | # Usage: 7 | # package_name.sh 8 | # e.g. `bash scripts/package_name.sh` 9 | 10 | # Exit immediately if a command exits with non-zero status 11 | set -e 12 | 13 | # Check that a Package.swift file exists 14 | if [ ! -f "Package.swift" ]; then 15 | echo "Error: Package.swift not found in current directory" 16 | exit 1 17 | fi 18 | 19 | # Using grep and sed to extract the package name 20 | # 1. grep finds the line containing "name:" 21 | # 2. sed extracts the text between quotes 22 | package_name=$(grep -m 1 'name:.*"' Package.swift | sed -n 's/.*name:[[:space:]]*"\([^"]*\)".*/\1/p') 23 | 24 | if [ -z "$package_name" ]; then 25 | echo "Error: Could not find package name in Package.swift" 26 | exit 1 27 | else 28 | echo "$package_name" 29 | fi 30 | -------------------------------------------------------------------------------- /scripts/package_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script creates a new version for `Package.swift`. 5 | # You can pass in a to validate any non-main branch. 6 | 7 | # Usage: 8 | # package_version.sh 9 | # e.g. `bash scripts/package_version.sh master` 10 | 11 | # Exit immediately if a command exits with non-zero status 12 | set -e 13 | 14 | # Use the script folder to refer to other scripts. 15 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 16 | SCRIPT_BRANCH_NAME="$FOLDER/git_default_branch.sh" 17 | SCRIPT_PACKAGE_NAME="$FOLDER/package_name.sh" 18 | SCRIPT_VERSION="$FOLDER/version.sh" 19 | 20 | # Get branch name 21 | DEFAULT_BRANCH=$("$SCRIPT_BRANCH_NAME") || { echo "Failed to get branch name"; exit 1; } 22 | BRANCH_NAME=${1:-$DEFAULT_BRANCH} 23 | 24 | # Get package name 25 | PACKAGE_NAME=$("$SCRIPT_PACKAGE_NAME") || { echo "Failed to get package name"; exit 1; } 26 | 27 | # Build package version 28 | bash $SCRIPT_VERSION $PACKAGE_NAME $BRANCH_NAME 29 | -------------------------------------------------------------------------------- /scripts/sync_from.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script syncs Swift Package Scripts from a . 5 | # This script will overwrite the existing "scripts" folder. 6 | # Only pass in the full path to a Swift Package Scripts root. 7 | 8 | # Usage: 9 | # package_name.sh 10 | # e.g. `bash sync_from.sh ../SwiftPackageScripts` 11 | 12 | # Define argument variables 13 | SOURCE=$1 14 | 15 | # Define variables 16 | FOLDER="scripts/" 17 | SOURCE_FOLDER="$SOURCE/$FOLDER" 18 | 19 | # Start script 20 | echo "" 21 | echo "Syncing scripts from $SOURCE_FOLDER..." 22 | echo "" 23 | 24 | # Remove existing folder 25 | rm -rf $FOLDER 26 | 27 | # Copy folder 28 | cp -r "$SOURCE_FOLDER/" "$FOLDER/" 29 | 30 | # Complete successfully 31 | echo "" 32 | echo "Script syncing from $SOURCE_FOLDER completed successfully!" 33 | echo "" 34 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script tests a for all provided . 5 | 6 | # Usage: 7 | # test.sh [ default:iOS macOS tvOS watchOS xrOS] 8 | # e.g. `bash scripts/test.sh MyTarget iOS macOS` 9 | 10 | # Exit immediately if a command exits with a non-zero status 11 | set -e 12 | 13 | # Verify that all required arguments are provided 14 | if [ $# -eq 0 ]; then 15 | echo "Error: This script requires at least one argument" 16 | echo "Usage: $0 [ default:iOS macOS tvOS watchOS xrOS]" 17 | echo "For instance: $0 MyTarget iOS macOS" 18 | exit 1 19 | fi 20 | 21 | # Define argument variables 22 | TARGET=$1 23 | 24 | # Remove TARGET from arguments list 25 | shift 26 | 27 | # Define platforms variable 28 | if [ $# -eq 0 ]; then 29 | set -- iOS macOS tvOS watchOS xrOS 30 | fi 31 | PLATFORMS=$@ 32 | 33 | # Start script 34 | echo "" 35 | echo "Testing $TARGET for [$PLATFORMS]..." 36 | echo "" 37 | 38 | # A function that gets the latest simulator for a certain OS. 39 | get_latest_simulator() { 40 | local PLATFORM=$1 41 | local SIMULATOR_TYPE 42 | 43 | case $PLATFORM in 44 | "iOS") 45 | SIMULATOR_TYPE="iPhone" 46 | ;; 47 | "tvOS") 48 | SIMULATOR_TYPE="Apple TV" 49 | ;; 50 | "watchOS") 51 | SIMULATOR_TYPE="Apple Watch" 52 | ;; 53 | "xrOS") 54 | SIMULATOR_TYPE="Apple Vision" 55 | ;; 56 | *) 57 | echo "Error: Unsupported platform for simulator '$PLATFORM'" 58 | return 1 59 | ;; 60 | esac 61 | 62 | # Get the latest simulator for the platform 63 | xcrun simctl list devices available | grep "$SIMULATOR_TYPE" | tail -1 | sed -E 's/.*\(([A-F0-9-]+)\).*/\1/' 64 | } 65 | 66 | # A function that tests $TARGET for a specific platform 67 | test_platform() { 68 | 69 | # Define a local $PLATFORM variable 70 | local PLATFORM="${1//_/ }" 71 | 72 | # Define the destination, based on the $PLATFORM 73 | case $PLATFORM in 74 | "iOS"|"tvOS"|"watchOS"|"xrOS") 75 | local SIMULATOR_UDID=$(get_latest_simulator "$PLATFORM") 76 | if [ -z "$SIMULATOR_UDID" ]; then 77 | echo "Error: No simulator found for $PLATFORM" 78 | return 1 79 | fi 80 | DESTINATION="id=$SIMULATOR_UDID" 81 | ;; 82 | "macOS") 83 | DESTINATION="platform=macOS" 84 | ;; 85 | *) 86 | echo "Error: Unsupported platform '$PLATFORM'" 87 | return 1 88 | ;; 89 | esac 90 | 91 | # Test $TARGET for the $DESTINATION 92 | echo "Testing $TARGET for $PLATFORM..." 93 | xcodebuild test -scheme $TARGET -derivedDataPath .build -destination "$DESTINATION" -enableCodeCoverage YES 94 | local TEST_RESULT=$? 95 | 96 | if [[ $TEST_RESULT -ne 0 ]]; then 97 | return $TEST_RESULT 98 | fi 99 | 100 | # Complete successfully 101 | echo "Successfully tested $TARGET for $PLATFORM" 102 | return 0 103 | } 104 | 105 | # Loop through all platforms and call the test function 106 | for PLATFORM in $PLATFORMS; do 107 | if ! test_platform "$PLATFORM"; then 108 | exit 1 109 | fi 110 | done 111 | 112 | # Complete successfully 113 | echo "" 114 | echo "Testing $TARGET completed successfully!" 115 | echo "" 116 | -------------------------------------------------------------------------------- /scripts/version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script creates a new version for the provided and . 5 | # This script targets iOS, macOS, tvOS, watchOS, and xrOS by default. 6 | # You can pass in a list of if you want to customize the build. 7 | 8 | # Usage: 9 | # version.sh [ default:iOS macOS tvOS watchOS xrOS]" 10 | # e.g. `scripts/version.sh MyTarget master iOS macOS` 11 | 12 | # This script will: 13 | # * Call version_validate_git.sh to validate the git repo. 14 | # * Call version_validate_target to run tests, swiftlint, etc. 15 | # * Call version_bump.sh if all validation steps above passed. 16 | 17 | # Exit immediately if a command exits with a non-zero status 18 | set -e 19 | 20 | # Verify that all required arguments are provided 21 | if [ $# -lt 2 ]; then 22 | echo "Error: This script requires at least two arguments" 23 | echo "Usage: $0 [ default:iOS macOS tvOS watchOS xrOS]" 24 | echo "For instance: $0 MyTarget master iOS macOS" 25 | exit 1 26 | fi 27 | 28 | # Define argument variables 29 | TARGET=$1 30 | BRANCH=${2:-main} 31 | 32 | # Remove TARGET and BRANCH from arguments list 33 | shift 34 | shift 35 | 36 | # Read platform arguments or use default value 37 | if [ $# -eq 0 ]; then 38 | set -- iOS macOS tvOS watchOS xrOS 39 | fi 40 | 41 | # Use the script folder to refer to other scripts. 42 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 43 | SCRIPT_VALIDATE_GIT="$FOLDER/version_validate_git.sh" 44 | SCRIPT_VALIDATE_TARGET="$FOLDER/version_validate_target.sh" 45 | SCRIPT_VERSION_BUMP="$FOLDER/version_bump.sh" 46 | 47 | # A function that run a certain script and checks for errors 48 | run_script() { 49 | local script="$1" 50 | shift # Remove the first argument (the script path) 51 | 52 | if [ ! -f "$script" ]; then 53 | echo "Error: Script not found: $script" 54 | exit 1 55 | fi 56 | 57 | chmod +x "$script" 58 | if ! "$script" "$@"; then 59 | echo "Error: Script $script failed" 60 | exit 1 61 | fi 62 | } 63 | 64 | # Start script 65 | echo "" 66 | echo "Creating a new version for $TARGET on the $BRANCH branch..." 67 | echo "" 68 | 69 | # Validate git and project 70 | echo "Validating..." 71 | run_script "$SCRIPT_VALIDATE_GIT" "$BRANCH" 72 | run_script "$SCRIPT_VALIDATE_TARGET" "$TARGET" 73 | 74 | # Bump version 75 | echo "Bumping version..." 76 | run_script "$SCRIPT_VERSION_BUMP" 77 | 78 | # Complete successfully 79 | echo "" 80 | echo "Version created successfully!" 81 | echo "" 82 | -------------------------------------------------------------------------------- /scripts/version_bump.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script bumps the project version number. 5 | # You can append --no-semver to disable semantic version validation. 6 | 7 | # Usage: 8 | # version_bump.sh [--no-semver] 9 | # e.g. `bash scripts/version_bump.sh` 10 | # e.g. `bash scripts/version_bump.sh --no-semver` 11 | 12 | # Exit immediately if a command exits with a non-zero status 13 | set -e 14 | 15 | # Use the script folder to refer to other scripts. 16 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 17 | SCRIPT_VERSION_NUMBER="$FOLDER/version_number.sh" 18 | 19 | 20 | # Parse --no-semver argument 21 | VALIDATE_SEMVER=true 22 | for arg in "$@"; do 23 | case $arg in 24 | --no-semver) 25 | VALIDATE_SEMVER=false 26 | shift # Remove --no-semver from processing 27 | ;; 28 | esac 29 | done 30 | 31 | # Start script 32 | echo "" 33 | echo "Bumping version number..." 34 | echo "" 35 | 36 | # Get the latest version 37 | VERSION=$($SCRIPT_VERSION_NUMBER) 38 | if [ $? -ne 0 ]; then 39 | echo "Failed to get the latest version" 40 | exit 1 41 | fi 42 | 43 | # Print the current version 44 | echo "The current version is: $VERSION" 45 | 46 | # Function to validate semver format, including optional -rc. suffix 47 | validate_semver() { 48 | if [ "$VALIDATE_SEMVER" = false ]; then 49 | return 0 50 | fi 51 | 52 | if [[ $1 =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then 53 | return 0 54 | else 55 | return 1 56 | fi 57 | } 58 | 59 | # Prompt user for new version 60 | while true; do 61 | read -p "Enter the new version number: " NEW_VERSION 62 | 63 | # Validate the version number to ensure that it's a semver version 64 | if validate_semver "$NEW_VERSION"; then 65 | break 66 | else 67 | echo "Invalid version format. Please use semver format (e.g., 1.2.3, v1.2.3, 1.2.3-rc.1, etc.)." 68 | exit 1 69 | fi 70 | done 71 | 72 | # Push the new tag 73 | git push -u origin HEAD 74 | git tag $NEW_VERSION 75 | git push --tags 76 | 77 | # Complete successfully 78 | echo "" 79 | echo "Version tag pushed successfully!" 80 | echo "" 81 | -------------------------------------------------------------------------------- /scripts/version_number.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script returns the latest project version. 5 | 6 | # Usage: 7 | # version_number.sh 8 | # e.g. `bash scripts/version_number.sh` 9 | 10 | # Exit immediately if a command exits with a non-zero status 11 | set -e 12 | 13 | # Check if the current directory is a Git repository 14 | if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then 15 | echo "Error: Not a Git repository" 16 | exit 1 17 | fi 18 | 19 | # Fetch all tags 20 | git fetch --tags > /dev/null 2>&1 21 | 22 | # Get the latest semver tag 23 | latest_version=$(git tag -l --sort=-v:refname | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) 24 | 25 | # Check if we found a version tag 26 | if [ -z "$latest_version" ]; then 27 | echo "Error: No semver tags found in this repository" >&2 28 | exit 1 29 | fi 30 | 31 | # Print the latest version 32 | echo "$latest_version" 33 | -------------------------------------------------------------------------------- /scripts/version_validate_git.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script validates the Git repository for release. 5 | # You can pass in a to validate any non-main branch. 6 | 7 | # Usage: 8 | # version_validate_git.sh " 9 | # e.g. `bash scripts/version_validate_git.sh master` 10 | 11 | # This script will: 12 | # * Validate that the script is run within a git repository. 13 | # * Validate that the git repository doesn't have any uncommitted changes. 14 | # * Validate that the current git branch matches the provided one. 15 | 16 | # Exit immediately if a command exits with a non-zero status 17 | set -e 18 | 19 | # Verify that all required arguments are provided 20 | if [ $# -eq 0 ]; then 21 | echo "Error: This script requires exactly one argument" 22 | echo "Usage: $0 " 23 | exit 1 24 | fi 25 | 26 | # Create local argument variables. 27 | BRANCH=$1 28 | 29 | # Start script 30 | echo "" 31 | echo "Validating git repository..." 32 | echo "" 33 | 34 | # Check if the current directory is a Git repository 35 | if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then 36 | echo "Error: Not a Git repository" 37 | exit 1 38 | fi 39 | 40 | # Check for uncommitted changes 41 | if [ -n "$(git status --porcelain)" ]; then 42 | echo "Error: Git repository is dirty. There are uncommitted changes." 43 | exit 1 44 | fi 45 | 46 | # Verify that we're on the correct branch 47 | current_branch=$(git rev-parse --abbrev-ref HEAD) 48 | if [ "$current_branch" != "$BRANCH" ]; then 49 | echo "Error: Not on the specified branch. Current branch is $current_branch, expected $1." 50 | exit 1 51 | fi 52 | 53 | # The Git repository validation succeeded. 54 | echo "" 55 | echo "Git repository validated successfully!" 56 | echo "" 57 | -------------------------------------------------------------------------------- /scripts/version_validate_target.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script validates a for release. 5 | # This script targets iOS, macOS, tvOS, watchOS, and xrOS by default. 6 | # You can pass in a list of if you want to customize the build. 7 | 8 | # Usage: 9 | # version_validate_target.sh [ default:iOS macOS tvOS watchOS xrOS]" 10 | # e.g. `bash scripts/version_validate_target.sh iOS macOS` 11 | 12 | # This script will: 13 | # * Validate that swiftlint passes. 14 | # * Validate that all unit tests passes for all . 15 | 16 | # Exit immediately if a command exits with a non-zero status 17 | set -e 18 | 19 | # Verify that all requires at least one argument" 20 | if [ $# -eq 0 ]; then 21 | echo "Error: This script requires at least one argument" 22 | echo "Usage: $0 [ default:iOS macOS tvOS watchOS xrOS]" 23 | exit 1 24 | fi 25 | 26 | # Create local argument variables. 27 | TARGET=$1 28 | 29 | # Remove TARGET from arguments list 30 | shift 31 | 32 | # Define platforms variable 33 | if [ $# -eq 0 ]; then 34 | set -- iOS macOS tvOS watchOS xrOS 35 | fi 36 | PLATFORMS=$@ 37 | 38 | # Use the script folder to refer to other scripts. 39 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 40 | SCRIPT_TEST="$FOLDER/test.sh" 41 | 42 | # A function that run a certain script and checks for errors 43 | run_script() { 44 | local script="$1" 45 | shift # Remove the first argument (script path) from the argument list 46 | 47 | if [ ! -f "$script" ]; then 48 | echo "Error: Script not found: $script" 49 | exit 1 50 | fi 51 | 52 | chmod +x "$script" 53 | if ! "$script" "$@"; then 54 | echo "Error: Script $script failed" 55 | exit 1 56 | fi 57 | } 58 | 59 | # Start script 60 | echo "" 61 | echo "Validating project..." 62 | echo "" 63 | 64 | # Run SwiftLint 65 | echo "Running SwiftLint" 66 | if ! swiftlint --strict; then 67 | echo "Error: SwiftLint failed" 68 | exit 1 69 | fi 70 | 71 | # Run unit tests 72 | echo "Testing..." 73 | run_script "$SCRIPT_TEST" "$TARGET" "$PLATFORMS" 74 | 75 | # Complete successfully 76 | echo "" 77 | echo "Project successfully validated!" 78 | echo "" 79 | --------------------------------------------------------------------------------