From 0c82bb9f5acb0f1e2e277157ce19a792e795d708 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Wed, 15 Jan 2025 16:18:54 +0100 Subject: [PATCH 1/4] Improve routing API --- .../Testing/SecretMiddleware.swift | 16 - Sources/VaporWalletOrders/OrdersService.swift | 12 +- .../OrdersServiceCustom+RouteCollection.swift | 182 +++++++++++ .../OrdersServiceCustom.swift | 251 +-------------- .../Extensions/OrdersService.md | 12 - .../VaporWalletOrders.docc/GettingStarted.md | 21 +- Sources/VaporWalletPasses/PassesService.swift | 12 +- .../PassesServiceCustom+RouteCollection.swift | 221 +++++++++++++ .../PassesServiceCustom.swift | 291 +----------------- .../Extensions/PassesService.md | 13 - .../VaporWalletPasses.docc/GettingStarted.md | 23 +- .../Utils/withApp.swift | 5 +- .../VaporWalletOrdersTests.swift | 84 ----- .../Utils/withApp.swift | 5 +- .../VaporWalletPassesTests.swift | 84 ----- 15 files changed, 442 insertions(+), 790 deletions(-) delete mode 100644 Sources/VaporWallet/Testing/SecretMiddleware.swift create mode 100644 Sources/VaporWalletOrders/OrdersServiceCustom+RouteCollection.swift delete mode 100644 Sources/VaporWalletOrders/VaporWalletOrders.docc/Extensions/OrdersService.md create mode 100644 Sources/VaporWalletPasses/PassesServiceCustom+RouteCollection.swift delete mode 100644 Sources/VaporWalletPasses/VaporWalletPasses.docc/Extensions/PassesService.md diff --git a/Sources/VaporWallet/Testing/SecretMiddleware.swift b/Sources/VaporWallet/Testing/SecretMiddleware.swift deleted file mode 100644 index ec9d64f..0000000 --- a/Sources/VaporWallet/Testing/SecretMiddleware.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Vapor - -package struct SecretMiddleware: AsyncMiddleware { - let secret: String - - package init(secret: String) { - self.secret = secret - } - - package func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { - guard request.headers.first(name: "X-Secret") == secret else { - throw Abort(.unauthorized, reason: "Incorrect X-Secret header.") - } - return try await next.respond(to: request) - } -} diff --git a/Sources/VaporWalletOrders/OrdersService.swift b/Sources/VaporWalletOrders/OrdersService.swift index d4f2dec..3a81b1b 100644 --- a/Sources/VaporWalletOrders/OrdersService.swift +++ b/Sources/VaporWalletOrders/OrdersService.swift @@ -10,8 +10,6 @@ public final class OrdersService: Sendable where Order == OD /// /// - Parameters: /// - app: The `Vapor.Application` to use in route handlers and APNs. - /// - pushRoutesMiddleware: The `Middleware` to use for push notification routes. If `nil`, push routes will not be registered. - /// - logger: The `Logger` to use. /// - pemWWDRCertificate: Apple's WWDR.pem certificate in PEM format. /// - pemCertificate: The PEM Certificate for signing orders. /// - pemPrivateKey: The PEM Certificate's private key for signing orders. @@ -19,8 +17,6 @@ public final class OrdersService: Sendable where Order == OD /// - openSSLPath: The location of the `openssl` command as a file path. public init( app: Application, - pushRoutesMiddleware: (any Middleware)? = nil, - logger: Logger? = nil, pemWWDRCertificate: String, pemCertificate: String, pemPrivateKey: String, @@ -29,8 +25,6 @@ public final class OrdersService: Sendable where Order == OD ) throws { self.service = try .init( app: app, - pushRoutesMiddleware: pushRoutesMiddleware, - logger: logger, pemWWDRCertificate: pemWWDRCertificate, pemCertificate: pemCertificate, pemPrivateKey: pemPrivateKey, @@ -68,3 +62,9 @@ public final class OrdersService: Sendable where Order == OD try await service.sendPushNotifications(for: order, on: db) } } + +extension OrdersService: RouteCollection { + public func boot(routes: any RoutesBuilder) throws { + try service.boot(routes: routes) + } +} diff --git a/Sources/VaporWalletOrders/OrdersServiceCustom+RouteCollection.swift b/Sources/VaporWalletOrders/OrdersServiceCustom+RouteCollection.swift new file mode 100644 index 0000000..bb5a619 --- /dev/null +++ b/Sources/VaporWalletOrders/OrdersServiceCustom+RouteCollection.swift @@ -0,0 +1,182 @@ +import Fluent +import FluentWalletOrders +import Vapor +import VaporWallet + +extension OrdersServiceCustom: RouteCollection { + public func boot(routes: any RoutesBuilder) throws { + let orderTypeIdentifier = PathComponent(stringLiteral: OD.typeIdentifier) + + let v1 = routes.grouped("v1") + v1.get("devices", ":deviceIdentifier", "registrations", orderTypeIdentifier, use: self.ordersForDevice) + v1.post("log", use: self.logMessage) + + let v1auth = v1.grouped(AppleOrderMiddleware()) + v1auth.post("devices", ":deviceIdentifier", "registrations", orderTypeIdentifier, ":orderIdentifier", use: self.registerDevice) + v1auth.get("orders", orderTypeIdentifier, ":orderIdentifier", use: self.latestVersionOfOrder) + v1auth.delete("devices", ":deviceIdentifier", "registrations", orderTypeIdentifier, ":orderIdentifier", use: self.unregisterDevice) + } + + private func latestVersionOfOrder(req: Request) async throws -> Response { + req.logger.debug("Called latestVersionOfOrder") + + var ifModifiedSince: TimeInterval = 0 + if let header = req.headers[.ifModifiedSince].first, let ims = TimeInterval(header) { + ifModifiedSince = ims + } + + guard let id = req.parameters.get("orderIdentifier", as: UUID.self) else { + throw Abort(.badRequest) + } + guard + let order = try await O.query(on: req.db) + .filter(\._$id == id) + .filter(\._$typeIdentifier == OD.typeIdentifier) + .first() + else { + throw Abort(.notFound) + } + + guard ifModifiedSince < order.updatedAt?.timeIntervalSince1970 ?? 0 else { + throw Abort(.notModified) + } + + guard + let orderData = try await OD.query(on: req.db) + .filter(\._$order.$id == id) + .first() + else { + throw Abort(.notFound) + } + + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "application/vnd.apple.order") + headers.lastModified = HTTPHeaders.LastModified(order.updatedAt ?? Date.distantPast) + headers.add(name: .contentTransferEncoding, value: "binary") + return try await Response( + status: .ok, + headers: headers, + body: Response.Body(data: self.build(order: orderData, on: req.db)) + ) + } + + private func registerDevice(req: Request) async throws -> HTTPStatus { + req.logger.debug("Called register device") + + let pushToken: String + do { + pushToken = try req.content.decode(PushTokenDTO.self).pushToken + } catch { + throw Abort(.badRequest) + } + + guard let orderIdentifier = req.parameters.get("orderIdentifier", as: UUID.self) else { + throw Abort(.badRequest) + } + let deviceIdentifier = req.parameters.get("deviceIdentifier")! + guard + let order = try await O.query(on: req.db) + .filter(\._$id == orderIdentifier) + .filter(\._$typeIdentifier == OD.typeIdentifier) + .first() + else { + throw Abort(.notFound) + } + + let device = try await D.query(on: req.db) + .filter(\._$libraryIdentifier == 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(libraryIdentifier: 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.libraryIdentifier, + typeIdentifier: order.typeIdentifier, + on: db + ) + .filter(O.self, \._$id == order.requireID()) + .first() + // If the registration already exists, docs say to return 200 OK + if r != nil { return .ok } + + let registration = R() + registration._$order.id = try order.requireID() + registration._$device.id = try device.requireID() + try await registration.create(on: db) + return .created + } + + private func ordersForDevice(req: Request) async throws -> OrderIdentifiersDTO { + req.logger.debug("Called ordersForDevice") + + let deviceIdentifier = req.parameters.get("deviceIdentifier")! + + var query = R.for( + deviceLibraryIdentifier: deviceIdentifier, + typeIdentifier: OD.typeIdentifier, + on: req.db + ) + if let since: TimeInterval = req.query["ordersModifiedSince"] { + let when = Date(timeIntervalSince1970: since) + 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 + for registration in registrations { + let order = try await registration._$order.get(on: req.db) + try orderIdentifiers.append(order.requireID().uuidString) + if let updatedAt = order.updatedAt, updatedAt > maxDate { + maxDate = updatedAt + } + } + + return OrderIdentifiersDTO(with: orderIdentifiers, maxDate: maxDate) + } + + private func logMessage(req: Request) async throws -> HTTPStatus { + let entries = try req.content.decode(LogEntriesDTO.self) + + for log in entries.logs { + req.logger.notice("VaporWalletOrders: \(log)") + } + + return .ok + } + + private func unregisterDevice(req: Request) async throws -> HTTPStatus { + req.logger.debug("Called unregisterDevice") + + 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, + typeIdentifier: OD.typeIdentifier, + on: req.db + ) + .filter(O.self, \._$id == orderIdentifier) + .first() + else { + throw Abort(.notFound) + } + try await r.delete(on: req.db) + return .ok + } +} diff --git a/Sources/VaporWalletOrders/OrdersServiceCustom.swift b/Sources/VaporWalletOrders/OrdersServiceCustom.swift index 5cba71d..e7af1ee 100644 --- a/Sources/VaporWalletOrders/OrdersServiceCustom.swift +++ b/Sources/VaporWalletOrders/OrdersServiceCustom.swift @@ -7,7 +7,6 @@ import Vapor import VaporAPNS import VaporWallet import WalletOrders -@_spi(CMS) import X509 import Zip /// Class to handle ``OrdersService``. @@ -24,15 +23,12 @@ public final class OrdersServiceCustom< R: OrdersRegistrationModel >: Sendable where O == OD.OrderType, O == R.OrderType, D == R.DeviceType { private unowned let app: Application - private let logger: Logger? - private let builder: OrderBuilder + let builder: OrderBuilder /// Initializes the service and registers all the routes required for Apple Wallet to work. /// /// - Parameters: /// - app: The `Vapor.Application` to use in route handlers and APNs. - /// - pushRoutesMiddleware: The `Middleware` to use for push notification routes. If `nil`, push routes will not be registered. - /// - logger: The `Logger` to use. /// - pemWWDRCertificate: Apple's WWDR.pem certificate in PEM format. /// - pemCertificate: The PEM Certificate for signing orders. /// - pemPrivateKey: The PEM Certificate's private key for signing orders. @@ -40,8 +36,6 @@ public final class OrdersServiceCustom< /// - openSSLPath: The location of the `openssl` command as a file path. public init( app: Application, - pushRoutesMiddleware: (any Middleware)? = nil, - logger: Logger? = nil, pemWWDRCertificate: String, pemCertificate: String, pemPrivateKey: String, @@ -49,7 +43,6 @@ public final class OrdersServiceCustom< openSSLPath: String = "/usr/bin/openssl" ) throws { self.app = app - self.logger = logger self.builder = OrderBuilder( pemWWDRCertificate: pemWWDRCertificate, pemCertificate: pemCertificate, @@ -90,244 +83,6 @@ public final class OrdersServiceCustom< as: .init(string: "orders"), isDefault: false ) - - let orderTypeIdentifier = PathComponent(stringLiteral: OD.typeIdentifier) - let v1 = app.grouped("api", "orders", "v1") - v1.get("devices", ":deviceIdentifier", "registrations", orderTypeIdentifier, use: { try await self.ordersForDevice(req: $0) }) - v1.post("log", use: { try await self.logMessage(req: $0) }) - - let v1auth = v1.grouped(AppleOrderMiddleware()) - 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) } - ) - - if let pushRoutesMiddleware { - let pushAuth = v1.grouped(pushRoutesMiddleware) - pushAuth.post("push", orderTypeIdentifier, ":orderIdentifier", use: { try await self.pushUpdatesForOrder(req: $0) }) - pushAuth.get("push", orderTypeIdentifier, ":orderIdentifier", use: { try await self.tokensForOrderUpdate(req: $0) }) - } - } -} - -// MARK: - API Routes -extension OrdersServiceCustom { - fileprivate func latestVersionOfOrder(req: Request) async throws -> Response { - logger?.debug("Called latestVersionOfOrder") - - var ifModifiedSince: TimeInterval = 0 - if let header = req.headers[.ifModifiedSince].first, let ims = TimeInterval(header) { - ifModifiedSince = ims - } - - guard let id = req.parameters.get("orderIdentifier", as: UUID.self) else { - throw Abort(.badRequest) - } - guard - let order = try await O.query(on: req.db) - .filter(\._$id == id) - .filter(\._$typeIdentifier == OD.typeIdentifier) - .first() - else { - throw Abort(.notFound) - } - - guard ifModifiedSince < order.updatedAt?.timeIntervalSince1970 ?? 0 else { - throw Abort(.notModified) - } - - guard - let orderData = try await OD.query(on: req.db) - .filter(\._$order.$id == id) - .first() - else { - throw Abort(.notFound) - } - - var headers = HTTPHeaders() - headers.add(name: .contentType, value: "application/vnd.apple.order") - headers.lastModified = HTTPHeaders.LastModified(order.updatedAt ?? Date.distantPast) - headers.add(name: .contentTransferEncoding, value: "binary") - return try await Response( - status: .ok, - headers: headers, - body: Response.Body(data: self.build(order: orderData, on: req.db)) - ) - } - - fileprivate func registerDevice(req: Request) async throws -> HTTPStatus { - logger?.debug("Called register device") - - let pushToken: String - do { - pushToken = try req.content.decode(PushTokenDTO.self).pushToken - } catch { - throw Abort(.badRequest) - } - - guard let orderIdentifier = req.parameters.get("orderIdentifier", as: UUID.self) else { - throw Abort(.badRequest) - } - let deviceIdentifier = req.parameters.get("deviceIdentifier")! - guard - let order = try await O.query(on: req.db) - .filter(\._$id == orderIdentifier) - .filter(\._$typeIdentifier == OD.typeIdentifier) - .first() - else { - throw Abort(.notFound) - } - - let device = try await D.query(on: req.db) - .filter(\._$libraryIdentifier == 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(libraryIdentifier: 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.libraryIdentifier, - typeIdentifier: order.typeIdentifier, - on: db - ) - .filter(O.self, \._$id == order.requireID()) - .first() - // If the registration already exists, docs say to return 200 OK - if r != nil { return .ok } - - let registration = R() - registration._$order.id = try order.requireID() - registration._$device.id = try device.requireID() - try await registration.create(on: db) - return .created - } - - fileprivate func ordersForDevice(req: Request) async throws -> OrderIdentifiersDTO { - logger?.debug("Called ordersForDevice") - - let deviceIdentifier = req.parameters.get("deviceIdentifier")! - - var query = R.for( - deviceLibraryIdentifier: deviceIdentifier, - typeIdentifier: OD.typeIdentifier, - on: req.db - ) - if let since: TimeInterval = req.query["ordersModifiedSince"] { - let when = Date(timeIntervalSince1970: since) - 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 - for registration in registrations { - let order = try await registration._$order.get(on: req.db) - try orderIdentifiers.append(order.requireID().uuidString) - if let updatedAt = order.updatedAt, updatedAt > maxDate { - maxDate = updatedAt - } - } - - return OrderIdentifiersDTO(with: orderIdentifiers, maxDate: maxDate) - } - - fileprivate func logMessage(req: Request) async throws -> HTTPStatus { - if let logger { - let body: LogEntriesDTO - do { - body = try req.content.decode(LogEntriesDTO.self) - } catch { - throw Abort(.badRequest) - } - - for log in body.logs { - logger.notice("VaporWalletOrders: \(log)") - } - return .ok - } else { - return .badRequest - } - } - - fileprivate func unregisterDevice(req: Request) async throws -> HTTPStatus { - logger?.debug("Called unregisterDevice") - - 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, - typeIdentifier: OD.typeIdentifier, - 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 - fileprivate 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) - } - - guard - let order = try await O.query(on: req.db) - .filter(\._$id == id) - .filter(\._$typeIdentifier == OD.typeIdentifier) - .first() - else { - throw Abort(.notFound) - } - - try await sendPushNotifications(for: order, on: req.db) - return .noContent - } - - fileprivate 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) - } - - guard - let order = try await O.query(on: req.db) - .filter(\._$id == id) - .filter(\._$typeIdentifier == OD.typeIdentifier) - .first() - else { - throw Abort(.notFound) - } - - return try await Self.registrations(for: order, on: req.db).map { $0.device.pushToken } } } @@ -342,7 +97,7 @@ extension OrdersServiceCustom { try await sendPushNotifications(for: orderData._$order.get(on: db), on: db) } - private func sendPushNotifications(for order: O, on db: any Database) async throws { + func sendPushNotifications(for order: O, on db: any Database) async throws { let registrations = try await Self.registrations(for: order, on: db) for reg in registrations { let backgroundNotification = APNSBackgroundNotification( @@ -376,7 +131,7 @@ extension OrdersServiceCustom { } } -// MARK: - order file generation +// MARK: - Order Building extension OrdersServiceCustom { /// Generates the order content bundle for a given order. /// diff --git a/Sources/VaporWalletOrders/VaporWalletOrders.docc/Extensions/OrdersService.md b/Sources/VaporWalletOrders/VaporWalletOrders.docc/Extensions/OrdersService.md deleted file mode 100644 index 2440da6..0000000 --- a/Sources/VaporWalletOrders/VaporWalletOrders.docc/Extensions/OrdersService.md +++ /dev/null @@ -1,12 +0,0 @@ -# ``VaporWalletOrders/OrdersService`` - -## Topics - -### Essentials - -- ``build(order:on:)`` -- ``register(migrations:)`` - -### Push Notifications - -- ``sendPushNotifications(for:on:)`` diff --git a/Sources/VaporWalletOrders/VaporWalletOrders.docc/GettingStarted.md b/Sources/VaporWalletOrders/VaporWalletOrders.docc/GettingStarted.md index 1d80e91..f9fa251 100644 --- a/Sources/VaporWalletOrders/VaporWalletOrders.docc/GettingStarted.md +++ b/Sources/VaporWalletOrders/VaporWalletOrders.docc/GettingStarted.md @@ -10,8 +10,6 @@ The order data model will be used to generate the `order.json` file contents. See [`FluentWalletOrders`'s documentation on `OrderDataModel`](https://swiftpackageindex.com/fpseverino/fluent-wallet/documentation/fluentwalletorders/orderdatamodel) to understand how to implement the order data model and do it before continuing with this guide. -> Important: You **must** add `api/orders/` to the `webServiceURL` key of the `OrderJSON.Properties` struct. - The order you distribute to a user is a signed bundle that contains the `order.json` file, images, and optional localizations. The `VaporWalletOrders` framework provides the ``OrdersService`` class that handles the creation of the order JSON file and the signing of the order bundle. The ``OrdersService`` class also provides methods to send push notifications to all devices registered when you update an order, and all the routes that Apple Wallet uses to retrieve orders. @@ -19,7 +17,8 @@ The ``OrdersService`` class also provides methods to send push notifications to ### Initialize the Service After creating the order data model and the order JSON data struct, initialize the ``OrdersService`` inside the `configure.swift` file. -This will implement all of the routes that Apple Wallet expects to exist on your server. + +To implement all of the routes that Apple Wallet expects to exist on your server, don't forget to register them using the ``OrdersService`` object as a `RouteCollection`. > 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. @@ -36,17 +35,9 @@ public func configure(_ app: Application) async throws { pemCertificate: Environment.get("PEM_CERTIFICATE")!, pemPrivateKey: Environment.get("PEM_PRIVATE_KEY")! ) -} -``` - -If you wish to include routes specifically for sending push notifications to updated orders, you can also pass to the ``OrdersService`` initializer whatever `Middleware` you want Vapor to use to authenticate the two routes. Doing so will add two routes, the first one sends notifications and the second one retrieves a list of push tokens which would be sent a notification. -```http -POST https://example.com/api/orders/v1/push/{orderTypeIdentifier}/{orderIdentifier} HTTP/2 -``` - -```http -GET https://example.com/api/orders/v1/push/{orderTypeIdentifier}/{orderIdentifier} HTTP/2 + try app.grouped("api", "orders").register(collection: ordersService) +} ``` ### Custom Implementation of OrdersService @@ -72,6 +63,8 @@ public func configure(_ app: Application) async throws { pemCertificate: Environment.get("PEM_CERTIFICATE")!, pemPrivateKey: Environment.get("PEM_PRIVATE_KEY")! ) + + try app.grouped("api", "orders").register(collection: ordersService) } ``` @@ -123,7 +116,7 @@ struct OrdersController: RouteCollection { Then use the object inside your route handlers to generate the order bundle with the ``OrdersService/build(order:on:)`` method and distribute it with the "`application/vnd.apple.order`" MIME type. ```swift -fileprivate func orderHandler(_ req: Request) async throws -> Response { +func orderHandler(_ req: Request) async throws -> Response { ... guard let order = try await OrderData.query(on: req.db) .filter(...) diff --git a/Sources/VaporWalletPasses/PassesService.swift b/Sources/VaporWalletPasses/PassesService.swift index 41dcaea..f06256c 100644 --- a/Sources/VaporWalletPasses/PassesService.swift +++ b/Sources/VaporWalletPasses/PassesService.swift @@ -38,8 +38,6 @@ public final class PassesService: Sendable where Pass == PD.P /// /// - Parameters: /// - app: The `Vapor.Application` to use in route handlers and APNs. - /// - pushRoutesMiddleware: The `Middleware` to use for push notification routes. If `nil`, push routes will not be registered. - /// - logger: The `Logger` to use. /// - pemWWDRCertificate: Apple's WWDR.pem certificate in PEM format. /// - pemCertificate: The PEM Certificate for signing passes. /// - pemPrivateKey: The PEM Certificate's private key for signing passes. @@ -47,8 +45,6 @@ public final class PassesService: Sendable where Pass == PD.P /// - openSSLPath: The location of the `openssl` command as a file path. public init( app: Application, - pushRoutesMiddleware: (any Middleware)? = nil, - logger: Logger? = nil, pemWWDRCertificate: String, pemCertificate: String, pemPrivateKey: String, @@ -57,8 +53,6 @@ public final class PassesService: Sendable where Pass == PD.P ) throws { self.service = try .init( app: app, - pushRoutesMiddleware: pushRoutesMiddleware, - logger: logger, pemWWDRCertificate: pemWWDRCertificate, pemCertificate: pemCertificate, pemPrivateKey: pemPrivateKey, @@ -116,3 +110,9 @@ public final class PassesService: Sendable where Pass == PD.P try await service.sendPushNotifications(for: pass, on: db) } } + +extension PassesService: RouteCollection { + public func boot(routes: any RoutesBuilder) throws { + try service.boot(routes: routes) + } +} diff --git a/Sources/VaporWalletPasses/PassesServiceCustom+RouteCollection.swift b/Sources/VaporWalletPasses/PassesServiceCustom+RouteCollection.swift new file mode 100644 index 0000000..df2702f --- /dev/null +++ b/Sources/VaporWalletPasses/PassesServiceCustom+RouteCollection.swift @@ -0,0 +1,221 @@ +import Fluent +import FluentWalletPasses +import Vapor +import VaporWallet + +extension PassesServiceCustom: RouteCollection { + public func boot(routes: any RoutesBuilder) throws { + let passTypeIdentifier = PathComponent(stringLiteral: PD.typeIdentifier) + + let v1 = routes.grouped("v1") + v1.get("devices", ":deviceLibraryIdentifier", "registrations", passTypeIdentifier, use: self.updatablePasses) + v1.post("log", use: self.logMessage) + v1.post("passes", passTypeIdentifier, ":passSerial", "personalize", use: self.personalizedPass) + + let v1auth = v1.grouped(ApplePassMiddleware

()) + v1auth.post("devices", ":deviceLibraryIdentifier", "registrations", passTypeIdentifier, ":passSerial", use: self.registerPass) + v1auth.get("passes", passTypeIdentifier, ":passSerial", use: self.updatedPass) + v1auth.delete("devices", ":deviceLibraryIdentifier", "registrations", passTypeIdentifier, ":passSerial", use: self.unregisterPass) + } + + private func registerPass(req: Request) async throws -> HTTPStatus { + req.logger.debug("Called register pass") + + let pushToken: String + do { + pushToken = try req.content.decode(PushTokenDTO.self).pushToken + } catch { + throw Abort(.badRequest) + } + + guard let serial = req.parameters.get("passSerial", as: UUID.self) else { + throw Abort(.badRequest) + } + let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! + guard + let pass = try await P.query(on: req.db) + .filter(\._$typeIdentifier == PD.typeIdentifier) + .filter(\._$id == serial) + .first() + else { + throw Abort(.notFound) + } + + let device = try await D.query(on: req.db) + .filter(\._$libraryIdentifier == deviceLibraryIdentifier) + .filter(\._$pushToken == pushToken) + .first() + if let device = device { + return try await Self.createRegistration(device: device, pass: pass, db: req.db) + } else { + let newDevice = D(libraryIdentifier: deviceLibraryIdentifier, pushToken: pushToken) + try await newDevice.create(on: req.db) + return try await Self.createRegistration(device: newDevice, pass: pass, db: req.db) + } + } + + private static func createRegistration(device: D, pass: P, db: any Database) async throws -> HTTPStatus { + let r = try await R.for( + deviceLibraryIdentifier: device.libraryIdentifier, + typeIdentifier: pass.typeIdentifier, + on: db + ) + .filter(P.self, \._$id == pass.requireID()) + .first() + // If the registration already exists, docs say to return 200 OK + if r != nil { return .ok } + + let registration = R() + registration._$pass.id = try pass.requireID() + registration._$device.id = try device.requireID() + try await registration.create(on: db) + return .created + } + + private func updatablePasses(req: Request) async throws -> SerialNumbersDTO { + req.logger.debug("Called updatablePasses") + + let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! + + var query = R.for( + deviceLibraryIdentifier: deviceLibraryIdentifier, + typeIdentifier: PD.typeIdentifier, + on: req.db + ) + if let since: TimeInterval = req.query["passesUpdatedSince"] { + let when = Date(timeIntervalSince1970: since) + query = query.filter(P.self, \._$updatedAt > when) + } + + let registrations = try await query.all() + guard !registrations.isEmpty else { + throw Abort(.noContent) + } + + var serialNumbers: [String] = [] + var maxDate = Date.distantPast + for registration in registrations { + let pass = try await registration._$pass.get(on: req.db) + try serialNumbers.append(pass.requireID().uuidString) + if let updatedAt = pass.updatedAt, updatedAt > maxDate { + maxDate = updatedAt + } + } + + return SerialNumbersDTO(with: serialNumbers, maxDate: maxDate) + } + + private func updatedPass(req: Request) async throws -> Response { + req.logger.debug("Called updatedPass") + + var ifModifiedSince: TimeInterval = 0 + if let header = req.headers[.ifModifiedSince].first, let ims = TimeInterval(header) { + ifModifiedSince = ims + } + + guard let id = req.parameters.get("passSerial", as: UUID.self) else { + throw Abort(.badRequest) + } + guard + let pass = try await P.query(on: req.db) + .filter(\._$id == id) + .filter(\._$typeIdentifier == PD.typeIdentifier) + .first() + else { + throw Abort(.notFound) + } + + guard ifModifiedSince < pass.updatedAt?.timeIntervalSince1970 ?? 0 else { + throw Abort(.notModified) + } + + guard + let passData = try await PD.query(on: req.db) + .filter(\._$pass.$id == id) + .first() + else { + throw Abort(.notFound) + } + + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "application/vnd.apple.pkpass") + headers.lastModified = HTTPHeaders.LastModified(pass.updatedAt ?? Date.distantPast) + headers.add(name: .contentTransferEncoding, value: "binary") + return try await Response( + status: .ok, + headers: headers, + body: Response.Body(data: self.build(pass: passData, on: req.db)) + ) + } + + private func unregisterPass(req: Request) async throws -> HTTPStatus { + req.logger.debug("Called unregisterPass") + + guard let passId = req.parameters.get("passSerial", as: UUID.self) else { + throw Abort(.badRequest) + } + let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! + + guard + let r = try await R.for( + deviceLibraryIdentifier: deviceLibraryIdentifier, + typeIdentifier: PD.typeIdentifier, + on: req.db + ) + .filter(P.self, \._$id == passId) + .first() + else { + throw Abort(.notFound) + } + try await r.delete(on: req.db) + return .ok + } + + private func logMessage(req: Request) async throws -> HTTPStatus { + let entries = try req.content.decode(LogEntriesDTO.self) + + for log in entries.logs { + req.logger.notice("VaporWalletPasses: \(log)") + } + + return .ok + } + + private func personalizedPass(req: Request) async throws -> Response { + req.logger.debug("Called personalizedPass") + + guard let id = req.parameters.get("passSerial", as: UUID.self) else { + throw Abort(.badRequest) + } + guard + try await P.query(on: req.db) + .filter(\._$id == id) + .filter(\._$typeIdentifier == PD.typeIdentifier) + .first() != nil + else { + throw Abort(.notFound) + } + + let userInfo = try req.content.decode(PersonalizationDictionaryDTO.self) + + let personalization = I() + personalization.fullName = userInfo.requiredPersonalizationInfo.fullName + personalization.givenName = userInfo.requiredPersonalizationInfo.givenName + personalization.familyName = userInfo.requiredPersonalizationInfo.familyName + personalization.emailAddress = userInfo.requiredPersonalizationInfo.emailAddress + personalization.postalCode = userInfo.requiredPersonalizationInfo.postalCode + personalization.isoCountryCode = userInfo.requiredPersonalizationInfo.isoCountryCode + personalization.phoneNumber = userInfo.requiredPersonalizationInfo.phoneNumber + personalization._$pass.id = id + try await personalization.create(on: req.db) + + guard let token = userInfo.personalizationToken.data(using: .utf8) else { + throw Abort(.internalServerError) + } + + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "application/octet-stream") + headers.add(name: .contentTransferEncoding, value: "binary") + return try Response(status: .ok, headers: headers, body: Response.Body(data: self.builder.signature(for: token))) + } +} diff --git a/Sources/VaporWalletPasses/PassesServiceCustom.swift b/Sources/VaporWalletPasses/PassesServiceCustom.swift index 90e7c2a..7ad437b 100644 --- a/Sources/VaporWalletPasses/PassesServiceCustom.swift +++ b/Sources/VaporWalletPasses/PassesServiceCustom.swift @@ -7,7 +7,6 @@ import Vapor import VaporAPNS import VaporWallet import WalletPasses -@_spi(CMS) import X509 import Zip /// Class to handle ``PassesService``. @@ -26,15 +25,12 @@ public final class PassesServiceCustom< R: PassesRegistrationModel >: Sendable where P == PD.PassType, P == R.PassType, D == R.DeviceType, I.PassType == P { private unowned let app: Application - private let logger: Logger? - private let builder: PassBuilder + let builder: PassBuilder /// Initializes the service and registers all the routes required for Apple Wallet to work. /// /// - Parameters: /// - app: The `Vapor.Application` to use in route handlers and APNs. - /// - pushRoutesMiddleware: The `Middleware` to use for push notification routes. If `nil`, push routes will not be registered. - /// - logger: The `Logger` to use. /// - pemWWDRCertificate: Apple's WWDR.pem certificate in PEM format. /// - pemCertificate: The PEM Certificate for signing passes. /// - pemPrivateKey: The PEM Certificate's private key for signing passes. @@ -42,8 +38,6 @@ public final class PassesServiceCustom< /// - openSSLPath: The location of the `openssl` command as a file path. public init( app: Application, - pushRoutesMiddleware: (any Middleware)? = nil, - logger: Logger? = nil, pemWWDRCertificate: String, pemCertificate: String, pemPrivateKey: String, @@ -51,7 +45,6 @@ public final class PassesServiceCustom< openSSLPath: String = "/usr/bin/openssl" ) throws { self.app = app - self.logger = logger self.builder = PassBuilder( pemWWDRCertificate: pemWWDRCertificate, pemCertificate: pemCertificate, @@ -92,284 +85,6 @@ public final class PassesServiceCustom< as: .init(string: "passes"), isDefault: false ) - - let passTypeIdentifier = PathComponent(stringLiteral: PD.typeIdentifier) - let v1 = app.grouped("api", "passes", "v1") - v1.get( - "devices", ":deviceLibraryIdentifier", "registrations", passTypeIdentifier, - use: { try await self.passesForDevice(req: $0) } - ) - v1.post("log", use: { try await self.logMessage(req: $0) }) - v1.post("passes", passTypeIdentifier, ":passSerial", "personalize", use: { try await self.personalizedPass(req: $0) }) - - let v1auth = v1.grouped(ApplePassMiddleware

()) - v1auth.post( - "devices", ":deviceLibraryIdentifier", "registrations", passTypeIdentifier, ":passSerial", - use: { try await self.registerDevice(req: $0) } - ) - v1auth.get("passes", passTypeIdentifier, ":passSerial", use: { try await self.latestVersionOfPass(req: $0) }) - v1auth.delete( - "devices", ":deviceLibraryIdentifier", "registrations", passTypeIdentifier, ":passSerial", - use: { try await self.unregisterDevice(req: $0) } - ) - - if let pushRoutesMiddleware { - let pushAuth = v1.grouped(pushRoutesMiddleware) - pushAuth.post("push", passTypeIdentifier, ":passSerial", use: { try await self.pushUpdatesForPass(req: $0) }) - pushAuth.get("push", passTypeIdentifier, ":passSerial", use: { try await self.tokensForPassUpdate(req: $0) }) - } - } -} - -// MARK: - API Routes -extension PassesServiceCustom { - fileprivate func registerDevice(req: Request) async throws -> HTTPStatus { - logger?.debug("Called register device") - - let pushToken: String - do { - pushToken = try req.content.decode(PushTokenDTO.self).pushToken - } catch { - throw Abort(.badRequest) - } - - guard let serial = req.parameters.get("passSerial", as: UUID.self) else { - throw Abort(.badRequest) - } - let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! - guard - let pass = try await P.query(on: req.db) - .filter(\._$typeIdentifier == PD.typeIdentifier) - .filter(\._$id == serial) - .first() - else { - throw Abort(.notFound) - } - - let device = try await D.query(on: req.db) - .filter(\._$libraryIdentifier == deviceLibraryIdentifier) - .filter(\._$pushToken == pushToken) - .first() - if let device = device { - return try await Self.createRegistration(device: device, pass: pass, db: req.db) - } else { - let newDevice = D(libraryIdentifier: deviceLibraryIdentifier, pushToken: pushToken) - try await newDevice.create(on: req.db) - return try await Self.createRegistration(device: newDevice, pass: pass, db: req.db) - } - } - - private static func createRegistration(device: D, pass: P, db: any Database) async throws -> HTTPStatus { - let r = try await R.for( - deviceLibraryIdentifier: device.libraryIdentifier, - typeIdentifier: pass.typeIdentifier, - on: db - ) - .filter(P.self, \._$id == pass.requireID()) - .first() - // If the registration already exists, docs say to return 200 OK - if r != nil { return .ok } - - let registration = R() - registration._$pass.id = try pass.requireID() - registration._$device.id = try device.requireID() - try await registration.create(on: db) - return .created - } - - fileprivate func passesForDevice(req: Request) async throws -> SerialNumbersDTO { - logger?.debug("Called passesForDevice") - - let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! - - var query = R.for( - deviceLibraryIdentifier: deviceLibraryIdentifier, - typeIdentifier: PD.typeIdentifier, - on: req.db - ) - if let since: TimeInterval = req.query["passesUpdatedSince"] { - let when = Date(timeIntervalSince1970: since) - query = query.filter(P.self, \._$updatedAt > when) - } - - let registrations = try await query.all() - guard !registrations.isEmpty else { - throw Abort(.noContent) - } - - var serialNumbers: [String] = [] - var maxDate = Date.distantPast - for registration in registrations { - let pass = try await registration._$pass.get(on: req.db) - try serialNumbers.append(pass.requireID().uuidString) - if let updatedAt = pass.updatedAt, updatedAt > maxDate { - maxDate = updatedAt - } - } - - return SerialNumbersDTO(with: serialNumbers, maxDate: maxDate) - } - - fileprivate func latestVersionOfPass(req: Request) async throws -> Response { - logger?.debug("Called latestVersionOfPass") - - var ifModifiedSince: TimeInterval = 0 - if let header = req.headers[.ifModifiedSince].first, let ims = TimeInterval(header) { - ifModifiedSince = ims - } - - guard let id = req.parameters.get("passSerial", as: UUID.self) else { - throw Abort(.badRequest) - } - guard - let pass = try await P.query(on: req.db) - .filter(\._$id == id) - .filter(\._$typeIdentifier == PD.typeIdentifier) - .first() - else { - throw Abort(.notFound) - } - - guard ifModifiedSince < pass.updatedAt?.timeIntervalSince1970 ?? 0 else { - throw Abort(.notModified) - } - - guard - let passData = try await PD.query(on: req.db) - .filter(\._$pass.$id == id) - .first() - else { - throw Abort(.notFound) - } - - var headers = HTTPHeaders() - headers.add(name: .contentType, value: "application/vnd.apple.pkpass") - headers.lastModified = HTTPHeaders.LastModified(pass.updatedAt ?? Date.distantPast) - headers.add(name: .contentTransferEncoding, value: "binary") - return try await Response( - status: .ok, - headers: headers, - body: Response.Body(data: self.build(pass: passData, on: req.db)) - ) - } - - fileprivate func unregisterDevice(req: Request) async throws -> HTTPStatus { - logger?.debug("Called unregisterDevice") - - guard let passId = req.parameters.get("passSerial", as: UUID.self) else { - throw Abort(.badRequest) - } - let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! - - guard - let r = try await R.for( - deviceLibraryIdentifier: deviceLibraryIdentifier, - typeIdentifier: PD.typeIdentifier, - on: req.db - ) - .filter(P.self, \._$id == passId) - .first() - else { - throw Abort(.notFound) - } - try await r.delete(on: req.db) - return .ok - } - - fileprivate func logMessage(req: Request) async throws -> HTTPStatus { - if let logger { - let body: LogEntriesDTO - do { - body = try req.content.decode(LogEntriesDTO.self) - } catch { - throw Abort(.badRequest) - } - - for log in body.logs { - logger.notice("VaporWalletPasses: \(log)") - } - return .ok - } else { - return .badRequest - } - } - - fileprivate func personalizedPass(req: Request) async throws -> Response { - logger?.debug("Called personalizedPass") - - guard let id = req.parameters.get("passSerial", as: UUID.self) else { - throw Abort(.badRequest) - } - guard - try await P.query(on: req.db) - .filter(\._$id == id) - .filter(\._$typeIdentifier == PD.typeIdentifier) - .first() != nil - else { - throw Abort(.notFound) - } - - let userInfo = try req.content.decode(PersonalizationDictionaryDTO.self) - - let personalization = I() - personalization.fullName = userInfo.requiredPersonalizationInfo.fullName - personalization.givenName = userInfo.requiredPersonalizationInfo.givenName - personalization.familyName = userInfo.requiredPersonalizationInfo.familyName - personalization.emailAddress = userInfo.requiredPersonalizationInfo.emailAddress - personalization.postalCode = userInfo.requiredPersonalizationInfo.postalCode - personalization.isoCountryCode = userInfo.requiredPersonalizationInfo.isoCountryCode - personalization.phoneNumber = userInfo.requiredPersonalizationInfo.phoneNumber - personalization._$pass.id = id - try await personalization.create(on: req.db) - - guard let token = userInfo.personalizationToken.data(using: .utf8) else { - throw Abort(.internalServerError) - } - - var headers = HTTPHeaders() - headers.add(name: .contentType, value: "application/octet-stream") - headers.add(name: .contentTransferEncoding, value: "binary") - return try Response(status: .ok, headers: headers, body: Response.Body(data: self.builder.signature(for: token))) - } - - // MARK: - Push Routes - fileprivate func pushUpdatesForPass(req: Request) async throws -> HTTPStatus { - logger?.debug("Called pushUpdatesForPass") - - guard let id = req.parameters.get("passSerial", as: UUID.self) else { - throw Abort(.badRequest) - } - - guard - let pass = try await P.query(on: req.db) - .filter(\._$id == id) - .filter(\._$typeIdentifier == PD.typeIdentifier) - .first() - else { - throw Abort(.notFound) - } - - try await sendPushNotifications(for: pass, on: req.db) - return .noContent - } - - fileprivate func tokensForPassUpdate(req: Request) async throws -> [String] { - logger?.debug("Called tokensForPassUpdate") - - guard let id = req.parameters.get("passSerial", as: UUID.self) else { - throw Abort(.badRequest) - } - - guard - let pass = try await P.query(on: req.db) - .filter(\._$id == id) - .filter(\._$typeIdentifier == PD.typeIdentifier) - .first() - else { - throw Abort(.notFound) - } - - return try await Self.registrations(for: pass, on: req.db).map { $0.device.pushToken } } } @@ -384,7 +99,7 @@ extension PassesServiceCustom { try await self.sendPushNotifications(for: passData._$pass.get(on: db), on: db) } - private func sendPushNotifications(for pass: P, on db: any Database) async throws { + func sendPushNotifications(for pass: P, on db: any Database) async throws { let registrations = try await Self.registrations(for: pass, on: db) for reg in registrations { let backgroundNotification = APNSBackgroundNotification( @@ -418,7 +133,7 @@ extension PassesServiceCustom { } } -// MARK: - pkpass file generation +// MARK: - Pass Building extension PassesServiceCustom { /// Generates the pass content bundle for a given pass. /// diff --git a/Sources/VaporWalletPasses/VaporWalletPasses.docc/Extensions/PassesService.md b/Sources/VaporWalletPasses/VaporWalletPasses.docc/Extensions/PassesService.md deleted file mode 100644 index 91e0668..0000000 --- a/Sources/VaporWalletPasses/VaporWalletPasses.docc/Extensions/PassesService.md +++ /dev/null @@ -1,13 +0,0 @@ -# ``VaporWalletPasses/PassesService`` - -## Topics - -### Essentials - -- ``build(pass:on:)`` -- ``build(passes:on:)`` -- ``register(migrations:withPersonalization:)`` - -### Push Notifications - -- ``sendPushNotifications(for:on:)`` diff --git a/Sources/VaporWalletPasses/VaporWalletPasses.docc/GettingStarted.md b/Sources/VaporWalletPasses/VaporWalletPasses.docc/GettingStarted.md index 74a032d..5c06258 100644 --- a/Sources/VaporWalletPasses/VaporWalletPasses.docc/GettingStarted.md +++ b/Sources/VaporWalletPasses/VaporWalletPasses.docc/GettingStarted.md @@ -10,8 +10,6 @@ The pass data model will be used to generate the `pass.json` file contents. See [`FluentWalletPasses`'s documentation on `PassDataModel`](https://swiftpackageindex.com/fpseverino/fluent-wallet/documentation/fluentwalletpasses/passdatamodel) to understand how to implement the pass data model and do it before continuing with this guide. -> Important: You **must** add `api/passes/` to the `webServiceURL` key of the `PassJSON.Properties` struct. - The pass you distribute to a user is a signed bundle that contains the `pass.json` file, images and optional localizations. The `VaporWalletPasses` framework provides the ``PassesService`` class that handles the creation of the pass JSON file and the signing of the pass bundle. The ``PassesService`` class also provides methods to send push notifications to all devices registered when you update a pass, and all the routes that Apple Wallet uses to retrieve passes. @@ -19,7 +17,8 @@ The ``PassesService`` class also provides methods to send push notifications to ### Initialize the Service After creating the pass data model and the pass JSON data struct, initialize the ``PassesService`` inside the `configure.swift` file. -This will implement all of the routes that Apple Wallet expects to exist on your server. + +To implement all of the routes that Apple Wallet expects to exist on your server, don't forget to register them using the ``PassesService`` object as a route collection. > 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). @@ -36,17 +35,9 @@ public func configure(_ app: Application) async throws { pemCertificate: Environment.get("PEM_CERTIFICATE")!, pemPrivateKey: Environment.get("PEM_PRIVATE_KEY")! ) -} -``` - -If you wish to include routes specifically for sending push notifications to updated passes, you can also pass to the ``PassesService`` initializer whatever `Middleware` you want Vapor to use to authenticate the two routes. Doing so will add two routes, the first one sends notifications and the second one retrieves a list of push tokens which would be sent a notification. -```http -POST https://example.com/api/passes/v1/push/{passTypeIdentifier}/{passSerial} HTTP/2 -``` - -```http -GET https://example.com/api/passes/v1/push/{passTypeIdentifier}/{passSerial} HTTP/2 + try app.grouped("api", "passes").register(collection: passesService) +} ``` ### Custom Implementation of PassesService @@ -73,6 +64,8 @@ public func configure(_ app: Application) async throws { pemCertificate: Environment.get("PEM_CERTIFICATE")!, pemPrivateKey: Environment.get("PEM_PRIVATE_KEY")! ) + + try app.grouped("api", "passes").register(collection: passesService) } ``` @@ -124,7 +117,7 @@ struct PassesController: RouteCollection { Then use the object inside your route handlers to generate the pass bundle with the ``PassesService/build(pass:on:)`` method and distribute it with the "`application/vnd.apple.pkpass`" MIME type. ```swift -fileprivate func passHandler(_ req: Request) async throws -> Response { +func passHandler(_ req: Request) async throws -> Response { ... guard let pass = try await PassData.query(on: req.db) .filter(...) @@ -153,7 +146,7 @@ The MIME type for a bundle of passes is "`application/vnd.apple.pkpasses`". > Note: You can have up to 10 passes or 150 MB for a bundle of passes. ```swift -fileprivate func passesHandler(_ req: Request) async throws -> Response { +func passesHandler(_ req: Request) async throws -> Response { ... let passes = try await PassData.query(on: req.db).all() diff --git a/Tests/VaporWalletOrdersTests/Utils/withApp.swift b/Tests/VaporWalletOrdersTests/Utils/withApp.swift index 29613e0..c7f6c70 100644 --- a/Tests/VaporWalletOrdersTests/Utils/withApp.swift +++ b/Tests/VaporWalletOrdersTests/Utils/withApp.swift @@ -22,15 +22,16 @@ func withApp( let ordersService = try OrdersService( app: app, - pushRoutesMiddleware: SecretMiddleware(secret: "foo"), - logger: app.logger, pemWWDRCertificate: TestCertificate.pemWWDRCertificate, pemCertificate: useEncryptedKey ? TestCertificate.encryptedPemCertificate : TestCertificate.pemCertificate, pemPrivateKey: useEncryptedKey ? TestCertificate.encryptedPemPrivateKey : TestCertificate.pemPrivateKey, pemPrivateKeyPassword: useEncryptedKey ? "password" : nil ) + app.databases.middleware.use(ordersService, on: .sqlite) + try app.grouped("api", "orders").register(collection: ordersService) + Zip.addCustomFileExtension("order") try await body(app, ordersService) diff --git a/Tests/VaporWalletOrdersTests/VaporWalletOrdersTests.swift b/Tests/VaporWalletOrdersTests/VaporWalletOrdersTests.swift index 6526c1a..730842f 100644 --- a/Tests/VaporWalletOrdersTests/VaporWalletOrdersTests.swift +++ b/Tests/VaporWalletOrdersTests/VaporWalletOrdersTests.swift @@ -233,27 +233,6 @@ struct VaporWalletOrdersTests { } ) - try await app.test( - .GET, - "\(ordersURI)push/\(order.typeIdentifier)/\(order.requireID())", - headers: ["X-Secret": "foo"], - afterResponse: { res async throws in - let pushTokens = try res.content.decode([String].self) - #expect(pushTokens.count == 1) - #expect(pushTokens[0] == pushToken) - } - ) - - // Test call with invalid UUID - try await app.test( - .GET, - "\(ordersURI)push/\(order.typeIdentifier)/\("not-a-uuid")", - headers: ["X-Secret": "foo"], - afterResponse: { res async throws in - #expect(res.status == .badRequest) - } - ) - // Test call with invalid UUID try await app.test( .DELETE, @@ -288,15 +267,6 @@ struct VaporWalletOrdersTests { #expect(res.status == .ok) } ) - - // Test call with no DTO - try await app.test( - .POST, - "\(ordersURI)log", - afterResponse: { res async throws in - #expect(res.status == .badRequest) - } - ) } } @@ -307,63 +277,9 @@ struct VaporWalletOrdersTests { let orderData = OrderData(title: "Test Order") try await orderData.create(on: app.db) - let order = try await orderData._$order.get(on: app.db) try await ordersService.sendPushNotifications(for: orderData, on: app.db) - let deviceLibraryIdentifier = "abcdefg" - let pushToken = "1234567890" - - // Test call with incorrect secret - try await app.test( - .POST, - "\(ordersURI)push/\(order.typeIdentifier)/\(order.requireID())", - headers: ["X-Secret": "bar"], - afterResponse: { res async throws in - #expect(res.status == .unauthorized) - } - ) - - try await app.test( - .POST, - "\(ordersURI)push/\(order.typeIdentifier)/\(order.requireID())", - headers: ["X-Secret": "foo"], - afterResponse: { res async throws in - #expect(res.status == .noContent) - } - ) - - try await app.test( - .POST, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.typeIdentifier)/\(order.requireID())", - headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], - beforeRequest: { req async throws in - try req.content.encode(PushTokenDTO(pushToken: pushToken)) - }, - afterResponse: { res async throws in - #expect(res.status == .created) - } - ) - - try await app.test( - .POST, - "\(ordersURI)push/\(order.typeIdentifier)/\(order.requireID())", - headers: ["X-Secret": "foo"], - afterResponse: { res async throws in - #expect(res.status == .internalServerError) - } - ) - - // Test call with invalid UUID - try await app.test( - .POST, - "\(ordersURI)push/\(order.typeIdentifier)/\("not-a-uuid")", - headers: ["X-Secret": "foo"], - afterResponse: { res async throws in - #expect(res.status == .badRequest) - } - ) - if !useEncryptedKey { // Test `AsyncModelMiddleware` update method orderData.title = "Test Order 2" diff --git a/Tests/VaporWalletPassesTests/Utils/withApp.swift b/Tests/VaporWalletPassesTests/Utils/withApp.swift index 9249cee..6d79244 100644 --- a/Tests/VaporWalletPassesTests/Utils/withApp.swift +++ b/Tests/VaporWalletPassesTests/Utils/withApp.swift @@ -22,15 +22,16 @@ func withApp( let passesService = try PassesService( app: app, - pushRoutesMiddleware: SecretMiddleware(secret: "foo"), - logger: app.logger, pemWWDRCertificate: TestCertificate.pemWWDRCertificate, pemCertificate: useEncryptedKey ? TestCertificate.encryptedPemCertificate : TestCertificate.pemCertificate, pemPrivateKey: useEncryptedKey ? TestCertificate.encryptedPemPrivateKey : TestCertificate.pemPrivateKey, pemPrivateKeyPassword: useEncryptedKey ? "password" : nil ) + app.databases.middleware.use(passesService, on: .sqlite) + try app.grouped("api", "passes").register(collection: passesService) + Zip.addCustomFileExtension("pkpass") try await body(app, passesService) diff --git a/Tests/VaporWalletPassesTests/VaporWalletPassesTests.swift b/Tests/VaporWalletPassesTests/VaporWalletPassesTests.swift index 3b9a583..44e0a43 100644 --- a/Tests/VaporWalletPassesTests/VaporWalletPassesTests.swift +++ b/Tests/VaporWalletPassesTests/VaporWalletPassesTests.swift @@ -369,27 +369,6 @@ struct VaporWalletPassesTests { } ) - try await app.test( - .GET, - "\(passesURI)push/\(pass.typeIdentifier)/\(pass.requireID())", - headers: ["X-Secret": "foo"], - afterResponse: { res async throws in - let pushTokens = try res.content.decode([String].self) - #expect(pushTokens.count == 1) - #expect(pushTokens[0] == pushToken) - } - ) - - // Test call with invalid UUID - try await app.test( - .GET, - "\(passesURI)push/\(pass.typeIdentifier)/\("not-a-uuid")", - headers: ["X-Secret": "foo"], - afterResponse: { res async throws in - #expect(res.status == .badRequest) - } - ) - // Test call with invalid UUID try await app.test( .DELETE, @@ -424,15 +403,6 @@ struct VaporWalletPassesTests { #expect(res.status == .ok) } ) - - // Test call with no DTO - try await app.test( - .POST, - "\(passesURI)log", - afterResponse: { res async throws in - #expect(res.status == .badRequest) - } - ) } } @@ -443,63 +413,9 @@ struct VaporWalletPassesTests { let passData = PassData(title: "Test Pass") try await passData.create(on: app.db) - let pass = try await passData.$pass.get(on: app.db) try await passesService.sendPushNotifications(for: passData, on: app.db) - let deviceLibraryIdentifier = "abcdefg" - let pushToken = "1234567890" - - // Test call with incorrect secret - try await app.test( - .POST, - "\(passesURI)push/\(pass.typeIdentifier)/\(pass.requireID())", - headers: ["X-Secret": "bar"], - afterResponse: { res async throws in - #expect(res.status == .unauthorized) - } - ) - - try await app.test( - .POST, - "\(passesURI)push/\(pass.typeIdentifier)/\(pass.requireID())", - headers: ["X-Secret": "foo"], - afterResponse: { res async throws in - #expect(res.status == .noContent) - } - ) - - try await app.test( - .POST, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.typeIdentifier)/\(pass.requireID())", - headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], - beforeRequest: { req async throws in - try req.content.encode(PushTokenDTO(pushToken: pushToken)) - }, - afterResponse: { res async throws in - #expect(res.status == .created) - } - ) - - try await app.test( - .POST, - "\(passesURI)push/\(pass.typeIdentifier)/\(pass.requireID())", - headers: ["X-Secret": "foo"], - afterResponse: { res async throws in - #expect(res.status == .internalServerError) - } - ) - - // Test call with invalid UUID - try await app.test( - .POST, - "\(passesURI)push/\(pass.typeIdentifier)/\("not-a-uuid")", - headers: ["X-Secret": "foo"], - afterResponse: { res async throws in - #expect(res.status == .badRequest) - } - ) - if !useEncryptedKey { // Test `AsyncModelMiddleware` update method passData.title = "Test Pass 2" From c9571dc454c54610a26327dfcf92aed6790d1f0b Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Thu, 16 Jan 2025 12:32:19 +0100 Subject: [PATCH 2/4] Rename generic types --- .../Middleware/AppleOrderMiddleware.swift | 4 +- .../OrdersService+AsyncModelMiddleware.swift | 14 +++--- Sources/VaporWalletOrders/OrdersService.swift | 8 ++-- .../OrdersServiceCustom+RouteCollection.swift | 38 ++++++++-------- .../OrdersServiceCustom.swift | 37 +++++++++------- .../Middleware/ApplePassMiddleware.swift | 4 +- .../PassesService+AsyncModelMiddleware.swift | 14 +++--- Sources/VaporWalletPasses/PassesService.swift | 10 ++--- .../PassesServiceCustom+RouteCollection.swift | 44 +++++++++---------- .../PassesServiceCustom.swift | 44 +++++++++++-------- 10 files changed, 114 insertions(+), 103 deletions(-) diff --git a/Sources/VaporWalletOrders/Middleware/AppleOrderMiddleware.swift b/Sources/VaporWalletOrders/Middleware/AppleOrderMiddleware.swift index 4b20b94..68a6220 100644 --- a/Sources/VaporWalletOrders/Middleware/AppleOrderMiddleware.swift +++ b/Sources/VaporWalletOrders/Middleware/AppleOrderMiddleware.swift @@ -2,14 +2,14 @@ import FluentKit import FluentWalletOrders import Vapor -struct AppleOrderMiddleware: AsyncMiddleware { +struct AppleOrderMiddleware: AsyncMiddleware { func respond( to request: Request, chainingTo next: any AsyncResponder ) async throws -> Response { guard let id = request.parameters.get("orderIdentifier", as: UUID.self), let authToken = request.headers["Authorization"].first?.replacingOccurrences(of: "AppleOrder ", with: ""), - (try await O.query(on: request.db) + (try await OrderType.query(on: request.db) .filter(\._$id == id) .filter(\._$authenticationToken == authToken) .first()) != nil diff --git a/Sources/VaporWalletOrders/Middleware/OrdersService+AsyncModelMiddleware.swift b/Sources/VaporWalletOrders/Middleware/OrdersService+AsyncModelMiddleware.swift index 3a05251..bb0fe89 100644 --- a/Sources/VaporWalletOrders/Middleware/OrdersService+AsyncModelMiddleware.swift +++ b/Sources/VaporWalletOrders/Middleware/OrdersService+AsyncModelMiddleware.swift @@ -3,9 +3,9 @@ import FluentWalletOrders import Foundation extension OrdersService: AsyncModelMiddleware { - public func create(model: OD, on db: any Database, next: any AnyAsyncModelResponder) async throws { + public func create(model: OrderDataType, on db: any Database, next: any AnyAsyncModelResponder) async throws { let order = Order( - typeIdentifier: OD.typeIdentifier, + typeIdentifier: OrderDataType.typeIdentifier, authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString() ) try await order.save(on: db) @@ -13,7 +13,7 @@ extension OrdersService: AsyncModelMiddleware { try await next.create(model, on: db) } - public func update(model: OD, on db: any Database, next: any AnyAsyncModelResponder) async throws { + public func update(model: OrderDataType, on db: any Database, next: any AnyAsyncModelResponder) async throws { let order = try await model._$order.get(on: db) order.updatedAt = Date.now try await order.save(on: db) @@ -23,9 +23,9 @@ extension OrdersService: AsyncModelMiddleware { } extension OrdersServiceCustom: AsyncModelMiddleware { - public func create(model: OD, on db: any Database, next: any AnyAsyncModelResponder) async throws { - let order = O( - typeIdentifier: OD.typeIdentifier, + public func create(model: OrderDataType, on db: any Database, next: any AnyAsyncModelResponder) async throws { + let order = OrderType( + typeIdentifier: OrderDataType.typeIdentifier, authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString() ) try await order.save(on: db) @@ -33,7 +33,7 @@ extension OrdersServiceCustom: AsyncModelMiddleware { try await next.create(model, on: db) } - public func update(model: OD, on db: any Database, next: any AnyAsyncModelResponder) async throws { + public func update(model: OrderDataType, on db: any Database, next: any AnyAsyncModelResponder) async throws { let order = try await model._$order.get(on: db) order.updatedAt = Date.now try await order.save(on: db) diff --git a/Sources/VaporWalletOrders/OrdersService.swift b/Sources/VaporWalletOrders/OrdersService.swift index 3a81b1b..7f6472a 100644 --- a/Sources/VaporWalletOrders/OrdersService.swift +++ b/Sources/VaporWalletOrders/OrdersService.swift @@ -3,8 +3,8 @@ import FluentWalletOrders import Vapor /// The main class that handles Wallet orders. -public final class OrdersService: Sendable where Order == OD.OrderType { - private let service: OrdersServiceCustom +public final class OrdersService: Sendable where Order == OrderDataType.OrderType { + private let service: OrdersServiceCustom /// Initializes the service and registers all the routes required for Apple Wallet to work. /// @@ -40,7 +40,7 @@ public final class OrdersService: Sendable where Order == OD /// - db: The `Database` to use. /// /// - Returns: The generated order content. - public func build(order: OD, on db: any Database) async throws -> Data { + public func build(order: OrderDataType, on db: any Database) async throws -> Data { try await service.build(order: order, on: db) } @@ -58,7 +58,7 @@ public final class OrdersService: Sendable where Order == OD /// - Parameters: /// - order: The order to send the notifications for. /// - db: The `Database` to use. - public func sendPushNotifications(for order: OD, on db: any Database) async throws { + public func sendPushNotifications(for order: OrderDataType, on db: any Database) async throws { try await service.sendPushNotifications(for: order, on: db) } } diff --git a/Sources/VaporWalletOrders/OrdersServiceCustom+RouteCollection.swift b/Sources/VaporWalletOrders/OrdersServiceCustom+RouteCollection.swift index bb5a619..841a75f 100644 --- a/Sources/VaporWalletOrders/OrdersServiceCustom+RouteCollection.swift +++ b/Sources/VaporWalletOrders/OrdersServiceCustom+RouteCollection.swift @@ -5,13 +5,13 @@ import VaporWallet extension OrdersServiceCustom: RouteCollection { public func boot(routes: any RoutesBuilder) throws { - let orderTypeIdentifier = PathComponent(stringLiteral: OD.typeIdentifier) + let orderTypeIdentifier = PathComponent(stringLiteral: OrderDataType.typeIdentifier) let v1 = routes.grouped("v1") v1.get("devices", ":deviceIdentifier", "registrations", orderTypeIdentifier, use: self.ordersForDevice) v1.post("log", use: self.logMessage) - let v1auth = v1.grouped(AppleOrderMiddleware()) + let v1auth = v1.grouped(AppleOrderMiddleware()) v1auth.post("devices", ":deviceIdentifier", "registrations", orderTypeIdentifier, ":orderIdentifier", use: self.registerDevice) v1auth.get("orders", orderTypeIdentifier, ":orderIdentifier", use: self.latestVersionOfOrder) v1auth.delete("devices", ":deviceIdentifier", "registrations", orderTypeIdentifier, ":orderIdentifier", use: self.unregisterDevice) @@ -29,9 +29,9 @@ extension OrdersServiceCustom: RouteCollection { throw Abort(.badRequest) } guard - let order = try await O.query(on: req.db) + let order = try await OrderType.query(on: req.db) .filter(\._$id == id) - .filter(\._$typeIdentifier == OD.typeIdentifier) + .filter(\._$typeIdentifier == OrderDataType.typeIdentifier) .first() else { throw Abort(.notFound) @@ -42,7 +42,7 @@ extension OrdersServiceCustom: RouteCollection { } guard - let orderData = try await OD.query(on: req.db) + let orderData = try await OrderDataType.query(on: req.db) .filter(\._$order.$id == id) .first() else { @@ -75,39 +75,39 @@ extension OrdersServiceCustom: RouteCollection { } let deviceIdentifier = req.parameters.get("deviceIdentifier")! guard - let order = try await O.query(on: req.db) + let order = try await OrderType.query(on: req.db) .filter(\._$id == orderIdentifier) - .filter(\._$typeIdentifier == OD.typeIdentifier) + .filter(\._$typeIdentifier == OrderDataType.typeIdentifier) .first() else { throw Abort(.notFound) } - let device = try await D.query(on: req.db) + let device = try await DeviceType.query(on: req.db) .filter(\._$libraryIdentifier == 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(libraryIdentifier: deviceIdentifier, pushToken: pushToken) + let newDevice = DeviceType(libraryIdentifier: 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( + private static func createRegistration(device: DeviceType, order: OrderType, db: any Database) async throws -> HTTPStatus { + let r = try await OrdersRegistrationType.for( deviceLibraryIdentifier: device.libraryIdentifier, typeIdentifier: order.typeIdentifier, on: db ) - .filter(O.self, \._$id == order.requireID()) + .filter(OrderType.self, \._$id == order.requireID()) .first() // If the registration already exists, docs say to return 200 OK if r != nil { return .ok } - let registration = R() + let registration = OrdersRegistrationType() registration._$order.id = try order.requireID() registration._$device.id = try device.requireID() try await registration.create(on: db) @@ -119,14 +119,14 @@ extension OrdersServiceCustom: RouteCollection { let deviceIdentifier = req.parameters.get("deviceIdentifier")! - var query = R.for( + var query = OrdersRegistrationType.for( deviceLibraryIdentifier: deviceIdentifier, - typeIdentifier: OD.typeIdentifier, + typeIdentifier: OrderDataType.typeIdentifier, on: req.db ) if let since: TimeInterval = req.query["ordersModifiedSince"] { let when = Date(timeIntervalSince1970: since) - query = query.filter(O.self, \._$updatedAt > when) + query = query.filter(OrderType.self, \._$updatedAt > when) } let registrations = try await query.all() @@ -166,12 +166,12 @@ extension OrdersServiceCustom: RouteCollection { let deviceIdentifier = req.parameters.get("deviceIdentifier")! guard - let r = try await R.for( + let r = try await OrdersRegistrationType.for( deviceLibraryIdentifier: deviceIdentifier, - typeIdentifier: OD.typeIdentifier, + typeIdentifier: OrderDataType.typeIdentifier, on: req.db ) - .filter(O.self, \._$id == orderIdentifier) + .filter(OrderType.self, \._$id == orderIdentifier) .first() else { throw Abort(.notFound) diff --git a/Sources/VaporWalletOrders/OrdersServiceCustom.swift b/Sources/VaporWalletOrders/OrdersServiceCustom.swift index e7af1ee..a519071 100644 --- a/Sources/VaporWalletOrders/OrdersServiceCustom.swift +++ b/Sources/VaporWalletOrders/OrdersServiceCustom.swift @@ -12,16 +12,21 @@ import Zip /// Class to handle ``OrdersService``. /// /// The generics should be passed in this order: -/// - Order Data Model -/// - Order Type -/// - Device Type -/// - Registration Type +/// - `OrderDataModel` +/// - `OrderModel` +/// - `DeviceModel` +/// - `OrdersRegistrationModel` public final class OrdersServiceCustom< - OD: OrderDataModel, - O: OrderModel, - D: DeviceModel, - R: OrdersRegistrationModel ->: Sendable where O == OD.OrderType, O == R.OrderType, D == R.DeviceType { + OrderDataType: OrderDataModel, + OrderType: OrderModel, + DeviceType: DeviceModel, + OrdersRegistrationType: OrdersRegistrationModel +>: Sendable +where + OrderDataType.OrderType == OrderType, + OrdersRegistrationType.OrderType == OrderType, + OrdersRegistrationType.DeviceType == DeviceType +{ private unowned let app: Application let builder: OrderBuilder @@ -93,11 +98,11 @@ extension OrdersServiceCustom { /// - Parameters: /// - orderData: The order to send the notifications for. /// - db: The `Database` to use. - public func sendPushNotifications(for orderData: OD, on db: any Database) async throws { + public func sendPushNotifications(for orderData: OrderDataType, on db: any Database) async throws { try await sendPushNotifications(for: orderData._$order.get(on: db), on: db) } - func sendPushNotifications(for order: O, on db: any Database) async throws { + func sendPushNotifications(for order: OrderType, on db: any Database) async throws { let registrations = try await Self.registrations(for: order, on: db) for reg in registrations { let backgroundNotification = APNSBackgroundNotification( @@ -117,16 +122,16 @@ extension OrdersServiceCustom { } } - private static func registrations(for order: O, on db: any Database) async throws -> [R] { + private static func registrations(for order: OrderType, on db: any Database) async throws -> [OrdersRegistrationType] { // 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) + try await OrdersRegistrationType.query(on: db) .join(parent: \._$order) .join(parent: \._$device) .with(\._$order) .with(\._$device) - .filter(O.self, \._$typeIdentifier == OD.typeIdentifier) - .filter(O.self, \._$id == order.requireID()) + .filter(OrderType.self, \._$typeIdentifier == OrderDataType.typeIdentifier) + .filter(OrderType.self, \._$id == order.requireID()) .all() } } @@ -140,7 +145,7 @@ extension OrdersServiceCustom { /// - db: The `Database` to use. /// /// - Returns: The generated order content as `Data`. - public func build(order: OD, on db: any Database) async throws -> Data { + public func build(order: OrderDataType, on db: any Database) async throws -> Data { try await self.builder.build( order: order.orderJSON(on: db), sourceFilesDirectoryPath: order.sourceFilesDirectoryPath(on: db) diff --git a/Sources/VaporWalletPasses/Middleware/ApplePassMiddleware.swift b/Sources/VaporWalletPasses/Middleware/ApplePassMiddleware.swift index b988b11..626bf67 100644 --- a/Sources/VaporWalletPasses/Middleware/ApplePassMiddleware.swift +++ b/Sources/VaporWalletPasses/Middleware/ApplePassMiddleware.swift @@ -30,14 +30,14 @@ import FluentKit import FluentWalletPasses import Vapor -struct ApplePassMiddleware: AsyncMiddleware { +struct ApplePassMiddleware: AsyncMiddleware { func respond( to request: Request, chainingTo next: any AsyncResponder ) async throws -> Response { guard let id = request.parameters.get("passSerial", as: UUID.self), let authToken = request.headers["Authorization"].first?.replacingOccurrences(of: "ApplePass ", with: ""), - (try await P.query(on: request.db) + (try await PassType.query(on: request.db) .filter(\._$id == id) .filter(\._$authenticationToken == authToken) .first()) != nil diff --git a/Sources/VaporWalletPasses/Middleware/PassesService+AsyncModelMiddleware.swift b/Sources/VaporWalletPasses/Middleware/PassesService+AsyncModelMiddleware.swift index 55ddc96..02465d0 100644 --- a/Sources/VaporWalletPasses/Middleware/PassesService+AsyncModelMiddleware.swift +++ b/Sources/VaporWalletPasses/Middleware/PassesService+AsyncModelMiddleware.swift @@ -3,9 +3,9 @@ import FluentWalletPasses import Foundation extension PassesService: AsyncModelMiddleware { - public func create(model: PD, on db: any Database, next: any AnyAsyncModelResponder) async throws { + public func create(model: PassDataType, on db: any Database, next: any AnyAsyncModelResponder) async throws { let pass = Pass( - typeIdentifier: PD.typeIdentifier, + typeIdentifier: PassDataType.typeIdentifier, authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString() ) try await pass.save(on: db) @@ -13,7 +13,7 @@ extension PassesService: AsyncModelMiddleware { try await next.create(model, on: db) } - public func update(model: PD, on db: any Database, next: any AnyAsyncModelResponder) async throws { + public func update(model: PassDataType, on db: any Database, next: any AnyAsyncModelResponder) async throws { let pass = try await model._$pass.get(on: db) pass.updatedAt = Date.now try await pass.save(on: db) @@ -23,9 +23,9 @@ extension PassesService: AsyncModelMiddleware { } extension PassesServiceCustom: AsyncModelMiddleware { - public func create(model: PD, on db: any Database, next: any AnyAsyncModelResponder) async throws { - let pass = P( - typeIdentifier: PD.typeIdentifier, + public func create(model: PassDataType, on db: any Database, next: any AnyAsyncModelResponder) async throws { + let pass = PassType( + typeIdentifier: PassDataType.typeIdentifier, authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString() ) try await pass.save(on: db) @@ -33,7 +33,7 @@ extension PassesServiceCustom: AsyncModelMiddleware { try await next.create(model, on: db) } - public func update(model: PD, on db: any Database, next: any AnyAsyncModelResponder) async throws { + public func update(model: PassDataType, on db: any Database, next: any AnyAsyncModelResponder) async throws { let pass = try await model._$pass.get(on: db) pass.updatedAt = Date.now try await pass.save(on: db) diff --git a/Sources/VaporWalletPasses/PassesService.swift b/Sources/VaporWalletPasses/PassesService.swift index f06256c..9c659f5 100644 --- a/Sources/VaporWalletPasses/PassesService.swift +++ b/Sources/VaporWalletPasses/PassesService.swift @@ -31,8 +31,8 @@ import FluentWalletPasses import Vapor /// The main class that handles Apple Wallet passes. -public final class PassesService: Sendable where Pass == PD.PassType { - private let service: PassesServiceCustom +public final class PassesService: Sendable where Pass == PassDataType.PassType { + private let service: PassesServiceCustom /// Initializes the service and registers all the routes required for Apple Wallet to work. /// @@ -68,7 +68,7 @@ public final class PassesService: Sendable where Pass == PD.P /// - db: The `Database` to use. /// /// - Returns: The generated pass content as `Data`. - public func build(pass: PD, on db: any Database) async throws -> Data { + public func build(pass: PassDataType, on db: any Database) async throws -> Data { try await service.build(pass: pass, on: db) } @@ -83,7 +83,7 @@ public final class PassesService: Sendable where Pass == PD.P /// - db: The `Database` to use. /// /// - Returns: The bundle of passes as `Data`. - public func build(passes: [PD], on db: any Database) async throws -> Data { + public func build(passes: [PassDataType], on db: any Database) async throws -> Data { try await service.build(passes: passes, on: db) } @@ -106,7 +106,7 @@ public final class PassesService: Sendable where Pass == PD.P /// - Parameters: /// - pass: The pass to send the notifications for. /// - db: The `Database` to use. - public func sendPushNotifications(for pass: PD, on db: any Database) async throws { + public func sendPushNotifications(for pass: PassDataType, on db: any Database) async throws { try await service.sendPushNotifications(for: pass, on: db) } } diff --git a/Sources/VaporWalletPasses/PassesServiceCustom+RouteCollection.swift b/Sources/VaporWalletPasses/PassesServiceCustom+RouteCollection.swift index df2702f..96d66c0 100644 --- a/Sources/VaporWalletPasses/PassesServiceCustom+RouteCollection.swift +++ b/Sources/VaporWalletPasses/PassesServiceCustom+RouteCollection.swift @@ -5,14 +5,14 @@ import VaporWallet extension PassesServiceCustom: RouteCollection { public func boot(routes: any RoutesBuilder) throws { - let passTypeIdentifier = PathComponent(stringLiteral: PD.typeIdentifier) + let passTypeIdentifier = PathComponent(stringLiteral: PassDataType.typeIdentifier) let v1 = routes.grouped("v1") v1.get("devices", ":deviceLibraryIdentifier", "registrations", passTypeIdentifier, use: self.updatablePasses) v1.post("log", use: self.logMessage) v1.post("passes", passTypeIdentifier, ":passSerial", "personalize", use: self.personalizedPass) - let v1auth = v1.grouped(ApplePassMiddleware

()) + let v1auth = v1.grouped(ApplePassMiddleware()) v1auth.post("devices", ":deviceLibraryIdentifier", "registrations", passTypeIdentifier, ":passSerial", use: self.registerPass) v1auth.get("passes", passTypeIdentifier, ":passSerial", use: self.updatedPass) v1auth.delete("devices", ":deviceLibraryIdentifier", "registrations", passTypeIdentifier, ":passSerial", use: self.unregisterPass) @@ -33,39 +33,39 @@ extension PassesServiceCustom: RouteCollection { } let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! guard - let pass = try await P.query(on: req.db) - .filter(\._$typeIdentifier == PD.typeIdentifier) + let pass = try await PassType.query(on: req.db) + .filter(\._$typeIdentifier == PassDataType.typeIdentifier) .filter(\._$id == serial) .first() else { throw Abort(.notFound) } - let device = try await D.query(on: req.db) + let device = try await DeviceType.query(on: req.db) .filter(\._$libraryIdentifier == deviceLibraryIdentifier) .filter(\._$pushToken == pushToken) .first() if let device = device { return try await Self.createRegistration(device: device, pass: pass, db: req.db) } else { - let newDevice = D(libraryIdentifier: deviceLibraryIdentifier, pushToken: pushToken) + let newDevice = DeviceType(libraryIdentifier: deviceLibraryIdentifier, pushToken: pushToken) try await newDevice.create(on: req.db) return try await Self.createRegistration(device: newDevice, pass: pass, db: req.db) } } - private static func createRegistration(device: D, pass: P, db: any Database) async throws -> HTTPStatus { - let r = try await R.for( + private static func createRegistration(device: DeviceType, pass: PassType, db: any Database) async throws -> HTTPStatus { + let r = try await PassesRegistrationType.for( deviceLibraryIdentifier: device.libraryIdentifier, typeIdentifier: pass.typeIdentifier, on: db ) - .filter(P.self, \._$id == pass.requireID()) + .filter(PassType.self, \._$id == pass.requireID()) .first() // If the registration already exists, docs say to return 200 OK if r != nil { return .ok } - let registration = R() + let registration = PassesRegistrationType() registration._$pass.id = try pass.requireID() registration._$device.id = try device.requireID() try await registration.create(on: db) @@ -77,14 +77,14 @@ extension PassesServiceCustom: RouteCollection { let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! - var query = R.for( + var query = PassesRegistrationType.for( deviceLibraryIdentifier: deviceLibraryIdentifier, - typeIdentifier: PD.typeIdentifier, + typeIdentifier: PassDataType.typeIdentifier, on: req.db ) if let since: TimeInterval = req.query["passesUpdatedSince"] { let when = Date(timeIntervalSince1970: since) - query = query.filter(P.self, \._$updatedAt > when) + query = query.filter(PassType.self, \._$updatedAt > when) } let registrations = try await query.all() @@ -117,9 +117,9 @@ extension PassesServiceCustom: RouteCollection { throw Abort(.badRequest) } guard - let pass = try await P.query(on: req.db) + let pass = try await PassType.query(on: req.db) .filter(\._$id == id) - .filter(\._$typeIdentifier == PD.typeIdentifier) + .filter(\._$typeIdentifier == PassDataType.typeIdentifier) .first() else { throw Abort(.notFound) @@ -130,7 +130,7 @@ extension PassesServiceCustom: RouteCollection { } guard - let passData = try await PD.query(on: req.db) + let passData = try await PassDataType.query(on: req.db) .filter(\._$pass.$id == id) .first() else { @@ -157,12 +157,12 @@ extension PassesServiceCustom: RouteCollection { let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! guard - let r = try await R.for( + let r = try await PassesRegistrationType.for( deviceLibraryIdentifier: deviceLibraryIdentifier, - typeIdentifier: PD.typeIdentifier, + typeIdentifier: PassDataType.typeIdentifier, on: req.db ) - .filter(P.self, \._$id == passId) + .filter(PassType.self, \._$id == passId) .first() else { throw Abort(.notFound) @@ -188,9 +188,9 @@ extension PassesServiceCustom: RouteCollection { throw Abort(.badRequest) } guard - try await P.query(on: req.db) + try await PassType.query(on: req.db) .filter(\._$id == id) - .filter(\._$typeIdentifier == PD.typeIdentifier) + .filter(\._$typeIdentifier == PassDataType.typeIdentifier) .first() != nil else { throw Abort(.notFound) @@ -198,7 +198,7 @@ extension PassesServiceCustom: RouteCollection { let userInfo = try req.content.decode(PersonalizationDictionaryDTO.self) - let personalization = I() + let personalization = PersonalizationInfoType() personalization.fullName = userInfo.requiredPersonalizationInfo.fullName personalization.givenName = userInfo.requiredPersonalizationInfo.givenName personalization.familyName = userInfo.requiredPersonalizationInfo.familyName diff --git a/Sources/VaporWalletPasses/PassesServiceCustom.swift b/Sources/VaporWalletPasses/PassesServiceCustom.swift index 7ad437b..0cefed4 100644 --- a/Sources/VaporWalletPasses/PassesServiceCustom.swift +++ b/Sources/VaporWalletPasses/PassesServiceCustom.swift @@ -12,18 +12,24 @@ import Zip /// Class to handle ``PassesService``. /// /// The generics should be passed in this order: -/// - Pass Data Model -/// - Pass Type -/// - Personalization Info Type -/// - Device Type -/// - Registration Type +/// - `PassDataModel` +/// - `PassModel` +/// - `PersonalizationInfoModel` +/// - `DeviceModel` +/// - `PassesRegistrationModel` public final class PassesServiceCustom< - PD: PassDataModel, - P: PassModel, - I: PersonalizationInfoModel, - D: DeviceModel, - R: PassesRegistrationModel ->: Sendable where P == PD.PassType, P == R.PassType, D == R.DeviceType, I.PassType == P { + PassDataType: PassDataModel, + PassType: PassModel, + PersonalizationInfoType: PersonalizationInfoModel, + DeviceType: DeviceModel, + PassesRegistrationType: PassesRegistrationModel +>: Sendable +where + PassDataType.PassType == PassType, + PersonalizationInfoType.PassType == PassType, + PassesRegistrationType.PassType == PassType, + PassesRegistrationType.DeviceType == DeviceType +{ private unowned let app: Application let builder: PassBuilder @@ -95,11 +101,11 @@ extension PassesServiceCustom { /// - Parameters: /// - passData: The pass to send the notifications for. /// - db: The `Database` to use. - public func sendPushNotifications(for passData: PD, on db: any Database) async throws { + public func sendPushNotifications(for passData: PassDataType, on db: any Database) async throws { try await self.sendPushNotifications(for: passData._$pass.get(on: db), on: db) } - func sendPushNotifications(for pass: P, on db: any Database) async throws { + func sendPushNotifications(for pass: PassType, on db: any Database) async throws { let registrations = try await Self.registrations(for: pass, on: db) for reg in registrations { let backgroundNotification = APNSBackgroundNotification( @@ -119,16 +125,16 @@ extension PassesServiceCustom { } } - private static func registrations(for pass: P, on db: any Database) async throws -> [R] { + private static func registrations(for pass: PassType, on db: any Database) async throws -> [PassesRegistrationType] { // 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) + try await PassesRegistrationType.query(on: db) .join(parent: \._$pass) .join(parent: \._$device) .with(\._$pass) .with(\._$device) - .filter(P.self, \._$typeIdentifier == PD.typeIdentifier) - .filter(P.self, \._$id == pass.requireID()) + .filter(PassType.self, \._$typeIdentifier == PassDataType.typeIdentifier) + .filter(PassType.self, \._$id == pass.requireID()) .all() } } @@ -142,7 +148,7 @@ extension PassesServiceCustom { /// - db: The `Database` to use. /// /// - Returns: The generated pass content as `Data`. - public func build(pass: PD, on db: any Database) async throws -> Data { + public func build(pass: PassDataType, on db: any Database) async throws -> Data { try await self.builder.build( pass: pass.passJSON(on: db), sourceFilesDirectoryPath: pass.sourceFilesDirectoryPath(on: db), @@ -161,7 +167,7 @@ extension PassesServiceCustom { /// - db: The `Database` to use. /// /// - Returns: The bundle of passes as `Data`. - public func build(passes: [PD], on db: any Database) async throws -> Data { + public func build(passes: [PassDataType], on db: any Database) async throws -> Data { guard passes.count > 1 && passes.count <= 10 else { throw WalletPassesError.invalidNumberOfPasses } From a44809e54a906ede4cc086f3af31811ef094c4e7 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Fri, 17 Jan 2025 23:55:32 +0100 Subject: [PATCH 3/4] Update README for v0.7.0 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2d0259b..b15e360 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Use the SPM string to easily include the dependendency in your `Package.swift` file. ```swift -.package(url: "https://github.com/vapor-community/wallet.git", from: "0.6.0") +.package(url: "https://github.com/vapor-community/wallet.git", from: "0.7.0") ``` > Note: This package is made for Vapor 4. From ff827000743bfc848429a88e5ac927d682e97114 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Sat, 18 Jan 2025 00:01:36 +0100 Subject: [PATCH 4/4] Update SPI links --- README.md | 4 ++-- Sources/VaporWallet/VaporWallet.docc/VaporWallet.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b15e360..cdd60dd 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Add the `VaporWalletPasses` product to your target's dependencies: .product(name: "VaporWalletPasses", package: "wallet") ``` -See the framework's [documentation](https://swiftpackageindex.com/vapor-community/wallet/documentation/passes) for information and guides on how to use it. +See the framework's [documentation](https://swiftpackageindex.com/vapor-community/wallet/documentation/vaporwalletpasses) for information and guides on how to use it. For information on Apple Wallet passes, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletpasses). @@ -54,6 +54,6 @@ Add the `VaporWalletOrders` product to your target's dependencies: .product(name: "VaporWalletOrders", package: "wallet") ``` -See the framework's [documentation](https://swiftpackageindex.com/vapor-community/wallet/documentation/orders) for information and guides on how to use it. +See the framework's [documentation](https://swiftpackageindex.com/vapor-community/wallet/documentation/vaporwalletorders) for information and guides on how to use it. For information on Apple Wallet orders, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletorders). diff --git a/Sources/VaporWallet/VaporWallet.docc/VaporWallet.md b/Sources/VaporWallet/VaporWallet.docc/VaporWallet.md index f951685..d3fc115 100644 --- a/Sources/VaporWallet/VaporWallet.docc/VaporWallet.md +++ b/Sources/VaporWallet/VaporWallet.docc/VaporWallet.md @@ -19,7 +19,7 @@ The `VaporWallet` framework provides a set of tools shared by the `VaporWalletPa The `VaporWalletPasses` framework provides a set of tools to help you create, build, and distribute digital passes for the Apple Wallet app using a Vapor server. It also provides a way to update passes after they have been distributed, using APNs, and models to store pass and device data. -See the framework's [documentation](https://swiftpackageindex.com/vapor-community/wallet/documentation/passes) for information and guides on how to use it. +See the framework's [documentation](https://swiftpackageindex.com/vapor-community/wallet/documentation/vaporwalletpasses) for information and guides on how to use it. For information on Apple Wallet passes, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletpasses). @@ -28,6 +28,6 @@ For information on Apple Wallet passes, see the [Apple Developer Documentation]( The `VaporWalletOrders` framework provides a set of tools to help you create, build, and distribute orders that users can track and manage in Apple Wallet using a Vapor server. It also provides a way to update orders after they have been distributed, using APNs, and models to store order and device data. -See the framework's [documentation](https://swiftpackageindex.com/vapor-community/wallet/documentation/orders) for information and guides on how to use it. +See the framework's [documentation](https://swiftpackageindex.com/vapor-community/wallet/documentation/vaporwalletorders) for information and guides on how to use it. For information on Apple Wallet orders, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletorders).