Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,21 @@ on:
jobs:
focal:
container:
image: swiftlang/swift:nightly-6.0-focal
image: swift:6.2-bookworm
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- run: swift test
thread:
container:
image: swiftlang/swift:nightly-6.0-focal
image: swift:6.2-bookworm
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- run: swift test --sanitize=thread
address:
container:
image: swiftlang/swift:nightly-6.0-focal
image: swift:6.2-bookworm
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
Expand Down
170 changes: 170 additions & 0 deletions BROADCAST_CHANNELS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# Broadcast Channels Support

This implementation adds support for iOS 18+ broadcast push notifications to APNSwift.

## What's Implemented

### Core Types (APNSCore)

- **APNSBroadcastEnvironment**: Production and sandbox broadcast environments
- **APNSBroadcastMessageStoragePolicy**: Enum for message storage options (none or most recent)
- **APNSBroadcastChannel**: Represents a broadcast channel configuration
- **APNSBroadcastChannelList**: List of channel IDs
- **APNSBroadcastRequest**: Generic request type for all broadcast operations
- **APNSBroadcastResponse**: Generic response type
- **APNSBroadcastClientProtocol**: Protocol defining broadcast operations

### Client (APNS)

- **APNSBroadcastClient**: Full implementation with HTTP method routing for:
- POST /channels (create)
- GET /channels (list all)
- GET /channels/{id} (read)
- DELETE /channels/{id} (delete)

### Test Infrastructure (APNSTestServer)

- **APNSTestServer**: Unified real SwiftNIO HTTP server that mocks both:
- Apple's regular push notification API (`POST /3/device/{token}`)
- Apple's broadcast channel API (`POST/GET/DELETE /channels[/{id}]`)
- In-memory channel storage
- Notification recording with full metadata
- Proper HTTP method handling
- Error responses (404, 400)
- Request ID generation

### Tests

- **APNSBroadcastChannelTests**: Unit tests for encoding/decoding channels (4 tests)
- **APNSBroadcastChannelListTests**: Unit tests for channel lists (3 tests)
- **APNSBroadcastClientTests**: Broadcast channel integration tests (9 tests)
- **APNSClientIntegrationTests**: Push notification integration tests (10 tests)
- Alert, Background, VoIP, FileProvider, Complication notifications
- Header validation, multiple notifications

## Usage Example

```swift
import APNS
import APNSCore
import Crypto

// Create a broadcast client
let client = APNSBroadcastClient(
authenticationMethod: .jwt(
privateKey: try P256.Signing.PrivateKey(pemRepresentation: privateKey),
keyIdentifier: "YOUR_KEY_ID",
teamIdentifier: "YOUR_TEAM_ID"
),
environment: .production, // or .development
eventLoopGroupProvider: .createNew,
responseDecoder: JSONDecoder(),
requestEncoder: JSONEncoder()
)

// Create a new broadcast channel
let channel = APNSBroadcastChannel(messageStoragePolicy: .mostRecentMessageStored)
let response = try await client.create(channel: channel, apnsRequestID: nil)
let channelID = response.body.channelID!

// Read channel info
let channelInfo = try await client.read(channelID: channelID, apnsRequestID: nil)

// List all channels
let allChannels = try await client.readAllChannelIDs(apnsRequestID: nil)
print("Channels: \\(allChannels.body.channels)")

// Delete a channel
try await client.delete(channelID: channelID, apnsRequestID: nil)

// Shutdown when done
try await client.shutdown()
```

## Testing with Mock Server

The unified `APNSTestServer` allows you to test both broadcast channels AND regular push notifications without hitting real Apple servers:

```swift
import APNSTestServer

// Start mock server on random port
let server = APNSTestServer()
try await server.start(port: 0)

// Test broadcast channels
let broadcastClient = APNSBroadcastClient(
authenticationMethod: .jwt(...),
environment: .custom(url: "http://127.0.0.1", port: server.port),
eventLoopGroupProvider: .createNew,
responseDecoder: JSONDecoder(),
requestEncoder: JSONEncoder()
)

// Test regular push notifications
let pushClient = APNSClient(
configuration: .init(
authenticationMethod: .jwt(...),
environment: .custom(url: "http://127.0.0.1", port: server.port)
),
eventLoopGroupProvider: .createNew,
responseDecoder: JSONDecoder(),
requestEncoder: JSONEncoder()
)

// Send notifications and verify
let notification = APNSAlertNotification(...)
try await pushClient.sendAlertNotification(notification, deviceToken: "device-token")

let sent = server.getSentNotifications()
XCTAssertEqual(sent.count, 1)
XCTAssertEqual(sent[0].pushType, "alert")

// Cleanup
try await broadcastClient.shutdown()
try await pushClient.shutdown()
try await server.shutdown()
```

