Skip to content

Commit

Permalink
MBL-1016: Add custom operators for common API patterns (#1900)
Browse files Browse the repository at this point in the history
* Remove defunct // TODO comment

* MBL-1016: Add custom operator for mapping fetch results with Combine

* MBL-1016: Add custom operator for handling API failures with Combine
  • Loading branch information
amy-at-kickstarter committed Dec 11, 2023
1 parent bd02759 commit 91c1afe
Show file tree
Hide file tree
Showing 5 changed files with 63 additions and 35 deletions.
1 change: 0 additions & 1 deletion Kickstarter-iOS/SharedViews/LoadingBarButtonItem.swift
@@ -1,7 +1,6 @@
import Library
import SwiftUI

// TODO(MBL-1039) - Refactor this so that saveTriggered takes a closure, not a binding
struct LoadingBarButtonItem: View {
@Binding var saveEnabled: Bool
@Binding var showLoading: Bool
Expand Down
4 changes: 4 additions & 0 deletions Kickstarter.xcodeproj/project.pbxproj
Expand Up @@ -1498,6 +1498,7 @@
E1A149242ACE02B300F49709 /* FetchProjectsEnvelope+FetchBackerProjectsQueryDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A149232ACE02B300F49709 /* FetchProjectsEnvelope+FetchBackerProjectsQueryDataTests.swift */; };
E1A149272ACE063400F49709 /* FetchBackerProjectsQueryDataTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A149262ACE063400F49709 /* FetchBackerProjectsQueryDataTemplate.swift */; };
E1AA8ABF2AEABBB100AC98BF /* Signal+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EA34EE2AE1B28400942A04 /* Signal+Combine.swift */; };
E1BB25642B1E81AA000BD2D6 /* Publisher+Service.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BB25632B1E81AA000BD2D6 /* Publisher+Service.swift */; };
E1FDB1E82AEAAC6100285F93 /* CombineTestObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FDB1E72AEAAC6100285F93 /* CombineTestObserver.swift */; };
/* End PBXBuildFile section */

Expand Down Expand Up @@ -3072,6 +3073,7 @@
E1A149212ACE013100F49709 /* FetchProjectsEnvelope.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchProjectsEnvelope.swift; sourceTree = "<group>"; };
E1A149232ACE02B300F49709 /* FetchProjectsEnvelope+FetchBackerProjectsQueryDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FetchProjectsEnvelope+FetchBackerProjectsQueryDataTests.swift"; sourceTree = "<group>"; };
E1A149262ACE063400F49709 /* FetchBackerProjectsQueryDataTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchBackerProjectsQueryDataTemplate.swift; sourceTree = "<group>"; };
E1BB25632B1E81AA000BD2D6 /* Publisher+Service.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+Service.swift"; sourceTree = "<group>"; };
E1EA34EE2AE1B28400942A04 /* Signal+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Signal+Combine.swift"; sourceTree = "<group>"; };
E1FDB1E72AEAAC6100285F93 /* CombineTestObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineTestObserver.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
Expand Down Expand Up @@ -6524,6 +6526,7 @@
D01587761EEB2ED6006E7684 /* MockService.swift */,
D01588261EEB2ED7006E7684 /* ServerConfig.swift */,
D01588271EEB2ED7006E7684 /* Service.swift */,
E1BB25632B1E81AA000BD2D6 /* Publisher+Service.swift */,
8479CF2B2530A5D700FD13F1 /* Service+DecodeHelpers.swift */,
775DFAD4215EB2AB00620CED /* Service+RequestHelpers.swift */,
D01588281EEB2ED7006E7684 /* ServiceTests.swift */,
Expand Down Expand Up @@ -8631,6 +8634,7 @@
D67B6CD6221F468100B63A6B /* Location+Encode.swift in Sources */,
6093098D2A6054CB004297AF /* GraphAPI.TriggerThirdPartyEventInput+TriggerThirdPartyEventInput.swift in Sources */,
8AC3E105269F4D1C00168BF8 /* GraphAPI.ApplePay+ApplePayParams.swift in Sources */,
E1BB25642B1E81AA000BD2D6 /* Publisher+Service.swift in Sources */,
D01588331EEB2ED7006E7684 /* Decodable.swift in Sources */,
06232D3E2795EC2C00A81755 /* Element+Helpers.swift in Sources */,
8ACF36E22627481C0026E74D /* ApiDateProtocol.swift in Sources */,
Expand Down
48 changes: 48 additions & 0 deletions KsApi/Publisher+Service.swift
@@ -0,0 +1,48 @@
import Combine
import Foundation

