From d49aee674395cf5ce2f65157296f5d2f93032499 Mon Sep 17 00:00:00 2001 From: LosFarmosCTL <80157503+LosFarmosCTL@users.noreply.github.com> Date: Fri, 12 Jul 2024 00:53:47 +0200 Subject: [PATCH] feat: add initial EventSub implementation (#14) --- Sources/Twitch/EventSub/EventSubClient.swift | 106 ++++++++++++++ .../Twitch/EventSub/EventSubConnection.swift | 137 ++++++++++++++++++ Sources/Twitch/EventSub/EventSubError.swift | 14 ++ Sources/Twitch/EventSub/EventSubHandler.swift | 50 +++++++ .../EventSub/EventSubSubscriptionType.swift | 14 -- .../Events/Channel/ChannelFollowEvent.swift | 8 + .../Events/Channel/ChannelUpdateEvent.swift | 3 + .../EventSub/Events/Chat/ChatClearEvent.swift | 11 ++ .../Events/Chat/ChatMessageEvent.swift | 3 + Sources/Twitch/EventSub/Events/Event.swift | 17 +++ Sources/Twitch/EventSub/KeepaliveTimer.swift | 29 ++++ .../EventSub/Subscriptions/+channel.swift | 19 +++ .../Twitch/EventSub/Subscriptions/+chat.swift | 25 ++++ .../Subscriptions/EventSubSubscription.swift | 5 + .../EventSub/TwitchClient+EventSub.swift | 58 ++++++++ .../EventSub/message/EventSubMessage.swift | 58 ++++++++ .../payloads/EventSubNotification.swift | 23 +++ .../message/payloads/EventSubPayload.swift | 7 + .../message/payloads/EventSubReconnect.swift | 24 +++ .../message/payloads/EventSubRevocation.swift | 29 ++++ .../message/payloads/EventSubWelcome.swift | 27 ++++ .../Ads/HelixEndpoint+getAdSchedule.swift | 9 -- .../Ads/HelixEndpoint+snoozeNextAd.swift | 6 - .../Ads/HelixEndpoint+startCommercial.swift | 8 +- .../HelixEndpoint+getExtensionAnalytics.swift | 7 +- .../HelixEndpoint+getGameAnalytics.swift | 7 +- .../HelixEndpoint+getChannelEditors.swift | 5 +- .../HelixEndpoint+getChannelFollowers.swift | 6 +- .../Channels/HelixEndpoint+getChannels.swift | 15 +- .../HelixEndpoint+getFollowedChannels.swift | 6 +- .../HelixEndpoint+updateChannel.swift | 14 +- .../Chat/HelixEndpoint+getChannelBadges.swift | 15 +- .../Chat/HelixEndpoint+getChannelEmotes.swift | 12 +- .../Chat/HelixEndpoint+getChatSettings.swift | 16 +- .../Chat/HelixEndpoint+getChatters.swift | 5 +- .../Chat/HelixEndpoint+getEmoteSets.swift | 13 +- .../Chat/HelixEndpoint+getGlobalEmotes.swift | 8 - .../Chat/HelixEndpoint+getUserColor.swift | 6 +- .../Chat/HelixEndpoint+sendChatMessage.swift | 12 +- .../HelixEndpoint+updateChatSettings.swift | 13 -- ...xEndpoint+createEventSubSubscription.swift | 84 +++++------ .../Games/HelixEndpoint+getGames.swift | 6 +- .../Moderation/HelixEndpoint+banUser.swift | 14 +- .../HelixEndpoint+checkAutomodStatus.swift | 4 +- .../HelixEndpoint+getAutomodSettings.swift | 16 +- .../HelixEndpoint+getBannedUsers.swift | 16 +- .../HelixEndpoint+getBlockedTerms.swift | 11 +- .../HelixEndpoint+getModeratedChannels.swift | 6 +- .../HelixEndpoint+getModerators.swift | 6 +- .../HelixEndpoint+getShieldModeStatus.swift | 8 +- .../Moderation/HelixEndpoint+getVIPs.swift | 6 +- .../HelixEndpoint+updateAutomodSettings.swift | 5 +- .../HelixEndpoint+searchCategories.swift | 6 - .../Search/HelixEndpoint+searchChannels.swift | 32 +--- .../Streams/HelixEndpoint+getStreams.swift | 21 +-- .../HelixEndpoint+checkSubscription.swift | 12 +- .../HelixEndpoint+getSubscribers.swift | 20 +-- .../HelixEndpoint+getUserBlocklist.swift | 6 +- .../Users/HelixEndpoint+getUsers.swift | 16 -- Sources/Twitch/Helix/HelixEndpoint.swift | 21 +-- Sources/Twitch/Helix/HelixResponse.swift | 16 +- Sources/Twitch/Helix/TwitchClient+Helix.swift | 2 +- .../Extensions}/Date+formatted.swift | 0 ...rmatter+iso8601withFractionalSeconds.swift | 0 .../Shared/Extensions/URL+appending.swift | 16 ++ .../Extensions}/URLSession+data.swift | 0 .../URLSessionWebSocketTask+receive.swift | 17 +++ .../PropertyWrappers/NilOnTypeMismatch.swift | 13 ++ Sources/Twitch/TwitchClient.swift | 9 +- 69 files changed, 879 insertions(+), 360 deletions(-) create mode 100644 Sources/Twitch/EventSub/EventSubClient.swift create mode 100644 Sources/Twitch/EventSub/EventSubConnection.swift create mode 100644 Sources/Twitch/EventSub/EventSubError.swift create mode 100644 Sources/Twitch/EventSub/EventSubHandler.swift delete mode 100644 Sources/Twitch/EventSub/EventSubSubscriptionType.swift create mode 100644 Sources/Twitch/EventSub/Events/Channel/ChannelFollowEvent.swift create mode 100644 Sources/Twitch/EventSub/Events/Channel/ChannelUpdateEvent.swift create mode 100644 Sources/Twitch/EventSub/Events/Chat/ChatClearEvent.swift create mode 100644 Sources/Twitch/EventSub/Events/Chat/ChatMessageEvent.swift create mode 100644 Sources/Twitch/EventSub/Events/Event.swift create mode 100644 Sources/Twitch/EventSub/KeepaliveTimer.swift create mode 100644 Sources/Twitch/EventSub/Subscriptions/+channel.swift create mode 100644 Sources/Twitch/EventSub/Subscriptions/+chat.swift create mode 100644 Sources/Twitch/EventSub/Subscriptions/EventSubSubscription.swift create mode 100644 Sources/Twitch/EventSub/TwitchClient+EventSub.swift create mode 100644 Sources/Twitch/EventSub/message/EventSubMessage.swift create mode 100644 Sources/Twitch/EventSub/message/payloads/EventSubNotification.swift create mode 100644 Sources/Twitch/EventSub/message/payloads/EventSubPayload.swift create mode 100644 Sources/Twitch/EventSub/message/payloads/EventSubReconnect.swift create mode 100644 Sources/Twitch/EventSub/message/payloads/EventSubRevocation.swift create mode 100644 Sources/Twitch/EventSub/message/payloads/EventSubWelcome.swift rename Sources/Twitch/{Helix/Endpoints => Shared/Extensions}/Date+formatted.swift (100%) rename Sources/Twitch/{Helix => Shared/Extensions}/Formatter+iso8601withFractionalSeconds.swift (100%) create mode 100644 Sources/Twitch/Shared/Extensions/URL+appending.swift rename Sources/Twitch/{Helix => Shared/Extensions}/URLSession+data.swift (100%) create mode 100644 Sources/Twitch/Shared/Extensions/URLSessionWebSocketTask+receive.swift create mode 100644 Sources/Twitch/Shared/PropertyWrappers/NilOnTypeMismatch.swift diff --git a/Sources/Twitch/EventSub/EventSubClient.swift b/Sources/Twitch/EventSub/EventSubClient.swift new file mode 100644 index 0000000..a18e90e --- /dev/null +++ b/Sources/Twitch/EventSub/EventSubClient.swift @@ -0,0 +1,106 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +private typealias EventID = String +private typealias SocketID = String + +internal class EventSubClient { + private static let eventSubURL = URL(string: "wss://eventsub.wss.twitch.tv/ws")! + private static let maxSubscriptionsPerConnection = 300 + + private let credentials: TwitchCredentials + private let urlSession: URLSession + private let decoder: JSONDecoder + + private var connections = [SocketID: EventSubConnection]() + private var connectionEvents = [SocketID: [EventID]]() + private var eventHandlers = [EventID: EventSubHandler]() + + internal init( + credentials: TwitchCredentials, urlSession: URLSession, decoder: JSONDecoder + ) { + self.credentials = credentials + self.urlSession = urlSession + self.decoder = decoder + } + + internal func addHandler( + _ handler: EventSubHandler, for eventID: String, on socketID: String + ) { + eventHandlers[eventID] = handler + + connectionEvents[socketID, default: []].append(eventID) + } + + internal func getFreeWebsocketID() async throws -> String { + for (socketID, events) in connectionEvents + where events.count < Self.maxSubscriptionsPerConnection { + return socketID + } + + return try await createConnection() + } + + private func createConnection(url: URL = eventSubURL) async throws -> SocketID { + let connection = EventSubConnection( + credentials: credentials, urlSession: urlSession, decoder: decoder, + eventSubURL: url, onMessage: receiveMessage(_:)) + + let socketID = try await connection.resume() + + connections[socketID] = connection + return socketID + } + + private func receiveMessage( + _ result: Result<EventSubNotification, EventSubConnectionError> + ) { + switch result { + case .success(let notification): + eventHandlers[notification.subscription.id]?.yield(notification.event) + + case .failure(let error): + switch error { + case .revocation(let revocation): + eventHandlers[revocation.subscriptionID]?.finish( + throwing: .revocation(revocation)) + case .reconnectRequested(let reconnectURL, let socketID): + self.reconnect(socketID, reconnectURL: reconnectURL) + case .disconnected(let error, let socketID): + finishConnection(socketID, throwing: .disconnected(with: error)) + case .timedOut(let socketID): + finishConnection(socketID, throwing: .timedOut) + } + } + } + + private func reconnect(_ socketID: SocketID, reconnectURL: URL) { + Task { + do { + let newSocketID = try await self.createConnection() + + // move all events to the new connection + connectionEvents[newSocketID] = connectionEvents[socketID] + + connections.removeValue(forKey: socketID) + connectionEvents.removeValue(forKey: socketID) + } catch { + finishConnection(socketID, throwing: .disconnected(with: error)) + } + } + } + + private func finishConnection(_ socketID: SocketID, throwing error: EventSubError) { + for (socket, events) in connectionEvents where socket == socketID { + events.forEach { event in + eventHandlers[event]?.finish(throwing: error) + } + } + + connectionEvents.removeValue(forKey: socketID) + connections.removeValue(forKey: socketID) + } +} diff --git a/Sources/Twitch/EventSub/EventSubConnection.swift b/Sources/Twitch/EventSub/EventSubConnection.swift new file mode 100644 index 0000000..a94ad3b --- /dev/null +++ b/Sources/Twitch/EventSub/EventSubConnection.swift @@ -0,0 +1,137 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +internal class EventSubConnection { + private let eventSubURL: URL + + private let credentials: TwitchCredentials + private let urlSession: URLSession + private let decoder: JSONDecoder + + private var keepaliveTimer: KeepaliveTimer? + private var websocket: URLSessionWebSocketTask? + private var socketID: String? + + private var onMessage: ((Result<EventSubNotification, EventSubConnectionError>) -> Void) + private var welcomeContinuation: CheckedContinuation<EventSubWelcome, any Error>? + + private var receivedMessageIDs = [String]() + + // TODO: look into deinitilizations + deinit { + self.websocket?.cancel(with: .goingAway, reason: nil) + } + + init( + credentials: TwitchCredentials, urlSession: URLSession, decoder: JSONDecoder, + eventSubURL: URL, + onMessage: @escaping (Result<EventSubNotification, EventSubConnectionError>) -> Void + ) { + self.credentials = credentials + self.urlSession = urlSession + self.decoder = decoder + + self.eventSubURL = eventSubURL + + self.onMessage = onMessage + } + + internal func resume() async throws -> String { + if let socketID = self.socketID { return socketID } + + self.websocket = urlSession.webSocketTask(with: eventSubURL) + self.websocket?.receive(completionHandler: receiveMessage(_:)) + self.websocket?.resume() + + // Twitch sends keepalive messages in a specified time interval, + // if we don't receive a message within that interval, we should + // consider the connection to be timed out + self.keepaliveTimer = KeepaliveTimer(duration: .seconds(10)) { + self.onMessage( + .failure(EventSubConnectionError.timedOut(socketID: self.socketID ?? ""))) + self.websocket?.cancel() + } + + // wait for the welcome message to be received + let welcomeMessage = try await withCheckedThrowingContinuation { continuation in + self.welcomeContinuation = continuation + } + + // use a slightly longer keepalive timeout to account for network latency + let timeout = welcomeMessage.keepaliveTimeout + .seconds(1) + await self.keepaliveTimer?.reset(duration: timeout) + + self.socketID = welcomeMessage.sessionID + return welcomeMessage.sessionID + } + + private func receiveMessage( + _ result: Result<URLSessionWebSocketTask.Message, any Error> + ) { + switch result { + case .success(let message): + // reset the keepalive timer on every message + Task { await self.keepaliveTimer?.reset() } + + if let message = parseMessage(message) { + + // ignore duplicate messages + if !receivedMessageIDs.contains(message.id) { + handleMessage(message) + receivedMessageIDs.append(message.id) + + // only keep the last 100 message IDs + if receivedMessageIDs.count > 100 { + receivedMessageIDs.removeFirst() + } + } + } + + // recursively receive the next message + self.websocket?.receive(completionHandler: receiveMessage) + case .failure(let error): + let disconnectedError = EventSubConnectionError.disconnected( + with: error, socketID: socketID ?? "") + + if let welcomeContinuation { + welcomeContinuation.resume(throwing: disconnectedError) + self.welcomeContinuation = nil + } + + onMessage(.failure(disconnectedError)) + } + } + + private func parseMessage(_ message: URLSessionWebSocketTask.Message) + -> EventSubMessage? + { + switch message { + case .string(let string): + return try? decoder.decode(EventSubMessage.self, from: Data(string.utf8)) + // ignore binary messages, Twitch only sends JSON + case .data: return nil + @unknown default: return nil + } + } + + private func handleMessage(_ message: EventSubMessage) { + switch message.payload { + case .keepalive: break // nothing to do for keepalive messages + case .welcome(let welcome): + welcomeContinuation?.resume(returning: welcome) + welcomeContinuation = nil + case .notification(let notification): + onMessage(.success(notification)) + case .revocation(let revocation): + onMessage(.failure(EventSubConnectionError.revocation(revocation))) + case .reconnect(let reconnect): + onMessage( + .failure( + EventSubConnectionError.reconnectRequested( + reconnectURL: reconnect.reconnectURL, socketID: socketID ?? ""))) + } + } +} diff --git a/Sources/Twitch/EventSub/EventSubError.swift b/Sources/Twitch/EventSub/EventSubError.swift new file mode 100644 index 0000000..b7e1482 --- /dev/null +++ b/Sources/Twitch/EventSub/EventSubError.swift @@ -0,0 +1,14 @@ +import Foundation + +public enum EventSubError: Error { + case revocation(EventSubRevocation) + case disconnected(with: Error) + case timedOut +} + +internal enum EventSubConnectionError: Error { + case revocation(EventSubRevocation) + case reconnectRequested(reconnectURL: URL, socketID: String) + case timedOut(socketID: String) + case disconnected(with: Error, socketID: String) +} diff --git a/Sources/Twitch/EventSub/EventSubHandler.swift b/Sources/Twitch/EventSub/EventSubHandler.swift new file mode 100644 index 0000000..249e280 --- /dev/null +++ b/Sources/Twitch/EventSub/EventSubHandler.swift @@ -0,0 +1,50 @@ +internal protocol EventSubHandler { + func yield(_ event: Event) + func finish(throwing error: EventSubError) +} + +internal struct EventSubCallbackHandler<T>: EventSubHandler { + var callback: (Result<T, EventSubError>) -> Void + + func yield(_ event: Event) { + if let event = event as? T { + callback(.success(event)) + } + } + + func finish(throwing error: EventSubError) { + callback(.failure(error)) + } +} + +internal struct EventSubContinuationHandler<T>: EventSubHandler { + var continuation: AsyncThrowingStream<T, any Error>.Continuation + + func yield(_ event: Event) { + if let event = event as? T { + continuation.yield(event) + } + } + + func finish(throwing error: EventSubError) { + continuation.finish(throwing: error) + } +} + +#if canImport(Combine) + import Combine + + internal struct EventSubSubjectHandler<T>: EventSubHandler { + var subject: PassthroughSubject<T, EventSubError> + + func yield(_ event: Event) { + if let event = event as? T { + subject.send(event) + } + } + + func finish(throwing error: EventSubError) { + subject.send(completion: .failure(error)) + } + } +#endif diff --git a/Sources/Twitch/EventSub/EventSubSubscriptionType.swift b/Sources/Twitch/EventSub/EventSubSubscriptionType.swift deleted file mode 100644 index ac86e0b..0000000 --- a/Sources/Twitch/EventSub/EventSubSubscriptionType.swift +++ /dev/null @@ -1,14 +0,0 @@ -public struct EventSubSubscriptionType { - let type: String - let version: String - let condition: [String: String] -} - -extension EventSubSubscriptionType { - public static func channelUpdate(broadcasterID: UserID, version: String = "2") -> Self { - .init( - type: "channel.update", version: version, - condition: ["broadcaster_user_id": broadcasterID]) - } - -} diff --git a/Sources/Twitch/EventSub/Events/Channel/ChannelFollowEvent.swift b/Sources/Twitch/EventSub/Events/Channel/ChannelFollowEvent.swift new file mode 100644 index 0000000..284cfdb --- /dev/null +++ b/Sources/Twitch/EventSub/Events/Channel/ChannelFollowEvent.swift @@ -0,0 +1,8 @@ +public struct ChannelFollowEvent: Event { + public let userID: String + public let userName: String + public let userDisplayName: String + public let broadcasterID: String + public let broadcasterName: String + public let broadcasterDisplayName: String +} diff --git a/Sources/Twitch/EventSub/Events/Channel/ChannelUpdateEvent.swift b/Sources/Twitch/EventSub/Events/Channel/ChannelUpdateEvent.swift new file mode 100644 index 0000000..f7093d4 --- /dev/null +++ b/Sources/Twitch/EventSub/Events/Channel/ChannelUpdateEvent.swift @@ -0,0 +1,3 @@ +public struct ChannelUpdateEvent: Event { + +} diff --git a/Sources/Twitch/EventSub/Events/Chat/ChatClearEvent.swift b/Sources/Twitch/EventSub/Events/Chat/ChatClearEvent.swift new file mode 100644 index 0000000..a078ac1 --- /dev/null +++ b/Sources/Twitch/EventSub/Events/Chat/ChatClearEvent.swift @@ -0,0 +1,11 @@ +public struct ChatClearEvent: Event { + public let broadcasterID: String + public let broadcasterLogin: String + public let broadcasterName: String + + enum CodingKeys: String, CodingKey { + case broadcasterID = "broadcasterUserId" + case broadcasterLogin = "broadcasterUserLogin" + case broadcasterName = "broadcasterUserName" + } +} diff --git a/Sources/Twitch/EventSub/Events/Chat/ChatMessageEvent.swift b/Sources/Twitch/EventSub/Events/Chat/ChatMessageEvent.swift new file mode 100644 index 0000000..710ccf1 --- /dev/null +++ b/Sources/Twitch/EventSub/Events/Chat/ChatMessageEvent.swift @@ -0,0 +1,3 @@ +public struct ChatMessageEvent: Event { + let chatterUserLogin: String +} diff --git a/Sources/Twitch/EventSub/Events/Event.swift b/Sources/Twitch/EventSub/Events/Event.swift new file mode 100644 index 0000000..591ede0 --- /dev/null +++ b/Sources/Twitch/EventSub/Events/Event.swift @@ -0,0 +1,17 @@ +public protocol Event: Decodable {} + +internal enum EventType: String, Decodable { + case channelFollow = "channel.follow" + case chatMessage = "channel.chat.message" + case channelUpdate = "channel.update" + case chatClear = "channel.chat.clear" + + var event: Event.Type { + switch self { + case .channelFollow: return ChannelFollowEvent.self + case .chatMessage: return ChatMessageEvent.self + case .channelUpdate: return ChannelUpdateEvent.self + case .chatClear: return ChatClearEvent.self + } + } +} diff --git a/Sources/Twitch/EventSub/KeepaliveTimer.swift b/Sources/Twitch/EventSub/KeepaliveTimer.swift new file mode 100644 index 0000000..f527ed7 --- /dev/null +++ b/Sources/Twitch/EventSub/KeepaliveTimer.swift @@ -0,0 +1,29 @@ +internal actor KeepaliveTimer { + private var task: Task<Void, any Error> + private var duration: Duration + + private let onTimeout: () -> Void + + init(duration: Duration = .seconds(10), onTimeout: @escaping () -> Void) { + self.duration = duration + self.onTimeout = onTimeout + + self.task = Task { + try await Task.sleep(for: duration) + } + } + + func reset(duration: Duration? = nil) { + if let duration { + self.duration = duration + } + + self.task.cancel() + + self.task = Task { + try await Task.sleep(for: self.duration) + + self.onTimeout() + } + } +} diff --git a/Sources/Twitch/EventSub/Subscriptions/+channel.swift b/Sources/Twitch/EventSub/Subscriptions/+channel.swift new file mode 100644 index 0000000..bfb731d --- /dev/null +++ b/Sources/Twitch/EventSub/Subscriptions/+channel.swift @@ -0,0 +1,19 @@ +extension EventSubSubscription where EventNotification == ChannelUpdateEvent { + public static func channelUpdate(broadcasterID: UserID, version: String = "2") -> Self { + .init( + type: EventType.channelUpdate.rawValue, version: version, + condition: [ + "broadcaster_user_id": broadcasterID + ]) + } +} + +extension EventSubSubscription where EventNotification == ChannelFollowEvent { + public static func channelFollow(broadcasterID: UserID, version: String = "1") -> Self { + .init( + type: EventType.channelFollow.rawValue, version: version, + condition: [ + "broadcaster_user_id": broadcasterID + ]) + } +} diff --git a/Sources/Twitch/EventSub/Subscriptions/+chat.swift b/Sources/Twitch/EventSub/Subscriptions/+chat.swift new file mode 100644 index 0000000..d56fae5 --- /dev/null +++ b/Sources/Twitch/EventSub/Subscriptions/+chat.swift @@ -0,0 +1,25 @@ +extension EventSubSubscription where EventNotification == ChatClearEvent { + public static func chatClear( + broadcasterID: UserID, userID: UserID, version: String = "1" + ) -> Self { + .init( + type: EventType.chatClear.rawValue, version: version, + condition: [ + "broadcaster_user_id": broadcasterID, + "user_id": userID, + ]) + } +} + +extension EventSubSubscription where EventNotification == ChatMessageEvent { + public static func chatMessage( + broadcasterID: UserID, userID: UserID, version: String = "1" + ) -> Self { + .init( + type: EventType.chatMessage.rawValue, version: version, + condition: [ + "broadcaster_user_id": broadcasterID, + "user_id": userID, + ]) + } +} diff --git a/Sources/Twitch/EventSub/Subscriptions/EventSubSubscription.swift b/Sources/Twitch/EventSub/Subscriptions/EventSubSubscription.swift new file mode 100644 index 0000000..0bea760 --- /dev/null +++ b/Sources/Twitch/EventSub/Subscriptions/EventSubSubscription.swift @@ -0,0 +1,5 @@ +public struct EventSubSubscription<EventNotification: Event> { + let type: String + let version: String + let condition: [String: String] +} diff --git a/Sources/Twitch/EventSub/TwitchClient+EventSub.swift b/Sources/Twitch/EventSub/TwitchClient+EventSub.swift new file mode 100644 index 0000000..956d24f --- /dev/null +++ b/Sources/Twitch/EventSub/TwitchClient+EventSub.swift @@ -0,0 +1,58 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +#if canImport(Combine) + import Combine +#endif + +extension TwitchClient { + public func eventStream<R: Decodable>(for event: EventSubSubscription<R>) async throws + -> AsyncThrowingStream<R, any Error> + { + let (response, socketID) = try await self.subscribe(to: event) + + return AsyncThrowingStream { continuation in + let handler = EventSubContinuationHandler(continuation: continuation) + self.eventSubClient.addHandler(handler, for: response.subscription.id, on: socketID) + } + } + + public func eventListener<R>( + on event: EventSubSubscription<R>, + eventHandler: @escaping (Result<R, EventSubError>) -> Void + ) async throws { + let (response, socketID) = try await self.subscribe(to: event) + + let handler = EventSubCallbackHandler(callback: eventHandler) + self.eventSubClient.addHandler(handler, for: response.subscription.id, on: socketID) + } + + #if canImport(Combine) + public func eventPublisher<R>(for event: EventSubSubscription<R>) async throws + -> AnyPublisher<R, EventSubError> + { + let (response, socketID) = try await self.subscribe(to: event) + + let subject = PassthroughSubject<R, EventSubError>() + let handler = EventSubSubjectHandler(subject: subject) + + self.eventSubClient.addHandler(handler, for: response.subscription.id, on: socketID) + + return subject.eraseToAnyPublisher() + } + #endif + + private func subscribe(to event: EventSubSubscription<some Decodable>) async throws + -> (response: CreateEventSubResponse, socketID: String) + { + let socketID = try await self.eventSubClient.getFreeWebsocketID() + + let response = try await self.helix( + endpoint: .createEventSubSubscription(using: .websocket(id: socketID), type: event)) + + return (response, socketID) + } +} diff --git a/Sources/Twitch/EventSub/message/EventSubMessage.swift b/Sources/Twitch/EventSub/message/EventSubMessage.swift new file mode 100644 index 0000000..48a8265 --- /dev/null +++ b/Sources/Twitch/EventSub/message/EventSubMessage.swift @@ -0,0 +1,58 @@ +import Foundation + +internal struct EventSubMessage: Decodable { + let id: String + let timestamp: Date + + let payload: EventSubPayload + + enum CodingKeys: String, CodingKey { + case metadata, payload + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let metadata = try container.decode(Metadata.self, forKey: .metadata) + + self.id = metadata.messageID + self.timestamp = metadata.messageTimestamp + + // depending on the type of message, we need to decode a different payload + switch metadata.messageType { + case .welcome: + self.payload = .welcome( + try container.decode(EventSubWelcome.self, forKey: .payload)) + case .keepalive: + self.payload = .keepalive + case .notification: + self.payload = .notification( + try container.decode(EventSubNotification.self, forKey: .payload)) + case .reconnect: + self.payload = .reconnect( + try container.decode(EventSubReconnect.self, forKey: .payload)) + case .revocation: + self.payload = .revocation( + try container.decode(EventSubRevocation.self, forKey: .payload)) + } + } + + internal struct Metadata: Decodable { + let messageID: String + let messageType: MessageType + let messageTimestamp: Date + + enum CodingKeys: String, CodingKey { + case messageID = "messageId" + case messageType, messageTimestamp + } + + enum MessageType: String, Decodable { + case welcome = "session_welcome" + case keepalive = "session_keepalive" + case notification = "notification" + case reconnect = "session_reconnect" + case revocation = "revocation" + } + } +} diff --git a/Sources/Twitch/EventSub/message/payloads/EventSubNotification.swift b/Sources/Twitch/EventSub/message/payloads/EventSubNotification.swift new file mode 100644 index 0000000..4bcaf7e --- /dev/null +++ b/Sources/Twitch/EventSub/message/payloads/EventSubNotification.swift @@ -0,0 +1,23 @@ +import Foundation + +internal struct EventSubNotification: Decodable { + let subscription: Subscription + let event: Event + + enum CodingKeys: CodingKey { + case subscription, event + } + + internal init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.subscription = try container.decode(Subscription.self, forKey: .subscription) + self.event = try container.decode(self.subscription.type.event, forKey: .event) + } + + struct Subscription: Decodable { + let id: String + let type: EventType + let version: String + } +} diff --git a/Sources/Twitch/EventSub/message/payloads/EventSubPayload.swift b/Sources/Twitch/EventSub/message/payloads/EventSubPayload.swift new file mode 100644 index 0000000..d54fab8 --- /dev/null +++ b/Sources/Twitch/EventSub/message/payloads/EventSubPayload.swift @@ -0,0 +1,7 @@ +internal enum EventSubPayload { + case welcome(EventSubWelcome) + case keepalive + case notification(EventSubNotification) + case reconnect(EventSubReconnect) + case revocation(EventSubRevocation) +} diff --git a/Sources/Twitch/EventSub/message/payloads/EventSubReconnect.swift b/Sources/Twitch/EventSub/message/payloads/EventSubReconnect.swift new file mode 100644 index 0000000..e909ec0 --- /dev/null +++ b/Sources/Twitch/EventSub/message/payloads/EventSubReconnect.swift @@ -0,0 +1,24 @@ +import Foundation + +internal struct EventSubReconnect: Decodable { + let sessionID: String + let reconnectURL: URL + + struct Session: Decodable { + let id: String + let reconnectUrl: URL + let connectedAt: Date + } + + enum CodingKeys: CodingKey { + case session + } + + internal init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let session = try container.decode(Session.self, forKey: .session) + + self.sessionID = session.id + self.reconnectURL = session.reconnectUrl + } +} diff --git a/Sources/Twitch/EventSub/message/payloads/EventSubRevocation.swift b/Sources/Twitch/EventSub/message/payloads/EventSubRevocation.swift new file mode 100644 index 0000000..054197e --- /dev/null +++ b/Sources/Twitch/EventSub/message/payloads/EventSubRevocation.swift @@ -0,0 +1,29 @@ +import Foundation + +public struct EventSubRevocation: Decodable { + internal let subscriptionID: String + public let status: Status + + public enum Status: String, Decodable { + case authorizationRevoked = "authorization_revoked" + case userRemoved = "user_removed" + case versionRemoved = "version_removed" + } + + struct Subscription: Decodable { + let id: String + let status: Status + } + + enum CodingKeys: CodingKey { + case subscription + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let subscription = try container.decode(Subscription.self, forKey: .subscription) + + self.status = subscription.status + self.subscriptionID = subscription.id + } +} diff --git a/Sources/Twitch/EventSub/message/payloads/EventSubWelcome.swift b/Sources/Twitch/EventSub/message/payloads/EventSubWelcome.swift new file mode 100644 index 0000000..9c3bbb4 --- /dev/null +++ b/Sources/Twitch/EventSub/message/payloads/EventSubWelcome.swift @@ -0,0 +1,27 @@ +import Foundation + +internal struct EventSubWelcome: Decodable { + let sessionID: String + let keepaliveTimeout: Duration + let connectedAt: Date + + internal struct Session: Decodable { + let id: String + let keepaliveTimeoutSeconds: Int + + let connectedAt: Date + } + + enum CodingKeys: CodingKey { + case session + } + + internal init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let session = try container.decode(Session.self, forKey: .session) + + self.sessionID = session.id + self.keepaliveTimeout = .seconds(session.keepaliveTimeoutSeconds) + self.connectedAt = session.connectedAt + } +} diff --git a/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+getAdSchedule.swift b/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+getAdSchedule.swift index e271f1e..b4acb40 100644 --- a/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+getAdSchedule.swift +++ b/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+getAdSchedule.swift @@ -22,13 +22,4 @@ public struct AdSchedule: Decodable { public let prerollFreeTime: Int public let snoozeCount: Int public let snoozeRefreshAt: Date - - enum CodingKeys: String, CodingKey { - case nextAdAt = "next_ad_at" - case lastAdAt = "last_ad_at" - case duration - case prerollFreeTime = "preroll_free_time" - case snoozeCount = "snooze_count" - case snoozeRefreshAt = "snooze_refresh_at" - } } diff --git a/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+snoozeNextAd.swift b/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+snoozeNextAd.swift index e0dd4d4..301462e 100644 --- a/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+snoozeNextAd.swift +++ b/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+snoozeNextAd.swift @@ -26,10 +26,4 @@ public struct SnoozeResult: Decodable { public let snoozeCount: Int public let snoozeRefreshAt: Date public let nextAdAt: Date - - enum CodingKeys: String, CodingKey { - case snoozeCount = "snooze_count" - case snoozeRefreshAt = "snooze_refresh_at" - case nextAdAt = "next_ad_at" - } } diff --git a/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+startCommercial.swift b/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+startCommercial.swift index aa8f325..9979db8 100644 --- a/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+startCommercial.swift +++ b/Sources/Twitch/Helix/Endpoints/Ads/HelixEndpoint+startCommercial.swift @@ -26,7 +26,7 @@ private struct StartCommercialRequestBody: Encodable { let length: Int enum CodingKeys: String, CodingKey { - case broadcasterID = "broadcaster_id" + case broadcasterID = "broadcasterId" case length } } @@ -35,10 +35,4 @@ public struct Commercial: Decodable { public let length: Int public let message: String public let retryAfter: Int - - enum CodingKeys: String, CodingKey { - case length - case message - case retryAfter = "retry_after" - } } diff --git a/Sources/Twitch/Helix/Endpoints/Analytics/HelixEndpoint+getExtensionAnalytics.swift b/Sources/Twitch/Helix/Endpoints/Analytics/HelixEndpoint+getExtensionAnalytics.swift index c28130a..f054e6d 100644 --- a/Sources/Twitch/Helix/Endpoints/Analytics/HelixEndpoint+getExtensionAnalytics.swift +++ b/Sources/Twitch/Helix/Endpoints/Analytics/HelixEndpoint+getExtensionAnalytics.swift @@ -32,15 +32,14 @@ public struct ExtensionReport: Decodable { public let range: DateInterval enum CodingKeys: String, CodingKey { - case extensionID = "extension_id" + case extensionID = "extensionId" case url = "URL" case type - case range = "date_range" + case range = "dateRange" } enum DateRangeCodingKeys: String, CodingKey { - case startedAt = "started_at" - case endedAt = "ended_at" + case startedAt, endedAt } public init(from decoder: Decoder) throws { diff --git a/Sources/Twitch/Helix/Endpoints/Analytics/HelixEndpoint+getGameAnalytics.swift b/Sources/Twitch/Helix/Endpoints/Analytics/HelixEndpoint+getGameAnalytics.swift index 83a95a1..06c1641 100644 --- a/Sources/Twitch/Helix/Endpoints/Analytics/HelixEndpoint+getGameAnalytics.swift +++ b/Sources/Twitch/Helix/Endpoints/Analytics/HelixEndpoint+getGameAnalytics.swift @@ -31,15 +31,14 @@ public struct GameReport: Decodable { public let range: DateInterval enum CodingKeys: String, CodingKey { - case gameID = "game_id" + case gameID = "gameId" case url = "URL" case type - case range = "date_range" + case range = "dateRange" } enum DateRangeCodingKeys: String, CodingKey { - case startedAt = "started_at" - case endedAt = "ended_at" + case startedAt, endedAt } public init(from decoder: Decoder) throws { diff --git a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannelEditors.swift b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannelEditors.swift index e4b0bc7..caf22bf 100644 --- a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannelEditors.swift +++ b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannelEditors.swift @@ -20,8 +20,7 @@ public struct Editor: Decodable { public let createdAt: Date enum CodingKeys: String, CodingKey { - case userID = "user_id" - case userName = "user_name" - case createdAt = "created_at" + case userID = "userId" + case userName, createdAt } } diff --git a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannelFollowers.swift b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannelFollowers.swift index 242fcd6..b9c4b0d 100644 --- a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannelFollowers.swift +++ b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannelFollowers.swift @@ -61,9 +61,7 @@ public struct Follower: Decodable { public let followedAt: Date enum CodingKeys: String, CodingKey { - case userID = "user_id" - case userLogin = "user_login" - case userName = "user_name" - case followedAt = "followed_at" + case userID = "userId" + case userLogin, userName, followedAt } } diff --git a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannels.swift b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannels.swift index 94190d6..e15149d 100644 --- a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannels.swift +++ b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getChannels.swift @@ -26,14 +26,11 @@ public struct Broadcaster: Decodable { public let tags: [String] enum CodingKeys: String, CodingKey { - case id = "broadcaster_id" - case login = "broadcaster_login" - case name = "broadcaster_name" - case language = "broadcaster_language" - case gameID = "game_id" - case gameName = "game_name" - case title - case delay - case tags + case id = "broadcasterId" + case login = "broadcasterLogin" + case name = "broadcasterName" + case language = "broadcasterLanguage" + case gameID = "gameId" + case gameName, title, delay, tags } } diff --git a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getFollowedChannels.swift b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getFollowedChannels.swift index f6cf818..84accb5 100644 --- a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getFollowedChannels.swift +++ b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+getFollowedChannels.swift @@ -60,9 +60,7 @@ public struct Follow: Decodable { public let followedAt: Date enum CodingKeys: String, CodingKey { - case broadcasterID = "broadcaster_id" - case broadcasterLogin = "broadcaster_login" - case broadcasterName = "broadcaster_name" - case followedAt = "followed_at" + case broadcasterID = "broadcasterId" + case broadcasterLogin, broadcasterName, followedAt } } diff --git a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+updateChannel.swift b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+updateChannel.swift index 2753d31..d8e138a 100644 --- a/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+updateChannel.swift +++ b/Sources/Twitch/Helix/Endpoints/Channels/HelixEndpoint+updateChannel.swift @@ -60,18 +60,14 @@ internal struct UpdateChannelRequestBody: Encodable { } enum CodingKeys: String, CodingKey { - case gameID = "game_id" - case broadcasterLanguage = "broadcaster_language" - case title - case delay - case tags - case contentClassificationLabels = "content_classification_labels" - case isBrandedContent = "is_branded_content" + case gameID = "gameId" + case broadcasterLanguage, title, delay, tags + case contentClassificationLabels + case isBrandedContent } enum LabelCodingKeys: String, CodingKey { - case id - case isEnabled = "is_enabled" + case id, isEnabled } func encode(to encoder: Encoder) throws { diff --git a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChannelBadges.swift b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChannelBadges.swift index d71cdf8..87fc779 100644 --- a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChannelBadges.swift +++ b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChannelBadges.swift @@ -18,7 +18,7 @@ public struct BadgeSet: Decodable { public let badges: [Badge] enum CodingKeys: String, CodingKey { - case setID = "set_id" + case setID = "setId" case badges = "versions" } } @@ -33,15 +33,10 @@ public struct Badge: Decodable { public let clickUrl: String? enum CodingKeys: String, CodingKey { - case id = "id" - case imageUrl1x = "image_url_1x" - case imageUrl2x = "image_url_2x" - case imageUrl4x = "image_url_4x" - case title = "title" - case description = "description" - - case clickAction = "click_action" - case clickUrl = "click_url" + case id, title, description, clickAction, clickUrl + case imageUrl1x = "imageUrl1X" + case imageUrl2x = "imageUrl2X" + case imageUrl4x = "imageUrl4X" } public init(from decoder: Decoder) throws { diff --git a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChannelEmotes.swift b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChannelEmotes.swift index c6e09e2..a48c1f4 100644 --- a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChannelEmotes.swift +++ b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChannelEmotes.swift @@ -36,14 +36,10 @@ public struct ChannelEmote: Decodable { public let themeMode: [Emote.ThemeMode] enum CodingKeys: String, CodingKey { - case id = "id" - case name = "name" - case tier = "tier" - case type = "emote_type" - case setID = "emote_set_id" - case format = "format" - case scale = "scale" - case themeMode = "theme_mode" + case id, name, tier + case type = "emoteType" + case setID = "emoteSetId" + case format, scale, themeMode } public func getURL( diff --git a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChatSettings.swift b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChatSettings.swift index 1ec8ac2..38636cd 100644 --- a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChatSettings.swift +++ b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChatSettings.swift @@ -34,14 +34,14 @@ public struct ChatSettings: Decodable { public let nonModeratorChatDelay: Int? enum CodingKeys: String, CodingKey { - case broadcasterID = "broadcaster_id" - case slowModeWaitTime = "slow_mode_wait_time" - case followerModeDuration = "follower_mode_duration" - case subscriberMode = "subscriber_mode" - case emoteMode = "emote_mode" - case uniqueChatMode = "unique_chat_mode" - - case nonModeratorChatDelayDuration = "non_moderator_chat_delay_duration" + case broadcasterID = "broadcasterId" + case slowModeWaitTime + case followerModeDuration + case subscriberMode + case emoteMode + case uniqueChatMode + + case nonModeratorChatDelayDuration } public init(from decoder: Decoder) throws { diff --git a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChatters.swift b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChatters.swift index 1bdbccb..ab9291d 100644 --- a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChatters.swift +++ b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getChatters.swift @@ -47,8 +47,7 @@ public struct Chatter: Decodable { public let userName: String enum CodingKeys: String, CodingKey { - case userID = "user_id" - case userLogin = "user_login" - case userName = "user_name" + case userID = "userId" + case userLogin, userName } } diff --git a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getEmoteSets.swift b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getEmoteSets.swift index 8b81b10..bef9c6c 100644 --- a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getEmoteSets.swift +++ b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getEmoteSets.swift @@ -42,14 +42,11 @@ public struct SetEmote: Decodable { public let themeMode: [Emote.ThemeMode] enum CodingKeys: String, CodingKey { - case id = "id" - case name = "name" - case type = "emote_type" - case setID = "emote_set_id" - case ownerID = "owner_id" - case format = "format" - case scale = "scale" - case themeMode = "theme_mode" + case id, name + case type = "emoteType" + case setID = "emoteSetId" + case ownerID = "ownerId" + case format, scale, themeMode } public func getURL( diff --git a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getGlobalEmotes.swift b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getGlobalEmotes.swift index e5bb93f..050e02e 100644 --- a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getGlobalEmotes.swift +++ b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getGlobalEmotes.swift @@ -32,14 +32,6 @@ public struct GlobalEmote: Decodable { public let scale: [Emote.Scale] public let themeMode: [Emote.ThemeMode] - enum CodingKeys: String, CodingKey { - case id = "id" - case name = "name" - case format = "format" - case scale = "scale" - case themeMode = "theme_mode" - } - public func getURL( from templateUrl: String, format: Emote.Format = .png, scale: Emote.Scale = .large, themeMode: Emote.ThemeMode = .dark diff --git a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getUserColor.swift b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getUserColor.swift index 2e337b5..e0b777f 100644 --- a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getUserColor.swift +++ b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+getUserColor.swift @@ -22,9 +22,7 @@ public struct UserColor: Decodable { public let color: String enum CodingKeys: String, CodingKey { - case userID = "user_id" - case userLogin = "user_login" - case userName = "user_name" - case color + case userID = "userId" + case userLogin, userName, color } } diff --git a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+sendChatMessage.swift b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+sendChatMessage.swift index f043211..773cb6d 100644 --- a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+sendChatMessage.swift +++ b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+sendChatMessage.swift @@ -35,10 +35,10 @@ public struct ChatMessageResponse: Decodable { public let dropReason: DropReason? enum CodingKeys: String, CodingKey { - case messageID = "message_id" - case isSent = "is_sent" + case messageID = "messageId" + case isSent - case dropReason = "drop_reason" + case dropReason } public struct DropReason: Decodable { @@ -55,10 +55,10 @@ internal struct SendChatMessageRequestBody: Encodable { let replyParentMessageID: String? enum CodingKeys: String, CodingKey { - case broadcasterID = "broadcaster_id" - case senderID = "sender_id" + case broadcasterID = "broadcasterId" + case senderID = "senderId" case message - case replyParentMessageID = "reply_parent_message_id" + case replyParentMessageID = "replyParentMessageId" } } diff --git a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+updateChatSettings.swift b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+updateChatSettings.swift index 5acbd97..ec34edc 100644 --- a/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+updateChatSettings.swift +++ b/Sources/Twitch/Helix/Endpoints/Chat/HelixEndpoint+updateChatSettings.swift @@ -58,19 +58,6 @@ internal struct UpdateChatSettingsRequestBody: Encodable { var nonModeratorChatDelay: Bool? var nonModeratorChatDelayDuration: Int? - enum CodingKeys: String, CodingKey { - case slowMode = "slow_mode" - case slowModeWaitTime = "slow_mode_wait_time" - case followerMode = "follower_mode" - case followerModeDuration = "follower_mode_duration" - case subscriberMode = "subscriber_mode" - case emoteMode = "emote_mode" - case uniqueChatMode = "unique_chat_mode" - - case nonModeratorChatDelay = "non_moderator_chat_delay" - case nonModeratorChatDelayDuration = "non_moderator_chat_delay_duration" - } - init(_ settings: [ChatSetting]) { for setting in settings { switch setting { diff --git a/Sources/Twitch/Helix/Endpoints/EventSub/HelixEndpoint+createEventSubSubscription.swift b/Sources/Twitch/Helix/Endpoints/EventSub/HelixEndpoint+createEventSubSubscription.swift index f810c7f..4746e27 100644 --- a/Sources/Twitch/Helix/Endpoints/EventSub/HelixEndpoint+createEventSubSubscription.swift +++ b/Sources/Twitch/Helix/Endpoints/EventSub/HelixEndpoint+createEventSubSubscription.swift @@ -3,10 +3,11 @@ import Foundation extension HelixEndpoint where EndpointResponseType == HelixEndpointResponseTypes.Normal, - ResponseType == CreateEventSubResponse, HelixResponseType == EventSubSubscription + ResponseType == CreateEventSubResponse, + HelixResponseType == CreateEventSubResponse.EventSubSubscription { public static func createEventSubSubscription( - using transport: EventSubTransport, type: EventSubSubscriptionType + using transport: EventSubTransport, type: EventSubSubscription<some Event> ) -> Self { .init( method: "POST", path: "eventsub/subscriptions", @@ -20,7 +21,7 @@ where throw HelixError.noDataInResponse } - guard let cost = $0.cost, + guard let total = $0.total, let totalCost = $0.totalCost, let maxTotalCost = $0.maxTotalCost else { @@ -28,7 +29,7 @@ where } return CreateEventSubResponse( - subscription: subscription, cost: cost, totalCost: totalCost, + subscription: subscription, total: total, totalCost: totalCost, maxTotalCost: maxTotalCost) }) } @@ -47,10 +48,10 @@ public enum EventSubTransport { conduitID: nil) case .websocket(let id): return .init( - method: "webhook", callback: nil, secret: nil, sessionID: id, conduitID: nil) + method: "websocket", callback: nil, secret: nil, sessionID: id, conduitID: nil) case .conduit(let id): return .init( - method: "webhook", callback: nil, secret: nil, sessionID: nil, conduitID: id) + method: "conduit", callback: nil, secret: nil, sessionID: nil, conduitID: id) } } } @@ -58,45 +59,45 @@ public enum EventSubTransport { public struct CreateEventSubResponse { public let subscription: EventSubSubscription - public let cost: Int + public let total: Int public let totalCost: Int public let maxTotalCost: Int -} -public struct EventSubSubscription: Decodable { - public let id: String - public let status: String - public let type: String - public let version: String - public let condition: [String: String] - public let createdAt: Date - public let transport: TransportResponse -} + public struct EventSubSubscription: Decodable { + public let id: String + public let status: String + public let type: String + public let version: String + public let condition: [String: String] + public let createdAt: Date + public let transport: TransportResponse + } -public struct TransportResponse: Decodable { - public let method: String + public struct TransportResponse: Decodable { + public let method: String - // only included if method is "webhook" - public let callback: String? - public let secret: String? + // only included if method is "webhook" + public let callback: String? + public let secret: String? - // only included if method is "websocket" - public let sessionID: String? - public let connectedAt: Date? + // only included if method is "websocket" + public let sessionID: String? + public let connectedAt: Date? - // only included if method is "conduit" - public let conduitID: String? + // only included if method is "conduit" + public let conduitID: String? - enum CodingKeys: String, CodingKey { - case method + enum CodingKeys: String, CodingKey { + case method - case callback - case secret + case callback + case secret - case sessionID = "session_id" - case connectedAt = "connected_at" + case sessionID = "sessionId" + case connectedAt - case conduitID = "conduit_id" + case conduitID = "conduitId" + } } } @@ -106,13 +107,6 @@ private struct CreateEventSubRequestBody: Encodable { let condition: [String: String] let transport: Transport - - enum CodingKeys: String, CodingKey { - case type - case version - case condition - case transport - } } private struct Transport: Encodable { @@ -123,10 +117,8 @@ private struct Transport: Encodable { let conduitID: String? enum CodingKeys: String, CodingKey { - case method - case callback - case secret - case sessionID = "session_id" - case conduitID = "conduit_id" + case method, callback, secret + case sessionID = "sessionId" + case conduitID = "conduitId" } } diff --git a/Sources/Twitch/Helix/Endpoints/Games/HelixEndpoint+getGames.swift b/Sources/Twitch/Helix/Endpoints/Games/HelixEndpoint+getGames.swift index 1cb67ab..d62b6f3 100644 --- a/Sources/Twitch/Helix/Endpoints/Games/HelixEndpoint+getGames.swift +++ b/Sources/Twitch/Helix/Endpoints/Games/HelixEndpoint+getGames.swift @@ -31,9 +31,7 @@ public struct Game: Decodable { public let igdbID: String enum CodingKeys: String, CodingKey { - case id - case name - case boxArtUrl = "box_art_url" - case igdbID = "igdb_id" + case id, name, boxArtUrl + case igdbID = "igdbId" } } diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+banUser.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+banUser.swift index fa2cc82..66207da 100644 --- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+banUser.swift +++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+banUser.swift @@ -39,9 +39,8 @@ private struct BanUserBody: Encodable { let duration: Duration? enum CodingKeys: String, CodingKey { - case userID = "user_id" - case reason - case duration + case userID = "userId" + case reason, duration } } @@ -53,10 +52,9 @@ public struct Ban: Decodable { public let endTime: Date? enum CodingKeys: String, CodingKey { - case broadcasterID = "broadcaster_id" - case moderatorID = "moderator_id" - case userID = "user_id" - case createdAt = "created_at" - case endTime = "end_time" + case broadcasterID = "broadcasterId" + case moderatorID = "moderatorId" + case userID = "userId" + case createdAt, endTime } } diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+checkAutomodStatus.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+checkAutomodStatus.swift index f8dd0b2..84c546e 100644 --- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+checkAutomodStatus.swift +++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+checkAutomodStatus.swift @@ -35,7 +35,7 @@ public struct AutomodStatus: Decodable { public let isPermitted: Bool enum CodingKeys: String, CodingKey { - case messageID = "msg_id" - case isPermitted = "is_permitted" + case messageID = "msgId" + case isPermitted } } diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getAutomodSettings.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getAutomodSettings.swift index 2a44964..54c69d5 100644 --- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getAutomodSettings.swift +++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getAutomodSettings.swift @@ -39,17 +39,11 @@ public struct AutomodSettings: Decodable { public let sexBasedTerms: Int enum CodingKeys: String, CodingKey { - case broadcasterID = "broadcaster_id" - case moderatorID = "moderator_id" - case overallLevel = "overall_level" + case broadcasterID = "broadcasterId" + case moderatorID = "moderatorId" + case overallLevel - case disability - case aggression - case sexualitySexOrGender = "sexuality_sex_or_gender" - case misogyny - case bullying - case swearing - case raceEthnicityOrReligion = "race_ethnicity_or_religion" - case sexBasedTerms = "sex_based_terms" + case disability, aggression, sexualitySexOrGender, misogyny, bullying, swearing + case raceEthnicityOrReligion, sexBasedTerms } } diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getBannedUsers.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getBannedUsers.swift index fc59b46..e40c8a3 100644 --- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getBannedUsers.swift +++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getBannedUsers.swift @@ -42,14 +42,12 @@ public struct BannedUser: Decodable { public let moderatorName: String enum CodingKeys: String, CodingKey { - case userID = "user_id" - case userLogin = "user_login" - case userName = "user_name" - case expiresAt = "expires_at" - case createdAt = "created_at" - case reason - case moderatorID = "moderator_id" - case moderatorLogin = "moderator_login" - case moderatorName = "moderator_name" + case userID = "userId" + case userLogin, userName + + case expiresAt, createdAt, reason + + case moderatorID = "moderatorId" + case moderatorLogin, moderatorName } } diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getBlockedTerms.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getBlockedTerms.swift index 2775746..6ff60db 100644 --- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getBlockedTerms.swift +++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getBlockedTerms.swift @@ -39,14 +39,9 @@ public struct BlockedTerm: Decodable { public let expiresAt: Date? enum CodingKeys: String, CodingKey { - case broadcasterID = "broadcaster_id" - case moderatorID = "moderator_id" + case broadcasterID = "broadcasterId" + case moderatorID = "moderatorId" - case id, text - - case createdAt = "created_at" - case updatedAt = "updated_at" - - case expiresAt = "expires_at" + case id, text, createdAt, updatedAt, expiresAt } } diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getModeratedChannels.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getModeratedChannels.swift index 5f834d6..5e147aa 100644 --- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getModeratedChannels.swift +++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getModeratedChannels.swift @@ -28,8 +28,8 @@ public struct ModeratedChannel: Decodable { public let displayName: String enum CodingKeys: String, CodingKey { - case id = "broadcaster_id" - case login = "broadcaster_login" - case displayName = "broadcaster_name" + case id = "broadcasterId" + case login = "broadcasterLogin" + case displayName = "broadcasterName" } } diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getModerators.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getModerators.swift index e671b8c..d7e2338 100644 --- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getModerators.swift +++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getModerators.swift @@ -28,8 +28,8 @@ public struct Moderator: Decodable { public let displayName: String enum CodingKeys: String, CodingKey { - case id = "user_id" - case login = "user_login" - case displayName = "user_name" + case id = "userId" + case login = "userLogin" + case displayName = "userName" } } diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getShieldModeStatus.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getShieldModeStatus.swift index c6c917a..9d84f73 100644 --- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getShieldModeStatus.swift +++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getShieldModeStatus.swift @@ -34,10 +34,8 @@ public struct ShieldModeStatus: Decodable { @NilOnTypeMismatch public var lastActivatedAt: Date? enum CodingKeys: String, CodingKey { - case isActive = "is_active" - case moderatorID = "moderator_id" - case moderatorName = "moderator_name" - case moderatorLogin = "moderator_login" - case lastActivatedAt = "last_activated_at" + case moderatorID = "moderatorId" + case moderatorName, moderatorLogin + case isActive, lastActivatedAt } } diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getVIPs.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getVIPs.swift index 861fef6..b2d9f2d 100644 --- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getVIPs.swift +++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+getVIPs.swift @@ -28,8 +28,8 @@ public struct VIP: Decodable { public let displayName: String enum CodingKeys: String, CodingKey { - case id = "user_id" - case login = "user_login" - case displayName = "user_name" + case id = "userId" + case login = "userLogin" + case displayName = "userName" } } diff --git a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+updateAutomodSettings.swift b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+updateAutomodSettings.swift index 9dcd8d1..53cefb7 100644 --- a/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+updateAutomodSettings.swift +++ b/Sources/Twitch/Helix/Endpoints/Moderation/HelixEndpoint+updateAutomodSettings.swift @@ -49,9 +49,6 @@ public struct AutomodConfiguration: Encodable { enum CodingKeys: String, CodingKey { case aggression, bullying, disability, misogyny, swearing - - case raceEthnicityOrReligion = "race_ethnicity_or_religion" - case sexBasedTerms = "sex_based_terms" - case sexualitySexOrGender = "sexuality_sex_or_gender" + case raceEthnicityOrReligion, sexBasedTerms, sexualitySexOrGender } } diff --git a/Sources/Twitch/Helix/Endpoints/Search/HelixEndpoint+searchCategories.swift b/Sources/Twitch/Helix/Endpoints/Search/HelixEndpoint+searchCategories.swift index 9fff95d..69d0282 100644 --- a/Sources/Twitch/Helix/Endpoints/Search/HelixEndpoint+searchCategories.swift +++ b/Sources/Twitch/Helix/Endpoints/Search/HelixEndpoint+searchCategories.swift @@ -28,10 +28,4 @@ public struct Category: Decodable { public let id: String public let name: String public let boxArtUrl: String - - enum CodingKeys: String, CodingKey { - case id - case name - case boxArtUrl = "box_art_url" - } } diff --git a/Sources/Twitch/Helix/Endpoints/Search/HelixEndpoint+searchChannels.swift b/Sources/Twitch/Helix/Endpoints/Search/HelixEndpoint+searchChannels.swift index 68a28bc..3f63122 100644 --- a/Sources/Twitch/Helix/Endpoints/Search/HelixEndpoint+searchChannels.swift +++ b/Sources/Twitch/Helix/Endpoints/Search/HelixEndpoint+searchChannels.swift @@ -45,32 +45,16 @@ public struct Channel: Decodable { enum CodingKeys: String, CodingKey { case id - case login = "broadcaster_login" - case name = "display_name" - case language = "broadcaster_language" + case login = "broadcasterLogin" + case name = "displayName" + case language = "broadcasterLanguage" - case gameID = "game_id" - case gameName = "game_name" + case gameID = "gameId" + case gameName - case isLive = "is_live" - case tags + case isLive, tags - case profilePictureURL = "thumbnail_url" - case title - case startedAt = "started_at" - } -} - -@propertyWrapper public struct NilOnTypeMismatch<Value> { - public var wrappedValue: Value? - public init(wrappedValue: Value?) { - self.wrappedValue = wrappedValue - } -} - -extension NilOnTypeMismatch: Decodable where Value: Decodable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - self.wrappedValue = try? container.decode(Value.self) + case profilePictureURL = "thumbnailUrl" + case title, startedAt } } diff --git a/Sources/Twitch/Helix/Endpoints/Streams/HelixEndpoint+getStreams.swift b/Sources/Twitch/Helix/Endpoints/Streams/HelixEndpoint+getStreams.swift index bede6e5..eeb49ee 100644 --- a/Sources/Twitch/Helix/Endpoints/Streams/HelixEndpoint+getStreams.swift +++ b/Sources/Twitch/Helix/Endpoints/Streams/HelixEndpoint+getStreams.swift @@ -61,21 +61,16 @@ public struct Stream: Decodable { enum CodingKeys: String, CodingKey { case id - case userID = "user_id" - case userLogin = "user_login" - case userName = "user_name" + case userID = "userId" + case userLogin, userName - case gameID = "game_id" - case gameName = "game_name" + case gameID = "gameId" + case gameName - case type - case title - case language - case tags - case isMature = "is_mature" + case type, title, language, tags, isMature - case viewerCount = "viewer_count" - case startedAt = "started_at" - case thumbnailURL = "thumbnail_url" + case viewerCount + case startedAt + case thumbnailURL = "thumbnailUrl" } } diff --git a/Sources/Twitch/Helix/Endpoints/Subscriptions/HelixEndpoint+checkSubscription.swift b/Sources/Twitch/Helix/Endpoints/Subscriptions/HelixEndpoint+checkSubscription.swift index 95843d3..95a86af 100644 --- a/Sources/Twitch/Helix/Endpoints/Subscriptions/HelixEndpoint+checkSubscription.swift +++ b/Sources/Twitch/Helix/Endpoints/Subscriptions/HelixEndpoint+checkSubscription.swift @@ -31,14 +31,12 @@ public struct Subscription: Decodable { public let tier: SubTier enum CodingKeys: String, CodingKey { - case broadcasterID = "broadcaster_id" - case broadcasterLogin = "broadcaster_login" - case broadcasterName = "broadcaster_name" + case broadcasterID = "broadcasterId" + case broadcasterLogin, broadcasterName - case isGift = "is_gift" - case gifterID = "gifter_id" - case gifterLogin = "gifter_login" - case gifterName = "gifter_name" + case isGift + case gifterID = "gifterId" + case gifterLogin, gifterName case tier } diff --git a/Sources/Twitch/Helix/Endpoints/Subscriptions/HelixEndpoint+getSubscribers.swift b/Sources/Twitch/Helix/Endpoints/Subscriptions/HelixEndpoint+getSubscribers.swift index d115106..debe85b 100644 --- a/Sources/Twitch/Helix/Endpoints/Subscriptions/HelixEndpoint+getSubscribers.swift +++ b/Sources/Twitch/Helix/Endpoints/Subscriptions/HelixEndpoint+getSubscribers.swift @@ -59,21 +59,17 @@ public struct Subscriber: Decodable { public let tier: SubTier enum CodingKeys: String, CodingKey { - case userID = "user_id" - case userLogin = "user_login" - case userName = "user_name" + case userID = "userId" + case userLogin, userName - case broadcasterID = "broadcaster_id" - case broadcasterLogin = "broadcaster_login" - case broadcasterName = "broadcaster_name" + case broadcasterID = "broadcasterId" + case broadcasterLogin, broadcasterName - case isGift = "is_gift" - case gifterID = "gifter_id" - case gifterLogin = "gifter_login" - case gifterName = "gifter_name" + case isGift + case gifterID = "gifterId" + case gifterLogin, gifterName - case planName = "plan_name" - case tier + case planName, tier } public init(from decoder: Decoder) throws { diff --git a/Sources/Twitch/Helix/Endpoints/Users/HelixEndpoint+getUserBlocklist.swift b/Sources/Twitch/Helix/Endpoints/Users/HelixEndpoint+getUserBlocklist.swift index 1142b96..2071347 100644 --- a/Sources/Twitch/Helix/Endpoints/Users/HelixEndpoint+getUserBlocklist.swift +++ b/Sources/Twitch/Helix/Endpoints/Users/HelixEndpoint+getUserBlocklist.swift @@ -29,8 +29,8 @@ public struct BlockedUser: Decodable { public let displayName: String enum CodingKeys: String, CodingKey { - case userID = "user_id" - case userLogin = "user_login" - case displayName = "display_name" + case userID = "userId" + case userLogin + case displayName } } diff --git a/Sources/Twitch/Helix/Endpoints/Users/HelixEndpoint+getUsers.swift b/Sources/Twitch/Helix/Endpoints/Users/HelixEndpoint+getUsers.swift index c4d014b..42e94fb 100644 --- a/Sources/Twitch/Helix/Endpoints/Users/HelixEndpoint+getUsers.swift +++ b/Sources/Twitch/Helix/Endpoints/Users/HelixEndpoint+getUsers.swift @@ -36,20 +36,4 @@ public struct User: Decodable { case affiliate case none = "" } - - enum CodingKeys: String, CodingKey { - case id - case login - case displayName = "display_name" - - case type - case broadcasterType = "broadcaster_type" - - case description - case profileImageUrl = "profile_image_url" - case offlineImageUrl = "offline_image_url" - case createdAt = "created_at" - - case email - } } diff --git a/Sources/Twitch/Helix/HelixEndpoint.swift b/Sources/Twitch/Helix/HelixEndpoint.swift index 43fe088..ca74d49 100644 --- a/Sources/Twitch/Helix/HelixEndpoint.swift +++ b/Sources/Twitch/Helix/HelixEndpoint.swift @@ -31,7 +31,10 @@ public struct HelixEndpoint< self.makeResponse = makeResponse } - internal func makeRequest(using authentication: TwitchCredentials) -> URLRequest { + internal func makeRequest( + using authentication: TwitchCredentials, + encoder: JSONEncoder + ) -> URLRequest { var url = baseURL.appending(path: path) let queryItems = @@ -51,7 +54,7 @@ public struct HelixEndpoint< if let body = makeBody(authentication) { urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") - urlRequest.httpBody = try? JSONEncoder().encode(body) + urlRequest.httpBody = try? encoder.encode(body) } return urlRequest @@ -85,17 +88,3 @@ public enum HelixEndpointResponseTypes { public typealias UserID = String public typealias PaginationCursor = String - -#if canImport(FoundationNetworking) - extension URL { - internal func appending(queryItems: [URLQueryItem]) -> URL { - var components = URLComponents(url: self, resolvingAgainstBaseURL: false)! - components.queryItems = (components.queryItems ?? []) + queryItems - return components.url! - } - - internal func appending(path: String) -> URL { - appendingPathComponent(path) - } - } -#endif diff --git a/Sources/Twitch/Helix/HelixResponse.swift b/Sources/Twitch/Helix/HelixResponse.swift index fad1b62..7e7dd40 100644 --- a/Sources/Twitch/Helix/HelixResponse.swift +++ b/Sources/Twitch/Helix/HelixResponse.swift @@ -1,26 +1,14 @@ +// TODO: include raw data in response for better errors internal struct HelixResponse<T: Decodable>: Decodable { let data: [T] let pagination: Pagination? - let total: Int? let points: Int? let template: String? - - let cost: Int? + let total: Int? let totalCost: Int? let maxTotalCost: Int? - enum CodingKeys: String, CodingKey { - case data - case pagination - case total - case points - case template - case cost - case totalCost = "total_cost" - case maxTotalCost = "max_total_cost" - } - internal struct Pagination: Decodable { let cursor: String? } } diff --git a/Sources/Twitch/Helix/TwitchClient+Helix.swift b/Sources/Twitch/Helix/TwitchClient+Helix.swift index 3fcf072..2dfcab5 100644 --- a/Sources/Twitch/Helix/TwitchClient+Helix.swift +++ b/Sources/Twitch/Helix/TwitchClient+Helix.swift @@ -106,7 +106,7 @@ extension TwitchClient { private func data( for endpoint: HelixEndpoint<some Any, some Decodable, some HelixEndpointResponseType> ) async throws -> Data { - let request = endpoint.makeRequest(using: self.authentication) + let request = endpoint.makeRequest(using: self.authentication, encoder: self.encoder) let (data, response): (Data, URLResponse) do { diff --git a/Sources/Twitch/Helix/Endpoints/Date+formatted.swift b/Sources/Twitch/Shared/Extensions/Date+formatted.swift similarity index 100% rename from Sources/Twitch/Helix/Endpoints/Date+formatted.swift rename to Sources/Twitch/Shared/Extensions/Date+formatted.swift diff --git a/Sources/Twitch/Helix/Formatter+iso8601withFractionalSeconds.swift b/Sources/Twitch/Shared/Extensions/Formatter+iso8601withFractionalSeconds.swift similarity index 100% rename from Sources/Twitch/Helix/Formatter+iso8601withFractionalSeconds.swift rename to Sources/Twitch/Shared/Extensions/Formatter+iso8601withFractionalSeconds.swift diff --git a/Sources/Twitch/Shared/Extensions/URL+appending.swift b/Sources/Twitch/Shared/Extensions/URL+appending.swift new file mode 100644 index 0000000..f829ba6 --- /dev/null +++ b/Sources/Twitch/Shared/Extensions/URL+appending.swift @@ -0,0 +1,16 @@ +#if canImport(FoundationNetworking) + import Foundation + import FoundationNetworking + + extension URL { + internal func appending(queryItems: [URLQueryItem]) -> URL { + var components = URLComponents(url: self, resolvingAgainstBaseURL: false)! + components.queryItems = (components.queryItems ?? []) + queryItems + return components.url! + } + + internal func appending(path: String) -> URL { + appendingPathComponent(path) + } + } +#endif diff --git a/Sources/Twitch/Helix/URLSession+data.swift b/Sources/Twitch/Shared/Extensions/URLSession+data.swift similarity index 100% rename from Sources/Twitch/Helix/URLSession+data.swift rename to Sources/Twitch/Shared/Extensions/URLSession+data.swift diff --git a/Sources/Twitch/Shared/Extensions/URLSessionWebSocketTask+receive.swift b/Sources/Twitch/Shared/Extensions/URLSessionWebSocketTask+receive.swift new file mode 100644 index 0000000..9cb741c --- /dev/null +++ b/Sources/Twitch/Shared/Extensions/URLSessionWebSocketTask+receive.swift @@ -0,0 +1,17 @@ +#if canImport(FoundationNetworking) + import FoundationNetworking + + extension URLSessionWebSocketTask { + func receive(completionHandler: @escaping (Result<Message, Error>) -> Void) { + Task { + do { + let message = try await self.receive() + completionHandler(.success(message)) + } catch { + completionHandler(.failure(error)) + } + } + } + } + +#endif diff --git a/Sources/Twitch/Shared/PropertyWrappers/NilOnTypeMismatch.swift b/Sources/Twitch/Shared/PropertyWrappers/NilOnTypeMismatch.swift new file mode 100644 index 0000000..db4c814 --- /dev/null +++ b/Sources/Twitch/Shared/PropertyWrappers/NilOnTypeMismatch.swift @@ -0,0 +1,13 @@ +@propertyWrapper public struct NilOnTypeMismatch<Value> { + public var wrappedValue: Value? + public init(wrappedValue: Value?) { + self.wrappedValue = wrappedValue + } +} + +extension NilOnTypeMismatch: Decodable where Value: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.wrappedValue = try? container.decode(Value.self) + } +} diff --git a/Sources/Twitch/TwitchClient.swift b/Sources/Twitch/TwitchClient.swift index d8fc455..fd20125 100644 --- a/Sources/Twitch/TwitchClient.swift +++ b/Sources/Twitch/TwitchClient.swift @@ -11,6 +11,8 @@ public actor TwitchClient { internal let encoder = JSONEncoder() internal let decoder = JSONDecoder() + internal var eventSubClient: EventSubClient + public init( authentication: TwitchCredentials, urlSession: URLSession = URLSession(configuration: .default) @@ -20,6 +22,11 @@ public actor TwitchClient { self.encoder.dateEncodingStrategy = .iso8601withFractionalSeconds self.decoder.dateDecodingStrategy = .iso8601withFractionalSeconds - } + self.decoder.keyDecodingStrategy = .convertFromSnakeCase + self.encoder.keyEncodingStrategy = .convertToSnakeCase + + self.eventSubClient = EventSubClient( + credentials: authentication, urlSession: urlSession, decoder: self.decoder) + } }