├── .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 │ ├── ContentView.swift │ ├── Demo.entitlements │ ├── DemoApp.swift │ ├── DemoScreen.swift │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ ├── Resources │ ├── AppIcon.icon │ │ ├── Assets │ │ │ ├── ApiKit-Bolt.png │ │ │ └── ApiKit-Cloud.png │ │ └── icon.json │ └── 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 │ │ └── Contents.json │ ├── Screens │ ├── TheMovieDbScreen.swift │ └── YelpScreen.swift │ └── View+ApiKeys.swift ├── LICENSE ├── Package.swift ├── README.md ├── RELEASE_NOTES.md ├── Resources ├── Icon-Badge.png └── Icon-Plain.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 └── scripts ├── build.sh ├── chmod.sh ├── docc.sh ├── framework.sh ├── git_default_branch.sh ├── package_name.sh ├── release.sh ├── sync_from.sh ├── test.sh ├── validate_git_branch.sh ├── validate_release.sh ├── version_bump.sh └── version_number.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 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 | A9C008952E8A831600000CB6 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = A9C008942E8A831600000CB6 /* AppIcon.icon */; }; 13 | A9C008992E8A859C00000CB6 /* View+ApiKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C008982E8A859C00000CB6 /* View+ApiKeys.swift */; }; 14 | A9C0089B2E8A955C00000CB6 /* YelpScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C0089A2E8A955C00000CB6 /* YelpScreen.swift */; }; 15 | A9C026AB29D2CEFA00371AE0 /* DemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C026AA29D2CEFA00371AE0 /* DemoApp.swift */; }; 16 | A9C026AD29D2CEFA00371AE0 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C026AC29D2CEFA00371AE0 /* ContentView.swift */; }; 17 | A9C026AF29D2CEFB00371AE0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A9C026AE29D2CEFB00371AE0 /* Assets.xcassets */; }; 18 | A9C026B329D2CEFB00371AE0 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A9C026B229D2CEFB00371AE0 /* Preview Assets.xcassets */; }; 19 | A9C026C029D3228100371AE0 /* ApiKit in Frameworks */ = {isa = PBXBuildFile; productRef = A9C026BF29D3228100371AE0 /* ApiKit */; }; 20 | A9E31BB62CB030D300D1A3AA /* ApiKit in Frameworks */ = {isa = PBXBuildFile; productRef = A9E31BB52CB030D300D1A3AA /* ApiKit */; }; 21 | A9E31BB92CB030F000D1A3AA /* ApiKit in Frameworks */ = {isa = PBXBuildFile; productRef = A9E31BB82CB030F000D1A3AA /* ApiKit */; }; 22 | A9E31BBC2CB0310600D1A3AA /* ApiKit in Frameworks */ = {isa = PBXBuildFile; productRef = A9E31BBB2CB0310600D1A3AA /* ApiKit */; }; 23 | /* End PBXBuildFile section */ 24 | 25 | /* Begin PBXFileReference section */ 26 | A9B2328929D346C700B85203 /* DemoScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoScreen.swift; sourceTree = ""; }; 27 | A9B2328C29D346F700B85203 /* TheMovieDbScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TheMovieDbScreen.swift; sourceTree = ""; }; 28 | A9C008942E8A831600000CB6 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; 29 | A9C008982E8A859C00000CB6 /* View+ApiKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ApiKeys.swift"; sourceTree = ""; }; 30 | A9C0089A2E8A955C00000CB6 /* YelpScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YelpScreen.swift; sourceTree = ""; }; 31 | A9C026A729D2CEFA00371AE0 /* Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Demo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 32 | A9C026AA29D2CEFA00371AE0 /* DemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoApp.swift; sourceTree = ""; }; 33 | A9C026AC29D2CEFA00371AE0 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 34 | A9C026AE29D2CEFB00371AE0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 35 | A9C026B029D2CEFB00371AE0 /* Demo.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Demo.entitlements; sourceTree = ""; }; 36 | A9C026B229D2CEFB00371AE0 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 37 | /* End PBXFileReference section */ 38 | 39 | /* Begin PBXFrameworksBuildPhase section */ 40 | A9C026A429D2CEFA00371AE0 /* Frameworks */ = { 41 | isa = PBXFrameworksBuildPhase; 42 | buildActionMask = 2147483647; 43 | files = ( 44 | A9E31BBC2CB0310600D1A3AA /* ApiKit in Frameworks */, 45 | A9E31BB62CB030D300D1A3AA /* ApiKit in Frameworks */, 46 | A9C026C029D3228100371AE0 /* ApiKit in Frameworks */, 47 | A9E31BB92CB030F000D1A3AA /* ApiKit in Frameworks */, 48 | ); 49 | runOnlyForDeploymentPostprocessing = 0; 50 | }; 51 | /* End PBXFrameworksBuildPhase section */ 52 | 53 | /* Begin PBXGroup section */ 54 | A9B2328B29D346E900B85203 /* Screens */ = { 55 | isa = PBXGroup; 56 | children = ( 57 | A9B2328C29D346F700B85203 /* TheMovieDbScreen.swift */, 58 | A9C0089A2E8A955C00000CB6 /* YelpScreen.swift */, 59 | ); 60 | path = Screens; 61 | sourceTree = ""; 62 | }; 63 | A9C008932E8A830000000CB6 /* Resources */ = { 64 | isa = PBXGroup; 65 | children = ( 66 | A9C008942E8A831600000CB6 /* AppIcon.icon */, 67 | A9C026AE29D2CEFB00371AE0 /* Assets.xcassets */, 68 | ); 69 | path = Resources; 70 | sourceTree = ""; 71 | }; 72 | A9C0269E29D2CEFA00371AE0 = { 73 | isa = PBXGroup; 74 | children = ( 75 | A9C026A929D2CEFA00371AE0 /* Demo */, 76 | A9C026A829D2CEFA00371AE0 /* Products */, 77 | A9C026BE29D3228100371AE0 /* Frameworks */, 78 | ); 79 | sourceTree = ""; 80 | }; 81 | A9C026A829D2CEFA00371AE0 /* Products */ = { 82 | isa = PBXGroup; 83 | children = ( 84 | A9C026A729D2CEFA00371AE0 /* Demo.app */, 85 | ); 86 | name = Products; 87 | sourceTree = ""; 88 | }; 89 | A9C026A929D2CEFA00371AE0 /* Demo */ = { 90 | isa = PBXGroup; 91 | children = ( 92 | A9C008932E8A830000000CB6 /* Resources */, 93 | A9B2328B29D346E900B85203 /* Screens */, 94 | A9C026AC29D2CEFA00371AE0 /* ContentView.swift */, 95 | A9C026B029D2CEFB00371AE0 /* Demo.entitlements */, 96 | A9C026AA29D2CEFA00371AE0 /* DemoApp.swift */, 97 | A9B2328929D346C700B85203 /* DemoScreen.swift */, 98 | A9C008982E8A859C00000CB6 /* View+ApiKeys.swift */, 99 | A9C026B129D2CEFB00371AE0 /* Preview Content */, 100 | ); 101 | path = Demo; 102 | sourceTree = ""; 103 | }; 104 | A9C026B129D2CEFB00371AE0 /* Preview Content */ = { 105 | isa = PBXGroup; 106 | children = ( 107 | A9C026B229D2CEFB00371AE0 /* Preview Assets.xcassets */, 108 | ); 109 | path = "Preview Content"; 110 | sourceTree = ""; 111 | }; 112 | A9C026BE29D3228100371AE0 /* Frameworks */ = { 113 | isa = PBXGroup; 114 | children = ( 115 | ); 116 | name = Frameworks; 117 | sourceTree = ""; 118 | }; 119 | /* End PBXGroup section */ 120 | 121 | /* Begin PBXNativeTarget section */ 122 | A9C026A629D2CEFA00371AE0 /* Demo */ = { 123 | isa = PBXNativeTarget; 124 | buildConfigurationList = A9C026B629D2CEFB00371AE0 /* Build configuration list for PBXNativeTarget "Demo" */; 125 | buildPhases = ( 126 | A9C026A329D2CEFA00371AE0 /* Sources */, 127 | A9C026A429D2CEFA00371AE0 /* Frameworks */, 128 | A9C026A529D2CEFA00371AE0 /* Resources */, 129 | ); 130 | buildRules = ( 131 | ); 132 | dependencies = ( 133 | ); 134 | name = Demo; 135 | packageProductDependencies = ( 136 | A9C026BF29D3228100371AE0 /* ApiKit */, 137 | A9E31BB52CB030D300D1A3AA /* ApiKit */, 138 | A9E31BB82CB030F000D1A3AA /* ApiKit */, 139 | A9E31BBB2CB0310600D1A3AA /* ApiKit */, 140 | ); 141 | productName = Demo; 142 | productReference = A9C026A729D2CEFA00371AE0 /* Demo.app */; 143 | productType = "com.apple.product-type.application"; 144 | }; 145 | /* End PBXNativeTarget section */ 146 | 147 | /* Begin PBXProject section */ 148 | A9C0269F29D2CEFA00371AE0 /* Project object */ = { 149 | isa = PBXProject; 150 | attributes = { 151 | BuildIndependentTargetsInParallel = 1; 152 | LastSwiftUpdateCheck = 1420; 153 | LastUpgradeCheck = 1530; 154 | ORGANIZATIONNAME = "Daniel Saidi"; 155 | TargetAttributes = { 156 | A9C026A629D2CEFA00371AE0 = { 157 | CreatedOnToolsVersion = 14.2; 158 | }; 159 | }; 160 | }; 161 | buildConfigurationList = A9C026A229D2CEFA00371AE0 /* Build configuration list for PBXProject "Demo" */; 162 | compatibilityVersion = "Xcode 14.0"; 163 | developmentRegion = en; 164 | hasScannedForEncodings = 0; 165 | knownRegions = ( 166 | en, 167 | Base, 168 | ); 169 | mainGroup = A9C0269E29D2CEFA00371AE0; 170 | packageReferences = ( 171 | A9E31BBA2CB0310600D1A3AA /* XCLocalSwiftPackageReference "../../apikit" */, 172 | ); 173 | productRefGroup = A9C026A829D2CEFA00371AE0 /* Products */; 174 | projectDirPath = ""; 175 | projectRoot = ""; 176 | targets = ( 177 | A9C026A629D2CEFA00371AE0 /* Demo */, 178 | ); 179 | }; 180 | /* End PBXProject section */ 181 | 182 | /* Begin PBXResourcesBuildPhase section */ 183 | A9C026A529D2CEFA00371AE0 /* Resources */ = { 184 | isa = PBXResourcesBuildPhase; 185 | buildActionMask = 2147483647; 186 | files = ( 187 | A9C026B329D2CEFB00371AE0 /* Preview Assets.xcassets in Resources */, 188 | A9C026AF29D2CEFB00371AE0 /* Assets.xcassets in Resources */, 189 | A9C008952E8A831600000CB6 /* AppIcon.icon in Resources */, 190 | ); 191 | runOnlyForDeploymentPostprocessing = 0; 192 | }; 193 | /* End PBXResourcesBuildPhase section */ 194 | 195 | /* Begin PBXSourcesBuildPhase section */ 196 | A9C026A329D2CEFA00371AE0 /* Sources */ = { 197 | isa = PBXSourcesBuildPhase; 198 | buildActionMask = 2147483647; 199 | files = ( 200 | A9C0089B2E8A955C00000CB6 /* YelpScreen.swift in Sources */, 201 | A9C026AD29D2CEFA00371AE0 /* ContentView.swift in Sources */, 202 | A9B2328D29D346F700B85203 /* TheMovieDbScreen.swift in Sources */, 203 | A9C008992E8A859C00000CB6 /* View+ApiKeys.swift in Sources */, 204 | A9B2328A29D346C700B85203 /* DemoScreen.swift in Sources */, 205 | A9C026AB29D2CEFA00371AE0 /* DemoApp.swift in Sources */, 206 | ); 207 | runOnlyForDeploymentPostprocessing = 0; 208 | }; 209 | /* End PBXSourcesBuildPhase section */ 210 | 211 | /* Begin XCBuildConfiguration section */ 212 | A9C026B429D2CEFB00371AE0 /* Debug */ = { 213 | isa = XCBuildConfiguration; 214 | buildSettings = { 215 | ALWAYS_SEARCH_USER_PATHS = NO; 216 | CLANG_ANALYZER_NONNULL = YES; 217 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 218 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 219 | CLANG_ENABLE_MODULES = YES; 220 | CLANG_ENABLE_OBJC_ARC = YES; 221 | CLANG_ENABLE_OBJC_WEAK = YES; 222 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 223 | CLANG_WARN_BOOL_CONVERSION = YES; 224 | CLANG_WARN_COMMA = YES; 225 | CLANG_WARN_CONSTANT_CONVERSION = YES; 226 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 227 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 228 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 229 | CLANG_WARN_EMPTY_BODY = YES; 230 | CLANG_WARN_ENUM_CONVERSION = YES; 231 | CLANG_WARN_INFINITE_RECURSION = YES; 232 | CLANG_WARN_INT_CONVERSION = YES; 233 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 234 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 235 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 236 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 237 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 238 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 239 | CLANG_WARN_STRICT_PROTOTYPES = YES; 240 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 241 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 242 | CLANG_WARN_UNREACHABLE_CODE = YES; 243 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 244 | COPY_PHASE_STRIP = NO; 245 | DEAD_CODE_STRIPPING = YES; 246 | DEBUG_INFORMATION_FORMAT = dwarf; 247 | ENABLE_STRICT_OBJC_MSGSEND = YES; 248 | ENABLE_TESTABILITY = YES; 249 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 250 | GCC_C_LANGUAGE_STANDARD = gnu11; 251 | GCC_DYNAMIC_NO_PIC = NO; 252 | GCC_NO_COMMON_BLOCKS = YES; 253 | GCC_OPTIMIZATION_LEVEL = 0; 254 | GCC_PREPROCESSOR_DEFINITIONS = ( 255 | "DEBUG=1", 256 | "$(inherited)", 257 | ); 258 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 259 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 260 | GCC_WARN_UNDECLARED_SELECTOR = YES; 261 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 262 | GCC_WARN_UNUSED_FUNCTION = YES; 263 | GCC_WARN_UNUSED_VARIABLE = YES; 264 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 265 | MTL_FAST_MATH = YES; 266 | ONLY_ACTIVE_ARCH = YES; 267 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 268 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 269 | }; 270 | name = Debug; 271 | }; 272 | A9C026B529D2CEFB00371AE0 /* Release */ = { 273 | isa = XCBuildConfiguration; 274 | buildSettings = { 275 | ALWAYS_SEARCH_USER_PATHS = NO; 276 | CLANG_ANALYZER_NONNULL = YES; 277 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 278 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 279 | CLANG_ENABLE_MODULES = YES; 280 | CLANG_ENABLE_OBJC_ARC = YES; 281 | CLANG_ENABLE_OBJC_WEAK = YES; 282 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 283 | CLANG_WARN_BOOL_CONVERSION = YES; 284 | CLANG_WARN_COMMA = YES; 285 | CLANG_WARN_CONSTANT_CONVERSION = YES; 286 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 287 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 288 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 289 | CLANG_WARN_EMPTY_BODY = YES; 290 | CLANG_WARN_ENUM_CONVERSION = YES; 291 | CLANG_WARN_INFINITE_RECURSION = YES; 292 | CLANG_WARN_INT_CONVERSION = YES; 293 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 294 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 295 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 296 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 297 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 298 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 299 | CLANG_WARN_STRICT_PROTOTYPES = YES; 300 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 301 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 302 | CLANG_WARN_UNREACHABLE_CODE = YES; 303 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 304 | COPY_PHASE_STRIP = NO; 305 | DEAD_CODE_STRIPPING = YES; 306 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 307 | ENABLE_NS_ASSERTIONS = NO; 308 | ENABLE_STRICT_OBJC_MSGSEND = YES; 309 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 310 | GCC_C_LANGUAGE_STANDARD = gnu11; 311 | GCC_NO_COMMON_BLOCKS = YES; 312 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 313 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 314 | GCC_WARN_UNDECLARED_SELECTOR = YES; 315 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 316 | GCC_WARN_UNUSED_FUNCTION = YES; 317 | GCC_WARN_UNUSED_VARIABLE = YES; 318 | MTL_ENABLE_DEBUG_INFO = NO; 319 | MTL_FAST_MATH = YES; 320 | SWIFT_COMPILATION_MODE = wholemodule; 321 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 322 | }; 323 | name = Release; 324 | }; 325 | A9C026B729D2CEFB00371AE0 /* Debug */ = { 326 | isa = XCBuildConfiguration; 327 | buildSettings = { 328 | ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES; 329 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 330 | "ASSETCATALOG_COMPILER_APPICON_NAME[sdk=xros*]" = "AppIcon-Vision"; 331 | "ASSETCATALOG_COMPILER_APPICON_NAME[sdk=xrsimulator*]" = "AppIcon-Vision"; 332 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 333 | CODE_SIGN_ENTITLEMENTS = Demo/Demo.entitlements; 334 | CODE_SIGN_STYLE = Automatic; 335 | CURRENT_PROJECT_VERSION = 1; 336 | DEAD_CODE_STRIPPING = YES; 337 | DEVELOPMENT_ASSET_PATHS = "\"Demo/Preview Content\""; 338 | DEVELOPMENT_TEAM = PMEDFW438U; 339 | ENABLE_HARDENED_RUNTIME = YES; 340 | ENABLE_PREVIEWS = YES; 341 | GENERATE_INFOPLIST_FILE = YES; 342 | INFOPLIST_KEY_CFBundleDisplayName = ApiKit; 343 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 344 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 345 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 346 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 347 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 348 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 349 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 350 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 351 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 352 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 353 | IPHONEOS_DEPLOYMENT_TARGET = 26.0; 354 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 355 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 356 | MACOSX_DEPLOYMENT_TARGET = 26.0; 357 | MARKETING_VERSION = 1.0; 358 | PRODUCT_BUNDLE_IDENTIFIER = com.danielsaidi.apikit.Demo; 359 | PRODUCT_NAME = "$(TARGET_NAME)"; 360 | SDKROOT = auto; 361 | SUPPORTED_PLATFORMS = "xrsimulator xros watchsimulator watchos macosx iphonesimulator iphoneos driverkit appletvsimulator appletvos"; 362 | SUPPORTS_MACCATALYST = YES; 363 | SWIFT_EMIT_LOC_STRINGS = YES; 364 | SWIFT_VERSION = 5.0; 365 | TARGETED_DEVICE_FAMILY = "1,2"; 366 | TVOS_DEPLOYMENT_TARGET = 26.0; 367 | WATCHOS_DEPLOYMENT_TARGET = 26.0; 368 | XROS_DEPLOYMENT_TARGET = 26.0; 369 | }; 370 | name = Debug; 371 | }; 372 | A9C026B829D2CEFB00371AE0 /* Release */ = { 373 | isa = XCBuildConfiguration; 374 | buildSettings = { 375 | ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES; 376 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 377 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 378 | CODE_SIGN_ENTITLEMENTS = Demo/Demo.entitlements; 379 | CODE_SIGN_STYLE = Automatic; 380 | CURRENT_PROJECT_VERSION = 1; 381 | DEAD_CODE_STRIPPING = YES; 382 | DEVELOPMENT_ASSET_PATHS = "\"Demo/Preview Content\""; 383 | DEVELOPMENT_TEAM = PMEDFW438U; 384 | ENABLE_HARDENED_RUNTIME = YES; 385 | ENABLE_PREVIEWS = YES; 386 | GENERATE_INFOPLIST_FILE = YES; 387 | INFOPLIST_KEY_CFBundleDisplayName = ApiKit; 388 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 389 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 390 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 391 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 392 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 393 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 394 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 395 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 396 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 397 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 398 | IPHONEOS_DEPLOYMENT_TARGET = 26.0; 399 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 400 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 401 | MACOSX_DEPLOYMENT_TARGET = 26.0; 402 | MARKETING_VERSION = 1.0; 403 | PRODUCT_BUNDLE_IDENTIFIER = com.danielsaidi.apikit.Demo; 404 | PRODUCT_NAME = "$(TARGET_NAME)"; 405 | SDKROOT = auto; 406 | SUPPORTED_PLATFORMS = "xrsimulator xros watchsimulator watchos macosx iphonesimulator iphoneos driverkit appletvsimulator appletvos"; 407 | SUPPORTS_MACCATALYST = YES; 408 | SWIFT_EMIT_LOC_STRINGS = YES; 409 | SWIFT_VERSION = 5.0; 410 | TARGETED_DEVICE_FAMILY = "1,2"; 411 | TVOS_DEPLOYMENT_TARGET = 26.0; 412 | WATCHOS_DEPLOYMENT_TARGET = 26.0; 413 | XROS_DEPLOYMENT_TARGET = 26.0; 414 | }; 415 | name = Release; 416 | }; 417 | /* End XCBuildConfiguration section */ 418 | 419 | /* Begin XCConfigurationList section */ 420 | A9C026A229D2CEFA00371AE0 /* Build configuration list for PBXProject "Demo" */ = { 421 | isa = XCConfigurationList; 422 | buildConfigurations = ( 423 | A9C026B429D2CEFB00371AE0 /* Debug */, 424 | A9C026B529D2CEFB00371AE0 /* Release */, 425 | ); 426 | defaultConfigurationIsVisible = 0; 427 | defaultConfigurationName = Release; 428 | }; 429 | A9C026B629D2CEFB00371AE0 /* Build configuration list for PBXNativeTarget "Demo" */ = { 430 | isa = XCConfigurationList; 431 | buildConfigurations = ( 432 | A9C026B729D2CEFB00371AE0 /* Debug */, 433 | A9C026B829D2CEFB00371AE0 /* Release */, 434 | ); 435 | defaultConfigurationIsVisible = 0; 436 | defaultConfigurationName = Release; 437 | }; 438 | /* End XCConfigurationList section */ 439 | 440 | /* Begin XCLocalSwiftPackageReference section */ 441 | A9E31BBA2CB0310600D1A3AA /* XCLocalSwiftPackageReference "../../apikit" */ = { 442 | isa = XCLocalSwiftPackageReference; 443 | relativePath = ../../apikit; 444 | }; 445 | /* End XCLocalSwiftPackageReference section */ 446 | 447 | /* Begin XCSwiftPackageProductDependency section */ 448 | A9C026BF29D3228100371AE0 /* ApiKit */ = { 449 | isa = XCSwiftPackageProductDependency; 450 | productName = ApiKit; 451 | }; 452 | A9E31BB52CB030D300D1A3AA /* ApiKit */ = { 453 | isa = XCSwiftPackageProductDependency; 454 | productName = ApiKit; 455 | }; 456 | A9E31BB82CB030F000D1A3AA /* ApiKit */ = { 457 | isa = XCSwiftPackageProductDependency; 458 | productName = ApiKit; 459 | }; 460 | A9E31BBB2CB0310600D1A3AA /* ApiKit */ = { 461 | isa = XCSwiftPackageProductDependency; 462 | productName = ApiKit; 463 | }; 464 | /* End XCSwiftPackageProductDependency section */ 465 | }; 466 | rootObject = A9C0269F29D2CEFA00371AE0 /* Project object */; 467 | } 468 | -------------------------------------------------------------------------------- /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 | 9 | 21 | 22 | 23 | 25 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /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/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 | @AppStorage(Self.movieDbApiKey) var movieDbApiKey = "" 15 | @AppStorage(Self.yelpApiKey) var yelpApiKey = "" 16 | 17 | var body: some View { 18 | NavigationStack { 19 | List { 20 | screenSection( 21 | title: "The Movie DB", 22 | icon: "popcorn", 23 | apiKey: $movieDbApiKey, 24 | screen: .theMovieDb(apiKey: movieDbApiKey) 25 | ) 26 | screenSection( 27 | title: "Yelp", 28 | icon: "fork.knife", 29 | apiKey: $yelpApiKey, 30 | screen: .yelp(apiKey: yelpApiKey) 31 | ) 32 | } 33 | .navigationTitle("ApiKit") 34 | .navigationDestination(for: DemoScreen.self) { $0.body } 35 | } 36 | } 37 | } 38 | 39 | private extension ContentView { 40 | 41 | func screenSection( 42 | title: String, 43 | icon: String, 44 | apiKey: Binding, 45 | screen: DemoScreen 46 | ) -> some View { 47 | Section { 48 | NavigationLink(value: screen) { 49 | Text("Explore") 50 | } 51 | TextField("Enter your API Key", text: apiKey) 52 | } header: { 53 | HStack { 54 | Image(systemName: icon) 55 | Text(title) 56 | } 57 | } 58 | } 59 | } 60 | #Preview { 61 | 62 | ContentView() 63 | } 64 | -------------------------------------------------------------------------------- /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/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: Hashable, View { 12 | 13 | case theMovieDb(apiKey: String) 14 | case yelp(apiKey: String) 15 | } 16 | 17 | extension DemoScreen { 18 | 19 | @ViewBuilder 20 | var body: some View { 21 | switch self { 22 | case .theMovieDb(let apiKey): TheMovieDbScreen(apiKey: apiKey) 23 | case .yelp(let apiKey): YelpScreen(apiKey: apiKey) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Demo/Demo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Demo/Resources/AppIcon.icon/Assets/ApiKit-Bolt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/cf3f2128dba09a5cf8e6080935f88ea56edbadb0/Demo/Demo/Resources/AppIcon.icon/Assets/ApiKit-Bolt.png -------------------------------------------------------------------------------- /Demo/Demo/Resources/AppIcon.icon/Assets/ApiKit-Cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/cf3f2128dba09a5cf8e6080935f88ea56edbadb0/Demo/Demo/Resources/AppIcon.icon/Assets/ApiKit-Cloud.png -------------------------------------------------------------------------------- /Demo/Demo/Resources/AppIcon.icon/icon.json: -------------------------------------------------------------------------------- 1 | { 2 | "color-space-for-untagged-svg-colors" : "display-p3", 3 | "fill" : { 4 | "linear-gradient" : [ 5 | "display-p3:0.04819,0.15344,0.36938,1.00000", 6 | "display-p3:0.03528,0.10236,0.24500,1.00000" 7 | ], 8 | "orientation" : { 9 | "start" : { 10 | "x" : 0.5, 11 | "y" : 0 12 | }, 13 | "stop" : { 14 | "x" : 0.5, 15 | "y" : 0.7 16 | } 17 | } 18 | }, 19 | "groups" : [ 20 | { 21 | "hidden" : false, 22 | "layers" : [ 23 | { 24 | "hidden" : false, 25 | "image-name" : "ApiKit-Cloud.png", 26 | "name" : "ApiKit-Cloud", 27 | "position" : { 28 | "scale" : 0.95, 29 | "translation-in-points" : [ 30 | 0, 31 | 0 32 | ] 33 | } 34 | } 35 | ], 36 | "name" : "Front", 37 | "shadow" : { 38 | "kind" : "neutral", 39 | "opacity" : 0.5 40 | }, 41 | "translucency" : { 42 | "enabled" : true, 43 | "value" : 0.5 44 | } 45 | }, 46 | { 47 | "blend-mode" : "normal", 48 | "blur-material" : null, 49 | "hidden" : false, 50 | "layers" : [ 51 | { 52 | "fill" : "automatic", 53 | "glass" : true, 54 | "hidden" : false, 55 | "image-name" : "ApiKit-Bolt.png", 56 | "name" : "ApiKit-Bolt", 57 | "position" : { 58 | "scale" : 0.95, 59 | "translation-in-points" : [ 60 | 0, 61 | 0 62 | ] 63 | } 64 | } 65 | ], 66 | "name" : "Accent", 67 | "shadow" : { 68 | "kind" : "layer-color", 69 | "opacity" : 0.5 70 | }, 71 | "specular" : false, 72 | "translucency" : { 73 | "enabled" : false, 74 | "value" : 0.5 75 | } 76 | }, 77 | { 78 | "layers" : [ 79 | 80 | ], 81 | "name" : "Back", 82 | "shadow" : { 83 | "kind" : "neutral", 84 | "opacity" : 0.5 85 | }, 86 | "translucency" : { 87 | "enabled" : true, 88 | "value" : 0.5 89 | } 90 | } 91 | ], 92 | "supported-platforms" : { 93 | "circles" : [ 94 | "watchOS" 95 | ], 96 | "squares" : "shared" 97 | } 98 | } -------------------------------------------------------------------------------- /Demo/Demo/Resources/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/Resources/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/Resources/Assets.xcassets/AppIcon-Vision.solidimagestack/Back.solidimagestacklayer/Content.imageset/Icon-visionOS-Back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/cf3f2128dba09a5cf8e6080935f88ea56edbadb0/Demo/Demo/Resources/Assets.xcassets/AppIcon-Vision.solidimagestack/Back.solidimagestacklayer/Content.imageset/Icon-visionOS-Back.png -------------------------------------------------------------------------------- /Demo/Demo/Resources/Assets.xcassets/AppIcon-Vision.solidimagestack/Back.solidimagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Demo/Resources/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/Resources/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/Resources/Assets.xcassets/AppIcon-Vision.solidimagestack/Front.solidimagestacklayer/Content.imageset/Icon-visionOS-Front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/cf3f2128dba09a5cf8e6080935f88ea56edbadb0/Demo/Demo/Resources/Assets.xcassets/AppIcon-Vision.solidimagestack/Front.solidimagestacklayer/Content.imageset/Icon-visionOS-Front.png -------------------------------------------------------------------------------- /Demo/Demo/Resources/Assets.xcassets/AppIcon-Vision.solidimagestack/Front.solidimagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Demo/Resources/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/Resources/Assets.xcassets/AppIcon-Vision.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Icon-visionOS-Middle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/cf3f2128dba09a5cf8e6080935f88ea56edbadb0/Demo/Demo/Resources/Assets.xcassets/AppIcon-Vision.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Icon-visionOS-Middle.png -------------------------------------------------------------------------------- /Demo/Demo/Resources/Assets.xcassets/AppIcon-Vision.solidimagestack/Middle.solidimagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Demo/Resources/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 | init(apiKey: String) { 15 | self.environment = .production(apiKey: apiKey) 16 | } 17 | 18 | let session = URLSession.shared 19 | let environment: TheMovieDb.Environment 20 | let gridColumns = [GridItem(.adaptive(minimum: 100), alignment: .top)] 21 | 22 | @StateObject 23 | private var model = ViewModel() 24 | 25 | typealias Item = TheMovieDb.Movie 26 | typealias ItemResult = TheMovieDb.MoviesPaginationResult 27 | 28 | class ViewModel: ObservableObject { 29 | 30 | @Published var defaultItems = [Item]() 31 | @Published var searchItems = [Item]() 32 | @Published var searchQuery = "" 33 | } 34 | 35 | var body: some View { 36 | ScrollView(.vertical) { 37 | LazyVGrid(columns: gridColumns) { 38 | ForEach(items) { 39 | gridItem(for: $0) 40 | } 41 | } 42 | .padding() 43 | } 44 | .task { fetchDefaultItems() } 45 | .searchable(text: $model.searchQuery) 46 | .onReceive(model.$searchQuery.throttle( 47 | for: 1, 48 | scheduler: RunLoop.main, 49 | latest: true 50 | ), perform: search) 51 | .navigationTitle("The Movie DB") 52 | } 53 | } 54 | 55 | extension TheMovieDbScreen { 56 | 57 | func gridItem(for item: Item) -> some View { 58 | VStack { 59 | AsyncImage( 60 | url: item.posterUrl(width: 300), 61 | content: { image in 62 | image.resizable() 63 | .cornerRadius(5) 64 | .aspectRatio(contentMode: .fit) 65 | }, 66 | placeholder: { 67 | ProgressView() 68 | } 69 | ) 70 | .accessibilityLabel(item.title) 71 | } 72 | } 73 | } 74 | 75 | extension TheMovieDbScreen { 76 | 77 | var items: [Item] { 78 | model.searchItems.isEmpty ? model.defaultItems : model.searchItems 79 | } 80 | 81 | func fetchDefaultItems() { 82 | Task { 83 | do { 84 | let result: ItemResult = try await session.request( 85 | at: TheMovieDb.Route.discoverMovies(page: 1), 86 | in: environment 87 | ) 88 | updateDefaultItems(with: result) 89 | } catch { 90 | print(error) 91 | } 92 | } 93 | } 94 | 95 | func search(with query: String) { 96 | Task { 97 | do { 98 | let result = try await search(with: query) 99 | updateSearchResult(with: result) 100 | } catch { 101 | print(error) 102 | } 103 | } 104 | } 105 | 106 | func search(with query: String) async throws -> ItemResult { 107 | try await session.request( 108 | at: TheMovieDb.Route.searchMovies(query: query, page: 1), 109 | in: environment 110 | ) 111 | } 112 | } 113 | 114 | @MainActor 115 | extension TheMovieDbScreen { 116 | 117 | func updateDefaultItems(with result: ItemResult) { 118 | model.defaultItems = result.results 119 | } 120 | 121 | func updateSearchResult(with result: ItemResult) { 122 | model.searchItems = result.results 123 | } 124 | } 125 | 126 | #Preview { 127 | 128 | struct Preview: View { 129 | 130 | @AppStorage(Self.movieDbApiKey) var apiKey = "" 131 | 132 | var body: some View { 133 | TheMovieDbScreen(apiKey: apiKey) 134 | #if os(macOS) 135 | .frame(minWidth: 500) 136 | #endif 137 | } 138 | } 139 | 140 | return Preview() 141 | } 142 | -------------------------------------------------------------------------------- /Demo/Demo/Screens/YelpScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YelpScreen.swift 3 | // Demo 4 | // 5 | // Created by Daniel Saidi on 2025-09-29. 6 | // Copyright © 2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import ApiKit 10 | import SwiftUI 11 | 12 | struct YelpScreen: View { 13 | 14 | init(apiKey: String) { 15 | self.environment = .v3(apiToken: apiKey) 16 | } 17 | 18 | let session = URLSession.shared 19 | let environment: Yelp.Environment 20 | let gridColumns = [GridItem(.adaptive(minimum: 100), alignment: .top)] 21 | 22 | @StateObject 23 | private var model = ViewModel() 24 | 25 | typealias Item = Yelp.Restaurant 26 | typealias ItemResult = Yelp.RestaurantSearchResult 27 | 28 | class ViewModel: ObservableObject { 29 | 30 | @Published var defaultItems = [Item]() 31 | @Published var searchItems = [Item]() 32 | @Published var searchQuery = "" 33 | } 34 | 35 | var body: some View { 36 | ScrollView(.vertical) { 37 | LazyVGrid(columns: gridColumns) { 38 | ForEach(items) { 39 | gridItem(for: $0) 40 | } 41 | }.padding() 42 | } 43 | .task { fetchDefaultItems() } 44 | // .searchable(text: $model.searchQuery) 45 | // .onReceive(model.$searchQuery.throttle( 46 | // for: 1, 47 | // scheduler: RunLoop.main, 48 | // latest: true 49 | // ), perform: search) 50 | .navigationTitle("Yelp") 51 | } 52 | } 53 | 54 | extension YelpScreen { 55 | 56 | func gridItem(for item: Item) -> some View { 57 | VStack { 58 | AsyncImage( 59 | url: item.imageUrl?.url, 60 | content: { image in 61 | image.resizable() 62 | .cornerRadius(5) 63 | .aspectRatio(contentMode: .fit) 64 | }, 65 | placeholder: { 66 | ProgressView() 67 | } 68 | ) 69 | .accessibilityLabel(item.name ?? item.id) 70 | } 71 | } 72 | } 73 | 74 | private extension String { 75 | 76 | var url: URL? { 77 | .init(string: self) 78 | } 79 | } 80 | 81 | extension YelpScreen { 82 | 83 | var items: [Item] { 84 | model.searchItems.isEmpty ? model.defaultItems : model.searchItems 85 | } 86 | 87 | func fetchDefaultItems() { 88 | Task { 89 | do { 90 | let result: ItemResult = try await session.request( 91 | at: Yelp.Route.search( 92 | params: .init( 93 | skip: 0, 94 | take: 25, 95 | radius: 5_000, 96 | coordinate: (lat: 59.3327, long: 18.0645) 97 | ) 98 | ), 99 | in: environment 100 | ) 101 | updateDefaultItems(with: result) 102 | } catch { 103 | print(error) 104 | } 105 | } 106 | } 107 | } 108 | 109 | @MainActor 110 | extension YelpScreen { 111 | 112 | func updateDefaultItems(with result: ItemResult) { 113 | model.defaultItems = result.businesses 114 | } 115 | } 116 | 117 | #Preview { 118 | 119 | struct Preview: View { 120 | 121 | @AppStorage(Self.yelpApiKey) var apiKey = "" 122 | 123 | var body: some View { 124 | YelpScreen(apiKey: apiKey) 125 | #if os(macOS) 126 | .frame(minWidth: 500) 127 | #endif 128 | } 129 | } 130 | 131 | return Preview() 132 | } 133 | -------------------------------------------------------------------------------- /Demo/Demo/View+ApiKeys.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 SwiftUI 10 | 11 | extension View { 12 | 13 | static var movieDbApiKey: String { "com.danielsaidi.apikit.moviedb.apikey" } 14 | static var yelpApiKey: String { "com.danielsaidi.apikit.yelp.apikey" } 15 | } 16 | -------------------------------------------------------------------------------- /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.1 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.1 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.1.0 10 | 11 | ### 💡 Adjustments 12 | 13 | * The package now uses Swift 6.1. 14 | * The demo app now targets iOS 26. 15 | 16 | 17 | 18 | ## 1.0.3 19 | 20 | ### 💡 Adjustments 21 | 22 | * `ApiError` now returns proper localized error descriptions. 23 | 24 | 25 | 26 | ## 1.0.2 27 | 28 | ### ✨ Features 29 | 30 | * `ApiError` now includes the status code in some errors. 31 | 32 | 33 | 34 | ## 1.0.1 35 | 36 | ### ✨ Features 37 | 38 | * `ApiError` now returns a readable, localized description. 39 | 40 | 41 | 42 | ## 1.0 43 | 44 | This major version bump removes deprecated code. 45 | 46 | ### 💥 Breaking changes 47 | 48 | * The `ApiRequestData` protocol has been removed. 49 | * All previously deprecated code has been removed. 50 | 51 | 52 | 53 | ## 0.9.2 54 | 55 | This version adds an `ApiModel` protocol that simplifies conforming to `Codable` and `Sendable`. 56 | 57 | 58 | 59 | ## 0.9.1 60 | 61 | This version adjusts HTTP status code terminology. 62 | 63 | ### ✨ New Features 64 | 65 | * `ApiClient` lets you provide a custom decoder. 66 | * `ApiError` has a new `invalidHttpStatusCode` error. 67 | * `ApiError` has a new `unsuccessfulHttpStatusCode` error. 68 | 69 | ### 💡 Adjustments 70 | 71 | * `100-599` is valid. 72 | * `100-199` and `300-599` is unsuccessful, not invalid. 73 | * All other status codes are invalid, since they're not in the spec. 74 | 75 | 76 | 77 | ## 0.9 78 | 79 | This version removes all deprecated code and makes the SDK use Swift 6. 80 | 81 | 82 | 83 | ## 0.8 84 | 85 | This version renames client functions to use the "request" terminology for more consistent naming. 86 | 87 | ### 🗑️ Deprecations 88 | 89 | * `ApiClient` has renamed all `fetch` operations to `request`. 90 | 91 | ### 💥 Breaking changes 92 | 93 | * `ApiClient` `fetchData` is renamed to `data` to match `URLSession`. 94 | 95 | 96 | 97 | ## 0.7 98 | 99 | ### ✨ New Features 100 | 101 | * ApiKit now supports visionOS. 102 | 103 | ### 💥 Breaking changes 104 | 105 | * SystemNotification now requires Swift 5.9. 106 | 107 | 108 | 109 | ## 0.6 110 | 111 | ### ✨ New Features 112 | 113 | * `ApiClient` now validates the response status code. 114 | * `ApiClient` can perform even more fetch operations. 115 | * `ApiError` has a new `invalidResponseStatusCode` error. 116 | 117 | ### 💥 Breaking Changes 118 | 119 | * `ApiClient` now only requires a data fetch implementation. 120 | 121 | 122 | 123 | ## 0.5 124 | 125 | ### ✨ New Features 126 | 127 | * `ApiClient` has a new `fetch(_:in:)` for fetching routes. 128 | * `ApiRequest` is a new type that simplifies fetching data. 129 | 130 | ### 💥 Breaking Changes 131 | 132 | * `ApiError.noDataInResponse` has been removed. 133 | * `ApiResult` properties are no longer optional. 134 | 135 | 136 | 137 | ## 0.4 138 | 139 | This version uses Swift 5.9 and renames some integration types. 140 | 141 | 142 | 143 | ## 0.3 144 | 145 | ### ✨ New Features 146 | 147 | * `Yelp` is a new namespace with Yelp API integrations. 148 | 149 | 150 | 151 | ## 0.2.1 152 | 153 | This version makes ApiKit support PATCH requests. 154 | 155 | ### ✨ New Features 156 | 157 | * `HttpMethod` now has a new `patch` case. 158 | 159 | 160 | 161 | ## 0.2 162 | 163 | This version adds supports for headers and for the environment to define global headers and query parameters. 164 | 165 | ### ✨ New Features 166 | 167 | * `ApiRequestData` is a new protocol that is implemented by both `ApiEnvironment` and `ApiRoute`. 168 | * `ApiEnvironment` and `ApiRoute` can now define custom headers. 169 | * `TheMovieDB` is a new type that can be used to integrate with The Movie DB api. 170 | 171 | ### 💡 Behavior Changes 172 | 173 | * All request data is now optional. 174 | * URL request creation is now throwing. 175 | * URL requests will now combine data from the environment and route. 176 | 177 | ### 🐛 Bug fixes 178 | 179 | * `ApiRequestData` removes the not needed url encoding. 180 | 181 | ### 💥 Breaking Changes 182 | 183 | * `ApiEnvironment` now uses a `String` as url. 184 | * `ApiRequestData` makes the `queryParams` property optional. 185 | * `ApiRoute` makes the `formParams` property optional. 186 | 187 | 188 | 189 | ## 0.1 190 | 191 | This is the first public release of ApiKit. 192 | 193 | ### ✨ New Features 194 | 195 | * You can create `ApiEnvironment` and `ApiRoute` implementations and use them with `ApiClient`. 196 | * `URLSession` implements `ApiClient` so you don't need a custom implementation 197 | -------------------------------------------------------------------------------- /Resources/Icon-Badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/cf3f2128dba09a5cf8e6080935f88ea56edbadb0/Resources/Icon-Badge.png -------------------------------------------------------------------------------- /Resources/Icon-Plain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/cf3f2128dba09a5cf8e6080935f88ea56edbadb0/Resources/Icon-Plain.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 perform API requests. 12 | /// 13 | /// You can use ``data(for:)`` to request raw data and ``request(_:)`` 14 | /// to request a validated ``ApiResult``. You can use ``request(with:)`` 15 | /// and ``request(at:in:)`` to request and parse any decodable data. 16 | /// 17 | /// This protocol is implemented by `URLSession`, so you can use the shared 18 | /// session directly. You can create a custom implementation to customize how it 19 | /// performs certain operations, for mocking, etc. 20 | public protocol ApiClient: AnyObject { 21 | 22 | /// Fetch data with the provided `URLRequest`. 23 | func data( 24 | for request: URLRequest 25 | ) async throws -> (Data, URLResponse) 26 | } 27 | 28 | extension URLSession: ApiClient {} 29 | 30 | public extension ApiClient { 31 | 32 | /// Request a raw ``ApiResult`` for the provided request. 33 | func request( 34 | _ request: URLRequest 35 | ) async throws -> ApiResult { 36 | let result = try await data(for: request) 37 | let data = result.0 38 | let response = result.1 39 | try validate(request: request, response: response, data: data) 40 | return ApiResult(data: data, response: response) 41 | } 42 | 43 | /// Request a raw ``ApiResult`` for the provided route. 44 | func request( 45 | _ route: ApiRoute, 46 | in environment: ApiEnvironment 47 | ) async throws -> ApiResult { 48 | let request = try route.urlRequest(for: environment) 49 | return try await self.request(request) 50 | } 51 | 52 | /// Request a typed result for the provided request. 53 | func request( 54 | with request: URLRequest, 55 | decoder: JSONDecoder? = nil 56 | ) async throws -> T { 57 | let result = try await self.request(request) 58 | let data = result.data 59 | let decoder = decoder ?? JSONDecoder() 60 | return try decoder.decode(T.self, from: data) 61 | } 62 | 63 | /// Request a typed result for the provided route. 64 | func request( 65 | at route: ApiRoute, 66 | in environment: ApiEnvironment, 67 | decoder: JSONDecoder? = nil 68 | ) async throws -> T { 69 | let request = try route.urlRequest(for: environment) 70 | return try await self.request(with: request, decoder: decoder) 71 | } 72 | 73 | /// Validate the provided request, response and data. 74 | func validate( 75 | request: URLRequest, 76 | response: URLResponse, 77 | data: Data 78 | ) throws(ApiError) { 79 | guard let httpResponse = response as? HTTPURLResponse else { return } 80 | let statusCode = httpResponse.statusCode 81 | guard statusCode.isValidHttpStatusCode else { 82 | throw ApiError.invalidHttpStatusCode(statusCode, request, response, data) 83 | } 84 | guard statusCode.isSuccessfulHttpStatusCode else { 85 | throw ApiError.unsuccessfulHttpStatusCode(statusCode, request, response, data) 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /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 specific API versions. 12 | /// 13 | /// You can use an enum to define several environments for a certain API, or use 14 | /// a struct if you want to allow for more extensibility. 15 | /// 16 | /// An ``ApiEnvironment`` must define an global environment ``url``, to 17 | /// which an environment-relative ``ApiRoute`` path can be appended. 18 | /// 19 | /// An ``ApiEnvironment`` can define any headers and query parameters it 20 | /// needs, which are then applied to all requests to that environment. A route can 21 | /// then override any header or query parameter. 22 | public protocol ApiEnvironment: Sendable { 23 | 24 | /// Optional header parameters to apply to all requests. 25 | var headers: [String: String]? { get } 26 | 27 | /// Optional query params to apply to all requests. 28 | var queryParams: [String: String]? { get } 29 | 30 | /// The base URL of the environment. 31 | var url: String { get } 32 | } 33 | 34 | extension ApiEnvironment { 35 | 36 | /// Convert ``queryParams`` to url encoded query items. 37 | var encodedQueryItems: [URLQueryItem]? { 38 | queryParams? 39 | .map { URLQueryItem(name: $0.key, value: $0.value) } 40 | .sorted { $0.name < $1.name } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /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 | import SwiftUI 11 | 12 | /// This enum defines errors that can be thrown by an ``ApiClient``. 13 | public enum ApiError: Equatable, LocalizedError { 14 | 15 | /// This error is thrown when an ``ApiEnvironment`` has an invalid ``ApiEnvironment/url``. 16 | case invalidEnvironmentUrl(String) 17 | 18 | /// This error is thrown when a URL request fails due to an invalid status code (outside of 100-599). 19 | case invalidHttpStatusCode(Int, URLRequest, URLResponse, Data) 20 | 21 | /// This error is thrown when a `URLRequest` will fail to be created due to invalid `URLComponents`. 22 | case noUrlInComponents(URLComponents) 23 | 24 | /// This error is thrown when a `URLRequest` will fail to be created due to an invalid `URL`. 25 | case failedToCreateComponentsFromUrl(URL) 26 | 27 | /// This error is thrown when a URL request fails due to an unsuccessful status code (100-199, 300-599). 28 | case unsuccessfulHttpStatusCode(Int, URLRequest, URLResponse, Data) 29 | } 30 | 31 | public extension ApiError { 32 | 33 | /// A user-friendly error description. 34 | var errorDescription: String? { 35 | switch self { 36 | case .invalidEnvironmentUrl: "Unable to connect to the service. Please check your network connection and try again." 37 | case .invalidHttpStatusCode(let code, _, _, _): "An invalid status code was returned (Code: \(code)). Please try again later." 38 | case .failedToCreateComponentsFromUrl: "Invalid request configuration." 39 | case .noUrlInComponents: "Invalid request configuration." 40 | case .unsuccessfulHttpStatusCode(let code, _, _, _): 41 | switch code { 42 | case 400: "The request was invalid. Please check your input and try again." 43 | case 401: "Authentication failed. Please sign in again." 44 | case 403: "You don't have permission to access this resource." 45 | case 404: "The requested resource was not found." 46 | case 408: "The request timed out. Please check your connection and try again." 47 | case 429: "Too many requests. Please wait a moment and try again." 48 | case 500...599: "The server is experiencing issues. Please try again later." 49 | default: "A network error occurred. Please try again later." 50 | } 51 | } 52 | } 53 | } 54 | 55 | public extension ApiError { 56 | 57 | /// Whether the error is a ``ApiError/invalidHttpStatusCode(_:_:_:_:)`` 58 | var isInvalidHttpStatusCodeError: Bool { 59 | switch self { 60 | case .invalidHttpStatusCode: true 61 | default: false 62 | } 63 | } 64 | 65 | /// Whether the error is a ``ApiError/invalidHttpStatusCode(_:_:_:_:)`` 66 | var isUnsuccessfulHttpStatusCodeError: Bool { 67 | switch self { 68 | case .unsuccessfulHttpStatusCode: true 69 | default: false 70 | } 71 | } 72 | } 73 | 74 | #Preview { 75 | 76 | return List { 77 | listItem(for: ApiError.invalidEnvironmentUrl("foo")) 78 | } 79 | 80 | func listItem(for error: Error) -> some View { 81 | Text(error.localizedDescription) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/ApiKit/ApiKit.docc/ApiKit.md: -------------------------------------------------------------------------------- 1 | # ``ApiKit`` 2 | 3 | ApiKit is a Swift library 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 | 16 | ## Installation 17 | 18 | ApiKit can be installed with the Swift Package Manager: 19 | 20 | ``` 21 | https://github.com/danielsaidi/ApiKit.git 22 | ``` 23 | 24 | 25 | ## Support My Work 26 | 27 | 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. 28 | 29 | 30 | 31 | ## Getting started 32 | 33 | 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``: 34 | 35 | ```swift 36 | let client = URLSession.shared 37 | let environment = MyEnvironment.production(apiToken: "TOKEN") 38 | let route = MyRoutes.user(id: "abc123") 39 | let user: ApiUser = try await client.request(at: route, in: environment) 40 | ``` 41 | 42 | 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. 43 | 44 | See the article for more information on how to define environments and routes. 45 | 46 | 47 | 48 | ## Repository 49 | 50 | For more information, source code, etc., visit the [project repository](https://github.com/danielsaidi/ApiKit). 51 | 52 | 53 | 54 | ## License 55 | 56 | ApiKit is available under the MIT license. 57 | 58 | 59 | 60 | ## Topics 61 | 62 | ### Articles 63 | 64 | - 65 | 66 | ### Essentials 67 | 68 | - ``ApiEnvironment`` 69 | - ``ApiRoute`` 70 | - ``ApiClient`` 71 | - ``ApiError`` 72 | - ``ApiRequest`` 73 | - ``ApiResult`` 74 | 75 | ### HTTP 76 | 77 | - ``HttpMethod`` 78 | 79 | ### Integrations 80 | 81 | - ``TheMovieDb`` 82 | - ``Yelp`` 83 | 84 | 85 | 86 | [Email]: mailto:daniel.saidi@gmail.com 87 | [Website]: https://danielsaidi.com 88 | [GitHub]: https://github.com/danielsaidi 89 | [OpenSource]: https://danielsaidi.com/opensource 90 | [Sponsors]: https://github.com/sponsors/danielsaidi 91 | -------------------------------------------------------------------------------- /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/cf3f2128dba09a5cf8e6080935f88ea56edbadb0/Sources/ApiKit/ApiKit.docc/Resources/Icon.png -------------------------------------------------------------------------------- /Sources/ApiKit/ApiKit.docc/Resources/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/cf3f2128dba09a5cf8e6080935f88ea56edbadb0/Sources/ApiKit/ApiKit.docc/Resources/Logo.png -------------------------------------------------------------------------------- /Sources/ApiKit/ApiKit.docc/Resources/Page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ApiKit/cf3f2128dba09a5cf8e6080935f88ea56edbadb0/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-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | /// This protocol can be implemented by API-specific models. 10 | public protocol ApiModel: Codable, Sendable {} 11 | -------------------------------------------------------------------------------- /Sources/ApiKit/ApiRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApiRequest.swift 3 | // ApiKit 4 | // 5 | // Created by Daniel Saidi on 2024-01-17. 6 | // Copyright © 2024-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This protocol can be used to define a API route, and its expected return type. 12 | /// 13 | /// You can use this protocol to avoid having to specify a type when fetching data 14 | /// from a route. Just use ``ApiClient/fetch(_:from:)`` to automatically 15 | /// decode the response data to the expected ``ResponseType``. 16 | public protocol ApiRequest: Codable { 17 | 18 | associatedtype ResponseType: Codable 19 | 20 | var route: ApiRoute { get } 21 | } 22 | 23 | public extension ApiClient { 24 | 25 | /// Try to request a certain ``ApiRequest``. 26 | func fetch( 27 | _ request: RequestType, 28 | from env: ApiEnvironment 29 | ) async throws -> RequestType.ResponseType { 30 | try await self.request(at: request.route, in: env) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /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 environment-relative``path``, which will 14 | /// be appended to an environment ``ApiEnvironment/url`` 15 | /// 16 | /// You can use an enum to define several routes for a certain API, or use a struct 17 | /// if you want to allow for more extensibility. 18 | /// 19 | /// When a route defines ``formParams``, the ``postData`` should not be 20 | /// used. Instead `application/x-www-form-urlencoded` should be used 21 | /// with the ``formParams`` value. 22 | /// 23 | /// An ``ApiRoute`` can override any headers and query parameters that are 24 | /// defined by an ``ApiEnvironment``. 25 | public protocol ApiRoute: Sendable { 26 | 27 | /// Optional header parameters to apply to the route. 28 | var headers: [String: String]? { get } 29 | 30 | /// The HTTP method to use for the route. 31 | var httpMethod: HttpMethod { get } 32 | 33 | /// The route's ``ApiEnvironment`` relative path. 34 | var path: String { get } 35 | 36 | /// Optional query params to apply to the route. 37 | var queryParams: [String: String]? { get } 38 | 39 | /// Optional form data, which is sent as request body. 40 | var formParams: [String: String]? { get } 41 | 42 | /// Optional post data, which is sent as request body. 43 | var postData: Data? { get } 44 | } 45 | 46 | public extension ApiRoute { 47 | 48 | /// Convert ``encodedFormItems`` to `.utf8` encoded data. 49 | var encodedFormData: Data? { 50 | guard let formParams, !formParams.isEmpty else { return nil } 51 | var params = URLComponents() 52 | params.queryItems = encodedFormItems 53 | let paramString = params.query 54 | return paramString?.data(using: .utf8) 55 | } 56 | 57 | /// Convert ``formParams`` to form encoded query items. 58 | var encodedFormItems: [URLQueryItem]? { 59 | formParams? 60 | .map { URLQueryItem(name: $0.key, value: $0.value.formEncoded()) } 61 | .sorted { $0.name < $1.name } 62 | } 63 | 64 | /// Get a `URLRequest` for the route and its properties. 65 | func urlRequest(for env: ApiEnvironment) throws -> URLRequest { 66 | guard let envUrl = URL(string: env.url) else { throw ApiError.invalidEnvironmentUrl(env.url) } 67 | let routeUrl = envUrl.appendingPathComponent(path) 68 | guard var components = urlComponents(from: routeUrl) else { throw ApiError.failedToCreateComponentsFromUrl(routeUrl) } 69 | components.queryItems = queryItems(for: env) 70 | guard let requestUrl = components.url else { throw ApiError.noUrlInComponents(components) } 71 | var request = URLRequest(url: requestUrl) 72 | let formData = encodedFormData 73 | request.allHTTPHeaderFields = headers(for: env) 74 | request.httpBody = formData ?? postData 75 | request.httpMethod = httpMethod.method 76 | let isFormRequest = formData != nil 77 | let contentType = isFormRequest ? "application/x-www-form-urlencoded" : "application/json" 78 | request.setValue(contentType, forHTTPHeaderField: "Content-Type") 79 | return request 80 | } 81 | } 82 | 83 | public extension ApiEnvironment { 84 | 85 | /// Get a `URLRequest` for a certain ``ApiRoute``. 86 | func urlRequest(for route: ApiRoute) throws -> URLRequest { 87 | try route.urlRequest(for: self) 88 | } 89 | } 90 | 91 | extension ApiRoute { 92 | 93 | var encodedQueryItems: [URLQueryItem]? { 94 | queryParams? 95 | .map { URLQueryItem(name: $0.key, value: $0.value) } 96 | .sorted { $0.name < $1.name } 97 | } 98 | } 99 | 100 | private extension ApiRoute { 101 | 102 | func headers(for env: ApiEnvironment) -> [String: String] { 103 | var result = env.headers ?? [:] 104 | headers?.forEach { 105 | result[$0.key] = $0.value 106 | } 107 | return result 108 | } 109 | 110 | func queryItems(for env: ApiEnvironment) -> [URLQueryItem] { 111 | let routeData = encodedQueryItems ?? [] 112 | let envData = env.encodedQueryItems ?? [] 113 | return routeData + envData 114 | } 115 | 116 | func urlComponents(from url: URL) -> URLComponents? { 117 | URLComponents(url: url, resolvingAgainstBaseURL: true) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /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-2025 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 `+` with `%2B`. 18 | func formEncoded() -> String? { 19 | self.urlEncoded()? 20 | .replacingOccurrences(of: "+", with: "%2B") 21 | } 22 | 23 | /// Encode the string for quary parameters. 24 | /// 25 | /// This will first `addingPercentEncoding` with a `.urlPathAllowed` character 26 | /// set, then replace every `&` with `%26`. 27 | func urlEncoded() -> String? { 28 | self.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)? 29 | .replacingOccurrences(of: "&", with: "%26") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /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 set up an API account at `https://themoviedb.org`. 14 | public struct TheMovieDb {} 15 | -------------------------------------------------------------------------------- /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, Identifiable { 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 = 0, 144 | take: Int = 25, 145 | radius: Int, 146 | coordinate: (lat: Double, long: Double), 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 | params["latitude"] = "\(coordinate.lat)" 186 | params["longitude"] = "\(coordinate.long)" 187 | 188 | if !budgetLevels.isEmpty { 189 | params["price"] = Set(budgetLevels) 190 | .map { $0.rawValue } 191 | .joined(separator: ",") 192 | } 193 | 194 | if openingHours == .openNow { 195 | params["open_now"] = "true" 196 | } 197 | 198 | return params 199 | } 200 | } 201 | 202 | /// This type represents a Yelp search result. 203 | struct RestaurantSearchResult: Codable { 204 | 205 | public let businesses: [Restaurant] 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /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 set up an API account at `https://yelp.com/developers`. 14 | public struct Yelp {} 15 | -------------------------------------------------------------------------------- /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-2025 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 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Function to display usage information 7 | show_usage() { 8 | echo 9 | echo "This script builds a for all provided ." 10 | 11 | echo 12 | echo "Usage: $0 [TARGET] [-p|--platforms ...]" 13 | echo " [TARGET] Optional. The target to build (defaults to package name)" 14 | echo " -p, --platforms Optional. List of platforms (default: iOS macOS tvOS watchOS xrOS)" 15 | 16 | echo 17 | echo "Examples:" 18 | echo " $0" 19 | echo " $0 MyTarget" 20 | echo " $0 -p iOS macOS" 21 | echo " $0 MyTarget -p iOS macOS" 22 | echo " $0 MyTarget --platforms iOS macOS tvOS watchOS xrOS" 23 | echo 24 | } 25 | 26 | # Function to display error message, show usage, and exit 27 | show_error_and_exit() { 28 | echo 29 | local error_message="$1" 30 | echo "Error: $error_message" 31 | show_usage 32 | exit 1 33 | } 34 | 35 | # Define argument variables 36 | TARGET="" 37 | PLATFORMS="iOS macOS tvOS watchOS xrOS" # Default platforms 38 | 39 | # Parse command line arguments 40 | while [[ $# -gt 0 ]]; do 41 | case $1 in 42 | -p|--platforms) 43 | shift # Remove --platforms from arguments 44 | PLATFORMS="" # Clear default platforms 45 | 46 | # Collect all platform arguments until we hit another flag or run out of args 47 | while [[ $# -gt 0 && ! "$1" =~ ^- ]]; do 48 | PLATFORMS="$PLATFORMS $1" 49 | shift 50 | done 51 | 52 | # Remove leading space and check if we got any platforms 53 | PLATFORMS=$(echo "$PLATFORMS" | sed 's/^ *//') 54 | if [ -z "$PLATFORMS" ]; then 55 | show_error_and_exit "--platforms requires at least one platform" 56 | fi 57 | ;; 58 | -h|--help) 59 | show_usage; exit 0 ;; 60 | -*) 61 | show_error_and_exit "Unknown option $1" ;; 62 | *) 63 | if [ -z "$TARGET" ]; then 64 | TARGET="$1" 65 | else 66 | show_error_and_exit "Unexpected argument '$1'" 67 | fi 68 | shift 69 | ;; 70 | esac 71 | done 72 | 73 | # If no TARGET was provided, try to get package name 74 | if [ -z "$TARGET" ]; then 75 | # Use the script folder to refer to other scripts 76 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 77 | SCRIPT_PACKAGE_NAME="$FOLDER/package_name.sh" 78 | 79 | # Check if package_name.sh exists 80 | if [ -f "$SCRIPT_PACKAGE_NAME" ]; then 81 | echo "No target provided, attempting to get package name..." 82 | if TARGET=$("$SCRIPT_PACKAGE_NAME"); then 83 | echo "Using package name: $TARGET" 84 | else 85 | echo "" 86 | read -p "Failed to get package name. Please enter the target to build: " TARGET 87 | if [ -z "$TARGET" ]; then 88 | show_error_and_exit "TARGET is required" 89 | fi 90 | fi 91 | else 92 | echo "" 93 | read -p "Please enter the target to build: " TARGET 94 | if [ -z "$TARGET" ]; then 95 | show_error_and_exit "TARGET is required" 96 | fi 97 | fi 98 | fi 99 | 100 | # A function that builds $TARGET for a specific platform 101 | build_platform() { 102 | 103 | # Define a local $PLATFORM variable 104 | local PLATFORM=$1 105 | 106 | # Build $TARGET for the $PLATFORM 107 | echo "Building $TARGET for $PLATFORM..." 108 | if ! xcodebuild -scheme $TARGET -derivedDataPath .build -destination generic/platform=$PLATFORM; then 109 | echo "Failed to build $TARGET for $PLATFORM" ; return 1 110 | fi 111 | 112 | # Complete successfully 113 | echo "Successfully built $TARGET for $PLATFORM" 114 | } 115 | 116 | # Start script 117 | echo 118 | echo "Building $TARGET for [$PLATFORMS]..." 119 | 120 | # Loop through all platforms and call the build function 121 | for PLATFORM in $PLATFORMS; do 122 | if ! build_platform "$PLATFORM"; then 123 | exit 1 124 | fi 125 | done 126 | 127 | # Complete successfully 128 | echo 129 | echo "Building $TARGET completed successfully!" 130 | echo -------------------------------------------------------------------------------- /scripts/chmod.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Function to display usage information 7 | show_usage() { 8 | echo 9 | echo "This script makes all .sh files in the current directory executable." 10 | 11 | echo 12 | echo "Usage: $0 [OPTIONS]" 13 | echo " -h, --help Show this help message" 14 | 15 | echo 16 | echo "Examples:" 17 | echo " $0" 18 | echo " bash scripts/chmod.sh" 19 | echo 20 | } 21 | 22 | # Function to display error message, show usage, and exit 23 | show_error_and_exit() { 24 | echo 25 | local error_message="$1" 26 | echo "Error: $error_message" 27 | show_usage 28 | exit 1 29 | } 30 | 31 | # Parse command line arguments 32 | while [[ $# -gt 0 ]]; do 33 | case $1 in 34 | -h|--help) 35 | show_usage; exit 0 ;; 36 | -*) 37 | show_error_and_exit "Unknown option $1" ;; 38 | *) 39 | show_error_and_exit "Unexpected argument '$1'" ;; 40 | esac 41 | done 42 | 43 | # Use the script folder to refer to other scripts 44 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 45 | 46 | # Function to make scripts executable 47 | make_executable() { 48 | local script="$1" 49 | local filename=$(basename "$script") 50 | 51 | echo "Making $filename executable..." 52 | if ! chmod +x "$script"; then 53 | echo "Failed to make $filename executable" ; return 1 54 | fi 55 | 56 | echo "Successfully made $filename executable" 57 | } 58 | 59 | # Start script 60 | echo 61 | echo "Making all .sh files in $(basename "$FOLDER") executable..." 62 | 63 | # Find all .sh files in the FOLDER except chmod.sh and make them executable 64 | SCRIPT_COUNT=0 65 | while read -r script; do 66 | if ! make_executable "$script"; then 67 | exit 1 68 | fi 69 | ((SCRIPT_COUNT++)) 70 | done < <(find "$FOLDER" -name "*.sh" ! -name "chmod.sh" -type f) 71 | 72 | # Complete successfully 73 | if [ $SCRIPT_COUNT -eq 0 ]; then 74 | echo 75 | echo "No .sh files found to make executable (excluding chmod.sh)" 76 | else 77 | echo 78 | echo "Successfully made $SCRIPT_COUNT script(s) executable!" 79 | fi 80 | 81 | echo -------------------------------------------------------------------------------- /scripts/docc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Fail if any command in a pipeline fails 7 | set -o pipefail 8 | 9 | # Function to display usage information 10 | show_usage() { 11 | echo 12 | echo "This script builds DocC for a and certain ." 13 | 14 | echo 15 | echo "Usage: $0 [TARGET] [-p|--platforms ...] [--hosting-base-path ]" 16 | echo " [TARGET] Optional. The target to build documentation for (defaults to package name)" 17 | echo " -p, --platforms Optional. List of platforms (default: iOS macOS tvOS watchOS xrOS)" 18 | echo " --hosting-base-path Optional. Base path for static hosting (default: TARGET name, use empty string \"\" for root)" 19 | 20 | echo 21 | echo "The documentation ends up in .build/docs-." 22 | 23 | echo 24 | echo "Examples:" 25 | echo " $0" 26 | echo " $0 MyTarget" 27 | echo " $0 -p iOS macOS" 28 | echo " $0 MyTarget -p iOS macOS" 29 | echo " $0 MyTarget --platforms iOS macOS tvOS watchOS xrOS" 30 | echo " $0 MyTarget --hosting-base-path \"\"" 31 | echo " $0 MyTarget --hosting-base-path \"custom/path\"" 32 | echo 33 | } 34 | 35 | # Function to display error message, show usage, and exit 36 | show_error_and_exit() { 37 | echo 38 | local error_message="$1" 39 | echo "Error: $error_message" 40 | show_usage 41 | exit 1 42 | } 43 | 44 | # Define argument variables 45 | TARGET="" 46 | PLATFORMS="iOS macOS tvOS watchOS xrOS" # Default platforms 47 | HOSTING_BASE_PATH="" # Will be set to TARGET if not specified 48 | 49 | # Parse command line arguments 50 | while [[ $# -gt 0 ]]; do 51 | case $1 in 52 | -p|--platforms) 53 | shift # Remove --platforms from arguments 54 | PLATFORMS="" # Clear default platforms 55 | 56 | # Collect all platform arguments until we hit another flag or run out of args 57 | while [[ $# -gt 0 && ! "$1" =~ ^- ]]; do 58 | PLATFORMS="$PLATFORMS $1" 59 | shift 60 | done 61 | 62 | # Remove leading space and check if we got any platforms 63 | PLATFORMS=$(echo "$PLATFORMS" | sed 's/^ *//') 64 | if [ -z "$PLATFORMS" ]; then 65 | show_error_and_exit "--platforms requires at least one platform" 66 | fi 67 | ;; 68 | --hosting-base-path) 69 | shift # Remove --hosting-base-path from arguments 70 | if [[ $# -eq 0 ]]; then 71 | show_error_and_exit "--hosting-base-path requires a value (use \"\" for empty path)" 72 | fi 73 | HOSTING_BASE_PATH="$1" 74 | shift 75 | ;; 76 | -h|--help) 77 | show_usage; exit 0 ;; 78 | -*) 79 | show_error_and_exit "Unknown option $1" ;; 80 | *) 81 | if [ -z "$TARGET" ]; then 82 | TARGET="$1" 83 | else 84 | show_error_and_exit "Unexpected argument '$1'" 85 | fi 86 | shift 87 | ;; 88 | esac 89 | done 90 | 91 | # If no TARGET was provided, try to get package name 92 | if [ -z "$TARGET" ]; then 93 | # Use the script folder to refer to other scripts 94 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 95 | SCRIPT_PACKAGE_NAME="$FOLDER/package_name.sh" 96 | 97 | # Check if package_name.sh exists 98 | if [ -f "$SCRIPT_PACKAGE_NAME" ]; then 99 | echo "No target provided, attempting to get package name..." 100 | if TARGET=$("$SCRIPT_PACKAGE_NAME"); then 101 | echo "Using package name: $TARGET" 102 | else 103 | echo "" 104 | read -p "Failed to get package name. Please enter the target to build documentation for: " TARGET 105 | if [ -z "$TARGET" ]; then 106 | show_error_and_exit "TARGET is required" 107 | fi 108 | fi 109 | else 110 | echo "" 111 | read -p "Please enter the target to build documentation for: " TARGET 112 | if [ -z "$TARGET" ]; then 113 | show_error_and_exit "TARGET is required" 114 | fi 115 | fi 116 | fi 117 | 118 | # Set default hosting base path if not specified 119 | if [ -z "$HOSTING_BASE_PATH" ]; then 120 | HOSTING_BASE_PATH="$TARGET" 121 | fi 122 | 123 | # Define target lowercase for redirect script 124 | TARGET_LOWERCASED=$(echo "$TARGET" | tr '[:upper:]' '[:lower:]') 125 | 126 | # Prepare the package for DocC 127 | swift package resolve; 128 | 129 | # A function that builds $TARGET documentation for a specific platform 130 | build_platform() { 131 | 132 | # Define a local $PLATFORM variable 133 | local PLATFORM=$1 134 | 135 | # Define the build folder name, based on the $PLATFORM 136 | case $PLATFORM in 137 | "iOS") 138 | DEBUG_PATH="Debug-iphoneos" 139 | ;; 140 | "macOS") 141 | DEBUG_PATH="Debug" 142 | ;; 143 | "tvOS") 144 | DEBUG_PATH="Debug-appletvos" 145 | ;; 146 | "watchOS") 147 | DEBUG_PATH="Debug-watchos" 148 | ;; 149 | "xrOS") 150 | DEBUG_PATH="Debug-xros" 151 | ;; 152 | *) 153 | echo "Error: Unsupported platform '$PLATFORM'" 154 | return 1 155 | ;; 156 | esac 157 | 158 | # Build $TARGET docs for the $PLATFORM 159 | echo "Building $TARGET docs for $PLATFORM..." 160 | if ! xcodebuild docbuild -scheme $TARGET -derivedDataPath .build/docbuild -destination "generic/platform=$PLATFORM"; then 161 | echo "Failed to build documentation for $PLATFORM" 162 | return 1 163 | fi 164 | 165 | # Transform docs for static hosting with configurable base path 166 | local DOCC_COMMAND="$(xcrun --find docc) process-archive transform-for-static-hosting .build/docbuild/Build/Products/$DEBUG_PATH/$TARGET.doccarchive --output-path .build/docs-$PLATFORM" 167 | 168 | # Add hosting-base-path only if it's not empty 169 | if [ -n "$HOSTING_BASE_PATH" ]; then 170 | DOCC_COMMAND="$DOCC_COMMAND --hosting-base-path \"$HOSTING_BASE_PATH\"" 171 | echo "Using hosting base path: '$HOSTING_BASE_PATH'" 172 | else 173 | echo "Using empty hosting base path (root level)" 174 | fi 175 | 176 | if ! eval "$DOCC_COMMAND"; then 177 | echo "Failed to transform documentation for $PLATFORM" 178 | return 1 179 | fi 180 | 181 | # Inject a root redirect script on the root page 182 | echo "" > .build/docs-$PLATFORM/index.html; 183 | 184 | # Complete successfully 185 | echo "Successfully built $TARGET docs for $PLATFORM" 186 | } 187 | 188 | # Start script 189 | echo 190 | echo "Building $TARGET docs for [$PLATFORMS]..." 191 | if [ -n "$HOSTING_BASE_PATH" ]; then 192 | echo "Hosting base path: '$HOSTING_BASE_PATH'" 193 | else 194 | echo "Hosting base path: (empty - root level)" 195 | fi 196 | 197 | # Loop through all platforms and call the build function 198 | for PLATFORM in $PLATFORMS; do 199 | if ! build_platform "$PLATFORM"; then 200 | exit 1 201 | fi 202 | done 203 | 204 | # Complete successfully 205 | echo 206 | echo "Building $TARGET docs completed successfully!" 207 | echo 208 | -------------------------------------------------------------------------------- /scripts/framework.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Function to display usage information 7 | show_usage() { 8 | echo 9 | echo "This script builds an XCFramework for a and certain ." 10 | 11 | echo 12 | echo "Usage: $0 [TARGET] [-p|--platforms ...]" 13 | echo " [TARGET] Optional. The target to build framework for (defaults to package name)" 14 | echo " -p, --platforms Optional. List of platforms (default: iOS macOS tvOS watchOS xrOS)" 15 | 16 | echo 17 | echo "Important: This script doesn't work on packages, only on .xcproj projects that generate a framework." 18 | 19 | echo 20 | echo "Examples:" 21 | echo " $0" 22 | echo " $0 MyTarget" 23 | echo " $0 -p iOS macOS" 24 | echo " $0 MyTarget -p iOS macOS" 25 | echo " $0 MyTarget --platforms iOS macOS tvOS watchOS xrOS" 26 | echo 27 | } 28 | 29 | # Function to display error message, show usage, and exit 30 | show_error_and_exit() { 31 | echo 32 | local error_message="$1" 33 | echo "Error: $error_message" 34 | show_usage 35 | exit 1 36 | } 37 | 38 | # Define argument variables 39 | TARGET="" 40 | PLATFORMS="iOS macOS tvOS watchOS xrOS" # Default platforms 41 | 42 | # Parse command line arguments 43 | while [[ $# -gt 0 ]]; do 44 | case $1 in 45 | -p|--platforms) 46 | shift # Remove --platforms from arguments 47 | PLATFORMS="" # Clear default platforms 48 | 49 | # Collect all platform arguments until we hit another flag or run out of args 50 | while [[ $# -gt 0 && ! "$1" =~ ^- ]]; do 51 | PLATFORMS="$PLATFORMS $1" 52 | shift 53 | done 54 | 55 | # Remove leading space and check if we got any platforms 56 | PLATFORMS=$(echo "$PLATFORMS" | sed 's/^ *//') 57 | if [ -z "$PLATFORMS" ]; then 58 | show_error_and_exit "--platforms requires at least one platform" 59 | fi 60 | ;; 61 | -h|--help) 62 | show_usage; exit 0 ;; 63 | -*) 64 | show_error_and_exit "Unknown option $1" ;; 65 | *) 66 | if [ -z "$TARGET" ]; then 67 | TARGET="$1" 68 | else 69 | show_error_and_exit "Unexpected argument '$1'" 70 | fi 71 | shift 72 | ;; 73 | esac 74 | done 75 | 76 | # If no TARGET was provided, try to get package name 77 | if [ -z "$TARGET" ]; then 78 | # Use the script folder to refer to other scripts 79 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 80 | SCRIPT_PACKAGE_NAME="$FOLDER/package_name.sh" 81 | 82 | # Check if package_name.sh exists 83 | if [ -f "$SCRIPT_PACKAGE_NAME" ]; then 84 | echo "No target provided, attempting to get package name..." 85 | if TARGET=$("$SCRIPT_PACKAGE_NAME"); then 86 | echo "Using package name: $TARGET" 87 | else 88 | echo "" 89 | read -p "Failed to get package name. Please enter the target to build framework for: " TARGET 90 | if [ -z "$TARGET" ]; then 91 | show_error_and_exit "TARGET is required" 92 | fi 93 | fi 94 | else 95 | echo "" 96 | read -p "Please enter the target to build framework for: " TARGET 97 | if [ -z "$TARGET" ]; then 98 | show_error_and_exit "TARGET is required" 99 | fi 100 | fi 101 | fi 102 | 103 | # Define local variables 104 | BUILD_FOLDER=.build 105 | BUILD_FOLDER_ARCHIVES=.build/framework_archives 106 | BUILD_FILE=$BUILD_FOLDER/$TARGET.xcframework 107 | BUILD_ZIP=$BUILD_FOLDER/$TARGET.zip 108 | 109 | # Start script 110 | echo 111 | echo "Building $TARGET XCFramework for [$PLATFORMS]..." 112 | 113 | # Delete old builds 114 | echo "Cleaning old builds..." 115 | rm -rf $BUILD_ZIP 116 | rm -rf $BUILD_FILE 117 | rm -rf $BUILD_FOLDER_ARCHIVES 118 | 119 | # Generate XCArchive files for all platforms 120 | echo "Generating XCArchives..." 121 | 122 | # Initialize the xcframework command 123 | XCFRAMEWORK_CMD="xcodebuild -create-xcframework" 124 | 125 | # Build iOS archives and append to the xcframework command 126 | if [[ " ${PLATFORMS} " =~ " iOS " ]]; then 127 | echo "Building iOS archives..." 128 | if ! xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=iOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-iOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES; then 129 | echo "Failed to build iOS archive" 130 | exit 1 131 | fi 132 | if ! 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; then 133 | echo "Failed to build iOS Simulator archive" 134 | exit 1 135 | fi 136 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-iOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 137 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-iOS-Sim.xcarchive/Products/Library/Frameworks/$TARGET.framework" 138 | fi 139 | 140 | # Build macOS archive and append to the xcframework command 141 | if [[ " ${PLATFORMS} " =~ " macOS " ]]; then 142 | echo "Building macOS archive..." 143 | if ! xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=macOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-macOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES; then 144 | echo "Failed to build macOS archive" 145 | exit 1 146 | fi 147 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-macOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 148 | fi 149 | 150 | # Build tvOS archives and append to the xcframework command 151 | if [[ " ${PLATFORMS} " =~ " tvOS " ]]; then 152 | echo "Building tvOS archives..." 153 | if ! xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=tvOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-tvOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES; then 154 | echo "Failed to build tvOS archive" 155 | exit 1 156 | fi 157 | if ! 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; then 158 | echo "Failed to build tvOS Simulator archive" 159 | exit 1 160 | fi 161 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-tvOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 162 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-tvOS-Sim.xcarchive/Products/Library/Frameworks/$TARGET.framework" 163 | fi 164 | 165 | # Build watchOS archives and append to the xcframework command 166 | if [[ " ${PLATFORMS} " =~ " watchOS " ]]; then 167 | echo "Building watchOS archives..." 168 | if ! xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=watchOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-watchOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES; then 169 | echo "Failed to build watchOS archive" 170 | exit 1 171 | fi 172 | if ! 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; then 173 | echo "Failed to build watchOS Simulator archive" 174 | exit 1 175 | fi 176 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-watchOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 177 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-watchOS-Sim.xcarchive/Products/Library/Frameworks/$TARGET.framework" 178 | fi 179 | 180 | # Build xrOS archives and append to the xcframework command 181 | if [[ " ${PLATFORMS} " =~ " xrOS " ]]; then 182 | echo "Building xrOS archives..." 183 | if ! xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=xrOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-xrOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES; then 184 | echo "Failed to build xrOS archive" 185 | exit 1 186 | fi 187 | if ! 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; then 188 | echo "Failed to build xrOS Simulator archive" 189 | exit 1 190 | fi 191 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-xrOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 192 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-xrOS-Sim.xcarchive/Products/Library/Frameworks/$TARGET.framework" 193 | fi 194 | 195 | # Generate XCFramework 196 | echo "Generating XCFramework..." 197 | XCFRAMEWORK_CMD+=" -output $BUILD_FILE" 198 | if ! eval "$XCFRAMEWORK_CMD"; then 199 | echo "Failed to generate XCFramework" 200 | exit 1 201 | fi 202 | 203 | # Generate XCFramework zip 204 | echo "Generating XCFramework zip..." 205 | if ! zip -r $BUILD_ZIP $BUILD_FILE; then 206 | echo "Failed to generate XCFramework zip" 207 | exit 1 208 | fi 209 | 210 | echo 211 | echo "***** CHECKSUM *****" 212 | swift package compute-checksum $BUILD_ZIP 213 | echo "********************" 214 | 215 | # Complete successfully 216 | echo 217 | echo "$TARGET XCFramework created successfully!" 218 | echo -------------------------------------------------------------------------------- /scripts/git_default_branch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Function to display usage information 7 | show_usage() { 8 | echo 9 | echo "This script outputs the default git branch name." 10 | 11 | echo 12 | echo "Usage: $0 [OPTIONS]" 13 | echo " -h, --help Show this help message" 14 | 15 | echo 16 | echo "Examples:" 17 | echo " $0" 18 | echo " bash scripts/git_default_branch.sh" 19 | echo 20 | } 21 | 22 | # Function to display error message, show usage, and exit 23 | show_error_and_exit() { 24 | echo 25 | local error_message="$1" 26 | echo "Error: $error_message" 27 | show_usage 28 | exit 1 29 | } 30 | 31 | # Parse command line arguments 32 | while [[ $# -gt 0 ]]; do 33 | case $1 in 34 | -h|--help) 35 | show_usage; exit 0 ;; 36 | -*) 37 | show_error_and_exit "Unknown option $1" ;; 38 | *) 39 | show_error_and_exit "Unexpected argument '$1'" ;; 40 | esac 41 | shift 42 | done 43 | 44 | # Get the default git branch name 45 | if ! BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'); then 46 | echo "Failed to get default git branch" 47 | exit 1 48 | fi 49 | 50 | # Output the branch name 51 | echo $BRANCH -------------------------------------------------------------------------------- /scripts/package_name.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Function to display usage information 7 | show_usage() { 8 | echo 9 | echo "This script finds the main target name in Package.swift." 10 | 11 | echo 12 | echo "Usage: $0 [OPTIONS]" 13 | echo " -h, --help Show this help message" 14 | 15 | echo 16 | echo "Examples:" 17 | echo " $0" 18 | echo " bash scripts/package_name.sh" 19 | echo 20 | } 21 | 22 | # Function to display error message, show usage, and exit 23 | show_error_and_exit() { 24 | echo 25 | local error_message="$1" 26 | echo "Error: $error_message" 27 | show_usage 28 | exit 1 29 | } 30 | 31 | # Parse command line arguments 32 | while [[ $# -gt 0 ]]; do 33 | case $1 in 34 | -h|--help) 35 | show_usage; exit 0 ;; 36 | -*) 37 | show_error_and_exit "Unknown option $1" ;; 38 | *) 39 | show_error_and_exit "Unexpected argument '$1'" ;; 40 | esac 41 | shift 42 | done 43 | 44 | # Check that a Package.swift file exists 45 | if [ ! -f "Package.swift" ]; then 46 | show_error_and_exit "Package.swift not found in current directory" 47 | fi 48 | 49 | # Using grep and sed to extract the package name 50 | # 1. grep finds the line containing "name:" 51 | # 2. sed extracts the text between quotes 52 | if ! package_name=$(grep -m 1 'name:.*"' Package.swift | sed -n 's/.*name:[[:space:]]*"\([^"]*\)".*/\1/p'); then 53 | show_error_and_exit "Could not find package name in Package.swift" 54 | fi 55 | 56 | if [ -z "$package_name" ]; then 57 | show_error_and_exit "Could not find package name in Package.swift" 58 | fi 59 | 60 | # Output the package name 61 | echo "$package_name" -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Function to display usage information 7 | show_usage() { 8 | echo 9 | echo "This script creates a new release for the provided and ." 10 | 11 | echo 12 | echo "Usage: $0 [TARGET] [BRANCH] [-p|--platforms ...]" 13 | echo " [TARGET] Optional. The target to release (defaults to package name)" 14 | echo " [BRANCH] Optional. The branch to validate (auto-detects main/master if not specified)" 15 | echo " -p, --platforms Optional. List of platforms (default: iOS macOS tvOS watchOS xrOS)" 16 | 17 | echo 18 | echo "This script will:" 19 | echo " * Call validate_release.sh to run tests, swiftlint, git validation, etc." 20 | echo " * Call version_bump.sh if all validation steps above passed" 21 | 22 | echo 23 | echo "Examples:" 24 | echo " $0" 25 | echo " $0 MyTarget" 26 | echo " $0 MyTarget master" 27 | echo " $0 -p iOS macOS" 28 | echo " $0 MyTarget master -p iOS macOS" 29 | echo " $0 MyTarget master --platforms iOS macOS tvOS watchOS xrOS" 30 | echo 31 | } 32 | 33 | # Function to display error message, show usage, and exit 34 | show_usage_error_and_exit() { 35 | echo 36 | local error_message="$1" 37 | echo "Error: $error_message" 38 | show_usage 39 | exit 1 40 | } 41 | 42 | # Function to display error message, and exit 43 | show_error_and_exit() { 44 | echo 45 | local error_message="$1" 46 | echo "Error: $error_message" 47 | echo 48 | exit 1 49 | } 50 | 51 | # Define argument variables 52 | TARGET="" 53 | BRANCH="" 54 | PLATFORMS="iOS macOS tvOS watchOS xrOS" # Default platforms 55 | 56 | # Parse command line arguments 57 | while [[ $# -gt 0 ]]; do 58 | case $1 in 59 | -p|--platforms) 60 | shift # Remove --platforms from arguments 61 | PLATFORMS="" # Clear default platforms 62 | 63 | # Collect all platform arguments until we hit another flag or run out of args 64 | while [[ $# -gt 0 && ! "$1" =~ ^- ]]; do 65 | PLATFORMS="$PLATFORMS $1" 66 | shift 67 | done 68 | 69 | # Remove leading space and check if we got any platforms 70 | PLATFORMS=$(echo "$PLATFORMS" | sed 's/^ *//') 71 | if [ -z "$PLATFORMS" ]; then 72 | show_usage_error_and_exit "--platforms requires at least one platform" 73 | fi 74 | ;; 75 | -h|--help) 76 | show_usage; exit 0 ;; 77 | -*) 78 | show_usage_error_and_exit "Unknown option $1" ;; 79 | *) 80 | if [ -z "$TARGET" ]; then 81 | TARGET="$1" 82 | elif [ -z "$BRANCH" ]; then 83 | BRANCH="$1" 84 | else 85 | show_usage_error_and_exit "Unexpected argument '$1'" 86 | fi 87 | shift 88 | ;; 89 | esac 90 | done 91 | 92 | # If no TARGET was provided, try to get package name 93 | if [ -z "$TARGET" ]; then 94 | # Use the script folder to refer to other scripts 95 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 96 | SCRIPT_PACKAGE_NAME="$FOLDER/package_name.sh" 97 | 98 | # Check if package_name.sh exists 99 | if [ -f "$SCRIPT_PACKAGE_NAME" ]; then 100 | echo "No target provided, attempting to get package name..." 101 | if TARGET=$("$SCRIPT_PACKAGE_NAME"); then 102 | echo "Using package name: $TARGET" 103 | else 104 | echo "" 105 | read -p "Failed to get package name. Please enter the target to release: " TARGET 106 | if [ -z "$TARGET" ]; then 107 | show_usage_error_and_exit "TARGET is required" 108 | fi 109 | fi 110 | else 111 | echo "" 112 | read -p "Please enter the target to release: " TARGET 113 | if [ -z "$TARGET" ]; then 114 | show_usage_error_and_exit "TARGET is required" 115 | fi 116 | fi 117 | fi 118 | 119 | # Set default branch if none provided 120 | if [ -z "$BRANCH" ]; then 121 | # Check if main or master branch exists and set default accordingly 122 | if git show-ref --verify --quiet refs/heads/main; then 123 | BRANCH="main" 124 | elif git show-ref --verify --quiet refs/heads/master; then 125 | BRANCH="master" 126 | else 127 | BRANCH="main" # Default to main if neither exists 128 | fi 129 | fi 130 | 131 | # Use the script folder to refer to other scripts 132 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 133 | SCRIPT_VALIDATE="$FOLDER/validate_release.sh" 134 | SCRIPT_VERSION_BUMP="$FOLDER/version_bump.sh" 135 | 136 | # A function that runs a certain script and checks for errors 137 | run_script() { 138 | local script="$1" 139 | shift # Remove the first argument (script path) from the argument list 140 | 141 | if [ ! -f "$script" ]; then 142 | show_error_and_exit "Script not found: $script" 143 | fi 144 | 145 | chmod +x "$script" 146 | if ! "$script" "$@"; then 147 | exit 1 148 | fi 149 | } 150 | 151 | # Start script 152 | echo 153 | echo "Creating a new release for $TARGET on the $BRANCH branch with platforms [$PLATFORMS]..." 154 | 155 | # Validate git and project 156 | echo "Validating project..." 157 | run_script "$SCRIPT_VALIDATE" "$TARGET" -p $PLATFORMS 158 | 159 | # Bump version 160 | echo "Bumping version..." 161 | run_script "$SCRIPT_VERSION_BUMP" 162 | 163 | # Complete successfully 164 | echo 165 | echo "Release created successfully!" 166 | echo 167 | -------------------------------------------------------------------------------- /scripts/sync_from.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Function to display usage information 7 | show_usage() { 8 | echo 9 | echo "This script syncs Swift Package Scripts from a ." 10 | 11 | echo 12 | echo "Usage: $0 " 13 | echo " Required. The full path to a Swift Package Scripts root" 14 | 15 | echo 16 | echo "This script will overwrite the existing 'scripts' folder." 17 | echo "Only pass in the full path to a Swift Package Scripts root." 18 | 19 | echo 20 | echo "Examples:" 21 | echo " $0 ../SwiftPackageScripts" 22 | echo " $0 /path/to/SwiftPackageScripts" 23 | echo 24 | } 25 | 26 | # Function to display error message, show usage, and exit 27 | show_error_and_exit() { 28 | echo 29 | local error_message="$1" 30 | echo "Error: $error_message" 31 | show_usage 32 | exit 1 33 | } 34 | 35 | # Define argument variables 36 | SOURCE="" 37 | 38 | # Parse command line arguments 39 | while [[ $# -gt 0 ]]; do 40 | case $1 in 41 | -h|--help) 42 | show_usage; exit 0 ;; 43 | -*) 44 | show_error_and_exit "Unknown option $1" ;; 45 | *) 46 | if [ -z "$SOURCE" ]; then 47 | SOURCE="$1" 48 | else 49 | show_error_and_exit "Unexpected argument '$1'" 50 | fi 51 | shift 52 | ;; 53 | esac 54 | done 55 | 56 | # Verify SOURCE was provided 57 | if [ -z "$SOURCE" ]; then 58 | echo "" 59 | read -p "Please enter the source folder path: " SOURCE 60 | if [ -z "$SOURCE" ]; then 61 | show_error_and_exit "SOURCE_FOLDER is required" 62 | fi 63 | fi 64 | 65 | # Define variables 66 | FOLDER="scripts/" 67 | SOURCE_FOLDER="$SOURCE/$FOLDER" 68 | 69 | # Verify source folder exists 70 | if [ ! -d "$SOURCE_FOLDER" ]; then 71 | show_error_and_exit "Source folder '$SOURCE_FOLDER' does not exist" 72 | fi 73 | 74 | # Start script 75 | echo 76 | echo "Syncing scripts from $SOURCE_FOLDER..." 77 | 78 | # Remove existing folder 79 | echo "Removing existing scripts folder..." 80 | rm -rf $FOLDER 81 | 82 | # Copy folder 83 | echo "Copying scripts from source..." 84 | if ! cp -r "$SOURCE_FOLDER/" "$FOLDER/"; then 85 | echo "Failed to copy scripts from $SOURCE_FOLDER" 86 | exit 1 87 | fi 88 | 89 | # Complete successfully 90 | echo 91 | echo "Script syncing from $SOURCE_FOLDER completed successfully!" 92 | echo -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Function to display usage information 7 | show_usage() { 8 | echo 9 | echo "This script tests a for all provided ." 10 | 11 | echo 12 | echo "Usage: $0 [TARGET] [-p|--platforms ...]" 13 | echo " [TARGET] Optional. The target to test (defaults to package name)" 14 | echo " -p, --platforms Optional. List of platforms (default: iOS macOS tvOS watchOS xrOS)" 15 | 16 | echo 17 | echo "Examples:" 18 | echo " $0" 19 | echo " $0 MyTarget" 20 | echo " $0 -p iOS macOS" 21 | echo " $0 MyTarget -p iOS macOS" 22 | echo " $0 MyTarget --platforms iOS macOS tvOS watchOS xrOS" 23 | echo 24 | } 25 | 26 | # Function to display error message, show usage, and exit 27 | show_error_and_exit() { 28 | echo 29 | local error_message="$1" 30 | echo "Error: $error_message" 31 | show_usage 32 | exit 1 33 | } 34 | 35 | # Define argument variables 36 | TARGET="" 37 | PLATFORMS="iOS macOS tvOS watchOS xrOS" # Default platforms 38 | 39 | # Parse command line arguments 40 | while [[ $# -gt 0 ]]; do 41 | case $1 in 42 | -p|--platforms) 43 | shift # Remove --platforms from arguments 44 | PLATFORMS="" # Clear default platforms 45 | 46 | # Collect all platform arguments until we hit another flag or run out of args 47 | while [[ $# -gt 0 && ! "$1" =~ ^- ]]; do 48 | PLATFORMS="$PLATFORMS $1" 49 | shift 50 | done 51 | 52 | # Remove leading space and check if we got any platforms 53 | PLATFORMS=$(echo "$PLATFORMS" | sed 's/^ *//') 54 | if [ -z "$PLATFORMS" ]; then 55 | show_error_and_exit "--platforms requires at least one platform" 56 | fi 57 | ;; 58 | -h|--help) 59 | show_usage; exit 0 ;; 60 | -*) 61 | show_error_and_exit "Unknown option $1" ;; 62 | *) 63 | if [ -z "$TARGET" ]; then 64 | TARGET="$1" 65 | else 66 | show_error_and_exit "Unexpected argument '$1'" 67 | fi 68 | shift 69 | ;; 70 | esac 71 | done 72 | 73 | # If no TARGET was provided, try to get package name 74 | if [ -z "$TARGET" ]; then 75 | # Use the script folder to refer to other scripts 76 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 77 | SCRIPT_PACKAGE_NAME="$FOLDER/package_name.sh" 78 | 79 | # Check if package_name.sh exists 80 | if [ -f "$SCRIPT_PACKAGE_NAME" ]; then 81 | echo "No target provided, attempting to get package name..." 82 | if TARGET=$("$SCRIPT_PACKAGE_NAME"); then 83 | echo "Using package name: $TARGET" 84 | else 85 | echo "" 86 | read -p "Failed to get package name. Please enter the target to test: " TARGET 87 | if [ -z "$TARGET" ]; then 88 | show_error_and_exit "TARGET is required" 89 | fi 90 | fi 91 | else 92 | echo "" 93 | read -p "Please enter the target to test: " TARGET 94 | if [ -z "$TARGET" ]; then 95 | show_error_and_exit "TARGET is required" 96 | fi 97 | fi 98 | fi 99 | 100 | # A function that gets the latest simulator for a certain OS 101 | get_latest_simulator() { 102 | local PLATFORM=$1 103 | local SIMULATOR_TYPE 104 | 105 | case $PLATFORM in 106 | "iOS") 107 | SIMULATOR_TYPE="iPhone" 108 | ;; 109 | "tvOS") 110 | SIMULATOR_TYPE="Apple TV" 111 | ;; 112 | "watchOS") 113 | SIMULATOR_TYPE="Apple Watch" 114 | ;; 115 | "xrOS") 116 | SIMULATOR_TYPE="Apple Vision" 117 | ;; 118 | *) 119 | echo "Error: Unsupported platform for simulator '$PLATFORM'" 120 | return 1 121 | ;; 122 | esac 123 | 124 | # Get the latest simulator for the platform 125 | xcrun simctl list devices available | grep "$SIMULATOR_TYPE" | tail -1 | sed -E 's/.*\(([A-F0-9-]+)\).*/\1/' 126 | } 127 | 128 | # A function that tests $TARGET for a specific platform 129 | test_platform() { 130 | 131 | # Define a local $PLATFORM variable 132 | local PLATFORM="${1//_/ }" 133 | 134 | # Define the destination, based on the $PLATFORM 135 | case $PLATFORM in 136 | "iOS"|"tvOS"|"watchOS"|"xrOS") 137 | local SIMULATOR_UDID=$(get_latest_simulator "$PLATFORM") 138 | if [ -z "$SIMULATOR_UDID" ]; then 139 | echo "Error: No simulator found for $PLATFORM" 140 | return 1 141 | fi 142 | DESTINATION="id=$SIMULATOR_UDID" 143 | ;; 144 | "macOS") 145 | DESTINATION="platform=macOS" 146 | ;; 147 | *) 148 | echo "Error: Unsupported platform '$PLATFORM'" 149 | return 1 150 | ;; 151 | esac 152 | 153 | # Test $TARGET for the $DESTINATION 154 | echo "Testing $TARGET for $PLATFORM..." 155 | if ! xcodebuild test -scheme $TARGET -derivedDataPath .build -destination "$DESTINATION" -enableCodeCoverage YES; then 156 | echo "Failed to test $TARGET for $PLATFORM" 157 | return 1 158 | fi 159 | 160 | # Complete successfully 161 | echo "Successfully tested $TARGET for $PLATFORM" 162 | } 163 | 164 | # Start script 165 | echo 166 | echo "Testing $TARGET for [$PLATFORMS]..." 167 | 168 | # Loop through all platforms and call the test function 169 | for PLATFORM in $PLATFORMS; do 170 | if ! test_platform "$PLATFORM"; then 171 | exit 1 172 | fi 173 | done 174 | 175 | # Complete successfully 176 | echo 177 | echo "Testing $TARGET completed successfully!" 178 | echo -------------------------------------------------------------------------------- /scripts/validate_git_branch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Function to display usage information 7 | show_usage() { 8 | echo 9 | echo "This script validates the Git repository for release." 10 | 11 | echo 12 | echo "Usage: $0 [BRANCH] [-b|--branch ]" 13 | echo " [BRANCH] Optional. The branch to validate (auto-detects main/master if not specified)" 14 | echo " -b, --branch Optional. The branch to validate" 15 | 16 | echo 17 | echo "This script will:" 18 | echo " * Validate that the script is run within a git repository" 19 | echo " * Validate that the git repository doesn't have any uncommitted changes" 20 | echo " * Validate that the current git branch matches the specified one" 21 | 22 | echo 23 | echo "Examples:" 24 | echo " $0" 25 | echo " $0 master" 26 | echo " $0 develop" 27 | echo " $0 -b main" 28 | echo " $0 --branch develop" 29 | echo 30 | } 31 | 32 | # Function to display error message, show usage, and exit 33 | show_usage_error_and_exit() { 34 | echo 35 | local error_message="$1" 36 | echo "Error: $error_message" 37 | show_usage 38 | exit 1 39 | } 40 | 41 | # Function to display error message, and exit 42 | show_error_and_exit() { 43 | echo 44 | local error_message="$1" 45 | echo "Error: $error_message" 46 | echo 47 | exit 1 48 | } 49 | 50 | # Define argument variables 51 | BRANCH="" # Will be set to default after parsing 52 | 53 | # Parse command line arguments 54 | while [[ $# -gt 0 ]]; do 55 | case $1 in 56 | -b|--branch) 57 | shift # Remove --branch from arguments 58 | if [[ $# -eq 0 || "$1" =~ ^- ]]; then 59 | show_usage_error_and_exit "--branch requires a branch name" 60 | fi 61 | BRANCH="$1" 62 | shift 63 | ;; 64 | -h|--help) 65 | show_usage; exit 0 ;; 66 | -*) 67 | show_usage_error_and_exit "Unknown option $1" ;; 68 | *) 69 | if [ -z "$BRANCH" ]; then 70 | BRANCH="$1" 71 | else 72 | show_usage_error_and_exit "Unexpected argument '$1'" 73 | fi 74 | shift 75 | ;; 76 | esac 77 | done 78 | 79 | # Set default branch if none provided 80 | if [ -z "$BRANCH" ]; then 81 | # Check if main or master branch exists and set default accordingly 82 | if git show-ref --verify --quiet refs/heads/main; then 83 | BRANCH="main" 84 | elif git show-ref --verify --quiet refs/heads/master; then 85 | BRANCH="master" 86 | else 87 | BRANCH="main" # Default to main if neither exists 88 | fi 89 | fi 90 | 91 | # Start script 92 | echo 93 | echo "Validating git repository for branch '$BRANCH'..." 94 | 95 | # Check if the current directory is a Git repository 96 | if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then 97 | show_error_and_exit "Not a Git repository" 98 | fi 99 | 100 | # Check for uncommitted changes 101 | if [ -n "$(git status --porcelain)" ]; then 102 | show_error_and_exit "Git repository is dirty. There are uncommitted changes" 103 | fi 104 | 105 | # Verify that we're on the correct branch 106 | if ! current_branch=$(git rev-parse --abbrev-ref HEAD); then 107 | show_error_and_exit "Failed to get current branch name" 108 | fi 109 | 110 | if [ "$current_branch" != "$BRANCH" ]; then 111 | show_error_and_exit "Not on the specified branch. Current branch is '$current_branch', expected '$BRANCH'" 112 | fi 113 | 114 | # Complete successfully 115 | echo 116 | echo "Git repository validated successfully!" 117 | echo 118 | -------------------------------------------------------------------------------- /scripts/validate_release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Function to display usage information 7 | show_usage() { 8 | echo 9 | echo "This script validates a for release by checking the git repo, then running lint and unit tests for all platforms." 10 | 11 | echo 12 | echo "Usage: $0 [TARGET] [-p|--platforms ...]" 13 | echo " [TARGET] Optional. The target to validate (defaults to package name)" 14 | echo " -p, --platforms Optional. List of platforms (default: iOS macOS tvOS watchOS xrOS)" 15 | 16 | echo 17 | echo "This script will:" 18 | echo " * Validate that swiftlint passes" 19 | echo " * Validate that all unit tests pass for all platforms" 20 | 21 | echo 22 | echo "Examples:" 23 | echo " $0" 24 | echo " $0 MyTarget" 25 | echo " $0 -p iOS macOS" 26 | echo " $0 MyTarget -p iOS macOS" 27 | echo " $0 MyTarget --platforms iOS macOS tvOS watchOS xrOS" 28 | echo 29 | } 30 | 31 | # Function to display error message, show usage, and exit 32 | show_usage_error_and_exit() { 33 | echo 34 | local error_message="$1" 35 | echo "Error: $error_message" 36 | show_usage 37 | exit 1 38 | } 39 | 40 | # Function to display error message, and exit 41 | show_error_and_exit() { 42 | echo 43 | local error_message="$1" 44 | echo "Error: $error_message" 45 | echo 46 | exit 1 47 | } 48 | 49 | # Define argument variables 50 | TARGET="" 51 | PLATFORMS="iOS macOS tvOS watchOS xrOS" # Default platforms 52 | 53 | # Parse command line arguments 54 | while [[ $# -gt 0 ]]; do 55 | case $1 in 56 | -p|--platforms) 57 | shift # Remove --platforms from arguments 58 | PLATFORMS="" # Clear default platforms 59 | 60 | # Collect all platform arguments until we hit another flag or run out of args 61 | while [[ $# -gt 0 && ! "$1" =~ ^- ]]; do 62 | PLATFORMS="$PLATFORMS $1" 63 | shift 64 | done 65 | 66 | # Remove leading space and check if we got any platforms 67 | PLATFORMS=$(echo "$PLATFORMS" | sed 's/^ *//') 68 | if [ -z "$PLATFORMS" ]; then 69 | show_usage_error_and_exit "--platforms requires at least one platform" 70 | fi 71 | ;; 72 | -h|--help) 73 | show_usage; exit 0 ;; 74 | -*) 75 | show_usage_error_and_exit "Unknown option $1" ;; 76 | *) 77 | if [ -z "$TARGET" ]; then 78 | TARGET="$1" 79 | else 80 | show_usage_error_and_exit "Unexpected argument '$1'" 81 | fi 82 | shift 83 | ;; 84 | esac 85 | done 86 | 87 | # If no TARGET was provided, try to get package name 88 | if [ -z "$TARGET" ]; then 89 | # Use the script folder to refer to other scripts 90 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 91 | SCRIPT_PACKAGE_NAME="$FOLDER/package_name.sh" 92 | 93 | # Check if package_name.sh exists 94 | if [ -f "$SCRIPT_PACKAGE_NAME" ]; then 95 | echo "No target provided, attempting to get package name..." 96 | if TARGET=$("$SCRIPT_PACKAGE_NAME"); then 97 | echo "Using package name: $TARGET" 98 | else 99 | echo "" 100 | read -p "Failed to get package name. Please enter the target to validate: " TARGET 101 | if [ -z "$TARGET" ]; then 102 | show_usage_error_and_exit "TARGET is required" 103 | fi 104 | fi 105 | else 106 | echo "" 107 | read -p "Please enter the target to validate: " TARGET 108 | if [ -z "$TARGET" ]; then 109 | show_usage_error_and_exit "TARGET is required" 110 | fi 111 | fi 112 | fi 113 | 114 | # Use the script folder to refer to other scripts 115 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 116 | SCRIPT_VALIDATE_GIT="$FOLDER/validate_git_branch.sh" 117 | SCRIPT_TEST="$FOLDER/test.sh" 118 | 119 | # A function that runs a certain script and checks for errors 120 | run_script() { 121 | local script="$1" 122 | shift # Remove the first argument (script path) from the argument list 123 | 124 | if [ ! -f "$script" ]; then 125 | show_error_and_exit "Script not found: $script" 126 | fi 127 | 128 | chmod +x "$script" 129 | if ! "$script" "$@"; then 130 | exit 1 131 | fi 132 | } 133 | 134 | # Start script 135 | echo 136 | echo "Validating project for target '$TARGET' with platforms [$PLATFORMS]..." 137 | 138 | # Run SwiftLint 139 | echo "Running SwiftLint..." 140 | if ! swiftlint --strict; then 141 | show_error_and_exit "SwiftLint failed" 142 | fi 143 | echo "SwiftLint passed" 144 | 145 | # Validate git 146 | echo "Validating git..." 147 | run_script "$SCRIPT_VALIDATE_GIT" 148 | 149 | # Run unit tests 150 | echo "Running unit tests..." 151 | run_script "$SCRIPT_TEST" "$TARGET" -p $PLATFORMS 152 | 153 | # Complete successfully 154 | echo 155 | echo "Project successfully validated!" 156 | echo 157 | -------------------------------------------------------------------------------- /scripts/version_bump.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Function to display usage information 7 | show_usage() { 8 | echo 9 | echo "This script bumps the project version number." 10 | 11 | echo 12 | echo "Usage: $0 [OPTIONS]" 13 | echo " --no-semver Disable semantic version validation" 14 | echo " -h, --help Show this help message" 15 | 16 | echo 17 | echo "Examples:" 18 | echo " $0" 19 | echo " $0 --no-semver" 20 | echo " bash scripts/version_bump.sh" 21 | echo 22 | } 23 | 24 | # Function to display error message, show usage, and exit 25 | show_error_and_exit() { 26 | echo 27 | local error_message="$1" 28 | echo "Error: $error_message" 29 | show_usage 30 | exit 1 31 | } 32 | 33 | # Define argument variables 34 | VALIDATE_SEMVER=true 35 | 36 | # Parse command line arguments 37 | while [[ $# -gt 0 ]]; do 38 | case $1 in 39 | --no-semver) 40 | VALIDATE_SEMVER=false 41 | shift 42 | ;; 43 | -h|--help) 44 | show_usage; exit 0 ;; 45 | -*) 46 | show_error_and_exit "Unknown option $1" ;; 47 | *) 48 | show_error_and_exit "Unexpected argument '$1'" ;; 49 | esac 50 | done 51 | 52 | # Use the script folder to refer to other scripts 53 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 54 | SCRIPT_VERSION_NUMBER="$FOLDER/version_number.sh" 55 | 56 | # Check if version_number.sh exists 57 | if [ ! -f "$SCRIPT_VERSION_NUMBER" ]; then 58 | show_error_and_exit "version_number.sh script not found at $SCRIPT_VERSION_NUMBER" 59 | fi 60 | 61 | # Function to validate semver format, including optional -rc. suffix 62 | validate_semver() { 63 | if [ "$VALIDATE_SEMVER" = false ]; then 64 | return 0 65 | fi 66 | 67 | if [[ $1 =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then 68 | return 0 69 | else 70 | return 1 71 | fi 72 | } 73 | 74 | # Start script 75 | echo 76 | echo "Bumping version number..." 77 | 78 | # Get the latest version 79 | echo "Getting current version..." 80 | if ! VERSION=$($SCRIPT_VERSION_NUMBER); then 81 | echo "Failed to get the latest version" 82 | exit 1 83 | fi 84 | 85 | # Print the current version 86 | echo "The current version is: $VERSION" 87 | 88 | # Prompt user for new version 89 | while true; do 90 | echo "" 91 | read -p "Enter the new version number: " NEW_VERSION 92 | 93 | # Validate the version number to ensure that it's a semver version 94 | if validate_semver "$NEW_VERSION"; then 95 | break 96 | else 97 | if [ "$VALIDATE_SEMVER" = true ]; then 98 | echo "Invalid version format. Please use semver format (e.g., 1.2.3, v1.2.3, 1.2.3-rc.1, etc.)." 99 | echo "Use --no-semver to disable validation." 100 | else 101 | break 102 | fi 103 | fi 104 | done 105 | 106 | # Push the current branch and create tag 107 | echo "Pushing current branch..." 108 | if ! git push -u origin HEAD; then 109 | echo "Failed to push current branch" 110 | exit 1 111 | fi 112 | 113 | echo "Creating and pushing tag $NEW_VERSION..." 114 | if ! git tag $NEW_VERSION; then 115 | echo "Failed to create tag $NEW_VERSION" 116 | exit 1 117 | fi 118 | 119 | if ! git push --tags; then 120 | echo "Failed to push tags" 121 | exit 1 122 | fi 123 | 124 | # Complete successfully 125 | echo 126 | echo "Version tag $NEW_VERSION pushed successfully!" 127 | echo -------------------------------------------------------------------------------- /scripts/version_number.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Function to display usage information 7 | show_usage() { 8 | echo 9 | echo "This script returns the latest project version." 10 | 11 | echo 12 | echo "Usage: $0 [OPTIONS]" 13 | echo " -h, --help Show this help message" 14 | 15 | echo 16 | echo "Examples:" 17 | echo " $0" 18 | echo " bash scripts/version_number.sh" 19 | echo 20 | } 21 | 22 | # Function to display error message, show usage, and exit 23 | show_error_and_exit() { 24 | echo 25 | local error_message="$1" 26 | echo "Error: $error_message" 27 | show_usage 28 | exit 1 29 | } 30 | 31 | # Parse command line arguments 32 | while [[ $# -gt 0 ]]; do 33 | case $1 in 34 | -h|--help) 35 | show_usage; exit 0 ;; 36 | -*) 37 | show_error_and_exit "Unknown option $1" ;; 38 | *) 39 | show_error_and_exit "Unexpected argument '$1'" ;; 40 | esac 41 | shift 42 | done 43 | 44 | # Check if the current directory is a Git repository 45 | if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then 46 | show_error_and_exit "Not a Git repository" 47 | fi 48 | 49 | # Fetch all tags 50 | if ! git fetch --tags > /dev/null 2>&1; then 51 | show_error_and_exit "Failed to fetch tags from remote" 52 | fi 53 | 54 | # Get the latest semver tag 55 | if ! latest_version=$(git tag -l --sort=-v:refname | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1); then 56 | show_error_and_exit "Failed to retrieve version tags" 57 | fi 58 | 59 | # Check if we found a version tag 60 | if [ -z "$latest_version" ]; then 61 | show_error_and_exit "No semver tags found in this repository" 62 | fi 63 | 64 | # Print the latest version 65 | echo "$latest_version" --------------------------------------------------------------------------------