Section 8/101 menit

8. Real Use Cases di Xcode Project

8. Real Use Cases di Xcode Project

Use Case 1: @Singleton Macro untuk Dependency Injection

Konteks: Di project iOS, banyak service class yang perlu pola singleton. Menulis static let shared = Self() berulang-ulang membosankan dan rawan lupa.

Implementasi Macro:

swift
// Public API — MyMacros/Sources/MyMacros/MyMacros.swift
@attached(member, names: named(shared))
public macro Singleton() = #externalMacro(
    module: "MyMacrosMacros",
    type: "SingletonMacro"
)
swift
// Implementasi — MyMacrosMacros/Sources/MyMacrosMacros/SingletonMacro.swift
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct SingletonMacro: MemberMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        conformingTo protocols: [TypeSyntax],
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {

        // Validasi: hanya boleh pada class
        guard declaration.is(ClassDeclSyntax.self) else {
            throw MacroError.requiresClass("@Singleton hanya bisa digunakan pada class")
        }

        guard let typeName = declaration.asProtocol(NamedDeclSyntax.self)?.name.text else {
            throw MacroError.unknownType
        }

        return [
            """
            public static let shared = \(raw: typeName)()
            """,
            """
            private override init() {
                super.init()
            }
            """
        ]
    }
}

Penggunaan di Xcode Project:

swift
import MyMacros

// ✓ Bersih, tidak ada boilerplate
@Singleton
class NetworkManager: NSObject {
    func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        // implementasi
    }
}

@Singleton
class UserSessionManager: NSObject {
    private(set) var currentUser: User?

    func login(with credentials: Credentials) async throws {
        // implementasi
    }

    func logout() {
        currentUser = nil
    }
}

// Penggunaan:
class HomeViewController: UIViewController {
    private let network = NetworkManager.shared
    private let session = UserSessionManager.shared

    override func viewDidLoad() {
        super.viewDidLoad()
        Task {
            let user = UserSessionManager.shared.currentUser
            // ...
        }
    }
}

Test:

swift
func testSingletonMacroExpansion() {
    assertMacroExpansion(
        """
        @Singleton
        class MyService: NSObject {}
        """,
        expandedSource: """
        class MyService: NSObject {

            public static let shared = MyService()

            private override init() {
                super.init()
            }
        }
        """,
        macros: testMacros
    )
}

Use Case 2: #URL Expression Macro untuk Type-Safe URL

Konteks: URL yang salah format baru ketahuan saat runtime dan menyebabkan crash. Dengan macro, URL divalidasi saat compile time.

Implementasi Macro:

swift
// Public API
@freestanding(expression)
public macro URL(_ string: String) -> Foundation.URL = #externalMacro(
    module: "MyMacrosMacros",
    type: "URLMacro"
)
swift
// Implementasi
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
import Foundation

public struct URLMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) throws -> ExprSyntax {

        // Ambil argumen string literal
        guard
            let argument = node.arguments.first?.expression,
            let stringLiteral = argument.as(StringLiteralExprSyntax.self),
            let urlString = stringLiteral.segments.first?.as(StringSegmentSyntax.self)?.content.text
        else {
            throw URLMacroError.requiresStringLiteral
        }

        // Validasi URL di compile time
        guard let _ = Foundation.URL(string: urlString), !urlString.isEmpty else {
            throw URLMacroError.invalidURL(urlString)
        }

        // Pastikan URL memiliki scheme yang valid
        guard urlString.hasPrefix("https://") || urlString.hasPrefix("http://") else {
            throw URLMacroError.missingScheme(urlString)
        }

        // Generate ekspresi URL yang guaranteed valid
        return "Foundation.URL(string: \(literal: urlString))!"
    }
}

enum URLMacroError: Error, CustomStringConvertible {
    case requiresStringLiteral
    case invalidURL(String)
    case missingScheme(String)

    var description: String {
        switch self {
        case .requiresStringLiteral:
            return "#URL membutuhkan string literal, bukan ekspresi dinamis"
        case .invalidURL(let url):
            return "'\(url)' bukan URL yang valid"
        case .missingScheme(let url):
            return "'\(url)' harus memiliki scheme https:// atau http://"
        }
    }
}

Penggunaan di Xcode Project:

swift
import MyMacros
import Foundation

enum APIEndpoint {
    static let base = #URL("https://api.example.com/v1")           // ✓ valid
    static let users = #URL("https://api.example.com/v1/users")    // ✓ valid
    static let broken = #URL("not-a-url")                          // ❌ COMPILE ERROR!
    static let noScheme = #URL("api.example.com/users")            // ❌ COMPILE ERROR!
}

// Penggunaan di NetworkManager:
class NetworkManager {
    private let session = URLSession.shared

    func fetchUsers() async throws -> [User] {
        let url = APIEndpoint.users
        let (data, _) = try await session.data(from: url)
        return try JSONDecoder().decode([User].self, from: data)
    }
}

// Penggunaan di VIP Interactor:
class HomeInteractor: HomeBusinessLogic {
    private let network = NetworkManager()

    func fetchHomeData(request: Home.FetchData.Request) async {
        do {
            let url = #URL("https://api.example.com/v1/home")
            // url dijamin valid — tidak perlu optional unwrapping
            let (data, _) = try await URLSession.shared.data(from: url)
            // proses data...
        } catch {
            // handle network error
        }
    }
}

