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