## Architecture Decisions

1. **Kept internal access control**: The `APNSPushType.Configuration` enum remains internal to avoid breaking the public API

2. **String-based HTTP methods**: APNSCore uses string-based HTTP methods to avoid depending on NIOHTTP1

3. **Generic request/response types**: Allows type-safe operations while maintaining flexibility

4. **Real NIO server for testing**: The mock server uses actual SwiftNIO HTTP server components for realistic testing

5. **Protocol-based client**: Allows for easy mocking and testing in consumer code

## Running Tests

```bash
# Run all tests
swift test

# Run only broadcast tests
swift test --filter Broadcast

# Run unit tests only
swift test --filter APNSBroadcastChannelTests
swift test --filter APNSBroadcastChannelListTests

# Run integration tests
swift test --filter APNSBroadcastClientTests
```

## What's Left to Do

1. **Documentation**: Add DocC documentation for all public APIs
2. **Send notifications to channels**: Implement sending push notifications to broadcast channels (separate from channel management)
3. **Error handling improvements**: Add more specific error types for broadcast operations
4. **Rate limiting**: Consider adding rate limiting for test server
5. **Swift 6 consideration**: Maintainer asked about making this Swift 6-only - decision pending

## References

- [Apple Push Notification service documentation](https://developer.apple.com/documentation/usernotifications)
- Issue: https://github.com/swift-server-community/APNSwift/issues/205
- Original WIP branch: https://github.com/eliperkins/APNSwift/tree/channels
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ let package = Package(
dependencies: [
.target(name: "APNSCore"),
.target(name: "APNS"),
.target(name: "APNSTestServer"),
]
),
.target(
Expand Down
1 change: 1 addition & 0 deletions [email protected]
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ let package = Package(
dependencies: [
.target(name: "APNSCore"),
.target(name: "APNS"),
.target(name: "APNSTestServer"),
]
),
.target(
Expand Down
184 changes: 184 additions & 0 deletions Sources/APNS/APNSBroadcastClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the APNSwift open source project
//
// Copyright (c) 2024 the APNSwift project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of APNSwift project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import APNSCore
import AsyncHTTPClient
import struct Foundation.Date
import struct Foundation.UUID
import NIOConcurrencyHelpers
import NIOCore
import NIOHTTP1
import NIOSSL
import NIOTLS
import NIOPosix

/// A client for managing Apple Push Notification broadcast channels.
public final class APNSBroadcastClient<Decoder: APNSJSONDecoder & Sendable, Encoder: APNSJSONEncoder & Sendable>: APNSBroadcastClientProtocol {

/// The broadcast environment to use.
private let environment: APNSBroadcastEnvironment

/// The ``HTTPClient`` used by the APNS broadcast client.
private let httpClient: HTTPClient

/// The decoder for the responses from APNs.
private let responseDecoder: Decoder

/// The encoder for the requests to APNs.
@usableFromInline
/* private */ internal let requestEncoder: Encoder

/// The authentication token manager.
private let authenticationTokenManager: APNSAuthenticationTokenManager<ContinuousClock>?

/// The ByteBufferAllocator
@usableFromInline
/* private */ internal let byteBufferAllocator: ByteBufferAllocator

/// Default ``HTTPHeaders`` which will be adapted for each request. This saves some allocations.
private let defaultRequestHeaders: HTTPHeaders = {
var headers = HTTPHeaders()
headers.reserveCapacity(10)
headers.add(name: "content-type", value: "application/json")
headers.add(name: "user-agent", value: "APNS/swift-nio")
return headers
}()

/// Initializes a new APNSBroadcastClient.
///
/// The client will create an internal ``HTTPClient`` which is used to make requests to APNs broadcast API.
///
/// - Parameters:
/// - authenticationMethod: The authentication method to use.
/// - environment: The broadcast environment (production or sandbox).
/// - eventLoopGroupProvider: Specify how EventLoopGroup will be created.
/// - responseDecoder: The decoder for the responses from APNs.
/// - requestEncoder: The encoder for the requests to APNs.
/// - byteBufferAllocator: The `ByteBufferAllocator`.
public init(
authenticationMethod: APNSClientConfiguration.AuthenticationMethod,
environment: APNSBroadcastEnvironment,
eventLoopGroupProvider: NIOEventLoopGroupProvider,
responseDecoder: Decoder,
requestEncoder: Encoder,
byteBufferAllocator: ByteBufferAllocator = .init()
) {
self.environment = environment
self.byteBufferAllocator = byteBufferAllocator
self.responseDecoder = responseDecoder
self.requestEncoder = requestEncoder

var tlsConfiguration = TLSConfiguration.makeClientConfiguration()
switch authenticationMethod.method {
case .jwt(let privateKey, let teamIdentifier, let keyIdentifier):
self.authenticationTokenManager = APNSAuthenticationTokenManager(
privateKey: privateKey,
teamIdentifier: teamIdentifier,
keyIdentifier: keyIdentifier,
clock: ContinuousClock()
)
case .tls(let privateKey, let certificateChain):
self.authenticationTokenManager = nil
tlsConfiguration.privateKey = privateKey
tlsConfiguration.certificateChain = certificateChain
}

var httpClientConfiguration = HTTPClient.Configuration()
httpClientConfiguration.tlsConfiguration = tlsConfiguration
httpClientConfiguration.httpVersion = .automatic

switch eventLoopGroupProvider {
case .shared(let eventLoopGroup):
self.httpClient = HTTPClient(
eventLoopGroupProvider: .shared(eventLoopGroup),
configuration: httpClientConfiguration
)
case .createNew:
self.httpClient = HTTPClient(
configuration: httpClientConfiguration
)
}
}

/// Shuts down the client gracefully.
public func shutdown() async throws {
try await self.httpClient.shutdown()
}
}

extension APNSBroadcastClient: Sendable where Decoder: Sendable, Encoder: Sendable {}

// MARK: - Broadcast operations

extension APNSBroadcastClient {

public func send<Message: Encodable & Sendable, ResponseBody: Decodable & Sendable>(
_ request: APNSBroadcastRequest<Message>
) async throws -> APNSBroadcastResponse<ResponseBody> {
var headers = self.defaultRequestHeaders

// Add request ID if present
if let apnsRequestID = request.apnsRequestID {
headers.add(name: "apns-request-id", value: apnsRequestID.uuidString.lowercased())
}

// Authorization token
if let authenticationTokenManager = self.authenticationTokenManager {
let token = try await authenticationTokenManager.nextValidToken
headers.add(name: "authorization", value: token)
}

// Build the request URL
let requestURL = "\(self.environment.url):\(self.environment.port)\(request.operation.path)"

// Create HTTP request
var httpClientRequest = HTTPClientRequest(url: requestURL)
httpClientRequest.method = HTTPMethod(rawValue: request.operation.httpMethod)
httpClientRequest.headers = headers

// Add body for operations that require it (e.g., create)
if let message = request.message {
var byteBuffer = self.byteBufferAllocator.buffer(capacity: 0)
try self.requestEncoder.encode(message, into: &byteBuffer)
httpClientRequest.body = .bytes(byteBuffer)
}

// Execute the request
let response = try await self.httpClient.execute(httpClientRequest, deadline: .distantFuture)

// Extract request ID from response
let apnsRequestID = response.headers.first(name: "apns-request-id").flatMap { UUID(uuidString: $0) }

// Handle successful responses
if response.status == .ok || response.status == .created {
let body = try await response.body.collect(upTo: 1024 * 1024) // 1MB max
let responseBody = try responseDecoder.decode(ResponseBody.self, from: body)
return APNSBroadcastResponse(apnsRequestID: apnsRequestID, body: responseBody)
}

// Handle error responses
let body = try await response.body.collect(upTo: 1024)
let errorResponse = try responseDecoder.decode(APNSErrorResponse.self, from: body)

let error = APNSError(
responseStatus: Int(response.status.code),
apnsID: nil,
apnsUniqueID: nil,
apnsResponse: errorResponse,
timestamp: errorResponse.timestampInSeconds.flatMap { Date(timeIntervalSince1970: $0) }
)

throw error
}
}
Loading