From 900653c265194f8b196abe603a5e26855c43870c Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino <96546612+fpseverino@users.noreply.github.com> Date: Tue, 14 Jan 2025 16:51:10 +0100 Subject: [PATCH] Adopt `swift-wallet` and `fluent-wallet` (#19) * Update to VaporTesting * Add dependencies to `swift-wallet` and `fluent-wallet` * Make `Personalization` migration in `PassesService` optional * Improve auth middleware * Rename `PassKit` to `VaporWallet` * Change `PassKit` to `wallet` --- .github/workflows/test.yml | 1 + .spi.yml | 2 +- Package.swift | 57 ++-- README.md | 30 +- Sources/Orders/DTOs/OrderJSON.swift | 106 ------ Sources/Orders/DTOs/OrdersForDeviceDTO.swift | 11 - .../Orders/Models/Concrete Models/Order.swift | 63 ---- .../Models/Concrete Models/OrdersDevice.swift | 49 --- .../Concrete Models/OrdersErrorLog.swift | 49 --- .../Concrete Models/OrdersRegistration.swift | 51 --- Sources/Orders/Models/OrderDataModel.swift | 48 --- Sources/Orders/Models/OrderModel.swift | 67 ---- .../Models/OrdersRegistrationModel.swift | 46 --- .../Orders.docc/Extensions/OrderJSON.md | 19 -- Sources/Orders/Orders.docc/GettingStarted.md | 256 --------------- Sources/Orders/Orders.docc/Orders.md | 35 -- Sources/PassKit/DTOs/ErrorLogDTO.swift | 37 --- Sources/PassKit/DTOs/RegistrationDTO.swift | 37 --- Sources/PassKit/Models/DeviceModel.swift | 66 ---- Sources/PassKit/Models/ErrorLogModel.swift | 51 --- Sources/PassKit/WalletError.swift | 69 ---- Sources/Passes/DTOs/PassJSON.swift | 136 -------- Sources/Passes/DTOs/PassesForDeviceDTO.swift | 39 --- .../DTOs/PersonalizationDictionaryDTO.swift | 26 -- Sources/Passes/DTOs/PersonalizationJSON.swift | 66 ---- .../Passes/Models/Concrete Models/Pass.swift | 72 ---- .../Models/Concrete Models/PassesDevice.swift | 49 --- .../Concrete Models/PassesErrorLog.swift | 49 --- .../Concrete Models/PassesRegistration.swift | 51 --- .../Concrete Models/UserPersonalization.swift | 79 ----- Sources/Passes/Models/PassDataModel.swift | 94 ------ Sources/Passes/Models/PassModel.swift | 107 ------ .../Models/PassesRegistrationModel.swift | 74 ----- .../Models/UserPersonalizationModel.swift | 114 ------- .../Passes/Passes.docc/Extensions/PassJSON.md | 24 -- Sources/Passes/Passes.docc/GettingStarted.md | 307 ------------------ Sources/Passes/Passes.docc/Passes.md | 50 --- Sources/Passes/Passes.docc/Personalization.md | 84 ----- Sources/VaporWallet/DTOs/DTOs+Content.swift | 5 + .../Testing/SecretMiddleware.swift | 0 .../Testing/TestCertificate.swift | 0 .../Testing/isLoggingConfigured.swift | 0 .../VaporWallet.docc}/Resources/wallet.png | Bin .../VaporWallet.docc/VaporWallet.md} | 12 +- .../DTOs/OrderIdentifiersDTO+Content.swift | 4 + Sources/VaporWalletOrders/Exports.swift | 1 + .../Middleware/AppleOrderMiddleware.swift | 8 +- .../OrdersService+AsyncModelMiddleware.swift | 1 + .../OrdersService.swift | 10 +- .../OrdersServiceCustom.swift | 178 +++------- .../Extensions/OrdersService.md | 2 +- .../VaporWalletOrders.docc/GettingStarted.md | 144 ++++++++ .../VaporWalletOrders.md | 17 + .../VaporWalletPasses/DTOs/DTOs+Content.swift | 6 + Sources/VaporWalletPasses/Exports.swift | 1 + .../Middleware/ApplePassMiddleware.swift | 8 +- .../PassesService+AsyncModelMiddleware.swift | 1 + .../PassesService.swift | 20 +- .../PassesServiceCustom.swift | 220 ++++--------- .../Extensions/PassesService.md | 4 +- .../VaporWalletPasses.docc/GettingStarted.md | 171 ++++++++++ .../Resources/passes.png | Bin .../VaporWalletPasses.md | 25 ++ Tests/OrdersTests/Utils/OrderJSONData.swift | 51 --- .../SourceFiles}/EmptyDir/.gitkeep | 0 .../SourceFiles}/icon.png | Bin .../it-IT.lproj/pet_store_logo.png | Bin .../SourceFiles}/pet_store_logo.png | Bin .../Utils/OrderData.swift | 8 +- .../Utils/OrderJSONData.swift | 35 ++ .../Utils/withApp.swift | 5 +- .../VaporWalletOrdersTests.swift} | 55 +--- .../SourceFiles}/EmptyDir/.gitkeep | 0 .../SourceFiles}/icon.png | Bin .../SourceFiles}/it-IT.lproj/logo.png | Bin .../it-IT.lproj/personalizationLogo.png | Bin .../SourceFiles}/logo.png | Bin .../SourceFiles}/personalizationLogo.png | Bin .../Utils/PassData.swift | 17 +- .../Utils/PassJSONData.swift | 57 ++-- .../Utils/withApp.swift | 7 +- .../VaporWalletPassesTests.swift} | 71 ++-- 82 files changed, 673 insertions(+), 3042 deletions(-) delete mode 100644 Sources/Orders/DTOs/OrderJSON.swift delete mode 100644 Sources/Orders/DTOs/OrdersForDeviceDTO.swift delete mode 100644 Sources/Orders/Models/Concrete Models/Order.swift delete mode 100644 Sources/Orders/Models/Concrete Models/OrdersDevice.swift delete mode 100644 Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift delete mode 100644 Sources/Orders/Models/Concrete Models/OrdersRegistration.swift delete mode 100644 Sources/Orders/Models/OrderDataModel.swift delete mode 100644 Sources/Orders/Models/OrderModel.swift delete mode 100644 Sources/Orders/Models/OrdersRegistrationModel.swift delete mode 100644 Sources/Orders/Orders.docc/Extensions/OrderJSON.md delete mode 100644 Sources/Orders/Orders.docc/GettingStarted.md delete mode 100644 Sources/Orders/Orders.docc/Orders.md delete mode 100644 Sources/PassKit/DTOs/ErrorLogDTO.swift delete mode 100644 Sources/PassKit/DTOs/RegistrationDTO.swift delete mode 100644 Sources/PassKit/Models/DeviceModel.swift delete mode 100644 Sources/PassKit/Models/ErrorLogModel.swift delete mode 100644 Sources/PassKit/WalletError.swift delete mode 100644 Sources/Passes/DTOs/PassJSON.swift delete mode 100644 Sources/Passes/DTOs/PassesForDeviceDTO.swift delete mode 100644 Sources/Passes/DTOs/PersonalizationDictionaryDTO.swift delete mode 100644 Sources/Passes/DTOs/PersonalizationJSON.swift delete mode 100644 Sources/Passes/Models/Concrete Models/Pass.swift delete mode 100644 Sources/Passes/Models/Concrete Models/PassesDevice.swift delete mode 100644 Sources/Passes/Models/Concrete Models/PassesErrorLog.swift delete mode 100644 Sources/Passes/Models/Concrete Models/PassesRegistration.swift delete mode 100644 Sources/Passes/Models/Concrete Models/UserPersonalization.swift delete mode 100644 Sources/Passes/Models/PassDataModel.swift delete mode 100644 Sources/Passes/Models/PassModel.swift delete mode 100644 Sources/Passes/Models/PassesRegistrationModel.swift delete mode 100644 Sources/Passes/Models/UserPersonalizationModel.swift delete mode 100644 Sources/Passes/Passes.docc/Extensions/PassJSON.md delete mode 100644 Sources/Passes/Passes.docc/GettingStarted.md delete mode 100644 Sources/Passes/Passes.docc/Passes.md delete mode 100644 Sources/Passes/Passes.docc/Personalization.md create mode 100644 Sources/VaporWallet/DTOs/DTOs+Content.swift rename Sources/{PassKit => VaporWallet}/Testing/SecretMiddleware.swift (100%) rename Sources/{PassKit => VaporWallet}/Testing/TestCertificate.swift (100%) rename Sources/{PassKit => VaporWallet}/Testing/isLoggingConfigured.swift (100%) rename Sources/{PassKit/PassKit.docc => VaporWallet/VaporWallet.docc}/Resources/wallet.png (100%) rename Sources/{PassKit/PassKit.docc/PassKit.md => VaporWallet/VaporWallet.docc/VaporWallet.md} (57%) create mode 100644 Sources/VaporWalletOrders/DTOs/OrderIdentifiersDTO+Content.swift create mode 100644 Sources/VaporWalletOrders/Exports.swift rename Sources/{Orders => VaporWalletOrders}/Middleware/AppleOrderMiddleware.swift (56%) rename Sources/{Orders => VaporWalletOrders}/Middleware/OrdersService+AsyncModelMiddleware.swift (98%) rename Sources/{Orders => VaporWalletOrders}/OrdersService.swift (93%) rename Sources/{Orders => VaporWalletOrders}/OrdersServiceCustom.swift (68%) rename Sources/{Orders/Orders.docc => VaporWalletOrders/VaporWalletOrders.docc}/Extensions/OrdersService.md (78%) create mode 100644 Sources/VaporWalletOrders/VaporWalletOrders.docc/GettingStarted.md create mode 100644 Sources/VaporWalletOrders/VaporWalletOrders.docc/VaporWalletOrders.md create mode 100644 Sources/VaporWalletPasses/DTOs/DTOs+Content.swift create mode 100644 Sources/VaporWalletPasses/Exports.swift rename Sources/{Passes => VaporWalletPasses}/Middleware/ApplePassMiddleware.swift (86%) rename Sources/{Passes => VaporWalletPasses}/Middleware/PassesService+AsyncModelMiddleware.swift (98%) rename Sources/{Passes => VaporWalletPasses}/PassesService.swift (87%) rename Sources/{Passes => VaporWalletPasses}/PassesServiceCustom.swift (66%) rename Sources/{Passes/Passes.docc => VaporWalletPasses/VaporWalletPasses.docc}/Extensions/PassesService.md (61%) create mode 100644 Sources/VaporWalletPasses/VaporWalletPasses.docc/GettingStarted.md rename Sources/{Passes/Passes.docc => VaporWalletPasses/VaporWalletPasses.docc}/Resources/passes.png (100%) create mode 100644 Sources/VaporWalletPasses/VaporWalletPasses.docc/VaporWalletPasses.md delete mode 100644 Tests/OrdersTests/Utils/OrderJSONData.swift rename Tests/{OrdersTests/Templates => VaporWalletOrdersTests/SourceFiles}/EmptyDir/.gitkeep (100%) rename Tests/{OrdersTests/Templates => VaporWalletOrdersTests/SourceFiles}/icon.png (100%) rename Tests/{OrdersTests/Templates => VaporWalletOrdersTests/SourceFiles}/it-IT.lproj/pet_store_logo.png (100%) rename Tests/{OrdersTests/Templates => VaporWalletOrdersTests/SourceFiles}/pet_store_logo.png (100%) rename Tests/{OrdersTests => VaporWalletOrdersTests}/Utils/OrderData.swift (85%) create mode 100644 Tests/VaporWalletOrdersTests/Utils/OrderJSONData.swift rename Tests/{OrdersTests => VaporWalletOrdersTests}/Utils/withApp.swift (95%) rename Tests/{OrdersTests/OrdersTests.swift => VaporWalletOrdersTests/VaporWalletOrdersTests.swift} (90%) rename Tests/{PassesTests/Templates => VaporWalletPassesTests/SourceFiles}/EmptyDir/.gitkeep (100%) rename Tests/{PassesTests/Templates => VaporWalletPassesTests/SourceFiles}/icon.png (100%) rename Tests/{PassesTests/Templates => VaporWalletPassesTests/SourceFiles}/it-IT.lproj/logo.png (100%) rename Tests/{PassesTests/Templates => VaporWalletPassesTests/SourceFiles}/it-IT.lproj/personalizationLogo.png (100%) rename Tests/{PassesTests/Templates => VaporWalletPassesTests/SourceFiles}/logo.png (100%) rename Tests/{PassesTests/Templates => VaporWalletPassesTests/SourceFiles}/personalizationLogo.png (100%) rename Tests/{PassesTests => VaporWalletPassesTests}/Utils/PassData.swift (76%) rename Tests/{PassesTests => VaporWalletPassesTests}/Utils/PassJSONData.swift (50%) rename Tests/{PassesTests => VaporWalletPassesTests}/Utils/withApp.swift (93%) rename Tests/{PassesTests/PassesTests.swift => VaporWalletPassesTests/VaporWalletPassesTests.swift} (89%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dbddd19..81e62e2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,5 +12,6 @@ jobs: with: with_linting: true test_filter: --no-parallel + with_tsan: false secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.spi.yml b/.spi.yml index ad36f6c..b044b2d 100644 --- a/.spi.yml +++ b/.spi.yml @@ -1,4 +1,4 @@ version: 1 builder: configs: - - documentation_targets: [PassKit, Passes, Orders] \ No newline at end of file + - documentation_targets: [VaporWallet, VaporWalletPasses, VaporWalletOrders] \ No newline at end of file diff --git a/Package.swift b/Package.swift index cdbc96a..38b924e 100644 --- a/Package.swift +++ b/Package.swift @@ -2,72 +2,71 @@ import PackageDescription let package = Package( - name: "PassKit", + name: "wallet", platforms: [ .macOS(.v13) ], products: [ - .library(name: "Passes", targets: ["Passes"]), - .library(name: "Orders", targets: ["Orders"]), + .library(name: "VaporWalletPasses", targets: ["VaporWalletPasses"]), + .library(name: "VaporWalletOrders", targets: ["VaporWalletOrders"]), ], dependencies: [ - .package(url: "https://github.com/vapor/vapor.git", from: "4.108.0"), + .package(url: "https://github.com/vapor/vapor.git", from: "4.111.0"), .package(url: "https://github.com/vapor/fluent.git", from: "4.12.0"), + .package(url: "https://github.com/fpseverino/fluent-wallet.git", from: "0.1.0"), .package(url: "https://github.com/vapor/apns.git", from: "4.2.0"), - .package(url: "https://github.com/vapor-community/Zip.git", from: "2.2.4"), - .package(url: "https://github.com/apple/swift-certificates.git", from: "1.6.1"), // used in tests .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.8.0"), ], targets: [ .target( - name: "PassKit", + name: "VaporWallet", dependencies: [ - .product(name: "Fluent", package: "fluent"), .product(name: "Vapor", package: "vapor"), + .product(name: "Fluent", package: "fluent"), .product(name: "VaporAPNS", package: "apns"), - .product(name: "Zip", package: "zip"), - .product(name: "X509", package: "swift-certificates"), - ], - swiftSettings: swiftSettings - ), - .target( - name: "Passes", - dependencies: [ - .target(name: "PassKit") ], swiftSettings: swiftSettings ), + // MARK: - Wallet Passes .target( - name: "Orders", + name: "VaporWalletPasses", dependencies: [ - .target(name: "PassKit") + .target(name: "VaporWallet"), + .product(name: "FluentWalletPasses", package: "fluent-wallet"), ], swiftSettings: swiftSettings ), .testTarget( - name: "PassesTests", + name: "VaporWalletPassesTests", dependencies: [ - .target(name: "Passes"), - .target(name: "PassKit"), - .product(name: "XCTVapor", package: "vapor"), + .target(name: "VaporWalletPasses"), + .product(name: "VaporTesting", package: "vapor"), .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), ], resources: [ - .copy("Templates") + .copy("SourceFiles") + ], + swiftSettings: swiftSettings + ), + // MARK: - Wallet Orders + .target( + name: "VaporWalletOrders", + dependencies: [ + .target(name: "VaporWallet"), + .product(name: "FluentWalletOrders", package: "fluent-wallet"), ], swiftSettings: swiftSettings ), .testTarget( - name: "OrdersTests", + name: "VaporWalletOrdersTests", dependencies: [ - .target(name: "Orders"), - .target(name: "PassKit"), - .product(name: "XCTVapor", package: "vapor"), + .target(name: "VaporWalletOrders"), + .product(name: "VaporTesting", package: "vapor"), .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), ], resources: [ - .copy("Templates") + .copy("SourceFiles") ], swiftSettings: swiftSettings ), diff --git a/README.md b/README.md index 4ec7ae8..2d0259b 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@
avatar -

PassKit

- +

Vapor Wallet

+
Documentation Team Chat MIT License - - Continuous Integration + + Continuous Integration - - + + Swift 6.0+ @@ -23,37 +23,37 @@ 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.6.0") +.package(url: "https://github.com/vapor-community/wallet.git", from: "0.6.0") ``` > Note: This package is made for Vapor 4. ## 🎟️ Wallet Passes -The Passes framework provides a set of tools to help you create, build, and distribute digital passes for the Apple Wallet app using a Vapor server. +The `VaporWalletPasses` framework provides a set of tools to help you create, build, and distribute digital passes for the Apple Wallet app using a Vapor server. It also provides a way to update passes after they have been distributed, using APNs, and models to store pass and device data. -Add the `Passes` product to your target's dependencies: +Add the `VaporWalletPasses` product to your target's dependencies: ```swift -.product(name: "Passes", package: "PassKit") +.product(name: "VaporWalletPasses", package: "wallet") ``` -See the framework's [documentation](https://swiftpackageindex.com/vapor-community/PassKit/documentation/passes) for information and guides on how to use it. +See the framework's [documentation](https://swiftpackageindex.com/vapor-community/wallet/documentation/passes) for information and guides on how to use it. For information on Apple Wallet passes, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletpasses). ## 📦 Wallet Orders -The Orders framework provides a set of tools to help you create, build, and distribute orders that users can track and manage in Apple Wallet using a Vapor server. +The `VaporWalletOrders` framework provides a set of tools to help you create, build, and distribute orders that users can track and manage in Apple Wallet using a Vapor server. It also provides a way to update orders after they have been distributed, using APNs, and models to store order and device data. -Add the `Orders` product to your target's dependencies: +Add the `VaporWalletOrders` product to your target's dependencies: ```swift -.product(name: "Orders", package: "PassKit") +.product(name: "VaporWalletOrders", package: "wallet") ``` -See the framework's [documentation](https://swiftpackageindex.com/vapor-community/PassKit/documentation/orders) for information and guides on how to use it. +See the framework's [documentation](https://swiftpackageindex.com/vapor-community/wallet/documentation/orders) for information and guides on how to use it. For information on Apple Wallet orders, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletorders). diff --git a/Sources/Orders/DTOs/OrderJSON.swift b/Sources/Orders/DTOs/OrderJSON.swift deleted file mode 100644 index 66bfea0..0000000 --- a/Sources/Orders/DTOs/OrderJSON.swift +++ /dev/null @@ -1,106 +0,0 @@ -/// The structure of a `order.json` file. -public struct OrderJSON { - /// A protocol that defines the structure of a `order.json` file. - /// - /// > Tip: See the [`Order`](https://developer.apple.com/documentation/walletorders/order) object to understand the keys. - public protocol Properties: Encodable { - /// The date and time when the customer created the order, in RFC 3339 format. - var createdAt: String { get } - - /// A unique order identifier scoped to your order type identifier. - /// - /// In combination with the order type identifier, this uniquely identifies an order within the system and isn’t displayed to the user. - var orderIdentifier: String { get } - - /// A URL where the customer can manage the order. - var orderManagementURL: String { get } - - /// The type of order this bundle represents. - /// - /// Currently the only supported value is `ecommerce`. - var orderType: OrderType { get } - - /// An identifier for the order type associated with the order. - /// - /// The value must correspond with your signing certificate and isn’t displayed to the user. - var orderTypeIdentifier: String { get } - - /// A high-level status of the order, used for display purposes. - /// - /// The system considers orders with status `completed` or `cancelled` closed. - var status: OrderStatus { get } - - /// The version of the schema used for the order. - /// - /// The current version is `1`. - var schemaVersion: SchemaVersion { get } - - /// The date and time when the order was last updated, in RFC 3339 format. - /// - /// This should equal the `createdAt` time, if the order hasn’t had any updates. - /// Must be monotonically increasing. - /// Consider using a hybrid logical clock if your web service can’t make that guarantee. - var updatedAt: String { get } - } -} - -extension OrderJSON { - /// A protocol that represents the merchant associated with the order. - /// - /// > Tip: See the [`Order.Merchant`](https://developer.apple.com/documentation/walletorders/merchant) object to understand the keys. - public protocol Merchant: Encodable { - /// The localized display name of the merchant. - var displayName: String { get } - - /// The Apple Merchant Identifier for this merchant, generated at `developer.apple.com`. - var merchantIdentifier: String { get } - - /// The URL for the merchant’s website or landing page. - var url: String { get } - } -} - -extension OrderJSON { - /// A protocol that represents the details of a barcode for an order. - /// - /// > Tip: See the [`Order.Barcode`](https://developer.apple.com/documentation/walletorders/barcode) object to understand the keys. - public protocol Barcode: Encodable { - /// The format of the barcode. - var format: BarcodeFormat { get } - - /// The contents of the barcode. - var message: String { get } - - /// The text encoding of the barcode message. - /// - /// Typically this is `iso-8859-1`, but you may specify an alternative encoding if required. - var messageEncoding: String { get } - } -} - -extension OrderJSON { - /// The type of order this bundle represents. - public enum OrderType: String, Encodable { - case ecommerce - } - - /// A high-level status of the order, used for display purposes. - public enum OrderStatus: String, Encodable { - case completed - case cancelled - case open - } - - /// The version of the schema used for the order. - public enum SchemaVersion: Int, Encodable { - case v1 = 1 - } - - /// The format of the barcode. - public enum BarcodeFormat: String, Encodable { - case pdf417 - case qr - case aztec - case code128 - } -} diff --git a/Sources/Orders/DTOs/OrdersForDeviceDTO.swift b/Sources/Orders/DTOs/OrdersForDeviceDTO.swift deleted file mode 100644 index 4891d2e..0000000 --- a/Sources/Orders/DTOs/OrdersForDeviceDTO.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Vapor - -struct OrdersForDeviceDTO: Content { - let orderIdentifiers: [String] - let lastModified: String - - init(with orderIdentifiers: [String], maxDate: Date) { - self.orderIdentifiers = orderIdentifiers - self.lastModified = String(maxDate.timeIntervalSince1970) - } -} diff --git a/Sources/Orders/Models/Concrete Models/Order.swift b/Sources/Orders/Models/Concrete Models/Order.swift deleted file mode 100644 index 529e48a..0000000 --- a/Sources/Orders/Models/Concrete Models/Order.swift +++ /dev/null @@ -1,63 +0,0 @@ -import FluentKit -import Foundation - -/// The `Model` that stores Wallet orders. -final public class Order: OrderModel, @unchecked Sendable { - /// The schema name of the order model. - public static let schema = Order.FieldKeys.schemaName - - /// A unique order identifier scoped to your order type identifier. - /// - /// In combination with the order type identifier, this uniquely identifies an order within the system and isn’t displayed to the user. - @ID - public var id: UUID? - - /// The date and time when the customer created the order. - @Timestamp(key: Order.FieldKeys.createdAt, on: .create) - public var createdAt: Date? - - /// The date and time when the order was last updated. - @Timestamp(key: Order.FieldKeys.updatedAt, on: .update) - public var updatedAt: Date? - - /// An identifier for the order type associated with the order. - @Field(key: Order.FieldKeys.typeIdentifier) - public var typeIdentifier: String - - /// The authentication token supplied to your web service. - @Field(key: Order.FieldKeys.authenticationToken) - public var authenticationToken: String - - public required init() {} - - public required init(typeIdentifier: String, authenticationToken: String) { - self.typeIdentifier = typeIdentifier - 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.createdAt, .datetime, .required) - .field(Order.FieldKeys.updatedAt, .datetime, .required) - .field(Order.FieldKeys.typeIdentifier, .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 createdAt = FieldKey(stringLiteral: "created_at") - static let updatedAt = FieldKey(stringLiteral: "updated_at") - static let typeIdentifier = FieldKey(stringLiteral: "type_identifier") - static let authenticationToken = FieldKey(stringLiteral: "authentication_token") - } -} diff --git a/Sources/Orders/Models/Concrete Models/OrdersDevice.swift b/Sources/Orders/Models/Concrete Models/OrdersDevice.swift deleted file mode 100644 index 046b293..0000000 --- a/Sources/Orders/Models/Concrete Models/OrdersDevice.swift +++ /dev/null @@ -1,49 +0,0 @@ -import FluentKit -import PassKit - -/// The `Model` that stores Wallet orders devices. -final public class OrdersDevice: DeviceModel, @unchecked Sendable { - /// The schema name of the orders device model. - public static let schema = OrdersDevice.FieldKeys.schemaName - - @ID(custom: .id) - public var id: Int? - - /// The push token used for sending updates to the device. - @Field(key: OrdersDevice.FieldKeys.pushToken) - public var pushToken: String - - /// The identifier Apple Wallet provides for the device. - @Field(key: OrdersDevice.FieldKeys.libraryIdentifier) - public var libraryIdentifier: String - - public init(libraryIdentifier: String, pushToken: String) { - self.libraryIdentifier = libraryIdentifier - 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.libraryIdentifier, .string, .required) - .unique(on: OrdersDevice.FieldKeys.pushToken, OrdersDevice.FieldKeys.libraryIdentifier) - .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 libraryIdentifier = FieldKey(stringLiteral: "library_identifier") - } -} diff --git a/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift b/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift deleted file mode 100644 index 3fd5571..0000000 --- a/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift +++ /dev/null @@ -1,49 +0,0 @@ -import FluentKit -import PassKit - -import struct Foundation.Date - -/// The `Model` that stores Wallet orders error logs. -final public class OrdersErrorLog: ErrorLogModel, @unchecked Sendable { - /// The schema name of the error log model. - public static let schema = OrdersErrorLog.FieldKeys.schemaName - - @ID(custom: .id) - public var id: Int? - - /// The date and time the error log was created. - @Timestamp(key: OrdersErrorLog.FieldKeys.createdAt, on: .create) - public var createdAt: Date? - - /// The error message provided by Apple Wallet. - @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") - } -} diff --git a/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift b/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift deleted file mode 100644 index 6b17af5..0000000 --- a/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift +++ /dev/null @@ -1,51 +0,0 @@ -import FluentKit - -/// The `Model` that stores orders registrations. -final public class OrdersRegistration: OrdersRegistrationModel, @unchecked Sendable { - public typealias OrderType = Order - public typealias DeviceType = OrdersDevice - - /// The schema name of the orders registration model. - public static let schema = OrdersRegistration.FieldKeys.schemaName - - @ID(custom: .id) - public var id: Int? - - /// The device for this registration. - @Parent(key: OrdersRegistration.FieldKeys.deviceID) - public var device: DeviceType - - /// The order for this registration. - @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") - } -} diff --git a/Sources/Orders/Models/OrderDataModel.swift b/Sources/Orders/Models/OrderDataModel.swift deleted file mode 100644 index 6558fc0..0000000 --- a/Sources/Orders/Models/OrderDataModel.swift +++ /dev/null @@ -1,48 +0,0 @@ -import FluentKit - -/// Represents the `Model` that stores custom app data associated to Wallet orders. -public protocol OrderDataModel: Model { - associatedtype OrderType: OrderModel - - /// An identifier for the order type associated with the order. - static var typeIdentifier: String { get } - - /// The foreign key to the order table. - var order: OrderType { get set } - - /// Encode the order into JSON. - /// - /// This method should generate the entire order JSON. - /// - /// - Parameter db: The SQL database to query against. - /// - /// - Returns: An object that conforms to ``OrderJSON/Properties``. - /// - /// > Tip: See the [`Order`](https://developer.apple.com/documentation/walletorders/order) object to understand the keys. - func orderJSON(on db: any Database) async throws -> any OrderJSON.Properties - - /// Should return a URL path which points to the template data for the order. - /// - /// The path 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` - /// - /// - Parameter db: The SQL database to query against. - /// - /// - Returns: A URL path which points to the template data for the order. - func template(on db: any Database) async throws -> String -} - -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 - } -} diff --git a/Sources/Orders/Models/OrderModel.swift b/Sources/Orders/Models/OrderModel.swift deleted file mode 100644 index 803cb94..0000000 --- a/Sources/Orders/Models/OrderModel.swift +++ /dev/null @@ -1,67 +0,0 @@ -import FluentKit -import Foundation - -/// 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 { - /// An identifier for the order type associated with the order. - var typeIdentifier: String { get set } - - /// The date and time when the customer created the order. - var createdAt: Date? { get set } - - /// The date and time when the order was last updated. - var updatedAt: Date? { get set } - - /// The authentication token supplied to your web service. - var authenticationToken: String { get set } - - /// The designated initializer. - /// - Parameters: - /// - typeIdentifier: The order type identifier that’s registered with Apple. - /// - authenticationToken: The authentication token to use with the web service in the `webServiceURL` key. - init(typeIdentifier: String, authenticationToken: String) -} - -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 _$typeIdentifier: Field { - guard let mirror = Mirror(reflecting: self).descendant("_typeIdentifier"), - let typeIdentifier = mirror as? Field - else { - fatalError("typeIdentifier property must be declared using @Field") - } - - return typeIdentifier - } - - 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 - } -} diff --git a/Sources/Orders/Models/OrdersRegistrationModel.swift b/Sources/Orders/Models/OrdersRegistrationModel.swift deleted file mode 100644 index 89ed421..0000000 --- a/Sources/Orders/Models/OrdersRegistrationModel.swift +++ /dev/null @@ -1,46 +0,0 @@ -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 } -} - -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, typeIdentifier: String, on db: any Database) -> QueryBuilder { - Self.query(on: db) - .join(parent: \._$order) - .join(parent: \._$device) - .with(\._$order) - .with(\._$device) - .filter(OrderType.self, \._$typeIdentifier == typeIdentifier) - .filter(DeviceType.self, \._$libraryIdentifier == deviceLibraryIdentifier) - } -} diff --git a/Sources/Orders/Orders.docc/Extensions/OrderJSON.md b/Sources/Orders/Orders.docc/Extensions/OrderJSON.md deleted file mode 100644 index 9dbc792..0000000 --- a/Sources/Orders/Orders.docc/Extensions/OrderJSON.md +++ /dev/null @@ -1,19 +0,0 @@ -# ``Orders/OrderJSON`` - -## Topics - -### Essentials - -- ``Properties`` -- ``SchemaVersion`` -- ``OrderType`` -- ``OrderStatus`` - -### Merchants - -- ``Merchant`` - -### Barcode - -- ``Barcode`` -- ``BarcodeFormat`` diff --git a/Sources/Orders/Orders.docc/GettingStarted.md b/Sources/Orders/Orders.docc/GettingStarted.md deleted file mode 100644 index e015b74..0000000 --- a/Sources/Orders/Orders.docc/GettingStarted.md +++ /dev/null @@ -1,256 +0,0 @@ -# Getting Started with Orders - -Create the order data model, build an order for Apple Wallet and distribute it with a Vapor server. - -## Overview - -The Orders framework provides models to save all the basic information for orders, user devices and their registration to each order. -For all the other custom data needed to generate the order, such as the barcodes, merchant info, etc., you have to create your own model and its model middleware to handle the creation and update of order. -The order data model will be used to generate the `order.json` file contents. - -The order you distribute to a user is a signed bundle that contains the `order.json` file, images, and optional localizations. -The Orders framework provides the ``OrdersService`` class that handles the creation of the order JSON file and the signing of the order bundle. -The ``OrdersService`` class also provides methods to send push notifications to all devices registered when you update an order, and all the routes that Apple Wallet uses to retrieve orders. - -### Implement the Order Data Model - -Your data model should contain all the fields that you store for your order, as well as a foreign key to ``Order``, the order model offered by the Orders framework, and a order type identifier that's registered with Apple. - -```swift -import Fluent -import Foundation -import Orders - -final class OrderData: OrderDataModel, @unchecked Sendable { - static let schema = "order_data" - - static let typeIdentifier = Environment.get("ORDER_TYPE_IDENTIFIER")! - - @ID - var id: UUID? - - @Parent(key: "order_id") - var order: Order - - // Example of other extra fields: - @Field(key: "merchant_name") - var merchantName: String - - // Add any other field relative to your app, such as an identifier, the order status, etc. - - init() { } -} - -struct CreateOrderData: AsyncMigration { - public func prepare(on database: Database) async throws { - try await database.schema(OrderData.schema) - .id() - .field("order_id", .uuid, .required, .references(Order.schema, .id, onDelete: .cascade)) - .field("merchant_name", .string, .required) - .create() - } - - public func revert(on database: Database) async throws { - try await database.schema(OrderData.schema).delete() - } -} -``` - -You also have to define two methods in the ``OrderDataModel``: -- ``OrderDataModel/orderJSON(on:)``, where you'll have to return a `struct` that conforms to ``OrderJSON/Properties``. -- ``OrderDataModel/template(on:)``, where you'll have to return the path to a folder containing the order files. - -```swift -extension OrderData { - func orderJSON(on db: any Database) async throws -> any OrderJSON.Properties { - try await OrderJSONData(data: self, order: self.$order.get(on: db)) - } - - func template(on db: any Database) async throws -> String { - // The location might vary depending on the type of order. - "Templates/Orders/" - } -} -``` - -### Handle Cleanup - -Depending on your implementation details, you may want to automatically clean out the orders and devices table when a registration is deleted. -The implementation will be based on your type of SQL database, as there's not yet a Fluent way to implement something like SQL's `NOT EXISTS` call with a `DELETE` statement. - -> Warning: Be careful with SQL triggers, as they can have unintended consequences if not properly implemented. - -### Model the order.json contents - -Create a `struct` that implements ``OrderJSON/Properties`` which will contain all the fields for the generated `order.json` file. -Create an initializer that takes your custom order data, the ``Order`` and everything else you may need. - -> Tip: For information on the various keys available see the [documentation](https://developer.apple.com/documentation/walletorders/order). - -```swift -import Orders - -struct OrderJSONData: OrderJSON.Properties { - let schemaVersion = OrderJSON.SchemaVersion.v1 - let orderTypeIdentifier = OrderData.typeIdentifier - let orderIdentifier: String - let orderType = OrderJSON.OrderType.ecommerce - let orderNumber = "HM090772020864" - let createdAt: String - let updatedAt: String - let status = OrderJSON.OrderStatus.open - let merchant: MerchantData - let orderManagementURL = "https://www.example.com/" - let authenticationToken: String - - private let webServiceURL = "https://example.com/api/orders/" - - struct MerchantData: OrderJSON.Merchant { - let merchantIdentifier = "com.example.pet-store" - let displayName: String - let url = "https://www.example.com/" - let logo = "pet_store_logo.png" - } - - init(data: OrderData, order: Order) { - self.orderIdentifier = order.id!.uuidString - self.authenticationToken = order.authenticationToken - self.merchant = MerchantData(displayName: data.title) - - let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = .withInternetDateTime - self.createdAt = dateFormatter.string(from: order.createdAt!) - self.updatedAt = dateFormatter.string(from: order.updatedAt!) - } -} -``` - -> Important: You **must** add `api/orders/` to your `webServiceURL`, as shown in the example above. - -### Initialize the Service - -Next, initialize the ``OrdersService`` inside the `configure.swift` file. -This will implement all of the routes that Apple Wallet expects to exist on your server. - -> 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). Those guides are for Wallet passes, but the process is similar for Wallet orders. - -```swift -import Fluent -import Vapor -import Orders - -public func configure(_ app: Application) async throws { - ... - let ordersService = try OrdersService( - app: app, - pemWWDRCertificate: Environment.get("PEM_WWDR_CERTIFICATE")!, - pemCertificate: Environment.get("PEM_CERTIFICATE")!, - pemPrivateKey: Environment.get("PEM_PRIVATE_KEY")! - ) -} -``` - -If you wish to include routes specifically for sending push notifications to updated orders, you can also pass to the ``OrdersService`` initializer whatever `Middleware` you want Vapor to use to authenticate the two routes. Doing so will add two routes, the first one sends notifications and the second one retrieves a list of push tokens which would be sent a notification. - -```http -POST https://example.com/api/orders/v1/push/{orderTypeIdentifier}/{orderIdentifier} HTTP/2 -``` - -```http -GET https://example.com/api/orders/v1/push/{orderTypeIdentifier}/{orderIdentifier} HTTP/2 -``` - -### Custom Implementation of OrdersService - -If you don't like the schema names provided by default, you can create your own models conforming to ``OrderModel``, `DeviceModel`, ``OrdersRegistrationModel`` and `ErrorLogModel` and instantiate the generic ``OrdersServiceCustom``, providing it your model types. - -```swift -import Fluent -import Vapor -import PassKit -import Orders - -public func configure(_ app: Application) async throws { - ... - let ordersService = try OrdersServiceCustom< - OrderData, - MyOrderType, - MyDeviceType, - MyOrdersRegistrationType, - MyErrorLogType - >( - app: app, - pemWWDRCertificate: Environment.get("PEM_WWDR_CERTIFICATE")!, - pemCertificate: Environment.get("PEM_CERTIFICATE")!, - pemPrivateKey: Environment.get("PEM_PRIVATE_KEY")! - ) -} -``` - -### Register Migrations - -If you're using the default schemas provided by this framework, you can register the default models in your `configure(_:)` method: - -```swift -OrdersService.register(migrations: app.migrations) -``` - -> Important: Register the default models before the migration of your order data model. - -### Order Data Model Middleware - -This framework provides a model middleware to handle the creation and update of the order data model. - -When you create an ``OrderDataModel`` object, it will automatically create an ``OrderModel`` object with a random auth token and the correct type identifier and link it to the order data model. -When you update an order data model, it will update the ``OrderModel`` object and send a push notification to all devices registered to that order. - -You can register it like so (either with an ``OrdersService`` or an ``OrdersServiceCustom``): - -```swift -app.databases.middleware.use(ordersService, on: .psql) -``` - -> Note: If you don't like the default implementation of the model middleware, it is highly recommended that you create your own. But remember: whenever your order data changes, you must update the ``Order/updatedAt`` time of the linked ``Order`` so that Wallet knows to retrieve a new order. - -### Generate the Order Content - -To generate and distribute the `.order` bundle, pass the ``OrdersService`` object to your `RouteCollection`. - -```swift -import Fluent -import Vapor -import Orders - -struct OrdersController: RouteCollection { - let ordersService: OrdersService - - func boot(routes: RoutesBuilder) throws { - ... - } -} -``` - -> Note: You'll have to register the `OrdersController` in the `configure.swift` file, in order to pass it the ``OrdersService`` object. - -Then use the object inside your route handlers to generate the order bundle with the ``OrdersService/build(order:on:)`` method and distribute it with the "`application/vnd.apple.order`" MIME type. - -```swift -fileprivate func orderHandler(_ req: Request) async throws -> Response { - ... - guard let order = try await OrderData.query(on: req.db) - .filter(...) - .first() - else { - throw Abort(.notFound) - } - - let bundle = try await ordersService.build(order: order, on: req.db) - let body = Response.Body(data: bundle) - var headers = HTTPHeaders() - headers.add(name: .contentType, value: "application/vnd.apple.order") - headers.add(name: .contentDisposition, value: "attachment; filename=name.order") - headers.lastModified = HTTPHeaders.LastModified(order.updatedAt ?? Date.distantPast) - headers.add(name: .contentTransferEncoding, value: "binary") - return Response(status: .ok, headers: headers, body: body) -} -``` diff --git a/Sources/Orders/Orders.docc/Orders.md b/Sources/Orders/Orders.docc/Orders.md deleted file mode 100644 index 60d6d52..0000000 --- a/Sources/Orders/Orders.docc/Orders.md +++ /dev/null @@ -1,35 +0,0 @@ -# ``Orders`` - -Create, distribute, and update orders in Apple Wallet with Vapor. - -## Overview - -The Orders framework provides a set of tools to help you create, build, and distribute orders trackable in the Apple Wallet app using a Vapor server. -It also provides a way to update orders after they have been distributed, using APNs, and models to store order and device data. - -For information on Apple Wallet orders, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletorders). - -## Topics - -### Essentials - -- -- ``OrderDataModel`` -- ``OrderJSON`` - -### Building and Distribution - -- ``OrdersService`` -- ``OrdersServiceCustom`` - -### Concrete Models - -- ``Order`` -- ``OrdersRegistration`` -- ``OrdersDevice`` -- ``OrdersErrorLog`` - -### Abstract Models - -- ``OrderModel`` -- ``OrdersRegistrationModel`` diff --git a/Sources/PassKit/DTOs/ErrorLogDTO.swift b/Sources/PassKit/DTOs/ErrorLogDTO.swift deleted file mode 100644 index 8c7e6c7..0000000 --- a/Sources/PassKit/DTOs/ErrorLogDTO.swift +++ /dev/null @@ -1,37 +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 - -package struct ErrorLogDTO: Content { - package let logs: [String] - - package init(logs: [String]) { - self.logs = logs - } -} diff --git a/Sources/PassKit/DTOs/RegistrationDTO.swift b/Sources/PassKit/DTOs/RegistrationDTO.swift deleted file mode 100644 index a095521..0000000 --- a/Sources/PassKit/DTOs/RegistrationDTO.swift +++ /dev/null @@ -1,37 +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 - -package struct RegistrationDTO: Content { - package let pushToken: String - - package init(pushToken: String) { - self.pushToken = pushToken - } -} diff --git a/Sources/PassKit/Models/DeviceModel.swift b/Sources/PassKit/Models/DeviceModel.swift deleted file mode 100644 index 23c13ce..0000000 --- a/Sources/PassKit/Models/DeviceModel.swift +++ /dev/null @@ -1,66 +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 FluentKit - -/// Represents the `Model` that stores Apple Wallet devices. -public protocol DeviceModel: Model where IDValue == Int { - /// The push token used for sending updates to the device. - var pushToken: String { get set } - - /// The identifier Apple Wallet provides for the device. - var libraryIdentifier: String { get set } - - /// The designated initializer. - /// - Parameters: - /// - libraryIdentifier: The device identifier as provided during registration. - /// - pushToken: The push token to use when sending updates via push notifications. - init(libraryIdentifier: String, pushToken: String) -} - -extension DeviceModel { - package var _$pushToken: Field { - guard let mirror = Mirror(reflecting: self).descendant("_pushToken"), - let pushToken = mirror as? Field - else { - fatalError("pushToken property must be declared using @Field") - } - - return pushToken - } - - package var _$libraryIdentifier: Field { - guard let mirror = Mirror(reflecting: self).descendant("_libraryIdentifier"), - let libraryIdentifier = mirror as? Field - else { - fatalError("libraryIdentifier property must be declared using @Field") - } - - return libraryIdentifier - } -} diff --git a/Sources/PassKit/Models/ErrorLogModel.swift b/Sources/PassKit/Models/ErrorLogModel.swift deleted file mode 100644 index 3153c64..0000000 --- a/Sources/PassKit/Models/ErrorLogModel.swift +++ /dev/null @@ -1,51 +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 FluentKit - -/// Represents the `Model` that stores Apple Wallet error logs. -public protocol ErrorLogModel: Model { - /// The error message provided by Apple Wallet. - var message: String { get set } - - /// The designated initializer. - /// - Parameter message: The error message. - init(message: String) -} - -extension ErrorLogModel { - package var _$message: Field { - guard let mirror = Mirror(reflecting: self).descendant("_message"), - let message = mirror as? Field - else { - fatalError("id property must be declared using @ID") - } - - return message - } -} diff --git a/Sources/PassKit/WalletError.swift b/Sources/PassKit/WalletError.swift deleted file mode 100644 index db48d6e..0000000 --- a/Sources/PassKit/WalletError.swift +++ /dev/null @@ -1,69 +0,0 @@ -/// Errors that can be thrown by Apple Wallet passes and orders. -public struct WalletError: Error, Sendable, Equatable { - /// The type of the errors that can be thrown by Apple Wallet passes and orders. - public struct ErrorType: Sendable, Hashable, CustomStringConvertible, Equatable { - enum Base: String, Sendable, Equatable { - case noSourceFiles - case noOpenSSLExecutable - case invalidNumberOfPasses - } - - let base: Base - - private init(_ base: Base) { - self.base = base - } - - /// The path for the source files is not a directory. - public static let noSourceFiles = Self(.noSourceFiles) - /// The `openssl` executable is missing. - public static let noOpenSSLExecutable = Self(.noOpenSSLExecutable) - /// The number of passes to bundle is invalid. - public static let invalidNumberOfPasses = Self(.invalidNumberOfPasses) - - /// A textual representation of this error. - public var description: String { - base.rawValue - } - } - - private struct Backing: Sendable, Equatable { - fileprivate let errorType: ErrorType - - init(errorType: ErrorType) { - self.errorType = errorType - } - - static func == (lhs: WalletError.Backing, rhs: WalletError.Backing) -> Bool { - lhs.errorType == rhs.errorType - } - } - - private var backing: Backing - - /// The type of this error. - public var errorType: ErrorType { backing.errorType } - - private init(errorType: ErrorType) { - self.backing = .init(errorType: errorType) - } - - /// The path for the source files is not a directory. - public static let noSourceFiles = Self(errorType: .noSourceFiles) - - /// The `openssl` executable is missing. - public static let noOpenSSLExecutable = Self(errorType: .noOpenSSLExecutable) - - /// The number of passes to bundle is invalid. - public static let invalidNumberOfPasses = Self(errorType: .invalidNumberOfPasses) - - public static func == (lhs: WalletError, rhs: WalletError) -> Bool { - lhs.backing == rhs.backing - } -} - -extension WalletError: CustomStringConvertible { - public var description: String { - "WalletError(errorType: \(self.errorType))" - } -} diff --git a/Sources/Passes/DTOs/PassJSON.swift b/Sources/Passes/DTOs/PassJSON.swift deleted file mode 100644 index 509fa89..0000000 --- a/Sources/Passes/DTOs/PassJSON.swift +++ /dev/null @@ -1,136 +0,0 @@ -/// The structure of a `pass.json` file. -public struct PassJSON { - /// 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 Properties: Encodable { - /// A short description that iOS accessibility technologies use for a pass. - var description: String { get } - - /// The version of the file format. - /// - /// The value must be `1`. - var formatVersion: FormatVersion { get } - - /// The name of the organization. - 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 } - - /// An alphanumeric serial number. - /// - /// The combination of the serial number and pass type identifier must be unique for each pass. - var serialNumber: String { get } - - /// The Team ID for the Apple Developer Program account that registered the pass type identifier. - var teamIdentifier: String { get } - } -} - -extension PassJSON { - /// 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 } - - /// The value to use for the field; for example, 42. - /// - /// A date or time value must include a time zone. - var value: String { get } - } -} - -extension PassJSON { - /// 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 } - } -} - -extension PassJSON { - /// 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 } - - /// The message or payload to display as a barcode. - 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 } - } -} - -extension PassJSON { - /// A protocol that represents a location that the system uses to show a relevant pass. - /// - /// > Tip: See the [`Pass.Locations`](https://developer.apple.com/documentation/walletpasses/pass/locations) object to understand the keys. - public protocol Locations: Encodable { - /// The latitude, in degrees, of the location. - var latitude: Double { get } - - /// (Required) - var longitude: Double { get } - } -} - -extension PassJSON { - /// An object that represents the near-field communication (NFC) payload the device passes to an Apple Pay terminal. - /// - /// > Tip: See the [`Pass.NFC`](https://developer.apple.com/documentation/walletpasses/pass/nfc) object to understand the keys. - public protocol NFC: Encodable { - /// The payload the device transmits to the Apple Pay terminal. - /// - /// The size must be no more than 64 bytes. - /// The system truncates messages longer than 64 bytes. - var message: String { get } - - /// The public encryption key the Value Added Services protocol uses. - /// - /// Use a Base64-encoded X.509 SubjectPublicKeyInfo structure that contains an ECDH public key for group P256. - var encryptionPublicKey: String { get } - } -} - -extension PassJSON { - /// The version of the file format. - public enum FormatVersion: Int, Encodable { - /// The value must be `1`. - case v1 = 1 - } - - /// The type of transit for a boarding pass. - public enum TransitType: String, Encodable { - case air = "PKTransitTypeAir" - case boat = "PKTransitTypeBoat" - case bus = "PKTransitTypeBus" - case generic = "PKTransitTypeGeneric" - case train = "PKTransitTypeTrain" - } - - /// The format of the barcode. - public enum BarcodeFormat: String, Encodable { - case pdf417 = "PKBarcodeFormatPDF417" - case qr = "PKBarcodeFormatQR" - case aztec = "PKBarcodeFormatAztec" - case code128 = "PKBarcodeFormatCode128" - } -} diff --git a/Sources/Passes/DTOs/PassesForDeviceDTO.swift b/Sources/Passes/DTOs/PassesForDeviceDTO.swift deleted file mode 100644 index 9ba58e2..0000000 --- a/Sources/Passes/DTOs/PassesForDeviceDTO.swift +++ /dev/null @@ -1,39 +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 - -struct PassesForDeviceDTO: Content { - let lastUpdated: String - let serialNumbers: [String] - - init(with serialNumbers: [String], maxDate: Date) { - lastUpdated = String(maxDate.timeIntervalSince1970) - self.serialNumbers = serialNumbers - } -} diff --git a/Sources/Passes/DTOs/PersonalizationDictionaryDTO.swift b/Sources/Passes/DTOs/PersonalizationDictionaryDTO.swift deleted file mode 100644 index 37b6302..0000000 --- a/Sources/Passes/DTOs/PersonalizationDictionaryDTO.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Vapor - -struct PersonalizationDictionaryDTO: Content { - let personalizationToken: String - let requiredPersonalizationInfo: RequiredPersonalizationInfo - - struct RequiredPersonalizationInfo: Content { - let emailAddress: String? - let familyName: String? - let fullName: String? - let givenName: String? - let isoCountryCode: String? - let phoneNumber: String? - let postalCode: String? - - enum CodingKeys: String, CodingKey { - case emailAddress - case familyName - case fullName - case givenName - case isoCountryCode = "ISOCountryCode" - case phoneNumber - case postalCode - } - } -} diff --git a/Sources/Passes/DTOs/PersonalizationJSON.swift b/Sources/Passes/DTOs/PersonalizationJSON.swift deleted file mode 100644 index 17d527a..0000000 --- a/Sources/Passes/DTOs/PersonalizationJSON.swift +++ /dev/null @@ -1,66 +0,0 @@ -/// The structure of a `personalization.json` file. -/// -/// This file specifies the personal information requested by the signup form. -/// It also contains a description of the program and (optionally) the program’s terms and conditions. -/// -/// > Tip: See the [documentation](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/PassPersonalization.html#//apple_ref/doc/uid/TP40012195-CH12-SW2) to understand the keys. -public struct PersonalizationJSON: Codable, Sendable { - /// The contents of this array define the data requested from the user. - /// - /// The signup form’s fields are generated based on these keys. - let requiredPersonalizationFields: [PersonalizationField] - - /// A brief description of the program. - /// - /// This is displayed on the signup sheet, under the personalization logo. - let description: String - - /// A description of the program’s terms and conditions. - /// - /// This string can contain HTML link tags to external content. - /// - /// If present, this information is displayed after the user enters their personal information and taps the Next button. - /// The user then has the option to agree to the terms, or to cancel out of the signup process. - let termsAndConditions: String? - - /// Initializes a new ``PersonalizationJSON`` instance. - /// - /// - Parameters: - /// - requiredPersonalizationFields: An array of ``PersonalizationField`` values that define the data requested to the user. - /// - description: A brief description of the program. - /// - termsAndConditions: A description of the program’s terms and conditions. - public init( - requiredPersonalizationFields: [PersonalizationField], - description: String, - termsAndConditions: String? = nil - ) { - self.requiredPersonalizationFields = requiredPersonalizationFields - self.description = description - self.termsAndConditions = termsAndConditions - } -} - -extension PersonalizationJSON { - /// Personal information requested by the signup form. - public enum PersonalizationField: String, Codable, Sendable { - /// Prompts the user for their name. - /// - /// `fullName`, `givenName`, and `familyName` are submitted in the personalize request. - case name = "PKPassPersonalizationFieldName" - - /// Prompts the user for their postal code. - /// - /// `postalCode` and `ISOCountryCode` are submitted in the personalize request. - case postalCode = "PKPassPersonalizationFieldPostalCode" - - /// Prompts the user for their email address. - /// - /// `emailAddress` is submitted in the personalize request. - case emailAddress = "PKPassPersonalizationFieldEmailAddress" - - /// Prompts the user for their phone number. - /// - /// `phoneNumber` is submitted in the personalize request. - case phoneNumber = "PKPassPersonalizationFieldPhoneNumber" - } -} diff --git a/Sources/Passes/Models/Concrete Models/Pass.swift b/Sources/Passes/Models/Concrete Models/Pass.swift deleted file mode 100644 index 33868c3..0000000 --- a/Sources/Passes/Models/Concrete Models/Pass.swift +++ /dev/null @@ -1,72 +0,0 @@ -import FluentKit -import Foundation - -/// The `Model` that stores Apple Wallet passes. -/// -/// Uses a UUID so people can't easily guess pass serial numbers. -final public class Pass: PassModel, @unchecked Sendable { - public typealias UserPersonalizationType = UserPersonalization - - /// The schema name of the pass model. - public static let schema = Pass.FieldKeys.schemaName - - /// The pass alphanumeric serial number. - /// - /// The combination of the serial number and pass type identifier must be unique for each pass. - /// Uses a UUID so people can't easily guess the pass serial number. - @ID - public var id: UUID? - - /// The last time the pass was modified. - @Timestamp(key: Pass.FieldKeys.updatedAt, on: .update) - public var updatedAt: Date? - - /// The pass type identifier that’s registered with Apple. - @Field(key: Pass.FieldKeys.typeIdentifier) - public var typeIdentifier: String - - /// The authentication token to use with the web service in the `webServiceURL` key. - @Field(key: Pass.FieldKeys.authenticationToken) - public var authenticationToken: String - - /// The user personalization info. - @OptionalParent(key: Pass.FieldKeys.userPersonalizationID) - public var userPersonalization: UserPersonalizationType? - - public required init() {} - - public required init(typeIdentifier: String, authenticationToken: String) { - self.typeIdentifier = typeIdentifier - self.authenticationToken = authenticationToken - } -} - -extension Pass: AsyncMigration { - public func prepare(on database: any Database) async throws { - try await database.schema(Self.schema) - .id() - .field(Pass.FieldKeys.updatedAt, .datetime, .required) - .field(Pass.FieldKeys.typeIdentifier, .string, .required) - .field(Pass.FieldKeys.authenticationToken, .string, .required) - .field( - Pass.FieldKeys.userPersonalizationID, .int, - .references(UserPersonalizationType.schema, .id) - ) - .unique(on: Pass.FieldKeys.userPersonalizationID) - .create() - } - - public func revert(on database: any Database) async throws { - try await database.schema(Self.schema).delete() - } -} - -extension Pass { - enum FieldKeys { - static let schemaName = "passes" - static let updatedAt = FieldKey(stringLiteral: "updated_at") - static let typeIdentifier = FieldKey(stringLiteral: "type_identifier") - static let authenticationToken = FieldKey(stringLiteral: "authentication_token") - static let userPersonalizationID = FieldKey(stringLiteral: "user_personalization_id") - } -} diff --git a/Sources/Passes/Models/Concrete Models/PassesDevice.swift b/Sources/Passes/Models/Concrete Models/PassesDevice.swift deleted file mode 100644 index a6eb292..0000000 --- a/Sources/Passes/Models/Concrete Models/PassesDevice.swift +++ /dev/null @@ -1,49 +0,0 @@ -import FluentKit -import PassKit - -/// The `Model` that stores Apple Wallet passes devices. -final public class PassesDevice: DeviceModel, @unchecked Sendable { - /// The schema name of the device model. - public static let schema = PassesDevice.FieldKeys.schemaName - - @ID(custom: .id) - public var id: Int? - - /// The push token used for sending updates to the device. - @Field(key: PassesDevice.FieldKeys.pushToken) - public var pushToken: String - - /// The identifier Apple Wallet provides for the device. - @Field(key: PassesDevice.FieldKeys.libraryIdentifier) - public var libraryIdentifier: String - - public init(libraryIdentifier: String, pushToken: String) { - self.libraryIdentifier = libraryIdentifier - 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.libraryIdentifier, .string, .required) - .unique(on: PassesDevice.FieldKeys.pushToken, PassesDevice.FieldKeys.libraryIdentifier) - .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 libraryIdentifier = FieldKey(stringLiteral: "library_identifier") - } -} diff --git a/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift b/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift deleted file mode 100644 index 11d6102..0000000 --- a/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift +++ /dev/null @@ -1,49 +0,0 @@ -import FluentKit -import PassKit - -import struct Foundation.Date - -/// The `Model` that stores Apple Wallet passes error logs. -final public class PassesErrorLog: ErrorLogModel, @unchecked Sendable { - /// The schema name of the error log model. - public static let schema = PassesErrorLog.FieldKeys.schemaName - - @ID(custom: .id) - public var id: Int? - - /// The date and time the error log was created. - @Timestamp(key: PassesErrorLog.FieldKeys.createdAt, on: .create) - public var createdAt: Date? - - /// The error message provided by Apple Wallet. - @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") - } -} diff --git a/Sources/Passes/Models/Concrete Models/PassesRegistration.swift b/Sources/Passes/Models/Concrete Models/PassesRegistration.swift deleted file mode 100644 index 52ab07d..0000000 --- a/Sources/Passes/Models/Concrete Models/PassesRegistration.swift +++ /dev/null @@ -1,51 +0,0 @@ -import FluentKit - -/// The `Model` that stores passes registrations. -final public class PassesRegistration: PassesRegistrationModel, @unchecked Sendable { - public typealias PassType = Pass - public typealias DeviceType = PassesDevice - - /// The schema name of the passes registration model. - public static let schema = PassesRegistration.FieldKeys.schemaName - - @ID(custom: .id) - public var id: Int? - - /// The device for this registration. - @Parent(key: PassesRegistration.FieldKeys.deviceID) - public var device: DeviceType - - /// The pass for this registration. - @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/Concrete Models/UserPersonalization.swift b/Sources/Passes/Models/Concrete Models/UserPersonalization.swift deleted file mode 100644 index e2c1688..0000000 --- a/Sources/Passes/Models/Concrete Models/UserPersonalization.swift +++ /dev/null @@ -1,79 +0,0 @@ -import FluentKit - -/// The `Model` that stores user personalization info. -final public class UserPersonalization: UserPersonalizationModel, @unchecked Sendable { - /// The schema name of the user personalization model. - public static let schema = UserPersonalization.FieldKeys.schemaName - - @ID(custom: .id) - public var id: Int? - - /// The user’s full name, as entered by the user. - @OptionalField(key: UserPersonalization.FieldKeys.fullName) - public var fullName: String? - - /// The user’s given name, parsed from the full name. - /// - /// This is the name bestowed upon an individual to differentiate them from other members of a group that share a family name (for example, “John”). - /// In some locales, this is also known as a first name or forename. - @OptionalField(key: UserPersonalization.FieldKeys.givenName) - public var givenName: String? - - /// The user’s family name, parsed from the full name. - /// - /// This is the name bestowed upon an individual to denote membership in a group or family (for example, “Appleseed”). - @OptionalField(key: UserPersonalization.FieldKeys.familyName) - public var familyName: String? - - /// The email address, as entered by the user. - @OptionalField(key: UserPersonalization.FieldKeys.emailAddress) - public var emailAddress: String? - - /// The postal code, as entered by the user. - @OptionalField(key: UserPersonalization.FieldKeys.postalCode) - public var postalCode: String? - - /// The user’s ISO country code. - /// - /// This key is only included when the system can deduce the country code. - @OptionalField(key: UserPersonalization.FieldKeys.isoCountryCode) - public var isoCountryCode: String? - - /// The phone number, as entered by the user. - @OptionalField(key: UserPersonalization.FieldKeys.phoneNumber) - public var phoneNumber: String? - - public init() {} -} - -extension UserPersonalization: AsyncMigration { - public func prepare(on database: any Database) async throws { - try await database.schema(Self.schema) - .field(.id, .int, .identifier(auto: true)) - .field(UserPersonalization.FieldKeys.fullName, .string) - .field(UserPersonalization.FieldKeys.givenName, .string) - .field(UserPersonalization.FieldKeys.familyName, .string) - .field(UserPersonalization.FieldKeys.emailAddress, .string) - .field(UserPersonalization.FieldKeys.postalCode, .string) - .field(UserPersonalization.FieldKeys.isoCountryCode, .string) - .field(UserPersonalization.FieldKeys.phoneNumber, .string) - .create() - } - - public func revert(on database: any Database) async throws { - try await database.schema(Self.schema).delete() - } -} - -extension UserPersonalization { - enum FieldKeys { - static let schemaName = "user_personalization_info" - static let fullName = FieldKey(stringLiteral: "full_name") - static let givenName = FieldKey(stringLiteral: "given_name") - static let familyName = FieldKey(stringLiteral: "family_name") - static let emailAddress = FieldKey(stringLiteral: "email_address") - static let postalCode = FieldKey(stringLiteral: "postal_code") - static let isoCountryCode = FieldKey(stringLiteral: "iso_country_code") - static let phoneNumber = FieldKey(stringLiteral: "phone_number") - } -} diff --git a/Sources/Passes/Models/PassDataModel.swift b/Sources/Passes/Models/PassDataModel.swift deleted file mode 100644 index 47d1e0f..0000000 --- a/Sources/Passes/Models/PassDataModel.swift +++ /dev/null @@ -1,94 +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 FluentKit - -/// Represents the `Model` that stores custom app data associated to Apple Wallet passes. -public protocol PassDataModel: Model { - associatedtype PassType: PassModel - - /// The pass type identifier that’s registered with Apple. - static var typeIdentifier: String { get } - - /// The foreign key to the pass table. - var pass: PassType { get set } - - /// Encode the pass into JSON. - /// - /// This method should generate the entire pass JSON. - /// - /// - Parameter db: The SQL database to query against. - /// - /// - Returns: An object that conforms to ``PassJSON/Properties``. - /// - /// > Tip: See the [`Pass`](https://developer.apple.com/documentation/walletpasses/pass) object to understand the keys. - func passJSON(on db: any Database) async throws -> any PassJSON.Properties - - /// Should return a URL path which points to the template data for the pass. - /// - /// The path should point to a directory containing all the images and localizations for the generated `.pkpass` archive - /// but should *not* contain any of these items: - /// - `manifest.json` - /// - `pass.json` - /// - `personalization.json` - /// - `signature` - /// - /// - Parameter db: The SQL database to query against. - /// - /// - Returns: A URL path which points to the template data for the pass. - func template(on db: any Database) async throws -> String - - /// Create the personalization JSON struct. - /// - /// This method should generate the entire personalization JSON struct. - /// If the pass in question requires personalization, you should return a ``PersonalizationJSON``. - /// If the pass does not require personalization, you should return `nil`. - /// - /// The default implementation of this method returns `nil`. - /// - /// - Parameter db: The SQL database to query against. - /// - /// - Returns: A ``PersonalizationJSON`` or `nil` if the pass does not require personalization. - func personalizationJSON(on db: any Database) async throws -> PersonalizationJSON? -} - -extension PassDataModel { - var _$pass: Parent { - guard let mirror = Mirror(reflecting: self).descendant("_pass"), - let pass = mirror as? Parent - else { - fatalError("pass property must be declared using @Parent") - } - - return pass - } -} - -extension PassDataModel { - public func personalizationJSON(on db: any Database) async throws -> PersonalizationJSON? { nil } -} diff --git a/Sources/Passes/Models/PassModel.swift b/Sources/Passes/Models/PassModel.swift deleted file mode 100644 index a17c312..0000000 --- a/Sources/Passes/Models/PassModel.swift +++ /dev/null @@ -1,107 +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 FluentKit -import Foundation - -/// Represents the `Model` that stores Apple Wallet passes. -/// -/// Uses a UUID so people can't easily guess pass serial numbers. -public protocol PassModel: Model where IDValue == UUID { - associatedtype UserPersonalizationType: UserPersonalizationModel - - /// The pass type identifier that’s registered with Apple. - var typeIdentifier: String { get set } - - /// The last time the pass was modified. - var updatedAt: Date? { get set } - - /// The authentication token to use with the web service in the `webServiceURL` key. - var authenticationToken: String { get set } - - /// The user personalization info. - var userPersonalization: UserPersonalizationType? { get set } - - /// The designated initializer. - /// - Parameters: - /// - typeIdentifier: The pass type identifier that’s registered with Apple. - /// - authenticationToken: The authentication token to use with the web service in the `webServiceURL` key. - init(typeIdentifier: String, authenticationToken: String) -} - -extension PassModel { - 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 _$typeIdentifier: Field { - guard let mirror = Mirror(reflecting: self).descendant("_typeIdentifier"), - let typeIdentifier = mirror as? Field - else { - fatalError("typeIdentifier property must be declared using @Field") - } - - return typeIdentifier - } - - 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 - } - - var _$userPersonalization: OptionalParent { - guard let mirror = Mirror(reflecting: self).descendant("_userPersonalization"), - let userPersonalization = mirror as? OptionalParent - else { - fatalError("userPersonalization property must be declared using @OptionalParent") - } - - return userPersonalization - } -} diff --git a/Sources/Passes/Models/PassesRegistrationModel.swift b/Sources/Passes/Models/PassesRegistrationModel.swift deleted file mode 100644 index c23accc..0000000 --- a/Sources/Passes/Models/PassesRegistrationModel.swift +++ /dev/null @@ -1,74 +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 FluentKit -import PassKit - -/// Represents the `Model` that stores passes registrations. -public protocol PassesRegistrationModel: Model where IDValue == Int { - associatedtype PassType: PassModel - associatedtype DeviceType: DeviceModel - - /// The device for this registration. - var device: DeviceType { get set } - - /// The pass for this registration. - var pass: PassType { get set } -} - -extension PassesRegistrationModel { - 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 _$pass: Parent { - guard let mirror = Mirror(reflecting: self).descendant("_pass"), - let pass = mirror as? Parent - else { - fatalError("pass property must be declared using @Parent") - } - - return pass - } - - static func `for`(deviceLibraryIdentifier: String, typeIdentifier: String, on db: any Database) -> QueryBuilder { - Self.query(on: db) - .join(parent: \._$pass) - .join(parent: \._$device) - .with(\._$pass) - .with(\._$device) - .filter(PassType.self, \._$typeIdentifier == typeIdentifier) - .filter(DeviceType.self, \._$libraryIdentifier == deviceLibraryIdentifier) - } -} diff --git a/Sources/Passes/Models/UserPersonalizationModel.swift b/Sources/Passes/Models/UserPersonalizationModel.swift deleted file mode 100644 index 030001c..0000000 --- a/Sources/Passes/Models/UserPersonalizationModel.swift +++ /dev/null @@ -1,114 +0,0 @@ -import FluentKit - -/// Represents the `Model` that stores user personalization info. -public protocol UserPersonalizationModel: Model where IDValue == Int { - /// The user’s full name, as entered by the user. - var fullName: String? { get set } - - /// The user’s given name, parsed from the full name. - /// - /// This is the name bestowed upon an individual to differentiate them from other members of a group that share a family name (for example, “John”). - /// In some locales, this is also known as a first name or forename. - var givenName: String? { get set } - - /// The user’s family name, parsed from the full name. - /// - /// This is the name bestowed upon an individual to denote membership in a group or family (for example, “Appleseed”). - var familyName: String? { get set } - - /// The email address, as entered by the user. - var emailAddress: String? { get set } - - /// The postal code, as entered by the user. - var postalCode: String? { get set } - - /// The user’s ISO country code. - /// - /// This key is only included when the system can deduce the country code. - var isoCountryCode: String? { get set } - - /// The phone number, as entered by the user. - var phoneNumber: String? { get set } -} - -extension UserPersonalizationModel { - 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 _$fullName: OptionalField { - guard let mirror = Mirror(reflecting: self).descendant("_fullName"), - let fullName = mirror as? OptionalField - else { - fatalError("fullName property must be declared using @OptionalField") - } - - return fullName - } - - var _$givenName: OptionalField { - guard let mirror = Mirror(reflecting: self).descendant("_givenName"), - let givenName = mirror as? OptionalField - else { - fatalError("givenName property must be declared using @OptionalField") - } - - return givenName - } - - var _$familyName: OptionalField { - guard let mirror = Mirror(reflecting: self).descendant("_familyName"), - let familyName = mirror as? OptionalField - else { - fatalError("familyName property must be declared using @OptionalField") - } - - return familyName - } - - var _$emailAddress: OptionalField { - guard let mirror = Mirror(reflecting: self).descendant("_emailAddress"), - let emailAddress = mirror as? OptionalField - else { - fatalError("emailAddress property must be declared using @OptionalField") - } - - return emailAddress - } - - var _$postalCode: OptionalField { - guard let mirror = Mirror(reflecting: self).descendant("_postalCode"), - let postalCode = mirror as? OptionalField - else { - fatalError("postalCode property must be declared using @OptionalField") - } - - return postalCode - } - - var _$isoCountryCode: OptionalField { - guard let mirror = Mirror(reflecting: self).descendant("_isoCountryCode"), - let isoCountryCode = mirror as? OptionalField - else { - fatalError("isoCountryCode property must be declared using @OptionalField") - } - - return isoCountryCode - } - - var _$phoneNumber: OptionalField { - guard let mirror = Mirror(reflecting: self).descendant("_phoneNumber"), - let phoneNumber = mirror as? OptionalField - else { - fatalError("phoneNumber property must be declared using @OptionalField") - } - - return phoneNumber - } -} diff --git a/Sources/Passes/Passes.docc/Extensions/PassJSON.md b/Sources/Passes/Passes.docc/Extensions/PassJSON.md deleted file mode 100644 index 3788249..0000000 --- a/Sources/Passes/Passes.docc/Extensions/PassJSON.md +++ /dev/null @@ -1,24 +0,0 @@ -# ``Passes/PassJSON`` - -## Topics - -### Essentials - -- ``Properties`` -- ``FormatVersion`` -- ``PassFieldContent`` - -### Barcodes and NFC - -- ``Barcodes`` -- ``BarcodeFormat`` -- ``NFC`` - -### Boarding Passes - -- ``BoardingPass`` -- ``TransitType`` - -### Miscellaneous - -- ``Locations`` \ No newline at end of file diff --git a/Sources/Passes/Passes.docc/GettingStarted.md b/Sources/Passes/Passes.docc/GettingStarted.md deleted file mode 100644 index a788f88..0000000 --- a/Sources/Passes/Passes.docc/GettingStarted.md +++ /dev/null @@ -1,307 +0,0 @@ -# Getting Started with Passes - -Create the pass data model, build a pass for Apple Wallet and distribute it with a Vapor server. - -## Overview - -The Passes framework provides models to save all the basic information for passes, user devices and their registration to each pass. -For all the other custom data needed to generate the pass, such as the barcodes, locations, etc., you have to create your own model and its model middleware to handle the creation and update of passes. -The pass data model will be used to generate the `pass.json` file contents. - -The pass you distribute to a user is a signed bundle that contains the `pass.json` file, images and optional localizations. -The Passes framework provides the ``PassesService`` class that handles the creation of the pass JSON file and the signing of the pass bundle. -The ``PassesService`` class also provides methods to send push notifications to all devices registered when you update a pass, and all the routes that Apple Wallet uses to retrieve passes. - -### Implement the Pass Data Model - -Your data model should contain all the fields that you store for your pass, as well as a foreign key to ``Pass``, the pass model offered by the Passes framework, and a pass type identifier that's registered with Apple. - -```swift -import Fluent -import Foundation -import Passes - -final class PassData: PassDataModel, @unchecked Sendable { - static let schema = "pass_data" - - static let typeIdentifier = Environment.get("PASS_TYPE_IDENTIFIER")! - - @ID - var id: UUID? - - @Parent(key: "pass_id") - var pass: Pass - - // 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() { } -} - -struct CreatePassData: AsyncMigration { - public func prepare(on database: Database) async throws { - try await database.schema(PassData.schema) - .id() - .field("pass_id", .uuid, .required, .references(Pass.schema, .id, onDelete: .cascade)) - .field("punches", .int, .required) - .field("title", .string, .required) - .create() - } - - public func revert(on database: Database) async throws { - try await database.schema(PassData.schema).delete() - } -} -``` - -You also have to define two methods in the ``PassDataModel``: -- ``PassDataModel/passJSON(on:)``, where you'll have to return a `struct` that conforms to ``PassJSON/Properties``. -- ``PassDataModel/template(on:)``, where you'll have to return the path to a folder containing the pass files. - -```swift -extension PassData { - func passJSON(on db: any Database) async throws -> any PassJSON.Properties { - try await PassJSONData(data: self, pass: self.$pass.get(on: db)) - } - - func template(on db: any Database) async throws -> String { - // The location might vary depending on the type of pass. - "Templates/Passes/" - } -} -``` - -### Handle Cleanup - -Depending on your implementation details, you may want to automatically clean out the passes and devices table when a registration is deleted. -The implementation will be based on your type of SQL database, as there's not yet a Fluent way to implement something like SQL's `NOT EXISTS` call with a `DELETE` statement. - -> Warning: 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/Properties`` which will contain all the fields for the generated `pass.json` file. -Create an initializer that takes your custom pass data, the ``Pass`` and everything else you may need. - -> 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. - -```swift -import Passes - -struct PassJSONData: PassJSON.Properties { - let description: String - let formatVersion = PassJSON.FormatVersion.v1 - let organizationName = "vapor-community" - let passTypeIdentifier = PassData.typeIdentifier - let serialNumber: String - let teamIdentifier = Environment.get("APPLE_TEAM_IDENTIFIER")! - - private let webServiceURL = "https://example.com/api/passes/" - private let authenticationToken: String - 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: PassJSON.Barcodes { - let format = PassJSON.BarcodeFormat.qr - let message: String - let messageEncoding = "iso-8859-1" - } - - let boardingPass = Boarding(transitType: .air) - struct Boarding: PassJSON.BoardingPass { - let transitType: PassJSON.TransitType - let headerFields: [PassField] - let primaryFields: [PassField] - let secondaryFields: [PassField] - let auxiliaryFields: [PassField] - let backFields: [PassField] - - struct PassField: PassJSON.PassFieldContent { - let key: String - let label: String - let value: String - } - - init(transitType: PassJSON.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: Pass) { - self.description = data.title - self.serialNumber = pass.id!.uuidString - self.authenticationToken = pass.authenticationToken - } -} -``` - -> Important: You **must** add `api/passes/` to your `webServiceURL`, as shown in the example above. - -### Initialize the Service - -Next, initialize the ``PassesService`` inside the `configure.swift` file. -This will implement all of the routes that Apple Wallet expects to exist on your server. - -> 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). - -```swift -import Fluent -import Vapor -import Passes - -public func configure(_ app: Application) async throws { - ... - let passesService = try PassesService( - app: app, - pemWWDRCertificate: Environment.get("PEM_WWDR_CERTIFICATE")!, - pemCertificate: Environment.get("PEM_CERTIFICATE")!, - pemPrivateKey: Environment.get("PEM_PRIVATE_KEY")! - ) -} -``` - -If you wish to include routes specifically for sending push notifications to updated passes, you can also pass to the ``PassesService`` initializer whatever `Middleware` you want Vapor to use to authenticate the two routes. Doing so will add two routes, the first one sends notifications and the second one retrieves a list of push tokens which would be sent a notification. - -```http -POST https://example.com/api/passes/v1/push/{passTypeIdentifier}/{passSerial} HTTP/2 -``` - -```http -GET https://example.com/api/passes/v1/push/{passTypeIdentifier}/{passSerial} HTTP/2 -``` - -### Custom Implementation of PassesService - -If you don't like the schema names provided by default, you can create your own models conforming to ``PassModel``, ``UserPersonalizationModel``, `DeviceModel`, ``PassesRegistrationModel`` and `ErrorLogModel` and instantiate the generic ``PassesServiceCustom``, providing it your model types. - -```swift -import Fluent -import Vapor -import PassKit -import Passes - -public func configure(_ app: Application) async throws { - ... - let passesService = try PassesServiceCustom< - PassData, - MyPassType, - MyUserPersonalizationType, - MyDeviceType, - MyPassesRegistrationType, - MyErrorLogType - >( - app: app, - pemWWDRCertificate: Environment.get("PEM_WWDR_CERTIFICATE")!, - pemCertificate: Environment.get("PEM_CERTIFICATE")!, - pemPrivateKey: Environment.get("PEM_PRIVATE_KEY")! - ) -} -``` - -### Register Migrations - -If you're using the default schemas provided by this framework, you can register the default models in your `configure(_:)` method: - -```swift -PassesService.register(migrations: app.migrations) -``` - -> Important: Register the default models before the migration of your pass data model. - -### Pass Data Model Middleware - -This framework provides a model middleware to handle the creation and update of the pass data model. - -When you create a ``PassDataModel`` object, it will automatically create a ``PassModel`` object with a random auth token and the correct type identifier and link it to the pass data model. -When you update a pass data model, it will update the ``PassModel`` object and send a push notification to all devices registered to that pass. - -You can register it like so (either with a ``PassesService`` or a ``PassesServiceCustom``): - -```swift -app.databases.middleware.use(passesService, on: .psql) -``` - -> Note: If you don't like the default implementation of the model middleware, it is highly recommended that you create your own. But remember: whenever your pass data changes, you must update the ``Pass/updatedAt`` time of the linked ``Pass`` so that Wallet knows to retrieve a new pass. - -### Generate the Pass Content - -To generate and distribute the `.pkpass` bundle, pass the ``PassesService`` object to your `RouteCollection`. - -```swift -import Fluent -import Vapor -import Passes - -struct PassesController: RouteCollection { - let passesService: PassesService - - func boot(routes: RoutesBuilder) throws { - ... - } -} -``` - -> Note: You'll have to register the `PassesController` in the `configure.swift` file, in order to pass it the ``PassesService`` object. - -Then use the object inside your route handlers to generate the pass bundle with the ``PassesService/build(pass:on:)`` method and distribute it with the "`application/vnd.apple.pkpass`" MIME type. - -```swift -fileprivate func passHandler(_ req: Request) async throws -> Response { - ... - guard let pass = try await PassData.query(on: req.db) - .filter(...) - .first() - else { - throw Abort(.notFound) - } - - let bundle = try await passesService.build(pass: pass, on: req.db) - let body = Response.Body(data: bundle) - var headers = HTTPHeaders() - headers.add(name: .contentType, value: "application/vnd.apple.pkpass") - headers.add(name: .contentDisposition, value: "attachment; filename=name.pkpass") - headers.lastModified = HTTPHeaders.LastModified(pass.updatedAt ?? Date.distantPast) - headers.add(name: .contentTransferEncoding, value: "binary") - return Response(status: .ok, headers: headers, body: body) -} -``` - -### Create a Bundle of Passes - -You can also create a bundle of passes to enable your user to download multiple passes at once. -Use the ``PassesService/build(passes:on:)`` method to generate the bundle and serve it to the user. -The MIME type for a bundle of passes is "`application/vnd.apple.pkpasses`". - -> Note: You can have up to 10 passes or 150 MB for a bundle of passes. - -```swift -fileprivate func passesHandler(_ req: Request) async throws -> Response { - ... - let passes = try await PassData.query(on: req.db).all() - - let bundle = try await passesService.build(passes: passes, on: req.db) - let body = Response.Body(data: bundle) - var headers = HTTPHeaders() - headers.add(name: .contentType, value: "application/vnd.apple.pkpasses") - headers.add(name: .contentDisposition, value: "attachment; filename=name.pkpasses") - headers.lastModified = HTTPHeaders.LastModified(Date()) - headers.add(name: .contentTransferEncoding, value: "binary") - return Response(status: .ok, headers: headers, body: body) -} -``` - -> Important: Bundles of passes are supported only in Safari. You can't send the bundle via AirDrop or other methods. diff --git a/Sources/Passes/Passes.docc/Passes.md b/Sources/Passes/Passes.docc/Passes.md deleted file mode 100644 index 4f962f0..0000000 --- a/Sources/Passes/Passes.docc/Passes.md +++ /dev/null @@ -1,50 +0,0 @@ -# ``Passes`` - -Create, distribute, and update passes for the Apple Wallet app with Vapor. - -## Overview - -@Row { - @Column { } - @Column(size: 4) { - ![Passes](passes) - } - @Column { } -} - -The Passes framework provides a set of tools to help you create, build, and distribute digital passes for the Apple Wallet app using a Vapor server. -It also provides a way to update passes after they have been distributed, using APNs, and models to store pass and device data. - -For information on Apple Wallet passes, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletpasses). - -## Topics - -### Essentials - -- -- ``PassDataModel`` -- ``PassJSON`` - -### Building and Distribution - -- ``PassesService`` -- ``PassesServiceCustom`` - -### Concrete Models - -- ``Pass`` -- ``PassesRegistration`` -- ``PassesDevice`` -- ``PassesErrorLog`` - -### Abstract Models - -- ``PassModel`` -- ``PassesRegistrationModel`` - -### Personalized Passes (⚠️ WIP) - -- -- ``PersonalizationJSON`` -- ``UserPersonalization`` -- ``UserPersonalizationModel`` diff --git a/Sources/Passes/Passes.docc/Personalization.md b/Sources/Passes/Passes.docc/Personalization.md deleted file mode 100644 index c3814ae..0000000 --- a/Sources/Passes/Passes.docc/Personalization.md +++ /dev/null @@ -1,84 +0,0 @@ -# Setting Up Pass Personalization (⚠️ WIP) - -Create and sign a personalized pass for Apple Wallet, and send it to a device with a Vapor server. - -## Overview - -> Warning: This section is a work in progress. Testing is hard without access to the certificates required to develop this feature. If you have access to the entitlements, please help us implement this feature. - -Pass Personalization lets you create passes, referred to as personalizable passes, that prompt the user to provide personal information during signup that is used to update the pass. - -> Important: Making a pass personalizable, just like adding NFC to a pass, requires a special entitlement issued by Apple. Although accessing such entitlements is hard if you're not a big company, you can learn more in [Getting Started with Apple Wallet](https://developer.apple.com/wallet/get-started/). - -Personalizable passes can be distributed like any other pass. For information on personalizable passes, see the [Wallet Developer Guide](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/PassPersonalization.html#//apple_ref/doc/uid/TP40012195-CH12-SW2) and [Return a Personalized Pass](https://developer.apple.com/documentation/walletpasses/return_a_personalized_pass). - -### Implement the Data Model - -You'll have to make a few changes to your ``PassDataModel`` to support personalizable passes. - -A personalizable pass is just a standard pass package with the following additional files: - -- A `personalization.json` file. -- A `personalizationLogo@XX.png` file. - -Implement the ``PassDataModel/personalizationJSON(on:)`` method. -If the pass requires personalization, and if it was not already personalized, create the ``PersonalizationJSON`` struct, which will contain all the fields for the generated `personalization.json` file, and return it, otherwise return `nil`. - -In the ``PassDataModel/template(on:)`` method, you have to return two different directory paths, depending on whether the pass has to be personalized or not. If it does, the directory must contain the `personalizationLogo@XX.png` file. - -Finally, you have to implement the ``PassDataModel/passJSON(on:)`` method as usual, but remember to use in the ``PassJSON/Properties`` initializer the user info that will be saved inside ``Pass/userPersonalization`` after the pass has been personalized. - -```swift -extension PassData { - func passJSON(on db: any Database) async throws -> any PassJSON.Properties { - // Here create the pass JSON data as usual. - try await PassJSONData(data: self, pass: self.$pass.get(on: db)) - } - - func personalizationJSON(on db: any Database) async throws -> PersonalizationJSON? { - if try await self.$pass.get(on: db).$userPersonalization.get(on: db) == nil { - // If the pass requires personalization, create the personalization JSON struct. - return PersonalizationJSON( - requiredPersonalizationFields: [.name, .postalCode, .emailAddress, .phoneNumber], - description: "Hello, World!" - ) - } else { - // Otherwise, return `nil`. - return nil - } - } - - func template(on db: any Database) async throws -> String { - if self.requiresPersonalization { - // If the pass requires personalization, return the URL path to the personalization template, - // which must contain the `personalizationLogo@XX.png` file. - return "Templates/Passes/Personalization/" - } else { - // Otherwise, return the URL path to the standard pass template. - return "Templates/Passes/Standard/" - } - } -} -``` - -### Implement the Web Service - -After implementing the data model methods, there is nothing else you have to do. - -Initializing the ``PassesService`` will automatically set up the endpoints that Apple Wallet expects to exist on your server to handle pass personalization. - -Adding the ``PassesService/register(migrations:)`` method to your `configure.swift` file will automatically set up the database table that stores the user personalization data. - -Generate the pass bundle with ``PassesService/build(pass:on:)`` as usual and distribute it. -The user will be prompted to provide the required personal information when they add the pass. -Wallet will then send the user personal information to your server, which will be saved in the ``UserPersonalization`` table. -Immediately after that, Wallet will request the updated pass. -This updated pass will contain the user personalization data that was previously saved inside the ``Pass/userPersonalization`` field. - -> Important: This updated and personalized pass **must not** contain the `personalization.json` file, so make sure that the ``PassDataModel/personalizationJSON(on:)`` method returns `nil` when the pass has already been personalized. - -## Topics - -### Data Model Method - -- ``PassDataModel/personalizationJSON(on:)`` diff --git a/Sources/VaporWallet/DTOs/DTOs+Content.swift b/Sources/VaporWallet/DTOs/DTOs+Content.swift new file mode 100644 index 0000000..ae65975 --- /dev/null +++ b/Sources/VaporWallet/DTOs/DTOs+Content.swift @@ -0,0 +1,5 @@ +import FluentWallet +import Vapor + +extension LogEntriesDTO: @retroactive Content {} +extension PushTokenDTO: @retroactive Content {} diff --git a/Sources/PassKit/Testing/SecretMiddleware.swift b/Sources/VaporWallet/Testing/SecretMiddleware.swift similarity index 100% rename from Sources/PassKit/Testing/SecretMiddleware.swift rename to Sources/VaporWallet/Testing/SecretMiddleware.swift diff --git a/Sources/PassKit/Testing/TestCertificate.swift b/Sources/VaporWallet/Testing/TestCertificate.swift similarity index 100% rename from Sources/PassKit/Testing/TestCertificate.swift rename to Sources/VaporWallet/Testing/TestCertificate.swift diff --git a/Sources/PassKit/Testing/isLoggingConfigured.swift b/Sources/VaporWallet/Testing/isLoggingConfigured.swift similarity index 100% rename from Sources/PassKit/Testing/isLoggingConfigured.swift rename to Sources/VaporWallet/Testing/isLoggingConfigured.swift diff --git a/Sources/PassKit/PassKit.docc/Resources/wallet.png b/Sources/VaporWallet/VaporWallet.docc/Resources/wallet.png similarity index 100% rename from Sources/PassKit/PassKit.docc/Resources/wallet.png rename to Sources/VaporWallet/VaporWallet.docc/Resources/wallet.png diff --git a/Sources/PassKit/PassKit.docc/PassKit.md b/Sources/VaporWallet/VaporWallet.docc/VaporWallet.md similarity index 57% rename from Sources/PassKit/PassKit.docc/PassKit.md rename to Sources/VaporWallet/VaporWallet.docc/VaporWallet.md index 6c4f717..f951685 100644 --- a/Sources/PassKit/PassKit.docc/PassKit.md +++ b/Sources/VaporWallet/VaporWallet.docc/VaporWallet.md @@ -1,10 +1,10 @@ -# ``PassKit`` +# ``VaporWallet`` Create, distribute, and update passes and orders for the Apple Wallet app with Vapor. ## Overview -The PassKit framework provides a set of tools shared by the Passes and Orders frameworks, which includes the two protocols for defining custom models for device data and error logs. +The `VaporWallet` framework provides a set of tools shared by the `VaporWalletPasses` and `VaporWalletOrders` frameworks. @Row { @Column(size: 2) { } @@ -16,18 +16,18 @@ The PassKit framework provides a set of tools shared by the Passes and Orders fr ### 🎟️ Wallet Passes -The Passes framework provides a set of tools to help you create, build, and distribute digital passes for the Apple Wallet app using a Vapor server. +The `VaporWalletPasses` framework provides a set of tools to help you create, build, and distribute digital passes for the Apple Wallet app using a Vapor server. It also provides a way to update passes after they have been distributed, using APNs, and models to store pass and device data. -See the framework's [documentation](https://swiftpackageindex.com/vapor-community/PassKit/documentation/passes) for information and guides on how to use it. +See the framework's [documentation](https://swiftpackageindex.com/vapor-community/wallet/documentation/passes) for information and guides on how to use it. For information on Apple Wallet passes, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletpasses). ### 📦 Wallet Orders -The Orders framework provides a set of tools to help you create, build, and distribute orders that users can track and manage in Apple Wallet using a Vapor server. +The `VaporWalletOrders` framework provides a set of tools to help you create, build, and distribute orders that users can track and manage in Apple Wallet using a Vapor server. It also provides a way to update orders after they have been distributed, using APNs, and models to store order and device data. -See the framework's [documentation](https://swiftpackageindex.com/vapor-community/PassKit/documentation/orders) for information and guides on how to use it. +See the framework's [documentation](https://swiftpackageindex.com/vapor-community/wallet/documentation/orders) for information and guides on how to use it. For information on Apple Wallet orders, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletorders). diff --git a/Sources/VaporWalletOrders/DTOs/OrderIdentifiersDTO+Content.swift b/Sources/VaporWalletOrders/DTOs/OrderIdentifiersDTO+Content.swift new file mode 100644 index 0000000..a8adbf3 --- /dev/null +++ b/Sources/VaporWalletOrders/DTOs/OrderIdentifiersDTO+Content.swift @@ -0,0 +1,4 @@ +import FluentWalletOrders +import Vapor + +extension OrderIdentifiersDTO: @retroactive Content {} diff --git a/Sources/VaporWalletOrders/Exports.swift b/Sources/VaporWalletOrders/Exports.swift new file mode 100644 index 0000000..8b25210 --- /dev/null +++ b/Sources/VaporWalletOrders/Exports.swift @@ -0,0 +1 @@ +@_exported import VaporWallet diff --git a/Sources/Orders/Middleware/AppleOrderMiddleware.swift b/Sources/VaporWalletOrders/Middleware/AppleOrderMiddleware.swift similarity index 56% rename from Sources/Orders/Middleware/AppleOrderMiddleware.swift rename to Sources/VaporWalletOrders/Middleware/AppleOrderMiddleware.swift index 2f8f83b..4b20b94 100644 --- a/Sources/Orders/Middleware/AppleOrderMiddleware.swift +++ b/Sources/VaporWalletOrders/Middleware/AppleOrderMiddleware.swift @@ -1,4 +1,5 @@ import FluentKit +import FluentWalletOrders import Vapor struct AppleOrderMiddleware: AsyncMiddleware { @@ -6,10 +7,11 @@ struct AppleOrderMiddleware: AsyncMiddleware { to request: Request, chainingTo next: any AsyncResponder ) async throws -> Response { guard - let auth = request.headers["Authorization"].first?.replacingOccurrences( - of: "AppleOrder ", with: ""), + let id = request.parameters.get("orderIdentifier", as: UUID.self), + let authToken = request.headers["Authorization"].first?.replacingOccurrences(of: "AppleOrder ", with: ""), (try await O.query(on: request.db) - .filter(\._$authenticationToken == auth) + .filter(\._$id == id) + .filter(\._$authenticationToken == authToken) .first()) != nil else { throw Abort(.unauthorized) diff --git a/Sources/Orders/Middleware/OrdersService+AsyncModelMiddleware.swift b/Sources/VaporWalletOrders/Middleware/OrdersService+AsyncModelMiddleware.swift similarity index 98% rename from Sources/Orders/Middleware/OrdersService+AsyncModelMiddleware.swift rename to Sources/VaporWalletOrders/Middleware/OrdersService+AsyncModelMiddleware.swift index 0a845b1..3a05251 100644 --- a/Sources/Orders/Middleware/OrdersService+AsyncModelMiddleware.swift +++ b/Sources/VaporWalletOrders/Middleware/OrdersService+AsyncModelMiddleware.swift @@ -1,4 +1,5 @@ import FluentKit +import FluentWalletOrders import Foundation extension OrdersService: AsyncModelMiddleware { diff --git a/Sources/Orders/OrdersService.swift b/Sources/VaporWalletOrders/OrdersService.swift similarity index 93% rename from Sources/Orders/OrdersService.swift rename to Sources/VaporWalletOrders/OrdersService.swift index 33220d5..d4f2dec 100644 --- a/Sources/Orders/OrdersService.swift +++ b/Sources/VaporWalletOrders/OrdersService.swift @@ -1,9 +1,10 @@ import FluentKit +import FluentWalletOrders import Vapor /// The main class that handles Wallet orders. public final class OrdersService: Sendable where Order == OD.OrderType { - private let service: OrdersServiceCustom + private let service: OrdersServiceCustom /// Initializes the service and registers all the routes required for Apple Wallet to work. /// @@ -53,10 +54,9 @@ public final class OrdersService: Sendable where Order == OD /// /// - 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()) + migrations.add(CreateOrder()) + migrations.add(CreateOrdersDevice()) + migrations.add(CreateOrdersRegistration()) } /// Sends push notifications for a given order. diff --git a/Sources/Orders/OrdersServiceCustom.swift b/Sources/VaporWalletOrders/OrdersServiceCustom.swift similarity index 68% rename from Sources/Orders/OrdersServiceCustom.swift rename to Sources/VaporWalletOrders/OrdersServiceCustom.swift index 2f17d22..5cba71d 100644 --- a/Sources/Orders/OrdersServiceCustom.swift +++ b/Sources/VaporWalletOrders/OrdersServiceCustom.swift @@ -1,10 +1,12 @@ import APNS import APNSCore import Fluent +import FluentWalletOrders import NIOSSL -import PassKit import Vapor import VaporAPNS +import VaporWallet +import WalletOrders @_spi(CMS) import X509 import Zip @@ -15,19 +17,15 @@ import Zip /// - Order Type /// - Device Type /// - Registration Type -/// - Error Log Type -public final class OrdersServiceCustom: Sendable -where O == OD.OrderType, O == R.OrderType, D == R.DeviceType { +public final class OrdersServiceCustom< + OD: OrderDataModel, + O: OrderModel, + D: DeviceModel, + R: OrdersRegistrationModel +>: Sendable where O == OD.OrderType, O == R.OrderType, D == R.DeviceType { private unowned let app: Application private let logger: Logger? - - private let pemWWDRCertificate: String - private let pemCertificate: String - private let pemPrivateKey: String - private let pemPrivateKeyPassword: String? - private let openSSLURL: URL - - private let encoder = JSONEncoder() + private let builder: OrderBuilder /// Initializes the service and registers all the routes required for Apple Wallet to work. /// @@ -52,12 +50,13 @@ where O == OD.OrderType, O == R.OrderType, D == R.DeviceType { ) throws { self.app = app self.logger = logger - - self.pemWWDRCertificate = pemWWDRCertificate - self.pemCertificate = pemCertificate - self.pemPrivateKey = pemPrivateKey - self.pemPrivateKeyPassword = pemPrivateKeyPassword - self.openSSLURL = URL(fileURLWithPath: openSSLPath) + self.builder = OrderBuilder( + pemWWDRCertificate: pemWWDRCertificate, + pemCertificate: pemCertificate, + pemPrivateKey: pemPrivateKey, + pemPrivateKeyPassword: pemPrivateKeyPassword, + openSSLPath: openSSLPath + ) let privateKeyBytes = pemPrivateKey.data(using: .utf8)!.map { UInt8($0) } let certificateBytes = pemCertificate.data(using: .utf8)!.map { UInt8($0) } @@ -87,7 +86,7 @@ where O == OD.OrderType, O == R.OrderType, D == R.DeviceType { apnsConfig, eventLoopGroupProvider: .shared(app.eventLoopGroup), responseDecoder: JSONDecoder(), - requestEncoder: self.encoder, + requestEncoder: JSONEncoder(), as: .init(string: "orders"), isDefault: false ) @@ -95,7 +94,7 @@ where O == OD.OrderType, O == R.OrderType, D == R.DeviceType { let orderTypeIdentifier = PathComponent(stringLiteral: OD.typeIdentifier) let v1 = app.grouped("api", "orders", "v1") v1.get("devices", ":deviceIdentifier", "registrations", orderTypeIdentifier, use: { try await self.ordersForDevice(req: $0) }) - v1.post("log", use: { try await self.logError(req: $0) }) + v1.post("log", use: { try await self.logMessage(req: $0) }) let v1auth = v1.grouped(AppleOrderMiddleware()) v1auth.post( @@ -166,7 +165,7 @@ extension OrdersServiceCustom { let pushToken: String do { - pushToken = try req.content.decode(RegistrationDTO.self).pushToken + pushToken = try req.content.decode(PushTokenDTO.self).pushToken } catch { throw Abort(.badRequest) } @@ -217,7 +216,7 @@ extension OrdersServiceCustom { return .created } - fileprivate func ordersForDevice(req: Request) async throws -> OrdersForDeviceDTO { + fileprivate func ordersForDevice(req: Request) async throws -> OrderIdentifiersDTO { logger?.debug("Called ordersForDevice") let deviceIdentifier = req.parameters.get("deviceIdentifier")! @@ -240,32 +239,32 @@ extension OrdersServiceCustom { var orderIdentifiers: [String] = [] var maxDate = Date.distantPast for registration in registrations { - let order = registration.order + let order = try await registration._$order.get(on: req.db) try orderIdentifiers.append(order.requireID().uuidString) if let updatedAt = order.updatedAt, updatedAt > maxDate { maxDate = updatedAt } } - return OrdersForDeviceDTO(with: orderIdentifiers, maxDate: maxDate) + return OrderIdentifiersDTO(with: orderIdentifiers, maxDate: maxDate) } - fileprivate 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) - } + fileprivate func logMessage(req: Request) async throws -> HTTPStatus { + if let logger { + let body: LogEntriesDTO + do { + body = try req.content.decode(LogEntriesDTO.self) + } catch { + throw Abort(.badRequest) + } - guard !body.logs.isEmpty else { - throw Abort(.badRequest) + for log in body.logs { + logger.notice("VaporWalletOrders: \(log)") + } + return .ok + } else { + return .badRequest } - - try await body.logs.map(E.init(message:)).create(on: req.db) - return .ok } fileprivate func unregisterDevice(req: Request) async throws -> HTTPStatus { @@ -379,71 +378,6 @@ extension OrdersServiceCustom { // MARK: - order file generation extension OrdersServiceCustom { - private func manifest(for directory: URL) throws -> Data { - var manifest: [String: String] = [:] - - let paths = try FileManager.default.subpathsOfDirectory(atPath: directory.path) - for relativePath in paths { - let file = URL(fileURLWithPath: relativePath, relativeTo: directory) - guard !file.hasDirectoryPath else { - continue - } - - let hash = try SHA256.hash(data: Data(contentsOf: file)) - manifest[relativePath] = hash.map { "0\(String($0, radix: 16))".suffix(2) }.joined() - } - - return try encoder.encode(manifest) - } - - private func signature(for manifest: Data) throws -> Data { - // Swift Crypto doesn't support encrypted PEM private keys, so we have to use OpenSSL for that. - if let pemPrivateKeyPassword { - guard FileManager.default.fileExists(atPath: self.openSSLURL.path) else { - throw WalletError.noOpenSSLExecutable - } - - let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: dir) } - - try manifest.write(to: dir.appendingPathComponent("manifest.json")) - try self.pemWWDRCertificate.write(to: dir.appendingPathComponent("wwdr.pem"), atomically: true, encoding: .utf8) - try self.pemCertificate.write(to: dir.appendingPathComponent("certificate.pem"), atomically: true, encoding: .utf8) - try self.pemPrivateKey.write(to: dir.appendingPathComponent("private.pem"), atomically: true, encoding: .utf8) - - let process = Process() - process.currentDirectoryURL = dir - process.executableURL = self.openSSLURL - process.arguments = [ - "smime", "-binary", "-sign", - "-certfile", dir.appendingPathComponent("wwdr.pem").path, - "-signer", dir.appendingPathComponent("certificate.pem").path, - "-inkey", dir.appendingPathComponent("private.pem").path, - "-in", dir.appendingPathComponent("manifest.json").path, - "-out", dir.appendingPathComponent("signature").path, - "-outform", "DER", - "-passin", "pass:\(pemPrivateKeyPassword)", - ] - try process.run() - process.waitUntilExit() - - return try Data(contentsOf: dir.appendingPathComponent("signature")) - } else { - let signature = try CMS.sign( - manifest, - signatureAlgorithm: .sha256WithRSAEncryption, - additionalIntermediateCertificates: [ - Certificate(pemEncoded: self.pemWWDRCertificate) - ], - certificate: Certificate(pemEncoded: self.pemCertificate), - privateKey: .init(pemEncoded: self.pemPrivateKey), - signingTime: Date() - ) - return Data(signature) - } - } - /// Generates the order content bundle for a given order. /// /// - Parameters: @@ -452,39 +386,9 @@ extension OrdersServiceCustom { /// /// - Returns: The generated order content as `Data`. public func build(order: OD, on db: any Database) async throws -> Data { - let filesDirectory = try await URL(fileURLWithPath: order.template(on: db), isDirectory: true) - guard - (try? filesDirectory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false - else { - throw WalletError.noSourceFiles - } - - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.copyItem(at: filesDirectory, to: tempDir) - defer { try? FileManager.default.removeItem(at: tempDir) } - - var files: [ArchiveFile] = [] - - let orderJSON = try await self.encoder.encode(order.orderJSON(on: db)) - try orderJSON.write(to: tempDir.appendingPathComponent("order.json")) - files.append(ArchiveFile(filename: "order.json", data: orderJSON)) - - let manifest = try self.manifest(for: tempDir) - files.append(ArchiveFile(filename: "manifest.json", data: manifest)) - try files.append(ArchiveFile(filename: "signature", data: self.signature(for: manifest))) - - let paths = try FileManager.default.subpathsOfDirectory(atPath: filesDirectory.path) - for relativePath in paths { - let file = URL(fileURLWithPath: relativePath, relativeTo: tempDir) - guard !file.hasDirectoryPath else { - continue - } - - try files.append(ArchiveFile(filename: relativePath, data: Data(contentsOf: file))) - } - - let zipFile = tempDir.appendingPathComponent("\(UUID().uuidString).order") - try Zip.zipData(archiveFiles: files, zipFilePath: zipFile) - return try Data(contentsOf: zipFile) + try await self.builder.build( + order: order.orderJSON(on: db), + sourceFilesDirectoryPath: order.sourceFilesDirectoryPath(on: db) + ) } } diff --git a/Sources/Orders/Orders.docc/Extensions/OrdersService.md b/Sources/VaporWalletOrders/VaporWalletOrders.docc/Extensions/OrdersService.md similarity index 78% rename from Sources/Orders/Orders.docc/Extensions/OrdersService.md rename to Sources/VaporWalletOrders/VaporWalletOrders.docc/Extensions/OrdersService.md index 5716063..2440da6 100644 --- a/Sources/Orders/Orders.docc/Extensions/OrdersService.md +++ b/Sources/VaporWalletOrders/VaporWalletOrders.docc/Extensions/OrdersService.md @@ -1,4 +1,4 @@ -# ``Orders/OrdersService`` +# ``VaporWalletOrders/OrdersService`` ## Topics diff --git a/Sources/VaporWalletOrders/VaporWalletOrders.docc/GettingStarted.md b/Sources/VaporWalletOrders/VaporWalletOrders.docc/GettingStarted.md new file mode 100644 index 0000000..1d80e91 --- /dev/null +++ b/Sources/VaporWalletOrders/VaporWalletOrders.docc/GettingStarted.md @@ -0,0 +1,144 @@ +# Getting Started with Orders + +Create the order data model, build an order for Apple Wallet and distribute it with a Vapor server. + +## Overview + +The `FluentWalletOrders` framework provides models to save all the basic information for orders, user devices and their registration to each order. +For all the other custom data needed to generate the order, such as the barcodes, merchant info, etc., you have to create your own model and its model middleware to handle the creation and update of order. +The order data model will be used to generate the `order.json` file contents. + +See [`FluentWalletOrders`'s documentation on `OrderDataModel`](https://swiftpackageindex.com/fpseverino/fluent-wallet/documentation/fluentwalletorders/orderdatamodel) to understand how to implement the order data model and do it before continuing with this guide. + +> Important: You **must** add `api/orders/` to the `webServiceURL` key of the `OrderJSON.Properties` struct. + +The order you distribute to a user is a signed bundle that contains the `order.json` file, images, and optional localizations. +The `VaporWalletOrders` framework provides the ``OrdersService`` class that handles the creation of the order JSON file and the signing of the order bundle. +The ``OrdersService`` class also provides methods to send push notifications to all devices registered when you update an order, and all the routes that Apple Wallet uses to retrieve orders. + +### Initialize the Service + +After creating the order data model and the order JSON data struct, initialize the ``OrdersService`` inside the `configure.swift` file. +This will implement all of the routes that Apple Wallet expects to exist on your server. + +> 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). Those guides are for Wallet passes, but the process is similar for Wallet orders. + +```swift +import Fluent +import Vapor +import VaporWalletOrders + +public func configure(_ app: Application) async throws { + ... + let ordersService = try OrdersService( + app: app, + pemWWDRCertificate: Environment.get("PEM_WWDR_CERTIFICATE")!, + pemCertificate: Environment.get("PEM_CERTIFICATE")!, + pemPrivateKey: Environment.get("PEM_PRIVATE_KEY")! + ) +} +``` + +If you wish to include routes specifically for sending push notifications to updated orders, you can also pass to the ``OrdersService`` initializer whatever `Middleware` you want Vapor to use to authenticate the two routes. Doing so will add two routes, the first one sends notifications and the second one retrieves a list of push tokens which would be sent a notification. + +```http +POST https://example.com/api/orders/v1/push/{orderTypeIdentifier}/{orderIdentifier} HTTP/2 +``` + +```http +GET https://example.com/api/orders/v1/push/{orderTypeIdentifier}/{orderIdentifier} HTTP/2 +``` + +### Custom Implementation of OrdersService + +If you don't like the schema names provided by `FluentWalletOrders`, you can create your own models conforming to `OrderModel`, `DeviceModel` and `OrdersRegistrationModel` and instantiate the generic ``OrdersServiceCustom``, providing it your model types. + +```swift +import Fluent +import FluentWalletOrders +import Vapor +import VaporWalletOrders + +public func configure(_ app: Application) async throws { + ... + let ordersService = try OrdersServiceCustom< + OrderData, + MyOrderType, + MyDeviceType, + MyOrdersRegistrationType + >( + app: app, + pemWWDRCertificate: Environment.get("PEM_WWDR_CERTIFICATE")!, + pemCertificate: Environment.get("PEM_CERTIFICATE")!, + pemPrivateKey: Environment.get("PEM_PRIVATE_KEY")! + ) +} +``` + +### Register Migrations + +If you're using the default schemas provided by `FluentWalletOrders`, you can register the default models in your `configure(_:)` method: + +```swift +OrdersService.register(migrations: app.migrations) +``` + +> Important: Register the default models before the migration of your order data model. + +### Order Data Model Middleware + +This framework provides a model middleware to handle the creation and update of the order data model. + +When you create an `OrderDataModel` object, it will automatically create an `OrderModel` object with a random auth token and the correct type identifier and link it to the order data model. +When you update an order data model, it will update the `OrderModel` object and send a push notification to all devices registered to that order. + +You can register it like so (either with an ``OrdersService`` or an ``OrdersServiceCustom``): + +```swift +app.databases.middleware.use(ordersService, on: .psql) +``` + +> Note: If you don't like the default implementation of the model middleware, it is highly recommended that you create your own. But remember: whenever your order data changes, you must update the `Order.updatedAt` time of the linked `Order` so that Wallet knows to retrieve a new order. + +### Generate the Order Content + +To generate and distribute the `.order` bundle, pass the ``OrdersService`` object to your `RouteCollection`. + +```swift +import Fluent +import Vapor +import VaporWalletOrders + +struct OrdersController: RouteCollection { + let ordersService: OrdersService + + func boot(routes: RoutesBuilder) throws { + ... + } +} +``` + +> Note: You'll have to register the `OrdersController` in the `configure.swift` file, in order to pass it the ``OrdersService`` object. + +Then use the object inside your route handlers to generate the order bundle with the ``OrdersService/build(order:on:)`` method and distribute it with the "`application/vnd.apple.order`" MIME type. + +```swift +fileprivate func orderHandler(_ req: Request) async throws -> Response { + ... + guard let order = try await OrderData.query(on: req.db) + .filter(...) + .first() + else { + throw Abort(.notFound) + } + + let bundle = try await ordersService.build(order: order, on: req.db) + let body = Response.Body(data: bundle) + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "application/vnd.apple.order") + headers.add(name: .contentDisposition, value: "attachment; filename=name.order") + headers.lastModified = HTTPHeaders.LastModified(order.updatedAt ?? Date.distantPast) + headers.add(name: .contentTransferEncoding, value: "binary") + return Response(status: .ok, headers: headers, body: body) +} +``` diff --git a/Sources/VaporWalletOrders/VaporWalletOrders.docc/VaporWalletOrders.md b/Sources/VaporWalletOrders/VaporWalletOrders.docc/VaporWalletOrders.md new file mode 100644 index 0000000..a11a711 --- /dev/null +++ b/Sources/VaporWalletOrders/VaporWalletOrders.docc/VaporWalletOrders.md @@ -0,0 +1,17 @@ +# ``VaporWalletOrders`` + +Create, distribute, and update orders in Apple Wallet with Vapor. + +## Overview + +The `VaporWalletOrders` framework provides a set of tools to help you create, build, distribute and update orders trackable in the Apple Wallet app using a Vapor server. + +For information on Apple Wallet orders, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletorders). + +## Topics + +### Essentials + +- +- ``OrdersService`` +- ``OrdersServiceCustom`` diff --git a/Sources/VaporWalletPasses/DTOs/DTOs+Content.swift b/Sources/VaporWalletPasses/DTOs/DTOs+Content.swift new file mode 100644 index 0000000..0cf692d --- /dev/null +++ b/Sources/VaporWalletPasses/DTOs/DTOs+Content.swift @@ -0,0 +1,6 @@ +import FluentWalletPasses +import Vapor + +extension SerialNumbersDTO: @retroactive Content {} +extension PersonalizationDictionaryDTO: @retroactive Content {} +extension PersonalizationDictionaryDTO.RequiredPersonalizationInfo: @retroactive Content {} diff --git a/Sources/VaporWalletPasses/Exports.swift b/Sources/VaporWalletPasses/Exports.swift new file mode 100644 index 0000000..8b25210 --- /dev/null +++ b/Sources/VaporWalletPasses/Exports.swift @@ -0,0 +1 @@ +@_exported import VaporWallet diff --git a/Sources/Passes/Middleware/ApplePassMiddleware.swift b/Sources/VaporWalletPasses/Middleware/ApplePassMiddleware.swift similarity index 86% rename from Sources/Passes/Middleware/ApplePassMiddleware.swift rename to Sources/VaporWalletPasses/Middleware/ApplePassMiddleware.swift index d040e5c..b988b11 100644 --- a/Sources/Passes/Middleware/ApplePassMiddleware.swift +++ b/Sources/VaporWalletPasses/Middleware/ApplePassMiddleware.swift @@ -27,6 +27,7 @@ /// THE SOFTWARE. import FluentKit +import FluentWalletPasses import Vapor struct ApplePassMiddleware: AsyncMiddleware { @@ -34,10 +35,11 @@ struct ApplePassMiddleware: AsyncMiddleware { to request: Request, chainingTo next: any AsyncResponder ) async throws -> Response { guard - let auth = request.headers["Authorization"].first?.replacingOccurrences( - of: "ApplePass ", with: ""), + let id = request.parameters.get("passSerial", as: UUID.self), + let authToken = request.headers["Authorization"].first?.replacingOccurrences(of: "ApplePass ", with: ""), (try await P.query(on: request.db) - .filter(\._$authenticationToken == auth) + .filter(\._$id == id) + .filter(\._$authenticationToken == authToken) .first()) != nil else { throw Abort(.unauthorized) diff --git a/Sources/Passes/Middleware/PassesService+AsyncModelMiddleware.swift b/Sources/VaporWalletPasses/Middleware/PassesService+AsyncModelMiddleware.swift similarity index 98% rename from Sources/Passes/Middleware/PassesService+AsyncModelMiddleware.swift rename to Sources/VaporWalletPasses/Middleware/PassesService+AsyncModelMiddleware.swift index 23c37e3..55ddc96 100644 --- a/Sources/Passes/Middleware/PassesService+AsyncModelMiddleware.swift +++ b/Sources/VaporWalletPasses/Middleware/PassesService+AsyncModelMiddleware.swift @@ -1,4 +1,5 @@ import FluentKit +import FluentWalletPasses import Foundation extension PassesService: AsyncModelMiddleware { diff --git a/Sources/Passes/PassesService.swift b/Sources/VaporWalletPasses/PassesService.swift similarity index 87% rename from Sources/Passes/PassesService.swift rename to Sources/VaporWalletPasses/PassesService.swift index 18bf6e2..41dcaea 100644 --- a/Sources/Passes/PassesService.swift +++ b/Sources/VaporWalletPasses/PassesService.swift @@ -27,11 +27,12 @@ /// THE SOFTWARE. import FluentKit +import FluentWalletPasses import Vapor /// The main class that handles Apple Wallet passes. public final class PassesService: Sendable where Pass == PD.PassType { - private let service: PassesServiceCustom + private let service: PassesServiceCustom /// Initializes the service and registers all the routes required for Apple Wallet to work. /// @@ -94,13 +95,16 @@ public final class PassesService: Sendable where Pass == PD.P /// Adds the migrations for Apple Wallet passes models. /// - /// - Parameter migrations: The `Migrations` object to add the migrations to. - public static func register(migrations: Migrations) { - migrations.add(UserPersonalization()) - migrations.add(Pass()) - migrations.add(PassesDevice()) - migrations.add(PassesRegistration()) - migrations.add(PassesErrorLog()) + /// - Parameters: + /// - migrations: The `Migrations` object to add the migrations to. + /// - withPersonalization: Whether to include the migration for the `PersonalizationInfo` model. Defaults to `false`. + public static func register(migrations: Migrations, withPersonalization: Bool = false) { + migrations.add(CreatePass()) + migrations.add(CreatePassesDevice()) + migrations.add(CreatePassesRegistration()) + if withPersonalization { + migrations.add(CreatePersonalizationInfo()) + } } /// Sends push notifications for a given pass. diff --git a/Sources/Passes/PassesServiceCustom.swift b/Sources/VaporWalletPasses/PassesServiceCustom.swift similarity index 66% rename from Sources/Passes/PassesServiceCustom.swift rename to Sources/VaporWalletPasses/PassesServiceCustom.swift index f454e5d..90e7c2a 100644 --- a/Sources/Passes/PassesServiceCustom.swift +++ b/Sources/VaporWalletPasses/PassesServiceCustom.swift @@ -1,10 +1,12 @@ import APNS import APNSCore import Fluent +import FluentWalletPasses import NIOSSL -import PassKit import Vapor import VaporAPNS +import VaporWallet +import WalletPasses @_spi(CMS) import X509 import Zip @@ -13,22 +15,19 @@ import Zip /// The generics should be passed in this order: /// - Pass Data Model /// - Pass Type -/// - User Personalization Type +/// - Personalization Info Type /// - Device Type /// - Registration Type -/// - Error Log Type -public final class PassesServiceCustom: Sendable -where P == PD.PassType, P == R.PassType, D == R.DeviceType, U == P.UserPersonalizationType { +public final class PassesServiceCustom< + PD: PassDataModel, + P: PassModel, + I: PersonalizationInfoModel, + D: DeviceModel, + R: PassesRegistrationModel +>: Sendable where P == PD.PassType, P == R.PassType, D == R.DeviceType, I.PassType == P { private unowned let app: Application private let logger: Logger? - - private let pemWWDRCertificate: String - private let pemCertificate: String - private let pemPrivateKey: String - private let pemPrivateKeyPassword: String? - private let openSSLURL: URL - - private let encoder = JSONEncoder() + private let builder: PassBuilder /// Initializes the service and registers all the routes required for Apple Wallet to work. /// @@ -53,12 +52,13 @@ where P == PD.PassType, P == R.PassType, D == R.DeviceType, U == P.UserPersonali ) throws { self.app = app self.logger = logger - - self.pemWWDRCertificate = pemWWDRCertificate - self.pemCertificate = pemCertificate - self.pemPrivateKey = pemPrivateKey - self.pemPrivateKeyPassword = pemPrivateKeyPassword - self.openSSLURL = URL(fileURLWithPath: openSSLPath) + self.builder = PassBuilder( + pemWWDRCertificate: pemWWDRCertificate, + pemCertificate: pemCertificate, + pemPrivateKey: pemPrivateKey, + pemPrivateKeyPassword: pemPrivateKeyPassword, + openSSLPath: openSSLPath + ) let privateKeyBytes = pemPrivateKey.data(using: .utf8)!.map { UInt8($0) } let certificateBytes = pemCertificate.data(using: .utf8)!.map { UInt8($0) } @@ -88,7 +88,7 @@ where P == PD.PassType, P == R.PassType, D == R.DeviceType, U == P.UserPersonali apnsConfig, eventLoopGroupProvider: .shared(app.eventLoopGroup), responseDecoder: JSONDecoder(), - requestEncoder: self.encoder, + requestEncoder: JSONEncoder(), as: .init(string: "passes"), isDefault: false ) @@ -99,7 +99,7 @@ where P == PD.PassType, P == R.PassType, D == R.DeviceType, U == P.UserPersonali "devices", ":deviceLibraryIdentifier", "registrations", passTypeIdentifier, use: { try await self.passesForDevice(req: $0) } ) - v1.post("log", use: { try await self.logError(req: $0) }) + v1.post("log", use: { try await self.logMessage(req: $0) }) v1.post("passes", passTypeIdentifier, ":passSerial", "personalize", use: { try await self.personalizedPass(req: $0) }) let v1auth = v1.grouped(ApplePassMiddleware

()) @@ -128,7 +128,7 @@ extension PassesServiceCustom { let pushToken: String do { - pushToken = try req.content.decode(RegistrationDTO.self).pushToken + pushToken = try req.content.decode(PushTokenDTO.self).pushToken } catch { throw Abort(.badRequest) } @@ -177,7 +177,7 @@ extension PassesServiceCustom { return .created } - fileprivate func passesForDevice(req: Request) async throws -> PassesForDeviceDTO { + fileprivate func passesForDevice(req: Request) async throws -> SerialNumbersDTO { logger?.debug("Called passesForDevice") let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! @@ -200,14 +200,14 @@ extension PassesServiceCustom { var serialNumbers: [String] = [] var maxDate = Date.distantPast for registration in registrations { - let pass = registration.pass + let pass = try await registration._$pass.get(on: req.db) try serialNumbers.append(pass.requireID().uuidString) if let updatedAt = pass.updatedAt, updatedAt > maxDate { maxDate = updatedAt } } - return PassesForDeviceDTO(with: serialNumbers, maxDate: maxDate) + return SerialNumbersDTO(with: serialNumbers, maxDate: maxDate) } fileprivate func latestVersionOfPass(req: Request) async throws -> Response { @@ -276,22 +276,22 @@ extension PassesServiceCustom { return .ok } - fileprivate 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) - } + fileprivate func logMessage(req: Request) async throws -> HTTPStatus { + if let logger { + let body: LogEntriesDTO + do { + body = try req.content.decode(LogEntriesDTO.self) + } catch { + throw Abort(.badRequest) + } - guard !body.logs.isEmpty else { - throw Abort(.badRequest) + for log in body.logs { + logger.notice("VaporWalletPasses: \(log)") + } + return .ok + } else { + return .badRequest } - - try await body.logs.map(E.init(message:)).create(on: req.db) - return .ok } fileprivate func personalizedPass(req: Request) async throws -> Response { @@ -301,28 +301,26 @@ extension PassesServiceCustom { throw Abort(.badRequest) } guard - let pass = try await P.query(on: req.db) + try await P.query(on: req.db) .filter(\._$id == id) .filter(\._$typeIdentifier == PD.typeIdentifier) - .first() + .first() != nil else { throw Abort(.notFound) } let userInfo = try req.content.decode(PersonalizationDictionaryDTO.self) - let userPersonalization = U() - userPersonalization.fullName = userInfo.requiredPersonalizationInfo.fullName - userPersonalization.givenName = userInfo.requiredPersonalizationInfo.givenName - userPersonalization.familyName = userInfo.requiredPersonalizationInfo.familyName - userPersonalization.emailAddress = userInfo.requiredPersonalizationInfo.emailAddress - userPersonalization.postalCode = userInfo.requiredPersonalizationInfo.postalCode - userPersonalization.isoCountryCode = userInfo.requiredPersonalizationInfo.isoCountryCode - userPersonalization.phoneNumber = userInfo.requiredPersonalizationInfo.phoneNumber - try await userPersonalization.create(on: req.db) - - pass._$userPersonalization.id = try userPersonalization.requireID() - try await pass.update(on: req.db) + let personalization = I() + personalization.fullName = userInfo.requiredPersonalizationInfo.fullName + personalization.givenName = userInfo.requiredPersonalizationInfo.givenName + personalization.familyName = userInfo.requiredPersonalizationInfo.familyName + personalization.emailAddress = userInfo.requiredPersonalizationInfo.emailAddress + personalization.postalCode = userInfo.requiredPersonalizationInfo.postalCode + personalization.isoCountryCode = userInfo.requiredPersonalizationInfo.isoCountryCode + personalization.phoneNumber = userInfo.requiredPersonalizationInfo.phoneNumber + personalization._$pass.id = id + try await personalization.create(on: req.db) guard let token = userInfo.personalizationToken.data(using: .utf8) else { throw Abort(.internalServerError) @@ -331,7 +329,7 @@ extension PassesServiceCustom { var headers = HTTPHeaders() headers.add(name: .contentType, value: "application/octet-stream") headers.add(name: .contentTransferEncoding, value: "binary") - return try Response(status: .ok, headers: headers, body: Response.Body(data: self.signature(for: token))) + return try Response(status: .ok, headers: headers, body: Response.Body(data: self.builder.signature(for: token))) } // MARK: - Push Routes @@ -422,72 +420,6 @@ extension PassesServiceCustom { // MARK: - pkpass file generation extension PassesServiceCustom { - private func manifest(for directory: URL) throws -> Data { - var manifest: [String: String] = [:] - - let paths = try FileManager.default.subpathsOfDirectory(atPath: directory.path) - for relativePath in paths { - let file = URL(fileURLWithPath: relativePath, relativeTo: directory) - guard !file.hasDirectoryPath else { - continue - } - - let hash = try Insecure.SHA1.hash(data: Data(contentsOf: file)) - manifest[relativePath] = hash.map { "0\(String($0, radix: 16))".suffix(2) }.joined() - } - - return try encoder.encode(manifest) - } - - // We use this function to sign the personalization token too. - private func signature(for manifest: Data) throws -> Data { - // Swift Crypto doesn't support encrypted PEM private keys, so we have to use OpenSSL for that. - if let pemPrivateKeyPassword { - guard FileManager.default.fileExists(atPath: self.openSSLURL.path) else { - throw WalletError.noOpenSSLExecutable - } - - let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: dir) } - - try manifest.write(to: dir.appendingPathComponent("manifest.json")) - try self.pemWWDRCertificate.write(to: dir.appendingPathComponent("wwdr.pem"), atomically: true, encoding: .utf8) - try self.pemCertificate.write(to: dir.appendingPathComponent("certificate.pem"), atomically: true, encoding: .utf8) - try self.pemPrivateKey.write(to: dir.appendingPathComponent("private.pem"), atomically: true, encoding: .utf8) - - let process = Process() - process.currentDirectoryURL = dir - process.executableURL = self.openSSLURL - process.arguments = [ - "smime", "-binary", "-sign", - "-certfile", dir.appendingPathComponent("wwdr.pem").path, - "-signer", dir.appendingPathComponent("certificate.pem").path, - "-inkey", dir.appendingPathComponent("private.pem").path, - "-in", dir.appendingPathComponent("manifest.json").path, - "-out", dir.appendingPathComponent("signature").path, - "-outform", "DER", - "-passin", "pass:\(pemPrivateKeyPassword)", - ] - try process.run() - process.waitUntilExit() - - return try Data(contentsOf: dir.appendingPathComponent("signature")) - } else { - let signature = try CMS.sign( - manifest, - signatureAlgorithm: .sha256WithRSAEncryption, - additionalIntermediateCertificates: [ - Certificate(pemEncoded: self.pemWWDRCertificate) - ], - certificate: Certificate(pemEncoded: self.pemCertificate), - privateKey: .init(pemEncoded: self.pemPrivateKey), - signingTime: Date() - ) - return Data(signature) - } - } - /// Generates the pass content bundle for a given pass. /// /// - Parameters: @@ -496,47 +428,11 @@ extension PassesServiceCustom { /// /// - Returns: The generated pass content as `Data`. public func build(pass: PD, on db: any Database) async throws -> Data { - let filesDirectory = try await URL(fileURLWithPath: pass.template(on: db), isDirectory: true) - guard - (try? filesDirectory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false - else { - throw WalletError.noSourceFiles - } - - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.copyItem(at: filesDirectory, to: tempDir) - defer { try? FileManager.default.removeItem(at: tempDir) } - - var files: [ArchiveFile] = [] - - let passJSON = try await self.encoder.encode(pass.passJSON(on: db)) - try passJSON.write(to: tempDir.appendingPathComponent("pass.json")) - files.append(ArchiveFile(filename: "pass.json", data: passJSON)) - - // Pass Personalization - if let personalizationJSON = try await pass.personalizationJSON(on: db) { - let personalizationJSONData = try self.encoder.encode(personalizationJSON) - try personalizationJSONData.write(to: tempDir.appendingPathComponent("personalization.json")) - files.append(ArchiveFile(filename: "personalization.json", data: personalizationJSONData)) - } - - let manifest = try self.manifest(for: tempDir) - files.append(ArchiveFile(filename: "manifest.json", data: manifest)) - try files.append(ArchiveFile(filename: "signature", data: self.signature(for: manifest))) - - let paths = try FileManager.default.subpathsOfDirectory(atPath: filesDirectory.path) - for relativePath in paths { - let file = URL(fileURLWithPath: relativePath, relativeTo: tempDir) - guard !file.hasDirectoryPath else { - continue - } - - try files.append(ArchiveFile(filename: relativePath, data: Data(contentsOf: file))) - } - - let zipFile = tempDir.appendingPathComponent("\(UUID().uuidString).pkpass") - try Zip.zipData(archiveFiles: files, zipFilePath: zipFile) - return try Data(contentsOf: zipFile) + try await self.builder.build( + pass: pass.passJSON(on: db), + sourceFilesDirectoryPath: pass.sourceFilesDirectoryPath(on: db), + personalization: pass.personalizationJSON(on: db) + ) } /// Generates a bundle of passes to enable your user to download multiple passes at once. @@ -552,7 +448,7 @@ extension PassesServiceCustom { /// - Returns: The bundle of passes as `Data`. public func build(passes: [PD], on db: any Database) async throws -> Data { guard passes.count > 1 && passes.count <= 10 else { - throw WalletError.invalidNumberOfPasses + throw WalletPassesError.invalidNumberOfPasses } var files: [ArchiveFile] = [] diff --git a/Sources/Passes/Passes.docc/Extensions/PassesService.md b/Sources/VaporWalletPasses/VaporWalletPasses.docc/Extensions/PassesService.md similarity index 61% rename from Sources/Passes/Passes.docc/Extensions/PassesService.md rename to Sources/VaporWalletPasses/VaporWalletPasses.docc/Extensions/PassesService.md index 6073ecd..91e0668 100644 --- a/Sources/Passes/Passes.docc/Extensions/PassesService.md +++ b/Sources/VaporWalletPasses/VaporWalletPasses.docc/Extensions/PassesService.md @@ -1,4 +1,4 @@ -# ``Passes/PassesService`` +# ``VaporWalletPasses/PassesService`` ## Topics @@ -6,7 +6,7 @@ - ``build(pass:on:)`` - ``build(passes:on:)`` -- ``register(migrations:)`` +- ``register(migrations:withPersonalization:)`` ### Push Notifications diff --git a/Sources/VaporWalletPasses/VaporWalletPasses.docc/GettingStarted.md b/Sources/VaporWalletPasses/VaporWalletPasses.docc/GettingStarted.md new file mode 100644 index 0000000..74a032d --- /dev/null +++ b/Sources/VaporWalletPasses/VaporWalletPasses.docc/GettingStarted.md @@ -0,0 +1,171 @@ +# Getting Started with Passes + +Create the pass data model, build a pass for Apple Wallet and distribute it with a Vapor server. + +## Overview + +The `FluentWalletPasses` framework provides models to save all the basic information for passes, user devices and their registration to each pass. +For all the other custom data needed to generate the pass, such as the barcodes, locations, etc., you have to create your own model and its model middleware to handle the creation and update of passes. +The pass data model will be used to generate the `pass.json` file contents. + +See [`FluentWalletPasses`'s documentation on `PassDataModel`](https://swiftpackageindex.com/fpseverino/fluent-wallet/documentation/fluentwalletpasses/passdatamodel) to understand how to implement the pass data model and do it before continuing with this guide. + +> Important: You **must** add `api/passes/` to the `webServiceURL` key of the `PassJSON.Properties` struct. + +The pass you distribute to a user is a signed bundle that contains the `pass.json` file, images and optional localizations. +The `VaporWalletPasses` framework provides the ``PassesService`` class that handles the creation of the pass JSON file and the signing of the pass bundle. +The ``PassesService`` class also provides methods to send push notifications to all devices registered when you update a pass, and all the routes that Apple Wallet uses to retrieve passes. + +### Initialize the Service + +After creating the pass data model and the pass JSON data struct, initialize the ``PassesService`` inside the `configure.swift` file. +This will implement all of the routes that Apple Wallet expects to exist on your server. + +> 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). + +```swift +import Fluent +import Vapor +import VaporWalletPasses + +public func configure(_ app: Application) async throws { + ... + let passesService = try PassesService( + app: app, + pemWWDRCertificate: Environment.get("PEM_WWDR_CERTIFICATE")!, + pemCertificate: Environment.get("PEM_CERTIFICATE")!, + pemPrivateKey: Environment.get("PEM_PRIVATE_KEY")! + ) +} +``` + +If you wish to include routes specifically for sending push notifications to updated passes, you can also pass to the ``PassesService`` initializer whatever `Middleware` you want Vapor to use to authenticate the two routes. Doing so will add two routes, the first one sends notifications and the second one retrieves a list of push tokens which would be sent a notification. + +```http +POST https://example.com/api/passes/v1/push/{passTypeIdentifier}/{passSerial} HTTP/2 +``` + +```http +GET https://example.com/api/passes/v1/push/{passTypeIdentifier}/{passSerial} HTTP/2 +``` + +### Custom Implementation of PassesService + +If you don't like the schema names provided by `FluentWalletPasses`, you can create your own models conforming to `PassModel`, `PersonalizationInfoModel`, `DeviceModel`, and `PassesRegistrationModel` and instantiate the generic ``PassesServiceCustom``, providing it your model types. + +```swift +import Fluent +import FluentWalletPasses +import Vapor +import VaporWalletPasses + +public func configure(_ app: Application) async throws { + ... + let passesService = try PassesServiceCustom< + PassData, + MyPassType, + MyPersonalizationInfoType, + MyDeviceType, + MyPassesRegistrationType + >( + app: app, + pemWWDRCertificate: Environment.get("PEM_WWDR_CERTIFICATE")!, + pemCertificate: Environment.get("PEM_CERTIFICATE")!, + pemPrivateKey: Environment.get("PEM_PRIVATE_KEY")! + ) +} +``` + +### Register Migrations + +If you're using the default schemas provided by `FluentWalletPasses`, you can register the default models in your `configure(_:)` method: + +```swift +PassesService.register(migrations: app.migrations) +``` + +> Important: Register the default models before the migration of your pass data model. + +### Pass Data Model Middleware + +This framework provides a model middleware to handle the creation and update of the pass data model. + +When you create a `PassDataModel` object, it will automatically create a `PassModel` object with a random auth token and the correct type identifier and link it to the pass data model. +When you update a pass data model, it will update the `PassModel` object and send a push notification to all devices registered to that pass. + +You can register it like so (either with a ``PassesService`` or a ``PassesServiceCustom``): + +```swift +app.databases.middleware.use(passesService, on: .psql) +``` + +> Note: If you don't like the default implementation of the model middleware, it is highly recommended that you create your own. But remember: whenever your pass data changes, you must update the `Pass.updatedAt` time of the linked `Pass` so that Wallet knows to retrieve a new pass. + +### Generate the Pass Content + +To generate and distribute the `.pkpass` bundle, pass the ``PassesService`` object to your `RouteCollection`. + +```swift +import Fluent +import Vapor +import VaporWalletPasses + +struct PassesController: RouteCollection { + let passesService: PassesService + + func boot(routes: RoutesBuilder) throws { + ... + } +} +``` + +> Note: You'll have to register the `PassesController` in the `configure.swift` file, in order to pass it the ``PassesService`` object. + +Then use the object inside your route handlers to generate the pass bundle with the ``PassesService/build(pass:on:)`` method and distribute it with the "`application/vnd.apple.pkpass`" MIME type. + +```swift +fileprivate func passHandler(_ req: Request) async throws -> Response { + ... + guard let pass = try await PassData.query(on: req.db) + .filter(...) + .first() + else { + throw Abort(.notFound) + } + + let bundle = try await passesService.build(pass: pass, on: req.db) + let body = Response.Body(data: bundle) + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "application/vnd.apple.pkpass") + headers.add(name: .contentDisposition, value: "attachment; filename=name.pkpass") + headers.lastModified = HTTPHeaders.LastModified(pass.updatedAt ?? Date.distantPast) + headers.add(name: .contentTransferEncoding, value: "binary") + return Response(status: .ok, headers: headers, body: body) +} +``` + +### Create a Bundle of Passes + +You can also create a bundle of passes to enable your user to download multiple passes at once. +Use the ``PassesService/build(passes:on:)`` method to generate the bundle and serve it to the user. +The MIME type for a bundle of passes is "`application/vnd.apple.pkpasses`". + +> Note: You can have up to 10 passes or 150 MB for a bundle of passes. + +```swift +fileprivate func passesHandler(_ req: Request) async throws -> Response { + ... + let passes = try await PassData.query(on: req.db).all() + + let bundle = try await passesService.build(passes: passes, on: req.db) + let body = Response.Body(data: bundle) + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "application/vnd.apple.pkpasses") + headers.add(name: .contentDisposition, value: "attachment; filename=name.pkpasses") + headers.lastModified = HTTPHeaders.LastModified(Date()) + headers.add(name: .contentTransferEncoding, value: "binary") + return Response(status: .ok, headers: headers, body: body) +} +``` + +> Important: Bundles of passes are supported only in Safari. You can't send the bundle via AirDrop or other methods. diff --git a/Sources/Passes/Passes.docc/Resources/passes.png b/Sources/VaporWalletPasses/VaporWalletPasses.docc/Resources/passes.png similarity index 100% rename from Sources/Passes/Passes.docc/Resources/passes.png rename to Sources/VaporWalletPasses/VaporWalletPasses.docc/Resources/passes.png diff --git a/Sources/VaporWalletPasses/VaporWalletPasses.docc/VaporWalletPasses.md b/Sources/VaporWalletPasses/VaporWalletPasses.docc/VaporWalletPasses.md new file mode 100644 index 0000000..e1c554a --- /dev/null +++ b/Sources/VaporWalletPasses/VaporWalletPasses.docc/VaporWalletPasses.md @@ -0,0 +1,25 @@ +# ``VaporWalletPasses`` + +Create, distribute, and update passes for the Apple Wallet app with Vapor. + +## Overview + +@Row { + @Column { } + @Column(size: 4) { + ![Passes](passes) + } + @Column { } +} + +The `VaporWalletPasses` framework provides a set of tools to help you create, build, distribute and update digital passes for the Apple Wallet app using a Vapor server. + +For information on Apple Wallet passes, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletpasses). + +## Topics + +### Essentials + +- +- ``PassesService`` +- ``PassesServiceCustom`` diff --git a/Tests/OrdersTests/Utils/OrderJSONData.swift b/Tests/OrdersTests/Utils/OrderJSONData.swift deleted file mode 100644 index d486ceb..0000000 --- a/Tests/OrdersTests/Utils/OrderJSONData.swift +++ /dev/null @@ -1,51 +0,0 @@ -import Foundation -import Orders - -extension OrderJSON.SchemaVersion: Decodable {} -extension OrderJSON.OrderType: Decodable {} -extension OrderJSON.OrderStatus: Decodable {} - -struct OrderJSONData: OrderJSON.Properties, Decodable { - let schemaVersion = OrderJSON.SchemaVersion.v1 - let orderTypeIdentifier = OrderData.typeIdentifier - let orderIdentifier: String - let orderType = OrderJSON.OrderType.ecommerce - let orderNumber = "HM090772020864" - let createdAt: String - let updatedAt: String - let status = OrderJSON.OrderStatus.open - let merchant: MerchantData - let orderManagementURL = "https://www.example.com/" - let authenticationToken: String - - private let webServiceURL = "https://www.example.com/api/orders/" - - enum CodingKeys: String, CodingKey { - case schemaVersion - case orderTypeIdentifier, orderIdentifier, orderType, orderNumber - case createdAt, updatedAt - case status, merchant - case orderManagementURL, authenticationToken, webServiceURL - } - - struct MerchantData: OrderJSON.Merchant, Decodable { - let merchantIdentifier = "com.example.pet-store" - let displayName: String - let url = "https://www.example.com/" - let logo = "pet_store_logo.png" - - enum CodingKeys: String, CodingKey { - case merchantIdentifier, displayName, url, logo - } - } - - init(data: OrderData, order: Order) { - self.orderIdentifier = order.id!.uuidString - self.authenticationToken = order.authenticationToken - self.merchant = MerchantData(displayName: data.title) - let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = .withInternetDateTime - self.createdAt = dateFormatter.string(from: order.createdAt!) - self.updatedAt = dateFormatter.string(from: order.updatedAt!) - } -} diff --git a/Tests/OrdersTests/Templates/EmptyDir/.gitkeep b/Tests/VaporWalletOrdersTests/SourceFiles/EmptyDir/.gitkeep similarity index 100% rename from Tests/OrdersTests/Templates/EmptyDir/.gitkeep rename to Tests/VaporWalletOrdersTests/SourceFiles/EmptyDir/.gitkeep diff --git a/Tests/OrdersTests/Templates/icon.png b/Tests/VaporWalletOrdersTests/SourceFiles/icon.png similarity index 100% rename from Tests/OrdersTests/Templates/icon.png rename to Tests/VaporWalletOrdersTests/SourceFiles/icon.png diff --git a/Tests/OrdersTests/Templates/it-IT.lproj/pet_store_logo.png b/Tests/VaporWalletOrdersTests/SourceFiles/it-IT.lproj/pet_store_logo.png similarity index 100% rename from Tests/OrdersTests/Templates/it-IT.lproj/pet_store_logo.png rename to Tests/VaporWalletOrdersTests/SourceFiles/it-IT.lproj/pet_store_logo.png diff --git a/Tests/OrdersTests/Templates/pet_store_logo.png b/Tests/VaporWalletOrdersTests/SourceFiles/pet_store_logo.png similarity index 100% rename from Tests/OrdersTests/Templates/pet_store_logo.png rename to Tests/VaporWalletOrdersTests/SourceFiles/pet_store_logo.png diff --git a/Tests/OrdersTests/Utils/OrderData.swift b/Tests/VaporWalletOrdersTests/Utils/OrderData.swift similarity index 85% rename from Tests/OrdersTests/Utils/OrderData.swift rename to Tests/VaporWalletOrdersTests/Utils/OrderData.swift index 180d291..6fec9b0 100644 --- a/Tests/OrdersTests/Utils/OrderData.swift +++ b/Tests/VaporWalletOrdersTests/Utils/OrderData.swift @@ -1,6 +1,8 @@ import Fluent +import FluentWalletOrders import Foundation -import Orders +import VaporWalletOrders +import WalletOrders final class OrderData: OrderDataModel, @unchecked Sendable { static let schema = OrderData.FieldKeys.schemaName @@ -51,7 +53,7 @@ extension OrderData { try await OrderJSONData(data: self, order: self.$order.get(on: db)) } - func template(on db: any Database) async throws -> String { - "\(FileManager.default.currentDirectoryPath)/Tests/OrdersTests/Templates/" + func sourceFilesDirectoryPath(on db: any Database) async throws -> String { + "\(FileManager.default.currentDirectoryPath)/Tests/VaporWalletOrdersTests/SourceFiles/" } } diff --git a/Tests/VaporWalletOrdersTests/Utils/OrderJSONData.swift b/Tests/VaporWalletOrdersTests/Utils/OrderJSONData.swift new file mode 100644 index 0000000..3ee359a --- /dev/null +++ b/Tests/VaporWalletOrdersTests/Utils/OrderJSONData.swift @@ -0,0 +1,35 @@ +import FluentWalletOrders +import Foundation +import WalletOrders + +struct OrderJSONData: OrderJSON.Properties, Decodable { + var schemaVersion = OrderJSON.SchemaVersion.v1 + var orderTypeIdentifier = OrderData.typeIdentifier + var orderIdentifier: String + var orderType = OrderJSON.OrderType.ecommerce + var orderNumber = "HM090772020864" + var createdAt: String + var updatedAt: String + var status = OrderJSON.OrderStatus.open + var merchant: MerchantData + var orderManagementURL = "https://www.example.com/" + var authenticationToken: String + var webServiceURL = "https://www.example.com/api/orders/" + + struct MerchantData: OrderJSON.Merchant, Decodable { + var merchantIdentifier = "com.example.pet-store" + var displayName: String + var url = "https://www.example.com/" + var logo = "pet_store_logo.png" + } + + init(data: OrderData, order: Order) { + self.orderIdentifier = order.id!.uuidString + self.authenticationToken = order.authenticationToken + self.merchant = MerchantData(displayName: data.title) + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = .withInternetDateTime + self.createdAt = dateFormatter.string(from: order.createdAt!) + self.updatedAt = dateFormatter.string(from: order.updatedAt!) + } +} diff --git a/Tests/OrdersTests/Utils/withApp.swift b/Tests/VaporWalletOrdersTests/Utils/withApp.swift similarity index 95% rename from Tests/OrdersTests/Utils/withApp.swift rename to Tests/VaporWalletOrdersTests/Utils/withApp.swift index a1c889a..29613e0 100644 --- a/Tests/OrdersTests/Utils/withApp.swift +++ b/Tests/VaporWalletOrdersTests/Utils/withApp.swift @@ -1,9 +1,10 @@ import FluentKit import FluentSQLiteDriver -import Orders -import PassKit +import FluentWalletOrders import Testing import Vapor +import VaporWalletOrders +import WalletOrders import Zip func withApp( diff --git a/Tests/OrdersTests/OrdersTests.swift b/Tests/VaporWalletOrdersTests/VaporWalletOrdersTests.swift similarity index 90% rename from Tests/OrdersTests/OrdersTests.swift rename to Tests/VaporWalletOrdersTests/VaporWalletOrdersTests.swift index 2fb7901..6526c1a 100644 --- a/Tests/OrdersTests/OrdersTests.swift +++ b/Tests/VaporWalletOrdersTests/VaporWalletOrdersTests.swift @@ -1,12 +1,13 @@ -import PassKit +import FluentWalletOrders import Testing -import XCTVapor +import VaporTesting +import WalletOrders import Zip -@testable import Orders +@testable import VaporWalletOrders -@Suite("Orders Tests", .serialized) -struct OrdersTests { +@Suite("VaporWalletOrders Tests", .serialized) +struct VaporWalletOrdersTests { let ordersURI = "/api/orders/v1/" let decoder = JSONDecoder() @@ -102,7 +103,7 @@ struct OrdersTests { "If-Modified-Since": "0", ], afterResponse: { res async throws in - #expect(res.status == .badRequest) + #expect(res.status == .unauthorized) } ) @@ -152,7 +153,7 @@ struct OrdersTests { .POST, "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.typeIdentifier)/\(order.requireID())", beforeRequest: { req async throws in - try req.content.encode(RegistrationDTO(pushToken: pushToken)) + try req.content.encode(PushTokenDTO(pushToken: pushToken)) }, afterResponse: { res async throws in #expect(res.status == .unauthorized) @@ -165,7 +166,7 @@ struct OrdersTests { "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\("order.com.example.NotFound")/\(UUID().uuidString)", headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], beforeRequest: { req async throws in - try req.content.encode(RegistrationDTO(pushToken: pushToken)) + try req.content.encode(PushTokenDTO(pushToken: pushToken)) }, afterResponse: { res async throws in #expect(res.status == .notFound) @@ -188,10 +189,10 @@ struct OrdersTests { "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.typeIdentifier)/\("not-a-uuid")", headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], beforeRequest: { req async throws in - try req.content.encode(RegistrationDTO(pushToken: pushToken)) + try req.content.encode(PushTokenDTO(pushToken: pushToken)) }, afterResponse: { res async throws in - #expect(res.status == .badRequest) + #expect(res.status == .unauthorized) } ) @@ -200,7 +201,7 @@ struct OrdersTests { "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.typeIdentifier)/\(order.requireID())", headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], beforeRequest: { req async throws in - try req.content.encode(RegistrationDTO(pushToken: pushToken)) + try req.content.encode(PushTokenDTO(pushToken: pushToken)) }, afterResponse: { res async throws in #expect(res.status == .created) @@ -213,7 +214,7 @@ struct OrdersTests { "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.typeIdentifier)/\(order.requireID())", headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], beforeRequest: { req async throws in - try req.content.encode(RegistrationDTO(pushToken: pushToken)) + try req.content.encode(PushTokenDTO(pushToken: pushToken)) }, afterResponse: { res async throws in #expect(res.status == .ok) @@ -224,7 +225,7 @@ struct OrdersTests { .GET, "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.typeIdentifier)?ordersModifiedSince=0", afterResponse: { res async throws in - let orders = try res.content.decode(OrdersForDeviceDTO.self) + let orders = try res.content.decode(OrderIdentifiersDTO.self) #expect(orders.orderIdentifiers.count == 1) let orderID = try order.requireID() #expect(orders.orderIdentifiers[0] == orderID.uuidString) @@ -259,7 +260,7 @@ struct OrdersTests { "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.typeIdentifier)/\("not-a-uuid")", headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], afterResponse: { res async throws in - #expect(res.status == .badRequest) + #expect(res.status == .unauthorized) } ) @@ -274,28 +275,20 @@ struct OrdersTests { } } - @Test("Error Logging") + @Test("Log a Message") func errorLog() async throws { try await withApp { app, ordersService in - let log1 = "Error 1" - let log2 = "Error 2" - try await app.test( .POST, "\(ordersURI)log", beforeRequest: { req async throws in - try req.content.encode(ErrorLogDTO(logs: [log1, log2])) + try req.content.encode(LogEntriesDTO(logs: ["Error 1", "Error 2"])) }, afterResponse: { res async throws in #expect(res.status == .ok) } ) - let logs = try await OrdersErrorLog.query(on: app.db).all() - #expect(logs.count == 2) - #expect(logs[0].message == log1) - #expect(logs[1]._$message.value == log2) - // Test call with no DTO try await app.test( .POST, @@ -304,18 +297,6 @@ struct OrdersTests { #expect(res.status == .badRequest) } ) - - // Test call with empty logs - try await app.test( - .POST, - "\(ordersURI)log", - beforeRequest: { req async throws in - try req.content.encode(ErrorLogDTO(logs: [])) - }, - afterResponse: { res async throws in - #expect(res.status == .badRequest) - } - ) } } @@ -357,7 +338,7 @@ struct OrdersTests { "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.typeIdentifier)/\(order.requireID())", headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], beforeRequest: { req async throws in - try req.content.encode(RegistrationDTO(pushToken: pushToken)) + try req.content.encode(PushTokenDTO(pushToken: pushToken)) }, afterResponse: { res async throws in #expect(res.status == .created) diff --git a/Tests/PassesTests/Templates/EmptyDir/.gitkeep b/Tests/VaporWalletPassesTests/SourceFiles/EmptyDir/.gitkeep similarity index 100% rename from Tests/PassesTests/Templates/EmptyDir/.gitkeep rename to Tests/VaporWalletPassesTests/SourceFiles/EmptyDir/.gitkeep diff --git a/Tests/PassesTests/Templates/icon.png b/Tests/VaporWalletPassesTests/SourceFiles/icon.png similarity index 100% rename from Tests/PassesTests/Templates/icon.png rename to Tests/VaporWalletPassesTests/SourceFiles/icon.png diff --git a/Tests/PassesTests/Templates/it-IT.lproj/logo.png b/Tests/VaporWalletPassesTests/SourceFiles/it-IT.lproj/logo.png similarity index 100% rename from Tests/PassesTests/Templates/it-IT.lproj/logo.png rename to Tests/VaporWalletPassesTests/SourceFiles/it-IT.lproj/logo.png diff --git a/Tests/PassesTests/Templates/it-IT.lproj/personalizationLogo.png b/Tests/VaporWalletPassesTests/SourceFiles/it-IT.lproj/personalizationLogo.png similarity index 100% rename from Tests/PassesTests/Templates/it-IT.lproj/personalizationLogo.png rename to Tests/VaporWalletPassesTests/SourceFiles/it-IT.lproj/personalizationLogo.png diff --git a/Tests/PassesTests/Templates/logo.png b/Tests/VaporWalletPassesTests/SourceFiles/logo.png similarity index 100% rename from Tests/PassesTests/Templates/logo.png rename to Tests/VaporWalletPassesTests/SourceFiles/logo.png diff --git a/Tests/PassesTests/Templates/personalizationLogo.png b/Tests/VaporWalletPassesTests/SourceFiles/personalizationLogo.png similarity index 100% rename from Tests/PassesTests/Templates/personalizationLogo.png rename to Tests/VaporWalletPassesTests/SourceFiles/personalizationLogo.png diff --git a/Tests/PassesTests/Utils/PassData.swift b/Tests/VaporWalletPassesTests/Utils/PassData.swift similarity index 76% rename from Tests/PassesTests/Utils/PassData.swift rename to Tests/VaporWalletPassesTests/Utils/PassData.swift index 063d806..5b4c614 100644 --- a/Tests/PassesTests/Utils/PassData.swift +++ b/Tests/VaporWalletPassesTests/Utils/PassData.swift @@ -1,11 +1,12 @@ import Fluent +import FluentWalletPasses import Foundation -import Passes +import WalletPasses final class PassData: PassDataModel, @unchecked Sendable { static let schema = PassData.FieldKeys.schemaName - static let typeIdentifier = "pass.com.vapor-community.Passes" + static let typeIdentifier = "pass.com.vapor-community.VaporWalletPasses" @ID(key: .id) var id: UUID? @@ -51,14 +52,20 @@ extension PassData { try await PassJSONData(data: self, pass: self.$pass.get(on: db)) } - func template(on db: any Database) async throws -> String { - "\(FileManager.default.currentDirectoryPath)/Tests/PassesTests/Templates/" + func sourceFilesDirectoryPath(on db: any Database) async throws -> String { + "\(FileManager.default.currentDirectoryPath)/Tests/VaporWalletPassesTests/SourceFiles/" } func personalizationJSON(on db: any Database) async throws -> PersonalizationJSON? { if self.title != "Personalize" { return nil } - if try await self.$pass.get(on: db).$userPersonalization.get(on: db) == nil { + let pass = try await self.$pass.get(on: db) + + let personalization = try await PersonalizationInfo.query(on: db) + .filter(\.$pass.$id == pass.requireID()) + .first() + + if personalization == nil { return PersonalizationJSON( requiredPersonalizationFields: [.name, .postalCode, .emailAddress, .phoneNumber], description: "Hello, World!" diff --git a/Tests/PassesTests/Utils/PassJSONData.swift b/Tests/VaporWalletPassesTests/Utils/PassJSONData.swift similarity index 50% rename from Tests/PassesTests/Utils/PassJSONData.swift rename to Tests/VaporWalletPassesTests/Utils/PassJSONData.swift index 91f15ed..f876aea 100644 --- a/Tests/PassesTests/Utils/PassJSONData.swift +++ b/Tests/VaporWalletPassesTests/Utils/PassJSONData.swift @@ -1,36 +1,28 @@ -import Passes - -extension PassJSON.FormatVersion: Decodable {} -extension PassJSON.BarcodeFormat: Decodable {} -extension PassJSON.TransitType: Decodable {} +import FluentWalletPasses +import WalletPasses struct PassJSONData: PassJSON.Properties, Decodable { - let description: String - let formatVersion = PassJSON.FormatVersion.v1 - let organizationName = "vapor-community" - let passTypeIdentifier = PassData.typeIdentifier - let serialNumber: String - let teamIdentifier = "K6512ZA2S5" - - private let webServiceURL = "https://www.example.com/api/passes/" - let authenticationToken: String - private let logoText = "Vapor Community" - private let sharingProhibited = true - let backgroundColor = "rgb(207, 77, 243)" - let foregroundColor = "rgb(255, 255, 255)" - - let barcodes = Barcode(message: "test") + var description: String + var formatVersion = PassJSON.FormatVersion.v1 + var organizationName = "vapor-community" + var passTypeIdentifier = PassData.typeIdentifier + var serialNumber: String + var teamIdentifier = "K6512ZA2S5" + var webServiceURL = "https://www.example.com/api/passes/" + var authenticationToken: String + var logoText = "Vapor Community" + var sharingProhibited = true + var backgroundColor = "rgb(207, 77, 243)" + var foregroundColor = "rgb(255, 255, 255)" + + var barcodes = Barcode(message: "test") struct Barcode: PassJSON.Barcodes, Decodable { - let format = PassJSON.BarcodeFormat.qr - let message: String - let messageEncoding = "iso-8859-1" - - enum CodingKeys: String, CodingKey { - case format, message, messageEncoding - } + var format = PassJSON.BarcodeFormat.qr + var message: String + var messageEncoding = "iso-8859-1" } - let boardingPass = Boarding(transitType: .air) + var boardingPass = Boarding(transitType: .air) struct Boarding: PassJSON.BoardingPass, Decodable { let transitType: PassJSON.TransitType let headerFields: [PassField] @@ -55,15 +47,6 @@ struct PassJSONData: PassJSON.Properties, Decodable { } } - enum CodingKeys: String, CodingKey { - case description - case formatVersion - case organizationName, passTypeIdentifier, serialNumber, teamIdentifier - case webServiceURL, authenticationToken - case logoText, sharingProhibited, backgroundColor, foregroundColor - case barcodes, boardingPass - } - init(data: PassData, pass: Pass) { self.description = data.title self.serialNumber = pass.id!.uuidString diff --git a/Tests/PassesTests/Utils/withApp.swift b/Tests/VaporWalletPassesTests/Utils/withApp.swift similarity index 93% rename from Tests/PassesTests/Utils/withApp.swift rename to Tests/VaporWalletPassesTests/Utils/withApp.swift index b5ac620..9249cee 100644 --- a/Tests/PassesTests/Utils/withApp.swift +++ b/Tests/VaporWalletPassesTests/Utils/withApp.swift @@ -1,9 +1,10 @@ import FluentKit import FluentSQLiteDriver -import PassKit -import Passes +import FluentWalletPasses import Testing import Vapor +import VaporWalletPasses +import WalletPasses import Zip func withApp( @@ -15,7 +16,7 @@ func withApp( try #require(isLoggingConfigured) app.databases.use(.sqlite(.memory), as: .sqlite) - PassesService.register(migrations: app.migrations) + PassesService.register(migrations: app.migrations, withPersonalization: true) app.migrations.add(CreatePassData()) try await app.autoMigrate() diff --git a/Tests/PassesTests/PassesTests.swift b/Tests/VaporWalletPassesTests/VaporWalletPassesTests.swift similarity index 89% rename from Tests/PassesTests/PassesTests.swift rename to Tests/VaporWalletPassesTests/VaporWalletPassesTests.swift index 3661783..3b9a583 100644 --- a/Tests/PassesTests/PassesTests.swift +++ b/Tests/VaporWalletPassesTests/VaporWalletPassesTests.swift @@ -1,12 +1,13 @@ -import PassKit +import FluentWalletPasses import Testing -import XCTVapor +import VaporTesting +import WalletPasses import Zip -@testable import Passes +@testable import VaporWalletPasses -@Suite("Passes Tests", .serialized) -struct PassesTests { +@Suite("VaporWalletPasses Tests", .serialized) +struct VaporWalletPassesTests { let passesURI = "/api/passes/v1/" let decoder = JSONDecoder() @@ -65,7 +66,7 @@ struct PassesTests { do { let data = try await passesService.build(passes: [passData1], on: app.db) Issue.record("Expected error, got \(data)") - } catch let error as WalletError { + } catch let error as WalletPassesError { #expect(error == .invalidNumberOfPasses) } } @@ -170,7 +171,7 @@ struct PassesTests { "If-Modified-Since": "0", ], afterResponse: { res async throws in - #expect(res.status == .badRequest) + #expect(res.status == .unauthorized) } ) @@ -221,10 +222,8 @@ struct PassesTests { } ) - let personalizationQuery = try await UserPersonalization.query(on: app.db).all() + let personalizationQuery = try await PersonalizationInfo.query(on: app.db).all() #expect(personalizationQuery.count == 1) - let passPersonalizationID = try await Pass.query(on: app.db).first()?._$userPersonalization.get(on: app.db)?.requireID() - #expect(personalizationQuery[0]._$id.value == passPersonalizationID) #expect(personalizationQuery[0]._$emailAddress.value == personalizationDict.requiredPersonalizationInfo.emailAddress) #expect(personalizationQuery[0]._$familyName.value == personalizationDict.requiredPersonalizationInfo.familyName) #expect(personalizationQuery[0]._$fullName.value == personalizationDict.requiredPersonalizationInfo.fullName) @@ -290,7 +289,7 @@ struct PassesTests { .POST, "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.typeIdentifier)/\(pass.requireID())", beforeRequest: { req async throws in - try req.content.encode(RegistrationDTO(pushToken: pushToken)) + try req.content.encode(PushTokenDTO(pushToken: pushToken)) }, afterResponse: { res async throws in #expect(res.status == .unauthorized) @@ -303,7 +302,7 @@ struct PassesTests { "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\("pass.com.example.NotFound")/\(UUID().uuidString)", headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], beforeRequest: { req async throws in - try req.content.encode(RegistrationDTO(pushToken: pushToken)) + try req.content.encode(PushTokenDTO(pushToken: pushToken)) }, afterResponse: { res async throws in #expect(res.status == .notFound) @@ -326,10 +325,10 @@ struct PassesTests { "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.typeIdentifier)/\("not-a-uuid")", headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], beforeRequest: { req async throws in - try req.content.encode(RegistrationDTO(pushToken: pushToken)) + try req.content.encode(PushTokenDTO(pushToken: pushToken)) }, afterResponse: { res async throws in - #expect(res.status == .badRequest) + #expect(res.status == .unauthorized) } ) @@ -338,7 +337,7 @@ struct PassesTests { "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.typeIdentifier)/\(pass.requireID())", headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], beforeRequest: { req async throws in - try req.content.encode(RegistrationDTO(pushToken: pushToken)) + try req.content.encode(PushTokenDTO(pushToken: pushToken)) }, afterResponse: { res async throws in #expect(res.status == .created) @@ -351,7 +350,7 @@ struct PassesTests { "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.typeIdentifier)/\(pass.requireID())", headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], beforeRequest: { req async throws in - try req.content.encode(RegistrationDTO(pushToken: pushToken)) + try req.content.encode(PushTokenDTO(pushToken: pushToken)) }, afterResponse: { res async throws in #expect(res.status == .ok) @@ -362,7 +361,7 @@ struct PassesTests { .GET, "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.typeIdentifier)?passesUpdatedSince=0", afterResponse: { res async throws in - let passes = try res.content.decode(PassesForDeviceDTO.self) + let passes = try res.content.decode(SerialNumbersDTO.self) #expect(passes.serialNumbers.count == 1) let passID = try pass.requireID() #expect(passes.serialNumbers[0] == passID.uuidString) @@ -397,7 +396,7 @@ struct PassesTests { "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.typeIdentifier)/\("not-a-uuid")", headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], afterResponse: { res async throws in - #expect(res.status == .badRequest) + #expect(res.status == .unauthorized) } ) @@ -412,28 +411,20 @@ struct PassesTests { } } - @Test("Error Logging") + @Test("Log a Message") func errorLog() async throws { try await withApp { app, passesService in - let log1 = "Error 1" - let log2 = "Error 2" - try await app.test( .POST, "\(passesURI)log", beforeRequest: { req async throws in - try req.content.encode(ErrorLogDTO(logs: [log1, log2])) + try req.content.encode(LogEntriesDTO(logs: ["Error 1", "Error 2"])) }, afterResponse: { res async throws in #expect(res.status == .ok) } ) - let logs = try await PassesErrorLog.query(on: app.db).all() - #expect(logs.count == 2) - #expect(logs[0].message == log1) - #expect(logs[1]._$message.value == log2) - // Test call with no DTO try await app.test( .POST, @@ -442,18 +433,6 @@ struct PassesTests { #expect(res.status == .badRequest) } ) - - // Test call with empty logs - try await app.test( - .POST, - "\(passesURI)log", - beforeRequest: { req async throws in - try req.content.encode(ErrorLogDTO(logs: [])) - }, - afterResponse: { res async throws in - #expect(res.status == .badRequest) - } - ) } } @@ -495,7 +474,7 @@ struct PassesTests { "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.typeIdentifier)/\(pass.requireID())", headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], beforeRequest: { req async throws in - try req.content.encode(RegistrationDTO(pushToken: pushToken)) + try req.content.encode(PushTokenDTO(pushToken: pushToken)) }, afterResponse: { res async throws in #expect(res.status == .created) @@ -532,14 +511,4 @@ struct PassesTests { } } } - - @Test("WalletError") - func walletError() { - #expect(WalletError.noSourceFiles.description == "WalletError(errorType: noSourceFiles)") - #expect(WalletError.noOpenSSLExecutable.description == "WalletError(errorType: noOpenSSLExecutable)") - #expect(WalletError.invalidNumberOfPasses.description == "WalletError(errorType: invalidNumberOfPasses)") - - #expect(WalletError.noSourceFiles == WalletError.noSourceFiles) - #expect(WalletError.noOpenSSLExecutable != WalletError.invalidNumberOfPasses) - } }