diff --git a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj index b15fb889..50dd980f 100644 --- a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj +++ b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj @@ -15,14 +15,17 @@ 23EA9CF6292FB70A00B8E418 /* SampleAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EA9CEB292FB70A00B8E418 /* SampleAPIError.swift */; }; 23EA9CF7292FB70A00B8E418 /* SampleUserAuthResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EA9CED292FB70A00B8E418 /* SampleUserAuthResponse.swift */; }; 23EA9CF8292FB70A00B8E418 /* SampleUsersResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EA9CEE292FB70A00B8E418 /* SampleUsersResponse.swift */; }; - 23EA9CF9292FB70A00B8E418 /* SampleUserResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EA9CEF292FB70A00B8E418 /* SampleUserResponse.swift */; }; + 23EA9CF9292FB70A00B8E418 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EA9CEF292FB70A00B8E418 /* User.swift */; }; 23EA9CFA292FB70A00B8E418 /* SampleUserAuthRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EA9CF1292FB70A00B8E418 /* SampleUserAuthRequest.swift */; }; 23EA9CFB292FB70A00B8E418 /* SampleUserRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EA9CF2292FB70A00B8E418 /* SampleUserRequest.swift */; }; 587CD0EF2B27713700E3CB71 /* TaskButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587CD0EE2B27713700E3CB71 /* TaskButton.swift */; }; + 587CD0EC2B271CF800E3CB71 /* SampleCreateUserResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587CD0EB2B271CF800E3CB71 /* SampleCreateUserResponse.swift */; }; 58C3E75E29B78EE6004FD1CD /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3E75C29B78ED3004FD1CD /* DownloadsView.swift */; }; 58C3E75F29B78EE8004FD1CD /* DownloadsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3E75D29B78ED3004FD1CD /* DownloadsViewModel.swift */; }; 58C3E76129B79259004FD1CD /* SampleDownloadRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3E76029B79259004FD1CD /* SampleDownloadRouter.swift */; }; 58C3E76529B7D709004FD1CD /* DownloadProgressViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3E76429B7D709004FD1CD /* DownloadProgressViewModel.swift */; }; + 58D6976F2B21FF8300E6C529 /* UsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D6976E2B21FF8300E6C529 /* UsersView.swift */; }; + 58D697712B21FF8E00E6C529 /* UsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D697702B21FF8E00E6C529 /* UsersViewModel.swift */; }; 58E4E0ED2982D884000ACBC0 /* SampleAuthorizationStorageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E4E0EC2982D884000ACBC0 /* SampleAuthorizationStorageManager.swift */; }; 58E4E0EF29843B42000ACBC0 /* NetworkingSampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E4E0EE29843B42000ACBC0 /* NetworkingSampleApp.swift */; }; 58E4E0F129850E86000ACBC0 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E4E0F029850E86000ACBC0 /* ContentView.swift */; }; @@ -57,14 +60,17 @@ 23EA9CEB292FB70A00B8E418 /* SampleAPIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SampleAPIError.swift; sourceTree = ""; }; 23EA9CED292FB70A00B8E418 /* SampleUserAuthResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SampleUserAuthResponse.swift; sourceTree = ""; }; 23EA9CEE292FB70A00B8E418 /* SampleUsersResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SampleUsersResponse.swift; sourceTree = ""; }; - 23EA9CEF292FB70A00B8E418 /* SampleUserResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SampleUserResponse.swift; sourceTree = ""; }; + 23EA9CEF292FB70A00B8E418 /* User.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; 23EA9CF1292FB70A00B8E418 /* SampleUserAuthRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SampleUserAuthRequest.swift; sourceTree = ""; }; 23EA9CF2292FB70A00B8E418 /* SampleUserRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SampleUserRequest.swift; sourceTree = ""; }; + 587CD0EB2B271CF800E3CB71 /* SampleCreateUserResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleCreateUserResponse.swift; sourceTree = ""; }; 587CD0EE2B27713700E3CB71 /* TaskButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskButton.swift; sourceTree = ""; }; 58C3E75C29B78ED3004FD1CD /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = ""; }; 58C3E75D29B78ED3004FD1CD /* DownloadsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsViewModel.swift; sourceTree = ""; }; 58C3E76029B79259004FD1CD /* SampleDownloadRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleDownloadRouter.swift; sourceTree = ""; }; 58C3E76429B7D709004FD1CD /* DownloadProgressViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadProgressViewModel.swift; sourceTree = ""; }; + 58D6976E2B21FF8300E6C529 /* UsersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersView.swift; sourceTree = ""; }; + 58D697702B21FF8E00E6C529 /* UsersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersViewModel.swift; sourceTree = ""; }; 58E4E0EC2982D884000ACBC0 /* SampleAuthorizationStorageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleAuthorizationStorageManager.swift; sourceTree = ""; }; 58E4E0EE29843B42000ACBC0 /* NetworkingSampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingSampleApp.swift; sourceTree = ""; }; 58E4E0F029850E86000ACBC0 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -145,6 +151,7 @@ 23A575ED25F8BF0E00617551 /* Scenes */ = { isa = PBXGroup; children = ( + 58D6976D2B21FF6A00E6C529 /* Users */, 58FB80C5298521DA0031FC59 /* Authorization */, 58C3E75B29B78ED3004FD1CD /* Download */, B52674BB2A370D0D006D3B9C /* Upload */, @@ -191,7 +198,7 @@ children = ( 23EA9CED292FB70A00B8E418 /* SampleUserAuthResponse.swift */, 23EA9CEE292FB70A00B8E418 /* SampleUsersResponse.swift */, - 23EA9CEF292FB70A00B8E418 /* SampleUserResponse.swift */, + 587CD0EB2B271CF800E3CB71 /* SampleCreateUserResponse.swift */, ); path = Responses; sourceTree = ""; @@ -225,6 +232,16 @@ path = Download; sourceTree = ""; }; + 58D6976D2B21FF6A00E6C529 /* Users */ = { + isa = PBXGroup; + children = ( + 23EA9CEF292FB70A00B8E418 /* User.swift */, + 58D697702B21FF8E00E6C529 /* UsersViewModel.swift */, + 58D6976E2B21FF8300E6C529 /* UsersView.swift */, + ); + path = Users; + sourceTree = ""; + }; 58FB80C5298521DA0031FC59 /* Authorization */ = { isa = PBXGroup; children = ( @@ -356,7 +373,7 @@ 58E4E0F129850E86000ACBC0 /* ContentView.swift in Sources */, B52674BD2A370D1D006D3B9C /* UploadService.swift in Sources */, 58C3E76529B7D709004FD1CD /* DownloadProgressViewModel.swift in Sources */, - 23EA9CF9292FB70A00B8E418 /* SampleUserResponse.swift in Sources */, + 23EA9CF9292FB70A00B8E418 /* User.swift in Sources */, DDD3AD1F2950E794006CB777 /* SampleAuthRouter.swift in Sources */, DD887780293E33850065ED03 /* SampleErrorProcessor.swift in Sources */, 23EA9CFB292FB70A00B8E418 /* SampleUserRequest.swift in Sources */, @@ -364,13 +381,16 @@ 58C3E76129B79259004FD1CD /* SampleDownloadRouter.swift in Sources */, 23EA9CF4292FB70A00B8E418 /* SampleUserRouter.swift in Sources */, B52674BF2A370D33006D3B9C /* UploadItem.swift in Sources */, + 58D6976F2B21FF8300E6C529 /* UsersView.swift in Sources */, 23EA9CF5292FB70A00B8E418 /* SampleAPIConstants.swift in Sources */, 58FB80C7298521FF0031FC59 /* AuthorizationView.swift in Sources */, DD410D6F293F2E6E006D8E31 /* AuthorizationViewModel.swift in Sources */, B52674BA2A370C15006D3B9C /* SampleUploadRouter.swift in Sources */, + 58D697712B21FF8E00E6C529 /* UsersViewModel.swift in Sources */, B52674C72A371046006D3B9C /* UploadItemView.swift in Sources */, 58C3E75F29B78EE8004FD1CD /* DownloadsViewModel.swift in Sources */, 58C3E75E29B78EE6004FD1CD /* DownloadsView.swift in Sources */, + 587CD0EC2B271CF800E3CB71 /* SampleCreateUserResponse.swift in Sources */, B52674C32A370E35006D3B9C /* UploadItemViewModel.swift in Sources */, B52674C12A370DFF006D3B9C /* UploadsViewModel.swift in Sources */, B52674C52A37102D006D3B9C /* UploadsView.swift in Sources */, diff --git a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/NetworkingSampleApp/NetworkingSampleApp/API/Responses/SampleCreateUserResponse.swift b/NetworkingSampleApp/NetworkingSampleApp/API/Responses/SampleCreateUserResponse.swift new file mode 100644 index 00000000..23b41f4a --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/API/Responses/SampleCreateUserResponse.swift @@ -0,0 +1,16 @@ +// +// SampleCreateUserResponse.swift +// NetworkingSampleApp +// +// Created by Matej Molnár on 11.12.2023. +// + +import Foundation + +/// Data structure of sample API create user response +struct SampleCreateUserResponse: Codable { + let id: String + let name: String + let job: String + let createdAt: Date +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/API/Responses/SampleUserResponse.swift b/NetworkingSampleApp/NetworkingSampleApp/API/Responses/SampleUserResponse.swift deleted file mode 100644 index 32aa9036..00000000 --- a/NetworkingSampleApp/NetworkingSampleApp/API/Responses/SampleUserResponse.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// SampleUserResponse.swift -// Networking sample app -// -// Created by Tomas Cejka on 07.04.2021. -// - -import Foundation - -/// Data structure of sample API user response -struct SampleUserResponse: Codable { - let id: Int - let email: String? -} diff --git a/NetworkingSampleApp/NetworkingSampleApp/API/Responses/SampleUsersResponse.swift b/NetworkingSampleApp/NetworkingSampleApp/API/Responses/SampleUsersResponse.swift index 0f01a5f5..b93bfaaa 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/API/Responses/SampleUsersResponse.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/API/Responses/SampleUsersResponse.swift @@ -7,8 +7,7 @@ import Foundation -/// Data structure of sample API user list response -struct SampleUsersResponse: Codable { - let page: Int - let data: [SampleUserResponse] +/// Data structure of sample API get user response +struct SampleUserResponse: Codable { + let data: User } diff --git a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUserRouter.swift b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUserRouter.swift index c3916f80..2af800db 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUserRouter.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUserRouter.swift @@ -13,7 +13,6 @@ enum SampleUserRouter: Requestable { case users(page: Int) case user(userId: Int) case createUser(user: SampleUserRequest) - case registerUser(user: SampleUserAuthRequest) var baseURL: URL { // swiftlint:disable:next force_unwrapping @@ -25,9 +24,7 @@ enum SampleUserRouter: Requestable { case .users, .createUser: "users" case let .user(userId): - "user/\(userId)" - case .registerUser: - "register" + "users/\(userId)" } } @@ -35,14 +32,14 @@ enum SampleUserRouter: Requestable { switch self { case let .users(page): ["page": page] - case .createUser, .registerUser, .user: + case .createUser, .user: nil } } var method: HTTPMethod { switch self { - case .createUser, .registerUser: + case .createUser: .post case .users, .user: .get @@ -53,8 +50,6 @@ enum SampleUserRouter: Requestable { switch self { case let .createUser(user): .encodable(user) - case let .registerUser(user): - .encodable(user) case .users, .user: nil } @@ -62,10 +57,8 @@ enum SampleUserRouter: Requestable { var isAuthenticationRequired: Bool { switch self { - case .registerUser: - false case .createUser, .users, .user: - true + false } } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift b/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift index 44bf5700..dbe824c5 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift @@ -8,6 +8,7 @@ import SwiftUI enum NetworkingFeature: String, Hashable, CaseIterable { + case users case authorization case downloads case uploads @@ -24,6 +25,8 @@ struct ContentView: View { .navigationTitle("Examples") .navigationDestination(for: NetworkingFeature.self) { feature in switch feature { + case .users: + UsersView() case .authorization: AuthorizationView() case .downloads: diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Users/User.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Users/User.swift new file mode 100644 index 00000000..f58b7bab --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Users/User.swift @@ -0,0 +1,25 @@ +// +// SampleUserResponse.swift +// Networking sample app +// +// Created by Tomas Cejka on 07.04.2021. +// + +import Foundation + +/// Data structure of sample API user response +struct User: Codable, Identifiable { + enum CodingKeys: String, CodingKey { + case id + case email + case firstName = "first_name" + case lastName = "last_name" + case avatarURL = "avatar" + } + + let id: Int + let email: String + let firstName: String + let lastName: String + let avatarURL: URL +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Users/UsersView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Users/UsersView.swift new file mode 100644 index 00000000..64d4da1f --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Users/UsersView.swift @@ -0,0 +1,120 @@ +// +// UsersView.swift +// NetworkingSampleApp +// +// Created by Matej Molnár on 07.12.2023. +// + +import SwiftUI + +struct UsersView: View { + @StateObject private var viewModel = UsersViewModel() + + @State private var fromUserID: Int = 1 + @State private var toUserID: Int = 3 + @State private var parallelise = false + @State private var userName: String = "" + @State private var userJob: String = "" + + var body: some View { + Form { + getUserView + + createUserView + } + .navigationTitle("Users") + } +} + +private extension UsersView { + var getUserView: some View { + Group { + Section { + HStack { + Text("From:") + + TextField("From user ID", value: $fromUserID, formatter: NumberFormatter()) + } + + HStack { + Text("To:") + + TextField("To user ID", value: $toUserID, formatter: NumberFormatter()) + } + + Toggle("Parallelise", isOn: $parallelise) + } header: { + Text("Get User by ID") + } footer: { + Button("Get Users") { + viewModel.getUsers( + in: fromUserID...toUserID, + parallelFetch: parallelise + ) + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + } + + if !viewModel.users.isEmpty { + Section("Users") { + ForEach(viewModel.users) { user in + userCell(user) + } + } + } + } + } + + var createUserView: some View { + Group { + Section { + TextField("Name", text: $userName) + TextField("Job", text: $userJob) + } header: { + Text("Create User with parameters") + } footer: { + Button("Create User") { + viewModel.createUser(name: userName, job: userJob) + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + } + + if let createdUser = viewModel.createdUser { + Section("Created User") { + Text("ID: \(createdUser.id)") + Text("Name: \(createdUser.name)") + Text("Job: \(createdUser.job)") + Text("Created at: \(createdUser.createdAt.formatted())") + } + } + } + } + + func userCell(_ user: User) -> some View { + HStack(alignment: .center) { + AsyncImage(url: user.avatarURL) { image in + image + .resizable() + } placeholder: { + Color.gray + } + .frame(width: 70, height: 70) + .clipShape(Circle()) + + VStack(alignment: .leading) { + Text(user.firstName + " " + user.lastName) + .font(.subheadline) + + Text(user.email) + .font(.footnote) + .foregroundStyle(.gray) + } + } + } +} + +#Preview { + UsersView() +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Users/UsersViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Users/UsersViewModel.swift new file mode 100644 index 00000000..4d3e020f --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Users/UsersViewModel.swift @@ -0,0 +1,82 @@ +// +// UsersViewModel.swift +// NetworkingSampleApp +// +// Created by Matej Molnár on 07.12.2023. +// + +import Foundation +import Networking + +@MainActor +final class UsersViewModel: ObservableObject { + @Published var users = [User]() + @Published var createdUser: SampleCreateUserResponse? + + /// Custom decoder needed for decoding `createdAt` parameter of SampleCreateUserResponse. + private let responseDecoder: JSONDecoder = { + let decoder = JSONDecoder() + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + decoder.dateDecodingStrategy = .formatted(dateFormatter) + return decoder + }() + + private lazy var apiManager: APIManager = { + var responseProcessors: [ResponseProcessing] = [ + LoggingInterceptor.shared, + StatusCodeProcessor.shared + ] + var errorProcessors: [ErrorProcessing] = [LoggingInterceptor.shared] + +#if DEBUG + responseProcessors.append(EndpointRequestStorageProcessor.shared) + errorProcessors.append(EndpointRequestStorageProcessor.shared) +#endif + + return APIManager( + requestAdapters: [LoggingInterceptor.shared], + responseProcessors: responseProcessors, + errorProcessors: errorProcessors + ) + }() +} + +extension UsersViewModel { + func getUsers(in range: ClosedRange, parallelFetch: Bool) { + Task { + users = [] + + if parallelFetch { + // Fire all user requests parallelly in a group, assign it to users array after all of them are completed. + users = try await withThrowingTaskGroup(of: User.self) { group in + for id in range { + group.addTask { + let response: SampleUserResponse = try await self.apiManager.request(SampleUserRouter.user(userId: id)) + return response.data + } + } + + return try await group.reduce(into: [User]()) { $0.append($1) } + } + } else { + // Fetch user add it to users array and wait for 0.5 seconds, before fetching the next one. + for id in range { + let response: SampleUserResponse = try await apiManager.request(SampleUserRouter.user(userId: id)) + users.append(response.data) + try await Task.sleep(for: .seconds(0.5)) + } + } + } + } + + func createUser(name: String, job: String) { + Task { + createdUser = try await self.apiManager.request( + SampleUserRouter.createUser(user: .init(name: name, job: job)), + decoder: responseDecoder, + retryConfiguration: .default + ) + } + } +}