extension Publisher {
/// A convenience method for mapping the results of your fetch to another data type. Any unknown errors are returned in the error as `ErrorEnvelope.couldNotParseJSON`.
func mapFetchResults<NewOutputType>(_ convertData: @escaping ((Output) -> NewOutputType?))
-> AnyPublisher<NewOutputType, ErrorEnvelope> {
return self.tryMap { (data: Output) -> NewOutputType in
guard let envelope = convertData(data) else {
throw ErrorEnvelope.couldNotParseJSON
}

return envelope
}
.mapError { rawError in

if let error = rawError as? ErrorEnvelope {
return error
}

return ErrorEnvelope.couldNotParseJSON
}
.eraseToAnyPublisher()
}

/// A convenience method for gracefully catching API failures.
/// If you handle your API failure in receiveCompletion:, that will actually cancel the entire pipeline, which means the failed request can't be retried.
/// This is a wrapper around the .catch operator, which just makes it a bit easier to read.
///
/// An example:
/// ```
/// self.somethingHappened
/// .flatMap() { _ in
/// self.doAnAPIRequest
/// .handleFailureAndAllowRetry() { e in
/// showTheError(e)
/// }
/// }

public func handleFailureAndAllowRetry(_ onFailure: @escaping (Self.Failure) -> Void)
-> AnyPublisher<Self.Output, Never> {
return self.catch { e in
onFailure(e)
return Empty<Self.Output, Never>()
}
.eraseToAnyPublisher()
}
}
19 changes: 2 additions & 17 deletions KsApi/Service.swift
Expand Up @@ -367,24 +367,9 @@ public struct Service: ServiceType {
-> AnyPublisher<UserEnvelope<GraphUserEmail>, ErrorEnvelope> {
GraphQL.shared.client
.fetch(query: GraphAPI.FetchUserEmailQuery())
// TODO: make this a custom extension, we'll want to reuse this pattern
.tryMap { (data: GraphAPI.FetchUserEmailQuery.Data) -> UserEnvelope<GraphUserEmail> in
guard let envelope = UserEnvelope<GraphUserEmail>.userEnvelope(from: data) else {
throw ErrorEnvelope.couldNotParseJSON
}

return envelope
}
.mapError { rawError in

if let error = rawError as? ErrorEnvelope {
return error
}

return ErrorEnvelope.couldNotParseJSON
.mapFetchResults { (data: GraphAPI.FetchUserEmailQuery.Data) -> UserEnvelope<GraphUserEmail>? in
UserEnvelope<GraphUserEmail>.userEnvelope(from: data)
}

.eraseToAnyPublisher()
}

public func fetchGraphUserSelf()
Expand Down
26 changes: 9 additions & 17 deletions Library/ViewModels/ReportProjectFormViewModel.swift
Expand Up @@ -57,24 +57,16 @@ public final class ReportProjectFormViewModel: ReportProjectFormViewModelType,
self.saveTriggeredSubject
.compactMap { [weak self] _ in
self?.createFlaggingInput()
.handleFailureAndAllowRetry { _ in
self?.bannerMessage = MessageBannerViewViewModel((
type: .error,
message: Strings.Something_went_wrong_please_try_again()
))
self?.saveButtonEnabled = true
self?.saveButtonLoading = false
}
}
.flatMap { [weak self] createFlaggingInput in
createFlaggingInput.catch { _ in
// An API error happens.
// We need to catch this up here in flatMap, instead of in sink,
// because we don't want an API failure to cancel this pipeline.
// If the pipeline gets canceled, you can't re-submit after a failure.

self?.bannerMessage = MessageBannerViewViewModel((
type: .error,
message: Strings.Something_went_wrong_please_try_again()
))
self?.saveButtonEnabled = true
self?.saveButtonLoading = false

return Empty<EmptyResponseEnvelope, Never>()
}
}
.flatMap { $0 }
.sink(receiveValue: { [weak self] _ in
// Submitted successfully

Expand Down

0 comments on commit 91c1afe

Please sign in to comment.