Skip to content

Commit

Permalink
Update for Strict Concurrency (#5)
Browse files Browse the repository at this point in the history
* Update for Strict Concurrency

* Update README.md

---------

Co-authored-by: Gwynne Raskind <[email protected]>
  • Loading branch information
fpseverino and gwynne authored Apr 15, 2024
1 parent 99fa653 commit d127cae
Show file tree
Hide file tree
Showing 5 changed files with 139 additions and 119 deletions.
8 changes: 4 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ let package = Package(
.library(name: "PassKit", targets: ["PassKit"]),
],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.92.4"),
.package(url: "https://github.com/vapor/vapor.git", from: "4.92.5"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"),
.package(url: "https://github.com/vapor/apns.git", from: "4.0.0"),
.package(url: "https://github.com/vapor/apns.git", from: "4.1.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.4")
],
targets: [
Expand Down Expand Up @@ -46,6 +46,6 @@ var swiftSettings: [SwiftSetting] { [
.enableUpcomingFeature("ConciseMagicFile"),
.enableUpcomingFeature("ForwardTrailingClosures"),
.enableUpcomingFeature("DisableOutwardActorInference"),
// .enableUpcomingFeature("StrictConcurrency"),
// .enableExperimentalFeature("StrictConcurrency=complete"),
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("StrictConcurrency=complete"),
] }
35 changes: 18 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,37 +119,38 @@ struct PassJsonData: Encodable {

Create a delegate file that implements `PassKitDelegate`.
In the `sslSigningFilesDirectory` you specify there must be the `WWDR.pem`, `passcertificate.pem` and `passkey.pem` files. If they are named like that you're good to go, otherwise you have to specify the custom name.
Obtaining the three certificates files could be a bit tricky, you could get some guidance from [this guide](https://github.com/alexandercerutti/passkit-generator/wiki/Generating-Certificates) and [this video](https://www.youtube.com/watch?v=rJZdPoXHtzI).
There are other fields available which have reasonable default values. See the delegate's documentation.
Because the files for your pass' template and the method of encoding might vary by pass type, you'll be provided the pass for those methods.

```swift
import Vapor
import Fluent
import PassKit

class PKD: PassKitDelegate {
var sslSigningFilesDirectory = URL(fileURLWithPath: "/www/myapp/sign", isDirectory: true)
final class PKDelegate: PassKitDelegate {
let sslSigningFilesDirectory = URL(fileURLWithPath: "/www/myapp/sign", isDirectory: true)

var pemPrivateKeyPassword: String? = Environment.get("PEM_PRIVATE_KEY_PASSWORD")!
let pemPrivateKeyPassword: String? = Environment.get("PEM_PRIVATE_KEY_PASSWORD")!

func encode<P: PassKitPass>(pass: P, db: Database, encoder: JSONEncoder) -> EventLoopFuture<Data> {
func encode<P: PassKitPass>(pass: P, db: Database, encoder: JSONEncoder) async throws -> Data {
// The specific PassData class you use here may vary based on the pass.type if you have multiple
// different types of passes, and thus multiple types of pass data.
return PassData.query(on: db)
.filter(\.$pass == pass.id!)
guard let passData = try await PassData.query(on: db)
.filter(\.$pass.$id == pass.id!)
.first()
.unwrap(or: Abort(.internalServerError))
.flatMap { passData in
guard let data = try? encoder.encode(PassJsonData(data: passData, pass: pass)) else {
return db.eventLoop.makeFailedFuture(Abort(.internalServerError))
}
return db.eventLoop.makeSucceededFuture(data)
else {
throw Abort(.internalServerError)
}
guard let data = try? encoder.encode(PassJsonData(data: passData, pass: pass)) else {
throw Abort(.internalServerError)
}
return data
}

func template<P: PassKitPass>(for: P, db: Database) -> EventLoopFuture<URL> {
func template<P: PassKitPass>(for: P, db: Database) async throws -> URL {
// The location might vary depending on the type of pass.
let url = URL(fileURLWithPath: "/www/myapp/pass", isDirectory: true)
return db.eventLoop.makeSucceededFuture(url)
return URL(fileURLWithPath: "/www/myapp/pass", isDirectory: true)
}
}
```
Expand All @@ -163,7 +164,7 @@ a global variable. You need to ensure that the delegate doesn't go out of scope
This will implement all of the routes that PassKit expects to exist on your server for you.

```swift
let delegate = PKD()
let delegate = PKDelegate()

func routes(_ app: Application) throws {
let pk = PassKit(app: app, delegate: delegate)
Expand Down Expand Up @@ -294,7 +295,7 @@ fileprivate func passHandler(_ req: Request) async throws -> Response {
throw Abort(.notFound)
}

let bundle = try await passKit.generatePassContent(for: passData.pass, on: req.db).get()
let bundle = try await passKit.generatePassContent(for: passData.pass, on: req.db)
let body = Response.Body(data: bundle)
var headers = HTTPHeaders()
headers.add(name: .contentType, value: "application/vnd.apple.pkpass")
Expand Down
32 changes: 32 additions & 0 deletions Sources/PassKit/FakeSendable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/// Copyright 2020 Gargoyle Software, LLC
///
/// Permission is hereby granted, free of charge, to any person obtaining a copy
/// of this software and associated documentation files (the "Software"), to deal
/// in the Software without restriction, including without limitation the rights
/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
/// copies of the Software, and to permit persons to whom the Software is
/// furnished to do so, subject to the following conditions:
///
/// The above copyright notice and this permission notice shall be included in
/// all copies or substantial portions of the Software.
///
/// Notwithstanding the foregoing, you may not use, copy, modify, merge, publish,
/// distribute, sublicense, create a derivative work, and/or sell copies of the
/// Software in any work that is designed, intended, or marketed for pedagogical or
/// instructional purposes related to programming, coding, application development,
/// or information technology. Permission for such use, copying, modification,
/// merger, publication, distribution, sublicensing, creation of derivative works,
/// or sale is expressly withheld.
///
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
/// THE SOFTWARE.

// This is a temporary fix until RoutesBuilder and EmptyPayload are not Sendable
struct FakeSendable<T>: @unchecked Sendable {
let value: T
}
138 changes: 60 additions & 78 deletions Sources/PassKit/PassKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@
import Vapor
import APNS
import VaporAPNS
import APNSCore
@preconcurrency import APNSCore
import Fluent
import NIOSSL

public class PassKit {
public final class PassKit: Sendable {
private let kit: PassKitCustom<PKPass, PKDevice, PKRegistration, PKErrorLog>

public init(app: Application, delegate: any PassKitDelegate, logger: Logger? = nil) {
Expand All @@ -42,8 +42,7 @@ public class PassKit {

/// Registers all the routes required for PassKit to work.
///
/// - Parameters:
/// - authorizationCode: The `authenticationToken` which you are going to use in the `pass.json` file.
/// - Parameter authorizationCode: The `authenticationToken` which you are going to use in the `pass.json` file.
public func registerRoutes(authorizationCode: String? = nil) {
kit.registerRoutes(authorizationCode: authorizationCode)
}
Expand All @@ -52,8 +51,8 @@ public class PassKit {
try kit.registerPushRoutes(middleware: middleware)
}

public func generatePassContent(for pass: PKPass, on db: any Database) -> EventLoopFuture<Data> {
kit.generatePassContent(for: pass, on: db)
public func generatePassContent(for pass: PKPass, on db: any Database) async throws -> Data {
try await kit.generatePassContent(for: pass, on: db)
}

public static func register(migrations: Migrations) {
Expand Down Expand Up @@ -83,50 +82,48 @@ public class PassKit {
/// - Device Type
/// - Registration Type
/// - Error Log Type
public class PassKitCustom<P, D, R: PassKitRegistration, E: PassKitErrorLog> where P == R.PassType, D == R.DeviceType {
public final class PassKitCustom<P, D, R: PassKitRegistration, E: PassKitErrorLog>: Sendable where P == R.PassType, D == R.DeviceType {
public unowned let delegate: any PassKitDelegate
private unowned let app: Application

private let processQueue = DispatchQueue(label: "com.vapor-community.PassKit", qos: .utility, attributes: .concurrent)
private let v1: any RoutesBuilder
private let v1: FakeSendable<any RoutesBuilder>
private let logger: Logger?

public init(app: Application, delegate: any PassKitDelegate, logger: Logger? = nil) {
self.delegate = delegate
self.logger = logger
self.app = app

v1 = app.grouped("api", "v1")
v1 = FakeSendable(value: app.grouped("api", "v1"))
}

/// Registers all the routes required for PassKit to work.
///
/// - Parameters:
/// - authorizationCode: The `authenticationToken` which you are going to use in the `pass.json` file.
/// - Parameter authorizationCode: The `authenticationToken` which you are going to use in the `pass.json` file.
public func registerRoutes(authorizationCode: String? = nil) {
v1.get("devices", ":deviceLibraryIdentifier", "registrations", ":type", use: passesForDevice)
v1.post("log", use: logError)
v1.value.get("devices", ":deviceLibraryIdentifier", "registrations", ":type", use: { try await self.passesForDevice(req: $0) })
v1.value.post("log", use: { try await self.logError(req: $0) })

guard let code = authorizationCode ?? Environment.get("PASS_KIT_AUTHORIZATION") else {
fatalError("Must pass in an authorization code")
}

let v1auth = v1.grouped(ApplePassMiddleware(authorizationCode: code))
let v1auth = v1.value.grouped(ApplePassMiddleware(authorizationCode: code))

v1auth.post("devices", ":deviceLibraryIdentifier", "registrations", ":type", ":passSerial", use: registerDevice)
v1auth.get("passes", ":type", ":passSerial", use: latestVersionOfPass)
v1auth.delete("devices", ":deviceLibraryIdentifier", "registrations", ":type", ":passSerial", use: unregisterDevice)
v1auth.post("devices", ":deviceLibraryIdentifier", "registrations", ":type", ":passSerial", use: { try await self.registerDevice(req: $0) })
v1auth.get("passes", ":type", ":passSerial", use: { try await self.latestVersionOfPass(req: $0) })
v1auth.delete("devices", ":deviceLibraryIdentifier", "registrations", ":type", ":passSerial", use: { try await self.unregisterDevice(req: $0) })
}

/// Registers routes to send push notifications for updated passes
///
/// ### Example ###
/// ```
/// ```swift
/// try pk.registerPushRoutes(environment: .sandbox, middleware: PushAuthMiddleware())
/// ```
///
/// - Parameters:
/// - middleware: The `Middleware` which will control authentication for the routes.
/// - Parameter middleware: The `Middleware` which will control authentication for the routes.
/// - Throws: An error of type `PassKitError`
public func registerPushRoutes(middleware: any Middleware) throws {
let privateKeyPath = URL(fileURLWithPath: delegate.pemPrivateKey, relativeTo:
Expand Down Expand Up @@ -173,14 +170,14 @@ public class PassKitCustom<P, D, R: PassKitRegistration, E: PassKitErrorLog> whe
isDefault: false
)

let pushAuth = v1.grouped(middleware)
let pushAuth = v1.value.grouped(middleware)

pushAuth.post("push", ":type", ":passSerial", use: pushUpdatesForPass)
pushAuth.get("push", ":type", ":passSerial", use: tokensForPassUpdate)
pushAuth.post("push", ":type", ":passSerial", use: { try await self.pushUpdatesForPass(req: $0) })
pushAuth.get("push", ":type", ":passSerial", use: { try await self.tokensForPassUpdate(req: $0) })
}

// MARK: - API Routes
@Sendable func registerDevice(req: Request) async throws -> HTTPStatus {
func registerDevice(req: Request) async throws -> HTTPStatus {
logger?.debug("Called register device")

guard let serial = req.parameters.get("passSerial", as: UUID.self) else {
Expand Down Expand Up @@ -220,7 +217,7 @@ public class PassKitCustom<P, D, R: PassKitRegistration, E: PassKitErrorLog> whe
}
}

@Sendable func passesForDevice(req: Request) async throws -> PassesForDeviceDto {
func passesForDevice(req: Request) async throws -> PassesForDeviceDto {
logger?.debug("Called passesForDevice")

let type = req.parameters.get("type")!
Expand Down Expand Up @@ -253,7 +250,7 @@ public class PassKitCustom<P, D, R: PassKitRegistration, E: PassKitErrorLog> whe
return PassesForDeviceDto(with: serialNumbers, maxDate: maxDate)
}

@Sendable func latestVersionOfPass(req: Request) async throws -> Response {
func latestVersionOfPass(req: Request) async throws -> Response {
logger?.debug("Called latestVersionOfPass")

guard FileManager.default.fileExists(atPath: delegate.zipBinary.unixPath()) else {
Expand Down Expand Up @@ -283,7 +280,7 @@ public class PassKitCustom<P, D, R: PassKitRegistration, E: PassKitErrorLog> whe
throw Abort(.notModified)
}

let data = try await self.generatePassContent(for: pass, on: req.db).get()
let data = try await self.generatePassContent(for: pass, on: req.db)
let body = Response.Body(data: data)

var headers = HTTPHeaders()
Expand All @@ -294,7 +291,7 @@ public class PassKitCustom<P, D, R: PassKitRegistration, E: PassKitErrorLog> whe
return Response(status: .ok, headers: headers, body: body)
}

@Sendable func unregisterDevice(req: Request) async throws -> HTTPStatus {
func unregisterDevice(req: Request) async throws -> HTTPStatus {
logger?.debug("Called unregisterDevice")

let type = req.parameters.get("type")!
Expand All @@ -315,7 +312,7 @@ public class PassKitCustom<P, D, R: PassKitRegistration, E: PassKitErrorLog> whe
return .ok
}

@Sendable func logError(req: Request) throws -> EventLoopFuture<HTTPStatus> {
func logError(req: Request) async throws -> HTTPStatus {
logger?.debug("Called logError")

let body: ErrorLogDto
Expand All @@ -330,13 +327,12 @@ public class PassKitCustom<P, D, R: PassKitRegistration, E: PassKitErrorLog> whe
throw Abort(.badRequest)
}

return body.logs
.map { E(message: $0).create(on: req.db) }
.flatten(on: req.eventLoop)
.map { .ok }
try await body.logs.map(E.init(message:)).create(on: req.db)

return .ok
}

@Sendable func pushUpdatesForPass(req: Request) async throws -> HTTPStatus {
func pushUpdatesForPass(req: Request) async throws -> HTTPStatus {
logger?.debug("Called pushUpdatesForPass")

guard let id = req.parameters.get("passSerial", as: UUID.self) else {
Expand All @@ -349,7 +345,7 @@ public class PassKitCustom<P, D, R: PassKitRegistration, E: PassKitErrorLog> whe
return .noContent
}

@Sendable func tokensForPassUpdate(req: Request) async throws -> [String] {
func tokensForPassUpdate(req: Request) async throws -> [String] {
logger?.debug("Called tokensForPassUpdate")

guard let id = req.parameters.get("passSerial", as: UUID.self) else {
Expand Down Expand Up @@ -501,54 +497,40 @@ public class PassKitCustom<P, D, R: PassKitRegistration, E: PassKitErrorLog> whe
proc.waitUntilExit()
}

public func generatePassContent(for pass: P, on db: any Database) -> EventLoopFuture<Data> {
public func generatePassContent(for pass: P, on db: any Database) async throws -> Data {
let tmp = FileManager.default.temporaryDirectory
let root = tmp.appendingPathComponent(UUID().uuidString, isDirectory: true)
let zipFile = tmp.appendingPathComponent("\(UUID().uuidString).zip")
let encoder = JSONEncoder()

return delegate.template(for: pass, db: db)
.flatMap { src in
var isDir: ObjCBool = false

guard src.hasDirectoryPath &&
FileManager.default.fileExists(atPath: src.unixPath(), isDirectory: &isDir) &&
isDir.boolValue else {
return db.eventLoop.makeFailedFuture(PassKitError.templateNotDirectory)
}

return self.delegate.encode(pass: pass, db: db, encoder: encoder)
.flatMap { encoded in
let result: EventLoopPromise<Data> = db.eventLoop.makePromise()

self.processQueue.async {
do {
try FileManager.default.copyItem(at: src, to: root)

defer {
_ = try? FileManager.default.removeItem(at: root)
}

try encoded.write(to: root.appendingPathComponent("pass.json"))

try Self.generateManifestFile(using: encoder, in: root)
try self.generateSignatureFile(in: root)

try self.zip(directory: root, to: zipFile)

defer {
_ = try? FileManager.default.removeItem(at: zipFile)
}

let data = try Data(contentsOf: zipFile)
result.completeWith(.success(data))
} catch {
result.completeWith(.failure(error))
}
}

return result.futureResult
}
let src = try await delegate.template(for: pass, db: db)
guard (try? src.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else {
throw PassKitError.templateNotDirectory
}

let encoded = try await self.delegate.encode(pass: pass, db: db, encoder: encoder)

do {
try FileManager.default.copyItem(at: src, to: root)

defer {
_ = try? FileManager.default.removeItem(at: root)
}

try encoded.write(to: root.appendingPathComponent("pass.json"))

try Self.generateManifestFile(using: encoder, in: root)
try self.generateSignatureFile(in: root)

try self.zip(directory: root, to: zipFile)

defer {
_ = try? FileManager.default.removeItem(at: zipFile)
}

return try Data(contentsOf: zipFile)
} catch {
throw error
}
}
}
Loading

0 comments on commit d127cae

Please sign in to comment.