,
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 |      3 |
  3 | 
  4 | 
  5 | 
  6 |      7 |
  7 |      8 |
  8 |      9 |
  9 |      10 |
 10 |      11 |
 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 | 
 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"
--------------------------------------------------------------------------------