diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 0899a5f..67a622f 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -13,7 +13,7 @@ jobs: container: image: swift:latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Build run: swift build -v - name: Run tests diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 1da40e1..98e74c9 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -9,12 +9,10 @@ on: jobs: build: name: Build macOS - runs-on: macos-14 + runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up Xcode version - run: sudo xcode-select -s /Applications/Xcode_15.3.app - name: Build run: swift build -v - name: Run tests diff --git a/.gitignore b/.gitignore index 5922fda..d78d4cd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ DerivedData/ .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc Package.resolved +.nova/ +.vscode diff --git a/Package.swift b/Package.swift index 3d90137..d348a6e 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,8 @@ let package = Package( ], targets: [ .target( - name: "EventSource"), + name: "EventSource", + swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]), .testTarget( name: "EventSourceTests", dependencies: ["EventSource"]), diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift index 194c054..53b4a19 100644 --- a/Package@swift-5.9.swift +++ b/Package@swift-5.9.swift @@ -18,7 +18,8 @@ let package = Package( ], targets: [ .target( - name: "EventSource"), + name: "EventSource", + swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]), .testTarget( name: "EventSourceTests", dependencies: ["EventSource"]), diff --git a/README.md b/README.md index c658be3..53d74f9 100644 --- a/README.md +++ b/README.md @@ -5,22 +5,23 @@ [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FRecouse%2FEventSource%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/Recouse/EventSource) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FRecouse%2FEventSource%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/Recouse/EventSource) -EventSource is a Swift package that provides a simple implementation of a client for [Server-Sent -Events](https://html.spec.whatwg.org/multipage/server-sent-events.html) (SSE). It allows you to easily -receive real-time updates from a server over a persistent HTTP connection, using a simple and efficient -interface. +EventSource is a Swift package that provides a simple implementation of a client for [Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html) (SSE). It allows you to easily receive real-time updates from a server over a persistent HTTP connection, using a simple and efficient interface. It also leverages Swift concurrency features to provide a more expressive and intuitive way to handle asynchronous operations. > [!Note] -> Please note that this package was originally developed to be used in conjunction with another package, -and as such, it may not cover all specification details. Please be aware of this limitation when -evaluating whether EventSource is suitable for your specific use case. +> Please note that this package was originally developed to be used in conjunction with another package, and as such, it may not cover all specification details. Please be aware of this limitation when evaluating whether EventSource is suitable for your specific use case. + +## Features + +- [x] Simple Swift API for SSE +- [x] Supports data-only mode +- [x] Data race safety with Swift 6 +- [ ] Broadcast event stream to multiple consumers (WIP) ## Installation -The module name of the package is `EventSource`. Choose one of the instructions below to install and add -the following import statement to your source code. +The module name of the package is `EventSource`. Choose one of the instructions below to install and add the following import statement to your source code. ```swift import EventSource @@ -52,21 +53,21 @@ And then, include "EventSource" as a dependency for your target: ## Usage -Using EventSource is easy. Simply create a new task from an instance of EventSource with the URLRequest of the SSE endpoint you want to connect to, and await for events: +Using EventSource is easy. Simply create a new data task from an instance of EventSource with the URLRequest of the SSE endpoint you want to connect to, and await for events: ```swift import EventSource let eventSource = EventSource() -let dataTask = eventSource.dataTask(for: urlRequest) +let dataTask = await eventSource.dataTask(for: urlRequest) -for await event in dataTask.events() { +for await event in await dataTask.events() { switch event { case .open: print("Connection was opened.") case .error(let error): print("Received an error:", error.localizedDescription) - case .message(let message): - print("Received a message", message.data ?? "") + case .event(let event): + print("Received an event", event.data ?? "") case .closed: print("Connection was closed.") } @@ -75,6 +76,47 @@ for await event in dataTask.events() { Use `dataTask.cancel()` to explicitly close the connection. However, in that case `.closed` event won't be emitted. +### Data-only mode + +EventSource can be used in data-only mode, making it suitable for popular APIs like [OpenAI](https://platform.openai.com/docs/overview). Below is an example using OpenAI's [completions](https://platform.openai.com/docs/guides/text-generation) API: +```swift +var urlRequest = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!) +urlRequest.allHTTPHeaderFields = [ + "Content-Type": "application/json", + "Authorization": "Bearer \(accessToken)" +] +urlRequest.httpMethod = "POST" +urlRequest.httpBody = """ +{ + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "Why is the sky blue?"} + ], + "stream": true +} +""".data(using: .utf8)! + +let eventSource = EventSource(mode: .dataOnly) +let dataTask = await eventSource.dataTask(for: urlRequest) + +var response: String = "" + +for await event in await dataTask.events() { + switch event { + case .event(let event): + if let eventData = event.data, let data = eventData.data(using: .utf8) { + let chunk = try? JSONDecoder().decode(ChatCompletionChunk.self, from: data) + let string = chunk?.choices.first?.delta.content ?? "" + response += string + } + default: + break + } +} + +print(response) +``` + ## Compatibility * macOS 10.15+ @@ -94,4 +136,3 @@ Contributions to are always welcomed! For more details see [CONTRIBUTING.md](CON ## License EventSource is released under the MIT License. See [LICENSE](LICENSE) for more information. - diff --git a/Sources/EventSource/MessageParser.swift b/Sources/EventSource/EventParser.swift similarity index 78% rename from Sources/EventSource/MessageParser.swift rename to Sources/EventSource/EventParser.swift index 2e7cb1b..f172db0 100644 --- a/Sources/EventSource/MessageParser.swift +++ b/Sources/EventSource/EventParser.swift @@ -1,5 +1,5 @@ // -// MessageParser.swift +// EventParser.swift // EventSource // // Copyright © 2023 Firdavs Khaydarov (Recouse). All rights reserved. @@ -8,15 +8,22 @@ import Foundation -public struct MessageParser { - public var parse: (_ data: Data) -> [ServerMessage] +public protocol EventParser: Sendable { + func parse(_ data: Data) -> [EVEvent] } -public extension MessageParser { +/// ``ServerEventParser`` is used to parse text data into ``ServerEvent``. +public struct ServerEventParser: EventParser { + let mode: EventSource.Mode + + init(mode: EventSource.Mode = .default) { + self.mode = mode + } + static let lf: UInt8 = 0x0A static let colon: UInt8 = 0x3A - - static let live = Self(parse: { data in + + public func parse(_ data: Data) -> [EVEvent] { // Split message with double newline let rawMessages: [Data] if #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, visionOS 1.0, *) { @@ -24,12 +31,12 @@ public extension MessageParser { } else { rawMessages = data.split(by: [Self.lf, Self.lf]) } - + // Parse data to ServerMessage model - let messages: [ServerMessage] = rawMessages.compactMap(ServerMessage.parse(from:)) - + let messages: [ServerEvent] = rawMessages.compactMap { ServerEvent.parse(from: $0, mode: mode) } + return messages - }) + } } fileprivate extension Data { diff --git a/Sources/EventSource/EventSource.swift b/Sources/EventSource/EventSource.swift index 299ef62..fb6cffd 100644 --- a/Sources/EventSource/EventSource.swift +++ b/Sources/EventSource/EventSource.swift @@ -11,12 +11,22 @@ import Foundation import FoundationNetworking #endif +/// The global actor used for isolating ``EventSource/EventSource/DataTask``. +@globalActor public actor EventSourceActor: GlobalActor { + public static let shared = EventSourceActor() +} + /// /// An `EventSource` instance opens a persistent connection to an HTTP server, /// which sends events in `text/event-stream` format. /// The connection remains open until closed by calling `close()`. /// -public struct EventSource { +public struct EventSource: Sendable { + public enum Mode: Sendable { + case `default` + case dataOnly + } + /// State of the connection. public enum ReadyState: Int { case none = -1 @@ -26,60 +36,73 @@ public struct EventSource { } /// Event type. - public enum EventType { + public enum EventType: Sendable { case error(Error) - case message(ServerMessage) + case event(EVEvent) case open case closed } - - private let messageParser: MessageParser + + private let mode: Mode + + private let eventParser: EventParser public var timeoutInterval: TimeInterval public init( - messageParser: MessageParser = .live, + mode: Mode = .default, + eventParser: EventParser? = nil, timeoutInterval: TimeInterval = 300 ) { - self.messageParser = messageParser + self.mode = mode + if let eventParser { + self.eventParser = eventParser + } else { + self.eventParser = ServerEventParser(mode: mode) + } self.timeoutInterval = timeoutInterval } - + + @EventSourceActor public func dataTask(for urlRequest: URLRequest) -> DataTask { DataTask( urlRequest: urlRequest, - messageParser: messageParser, + eventParser: eventParser, timeoutInterval: timeoutInterval ) } } public extension EventSource { - class DataTask { - /// A number representing the state of the connection. + /// An EventSource task that handles connecting to the URLRequest and creating an event stream. + /// + /// Creation of a task is exclusively handled by ``EventSource``. A new task can be created by calling + /// ``EventSource/EventSource/dataTask(for:)`` method on the EventSource instance. After creating a task, + /// it can be started by iterating event stream returned by ``DataTask/events()``. + @EventSourceActor final class DataTask { + /// A value representing the state of the connection. public private(set) var readyState: ReadyState = .none - + + /// Last event's ID string value. + /// + /// Sent in a HTTP request header and used when a user is to reestablish the connection. public private(set) var lastMessageId: String = "" - - /// A string representing the URL of the source. + + /// A URLRequest of the events source. public let urlRequest: URLRequest - - private let messageParser: MessageParser - + + private let eventParser: EventParser + private let timeoutInterval: TimeInterval - + private var continuation: AsyncStream.Continuation? - + private var urlSession: URLSession? - - private var sessionDelegate = SessionDelegate() - - private var sessionDelegateTask: Task? - + private var urlSessionDataTask: URLSessionDataTask? - + private var httpResponseErrorStatusCode: Int? - + private var urlSessionConfiguration: URLSessionConfiguration { let configuration = URLSessionConfiguration.default configuration.httpAdditionalHeaders = [ @@ -91,50 +114,63 @@ public extension EventSource { configuration.timeoutIntervalForResource = self.timeoutInterval return configuration } - + internal init( urlRequest: URLRequest, - messageParser: MessageParser, + eventParser: EventParser, timeoutInterval: TimeInterval ) { self.urlRequest = urlRequest - self.messageParser = messageParser + self.eventParser = eventParser self.timeoutInterval = timeoutInterval } - + + /// Creates and returns event stream. public func events() -> AsyncStream { - AsyncStream { continuation in + if urlSessionDataTask != nil { + return AsyncStream { continuation in + continuation.yield(.error(EventSourceError.alreadyConsumed)) + continuation.finish() + } + } + + return AsyncStream { continuation in + let sessionDelegate = SessionDelegate() + let sesstionDelegateTask = Task { [weak self] in + for await event in sessionDelegate.eventStream { + guard let self else { return } + + switch event { + case let .didCompleteWithError(error): + handleSessionError(error) + case let .didReceiveResponse(response, completionHandler): + handleSessionResponse(response, completionHandler: completionHandler) + case let .didReceiveData(data): + parseMessages(from: data) + } + } + } + continuation.onTermination = { @Sendable [weak self] _ in - self?.close() + sesstionDelegateTask.cancel() + Task { await self?.close() } } - + self.continuation = continuation - + + urlSession = URLSession( configuration: urlSessionConfiguration, delegate: sessionDelegate, delegateQueue: nil ) - - sessionDelegate.onEvent = { [weak self] event in - guard let self else { return } - - switch event { - case let .didCompleteWithError(error): - handleSessionError(error) - case let .didReceiveResponse(response, completionHandler): - handleSessionResponse(response, completionHandler: completionHandler) - case let .didReceiveData(data): - parseMessages(from: data) - } - } - + urlSessionDataTask = urlSession!.dataTask(with: urlRequest) urlSessionDataTask!.resume() readyState = .connecting } } - + private func handleSessionError(_ error: Error?) { guard readyState != .closed else { close() @@ -149,7 +185,7 @@ public extension EventSource { // Close connection close() } - + private func handleSessionResponse( _ response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void @@ -181,11 +217,11 @@ public extension EventSource { completionHandler(.allow) } - + /// Closes the connection, if one was made, /// and sets the `readyState` property to `.closed`. /// - Returns: State before closing. - @Sendable private func close() { + private func close() { let previousState = self.readyState if previousState != .closed { continuation?.yield(.closed) @@ -193,7 +229,7 @@ public extension EventSource { } cancel() } - + private func parseMessages(from data: Data) { if let httpResponseErrorStatusCode { self.httpResponseErrorStatusCode = nil @@ -203,15 +239,15 @@ public extension EventSource { return } - let messages = messageParser.parse(data) - + let events = eventParser.parse(data) + // Update last message ID - if let lastMessageWithId = messages.last(where: { $0.id != nil }) { + if let lastMessageWithId = events.last(where: { $0.id != nil }) { lastMessageId = lastMessageWithId.id ?? "" } - messages.forEach { - continuation?.yield(.message($0)) + events.forEach { + continuation?.yield(.event($0)) } } @@ -219,15 +255,20 @@ public extension EventSource { readyState = .open continuation?.yield(.open) } - + private func sendErrorEvent(with error: Error) { continuation?.yield(.error(error)) } - + + /// Cancels the task. + /// + /// ## Notes: + /// The event stream supports cooperative task cancellation. However, it should be noted that + /// canceling the parent Task only cancels the underlying `URLSessionDataTask` of + /// ``EventSource/EventSource/DataTask``; this does not actually stop the ongoing request. public func cancel() { readyState = .closed lastMessageId = "" - sessionDelegateTask?.cancel() urlSessionDataTask?.cancel() urlSession?.invalidateAndCancel() } diff --git a/Sources/EventSource/EventSourceError.swift b/Sources/EventSource/EventSourceError.swift index c8d9735..f936c86 100644 --- a/Sources/EventSource/EventSourceError.swift +++ b/Sources/EventSource/EventSourceError.swift @@ -8,7 +8,21 @@ import Foundation -public enum EventSourceError: Error { +public enum EventSourceError: LocalizedError { case undefinedConnectionError + case connectionError(statusCode: Int, response: Data) + + /// The ``EventSource/EventSource/DataTask`` event stream is already being consumed by another task. + /// A stream can only be consumed by one task at a time. + case alreadyConsumed + + public var errorDescription: String? { + switch self { + case .alreadyConsumed: + "The `DataTask` events stream is already being consumed by another task." + default: + nil + } + } } diff --git a/Sources/EventSource/ServerMessage.swift b/Sources/EventSource/ServerEvent.swift similarity index 63% rename from Sources/EventSource/ServerMessage.swift rename to Sources/EventSource/ServerEvent.swift index 4067146..bfecce8 100644 --- a/Sources/EventSource/ServerMessage.swift +++ b/Sources/EventSource/ServerEvent.swift @@ -1,5 +1,5 @@ // -// ServerMessage.swift +// ServerEvent.swift // EventSource // // Copyright © 2023 Firdavs Khaydarov (Recouse). All rights reserved. @@ -8,7 +8,45 @@ import Foundation -public struct ServerMessage { +/// Protocol for defining a basic event structure. It is used by the ``EventParser`` +/// and should be implemented as a custom type when a custom ``EventParser`` is required. +public protocol EVEvent: Sendable { + var id: String? { get } + var event: String? { get } + var data: String? { get } + var other: [String: String]? { get } + var time: String? { get } +} + +public extension EVEvent { + /// Checks if all event fields are empty. + var isEmpty: Bool { + if let id, !id.isEmpty { + return false + } + + if let event, !event.isEmpty { + return false + } + + if let data, !data.isEmpty { + return false + } + + if let other, !other.isEmpty { + return false + } + + if let time, !time.isEmpty { + return false + } + + return true + } +} + +/// Default implementation of ``EventSourceEvent`` used in the package. +public struct ServerEvent: EVEvent { public var id: String? public var event: String? public var data: String? @@ -29,63 +67,44 @@ public struct ServerMessage { self.time = time } - private func isEmpty() -> Bool { - if let id, !id.isEmpty { - return false - } - - if let event, !event.isEmpty { - return false + public static func parse(from data: Data, mode: EventSource.Mode = .default) -> ServerEvent? { + let rows: [Data] = switch mode { + case .default: + data.split(separator: ServerEventParser.lf) // Separate event fields + case .dataOnly: + [data] // Do not split data in data-only mode } - - if let data, !data.isEmpty { - return false - } - - if let other, !other.isEmpty { - return false - } - - if let time, !time.isEmpty { - return false - } - - return true - } - - public static func parse(from data: Data) -> ServerMessage? { - let rows = data.split(separator: MessageParser.lf) // Separate message fields - - var message = ServerMessage() + + var message = ServerEvent() for row in rows { // Skip the line if it is empty or it starts with a colon character - if row.isEmpty, row.first == MessageParser.colon { + if row.isEmpty, row.first == ServerEventParser.colon { continue } - let keyValue = row.split(separator: MessageParser.colon, maxSplits: 1) + let keyValue = row.split(separator: ServerEventParser.colon, maxSplits: 1) let key = keyValue[0].utf8String.trimmingCharacters(in: .whitespaces) let value = keyValue[safe: 1]?.utf8String.trimmingCharacters(in: .whitespaces) switch key { case "id": - message.id = value?.trimmingCharacters(in: .whitespaces) + message.id = value case "event": - message.event = value?.trimmingCharacters(in: .whitespaces) + message.event = value case "data": if let existingData = message.data { - message.data = existingData + "\n" + (value?.trimmingCharacters(in: .whitespaces) ?? "") + message.data = existingData + "\n" + (value ?? "") } else { - message.data = value?.trimmingCharacters(in: .whitespaces) + message.data = value } case "time": - message.time = value?.trimmingCharacters(in: .whitespaces) + message.time = value default: - // If the line is not empty but does not contain a color character + // If the line is not empty but does not contain a colon character // add it to the other fields using the whole line as the field name, // and the empty string as the field value. - if row.contains(MessageParser.colon) == false { + if row.contains(ServerEventParser.colon) == false { let string = row.utf8String if var other = message.other { other[string] = "" @@ -97,7 +116,7 @@ public struct ServerMessage { } } - if message.isEmpty() { + if message.isEmpty { return nil } diff --git a/Sources/EventSource/SessionDelegate.swift b/Sources/EventSource/SessionDelegate.swift index fbe1ed2..be55a5a 100644 --- a/Sources/EventSource/SessionDelegate.swift +++ b/Sources/EventSource/SessionDelegate.swift @@ -12,29 +12,31 @@ import Foundation #endif final class SessionDelegate: NSObject, URLSessionDataDelegate { - enum Event { + enum Event: Sendable { case didCompleteWithError(Error?) - case didReceiveResponse(URLResponse, (URLSession.ResponseDisposition) -> Void) + case didReceiveResponse(URLResponse, @Sendable (URLSession.ResponseDisposition) -> Void) case didReceiveData(Data) } - - var onEvent: (Event) -> Void = { _ in } - + + private let internalStream = AsyncStream.makeStream() + + var eventStream: AsyncStream { internalStream.stream } + func urlSession( _ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error? ) { - onEvent(.didCompleteWithError(error)) + internalStream.continuation.yield(.didCompleteWithError(error)) } func urlSession( _ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, - completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + completionHandler: @Sendable @escaping (URLSession.ResponseDisposition) -> Void ) { - onEvent(.didReceiveResponse(response, completionHandler)) + internalStream.continuation.yield(.didReceiveResponse(response, completionHandler)) } func urlSession( @@ -42,6 +44,6 @@ final class SessionDelegate: NSObject, URLSessionDataDelegate { dataTask: URLSessionDataTask, didReceive data: Data ) { - onEvent(.didReceiveData(data)) + internalStream.continuation.yield(.didReceiveData(data)) } } diff --git a/Tests/EventSourceTests/EventParserTests.swift b/Tests/EventSourceTests/EventParserTests.swift new file mode 100644 index 0000000..68285a5 --- /dev/null +++ b/Tests/EventSourceTests/EventParserTests.swift @@ -0,0 +1,158 @@ +// +// Copyright © 2023 Firdavs Khaydarov (Recouse). All rights reserved. +// Licensed under the MIT License. +// + +import Foundation +import Testing +@testable import EventSource + +struct EventParserTests { + @Test func messagesParsing() throws { + let parser = ServerEventParser() + + let text = """ + data: test 1 + + data: test 2 + data: continued + + event: add + data: test 3 + + event: remove + data: test 4 + + id: 5 + event: ping + data: test 5 + """ + let data = Data(text.utf8) + + let messages = parser.parse(data) + + #expect(messages.count == 5) + + #expect(messages[0].data != nil) + #expect(messages[0].data! == "test 1") + + #expect(messages[1].data != nil) + #expect(messages[1].data! == "test 2\ncontinued") + + #expect(messages[2].event != nil) + #expect(messages[2].data != nil) + #expect(messages[2].event! == "add") + #expect(messages[2].data! == "test 3") + + #expect(messages[3].event != nil) + #expect(messages[3].data != nil) + #expect(messages[3].event! == "remove") + #expect(messages[3].data! == "test 4") + + #expect(messages[4].id != nil) + #expect(messages[4].event != nil) + #expect(messages[4].data != nil) + #expect(messages[4].id! == "5") + #expect(messages[4].event! == "ping") + #expect(messages[4].data! == "test 5") + } + + @Test func emptyData() { + let parser = ServerEventParser() + + let text = """ + + + """ + let data = Data(text.utf8) + + let messages = parser.parse(data) + + #expect(messages.isEmpty) + } + + @Test func otherMessageFormats() { + let parser = ServerEventParser() + + let text = """ + data : test 1 + + id : 2 + data : test 2 + + event : add + data : test 3 + + id : 4 + event : ping + data : test 4 + + test 5 + + message 6 + message 6-1 + """ + let data = Data(text.utf8) + + let messages = parser.parse(data) + + #expect(messages[0].data != nil) + #expect(messages[0].data! == "test 1") + + #expect(messages[1].id != nil) + #expect(messages[1].data != nil) + #expect(messages[1].id! == "2") + #expect(messages[1].data! == "test 2") + + #expect(messages[2].event != nil) + #expect(messages[2].data != nil) + #expect(messages[2].event! == "add") + #expect(messages[2].data! == "test 3") + + #expect(messages[3].id != nil) + #expect(messages[3].event != nil) + #expect(messages[3].data != nil) + #expect(messages[3].id! == "4") + #expect(messages[3].event! == "ping") + #expect(messages[3].data! == "test 4") + + #expect(messages[4].other != nil) + #expect(messages[4].other!["test 5"] == "") + + #expect(messages[5].other != nil) + #expect(messages[5].other!["message 6"] == "") + #expect(messages[5].other!["message 6-1"] == "") + } + + @Test func dataOnlyMode() throws { + let parser = ServerEventParser(mode: .dataOnly) + let jsonDecoder = JSONDecoder() + + let text = """ + data: {"id":"abcd-1","type":"message","content":"\\ntest\\n"} + + data: {"id":"abcd-2","type":"message","content":"\\n\\n"} + + """ + let data = Data(text.utf8) + + let messages = parser.parse(data) + + let data1 = Data(try #require(messages[0].data?.utf8)) + let data2 = Data(try #require(messages[1].data?.utf8)) + + let message1 = try jsonDecoder.decode(TestModel.self, from: data1) + let message2 = try jsonDecoder.decode(TestModel.self, from: data2) + + #expect(message1.content == "\ntest\n") + #expect(message2.content == "\n\n") + } +} + +fileprivate extension EventParserTests { + struct TestModel: Decodable { + let id: String + let type: String + let content: String + } +} diff --git a/Tests/EventSourceTests/MessageParserTests.swift b/Tests/EventSourceTests/MessageParserTests.swift deleted file mode 100644 index a03acf2..0000000 --- a/Tests/EventSourceTests/MessageParserTests.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// Copyright © 2023 Firdavs Khaydarov (Recouse). All rights reserved. -// Licensed under the MIT License. -// - -import XCTest -@testable import EventSource - -final class MessageParserTests: XCTestCase { - func testMessagesParsing() throws { - let parser = MessageParser.live - - let text = """ - data: test 1 - - data: test 2 - data: continued - - event: add - data: test 3 - - event: remove - data: test 4 - - id: 5 - event: ping - data: test 5 - """ - let data = Data(text.utf8) - - let messages = parser.parse(data) - - XCTAssertEqual(messages.count, 5) - - XCTAssertNotNil(messages[0].data) - XCTAssertEqual(messages[0].data!, "test 1") - - XCTAssertNotNil(messages[1].data) - XCTAssertEqual(messages[1].data!, "test 2\ncontinued") - - XCTAssertNotNil(messages[2].event) - XCTAssertNotNil(messages[2].data) - XCTAssertEqual(messages[2].event!, "add") - XCTAssertEqual(messages[2].data!, "test 3") - - XCTAssertNotNil(messages[3].event) - XCTAssertNotNil(messages[3].data) - XCTAssertEqual(messages[3].event!, "remove") - XCTAssertEqual(messages[3].data!, "test 4") - - XCTAssertNotNil(messages[4].id) - XCTAssertNotNil(messages[4].event) - XCTAssertNotNil(messages[4].data) - XCTAssertEqual(messages[4].id!, "5") - XCTAssertEqual(messages[4].event!, "ping") - XCTAssertEqual(messages[4].data!, "test 5") - } - - func testEmptyData() { - let parser = MessageParser.live - - let text = """ - - - """ - let data = Data(text.utf8) - - let messages = parser.parse(data) - - XCTAssertTrue(messages.isEmpty) - } - - func testOtherMessageFormats() { - let parser = MessageParser.live - - let text = """ - data : test 1 - - id : 2 - data : test 2 - - event : add - data : test 3 - - id : 4 - event : ping - data : test 4 - - test 5 - - message 6 - message 6-1 - """ - let data = Data(text.utf8) - - let messages = parser.parse(data) - - XCTAssertNotNil(messages[0].data) - XCTAssertEqual(messages[0].data!, "test 1") - - XCTAssertNotNil(messages[1].id) - XCTAssertNotNil(messages[1].data) - XCTAssertEqual(messages[1].id!, "2") - XCTAssertEqual(messages[1].data!, "test 2") - - XCTAssertNotNil(messages[2].event) - XCTAssertNotNil(messages[2].data) - XCTAssertEqual(messages[2].event!, "add") - XCTAssertEqual(messages[2].data!, "test 3") - - XCTAssertNotNil(messages[3].id) - XCTAssertNotNil(messages[3].event) - XCTAssertNotNil(messages[3].data) - XCTAssertEqual(messages[3].id!, "4") - XCTAssertEqual(messages[3].event!, "ping") - XCTAssertEqual(messages[3].data!, "test 4") - - XCTAssertNotNil(messages[4].other) - XCTAssertEqual(messages[4].other!["test 5"], "") - - XCTAssertNotNil(messages[5].other) - XCTAssertEqual(messages[5].other!["message 6"], "") - XCTAssertEqual(messages[5].other!["message 6-1"], "") - } - - func testJSONData() { - let parser = MessageParser.live - let jsonDecoder = JSONDecoder() - - let text = """ - data: {\"id\":\"abcd-1\",\"type\":\"message\",\"content\":\"\\ntest\\n\"} - - id: abcd-2 - data: {\"id\":\"abcd-2\",\"type\":\"message\",\"content\":\"\\n\\n"} - - """ - let data = Data(text.utf8) - - let messages = parser.parse(data) - - XCTAssertNotNil(messages[0].data) - XCTAssertNotNil(messages[1].data) - - do { - let decoded1 = try jsonDecoder.decode(TestModel.self, from: Data(messages[0].data!.utf8)) - let decoded2 = try jsonDecoder.decode(TestModel.self, from: Data(messages[1].data!.utf8)) - } catch { - XCTFail("The JSON strings provided in the test data were parsed incorrectly.") - } - } -} - -fileprivate extension MessageParserTests { - struct TestModel: Decodable { - let id: String - let type: String - let content: String - } -}