diff --git a/.gitignore b/.gitignore index 17f73d0..27d6dc0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /*.xcodeproj xcuserdata/ Package.resolved +.swiftpm diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index 54782e3..0000000 --- a/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded - - - diff --git a/Package.swift b/Package.swift index ae85b76..7a8c575 100644 --- a/Package.swift +++ b/Package.swift @@ -4,12 +4,11 @@ import PackageDescription let package = Package( name: "PassKit", platforms: [ - .macOS(.v13), .iOS(.v16) + .macOS(.v13) ], products: [ - .library(name: "PassKit", targets: ["PassKit"]), - .library(name: "Passes", targets: ["PassKit", "Passes"]), - .library(name: "Orders", targets: ["PassKit", "Orders"]), + .library(name: "Passes", targets: ["Passes"]), + .library(name: "Orders", targets: ["Orders"]), ], dependencies: [ .package(url: "https://github.com/vapor/vapor.git", from: "4.102.0"), @@ -41,10 +40,16 @@ let package = Package( swiftSettings: swiftSettings ), .testTarget( - name: "PassKitTests", + name: "PassesTests", dependencies: [ - .target(name: "PassKit"), .target(name: "Passes"), + .product(name: "XCTVapor", package: "vapor"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "OrdersTests", + dependencies: [ .target(name: "Orders"), .product(name: "XCTVapor", package: "vapor"), ], diff --git a/README.md b/README.md index 8673607..5b5aab1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
avatar

PassKit

- + Documentation Team Chat @@ -18,7 +18,7 @@

-🎟️ 📦 A Vapor package which handles all the server side elements required to implement Apple Wallet passes and orders. +🎟️ 📦 Create, distribute, and update passes and orders for the Apple Wallet app with Vapor. ### Major Releases @@ -26,426 +26,45 @@ The table below shows a list of PassKit major releases alongside their compatibl |Version|Swift|SPM| |---|---|---| +|0.5.0|5.10+|`from: "0.5.0"`| |0.4.0|5.10+|`from: "0.4.0"`| |0.2.0|5.9+|`from: "0.2.0"`| |0.1.0|5.9+|`from: "0.1.0"`| -Use the SPM string to easily include the dependendency in your `Package.swift` file +Use the SPM string to easily include the dependendency in your `Package.swift` file. ```swift -.package(url: "https://github.com/vapor-community/PassKit.git", from: "0.4.0") +.package(url: "https://github.com/vapor-community/PassKit.git", from: "0.5.0") ``` > Note: This package is made for Vapor 4. ## 🎟️ Wallet Passes +The Passes framework provides a set of tools to help you create, build, and distribute digital passes for the Apple Wallet app using a Vapor server. +It also provides a way to update passes after they have been distributed, using APNs, and models to store pass and device data. + Add the `Passes` product to your target's dependencies: ```swift .product(name: "Passes", package: "PassKit") ``` -### Implement your pass data model - -Your data model should contain all the fields that you store for your pass, as well as a foreign key for the pass itself. - -```swift -import Fluent -import struct Foundation.UUID -import Passes - -final class PassData: PassDataModel, @unchecked Sendable { - static let schema = "pass_data" - - @ID - var id: UUID? - - @Parent(key: "pass_id") - var pass: PKPass - - // Examples of other extra fields: - @Field(key: "punches") - var punches: Int - - @Field(key: "title") - var title: String - - // Add any other field relative to your app, such as a location, a date, etc. - - init() { } -} - -struct CreatePassData: AsyncMigration { - public func prepare(on database: Database) async throws { - try await database.schema(Self.schema) - .id() - .field("pass_id", .uuid, .required, .references(PKPass.schema, .id, onDelete: .cascade)) - .field("punches", .int, .required) - .field("title", .string, .required) - .create() - } - - public func revert(on database: Database) async throws { - try await database.schema(Self.schema).delete() - } -} -``` - -### Handle cleanup - -Depending on your implementation details, you'll likely want to automatically clean out the passes and devices table when a registration is deleted. -You'll need to implement based on your type of SQL database as there's not yet a Fluent way to implement something like SQL's `NOT EXISTS` call with a `DELETE` statement. -If you're using PostgreSQL, you can setup these triggers/methods: - -```sql -CREATE OR REPLACE FUNCTION public."RemoveUnregisteredItems"() RETURNS trigger - LANGUAGE plpgsql - AS $$BEGIN - DELETE FROM devices d - WHERE NOT EXISTS ( - SELECT 1 - FROM passes_registrations r - WHERE d."id" = r.device_id - LIMIT 1 - ); - - DELETE FROM passes p - WHERE NOT EXISTS ( - SELECT 1 - FROM passes_registrations r - WHERE p."id" = r.pass_id - LIMIT 1 - ); - - RETURN OLD; -END -$$; - -CREATE TRIGGER "OnRegistrationDelete" -AFTER DELETE ON "public"."passes_registrations" -FOR EACH ROW -EXECUTE PROCEDURE "public"."RemoveUnregisteredItems"(); -``` - -> [!CAUTION] -> Be careful with SQL triggers, as they can have unintended consequences if not properly implemented. - -### Model the `pass.json` contents - -Create a `struct` that implements `PassJSON` which will contain all the fields for the generated `pass.json` file. -Create an initializer that takes your custom pass data, the `PKPass` and everything else you may need. - -> [!TIP] -> For information on the various keys available see the [documentation](https://developer.apple.com/documentation/walletpasses/pass). See also [this guide](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/index.html#//apple_ref/doc/uid/TP40012195-CH1-SW1) for some help. - -Here's an example of a `struct` that implements `PassJSON`. - -```swift -import Passes - -struct PassJSONData: PassJSON { - let description: String - let formatVersion = 1 - let organizationName = "vapor-community" - let passTypeIdentifier = Environment.get("PASSKIT_PASS_TYPE_IDENTIFIER")! - let serialNumber: String - let teamIdentifier = Environment.get("APPLE_TEAM_IDENTIFIER")! - - private let webServiceURL = "https://example.com/api/passes/" - private let authenticationToken: String - private let logoText = "Vapor" - private let sharingProhibited = true - let backgroundColor = "rgb(207, 77, 243)" - let foregroundColor = "rgb(255, 255, 255)" - - let barcodes = Barcode(message: "test") - struct Barcode: Barcodes { - let format = BarcodeFormat.qr - let message: String - let messageEncoding = "iso-8859-1" - } - - let boardingPass = Boarding(transitType: .air) - struct Boarding: BoardingPass { - let transitType: TransitType - let headerFields: [PassField] - let primaryFields: [PassField] - let secondaryFields: [PassField] - let auxiliaryFields: [PassField] - let backFields: [PassField] - - struct PassField: PassFieldContent { - let key: String - let label: String - let value: String - } - - init(transitType: TransitType) { - self.headerFields = [.init(key: "header", label: "Header", value: "Header")] - self.primaryFields = [.init(key: "primary", label: "Primary", value: "Primary")] - self.secondaryFields = [.init(key: "secondary", label: "Secondary", value: "Secondary")] - self.auxiliaryFields = [.init(key: "auxiliary", label: "Auxiliary", value: "Auxiliary")] - self.backFields = [.init(key: "back", label: "Back", value: "Back")] - self.transitType = transitType - } - } - - init(data: PassData, pass: PKPass) { - self.description = data.title - self.serialNumber = pass.id!.uuidString - self.authenticationToken = pass.authenticationToken - } -} -``` - -> [!IMPORTANT] -> You **must** add `api/passes/` to your `webServiceURL`, as shown in the example above. - -### Implement the delegate. - -Create a delegate file that implements `PassesDelegate`. -In the `sslSigningFilesDirectory` you specify there must be the `WWDR.pem`, `passcertificate.pem` and `passkey.pem` files. If they are named like that you're good to go, otherwise you have to specify the custom name. - -> [!TIP] -> Obtaining the three certificates files could be a bit tricky. You could get some guidance from [this guide](https://github.com/alexandercerutti/passkit-generator/wiki/Generating-Certificates) and [this video](https://www.youtube.com/watch?v=rJZdPoXHtzI). - -There are other fields available which have reasonable default values. See the delegate's documentation. - -Because the files for your pass' template and the method of encoding might vary by pass type, you'll be provided the pass for those methods. - -```swift -import Vapor -import Fluent -import Passes - -final class PassDelegate: PassesDelegate { - let sslSigningFilesDirectory = URL(fileURLWithPath: "Certificates/Passes/", isDirectory: true) - - let pemPrivateKeyPassword: String? = Environment.get("PEM_PRIVATE_KEY_PASSWORD")! - - func encode(pass: P, db: Database, encoder: JSONEncoder) async throws -> Data { - // The specific PassData class you use here may vary based on the pass.type if you have multiple - // different types of passes, and thus multiple types of pass data. - guard let passData = try await PassData.query(on: db) - .filter(\.$pass.$id == pass.id!) - .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: P, db: Database) async throws -> URL { - // The location might vary depending on the type of pass. - return URL(fileURLWithPath: "Templates/Passes/", isDirectory: true) - } -} -``` - -> [!IMPORTANT] -> You **must** explicitly declare `pemPrivateKeyPassword` as a `String?` or Swift will ignore it as it'll think it's a `String` instead. - -### Register Routes - -Next, register the routes in `routes.swift`. -This will implement all of the routes that PassKit expects to exist on your server for you. - -```swift -import Vapor -import Passes - -let passDelegate = PassDelegate() - -func routes(_ app: Application) throws { - let passesService = PassesService(app: app, delegate: passDelegate) - passesService.registerRoutes() -} -``` - -> [!NOTE] -> Notice how the `delegate` is created as a global variable. You need to ensure that the delegate doesn't go out of scope as soon as the `routes(_:)` method exits! - -#### Push Notifications - -If you wish to include routes specifically for sending push notifications to updated passes you can also include this line in your `routes(_:)` method. You'll need to pass in whatever `Middleware` you want Vapor to use to authenticate the two routes. - -> [!IMPORTANT] -> If you don't include this line, you have to configure an APNS container yourself - -```swift -try passesService.registerPushRoutes(middleware: SecretMiddleware(secret: "foo")) -``` - -That will add two routes: - -- POST .../api/passes/v1/push/*:passTypeIdentifier*/*:passSerial* (Sends notifications) -- GET .../api/passes/v1/push/*:passTypeIdentifier*/*:passSerial* (Retrieves a list of push tokens which would be sent a notification) - -#### Pass data model middleware - -Whether you include the routes or not, you'll want to add a model middleware that sends push notifications and updates the `updatedAt` field when your pass data updates. The model middleware could also create and link the `PKPass` during the creation of the pass data, depending on your requirements. - -You can implement it like so: - -```swift -import Vapor -import Fluent -import Passes - -struct PassDataMiddleware: AsyncModelMiddleware { - private unowned let app: Application - - init(app: Application) { - self.app = app - } - - // Create the PKPass and add it to the PassData automatically at creation - func create(model: PassData, on db: Database, next: AnyAsyncModelResponder) async throws { - let pkPass = PKPass( - passTypeIdentifier: "pass.com.yoursite.passType", - authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString()) - try await pkPass.save(on: db) - model.$pass.id = try pkPass.requireID() - try await next.create(model, on: db) - } - - func update(model: PassData, on db: Database, next: AnyAsyncModelResponder) async throws { - let pkPass = try await model.$pass.get(on: db) - pkPass.updatedAt = Date() - try await pkPass.save(on: db) - try await next.update(model, on: db) - try await PassesService.sendPushNotifications(for: pkPass, on: db, app: self.app) - } -} -``` - -and register it in *configure.swift*: - -```swift -app.databases.middleware.use(PassDataMiddleware(app: app), on: .psql) -``` - -> [!IMPORTANT] -> Whenever your pass data changes, you must update the *updatedAt* time of the linked pass so that Apple knows to send you a new pass. - -#### APNSwift - -If you did not include the routes, remember to configure APNSwift yourself like this: - -```swift -let apnsConfig: APNSClientConfiguration -if let pemPrivateKeyPassword { - apnsConfig = APNSClientConfiguration( - authenticationMethod: try .tls( - privateKey: .privateKey( - NIOSSLPrivateKey(file: privateKeyPath, format: .pem) { closure in - closure(pemPrivateKeyPassword.utf8) - }), - certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) } - ), - environment: .production - ) -} else { - apnsConfig = APNSClientConfiguration( - authenticationMethod: try .tls( - privateKey: .file(privateKeyPath), - certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) } - ), - environment: .production - ) -} -app.apns.containers.use( - apnsConfig, - eventLoopGroupProvider: .shared(app.eventLoopGroup), - responseDecoder: JSONDecoder(), - requestEncoder: JSONEncoder(), - as: .init(string: "passes"), - isDefault: false -) -``` - -### Custom Implementation +See the framework's [documentation](https://swiftpackageindex.com/vapor-community/PassKit/0.5.0/documentation/passes) for information on how to use it. -If you don't like the schema names that are used by default, you can instead create your own models conforming to `PassModel`, `DeviceModel`, `PassesRegistrationModel` and `ErrorLogModel` and instantiate the generic `PassesServiceCustom`, providing it your model types. - -```swift -import PassKit -import Passes - -let passesService = PassesServiceCustom(app: app, delegate: delegate) -``` - -The `DeviceModel` and `ErrorLogModel` protocols are found inside the the `PassKit` product. If you want to customize the devices and error logs models you have to add it to the package manifest: - -```swift -.product(name: "PassKit", package: "PassKit") -``` - -### Register Migrations - -If you're using the default schemas provided by this package you can register the default models in your `configure(_:)` method: - -```swift -PassesService.register(migrations: app.migrations) -``` - -> [!IMPORTANT] -> Register the default models before the migration of your pass data model. - -### Generate Pass Content - -To generate and distribute the `.pkpass` bundle, pass the `PassesService` object to your `RouteCollection`: - -```swift -import Fluent -import Vapor -import Passes - -struct PassesController: RouteCollection { - let passesService: PassesService - - func boot(routes: RoutesBuilder) throws { - ... - } -} -``` - -and then use it in route handlers: - -```swift -fileprivate func passHandler(_ req: Request) async throws -> Response { - ... - guard let passData = try await PassData.query(on: req.db) - .filter(...) - .with(\.$pass) - .first() - else { - throw Abort(.notFound) - } - - let bundle = try await passesService.generatePassContent(for: passData.pass, on: req.db) - let body = Response.Body(data: bundle) - var headers = HTTPHeaders() - headers.add(name: .contentType, value: "application/vnd.apple.pkpass") - headers.add(name: .contentDisposition, value: "attachment; filename=pass.pkpass") // Add this header only if you are serving the pass in a web page - headers.add(name: .lastModified, value: String(passData.pass.updatedAt?.timeIntervalSince1970 ?? 0)) - headers.add(name: .contentTransferEncoding, value: "binary") - return Response(status: .ok, headers: headers, body: body) -} -``` +For information on Apple Wallet passes, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletpasses). ## 📦 Wallet Orders +The Orders framework provides a set of tools to help you create, build, and distribute orders that users can track and manage in Apple Wallet using a Vapor server. +It also provides a way to update orders after they have been distributed, using APNs, and models to store order and device data. + Add the `Orders` product to your target's dependencies: ```swift .product(name: "Orders", package: "PassKit") ``` -> [!WARNING] -> The `Orders` is WIP, right now you can only set up the models and generate `.order` bundles. -APNS support and order updates will be added soon. See the `Orders` target's documentation. \ No newline at end of file +See the framework's [documentation](https://swiftpackageindex.com/vapor-community/PassKit/0.5.0/documentation/orders) for information on how to use it. + +For information on Apple Wallet orders, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletorders). diff --git a/Sources/Orders/DTOs/OrderJSON.swift b/Sources/Orders/DTOs/OrderJSON.swift new file mode 100644 index 0000000..31a12a4 --- /dev/null +++ b/Sources/Orders/DTOs/OrderJSON.swift @@ -0,0 +1,87 @@ +// +// 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. + /// + /// > Tip: See the [`Order`](https://developer.apple.com/documentation/walletorders/order) object to understand the keys. + public protocol Properties: Encodable { + /// The date and time when the customer created the order, in RFC 3339 format. + var createdAt: String { get } + + /// A unique order identifier scoped to your order type identifier. + /// + /// In combination with the order type identifier, this uniquely identifies an order within the system and isn’t displayed to the user. + var orderIdentifier: String { get } + + /// A URL where the customer can manage the order. + var orderManagementURL: String { get } + + /// The type of order this bundle represents. + /// + /// Currently the only supported value is `ecommerce`. + var orderType: OrderType { get } + + /// An identifier for the order type associated with the order. + /// + /// The value must correspond with your signing certificate and isn’t displayed to the user. + var orderTypeIdentifier: String { get } + + /// A high-level status of the order, used for display purposes. + /// + /// The system considers orders with status `completed` or `cancelled` closed. + var status: OrderStatus { get } + + /// The version of the schema used for the order. + /// + /// The current version is `1`. + var schemaVersion: SchemaVersion { get } + + /// The date and time when the order was last updated, in RFC 3339 format. + /// + /// This should equal the `createdAt` time, if the order hasn’t had any updates. + /// Must be monotonically increasing. + /// Consider using a hybrid logical clock if your web service can’t make that guarantee. + var updatedAt: String { get } + } +} + +extension OrderJSON { + /// The type of order this bundle represents. + public enum OrderType: String, Encodable { + case ecommerce + } + + /// A high-level status of the order, used for display purposes. + public enum OrderStatus: String, Encodable { + case completed + case cancelled + case open + } + + /// The version of the schema used for the order. + public enum SchemaVersion: Int, Encodable { + case v1 = 1 + } +} + +extension OrderJSON { + /// A protocol that represents the merchant associated with the order. + /// + /// > Tip: See the [`Order.Merchant`](https://developer.apple.com/documentation/walletorders/merchant) object to understand the keys. + public protocol Merchant: Encodable { + /// The localized display name of the merchant. + var displayName: String { get } + + /// The Apple Merchant Identifier for this merchant, generated at `developer.apple.com`. + var merchantIdentifier: String { get } + + /// The URL for the merchant’s website or landing page. + var url: String { get } + } +} diff --git a/Sources/Orders/DTOs/OrdersForDeviceDTO.swift b/Sources/Orders/DTOs/OrdersForDeviceDTO.swift index 921da6b..ef07b22 100644 --- a/Sources/Orders/DTOs/OrdersForDeviceDTO.swift +++ b/Sources/Orders/DTOs/OrdersForDeviceDTO.swift @@ -13,6 +13,10 @@ struct OrdersForDeviceDTO: Content { init(with orderIdentifiers: [String], maxDate: Date) { self.orderIdentifiers = orderIdentifiers - lastModified = String(maxDate.timeIntervalSince1970) + self.lastModified = ISO8601DateFormatter.string( + from: maxDate, + timeZone: .init(secondsFromGMT: 0)!, + formatOptions: .withInternetDateTime + ) } -} \ No newline at end of file +} diff --git a/Sources/Orders/Middleware/AppleOrderMiddleware.swift b/Sources/Orders/Middleware/AppleOrderMiddleware.swift index 517fe9d..04d411e 100644 --- a/Sources/Orders/Middleware/AppleOrderMiddleware.swift +++ b/Sources/Orders/Middleware/AppleOrderMiddleware.swift @@ -19,4 +19,4 @@ struct AppleOrderMiddleware: AsyncMiddleware { } return try await next.respond(to: request) } -} \ No newline at end of file +} diff --git a/Sources/Orders/Models/Concrete Models/Order.swift b/Sources/Orders/Models/Concrete Models/Order.swift index d625205..cced3f2 100644 --- a/Sources/Orders/Models/Concrete Models/Order.swift +++ b/Sources/Orders/Models/Concrete Models/Order.swift @@ -10,17 +10,28 @@ import FluentKit /// The `Model` that stores Wallet orders. open class Order: OrderModel, @unchecked Sendable { + /// The schema name of the order model. public static let schema = Order.FieldKeys.schemaName + /// A unique order identifier scoped to your order type identifier. + /// + /// In combination with the order type identifier, this uniquely identifies an order within the system and isn’t displayed to the user. @ID public var id: UUID? + /// The date and time when the customer created the order. + @Timestamp(key: Order.FieldKeys.createdAt, on: .create) + public var createdAt: Date? + + /// The date and time when the order was last updated. @Timestamp(key: Order.FieldKeys.updatedAt, on: .update) public var updatedAt: Date? + /// An identifier for the order type associated with the order. @Field(key: Order.FieldKeys.orderTypeIdentifier) public var orderTypeIdentifier: String + /// The authentication token supplied to your web service. @Field(key: Order.FieldKeys.authenticationToken) public var authenticationToken: String @@ -36,6 +47,7 @@ extension Order: AsyncMigration { public func prepare(on database: any Database) async throws { try await database.schema(Self.schema) .id() + .field(Order.FieldKeys.createdAt, .datetime, .required) .field(Order.FieldKeys.updatedAt, .datetime, .required) .field(Order.FieldKeys.orderTypeIdentifier, .string, .required) .field(Order.FieldKeys.authenticationToken, .string, .required) @@ -50,8 +62,9 @@ extension Order: AsyncMigration { extension Order { enum FieldKeys { static let schemaName = "orders" + static let createdAt = FieldKey(stringLiteral: "created_at") static let updatedAt = FieldKey(stringLiteral: "updated_at") static let orderTypeIdentifier = FieldKey(stringLiteral: "order_type_identifier") static let authenticationToken = FieldKey(stringLiteral: "authentication_token") } -} \ No newline at end of file +} diff --git a/Sources/Orders/Models/Concrete Models/OrdersDevice.swift b/Sources/Orders/Models/Concrete Models/OrdersDevice.swift index b2519ea..4895ca2 100644 --- a/Sources/Orders/Models/Concrete Models/OrdersDevice.swift +++ b/Sources/Orders/Models/Concrete Models/OrdersDevice.swift @@ -10,14 +10,17 @@ import PassKit /// The `Model` that stores Wallet orders devices. final public class OrdersDevice: DeviceModel, @unchecked Sendable { + /// The schema name of the orders device model. public static let schema = OrdersDevice.FieldKeys.schemaName @ID(custom: .id) public var id: Int? + /// The push token used for sending updates to the device. @Field(key: OrdersDevice.FieldKeys.pushToken) public var pushToken: String + /// The identifier Apple Wallet provides for the device. @Field(key: OrdersDevice.FieldKeys.deviceLibraryIdentifier) public var deviceLibraryIdentifier: String @@ -50,4 +53,4 @@ extension OrdersDevice { static let pushToken = FieldKey(stringLiteral: "push_token") static let deviceLibraryIdentifier = FieldKey(stringLiteral: "device_library_identifier") } -} \ No newline at end of file +} diff --git a/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift b/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift index 173e340..4af3aa0 100644 --- a/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift +++ b/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift @@ -11,14 +11,17 @@ import PassKit /// The `Model` that stores Wallet orders error logs. final public class OrdersErrorLog: ErrorLogModel, @unchecked Sendable { + /// The schema name of the error log model. public static let schema = OrdersErrorLog.FieldKeys.schemaName @ID(custom: .id) public var id: Int? + /// The date and time the error log was created. @Timestamp(key: OrdersErrorLog.FieldKeys.createdAt, on: .create) public var createdAt: Date? + /// The error message provided by Apple Wallet. @Field(key: OrdersErrorLog.FieldKeys.message) public var message: String @@ -49,4 +52,4 @@ extension OrdersErrorLog { static let createdAt = FieldKey(stringLiteral: "created_at") static let message = FieldKey(stringLiteral: "message") } -} \ No newline at end of file +} diff --git a/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift b/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift index 6acae38..7a43280 100644 --- a/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift +++ b/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift @@ -12,14 +12,17 @@ final public class OrdersRegistration: OrdersRegistrationModel, @unchecked Senda public typealias OrderType = Order public typealias DeviceType = OrdersDevice + /// The schema name of the orders registration model. public static let schema = OrdersRegistration.FieldKeys.schemaName @ID(custom: .id) public var id: Int? + /// The device for this registration. @Parent(key: OrdersRegistration.FieldKeys.deviceID) public var device: DeviceType + /// The order for this registration. @Parent(key: OrdersRegistration.FieldKeys.orderID) public var order: OrderType @@ -46,4 +49,4 @@ extension OrdersRegistration { static let deviceID = FieldKey(stringLiteral: "device_id") static let orderID = FieldKey(stringLiteral: "order_id") } -} \ No newline at end of file +} diff --git a/Sources/Orders/Models/OrderDataModel.swift b/Sources/Orders/Models/OrderDataModel.swift index 55f3030..d5a999e 100644 --- a/Sources/Orders/Models/OrderDataModel.swift +++ b/Sources/Orders/Models/OrderDataModel.swift @@ -11,7 +11,7 @@ import FluentKit public protocol OrderDataModel: Model { associatedtype OrderType: OrderModel - /// The foreign key to the order table + /// The foreign key to the order table. var order: OrderType { get set } } @@ -24,4 +24,4 @@ internal extension OrderDataModel { return order } -} \ No newline at end of file +} diff --git a/Sources/Orders/Models/OrderModel.swift b/Sources/Orders/Models/OrderModel.swift index 09f3221..227e2ac 100644 --- a/Sources/Orders/Models/OrderModel.swift +++ b/Sources/Orders/Models/OrderModel.swift @@ -9,16 +9,19 @@ import Foundation import FluentKit /// Represents the `Model` that stores Waller orders. -/// -/// Uses a UUID so people can't easily guess order IDs +/// +/// Uses a UUID so people can't easily guess order IDs. public protocol OrderModel: Model where IDValue == UUID { - /// The order type identifier. + /// An identifier for the order type associated with the order. var orderTypeIdentifier: String { get set } + + /// The date and time when the customer created the order. + var createdAt: Date? { get set } - /// The last time the order was modified. + /// The date and time when the order was last updated. var updatedAt: Date? { get set } - /// The authentication token for the order. + /// The authentication token supplied to your web service. var authenticationToken: String { get set } } @@ -40,6 +43,15 @@ internal extension OrderModel { return orderTypeIdentifier } + + var _$createdAt: Timestamp { + guard let mirror = Mirror(reflecting: self).descendant("_createdAt"), + let createdAt = mirror as? Timestamp else { + fatalError("createdAt property must be declared using @Timestamp(on: .create)") + } + + return createdAt + } var _$updatedAt: Timestamp { guard let mirror = Mirror(reflecting: self).descendant("_updatedAt"), @@ -58,4 +70,4 @@ internal extension OrderModel { return authenticationToken } -} \ No newline at end of file +} diff --git a/Sources/Orders/Models/OrdersRegistrationModel.swift b/Sources/Orders/Models/OrdersRegistrationModel.swift index ffcc912..a5c96b2 100644 --- a/Sources/Orders/Models/OrdersRegistrationModel.swift +++ b/Sources/Orders/Models/OrdersRegistrationModel.swift @@ -48,4 +48,4 @@ internal extension OrdersRegistrationModel { .filter(OrderType.self, \._$orderTypeIdentifier == orderTypeIdentifier) .filter(DeviceType.self, \._$deviceLibraryIdentifier == deviceLibraryIdentifier) } -} \ No newline at end of file +} diff --git a/Sources/Orders/Orders.docc/DistributeUpdate.md b/Sources/Orders/Orders.docc/DistributeUpdate.md new file mode 100644 index 0000000..1146042 --- /dev/null +++ b/Sources/Orders/Orders.docc/DistributeUpdate.md @@ -0,0 +1,181 @@ +# Building, Distributing and Updating an Order + +Build a distributable order and distribute it to your users or update an existing order. + +## Overview + +The order you distribute to a user is a signed bundle that contains the JSON description of the order, 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 ``OrdersService`` class also provides methods to send push notifications to all devices registered to an order when it's updated and all the routes that Apple Wallet expects to get and update orders. + +### Implement the Delegate + +Create a delegate file that implements ``OrdersDelegate``. +In the ``OrdersDelegate/sslSigningFilesDirectory`` you specify there must be the `WWDR.pem`, `ordercertificate.pem` and `orderkey.pem` files. +If they are named like that you're good to go, otherwise you have to specify the custom name. + +> Tip: Obtaining the three certificates files could be a bit tricky. You could get some guidance from [this guide](https://github.com/alexandercerutti/passkit-generator/wiki/Generating-Certificates) and [this video](https://www.youtube.com/watch?v=rJZdPoXHtzI). Those guides are for Wallet passes, but the process is similar for Wallet orders. + +There are other fields available which have reasonable default values. See ``OrdersDelegate``'s documentation. + +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 { + let sslSigningFilesDirectory = URL(fileURLWithPath: "Certificates/Orders/", isDirectory: true) + + let pemPrivateKeyPassword: String? = Environment.get("ORDER_PEM_PRIVATE_KEY_PASSWORD")! + + func encode(order: O, db: Database, encoder: JSONEncoder) async throws -> Data { + // The specific OrderData class you use here may vary based on the `order.orderTypeIdentifier` + // 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.id!) + .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: O, db: Database) async throws -> URL { + // The location might vary depending on the type of order. + return URL(fileURLWithPath: "Templates/Orders/", isDirectory: true) + } +} +``` + +> Important: You **must** explicitly declare ``OrdersDelegate/pemPrivateKeyPassword`` as a `String?` or Swift will ignore it as it'll think it's a `String` instead. + +### Register the Routes + +Next, register the routes in `routes.swift`. +This will implement all of the routes that Apple Wallet expects to exist on your server for you. + +```swift +import Vapor +import Orders + +let orderDelegate = OrderDelegate() + +func routes(_ app: Application) throws { + let ordersService = OrdersService(app: app, delegate: orderDelegate) + ordersService.registerRoutes() +} +``` + +> 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 `routes(_:)` method exits. + +### Push Notifications + +If you wish to include routes specifically for sending push notifications to updated orders you can also include this line in your `routes(_:)` method. You'll need to pass in whatever `Middleware` you want Vapor to use to authenticate the two routes. + +> Important: If you don't include this line, you have to configure an APNS container yourself. + +```swift +try ordersService.registerPushRoutes(middleware: SecretMiddleware(secret: "foo")) +``` + +That will add two routes, the first one sends notifications and the second one retrieves a list of push tokens which would be sent a notification. + +```http +POST https://example.com/api/orders/v1/push/{orderTypeIdentifier}/{orderIdentifier} HTTP/2 +``` + +```http +GET https://example.com/api/orders/v1/push/{orderTypeIdentifier}/{orderIdentifier} HTTP/2 +``` + +### Order Data Model Middleware + +Whether you include the routes or not, you'll want to add a model middleware that sends push notifications and updates the ``Order/updatedAt`` field when your order data updates. The model middleware could also create and link the ``Order`` during the creation of the order data, depending on your requirements. + +See for more information. + +### Apple Push Notification service + +If you did not include the push notification routes, remember to configure APNs yourself. + +> Important: Apple Wallet *only* works with the APNs production environment. You can't pass in the `.sandbox` environment. + +```swift +let apnsConfig: APNSClientConfiguration +if let pemPrivateKeyPassword { + apnsConfig = APNSClientConfiguration( + authenticationMethod: try .tls( + privateKey: .privateKey( + NIOSSLPrivateKey(file: privateKeyPath, format: .pem) { closure in + closure(pemPrivateKeyPassword.utf8) + }), + certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) } + ), + environment: .production + ) +} else { + apnsConfig = APNSClientConfiguration( + authenticationMethod: try .tls( + privateKey: .file(privateKeyPath), + certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) } + ), + environment: .production + ) +} +app.apns.containers.use( + apnsConfig, + eventLoopGroupProvider: .shared(app.eventLoopGroup), + responseDecoder: JSONDecoder(), + requestEncoder: JSONEncoder(), + as: .init(string: "orders"), + isDefault: false +) +``` + +### Generate the Order Content + +To generate and distribute the `.order` bundle, pass the ``OrdersService`` object to your `RouteCollection`. + +```swift +import Fluent +import Vapor +import Orders + +struct OrdersController: RouteCollection { + let ordersService: OrdersService + + func boot(routes: RoutesBuilder) throws { + ... + } +} +``` + +Then use the object inside your route handlers to generate and distribute the order bundle. + +```swift +fileprivate func passHandler(_ req: Request) async throws -> Response { + ... + guard let orderData = try await OrderData.query(on: req.db) + .filter(...) + .with(\.$order) + .first() + else { + throw Abort(.notFound) + } + + let bundle = try await ordersService.generateOrderContent(for: orderData.order, on: req.db) + let body = Response.Body(data: bundle) + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "application/vnd.apple.order") + headers.add(name: .contentDisposition, value: "attachment; filename=name.order") // Add this header only if you are serving the order in a web page + headers.add(name: .lastModified, value: String(orderData.order.updatedAt?.timeIntervalSince1970 ?? 0)) + headers.add(name: .contentTransferEncoding, value: "binary") + return Response(status: .ok, headers: headers, body: body) +} +``` diff --git a/Sources/Orders/Orders.docc/Extensions/OrderJSON.md b/Sources/Orders/Orders.docc/Extensions/OrderJSON.md new file mode 100644 index 0000000..3a99a9c --- /dev/null +++ b/Sources/Orders/Orders.docc/Extensions/OrderJSON.md @@ -0,0 +1,14 @@ +# ``Orders/OrderJSON`` + +## Topics + +### Essentials + +- ``Properties`` +- ``SchemaVersion`` +- ``OrderType`` +- ``OrderStatus`` + +### Merchants + +- ``Merchant`` \ No newline at end of file diff --git a/Sources/Orders/Orders.docc/Extensions/OrdersService.md b/Sources/Orders/Orders.docc/Extensions/OrdersService.md new file mode 100644 index 0000000..ebaa346 --- /dev/null +++ b/Sources/Orders/Orders.docc/Extensions/OrdersService.md @@ -0,0 +1,16 @@ +# ``Orders/OrdersService`` + +## Topics + +### Essentials + +- ``generateOrderContent(for:on:)`` +- ``register(migrations:)`` +- ``registerRoutes()`` + +### Push Notifications + +- ``registerPushRoutes(middleware:)`` +- ``sendPushNotifications(for:on:app:)-wkeu`` +- ``sendPushNotifications(for:on:app:)-4hxhb`` +- ``sendPushNotificationsForOrder(id:of:on:app:)`` diff --git a/Sources/Orders/Orders.docc/OrderData.md b/Sources/Orders/Orders.docc/OrderData.md new file mode 100644 index 0000000..803ce7f --- /dev/null +++ b/Sources/Orders/Orders.docc/OrderData.md @@ -0,0 +1,176 @@ +# Create the Order Data Model + +Implement the order data model, its model middleware and define the order file contents. + +## Overview + +The Orders framework provides models to save all the basic information for orders, user devices and their registration to each order. +For all the other custom data needed to generate the order (such as the barcodes, merchant info, etc.), you have to create your own model and its model middleware to handle the creation and update of order. +The order data model will be used to generate the `order.json` file contents, along side image files for the icon and other visual elements, such as a logo. + +### 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. + +```swift +import Fluent +import struct Foundation.UUID +import Orders + +final class OrderData: OrderDataModel, @unchecked Sendable { + static let schema = "order_data" + + @ID + var id: UUID? + + @Parent(key: "order_id") + var order: Order + + // Example of other extra fields: + @Field(key: "merchant_name") + var merchantName: String + + // Add any other field relative to your app, such as an identifier, the order status, etc. + + init() { } +} + +struct CreateOrderData: AsyncMigration { + public func prepare(on database: Database) async throws { + try await database.schema(Self.schema) + .id() + .field("order_id", .uuid, .required, .references(Order.schema, .id, onDelete: .cascade)) + .field("merchant_name", .string, .required) + .create() + } + + public func revert(on database: Database) async throws { + try await database.schema(Self.schema).delete() + } +} +``` + +### 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. + +See for more information on how to send push notifications. + +```swift +import Vapor +import Fluent +import Orders + +struct OrderDataMiddleware: AsyncModelMiddleware { + private unowned let app: Application + + init(app: Application) { + self.app = app + } + + // 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( + orderTypeIdentifier: "order.com.yoursite.orderType", + 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 OrdersService.sendPushNotifications(for: order, on: db, app: self.app) + } +} +``` + +Remember to register it in the `configure.swift` file. + +```swift +app.databases.middleware.use(OrderDataMiddleware(app: app), on: .psql) +``` + +> Important: Whenever your order data changes, you must update the ``Order/updatedAt`` time of the linked order so that Apple knows to send you a new order. + +### Handle Cleanup + +Depending on your implementation details, you may want to automatically clean out the orders and devices table when a registration is deleted. +You'll need to implement based on your type of SQL database as there's not yet a Fluent way to implement something like SQL's `NOT EXISTS` call with a `DELETE` statement. + +> Warning: Be careful with SQL triggers, as they can have unintended consequences if not properly implemented. + +### Model the order.json contents + +Create a `struct` that implements ``OrderJSON/Properties`` which will contain all the fields for the generated `order.json` file. +Create an initializer that takes your custom order data, the ``Order`` and everything else you may need. + +> Tip: For information on the various keys available see the [documentation](https://developer.apple.com/documentation/walletorders/order). + +```swift +import Orders + +struct OrderJSONData: OrderJSON.Properties { + let schemaVersion = OrderJSON.SchemaVersion.v1 + let orderTypeIdentifier = Environment.get("PASSKIT_ORDER_TYPE_IDENTIFIER")! + let orderIdentifier: String + let orderType = OrderJSON.OrderType.ecommerce + let orderNumber = "HM090772020864" + let createdAt: String + let updatedAt: String + let status = OrderJSON.OrderStatus.open + let merchant: MerchantData + let orderManagementURL = "https://www.example.com/" + let authenticationToken: String + + private let webServiceURL = "https://example.com/api/orders/" + + struct MerchantData: OrderJSON.Merchant { + let merchantIdentifier = "com.example.pet-store" + let displayName: String + let url = "https://www.example.com/" + let logo = "pet_store_logo.png" + } + + init(data: OrderData, order: Order) { + self.orderIdentifier = order.id!.uuidString + self.authenticationToken = order.authenticationToken + self.merchant = MerchantData(displayName: data.title) + + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = .withInternetDateTime + self.createdAt = dateFormatter.string(from: order.createdAt!) + self.updatedAt = dateFormatter.string(from: order.updatedAt!) + } +} +``` + +> Important: You **must** add `api/orders/` to your `webServiceURL`, as shown in the example above. + +### Register Migrations + +If you're using the default schemas provided by this framework, you can register the default models in your `configure(_:)` method: + +```swift +OrdersService.register(migrations: app.migrations) +``` + +> Important: Register the default models before the migration of your order data model. + +### Custom Implementation + +If you don't like the schema names provided by the framework that are used by default, you can instead create your own models conforming to ``OrderModel``, `DeviceModel`, ``OrdersRegistrationModel`` and `ErrorLogModel` and instantiate the generic ``OrdersServiceCustom``, providing it your model types. + +```swift +import PassKit +import Orders + +let ordersService = OrdersServiceCustom(app: app, delegate: delegate) +``` + +> Important: `DeviceModel` and `ErrorLogModel` are defined in the PassKit framework. diff --git a/Sources/Orders/Orders.docc/Orders.md b/Sources/Orders/Orders.docc/Orders.md new file mode 100644 index 0000000..0a57fcb --- /dev/null +++ b/Sources/Orders/Orders.docc/Orders.md @@ -0,0 +1,41 @@ +# ``Orders`` + +Create, distribute, and update orders in Apple Wallet with Vapor. + +## Overview + +The Orders framework provides a set of tools to help you create, build, and distribute orders that users can track and manage in Apple Wallet using a Vapor server. +It also provides a way to update orders after they have been distributed, using APNs, and models to store order and device data. + +For information on Apple Wallet orders, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletorders). + +## Topics + +### Essentials + +- +- +- ``OrderJSON`` + +### Building and Distribution + +- ``OrdersDelegate`` +- ``OrdersService`` +- ``OrdersServiceCustom`` + +### Concrete Models + +- ``Order`` +- ``OrdersRegistration`` +- ``OrdersDevice`` +- ``OrdersErrorLog`` + +### Abstract Models + +- ``OrderModel`` +- ``OrdersRegistrationModel`` +- ``OrderDataModel`` + +### Errors + +- ``OrdersError`` \ No newline at end of file diff --git a/Sources/Orders/OrdersDelegate.swift b/Sources/Orders/OrdersDelegate.swift index 87a784f..66c8df1 100644 --- a/Sources/Orders/OrdersDelegate.swift +++ b/Sources/Orders/OrdersDelegate.swift @@ -117,4 +117,4 @@ public extension OrdersDelegate { func generateSignatureFile(in root: URL) -> Bool { return false } -} \ No newline at end of file +} diff --git a/Sources/Orders/OrdersError.swift b/Sources/Orders/OrdersError.swift index 223dc75..62975bf 100644 --- a/Sources/Orders/OrdersError.swift +++ b/Sources/Orders/OrdersError.swift @@ -2,25 +2,79 @@ // OrdersError.swift // PassKit // -// Created by Francesco Paolo Severino on 30/06/24. +// Created by Francesco Paolo Severino on 04/07/24. // -public enum OrdersError: Error { - /// The template path is not a directory - case templateNotDirectory +/// Errors that can be thrown by Apple Wallet orders. +public struct OrdersError: Error, Sendable { + /// The type of the errors that can be thrown by Apple Wallet orders. + public struct ErrorType: Sendable, Hashable, CustomStringConvertible { + enum Base: String, Sendable { + case templateNotDirectory + case pemCertificateMissing + case pemPrivateKeyMissing + case zipBinaryMissing + case opensslBinaryMissing + } + + let base: Base + + private init(_ base: Base) { + self.base = base + } + + /// The template path is not a directory. + public static let templateNotDirectory = Self(.templateNotDirectory) + /// The `pemCertificate` file is missing. + public static let pemCertificateMissing = Self(.pemCertificateMissing) + /// The `pemPrivateKey` file is missing. + public static let pemPrivateKeyMissing = Self(.pemPrivateKeyMissing) + /// The path to the `zip` binary is incorrect. + public static let zipBinaryMissing = Self(.zipBinaryMissing) + /// The path to the `openssl` binary is incorrect. + public static let opensslBinaryMissing = Self(.opensslBinaryMissing) + + /// A textual representation of this error. + public var description: String { + base.rawValue + } + } + + private struct Backing: Sendable { + fileprivate let errorType: ErrorType + + init(errorType: ErrorType) { + self.errorType = errorType + } + } + + private var backing: Backing + + /// The type of this error. + public var errorType: ErrorType { backing.errorType } + + private init(errorType: ErrorType) { + self.backing = .init(errorType: errorType) + } + + /// The template path is not a directory. + public static let templateNotDirectory = Self(errorType: .templateNotDirectory) /// The `pemCertificate` file is missing. - case pemCertificateMissing + public static let pemCertificateMissing = Self(errorType: .pemCertificateMissing) /// The `pemPrivateKey` file is missing. - case pemPrivateKeyMissing + public static let pemPrivateKeyMissing = Self(errorType: .pemPrivateKeyMissing) - /// Swift NIO failed to read the key. - case nioPrivateKeyReadFailed(any Error) + /// The path to the `zip` binary is incorrect. + public static let zipBinaryMissing = Self(errorType: .zipBinaryMissing) - /// The path to the zip binary is incorrect. - case zipBinaryMissing + /// The path to the `openssl` binary is incorrect. + public static let opensslBinaryMissing = Self(errorType: .opensslBinaryMissing) +} - /// The path to the openssl binary is incorrect - case opensslBinaryMissing -} \ No newline at end of file +extension OrdersError: CustomStringConvertible { + public var description: String { + "OrdersError(errorType: \(self.errorType))" + } +} diff --git a/Sources/Orders/OrdersService.swift b/Sources/Orders/OrdersService.swift index 23977ba..bf53385 100644 --- a/Sources/Orders/OrdersService.swift +++ b/Sources/Orders/OrdersService.swift @@ -12,10 +12,28 @@ import FluentKit public final class OrdersService: Sendable { private let service: OrdersServiceCustom + /// Initializes the service. + /// + /// - Parameters: + /// - app: The `Vapor.Application` to use in route handlers and APNs. + /// - delegate: The ``OrdersDelegate`` to use for order generation. + /// - logger: The `Logger` to use. public init(app: Application, delegate: any OrdersDelegate, logger: Logger? = nil) { service = .init(app: app, delegate: delegate, logger: logger) } + /// Registers all the routes required for Wallet orders to work. + public func registerRoutes() { + service.registerRoutes() + } + + /// Registers routes to send push notifications to updated orders. + /// + /// - Parameter middleware: The `Middleware` which will control authentication for the routes. + public func registerPushRoutes(middleware: any Middleware) throws { + try service.registerPushRoutes(middleware: middleware) + } + /// Generates the order content bundle for a given order. /// /// - Parameters: @@ -35,4 +53,35 @@ public final class OrdersService: Sendable { migrations.add(OrdersRegistration()) migrations.add(OrdersErrorLog()) } + + /// Sends push notifications for a given order. + /// + /// - Parameters: + /// - id: The `UUID` of the order to send the notifications for. + /// - orderTypeIdentifier: The type identifier of the order. + /// - db: The `Database` to use. + /// - app: The `Application` to use. + public static func sendPushNotificationsForOrder(id: UUID, of orderTypeIdentifier: String, on db: any Database, app: Application) async throws { + try await OrdersServiceCustom.sendPushNotificationsForOrder(id: id, of: orderTypeIdentifier, on: db, app: app) + } + + /// Sends push notifications for a given order. + /// + /// - Parameters: + /// - order: The order to send the notifications for. + /// - db: The `Database` to use. + /// - app: The `Application` to use. + public static func sendPushNotifications(for order: Order, on db: any Database, app: Application) async throws { + try await OrdersServiceCustom.sendPushNotifications(for: order, on: db, app: app) + } + + /// Sends push notifications for a given order. + /// + /// - Parameters: + /// - order: The order (as the `ParentProperty`) to send the notifications for. + /// - db: The `Database` to use. + /// - app: The `Application` to use. + public static func sendPushNotifications(for order: ParentProperty, on db: any Database, app: Application) async throws { + try await OrdersServiceCustom.sendPushNotifications(for: order, on: db, app: app) + } } diff --git a/Sources/Orders/OrdersServiceCustom.swift b/Sources/Orders/OrdersServiceCustom.swift index c70b974..fb767bf 100644 --- a/Sources/Orders/OrdersServiceCustom.swift +++ b/Sources/Orders/OrdersServiceCustom.swift @@ -13,7 +13,7 @@ import Fluent import NIOSSL import PassKit -/// Class to handle `OrdersService`. +/// Class to handle ``OrdersService``. /// /// The generics should be passed in this order: /// - Order Type @@ -21,12 +21,19 @@ import PassKit /// - Registration Type /// - Error Log Type public final class OrdersServiceCustom: Sendable where O == R.OrderType, D == R.DeviceType { + /// The ``OrdersDelegate`` to use for order generation. public unowned let delegate: any OrdersDelegate private unowned let app: Application private let v1: any RoutesBuilder private let logger: Logger? + /// Initializes the service. + /// + /// - Parameters: + /// - app: The `Vapor.Application` to use in route handlers and APNs. + /// - delegate: The ``OrdersDelegate`` to use for order generation. + /// - logger: The `Logger` to use. public init(app: Application, delegate: any OrdersDelegate, logger: Logger? = nil) { self.delegate = delegate self.logger = logger @@ -34,6 +41,375 @@ public final class OrdersServiceCustom()) + + v1auth.post("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.delete("devices", ":deviceIdentifier", "registrations", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.unregisterDevice(req: $0) }) + } + + /// Registers routes to send push notifications for updated orders. + /// + /// ### Example ### + /// ```swift + /// try ordersService.registerPushRoutes(middleware: SecretMiddleware(secret: "foo")) + /// ``` + /// + /// - Parameter middleware: The `Middleware` which will control authentication for the routes. + /// - Throws: An error of type ``OrdersError``. + public func registerPushRoutes(middleware: any Middleware) throws { + let privateKeyPath = URL( + fileURLWithPath: delegate.pemPrivateKey, + relativeTo: delegate.sslSigningFilesDirectory).unixPath() + + guard FileManager.default.fileExists(atPath: privateKeyPath) else { + throw OrdersError.pemPrivateKeyMissing + } + + let pemPath = URL( + fileURLWithPath: delegate.pemCertificate, + relativeTo: delegate.sslSigningFilesDirectory).unixPath() + + guard FileManager.default.fileExists(atPath: pemPath) else { + throw OrdersError.pemCertificateMissing + } + + // Apple Wallet *only* works with the production APNs. You can't pass in `.sandbox` here. + let apnsConfig: APNSClientConfiguration + if let pwd = delegate.pemPrivateKeyPassword { + apnsConfig = APNSClientConfiguration( + authenticationMethod: try .tls( + privateKey: .privateKey( + NIOSSLPrivateKey(file: privateKeyPath, format: .pem) { closure in + closure(pwd.utf8) + }), + certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) } + ), + environment: .production + ) + } else { + apnsConfig = APNSClientConfiguration( + authenticationMethod: try .tls( + privateKey: .file(privateKeyPath), + certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) } + ), + environment: .production + ) + } + app.apns.containers.use( + apnsConfig, + eventLoopGroupProvider: .shared(app.eventLoopGroup), + responseDecoder: JSONDecoder(), + requestEncoder: JSONEncoder(), + as: .init(string: "orders"), + isDefault: false + ) + + let pushAuth = v1.grouped(middleware) + + pushAuth.post("push", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.pushUpdatesForOrder(req: $0) }) + pushAuth.get("push", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.tokensForOrderUpdate(req: $0) }) + } +} + +// MARK: - API Routes +extension OrdersServiceCustom { + func latestVersionOfOrder(req: Request) async throws -> Response { + logger?.debug("Called latestVersionOfOrder") + + guard FileManager.default.fileExists(atPath: delegate.zipBinary.unixPath()) else { + throw Abort(.internalServerError, suggestedFixes: ["Provide full path to zip command"]) + } + + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = .withInternetDateTime + + var ifModifiedSince = Date.distantPast + + if let header = req.headers[.ifModifiedSince].first, let ims = dateFormatter.date(from: header) { + ifModifiedSince = ims + } + + guard let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier"), + 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(\._$orderTypeIdentifier == orderTypeIdentifier) + .first() + else { + throw Abort(.notFound) + } + + guard ifModifiedSince < order.updatedAt ?? Date.distantPast else { + throw Abort(.notModified) + } + + let data = try await self.generateOrderContent(for: order, on: req.db) + let body = Response.Body(data: data) + + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "application/vnd.apple.order") + headers.add(name: .lastModified, value: dateFormatter.string(from: order.updatedAt ?? Date.distantPast)) + headers.add(name: .contentTransferEncoding, value: "binary") + + return Response(status: .ok, headers: headers, body: body) + } + + func registerDevice(req: Request) async throws -> HTTPStatus { + logger?.debug("Called register device") + + guard let orderIdentifier = req.parameters.get("orderIdentifier", as: UUID.self) else { + throw Abort(.badRequest) + } + + let pushToken: String + do { + let content = try req.content.decode(RegistrationDTO.self) + pushToken = content.pushToken + } catch { + throw Abort(.badRequest) + } + + let 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(\._$orderTypeIdentifier == orderTypeIdentifier) + .first() + else { + throw Abort(.notFound) + } + + let device = try await D.query(on: req.db) + .filter(\._$deviceLibraryIdentifier == deviceIdentifier) + .filter(\._$pushToken == pushToken) + .first() + + if let device = device { + return try await Self.createRegistration(device: device, order: order, db: req.db) + } else { + let newDevice = D(deviceLibraryIdentifier: deviceIdentifier, pushToken: pushToken) + try await newDevice.create(on: req.db) + return try await Self.createRegistration(device: newDevice, order: order, db: req.db) + } + } + + private static func createRegistration(device: D, order: O, db: any Database) async throws -> HTTPStatus { + let r = try await R.for( + deviceLibraryIdentifier: device.deviceLibraryIdentifier, + orderTypeIdentifier: order.orderTypeIdentifier, + on: db + ).filter(O.self, \._$id == order.id!).first() + + if r != nil { + // If the registration already exists, docs say to return a 200 + return .ok + } + + let registration = R() + registration._$order.id = order.id! + registration._$device.id = device.id! + + try await registration.create(on: db) + return .created + } + + 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, + orderTypeIdentifier: orderTypeIdentifier, + on: req.db) + + if let since: String = req.query["ordersModifiedSince"] { + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = .withInternetDateTime + let when = dateFormatter.date(from: since) ?? Date.distantPast + query = query.filter(O.self, \._$updatedAt > when) + } + + let registrations = try await query.all() + guard !registrations.isEmpty else { + throw Abort(.noContent) + } + + var orderIdentifiers: [String] = [] + var maxDate = Date.distantPast + + registrations.forEach { r in + let order = r.order + + orderIdentifiers.append(order.id!.uuidString) + if let updatedAt = order.updatedAt, updatedAt > maxDate { + maxDate = updatedAt + } + } + + return OrdersForDeviceDTO(with: orderIdentifiers, maxDate: maxDate) + } + + func logError(req: Request) async throws -> HTTPStatus { + logger?.debug("Called logError") + + let body: ErrorLogDTO + + do { + body = try req.content.decode(ErrorLogDTO.self) + } catch { + throw Abort(.badRequest) + } + + guard body.logs.isEmpty == false else { + throw Abort(.badRequest) + } + + try await body.logs.map(E.init(message:)).create(on: req.db) + + return .ok + } + + func unregisterDevice(req: Request) async throws -> HTTPStatus { + logger?.debug("Called unregisterDevice") + + let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! + + guard let orderIdentifier = req.parameters.get("orderIdentifier", as: UUID.self) else { + throw Abort(.badRequest) + } + + let deviceIdentifier = req.parameters.get("deviceIdentifier")! + + guard let r = try await R.for( + deviceLibraryIdentifier: deviceIdentifier, + orderTypeIdentifier: orderTypeIdentifier, + on: req.db + ).filter(O.self, \._$id == orderIdentifier).first() + else { + throw Abort(.notFound) + } + + try await r.delete(on: req.db) + return .ok + } + + // MARK: - Push Routes + func pushUpdatesForOrder(req: Request) async throws -> HTTPStatus { + logger?.debug("Called pushUpdatesForOrder") + + guard let id = req.parameters.get("orderIdentifier", as: UUID.self) else { + throw Abort(.badRequest) + } + + let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! + + try await Self.sendPushNotificationsForOrder(id: id, of: orderTypeIdentifier, on: req.db, app: req.application) + return .noContent + } + + func tokensForOrderUpdate(req: Request) async throws -> [String] { + logger?.debug("Called tokensForOrderUpdate") + + guard let id = req.parameters.get("orderIdentifier", as: UUID.self) else { + throw Abort(.badRequest) + } + + let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! + + let registrations = try await Self.registrationsForOrder(id: id, of: orderTypeIdentifier, on: req.db) + return registrations.map { $0.device.pushToken } + } +} + +// MARK: - Push Notifications +extension OrdersServiceCustom { + /// Sends push notifications for a given order. + /// + /// - Parameters: + /// - id: The `UUID` of the order to send the notifications for. + /// - orderTypeIdentifier: The type identifier of the order. + /// - db: The `Database` to use. + /// - app: The `Application` to use. + public static func sendPushNotificationsForOrder(id: UUID, of orderTypeIdentifier: String, on db: any Database, app: Application) async throws { + let registrations = try await Self.registrationsForOrder(id: id, of: orderTypeIdentifier, on: db) + for reg in registrations { + let backgroundNotification = APNSBackgroundNotification( + expiration: .immediately, + topic: reg.order.orderTypeIdentifier, + payload: EmptyPayload() + ) + + do { + try await app.apns.client(.init(string: "orders")) + .sendBackgroundNotification( + backgroundNotification, + deviceToken: reg.device.pushToken + ) + } catch let error as APNSCore.APNSError where error.reason == .badDeviceToken { + try await reg.device.delete(on: db) + try await reg.delete(on: db) + } + } + } + + /// Sends push notifications for a given order. + /// + /// - Parameters: + /// - order: The order to send the notifications for. + /// - db: The `Database` to use. + /// - app: The `Application` to use. + public static func sendPushNotifications(for order: O, on db: any Database, app: Application) async throws { + guard let id = order.id else { + throw FluentError.idRequired + } + + try await Self.sendPushNotificationsForOrder(id: id, of: order.orderTypeIdentifier, on: db, app: app) + } + + /// Sends push notifications for a given order. + /// + /// - Parameters: + /// - order: The order (as the `ParentProperty`) to send the notifications for. + /// - db: The `Database` to use. + /// - app: The `Application` to use. + public static func sendPushNotifications(for order: ParentProperty, on db: any Database, app: Application) async throws { + let value: O + + if let eagerLoaded = order.value { + value = eagerLoaded + } else { + value = try await order.get(on: db) + } + + try await sendPushNotifications(for: value, on: db, app: app) + } + + private static func registrationsForOrder(id: UUID, of orderTypeIdentifier: String, on db: any Database) async throws -> [R] { + // This could be done by enforcing the caller to have a Siblings property + // wrapper, but there's not really any value to forcing that on them when + // we can just do the query ourselves like this. + try await R.query(on: db) + .join(parent: \._$order) + .join(parent: \._$device) + .with(\._$order) + .with(\._$device) + .filter(O.self, \._$orderTypeIdentifier == orderTypeIdentifier) + .filter(O.self, \._$id == id) + .all() + } } // MARK: - order file generation @@ -108,6 +484,12 @@ extension OrdersServiceCustom { proc.waitUntilExit() } + /// Generates the order content bundle for a given order. + /// + /// - Parameters: + /// - order: The order to generate the content for. + /// - db: The `Database` to use. + /// - Returns: The generated order content as `Data`. public func generateOrderContent(for order: O, on db: any Database) async throws -> Data { let tmp = FileManager.default.temporaryDirectory let root = tmp.appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -144,4 +526,4 @@ extension OrdersServiceCustom { throw error } } -} \ No newline at end of file +} diff --git a/Sources/PassKit/Models/ErrorLogModel.swift b/Sources/PassKit/Models/ErrorLogModel.swift index fc417df..27b21b7 100644 --- a/Sources/PassKit/Models/ErrorLogModel.swift +++ b/Sources/PassKit/Models/ErrorLogModel.swift @@ -30,10 +30,10 @@ import FluentKit /// Represents the `Model` that stores PassKit error logs. public protocol ErrorLogModel: Model { - /// The error message provided by PassKit + /// The error message provided by PassKit. var message: String { get set } - /// The designated initializer + /// The designated initializer. /// - Parameter message: The error message. init(message: String) } diff --git a/Sources/PassKit/PassKit.docc/PassKit.md b/Sources/PassKit/PassKit.docc/PassKit.md new file mode 100644 index 0000000..17b02b9 --- /dev/null +++ b/Sources/PassKit/PassKit.docc/PassKit.md @@ -0,0 +1,33 @@ +# ``PassKit`` + +Create, distribute, and update passes and orders for the Apple Wallet app with Vapor. + +## Overview + +The PassKit framework provides a set of tools shared by the Passes and Orders frameworks, which includes the two protocols for defining custom models for device data and error logs. + +@Row { + @Column(size: 2) { } + @Column { + ![Apple Wallet](wallet) + } + @Column(size: 2) { } +} + +### 🎟️ Wallet Passes + +The Passes framework provides a set of tools to help you create, build, and distribute digital passes for the Apple Wallet app using a Vapor server. +It also provides a way to update passes after they have been distributed, using APNs, and models to store pass and device data. + +See the framework's [documentation](https://swiftpackageindex.com/vapor-community/PassKit/0.5.0/documentation/passes) for information on how to use it. + +For information on Apple Wallet passes, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletpasses). + +### 📦 Wallet Orders + +The Orders framework provides a set of tools to help you create, build, and distribute orders that users can track and manage in Apple Wallet using a Vapor server. +It also provides a way to update orders after they have been distributed, using APNs, and models to store order and device data. + +See the framework's [documentation](https://swiftpackageindex.com/vapor-community/PassKit/0.5.0/documentation/orders) for information on how to use it. + +For information on Apple Wallet orders, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletorders). diff --git a/Sources/PassKit/PassKit.docc/Resources/wallet.png b/Sources/PassKit/PassKit.docc/Resources/wallet.png new file mode 100644 index 0000000..0e699a5 Binary files /dev/null and b/Sources/PassKit/PassKit.docc/Resources/wallet.png differ diff --git a/Sources/Passes/DTOs/PassJSON.swift b/Sources/Passes/DTOs/PassJSON.swift index 0cccc7e..d585176 100644 --- a/Sources/Passes/DTOs/PassJSON.swift +++ b/Sources/Passes/DTOs/PassJSON.swift @@ -5,89 +5,108 @@ // Created by Francesco Paolo Severino on 28/06/24. // -/// A protocol that defines the structure of a `pass.json` file. -/// -/// > Tip: See the [`Pass`](https://developer.apple.com/documentation/walletpasses/pass) object to understand the keys. -public protocol PassJSON: Encodable { - /// A short description that iOS accessibility technologies use for a pass. - var description: String { get } - - /// The version of the file format. The value must be 1. - var formatVersion: Int { get } - - /// The name of the organization. - var organizationName: String { get } - - /// The pass type identifier that’s registered with Apple. - /// - /// The value must be the same as the distribution certificate used to sign the pass. - var passTypeIdentifier: String { get } - - /// An alphanumeric serial number. - /// - /// The combination of the serial number and pass type identifier must be unique for each pass. - var serialNumber: String { get } - - /// The Team ID for the Apple Developer Program account that registered the pass type identifier. - var teamIdentifier: String { get } +/// The structure of a `pass.json` file. +public struct PassJSON { + /// A protocol that defines the structure of a `pass.json` file. + /// + /// > Tip: See the [`Pass`](https://developer.apple.com/documentation/walletpasses/pass) object to understand the keys. + public protocol Properties: Encodable { + /// A short description that iOS accessibility technologies use for a pass. + var description: String { get } + + /// The version of the file format. + /// + /// The value must be `1`. + var formatVersion: FormatVersion { get } + + /// The name of the organization. + var organizationName: String { get } + + /// The pass type identifier that’s registered with Apple. + /// + /// The value must be the same as the distribution certificate used to sign the pass. + var passTypeIdentifier: String { get } + + /// An alphanumeric serial number. + /// + /// The combination of the serial number and pass type identifier must be unique for each pass. + var serialNumber: String { get } + + /// The Team ID for the Apple Developer Program account that registered the pass type identifier. + var teamIdentifier: String { get } + } } -/// A protocol that represents the information to display in a field on a pass. -/// -/// > Tip: See the [`PassFieldContent`](https://developer.apple.com/documentation/walletpasses/passfieldcontent) object to understand the keys. -public protocol PassFieldContent: Encodable { - /// A unique key that identifies a field in the pass; for example, `departure-gate`. - var key: String { get } - - /// The value to use for the field; for example, 42. - /// - /// A date or time value must include a time zone. - var value: String { get } +extension PassJSON { + /// A protocol that represents the information to display in a field on a pass. + /// + /// > Tip: See the [`PassFieldContent`](https://developer.apple.com/documentation/walletpasses/passfieldcontent) object to understand the keys. + public protocol PassFieldContent: Encodable { + /// A unique key that identifies a field in the pass; for example, `departure-gate`. + var key: String { get } + + /// The value to use for the field; for example, 42. + /// + /// A date or time value must include a time zone. + var value: String { get } + } } -/// A protocol that represents the groups of fields that display the information for a boarding pass. -/// -/// > Tip: See the [`Pass.BoardingPass`](https://developer.apple.com/documentation/walletpasses/pass/boardingpass) object to understand the keys. -public protocol BoardingPass: Encodable { - /// The type of transit for a boarding pass. - /// - /// This key is invalid for other types of passes. - /// - /// The system may use the value to display more information, - /// such as showing an airplane icon for the pass on watchOS when the value is set to `PKTransitTypeAir`. - var transitType: TransitType { get } +extension PassJSON { + /// A protocol that represents the groups of fields that display the information for a boarding pass. + /// + /// > Tip: See the [`Pass.BoardingPass`](https://developer.apple.com/documentation/walletpasses/pass/boardingpass) object to understand the keys. + public protocol BoardingPass: Encodable { + /// The type of transit for a boarding pass. + /// + /// This key is invalid for other types of passes. + /// + /// The system may use the value to display more information, + /// such as showing an airplane icon for the pass on watchOS when the value is set to `PKTransitTypeAir`. + var transitType: TransitType { get } + } } -/// The type of transit for a boarding pass. -public enum TransitType: String, Encodable { - case air = "PKTransitTypeAir" - case boat = "PKTransitTypeBoat" - case bus = "PKTransitTypeBus" - case generic = "PKTransitTypeGeneric" - case train = "PKTransitTypeTrain" +extension PassJSON { + /// A protocol that represents a barcode on a pass. + /// + /// > Tip: See the [`Pass.Barcodes`](https://developer.apple.com/documentation/walletpasses/pass/barcodes) object to understand the keys. + public protocol Barcodes: Encodable { + /// The format of the barcode. + /// + /// The barcode format `PKBarcodeFormatCode128` isn’t supported for watchOS. + var format: BarcodeFormat { get } + + /// The message or payload to display as a barcode. + var message: String { get } + + /// The IANA character set name of the text encoding to use to convert message + /// from a string representation to a data representation that the system renders as a barcode, such as `iso-8859-1`. + var messageEncoding: String { get } + } } -/// A protocol that represents a barcode on a pass. -/// -/// > Tip: See the [`Pass.Barcodes`](https://developer.apple.com/documentation/walletpasses/pass/barcodes) object to understand the keys. -public protocol Barcodes: Encodable { +extension PassJSON { + /// The version of the file format. + public enum FormatVersion: Int, Encodable { + /// The value must be `1`. + case v1 = 1 + } + + /// The type of transit for a boarding pass. + public enum TransitType: String, Encodable { + case air = "PKTransitTypeAir" + case boat = "PKTransitTypeBoat" + case bus = "PKTransitTypeBus" + case generic = "PKTransitTypeGeneric" + case train = "PKTransitTypeTrain" + } + /// The format of the barcode. - /// - /// The barcode format `PKBarcodeFormatCode128` isn’t supported for watchOS. - var format: BarcodeFormat { get } - - /// The message or payload to display as a barcode. - var message: String { get } - - /// The IANA character set name of the text encoding to use to convert message - /// from a string representation to a data representation that the system renders as a barcode, such as `iso-8859-1`. - var messageEncoding: String { get } -} - -/// The format of the barcode. -public enum BarcodeFormat: String, Encodable { - case pdf417 = "PKBarcodeFormatPDF417" - case qr = "PKBarcodeFormatQR" - case aztec = "PKBarcodeFormatAztec" - case code128 = "PKBarcodeFormatCode128" + public enum BarcodeFormat: String, Encodable { + case pdf417 = "PKBarcodeFormatPDF417" + case qr = "PKBarcodeFormatQR" + case aztec = "PKBarcodeFormatAztec" + case code128 = "PKBarcodeFormatCode128" + } } diff --git a/Sources/Passes/DTOs/PersonalizationDictionaryDTO.swift b/Sources/Passes/DTOs/PersonalizationDictionaryDTO.swift new file mode 100644 index 0000000..51465d4 --- /dev/null +++ b/Sources/Passes/DTOs/PersonalizationDictionaryDTO.swift @@ -0,0 +1,23 @@ +// +// PersonalizationDictionaryDTO.swift +// PassKit +// +// Created by Francesco Paolo Severino on 04/07/24. +// + +import Vapor + +struct PersonalizationDictionaryDTO: Content { + let personalizationToken: String + let requiredPersonalizationInfo: RequiredPersonalizationInfo + + struct RequiredPersonalizationInfo: Content { + let emailAddress: String? + let familyName: String? + let fullName: String? + let givenName: String? + let ISOCountryCode: String? + let phoneNumber: String? + let postalCode: String? + } +} \ No newline at end of file diff --git a/Sources/Passes/DTOs/PersonalizationJSON.swift b/Sources/Passes/DTOs/PersonalizationJSON.swift new file mode 100644 index 0000000..ac6a301 --- /dev/null +++ b/Sources/Passes/DTOs/PersonalizationJSON.swift @@ -0,0 +1,52 @@ +// +// PersonalizationJSON.swift +// PassKit +// +// Created by Francesco Paolo Severino on 04/07/24. +// + +/// The structure of a `personalization.json` file. +/// +/// This file specifies the personal information requested by the signup form. +/// It also contains a description of the program and (optionally) the program’s terms and conditions. +public struct PersonalizationJSON { + /// A protocol that defines the structure of a `personalization.json` file. + /// + /// > Tip: See the [documentation](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/PassPersonalization.html#//apple_ref/doc/uid/TP40012195-CH12-SW2) to understand the keys. + public protocol Properties: Encodable { + /// 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] { get } + + /// A brief description of the program. + /// + /// This is displayed on the signup sheet, under the personalization logo. + var description: String { get } + } +} + +extension PersonalizationJSON { + /// Personal information requested by the signup form. + public enum PersonalizationField: String, Encodable { + /// Prompts the user for their name. + /// + /// `fullName`, `givenName`, and `familyName` are submitted in the personalize request. + case name = "PKPassPersonalizationFieldName" + + /// Prompts the user for their postal code. + /// + /// `postalCode` and `ISOCountryCode` are submitted in the personalize request. + case postalCode = "PKPassPersonalizationFieldPostalCode" + + /// Prompts the user for their email address. + /// + /// `emailAddress` is submitted in the personalize request. + case emailAddress = "PKPassPersonalizationFieldEmailAddress" + + /// Prompts the user for their phone number. + /// + /// `phoneNumber` is submitted in the personalize request. + case phoneNumber = "PKPassPersonalizationFieldPhoneNumber" + } +} \ No newline at end of file diff --git a/Sources/Passes/Models/Concrete Models/PKPass.swift b/Sources/Passes/Models/Concrete Models/PKPass.swift index 4103a09..12f035b 100644 --- a/Sources/Passes/Models/Concrete Models/PKPass.swift +++ b/Sources/Passes/Models/Concrete Models/PKPass.swift @@ -9,18 +9,28 @@ import Foundation import FluentKit /// The `Model` that stores PassKit passes. +/// +/// Uses a UUID so people can't easily guess pass serial numbers. open class PKPass: PassModel, @unchecked Sendable { + /// The schema name of the pass model. public static let schema = PKPass.FieldKeys.schemaName + /// The pass alphanumeric serial number. + /// + /// The combination of the serial number and pass type identifier must be unique for each pass. + /// Uses a UUID so people can't easily guess the pass serial number. @ID public var id: UUID? + /// The last time the pass was modified. @Timestamp(key: PKPass.FieldKeys.updatedAt, on: .update) public var updatedAt: Date? + /// The pass type identifier that’s registered with Apple. @Field(key: PKPass.FieldKeys.passTypeIdentifier) public var passTypeIdentifier: String + /// The authentication token to use with the web service in the `webServiceURL` key. @Field(key: PKPass.FieldKeys.authenticationToken) public var authenticationToken: String diff --git a/Sources/Passes/Models/Concrete Models/PassesDevice.swift b/Sources/Passes/Models/Concrete Models/PassesDevice.swift index c06eba0..6bda9db 100644 --- a/Sources/Passes/Models/Concrete Models/PassesDevice.swift +++ b/Sources/Passes/Models/Concrete Models/PassesDevice.swift @@ -10,14 +10,17 @@ import PassKit /// The `Model` that stores PassKit passes devices. final public class PassesDevice: DeviceModel, @unchecked Sendable { + /// The schema name of the device model. public static let schema = PassesDevice.FieldKeys.schemaName @ID(custom: .id) public var id: Int? + /// The push token used for sending updates to the device. @Field(key: PassesDevice.FieldKeys.pushToken) public var pushToken: String + /// The identifier PassKit provides for the device. @Field(key: PassesDevice.FieldKeys.deviceLibraryIdentifier) public var deviceLibraryIdentifier: String diff --git a/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift b/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift index 3e0497c..e7d6107 100644 --- a/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift +++ b/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift @@ -11,14 +11,17 @@ import PassKit /// The `Model` that stores PassKit passes error logs. final public class PassesErrorLog: ErrorLogModel, @unchecked Sendable { + /// The schema name of the error log model. public static let schema = PassesErrorLog.FieldKeys.schemaName @ID(custom: .id) public var id: Int? + /// The date and time the error log was created. @Timestamp(key: PassesErrorLog.FieldKeys.createdAt, on: .create) public var createdAt: Date? + /// The error message provided by PassKit. @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 35cea46..9c71284 100644 --- a/Sources/Passes/Models/Concrete Models/PassesRegistration.swift +++ b/Sources/Passes/Models/Concrete Models/PassesRegistration.swift @@ -12,14 +12,17 @@ final public class PassesRegistration: PassesRegistrationModel, @unchecked Senda public typealias PassType = PKPass public typealias DeviceType = PassesDevice + /// The schema name of the passes registration model. public static let schema = PassesRegistration.FieldKeys.schemaName @ID(custom: .id) public var id: Int? + /// The device for this registration. @Parent(key: PassesRegistration.FieldKeys.deviceID) public var device: DeviceType + /// The pass for this registration. @Parent(key: PassesRegistration.FieldKeys.passID) public var pass: PassType diff --git a/Sources/Passes/Models/PassDataModel.swift b/Sources/Passes/Models/PassDataModel.swift index 7da4280..abe2425 100644 --- a/Sources/Passes/Models/PassDataModel.swift +++ b/Sources/Passes/Models/PassDataModel.swift @@ -32,7 +32,7 @@ import FluentKit public protocol PassDataModel: Model { associatedtype PassType: PassModel - /// The foreign key to the pass table + /// The foreign key to the pass table. var pass: PassType { get set } } diff --git a/Sources/Passes/Models/PassModel.swift b/Sources/Passes/Models/PassModel.swift index 430b035..1dfbdc1 100644 --- a/Sources/Passes/Models/PassModel.swift +++ b/Sources/Passes/Models/PassModel.swift @@ -30,16 +30,16 @@ import Foundation import FluentKit /// Represents the `Model` that stores PassKit passes. -/// -/// Uses a UUID so people can't easily guess pass IDs +/// +/// Uses a UUID so people can't easily guess pass serial numbers. public protocol PassModel: Model where IDValue == UUID { - /// The pass type identifier. + /// The pass type identifier that’s registered with Apple. var passTypeIdentifier: String { get set } /// The last time the pass was modified. var updatedAt: Date? { get set } - /// The authentication token for the pass. + /// The authentication token to use with the web service in the `webServiceURL` key. var authenticationToken: String { get set } } diff --git a/Sources/Passes/Models/PassesRegistrationModel.swift b/Sources/Passes/Models/PassesRegistrationModel.swift index 91310b5..afe1699 100644 --- a/Sources/Passes/Models/PassesRegistrationModel.swift +++ b/Sources/Passes/Models/PassesRegistrationModel.swift @@ -37,7 +37,7 @@ public protocol PassesRegistrationModel: Model where IDValue == Int { /// The device for this registration. var device: DeviceType { get set } - /// /The pass for this registration. + /// The pass for this registration. var pass: PassType { get set } } diff --git a/Sources/Passes/Passes.docc/DistributeUpdate.md b/Sources/Passes/Passes.docc/DistributeUpdate.md new file mode 100644 index 0000000..39d5e2a --- /dev/null +++ b/Sources/Passes/Passes.docc/DistributeUpdate.md @@ -0,0 +1,208 @@ +# Building, Distributing and Updating a Pass + +Build a distributable pass and distribute it to your users or update an existing pass. + +## Overview + +The pass you distribute to a user is a signed bundle that contains the JSON description of the pass, 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 ``PassesService`` class also provides methods to send push notifications to all devices registered to a pass when it's updated and all the routes that Apple Wallet expects to get and update passes. + +### Implement the Delegate + +Create a delegate file that implements ``PassesDelegate``. +In the ``PassesDelegate/sslSigningFilesDirectory`` you specify there must be the `WWDR.pem`, `passcertificate.pem` and `passkey.pem` files. +If they are named like that you're good to go, otherwise you have to specify the custom name. + +> Tip: Obtaining the three certificates files could be a bit tricky. You could get some guidance from [this guide](https://github.com/alexandercerutti/passkit-generator/wiki/Generating-Certificates) and [this video](https://www.youtube.com/watch?v=rJZdPoXHtzI). + +There are other fields available which have reasonable default values. See ``PassesDelegate``'s documentation. + +Because the files for your pass' template and the method of encoding might vary by pass type, you'll be provided the ``PKPass`` 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 { + let sslSigningFilesDirectory = URL(fileURLWithPath: "Certificates/Passes/", isDirectory: true) + + let pemPrivateKeyPassword: String? = Environment.get("PEM_PRIVATE_KEY_PASSWORD")! + + func encode(pass: P, db: Database, encoder: JSONEncoder) async throws -> Data { + // The specific PassData class you use here may vary based on the `pass.passTypeIdentifier` + // 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.id!) + .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: P, db: Database) async throws -> URL { + // The location might vary depending on the type of pass. + return URL(fileURLWithPath: "Templates/Passes/", isDirectory: true) + } +} +``` + +> Important: You **must** explicitly declare ``PassesDelegate/pemPrivateKeyPassword`` as a `String?` or Swift will ignore it as it'll think it's a `String` instead. + +### Register the Routes + +Next, register the routes in `routes.swift`. +This will implement all of the routes that Apple Wallet expects to exist on your server for you. + +```swift +import Vapor +import Passes + +let passDelegate = PassDelegate() + +func routes(_ app: Application) throws { + let passesService = PassesService(app: app, delegate: passDelegate) + passesService.registerRoutes() +} +``` + +> 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 `routes(_:)` method exits. + +### Push Notifications + +If you wish to include routes specifically for sending push notifications to updated passes you can also include this line in your `routes(_:)` method. You'll need to pass in whatever `Middleware` you want Vapor to use to authenticate the two routes. + +> Important: If you don't include this line, you have to configure an APNS container yourself. + +```swift +try passesService.registerPushRoutes(middleware: SecretMiddleware(secret: "foo")) +``` + +That will add two routes, the first one sends notifications and the second one retrieves a list of push tokens which would be sent a notification. + +```http +POST https://example.com/api/passes/v1/push/{passTypeIdentifier}/{passSerial} HTTP/2 +``` + +```http +GET https://example.com/api/passes/v1/push/{passTypeIdentifier}/{passSerial} HTTP/2 +``` + +### Pass Data Model Middleware + +Whether you include the routes or not, you'll want to add a model middleware that sends push notifications and updates the ``PKPass/updatedAt`` field when your pass data updates. The model middleware could also create and link the ``PKPass`` during the creation of the pass data, depending on your requirements. + +See for more information. + +### Apple Push Notification service + +If you did not include the push notification routes, remember to configure APNs yourself. + +> Important: PassKit *only* works with the APNs production environment. You can't pass in the `.sandbox` environment. + +```swift +let apnsConfig: APNSClientConfiguration +if let pemPrivateKeyPassword { + apnsConfig = APNSClientConfiguration( + authenticationMethod: try .tls( + privateKey: .privateKey( + NIOSSLPrivateKey(file: privateKeyPath, format: .pem) { closure in + closure(pemPrivateKeyPassword.utf8) + }), + certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) } + ), + environment: .production + ) +} else { + apnsConfig = APNSClientConfiguration( + authenticationMethod: try .tls( + privateKey: .file(privateKeyPath), + certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) } + ), + environment: .production + ) +} +app.apns.containers.use( + apnsConfig, + eventLoopGroupProvider: .shared(app.eventLoopGroup), + responseDecoder: JSONDecoder(), + requestEncoder: JSONEncoder(), + as: .init(string: "passes"), + isDefault: false +) +``` + +### Generate the Pass Content + +To generate and distribute the `.pkpass` bundle, pass the ``PassesService`` object to your `RouteCollection`. + +```swift +import Fluent +import Vapor +import Passes + +struct PassesController: RouteCollection { + let passesService: PassesService + + func boot(routes: RoutesBuilder) throws { + ... + } +} +``` + +Then use the object inside your route handlers to generate the pass bundle with the ``PassesService/generatePassContent(for:on:)`` method and distribute it with the "`application/vnd.apple.pkpass`" MIME type. + +```swift +fileprivate func passHandler(_ req: Request) async throws -> Response { + ... + guard let passData = try await PassData.query(on: req.db) + .filter(...) + .with(\.$pass) + .first() + else { + throw Abort(.notFound) + } + + let bundle = try await passesService.generatePassContent(for: passData.pass, on: req.db) + let body = Response.Body(data: bundle) + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "application/vnd.apple.pkpass") + headers.add(name: .contentDisposition, value: "attachment; filename=name.pkpass") // Add this header only if you are serving the pass in a web page + headers.add(name: .lastModified, value: String(passData.pass.updatedAt?.timeIntervalSince1970 ?? 0)) + headers.add(name: .contentTransferEncoding, value: "binary") + return Response(status: .ok, headers: headers, body: body) +} +``` + +### Create a Bundle of Passes + +You can also create a bundle of passes to enable your user to download multiple passes at once. +Use the ``PassesService/generatePassesContent(for:on:)`` method to generate the bundle and serve it to the user. +The MIME type for a bundle of passes is "`application/vnd.apple.pkpasses`". + +> Note: You can have up to 10 passes or 150 MB for a bundle of passes. + +> Important: Bundles of passes are supported only in Safari. You can't send the bundle via AirDrop or other methods. + +```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 bundle = try await passesService.generatePassesContent(for: passes, on: req.db) + let body = Response.Body(data: bundle) + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "application/vnd.apple.pkpasses") + headers.add(name: .contentDisposition, value: "attachment; filename=name.pkpasses") + headers.add(name: .lastModified, value: String(Date().timeIntervalSince1970)) + headers.add(name: .contentTransferEncoding, value: "binary") + return Response(status: .ok, headers: headers, body: body) +} +``` diff --git a/Sources/Passes/Passes.docc/Extensions/PassJSON.md b/Sources/Passes/Passes.docc/Extensions/PassJSON.md new file mode 100644 index 0000000..0a46f38 --- /dev/null +++ b/Sources/Passes/Passes.docc/Extensions/PassJSON.md @@ -0,0 +1,19 @@ +# ``Passes/PassJSON`` + +## Topics + +### Essentials + +- ``Properties`` +- ``FormatVersion`` +- ``PassFieldContent`` + +### Barcodes + +- ``Barcodes`` +- ``BarcodeFormat`` + +### Boarding Passes + +- ``BoardingPass`` +- ``TransitType`` \ No newline at end of file diff --git a/Sources/Passes/Passes.docc/Extensions/PassesService.md b/Sources/Passes/Passes.docc/Extensions/PassesService.md new file mode 100644 index 0000000..86d11d5 --- /dev/null +++ b/Sources/Passes/Passes.docc/Extensions/PassesService.md @@ -0,0 +1,17 @@ +# ``Passes/PassesService`` + +## Topics + +### Essentials + +- ``generatePassContent(for:on:)`` +- ``generatePassesContent(for:on:)`` +- ``register(migrations:)`` +- ``registerRoutes()`` + +### Push Notifications + +- ``registerPushRoutes(middleware:)`` +- ``sendPushNotifications(for:on:app:)-2em82`` +- ``sendPushNotifications(for:on:app:)-487hq`` +- ``sendPushNotificationsForPass(id:of:on:app:)`` diff --git a/Sources/Passes/Passes.docc/PassData.md b/Sources/Passes/Passes.docc/PassData.md new file mode 100644 index 0000000..0ebc56d --- /dev/null +++ b/Sources/Passes/Passes.docc/PassData.md @@ -0,0 +1,200 @@ +# Create the Pass Data Model + +Implement the pass data model, its model middleware and define the pass file contents. + +## Overview + +The Passes framework provides models to save all the basic information for passes, user devices and their registration to each pass. +For all the other custom data needed to generate the pass (such as the barcodes, locations, etc.), you have to create your own model and its model middleware to handle the creation and update of passes. +The pass data model will be used to generate the `pass.json` file contents, along side image files for the icon and other visual elements, such as a logo. + +### 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 ``PKPass``, the pass model offered by the Passes framework. + +```swift +import Fluent +import struct Foundation.UUID +import Passes + +final class PassData: PassDataModel, @unchecked Sendable { + static let schema = "pass_data" + + @ID + var id: UUID? + + @Parent(key: "pass_id") + var pass: PKPass + + // Examples of other extra fields: + @Field(key: "punches") + var punches: Int + + @Field(key: "title") + var title: String + + // Add any other field relative to your app, such as a location, a date, etc. + + init() { } +} + +struct CreatePassData: AsyncMigration { + public func prepare(on database: Database) async throws { + try await database.schema(Self.schema) + .id() + .field("pass_id", .uuid, .required, .references(PKPass.schema, .id, onDelete: .cascade)) + .field("punches", .int, .required) + .field("title", .string, .required) + .create() + } + + public func revert(on database: Database) async throws { + try await database.schema(Self.schema).delete() + } +} +``` + +### 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 ``PKPass`` to the pass data model, depending on your requirements. +When your pass data changes, it should also update the ``PKPass/updatedAt`` field of the ``PKPass`` and send a push notification to all devices registered to that pass. + +See for more information on how to send push notifications. + +```swift +import Vapor +import Fluent +import Passes + +struct PassDataMiddleware: AsyncModelMiddleware { + private unowned let app: Application + + init(app: Application) { + self.app = app + } + + // Create the `PKPass` and add it to the `PassData` automatically at creation + func create(model: PassData, on db: Database, next: AnyAsyncModelResponder) async throws { + let pkPass = PKPass( + passTypeIdentifier: "pass.com.yoursite.passType", + authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString()) + try await pkPass.save(on: db) + model.$pass.id = try pkPass.requireID() + try await next.create(model, on: db) + } + + func update(model: PassData, on db: Database, next: AnyAsyncModelResponder) async throws { + let pkPass = try await model.$pass.get(on: db) + pkPass.updatedAt = Date() + try await pkPass.save(on: db) + try await next.update(model, on: db) + try await PassesService.sendPushNotifications(for: pkPass, on: db, app: self.app) + } +} +``` + +Remember to register it in the `configure.swift` file. + +```swift +app.databases.middleware.use(PassDataMiddleware(app: app), on: .psql) +``` + +> Important: Whenever your pass data changes, you must update the ``PKPass/updatedAt`` time of the linked pass so that Apple knows to send you a new pass. + +### Handle Cleanup + +Depending on your implementation details, you may want to automatically clean out the passes and devices table when a registration is deleted. +You'll need to implement based on your type of SQL database as there's not yet a Fluent way to implement something like SQL's `NOT EXISTS` call with a `DELETE` statement. + +> Warning: Be careful with SQL triggers, as they can have unintended consequences if not properly implemented. + +### Model the pass.json contents + +Create a `struct` that implements ``PassJSON/Properties`` which will contain all the fields for the generated `pass.json` file. +Create an initializer that takes your custom pass data, the ``PKPass`` and everything else you may need. + +> Tip: For information on the various keys available see the [documentation](https://developer.apple.com/documentation/walletpasses/pass). See also [this guide](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/index.html#//apple_ref/doc/uid/TP40012195-CH1-SW1) for some help. + +```swift +import Passes + +struct PassJSONData: PassJSON.Properties { + let description: String + let formatVersion = PassJSON.FormatVersion.v1 + let organizationName = "vapor-community" + let passTypeIdentifier = Environment.get("PASSKIT_PASS_TYPE_IDENTIFIER")! + let serialNumber: String + let teamIdentifier = Environment.get("APPLE_TEAM_IDENTIFIER")! + + private let webServiceURL = "https://example.com/api/passes/" + private let authenticationToken: String + private let logoText = "Vapor" + private let sharingProhibited = true + let backgroundColor = "rgb(207, 77, 243)" + let foregroundColor = "rgb(255, 255, 255)" + + let barcodes = Barcode(message: "test") + struct Barcode: PassJSON.Barcodes { + let format = PassJSON.BarcodeFormat.qr + let message: String + let messageEncoding = "iso-8859-1" + } + + let boardingPass = Boarding(transitType: .air) + struct Boarding: PassJSON.BoardingPass { + let transitType: PassJSON.TransitType + let headerFields: [PassField] + let primaryFields: [PassField] + let secondaryFields: [PassField] + let auxiliaryFields: [PassField] + let backFields: [PassField] + + struct PassField: PassJSON.PassFieldContent { + let key: String + let label: String + let value: String + } + + init(transitType: PassJSON.TransitType) { + self.headerFields = [.init(key: "header", label: "Header", value: "Header")] + self.primaryFields = [.init(key: "primary", label: "Primary", value: "Primary")] + self.secondaryFields = [.init(key: "secondary", label: "Secondary", value: "Secondary")] + self.auxiliaryFields = [.init(key: "auxiliary", label: "Auxiliary", value: "Auxiliary")] + self.backFields = [.init(key: "back", label: "Back", value: "Back")] + self.transitType = transitType + } + } + + init(data: PassData, pass: PKPass) { + self.description = data.title + self.serialNumber = pass.id!.uuidString + self.authenticationToken = pass.authenticationToken + } +} +``` + +> Important: You **must** add `api/passes/` to your `webServiceURL`, as shown in the example above. + +### Register Migrations + +If you're using the default schemas provided by this framework, you can register the default models in your `configure(_:)` method: + +```swift +PassesService.register(migrations: app.migrations) +``` + +> Important: Register the default models before the migration of your pass data model. + +### Custom Implementation + +If you don't like the schema names provided by the framework that are used by default, you can instead create your own models conforming to ``PassModel``, `DeviceModel`, ``PassesRegistrationModel`` and `ErrorLogModel` and instantiate the generic ``PassesServiceCustom``, providing it your model types. + +```swift +import PassKit +import Passes + +let passesService = PassesServiceCustom(app: app, delegate: delegate) +``` + +> Important: `DeviceModel` and `ErrorLogModel` are defined in the PassKit framework. diff --git a/Sources/Passes/Passes.docc/Passes.md b/Sources/Passes/Passes.docc/Passes.md new file mode 100644 index 0000000..3625ba2 --- /dev/null +++ b/Sources/Passes/Passes.docc/Passes.md @@ -0,0 +1,53 @@ +# ``Passes`` + +Create, distribute, and update passes for the Apple Wallet app with Vapor. + +## Overview + +@Row { + @Column { } + @Column(size: 4) { + ![Passes](passes) + } + @Column { } +} + +The Passes framework provides a set of tools to help you create, build, and distribute digital passes for the Apple Wallet app using a Vapor server. It also provides a way to update passes after they have been distributed, using APNs, and models to store pass and device data. + +For information on Apple Wallet passes, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletpasses). + +## Topics + +### Essentials + +- +- +- ``PassJSON`` + +### Building and Distribution + +- ``PassesDelegate`` +- ``PassesService`` +- ``PassesServiceCustom`` + +### Concrete Models + +- ``PKPass`` +- ``PassesRegistration`` +- ``PassesDevice`` +- ``PassesErrorLog`` + +### Abstract Models + +- ``PassModel`` +- ``PassesRegistrationModel`` +- ``PassDataModel`` + +### Errors + +- ``PassesError`` + +### Personalized Passes (⚠️ WIP) + +- +- ``PersonalizationJSON`` diff --git a/Sources/Passes/Passes.docc/Personalization.md b/Sources/Passes/Passes.docc/Personalization.md new file mode 100644 index 0000000..a89c8be --- /dev/null +++ b/Sources/Passes/Passes.docc/Personalization.md @@ -0,0 +1,128 @@ +# Setting Up Pass Personalization + +Create and sign a personalized pass, and send it to a device. + +## Overview + +Pass Personalization lets you create passes, referred to as personalizable passes, that prompt the user to provide personal information during signup that is used to update the pass. + +> Important: Making a pass personalizable, just like adding NFC to a pass, requires a special entitlement issued by Apple. Although accessing such entitlements is hard if you're not a big company, you can learn more in [Getting Started with Apple Wallet](https://developer.apple.com/wallet/get-started/). + +Personalizable passes can be distributed like any other pass. For information on personalizable passes, see the [Wallet Developer Guide](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/PassPersonalization.html#//apple_ref/doc/uid/TP40012195-CH12-SW2) and [Return a Personalized Pass](https://developer.apple.com/documentation/walletpasses/return_a_personalized_pass). + +### Model the personalization.json contents + +A personalizable pass is just a standard pass package with the following additional files: + +- A `personalization.json` file. +- A `personalizationLogo@XX.png` file. + +Create a `struct` that implements ``PersonalizationJSON/Properties`` which will contain all the fields for the generated `personalization.json` file. +Create an initializer that takes your custom pass data, the ``PKPass`` and everything else you may need. + +```swift +import Passes + +struct PersonalizationJSONData: PersonalizationJSON.Properties { + var requiredPersonalizationFields = [ + PersonalizationJSON.PersonalizationField.name, + PersonalizationJSON.PersonalizationField.postalCode, + PersonalizationJSON.PersonalizationField.emailAddress, + PersonalizationJSON.PersonalizationField.phoneNumber + ] + var description: String + + init(data: PassData, pass: PKPass) { + self.description = data.title + } +} +``` + +### Implement the Delegate + +Then implement the ``PassesDelegate/encodePersonalization(for:db:encoder:)`` method of the ``PassesDelegate`` to add the personalization JSON file to passes that require it. + +```swift +import Vapor +import Fluent +import Passes + +final class PassDelegate: PassesDelegate { + let sslSigningFilesDirectory = URL(fileURLWithPath: "Certificates/Passes/", isDirectory: true) + + let pemPrivateKeyPassword: String? = Environment.get("PEM_PRIVATE_KEY_PASSWORD")! + + 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.id!) + .first() + else { + throw Abort(.internalServerError) + } + guard let data = try? encoder.encode(PassJSONData(data: passData, pass: pass)) else { + throw Abort(.internalServerError) + } + return data + } + + func encodePersonalization(for pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data? { + guard let passData = try await PassData.query(on: db) + .filter(\.$pass.$id == pass.id!) + .first() + else { + throw Abort(.internalServerError) + } + + if passData.requiresPersonalization { + // If the pass requires personalization, encode the personalization JSON data. + guard let data = try? encoder.encode(PersonalizationJSONData(data: passData, pass: pass)) else { + throw Abort(.internalServerError) + } + return data + } else { + // Otherwise, return `nil`. + return nil + } + } + + func template(for: P, db: Database) async throws -> URL { + guard let passData = try await PassData.query(on: db) + .filter(\.$pass.$id == pass.id!) + .first() + else { + throw Abort(.internalServerError) + } + + if passData.requiresPersonalization { + // If the pass requires personalization, return the URL to the personalization template, + // which must contain the `personalizationLogo@XX.png` files. + return URL(fileURLWithPath: "Templates/Passes/Personalization/", isDirectory: true) + } else { + // Otherwise, return the URL to the standard pass template. + return URL(fileURLWithPath: "Templates/Passes/Standard/", isDirectory: true) + } + } +} +``` + +> Note: If you don't need to personalize passes for your app, you don't need to implement the ``PassesDelegate/encodePersonalization(for:db:encoder:)`` method. + +### Implement the User Data Model (⚠️ WIP) + +> Warning: This section is a work in progress. Right now, the data model required to save users' personal information for each pass **is not implemented**. Development is hard without access to the certificates required to test pass personalization. If you have access to the entitlements, please help us implement this feature. + +### Implement the Web Service (⚠️ WIP) + +> Warning: This section is a work in progress. Right now, the endpoint required to handle pass personalization **is not implemented**. Development is hard without access to the certificates required to test this feature. If you have access to the entitlements, please help us implement this feature. + +After implementing the JSON `struct` and the delegate, there is nothing else you have to do. +Adding the ``PassesService/registerRoutes()`` method to your `routes.swift` file will automatically set up the endpoints that Apple Wallet expects to exist on your server to handle pass personalization. + +Generate the pass bundle with ``PassesService/generatePassContent(for:on:)`` as usual and distribute it to the user. The Passes framework and Apple Wallet will take care of the rest. + +## Topics + +### Delegate Method + +- ``PassesDelegate/encodePersonalization(for:db:encoder:)`` diff --git a/Sources/Passes/Passes.docc/Resources/passes.png b/Sources/Passes/Passes.docc/Resources/passes.png new file mode 100644 index 0000000..a900049 Binary files /dev/null and b/Sources/Passes/Passes.docc/Resources/passes.png differ diff --git a/Sources/Passes/PassesDelegate.swift b/Sources/Passes/PassesDelegate.swift index ca57ec5..19a1b35 100644 --- a/Sources/Passes/PassesDelegate.swift +++ b/Sources/Passes/PassesDelegate.swift @@ -36,6 +36,7 @@ public protocol PassesDelegate: AnyObject, Sendable { /// The URL 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: @@ -71,6 +72,24 @@ public protocol PassesDelegate: AnyObject, Sendable { /// > 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 + /// Encode the personalization JSON file. + /// + /// This method of the ``PassesDelegate`` should generate the entire personalization JSON file. + /// You are provided with the pass data from the SQL database and, + /// if the pass in question requires personalization, + /// you should return a properly formatted personalization JSON file. + /// + /// 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. + /// - encoder: The `JSONEncoder` which you should use. + /// - Returns: The encoded personalization JSON data, or `nil` if the pass does not require personalization. + func encodePersonalization(for pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data? + /// Should return a `URL` which points to the template data for the pass. /// /// The URL should point to a directory containing the files specified by these keys: @@ -138,4 +157,8 @@ public extension PassesDelegate { func generateSignatureFile(in root: URL) -> Bool { return false } + + func encodePersonalization(for pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data? { + return nil + } } diff --git a/Sources/Passes/PassesError.swift b/Sources/Passes/PassesError.swift index 673211a..7bb5fde 100644 --- a/Sources/Passes/PassesError.swift +++ b/Sources/Passes/PassesError.swift @@ -2,25 +2,85 @@ // PassesError.swift // PassKit // -// Created by Scott Grosch on 1/22/20. +// Created by Francesco Paolo Severino on 04/07/24. // -public enum PassesError: Error { - /// The template path is not a directory - case templateNotDirectory +/// Errors that can be thrown by PassKit passes. +public struct PassesError: Error, Sendable { + /// The type of the errors that can be thrown by PassKit passes. + public struct ErrorType: Sendable, Hashable, CustomStringConvertible { + enum Base: String, Sendable { + case templateNotDirectory + case pemCertificateMissing + case pemPrivateKeyMissing + case zipBinaryMissing + case opensslBinaryMissing + case invalidNumberOfPasses + } + + let base: Base + + private init(_ base: Base) { + self.base = base + } + + /// The template path is not a directory. + public static let templateNotDirectory = Self(.templateNotDirectory) + /// The `pemCertificate` file is missing. + public static let pemCertificateMissing = Self(.pemCertificateMissing) + /// The `pemPrivateKey` file is missing. + public static let pemPrivateKeyMissing = Self(.pemPrivateKeyMissing) + /// The path to the `zip` binary is incorrect. + public static let zipBinaryMissing = Self(.zipBinaryMissing) + /// The path to the `openssl` binary is incorrect. + public static let opensslBinaryMissing = Self(.opensslBinaryMissing) + /// The number of passes to bundle is invalid. + public static let invalidNumberOfPasses = Self(.invalidNumberOfPasses) + + /// A textual representation of this error. + public var description: String { + base.rawValue + } + } + + private struct Backing: Sendable { + fileprivate let errorType: ErrorType + + init(errorType: ErrorType) { + self.errorType = errorType + } + } + + private var backing: Backing + + /// The type of this error. + public var errorType: ErrorType { backing.errorType } + + private init(errorType: ErrorType) { + self.backing = .init(errorType: errorType) + } + + /// The template path is not a directory. + public static let templateNotDirectory = Self(errorType: .templateNotDirectory) /// The `pemCertificate` file is missing. - case pemCertificateMissing + public static let pemCertificateMissing = Self(errorType: .pemCertificateMissing) /// The `pemPrivateKey` file is missing. - case pemPrivateKeyMissing + public static let pemPrivateKeyMissing = Self(errorType: .pemPrivateKeyMissing) - /// Swift NIO failed to read the key. - case nioPrivateKeyReadFailed(any Error) + /// The path to the `zip` binary is incorrect. + public static let zipBinaryMissing = Self(errorType: .zipBinaryMissing) - /// The path to the zip binary is incorrect. - case zipBinaryMissing + /// The path to the `openssl` binary is incorrect. + public static let opensslBinaryMissing = Self(errorType: .opensslBinaryMissing) + + /// The number of passes to bundle is invalid. + public static let invalidNumberOfPasses = Self(errorType: .invalidNumberOfPasses) +} - /// The path to the openssl binary is incorrect - case opensslBinaryMissing +extension PassesError: CustomStringConvertible { + public var description: String { + "PassesError(errorType: \(self.errorType))" + } } diff --git a/Sources/Passes/PassesService.swift b/Sources/Passes/PassesService.swift index e34cc09..6ff6bf5 100644 --- a/Sources/Passes/PassesService.swift +++ b/Sources/Passes/PassesService.swift @@ -33,6 +33,12 @@ import FluentKit public final class PassesService: Sendable { private let service: PassesServiceCustom + /// Initializes the service. + /// + /// - Parameters: + /// - app: The `Vapor.Application` to use in route handlers and APNs. + /// - delegate: The ``PassesDelegate`` to use for pass generation. + /// - logger: The `Logger` to use. public init(app: Application, delegate: any PassesDelegate, logger: Logger? = nil) { service = .init(app: app, delegate: delegate, logger: logger) } @@ -54,10 +60,24 @@ public final class PassesService: Sendable { /// - Parameters: /// - pass: The pass to generate the content for. /// - db: The `Database` to use. - /// - Returns: The generated pass content. + /// - Returns: The generated pass content as `Data`. public func generatePassContent(for pass: PKPass, on db: any Database) async throws -> Data { try await service.generatePassContent(for: pass, on: db) } + + /// Generates a bundle of passes to enable your user to download multiple passes at once. + /// + /// > Note: You can have up to 10 passes or 150 MB for a bundle of passes. + /// + /// > Important: Bundles of passes are supported only in Safari. You can't send the bundle via AirDrop or other methods. + /// + /// - Parameters: + /// - passes: The passes to include in the bundle. + /// - db: The `Database` to use. + /// - Returns: The bundle of passes as `Data`. + public func generatePassesContent(for passes: [PKPass], on db: any Database) async throws -> Data { + try await service.generatePassesContent(for: passes, on: db) + } /// Adds the migrations for PassKit passes models. /// diff --git a/Sources/Passes/PassesServiceCustom.swift b/Sources/Passes/PassesServiceCustom.swift index 1a61918..5b465d7 100644 --- a/Sources/Passes/PassesServiceCustom.swift +++ b/Sources/Passes/PassesServiceCustom.swift @@ -13,7 +13,7 @@ import Fluent import NIOSSL import PassKit -/// Class to handle `PassesService`. +/// Class to handle ``PassesService``. /// /// The generics should be passed in this order: /// - Pass Type @@ -21,12 +21,19 @@ import PassKit /// - Registration Type /// - Error Log Type public final class PassesServiceCustom: Sendable where P == R.PassType, D == R.DeviceType { + /// The ``PassesDelegate`` to use for pass generation. public unowned let delegate: any PassesDelegate private unowned let app: Application private let v1: any RoutesBuilder private let logger: Logger? + /// Initializes the service. + /// + /// - Parameters: + /// - app: The `Vapor.Application` to use in route handlers and APNs. + /// - delegate: The ``PassesDelegate`` to use for pass generation. + /// - logger: The `Logger` to use. public init(app: Application, delegate: any PassesDelegate, logger: Logger? = nil) { self.delegate = delegate self.logger = logger @@ -45,6 +52,7 @@ public final class PassesServiceCustom HTTPStatus { + let r = try await R.for(deviceLibraryIdentifier: device.deviceLibraryIdentifier, passTypeIdentifier: pass.passTypeIdentifier, on: db) + .filter(P.self, \._$id == pass.id!) + .first() + if r != nil { + // If the registration already exists, docs say to return a 200 + return .ok } + + let registration = R() + registration._$pass.id = pass.id! + registration._$device.id = device.id! + + try await registration.create(on: db) + return .created } func passesForDevice(req: Request) async throws -> PassesForDeviceDTO { @@ -264,7 +289,31 @@ extension PassesServiceCustom { return .ok } + + 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 { + throw Abort(.badRequest) + } + + guard let pass = try await P.query(on: req.db) + .filter(\._$id == id) + .filter(\._$passTypeIdentifier == passTypeIdentifier) + .first() + else { + throw Abort(.notFound) + } + + let personalization = try req.content.decode(PersonalizationDictionaryDTO.self) + */ + + throw Abort(.notImplemented) + } + // MARK: - Push Routes func pushUpdatesForPass(req: Request) async throws -> HTTPStatus { logger?.debug("Called pushUpdatesForPass") @@ -290,27 +339,17 @@ extension PassesServiceCustom { let registrations = try await Self.registrationsForPass(id: id, of: passTypeIdentifier, on: req.db) return registrations.map { $0.device.pushToken } } - - private static func createRegistration(device: D, pass: P, req: Request) async throws -> HTTPStatus { - let r = try await R.for(deviceLibraryIdentifier: device.deviceLibraryIdentifier, passTypeIdentifier: pass.passTypeIdentifier, on: req.db) - .filter(P.self, \._$id == pass.id!) - .first() - if r != nil { - // If the registration already exists, docs say to return a 200 - return .ok - } - - let registration = R() - registration._$pass.id = pass.id! - registration._$device.id = device.id! - - try await registration.create(on: req.db) - return .created - } } // MARK: - Push Notifications extension PassesServiceCustom { + /// Sends push notifications for a given pass. + /// + /// - Parameters: + /// - id: The `UUID` of the pass to send the notifications for. + /// - passTypeIdentifier: The type identifier of the pass. + /// - db: The `Database` to use. + /// - app: The `Application` to use. public static func sendPushNotificationsForPass(id: UUID, of passTypeIdentifier: String, on db: any Database, app: Application) async throws { let registrations = try await Self.registrationsForPass(id: id, of: passTypeIdentifier, on: db) for reg in registrations { @@ -327,6 +366,12 @@ extension PassesServiceCustom { } } + /// Sends push notifications for a given pass. + /// + /// - Parameters: + /// - pass: The pass to send the notifications for. + /// - db: The `Database` to use. + /// - app: The `Application` to use. public static func sendPushNotifications(for pass: P, on db: any Database, app: Application) async throws { guard let id = pass.id else { throw FluentError.idRequired @@ -335,6 +380,12 @@ extension PassesServiceCustom { try await Self.sendPushNotificationsForPass(id: id, of: pass.passTypeIdentifier, on: db, app: app) } + /// Sends push notifications for a given pass. + /// + /// - Parameters: + /// - pass: The pass (as the `ParentProperty`) to send the notifications for. + /// - db: The `Database` to use. + /// - app: The `Application` to use. public static func sendPushNotifications(for pass: ParentProperty, on db: any Database, app: Application) async throws { let value: P @@ -434,6 +485,12 @@ extension PassesServiceCustom { proc.waitUntilExit() } + /// Generates the pass content bundle for a given pass. + /// + /// - Parameters: + /// - pass: The pass to generate the content for. + /// - db: The `Database` to use. + /// - Returns: The generated pass content as `Data`. public func generatePassContent(for pass: P, on db: any Database) async throws -> Data { let tmp = FileManager.default.temporaryDirectory let root = tmp.appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -455,6 +512,11 @@ extension PassesServiceCustom { } try encoded.write(to: root.appendingPathComponent("pass.json")) + + // Pass Personalization + if let encodedPersonalization = try await self.delegate.encodePersonalization(for: pass, db: db, encoder: encoder) { + try encodedPersonalization.write(to: root.appendingPathComponent("personalization.json")) + } try Self.generateManifestFile(using: encoder, in: root) try self.generateSignatureFile(in: root) @@ -470,4 +532,43 @@ extension PassesServiceCustom { throw error } } + + /// Generates a bundle of passes to enable your user to download multiple passes at once. + /// + /// > Note: You can have up to 10 passes or 150 MB for a bundle of passes. + /// + /// > Important: Bundles of passes are supported only in Safari. You can't send the bundle via AirDrop or other methods. + /// + /// - Parameters: + /// - passes: The passes to include in the bundle. + /// - db: The `Database` to use. + /// - Returns: The bundle of passes as `Data`. + public func generatePassesContent(for passes: [P], on db: any Database) async throws -> Data { + guard passes.count > 1 && passes.count <= 10 else { + throw PassesError.invalidNumberOfPasses + } + + let tmp = FileManager.default.temporaryDirectory + let root = tmp.appendingPathComponent(UUID().uuidString, isDirectory: true) + let zipFile = tmp.appendingPathComponent("\(UUID().uuidString).zip") + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + + for (i, pass) in passes.enumerated() { + try await self.generatePassContent(for: pass, on: db) + .write(to: root.appendingPathComponent("pass\(i).pkpass")) + } + + defer { + _ = try? FileManager.default.removeItem(at: root) + } + + try self.zip(directory: root, to: zipFile) + + defer { + _ = try? FileManager.default.removeItem(at: zipFile) + } + + return try Data(contentsOf: zipFile) + } } diff --git a/Tests/OrdersTests/OrdersTests.swift b/Tests/OrdersTests/OrdersTests.swift new file mode 100644 index 0000000..ba5d087 --- /dev/null +++ b/Tests/OrdersTests/OrdersTests.swift @@ -0,0 +1,15 @@ +import XCTVapor +@testable import Orders + +final class OrdersTests: XCTestCase { + func testExample() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct + // results. + //XCTAssertEqual(OrdersService().text, "Hello, World!") + } + + static var allTests = [ + ("testExample", testExample), + ] +} diff --git a/Tests/PassKitTests/PassKitTests.swift b/Tests/PassesTests/PassesTests.swift similarity index 86% rename from Tests/PassKitTests/PassKitTests.swift rename to Tests/PassesTests/PassesTests.swift index e0fb540..6e30f98 100644 --- a/Tests/PassKitTests/PassKitTests.swift +++ b/Tests/PassesTests/PassesTests.swift @@ -1,7 +1,7 @@ -import XCTest +import XCTVapor @testable import Passes -final class PassKitTests: XCTestCase { +final class PassesTests: XCTestCase { func testExample() { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct