From 25ee198fd7df78afdadec9d488b76b1cba1d70a5 Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Fri, 16 Oct 2026 16:02:18 +0200 Subject: [PATCH 1/5] stuff --- Package.resolved | 267 +++++++++++++++++++++ Package.swift | 4 +- Scripts/format.sh | 4 +- Sources/Nectar/Endpoint.swift | 3 + Sources/Nectar/EndpointAdditionError.swift | 14 ++ Sources/Nectar/EndpointBuilder.swift | 4 +- Sources/Nectar/EndpointDeclaration.swift | 5 - Sources/Nectar/EndpointGroup.swift | 59 ++++- Sources/Nectar/Macros.swift | 1 + Sources/Nectar/NectarApp.swift | 2 +- Sources/Nectar/PathExtension.swift | 13 +- Sources/Nectar/RouterExtension.swift | 13 +- Sources/NectarMacros/Helpers.swift | 13 +- Tests/NectarTests/MacroTests.swift | 23 ++ Tests/NectarTests/RouteTests.swift | 102 +++++++- 15 files changed, 470 insertions(+), 57 deletions(-) create mode 100644 Package.resolved create mode 100644 Sources/Nectar/EndpointAdditionError.swift diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..e653e0d --- /dev/null +++ b/Package.resolved @@ -0,0 +1,267 @@ +{ + "originHash" : "648dae0e63a1ba1721db1401e53f13780f2411b89c309329ecbf475760bafb9e", + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "4c005f955e83f888d5616e579717a36d2dfc6301", + "version" : "1.36.2" + } + }, + { + "identity" : "hummingbird", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hummingbird-project/hummingbird.git", + "state" : { + "revision" : "e12178302dacb8226fc78157f89078a59c75dd98", + "version" : "2.28.0" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "3b6410f7dee09eb33cdd26260c5fd47fda19b0e2", + "version" : "1.7.3" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "13713a4ffdee8abd929f92568ee9462a46ae26e0", + "version" : "1.1.7" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "ff86b924ead66f853b8baf91f3c41926a8f36177", + "version" : "1.21.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "35a1edcb8ebd2bfe251d587f0b69e62af28f508c", + "version" : "1.7.3" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "8bae88f79dbddec793390c5bf3e2abb4ac2ece42", + "version" : "1.2.2" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "1c80d3aff53fedc445b60692980cb4f19a618657", + "version" : "5.0.1" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "4fb9e7cbb4b8f5a7354005db39447f570d6b3e19", + "version" : "1.7.3" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "117c46edcc9e7a9a26a539d30044b2275d9c9ab7", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "bff4b6903cdc99dda49649dd52f46c11cfd3ed50", + "version" : "1.8.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "4038b6a4f74a1426f47eb1dd5b4bb5628a15d597", + "version" : "1.16.1" + } + }, + { + "identity" : "swift-macro-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-macro-testing.git", + "state" : { + "revision" : "0b6bd11ea72e07eec539f2f000efad0b363a90cc", + "version" : "0.7.0" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "9467f9cc3c32b4386fad1ecc4618328887ef5758", + "version" : "2.12.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "6db9d9abd92ad7399b8a564cafbb87d3507245fa", + "version" : "2.104.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "41449336c8ecfadac6b4b5be75f9c3c306e61ced", + "version" : "1.35.1" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "0f3e54e29c944c2e835ad52159da7d9e1c94ac69", + "version" : "1.46.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "322f3c2a4a21df31c84ca416bf65ee5e9059e440", + "version" : "2.37.5" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d93beaa2b2c585cb9be08b070e46dda9e2d203be", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "c55297914e26ce3085b73ca88c521ef99136f6ad", + "version" : "2.12.1" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "28e5de025e3fd98991791bfbf83023ab3708da03", + "version" : "1.19.6" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "050f1a346fbbac0ca2cfb15a95274f7bd1cf0ccf", + "version" : "604.0.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "d9308ad679143c26a30dec52daefe82f54af2b82", + "version" : "1.13.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index b0c4daa..5eb947a 100644 --- a/Package.swift +++ b/Package.swift @@ -15,7 +15,7 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.25.0"), + .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.28.0"), .package(url: "https://github.com/swiftlang/swift-syntax.git", "600.0.0" ..< "605.0.0"), .package(url: "https://github.com/pointfreeco/swift-macro-testing.git", from: "0.7.0"), .package( @@ -23,7 +23,6 @@ let package = Package( from: "1.0.2", traits: [] ), - .package(url: "https://github.com/apple/swift-service-context.git", from: "1.0.0"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -32,7 +31,6 @@ let package = Package( name: "Nectar", dependencies: [ .product(name: "Hummingbird", package: "hummingbird"), - .product(name: "ServiceContextModule", package: "swift-service-context"), "NectarMacros" ], swiftSettings: [ diff --git a/Scripts/format.sh b/Scripts/format.sh index dff0bce..c1d2dba 100755 --- a/Scripts/format.sh +++ b/Scripts/format.sh @@ -1,7 +1,7 @@ cd "$(dirname "$0")"/../ || exit 1 if [ -z "$1" ]; then - swiftformat . --exclude Sources/ULID/*,Tests/ULIDTests/*,Sources/VeinCore/VeinCore.docc/*,Sources/VeinSwiftUI/VeinSwiftUI.docc/*,Sources/VeinSCUI/VeinSCUI.docc/* + swiftformat . --exclude else swiftformat "$1" -fi \ No newline at end of file +fi diff --git a/Sources/Nectar/Endpoint.swift b/Sources/Nectar/Endpoint.swift index a6be88e..f909e47 100644 --- a/Sources/Nectar/Endpoint.swift +++ b/Sources/Nectar/Endpoint.swift @@ -13,16 +13,19 @@ public struct Endpoint { let partialPath: String let httpMethod: HTTPMethod + let pathParams: [String] let handler: @Sendable (Request, any RequestContext) async throws -> Response public init( partialPath: String, httpMethod: HTTPMethod, + pathParams: [String], handler: @Sendable @escaping (Request, any RequestContext) async throws -> Response ) { self.partialPath = partialPath self.httpMethod = httpMethod self.handler = handler + self.pathParams = pathParams } } diff --git a/Sources/Nectar/EndpointAdditionError.swift b/Sources/Nectar/EndpointAdditionError.swift new file mode 100644 index 0000000..0622366 --- /dev/null +++ b/Sources/Nectar/EndpointAdditionError.swift @@ -0,0 +1,14 @@ +import Foundation + +public enum EndpointAdditionError: Error { + case missingPathParameter(String, String) +} + +extension EndpointAdditionError: LocalizedError { + public var errorDescription: String? { + switch self { + case .missingPathParameter(let string, let uri): + "The required path parameter '\(string)' is not part of the URI '\(uri)'" + } + } +} diff --git a/Sources/Nectar/EndpointBuilder.swift b/Sources/Nectar/EndpointBuilder.swift index cbda073..41192c7 100644 --- a/Sources/Nectar/EndpointBuilder.swift +++ b/Sources/Nectar/EndpointBuilder.swift @@ -10,7 +10,7 @@ @resultBuilder public struct EndpointBuilder { - public static func buildBlock(_ components: EndpointDeclaration...) -> [Endpoint] { - return components.reduce(into: [Endpoint]()) { $0 += $1.endpoints } + public static func buildBlock(_ components: EndpointGroup...) -> [EndpointGroup] { + return components.reduce(into: [EndpointGroup]()) { $0.append($1) } } } diff --git a/Sources/Nectar/EndpointDeclaration.swift b/Sources/Nectar/EndpointDeclaration.swift index 0e5ff56..cafde47 100644 --- a/Sources/Nectar/EndpointDeclaration.swift +++ b/Sources/Nectar/EndpointDeclaration.swift @@ -8,8 +8,3 @@ // // ===----------------------------------------------------------------------=== -public protocol EndpointDeclaration { - var partialName: String { get } - - var endpoints: [Endpoint] { get } -} diff --git a/Sources/Nectar/EndpointGroup.swift b/Sources/Nectar/EndpointGroup.swift index 002d91b..f6c29f5 100644 --- a/Sources/Nectar/EndpointGroup.swift +++ b/Sources/Nectar/EndpointGroup.swift @@ -8,24 +8,57 @@ // // ===----------------------------------------------------------------------=== -public protocol EndpointGroup: EndpointDeclaration { +import Hummingbird +public protocol EndpointGroup { var _endpoints: [Endpoint] { get } + var partialName: String { get } @EndpointBuilder - var subgroups: [Endpoint] { get } + var subgroups: [any EndpointGroup] { get } + + func addMiddlewares(to group: RouterGroup) -> RouterGroup } extension EndpointGroup { - public var endpoints: [Endpoint] { - (_endpoints + subgroups).map { - Endpoint( - partialPath: partialName + "/" + $0.partialPath, - httpMethod: $0.httpMethod, - handler: $0.handler - ) - } - } - - public var subgroups: [Endpoint] { + public var subgroups: [any EndpointGroup] { [] } + + public func addMiddlewares(to group: RouterGroup) -> RouterGroup { + group + } + + public func build(into group: RouterGroup, currentURI: String) throws(EndpointAdditionError) { + let currentURI = currentURI + "/" + partialName + var group = group.group("\(partialName)") + + group = addMiddlewares(to: group) + + for subgroup in subgroups { + try subgroup.build(into: group, currentURI: currentURI) + } + + for endpoint in _endpoints { + if !endpoint.pathParams.isEmpty { + let currentURI = currentURI + "/" + endpoint.partialPath + let splitURI = currentURI.split(separator: "/") + + for param in endpoint.pathParams { + if !splitURI.contains(where: { $0 == ":" + param }) { + throw EndpointAdditionError.missingPathParameter(param, currentURI) + } + } + } + + switch endpoint.httpMethod { + case .get: + group.get(RouterPath(endpoint.partialPath), use: endpoint.handler) + case .patch: + group.patch(RouterPath(endpoint.partialPath), use: endpoint.handler) + case .post: + group.post(RouterPath(endpoint.partialPath), use: endpoint.handler) + case .delete: + group.delete(RouterPath(endpoint.partialPath), use: endpoint.handler) + } + } + } } diff --git a/Sources/Nectar/Macros.swift b/Sources/Nectar/Macros.swift index 0b62700..050d128 100644 --- a/Sources/Nectar/Macros.swift +++ b/Sources/Nectar/Macros.swift @@ -7,6 +7,7 @@ // Licensed under Apache License v2.0 // // ===----------------------------------------------------------------------=== +import Hummingbird @attached(body) public macro GET(pattern: String? = nil) = #externalMacro(module: "NectarMacros", type: "GetMacro") diff --git a/Sources/Nectar/NectarApp.swift b/Sources/Nectar/NectarApp.swift index db51167..271dada 100644 --- a/Sources/Nectar/NectarApp.swift +++ b/Sources/Nectar/NectarApp.swift @@ -10,5 +10,5 @@ public protocol NectarApp { @EndpointBuilder - var endpoints: [Endpoint] { get } + var endpoints: [any EndpointGroup] { get } } diff --git a/Sources/Nectar/PathExtension.swift b/Sources/Nectar/PathExtension.swift index 7d75f8f..575bc97 100644 --- a/Sources/Nectar/PathExtension.swift +++ b/Sources/Nectar/PathExtension.swift @@ -10,16 +10,11 @@ public struct PathExtension: EndpointGroup { public let partialName: String - public let _endpoints: [Endpoint] + public let _endpoints: [Endpoint] = [] + public var subgroups: [any EndpointGroup] - public init(_ partialPath: String, @EndpointBuilder endpoints: () -> [Endpoint]) { + public init(_ partialPath: String, @EndpointBuilder endpoints: () -> [EndpointGroup]) { self.partialName = partialPath - self._endpoints = endpoints().map { - Endpoint( - partialPath: "/" + $0.partialPath, - httpMethod: $0.httpMethod, - handler: $0.handler - ) - } + self.subgroups = endpoints() } } diff --git a/Sources/Nectar/RouterExtension.swift b/Sources/Nectar/RouterExtension.swift index c6b5e47..1adf9bd 100644 --- a/Sources/Nectar/RouterExtension.swift +++ b/Sources/Nectar/RouterExtension.swift @@ -11,18 +11,9 @@ import Hummingbird extension Router { - public func registerNectarApp(_ app: NectarApp) { + public func registerNectarApp(_ app: NectarApp) throws(EndpointAdditionError) { for endpoint in app.endpoints { - switch endpoint.httpMethod { - case .get: - self.get(RouterPath(endpoint.partialPath), use: endpoint.handler) - case .patch: - self.patch(RouterPath(endpoint.partialPath), use: endpoint.handler) - case .post: - self.post(RouterPath(endpoint.partialPath), use: endpoint.handler) - case .delete: - self.delete(RouterPath(endpoint.partialPath), use: endpoint.handler) - } + try endpoint.build(into: self.group(), currentURI: "") } } } diff --git a/Sources/NectarMacros/Helpers.swift b/Sources/NectarMacros/Helpers.swift index d71de74..8591a18 100644 --- a/Sources/NectarMacros/Helpers.swift +++ b/Sources/NectarMacros/Helpers.swift @@ -28,22 +28,27 @@ extension StructDeclSyntax { method.reportIllegalParameters(for: arguments, using: context) method.warnUnusedParameters(for: arguments, using: context) - + let pathParameters = arguments.filter { (name, type) in name != "body" && type.typeAllowsPathParameter - }.map { ":\($0.0)" } + } + + let pathParametersString = pathParameters.map { ":\($0.0)" } let partialPath = method.pattern - ?? pathParameters.joined(separator: "/") + ?? pathParametersString.joined(separator: "/") let name = function.name.text + + let pathParametersArray = pathParameters.map { "\"\($0.0)\"" }.joined(separator: ", ") endpoints.append( """ Endpoint( partialPath: \(literal: partialPath), httpMethod: .\(raw: method.method), - handler: { request, context in + pathParams: [\(raw: pathParametersArray)], + handler: { request, context in try await _\(raw: name)(request, context) } ) diff --git a/Tests/NectarTests/MacroTests.swift b/Tests/NectarTests/MacroTests.swift index dcf5b7c..decc2da 100644 --- a/Tests/NectarTests/MacroTests.swift +++ b/Tests/NectarTests/MacroTests.swift @@ -67,6 +67,7 @@ struct MacroTests { Endpoint( partialPath: "", httpMethod: .get, + pathParams: [], handler: { request, context in try await _get(request, context) } @@ -74,6 +75,7 @@ struct MacroTests { Endpoint( partialPath: "", httpMethod: .post, + pathParams: [], handler: { request, context in try await _post(request, context) } @@ -81,6 +83,7 @@ struct MacroTests { Endpoint( partialPath: "", httpMethod: .patch, + pathParams: [], handler: { request, context in try await _patch(request, context) } @@ -88,6 +91,7 @@ struct MacroTests { Endpoint( partialPath: "", httpMethod: .delete, + pathParams: [], handler: { request, context in try await _delete(request, context) } @@ -184,6 +188,7 @@ struct MacroTests { Endpoint( partialPath: "", httpMethod: .get, + pathParams: [], handler: { request, context in try await _get(request, context) } @@ -191,6 +196,7 @@ struct MacroTests { Endpoint( partialPath: "", httpMethod: .post, + pathParams: [], handler: { request, context in try await _post(request, context) } @@ -198,6 +204,7 @@ struct MacroTests { Endpoint( partialPath: "", httpMethod: .patch, + pathParams: [], handler: { request, context in try await _patch(request, context) } @@ -205,6 +212,7 @@ struct MacroTests { Endpoint( partialPath: "", httpMethod: .delete, + pathParams: [], handler: { request, context in try await _delete(request, context) } @@ -333,6 +341,7 @@ struct MacroTests { Endpoint( partialPath: ":number", httpMethod: .get, + pathParams: ["number"], handler: { request, context in try await _get(request, context) } @@ -340,6 +349,7 @@ struct MacroTests { Endpoint( partialPath: "", httpMethod: .post, + pathParams: [], handler: { request, context in try await _post(request, context) } @@ -347,6 +357,7 @@ struct MacroTests { Endpoint( partialPath: "", httpMethod: .patch, + pathParams: [], handler: { request, context in try await _patch(request, context) } @@ -354,6 +365,7 @@ struct MacroTests { Endpoint( partialPath: "", httpMethod: .delete, + pathParams: [], handler: { request, context in try await _delete(request, context) } @@ -361,6 +373,7 @@ struct MacroTests { Endpoint( partialPath: "", httpMethod: .delete, + pathParams: [], handler: { request, context in try await _deleteReq(request, context) } @@ -368,6 +381,7 @@ struct MacroTests { Endpoint( partialPath: "", httpMethod: .delete, + pathParams: [], handler: { request, context in try await _deleteContext(request, context) } @@ -375,6 +389,7 @@ struct MacroTests { Endpoint( partialPath: ":number", httpMethod: .delete, + pathParams: ["number"], handler: { request, context in try await _deleteEverything(request, context) } @@ -559,6 +574,7 @@ struct MacroTests { Endpoint( partialPath: ":number/activity", httpMethod: .get, + pathParams: ["number"], handler: { request, context in try await _get(request, context) } @@ -566,6 +582,7 @@ struct MacroTests { Endpoint( partialPath: "integer1", httpMethod: .post, + pathParams: [], handler: { request, context in try await _post(request, context) } @@ -573,6 +590,7 @@ struct MacroTests { Endpoint( partialPath: "integer2", httpMethod: .patch, + pathParams: [], handler: { request, context in try await _patch(request, context) } @@ -580,6 +598,7 @@ struct MacroTests { Endpoint( partialPath: "body", httpMethod: .delete, + pathParams: [], handler: { request, context in try await _delete(request, context) } @@ -587,6 +606,7 @@ struct MacroTests { Endpoint( partialPath: "request", httpMethod: .delete, + pathParams: [], handler: { request, context in try await _deleteReq(request, context) } @@ -594,6 +614,7 @@ struct MacroTests { Endpoint( partialPath: "context", httpMethod: .delete, + pathParams: [], handler: { request, context in try await _deleteContext(request, context) } @@ -601,6 +622,7 @@ struct MacroTests { Endpoint( partialPath: ":number/activity", httpMethod: .delete, + pathParams: ["number"], handler: { request, context in try await _deleteEverything(request, context) } @@ -749,6 +771,7 @@ struct MacroTests { Endpoint( partialPath: ":number/activity", httpMethod: .get, + pathParams: [], handler: { request, context in try await _get(request, context) } diff --git a/Tests/NectarTests/RouteTests.swift b/Tests/NectarTests/RouteTests.swift index 937f7fb..3486a02 100644 --- a/Tests/NectarTests/RouteTests.swift +++ b/Tests/NectarTests/RouteTests.swift @@ -24,9 +24,9 @@ private let reader = ConfigReader(providers: [ ]) @Suite struct RouteGenerationTests { - @Test func `all routes registered`() { + @Test func `all routes registered`() async throws { let router = Router() - router.registerNectarApp(App()) + try router.registerNectarApp(App()) let routes = router.routes.map(\.description).sorted() @@ -36,6 +36,7 @@ private let reader = ConfigReader(providers: [ "GET /users/{id}/activity", "GET /users/{id}/comments", "GET /products", + "GET /secret", "PATCH /products/{id}/price" ].sorted() @@ -43,7 +44,7 @@ private let reader = ConfigReader(providers: [ } @Test func testAllRoutes() async throws { - let app = buildApplication() + let app = try buildApplication(app: App()) try await app.test(.router) { client in let uuid = UUID() @@ -131,11 +132,48 @@ private let reader = ConfigReader(providers: [ #expect(response.status == .ok) #expect(body == "PATCH price for product \(uuid.uuidString): \(updatePrice.newPrice)") } + + try await app.test(.router) { client in + let response = try await client.execute( + uri: "/secret", + method: .get + ) + + #expect(response.status == .unauthorized) + + var headers = HTTPFields() + headers[.authorization] = "Bearer 123" + + let authorized = try await client.execute( + uri: "/secret", + method: .get, + headers: headers + ) + + #expect(authorized.status == .ok) + let body = String(buffer: authorized.body) + #expect(body == "Jokes on you, there is no secret") + } + } + + @Test func `missing path parameter throws on registration`() { + do { + _ = try buildApplication(app: MissingApp()) + } catch let error as EndpointAdditionError { + if case .missingPathParameter(let param, let uri) = error { + #expect(param == "required") + #expect(uri == "/missing/:required") + return + } + Issue.record(error) + } catch { + Issue.record(error) + } } - func buildApplication() -> some ApplicationProtocol { + func buildApplication(app: NectarApp) throws -> some ApplicationProtocol { let router = Router() - router.registerNectarApp(App()) + try router.registerNectarApp(app) let app = Application( router: router, configuration: ApplicationConfiguration(reader: reader.scoped(to: "http")) @@ -146,9 +184,16 @@ private let reader = ConfigReader(providers: [ } fileprivate struct App: NectarApp { - var endpoints: [Endpoint] { + var endpoints: [EndpointGroup] { UserEndpointGroup() ProductEndpointGroup() + MiddlewareTest() + } +} + +fileprivate struct MissingApp: NectarApp { + var endpoints: [EndpointGroup] { + MissingPathParam() } } @@ -156,7 +201,7 @@ fileprivate struct App: NectarApp { fileprivate struct UserEndpointGroup: EndpointGroup { let partialName = "users" - var subgroups: [Endpoint] { + var subgroups: [any EndpointGroup] { // Tests nested path composition: /users/:id/comments PathExtension(":id") { UserCommentEndpointGroup() @@ -208,6 +253,32 @@ fileprivate struct ProductEndpointGroup: EndpointGroup { } } +@Endpoints +fileprivate struct MiddlewareTest: EndpointGroup { + let partialName: String = "" + + @GET(pattern: "/secret") + func secret() -> String { + "Jokes on you, there is no secret" + } + + func addMiddlewares(to group: RouterGroup) -> RouterGroup { + group.addMiddleware { + Middleware() + } + } +} + +@Endpoints +fileprivate struct MissingPathParam: EndpointGroup { + let partialName: String = "missing" + + @GET + func get(required: Bool) -> String { + "" + } +} + fileprivate struct PriceUpdateDTO: Codable { var newPrice: Int } @@ -215,3 +286,20 @@ fileprivate struct UserCreationDTO: Codable { var name: String var age: Int } + +struct Middleware: RouterMiddleware { + func handle( + _ input: HummingbirdCore.Request, + context: C, + next: @concurrent (HummingbirdCore.Request, C) async throws -> HummingbirdCore.Response + ) async throws -> HummingbirdCore.Response { + if input.headers[.authorization] != "Bearer 123" { + return Response(status: .unauthorized) + } + return try await next(input, context) + } + + typealias Input = Request + typealias Output = Response + typealias Context = C +} From 87e6acadaa90d7045623f7984d936af1a7655703 Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Fri, 16 Oct 2026 16:10:12 +0200 Subject: [PATCH 2/5] added non-void check --- Sources/NectarMacros/Helpers.swift | 24 ++++++++++++ Tests/NectarTests/MacroTests.swift | 59 ++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/Sources/NectarMacros/Helpers.swift b/Sources/NectarMacros/Helpers.swift index 8591a18..87b5c87 100644 --- a/Sources/NectarMacros/Helpers.swift +++ b/Sources/NectarMacros/Helpers.swift @@ -23,6 +23,30 @@ extension StructDeclSyntax { for function in functions { guard let method = function.httpMethod else { continue } + guard + let returnClause = function.signature.returnClause + else { + context.diagnose(Diagnostic( + node: function, + message: Message( + message: "Function needs a return value.", + id: "function-needs-return-value", + severity: .error + ) + )) + continue + } + guard returnClause.type.trimmedDescription != "Void" else { + context.diagnose(Diagnostic( + node: returnClause, + message: Message( + message: "Function needs a return a non-void value.", + id: "function-needs-non-void-return-value", + severity: .error + ) + )) + continue + } let arguments = function.arguments diff --git a/Tests/NectarTests/MacroTests.swift b/Tests/NectarTests/MacroTests.swift index decc2da..5eb34ea 100644 --- a/Tests/NectarTests/MacroTests.swift +++ b/Tests/NectarTests/MacroTests.swift @@ -857,4 +857,63 @@ struct MacroTests { """ } } + + @Test func `reports no return value`() async throws { + assertMacro { + """ + @Endpoints + struct BasicEndpoints: EndpointGroup { + let partialName = "basic" + + var subgroups: [Endpoint] { } + + @GET + func get() { "" } + } + """ + } diagnostics: { + """ + @Endpoints + struct BasicEndpoints: EndpointGroup { + let partialName = "basic" + + var subgroups: [Endpoint] { } + + @GET + ╰─ 🛑 Function needs a return value. + func get() { "" } + } + """ + } + } + + @Test func `reports Void return value`() async throws { + assertMacro { + """ + @Endpoints + struct BasicEndpoints: EndpointGroup { + let partialName = "basic" + + var subgroups: [Endpoint] { } + + @GET + func get() -> Void { "" } + } + """ + } diagnostics: { + """ + @Endpoints + struct BasicEndpoints: EndpointGroup { + let partialName = "basic" + + var subgroups: [Endpoint] { } + + @GET + func get() -> Void { "" } + ┬────── + ╰─ 🛑 Function needs a return a non-void value. + } + """ + } + } } From 6df02dddeb9c30c3ad902e8c36690b2d18076f74 Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Fri, 16 Oct 2026 16:49:33 +0200 Subject: [PATCH 3/5] more stuff --- Scripts/format.sh | 2 +- Sources/Nectar/EndpointGroup.swift | 4 +- Sources/NectarMacros/Helpers.swift | 5 ++- Tests/NectarTests/MacroTests.swift | 62 +++++++++++++++++++++++++++++- Tests/NectarTests/RouteTests.swift | 5 ++- 5 files changed, 70 insertions(+), 8 deletions(-) diff --git a/Scripts/format.sh b/Scripts/format.sh index c1d2dba..3416c7d 100755 --- a/Scripts/format.sh +++ b/Scripts/format.sh @@ -1,7 +1,7 @@ cd "$(dirname "$0")"/../ || exit 1 if [ -z "$1" ]; then - swiftformat . --exclude + swiftformat . else swiftformat "$1" fi diff --git a/Sources/Nectar/EndpointGroup.swift b/Sources/Nectar/EndpointGroup.swift index f6c29f5..3ad3be0 100644 --- a/Sources/Nectar/EndpointGroup.swift +++ b/Sources/Nectar/EndpointGroup.swift @@ -28,7 +28,7 @@ extension EndpointGroup { } public func build(into group: RouterGroup, currentURI: String) throws(EndpointAdditionError) { - let currentURI = currentURI + "/" + partialName + let currentURI = currentURI + (partialName.isEmpty ? "": "/" + partialName) var group = group.group("\(partialName)") group = addMiddlewares(to: group) @@ -39,7 +39,7 @@ extension EndpointGroup { for endpoint in _endpoints { if !endpoint.pathParams.isEmpty { - let currentURI = currentURI + "/" + endpoint.partialPath + let currentURI = currentURI + (endpoint.partialPath.isEmpty ? "": "/" + endpoint.partialPath) let splitURI = currentURI.split(separator: "/") for param in endpoint.pathParams { diff --git a/Sources/NectarMacros/Helpers.swift b/Sources/NectarMacros/Helpers.swift index 87b5c87..4299c0d 100644 --- a/Sources/NectarMacros/Helpers.swift +++ b/Sources/NectarMacros/Helpers.swift @@ -36,11 +36,12 @@ extension StructDeclSyntax { )) continue } - guard returnClause.type.trimmedDescription != "Void" else { + let returnType = returnClause.type.trimmedDescription + guard returnType != "Void", returnType != "()", returnType != "Swift.Void" else { context.diagnose(Diagnostic( node: returnClause, message: Message( - message: "Function needs a return a non-void value.", + message: "Function needs to return a non-void value.", id: "function-needs-non-void-return-value", severity: .error ) diff --git a/Tests/NectarTests/MacroTests.swift b/Tests/NectarTests/MacroTests.swift index 5eb34ea..498b9c7 100644 --- a/Tests/NectarTests/MacroTests.swift +++ b/Tests/NectarTests/MacroTests.swift @@ -911,7 +911,67 @@ struct MacroTests { @GET func get() -> Void { "" } ┬────── - ╰─ 🛑 Function needs a return a non-void value. + ╰─ 🛑 Function needs to return a non-void value. + } + """ + } + } + + @Test func `reports Swift.Void return value`() async throws { + assertMacro { + """ + @Endpoints + struct BasicEndpoints: EndpointGroup { + let partialName = "basic" + + var subgroups: [Endpoint] { } + + @GET + func get() -> Void { "" } + } + """ + } diagnostics: { + """ + @Endpoints + struct BasicEndpoints: EndpointGroup { + let partialName = "basic" + + var subgroups: [Endpoint] { } + + @GET + func get() -> Void { "" } + ┬────── + ╰─ 🛑 Function needs to return a non-void value. + } + """ + } + } + + @Test func `reports () return value`() async throws { + assertMacro { + """ + @Endpoints + struct BasicEndpoints: EndpointGroup { + let partialName = "basic" + + var subgroups: [Endpoint] { } + + @GET + func get() -> Void { "" } + } + """ + } diagnostics: { + """ + @Endpoints + struct BasicEndpoints: EndpointGroup { + let partialName = "basic" + + var subgroups: [Endpoint] { } + + @GET + func get() -> Void { "" } + ┬────── + ╰─ 🛑 Function needs to return a non-void value. } """ } diff --git a/Tests/NectarTests/RouteTests.swift b/Tests/NectarTests/RouteTests.swift index 3486a02..72f108a 100644 --- a/Tests/NectarTests/RouteTests.swift +++ b/Tests/NectarTests/RouteTests.swift @@ -159,10 +159,11 @@ private let reader = ConfigReader(providers: [ @Test func `missing path parameter throws on registration`() { do { _ = try buildApplication(app: MissingApp()) + Issue.record("Expected EndpointAdditionError.missingPathParameter") } catch let error as EndpointAdditionError { if case .missingPathParameter(let param, let uri) = error { #expect(param == "required") - #expect(uri == "/missing/:required") + #expect(uri == "/missing") return } Issue.record(error) @@ -273,7 +274,7 @@ fileprivate struct MiddlewareTest: EndpointGroup { fileprivate struct MissingPathParam: EndpointGroup { let partialName: String = "missing" - @GET + @GET(pattern: "") func get(required: Bool) -> String { "" } From 5cccd2f31379677d3f27a107a9f9974ae4576daa Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Fri, 16 Oct 2026 16:52:26 +0200 Subject: [PATCH 4/5] readme --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d7371a6..f726ee6 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,16 @@ struct CommentEndpointGroup: EndpointGroup { func getComments(id: UUID) -> String { "id: \(id.uuidString)" } + + func addMiddlewares(to group: RouterGroup) -> RouterGroup { + group.addMiddleware { + LogRequestMiddleware(.info) + } + } } // register: -router.registerNectarApp(AppEntry()) +try router.registerNectarApp(AppEntry()) ``` ## Goal @@ -69,5 +75,6 @@ Nectar combines **Swift Macros** and **Result Builders** to extract parameters a * **Optional parameters** are automatically resolved as **query parameters**. * Arguments typed as `Request` or `any RequestContext` will seamlessly receive the raw request or request context. * **Path Customization:** Override or explicitly specify structural sub-paths using the `pattern` argument inside the route macros (e.g., `@GET(pattern: ":id/activity")`). +* On startup every endpoint is checked for using path parameters thar aren't declared. From ddae4c7eb4218f079215eb41cf474daf3654249d Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Fri, 16 Oct 2026 16:57:35 +0200 Subject: [PATCH 5/5] formatted --- Sources/Nectar/EndpointAdditionError.swift | 10 +++++++++ Sources/Nectar/EndpointDeclaration.swift | 1 - Sources/Nectar/EndpointGroup.swift | 24 +++++++++++++--------- Sources/Nectar/Macros.swift | 1 + Sources/NectarMacros/Helpers.swift | 6 +++--- Tests/NectarTests/MacroTests.swift | 24 +++++++++++----------- Tests/NectarTests/RouteTests.swift | 20 +++++++++--------- 7 files changed, 50 insertions(+), 36 deletions(-) diff --git a/Sources/Nectar/EndpointAdditionError.swift b/Sources/Nectar/EndpointAdditionError.swift index 0622366..beb8292 100644 --- a/Sources/Nectar/EndpointAdditionError.swift +++ b/Sources/Nectar/EndpointAdditionError.swift @@ -1,3 +1,13 @@ +// ===----------------------------------------------------------------------=== +// +// This source file is part of the Nectar open source project +// +// Copyright (c) 2026 Mia Koring. +// +// Licensed under Apache License v2.0 +// +// ===----------------------------------------------------------------------=== + import Foundation public enum EndpointAdditionError: Error { diff --git a/Sources/Nectar/EndpointDeclaration.swift b/Sources/Nectar/EndpointDeclaration.swift index cafde47..e10ec3a 100644 --- a/Sources/Nectar/EndpointDeclaration.swift +++ b/Sources/Nectar/EndpointDeclaration.swift @@ -7,4 +7,3 @@ // Licensed under Apache License v2.0 // // ===----------------------------------------------------------------------=== - diff --git a/Sources/Nectar/EndpointGroup.swift b/Sources/Nectar/EndpointGroup.swift index 3ad3be0..ba97be3 100644 --- a/Sources/Nectar/EndpointGroup.swift +++ b/Sources/Nectar/EndpointGroup.swift @@ -14,7 +14,7 @@ public protocol EndpointGroup { var partialName: String { get } @EndpointBuilder var subgroups: [any EndpointGroup] { get } - + func addMiddlewares(to group: RouterGroup) -> RouterGroup } @@ -22,33 +22,37 @@ extension EndpointGroup { public var subgroups: [any EndpointGroup] { [] } - + public func addMiddlewares(to group: RouterGroup) -> RouterGroup { group } - - public func build(into group: RouterGroup, currentURI: String) throws(EndpointAdditionError) { + + public func build( + into group: RouterGroup, + currentURI: String + ) throws(EndpointAdditionError) { let currentURI = currentURI + (partialName.isEmpty ? "": "/" + partialName) var group = group.group("\(partialName)") - + group = addMiddlewares(to: group) - + for subgroup in subgroups { try subgroup.build(into: group, currentURI: currentURI) } - + for endpoint in _endpoints { if !endpoint.pathParams.isEmpty { - let currentURI = currentURI + (endpoint.partialPath.isEmpty ? "": "/" + endpoint.partialPath) + let currentURI = currentURI + + (endpoint.partialPath.isEmpty ? "": "/" + endpoint.partialPath) let splitURI = currentURI.split(separator: "/") - + for param in endpoint.pathParams { if !splitURI.contains(where: { $0 == ":" + param }) { throw EndpointAdditionError.missingPathParameter(param, currentURI) } } } - + switch endpoint.httpMethod { case .get: group.get(RouterPath(endpoint.partialPath), use: endpoint.handler) diff --git a/Sources/Nectar/Macros.swift b/Sources/Nectar/Macros.swift index 050d128..bffba8c 100644 --- a/Sources/Nectar/Macros.swift +++ b/Sources/Nectar/Macros.swift @@ -7,6 +7,7 @@ // Licensed under Apache License v2.0 // // ===----------------------------------------------------------------------=== + import Hummingbird @attached(body) diff --git a/Sources/NectarMacros/Helpers.swift b/Sources/NectarMacros/Helpers.swift index 4299c0d..7dc4a53 100644 --- a/Sources/NectarMacros/Helpers.swift +++ b/Sources/NectarMacros/Helpers.swift @@ -53,18 +53,18 @@ extension StructDeclSyntax { method.reportIllegalParameters(for: arguments, using: context) method.warnUnusedParameters(for: arguments, using: context) - + let pathParameters = arguments.filter { (name, type) in name != "body" && type.typeAllowsPathParameter } - + let pathParametersString = pathParameters.map { ":\($0.0)" } let partialPath = method.pattern ?? pathParametersString.joined(separator: "/") let name = function.name.text - + let pathParametersArray = pathParameters.map { "\"\($0.0)\"" }.joined(separator: ", ") endpoints.append( diff --git a/Tests/NectarTests/MacroTests.swift b/Tests/NectarTests/MacroTests.swift index 498b9c7..e8987e9 100644 --- a/Tests/NectarTests/MacroTests.swift +++ b/Tests/NectarTests/MacroTests.swift @@ -857,16 +857,16 @@ struct MacroTests { """ } } - + @Test func `reports no return value`() async throws { assertMacro { """ @Endpoints struct BasicEndpoints: EndpointGroup { let partialName = "basic" - + var subgroups: [Endpoint] { } - + @GET func get() { "" } } @@ -886,16 +886,16 @@ struct MacroTests { """ } } - + @Test func `reports Void return value`() async throws { assertMacro { """ @Endpoints struct BasicEndpoints: EndpointGroup { let partialName = "basic" - + var subgroups: [Endpoint] { } - + @GET func get() -> Void { "" } } @@ -916,16 +916,16 @@ struct MacroTests { """ } } - + @Test func `reports Swift.Void return value`() async throws { assertMacro { """ @Endpoints struct BasicEndpoints: EndpointGroup { let partialName = "basic" - + var subgroups: [Endpoint] { } - + @GET func get() -> Void { "" } } @@ -946,16 +946,16 @@ struct MacroTests { """ } } - + @Test func `reports () return value`() async throws { assertMacro { """ @Endpoints struct BasicEndpoints: EndpointGroup { let partialName = "basic" - + var subgroups: [Endpoint] { } - + @GET func get() -> Void { "" } } diff --git a/Tests/NectarTests/RouteTests.swift b/Tests/NectarTests/RouteTests.swift index 72f108a..b6dab9f 100644 --- a/Tests/NectarTests/RouteTests.swift +++ b/Tests/NectarTests/RouteTests.swift @@ -132,30 +132,30 @@ private let reader = ConfigReader(providers: [ #expect(response.status == .ok) #expect(body == "PATCH price for product \(uuid.uuidString): \(updatePrice.newPrice)") } - + try await app.test(.router) { client in let response = try await client.execute( uri: "/secret", method: .get ) - + #expect(response.status == .unauthorized) - + var headers = HTTPFields() headers[.authorization] = "Bearer 123" - + let authorized = try await client.execute( uri: "/secret", method: .get, headers: headers ) - + #expect(authorized.status == .ok) let body = String(buffer: authorized.body) #expect(body == "Jokes on you, there is no secret") } } - + @Test func `missing path parameter throws on registration`() { do { _ = try buildApplication(app: MissingApp()) @@ -257,12 +257,12 @@ fileprivate struct ProductEndpointGroup: EndpointGroup { @Endpoints fileprivate struct MiddlewareTest: EndpointGroup { let partialName: String = "" - + @GET(pattern: "/secret") func secret() -> String { "Jokes on you, there is no secret" } - + func addMiddlewares(to group: RouterGroup) -> RouterGroup { group.addMiddleware { Middleware() @@ -273,7 +273,7 @@ fileprivate struct MiddlewareTest: EndpointGroup { @Endpoints fileprivate struct MissingPathParam: EndpointGroup { let partialName: String = "missing" - + @GET(pattern: "") func get(required: Bool) -> String { "" @@ -299,7 +299,7 @@ struct Middleware: RouterMiddleware { } return try await next(input, context) } - + typealias Input = Request typealias Output = Response typealias Context = C