Use Case 3: @Logged Peer Macro untuk Automatic Logging

Konteks: Di project production, logging penting untuk debugging. Tapi menambahkan logging manual ke setiap fungsi membutuhkan banyak kode berulang dan mudah terlupa.

Implementasi Macro:

swift
// Public API
@attached(peer, names: prefixed(__logged_))
public macro Logged() = #externalMacro(
    module: "MyMacrosMacros",
    type: "LoggedMacro"
)
swift
// Implementasi
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct LoggedMacro: PeerMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingPeersOf declaration: some DeclSyntaxProtocol,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {

        guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else {
            throw LoggedMacroError.onlyFunctions
        }

        let funcName = funcDecl.name.text
        let params = funcDecl.signature.parameterClause.parameters
        let returnType = funcDecl.signature.returnClause?.type.description ?? "Void"
        let isAsync = funcDecl.signature.effectSpecifiers?.asyncSpecifier != nil
        let isThrowing = funcDecl.signature.effectSpecifiers?.throwsClause != nil

        // Buat parameter call string untuk forward ke fungsi asli
        let paramCall = params.map { param in
            let label = param.firstName.text
            let name = param.secondName?.text ?? param.firstName.text
            if label == "_" {
                return name
            }
            return "\(label): \(name)"
        }.joined(separator: ", ")

        // Buat daftar nama parameter untuk logging
        let paramLog = params.map { param in
            let name = param.secondName?.text ?? param.firstName.text
            return "\\(\(name))"
        }.joined(separator: ", ")

        let asyncKeyword = isAsync ? "async " : ""
        let throwsKeyword = isThrowing ? "throws " : ""
        let awaitKeyword = isAsync ? "await " : ""
        let tryKeyword = isThrowing ? "try " : ""

        let loggedFuncName = "__logged_\(funcName)"

        return [
            """
            func \(raw: loggedFuncName)(\(raw: params.description)) \(raw: asyncKeyword)\(raw: throwsKeyword)-> \(raw: returnType) {
                let startTime = Date()
                os_log(.debug, "[\(raw: funcName)] Called with: \(raw: paramLog.isEmpty ? "(no params)" : paramLog)")
                defer {
                    let elapsed = Date().timeIntervalSince(startTime)
                    os_log(.debug, "[\(raw: funcName)] Completed in %.3f seconds", elapsed)
                }
                return \(raw: tryKeyword)\(raw: awaitKeyword)\(raw: funcName)(\(raw: paramCall))
            }
            """
        ]
    }
}

enum LoggedMacroError: Error {
    case onlyFunctions
}

Penggunaan di Xcode Project (VIP Architecture):

swift
import MyMacros
import os

// Interactor dengan logging otomatis
class ProfileInteractor: ProfileBusinessLogic {
    var presenter: ProfilePresentationLogic?
    private let worker = ProfileWorker()

    @Logged
    func fetchProfile(request: Profile.FetchProfile.Request) async {
        do {
            let user = try await worker.getUser(id: request.userId)
            let response = Profile.FetchProfile.Response(user: user)
            await presenter?.presentProfile(response: response)
        } catch {
            let response = Profile.FetchProfile.Response(error: error)
            await presenter?.presentProfile(response: response)
        }
    }

    @Logged
    func updateProfile(request: Profile.UpdateProfile.Request) async throws {
        let updated = try await worker.updateUser(
            id: request.userId,
            name: request.name,
            email: request.email
        )
        let response = Profile.UpdateProfile.Response(user: updated)
        await presenter?.presentUpdateResult(response: response)
    }
}

// Macro generate peer function untuk setiap @Logged function:
// func __logged_fetchProfile(request: Profile.FetchProfile.Request) async {
//     let startTime = Date()
//     os_log(.debug, "[fetchProfile] Called with: ...")
//     defer { os_log(.debug, "[fetchProfile] Completed in X.XXX seconds", ...) }
//     return await fetchProfile(request: request)
// }

// ViewController menggunakan __logged_ variant untuk observability:
class ProfileViewController: UIViewController {
    var interactor: ProfileBusinessLogic?

    override func viewDidLoad() {
        super.viewDidLoad()
        Task {
            // Dalam production mode, gunakan __logged_ variant
            if let loggedInteractor = interactor as? ProfileInteractor {
                await loggedInteractor.__logged_fetchProfile(
                    request: .init(userId: "user_123")
                )
            }
        }
    }
}

Test untuk @Logged Macro:

swift
func testLoggedMacroGeneratesPeer() {
    assertMacroExpansion(
        """
        @Logged
        func greet(name: String) -> String {
            return "Hello, \\(name)"
        }
        """,
        expandedSource: """
        func greet(name: String) -> String {
            return "Hello, \\(name)"
        }

        func __logged_greet(name: String) -> String {
            let startTime = Date()
            os_log(.debug, "[greet] Called with: \\(name)")
            defer {
                let elapsed = Date().timeIntervalSince(startTime)
                os_log(.debug, "[greet] Completed in %.3f seconds", elapsed)
            }
            return greet(name: name)
        }
        """,
        macros: testMacros
    )
}