From 2278af4e7f71110a1adb28c32dfb6dcdbbdc4757 Mon Sep 17 00:00:00 2001 From: Simon Whitty Date: Sat, 28 Oct 2023 14:33:26 +1100 Subject: [PATCH 1/4] HTTPRoute Macro --- .../Sources/Handlers/HTTPHandlerMacro.swift | 41 ++++ Macro/Sources/CustomError.swift | 44 +++++ Macro/Sources/FunctionDecl.swift | 175 ++++++++++++++++++ Macro/Sources/HTTPHandlerMacro.swift | 156 ++++++++++++++++ Macro/Sources/HTTPRouteMacro.swift | 110 +++++++++++ Macro/Sources/Plugin.swift | 10 + Package.resolved | 14 ++ Package@swift-5.9.swift | 65 +++++++ 8 files changed, 615 insertions(+) create mode 100644 FlyingFox/Sources/Handlers/HTTPHandlerMacro.swift create mode 100644 Macro/Sources/CustomError.swift create mode 100644 Macro/Sources/FunctionDecl.swift create mode 100644 Macro/Sources/HTTPHandlerMacro.swift create mode 100644 Macro/Sources/HTTPRouteMacro.swift create mode 100644 Macro/Sources/Plugin.swift create mode 100644 Package.resolved create mode 100644 Package@swift-5.9.swift diff --git a/FlyingFox/Sources/Handlers/HTTPHandlerMacro.swift b/FlyingFox/Sources/Handlers/HTTPHandlerMacro.swift new file mode 100644 index 00000000..c47ee359 --- /dev/null +++ b/FlyingFox/Sources/Handlers/HTTPHandlerMacro.swift @@ -0,0 +1,41 @@ +// +// HTTPHandlerMacro.swift +// FlyingFox +// +// Created by Simon Whitty on 26/10/2023. +// Copyright © 2023 Simon Whitty. All rights reserved. +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/swhitty/FlyingFox +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +#if compiler(>=5.9) + +@attached(peer) +public macro HTTPRoute(_ name: StringLiteralType, statusCode: HTTPStatusCode = .ok) = #externalMacro(module: "Macro", type: "HTTPRouteMacro") + +@attached(member, names: named(performAction), named(Action), named(handleRequest)) +@attached(extension, conformances: HTTPHandler, Sendable) +public macro HTTPHandler() = #externalMacro(module: "Macro", type: "HTTPHandlerMacro") + +#endif diff --git a/Macro/Sources/CustomError.swift b/Macro/Sources/CustomError.swift new file mode 100644 index 00000000..4e723aa2 --- /dev/null +++ b/Macro/Sources/CustomError.swift @@ -0,0 +1,44 @@ +// +// CustomError.swift +// FlyingFox +// +// Created by Simon Whitty on 26/10/2023. +// Copyright © 2023 Simon Whitty. All rights reserved. +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/swhitty/FlyingFox +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftDiagnostics +import SwiftSyntax + +enum CustomError: Error, CustomStringConvertible { + case message(String) + + var description: String { + switch self { + case .message(let text): + return text + } + } +} diff --git a/Macro/Sources/FunctionDecl.swift b/Macro/Sources/FunctionDecl.swift new file mode 100644 index 00000000..b08f983f --- /dev/null +++ b/Macro/Sources/FunctionDecl.swift @@ -0,0 +1,175 @@ +// +// FunctionDecl.swift +// FlyingFox +// +// Created by Simon Whitty on 26/10/2023. +// Copyright © 2023 Simon Whitty. All rights reserved. +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/swhitty/FlyingFox +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftSyntax + +struct FunctionDecl { + var name: String + var parameters: [Parameter] + var effects: Effects + var attributes: [Attribute] + var returnType: ReturnType + + struct Parameter { + var label: String? + var name: String + var type: String + } + + struct Attribute { + var name: String + var labelExpressions: [LabelExpression] + + struct LabelExpression { + var name: String? + var expression: String + } + + func expression(name: String) -> LabelExpression? { + labelExpressions.first { $0.name == name } + } + } + + enum ReturnType { + case void + case type(String) + } + + struct Effects: OptionSet { + var rawValue: Int = 0 + + static let async = Effects(rawValue: 1 << 1) + static let `throws` = Effects(rawValue: 1 << 2) + } + + func attribute(name: String) -> Attribute? { + attributes.first { $0.name == name } + } +} + + +extension FunctionDecl { + + static func make(from syntax: FunctionDeclSyntax) -> Self { + var decl = FunctionDecl( + name: syntax.name.text, + parameters: [], + effects: [], + attributes: [], + returnType: .init(syntax.signature.returnClause?.type.as(IdentifierTypeSyntax.self)?.name.text) + ) + + decl.attributes = syntax.attributes + .compactMap { + $0.as(AttributeSyntax.self) + } + .compactMap(Attribute.make) + + decl.parameters = syntax.signature + .parameterClause + .parameters + .compactMap { param in + guard let typeID = param.type.as(IdentifierTypeSyntax.self) else { return nil } + return FunctionDecl.Parameter( + label: param.firstName.text == "_" ? nil : param.firstName.text, + name: param.secondName?.text ?? param.firstName.text, + type: typeID.name.text + ) + } + + if syntax.signature.effectSpecifiers?.asyncSpecifier != nil { + decl.effects.insert(.async) + } + + if syntax.signature.effectSpecifiers?.throwsSpecifier != nil { + decl.effects.insert(.throws) + } + + return decl + } +} + +extension FunctionDecl.Attribute { + + static func make(from syntax: AttributeSyntax) -> Self? { + guard let name = syntax.attributeName.as(IdentifierTypeSyntax.self)?.name.text else { + return nil + } + + var decl = FunctionDecl.Attribute ( + name: name, + labelExpressions: [] + ) + + guard let labelSyntax = syntax.arguments?.as(LabeledExprListSyntax.self) else { + return decl + } + + decl.labelExpressions = labelSyntax + .map(LabelExpression.make) + + return decl + } +} + +extension FunctionDecl.Attribute.LabelExpression { + + static func make(from syntax: LabeledExprSyntax) -> Self { + let name = syntax.label?.text + let expression = try? syntax.expression + .as(StringLiteralExprSyntax.self)? + .singleStringSegment() + + return Self( + name: name == "_" ? nil : name, + expression: expression ?? String(describing: syntax.expression) + ) + } +} + +extension FunctionDecl.ReturnType: Equatable { + + init(_ value: String?) { + switch value { + case .none, "()", "Void": + self = .void + case .some(let string): + self = .type(string) + } + } +} + +extension FunctionDecl.ReturnType: ExpressibleByStringLiteral { + + public init(stringLiteral value: String) { + self = .init(value) + } +} diff --git a/Macro/Sources/HTTPHandlerMacro.swift b/Macro/Sources/HTTPHandlerMacro.swift new file mode 100644 index 00000000..90d3df64 --- /dev/null +++ b/Macro/Sources/HTTPHandlerMacro.swift @@ -0,0 +1,156 @@ +// +// HTTPHandlerMacro.swift +// FlyingFox +// +// Created by Simon Whitty on 26/10/2023. +// Copyright © 2023 Simon Whitty. All rights reserved. +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/swhitty/FlyingFox +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +public enum HTTPHandlerMacro: MemberMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + let memberList = declaration.memberBlock.members + + let routes = memberList.compactMap { member -> RouteDecl? in + guard let funcSyntax = member.decl.as(FunctionDeclSyntax.self) else { + return nil + } + + let funcDecl = FunctionDecl.make(from: funcSyntax) + guard let routeAtt = funcDecl.attribute(name: "HTTPRoute") else { + return nil + } + + guard let firstLabel = routeAtt.labelExpressions.first else { + return nil + } + + return RouteDecl( + route: firstLabel.expression, + statusCode: routeAtt.expression(name: "statusCode")?.expression ?? ".ok", + funcDecl: funcDecl + ) + } + + var validRoutes = Set() + for route in routes { + guard !validRoutes.contains(route.route) else { + throw CustomError.message( + "@HTTPRoute(\"\(route.route)\") is ambiguous" + ) + } + validRoutes.insert(route.route) + } + + let routeDecl: DeclSyntax = """ + func handleRequest(_ request: HTTPRequest) async throws -> HTTPResponse { + \(raw: routes.map(\.routeSyntax).joined(separator: "\n")) + throw HTTPUnhandledError() + } + """ + + // + + return [ + routeDecl + ] + } +} + +extension HTTPHandlerMacro: ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + [try ExtensionDeclSyntax("extension \(type.trimmed): HTTPHandler {}")] + } +} + +private extension HTTPHandlerMacro { + struct RouteDecl { + var route: String + var statusCode: String + var funcDecl: FunctionDecl + + var routeSyntax: String { + if funcDecl.returnType.isVoid { + return """ + if await HTTPRoute("\(route)") ~= request { \(funcCallSyntax) + return HTTPResponse(statusCode: \(statusCode)) + } + """ + } else { + return """ + if await HTTPRoute("\(route)") ~= request { return \(funcCallSyntax) } + """ + } + } + + var funcCallSyntax: String { + var call = "" + if funcDecl.effects.contains(.throws) { + call += "try " + } + if funcDecl.effects.contains(.async) { + call += "await " + } + call += funcDecl.name + if let param = funcDecl.parameters.first { + if let label = param.label { + call += "(" + label + ": request)" + } else { + call += "(request)" + } + } else { + call += "()" + } + return call + } + } +} + +extension StringLiteralExprSyntax { + + func singleStringSegment() throws -> String { + guard segments.count == 1, + case let .stringSegment(segment)? = segments.first else { + throw CustomError.message( + "invalid String Literal" + ) + } + return segment.content.text + } +} diff --git a/Macro/Sources/HTTPRouteMacro.swift b/Macro/Sources/HTTPRouteMacro.swift new file mode 100644 index 00000000..f937b711 --- /dev/null +++ b/Macro/Sources/HTTPRouteMacro.swift @@ -0,0 +1,110 @@ +// +// HTTPRouteMacro.swift +// FlyingFox +// +// Created by Simon Whitty on 26/10/2023. +// Copyright © 2023 Simon Whitty. All rights reserved. +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/swhitty/FlyingFox +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +public struct HTTPRouteMacro: PeerMacro { + public static func expansion< + Context: MacroExpansionContext, + Declaration: DeclSyntaxProtocol + >( + of node: AttributeSyntax, + providingPeersOf declaration: Declaration, + in context: Context + ) throws -> [DeclSyntax] { + + // Only func can be a route + guard let funcSyntax = declaration.as(FunctionDeclSyntax.self) else { + throw CustomError.message("@HTTPRoute can only be attacehd to functions") + } + + let funcDecl = FunctionDecl.make(from: funcSyntax) + let routeAtt = funcDecl.attribute(name: "HTTPRoute")! + + if funcDecl.returnType.isHTTPResponse { + guard routeAtt.expression(name: "statusCode") == nil else { + throw CustomError.message( + "statusCode can not be supplied when returning HTTPResponse" + ) + } + } + + guard funcDecl.returnType.isVoid || funcDecl.returnType.isHTTPResponse else { + throw CustomError.message( + "@Route requires an function that returns HTTPResponse" + ) + } + + guard funcDecl.parameters.isEmpty || + (funcDecl.parameters[0].type.isHTTPRequest && funcDecl.parameters.count == 1) else { + throw CustomError.message( + "@Route requires an function with argument `HTTPRequest`" + ) + } + + // Does nothing, used only to decorate functions with data + return [] + } +} + +extension String { + + var isHTTPResponse: Bool { + self == "HTTPResponse" || self == "FlyingFox.HTTPResponse" + } + + var isHTTPRequest: Bool { + self == "HTTPRequest" || self == "FlyingFox.HTTPRequest" + } +} + +extension FunctionDecl.ReturnType { + + var isVoid: Bool { + switch self { + case .void: + return true + case .type(let string): + return FunctionDecl.ReturnType.void == .init(string) + } + } + + var isHTTPResponse: Bool { + switch self { + case .void: + return false + case .type(let string): + return string.isHTTPResponse + } + } +} diff --git a/Macro/Sources/Plugin.swift b/Macro/Sources/Plugin.swift new file mode 100644 index 00000000..f493592e --- /dev/null +++ b/Macro/Sources/Plugin.swift @@ -0,0 +1,10 @@ +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct MyMacroPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + HTTPRouteMacro.self, + HTTPHandlerMacro.self + ] +} diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 00000000..0abb550c --- /dev/null +++ b/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "ffa3cd6fc2aa62adbedd31d3efaf7c0d86a9f029", + "version" : "509.0.1" + } + } + ], + "version" : 2 +} diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift new file mode 100644 index 00000000..8fc0aea0 --- /dev/null +++ b/Package@swift-5.9.swift @@ -0,0 +1,65 @@ +// swift-tools-version: 5.9 + +import PackageDescription +import CompilerPluginSupport + +let package = Package( + name: "FlyingFox", + platforms: [ + .macOS(.v10_15), .iOS(.v13), .tvOS(.v13) + ], + products: [ + .library( + name: "FlyingFox", + targets: ["FlyingFox"] + ), + .library( + name: "FlyingSocks", + targets: ["FlyingSocks"] + ) + ], + dependencies: [ + // Depend on the Swift 5.9 release of SwiftSyntax + .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0"), + ], + targets: [ + .target( + name: "FlyingFox", + dependencies: ["FlyingSocks", "Macro"], + path: "FlyingFox/Sources" + ), + .testTarget( + name: "FlyingFoxTests", + dependencies: ["FlyingFox"], + path: "FlyingFox/Tests", + resources: [ + .copy("Stubs") + ] + ), + .target( + name: "FlyingSocks", + dependencies: [.target(name: "CSystemLinux", condition: .when(platforms: [.linux]))], + path: "FlyingSocks/Sources" + ), + .testTarget( + name: "FlyingSocksTests", + dependencies: ["FlyingSocks"], + path: "FlyingSocks/Tests", + resources: [ + .copy("Resources") + ] + ), + .target( + name: "CSystemLinux", + path: "CSystemLinux" + ), + .macro( + name: "Macro", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax") + ], + path: "Macro/Sources" + ) + ] +) From 9af1244988ef329dbbd6d1011ccf8d0cfa303be2 Mon Sep 17 00:00:00 2001 From: Simon Whitty Date: Sat, 28 Oct 2023 15:00:42 +1100 Subject: [PATCH 2/4] HTTPRoute Macro Tests --- .../Handlers/HTTPHandlerMacroTests.swift | 74 +++++++++++++++++++ Package@swift-5.9.swift | 2 +- 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 FlyingFox/Tests/Handlers/HTTPHandlerMacroTests.swift diff --git a/FlyingFox/Tests/Handlers/HTTPHandlerMacroTests.swift b/FlyingFox/Tests/Handlers/HTTPHandlerMacroTests.swift new file mode 100644 index 00000000..565d6bb8 --- /dev/null +++ b/FlyingFox/Tests/Handlers/HTTPHandlerMacroTests.swift @@ -0,0 +1,74 @@ +// +// HTTPHandlerMacroTests.swift +// FlyingFox +// +// Created by Simon Whitty on 28/10/2023. +// Copyright © 2023 Simon Whitty. All rights reserved. +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/swhitty/FlyingFox +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + + + +@testable import FlyingFox +import XCTest + +#if compiler(>=5.9) +final class HTTPHandlerMacroTests: XCTestCase { + + func testHandler() async throws { + let handler = MacroHandler() + + await AsyncAssertEqual( + try await handler.handleRequest(.make(path: "/ok")).statusCode, + .ok + ) + await AsyncAssertEqual( + try await handler.handleRequest(.make(path: "/accepted")).statusCode, + .accepted + ) + await AsyncAssertEqual( + try await handler.handleRequest(.make(path: "/teapot")).statusCode, + .teapot + ) + } +} + +@HTTPHandler +struct MacroHandler { + + @HTTPRoute("/ok") + func didAppear() -> HTTPResponse { + HTTPResponse(statusCode: .ok) + } + + @HTTPRoute("/accepted") + func willAppear(_ val: HTTPRequest) async -> HTTPResponse { + HTTPResponse(statusCode: .accepted) + } + + @HTTPRoute("/teapot", statusCode: .teapot) + func willAppear() throws { } +} +#endif diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift index 8fc0aea0..efb3ad3e 100644 --- a/Package@swift-5.9.swift +++ b/Package@swift-5.9.swift @@ -30,7 +30,7 @@ let package = Package( ), .testTarget( name: "FlyingFoxTests", - dependencies: ["FlyingFox"], + dependencies: ["FlyingFox", "Macro"], path: "FlyingFox/Tests", resources: [ .copy("Stubs") From a74eb9b484cfd343bd8f484832d7479ae54a77b8 Mon Sep 17 00:00:00 2001 From: Simon Whitty Date: Sun, 29 Oct 2023 13:38:28 +1100 Subject: [PATCH 3/4] JSONRoute --- .../xcschemes/FlyingFox-Package.xcscheme | 130 ++++++++++++++++++ .../xcshareddata/xcschemes/FlyingFox.xcscheme | 66 +++++++++ .../xcschemes/FlyingSocks.xcscheme | 66 +++++++++ .../Sources/Handlers/HTTPHandlerMacro.swift | 16 ++- .../Handlers/HTTPHandlerMacroTests.swift | 30 +++- Macro/Sources/CustomError.swift | 27 ++++ Macro/Sources/FunctionDecl.swift | 40 +++++- Macro/Sources/HTTPHandlerMacro.swift | 107 +++++++++----- Macro/Sources/HTTPRouteMacro.swift | 50 ++----- Macro/Sources/JSONRouteMacro.swift | 83 +++++++++++ Macro/Sources/Plugin.swift | 1 + 11 files changed, 541 insertions(+), 75 deletions(-) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/FlyingFox-Package.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/FlyingFox.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/FlyingSocks.xcscheme create mode 100644 Macro/Sources/JSONRouteMacro.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/FlyingFox-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/FlyingFox-Package.xcscheme new file mode 100644 index 00000000..dd492d93 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/FlyingFox-Package.xcscheme @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/FlyingFox.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/FlyingFox.xcscheme new file mode 100644 index 00000000..bea3ee4c --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/FlyingFox.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/FlyingSocks.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/FlyingSocks.xcscheme new file mode 100644 index 00000000..8436523a --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/FlyingSocks.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FlyingFox/Sources/Handlers/HTTPHandlerMacro.swift b/FlyingFox/Sources/Handlers/HTTPHandlerMacro.swift index c47ee359..2fdf3a05 100644 --- a/FlyingFox/Sources/Handlers/HTTPHandlerMacro.swift +++ b/FlyingFox/Sources/Handlers/HTTPHandlerMacro.swift @@ -30,9 +30,23 @@ // #if compiler(>=5.9) +import Foundation @attached(peer) -public macro HTTPRoute(_ name: StringLiteralType, statusCode: HTTPStatusCode = .ok) = #externalMacro(module: "Macro", type: "HTTPRouteMacro") +public macro HTTPRoute( + _ route: StringLiteralType, + statusCode: HTTPStatusCode = .ok, + headers: [HTTPHeader: String] = [:] +) = #externalMacro(module: "Macro", type: "HTTPRouteMacro") + +@attached(peer) +public macro JSONRoute( + _ route: StringLiteralType, + statusCode: HTTPStatusCode = .ok, + headers: [HTTPHeader: String] = [.contentType: "application/json"], + encoder: JSONEncoder = JSONEncoder(), + decoder: JSONDecoder = JSONDecoder() +) = #externalMacro(module: "Macro", type: "JSONRouteMacro") @attached(member, names: named(performAction), named(Action), named(handleRequest)) @attached(extension, conformances: HTTPHandler, Sendable) diff --git a/FlyingFox/Tests/Handlers/HTTPHandlerMacroTests.swift b/FlyingFox/Tests/Handlers/HTTPHandlerMacroTests.swift index 565d6bb8..c2c1e942 100644 --- a/FlyingFox/Tests/Handlers/HTTPHandlerMacroTests.swift +++ b/FlyingFox/Tests/Handlers/HTTPHandlerMacroTests.swift @@ -52,6 +52,11 @@ final class HTTPHandlerMacroTests: XCTestCase { try await handler.handleRequest(.make(path: "/teapot")).statusCode, .teapot ) + + await AsyncAssertEqual( + try await handler.handleRequest(.make(path: "/fish")).jsonDictionaryBody, + ["name": "Pickles"] + ) } } @@ -69,6 +74,29 @@ struct MacroHandler { } @HTTPRoute("/teapot", statusCode: .teapot) - func willAppear() throws { } + func getTeapot() throws { } + + @JSONRoute("/fish") + func getFish() -> Fish { + Fish(name: "Pickles") + } + + struct Fish: Encodable { + var name: String + } +} + +private extension HTTPResponse { + + var jsonDictionaryBody: NSDictionary? { + get async { + guard let data = try? await bodyData, + let object = try? JSONSerialization.jsonObject(with: data, options: []) else { + return nil + } + return object as? NSDictionary + } + } } + #endif diff --git a/Macro/Sources/CustomError.swift b/Macro/Sources/CustomError.swift index 4e723aa2..e1b9f202 100644 --- a/Macro/Sources/CustomError.swift +++ b/Macro/Sources/CustomError.swift @@ -31,6 +31,7 @@ import SwiftDiagnostics import SwiftSyntax +import SwiftSyntaxMacros enum CustomError: Error, CustomStringConvertible { case message(String) @@ -42,3 +43,29 @@ enum CustomError: Error, CustomStringConvertible { } } } + +struct SimpleDiagnostic: DiagnosticMessage { + var message: String + var diagnosticID: MessageID + var severity: DiagnosticSeverity + + static func warning(_ message: String) -> Self { + SimpleDiagnostic( + message: message, + diagnosticID: .init(domain: "Macro", id: message), + severity: .warning + ) + } +} + +extension MacroExpansionContext { + + func diagnoseWarning(for node: some SyntaxProtocol, _ message: String) { + diagnose( + Diagnostic( + node: node, + message: SimpleDiagnostic.warning(message) + ) + ) + } +} diff --git a/Macro/Sources/FunctionDecl.swift b/Macro/Sources/FunctionDecl.swift index b08f983f..4b6ded6c 100644 --- a/Macro/Sources/FunctionDecl.swift +++ b/Macro/Sources/FunctionDecl.swift @@ -75,9 +75,15 @@ struct FunctionDecl { } } - extension FunctionDecl { + static func make(from syntax: MemberBlockItemSyntax) -> Self? { + guard let funcSyntax = syntax.decl.as(FunctionDeclSyntax.self) else { + return nil + } + return .make(from: funcSyntax) + } + static func make(from syntax: FunctionDeclSyntax) -> Self { var decl = FunctionDecl( name: syntax.name.text, @@ -173,3 +179,35 @@ extension FunctionDecl.ReturnType: ExpressibleByStringLiteral { self = .init(value) } } + +extension FunctionDecl.ReturnType { + + var isVoid: Bool { + switch self { + case .void: + return true + case .type(let string): + return FunctionDecl.ReturnType.void == .init(string) + } + } + + var isHTTPResponse: Bool { + switch self { + case .void: + return false + case .type(let string): + return string.isHTTPResponse + } + } +} + +extension String { + + var isHTTPResponse: Bool { + self == "HTTPResponse" || self == "FlyingFox.HTTPResponse" + } + + var isHTTPRequest: Bool { + self == "HTTPRequest" || self == "FlyingFox.HTTPRequest" + } +} diff --git a/Macro/Sources/HTTPHandlerMacro.swift b/Macro/Sources/HTTPHandlerMacro.swift index 90d3df64..ebb7d332 100644 --- a/Macro/Sources/HTTPHandlerMacro.swift +++ b/Macro/Sources/HTTPHandlerMacro.swift @@ -42,34 +42,27 @@ public enum HTTPHandlerMacro: MemberMacro { let memberList = declaration.memberBlock.members let routes = memberList.compactMap { member -> RouteDecl? in - guard let funcSyntax = member.decl.as(FunctionDeclSyntax.self) else { + guard let funcDecl = FunctionDecl.make(from: member), + let routeAtt = funcDecl.attribute(name: "HTTPRoute") ?? funcDecl.attribute(name: "JSONRoute") else { return nil } - let funcDecl = FunctionDecl.make(from: funcSyntax) - guard let routeAtt = funcDecl.attribute(name: "HTTPRoute") else { - return nil - } - - guard let firstLabel = routeAtt.labelExpressions.first else { - return nil - } + let isJSON = routeAtt.name == "JSONRoute" + let defaultHeaders = isJSON ? #"[.contentType: "application/json"]"# : "[:]" return RouteDecl( - route: firstLabel.expression, + route: routeAtt.labelExpressions[0].expression, statusCode: routeAtt.expression(name: "statusCode")?.expression ?? ".ok", - funcDecl: funcDecl + headers: routeAtt.expression(name: "headers")?.expression ?? defaultHeaders, + funcDecl: funcDecl, + isJSON: isJSON, + encoder: routeAtt.expression(name: "encoder")?.expression ?? "JSONEncoder()", + decoder: routeAtt.expression(name: "decoder")?.expression ?? "JSONDecoder()" ) } - var validRoutes = Set() - for route in routes { - guard !validRoutes.contains(route.route) else { - throw CustomError.message( - "@HTTPRoute(\"\(route.route)\") is ambiguous" - ) - } - validRoutes.insert(route.route) + if routes.isEmpty { + context.diagnoseWarning(for: node, "No HTTPRoute found") } let routeDecl: DeclSyntax = """ @@ -103,22 +96,66 @@ private extension HTTPHandlerMacro { struct RouteDecl { var route: String var statusCode: String + var headers: String var funcDecl: FunctionDecl + var isJSON: Bool + var encoder: String + var decoder: String var routeSyntax: String { + if isJSON { + jsonRouteSyntax + } else { + httpRouteSyntax + } + } + + var httpRouteSyntax: String { if funcDecl.returnType.isVoid { - return """ + """ if await HTTPRoute("\(route)") ~= request { \(funcCallSyntax) - return HTTPResponse(statusCode: \(statusCode)) + return HTTPResponse(statusCode: \(statusCode), headers: \(headers)) } """ } else { - return """ + """ if await HTTPRoute("\(route)") ~= request { return \(funcCallSyntax) } """ } } + var jsonBodyDecodeSyntax: String { + guard let bodyParam = funcDecl.parameters.first(where: { !$0.type.isHTTPRequest }) else { + return "" + } + return """ + let body = try await \(decoder).decode(\(bodyParam.type).self, from: request.bodyData) + """ + } + + var jsonRouteSyntax: String { + if funcDecl.returnType.isVoid { + """ + if await HTTPRoute("\(route)") ~= request { + \(jsonBodyDecodeSyntax)\(funcCallSyntax) + return HTTPResponse(statusCode: \(statusCode), headers: \(headers)) + } + """ + } else { + """ + if await HTTPRoute("\(route)") ~= request { + \(jsonBodyDecodeSyntax) + let ret = \(funcCallSyntax) + return try HTTPResponse( + statusCode: \(statusCode), + headers: \(headers), + body: \(encoder).encode(ret) + ) + } + """ + } + } + var funcCallSyntax: String { var call = "" if funcDecl.effects.contains(.throws) { @@ -127,21 +164,27 @@ private extension HTTPHandlerMacro { if funcDecl.effects.contains(.async) { call += "await " } - call += funcDecl.name - if let param = funcDecl.parameters.first { - if let label = param.label { - call += "(" + label + ": request)" - } else { - call += "(request)" - } - } else { - call += "()" - } + call += funcDecl.name + "(" + call += funcDecl.parameters + .map(\.funcCallSyntax) + .joined(separator: ", ") + + call += ")" return call } } } +extension FunctionDecl.Parameter { + + var funcCallSyntax: String { + let variable = type.isHTTPRequest ? "request" : "body" + return [label, variable] + .compactMap { $0 } + .joined(separator: ": ") + } +} + extension StringLiteralExprSyntax { func singleStringSegment() throws -> String { diff --git a/Macro/Sources/HTTPRouteMacro.swift b/Macro/Sources/HTTPRouteMacro.swift index f937b711..bb7c3e6a 100644 --- a/Macro/Sources/HTTPRouteMacro.swift +++ b/Macro/Sources/HTTPRouteMacro.swift @@ -51,60 +51,30 @@ public struct HTTPRouteMacro: PeerMacro { let funcDecl = FunctionDecl.make(from: funcSyntax) let routeAtt = funcDecl.attribute(name: "HTTPRoute")! - if funcDecl.returnType.isHTTPResponse { - guard routeAtt.expression(name: "statusCode") == nil else { - throw CustomError.message( - "statusCode can not be supplied when returning HTTPResponse" - ) - } - } - guard funcDecl.returnType.isVoid || funcDecl.returnType.isHTTPResponse else { throw CustomError.message( - "@Route requires an function that returns HTTPResponse" + "@HTTPRoute requires an function that returns HTTPResponse" ) } guard funcDecl.parameters.isEmpty || (funcDecl.parameters[0].type.isHTTPRequest && funcDecl.parameters.count == 1) else { throw CustomError.message( - "@Route requires an function with argument `HTTPRequest`" + "@HTTPRoute requires an function with argument `HTTPRequest`" ) } - // Does nothing, used only to decorate functions with data - return [] - } -} - -extension String { - - var isHTTPResponse: Bool { - self == "HTTPResponse" || self == "FlyingFox.HTTPResponse" - } + if routeAtt.expression(name: "encoder") != nil && + (funcDecl.returnType.isHTTPResponse || funcDecl.returnType.isVoid) { - var isHTTPRequest: Bool { - self == "HTTPRequest" || self == "FlyingFox.HTTPRequest" - } -} - -extension FunctionDecl.ReturnType { - - var isVoid: Bool { - switch self { - case .void: - return true - case .type(let string): - return FunctionDecl.ReturnType.void == .init(string) } - } - var isHTTPResponse: Bool { - switch self { - case .void: - return false - case .type(let string): - return string.isHTTPResponse + if funcDecl.returnType.isHTTPResponse && + routeAtt.expression(name: "statusCode") != nil { + context.diagnoseWarning(for: node, "statusCode is unused for return type HTTPResponse") } + + // Does nothing, used only to decorate functions with data + return [] } } diff --git a/Macro/Sources/JSONRouteMacro.swift b/Macro/Sources/JSONRouteMacro.swift new file mode 100644 index 00000000..f1905074 --- /dev/null +++ b/Macro/Sources/JSONRouteMacro.swift @@ -0,0 +1,83 @@ +// +// JSONRouteMacro.swift +// FlyingFox +// +// Created by Simon Whitty on 29/10/2023. +// Copyright © 2023 Simon Whitty. All rights reserved. +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/swhitty/FlyingFox +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftDiagnostics + +public struct JSONRouteMacro: PeerMacro { + public static func expansion< + Context: MacroExpansionContext, + Declaration: DeclSyntaxProtocol + >( + of node: AttributeSyntax, + providingPeersOf declaration: Declaration, + in context: Context + ) throws -> [DeclSyntax] { + + // Only func can be a route + guard let funcSyntax = declaration.as(FunctionDeclSyntax.self) else { + throw CustomError.message("@JSONRoute can only be attached to functions") + } + + let funcDecl = FunctionDecl.make(from: funcSyntax) + let routeAtt = funcDecl.attribute(name: "JSONRoute")! + + switch funcDecl.parameters.count { + case 2: + guard funcDecl.parameters[0].type.isHTTPRequest else { + throw CustomError.message( + "@JSONRoute requires the first parameter is HTTPRequest" + ) + } + case 1, 0: + () + default: + throw CustomError.message( + "@JSONRoute requires a function that accepts 1 paramerter" + ) + } + + if routeAtt.expression(name: "decoder") != nil && + !funcDecl.parameters.contains(where: { !$0.type.isHTTPRequest }) { + context.diagnoseWarning(for: node, "decoder is unused") + } + + if routeAtt.expression(name: "encoder") != nil && + (funcDecl.returnType.isHTTPResponse || funcDecl.returnType.isVoid) { + context.diagnoseWarning(for: node, "encoder is unused for return type") + } + + // Does nothing, used only to decorate functions with data + return [] + } +} diff --git a/Macro/Sources/Plugin.swift b/Macro/Sources/Plugin.swift index f493592e..163702c3 100644 --- a/Macro/Sources/Plugin.swift +++ b/Macro/Sources/Plugin.swift @@ -5,6 +5,7 @@ import SwiftSyntaxMacros struct MyMacroPlugin: CompilerPlugin { let providingMacros: [Macro.Type] = [ HTTPRouteMacro.self, + JSONRouteMacro.self, HTTPHandlerMacro.self ] } From 73bd30ed26c4e557c1df94a7998794ae43f7772c Mon Sep 17 00:00:00 2001 From: Simon Whitty Date: Sun, 29 Oct 2023 14:12:43 +1100 Subject: [PATCH 4/4] Macro README --- README.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/README.md b/README.md index 90ca2617..6c36308c 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,70 @@ Handlers can throw `HTTPUnhandledError` if after inspecting the request, they ca Requests that do not match any handled route receive `HTTP 404`. +### Preview Macro Handler + +The branch [preview/macro](https://github.com/swhitty/FlyingFox/tree/preview/macro) contains an experimental preview implemenation of using Swift 5.9 macros to annotate functions with routes: + +```swift +@HTTPHandler +struct MyHandler { + + @HTTPRoute("/ping") + func ping() { } + + @HTTPRoute("/pong") + func getPong(_ request: HTTPRequest) -> HTTPResponse { + HTTPResponse(statusCode: .accepted) + } +} + +let server = HTTPServer(port: 80, handler: MyHandler()) +try await server.start() +``` + +The macro synthesises conformance to `HTTPHandler` delegating handling to the first matching route. Expanding the example above to the following: + +```swift +func handleRequest(_ request: HTTPRequest) async throws -> HTTPResponse { + if await HTTPRoute("/ping") ~= request { + ping() + return HTTPResponse(statusCode: .ok, headers: [:]) + } + if await HTTPRoute("/pong") ~= request { + return getPong(request) + } + throw HTTPUnhandledError() +} +``` + +`@HTTPRoute` annotations can specify specific properties of the returned `HTTPResponse`: + +```swift +@HTTPRoute("/refresh", statusCode: .teapot, headers: [.eTag: "t3a"]) +func refresh() +``` + +`@JSONRoute` annotations can be added to functions that accept `Codable` types. `JSONDecoder` decodes the body that is passed to the method, the returned object is encoded to the response body using `JSONEncoder`: + +```swift +@JSONRoute("POST /account") +func createAccount(body: AccountRequest) -> AccountResponse +``` + +The original `HTTPRequest` can be optionally passed to the method: + +```swift +@JSONRoute("POST /account") +func createAccount(request: HTTPRequest, body: AccountRequest) -> AccountResponse +``` + +`JSONEncoder` / `JSONDecoder` instances can be passed for specific JSON coding strategies: + +```swift +@JSONRoute("GET /account", encoder: JSONEncoder()) +func getAccount() -> AccountResponse +``` + ### FileHTTPHandler Requests can be routed to static files with `FileHTTPHandler`: