From f41e174f2ec9b275ab14bbc7006c0f56047e2711 Mon Sep 17 00:00:00 2001 From: David Hunt Date: Wed, 24 Dec 2025 20:47:05 -0600 Subject: [PATCH] Improved Vapor/Leaf Support --- Package.resolved | 11 ++- Package.swift | 2 + .../Localization/LocalizedProperty.swift | 1 - .../Protocols/ServerRequest+Fetch.swift | 2 - .../SwiftUI Support/MVVMEnvironment.swift | 2 - .../MVVMEnvirontmentView.swift | 2 +- Sources/FOSMVVM/SwiftUI Support/Tab.swift | 2 +- Sources/FOSMVVM/SwiftUI Support/Text.swift | 2 +- .../SwiftUI Support/ViewModelView.swift | 2 +- .../Extensions/Application+FOS.swift | 4 +- .../Extensions/Localizable+Leaf.swift | 62 +++++++++++++ .../Extensions/String+Pluralize.swift | 2 +- .../Vapor Support/UpdateController.swift | 92 +++++++++++++++++++ .../VaporServerRequestHost.swift | 2 +- .../VaporServerRequestMiddleware.swift | 2 +- .../Vapor Support/ViewModelRequest.swift | 2 +- 16 files changed, 176 insertions(+), 16 deletions(-) create mode 100644 Sources/FOSMVVMVapor/Extensions/Localizable+Leaf.swift create mode 100644 Sources/FOSMVVMVapor/Vapor Support/UpdateController.swift diff --git a/Package.resolved b/Package.resolved index b100f96..f447c00 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1c8b34e221c365861bc75f5b4b213f1b5513152eea4a60463ce8181c851a0135", + "originHash" : "d73e0905e05ae33efba8bba0c2299e869bfe038f47dcac09d27aa5bf48345229", "pins" : [ { "identity" : "async-http-client", @@ -37,6 +37,15 @@ "version" : "1.53.0" } }, + { + "identity" : "leaf-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/leaf-kit.git", + "state" : { + "revision" : "0c325fc46d42455914abd0105e88fe4561fc31a8", + "version" : "1.14.0" + } + }, { "identity" : "multipart-kit", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 5974a7f..31de991 100644 --- a/Package.swift +++ b/Package.swift @@ -70,6 +70,7 @@ let package = Package( #if os(macOS) || os(Linux) result.append(.package(url: "https://github.com/vapor/vapor.git", .upToNextMajor(from: "4.119.0"))) result.append(.package(url: "https://github.com/vapor/fluent-kit.git", .upToNextMajor(from: "1.52.2"))) + result.append(.package(url: "https://github.com/vapor/leaf-kit.git", .upToNextMajor(from: "1.11.0"))) #endif return result @@ -175,6 +176,7 @@ let package = Package( .byName(name: "FOSMacros"), .product(name: "Vapor", package: "Vapor"), .product(name: "FluentKit", package: "fluent-kit"), + .product(name: "LeafKit", package: "leaf-kit"), .product(name: "Yams", package: "Yams") ] )) diff --git a/Sources/FOSMVVM/Localization/LocalizedProperty.swift b/Sources/FOSMVVM/Localization/LocalizedProperty.swift index f39f5ec..c9340a7 100644 --- a/Sources/FOSMVVM/Localization/LocalizedProperty.swift +++ b/Sources/FOSMVVM/Localization/LocalizedProperty.swift @@ -467,7 +467,6 @@ public extension RetrievablePropertyNames { self.vFirst = vFirst ?? .vInitial self.vLast = vLast } - } public extension _LocalizedProperty { diff --git a/Sources/FOSMVVM/Protocols/ServerRequest+Fetch.swift b/Sources/FOSMVVM/Protocols/ServerRequest+Fetch.swift index 8de28d8..a8d21ec 100644 --- a/Sources/FOSMVVM/Protocols/ServerRequest+Fetch.swift +++ b/Sources/FOSMVVM/Protocols/ServerRequest+Fetch.swift @@ -102,7 +102,6 @@ public extension ServerRequest { return responseBody } - #if canImport(SwiftUI) /// Send the ``ServerRequest`` to the web service and wait for a response /// /// Upon receipt of a response from the server, ``responseBody`` will be updated with @@ -131,7 +130,6 @@ public extension ServerRequest { } } } - #endif } public enum ServerRequestProcessingError: Error, CustomDebugStringConvertible { diff --git a/Sources/FOSMVVM/SwiftUI Support/MVVMEnvironment.swift b/Sources/FOSMVVM/SwiftUI Support/MVVMEnvironment.swift index 360a0ff..382070d 100644 --- a/Sources/FOSMVVM/SwiftUI Support/MVVMEnvironment.swift +++ b/Sources/FOSMVVM/SwiftUI Support/MVVMEnvironment.swift @@ -317,7 +317,6 @@ public final class MVVMEnvironment: @unchecked Sendable { self.deploymentURLs = deploymentURLs self.requestErrorHandler = requestErrorHandler - #if canImport(SwiftUI) self.loadingView = { AnyView(DefaultLoadingView()) } let currentVersion = currentVersion ?? (try? appBundle.appleOSVersion) ?? SystemVersion.current @@ -326,7 +325,6 @@ public final class MVVMEnvironment: @unchecked Sendable { let currentVersion = currentVersion ?? SystemVersion.current SystemVersion.setCurrentVersion(currentVersion) #endif - } /// Initializes the ``MVVMEnvironment`` for non-SwiftUI Applications diff --git a/Sources/FOSMVVM/SwiftUI Support/MVVMEnvirontmentView.swift b/Sources/FOSMVVM/SwiftUI Support/MVVMEnvirontmentView.swift index f77508d..44cb956 100644 --- a/Sources/FOSMVVM/SwiftUI Support/MVVMEnvirontmentView.swift +++ b/Sources/FOSMVVM/SwiftUI Support/MVVMEnvirontmentView.swift @@ -1,6 +1,6 @@ // MVVMEnvirontmentView.swift // -// Copyright 2025 FOS Computer Services, LLC +// Copyright 2024 FOS Computer Services, LLC // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. diff --git a/Sources/FOSMVVM/SwiftUI Support/Tab.swift b/Sources/FOSMVVM/SwiftUI Support/Tab.swift index 6fdf0f9..cd8d048 100644 --- a/Sources/FOSMVVM/SwiftUI Support/Tab.swift +++ b/Sources/FOSMVVM/SwiftUI Support/Tab.swift @@ -1,6 +1,6 @@ // Tab.swift // -// Copyright 2025 FOS Computer Services, LLC +// Copyright 2024 FOS Computer Services, LLC // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. diff --git a/Sources/FOSMVVM/SwiftUI Support/Text.swift b/Sources/FOSMVVM/SwiftUI Support/Text.swift index cfcd0ad..6ffb624 100644 --- a/Sources/FOSMVVM/SwiftUI Support/Text.swift +++ b/Sources/FOSMVVM/SwiftUI Support/Text.swift @@ -1,6 +1,6 @@ // Text.swift // -// Copyright 2025 FOS Computer Services, LLC +// Copyright 2024 FOS Computer Services, LLC // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. diff --git a/Sources/FOSMVVM/SwiftUI Support/ViewModelView.swift b/Sources/FOSMVVM/SwiftUI Support/ViewModelView.swift index 4fe3f0d..ff88179 100644 --- a/Sources/FOSMVVM/SwiftUI Support/ViewModelView.swift +++ b/Sources/FOSMVVM/SwiftUI Support/ViewModelView.swift @@ -1,6 +1,6 @@ // ViewModelView.swift // -// Copyright 2025 FOS Computer Services, LLC +// Copyright 2024 FOS Computer Services, LLC // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. diff --git a/Sources/FOSMVVMVapor/Extensions/Application+FOS.swift b/Sources/FOSMVVMVapor/Extensions/Application+FOS.swift index eb4b8e1..9755f57 100644 --- a/Sources/FOSMVVMVapor/Extensions/Application+FOS.swift +++ b/Sources/FOSMVVMVapor/Extensions/Application+FOS.swift @@ -85,9 +85,9 @@ public extension Application { } func initMVVMEnvironment(_ mvvmEnvironment: MVVMEnvironment) async throws { - lifecycle.use(MVVMEnvironmentInitializer( + try await lifecycle.use(MVVMEnvironmentInitializer( mvvmEnvironment: mvvmEnvironment, - serverBaseURL: try await mvvmEnvironment.serverBaseURL + serverBaseURL: mvvmEnvironment.serverBaseURL )) } } diff --git a/Sources/FOSMVVMVapor/Extensions/Localizable+Leaf.swift b/Sources/FOSMVVMVapor/Extensions/Localizable+Leaf.swift new file mode 100644 index 0000000..117ac50 --- /dev/null +++ b/Sources/FOSMVVMVapor/Extensions/Localizable+Leaf.swift @@ -0,0 +1,62 @@ +// Localizable+Leaf.swift +// +// Copyright 2025 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FOSMVVM +import LeafKit + +// Extends `Localizable` types to render as their `localizedString` in Leaf templates +// +// Without this extension, Localizable types that encode as keyed containers +// (like `LocalizableDate`) would render as their debug description instead of +// the localized string value. + +// MARK: - Concrete Conformances + +extension LocalizableString: LeafDataRepresentable { + public var leafData: LeafData { + .string((try? localizedString) ?? "") + } +} + +extension LocalizableDate: LeafDataRepresentable { + public var leafData: LeafData { + .string((try? localizedString) ?? "") + } +} + +extension LocalizableInt: LeafDataRepresentable { + public var leafData: LeafData { + .string((try? localizedString) ?? "") + } +} + +extension LocalizableArray: LeafDataRepresentable { + public var leafData: LeafData { + .string((try? localizedString) ?? "") + } +} + +extension LocalizableCompoundValue: LeafDataRepresentable { + public var leafData: LeafData { + .string((try? localizedString) ?? "") + } +} + +extension LocalizableSubstitutions: LeafDataRepresentable { + public var leafData: LeafData { + .string((try? localizedString) ?? "") + } +} diff --git a/Sources/FOSMVVMVapor/Extensions/String+Pluralize.swift b/Sources/FOSMVVMVapor/Extensions/String+Pluralize.swift index bc6cc90..bc3b8c5 100644 --- a/Sources/FOSMVVMVapor/Extensions/String+Pluralize.swift +++ b/Sources/FOSMVVMVapor/Extensions/String+Pluralize.swift @@ -34,7 +34,7 @@ extension String { private func pluralizeByRules() -> String { guard !isEmpty else { return self } - let lowercased = self.lowercased() + let lowercased = lowercased() // Words ending in -s, -ss, -x, -z, -ch, -sh → add "es" if lowercased.hasSuffix("ss") || diff --git a/Sources/FOSMVVMVapor/Vapor Support/UpdateController.swift b/Sources/FOSMVVMVapor/Vapor Support/UpdateController.swift new file mode 100644 index 0000000..1fb2e9e --- /dev/null +++ b/Sources/FOSMVVMVapor/Vapor Support/UpdateController.swift @@ -0,0 +1,92 @@ +// UpdateController.swift +// +// Copyright 2025 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FOSFoundation +import FOSMVVM +import Vapor + +public protocol ServerRequestController: AnyObject, ControllerRouting, RouteCollection { + associatedtype TRequest: ServerRequest + + typealias ActionProcessor = ( + Vapor.Request, + TRequest, + TRequest.RequestBody + ) async throws -> TRequest.ResponseBody + + var actions: [ServerRequestAction: ActionProcessor] { get } +} + +// MARK: Default Implementation + +public extension ServerRequestController { + static var baseURL: String { TRequest.path } + + func boot(routes: RoutesBuilder) throws { + let groupName = Self.baseURL == "/" + ? "" + : Self.baseURL + + let routeGroup = routes + .grouped(.constant(groupName)) + + for pair in actions { + switch pair.key { + case .create: routeGroup.post { try await Self.run($0, processor: pair.value) } + case .replace: routeGroup.put { try await Self.run($0, processor: pair.value) } + case .update: routeGroup.patch { try await Self.run($0, processor: pair.value) } + default: throw ServerRequestControllerError.invalidAction(pair.key) + } + } + } +} + +public enum ServerRequestControllerError: Error, CustomDebugStringConvertible { + case invalidAction(ServerRequestAction) + case missingRequestBody + + public var debugDescription: String { + switch self { + case .invalidAction(let action): + "Invalid ServerRequestAction: \(action). Only create, replace and update are allowed." + case .missingRequestBody: + "Server request was missing its request body." + } + } +} + +// MARK: Private Methods + +private extension ServerRequestController { + static func run(_ req: Vapor.Request, processor: ActionProcessor) async throws -> Vapor.Response { + let requestBody: TRequest.RequestBody = if TRequest.RequestBody.self == EmptyBody.self { + // swiftlint:disable:next force_cast + (EmptyBody() as! TRequest.RequestBody) + } else { + try req.content.decode(TRequest.RequestBody.self) + } + + let serverRequest = TRequest( + query: nil, + fragment: nil, + requestBody: requestBody, + responseBody: nil + ) + + return try await processor(req, serverRequest, requestBody) + .buildResponse(req) + } +} diff --git a/Sources/FOSMVVMVapor/Vapor Support/VaporServerRequestHost.swift b/Sources/FOSMVVMVapor/Vapor Support/VaporServerRequestHost.swift index 23436ae..29bcf9f 100644 --- a/Sources/FOSMVVMVapor/Vapor Support/VaporServerRequestHost.swift +++ b/Sources/FOSMVVMVapor/Vapor Support/VaporServerRequestHost.swift @@ -1,6 +1,6 @@ // VaporServerRequestHost.swift // -// Copyright 2025 FOS Computer Services, LLC +// Copyright 2024 FOS Computer Services, LLC // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. diff --git a/Sources/FOSMVVMVapor/Vapor Support/VaporServerRequestMiddleware.swift b/Sources/FOSMVVMVapor/Vapor Support/VaporServerRequestMiddleware.swift index 8768537..b370e39 100644 --- a/Sources/FOSMVVMVapor/Vapor Support/VaporServerRequestMiddleware.swift +++ b/Sources/FOSMVVMVapor/Vapor Support/VaporServerRequestMiddleware.swift @@ -1,6 +1,6 @@ // VaporServerRequestMiddleware.swift // -// Copyright 2025 FOS Computer Services, LLC +// Copyright 2024 FOS Computer Services, LLC // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. diff --git a/Sources/FOSMVVMVapor/Vapor Support/ViewModelRequest.swift b/Sources/FOSMVVMVapor/Vapor Support/ViewModelRequest.swift index 0170b18..8117bf0 100644 --- a/Sources/FOSMVVMVapor/Vapor Support/ViewModelRequest.swift +++ b/Sources/FOSMVVMVapor/Vapor Support/ViewModelRequest.swift @@ -1,6 +1,6 @@ // ViewModelRequest.swift // -// Copyright 2025 FOS Computer Services, LLC +// Copyright 2024 FOS Computer Services, LLC // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License.