Skip to content
This repository has been archived by the owner on Jan 24, 2023. It is now read-only.

Commit

Permalink
Merge pull request #159 from UrbanCompass/thomas/observable-to-publisher
Browse files Browse the repository at this point in the history
ObservableType (Snail) -> Publisher (Combine)
  • Loading branch information
tbajis authored Sep 23, 2021
2 parents 2ce2f5d + db3af24 commit 1ed8cc9
Show file tree
Hide file tree
Showing 11 changed files with 1,896 additions and 0 deletions.
56 changes: 56 additions & 0 deletions Snail.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@
24FABD581DFEF7EC005CF84E /* FailTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24FABD571DFEF7EC005CF84E /* FailTests.swift */; };
24FABD5A1DFF0B48005CF84E /* Replay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24FABD591DFF0B48005CF84E /* Replay.swift */; };
24FABD5C1DFF0BAF005CF84E /* ReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24FABD5B1DFF0BAF005CF84E /* ReplayTests.swift */; };
2E53BDEC264ECC940030B9FB /* Observable+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E53BDEB264ECC940030B9FB /* Observable+Combine.swift */; };
2E53BDEE264ED4C70030B9FB /* SnailSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E53BDED264ED4C70030B9FB /* SnailSubscription.swift */; };
2E53BE072651F2990030B9FB /* ObservableAsPublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E53BE062651F2990030B9FB /* ObservableAsPublisherTests.swift */; };
2E53BE092652B6D60030B9FB /* DemandBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E53BE082652B6D60030B9FB /* DemandBuffer.swift */; };
2E53BE0B2652D0960030B9FB /* FailAsPublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E53BE0A2652D0960030B9FB /* FailAsPublisherTests.swift */; };
2E53BE0D2652D37D0030B9FB /* JustAsPublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E53BE0C2652D37D0030B9FB /* JustAsPublisherTests.swift */; };
2E53BE0F2652D4E50030B9FB /* ReplayAsPublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E53BE0E2652D4E50030B9FB /* ReplayAsPublisherTests.swift */; };
2E53BE112652D7370030B9FB /* UniqueAsPublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E53BE102652D7370030B9FB /* UniqueAsPublisherTests.swift */; };
2E53BE132652D8B80030B9FB /* VariableAsPublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E53BE122652D8B80030B9FB /* VariableAsPublisherTests.swift */; };
2E53BE512654717B0030B9FB /* SnailPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E53BE502654717B0030B9FB /* SnailPublisher.swift */; };
CB2936771DFE151B00792E6B /* Just.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB2936761DFE151B00792E6B /* Just.swift */; };
CB2936791DFEF77500792E6B /* JustTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB2936781DFEF77500792E6B /* JustTests.swift */; };
CBE54A7A1E5A16AC00971F74 /* Subscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE54A791E5A16AC00971F74 /* Subscriber.swift */; };
Expand Down Expand Up @@ -59,6 +69,16 @@
24FABD571DFEF7EC005CF84E /* FailTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FailTests.swift; sourceTree = "<group>"; };
24FABD591DFF0B48005CF84E /* Replay.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Replay.swift; sourceTree = "<group>"; };
24FABD5B1DFF0BAF005CF84E /* ReplayTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReplayTests.swift; sourceTree = "<group>"; };
2E53BDEB264ECC940030B9FB /* Observable+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Observable+Combine.swift"; sourceTree = "<group>"; };
2E53BDED264ED4C70030B9FB /* SnailSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnailSubscription.swift; sourceTree = "<group>"; };
2E53BE062651F2990030B9FB /* ObservableAsPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableAsPublisherTests.swift; sourceTree = "<group>"; };
2E53BE082652B6D60030B9FB /* DemandBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemandBuffer.swift; sourceTree = "<group>"; };
2E53BE0A2652D0960030B9FB /* FailAsPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailAsPublisherTests.swift; sourceTree = "<group>"; };
2E53BE0C2652D37D0030B9FB /* JustAsPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JustAsPublisherTests.swift; sourceTree = "<group>"; };
2E53BE0E2652D4E50030B9FB /* ReplayAsPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayAsPublisherTests.swift; sourceTree = "<group>"; };
2E53BE102652D7370030B9FB /* UniqueAsPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniqueAsPublisherTests.swift; sourceTree = "<group>"; };
2E53BE122652D8B80030B9FB /* VariableAsPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VariableAsPublisherTests.swift; sourceTree = "<group>"; };
2E53BE502654717B0030B9FB /* SnailPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnailPublisher.swift; sourceTree = "<group>"; };
CB2936761DFE151B00792E6B /* Just.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Just.swift; sourceTree = "<group>"; };
CB2936781DFEF77500792E6B /* JustTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JustTests.swift; sourceTree = "<group>"; };
CBE54A791E5A16AC00971F74 /* Subscriber.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Subscriber.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -112,6 +132,30 @@
path = Extensions;
sourceTree = "<group>";
};
2E53BDEA264ECC770030B9FB /* Snail+Combine */ = {
isa = PBXGroup;
children = (
2E53BDEB264ECC940030B9FB /* Observable+Combine.swift */,
2E53BDED264ED4C70030B9FB /* SnailSubscription.swift */,
2E53BE082652B6D60030B9FB /* DemandBuffer.swift */,
2E53BE502654717B0030B9FB /* SnailPublisher.swift */,
);
path = "Snail+Combine";
sourceTree = "<group>";
};
2E53BE052651F2710030B9FB /* Combine */ = {
isa = PBXGroup;
children = (
2E53BE062651F2990030B9FB /* ObservableAsPublisherTests.swift */,
2E53BE0A2652D0960030B9FB /* FailAsPublisherTests.swift */,
2E53BE0C2652D37D0030B9FB /* JustAsPublisherTests.swift */,
2E53BE0E2652D4E50030B9FB /* ReplayAsPublisherTests.swift */,
2E53BE102652D7370030B9FB /* UniqueAsPublisherTests.swift */,
2E53BE122652D8B80030B9FB /* VariableAsPublisherTests.swift */,
);
path = Combine;
sourceTree = "<group>";
};
CBE54E371DFB36DF0008DD64 = {
isa = PBXGroup;
children = (
Expand All @@ -133,6 +177,7 @@
CBE54E431DFB36DF0008DD64 /* Snail */ = {
isa = PBXGroup;
children = (
2E53BDEA264ECC770030B9FB /* Snail+Combine */,
F569538B2320476100D35C80 /* Closure.swift */,
CBE54E441DFB36DF0008DD64 /* Snail.h */,
CBE54E451DFB36DF0008DD64 /* Info.plist */,
Expand All @@ -155,6 +200,7 @@
CBE54E4E1DFB36DF0008DD64 /* SnailTests */ = {
isa = PBXGroup;
children = (
2E53BE052651F2710030B9FB /* Combine */,
CBE54E511DFB36DF0008DD64 /* Info.plist */,
F5695389232046AA00D35C80 /* ClosureTests.swift */,
F5C973A722F4B359003DB42C /* DisposerTests.swift */,
Expand Down Expand Up @@ -314,6 +360,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
2E53BDEC264ECC940030B9FB /* Observable+Combine.swift in Sources */,
CBE54E621DFB39510008DD64 /* ObservableType.swift in Sources */,
CBE54A7A1E5A16AC00971F74 /* Subscriber.swift in Sources */,
2421BA721E09801000EA9064 /* UIGestureRecognizerExtensions.swift in Sources */,
Expand All @@ -325,8 +372,11 @@
CB2936771DFE151B00792E6B /* Just.swift in Sources */,
241F15581E03124600DD70A2 /* UIBarButtonItemExtensions.swift in Sources */,
24CF1FA81EF875A400F34234 /* URLSessionExtensions.swift in Sources */,
2E53BE092652B6D60030B9FB /* DemandBuffer.swift in Sources */,
2408FA902112A15900B9F59E /* Scheduler.swift in Sources */,
2E53BE512654717B0030B9FB /* SnailPublisher.swift in Sources */,
CBE54E601DFB39510008DD64 /* Event.swift in Sources */,
2E53BDEE264ED4C70030B9FB /* SnailSubscription.swift in Sources */,
F5C973A622F20C86003DB42C /* Disposer.swift in Sources */,
CBE54E631DFB39510008DD64 /* Variable.swift in Sources */,
CBE54E6D1DFB6A910008DD64 /* UIControlExtensions.swift in Sources */,
Expand All @@ -341,11 +391,17 @@
buildActionMask = 2147483647;
files = (
CB2936791DFEF77500792E6B /* JustTests.swift in Sources */,
2E53BE132652D8B80030B9FB /* VariableAsPublisherTests.swift in Sources */,
2E53BE0F2652D4E50030B9FB /* ReplayAsPublisherTests.swift in Sources */,
CBE54E651DFB395A0008DD64 /* ObservableTests.swift in Sources */,
2E53BE0B2652D0960030B9FB /* FailAsPublisherTests.swift in Sources */,
F5C973A822F4B359003DB42C /* DisposerTests.swift in Sources */,
24FABD5C1DFF0BAF005CF84E /* ReplayTests.swift in Sources */,
DEF9B98D1FD5D8FD00F8514E /* UniqueTests.swift in Sources */,
2E53BE112652D7370030B9FB /* UniqueAsPublisherTests.swift in Sources */,
F569538A232046AA00D35C80 /* ClosureTests.swift in Sources */,
2E53BE072651F2990030B9FB /* ObservableAsPublisherTests.swift in Sources */,
2E53BE0D2652D37D0030B9FB /* JustAsPublisherTests.swift in Sources */,
CBE54E671DFB4F3F0008DD64 /* VariableTests.swift in Sources */,
24FABD581DFEF7EC005CF84E /* FailTests.swift in Sources */,
DEEDA8EB2051BCB4000FE578 /* NotificationCenterExtensions.swift in Sources */,
Expand Down
129 changes: 129 additions & 0 deletions Snail/Snail+Combine/DemandBuffer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//
// DemandBuffer.swift
// RxCombine
//
// Created by Shai Mishali on 21/02/2020.
// Copyright © 2020 Combine Community. All rights reserved.
//

import Combine
import Darwin
import Foundation

/// A buffer responsible for managing the demand of a downstream
/// subscriber for an upstream publisher
///
/// It buffers values and completion events and forwards them dynamically
/// according to the demand requested by the downstream
///
/// In a sense, the subscription only relays the requests for demand, as well
/// the events emitted by the upstream — to this buffer, which manages
/// the entire behavior and backpressure contract
@available(iOS 13.0, *)
class DemandBuffer<S: Combine.Subscriber> {
private let lock = NSRecursiveLock()
private var buffer = [S.Input]()
private let subscriber: S
private var completion: Subscribers.Completion<S.Failure>?
private var demandState = Demand()

/// Initialize a new demand buffer for a provided downstream subscriber
///
/// - parameter subscriber: The downstream subscriber demanding events
init(subscriber: S) {
self.subscriber = subscriber
}

/// Buffer an upstream value to later be forwarded to
/// the downstream subscriber, once it demands it
///
/// - parameter value: Upstream value to buffer
///
/// - returns: The demand fulfilled by the bufferr
func buffer(value: S.Input) -> Subscribers.Demand {
precondition(self.completion == nil,
"How could a completed publisher sent values?! Beats me 🤷‍♂️")

switch demandState.requested {
case .unlimited:
return subscriber.receive(value)
default:
buffer.append(value)
return flush()
}
}

/// Complete the demand buffer with an upstream completion event
///
/// This method will deplete the buffer immediately,
/// based on the currently accumulated demand, and relay the
/// completion event down as soon as demand is fulfilled
///
/// - parameter completion: Completion event
func complete(completion: Subscribers.Completion<S.Failure>) {
precondition(self.completion == nil,
"Completion have already occured, which is quite awkward 🥺")

self.completion = completion
_ = flush()
}

/// Signal to the buffer that the downstream requested new demand
///
/// - note: The buffer will attempt to flush as many events rqeuested
/// by the downstream at this point
func demand(_ demand: Subscribers.Demand) -> Subscribers.Demand {
flush(adding: demand)
}

/// Flush buffered events to the downstream based on the current
/// state of the downstream's demand
///
/// - parameter newDemand: The new demand to add. If `nil`, the flush isn't the
/// result of an explicit demand change
///
/// - note: After fulfilling the downstream's request, if completion
/// has already occured, the buffer will be cleared and the
/// completion event will be sent to the downstream subscriber
private func flush(adding newDemand: Subscribers.Demand? = nil) -> Subscribers.Demand {
lock.lock()
defer { lock.unlock() }

if let newDemand = newDemand {
demandState.requested += newDemand
}

// If buffer isn't ready for flushing, return immediately
guard demandState.requested > 0 || newDemand == Subscribers.Demand.none else { return .none }

while !buffer.isEmpty && demandState.processed < demandState.requested {
demandState.requested += subscriber.receive(buffer.remove(at: 0))
demandState.processed += 1
}

if let completion = completion {
// Completion event was already sent
buffer = []
demandState = .init()
self.completion = nil
subscriber.receive(completion: completion)
return .none
}

let sentDemand = demandState.requested - demandState.sent
demandState.sent += sentDemand
return sentDemand
}
}

// MARK: - Private Helpers
@available(iOS 13.0, *)
private extension DemandBuffer {
/// A model that tracks the downstream's
/// accumulated demand state
struct Demand {
var processed: Subscribers.Demand = .none
var requested: Subscribers.Demand = .none
var sent: Subscribers.Demand = .none
}
}
11 changes: 11 additions & 0 deletions Snail/Snail+Combine/Observable+Combine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Copyright © 2021 Compass. All rights reserved.

import Combine
import Foundation

@available(iOS 13.0, *)
public extension ObservableType {
func asAnyPublisher() -> AnyPublisher<T, Error> {
return SnailPublisher(upstream: self).eraseToAnyPublisher()
}
}
21 changes: 21 additions & 0 deletions Snail/Snail+Combine/SnailPublisher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright © 2021 Compass. All rights reserved.

import Combine
import Foundation

@available(iOS 13.0, *)
public class SnailPublisher<Upstream: ObservableType>: Publisher {
public typealias Output = Upstream.T
public typealias Failure = Error

private let upstream: Upstream

init(upstream: Upstream) {
self.upstream = upstream
}

public func receive<S: Combine.Subscriber>(subscriber: S) where Failure == S.Failure, Output == S.Input {
subscriber.receive(subscription: SnailSubscription(upstream: upstream,
downstream: subscriber))
}
}
37 changes: 37 additions & 0 deletions Snail/Snail+Combine/SnailSubscription.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright © 2021 Compass. All rights reserved.

import Combine
import Foundation

@available(iOS 13.0, *)
class SnailSubscription<Upstream: ObservableType, Downstream: Combine.Subscriber>: Combine.Subscription where Downstream.Input == Upstream.T, Downstream.Failure == Error {
private var disposable: Subscriber<Upstream.T>?
private let buffer: DemandBuffer<Downstream>

init(upstream: Upstream,
downstream: Downstream) {
buffer = DemandBuffer(subscriber: downstream)
disposable = upstream.subscribe(queue: nil,
onNext: { [weak self] value in
guard let self = self else { return }
_ = self.buffer.buffer(value: value)
},
onError: { [weak self] error in
guard let self = self else { return }
self.buffer.complete(completion: .failure(error))
},
onDone: { [weak self] in
guard let self = self else { return }
self.buffer.complete(completion: .finished)
})
}

func request(_ demand: Subscribers.Demand) {
_ = self.buffer.demand(demand)
}

func cancel() {
disposable?.dispose()
disposable = nil
}
}
56 changes: 56 additions & 0 deletions SnailTests/Combine/FailAsPublisherTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright © 2021 Compass. All rights reserved.

import Combine
import Foundation
@testable import Snail
import XCTest

@available(iOS 13.0, *)
class FailAsPublisherTests: XCTestCase {
enum TestError: Error {
case test
}

private var subject: Observable<String>!
private var strings: [String]!
private var error: Error?
private var subscriptions: Set<AnyCancellable>!

override func setUp() {
super.setUp()
subject = Fail(TestError.test)
strings = []
subscriptions = Set<AnyCancellable>()
error = nil
}

override func tearDown() {
subject = nil
strings = nil
error = nil
subscriptions = nil
}

func testOnErrorIsRun() {
subject.asAnyPublisher()
.sink(receiveCompletion: { [unowned self] completion in
if case let .failure(underlying) = completion {
self.error = underlying as? TestError
}
},
receiveValue: { _ in })
.store(in: &subscriptions)

XCTAssertEqual((error as? TestError), TestError.test)
}

func testOnNextIsNotRun() {
subject.asAnyPublisher()
.sink(receiveCompletion: { _ in },
receiveValue: { [unowned self] in self.strings.append($0) })
.store(in: &subscriptions)
subject?.on(.next("1"))

XCTAssertEqual(strings?.count, 0)
}
}
Loading

0 comments on commit 1ed8cc9

Please sign in to comment.