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/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. diff --git a/Scripts/format.sh b/Scripts/format.sh index dff0bce..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 Sources/ULID/*,Tests/ULIDTests/*,Sources/VeinCore/VeinCore.docc/*,Sources/VeinSwiftUI/VeinSwiftUI.docc/*,Sources/VeinSCUI/VeinSCUI.docc/* + swiftformat . 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..beb8292 --- /dev/null +++ b/Sources/Nectar/EndpointAdditionError.swift @@ -0,0 +1,24 @@ +// ===----------------------------------------------------------------------=== +// +// 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 { + 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..e10ec3a 100644 --- a/Sources/Nectar/EndpointDeclaration.swift +++ b/Sources/Nectar/EndpointDeclaration.swift @@ -7,9 +7,3 @@ // Licensed under Apache License v2.0 // // ===----------------------------------------------------------------------=== - -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..ba97be3 100644 --- a/Sources/Nectar/EndpointGroup.swift +++ b/Sources/Nectar/EndpointGroup.swift @@ -8,24 +8,61 @@ // // ===----------------------------------------------------------------------=== -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: [any EndpointGroup] { + [] } - public var subgroups: [Endpoint] { - [] + public func addMiddlewares(to group: RouterGroup) -> RouterGroup { + group + } + + 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 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..bffba8c 100644 --- a/Sources/Nectar/Macros.swift +++ b/Sources/Nectar/Macros.swift @@ -8,6 +8,8 @@ // // ===----------------------------------------------------------------------=== +import Hummingbird + @attached(body) public macro GET(pattern: String? = nil) = #externalMacro(module: "NectarMacros", type: "GetMacro") @attached(body) 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..7dc4a53 100644 --- a/Sources/NectarMacros/Helpers.swift +++ b/Sources/NectarMacros/Helpers.swift @@ -23,6 +23,31 @@ 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 + } + let returnType = returnClause.type.trimmedDescription + guard returnType != "Void", returnType != "()", returnType != "Swift.Void" else { + context.diagnose(Diagnostic( + node: returnClause, + message: Message( + message: "Function needs to return a non-void value.", + id: "function-needs-non-void-return-value", + severity: .error + ) + )) + continue + } let arguments = function.arguments @@ -31,19 +56,24 @@ extension StructDeclSyntax { 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..e8987e9 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) } @@ -834,4 +857,123 @@ 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 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 937f7fb..b6dab9f 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,49 @@ 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()) + Issue.record("Expected EndpointAdditionError.missingPathParameter") + } catch let error as EndpointAdditionError { + if case .missingPathParameter(let param, let uri) = error { + #expect(param == "required") + #expect(uri == "/missing") + 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 +185,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 +202,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 +254,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(pattern: "") + func get(required: Bool) -> String { + "" + } +} + fileprivate struct PriceUpdateDTO: Codable { var newPrice: Int } @@ -215,3 +287,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 +}