From d99ee4bf85a6cbffad0325c7f099e1c2b1d32489 Mon Sep 17 00:00:00 2001 From: Thomas De Leon <3507743+tdeleon@users.noreply.github.com> Date: Wed, 3 Jan 2024 14:55:33 -0800 Subject: [PATCH] Add @APIService Macro (#41) * #35 Adding RestAPI macro * #35: Add missing names parameter for @attached extension macro declaration; remove unnecessary @attached member declaration; remove macro without baseURL paramater * #35: Rename @RestAPI to @APIService * Update test.yml for swift 5.9 * Update test.yml * Add macros target as test dependency * #35: Temporarily disable macros tests on windows * #35 temporarily disable building macros on windows when testing * #35: Disable macros on windows during tests * #35: Don't use macros in regular unit tests * Update test.yml to set Xcode 15.1 on macOS 13 runner * Update test.yml - select swift version * Update test.yml * Update test.yml * Update test.yml * Update test.yml * #35: Complete macro renaming * #35: Add error handling for @APIService macro * #35: Maintain support for swift <5.9 * #35: maintain support for swift <5.9 * #35: maintain support for swift <5.9 * #35: maintain support for swift <5.9 * #35: maintain support for swift <5.9 * #35: maintain support for swift <5.9 * #35: maintain support for swift <5.9 * #35: maintain support for swift <5.9 * #35: maintain support for swift <5.9 * #35: maintain support for swift <5.9 * #35: maintain support for swift <5.9 * #35: maintain support for swift <5.9 * #35: maintain support for swift <5.9 * #35: maintain support for swift <5.9 * #35: maintain support for swift <5.9 * #35: maintain support for swift <5.9 * Report swift version in ci action * #35: Remove unnecessary import check --- .github/workflows/test.yml | 24 ++-- .../xcschemes/Relax-Package.xcscheme | 116 ++++++++++++++++++ .../xcshareddata/xcschemes/URLMock.xcscheme | 66 ++++++++++ Package.resolved | 14 +++ Package@swift-5.9.swift | 66 ++++++++++ Sources/Relax/Macros/APIService.swift | 13 ++ Sources/RelaxMacros/APIServiceMacro.swift | 51 ++++++++ .../RelaxMacros/RelaxMacroDiagnostic.swift | 25 ++++ Sources/RelaxMacros/RelaxMacrosPlugin.swift | 16 +++ Tests/RelaxMacrosTests/MacroTests.swift | 63 ++++++++++ Tests/RelaxTests/Helpers/MockServices.swift | 5 +- .../RelaxTests/Helpers/Service+Testing.swift | 2 +- Tests/RelaxTests/Request/ServiceTests.swift | 2 +- 13 files changed, 451 insertions(+), 12 deletions(-) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/Relax-Package.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/URLMock.xcscheme create mode 100644 Package.resolved create mode 100644 Package@swift-5.9.swift create mode 100644 Sources/Relax/Macros/APIService.swift create mode 100644 Sources/RelaxMacros/APIServiceMacro.swift create mode 100644 Sources/RelaxMacros/RelaxMacroDiagnostic.swift create mode 100644 Sources/RelaxMacros/RelaxMacrosPlugin.swift create mode 100644 Tests/RelaxMacrosTests/MacroTests.swift diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2a5f23e..2c3f814 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,29 +1,39 @@ -name: Test +name: Build & Test on: [push] jobs: build: strategy: - max-parallel: 5 fail-fast: false matrix: - os: [macos-12, macos-latest, ubuntu-latest, windows-latest] + os: [macos-13, ubuntu-latest, windows-latest] + swift: ["5.7", "5.8", "5.9"] runs-on: ${{ matrix.os }} steps: - - if: runner.os == 'Windows' - name: Setup Swift + - if: ${{ (runner.os != 'Windows') || (runner.os == 'Windows' && matrix.swift != '5.7') }} + name: Setup Swift Version + uses: SwiftyLab/setup-swift@latest + with: + swift-version: ${{ matrix.swift }} + + - if: ${{ (runner.os == 'Windows') && (matrix.swift == '5.7') }} + name: Setup Swift Version (5.7 on Windows) uses: compnerd/gha-setup-swift@main with: branch: swift-5.7-release tag: 5.7-RELEASE - + + - name: Get Swift Version + run: swift --version + - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Build run: swift build - name: Test run: swift test + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Relax-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Relax-Package.xcscheme new file mode 100644 index 0000000..1809d9f --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Relax-Package.xcscheme @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/URLMock.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/URLMock.xcscheme new file mode 100644 index 0000000..db97996 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/URLMock.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..6fd9cd0 --- /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" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", + "version" : "509.0.2" + } + } + ], + "version" : 2 +} diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift new file mode 100644 index 0000000..9645f0c --- /dev/null +++ b/Package@swift-5.9.swift @@ -0,0 +1,66 @@ +// swift-tools-version:5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription +import CompilerPluginSupport + + +var targets: [Target] = [ + .target( + name: "URLMock", + dependencies: ["Relax"] + ), + .testTarget( + name: "RelaxTests", + dependencies: ["Relax", "URLMock"] + ), +] + +var dependencies = [Package.Dependency]() + +// Macros do not currently compile on windows when building tests: https://github.com/apple/swift-package-manager/issues/7174 +#if canImport(XCTest) && os(Windows) +targets.append(.target(name: "Relax")) +#else +targets.append( + contentsOf: [ + .target(name: "Relax", dependencies: ["RelaxMacros"]), + .macro( + name: "RelaxMacros", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax") + ] + ), + .testTarget( + name: "RelaxMacrosTests", + dependencies: ["RelaxMacros", .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax")] + ) + ] +) +dependencies = [ + // Depend on the Swift 5.9 release of SwiftSyntax + .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0"), +] +#endif + +let package = Package( + name: "Relax", + platforms: [ + .iOS(.v14), + .tvOS(.v14), + .watchOS(.v7), + .macOS(.v12), + ], + products: [ + // Products define the executables and libraries produced by a package, and make them visible to other packages. + .library( + name: "Relax", + targets: ["Relax"]), + .library( + name: "URLMock", + targets: ["URLMock"]) + ], + dependencies: dependencies, + targets: targets +) diff --git a/Sources/Relax/Macros/APIService.swift b/Sources/Relax/Macros/APIService.swift new file mode 100644 index 0000000..b7b1959 --- /dev/null +++ b/Sources/Relax/Macros/APIService.swift @@ -0,0 +1,13 @@ +// +// APIService.swift +// +// +// Created by Thomas De Leon on 12/27/23. +// + +#if swift(>=5.9) +import Foundation + +@attached(extension, conformances: APIComponent, names: named(baseURL)) +public macro APIService(_ baseURL: String) = #externalMacro(module: "RelaxMacros", type: "APIServiceMacro") +#endif diff --git a/Sources/RelaxMacros/APIServiceMacro.swift b/Sources/RelaxMacros/APIServiceMacro.swift new file mode 100644 index 0000000..c5aa9db --- /dev/null +++ b/Sources/RelaxMacros/APIServiceMacro.swift @@ -0,0 +1,51 @@ +// +// APIServiceMacro.swift +// +// +// Created by Thomas De Leon on 12/27/23. +// + +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftDiagnostics +import Foundation + +public struct APIServiceMacro: 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] { + guard let urlString = node + .arguments?.as(LabeledExprListSyntax.self)? + .first?.expression.as(StringLiteralExprSyntax.self)? + .segments + .first?.trimmedDescription else { + let error = Diagnostic(node: node, message: RelaxMacroDiagnostic.invalidBaseURL) + context.diagnose(error) + return [] + } + + // check that the URL will be valid + guard URL(string: urlString) != nil else { + let error = Diagnostic(node: node, message: RelaxMacroDiagnostic.invalidBaseURL) + context.diagnose(error) + return [] + } + + let decl: DeclSyntax = + """ + extension \(type.trimmed): APIComponent { + static let baseURL: URL = URL(string: \"\(raw: urlString)\")! + } + """ + + guard let extensionDecl = decl.as(ExtensionDeclSyntax.self) else { + return [] + } + return [extensionDecl] + } +} diff --git a/Sources/RelaxMacros/RelaxMacroDiagnostic.swift b/Sources/RelaxMacros/RelaxMacroDiagnostic.swift new file mode 100644 index 0000000..078dfef --- /dev/null +++ b/Sources/RelaxMacros/RelaxMacroDiagnostic.swift @@ -0,0 +1,25 @@ +// +// RelaxMacroDiagnostic.swift +// +// +// Created by Thomas De Leon on 1/2/24. +// + +import SwiftDiagnostics + +enum RelaxMacroDiagnostic: String, DiagnosticMessage { + case invalidBaseURL + + var severity: DiagnosticSeverity { .error } + + var message: String { + switch self { + case .invalidBaseURL: + "The base URL is invalid." + } + } + + var diagnosticID: MessageID { + MessageID(domain: "RelaxMacros", id: rawValue) + } +} diff --git a/Sources/RelaxMacros/RelaxMacrosPlugin.swift b/Sources/RelaxMacros/RelaxMacrosPlugin.swift new file mode 100644 index 0000000..715a77b --- /dev/null +++ b/Sources/RelaxMacros/RelaxMacrosPlugin.swift @@ -0,0 +1,16 @@ +// +// RelaxMacrosPlugin.swift +// +// +// Created by Thomas De Leon on 12/27/23. +// + +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct RelaxMacrosPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + APIServiceMacro.self + ] +} diff --git a/Tests/RelaxMacrosTests/MacroTests.swift b/Tests/RelaxMacrosTests/MacroTests.swift new file mode 100644 index 0000000..a281765 --- /dev/null +++ b/Tests/RelaxMacrosTests/MacroTests.swift @@ -0,0 +1,63 @@ +// +// MacroTests.swift +// +// +// Created by Thomas De Leon on 12/27/23. +// + +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest +@testable import RelaxMacros + +#if canImport(RelaxMacros) +import RelaxMacros +let testMacros: [String: Macro.Type] = [ + "APIService": APIServiceMacro.self +] +#endif + +final class MacroTests: XCTestCase { + func testAPIService() throws { + #if canImport(RelaxMacros) + assertMacroExpansion( + """ + @APIService("https://example.com/") + enum TestService { + } + """, + expandedSource: """ + enum TestService { + } + + extension TestService: APIComponent { + static let baseURL: URL = URL(string: "https://example.com/")! + } + """, + macros: testMacros + ) + #else + throw XCTSkip("macros are only supported when running tests for the host platform") + #endif + } + + func testAPIServiceInvalidBaseURL() throws { + #if canImport(RelaxMacros) + assertMacroExpansion( + """ + @APIService("https:// .com") + enum TestService { + } + """, + expandedSource: """ + enum TestService { + } + """, + diagnostics: [.init(message: RelaxMacroDiagnostic.invalidBaseURL.message, line: 1, column: 1)], + macros: testMacros + ) + #else + throw XCTSkip("macros are only supported when running tests for the host platform") + #endif + } +} diff --git a/Tests/RelaxTests/Helpers/MockServices.swift b/Tests/RelaxTests/Helpers/MockServices.swift index 174c7b0..0d734c5 100644 --- a/Tests/RelaxTests/Helpers/MockServices.swift +++ b/Tests/RelaxTests/Helpers/MockServices.swift @@ -12,8 +12,7 @@ import FoundationNetworking @testable import Relax enum ExampleService: Service { - static let baseURL: URL = URL(string: "https://www.example.com")! - + static let baseURL: URL = URL(string: "https://example.com")! static var session: URLSession = .shared @RequestBuilder @@ -136,7 +135,7 @@ enum ExampleService: Service { } } -struct BadURLService: Service { +enum BadURLService: Service { static let baseURL: URL = URL(string: "a://@@")! @RequestBuilder diff --git a/Tests/RelaxTests/Helpers/Service+Testing.swift b/Tests/RelaxTests/Helpers/Service+Testing.swift index 12830a7..0a70446 100644 --- a/Tests/RelaxTests/Helpers/Service+Testing.swift +++ b/Tests/RelaxTests/Helpers/Service+Testing.swift @@ -12,7 +12,7 @@ import FoundationNetworking import XCTest @testable import Relax -extension Service { +extension APIComponent { // func checkSuccess(request: some Request, received: URLRequest) { // // check request method type // XCTAssertEqual(received.httpMethod, request.httpMethod.rawValue) diff --git a/Tests/RelaxTests/Request/ServiceTests.swift b/Tests/RelaxTests/Request/ServiceTests.swift index 35624b3..0a4eb89 100644 --- a/Tests/RelaxTests/Request/ServiceTests.swift +++ b/Tests/RelaxTests/Request/ServiceTests.swift @@ -49,7 +49,7 @@ final class ServiceTests: XCTestCase { } func testDefaults() { - enum Testing: Service { + enum Testing: APIComponent { static let baseURL = URL(string: "https://example.com")! }