From a4a814fe3c3ad3c981aaef3a99f0cbf509323640 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Wed, 26 Jun 2024 16:29:23 +0200 Subject: [PATCH 01/27] Fix models FieldKeys --- Sources/Passes/Models/ConcreteModels.swift | 78 +++++++++++++++------- 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/Sources/Passes/Models/ConcreteModels.swift b/Sources/Passes/Models/ConcreteModels.swift index f6c7bea..f18e873 100644 --- a/Sources/Passes/Models/ConcreteModels.swift +++ b/Sources/Passes/Models/ConcreteModels.swift @@ -31,15 +31,15 @@ import Fluent /// The `Model` that stores PassKit devices. final public class PKDevice: PassKitDevice, @unchecked Sendable { - public static let schema = "devices" + public static let schema = PKDevice.FieldKeys.schemaName @ID(custom: .id) public var id: Int? - @Field(key: "push_token") + @Field(key: PKDevice.FieldKeys.pushToken) public var pushToken: String - @Field(key: "device_library_identifier") + @Field(key: PKDevice.FieldKeys.deviceLibraryIdentifier) public var deviceLibraryIdentifier: String public init(deviceLibraryIdentifier: String, pushToken: String) { @@ -54,9 +54,9 @@ extension PKDevice: AsyncMigration { public func prepare(on database: any Database) async throws { try await database.schema(Self.schema) .field(.id, .int, .identifier(auto: true)) - .field("push_token", .string, .required) - .field("device_library_identifier", .string, .required) - .unique(on: "push_token", "device_library_identifier") + .field(PKDevice.FieldKeys.pushToken, .string, .required) + .field(PKDevice.FieldKeys.deviceLibraryIdentifier, .string, .required) + .unique(on: PKDevice.FieldKeys.pushToken, PKDevice.FieldKeys.deviceLibraryIdentifier) .create() } @@ -65,17 +65,25 @@ extension PKDevice: AsyncMigration { } } +extension PKDevice { + enum FieldKeys { + static let schemaName = "devices" + static let pushToken = FieldKey(stringLiteral: "push_token") + static let deviceLibraryIdentifier = FieldKey(stringLiteral: "device_library_identifier") + } +} + /// The `Model` that stores PassKit passes. open class PKPass: PassKitPass, @unchecked Sendable { - public static let schema = "passes" + public static let schema = PKPass.FieldKeys.schemaName @ID public var id: UUID? - @Timestamp(key: "updated_at", on: .update) + @Timestamp(key: PKPass.FieldKeys.updatedAt, on: .update) public var updatedAt: Date? - @Field(key: "pass_type_identifier") + @Field(key: PKPass.FieldKeys.passTypeIdentifier) public var passTypeIdentifier: String public required init() { } @@ -89,8 +97,8 @@ extension PKPass: AsyncMigration { public func prepare(on database: any Database) async throws { try await database.schema(Self.schema) .id() - .field("updated_at", .datetime, .required) - .field("pass_type_identifier", .string, .required) + .field(PKPass.FieldKeys.updatedAt, .datetime, .required) + .field(PKPass.FieldKeys.passTypeIdentifier, .string, .required) .create() } @@ -99,17 +107,25 @@ extension PKPass: AsyncMigration { } } +extension PKPass { + enum FieldKeys { + static let schemaName = "passes" + static let updatedAt = FieldKey(stringLiteral: "updated_at") + static let passTypeIdentifier = FieldKey(stringLiteral: "pass_type_identifier") + } +} + /// The `Model` that stores PassKit error logs. final public class PKErrorLog: PassKitErrorLog, @unchecked Sendable { - public static let schema = "errors" + public static let schema = PKErrorLog.FieldKeys.schemaName @ID(custom: .id) public var id: Int? - @Timestamp(key: "created_at", on: .create) + @Timestamp(key: PKErrorLog.FieldKeys.createdAt, on: .create) public var createdAt: Date? - @Field(key: "message") + @Field(key: PKErrorLog.FieldKeys.message) public var message: String public init(message: String) { @@ -123,13 +139,21 @@ extension PKErrorLog: AsyncMigration { public func prepare(on database: any Database) async throws { try await database.schema(Self.schema) .field(.id, .int, .identifier(auto: true)) - .field("created", .datetime, .required) - .field("message", .string, .required) + .field(PKErrorLog.FieldKeys.createdAt, .datetime, .required) + .field(PKErrorLog.FieldKeys.message, .string, .required) .create() } public func revert(on database: any Database) async throws { - try await database.schema(PKErrorLog.schema).delete() + try await database.schema(Self.schema).delete() + } +} + +extension PKErrorLog { + enum FieldKeys { + static let schemaName = "errors" + static let createdAt = FieldKey(stringLiteral: "created_at") + static let message = FieldKey(stringLiteral: "message") } } @@ -138,15 +162,15 @@ final public class PKRegistration: PassKitRegistration, @unchecked Sendable { public typealias PassType = PKPass public typealias DeviceType = PKDevice - public static let schema = "registrations" + public static let schema = PKRegistration.FieldKeys.schemaName @ID(custom: .id) public var id: Int? - @Parent(key: "device_id") + @Parent(key: PKRegistration.FieldKeys.deviceID) public var device: DeviceType - @Parent(key: "pass_id") + @Parent(key: PKRegistration.FieldKeys.passID) public var pass: PassType public init() {} @@ -156,10 +180,8 @@ extension PKRegistration: AsyncMigration { public func prepare(on database: any Database) async throws { try await database.schema(Self.schema) .field(.id, .int, .identifier(auto: true)) - .field("device_id", .int, .required) - .field("pass_id", .uuid, .required) - .foreignKey("device_id", references: DeviceType.schema, .id, onDelete: .cascade) - .foreignKey("pass_id", references: PassType.schema, .id, onDelete: .cascade) + .field(PKRegistration.FieldKeys.deviceID, .int, .required, .references(DeviceType.schema, .id, onDelete: .cascade)) + .field(PKRegistration.FieldKeys.passID, .uuid, .required, .references(PassType.schema, .id, onDelete: .cascade)) .create() } @@ -167,3 +189,11 @@ extension PKRegistration: AsyncMigration { try await database.schema(Self.schema).delete() } } + +extension PKRegistration { + enum FieldKeys { + static let schemaName = "registrations" + static let deviceID = FieldKey(stringLiteral: "device_id") + static let passID = FieldKey(stringLiteral: "pass_id") + } +} From 42367dd8ffce68fcd68a0188f771641d3e56f38f Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Wed, 26 Jun 2024 16:59:55 +0200 Subject: [PATCH 02/27] Create new PassKit product --- Package.swift | 12 ++++++++++-- Sources/{Passes => PassKit}/DTOs/ErrorLogDTO.swift | 4 ++-- .../{Passes => PassKit}/DTOs/RegistrationDTO.swift | 4 ++-- Sources/{Passes => PassKit}/FakeSendable.swift | 8 ++++++-- Sources/{Passes => PassKit}/URL+Extension.swift | 2 +- Sources/Passes/Passes.swift | 13 +++++++------ .../{PassKitError.swift => PassesError.swift} | 4 ++-- 7 files changed, 30 insertions(+), 17 deletions(-) rename Sources/{Passes => PassKit}/DTOs/ErrorLogDTO.swift (96%) rename Sources/{Passes => PassKit}/DTOs/RegistrationDTO.swift (95%) rename Sources/{Passes => PassKit}/FakeSendable.swift (92%) rename Sources/{Passes => PassKit}/URL+Extension.swift (97%) rename Sources/Passes/{PassKitError.swift => PassesError.swift} (90%) diff --git a/Package.swift b/Package.swift index 63291a0..cb4ce13 100644 --- a/Package.swift +++ b/Package.swift @@ -7,7 +7,7 @@ let package = Package( .macOS(.v13), .iOS(.v16) ], products: [ - .library(name: "Passes", targets: ["Passes"]), + .library(name: "Passes", targets: ["PassKit", "Passes"]), ], dependencies: [ .package(url: "https://github.com/vapor/vapor.git", from: "4.102.0"), @@ -17,7 +17,7 @@ let package = Package( ], targets: [ .target( - name: "Passes", + name: "PassKit", dependencies: [ .product(name: "Fluent", package: "fluent"), .product(name: "Vapor", package: "vapor"), @@ -26,9 +26,17 @@ let package = Package( ], swiftSettings: swiftSettings ), + .target( + name: "Passes", + dependencies: [ + .target(name: "PassKit"), + ], + swiftSettings: swiftSettings + ), .testTarget( name: "PassKitTests", dependencies: [ + .target(name: "PassKit"), .target(name: "Passes"), .product(name: "XCTVapor", package: "vapor"), ], diff --git a/Sources/Passes/DTOs/ErrorLogDTO.swift b/Sources/PassKit/DTOs/ErrorLogDTO.swift similarity index 96% rename from Sources/Passes/DTOs/ErrorLogDTO.swift rename to Sources/PassKit/DTOs/ErrorLogDTO.swift index a1af6f7..3096031 100644 --- a/Sources/Passes/DTOs/ErrorLogDTO.swift +++ b/Sources/PassKit/DTOs/ErrorLogDTO.swift @@ -28,6 +28,6 @@ import Vapor -struct ErrorLogDTO: Content { - let logs: [String] +package struct ErrorLogDTO: Content { + package let logs: [String] } diff --git a/Sources/Passes/DTOs/RegistrationDTO.swift b/Sources/PassKit/DTOs/RegistrationDTO.swift similarity index 95% rename from Sources/Passes/DTOs/RegistrationDTO.swift rename to Sources/PassKit/DTOs/RegistrationDTO.swift index a0a2068..179ec41 100644 --- a/Sources/Passes/DTOs/RegistrationDTO.swift +++ b/Sources/PassKit/DTOs/RegistrationDTO.swift @@ -28,6 +28,6 @@ import Vapor -struct RegistrationDTO: Content { - let pushToken: String +package struct RegistrationDTO: Content { + package let pushToken: String } diff --git a/Sources/Passes/FakeSendable.swift b/Sources/PassKit/FakeSendable.swift similarity index 92% rename from Sources/Passes/FakeSendable.swift rename to Sources/PassKit/FakeSendable.swift index b048685..2d25bae 100644 --- a/Sources/Passes/FakeSendable.swift +++ b/Sources/PassKit/FakeSendable.swift @@ -27,6 +27,10 @@ /// THE SOFTWARE. // This is a temporary fix until RoutesBuilder and EmptyPayload are not Sendable -struct FakeSendable: @unchecked Sendable { - let value: T +package struct FakeSendable: @unchecked Sendable { + package let value: T + + package init(value: T) { + self.value = value + } } diff --git a/Sources/Passes/URL+Extension.swift b/Sources/PassKit/URL+Extension.swift similarity index 97% rename from Sources/Passes/URL+Extension.swift rename to Sources/PassKit/URL+Extension.swift index 5803298..5114164 100644 --- a/Sources/Passes/URL+Extension.swift +++ b/Sources/PassKit/URL+Extension.swift @@ -29,7 +29,7 @@ import Foundation extension URL { - func unixPath() -> String { + package func unixPath() -> String { absoluteString.replacingOccurrences(of: "file://", with: "") } } diff --git a/Sources/Passes/Passes.swift b/Sources/Passes/Passes.swift index 13277ce..67a04a9 100644 --- a/Sources/Passes/Passes.swift +++ b/Sources/Passes/Passes.swift @@ -32,6 +32,7 @@ import VaporAPNS @preconcurrency import APNSCore import Fluent import NIOSSL +import PassKit /// The main class that handles PassKit passes. public final class Passes: Sendable { @@ -156,19 +157,19 @@ public final class PassesCustom Date: Wed, 26 Jun 2024 17:21:09 +0200 Subject: [PATCH 03/27] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6a1b55b..47f5f2b 100644 --- a/README.md +++ b/README.md @@ -26,14 +26,14 @@ The table below shows a list of PassKit major releases alongside their compatibl |Version|Swift|SPM| |---|---|---| -|0.3.0|5.10+|`from: "0.3.0"`| +|0.3.0|5.10+|`from: "0.3.1"`| |0.2.0|5.9+|`from: "0.2.0"`| |0.1.0|5.9+|`from: "0.1.0"`| Use the SPM string to easily include the dependendency in your `Package.swift` file ```swift -.package(url: "https://github.com/vapor-community/PassKit.git", from: "0.3.0") +.package(url: "https://github.com/vapor-community/PassKit.git", from: "0.3.1") ``` and add it to your target's dependencies: From 2a784843d0c2ce9d9a3fb7821a4ded9312c68a14 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Wed, 26 Jun 2024 20:08:00 +0200 Subject: [PATCH 04/27] Fix README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 47f5f2b..4448c0f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
avatar -

PassKit

+

PassKit

Documentation From a079e2fb3f5942fb5794356251d13051591bc766 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Thu, 27 Jun 2024 20:04:11 +0200 Subject: [PATCH 05/27] Change `Passes` to `PassesService` and `PassKitRegistration` to `PassesRegistrationModel` --- README.md | 20 +++++++++---------- Sources/Passes/Models/ConcreteModels.swift | 2 +- ...on.swift => PassesRegistrationModel.swift} | 4 ++-- .../{Passes.swift => PassesService.swift} | 14 ++++++------- Tests/PassKitTests/PassKitTests.swift | 2 +- 5 files changed, 21 insertions(+), 21 deletions(-) rename Sources/Passes/Models/{PassKitRegistration.swift => PassesRegistrationModel.swift} (96%) rename Sources/Passes/{Passes.swift => PassesService.swift} (96%) diff --git a/README.md b/README.md index 4448c0f..0687788 100644 --- a/README.md +++ b/README.md @@ -209,8 +209,8 @@ import Passes let pkDelegate = PKDelegate() func routes(_ app: Application) throws { - let passes = Passes(app: app, delegate: pkDelegate) - passes.registerRoutes(authorizationCode: PassJSONData.token) + let passesService = PassesService(app: app, delegate: pkDelegate) + passesService.registerRoutes(authorizationCode: PassJSONData.token) } ``` @@ -219,7 +219,7 @@ func routes(_ app: Application) throws { If you wish to include routes specifically for sending push notifications to updated passes you can also include this line in your `routes(_:)` method. You'll need to pass in whatever `Middleware` you want Vapor to use to authenticate the two routes. If you don't include this line, you have to configure an APNS container yourself ```swift -try passes.registerPushRoutes(middleware: SecretMiddleware(secret: "foo")) +try passesService.registerPushRoutes(middleware: SecretMiddleware(secret: "foo")) ``` That will add two routes: @@ -254,7 +254,7 @@ struct PassDataMiddleware: AsyncModelMiddleware { pkPass.updatedAt = Date() try await pkPass.save(on: db) try await next.update(model, on: db) - try await Passes.sendPushNotifications(for: pkPass, on: db, app: self.app) + try await PassesService.sendPushNotifications(for: pkPass, on: db, app: self.app) } } ``` @@ -304,10 +304,10 @@ app.apns.containers.use( #### Custom Implementation -If you don't like the schema names that are used by default, you can instead instantiate the generic `PassesCustom` and provide your model types. +If you don't like the schema names that are used by default, you can instead instantiate the generic `PassesServiceCustom` and provide your model types. ```swift -let passes = PassesCustom(app: app, delegate: delegate) +let passesService = PassesServiceCustom(app: app, delegate: delegate) ``` ### Register Migrations @@ -315,14 +315,14 @@ let passes = PassesCustom Response { throw Abort(.notFound) } - let bundle = try await passes.generatePassContent(for: passData.pass, on: req.db) + let bundle = try await passesService.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") diff --git a/Sources/Passes/Models/ConcreteModels.swift b/Sources/Passes/Models/ConcreteModels.swift index f18e873..52d855d 100644 --- a/Sources/Passes/Models/ConcreteModels.swift +++ b/Sources/Passes/Models/ConcreteModels.swift @@ -158,7 +158,7 @@ extension PKErrorLog { } /// The `Model` that stores PassKit registrations. -final public class PKRegistration: PassKitRegistration, @unchecked Sendable { +final public class PKRegistration: PassesRegistrationModel, @unchecked Sendable { public typealias PassType = PKPass public typealias DeviceType = PKDevice diff --git a/Sources/Passes/Models/PassKitRegistration.swift b/Sources/Passes/Models/PassesRegistrationModel.swift similarity index 96% rename from Sources/Passes/Models/PassKitRegistration.swift rename to Sources/Passes/Models/PassesRegistrationModel.swift index 18c53ac..203ccd7 100644 --- a/Sources/Passes/Models/PassKitRegistration.swift +++ b/Sources/Passes/Models/PassesRegistrationModel.swift @@ -30,7 +30,7 @@ import Vapor import Fluent /// Represents the `Model` that stores PassKit registrations. -public protocol PassKitRegistration: Model where IDValue == Int { +public protocol PassesRegistrationModel: Model where IDValue == Int { associatedtype PassType: PassKitPass associatedtype DeviceType: PassKitDevice @@ -41,7 +41,7 @@ public protocol PassKitRegistration: Model where IDValue == Int { var pass: PassType { get set } } -internal extension PassKitRegistration { +internal extension PassesRegistrationModel { var _$device: Parent { guard let mirror = Mirror(reflecting: self).descendant("_device"), let device = mirror as? Parent else { diff --git a/Sources/Passes/Passes.swift b/Sources/Passes/PassesService.swift similarity index 96% rename from Sources/Passes/Passes.swift rename to Sources/Passes/PassesService.swift index 67a04a9..9ca14c5 100644 --- a/Sources/Passes/Passes.swift +++ b/Sources/Passes/PassesService.swift @@ -35,8 +35,8 @@ import NIOSSL import PassKit /// The main class that handles PassKit passes. -public final class Passes: Sendable { - private let kit: PassesCustom +public final class PassesService: Sendable { + private let kit: PassesServiceCustom public init(app: Application, delegate: any PassesDelegate, logger: Logger? = nil) { kit = .init(app: app, delegate: delegate, logger: logger) @@ -84,7 +84,7 @@ public final class Passes: Sendable { /// - db: The `Database` to use. /// - app: The `Application` to use. public static func sendPushNotificationsForPass(id: UUID, of passTypeIdentifier: String, on db: any Database, app: Application) async throws { - try await PassesCustom.sendPushNotificationsForPass(id: id, of: passTypeIdentifier, on: db, app: app) + try await PassesServiceCustom.sendPushNotificationsForPass(id: id, of: passTypeIdentifier, on: db, app: app) } /// Sends push notifications for a given pass. @@ -94,7 +94,7 @@ public final class Passes: Sendable { /// - db: The `Database` to use. /// - app: The `Application` to use. public static func sendPushNotifications(for pass: PKPass, on db: any Database, app: Application) async throws { - try await PassesCustom.sendPushNotifications(for: pass, on: db, app: app) + try await PassesServiceCustom.sendPushNotifications(for: pass, on: db, app: app) } /// Sends push notifications for a given pass. @@ -104,18 +104,18 @@ public final class Passes: Sendable { /// - db: The `Database` to use. /// - app: The `Application` to use. public static func sendPushNotifications(for pass: ParentProperty, on db: any Database, app: Application) async throws { - try await PassesCustom.sendPushNotifications(for: pass, on: db, app: app) + try await PassesServiceCustom.sendPushNotifications(for: pass, on: db, app: app) } } -/// Class to handle `Passes`. +/// Class to handle `PassesService`. /// /// The generics should be passed in this order: /// - Pass Type /// - Device Type /// - Registration Type /// - Error Log Type -public final class PassesCustom: Sendable where P == R.PassType, D == R.DeviceType { +public final class PassesServiceCustom: Sendable where P == R.PassType, D == R.DeviceType { public unowned let delegate: any PassesDelegate private unowned let app: Application diff --git a/Tests/PassKitTests/PassKitTests.swift b/Tests/PassKitTests/PassKitTests.swift index 3ae5b18..e0fb540 100644 --- a/Tests/PassKitTests/PassKitTests.swift +++ b/Tests/PassKitTests/PassKitTests.swift @@ -6,7 +6,7 @@ final class PassKitTests: XCTestCase { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct // results. - //XCTAssertEqual(Passes().text, "Hello, World!") + //XCTAssertEqual(PassesService().text, "Hello, World!") } static var allTests = [ From d997d29933e2abc323372dfbbd162aa67a64e363 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Thu, 27 Jun 2024 20:11:32 +0200 Subject: [PATCH 06/27] Change `PassKitPass` to `PassModel` --- README.md | 8 ++++---- Sources/Passes/Models/ConcreteModels.swift | 2 +- .../Models/{PassKitPassData.swift => PassDataModel.swift} | 6 +++--- .../Passes/Models/{PassKitPass.swift => PassModel.swift} | 4 ++-- Sources/Passes/Models/PassesRegistrationModel.swift | 2 +- Sources/Passes/PassesDelegate.swift | 4 ++-- 6 files changed, 13 insertions(+), 13 deletions(-) rename Sources/Passes/Models/{PassKitPassData.swift => PassDataModel.swift} (94%) rename Sources/Passes/Models/{PassKitPass.swift => PassModel.swift} (96%) diff --git a/README.md b/README.md index 0687788..12c65fb 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ The table below shows a list of PassKit major releases alongside their compatibl |Version|Swift|SPM| |---|---|---| -|0.3.0|5.10+|`from: "0.3.1"`| +|0.3.1|5.10+|`from: "0.3.1"`| |0.2.0|5.9+|`from: "0.2.0"`| |0.1.0|5.9+|`from: "0.1.0"`| @@ -55,7 +55,7 @@ import Fluent import struct Foundation.UUID import Passes -final class PassData: PassKitPassData, @unchecked Sendable { +final class PassData: PassDataModel, @unchecked Sendable { static let schema = "pass_data" @ID @@ -172,7 +172,7 @@ final class PKDelegate: PassesDelegate { let pemPrivateKeyPassword: String? = Environment.get("PEM_PRIVATE_KEY_PASSWORD")! - func encode(pass: P, db: Database, encoder: JSONEncoder) async throws -> Data { + func encode(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. guard let passData = try await PassData.query(on: db) @@ -187,7 +187,7 @@ final class PKDelegate: PassesDelegate { return data } - func template(for: P, db: Database) async throws -> URL { + func template(for: P, db: Database) async throws -> URL { // The location might vary depending on the type of pass. return URL(fileURLWithPath: "PassKitTemplate/", isDirectory: true) } diff --git a/Sources/Passes/Models/ConcreteModels.swift b/Sources/Passes/Models/ConcreteModels.swift index 52d855d..08fec29 100644 --- a/Sources/Passes/Models/ConcreteModels.swift +++ b/Sources/Passes/Models/ConcreteModels.swift @@ -74,7 +74,7 @@ extension PKDevice { } /// The `Model` that stores PassKit passes. -open class PKPass: PassKitPass, @unchecked Sendable { +open class PKPass: PassModel, @unchecked Sendable { public static let schema = PKPass.FieldKeys.schemaName @ID diff --git a/Sources/Passes/Models/PassKitPassData.swift b/Sources/Passes/Models/PassDataModel.swift similarity index 94% rename from Sources/Passes/Models/PassKitPassData.swift rename to Sources/Passes/Models/PassDataModel.swift index cf419a2..62c7f00 100644 --- a/Sources/Passes/Models/PassKitPassData.swift +++ b/Sources/Passes/Models/PassDataModel.swift @@ -30,14 +30,14 @@ import Vapor import Fluent /// Represents the `Model` that stores custom app data associated to PassKit passes. -public protocol PassKitPassData: Model { - associatedtype PassType: PassKitPass +public protocol PassDataModel: Model { + associatedtype PassType: PassModel /// The foreign key to the pass table var pass: PassType { get set } } -internal extension PassKitPassData { +internal extension PassDataModel { var _$pass: Parent { guard let mirror = Mirror(reflecting: self).descendant("_pass"), let pass = mirror as? Parent else { diff --git a/Sources/Passes/Models/PassKitPass.swift b/Sources/Passes/Models/PassModel.swift similarity index 96% rename from Sources/Passes/Models/PassKitPass.swift rename to Sources/Passes/Models/PassModel.swift index 6f4e174..4f90c04 100644 --- a/Sources/Passes/Models/PassKitPass.swift +++ b/Sources/Passes/Models/PassModel.swift @@ -30,7 +30,7 @@ import Vapor import Fluent /// Represents the `Model` that stores PassKit passes. Uses a UUID so people can't easily guess pass IDs -public protocol PassKitPass: Model where IDValue == UUID { +public protocol PassModel: Model where IDValue == UUID { /// The pass type identifier. var passTypeIdentifier: String { get set } @@ -38,7 +38,7 @@ public protocol PassKitPass: Model where IDValue == UUID { var updatedAt: Date? { get set } } -internal extension PassKitPass { +internal extension PassModel { var _$id: ID { guard let mirror = Mirror(reflecting: self).descendant("_id"), let id = mirror as? ID else { diff --git a/Sources/Passes/Models/PassesRegistrationModel.swift b/Sources/Passes/Models/PassesRegistrationModel.swift index 203ccd7..4a804c2 100644 --- a/Sources/Passes/Models/PassesRegistrationModel.swift +++ b/Sources/Passes/Models/PassesRegistrationModel.swift @@ -31,7 +31,7 @@ import Fluent /// Represents the `Model` that stores PassKit registrations. public protocol PassesRegistrationModel: Model where IDValue == Int { - associatedtype PassType: PassKitPass + associatedtype PassType: PassModel associatedtype DeviceType: PassKitDevice /// The device for this registration. diff --git a/Sources/Passes/PassesDelegate.swift b/Sources/Passes/PassesDelegate.swift index 4154954..1e8f7d8 100644 --- a/Sources/Passes/PassesDelegate.swift +++ b/Sources/Passes/PassesDelegate.swift @@ -45,7 +45,7 @@ public protocol PassesDelegate: AnyObject, Sendable { /// - Returns: A `URL` which points to the template data for the pass. /// /// > Important: Be sure to use the `URL(fileURLWithPath:isDirectory:)` constructor. - func template(for: P, db: any Database) async throws -> URL + func template(for: P, db: any Database) async throws -> URL /// Generates the SSL `signature` file. /// @@ -69,7 +69,7 @@ public protocol PassesDelegate: AnyObject, Sendable { /// - Returns: The encoded pass JSON data. /// /// > Tip: See the [Pass](https://developer.apple.com/documentation/walletpasses/pass) object to understand the keys. - func encode(pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data + func encode(pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data /// Should return a `URL` which points to the template data for the pass. /// From 49aae8de09a79797bc423edd247fecda2c4ee39d Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Thu, 27 Jun 2024 20:20:59 +0200 Subject: [PATCH 07/27] Change `PKRegistration` to `PassesRegistration` --- README.md | 12 ++++++------ Sources/Passes/Models/ConcreteModels.swift | 18 +++++++++--------- Sources/Passes/PassesService.swift | 12 ++++++------ 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 12c65fb..9cee9e2 100644 --- a/README.md +++ b/README.md @@ -26,14 +26,14 @@ The table below shows a list of PassKit major releases alongside their compatibl |Version|Swift|SPM| |---|---|---| -|0.3.1|5.10+|`from: "0.3.1"`| +|0.4.0|5.10+|`from: "0.4.0"`| |0.2.0|5.9+|`from: "0.2.0"`| |0.1.0|5.9+|`from: "0.1.0"`| Use the SPM string to easily include the dependendency in your `Package.swift` file ```swift -.package(url: "https://github.com/vapor-community/PassKit.git", from: "0.3.1") +.package(url: "https://github.com/vapor-community/PassKit.git", from: "0.4.0") ``` and add it to your target's dependencies: @@ -42,7 +42,7 @@ and add it to your target's dependencies: .product(name: "Passes", package: "PassKit") ``` -> Note: This package requires Vapor 4. +> Note: This package is made for Vapor 4. ## Usage @@ -99,7 +99,7 @@ CREATE OR REPLACE FUNCTION public."RemoveUnregisteredItems"() RETURNS trigger DELETE FROM devices d WHERE NOT EXISTS ( SELECT 1 - FROM registrations r + FROM passes_registrations r WHERE d."id" = r.device_id LIMIT 1 ); @@ -107,7 +107,7 @@ CREATE OR REPLACE FUNCTION public."RemoveUnregisteredItems"() RETURNS trigger DELETE FROM passes p WHERE NOT EXISTS ( SELECT 1 - FROM registrations r + FROM passes_registrations r WHERE p."id" = r.pass_id LIMIT 1 ); @@ -117,7 +117,7 @@ END $$; CREATE TRIGGER "OnRegistrationDelete" -AFTER DELETE ON "public"."registrations" +AFTER DELETE ON "public"."passes_registrations" FOR EACH ROW EXECUTE PROCEDURE "public"."RemoveUnregisteredItems"(); ``` diff --git a/Sources/Passes/Models/ConcreteModels.swift b/Sources/Passes/Models/ConcreteModels.swift index 08fec29..f1cb595 100644 --- a/Sources/Passes/Models/ConcreteModels.swift +++ b/Sources/Passes/Models/ConcreteModels.swift @@ -158,30 +158,30 @@ extension PKErrorLog { } /// The `Model` that stores PassKit registrations. -final public class PKRegistration: PassesRegistrationModel, @unchecked Sendable { +final public class PassesRegistration: PassesRegistrationModel, @unchecked Sendable { public typealias PassType = PKPass public typealias DeviceType = PKDevice - public static let schema = PKRegistration.FieldKeys.schemaName + public static let schema = PassesRegistration.FieldKeys.schemaName @ID(custom: .id) public var id: Int? - @Parent(key: PKRegistration.FieldKeys.deviceID) + @Parent(key: PassesRegistration.FieldKeys.deviceID) public var device: DeviceType - @Parent(key: PKRegistration.FieldKeys.passID) + @Parent(key: PassesRegistration.FieldKeys.passID) public var pass: PassType public init() {} } -extension PKRegistration: AsyncMigration { +extension PassesRegistration: AsyncMigration { public func prepare(on database: any Database) async throws { try await database.schema(Self.schema) .field(.id, .int, .identifier(auto: true)) - .field(PKRegistration.FieldKeys.deviceID, .int, .required, .references(DeviceType.schema, .id, onDelete: .cascade)) - .field(PKRegistration.FieldKeys.passID, .uuid, .required, .references(PassType.schema, .id, onDelete: .cascade)) + .field(PassesRegistration.FieldKeys.deviceID, .int, .required, .references(DeviceType.schema, .id, onDelete: .cascade)) + .field(PassesRegistration.FieldKeys.passID, .uuid, .required, .references(PassType.schema, .id, onDelete: .cascade)) .create() } @@ -190,9 +190,9 @@ extension PKRegistration: AsyncMigration { } } -extension PKRegistration { +extension PassesRegistration { enum FieldKeys { - static let schemaName = "registrations" + static let schemaName = "passes_registrations" static let deviceID = FieldKey(stringLiteral: "device_id") static let passID = FieldKey(stringLiteral: "pass_id") } diff --git a/Sources/Passes/PassesService.swift b/Sources/Passes/PassesService.swift index 9ca14c5..111c6ec 100644 --- a/Sources/Passes/PassesService.swift +++ b/Sources/Passes/PassesService.swift @@ -36,7 +36,7 @@ import PassKit /// The main class that handles PassKit passes. public final class PassesService: Sendable { - private let kit: PassesServiceCustom + private let kit: PassesServiceCustom public init(app: Application, delegate: any PassesDelegate, logger: Logger? = nil) { kit = .init(app: app, delegate: delegate, logger: logger) @@ -72,7 +72,7 @@ public final class PassesService: Sendable { public static func register(migrations: Migrations) { migrations.add(PKPass()) migrations.add(PKDevice()) - migrations.add(PKRegistration()) + migrations.add(PassesRegistration()) migrations.add(PKErrorLog()) } @@ -84,7 +84,7 @@ public final class PassesService: Sendable { /// - db: The `Database` to use. /// - app: The `Application` to use. public static func sendPushNotificationsForPass(id: UUID, of passTypeIdentifier: String, on db: any Database, app: Application) async throws { - try await PassesServiceCustom.sendPushNotificationsForPass(id: id, of: passTypeIdentifier, on: db, app: app) + try await PassesServiceCustom.sendPushNotificationsForPass(id: id, of: passTypeIdentifier, on: db, app: app) } /// Sends push notifications for a given pass. @@ -94,7 +94,7 @@ public final class PassesService: Sendable { /// - db: The `Database` to use. /// - app: The `Application` to use. public static func sendPushNotifications(for pass: PKPass, on db: any Database, app: Application) async throws { - try await PassesServiceCustom.sendPushNotifications(for: pass, on: db, app: app) + try await PassesServiceCustom.sendPushNotifications(for: pass, on: db, app: app) } /// Sends push notifications for a given pass. @@ -103,8 +103,8 @@ public final class PassesService: Sendable { /// - pass: The pass (as the `ParentProperty`) to send the notifications for. /// - db: The `Database` to use. /// - app: The `Application` to use. - public static func sendPushNotifications(for pass: ParentProperty, on db: any Database, app: Application) async throws { - try await PassesServiceCustom.sendPushNotifications(for: pass, on: db, app: app) + public static func sendPushNotifications(for pass: ParentProperty, on db: any Database, app: Application) async throws { + try await PassesServiceCustom.sendPushNotifications(for: pass, on: db, app: app) } } From 25cb54da195ee1dd868c212e96e89d4542e81cb0 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Fri, 28 Jun 2024 09:45:22 +0200 Subject: [PATCH 08/27] Add PassJSON --- Sources/Passes/DTOs/PassJSON.swift | 99 +++++++++++++++++++++++++++++ Sources/Passes/PassesDelegate.swift | 2 +- Sources/Passes/PassesError.swift | 2 +- 3 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 Sources/Passes/DTOs/PassJSON.swift diff --git a/Sources/Passes/DTOs/PassJSON.swift b/Sources/Passes/DTOs/PassJSON.swift new file mode 100644 index 0000000..50fcd21 --- /dev/null +++ b/Sources/Passes/DTOs/PassJSON.swift @@ -0,0 +1,99 @@ +// +// PassJSON.swift +// PassKit +// +// Created by Francesco Paolo Severino on 28/06/24. +// + +/// A protocol that defines the structure of a `pass.json` file. +/// +/// > Tip: See the [`Pass`](https://developer.apple.com/documentation/walletpasses/pass) object to understand the keys. +public protocol PassJSON: Encodable { + /// A short description that iOS accessibility technologies use for a pass. + var description: String { get set } + + /// The version of the file format. The value must be 1. + var formatVersion: Int { get } + + /// The name of the organization. + var organizationName: String { get set } + + /// The pass type identifier that’s registered with Apple. + /// + /// The value must be the same as the distribution certificate used to sign the pass. + var passTypeIdentifier: String { get set } + + /// An alphanumeric serial number. + /// + /// The combination of the serial number and pass type identifier must be unique for each pass. + var serialNumber: String { get set } + + /// The Team ID for the Apple Developer Program account that registered the pass type identifier. + var teamIdentifier: String { get set } +} + +public extension PassJSON { + var formatVersion: Int { + return 1 + } +} + +/// A protocol that represents the information to display in a field on a pass. +/// +/// > Tip: See the [`PassFieldContent`](https://developer.apple.com/documentation/walletpasses/passfieldcontent) object to understand the keys. +public protocol PassFieldContent: Encodable { + /// A unique key that identifies a field in the pass; for example, `departure-gate`. + var key: String { get set } + + /// The value to use for the field; for example, 42. + /// + /// A date or time value must include a time zone. + var value: String { get set } +} + +/// A protocol that represents the groups of fields that display the information for a boarding pass. +/// +/// > Tip: See the [`Pass.BoardingPass`](https://developer.apple.com/documentation/walletpasses/pass/boardingpass) object to understand the keys. +public protocol BoardingPass: Encodable { + /// The type of transit for a boarding pass. + /// + /// This key is invalid for other types of passes. + /// + /// The system may use the value to display more information, + /// such as showing an airplane icon for the pass on watchOS when the value is set to `PKTransitTypeAir`. + var transitType: TransitType { get set } +} + +/// The type of transit for a boarding pass. +public enum TransitType: String { + case air = "PKTransitTypeAir" + case boat = "PKTransitTypeBoat" + case bus = "PKTransitTypeBus" + case generic = "PKTransitTypeGeneric" + case train = "PKTransitTypeTrain" +} + +/// A protocol that represents a barcode on a pass. +/// +/// > Tip: See the [`Pass.Barcodes`](https://developer.apple.com/documentation/walletpasses/pass/barcodes) object to understand the keys. +public protocol Barcodes: Encodable { + /// The format of the barcode. + /// + /// The barcode format `PKBarcodeFormatCode128` isn’t supported for watchOS. + var format: BarcodeFormat { get set } + + /// The message or payload to display as a barcode. + var message: String { get set } + + /// The IANA character set name of the text encoding to use to convert message + /// from a string representation to a data representation that the system renders as a barcode, such as `iso-8859-1`. + var messageEncoding: String { get set } +} + +/// The format of the barcode. +public enum BarcodeFormat: String { + case pdf417 = "PKBarcodeFormatPDF417" + case qr = "PKBarcodeFormatQR" + case aztec = "PKBarcodeFormatAztec" + case code128 = "PKBarcodeFormatCode128" +} diff --git a/Sources/Passes/PassesDelegate.swift b/Sources/Passes/PassesDelegate.swift index 1e8f7d8..eb6712f 100644 --- a/Sources/Passes/PassesDelegate.swift +++ b/Sources/Passes/PassesDelegate.swift @@ -68,7 +68,7 @@ public protocol PassesDelegate: AnyObject, Sendable { /// - encoder: The `JSONEncoder` which you should use. /// - Returns: The encoded pass JSON data. /// - /// > Tip: See the [Pass](https://developer.apple.com/documentation/walletpasses/pass) object to understand the keys. + /// > Tip: See the [`Pass`](https://developer.apple.com/documentation/walletpasses/pass) object to understand the keys. func encode(pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data /// Should return a `URL` which points to the template data for the pass. diff --git a/Sources/Passes/PassesError.swift b/Sources/Passes/PassesError.swift index a28a089..0c0f34c 100644 --- a/Sources/Passes/PassesError.swift +++ b/Sources/Passes/PassesError.swift @@ -1,6 +1,6 @@ // // PassesError.swift -// +// PassKit // // Created by Scott Grosch on 1/22/20. // From 4aa6f1059c84f5940e22f77a3f674eeb418e240a Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Fri, 28 Jun 2024 09:58:01 +0200 Subject: [PATCH 09/27] Fix PassJSON --- Sources/Passes/DTOs/PassJSON.swift | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/Sources/Passes/DTOs/PassJSON.swift b/Sources/Passes/DTOs/PassJSON.swift index 50fcd21..f0cbafc 100644 --- a/Sources/Passes/DTOs/PassJSON.swift +++ b/Sources/Passes/DTOs/PassJSON.swift @@ -10,26 +10,26 @@ /// > Tip: See the [`Pass`](https://developer.apple.com/documentation/walletpasses/pass) object to understand the keys. public protocol PassJSON: Encodable { /// A short description that iOS accessibility technologies use for a pass. - var description: String { get set } + var description: String { get } /// The version of the file format. The value must be 1. var formatVersion: Int { get } /// The name of the organization. - var organizationName: String { get set } + var organizationName: String { get } /// The pass type identifier that’s registered with Apple. /// /// The value must be the same as the distribution certificate used to sign the pass. - var passTypeIdentifier: String { get set } + var passTypeIdentifier: String { get } /// An alphanumeric serial number. /// /// The combination of the serial number and pass type identifier must be unique for each pass. - var serialNumber: String { get set } + var serialNumber: String { get } /// The Team ID for the Apple Developer Program account that registered the pass type identifier. - var teamIdentifier: String { get set } + var teamIdentifier: String { get } } public extension PassJSON { @@ -43,12 +43,12 @@ public extension PassJSON { /// > Tip: See the [`PassFieldContent`](https://developer.apple.com/documentation/walletpasses/passfieldcontent) object to understand the keys. public protocol PassFieldContent: Encodable { /// A unique key that identifies a field in the pass; for example, `departure-gate`. - var key: String { get set } + var key: String { get } /// The value to use for the field; for example, 42. /// /// A date or time value must include a time zone. - var value: String { get set } + var value: String { get } } /// A protocol that represents the groups of fields that display the information for a boarding pass. @@ -61,7 +61,7 @@ public protocol BoardingPass: Encodable { /// /// The system may use the value to display more information, /// such as showing an airplane icon for the pass on watchOS when the value is set to `PKTransitTypeAir`. - var transitType: TransitType { get set } + var transitType: TransitType.RawValue { get } } /// The type of transit for a boarding pass. @@ -80,14 +80,20 @@ public protocol Barcodes: Encodable { /// The format of the barcode. /// /// The barcode format `PKBarcodeFormatCode128` isn’t supported for watchOS. - var format: BarcodeFormat { get set } + var format: BarcodeFormat.RawValue { get } /// The message or payload to display as a barcode. - var message: String { get set } + var message: String { get } /// The IANA character set name of the text encoding to use to convert message /// from a string representation to a data representation that the system renders as a barcode, such as `iso-8859-1`. - var messageEncoding: String { get set } + var messageEncoding: String { get } +} + +public extension Barcodes { + var messageEncoding: String { + return "iso-8859-1" + } } /// The format of the barcode. From 11c4cf4006ddf4213e5b16676921ca55ca201645 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Fri, 28 Jun 2024 10:08:34 +0200 Subject: [PATCH 10/27] Make PassJSON enums Encodable --- Sources/Passes/DTOs/PassJSON.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Passes/DTOs/PassJSON.swift b/Sources/Passes/DTOs/PassJSON.swift index f0cbafc..0398d32 100644 --- a/Sources/Passes/DTOs/PassJSON.swift +++ b/Sources/Passes/DTOs/PassJSON.swift @@ -61,11 +61,11 @@ public protocol BoardingPass: Encodable { /// /// The system may use the value to display more information, /// such as showing an airplane icon for the pass on watchOS when the value is set to `PKTransitTypeAir`. - var transitType: TransitType.RawValue { get } + var transitType: TransitType { get } } /// The type of transit for a boarding pass. -public enum TransitType: String { +public enum TransitType: String, Encodable { case air = "PKTransitTypeAir" case boat = "PKTransitTypeBoat" case bus = "PKTransitTypeBus" @@ -80,7 +80,7 @@ public protocol Barcodes: Encodable { /// The format of the barcode. /// /// The barcode format `PKBarcodeFormatCode128` isn’t supported for watchOS. - var format: BarcodeFormat.RawValue { get } + var format: BarcodeFormat { get } /// The message or payload to display as a barcode. var message: String { get } @@ -97,7 +97,7 @@ public extension Barcodes { } /// The format of the barcode. -public enum BarcodeFormat: String { +public enum BarcodeFormat: String, Encodable { case pdf417 = "PKBarcodeFormatPDF417" case qr = "PKBarcodeFormatQR" case aztec = "PKBarcodeFormatAztec" From c87112586fdbc12c77091cd2cc0f844ed3be23ae Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Fri, 28 Jun 2024 11:29:41 +0200 Subject: [PATCH 11/27] Remove default values from the protocol --- README.md | 70 +++++++++++++++++++++++------- Sources/Passes/DTOs/PassJSON.swift | 15 ++----- 2 files changed, 58 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 9cee9e2..ca71fb8 100644 --- a/README.md +++ b/README.md @@ -64,10 +64,15 @@ final class PassData: PassDataModel, @unchecked Sendable { @Parent(key: "pass_id") var pass: PKPass - // Add any other field relative to your app, such as a location, a date, etc. + // Examples of other extra fields: @Field(key: "punches") var punches: Int + @Field(key: "title") + var title: String + + // Add any other field relative to your app, such as a location, a date, etc. + init() { } } @@ -75,8 +80,9 @@ struct CreatePassData: AsyncMigration { public func prepare(on database: Database) async throws { try await database.schema(Self.schema) .id() - .field("punches", .int, .required) .field("pass_id", .uuid, .required, .references(PKPass.schema, .id, onDelete: .cascade)) + .field("punches", .int, .required) + .field("title", .string, .required) .create() } @@ -124,32 +130,66 @@ EXECUTE PROCEDURE "public"."RemoveUnregisteredItems"(); ### Model the `pass.json` contents -Create a `struct` that implements `Encodable` which will contain all the fields for the generated `pass.json` file. +Create a `struct` that implements `PassJSON` which will contain all the fields for the generated `pass.json` file. Create an initializer that takes your custom pass data, the `PKPass` and everything else you may need. For information on the various keys available see the [documentation](https://developer.apple.com/documentation/walletpasses/pass). -See also [this guide](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/index.html#//apple_ref/doc/uid/TP40012195-CH1-SW1) for some help. +See also [this guide](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/index.html#//apple_ref/doc/uid/TP40012195-CH1-SW1) for some help. Here's an example of a `struct` that implements `PassJSON`. ```swift -struct PassJSONData: Encodable { +import Passes + +struct PassJSONData: PassJSON { public static let token = "EB80D9C6-AD37-41A0-875E-3802E88CA478" - private let formatVersion = 1 - private let passTypeIdentifier = "pass.com.yoursite.passType" - private let authenticationToken = token + let description: String + let formatVersion = 1 + let organizationName = "vapor-community" + let passTypeIdentifier = Environment.get("PASSKIT_PASS_TYPE_IDENTIFIER")! let serialNumber: String - let relevantDate: String - let barcodes: [PassJSONData.Barcode] - ... + let teamIdentifier = Environment.get("APPLE_TEAM_IDENTIFIER")! - struct Barcode: Encodable { - let altText: String - let format = "PKBarcodeFormatQR" + private let webServiceURL = "\(Environment.get("WEBSITE_URL")!)api/" + private let authenticationToken = token + private let logoText = "Vapor" + private let sharingProhibited = true + let backgroundColor = "rgb(207, 77, 243)" + let foregroundColor = "rgb(255, 255, 255)" + + let barcodes = Barcode(message: "test") + struct Barcode: Barcodes { + let format = BarcodeFormat.qr let message: String let messageEncoding = "iso-8859-1" } + let boardingPass = Boarding(transitType: .air) + struct Boarding: BoardingPass { + let transitType: TransitType + let headerFields: [PassField] + let primaryFields: [PassField] + let secondaryFields: [PassField] + let auxiliaryFields: [PassField] + let backFields: [PassField] + + struct PassField: PassFieldContent { + let key: String + let label: String + let value: String + } + + init(transitType: TransitType) { + self.headerFields = [.init(key: "header", label: "Header", value: "Header")] + self.primaryFields = [.init(key: "primary", label: "Primary", value: "Primary")] + self.secondaryFields = [.init(key: "secondary", label: "Secondary", value: "Secondary")] + self.auxiliaryFields = [.init(key: "auxiliary", label: "Auxiliary", value: "Auxiliary")] + self.backFields = [.init(key: "back", label: "Back", value: "Back")] + self.transitType = transitType + } + } + init(data: PassData, pass: PKPass) { - ... + self.description = data.title + self.serialNumber = pass.id!.uuidString } } ``` diff --git a/Sources/Passes/DTOs/PassJSON.swift b/Sources/Passes/DTOs/PassJSON.swift index 0398d32..1aa4250 100644 --- a/Sources/Passes/DTOs/PassJSON.swift +++ b/Sources/Passes/DTOs/PassJSON.swift @@ -9,6 +9,9 @@ /// /// > Tip: See the [`Pass`](https://developer.apple.com/documentation/walletpasses/pass) object to understand the keys. public protocol PassJSON: Encodable { + /// The authentication token to use with the web service. + static var token: String { get } + /// A short description that iOS accessibility technologies use for a pass. var description: String { get } @@ -32,12 +35,6 @@ public protocol PassJSON: Encodable { var teamIdentifier: String { get } } -public extension PassJSON { - var formatVersion: Int { - return 1 - } -} - /// A protocol that represents the information to display in a field on a pass. /// /// > Tip: See the [`PassFieldContent`](https://developer.apple.com/documentation/walletpasses/passfieldcontent) object to understand the keys. @@ -90,12 +87,6 @@ public protocol Barcodes: Encodable { var messageEncoding: String { get } } -public extension Barcodes { - var messageEncoding: String { - return "iso-8859-1" - } -} - /// The format of the barcode. public enum BarcodeFormat: String, Encodable { case pdf417 = "PKBarcodeFormatPDF417" From 520c400d7b15e6469593c60b124f2aa559372f99 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Fri, 28 Jun 2024 12:32:18 +0200 Subject: [PATCH 12/27] Make `authenticationToken` unique for each pass --- README.md | 11 +++++---- Sources/Passes/DTOs/PassJSON.swift | 3 --- .../Middleware/ApplePassMiddleware.swift | 12 ++++++---- Sources/Passes/Models/ConcreteModels.swift | 10 ++++++-- Sources/Passes/Models/PassModel.swift | 16 ++++++++++++- .../Models/PassesRegistrationModel.swift | 2 +- Sources/Passes/PassesService.swift | 24 +++++++------------ 7 files changed, 45 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index ca71fb8..95dbb8c 100644 --- a/README.md +++ b/README.md @@ -139,8 +139,6 @@ See also [this guide](https://developer.apple.com/library/archive/documentation/ import Passes struct PassJSONData: PassJSON { - public static let token = "EB80D9C6-AD37-41A0-875E-3802E88CA478" - let description: String let formatVersion = 1 let organizationName = "vapor-community" @@ -149,7 +147,7 @@ struct PassJSONData: PassJSON { let teamIdentifier = Environment.get("APPLE_TEAM_IDENTIFIER")! private let webServiceURL = "\(Environment.get("WEBSITE_URL")!)api/" - private let authenticationToken = token + private let authenticationToken: String private let logoText = "Vapor" private let sharingProhibited = true let backgroundColor = "rgb(207, 77, 243)" @@ -190,6 +188,7 @@ struct PassJSONData: PassJSON { init(data: PassData, pass: PKPass) { self.description = data.title self.serialNumber = pass.id!.uuidString + self.authenticationToken = pass.authenticationToken } } ``` @@ -250,7 +249,7 @@ let pkDelegate = PKDelegate() func routes(_ app: Application) throws { let passesService = PassesService(app: app, delegate: pkDelegate) - passesService.registerRoutes(authorizationCode: PassJSONData.token) + passesService.registerRoutes() } ``` @@ -283,7 +282,9 @@ struct PassDataMiddleware: AsyncModelMiddleware { // Create the PKPass and add it to the PassData automatically at creation func create(model: PassData, on db: Database, next: AnyAsyncModelResponder) async throws { - let pkPass = PKPass(passTypeIdentifier: "pass.com.yoursite.passType") + let pkPass = PKPass( + passTypeIdentifier: "pass.com.yoursite.passType", + authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString()) try await pkPass.save(on: db) model.$pass.id = try pkPass.requireID() try await next.create(model, on: db) diff --git a/Sources/Passes/DTOs/PassJSON.swift b/Sources/Passes/DTOs/PassJSON.swift index 1aa4250..0cccc7e 100644 --- a/Sources/Passes/DTOs/PassJSON.swift +++ b/Sources/Passes/DTOs/PassJSON.swift @@ -9,9 +9,6 @@ /// /// > Tip: See the [`Pass`](https://developer.apple.com/documentation/walletpasses/pass) object to understand the keys. public protocol PassJSON: Encodable { - /// The authentication token to use with the web service. - static var token: String { get } - /// A short description that iOS accessibility technologies use for a pass. var description: String { get } diff --git a/Sources/Passes/Middleware/ApplePassMiddleware.swift b/Sources/Passes/Middleware/ApplePassMiddleware.swift index cd06789..ee498e5 100644 --- a/Sources/Passes/Middleware/ApplePassMiddleware.swift +++ b/Sources/Passes/Middleware/ApplePassMiddleware.swift @@ -27,13 +27,15 @@ /// THE SOFTWARE. import Vapor +import FluentKit -struct ApplePassMiddleware: AsyncMiddleware { - let authorizationCode: String - +struct ApplePassMiddleware: AsyncMiddleware { func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { - let auth = request.headers["Authorization"] - guard auth.first == "ApplePass \(authorizationCode)" else { + guard let auth = request.headers["Authorization"].first?.replacingOccurrences(of: "ApplePass ", with: ""), + let _ = try await P.query(on: request.db) + .filter(\._$authenticationToken == auth) + .first() + else { throw Abort(.unauthorized) } return try await next.respond(to: request) diff --git a/Sources/Passes/Models/ConcreteModels.swift b/Sources/Passes/Models/ConcreteModels.swift index f1cb595..5f2455d 100644 --- a/Sources/Passes/Models/ConcreteModels.swift +++ b/Sources/Passes/Models/ConcreteModels.swift @@ -85,11 +85,15 @@ open class PKPass: PassModel, @unchecked Sendable { @Field(key: PKPass.FieldKeys.passTypeIdentifier) public var passTypeIdentifier: String + + @Field(key: PKPass.FieldKeys.authenticationToken) + public var authenticationToken: String public required init() { } - public required init(passTypeIdentifier: String) { + public required init(passTypeIdentifier: String, authenticationToken: String) { self.passTypeIdentifier = passTypeIdentifier + self.authenticationToken = authenticationToken } } @@ -99,6 +103,7 @@ extension PKPass: AsyncMigration { .id() .field(PKPass.FieldKeys.updatedAt, .datetime, .required) .field(PKPass.FieldKeys.passTypeIdentifier, .string, .required) + .field(PKPass.FieldKeys.authenticationToken, .string, .required) .create() } @@ -112,6 +117,7 @@ extension PKPass { static let schemaName = "passes" static let updatedAt = FieldKey(stringLiteral: "updated_at") static let passTypeIdentifier = FieldKey(stringLiteral: "pass_type_identifier") + static let authenticationToken = FieldKey(stringLiteral: "authentication_token") } } @@ -157,7 +163,7 @@ extension PKErrorLog { } } -/// The `Model` that stores PassKit registrations. +/// The `Model` that stores passes registrations. final public class PassesRegistration: PassesRegistrationModel, @unchecked Sendable { public typealias PassType = PKPass public typealias DeviceType = PKDevice diff --git a/Sources/Passes/Models/PassModel.swift b/Sources/Passes/Models/PassModel.swift index 4f90c04..157c01e 100644 --- a/Sources/Passes/Models/PassModel.swift +++ b/Sources/Passes/Models/PassModel.swift @@ -29,13 +29,18 @@ import Vapor import Fluent -/// Represents the `Model` that stores PassKit passes. Uses a UUID so people can't easily guess pass IDs +/// Represents the `Model` that stores PassKit passes. +/// +/// Uses a UUID so people can't easily guess pass IDs public protocol PassModel: Model where IDValue == UUID { /// The pass type identifier. var passTypeIdentifier: String { get set } /// The last time the pass was modified. var updatedAt: Date? { get set } + + /// The authentication token for the pass. + var authenticationToken: String { get set } } internal extension PassModel { @@ -65,4 +70,13 @@ internal extension PassModel { return updatedAt } + + var _$authenticationToken: Field { + guard let mirror = Mirror(reflecting: self).descendant("_authenticationToken"), + let authenticationToken = mirror as? Field else { + fatalError("authenticationToken property must be declared using @Field") + } + + return authenticationToken + } } diff --git a/Sources/Passes/Models/PassesRegistrationModel.swift b/Sources/Passes/Models/PassesRegistrationModel.swift index 4a804c2..80ae020 100644 --- a/Sources/Passes/Models/PassesRegistrationModel.swift +++ b/Sources/Passes/Models/PassesRegistrationModel.swift @@ -29,7 +29,7 @@ import Vapor import Fluent -/// Represents the `Model` that stores PassKit registrations. +/// Represents the `Model` that stores passes registrations. public protocol PassesRegistrationModel: Model where IDValue == Int { associatedtype PassType: PassModel associatedtype DeviceType: PassKitDevice diff --git a/Sources/Passes/PassesService.swift b/Sources/Passes/PassesService.swift index 111c6ec..f7e6528 100644 --- a/Sources/Passes/PassesService.swift +++ b/Sources/Passes/PassesService.swift @@ -36,24 +36,22 @@ import PassKit /// The main class that handles PassKit passes. public final class PassesService: Sendable { - private let kit: PassesServiceCustom + private let service: PassesServiceCustom public init(app: Application, delegate: any PassesDelegate, logger: Logger? = nil) { - kit = .init(app: app, delegate: delegate, logger: logger) + service = .init(app: app, delegate: delegate, logger: logger) } /// Registers all the routes required for PassKit to work. - /// - /// - 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) + public func registerRoutes() { + service.registerRoutes() } /// Registers routes to send push notifications to updated passes. /// /// - Parameter middleware: The `Middleware` which will control authentication for the routes. public func registerPushRoutes(middleware: any Middleware) throws { - try kit.registerPushRoutes(middleware: middleware) + try service.registerPushRoutes(middleware: middleware) } /// Generates the pass content bundle for a given pass. @@ -63,7 +61,7 @@ public final class PassesService: Sendable { /// - db: The `Database` to use. /// - Returns: The generated pass content. public func generatePassContent(for pass: PKPass, on db: any Database) async throws -> Data { - try await kit.generatePassContent(for: pass, on: db) + try await service.generatePassContent(for: pass, on: db) } /// Adds the migrations for PassKit passes models. @@ -132,17 +130,11 @@ public final class PassesServiceCustom()) v1auth.post("devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", ":passSerial", use: { try await self.registerDevice(req: $0) }) v1auth.get("passes", ":passTypeIdentifier", ":passSerial", use: { try await self.latestVersionOfPass(req: $0) }) From 88bd2ec38fd27db98e513857372022bcf8edfbcb Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Fri, 28 Jun 2024 13:18:56 +0200 Subject: [PATCH 13/27] Move the device and error log models to the PassKit module --- Package.swift | 1 + README.md | 9 ++-- .../Models/DeviceModel.swift} | 4 +- .../Models/ErrorLogModel.swift} | 4 +- Sources/Passes/Models/ConcreteModels.swift | 41 ++++++++++--------- .../Models/PassesRegistrationModel.swift | 3 +- Sources/Passes/PassesService.swift | 14 +++---- 7 files changed, 41 insertions(+), 35 deletions(-) rename Sources/{Passes/Models/PassKitDevice.swift => PassKit/Models/DeviceModel.swift} (97%) rename Sources/{Passes/Models/PassKitErrorLog.swift => PassKit/Models/ErrorLogModel.swift} (96%) diff --git a/Package.swift b/Package.swift index cb4ce13..5ad42b4 100644 --- a/Package.swift +++ b/Package.swift @@ -7,6 +7,7 @@ let package = Package( .macOS(.v13), .iOS(.v16) ], products: [ + .library(name: "PassKit", targets: ["PassKit"]), .library(name: "Passes", targets: ["PassKit", "Passes"]), ], dependencies: [ diff --git a/README.md b/README.md index 95dbb8c..f954369 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ import Vapor import Fluent import Passes -final class PKDelegate: PassesDelegate { +final class PassDelegate: PassesDelegate { let sslSigningFilesDirectory = URL(fileURLWithPath: "Certificates/", isDirectory: true) let pemPrivateKeyPassword: String? = Environment.get("PEM_PRIVATE_KEY_PASSWORD")! @@ -245,10 +245,10 @@ This will implement all of the routes that PassKit expects to exist on your serv import Vapor import Passes -let pkDelegate = PKDelegate() +let passDelegate = PassDelegate() func routes(_ app: Application) throws { - let passesService = PassesService(app: app, delegate: pkDelegate) + let passesService = PassesService(app: app, delegate: passDelegate) passesService.registerRoutes() } ``` @@ -348,6 +348,9 @@ app.apns.containers.use( If you don't like the schema names that are used by default, you can instead instantiate the generic `PassesServiceCustom` and provide your model types. ```swift +import PassKit +import Passes + let passesService = PassesServiceCustom(app: app, delegate: delegate) ``` diff --git a/Sources/Passes/Models/PassKitDevice.swift b/Sources/PassKit/Models/DeviceModel.swift similarity index 97% rename from Sources/Passes/Models/PassKitDevice.swift rename to Sources/PassKit/Models/DeviceModel.swift index 2df7b68..40eccf6 100644 --- a/Sources/Passes/Models/PassKitDevice.swift +++ b/Sources/PassKit/Models/DeviceModel.swift @@ -30,7 +30,7 @@ import Vapor import Fluent /// Represents the `Model` that stores PassKit devices. -public protocol PassKitDevice: Model where IDValue == Int { +public protocol DeviceModel: Model where IDValue == Int { /// The push token used for sending updates to the device. var pushToken: String { get set } @@ -44,7 +44,7 @@ public protocol PassKitDevice: Model where IDValue == Int { init(deviceLibraryIdentifier: String, pushToken: String) } -internal extension PassKitDevice { +package extension DeviceModel { var _$id: ID { guard let mirror = Mirror(reflecting: self).descendant("_id"), let id = mirror as? ID else { diff --git a/Sources/Passes/Models/PassKitErrorLog.swift b/Sources/PassKit/Models/ErrorLogModel.swift similarity index 96% rename from Sources/Passes/Models/PassKitErrorLog.swift rename to Sources/PassKit/Models/ErrorLogModel.swift index b98ddc6..bcd42d3 100644 --- a/Sources/Passes/Models/PassKitErrorLog.swift +++ b/Sources/PassKit/Models/ErrorLogModel.swift @@ -30,7 +30,7 @@ import Vapor import Fluent /// Represents the `Model` that stores PassKit error logs. -public protocol PassKitErrorLog: Model { +public protocol ErrorLogModel: Model { /// The error message provided by PassKit var message: String { get set } @@ -39,7 +39,7 @@ public protocol PassKitErrorLog: Model { init(message: String) } -internal extension PassKitErrorLog { +package extension ErrorLogModel { var _$message: Field { guard let mirror = Mirror(reflecting: self).descendant("_message"), let message = mirror as? Field else { diff --git a/Sources/Passes/Models/ConcreteModels.swift b/Sources/Passes/Models/ConcreteModels.swift index 5f2455d..f051f8e 100644 --- a/Sources/Passes/Models/ConcreteModels.swift +++ b/Sources/Passes/Models/ConcreteModels.swift @@ -28,18 +28,19 @@ import Vapor import Fluent +import PassKit /// The `Model` that stores PassKit devices. -final public class PKDevice: PassKitDevice, @unchecked Sendable { - public static let schema = PKDevice.FieldKeys.schemaName +final public class PassesDevice: DeviceModel, @unchecked Sendable { + public static let schema = PassesDevice.FieldKeys.schemaName @ID(custom: .id) public var id: Int? - @Field(key: PKDevice.FieldKeys.pushToken) + @Field(key: PassesDevice.FieldKeys.pushToken) public var pushToken: String - @Field(key: PKDevice.FieldKeys.deviceLibraryIdentifier) + @Field(key: PassesDevice.FieldKeys.deviceLibraryIdentifier) public var deviceLibraryIdentifier: String public init(deviceLibraryIdentifier: String, pushToken: String) { @@ -50,13 +51,13 @@ final public class PKDevice: PassKitDevice, @unchecked Sendable { public init() {} } -extension PKDevice: AsyncMigration { +extension PassesDevice: AsyncMigration { public func prepare(on database: any Database) async throws { try await database.schema(Self.schema) .field(.id, .int, .identifier(auto: true)) - .field(PKDevice.FieldKeys.pushToken, .string, .required) - .field(PKDevice.FieldKeys.deviceLibraryIdentifier, .string, .required) - .unique(on: PKDevice.FieldKeys.pushToken, PKDevice.FieldKeys.deviceLibraryIdentifier) + .field(PassesDevice.FieldKeys.pushToken, .string, .required) + .field(PassesDevice.FieldKeys.deviceLibraryIdentifier, .string, .required) + .unique(on: PassesDevice.FieldKeys.pushToken, PassesDevice.FieldKeys.deviceLibraryIdentifier) .create() } @@ -65,9 +66,9 @@ extension PKDevice: AsyncMigration { } } -extension PKDevice { +extension PassesDevice { enum FieldKeys { - static let schemaName = "devices" + static let schemaName = "passes_devices" static let pushToken = FieldKey(stringLiteral: "push_token") static let deviceLibraryIdentifier = FieldKey(stringLiteral: "device_library_identifier") } @@ -122,16 +123,16 @@ extension PKPass { } /// The `Model` that stores PassKit error logs. -final public class PKErrorLog: PassKitErrorLog, @unchecked Sendable { - public static let schema = PKErrorLog.FieldKeys.schemaName +final public class PassesErrorLog: ErrorLogModel, @unchecked Sendable { + public static let schema = PassesErrorLog.FieldKeys.schemaName @ID(custom: .id) public var id: Int? - @Timestamp(key: PKErrorLog.FieldKeys.createdAt, on: .create) + @Timestamp(key: PassesErrorLog.FieldKeys.createdAt, on: .create) public var createdAt: Date? - @Field(key: PKErrorLog.FieldKeys.message) + @Field(key: PassesErrorLog.FieldKeys.message) public var message: String public init(message: String) { @@ -141,12 +142,12 @@ final public class PKErrorLog: PassKitErrorLog, @unchecked Sendable { public init() {} } -extension PKErrorLog: AsyncMigration { +extension PassesErrorLog: AsyncMigration { public func prepare(on database: any Database) async throws { try await database.schema(Self.schema) .field(.id, .int, .identifier(auto: true)) - .field(PKErrorLog.FieldKeys.createdAt, .datetime, .required) - .field(PKErrorLog.FieldKeys.message, .string, .required) + .field(PassesErrorLog.FieldKeys.createdAt, .datetime, .required) + .field(PassesErrorLog.FieldKeys.message, .string, .required) .create() } @@ -155,9 +156,9 @@ extension PKErrorLog: AsyncMigration { } } -extension PKErrorLog { +extension PassesErrorLog { enum FieldKeys { - static let schemaName = "errors" + static let schemaName = "passes_errors" static let createdAt = FieldKey(stringLiteral: "created_at") static let message = FieldKey(stringLiteral: "message") } @@ -166,7 +167,7 @@ extension PKErrorLog { /// The `Model` that stores passes registrations. final public class PassesRegistration: PassesRegistrationModel, @unchecked Sendable { public typealias PassType = PKPass - public typealias DeviceType = PKDevice + public typealias DeviceType = PassesDevice public static let schema = PassesRegistration.FieldKeys.schemaName diff --git a/Sources/Passes/Models/PassesRegistrationModel.swift b/Sources/Passes/Models/PassesRegistrationModel.swift index 80ae020..7691713 100644 --- a/Sources/Passes/Models/PassesRegistrationModel.swift +++ b/Sources/Passes/Models/PassesRegistrationModel.swift @@ -28,11 +28,12 @@ import Vapor import Fluent +import PassKit /// Represents the `Model` that stores passes registrations. public protocol PassesRegistrationModel: Model where IDValue == Int { associatedtype PassType: PassModel - associatedtype DeviceType: PassKitDevice + associatedtype DeviceType: DeviceModel /// The device for this registration. var device: DeviceType { get set } diff --git a/Sources/Passes/PassesService.swift b/Sources/Passes/PassesService.swift index f7e6528..24efeb4 100644 --- a/Sources/Passes/PassesService.swift +++ b/Sources/Passes/PassesService.swift @@ -36,7 +36,7 @@ import PassKit /// The main class that handles PassKit passes. public final class PassesService: Sendable { - private let service: PassesServiceCustom + private let service: PassesServiceCustom public init(app: Application, delegate: any PassesDelegate, logger: Logger? = nil) { service = .init(app: app, delegate: delegate, logger: logger) @@ -69,9 +69,9 @@ public final class PassesService: Sendable { /// - Parameter migrations: The `Migrations` object to add the migrations to. public static func register(migrations: Migrations) { migrations.add(PKPass()) - migrations.add(PKDevice()) + migrations.add(PassesDevice()) migrations.add(PassesRegistration()) - migrations.add(PKErrorLog()) + migrations.add(PassesErrorLog()) } /// Sends push notifications for a given pass. @@ -82,7 +82,7 @@ public final class PassesService: Sendable { /// - db: The `Database` to use. /// - app: The `Application` to use. public static func sendPushNotificationsForPass(id: UUID, of passTypeIdentifier: String, on db: any Database, app: Application) async throws { - try await PassesServiceCustom.sendPushNotificationsForPass(id: id, of: passTypeIdentifier, on: db, app: app) + try await PassesServiceCustom.sendPushNotificationsForPass(id: id, of: passTypeIdentifier, on: db, app: app) } /// Sends push notifications for a given pass. @@ -92,7 +92,7 @@ public final class PassesService: Sendable { /// - db: The `Database` to use. /// - app: The `Application` to use. public static func sendPushNotifications(for pass: PKPass, on db: any Database, app: Application) async throws { - try await PassesServiceCustom.sendPushNotifications(for: pass, on: db, app: app) + try await PassesServiceCustom.sendPushNotifications(for: pass, on: db, app: app) } /// Sends push notifications for a given pass. @@ -102,7 +102,7 @@ public final class PassesService: Sendable { /// - db: The `Database` to use. /// - app: The `Application` to use. public static func sendPushNotifications(for pass: ParentProperty, on db: any Database, app: Application) async throws { - try await PassesServiceCustom.sendPushNotifications(for: pass, on: db, app: app) + try await PassesServiceCustom.sendPushNotifications(for: pass, on: db, app: app) } } @@ -113,7 +113,7 @@ public final class PassesService: Sendable { /// - Device Type /// - Registration Type /// - Error Log Type -public final class PassesServiceCustom: Sendable where P == R.PassType, D == R.DeviceType { +public final class PassesServiceCustom: Sendable where P == R.PassType, D == R.DeviceType { public unowned let delegate: any PassesDelegate private unowned let app: Application From 2dc8218a25b15c128696198f3379b7d5648a31ec Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Fri, 28 Jun 2024 13:24:30 +0200 Subject: [PATCH 14/27] Update `.spi.yml` --- .spi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.spi.yml b/.spi.yml index bb99af5..280f8ca 100644 --- a/.spi.yml +++ b/.spi.yml @@ -1,4 +1,4 @@ version: 1 builder: configs: - - documentation_targets: [Passes] \ No newline at end of file + - documentation_targets: [PassKit, Passes] \ No newline at end of file From 4387bba30dde9276f3267daee82d06c91cb4ccd8 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Fri, 28 Jun 2024 14:57:23 +0200 Subject: [PATCH 15/27] Update README.md --- README.md | 52 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index f954369..640f0a0 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,11 @@ EXECUTE PROCEDURE "public"."RemoveUnregisteredItems"(); Create a `struct` that implements `PassJSON` which will contain all the fields for the generated `pass.json` file. Create an initializer that takes your custom pass data, the `PKPass` and everything else you may need. -For information on the various keys available see the [documentation](https://developer.apple.com/documentation/walletpasses/pass). -See also [this guide](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/index.html#//apple_ref/doc/uid/TP40012195-CH1-SW1) for some help. Here's an example of a `struct` that implements `PassJSON`. + +> [!TIP] +> For information on the various keys available see the [documentation](https://developer.apple.com/documentation/walletpasses/pass). See also [this guide](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/index.html#//apple_ref/doc/uid/TP40012195-CH1-SW1) for some help. + +Here's an example of a `struct` that implements `PassJSON`. ```swift import Passes @@ -197,8 +200,12 @@ struct PassJSONData: PassJSON { Create a delegate file that implements `PassesDelegate`. 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). + +> [!TIP] +> 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 @@ -233,12 +240,12 @@ final class PassDelegate: PassesDelegate { } ``` -You **must** explicitly declare `pemPrivateKeyPassword` as a `String?` or Swift will ignore it as it'll think it's a `String` instead. +> [!IMPORTANT] +> You **must** explicitly declare `pemPrivateKeyPassword` as a `String?` or Swift will ignore it as it'll think it's a `String` instead. ### Register Routes -Next, register the routes in `routes.swift`. Notice how the `delegate` is created as -a global variable. You need to ensure that the delegate doesn't go out of scope as soon as the `routes(_:)` method exits! +Next, register the routes in `routes.swift`. This will implement all of the routes that PassKit expects to exist on your server for you. ```swift @@ -253,9 +260,15 @@ func routes(_ app: Application) throws { } ``` +> [!NOTE] +> Notice how the `delegate` is created as a global variable. You need to ensure that the delegate doesn't go out of scope as soon as the `routes(_:)` method exits! + #### Push Notifications -If you wish to include routes specifically for sending push notifications to updated passes you can also include this line in your `routes(_:)` method. You'll need to pass in whatever `Middleware` you want Vapor to use to authenticate the two routes. If you don't include this line, you have to configure an APNS container yourself +If you wish to include routes specifically for sending push notifications to updated passes you can also include this line in your `routes(_:)` method. You'll need to pass in whatever `Middleware` you want Vapor to use to authenticate the two routes. + +> [!IMPORTANT] +> If you don't include this line, you have to configure an APNS container yourself ```swift try passesService.registerPushRoutes(middleware: SecretMiddleware(secret: "foo")) @@ -266,7 +279,11 @@ That will add two routes: - POST .../api/v1/push/*passTypeIdentifier*/*passBarcode* (Sends notifications) - GET .../api/v1/push/*passTypeIdentifier*/*passBarcode* (Retrieves a list of push tokens which would be sent a notification) -Whether you include the routes or not, you'll want to add a model middleware that sends push notifications and updates the `updatedAt` field when your pass data updates. The model middleware could also create and link the `PKPass` during the creation of the pass data, depending on your requirements. You can implement it like so: +#### Pass data model middleware + +Whether you include the routes or not, you'll want to add a model middleware that sends push notifications and updates the `updatedAt` field when your pass data updates. The model middleware could also create and link the `PKPass` during the creation of the pass data, depending on your requirements. + +You can implement it like so: ```swift import Vapor @@ -309,7 +326,9 @@ app.databases.middleware.use(PassDataMiddleware(app: app), on: .psql) > [!IMPORTANT] > Whenever your pass data changes, you must update the *updatedAt* time of the linked pass so that Apple knows to send you a new pass. -If you did not include the routes remember to configure APNSwift yourself like this: +#### APNSwift + +If you did not include the routes, remember to configure APNSwift yourself like this: ```swift let apnsConfig: APNSClientConfiguration @@ -343,9 +362,9 @@ app.apns.containers.use( ) ``` -#### Custom Implementation +### Custom Implementation -If you don't like the schema names that are used by default, you can instead instantiate the generic `PassesServiceCustom` and provide your model types. +If you don't like the schema names that are used by default, you can instead create your own models conforming to `PassModel`, `DeviceModel`, `PassesRegistrationModel` and `ErrorLogModel` and instantiate the generic `PassesServiceCustom`, providing it your model types. ```swift import PassKit @@ -354,6 +373,12 @@ import Passes let passesService = PassesServiceCustom(app: app, delegate: delegate) ``` +The `DeviceModel` and `ErrorLogModel` protocols are found inside the the `PassKit` product. If you want to customize the devices and error logs models you have to add it to the package manifest: + +```swift +.product(name: "PassKit", package: "PassKit") +``` + ### Register Migrations If you're using the default schemas provided by this package you can register the default models in your `configure(_:)` method: @@ -362,7 +387,8 @@ If you're using the default schemas provided by this package you can register th PassesService.register(migrations: app.migrations) ``` -Register the default models before the migration of your pass data model. +> [!IMPORTANT] +> Register the default models before the migration of your pass data model. ### Generate Pass Content @@ -382,7 +408,7 @@ struct PassesController: RouteCollection { } ``` -and then use it in the route handler: +and then use it in route handlers: ```swift fileprivate func passHandler(_ req: Request) async throws -> Response { From 4c70b9a256616d4b28b7b7996a0e4968e1e74a00 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Sat, 29 Jun 2024 12:59:12 +0200 Subject: [PATCH 16/27] Refactor models and services --- .../Models/Concrete Models/PKPass.swift | 57 +++ .../Models/Concrete Models/PassesDevice.swift | 54 ++ .../Concrete Models/PassesErrorLog.swift | 52 ++ .../Concrete Models/PassesRegistration.swift | 50 ++ Sources/Passes/Models/ConcreteModels.swift | 206 -------- Sources/Passes/Models/PassDataModel.swift | 2 +- Sources/Passes/Models/PassModel.swift | 2 +- .../Models/PassesRegistrationModel.swift | 2 +- Sources/Passes/PassesDelegate.swift | 2 +- Sources/Passes/PassesError.swift | 2 - Sources/Passes/PassesService.swift | 461 +---------------- Sources/Passes/PassesServiceCustom.swift | 468 ++++++++++++++++++ 12 files changed, 686 insertions(+), 672 deletions(-) create mode 100644 Sources/Passes/Models/Concrete Models/PKPass.swift create mode 100644 Sources/Passes/Models/Concrete Models/PassesDevice.swift create mode 100644 Sources/Passes/Models/Concrete Models/PassesErrorLog.swift create mode 100644 Sources/Passes/Models/Concrete Models/PassesRegistration.swift delete mode 100644 Sources/Passes/Models/ConcreteModels.swift create mode 100644 Sources/Passes/PassesServiceCustom.swift diff --git a/Sources/Passes/Models/Concrete Models/PKPass.swift b/Sources/Passes/Models/Concrete Models/PKPass.swift new file mode 100644 index 0000000..4103a09 --- /dev/null +++ b/Sources/Passes/Models/Concrete Models/PKPass.swift @@ -0,0 +1,57 @@ +// +// PKPass.swift +// PassKit +// +// Created by Francesco Paolo Severino on 29/06/24. +// + +import Foundation +import FluentKit + +/// The `Model` that stores PassKit passes. +open class PKPass: PassModel, @unchecked Sendable { + public static let schema = PKPass.FieldKeys.schemaName + + @ID + public var id: UUID? + + @Timestamp(key: PKPass.FieldKeys.updatedAt, on: .update) + public var updatedAt: Date? + + @Field(key: PKPass.FieldKeys.passTypeIdentifier) + public var passTypeIdentifier: String + + @Field(key: PKPass.FieldKeys.authenticationToken) + public var authenticationToken: String + + public required init() { } + + public required init(passTypeIdentifier: String, authenticationToken: String) { + self.passTypeIdentifier = passTypeIdentifier + self.authenticationToken = authenticationToken + } +} + +extension PKPass: AsyncMigration { + public func prepare(on database: any Database) async throws { + try await database.schema(Self.schema) + .id() + .field(PKPass.FieldKeys.updatedAt, .datetime, .required) + .field(PKPass.FieldKeys.passTypeIdentifier, .string, .required) + .field(PKPass.FieldKeys.authenticationToken, .string, .required) + .create() + } + + public func revert(on database: any Database) async throws { + try await database.schema(Self.schema).delete() + } +} + +extension PKPass { + enum FieldKeys { + static let schemaName = "passes" + static let updatedAt = FieldKey(stringLiteral: "updated_at") + static let passTypeIdentifier = FieldKey(stringLiteral: "pass_type_identifier") + static let authenticationToken = FieldKey(stringLiteral: "authentication_token") + } +} diff --git a/Sources/Passes/Models/Concrete Models/PassesDevice.swift b/Sources/Passes/Models/Concrete Models/PassesDevice.swift new file mode 100644 index 0000000..6979f90 --- /dev/null +++ b/Sources/Passes/Models/Concrete Models/PassesDevice.swift @@ -0,0 +1,54 @@ +// +// PassesDevice.swift +// PassKit +// +// Created by Francesco Paolo Severino on 29/06/24. +// + +import Foundation +import FluentKit +import PassKit + +/// The `Model` that stores PassKit passes devices. +final public class PassesDevice: DeviceModel, @unchecked Sendable { + public static let schema = PassesDevice.FieldKeys.schemaName + + @ID(custom: .id) + public var id: Int? + + @Field(key: PassesDevice.FieldKeys.pushToken) + public var pushToken: String + + @Field(key: PassesDevice.FieldKeys.deviceLibraryIdentifier) + public var deviceLibraryIdentifier: String + + public init(deviceLibraryIdentifier: String, pushToken: String) { + self.deviceLibraryIdentifier = deviceLibraryIdentifier + self.pushToken = pushToken + } + + public init() {} +} + +extension PassesDevice: AsyncMigration { + public func prepare(on database: any Database) async throws { + try await database.schema(Self.schema) + .field(.id, .int, .identifier(auto: true)) + .field(PassesDevice.FieldKeys.pushToken, .string, .required) + .field(PassesDevice.FieldKeys.deviceLibraryIdentifier, .string, .required) + .unique(on: PassesDevice.FieldKeys.pushToken, PassesDevice.FieldKeys.deviceLibraryIdentifier) + .create() + } + + public func revert(on database: any Database) async throws { + try await database.schema(Self.schema).delete() + } +} + +extension PassesDevice { + enum FieldKeys { + static let schemaName = "passes_devices" + static let pushToken = FieldKey(stringLiteral: "push_token") + static let deviceLibraryIdentifier = FieldKey(stringLiteral: "device_library_identifier") + } +} \ No newline at end of file diff --git a/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift b/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift new file mode 100644 index 0000000..abfcbbb --- /dev/null +++ b/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift @@ -0,0 +1,52 @@ +// +// PassesErrorLog.swift +// PassKit +// +// Created by Francesco Paolo Severino on 29/06/24. +// + +import Foundation +import FluentKit +import PassKit + +/// The `Model` that stores PassKit passes error logs. +final public class PassesErrorLog: ErrorLogModel, @unchecked Sendable { + public static let schema = PassesErrorLog.FieldKeys.schemaName + + @ID(custom: .id) + public var id: Int? + + @Timestamp(key: PassesErrorLog.FieldKeys.createdAt, on: .create) + public var createdAt: Date? + + @Field(key: PassesErrorLog.FieldKeys.message) + public var message: String + + public init(message: String) { + self.message = message + } + + public init() {} +} + +extension PassesErrorLog: AsyncMigration { + public func prepare(on database: any Database) async throws { + try await database.schema(Self.schema) + .field(.id, .int, .identifier(auto: true)) + .field(PassesErrorLog.FieldKeys.createdAt, .datetime, .required) + .field(PassesErrorLog.FieldKeys.message, .string, .required) + .create() + } + + public func revert(on database: any Database) async throws { + try await database.schema(Self.schema).delete() + } +} + +extension PassesErrorLog { + enum FieldKeys { + static let schemaName = "passes_errors" + static let createdAt = FieldKey(stringLiteral: "created_at") + static let message = FieldKey(stringLiteral: "message") + } +} \ No newline at end of file diff --git a/Sources/Passes/Models/Concrete Models/PassesRegistration.swift b/Sources/Passes/Models/Concrete Models/PassesRegistration.swift new file mode 100644 index 0000000..d759760 --- /dev/null +++ b/Sources/Passes/Models/Concrete Models/PassesRegistration.swift @@ -0,0 +1,50 @@ +// +// PassesRegistration.swift +// PassKit +// +// Created by Francesco Paolo Severino on 29/06/24. +// + +import Foundation +import FluentKit + +/// The `Model` that stores passes registrations. +final public class PassesRegistration: PassesRegistrationModel, @unchecked Sendable { + public typealias PassType = PKPass + public typealias DeviceType = PassesDevice + + public static let schema = PassesRegistration.FieldKeys.schemaName + + @ID(custom: .id) + public var id: Int? + + @Parent(key: PassesRegistration.FieldKeys.deviceID) + public var device: DeviceType + + @Parent(key: PassesRegistration.FieldKeys.passID) + public var pass: PassType + + public init() {} +} + +extension PassesRegistration: AsyncMigration { + public func prepare(on database: any Database) async throws { + try await database.schema(Self.schema) + .field(.id, .int, .identifier(auto: true)) + .field(PassesRegistration.FieldKeys.deviceID, .int, .required, .references(DeviceType.schema, .id, onDelete: .cascade)) + .field(PassesRegistration.FieldKeys.passID, .uuid, .required, .references(PassType.schema, .id, onDelete: .cascade)) + .create() + } + + public func revert(on database: any Database) async throws { + try await database.schema(Self.schema).delete() + } +} + +extension PassesRegistration { + enum FieldKeys { + static let schemaName = "passes_registrations" + static let deviceID = FieldKey(stringLiteral: "device_id") + static let passID = FieldKey(stringLiteral: "pass_id") + } +} diff --git a/Sources/Passes/Models/ConcreteModels.swift b/Sources/Passes/Models/ConcreteModels.swift deleted file mode 100644 index f051f8e..0000000 --- a/Sources/Passes/Models/ConcreteModels.swift +++ /dev/null @@ -1,206 +0,0 @@ -/// 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. - -import Vapor -import Fluent -import PassKit - -/// The `Model` that stores PassKit devices. -final public class PassesDevice: DeviceModel, @unchecked Sendable { - public static let schema = PassesDevice.FieldKeys.schemaName - - @ID(custom: .id) - public var id: Int? - - @Field(key: PassesDevice.FieldKeys.pushToken) - public var pushToken: String - - @Field(key: PassesDevice.FieldKeys.deviceLibraryIdentifier) - public var deviceLibraryIdentifier: String - - public init(deviceLibraryIdentifier: String, pushToken: String) { - self.deviceLibraryIdentifier = deviceLibraryIdentifier - self.pushToken = pushToken - } - - public init() {} -} - -extension PassesDevice: AsyncMigration { - public func prepare(on database: any Database) async throws { - try await database.schema(Self.schema) - .field(.id, .int, .identifier(auto: true)) - .field(PassesDevice.FieldKeys.pushToken, .string, .required) - .field(PassesDevice.FieldKeys.deviceLibraryIdentifier, .string, .required) - .unique(on: PassesDevice.FieldKeys.pushToken, PassesDevice.FieldKeys.deviceLibraryIdentifier) - .create() - } - - public func revert(on database: any Database) async throws { - try await database.schema(Self.schema).delete() - } -} - -extension PassesDevice { - enum FieldKeys { - static let schemaName = "passes_devices" - static let pushToken = FieldKey(stringLiteral: "push_token") - static let deviceLibraryIdentifier = FieldKey(stringLiteral: "device_library_identifier") - } -} - -/// The `Model` that stores PassKit passes. -open class PKPass: PassModel, @unchecked Sendable { - public static let schema = PKPass.FieldKeys.schemaName - - @ID - public var id: UUID? - - @Timestamp(key: PKPass.FieldKeys.updatedAt, on: .update) - public var updatedAt: Date? - - @Field(key: PKPass.FieldKeys.passTypeIdentifier) - public var passTypeIdentifier: String - - @Field(key: PKPass.FieldKeys.authenticationToken) - public var authenticationToken: String - - public required init() { } - - public required init(passTypeIdentifier: String, authenticationToken: String) { - self.passTypeIdentifier = passTypeIdentifier - self.authenticationToken = authenticationToken - } -} - -extension PKPass: AsyncMigration { - public func prepare(on database: any Database) async throws { - try await database.schema(Self.schema) - .id() - .field(PKPass.FieldKeys.updatedAt, .datetime, .required) - .field(PKPass.FieldKeys.passTypeIdentifier, .string, .required) - .field(PKPass.FieldKeys.authenticationToken, .string, .required) - .create() - } - - public func revert(on database: any Database) async throws { - try await database.schema(Self.schema).delete() - } -} - -extension PKPass { - enum FieldKeys { - static let schemaName = "passes" - static let updatedAt = FieldKey(stringLiteral: "updated_at") - static let passTypeIdentifier = FieldKey(stringLiteral: "pass_type_identifier") - static let authenticationToken = FieldKey(stringLiteral: "authentication_token") - } -} - -/// The `Model` that stores PassKit error logs. -final public class PassesErrorLog: ErrorLogModel, @unchecked Sendable { - public static let schema = PassesErrorLog.FieldKeys.schemaName - - @ID(custom: .id) - public var id: Int? - - @Timestamp(key: PassesErrorLog.FieldKeys.createdAt, on: .create) - public var createdAt: Date? - - @Field(key: PassesErrorLog.FieldKeys.message) - public var message: String - - public init(message: String) { - self.message = message - } - - public init() {} -} - -extension PassesErrorLog: AsyncMigration { - public func prepare(on database: any Database) async throws { - try await database.schema(Self.schema) - .field(.id, .int, .identifier(auto: true)) - .field(PassesErrorLog.FieldKeys.createdAt, .datetime, .required) - .field(PassesErrorLog.FieldKeys.message, .string, .required) - .create() - } - - public func revert(on database: any Database) async throws { - try await database.schema(Self.schema).delete() - } -} - -extension PassesErrorLog { - enum FieldKeys { - static let schemaName = "passes_errors" - static let createdAt = FieldKey(stringLiteral: "created_at") - static let message = FieldKey(stringLiteral: "message") - } -} - -/// The `Model` that stores passes registrations. -final public class PassesRegistration: PassesRegistrationModel, @unchecked Sendable { - public typealias PassType = PKPass - public typealias DeviceType = PassesDevice - - public static let schema = PassesRegistration.FieldKeys.schemaName - - @ID(custom: .id) - public var id: Int? - - @Parent(key: PassesRegistration.FieldKeys.deviceID) - public var device: DeviceType - - @Parent(key: PassesRegistration.FieldKeys.passID) - public var pass: PassType - - public init() {} -} - -extension PassesRegistration: AsyncMigration { - public func prepare(on database: any Database) async throws { - try await database.schema(Self.schema) - .field(.id, .int, .identifier(auto: true)) - .field(PassesRegistration.FieldKeys.deviceID, .int, .required, .references(DeviceType.schema, .id, onDelete: .cascade)) - .field(PassesRegistration.FieldKeys.passID, .uuid, .required, .references(PassType.schema, .id, onDelete: .cascade)) - .create() - } - - public func revert(on database: any Database) async throws { - try await database.schema(Self.schema).delete() - } -} - -extension PassesRegistration { - enum FieldKeys { - static let schemaName = "passes_registrations" - static let deviceID = FieldKey(stringLiteral: "device_id") - static let passID = FieldKey(stringLiteral: "pass_id") - } -} diff --git a/Sources/Passes/Models/PassDataModel.swift b/Sources/Passes/Models/PassDataModel.swift index 62c7f00..def476f 100644 --- a/Sources/Passes/Models/PassDataModel.swift +++ b/Sources/Passes/Models/PassDataModel.swift @@ -27,7 +27,7 @@ /// THE SOFTWARE. import Vapor -import Fluent +import FluentKit /// Represents the `Model` that stores custom app data associated to PassKit passes. public protocol PassDataModel: Model { diff --git a/Sources/Passes/Models/PassModel.swift b/Sources/Passes/Models/PassModel.swift index 157c01e..db8962f 100644 --- a/Sources/Passes/Models/PassModel.swift +++ b/Sources/Passes/Models/PassModel.swift @@ -27,7 +27,7 @@ /// THE SOFTWARE. import Vapor -import Fluent +import FluentKit /// Represents the `Model` that stores PassKit passes. /// diff --git a/Sources/Passes/Models/PassesRegistrationModel.swift b/Sources/Passes/Models/PassesRegistrationModel.swift index 7691713..c7d30aa 100644 --- a/Sources/Passes/Models/PassesRegistrationModel.swift +++ b/Sources/Passes/Models/PassesRegistrationModel.swift @@ -27,7 +27,7 @@ /// THE SOFTWARE. import Vapor -import Fluent +import FluentKit import PassKit /// Represents the `Model` that stores passes registrations. diff --git a/Sources/Passes/PassesDelegate.swift b/Sources/Passes/PassesDelegate.swift index eb6712f..a992744 100644 --- a/Sources/Passes/PassesDelegate.swift +++ b/Sources/Passes/PassesDelegate.swift @@ -27,7 +27,7 @@ /// THE SOFTWARE. import Vapor -import Fluent +import FluentKit /// The delegate which is responsible for generating the pass files. public protocol PassesDelegate: AnyObject, Sendable { diff --git a/Sources/Passes/PassesError.swift b/Sources/Passes/PassesError.swift index 0c0f34c..673211a 100644 --- a/Sources/Passes/PassesError.swift +++ b/Sources/Passes/PassesError.swift @@ -5,8 +5,6 @@ // Created by Scott Grosch on 1/22/20. // -import Foundation - public enum PassesError: Error { /// The template path is not a directory case templateNotDirectory diff --git a/Sources/Passes/PassesService.swift b/Sources/Passes/PassesService.swift index 24efeb4..e34cc09 100644 --- a/Sources/Passes/PassesService.swift +++ b/Sources/Passes/PassesService.swift @@ -27,12 +27,7 @@ /// THE SOFTWARE. import Vapor -import APNS -import VaporAPNS -@preconcurrency import APNSCore -import Fluent -import NIOSSL -import PassKit +import FluentKit /// The main class that handles PassKit passes. public final class PassesService: Sendable { @@ -105,457 +100,3 @@ public final class PassesService: Sendable { try await PassesServiceCustom.sendPushNotifications(for: pass, on: db, app: app) } } - -/// Class to handle `PassesService`. -/// -/// The generics should be passed in this order: -/// - Pass Type -/// - Device Type -/// - Registration Type -/// - Error Log Type -public final class PassesServiceCustom: Sendable where P == R.PassType, D == R.DeviceType { - public unowned let delegate: any PassesDelegate - private unowned let app: Application - - private let processQueue = DispatchQueue(label: "com.vapor-community.PassKit", qos: .utility, attributes: .concurrent) - private let v1: FakeSendable - private let logger: Logger? - - public init(app: Application, delegate: any PassesDelegate, logger: Logger? = nil) { - self.delegate = delegate - self.logger = logger - self.app = app - - v1 = FakeSendable(value: app.grouped("api", "v1")) - } - - /// Registers all the routes required for PassKit to work. - public func registerRoutes() { - v1.value.get("devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", use: { try await self.passesForDevice(req: $0) }) - v1.value.post("log", use: { try await self.logError(req: $0) }) - - let v1auth = v1.value.grouped(ApplePassMiddleware

()) - - v1auth.post("devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", ":passSerial", use: { try await self.registerDevice(req: $0) }) - v1auth.get("passes", ":passTypeIdentifier", ":passSerial", use: { try await self.latestVersionOfPass(req: $0) }) - v1auth.delete("devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", ":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()) - /// ``` - /// - /// - Parameter middleware: The `Middleware` which will control authentication for the routes. - /// - Throws: An error of type `PassesError` - public func registerPushRoutes(middleware: any Middleware) throws { - let privateKeyPath = URL(fileURLWithPath: delegate.pemPrivateKey, relativeTo: - delegate.sslSigningFilesDirectory).unixPath() - - guard FileManager.default.fileExists(atPath: privateKeyPath) else { - throw PassesError.pemPrivateKeyMissing - } - - let pemPath = URL(fileURLWithPath: delegate.pemCertificate, relativeTo: delegate.sslSigningFilesDirectory).unixPath() - - guard FileManager.default.fileExists(atPath: privateKeyPath) else { - throw PassesError.pemCertificateMissing - } - - // PassKit *only* works with the production APNs. You can't pass in .sandbox here. - let apnsConfig: APNSClientConfiguration - if let pwd = delegate.pemPrivateKeyPassword { - apnsConfig = APNSClientConfiguration( - authenticationMethod: try .tls( - privateKey: .privateKey( - NIOSSLPrivateKey(file: privateKeyPath, format: .pem) { closure in - closure(pwd.utf8) - }), - certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) } - ), - environment: .production - ) - } else { - apnsConfig = APNSClientConfiguration( - authenticationMethod: try .tls( - privateKey: .file(privateKeyPath), - certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) } - ), - environment: .production - ) - } - app.apns.containers.use( - apnsConfig, - eventLoopGroupProvider: .shared(app.eventLoopGroup), - responseDecoder: JSONDecoder(), - requestEncoder: JSONEncoder(), - as: .init(string: "passes"), - isDefault: false - ) - - let pushAuth = v1.value.grouped(middleware) - - pushAuth.post("push", ":passTypeIdentifier", ":passSerial", use: { try await self.pushUpdatesForPass(req: $0) }) - pushAuth.get("push", ":passTypeIdentifier", ":passSerial", use: { try await self.tokensForPassUpdate(req: $0) }) - } - - // MARK: - API Routes - func registerDevice(req: Request) async throws -> HTTPStatus { - logger?.debug("Called register device") - - guard let serial = req.parameters.get("passSerial", as: UUID.self) else { - throw Abort(.badRequest) - } - - let pushToken: String - do { - let content = try req.content.decode(RegistrationDTO.self) - pushToken = content.pushToken - } catch { - throw Abort(.badRequest) - } - - let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! - let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! - - guard let pass = try await P.query(on: req.db) - .filter(\._$passTypeIdentifier == passTypeIdentifier) - .filter(\._$id == serial) - .first() - else { - throw Abort(.notFound) - } - - let device = try await D.query(on: req.db) - .filter(\._$deviceLibraryIdentifier == deviceLibraryIdentifier) - .filter(\._$pushToken == pushToken) - .first() - - if let device = device { - return try await Self.createRegistration(device: device, pass: pass, req: req) - } else { - let newDevice = D(deviceLibraryIdentifier: deviceLibraryIdentifier, pushToken: pushToken) - try await newDevice.create(on: req.db) - return try await Self.createRegistration(device: newDevice, pass: pass, req: req) - } - } - - func passesForDevice(req: Request) async throws -> PassesForDeviceDTO { - logger?.debug("Called passesForDevice") - - let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! - let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! - - var query = R.for(deviceLibraryIdentifier: deviceLibraryIdentifier, passTypeIdentifier: passTypeIdentifier, on: req.db) - - if let since: TimeInterval = req.query["passesUpdatedSince"] { - let when = Date(timeIntervalSince1970: since) - query = query.filter(P.self, \._$updatedAt > when) - } - - let registrations = try await query.all() - guard !registrations.isEmpty else { - throw Abort(.noContent) - } - - var serialNumbers: [String] = [] - var maxDate = Date.distantPast - - registrations.forEach { r in - let pass = r.pass - - serialNumbers.append(pass.id!.uuidString) - if let updatedAt = pass.updatedAt, updatedAt > maxDate { - maxDate = updatedAt - } - } - - return PassesForDeviceDTO(with: serialNumbers, maxDate: maxDate) - } - - func latestVersionOfPass(req: Request) async throws -> Response { - logger?.debug("Called latestVersionOfPass") - - guard FileManager.default.fileExists(atPath: delegate.zipBinary.unixPath()) else { - throw Abort(.internalServerError, suggestedFixes: ["Provide full path to zip command"]) - } - - var ifModifiedSince: TimeInterval = 0 - - if let header = req.headers[.ifModifiedSince].first, let ims = TimeInterval(header) { - ifModifiedSince = ims - } - - guard let passTypeIdentifier = req.parameters.get("passTypeIdentifier"), - let id = req.parameters.get("passSerial", as: UUID.self) else { - throw Abort(.badRequest) - } - - guard let pass = try await P.query(on: req.db) - .filter(\._$id == id) - .filter(\._$passTypeIdentifier == passTypeIdentifier) - .first() - else { - throw Abort(.notFound) - } - - guard ifModifiedSince < pass.updatedAt?.timeIntervalSince1970 ?? 0 else { - throw Abort(.notModified) - } - - let data = try await self.generatePassContent(for: pass, on: req.db) - let body = Response.Body(data: data) - - var headers = HTTPHeaders() - headers.add(name: .contentType, value: "application/vnd.apple.pkpass") - headers.add(name: .lastModified, value: String(pass.updatedAt?.timeIntervalSince1970 ?? 0)) - headers.add(name: .contentTransferEncoding, value: "binary") - - return Response(status: .ok, headers: headers, body: body) - } - - func unregisterDevice(req: Request) async throws -> HTTPStatus { - logger?.debug("Called unregisterDevice") - - let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! - - guard let passId = req.parameters.get("passSerial", as: UUID.self) else { - throw Abort(.badRequest) - } - - let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! - - guard let r = try await R.for(deviceLibraryIdentifier: deviceLibraryIdentifier, passTypeIdentifier: passTypeIdentifier, on: req.db) - .filter(P.self, \._$id == passId) - .first() - else { - throw Abort(.notFound) - } - try await r.delete(on: req.db) - return .ok - } - - func logError(req: Request) async throws -> HTTPStatus { - logger?.debug("Called logError") - - let body: ErrorLogDTO - - do { - body = try req.content.decode(ErrorLogDTO.self) - } catch { - throw Abort(.badRequest) - } - - guard body.logs.isEmpty == false else { - throw Abort(.badRequest) - } - - try await body.logs.map(E.init(message:)).create(on: req.db) - - return .ok - } - - func pushUpdatesForPass(req: Request) async throws -> HTTPStatus { - logger?.debug("Called pushUpdatesForPass") - - guard let id = req.parameters.get("passSerial", as: UUID.self) else { - throw Abort(.badRequest) - } - - let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! - - try await Self.sendPushNotificationsForPass(id: id, of: passTypeIdentifier, on: req.db, app: req.application) - return .noContent - } - - func tokensForPassUpdate(req: Request) async throws -> [String] { - logger?.debug("Called tokensForPassUpdate") - - guard let id = req.parameters.get("passSerial", as: UUID.self) else { - throw Abort(.badRequest) - } - - let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! - - let registrations = try await Self.registrationsForPass(id: id, of: passTypeIdentifier, on: req.db) - return registrations.map { $0.device.pushToken } - } - - private static func createRegistration(device: D, pass: P, req: Request) async throws -> HTTPStatus { - let r = try await R.for(deviceLibraryIdentifier: device.deviceLibraryIdentifier, passTypeIdentifier: pass.passTypeIdentifier, on: req.db) - .filter(P.self, \._$id == pass.id!) - .first() - if r != nil { - // If the registration already exists, docs say to return a 200 - return .ok - } - - let registration = R() - registration._$pass.id = pass.id! - registration._$device.id = device.id! - - try await registration.create(on: req.db) - return .created - } - - // MARK: - Push Notifications - public static func sendPushNotificationsForPass(id: UUID, of passTypeIdentifier: String, on db: any Database, app: Application) async throws { - let registrations = try await Self.registrationsForPass(id: id, of: passTypeIdentifier, on: db) - for reg in registrations { - let backgroundNotification = APNSBackgroundNotification(expiration: .immediately, topic: reg.pass.passTypeIdentifier, payload: EmptyPayload()) - do { - try await app.apns.client(.init(string: "passes")).sendBackgroundNotification( - backgroundNotification, - deviceToken: reg.device.pushToken - ) - } catch let error as APNSCore.APNSError where error.reason == .badDeviceToken { - try await reg.device.delete(on: db) - try await reg.delete(on: db) - } - } - } - - public static func sendPushNotifications(for pass: P, on db: any Database, app: Application) async throws { - guard let id = pass.id else { - throw FluentError.idRequired - } - - try await Self.sendPushNotificationsForPass(id: id, of: pass.passTypeIdentifier, on: db, app: app) - } - - public static func sendPushNotifications(for pass: ParentProperty, on db: any Database, app: Application) async throws { - let value: P - - if let eagerLoaded = pass.value { - value = eagerLoaded - } else { - value = try await pass.get(on: db) - } - - try await sendPushNotifications(for: value, on: db, app: app) - } - - private static func registrationsForPass(id: UUID, of passTypeIdentifier: String, on db: any Database) async throws -> [R] { - // This could be done by enforcing the caller to have a Siblings property - // wrapper, but there's not really any value to forcing that on them when - // we can just do the query ourselves like this. - try await R.query(on: db) - .join(parent: \._$pass) - .join(parent: \._$device) - .with(\._$pass) - .with(\._$device) - .filter(P.self, \._$passTypeIdentifier == passTypeIdentifier) - .filter(P.self, \._$id == id) - .all() - } - - // MARK: - pkpass file generation - private static func generateManifestFile(using encoder: JSONEncoder, in root: URL) throws { - var manifest: [String: String] = [:] - - let paths = try FileManager.default.subpathsOfDirectory(atPath: root.unixPath()) - try paths.forEach { relativePath in - let file = URL(fileURLWithPath: relativePath, relativeTo: root) - guard !file.hasDirectoryPath else { - return - } - - let data = try Data(contentsOf: file) - let hash = Insecure.SHA1.hash(data: data) - manifest[relativePath] = hash.map { "0\(String($0, radix: 16))".suffix(2) }.joined() - } - - let encoded = try encoder.encode(manifest) - try encoded.write(to: root.appendingPathComponent("manifest.json")) - } - - private func generateSignatureFile(in root: URL) throws { - if delegate.generateSignatureFile(in: root) { - // If the caller's delegate generated a file we don't have to do it. - return - } - - let sslBinary = delegate.sslBinary - - guard FileManager.default.fileExists(atPath: sslBinary.unixPath()) else { - throw PassesError.opensslBinaryMissing - } - - let proc = Process() - proc.currentDirectoryURL = delegate.sslSigningFilesDirectory - proc.executableURL = sslBinary - - proc.arguments = [ - "smime", "-binary", "-sign", - "-certfile", delegate.wwdrCertificate, - "-signer", delegate.pemCertificate, - "-inkey", delegate.pemPrivateKey, - "-in", root.appendingPathComponent("manifest.json").unixPath(), - "-out", root.appendingPathComponent("signature").unixPath(), - "-outform", "DER" - ] - - if let pwd = delegate.pemPrivateKeyPassword { - proc.arguments!.append(contentsOf: ["-passin", "pass:\(pwd)"]) - } - - try proc.run() - - proc.waitUntilExit() - } - - private func zip(directory: URL, to: URL) throws { - let zipBinary = delegate.zipBinary - guard FileManager.default.fileExists(atPath: zipBinary.unixPath()) else { - throw PassesError.zipBinaryMissing - } - - let proc = Process() - proc.currentDirectoryURL = directory - proc.executableURL = zipBinary - - proc.arguments = [ to.unixPath(), "-r", "-q", "." ] - - try proc.run() - proc.waitUntilExit() - } - - 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() - - let src = try await delegate.template(for: pass, db: db) - guard (try? src.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else { - throw PassesError.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 - } - } -} diff --git a/Sources/Passes/PassesServiceCustom.swift b/Sources/Passes/PassesServiceCustom.swift new file mode 100644 index 0000000..078f945 --- /dev/null +++ b/Sources/Passes/PassesServiceCustom.swift @@ -0,0 +1,468 @@ +// +// PassesServiceCustom.swift +// PassKit +// +// Created by Francesco Paolo Severino on 29/06/24. +// + +import Vapor +import APNS +import VaporAPNS +@preconcurrency import APNSCore +import Fluent +import NIOSSL +import PassKit + +/// Class to handle `PassesService`. +/// +/// The generics should be passed in this order: +/// - Pass Type +/// - Device Type +/// - Registration Type +/// - Error Log Type +public final class PassesServiceCustom: Sendable where P == R.PassType, D == R.DeviceType { + public unowned let delegate: any PassesDelegate + private unowned let app: Application + + private let processQueue = DispatchQueue(label: "com.vapor-community.PassKit", qos: .utility, attributes: .concurrent) + private let v1: FakeSendable + private let logger: Logger? + + public init(app: Application, delegate: any PassesDelegate, logger: Logger? = nil) { + self.delegate = delegate + self.logger = logger + self.app = app + + v1 = FakeSendable(value: app.grouped("api", "v1")) + } + + /// Registers all the routes required for PassKit to work. + public func registerRoutes() { + v1.value.get("devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", use: { try await self.passesForDevice(req: $0) }) + v1.value.post("log", use: { try await self.logError(req: $0) }) + + let v1auth = v1.value.grouped(ApplePassMiddleware

()) + + v1auth.post("devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", ":passSerial", use: { try await self.registerDevice(req: $0) }) + v1auth.get("passes", ":passTypeIdentifier", ":passSerial", use: { try await self.latestVersionOfPass(req: $0) }) + v1auth.delete("devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", ":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()) + /// ``` + /// + /// - Parameter middleware: The `Middleware` which will control authentication for the routes. + /// - Throws: An error of type `PassesError` + public func registerPushRoutes(middleware: any Middleware) throws { + let privateKeyPath = URL(fileURLWithPath: delegate.pemPrivateKey, relativeTo: + delegate.sslSigningFilesDirectory).unixPath() + + guard FileManager.default.fileExists(atPath: privateKeyPath) else { + throw PassesError.pemPrivateKeyMissing + } + + let pemPath = URL(fileURLWithPath: delegate.pemCertificate, relativeTo: delegate.sslSigningFilesDirectory).unixPath() + + guard FileManager.default.fileExists(atPath: privateKeyPath) else { + throw PassesError.pemCertificateMissing + } + + // PassKit *only* works with the production APNs. You can't pass in .sandbox here. + let apnsConfig: APNSClientConfiguration + if let pwd = delegate.pemPrivateKeyPassword { + apnsConfig = APNSClientConfiguration( + authenticationMethod: try .tls( + privateKey: .privateKey( + NIOSSLPrivateKey(file: privateKeyPath, format: .pem) { closure in + closure(pwd.utf8) + }), + certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) } + ), + environment: .production + ) + } else { + apnsConfig = APNSClientConfiguration( + authenticationMethod: try .tls( + privateKey: .file(privateKeyPath), + certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) } + ), + environment: .production + ) + } + app.apns.containers.use( + apnsConfig, + eventLoopGroupProvider: .shared(app.eventLoopGroup), + responseDecoder: JSONDecoder(), + requestEncoder: JSONEncoder(), + as: .init(string: "passes"), + isDefault: false + ) + + let pushAuth = v1.value.grouped(middleware) + + pushAuth.post("push", ":passTypeIdentifier", ":passSerial", use: { try await self.pushUpdatesForPass(req: $0) }) + pushAuth.get("push", ":passTypeIdentifier", ":passSerial", use: { try await self.tokensForPassUpdate(req: $0) }) + } + + // MARK: - API Routes + func registerDevice(req: Request) async throws -> HTTPStatus { + logger?.debug("Called register device") + + guard let serial = req.parameters.get("passSerial", as: UUID.self) else { + throw Abort(.badRequest) + } + + let pushToken: String + do { + let content = try req.content.decode(RegistrationDTO.self) + pushToken = content.pushToken + } catch { + throw Abort(.badRequest) + } + + let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! + let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! + + guard let pass = try await P.query(on: req.db) + .filter(\._$passTypeIdentifier == passTypeIdentifier) + .filter(\._$id == serial) + .first() + else { + throw Abort(.notFound) + } + + let device = try await D.query(on: req.db) + .filter(\._$deviceLibraryIdentifier == deviceLibraryIdentifier) + .filter(\._$pushToken == pushToken) + .first() + + if let device = device { + return try await Self.createRegistration(device: device, pass: pass, req: req) + } else { + let newDevice = D(deviceLibraryIdentifier: deviceLibraryIdentifier, pushToken: pushToken) + try await newDevice.create(on: req.db) + return try await Self.createRegistration(device: newDevice, pass: pass, req: req) + } + } + + func passesForDevice(req: Request) async throws -> PassesForDeviceDTO { + logger?.debug("Called passesForDevice") + + let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! + let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! + + var query = R.for(deviceLibraryIdentifier: deviceLibraryIdentifier, passTypeIdentifier: passTypeIdentifier, on: req.db) + + if let since: TimeInterval = req.query["passesUpdatedSince"] { + let when = Date(timeIntervalSince1970: since) + query = query.filter(P.self, \._$updatedAt > when) + } + + let registrations = try await query.all() + guard !registrations.isEmpty else { + throw Abort(.noContent) + } + + var serialNumbers: [String] = [] + var maxDate = Date.distantPast + + registrations.forEach { r in + let pass = r.pass + + serialNumbers.append(pass.id!.uuidString) + if let updatedAt = pass.updatedAt, updatedAt > maxDate { + maxDate = updatedAt + } + } + + return PassesForDeviceDTO(with: serialNumbers, maxDate: maxDate) + } + + func latestVersionOfPass(req: Request) async throws -> Response { + logger?.debug("Called latestVersionOfPass") + + guard FileManager.default.fileExists(atPath: delegate.zipBinary.unixPath()) else { + throw Abort(.internalServerError, suggestedFixes: ["Provide full path to zip command"]) + } + + var ifModifiedSince: TimeInterval = 0 + + if let header = req.headers[.ifModifiedSince].first, let ims = TimeInterval(header) { + ifModifiedSince = ims + } + + guard let passTypeIdentifier = req.parameters.get("passTypeIdentifier"), + let id = req.parameters.get("passSerial", as: UUID.self) else { + throw Abort(.badRequest) + } + + guard let pass = try await P.query(on: req.db) + .filter(\._$id == id) + .filter(\._$passTypeIdentifier == passTypeIdentifier) + .first() + else { + throw Abort(.notFound) + } + + guard ifModifiedSince < pass.updatedAt?.timeIntervalSince1970 ?? 0 else { + throw Abort(.notModified) + } + + let data = try await self.generatePassContent(for: pass, on: req.db) + let body = Response.Body(data: data) + + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "application/vnd.apple.pkpass") + headers.add(name: .lastModified, value: String(pass.updatedAt?.timeIntervalSince1970 ?? 0)) + headers.add(name: .contentTransferEncoding, value: "binary") + + return Response(status: .ok, headers: headers, body: body) + } + + func unregisterDevice(req: Request) async throws -> HTTPStatus { + logger?.debug("Called unregisterDevice") + + let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! + + guard let passId = req.parameters.get("passSerial", as: UUID.self) else { + throw Abort(.badRequest) + } + + let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! + + guard let r = try await R.for(deviceLibraryIdentifier: deviceLibraryIdentifier, passTypeIdentifier: passTypeIdentifier, on: req.db) + .filter(P.self, \._$id == passId) + .first() + else { + throw Abort(.notFound) + } + try await r.delete(on: req.db) + return .ok + } + + func logError(req: Request) async throws -> HTTPStatus { + logger?.debug("Called logError") + + let body: ErrorLogDTO + + do { + body = try req.content.decode(ErrorLogDTO.self) + } catch { + throw Abort(.badRequest) + } + + guard body.logs.isEmpty == false else { + throw Abort(.badRequest) + } + + try await body.logs.map(E.init(message:)).create(on: req.db) + + return .ok + } + + func pushUpdatesForPass(req: Request) async throws -> HTTPStatus { + logger?.debug("Called pushUpdatesForPass") + + guard let id = req.parameters.get("passSerial", as: UUID.self) else { + throw Abort(.badRequest) + } + + let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! + + try await Self.sendPushNotificationsForPass(id: id, of: passTypeIdentifier, on: req.db, app: req.application) + return .noContent + } + + func tokensForPassUpdate(req: Request) async throws -> [String] { + logger?.debug("Called tokensForPassUpdate") + + guard let id = req.parameters.get("passSerial", as: UUID.self) else { + throw Abort(.badRequest) + } + + let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! + + let registrations = try await Self.registrationsForPass(id: id, of: passTypeIdentifier, on: req.db) + return registrations.map { $0.device.pushToken } + } + + private static func createRegistration(device: D, pass: P, req: Request) async throws -> HTTPStatus { + let r = try await R.for(deviceLibraryIdentifier: device.deviceLibraryIdentifier, passTypeIdentifier: pass.passTypeIdentifier, on: req.db) + .filter(P.self, \._$id == pass.id!) + .first() + if r != nil { + // If the registration already exists, docs say to return a 200 + return .ok + } + + let registration = R() + registration._$pass.id = pass.id! + registration._$device.id = device.id! + + try await registration.create(on: req.db) + return .created + } + + // MARK: - Push Notifications + public static func sendPushNotificationsForPass(id: UUID, of passTypeIdentifier: String, on db: any Database, app: Application) async throws { + let registrations = try await Self.registrationsForPass(id: id, of: passTypeIdentifier, on: db) + for reg in registrations { + let backgroundNotification = APNSBackgroundNotification(expiration: .immediately, topic: reg.pass.passTypeIdentifier, payload: EmptyPayload()) + do { + try await app.apns.client(.init(string: "passes")).sendBackgroundNotification( + backgroundNotification, + deviceToken: reg.device.pushToken + ) + } catch let error as APNSCore.APNSError where error.reason == .badDeviceToken { + try await reg.device.delete(on: db) + try await reg.delete(on: db) + } + } + } + + public static func sendPushNotifications(for pass: P, on db: any Database, app: Application) async throws { + guard let id = pass.id else { + throw FluentError.idRequired + } + + try await Self.sendPushNotificationsForPass(id: id, of: pass.passTypeIdentifier, on: db, app: app) + } + + public static func sendPushNotifications(for pass: ParentProperty, on db: any Database, app: Application) async throws { + let value: P + + if let eagerLoaded = pass.value { + value = eagerLoaded + } else { + value = try await pass.get(on: db) + } + + try await sendPushNotifications(for: value, on: db, app: app) + } + + private static func registrationsForPass(id: UUID, of passTypeIdentifier: String, on db: any Database) async throws -> [R] { + // This could be done by enforcing the caller to have a Siblings property + // wrapper, but there's not really any value to forcing that on them when + // we can just do the query ourselves like this. + try await R.query(on: db) + .join(parent: \._$pass) + .join(parent: \._$device) + .with(\._$pass) + .with(\._$device) + .filter(P.self, \._$passTypeIdentifier == passTypeIdentifier) + .filter(P.self, \._$id == id) + .all() + } + + // MARK: - pkpass file generation + private static func generateManifestFile(using encoder: JSONEncoder, in root: URL) throws { + var manifest: [String: String] = [:] + + let paths = try FileManager.default.subpathsOfDirectory(atPath: root.unixPath()) + try paths.forEach { relativePath in + let file = URL(fileURLWithPath: relativePath, relativeTo: root) + guard !file.hasDirectoryPath else { + return + } + + let data = try Data(contentsOf: file) + let hash = Insecure.SHA1.hash(data: data) + manifest[relativePath] = hash.map { "0\(String($0, radix: 16))".suffix(2) }.joined() + } + + let encoded = try encoder.encode(manifest) + try encoded.write(to: root.appendingPathComponent("manifest.json")) + } + + private func generateSignatureFile(in root: URL) throws { + if delegate.generateSignatureFile(in: root) { + // If the caller's delegate generated a file we don't have to do it. + return + } + + let sslBinary = delegate.sslBinary + + guard FileManager.default.fileExists(atPath: sslBinary.unixPath()) else { + throw PassesError.opensslBinaryMissing + } + + let proc = Process() + proc.currentDirectoryURL = delegate.sslSigningFilesDirectory + proc.executableURL = sslBinary + + proc.arguments = [ + "smime", "-binary", "-sign", + "-certfile", delegate.wwdrCertificate, + "-signer", delegate.pemCertificate, + "-inkey", delegate.pemPrivateKey, + "-in", root.appendingPathComponent("manifest.json").unixPath(), + "-out", root.appendingPathComponent("signature").unixPath(), + "-outform", "DER" + ] + + if let pwd = delegate.pemPrivateKeyPassword { + proc.arguments!.append(contentsOf: ["-passin", "pass:\(pwd)"]) + } + + try proc.run() + + proc.waitUntilExit() + } + + private func zip(directory: URL, to: URL) throws { + let zipBinary = delegate.zipBinary + guard FileManager.default.fileExists(atPath: zipBinary.unixPath()) else { + throw PassesError.zipBinaryMissing + } + + let proc = Process() + proc.currentDirectoryURL = directory + proc.executableURL = zipBinary + + proc.arguments = [ to.unixPath(), "-r", "-q", "." ] + + try proc.run() + proc.waitUntilExit() + } + + 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() + + let src = try await delegate.template(for: pass, db: db) + guard (try? src.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else { + throw PassesError.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 + } + } +} From b64171ab23daaf9690f3180e143db2901656b436 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Sat, 29 Jun 2024 13:18:05 +0200 Subject: [PATCH 17/27] Remove superfluous swift-log dependency --- Package.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Package.swift b/Package.swift index 5ad42b4..34c0659 100644 --- a/Package.swift +++ b/Package.swift @@ -14,7 +14,6 @@ let package = Package( .package(url: "https://github.com/vapor/vapor.git", from: "4.102.0"), .package(url: "https://github.com/vapor/fluent.git", from: "4.11.0"), .package(url: "https://github.com/vapor/apns.git", from: "4.1.0"), - .package(url: "https://github.com/apple/swift-log.git", from: "1.6.0"), ], targets: [ .target( @@ -23,7 +22,6 @@ let package = Package( .product(name: "Fluent", package: "fluent"), .product(name: "Vapor", package: "vapor"), .product(name: "VaporAPNS", package: "apns"), - .product(name: "Logging", package: "swift-log"), ], swiftSettings: swiftSettings ), From bc58a7b8ce525e3a89a0b4304123d28eee6c99bb Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Sat, 29 Jun 2024 15:11:28 +0200 Subject: [PATCH 18/27] Remove `DispatchQueue` --- Sources/Passes/PassesServiceCustom.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/Passes/PassesServiceCustom.swift b/Sources/Passes/PassesServiceCustom.swift index 078f945..e720701 100644 --- a/Sources/Passes/PassesServiceCustom.swift +++ b/Sources/Passes/PassesServiceCustom.swift @@ -24,7 +24,6 @@ public final class PassesServiceCustom private let logger: Logger? From d21c4827e83175ed1adbd9b8b9b34c074bbff6ca Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Sun, 30 Jun 2024 12:38:32 +0200 Subject: [PATCH 19/27] chore: Remove unused imports and reorganize Passes module --- Sources/PassKit/FakeSendable.swift | 28 ------------------- Sources/PassKit/Models/DeviceModel.swift | 3 +- Sources/PassKit/Models/ErrorLogModel.swift | 3 +- .../Models/Concrete Models/PassesDevice.swift | 3 +- .../Concrete Models/PassesErrorLog.swift | 4 +-- .../Concrete Models/PassesRegistration.swift | 1 - Sources/Passes/Models/PassDataModel.swift | 1 - Sources/Passes/Models/PassModel.swift | 2 +- .../Models/PassesRegistrationModel.swift | 1 - Sources/Passes/PassesDelegate.swift | 2 +- Sources/Passes/PassesServiceCustom.swift | 14 +++++++--- 11 files changed, 17 insertions(+), 45 deletions(-) diff --git a/Sources/PassKit/FakeSendable.swift b/Sources/PassKit/FakeSendable.swift index 2d25bae..2417d8f 100644 --- a/Sources/PassKit/FakeSendable.swift +++ b/Sources/PassKit/FakeSendable.swift @@ -1,31 +1,3 @@ -/// 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 package struct FakeSendable: @unchecked Sendable { package let value: T diff --git a/Sources/PassKit/Models/DeviceModel.swift b/Sources/PassKit/Models/DeviceModel.swift index 40eccf6..50f86e1 100644 --- a/Sources/PassKit/Models/DeviceModel.swift +++ b/Sources/PassKit/Models/DeviceModel.swift @@ -26,8 +26,7 @@ /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN /// THE SOFTWARE. -import Vapor -import Fluent +import FluentKit /// Represents the `Model` that stores PassKit devices. public protocol DeviceModel: Model where IDValue == Int { diff --git a/Sources/PassKit/Models/ErrorLogModel.swift b/Sources/PassKit/Models/ErrorLogModel.swift index bcd42d3..fc417df 100644 --- a/Sources/PassKit/Models/ErrorLogModel.swift +++ b/Sources/PassKit/Models/ErrorLogModel.swift @@ -26,8 +26,7 @@ /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN /// THE SOFTWARE. -import Vapor -import Fluent +import FluentKit /// Represents the `Model` that stores PassKit error logs. public protocol ErrorLogModel: Model { diff --git a/Sources/Passes/Models/Concrete Models/PassesDevice.swift b/Sources/Passes/Models/Concrete Models/PassesDevice.swift index 6979f90..c06eba0 100644 --- a/Sources/Passes/Models/Concrete Models/PassesDevice.swift +++ b/Sources/Passes/Models/Concrete Models/PassesDevice.swift @@ -5,7 +5,6 @@ // Created by Francesco Paolo Severino on 29/06/24. // -import Foundation import FluentKit import PassKit @@ -51,4 +50,4 @@ extension PassesDevice { static let pushToken = FieldKey(stringLiteral: "push_token") static let deviceLibraryIdentifier = FieldKey(stringLiteral: "device_library_identifier") } -} \ No newline at end of file +} diff --git a/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift b/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift index abfcbbb..3e0497c 100644 --- a/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift +++ b/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift @@ -5,7 +5,7 @@ // Created by Francesco Paolo Severino on 29/06/24. // -import Foundation +import struct Foundation.Date import FluentKit import PassKit @@ -49,4 +49,4 @@ extension PassesErrorLog { static let createdAt = FieldKey(stringLiteral: "created_at") static let message = FieldKey(stringLiteral: "message") } -} \ No newline at end of file +} diff --git a/Sources/Passes/Models/Concrete Models/PassesRegistration.swift b/Sources/Passes/Models/Concrete Models/PassesRegistration.swift index d759760..35cea46 100644 --- a/Sources/Passes/Models/Concrete Models/PassesRegistration.swift +++ b/Sources/Passes/Models/Concrete Models/PassesRegistration.swift @@ -5,7 +5,6 @@ // Created by Francesco Paolo Severino on 29/06/24. // -import Foundation import FluentKit /// The `Model` that stores passes registrations. diff --git a/Sources/Passes/Models/PassDataModel.swift b/Sources/Passes/Models/PassDataModel.swift index def476f..7da4280 100644 --- a/Sources/Passes/Models/PassDataModel.swift +++ b/Sources/Passes/Models/PassDataModel.swift @@ -26,7 +26,6 @@ /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN /// THE SOFTWARE. -import Vapor import FluentKit /// Represents the `Model` that stores custom app data associated to PassKit passes. diff --git a/Sources/Passes/Models/PassModel.swift b/Sources/Passes/Models/PassModel.swift index db8962f..430b035 100644 --- a/Sources/Passes/Models/PassModel.swift +++ b/Sources/Passes/Models/PassModel.swift @@ -26,7 +26,7 @@ /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN /// THE SOFTWARE. -import Vapor +import Foundation import FluentKit /// Represents the `Model` that stores PassKit passes. diff --git a/Sources/Passes/Models/PassesRegistrationModel.swift b/Sources/Passes/Models/PassesRegistrationModel.swift index c7d30aa..91310b5 100644 --- a/Sources/Passes/Models/PassesRegistrationModel.swift +++ b/Sources/Passes/Models/PassesRegistrationModel.swift @@ -26,7 +26,6 @@ /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN /// THE SOFTWARE. -import Vapor import FluentKit import PassKit diff --git a/Sources/Passes/PassesDelegate.swift b/Sources/Passes/PassesDelegate.swift index a992744..8170e51 100644 --- a/Sources/Passes/PassesDelegate.swift +++ b/Sources/Passes/PassesDelegate.swift @@ -26,7 +26,7 @@ /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN /// THE SOFTWARE. -import Vapor +import Foundation import FluentKit /// The delegate which is responsible for generating the pass files. diff --git a/Sources/Passes/PassesServiceCustom.swift b/Sources/Passes/PassesServiceCustom.swift index e720701..15b7115 100644 --- a/Sources/Passes/PassesServiceCustom.swift +++ b/Sources/Passes/PassesServiceCustom.swift @@ -51,7 +51,7 @@ public final class PassesServiceCustom HTTPStatus { logger?.debug("Called register device") @@ -305,8 +307,10 @@ public final class PassesServiceCustom Date: Sun, 30 Jun 2024 23:28:38 +0200 Subject: [PATCH 20/27] Start work on the Orders target --- Package.swift | 8 +++ Sources/Orders/DTOs/OrdersForDeviceDTO.swift | 18 ++++++ .../Middleware/AppleOrderMiddleware.swift | 22 +++++++ .../Orders/Models/Concrete Models/Order.swift | 57 +++++++++++++++++ .../Models/Concrete Models/OrdersDevice.swift | 53 ++++++++++++++++ .../Concrete Models/OrdersErrorLog.swift | 52 ++++++++++++++++ .../Concrete Models/OrdersRegistration.swift | 49 +++++++++++++++ Sources/Orders/Models/OrderDataModel.swift | 27 ++++++++ Sources/Orders/Models/OrderModel.swift | 61 +++++++++++++++++++ .../Models/OrdersRegistrationModel.swift | 51 ++++++++++++++++ Sources/Orders/OrdersError.swift | 26 ++++++++ 11 files changed, 424 insertions(+) create mode 100644 Sources/Orders/DTOs/OrdersForDeviceDTO.swift create mode 100644 Sources/Orders/Middleware/AppleOrderMiddleware.swift create mode 100644 Sources/Orders/Models/Concrete Models/Order.swift create mode 100644 Sources/Orders/Models/Concrete Models/OrdersDevice.swift create mode 100644 Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift create mode 100644 Sources/Orders/Models/Concrete Models/OrdersRegistration.swift create mode 100644 Sources/Orders/Models/OrderDataModel.swift create mode 100644 Sources/Orders/Models/OrderModel.swift create mode 100644 Sources/Orders/Models/OrdersRegistrationModel.swift create mode 100644 Sources/Orders/OrdersError.swift diff --git a/Package.swift b/Package.swift index 34c0659..d1e8051 100644 --- a/Package.swift +++ b/Package.swift @@ -32,11 +32,19 @@ let package = Package( ], swiftSettings: swiftSettings ), + .target( + name: "Orders", + dependencies: [ + .target(name: "PassKit"), + ], + swiftSettings: swiftSettings + ), .testTarget( name: "PassKitTests", dependencies: [ .target(name: "PassKit"), .target(name: "Passes"), + .target(name: "Orders"), .product(name: "XCTVapor", package: "vapor"), ], swiftSettings: swiftSettings diff --git a/Sources/Orders/DTOs/OrdersForDeviceDTO.swift b/Sources/Orders/DTOs/OrdersForDeviceDTO.swift new file mode 100644 index 0000000..921da6b --- /dev/null +++ b/Sources/Orders/DTOs/OrdersForDeviceDTO.swift @@ -0,0 +1,18 @@ +// +// OrdersForDeviceDTO.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +import Vapor + +struct OrdersForDeviceDTO: Content { + let orderIdentifiers: [String] + let lastModified: String + + init(with orderIdentifiers: [String], maxDate: Date) { + self.orderIdentifiers = orderIdentifiers + lastModified = String(maxDate.timeIntervalSince1970) + } +} \ No newline at end of file diff --git a/Sources/Orders/Middleware/AppleOrderMiddleware.swift b/Sources/Orders/Middleware/AppleOrderMiddleware.swift new file mode 100644 index 0000000..517fe9d --- /dev/null +++ b/Sources/Orders/Middleware/AppleOrderMiddleware.swift @@ -0,0 +1,22 @@ +// +// AppleOrderMiddleware.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +import Vapor +import FluentKit + +struct AppleOrderMiddleware: AsyncMiddleware { + func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { + guard let auth = request.headers["Authorization"].first?.replacingOccurrences(of: "AppleOrder ", with: ""), + let _ = try await O.query(on: request.db) + .filter(\._$authenticationToken == auth) + .first() + else { + throw Abort(.unauthorized) + } + return try await next.respond(to: request) + } +} \ No newline at end of file diff --git a/Sources/Orders/Models/Concrete Models/Order.swift b/Sources/Orders/Models/Concrete Models/Order.swift new file mode 100644 index 0000000..d625205 --- /dev/null +++ b/Sources/Orders/Models/Concrete Models/Order.swift @@ -0,0 +1,57 @@ +// +// Order.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +import Foundation +import FluentKit + +/// The `Model` that stores Wallet orders. +open class Order: OrderModel, @unchecked Sendable { + public static let schema = Order.FieldKeys.schemaName + + @ID + public var id: UUID? + + @Timestamp(key: Order.FieldKeys.updatedAt, on: .update) + public var updatedAt: Date? + + @Field(key: Order.FieldKeys.orderTypeIdentifier) + public var orderTypeIdentifier: String + + @Field(key: Order.FieldKeys.authenticationToken) + public var authenticationToken: String + + public required init() { } + + public required init(orderTypeIdentifier: String, authenticationToken: String) { + self.orderTypeIdentifier = orderTypeIdentifier + self.authenticationToken = authenticationToken + } +} + +extension Order: AsyncMigration { + public func prepare(on database: any Database) async throws { + try await database.schema(Self.schema) + .id() + .field(Order.FieldKeys.updatedAt, .datetime, .required) + .field(Order.FieldKeys.orderTypeIdentifier, .string, .required) + .field(Order.FieldKeys.authenticationToken, .string, .required) + .create() + } + + public func revert(on database: any Database) async throws { + try await database.schema(Self.schema).delete() + } +} + +extension Order { + enum FieldKeys { + static let schemaName = "orders" + static let updatedAt = FieldKey(stringLiteral: "updated_at") + static let orderTypeIdentifier = FieldKey(stringLiteral: "order_type_identifier") + static let authenticationToken = FieldKey(stringLiteral: "authentication_token") + } +} \ No newline at end of file diff --git a/Sources/Orders/Models/Concrete Models/OrdersDevice.swift b/Sources/Orders/Models/Concrete Models/OrdersDevice.swift new file mode 100644 index 0000000..b2519ea --- /dev/null +++ b/Sources/Orders/Models/Concrete Models/OrdersDevice.swift @@ -0,0 +1,53 @@ +// +// OrdersDevice.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +import FluentKit +import PassKit + +/// The `Model` that stores Wallet orders devices. +final public class OrdersDevice: DeviceModel, @unchecked Sendable { + public static let schema = OrdersDevice.FieldKeys.schemaName + + @ID(custom: .id) + public var id: Int? + + @Field(key: OrdersDevice.FieldKeys.pushToken) + public var pushToken: String + + @Field(key: OrdersDevice.FieldKeys.deviceLibraryIdentifier) + public var deviceLibraryIdentifier: String + + public init(deviceLibraryIdentifier: String, pushToken: String) { + self.deviceLibraryIdentifier = deviceLibraryIdentifier + self.pushToken = pushToken + } + + public init() {} +} + +extension OrdersDevice: AsyncMigration { + public func prepare(on database: any Database) async throws { + try await database.schema(Self.schema) + .field(.id, .int, .identifier(auto: true)) + .field(OrdersDevice.FieldKeys.pushToken, .string, .required) + .field(OrdersDevice.FieldKeys.deviceLibraryIdentifier, .string, .required) + .unique(on: OrdersDevice.FieldKeys.pushToken, OrdersDevice.FieldKeys.deviceLibraryIdentifier) + .create() + } + + public func revert(on database: any Database) async throws { + try await database.schema(Self.schema).delete() + } +} + +extension OrdersDevice { + enum FieldKeys { + static let schemaName = "orders_devices" + static let pushToken = FieldKey(stringLiteral: "push_token") + static let deviceLibraryIdentifier = FieldKey(stringLiteral: "device_library_identifier") + } +} \ No newline at end of file diff --git a/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift b/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift new file mode 100644 index 0000000..173e340 --- /dev/null +++ b/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift @@ -0,0 +1,52 @@ +// +// OrdersErrorLog.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +import struct Foundation.Date +import FluentKit +import PassKit + +/// The `Model` that stores Wallet orders error logs. +final public class OrdersErrorLog: ErrorLogModel, @unchecked Sendable { + public static let schema = OrdersErrorLog.FieldKeys.schemaName + + @ID(custom: .id) + public var id: Int? + + @Timestamp(key: OrdersErrorLog.FieldKeys.createdAt, on: .create) + public var createdAt: Date? + + @Field(key: OrdersErrorLog.FieldKeys.message) + public var message: String + + public init(message: String) { + self.message = message + } + + public init() {} +} + +extension OrdersErrorLog: AsyncMigration { + public func prepare(on database: any Database) async throws { + try await database.schema(Self.schema) + .field(.id, .int, .identifier(auto: true)) + .field(OrdersErrorLog.FieldKeys.createdAt, .datetime, .required) + .field(OrdersErrorLog.FieldKeys.message, .string, .required) + .create() + } + + public func revert(on database: any Database) async throws { + try await database.schema(Self.schema).delete() + } +} + +extension OrdersErrorLog { + enum FieldKeys { + static let schemaName = "orders_errors" + static let createdAt = FieldKey(stringLiteral: "created_at") + static let message = FieldKey(stringLiteral: "message") + } +} \ No newline at end of file diff --git a/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift b/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift new file mode 100644 index 0000000..6acae38 --- /dev/null +++ b/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift @@ -0,0 +1,49 @@ +// +// OrdersRegistration.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +import FluentKit + +/// The `Model` that stores orders registrations. +final public class OrdersRegistration: OrdersRegistrationModel, @unchecked Sendable { + public typealias OrderType = Order + public typealias DeviceType = OrdersDevice + + public static let schema = OrdersRegistration.FieldKeys.schemaName + + @ID(custom: .id) + public var id: Int? + + @Parent(key: OrdersRegistration.FieldKeys.deviceID) + public var device: DeviceType + + @Parent(key: OrdersRegistration.FieldKeys.orderID) + public var order: OrderType + + public init() {} +} + +extension OrdersRegistration: AsyncMigration { + public func prepare(on database: any Database) async throws { + try await database.schema(Self.schema) + .field(.id, .int, .identifier(auto: true)) + .field(OrdersRegistration.FieldKeys.deviceID, .int, .required, .references(DeviceType.schema, .id, onDelete: .cascade)) + .field(OrdersRegistration.FieldKeys.orderID, .uuid, .required, .references(OrderType.schema, .id, onDelete: .cascade)) + .create() + } + + public func revert(on database: any Database) async throws { + try await database.schema(Self.schema).delete() + } +} + +extension OrdersRegistration { + enum FieldKeys { + static let schemaName = "orders_registrations" + static let deviceID = FieldKey(stringLiteral: "device_id") + static let orderID = FieldKey(stringLiteral: "order_id") + } +} \ No newline at end of file diff --git a/Sources/Orders/Models/OrderDataModel.swift b/Sources/Orders/Models/OrderDataModel.swift new file mode 100644 index 0000000..55f3030 --- /dev/null +++ b/Sources/Orders/Models/OrderDataModel.swift @@ -0,0 +1,27 @@ +// +// OrderDataModel.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +import FluentKit + +/// Represents the `Model` that stores custom app data associated to Wallet orders. +public protocol OrderDataModel: Model { + associatedtype OrderType: OrderModel + + /// The foreign key to the order table + var order: OrderType { get set } +} + +internal extension OrderDataModel { + var _$order: Parent { + guard let mirror = Mirror(reflecting: self).descendant("_order"), + let order = mirror as? Parent else { + fatalError("order property must be declared using @Parent") + } + + return order + } +} \ No newline at end of file diff --git a/Sources/Orders/Models/OrderModel.swift b/Sources/Orders/Models/OrderModel.swift new file mode 100644 index 0000000..09f3221 --- /dev/null +++ b/Sources/Orders/Models/OrderModel.swift @@ -0,0 +1,61 @@ +// +// OrderModel.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +import Foundation +import FluentKit + +/// Represents the `Model` that stores Waller orders. +/// +/// Uses a UUID so people can't easily guess order IDs +public protocol OrderModel: Model where IDValue == UUID { + /// The order type identifier. + var orderTypeIdentifier: String { get set } + + /// The last time the order was modified. + var updatedAt: Date? { get set } + + /// The authentication token for the order. + var authenticationToken: String { get set } +} + +internal extension OrderModel { + var _$id: ID { + guard let mirror = Mirror(reflecting: self).descendant("_id"), + let id = mirror as? ID else { + fatalError("id property must be declared using @ID") + } + + return id + } + + var _$orderTypeIdentifier: Field { + guard let mirror = Mirror(reflecting: self).descendant("_orderTypeIdentifier"), + let orderTypeIdentifier = mirror as? Field else { + fatalError("orderTypeIdentifier property must be declared using @Field") + } + + return orderTypeIdentifier + } + + var _$updatedAt: Timestamp { + guard let mirror = Mirror(reflecting: self).descendant("_updatedAt"), + let updatedAt = mirror as? Timestamp else { + fatalError("updatedAt property must be declared using @Timestamp(on: .update)") + } + + return updatedAt + } + + var _$authenticationToken: Field { + guard let mirror = Mirror(reflecting: self).descendant("_authenticationToken"), + let authenticationToken = mirror as? Field else { + fatalError("authenticationToken property must be declared using @Field") + } + + return authenticationToken + } +} \ No newline at end of file diff --git a/Sources/Orders/Models/OrdersRegistrationModel.swift b/Sources/Orders/Models/OrdersRegistrationModel.swift new file mode 100644 index 0000000..ffcc912 --- /dev/null +++ b/Sources/Orders/Models/OrdersRegistrationModel.swift @@ -0,0 +1,51 @@ +// +// OrdersRegistrationModel.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +import FluentKit +import PassKit + +/// Represents the `Model` that stores orders registrations. +public protocol OrdersRegistrationModel: Model where IDValue == Int { + associatedtype OrderType: OrderModel + associatedtype DeviceType: DeviceModel + + /// The device for this registration. + var device: DeviceType { get set } + + /// The order for this registration. + var order: OrderType { get set } +} + +internal extension OrdersRegistrationModel { + var _$device: Parent { + guard let mirror = Mirror(reflecting: self).descendant("_device"), + let device = mirror as? Parent else { + fatalError("device property must be declared using @Parent") + } + + return device + } + + var _$order: Parent { + guard let mirror = Mirror(reflecting: self).descendant("_order"), + let order = mirror as? Parent else { + fatalError("order property must be declared using @Parent") + } + + return order + } + + static func `for`(deviceLibraryIdentifier: String, orderTypeIdentifier: String, on db: any Database) -> QueryBuilder { + Self.query(on: db) + .join(parent: \._$order) + .join(parent: \._$device) + .with(\._$order) + .with(\._$device) + .filter(OrderType.self, \._$orderTypeIdentifier == orderTypeIdentifier) + .filter(DeviceType.self, \._$deviceLibraryIdentifier == deviceLibraryIdentifier) + } +} \ No newline at end of file diff --git a/Sources/Orders/OrdersError.swift b/Sources/Orders/OrdersError.swift new file mode 100644 index 0000000..223dc75 --- /dev/null +++ b/Sources/Orders/OrdersError.swift @@ -0,0 +1,26 @@ +// +// OrdersError.swift +// PassKit +// +// Created by Francesco Paolo Severino on 30/06/24. +// + +public enum OrdersError: Error { + /// The template path is not a directory + case templateNotDirectory + + /// The `pemCertificate` file is missing. + case pemCertificateMissing + + /// The `pemPrivateKey` file is missing. + case pemPrivateKeyMissing + + /// Swift NIO failed to read the key. + case nioPrivateKeyReadFailed(any Error) + + /// The path to the zip binary is incorrect. + case zipBinaryMissing + + /// The path to the openssl binary is incorrect + case opensslBinaryMissing +} \ No newline at end of file From c358a9b0b6acf10d975b22c2fd63145ce6a3894f Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Mon, 1 Jul 2024 10:53:53 +0200 Subject: [PATCH 21/27] Update passes routes path components to distinguish them from the orders ones --- README.md | 13 ++++++++----- Sources/Passes/PassesServiceCustom.swift | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 640f0a0..404410c 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ struct PassJSONData: PassJSON { let serialNumber: String let teamIdentifier = Environment.get("APPLE_TEAM_IDENTIFIER")! - private let webServiceURL = "\(Environment.get("WEBSITE_URL")!)api/" + private let webServiceURL = "https://example.com/api/passes/" private let authenticationToken: String private let logoText = "Vapor" private let sharingProhibited = true @@ -196,6 +196,9 @@ struct PassJSONData: PassJSON { } ``` +> [!IMPORTANT] +> You **must** add `api/passes/` to your `webServiceURL`, as shown in the example above. + ### Implement the delegate. Create a delegate file that implements `PassesDelegate`. @@ -214,7 +217,7 @@ import Fluent import Passes final class PassDelegate: PassesDelegate { - let sslSigningFilesDirectory = URL(fileURLWithPath: "Certificates/", isDirectory: true) + let sslSigningFilesDirectory = URL(fileURLWithPath: "Certificates/Passes/", isDirectory: true) let pemPrivateKeyPassword: String? = Environment.get("PEM_PRIVATE_KEY_PASSWORD")! @@ -235,7 +238,7 @@ final class PassDelegate: PassesDelegate { func template(for: P, db: Database) async throws -> URL { // The location might vary depending on the type of pass. - return URL(fileURLWithPath: "PassKitTemplate/", isDirectory: true) + return URL(fileURLWithPath: "Templates/Passes/", isDirectory: true) } } ``` @@ -276,8 +279,8 @@ try passesService.registerPushRoutes(middleware: SecretMiddleware(secret: "foo") That will add two routes: -- POST .../api/v1/push/*passTypeIdentifier*/*passBarcode* (Sends notifications) -- GET .../api/v1/push/*passTypeIdentifier*/*passBarcode* (Retrieves a list of push tokens which would be sent a notification) +- POST .../api/passes/v1/push/*:passTypeIdentifier*/*:passSerial* (Sends notifications) +- GET .../api/passes/v1/push/*:passTypeIdentifier*/*:passSerial* (Retrieves a list of push tokens which would be sent a notification) #### Pass data model middleware diff --git a/Sources/Passes/PassesServiceCustom.swift b/Sources/Passes/PassesServiceCustom.swift index 15b7115..39d4ca1 100644 --- a/Sources/Passes/PassesServiceCustom.swift +++ b/Sources/Passes/PassesServiceCustom.swift @@ -32,7 +32,7 @@ public final class PassesServiceCustom Date: Mon, 1 Jul 2024 11:19:41 +0200 Subject: [PATCH 22/27] Test order content generation --- Sources/Orders/OrdersDelegate.swift | 120 ++++++++++++++++++ Sources/Orders/OrdersService.swift | 38 ++++++ Sources/Orders/OrdersServiceCustom.swift | 147 +++++++++++++++++++++++ Sources/Passes/PassesDelegate.swift | 2 +- 4 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 Sources/Orders/OrdersDelegate.swift create mode 100644 Sources/Orders/OrdersService.swift create mode 100644 Sources/Orders/OrdersServiceCustom.swift diff --git a/Sources/Orders/OrdersDelegate.swift b/Sources/Orders/OrdersDelegate.swift new file mode 100644 index 0000000..87a784f --- /dev/null +++ b/Sources/Orders/OrdersDelegate.swift @@ -0,0 +1,120 @@ +// +// OrdersDelegate.swift +// PassKit +// +// Created by Francesco Paolo Severino on 01/07/24. +// + +import Foundation +import FluentKit + +/// The delegate which is responsible for generating the order files. +public protocol OrdersDelegate: AnyObject, Sendable { + /// Should return a `URL` which points to the template data for the order. + /// + /// The URL should point to a directory containing all the images and localizations for the generated `.order` archive but should *not* contain any of these items: + /// - `manifest.json` + /// - `order.json` + /// - `signature` + /// + /// - Parameters: + /// - for: The order data from the SQL server. + /// - db: The SQL database to query against. + /// + /// - Returns: A `URL` which points to the template data for the order. + /// + /// > Important: Be sure to use the `URL(fileURLWithPath:isDirectory:)` constructor. + func template(for: O, db: any Database) async throws -> URL + + /// Generates the SSL `signature` file. + /// + /// If you need to implement custom S/Mime signing you can use this + /// method to do so. You must generate a detached DER signature of the `manifest.json` file. + /// + /// - Parameter root: The location of the `manifest.json` and where to write the `signature` to. + /// - Returns: Return `true` if you generated a custom `signature`, otherwise `false`. + func generateSignatureFile(in root: URL) -> Bool + + /// Encode the order into JSON. + /// + /// This method should generate the entire order JSON. You are provided with + /// the order data from the SQL database and you should return a properly + /// formatted order file encoding. + /// + /// - Parameters: + /// - order: The order data from the SQL server + /// - db: The SQL database to query against. + /// - encoder: The `JSONEncoder` which you should use. + /// - Returns: The encoded order JSON data. + /// + /// > Tip: See the [`Order`](https://developer.apple.com/documentation/walletorders/order) object to understand the keys. + func encode(order: O, db: any Database, encoder: JSONEncoder) async throws -> Data + + /// Should return a `URL` which points to the template data for the order. + /// + /// The URL should point to a directory containing the files specified by these keys: + /// - `wwdrCertificate` + /// - `pemCertificate` + /// - `pemPrivateKey` + /// + /// > Important: Be sure to use the `URL(fileURLWithPath:isDirectory:)` initializer! + var sslSigningFilesDirectory: URL { get } + + /// The location of the `openssl` command as a file URL. + /// + /// > Important: Be sure to use the `URL(fileURLWithPath:)` constructor. + var sslBinary: URL { get } + + /// The full path to the `zip` command as a file URL. + /// + /// > Important: Be sure to use the `URL(fileURLWithPath:)` constructor. + var zipBinary: URL { get } + + /// The name of Apple's WWDR.pem certificate as contained in `sslSigningFiles` path. + /// + /// Defaults to `WWDR.pem` + var wwdrCertificate: String { get } + + /// The name of the PEM Certificate for signing the order as contained in `sslSigningFiles` path. + /// + /// Defaults to `ordercertificate.pem` + var pemCertificate: String { get } + + /// The name of the PEM Certificate's private key for signing the order as contained in `sslSigningFiles` path. + /// + /// Defaults to `orderkey.pem` + var pemPrivateKey: String { get } + + /// The password to the private key file. + var pemPrivateKeyPassword: String? { get } +} + +public extension OrdersDelegate { + var wwdrCertificate: String { + get { return "WWDR.pem" } + } + + var pemCertificate: String { + get { return "ordercertificate.pem" } + } + + var pemPrivateKey: String { + get { return "orderkey.pem" } + } + + var pemPrivateKeyPassword: String? { + get { return nil } + } + + var sslBinary: URL { + get { return URL(fileURLWithPath: "/usr/bin/openssl") } + } + + var zipBinary: URL { + get { return URL(fileURLWithPath: "/usr/bin/zip") } + } + + func generateSignatureFile(in root: URL) -> Bool { + return false + } +} \ No newline at end of file diff --git a/Sources/Orders/OrdersService.swift b/Sources/Orders/OrdersService.swift new file mode 100644 index 0000000..b711829 --- /dev/null +++ b/Sources/Orders/OrdersService.swift @@ -0,0 +1,38 @@ +// +// OrdersService.swift +// PassKit +// +// Created by Francesco Paolo Severino on 01/07/24. +// + +import Vapor +import FluentKit + +/// The main class that handles Wallet orders. +public final class OrdersService: Sendable { + private let service: OrdersServiceCustom + + public init(app: Application, delegate: any OrdersDelegate, logger: Logger? = nil) { + service = .init(app: app, delegate: delegate, logger: logger) + } + + /// Generates the order content bundle for a given order. + /// + /// - Parameters: + /// - order: The order to generate the content for. + /// - db: The `Database` to use. + /// - Returns: The generated order content. + public func generatePassContent(for order: Order, on db: any Database) async throws -> Data { + try await service.generateOrderContent(for: order, on: db) + } + + /// Adds the migrations for Wallet orders models. + /// + /// - Parameter migrations: The `Migrations` object to add the migrations to. + public static func register(migrations: Migrations) { + migrations.add(Order()) + migrations.add(OrdersDevice()) + migrations.add(OrdersRegistration()) + migrations.add(OrdersErrorLog()) + } +} \ No newline at end of file diff --git a/Sources/Orders/OrdersServiceCustom.swift b/Sources/Orders/OrdersServiceCustom.swift new file mode 100644 index 0000000..68f19ed --- /dev/null +++ b/Sources/Orders/OrdersServiceCustom.swift @@ -0,0 +1,147 @@ +// +// OrdersServiceCustom.swift +// PassKit +// +// Created by Francesco Paolo Severino on 01/07/24. +// + +import Vapor +import APNS +import VaporAPNS +@preconcurrency import APNSCore +import Fluent +import NIOSSL +import PassKit + +/// Class to handle `OrdersService`. +/// +/// The generics should be passed in this order: +/// - Order Type +/// - Device Type +/// - Registration Type +/// - Error Log Type +public final class OrdersServiceCustom: Sendable where O == R.OrderType, D == R.DeviceType { + public unowned let delegate: any OrdersDelegate + private unowned let app: Application + + private let v1: FakeSendable + private let logger: Logger? + + public init(app: Application, delegate: any OrdersDelegate, logger: Logger? = nil) { + self.delegate = delegate + self.logger = logger + self.app = app + + v1 = FakeSendable(value: app.grouped("api", "orders", "v1")) + } +} + +// MARK: - order file generation +extension OrdersServiceCustom { + private static func generateManifestFile(using encoder: JSONEncoder, in root: URL) throws { + var manifest: [String: String] = [:] + + let paths = try FileManager.default.subpathsOfDirectory(atPath: root.unixPath()) + try paths.forEach { relativePath in + let file = URL(fileURLWithPath: relativePath, relativeTo: root) + guard !file.hasDirectoryPath else { + return + } + + let data = try Data(contentsOf: file) + let hash = SHA256.hash(data: data) + manifest[relativePath] = hash.map { "0\(String($0, radix: 16))".suffix(2) }.joined() + } + + let encoded = try encoder.encode(manifest) + try encoded.write(to: root.appendingPathComponent("manifest.json")) + } + + private func generateSignatureFile(in root: URL) throws { + if delegate.generateSignatureFile(in: root) { + // If the caller's delegate generated a file we don't have to do it. + return + } + + let sslBinary = delegate.sslBinary + + guard FileManager.default.fileExists(atPath: sslBinary.unixPath()) else { + throw OrdersError.opensslBinaryMissing + } + + let proc = Process() + proc.currentDirectoryURL = delegate.sslSigningFilesDirectory + proc.executableURL = sslBinary + + proc.arguments = [ + "smime", "-binary", "-sign", + "-certfile", delegate.wwdrCertificate, + "-signer", delegate.pemCertificate, + "-inkey", delegate.pemPrivateKey, + "-in", root.appendingPathComponent("manifest.json").unixPath(), + "-out", root.appendingPathComponent("signature").unixPath(), + "-outform", "DER" + ] + + if let pwd = delegate.pemPrivateKeyPassword { + proc.arguments!.append(contentsOf: ["-passin", "pass:\(pwd)"]) + } + + try proc.run() + + proc.waitUntilExit() + } + + private func zip(directory: URL, to: URL) throws { + let zipBinary = delegate.zipBinary + guard FileManager.default.fileExists(atPath: zipBinary.unixPath()) else { + throw OrdersError.zipBinaryMissing + } + + let proc = Process() + proc.currentDirectoryURL = directory + proc.executableURL = zipBinary + + proc.arguments = [ to.unixPath(), "-r", "-q", "." ] + + try proc.run() + proc.waitUntilExit() + } + + public func generateOrderContent(for order: O, 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() + + let src = try await delegate.template(for: order, db: db) + guard (try? src.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else { + throw OrdersError.templateNotDirectory + } + + let encoded = try await self.delegate.encode(order: order, 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("order.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 + } + } +} \ No newline at end of file diff --git a/Sources/Passes/PassesDelegate.swift b/Sources/Passes/PassesDelegate.swift index 8170e51..ca57ec5 100644 --- a/Sources/Passes/PassesDelegate.swift +++ b/Sources/Passes/PassesDelegate.swift @@ -39,7 +39,7 @@ public protocol PassesDelegate: AnyObject, Sendable { /// - `signature` /// /// - Parameters: - /// - pass: The pass data from the SQL server. + /// - for: The pass data from the SQL server. /// - db: The SQL database to query against. /// /// - Returns: A `URL` which points to the template data for the pass. From 4aa7200de7160f1a82599e2053013c763ce239a4 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Mon, 1 Jul 2024 11:20:21 +0200 Subject: [PATCH 23/27] Add `Orders` product --- Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Package.swift b/Package.swift index d1e8051..ae85b76 100644 --- a/Package.swift +++ b/Package.swift @@ -9,6 +9,7 @@ let package = Package( products: [ .library(name: "PassKit", targets: ["PassKit"]), .library(name: "Passes", targets: ["PassKit", "Passes"]), + .library(name: "Orders", targets: ["PassKit", "Orders"]), ], dependencies: [ .package(url: "https://github.com/vapor/vapor.git", from: "4.102.0"), From a43bf6d2ab035c1447661f118f4a992aa585bd15 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Mon, 1 Jul 2024 11:56:45 +0200 Subject: [PATCH 24/27] Fix typo --- Sources/Orders/OrdersService.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Orders/OrdersService.swift b/Sources/Orders/OrdersService.swift index b711829..23977ba 100644 --- a/Sources/Orders/OrdersService.swift +++ b/Sources/Orders/OrdersService.swift @@ -22,7 +22,7 @@ public final class OrdersService: Sendable { /// - order: The order to generate the content for. /// - db: The `Database` to use. /// - Returns: The generated order content. - public func generatePassContent(for order: Order, on db: any Database) async throws -> Data { + public func generateOrderContent(for order: Order, on db: any Database) async throws -> Data { try await service.generateOrderContent(for: order, on: db) } @@ -35,4 +35,4 @@ public final class OrdersService: Sendable { migrations.add(OrdersRegistration()) migrations.add(OrdersErrorLog()) } -} \ No newline at end of file +} From 6826b278984de7ad51353e75f1aeed1526af4a3d Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Mon, 1 Jul 2024 14:21:26 +0200 Subject: [PATCH 25/27] Remove `FakeSendable` --- Sources/Orders/OrdersServiceCustom.swift | 6 +++--- Sources/PassKit/FakeSendable.swift | 8 -------- Sources/Passes/PassesServiceCustom.swift | 14 +++++++------- 3 files changed, 10 insertions(+), 18 deletions(-) delete mode 100644 Sources/PassKit/FakeSendable.swift diff --git a/Sources/Orders/OrdersServiceCustom.swift b/Sources/Orders/OrdersServiceCustom.swift index 68f19ed..c70b974 100644 --- a/Sources/Orders/OrdersServiceCustom.swift +++ b/Sources/Orders/OrdersServiceCustom.swift @@ -5,7 +5,7 @@ // Created by Francesco Paolo Severino on 01/07/24. // -import Vapor +@preconcurrency import Vapor import APNS import VaporAPNS @preconcurrency import APNSCore @@ -24,7 +24,7 @@ public final class OrdersServiceCustom + private let v1: any RoutesBuilder private let logger: Logger? public init(app: Application, delegate: any OrdersDelegate, logger: Logger? = nil) { @@ -32,7 +32,7 @@ public final class OrdersServiceCustom: @unchecked Sendable { - package let value: T - - package init(value: T) { - self.value = value - } -} diff --git a/Sources/Passes/PassesServiceCustom.swift b/Sources/Passes/PassesServiceCustom.swift index 39d4ca1..1a61918 100644 --- a/Sources/Passes/PassesServiceCustom.swift +++ b/Sources/Passes/PassesServiceCustom.swift @@ -5,7 +5,7 @@ // Created by Francesco Paolo Severino on 29/06/24. // -import Vapor +@preconcurrency import Vapor import APNS import VaporAPNS @preconcurrency import APNSCore @@ -24,7 +24,7 @@ public final class PassesServiceCustom + private let v1: any RoutesBuilder private let logger: Logger? public init(app: Application, delegate: any PassesDelegate, logger: Logger? = nil) { @@ -32,15 +32,15 @@ public final class PassesServiceCustom()) + let v1auth = v1.grouped(ApplePassMiddleware

()) v1auth.post("devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", ":passSerial", use: { try await self.registerDevice(req: $0) }) v1auth.get("passes", ":passTypeIdentifier", ":passSerial", use: { try await self.latestVersionOfPass(req: $0) }) @@ -101,7 +101,7 @@ public final class PassesServiceCustom Date: Mon, 1 Jul 2024 14:23:14 +0200 Subject: [PATCH 26/27] Add `Orders` to `.spi.yml` --- .spi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.spi.yml b/.spi.yml index 280f8ca..ad36f6c 100644 --- a/.spi.yml +++ b/.spi.yml @@ -1,4 +1,4 @@ version: 1 builder: configs: - - documentation_targets: [PassKit, Passes] \ No newline at end of file + - documentation_targets: [PassKit, Passes, Orders] \ No newline at end of file From 6e6c69fd7982080ab0cf94d4f421aea56b3f8af2 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Mon, 1 Jul 2024 14:36:36 +0200 Subject: [PATCH 27/27] Update README --- README.md | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 404410c..8673607 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@


