diff --git a/Package.swift b/Package.swift index 4bfb12f..cdbc96a 100644 --- a/Package.swift +++ b/Package.swift @@ -4,14 +4,14 @@ import PackageDescription let package = Package( name: "PassKit", platforms: [ - .macOS(.v14) + .macOS(.v13) ], products: [ .library(name: "Passes", targets: ["Passes"]), .library(name: "Orders", targets: ["Orders"]), ], dependencies: [ - .package(url: "https://github.com/vapor/vapor.git", from: "4.106.1"), + .package(url: "https://github.com/vapor/vapor.git", from: "4.108.0"), .package(url: "https://github.com/vapor/fluent.git", from: "4.12.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"), @@ -76,7 +76,6 @@ let package = Package( var swiftSettings: [SwiftSetting] { [ - .enableUpcomingFeature("ExistentialAny"), - .enableUpcomingFeature("FullTypedThrows"), + .enableUpcomingFeature("ExistentialAny") ] } diff --git a/Sources/Orders/DTOs/OrderJSON.swift b/Sources/Orders/DTOs/OrderJSON.swift index 23b7afb..66bfea0 100644 --- a/Sources/Orders/DTOs/OrderJSON.swift +++ b/Sources/Orders/DTOs/OrderJSON.swift @@ -1,10 +1,3 @@ -// -// OrderJSON.swift -// PassKit -// -// Created by Francesco Paolo Severino on 02/07/24. -// - /// The structure of a `order.json` file. public struct OrderJSON { /// A protocol that defines the structure of a `order.json` file. diff --git a/Sources/Orders/DTOs/OrdersForDeviceDTO.swift b/Sources/Orders/DTOs/OrdersForDeviceDTO.swift index 6ef7f64..4891d2e 100644 --- a/Sources/Orders/DTOs/OrdersForDeviceDTO.swift +++ b/Sources/Orders/DTOs/OrdersForDeviceDTO.swift @@ -1,10 +1,3 @@ -// -// OrdersForDeviceDTO.swift -// PassKit -// -// Created by Francesco Paolo Severino on 30/06/24. -// - import Vapor struct OrdersForDeviceDTO: Content { diff --git a/Sources/Orders/Middleware/AppleOrderMiddleware.swift b/Sources/Orders/Middleware/AppleOrderMiddleware.swift index 66d8544..2f8f83b 100644 --- a/Sources/Orders/Middleware/AppleOrderMiddleware.swift +++ b/Sources/Orders/Middleware/AppleOrderMiddleware.swift @@ -1,10 +1,3 @@ -// -// AppleOrderMiddleware.swift -// PassKit -// -// Created by Francesco Paolo Severino on 30/06/24. -// - import FluentKit import Vapor diff --git a/Sources/Orders/Middleware/OrdersService+AsyncModelMiddleware.swift b/Sources/Orders/Middleware/OrdersService+AsyncModelMiddleware.swift new file mode 100644 index 0000000..0a845b1 --- /dev/null +++ b/Sources/Orders/Middleware/OrdersService+AsyncModelMiddleware.swift @@ -0,0 +1,42 @@ +import FluentKit +import Foundation + +extension OrdersService: AsyncModelMiddleware { + public func create(model: OD, on db: any Database, next: any AnyAsyncModelResponder) async throws { + let order = Order( + typeIdentifier: OD.typeIdentifier, + authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString() + ) + try await order.save(on: db) + model._$order.id = try order.requireID() + try await next.create(model, on: db) + } + + public func update(model: OD, on db: any Database, next: any AnyAsyncModelResponder) async throws { + let order = try await model._$order.get(on: db) + order.updatedAt = Date.now + try await order.save(on: db) + try await next.update(model, on: db) + try await self.sendPushNotifications(for: model, on: db) + } +} + +extension OrdersServiceCustom: AsyncModelMiddleware { + public func create(model: OD, on db: any Database, next: any AnyAsyncModelResponder) async throws { + let order = O( + typeIdentifier: OD.typeIdentifier, + authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString() + ) + try await order.save(on: db) + model._$order.id = try order.requireID() + try await next.create(model, on: db) + } + + public func update(model: OD, on db: any Database, next: any AnyAsyncModelResponder) async throws { + let order = try await model._$order.get(on: db) + order.updatedAt = Date.now + try await order.save(on: db) + try await next.update(model, on: db) + try await self.sendPushNotifications(for: model, on: db) + } +} diff --git a/Sources/Orders/Models/Concrete Models/Order.swift b/Sources/Orders/Models/Concrete Models/Order.swift index 8cf29ef..529e48a 100644 --- a/Sources/Orders/Models/Concrete Models/Order.swift +++ b/Sources/Orders/Models/Concrete Models/Order.swift @@ -1,10 +1,3 @@ -// -// Order.swift -// PassKit -// -// Created by Francesco Paolo Severino on 30/06/24. -// - import FluentKit import Foundation diff --git a/Sources/Orders/Models/Concrete Models/OrdersDevice.swift b/Sources/Orders/Models/Concrete Models/OrdersDevice.swift index b739e94..046b293 100644 --- a/Sources/Orders/Models/Concrete Models/OrdersDevice.swift +++ b/Sources/Orders/Models/Concrete Models/OrdersDevice.swift @@ -1,10 +1,3 @@ -// -// OrdersDevice.swift -// PassKit -// -// Created by Francesco Paolo Severino on 30/06/24. -// - import FluentKit import PassKit diff --git a/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift b/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift index d0b7ac6..3fd5571 100644 --- a/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift +++ b/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift @@ -1,10 +1,3 @@ -// -// OrdersErrorLog.swift -// PassKit -// -// Created by Francesco Paolo Severino on 30/06/24. -// - import FluentKit import PassKit diff --git a/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift b/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift index ea4ddf3..6b17af5 100644 --- a/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift +++ b/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift @@ -1,10 +1,3 @@ -// -// OrdersRegistration.swift -// PassKit -// -// Created by Francesco Paolo Severino on 30/06/24. -// - import FluentKit /// The `Model` that stores orders registrations. diff --git a/Sources/Orders/Models/OrderDataModel.swift b/Sources/Orders/Models/OrderDataModel.swift index cbb89f1..6558fc0 100644 --- a/Sources/Orders/Models/OrderDataModel.swift +++ b/Sources/Orders/Models/OrderDataModel.swift @@ -1,18 +1,38 @@ -// -// OrderDataModel.swift -// PassKit -// -// Created by Francesco Paolo Severino on 30/06/24. -// - import FluentKit /// Represents the `Model` that stores custom app data associated to Wallet orders. public protocol OrderDataModel: Model { associatedtype OrderType: OrderModel + /// 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 { diff --git a/Sources/Orders/Models/OrderModel.swift b/Sources/Orders/Models/OrderModel.swift index 92cbd4f..803cb94 100644 --- a/Sources/Orders/Models/OrderModel.swift +++ b/Sources/Orders/Models/OrderModel.swift @@ -1,10 +1,3 @@ -// -// OrderModel.swift -// PassKit -// -// Created by Francesco Paolo Severino on 30/06/24. -// - import FluentKit import Foundation @@ -23,6 +16,12 @@ public protocol OrderModel: Model where IDValue == UUID { /// 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 { diff --git a/Sources/Orders/Models/OrdersRegistrationModel.swift b/Sources/Orders/Models/OrdersRegistrationModel.swift index 22a8708..89ed421 100644 --- a/Sources/Orders/Models/OrdersRegistrationModel.swift +++ b/Sources/Orders/Models/OrdersRegistrationModel.swift @@ -1,10 +1,3 @@ -// -// OrdersRegistrationModel.swift -// PassKit -// -// Created by Francesco Paolo Severino on 30/06/24. -// - import FluentKit import PassKit diff --git a/Sources/Orders/Orders.docc/GettingStarted.md b/Sources/Orders/Orders.docc/GettingStarted.md index ec20edf..e015b74 100644 --- a/Sources/Orders/Orders.docc/GettingStarted.md +++ b/Sources/Orders/Orders.docc/GettingStarted.md @@ -9,21 +9,23 @@ For all the other custom data needed to generate the order, such as the barcodes 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, using an ``OrdersDelegate`` that you must implement. +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. +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 struct Foundation.UUID +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? @@ -54,6 +56,23 @@ struct CreateOrderData: AsyncMigration { } ``` +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. @@ -73,7 +92,7 @@ import Orders struct OrderJSONData: OrderJSON.Properties { let schemaVersion = OrderJSON.SchemaVersion.v1 - let orderTypeIdentifier = Environment.get("ORDER_TYPE_IDENTIFIER")! + let orderTypeIdentifier = OrderData.typeIdentifier let orderIdentifier: String let orderType = OrderJSON.OrderType.ecommerce let orderNumber = "HM090772020864" @@ -108,41 +127,6 @@ struct OrderJSONData: OrderJSON.Properties { > Important: You **must** add `api/orders/` to your `webServiceURL`, as shown in the example above. -### Implement the Delegate - -Create a delegate class that implements ``OrdersDelegate``. - -Because the files for your order's template and the method of encoding might vary by order type, you'll be provided the ``Order`` for those methods. -In the ``OrdersDelegate/encode(order:db:encoder:)`` method, you'll want to encode a `struct` that conforms to ``OrderJSON``. - -```swift -import Vapor -import Fluent -import Orders - -final class OrderDelegate: OrdersDelegate { - func encode(order: O, db: Database, encoder: JSONEncoder) async throws -> Data { - // The specific OrderData class you use here may vary based on the `order.typeIdentifier` - // if you have multiple different types of orders, and thus multiple types of order data. - guard let orderData = try await OrderData.query(on: db) - .filter(\.$order.$id == order.requireID()) - .first() - else { - throw Abort(.internalServerError) - } - guard let data = try? encoder.encode(OrderJSONData(data: orderData, order: order)) else { - throw Abort(.internalServerError) - } - return data - } - - func template(for order: O, db: Database) async throws -> String { - // The location might vary depending on the type of order. - "Templates/Orders/" - } -} -``` - ### Initialize the Service Next, initialize the ``OrdersService`` inside the `configure.swift` file. @@ -155,13 +139,10 @@ import Fluent import Vapor import Orders -let orderDelegate = OrderDelegate() - public func configure(_ app: Application) async throws { ... - let ordersService = try OrdersService( + let ordersService = try OrdersService( app: app, - delegate: orderDelegate, pemWWDRCertificate: Environment.get("PEM_WWDR_CERTIFICATE")!, pemCertificate: Environment.get("PEM_CERTIFICATE")!, pemPrivateKey: Environment.get("PEM_PRIVATE_KEY")! @@ -169,8 +150,6 @@ public func configure(_ app: Application) async throws { } ``` -> Note: Notice how the ``OrdersDelegate`` is created as a global variable. You need to ensure that the delegate doesn't go out of scope as soon as the `configure(_:)` method exits. - 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 @@ -191,18 +170,16 @@ import Vapor import PassKit import Orders -let orderDelegate = OrderDelegate() - public func configure(_ app: Application) async throws { ... let ordersService = try OrdersServiceCustom< + OrderData, MyOrderType, MyDeviceType, MyOrdersRegistrationType, MyErrorLogType >( app: app, - delegate: orderDelegate, pemWWDRCertificate: Environment.get("PEM_WWDR_CERTIFICATE")!, pemCertificate: Environment.get("PEM_CERTIFICATE")!, pemPrivateKey: Environment.get("PEM_PRIVATE_KEY")! @@ -215,56 +192,25 @@ public func configure(_ app: Application) async throws { 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) +OrdersService.register(migrations: app.migrations) ``` > Important: Register the default models before the migration of your order data model. ### Order Data Model Middleware -You'll want to create a model middleware to handle the creation and update of the order data model. -This middleware could be responsible for creating and linking an ``Order`` to the order data model, depending on your requirements. -When your order data changes, it should also update the ``Order/updatedAt`` field of the ``Order`` and send a push notification to all devices registered to that order. +This framework provides a model middleware to handle the creation and update of the order data model. -```swift -import Vapor -import Fluent -import Orders - -struct OrderDataMiddleware: AsyncModelMiddleware { - private unowned let service: OrdersService - - init(service: OrdersService) { - self.service = service - } - - // Create the `Order` and add it to the `OrderData` automatically at creation - func create(model: OrderData, on db: Database, next: AnyAsyncModelResponder) async throws { - let order = Order( - typeIdentifier: Environment.get("ORDER_TYPE_IDENTIFIER")!, - authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString()) - try await order.save(on: db) - model.$order.id = try order.requireID() - try await next.create(model, on: db) - } - - func update(model: OrderData, on db: Database, next: AnyAsyncModelResponder) async throws { - let order = try await model.$order.get(on: db) - order.updatedAt = Date() - try await order.save(on: db) - try await next.update(model, on: db) - try await service.sendPushNotifications(for: order, on: db) - } -} -``` +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 could register it in the `configure.swift` file. +You can register it like so (either with an ``OrdersService`` or an ``OrdersServiceCustom``): ```swift -app.databases.middleware.use(OrderDataMiddleware(service: ordersService), on: .psql) +app.databases.middleware.use(ordersService, on: .psql) ``` -> Important: 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. +> 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 @@ -291,15 +237,14 @@ Then use the object inside your route handlers to generate the order bundle with ```swift fileprivate func orderHandler(_ req: Request) async throws -> Response { ... - guard let orderData = try await OrderData.query(on: req.db) + guard let order = try await OrderData.query(on: req.db) .filter(...) - .with(\.$order) .first() else { throw Abort(.notFound) } - let bundle = try await ordersService.build(order: orderData.order, on: req.db) + 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") diff --git a/Sources/Orders/Orders.docc/Orders.md b/Sources/Orders/Orders.docc/Orders.md index 952a12b..60d6d52 100644 --- a/Sources/Orders/Orders.docc/Orders.md +++ b/Sources/Orders/Orders.docc/Orders.md @@ -14,11 +14,11 @@ For information on Apple Wallet orders, see the [Apple Developer Documentation]( ### Essentials - +- ``OrderDataModel`` - ``OrderJSON`` ### Building and Distribution -- ``OrdersDelegate`` - ``OrdersService`` - ``OrdersServiceCustom`` @@ -33,4 +33,3 @@ For information on Apple Wallet orders, see the [Apple Developer Documentation]( - ``OrderModel`` - ``OrdersRegistrationModel`` -- ``OrderDataModel`` diff --git a/Sources/Orders/OrdersDelegate.swift b/Sources/Orders/OrdersDelegate.swift deleted file mode 100644 index e63979c..0000000 --- a/Sources/Orders/OrdersDelegate.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// OrdersDelegate.swift -// PassKit -// -// Created by Francesco Paolo Severino on 01/07/24. -// - -import FluentKit -import Foundation - -/// The delegate which is responsible for generating the order files. -public protocol OrdersDelegate: AnyObject, Sendable { - /// 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` - /// - /// - Parameters: - /// - order: The order data from the SQL server. - /// - db: The SQL database to query against. - /// - /// - Returns: A URL path which points to the template data for the order. - func template(for order: O, db: any Database) async throws -> String - - /// Encode the order into JSON. - /// - /// This method should generate the entire order JSON. You are provided with - /// the order data from the SQL database and you should return a properly - /// formatted order file encoding. - /// - /// - Parameters: - /// - order: The order data from the SQL server - /// - db: The SQL database to query against. - /// - encoder: The `JSONEncoder` which you should use. - /// - Returns: The encoded order JSON data. - /// - /// > Tip: See the [`Order`](https://developer.apple.com/documentation/walletorders/order) object to understand the keys. - func encode( - order: O, db: any Database, encoder: JSONEncoder - ) async throws -> Data -} diff --git a/Sources/Orders/OrdersService.swift b/Sources/Orders/OrdersService.swift index e915472..33220d5 100644 --- a/Sources/Orders/OrdersService.swift +++ b/Sources/Orders/OrdersService.swift @@ -1,22 +1,14 @@ -// -// OrdersService.swift -// PassKit -// -// Created by Francesco Paolo Severino on 01/07/24. -// - import FluentKit import Vapor /// The main class that handles Wallet orders. -public final class OrdersService: Sendable { - private let service: OrdersServiceCustom +public final class OrdersService: Sendable where Order == OD.OrderType { + private let service: OrdersServiceCustom /// Initializes the service and registers all the routes required for Apple Wallet to work. /// /// - Parameters: /// - app: The `Vapor.Application` to use in route handlers and APNs. - /// - delegate: The ``OrdersDelegate`` to use for order generation. /// - pushRoutesMiddleware: The `Middleware` to use for push notification routes. If `nil`, push routes will not be registered. /// - logger: The `Logger` to use. /// - pemWWDRCertificate: Apple's WWDR.pem certificate in PEM format. @@ -26,7 +18,6 @@ public final class OrdersService: Sendable { /// - openSSLPath: The location of the `openssl` command as a file path. public init( app: Application, - delegate: any OrdersDelegate, pushRoutesMiddleware: (any Middleware)? = nil, logger: Logger? = nil, pemWWDRCertificate: String, @@ -37,7 +28,6 @@ public final class OrdersService: Sendable { ) throws { self.service = try .init( app: app, - delegate: delegate, pushRoutesMiddleware: pushRoutesMiddleware, logger: logger, pemWWDRCertificate: pemWWDRCertificate, @@ -55,7 +45,7 @@ public final class OrdersService: Sendable { /// - db: The `Database` to use. /// /// - Returns: The generated order content. - public func build(order: Order, on db: any Database) async throws -> Data { + public func build(order: OD, on db: any Database) async throws -> Data { try await service.build(order: order, on: db) } @@ -74,7 +64,7 @@ public final class OrdersService: Sendable { /// - Parameters: /// - order: The order to send the notifications for. /// - db: The `Database` to use. - public func sendPushNotifications(for order: Order, on db: any Database) async throws { + public func sendPushNotifications(for order: OD, on db: any Database) async throws { try await service.sendPushNotifications(for: order, on: db) } } diff --git a/Sources/Orders/OrdersServiceCustom.swift b/Sources/Orders/OrdersServiceCustom.swift index 3dde8f0..2f17d22 100644 --- a/Sources/Orders/OrdersServiceCustom.swift +++ b/Sources/Orders/OrdersServiceCustom.swift @@ -1,10 +1,3 @@ -// -// OrdersServiceCustom.swift -// PassKit -// -// Created by Francesco Paolo Severino on 01/07/24. -// - import APNS import APNSCore import Fluent @@ -18,14 +11,14 @@ import Zip /// Class to handle ``OrdersService``. /// /// The generics should be passed in this order: +/// - Order Data Model /// - Order Type /// - Device Type /// - Registration Type /// - Error Log Type -public final class OrdersServiceCustom: Sendable -where O == R.OrderType, D == R.DeviceType { +public final class OrdersServiceCustom: Sendable +where O == OD.OrderType, O == R.OrderType, D == R.DeviceType { private unowned let app: Application - private unowned let delegate: any OrdersDelegate private let logger: Logger? private let pemWWDRCertificate: String @@ -40,7 +33,6 @@ where O == R.OrderType, D == R.DeviceType { /// /// - Parameters: /// - app: The `Vapor.Application` to use in route handlers and APNs. - /// - delegate: The ``OrdersDelegate`` to use for order generation. /// - pushRoutesMiddleware: The `Middleware` to use for push notification routes. If `nil`, push routes will not be registered. /// - logger: The `Logger` to use. /// - pemWWDRCertificate: Apple's WWDR.pem certificate in PEM format. @@ -50,7 +42,6 @@ where O == R.OrderType, D == R.DeviceType { /// - openSSLPath: The location of the `openssl` command as a file path. public init( app: Application, - delegate: any OrdersDelegate, pushRoutesMiddleware: (any Middleware)? = nil, logger: Logger? = nil, pemWWDRCertificate: String, @@ -60,7 +51,6 @@ where O == R.OrderType, D == R.DeviceType { openSSLPath: String = "/usr/bin/openssl" ) throws { self.app = app - self.delegate = delegate self.logger = logger self.pemWWDRCertificate = pemWWDRCertificate @@ -102,25 +92,26 @@ where O == R.OrderType, D == R.DeviceType { isDefault: false ) + 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.get("devices", ":deviceIdentifier", "registrations", orderTypeIdentifier, use: { try await self.ordersForDevice(req: $0) }) v1.post("log", use: { try await self.logError(req: $0) }) let v1auth = v1.grouped(AppleOrderMiddleware()) v1auth.post( - "devices", ":deviceIdentifier", "registrations", ":orderTypeIdentifier", ":orderIdentifier", + "devices", ":deviceIdentifier", "registrations", orderTypeIdentifier, ":orderIdentifier", use: { try await self.registerDevice(req: $0) } ) - v1auth.get("orders", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.latestVersionOfOrder(req: $0) }) + v1auth.get("orders", orderTypeIdentifier, ":orderIdentifier", use: { try await self.latestVersionOfOrder(req: $0) }) v1auth.delete( - "devices", ":deviceIdentifier", "registrations", ":orderTypeIdentifier", ":orderIdentifier", + "devices", ":deviceIdentifier", "registrations", orderTypeIdentifier, ":orderIdentifier", use: { try await self.unregisterDevice(req: $0) } ) if let pushRoutesMiddleware { let pushAuth = v1.grouped(pushRoutesMiddleware) - pushAuth.post("push", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.pushUpdatesForOrder(req: $0) }) - pushAuth.get("push", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.tokensForOrderUpdate(req: $0) }) + pushAuth.post("push", orderTypeIdentifier, ":orderIdentifier", use: { try await self.pushUpdatesForOrder(req: $0) }) + pushAuth.get("push", orderTypeIdentifier, ":orderIdentifier", use: { try await self.tokensForOrderUpdate(req: $0) }) } } } @@ -135,15 +126,13 @@ extension OrdersServiceCustom { ifModifiedSince = ims } - guard let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier"), - let id = req.parameters.get("orderIdentifier", as: UUID.self) - else { + guard let id = req.parameters.get("orderIdentifier", as: UUID.self) else { throw Abort(.badRequest) } guard let order = try await O.query(on: req.db) .filter(\._$id == id) - .filter(\._$typeIdentifier == orderTypeIdentifier) + .filter(\._$typeIdentifier == OD.typeIdentifier) .first() else { throw Abort(.notFound) @@ -153,6 +142,14 @@ extension OrdersServiceCustom { throw Abort(.notModified) } + guard + let orderData = try await OD.query(on: req.db) + .filter(\._$order.$id == id) + .first() + else { + throw Abort(.notFound) + } + var headers = HTTPHeaders() headers.add(name: .contentType, value: "application/vnd.apple.order") headers.lastModified = HTTPHeaders.LastModified(order.updatedAt ?? Date.distantPast) @@ -160,7 +157,7 @@ extension OrdersServiceCustom { return try await Response( status: .ok, headers: headers, - body: Response.Body(data: self.build(order: order, on: req.db)) + body: Response.Body(data: self.build(order: orderData, on: req.db)) ) } @@ -177,12 +174,11 @@ extension OrdersServiceCustom { guard let orderIdentifier = req.parameters.get("orderIdentifier", as: UUID.self) else { throw Abort(.badRequest) } - let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! let deviceIdentifier = req.parameters.get("deviceIdentifier")! guard let order = try await O.query(on: req.db) .filter(\._$id == orderIdentifier) - .filter(\._$typeIdentifier == orderTypeIdentifier) + .filter(\._$typeIdentifier == OD.typeIdentifier) .first() else { throw Abort(.notFound) @@ -224,12 +220,11 @@ extension OrdersServiceCustom { fileprivate func ordersForDevice(req: Request) async throws -> OrdersForDeviceDTO { logger?.debug("Called ordersForDevice") - let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! let deviceIdentifier = req.parameters.get("deviceIdentifier")! var query = R.for( deviceLibraryIdentifier: deviceIdentifier, - typeIdentifier: orderTypeIdentifier, + typeIdentifier: OD.typeIdentifier, on: req.db ) if let since: TimeInterval = req.query["ordersModifiedSince"] { @@ -279,13 +274,12 @@ extension OrdersServiceCustom { guard let orderIdentifier = req.parameters.get("orderIdentifier", as: UUID.self) else { throw Abort(.badRequest) } - let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! let deviceIdentifier = req.parameters.get("deviceIdentifier")! guard let r = try await R.for( deviceLibraryIdentifier: deviceIdentifier, - typeIdentifier: orderTypeIdentifier, + typeIdentifier: OD.typeIdentifier, on: req.db ) .filter(O.self, \._$id == orderIdentifier) @@ -304,12 +298,11 @@ extension OrdersServiceCustom { guard let id = req.parameters.get("orderIdentifier", as: UUID.self) else { throw Abort(.badRequest) } - let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! guard let order = try await O.query(on: req.db) .filter(\._$id == id) - .filter(\._$typeIdentifier == orderTypeIdentifier) + .filter(\._$typeIdentifier == OD.typeIdentifier) .first() else { throw Abort(.notFound) @@ -325,12 +318,11 @@ extension OrdersServiceCustom { guard let id = req.parameters.get("orderIdentifier", as: UUID.self) else { throw Abort(.badRequest) } - let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! guard let order = try await O.query(on: req.db) .filter(\._$id == id) - .filter(\._$typeIdentifier == orderTypeIdentifier) + .filter(\._$typeIdentifier == OD.typeIdentifier) .first() else { throw Abort(.notFound) @@ -345,9 +337,13 @@ extension OrdersServiceCustom { /// Sends push notifications for a given order. /// /// - Parameters: - /// - order: The order to send the notifications for. + /// - orderData: The order to send the notifications for. /// - db: The `Database` to use. - public func sendPushNotifications(for order: O, on db: any Database) async throws { + public func sendPushNotifications(for orderData: OD, on db: any Database) async throws { + try await sendPushNotifications(for: orderData._$order.get(on: db), on: db) + } + + private func sendPushNotifications(for order: O, on db: any Database) async throws { let registrations = try await Self.registrations(for: order, on: db) for reg in registrations { let backgroundNotification = APNSBackgroundNotification( @@ -375,7 +371,7 @@ extension OrdersServiceCustom { .join(parent: \._$device) .with(\._$order) .with(\._$device) - .filter(O.self, \._$typeIdentifier == order._$typeIdentifier.value!) + .filter(O.self, \._$typeIdentifier == OD.typeIdentifier) .filter(O.self, \._$id == order.requireID()) .all() } @@ -455,8 +451,8 @@ extension OrdersServiceCustom { /// - db: The `Database` to use. /// /// - Returns: The generated order content as `Data`. - public func build(order: O, on db: any Database) async throws -> Data { - let filesDirectory = try await URL(fileURLWithPath: delegate.template(for: order, db: db), isDirectory: true) + 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 { @@ -469,7 +465,7 @@ extension OrdersServiceCustom { var files: [ArchiveFile] = [] - let orderJSON = try await self.delegate.encode(order: order, db: db, encoder: self.encoder) + 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)) diff --git a/Sources/PassKit/Models/DeviceModel.swift b/Sources/PassKit/Models/DeviceModel.swift index 6062737..23c13ce 100644 --- a/Sources/PassKit/Models/DeviceModel.swift +++ b/Sources/PassKit/Models/DeviceModel.swift @@ -28,12 +28,12 @@ import FluentKit -/// Represents the `Model` that stores PassKit devices. +/// 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 PassKit provides for the device. + /// The identifier Apple Wallet provides for the device. var libraryIdentifier: String { get set } /// The designated initializer. diff --git a/Sources/PassKit/Models/ErrorLogModel.swift b/Sources/PassKit/Models/ErrorLogModel.swift index f49de87..3153c64 100644 --- a/Sources/PassKit/Models/ErrorLogModel.swift +++ b/Sources/PassKit/Models/ErrorLogModel.swift @@ -28,9 +28,9 @@ import FluentKit -/// Represents the `Model` that stores PassKit error logs. +/// Represents the `Model` that stores Apple Wallet error logs. public protocol ErrorLogModel: Model { - /// The error message provided by PassKit. + /// The error message provided by Apple Wallet. var message: String { get set } /// The designated initializer. diff --git a/Sources/PassKit/WalletError.swift b/Sources/PassKit/WalletError.swift index 6209a7a..db48d6e 100644 --- a/Sources/PassKit/WalletError.swift +++ b/Sources/PassKit/WalletError.swift @@ -1,10 +1,3 @@ -// -// WalletError.swift -// PassKit -// -// Created by Francesco Paolo Severino on 04/07/24. -// - /// 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. diff --git a/Sources/Passes/DTOs/PassJSON.swift b/Sources/Passes/DTOs/PassJSON.swift index c93ecb7..509fa89 100644 --- a/Sources/Passes/DTOs/PassJSON.swift +++ b/Sources/Passes/DTOs/PassJSON.swift @@ -1,10 +1,3 @@ -// -// PassJSON.swift -// PassKit -// -// Created by Francesco Paolo Severino on 28/06/24. -// - /// The structure of a `pass.json` file. public struct PassJSON { /// A protocol that defines the structure of a `pass.json` file. diff --git a/Sources/Passes/DTOs/PersonalizationDictionaryDTO.swift b/Sources/Passes/DTOs/PersonalizationDictionaryDTO.swift index 7ce0988..37b6302 100644 --- a/Sources/Passes/DTOs/PersonalizationDictionaryDTO.swift +++ b/Sources/Passes/DTOs/PersonalizationDictionaryDTO.swift @@ -1,10 +1,3 @@ -// -// PersonalizationDictionaryDTO.swift -// PassKit -// -// Created by Francesco Paolo Severino on 04/07/24. -// - import Vapor struct PersonalizationDictionaryDTO: Content { diff --git a/Sources/Passes/DTOs/PersonalizationJSON.swift b/Sources/Passes/DTOs/PersonalizationJSON.swift index 08fd2a5..17d527a 100644 --- a/Sources/Passes/DTOs/PersonalizationJSON.swift +++ b/Sources/Passes/DTOs/PersonalizationJSON.swift @@ -8,12 +8,12 @@ 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. - var requiredPersonalizationFields: [PersonalizationField] + let requiredPersonalizationFields: [PersonalizationField] /// A brief description of the program. /// /// This is displayed on the signup sheet, under the personalization logo. - var description: String + let description: String /// A description of the program’s terms and conditions. /// @@ -21,7 +21,7 @@ public struct PersonalizationJSON: Codable, Sendable { /// /// 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. - var termsAndConditions: String? + let termsAndConditions: String? /// Initializes a new ``PersonalizationJSON`` instance. /// diff --git a/Sources/Passes/Middleware/PassesService+AsyncModelMiddleware.swift b/Sources/Passes/Middleware/PassesService+AsyncModelMiddleware.swift new file mode 100644 index 0000000..23c37e3 --- /dev/null +++ b/Sources/Passes/Middleware/PassesService+AsyncModelMiddleware.swift @@ -0,0 +1,42 @@ +import FluentKit +import Foundation + +extension PassesService: AsyncModelMiddleware { + public func create(model: PD, on db: any Database, next: any AnyAsyncModelResponder) async throws { + let pass = Pass( + typeIdentifier: PD.typeIdentifier, + authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString() + ) + try await pass.save(on: db) + model._$pass.id = try pass.requireID() + try await next.create(model, on: db) + } + + public func update(model: PD, on db: any Database, next: any AnyAsyncModelResponder) async throws { + let pass = try await model._$pass.get(on: db) + pass.updatedAt = Date.now + try await pass.save(on: db) + try await next.update(model, on: db) + try await self.sendPushNotifications(for: model, on: db) + } +} + +extension PassesServiceCustom: AsyncModelMiddleware { + public func create(model: PD, on db: any Database, next: any AnyAsyncModelResponder) async throws { + let pass = P( + typeIdentifier: PD.typeIdentifier, + authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString() + ) + try await pass.save(on: db) + model._$pass.id = try pass.requireID() + try await next.create(model, on: db) + } + + public func update(model: PD, on db: any Database, next: any AnyAsyncModelResponder) async throws { + let pass = try await model._$pass.get(on: db) + pass.updatedAt = Date.now + try await pass.save(on: db) + try await next.update(model, on: db) + try await self.sendPushNotifications(for: model, on: db) + } +} diff --git a/Sources/Passes/Models/Concrete Models/Pass.swift b/Sources/Passes/Models/Concrete Models/Pass.swift index e723208..33868c3 100644 --- a/Sources/Passes/Models/Concrete Models/Pass.swift +++ b/Sources/Passes/Models/Concrete Models/Pass.swift @@ -1,14 +1,7 @@ -// -// Pass.swift -// PassKit -// -// Created by Francesco Paolo Severino on 29/06/24. -// - import FluentKit import Foundation -/// The `Model` that stores PassKit passes. +/// 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 { diff --git a/Sources/Passes/Models/Concrete Models/PassesDevice.swift b/Sources/Passes/Models/Concrete Models/PassesDevice.swift index 593b62f..a6eb292 100644 --- a/Sources/Passes/Models/Concrete Models/PassesDevice.swift +++ b/Sources/Passes/Models/Concrete Models/PassesDevice.swift @@ -1,14 +1,7 @@ -// -// PassesDevice.swift -// PassKit -// -// Created by Francesco Paolo Severino on 29/06/24. -// - import FluentKit import PassKit -/// The `Model` that stores PassKit passes devices. +/// 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 @@ -20,7 +13,7 @@ final public class PassesDevice: DeviceModel, @unchecked Sendable { @Field(key: PassesDevice.FieldKeys.pushToken) public var pushToken: String - /// The identifier PassKit provides for the device. + /// The identifier Apple Wallet provides for the device. @Field(key: PassesDevice.FieldKeys.libraryIdentifier) public var libraryIdentifier: String diff --git a/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift b/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift index 92b8c5f..11d6102 100644 --- a/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift +++ b/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift @@ -1,16 +1,9 @@ -// -// PassesErrorLog.swift -// PassKit -// -// Created by Francesco Paolo Severino on 29/06/24. -// - import FluentKit import PassKit import struct Foundation.Date -/// The `Model` that stores PassKit passes error logs. +/// 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 @@ -22,7 +15,7 @@ final public class PassesErrorLog: ErrorLogModel, @unchecked Sendable { @Timestamp(key: PassesErrorLog.FieldKeys.createdAt, on: .create) public var createdAt: Date? - /// The error message provided by PassKit. + /// The error message provided by Apple Wallet. @Field(key: PassesErrorLog.FieldKeys.message) public var message: String diff --git a/Sources/Passes/Models/Concrete Models/PassesRegistration.swift b/Sources/Passes/Models/Concrete Models/PassesRegistration.swift index 4ef396f..52ab07d 100644 --- a/Sources/Passes/Models/Concrete Models/PassesRegistration.swift +++ b/Sources/Passes/Models/Concrete Models/PassesRegistration.swift @@ -1,10 +1,3 @@ -// -// PassesRegistration.swift -// PassKit -// -// Created by Francesco Paolo Severino on 29/06/24. -// - import FluentKit /// The `Model` that stores passes registrations. diff --git a/Sources/Passes/Models/Concrete Models/UserPersonalization.swift b/Sources/Passes/Models/Concrete Models/UserPersonalization.swift index 4439fdb..e2c1688 100644 --- a/Sources/Passes/Models/Concrete Models/UserPersonalization.swift +++ b/Sources/Passes/Models/Concrete Models/UserPersonalization.swift @@ -1,10 +1,3 @@ -// -// UserPersonalization.swift -// PassKit -// -// Created by Francesco Paolo Severino on 12/07/24. -// - import FluentKit /// The `Model` that stores user personalization info. diff --git a/Sources/Passes/Models/PassDataModel.swift b/Sources/Passes/Models/PassDataModel.swift index 1c5b96b..47d1e0f 100644 --- a/Sources/Passes/Models/PassDataModel.swift +++ b/Sources/Passes/Models/PassDataModel.swift @@ -28,12 +28,53 @@ import FluentKit -/// Represents the `Model` that stores custom app data associated to PassKit passes. +/// 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 { @@ -47,3 +88,7 @@ extension PassDataModel { 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 index e4fe6a8..a17c312 100644 --- a/Sources/Passes/Models/PassModel.swift +++ b/Sources/Passes/Models/PassModel.swift @@ -29,7 +29,7 @@ import FluentKit import Foundation -/// Represents the `Model` that stores PassKit passes. +/// 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 { diff --git a/Sources/Passes/Models/UserPersonalizationModel.swift b/Sources/Passes/Models/UserPersonalizationModel.swift index ccaa357..030001c 100644 --- a/Sources/Passes/Models/UserPersonalizationModel.swift +++ b/Sources/Passes/Models/UserPersonalizationModel.swift @@ -1,10 +1,3 @@ -// -// UserPersonalizationModel.swift -// PassKit -// -// Created by Francesco Paolo Severino on 12/07/24. -// - import FluentKit /// Represents the `Model` that stores user personalization info. diff --git a/Sources/Passes/Passes.docc/GettingStarted.md b/Sources/Passes/Passes.docc/GettingStarted.md index d56b243..a788f88 100644 --- a/Sources/Passes/Passes.docc/GettingStarted.md +++ b/Sources/Passes/Passes.docc/GettingStarted.md @@ -9,21 +9,23 @@ For all the other custom data needed to generate the pass, such as the barcodes, 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, using a ``PassesDelegate`` that you must implement. +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. +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 struct Foundation.UUID +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? @@ -58,6 +60,23 @@ struct CreatePassData: AsyncMigration { } ``` +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. @@ -79,7 +98,7 @@ struct PassJSONData: PassJSON.Properties { let description: String let formatVersion = PassJSON.FormatVersion.v1 let organizationName = "vapor-community" - let passTypeIdentifier = Environment.get("PASS_TYPE_IDENTIFIER")! + let passTypeIdentifier = PassData.typeIdentifier let serialNumber: String let teamIdentifier = Environment.get("APPLE_TEAM_IDENTIFIER")! @@ -132,41 +151,6 @@ struct PassJSONData: PassJSON.Properties { > Important: You **must** add `api/passes/` to your `webServiceURL`, as shown in the example above. -### Implement the Delegate - -Create a delegate class that implements ``PassesDelegate``. - -Because the files for your pass' template and the method of encoding might vary by pass type, you'll be provided the ``Pass`` for those methods. -In the ``PassesDelegate/encode(pass:db:encoder:)`` method, you'll want to encode a `struct` that conforms to ``PassJSON``. - -```swift -import Vapor -import Fluent -import Passes - -final class PassDelegate: PassesDelegate { - func encode(pass: P, db: Database, encoder: JSONEncoder) async throws -> Data { - // The specific PassData class you use here may vary based on the `pass.typeIdentifier` - // if you have multiple different types of passes, and thus multiple types of pass data. - guard let passData = try await PassData.query(on: db) - .filter(\.$pass.$id == pass.requireID()) - .first() - else { - throw Abort(.internalServerError) - } - guard let data = try? encoder.encode(PassJSONData(data: passData, pass: pass)) else { - throw Abort(.internalServerError) - } - return data - } - - func template(for pass: P, db: Database) async throws -> String { - // The location might vary depending on the type of pass. - "Templates/Passes/" - } -} -``` - ### Initialize the Service Next, initialize the ``PassesService`` inside the `configure.swift` file. @@ -179,13 +163,10 @@ import Fluent import Vapor import Passes -let passDelegate = PassDelegate() - public func configure(_ app: Application) async throws { ... - let passesService = try PassesService( + let passesService = try PassesService( app: app, - delegate: passDelegate, pemWWDRCertificate: Environment.get("PEM_WWDR_CERTIFICATE")!, pemCertificate: Environment.get("PEM_CERTIFICATE")!, pemPrivateKey: Environment.get("PEM_PRIVATE_KEY")! @@ -193,8 +174,6 @@ public func configure(_ app: Application) async throws { } ``` -> Note: Notice how the ``PassesDelegate`` is created as a global variable. You need to ensure that the delegate doesn't go out of scope as soon as the `configure(_:)` method exits. - 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 @@ -215,11 +194,10 @@ import Vapor import PassKit import Passes -let passDelegate = PassDelegate() - public func configure(_ app: Application) async throws { ... let passesService = try PassesServiceCustom< + PassData, MyPassType, MyUserPersonalizationType, MyDeviceType, @@ -227,7 +205,6 @@ public func configure(_ app: Application) async throws { MyErrorLogType >( app: app, - delegate: passDelegate, pemWWDRCertificate: Environment.get("PEM_WWDR_CERTIFICATE")!, pemCertificate: Environment.get("PEM_CERTIFICATE")!, pemPrivateKey: Environment.get("PEM_PRIVATE_KEY")! @@ -240,56 +217,25 @@ public func configure(_ app: Application) async throws { 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) +PassesService.register(migrations: app.migrations) ``` > Important: Register the default models before the migration of your pass data model. ### Pass Data Model Middleware -You'll want to create a model middleware to handle the creation and update of the pass data model. -This middleware could be responsible for creating and linking a ``Pass`` to the pass data model, depending on your requirements. -When your pass data changes, it should also update the ``Pass/updatedAt`` field of the ``Pass`` and send a push notification to all devices registered to that pass. - -```swift -import Vapor -import Fluent -import Passes - -struct PassDataMiddleware: AsyncModelMiddleware { - private unowned let service: PassesService - - init(service: PassesService) { - self.service = service - } +This framework provides a model middleware to handle the creation and update of the pass data model. - // Create the `Pass` and add it to the `PassData` automatically at creation - func create(model: PassData, on db: Database, next: AnyAsyncModelResponder) async throws { - let pass = Pass( - typeIdentifier: Environment.get("PASS_TYPE_IDENTIFIER")!, - authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString()) - try await pass.save(on: db) - model.$pass.id = try pass.requireID() - try await next.create(model, on: db) - } - - func update(model: PassData, on db: Database, next: AnyAsyncModelResponder) async throws { - let pass = try await model.$pass.get(on: db) - pass.updatedAt = Date() - try await pass.save(on: db) - try await next.update(model, on: db) - try await service.sendPushNotifications(for: pass, on: db) - } -} -``` +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 could register it in the `configure.swift` file. +You can register it like so (either with a ``PassesService`` or a ``PassesServiceCustom``): ```swift -app.databases.middleware.use(PassDataMiddleware(service: passesService), on: .psql) +app.databases.middleware.use(passesService, on: .psql) ``` -> Important: 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. +> 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 @@ -316,15 +262,14 @@ Then use the object inside your route handlers to generate the pass bundle with ```swift fileprivate func passHandler(_ req: Request) async throws -> Response { ... - guard let passData = try await PassData.query(on: req.db) + guard let pass = try await PassData.query(on: req.db) .filter(...) - .with(\.$pass) .first() else { throw Abort(.notFound) } - let bundle = try await passesService.build(pass: passData.pass, on: req.db) + 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") @@ -346,8 +291,7 @@ The MIME type for a bundle of passes is "`application/vnd.apple.pkpasses`". ```swift fileprivate func passesHandler(_ req: Request) async throws -> Response { ... - let passesData = try await PassData.query(on: req.db).with(\.$pass).all() - let passes = passesData.map { $0.pass } + 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) diff --git a/Sources/Passes/Passes.docc/Passes.md b/Sources/Passes/Passes.docc/Passes.md index 3d3ed98..4f962f0 100644 --- a/Sources/Passes/Passes.docc/Passes.md +++ b/Sources/Passes/Passes.docc/Passes.md @@ -22,11 +22,11 @@ For information on Apple Wallet passes, see the [Apple Developer Documentation]( ### Essentials - +- ``PassDataModel`` - ``PassJSON`` ### Building and Distribution -- ``PassesDelegate`` - ``PassesService`` - ``PassesServiceCustom`` @@ -41,7 +41,6 @@ For information on Apple Wallet passes, see the [Apple Developer Documentation]( - ``PassModel`` - ``PassesRegistrationModel`` -- ``PassDataModel`` ### Personalized Passes (⚠️ WIP) diff --git a/Sources/Passes/Passes.docc/Personalization.md b/Sources/Passes/Passes.docc/Personalization.md index 7756781..c3814ae 100644 --- a/Sources/Passes/Passes.docc/Personalization.md +++ b/Sources/Passes/Passes.docc/Personalization.md @@ -12,52 +12,31 @@ Pass Personalization lets you create passes, referred to as personalizable passe 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 Delegate +### Implement the Data Model -You'll have to make a few changes to ``PassesDelegate`` to support personalizable passes. +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 ``PassesDelegate/personalizationJSON(for:db:)`` method, which gives you the ``Pass`` to encode. +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 ``PassesDelegate/template(for:db:)`` 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. +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 ``PassesDelegate/encode(pass:db:encoder:)`` method as usual, but remember to use in the ``PassJSON`` initializer the user info that will be saved inside ``Pass/userPersonalization`` after the pass has been personalized. +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 -import Vapor -import Fluent -import Passes - -final class PassDelegate: PassesDelegate { - func encode(pass: P, db: Database, encoder: JSONEncoder) async throws -> Data { - // Here encode the pass JSON data as usual. - guard let passData = try await PassData.query(on: db) - .filter(\.$pass.$id == pass.requireID()) - .first() - else { - throw Abort(.internalServerError) - } - guard let data = try? encoder.encode(PassJSONData(data: passData, pass: pass)) else { - throw Abort(.internalServerError) - } - return data +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(for pass: P, db: any Database) async throws -> PersonalizationJSON? { - guard let passData = try await PassData.query(on: db) - .filter(\.$pass.$id == pass.requireID()) - .with(\.$pass) - .first() - else { - throw Abort(.internalServerError) - } - - if try await passData.pass.$userPersonalization.get(on: db) == nil { + 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], @@ -69,15 +48,8 @@ final class PassDelegate: PassesDelegate { } } - func template(for pass: P, db: Database) async throws -> String { - guard let passData = try await PassData.query(on: db) - .filter(\.$pass.$id == pass.requireID()) - .first() - else { - throw Abort(.internalServerError) - } - - if passData.requiresPersonalization { + 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/" @@ -91,7 +63,7 @@ final class PassDelegate: PassesDelegate { ### Implement the Web Service -After implementing the delegate methods, there is nothing else you have to do. +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. @@ -103,10 +75,10 @@ Wallet will then send the user personal information to your server, which will b 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 ``PassesDelegate/personalizationJSON(for:db:)`` method returns `nil` when the pass has already been personalized. +> 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 -### Delegate Method +### Data Model Method -- ``PassesDelegate/personalizationJSON(for:db:)`` +- ``PassDataModel/personalizationJSON(on:)`` diff --git a/Sources/Passes/PassesDelegate.swift b/Sources/Passes/PassesDelegate.swift deleted file mode 100644 index 7b9c46d..0000000 --- a/Sources/Passes/PassesDelegate.swift +++ /dev/null @@ -1,87 +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 - -/// The delegate which is responsible for generating the pass files. -public protocol PassesDelegate: AnyObject, Sendable { - /// 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` - /// - /// - Parameters: - /// - pass: The pass data from the SQL server. - /// - db: The SQL database to query against. - /// - /// - Returns: A URL path which points to the template data for the pass. - func template(for pass: P, db: any Database) async throws -> String - - /// Encode the pass into JSON. - /// - /// This method should generate the entire pass JSON. You are provided with - /// the pass data from the SQL database and you should return a properly - /// formatted pass file encoding. - /// - /// - Parameters: - /// - pass: The pass data from the SQL server - /// - db: The SQL database to query against. - /// - encoder: The `JSONEncoder` which you should use. - /// - Returns: The encoded pass JSON data. - /// - /// > Tip: See the [`Pass`](https://developer.apple.com/documentation/walletpasses/pass) object to understand the keys. - func encode(pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data - - /// Create the personalization JSON struct. - /// - /// This method of the ``PassesDelegate`` should generate the entire personalization JSON struct. - /// You are provided with the pass data from the SQL database and, - /// 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`. - /// - /// - Parameters: - /// - pass: The pass data from the SQL server. - /// - db: The SQL database to query against. - /// - Returns: A ``PersonalizationJSON`` or `nil` if the pass does not require personalization. - func personalizationJSON(for pass: P, db: any Database) async throws -> PersonalizationJSON? -} - -extension PassesDelegate { - public func personalizationJSON(for pass: P, db: any Database) async throws -> PersonalizationJSON? { - return nil - } -} diff --git a/Sources/Passes/PassesService.swift b/Sources/Passes/PassesService.swift index d32f433..18bf6e2 100644 --- a/Sources/Passes/PassesService.swift +++ b/Sources/Passes/PassesService.swift @@ -29,15 +29,14 @@ import FluentKit import Vapor -/// The main class that handles PassKit passes. -public final class PassesService: Sendable { - private let service: PassesServiceCustom +/// The main class that handles Apple Wallet passes. +public final class PassesService: Sendable where Pass == PD.PassType { + private let service: PassesServiceCustom - /// Initializes the service and registers all the routes required for PassKit to work. + /// Initializes the service and registers all the routes required for Apple Wallet to work. /// /// - Parameters: /// - app: The `Vapor.Application` to use in route handlers and APNs. - /// - delegate: The ``PassesDelegate`` to use for pass generation. /// - pushRoutesMiddleware: The `Middleware` to use for push notification routes. If `nil`, push routes will not be registered. /// - logger: The `Logger` to use. /// - pemWWDRCertificate: Apple's WWDR.pem certificate in PEM format. @@ -47,7 +46,6 @@ public final class PassesService: Sendable { /// - openSSLPath: The location of the `openssl` command as a file path. public init( app: Application, - delegate: any PassesDelegate, pushRoutesMiddleware: (any Middleware)? = nil, logger: Logger? = nil, pemWWDRCertificate: String, @@ -58,7 +56,6 @@ public final class PassesService: Sendable { ) throws { self.service = try .init( app: app, - delegate: delegate, pushRoutesMiddleware: pushRoutesMiddleware, logger: logger, pemWWDRCertificate: pemWWDRCertificate, @@ -76,7 +73,7 @@ public final class PassesService: Sendable { /// - db: The `Database` to use. /// /// - Returns: The generated pass content as `Data`. - public func build(pass: Pass, on db: any Database) async throws -> Data { + public func build(pass: PD, on db: any Database) async throws -> Data { try await service.build(pass: pass, on: db) } @@ -91,11 +88,11 @@ public final class PassesService: Sendable { /// - db: The `Database` to use. /// /// - Returns: The bundle of passes as `Data`. - public func build(passes: [Pass], on db: any Database) async throws -> Data { + public func build(passes: [PD], on db: any Database) async throws -> Data { try await service.build(passes: passes, on: db) } - /// Adds the migrations for PassKit passes models. + /// Adds the migrations for Apple Wallet passes models. /// /// - Parameter migrations: The `Migrations` object to add the migrations to. public static func register(migrations: Migrations) { @@ -111,7 +108,7 @@ public final class PassesService: Sendable { /// - Parameters: /// - pass: The pass to send the notifications for. /// - db: The `Database` to use. - public func sendPushNotifications(for pass: Pass, on db: any Database) async throws { + public func sendPushNotifications(for pass: PD, on db: any Database) async throws { try await service.sendPushNotifications(for: pass, on: db) } } diff --git a/Sources/Passes/PassesServiceCustom.swift b/Sources/Passes/PassesServiceCustom.swift index 6c6fb31..f454e5d 100644 --- a/Sources/Passes/PassesServiceCustom.swift +++ b/Sources/Passes/PassesServiceCustom.swift @@ -1,10 +1,3 @@ -// -// PassesServiceCustom.swift -// PassKit -// -// Created by Francesco Paolo Severino on 29/06/24. -// - import APNS import APNSCore import Fluent @@ -18,15 +11,15 @@ import Zip /// Class to handle ``PassesService``. /// /// The generics should be passed in this order: +/// - Pass Data Model /// - Pass Type /// - User Personalization Type /// - Device Type /// - Registration Type /// - Error Log Type -public final class PassesServiceCustom: Sendable -where P == R.PassType, D == R.DeviceType, U == P.UserPersonalizationType { +public final class PassesServiceCustom: Sendable +where P == PD.PassType, P == R.PassType, D == R.DeviceType, U == P.UserPersonalizationType { private unowned let app: Application - private unowned let delegate: any PassesDelegate private let logger: Logger? private let pemWWDRCertificate: String @@ -37,11 +30,10 @@ where P == R.PassType, D == R.DeviceType, U == P.UserPersonalizationType { private let encoder = JSONEncoder() - /// Initializes the service and registers all the routes required for PassKit to work. + /// Initializes the service and registers all the routes required for Apple Wallet to work. /// /// - Parameters: /// - app: The `Vapor.Application` to use in route handlers and APNs. - /// - delegate: The ``PassesDelegate`` to use for pass generation. /// - pushRoutesMiddleware: The `Middleware` to use for push notification routes. If `nil`, push routes will not be registered. /// - logger: The `Logger` to use. /// - pemWWDRCertificate: Apple's WWDR.pem certificate in PEM format. @@ -51,7 +43,6 @@ where P == R.PassType, D == R.DeviceType, U == P.UserPersonalizationType { /// - openSSLPath: The location of the `openssl` command as a file path. public init( app: Application, - delegate: any PassesDelegate, pushRoutesMiddleware: (any Middleware)? = nil, logger: Logger? = nil, pemWWDRCertificate: String, @@ -61,7 +52,6 @@ where P == R.PassType, D == R.DeviceType, U == P.UserPersonalizationType { openSSLPath: String = "/usr/bin/openssl" ) throws { self.app = app - self.delegate = delegate self.logger = logger self.pemWWDRCertificate = pemWWDRCertificate @@ -103,29 +93,30 @@ where P == R.PassType, D == R.DeviceType, U == P.UserPersonalizationType { isDefault: false ) + let passTypeIdentifier = PathComponent(stringLiteral: PD.typeIdentifier) let v1 = app.grouped("api", "passes", "v1") v1.get( - "devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", + "devices", ":deviceLibraryIdentifier", "registrations", passTypeIdentifier, use: { try await self.passesForDevice(req: $0) } ) v1.post("log", use: { try await self.logError(req: $0) }) - v1.post("passes", ":passTypeIdentifier", ":passSerial", "personalize", use: { try await self.personalizedPass(req: $0) }) + v1.post("passes", passTypeIdentifier, ":passSerial", "personalize", use: { try await self.personalizedPass(req: $0) }) let v1auth = v1.grouped(ApplePassMiddleware

()) v1auth.post( - "devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", ":passSerial", + "devices", ":deviceLibraryIdentifier", "registrations", passTypeIdentifier, ":passSerial", use: { try await self.registerDevice(req: $0) } ) - v1auth.get("passes", ":passTypeIdentifier", ":passSerial", use: { try await self.latestVersionOfPass(req: $0) }) + v1auth.get("passes", passTypeIdentifier, ":passSerial", use: { try await self.latestVersionOfPass(req: $0) }) v1auth.delete( - "devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", ":passSerial", + "devices", ":deviceLibraryIdentifier", "registrations", passTypeIdentifier, ":passSerial", use: { try await self.unregisterDevice(req: $0) } ) if let pushRoutesMiddleware { let pushAuth = v1.grouped(pushRoutesMiddleware) - pushAuth.post("push", ":passTypeIdentifier", ":passSerial", use: { try await self.pushUpdatesForPass(req: $0) }) - pushAuth.get("push", ":passTypeIdentifier", ":passSerial", use: { try await self.tokensForPassUpdate(req: $0) }) + pushAuth.post("push", passTypeIdentifier, ":passSerial", use: { try await self.pushUpdatesForPass(req: $0) }) + pushAuth.get("push", passTypeIdentifier, ":passSerial", use: { try await self.tokensForPassUpdate(req: $0) }) } } } @@ -145,11 +136,10 @@ extension PassesServiceCustom { guard let serial = req.parameters.get("passSerial", as: UUID.self) else { throw Abort(.badRequest) } - let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! guard let pass = try await P.query(on: req.db) - .filter(\._$typeIdentifier == passTypeIdentifier) + .filter(\._$typeIdentifier == PD.typeIdentifier) .filter(\._$id == serial) .first() else { @@ -190,12 +180,11 @@ extension PassesServiceCustom { fileprivate func passesForDevice(req: Request) async throws -> PassesForDeviceDTO { logger?.debug("Called passesForDevice") - let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! var query = R.for( deviceLibraryIdentifier: deviceLibraryIdentifier, - typeIdentifier: passTypeIdentifier, + typeIdentifier: PD.typeIdentifier, on: req.db ) if let since: TimeInterval = req.query["passesUpdatedSince"] { @@ -229,15 +218,13 @@ extension PassesServiceCustom { ifModifiedSince = ims } - guard let passTypeIdentifier = req.parameters.get("passTypeIdentifier"), - let id = req.parameters.get("passSerial", as: UUID.self) - else { + guard let id = req.parameters.get("passSerial", as: UUID.self) else { throw Abort(.badRequest) } guard let pass = try await P.query(on: req.db) .filter(\._$id == id) - .filter(\._$typeIdentifier == passTypeIdentifier) + .filter(\._$typeIdentifier == PD.typeIdentifier) .first() else { throw Abort(.notFound) @@ -247,6 +234,14 @@ extension PassesServiceCustom { throw Abort(.notModified) } + guard + let passData = try await PD.query(on: req.db) + .filter(\._$pass.$id == id) + .first() + else { + throw Abort(.notFound) + } + var headers = HTTPHeaders() headers.add(name: .contentType, value: "application/vnd.apple.pkpass") headers.lastModified = HTTPHeaders.LastModified(pass.updatedAt ?? Date.distantPast) @@ -254,7 +249,7 @@ extension PassesServiceCustom { return try await Response( status: .ok, headers: headers, - body: Response.Body(data: self.build(pass: pass, on: req.db)) + body: Response.Body(data: self.build(pass: passData, on: req.db)) ) } @@ -264,13 +259,12 @@ extension PassesServiceCustom { guard let passId = req.parameters.get("passSerial", as: UUID.self) else { throw Abort(.badRequest) } - let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! guard let r = try await R.for( deviceLibraryIdentifier: deviceLibraryIdentifier, - typeIdentifier: passTypeIdentifier, + typeIdentifier: PD.typeIdentifier, on: req.db ) .filter(P.self, \._$id == passId) @@ -303,15 +297,13 @@ extension PassesServiceCustom { fileprivate func personalizedPass(req: Request) async throws -> Response { logger?.debug("Called personalizedPass") - guard let passTypeIdentifier = req.parameters.get("passTypeIdentifier"), - let id = req.parameters.get("passSerial", as: UUID.self) - else { + guard let id = req.parameters.get("passSerial", as: UUID.self) else { throw Abort(.badRequest) } guard let pass = try await P.query(on: req.db) .filter(\._$id == id) - .filter(\._$typeIdentifier == passTypeIdentifier) + .filter(\._$typeIdentifier == PD.typeIdentifier) .first() else { throw Abort(.notFound) @@ -349,12 +341,11 @@ extension PassesServiceCustom { guard let id = req.parameters.get("passSerial", as: UUID.self) else { throw Abort(.badRequest) } - let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! guard let pass = try await P.query(on: req.db) .filter(\._$id == id) - .filter(\._$typeIdentifier == passTypeIdentifier) + .filter(\._$typeIdentifier == PD.typeIdentifier) .first() else { throw Abort(.notFound) @@ -370,12 +361,11 @@ extension PassesServiceCustom { guard let id = req.parameters.get("passSerial", as: UUID.self) else { throw Abort(.badRequest) } - let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! guard let pass = try await P.query(on: req.db) .filter(\._$id == id) - .filter(\._$typeIdentifier == passTypeIdentifier) + .filter(\._$typeIdentifier == PD.typeIdentifier) .first() else { throw Abort(.notFound) @@ -390,9 +380,13 @@ extension PassesServiceCustom { /// Sends push notifications for a given pass. /// /// - Parameters: - /// - pass: The pass to send the notifications for. + /// - passData: The pass to send the notifications for. /// - db: The `Database` to use. - public func sendPushNotifications(for pass: P, on db: any Database) async throws { + public func sendPushNotifications(for passData: PD, on db: any Database) async throws { + try await self.sendPushNotifications(for: passData._$pass.get(on: db), on: db) + } + + private func sendPushNotifications(for pass: P, on db: any Database) async throws { let registrations = try await Self.registrations(for: pass, on: db) for reg in registrations { let backgroundNotification = APNSBackgroundNotification( @@ -420,7 +414,7 @@ extension PassesServiceCustom { .join(parent: \._$device) .with(\._$pass) .with(\._$device) - .filter(P.self, \._$typeIdentifier == pass._$typeIdentifier.value!) + .filter(P.self, \._$typeIdentifier == PD.typeIdentifier) .filter(P.self, \._$id == pass.requireID()) .all() } @@ -501,8 +495,8 @@ extension PassesServiceCustom { /// - db: The `Database` to use. /// /// - Returns: The generated pass content as `Data`. - public func build(pass: P, on db: any Database) async throws -> Data { - let filesDirectory = try await URL(fileURLWithPath: delegate.template(for: pass, db: db), isDirectory: true) + 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 { @@ -515,12 +509,12 @@ extension PassesServiceCustom { var files: [ArchiveFile] = [] - let passJSON = try await self.delegate.encode(pass: pass, db: db, encoder: self.encoder) + 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 self.delegate.personalizationJSON(for: pass, db: db) { + 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)) @@ -556,7 +550,7 @@ extension PassesServiceCustom { /// - db: The `Database` to use. /// /// - Returns: The bundle of passes as `Data`. - public func build(passes: [P], on db: any Database) async throws -> Data { + public func build(passes: [PD], on db: any Database) async throws -> Data { guard passes.count > 1 && passes.count <= 10 else { throw WalletError.invalidNumberOfPasses } diff --git a/Tests/OrdersTests/OrderData.swift b/Tests/OrdersTests/OrderData.swift deleted file mode 100644 index 627ecd3..0000000 --- a/Tests/OrdersTests/OrderData.swift +++ /dev/null @@ -1,122 +0,0 @@ -import Fluent -import Orders -import Vapor - -import struct Foundation.UUID - -final class OrderData: OrderDataModel, @unchecked Sendable { - static let schema = OrderData.FieldKeys.schemaName - - @ID(key: .id) - var id: UUID? - - @Field(key: OrderData.FieldKeys.title) - var title: String - - @Parent(key: OrderData.FieldKeys.orderID) - var order: Order - - init() {} - - init(id: UUID? = nil, title: String) { - self.id = id - self.title = title - } -} - -struct CreateOrderData: AsyncMigration { - func prepare(on database: any Database) async throws { - try await database.schema(OrderData.FieldKeys.schemaName) - .id() - .field(OrderData.FieldKeys.title, .string, .required) - .field(OrderData.FieldKeys.orderID, .uuid, .required, .references(Order.schema, .id, onDelete: .cascade)) - .create() - } - - func revert(on database: any Database) async throws { - try await database.schema(OrderData.FieldKeys.schemaName).delete() - } -} - -extension OrderData { - enum FieldKeys { - static let schemaName = "order_data" - static let title = FieldKey(stringLiteral: "title") - static let orderID = FieldKey(stringLiteral: "order_id") - } -} - -extension OrderJSON.SchemaVersion: Decodable {} -extension OrderJSON.OrderType: Decodable {} -extension OrderJSON.OrderStatus: Decodable {} - -struct OrderJSONData: OrderJSON.Properties, Decodable { - let schemaVersion = OrderJSON.SchemaVersion.v1 - let orderTypeIdentifier = "order.com.example.pet-store" - 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!) - } -} - -struct OrderDataMiddleware: AsyncModelMiddleware { - private unowned let service: OrdersService - - init(service: OrdersService) { - self.service = service - } - - func create(model: OrderData, on db: any Database, next: any AnyAsyncModelResponder) async throws { - let order = Order( - typeIdentifier: "order.com.example.pet-store", - authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString() - ) - try await order.save(on: db) - model.$order.id = try order.requireID() - try await next.create(model, on: db) - } - - func update(model: OrderData, on db: any Database, next: any AnyAsyncModelResponder) async throws { - let order = try await model.$order.get(on: db) - order.updatedAt = Date() - try await order.save(on: db) - try await next.update(model, on: db) - try await service.sendPushNotifications(for: order, on: db) - } -} diff --git a/Tests/OrdersTests/OrdersTests.swift b/Tests/OrdersTests/OrdersTests.swift index 4d41358..2fb7901 100644 --- a/Tests/OrdersTests/OrdersTests.swift +++ b/Tests/OrdersTests/OrdersTests.swift @@ -1,4 +1,3 @@ -import FluentKit import PassKit import Testing import XCTVapor @@ -17,7 +16,9 @@ struct OrdersTests { let orderData = OrderData(title: "Test Order") try await orderData.create(on: app.db) let order = try await orderData.$order.get(on: app.db) - let data = try await ordersService.build(order: order, on: app.db) + + let data = try await ordersService.build(order: orderData, on: app.db) + let orderURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).order") try data.write(to: orderURL) let orderFolder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -327,7 +328,7 @@ struct OrdersTests { try await orderData.create(on: app.db) let order = try await orderData._$order.get(on: app.db) - try await ordersService.sendPushNotifications(for: order, on: app.db) + try await ordersService.sendPushNotifications(for: orderData, on: app.db) let deviceLibraryIdentifier = "abcdefg" let pushToken = "1234567890" @@ -383,7 +384,7 @@ struct OrdersTests { ) if !useEncryptedKey { - // Test `OrderDataMiddleware` update method + // Test `AsyncModelMiddleware` update method orderData.title = "Test Order 2" do { try await orderData.update(on: app.db) diff --git a/Tests/OrdersTests/TestOrdersDelegate.swift b/Tests/OrdersTests/TestOrdersDelegate.swift deleted file mode 100644 index b5b45bb..0000000 --- a/Tests/OrdersTests/TestOrdersDelegate.swift +++ /dev/null @@ -1,24 +0,0 @@ -import FluentKit -import Orders -import Vapor - -final class TestOrdersDelegate: OrdersDelegate { - func encode(order: O, db: any Database, encoder: JSONEncoder) async throws -> Data { - guard - let orderData = try await OrderData.query(on: db) - .filter(\.$order.$id == order.requireID()) - .with(\.$order) - .first() - else { - throw Abort(.internalServerError) - } - guard let data = try? encoder.encode(OrderJSONData(data: orderData, order: orderData.order)) else { - throw Abort(.internalServerError) - } - return data - } - - func template(for: O, db: any Database) async throws -> String { - "\(FileManager.default.currentDirectoryPath)/Tests/OrdersTests/Templates/" - } -} diff --git a/Tests/OrdersTests/Utils/OrderData.swift b/Tests/OrdersTests/Utils/OrderData.swift new file mode 100644 index 0000000..180d291 --- /dev/null +++ b/Tests/OrdersTests/Utils/OrderData.swift @@ -0,0 +1,57 @@ +import Fluent +import Foundation +import Orders + +final class OrderData: OrderDataModel, @unchecked Sendable { + static let schema = OrderData.FieldKeys.schemaName + + static let typeIdentifier = "order.com.example.pet-store" + + @ID(key: .id) + var id: UUID? + + @Field(key: OrderData.FieldKeys.title) + var title: String + + @Parent(key: OrderData.FieldKeys.orderID) + var order: Order + + init() {} + + init(id: UUID? = nil, title: String) { + self.id = id + self.title = title + } +} + +struct CreateOrderData: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(OrderData.FieldKeys.schemaName) + .id() + .field(OrderData.FieldKeys.title, .string, .required) + .field(OrderData.FieldKeys.orderID, .uuid, .required, .references(Order.schema, .id, onDelete: .cascade)) + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(OrderData.FieldKeys.schemaName).delete() + } +} + +extension OrderData { + enum FieldKeys { + static let schemaName = "order_data" + static let title = FieldKey(stringLiteral: "title") + static let orderID = FieldKey(stringLiteral: "order_id") + } +} + +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 { + "\(FileManager.default.currentDirectoryPath)/Tests/OrdersTests/Templates/" + } +} diff --git a/Tests/OrdersTests/Utils/OrderJSONData.swift b/Tests/OrdersTests/Utils/OrderJSONData.swift new file mode 100644 index 0000000..d486ceb --- /dev/null +++ b/Tests/OrdersTests/Utils/OrderJSONData.swift @@ -0,0 +1,51 @@ +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/withApp.swift b/Tests/OrdersTests/Utils/withApp.swift similarity index 77% rename from Tests/OrdersTests/withApp.swift rename to Tests/OrdersTests/Utils/withApp.swift index 8928800..a1c889a 100644 --- a/Tests/OrdersTests/withApp.swift +++ b/Tests/OrdersTests/Utils/withApp.swift @@ -8,21 +8,19 @@ import Zip func withApp( useEncryptedKey: Bool = false, - _ body: (Application, OrdersService) async throws -> Void + _ body: (Application, OrdersService) async throws -> Void ) async throws { let app = try await Application.make(.testing) do { try #require(isLoggingConfigured) app.databases.use(.sqlite(.memory), as: .sqlite) - OrdersService.register(migrations: app.migrations) + OrdersService.register(migrations: app.migrations) app.migrations.add(CreateOrderData()) try await app.autoMigrate() - let delegate = TestOrdersDelegate() - let ordersService = try OrdersService( + let ordersService = try OrdersService( app: app, - delegate: delegate, pushRoutesMiddleware: SecretMiddleware(secret: "foo"), logger: app.logger, pemWWDRCertificate: TestCertificate.pemWWDRCertificate, @@ -30,7 +28,7 @@ func withApp( pemPrivateKey: useEncryptedKey ? TestCertificate.encryptedPemPrivateKey : TestCertificate.pemPrivateKey, pemPrivateKeyPassword: useEncryptedKey ? "password" : nil ) - app.databases.middleware.use(OrderDataMiddleware(service: ordersService), on: .sqlite) + app.databases.middleware.use(ordersService, on: .sqlite) Zip.addCustomFileExtension("order") @@ -38,6 +36,7 @@ func withApp( try await app.autoRevert() } catch { + try? await app.autoRevert() try await app.asyncShutdown() throw error } diff --git a/Tests/PassesTests/PassesTests.swift b/Tests/PassesTests/PassesTests.swift index 9bc3637..3661783 100644 --- a/Tests/PassesTests/PassesTests.swift +++ b/Tests/PassesTests/PassesTests.swift @@ -1,4 +1,3 @@ -import FluentKit import PassKit import Testing import XCTVapor @@ -17,7 +16,9 @@ struct PassesTests { let passData = PassData(title: "Test Pass") try await passData.create(on: app.db) let pass = try await passData.$pass.get(on: app.db) - let data = try await passesService.build(pass: pass, on: app.db) + + let data = try await passesService.build(pass: passData, on: app.db) + let passURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).pkpass") try data.write(to: passURL) let passFolder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -54,17 +55,15 @@ struct PassesTests { try await withApp { app, passesService in let passData1 = PassData(title: "Test Pass 1") try await passData1.create(on: app.db) - let pass1 = try await passData1.$pass.get(on: app.db) let passData2 = PassData(title: "Test Pass 2") try await passData2.create(on: app.db) - let pass2 = try await passData2._$pass.get(on: app.db) - let data = try await passesService.build(passes: [pass1, pass2], on: app.db) + let data = try await passesService.build(passes: [passData1, passData2], on: app.db) #expect(data != nil) do { - let data = try await passesService.build(passes: [pass1], on: app.db) + let data = try await passesService.build(passes: [passData1], on: app.db) Issue.record("Expected error, got \(data)") } catch let error as WalletError { #expect(error == .invalidNumberOfPasses) @@ -78,7 +77,9 @@ struct PassesTests { let passData = PassData(title: "Personalize") try await passData.create(on: app.db) let pass = try await passData.$pass.get(on: app.db) - let data = try await passesService.build(pass: pass, on: app.db) + + let data = try await passesService.build(pass: passData, on: app.db) + let passURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).pkpass") try data.write(to: passURL) let passFolder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -463,9 +464,9 @@ struct PassesTests { let passData = PassData(title: "Test Pass") try await passData.create(on: app.db) - let pass = try await passData._$pass.get(on: app.db) + let pass = try await passData.$pass.get(on: app.db) - try await passesService.sendPushNotifications(for: pass, on: app.db) + try await passesService.sendPushNotifications(for: passData, on: app.db) let deviceLibraryIdentifier = "abcdefg" let pushToken = "1234567890" @@ -521,7 +522,7 @@ struct PassesTests { ) if !useEncryptedKey { - // Test `PassDataMiddleware` update method + // Test `AsyncModelMiddleware` update method passData.title = "Test Pass 2" do { try await passData.update(on: app.db) @@ -541,22 +542,4 @@ struct PassesTests { #expect(WalletError.noSourceFiles == WalletError.noSourceFiles) #expect(WalletError.noOpenSSLExecutable != WalletError.invalidNumberOfPasses) } - - @Test("Default PassesDelegate Properties") - func defaultDelegate() async throws { - final class DefaultPassesDelegate: PassesDelegate { - func template(for pass: P, db: any Database) async throws -> String { "" } - func encode(pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data { Data() } - } - - let defaultDelegate = DefaultPassesDelegate() - - try await withApp { app, passesService in - let passData = PassData(title: "Test Pass") - try await passData.create(on: app.db) - let pass = try await passData.$pass.get(on: app.db) - let data = try await defaultDelegate.personalizationJSON(for: pass, db: app.db) - #expect(data == nil) - } - } } diff --git a/Tests/PassesTests/TestPassesDelegate.swift b/Tests/PassesTests/TestPassesDelegate.swift deleted file mode 100644 index c323c8d..0000000 --- a/Tests/PassesTests/TestPassesDelegate.swift +++ /dev/null @@ -1,46 +0,0 @@ -import FluentKit -import Passes -import Vapor - -final class TestPassesDelegate: PassesDelegate { - func encode(pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data { - guard - let passData = try await PassData.query(on: db) - .filter(\.$pass.$id == pass.requireID()) - .with(\.$pass) - .first() - else { - throw Abort(.internalServerError) - } - guard let data = try? encoder.encode(PassJSONData(data: passData, pass: passData.pass)) else { - throw Abort(.internalServerError) - } - return data - } - - func personalizationJSON(for pass: P, db: any Database) async throws -> PersonalizationJSON? { - guard - let passData = try await PassData.query(on: db) - .filter(\.$pass.$id == pass.id!) - .with(\.$pass) - .first() - else { - throw Abort(.internalServerError) - } - - if passData.title != "Personalize" { return nil } - - if try await passData.pass.$userPersonalization.get(on: db) == nil { - return PersonalizationJSON( - requiredPersonalizationFields: [.name, .postalCode, .emailAddress, .phoneNumber], - description: "Hello, World!" - ) - } else { - return nil - } - } - - func template(for pass: P, db: any Database) async throws -> String { - "\(FileManager.default.currentDirectoryPath)/Tests/PassesTests/Templates/" - } -} diff --git a/Tests/PassesTests/Utils/PassData.swift b/Tests/PassesTests/Utils/PassData.swift new file mode 100644 index 0000000..063d806 --- /dev/null +++ b/Tests/PassesTests/Utils/PassData.swift @@ -0,0 +1,70 @@ +import Fluent +import Foundation +import Passes + +final class PassData: PassDataModel, @unchecked Sendable { + static let schema = PassData.FieldKeys.schemaName + + static let typeIdentifier = "pass.com.vapor-community.Passes" + + @ID(key: .id) + var id: UUID? + + @Field(key: PassData.FieldKeys.title) + var title: String + + @Parent(key: PassData.FieldKeys.passID) + var pass: Pass + + init() {} + + init(id: UUID? = nil, title: String) { + self.id = id + self.title = title + } +} + +struct CreatePassData: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(PassData.FieldKeys.schemaName) + .id() + .field(PassData.FieldKeys.title, .string, .required) + .field(PassData.FieldKeys.passID, .uuid, .required, .references(Pass.schema, .id, onDelete: .cascade)) + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(PassData.FieldKeys.schemaName).delete() + } +} + +extension PassData { + enum FieldKeys { + static let schemaName = "pass_data" + static let title = FieldKey(stringLiteral: "title") + static let passID = FieldKey(stringLiteral: "pass_id") + } +} + +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 { + "\(FileManager.default.currentDirectoryPath)/Tests/PassesTests/Templates/" + } + + 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 { + return PersonalizationJSON( + requiredPersonalizationFields: [.name, .postalCode, .emailAddress, .phoneNumber], + description: "Hello, World!" + ) + } else { + return nil + } + } +} diff --git a/Tests/PassesTests/PassData.swift b/Tests/PassesTests/Utils/PassJSONData.swift similarity index 54% rename from Tests/PassesTests/PassData.swift rename to Tests/PassesTests/Utils/PassJSONData.swift index c5388c4..91f15ed 100644 --- a/Tests/PassesTests/PassData.swift +++ b/Tests/PassesTests/Utils/PassJSONData.swift @@ -1,50 +1,4 @@ -import Fluent import Passes -import Vapor - -import struct Foundation.UUID - -final class PassData: PassDataModel, @unchecked Sendable { - static let schema = PassData.FieldKeys.schemaName - - @ID(key: .id) - var id: UUID? - - @Field(key: PassData.FieldKeys.title) - var title: String - - @Parent(key: PassData.FieldKeys.passID) - var pass: Pass - - init() {} - - init(id: UUID? = nil, title: String) { - self.id = id - self.title = title - } -} - -struct CreatePassData: AsyncMigration { - func prepare(on database: any Database) async throws { - try await database.schema(PassData.FieldKeys.schemaName) - .id() - .field(PassData.FieldKeys.title, .string, .required) - .field(PassData.FieldKeys.passID, .uuid, .required, .references(Pass.schema, .id, onDelete: .cascade)) - .create() - } - - func revert(on database: any Database) async throws { - try await database.schema(PassData.FieldKeys.schemaName).delete() - } -} - -extension PassData { - enum FieldKeys { - static let schemaName = "pass_data" - static let title = FieldKey(stringLiteral: "title") - static let passID = FieldKey(stringLiteral: "pass_id") - } -} extension PassJSON.FormatVersion: Decodable {} extension PassJSON.BarcodeFormat: Decodable {} @@ -54,7 +8,7 @@ struct PassJSONData: PassJSON.Properties, Decodable { let description: String let formatVersion = PassJSON.FormatVersion.v1 let organizationName = "vapor-community" - let passTypeIdentifier = "pass.com.vapor-community.PassKit" + let passTypeIdentifier = PassData.typeIdentifier let serialNumber: String let teamIdentifier = "K6512ZA2S5" @@ -116,29 +70,3 @@ struct PassJSONData: PassJSON.Properties, Decodable { self.authenticationToken = pass.authenticationToken } } - -struct PassDataMiddleware: AsyncModelMiddleware { - private unowned let service: PassesService - - init(service: PassesService) { - self.service = service - } - - func create(model: PassData, on db: any Database, next: any AnyAsyncModelResponder) async throws { - let pass = Pass( - typeIdentifier: "pass.com.vapor-community.PassKit", - authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString() - ) - try await pass.save(on: db) - model.$pass.id = try pass.requireID() - try await next.create(model, on: db) - } - - func update(model: PassData, on db: any Database, next: any AnyAsyncModelResponder) async throws { - let pass = try await model.$pass.get(on: db) - pass.updatedAt = Date() - try await pass.save(on: db) - try await next.update(model, on: db) - try await service.sendPushNotifications(for: pass, on: db) - } -} diff --git a/Tests/PassesTests/withApp.swift b/Tests/PassesTests/Utils/withApp.swift similarity index 77% rename from Tests/PassesTests/withApp.swift rename to Tests/PassesTests/Utils/withApp.swift index 4681421..b5ac620 100644 --- a/Tests/PassesTests/withApp.swift +++ b/Tests/PassesTests/Utils/withApp.swift @@ -8,21 +8,19 @@ import Zip func withApp( useEncryptedKey: Bool = false, - _ body: (Application, PassesService) async throws -> Void + _ body: (Application, PassesService) async throws -> Void ) async throws { let app = try await Application.make(.testing) do { try #require(isLoggingConfigured) app.databases.use(.sqlite(.memory), as: .sqlite) - PassesService.register(migrations: app.migrations) + PassesService.register(migrations: app.migrations) app.migrations.add(CreatePassData()) try await app.autoMigrate() - let delegate = TestPassesDelegate() - let passesService = try PassesService( + let passesService = try PassesService( app: app, - delegate: delegate, pushRoutesMiddleware: SecretMiddleware(secret: "foo"), logger: app.logger, pemWWDRCertificate: TestCertificate.pemWWDRCertificate, @@ -30,7 +28,7 @@ func withApp( pemPrivateKey: useEncryptedKey ? TestCertificate.encryptedPemPrivateKey : TestCertificate.pemPrivateKey, pemPrivateKeyPassword: useEncryptedKey ? "password" : nil ) - app.databases.middleware.use(PassDataMiddleware(service: passesService), on: .sqlite) + app.databases.middleware.use(passesService, on: .sqlite) Zip.addCustomFileExtension("pkpass") @@ -38,6 +36,7 @@ func withApp( try await app.autoRevert() } catch { + try? await app.autoRevert() try await app.asyncShutdown() throw error }