-🎟️ A Vapor package which handles all the server side elements required to implement passes for Apple Wallet. +🎟️ 📦 A Vapor package which handles all the server side elements required to implement Apple Wallet passes and orders. ### Major Releases @@ -36,16 +36,16 @@ Use the SPM string to easily include the dependendency in your `Package.swift` f .package(url: "https://github.com/vapor-community/PassKit.git", from: "0.4.0") ``` -and add it to your target's dependencies: +> Note: This package is made for Vapor 4. + +## 🎟️ Wallet Passes + +Add the `Passes` product to your target's dependencies: ```swift .product(name: "Passes", package: "PassKit") ``` -> Note: This package is made for Vapor 4. - -## Usage - ### Implement your pass data model Your data model should contain all the fields that you store for your pass, as well as a foreign key for the pass itself. @@ -128,6 +128,9 @@ FOR EACH ROW EXECUTE PROCEDURE "public"."RemoveUnregisteredItems"(); ``` +> [!CAUTION] +> Be careful with SQL triggers, as they can have unintended consequences if not properly implemented. + ### Model the `pass.json` contents Create a `struct` that implements `PassJSON` which will contain all the fields for the generated `pass.json` file. @@ -434,3 +437,15 @@ fileprivate func passHandler(_ req: Request) async throws -> Response { return Response(status: .ok, headers: headers, body: body) } ``` + +## 📦 Wallet Orders + +Add the `Orders` product to your target's dependencies: + +```swift +.product(name: "Orders", package: "PassKit") +``` + +> [!WARNING] +> The `Orders` is WIP, right now you can only set up the models and generate `.order` bundles. +APNS support and order updates will be added soon. See the `Orders` target's documentation. \ No newline at end of file