Swift Macros: Simplifying Repetitive Boilerplate Code Generation.
Abhinav Jha
Senior iOS Developer | Driving Innovation at Emirates NBD | Expertise in Banking Apps (Emirates NBD & JP Morgan) and OTT Solutions (Sony Liv, Zee5 & CNA)
Consider a scenario where we encounter multiple APIs with different endpoints. To handle this, we can create an Enum with cases that represent the endpoints. By implementing a function within the Enum, we can retrieve the respective endpoint URL for each case.
In Swift development, it is common to use camel case for naming properties, where the first letter is lowercase and subsequent words begin with a capitalized letter (e.g., nowPlaying or topRated). However, many web APIs prefer snake case, where words are separated by underscores (e.g., now_playing or top_rated). To bridge this naming convention gap, we can design our Enum to extract the respective endpoints using the following approach:
To address this, we can create a function that converts camel case to snake case, which allows for automatic generation of endpoint URLs based on the case names. However, this approach may not be suitable if we require custom endpoints for specific cases. In such scenarios, modifying the function every time we add a new case becomes impractical for long-term maintenance.
Therefore, when dealing with a larger number of endpoints or the potential for future additions, it is worth considering alternative strategies that can facilitate the generation of endpoint URLs without significant manual effort.
The Alternative Strategy - Swift Macros
Swift macros allow us to generate the repetitive boiler plate code at compile time, making our app's codebases more expressive and easier to read.
Allow me to illustrate this with a compelling example that truly captures the essence of the concept. There's no better way to grasp it than experiencing it firsthand.
In the above example, we can observe the use of the Swift macro @APIEndpoints(), which automates the generation of boilerplate code and handles the conversion from camel case to snake case. This macro significantly simplifies the process and eliminates the need for manually writing individual cases, even when dealing with a large number of endpoints.
By leveraging the @APIEndpoints() macro, we can eliminate the concerns of maintaining and updating cases for numerous endpoints. It streamlines the codebase, making it more concise and manageable. This approach ensures that we no longer need to invest time and effort in writing and modifying cases as new endpoints are added, making it suitable for scenarios with two dozen or more endpoints.
Without further ado, let's delve into the implementation details of how we accomplished this convenient solution.
Implementation Details - Swift Macros
Step 1 - Creating a package
To create Swift macros, we need to start by creating a new package. When creating a new package, we will notice a convenient macro template available in the package-choose window. For our example, let's give the package a meaningful name like "APIEndpoints".
Step 2 - Declaring Macro
To dynamically determine the endpoint URL based on specific cases, we utilize the "endpointUrl" variable. In order to declare this variable as an attached member macro, we employ the "@attached(member)" attribute.
@attached(member) - Adds new declarations inside the type/extension it’s applied to
Here APIEndpointsMacro will contain the expansion performed by our macro, which resides in APIEndpointsMacroMacros.
The APIEndpoints macro contains default parameter
In this context, we will discuss scenarios where it is unnecessary to convert specific camel case URL endpoints to snake case and situations where we may want to modify a particular endpoint to a custom value. However, for the present discussion, we will adhere to the default implementation of the APIEndpoints Macro.
Step 2 - Implementing Macro
To begin, let's navigate to the APIEndpointsMacro.swift file and make the following modifications. Firstly, we'll add a public structure named APIEndpointsMacro. Since we have declared APIEndpoints as an attached member macro, the implementation for this structure should conform to the MemberMacro protocol. This protocol specifies a single requirement: the expansion function, which is similar to the ExpressionMacro demonstrated in the macro #Stringify.
The expansion function accepts the node attribute used to apply the macro to a declaration, as well as the declaration itself to which the macro is being applied. In our case, this declaration will be an enum.
In order to ensure the desired behavior of our macro, we intend to follow a test-driven development approach. Consequently, we will initially keep the implementation of the macro empty until we create a corresponding test case. Once we have defined the expected behavior of the macro through the test case, we will proceed to write the implementation that aligns with that specific test case.
In the provided template, we utilize the 'assertMacroExpansion' function to verify the behavior of our macro. At this stage, the macro doesn't perform any actions, so we expect the expanded code to be identical to the input code, excluding the '@APIEndpoints' attribute.
To ensure that the test case expands the 'APIEndpoints' macro using the 'APIEndpointsMacro' implementation, we need to create a mapping between the macro name and its implementing type in the 'testMacros' dictionary. This dictionary is then passed to the assertion function.
Now, let's proceed to run our tests and validate the functionality we have implemented thus far. Upon running the tests, we can confirm that the code functions as expected.
However, our primary objective is to verify that the macro generates the variable endpointUrl, rather than solely removing the attribute. To achieve this, we will extend our test to examine the output generated by applying the macro to the Enum. This modification will serve as the input for our test case, reflecting the desired outcome that we anticipate the plugin to produce.
Upon rerunning the test, it fails because our macro does not currently generate the initializer. To address this issue, we need to implement the generation of the initializer.
领英推荐
The variable iterates through all the elements declared in the Paths enum. To begin, we need to extract these enum elements from the declaration. Since enum elements can only be declared within enum declarations, we cast the 'declaration' variable to an enum declaration.
If the macro is associated with a type that is not an enum, we should generate an error. Let's add a TODO to address this issue later, and for now, return an empty array.
Next, we must retrieve all the elements declared within the enum. To accomplish this, we examine the syntactic structure of our enum in the SwiftSyntax tree.
Given that the macro's implementation is essentially a regular Swift program, we have the convenience of utilizing familiar debugging tools from Xcode to troubleshoot our code. One such approach is setting a breakpoint within the expansion function and running test cases that trigger the breakpoint.
By doing so, we can effectively pause the debugger within the macro's implementation. At this point, the Paths enum, for instance, becomes accessible as 'enumDecl'. To examine its contents, we can print it in the debugger by typing 'po enumDecl'.
The Output -
Here's a breakdown of the above Swift syntax tree:
To access the members from the members list, we can begin with enumDecl.memberBlock.members. These members encompass the actual declarations that define the enum cases. By utilizing compactMap, we can obtain a list of all the member declarations that specifically represent enum cases. It's important to note that case declaration has multiple elements. To retrieve all of these elements, we can make use of flatMap.
Now that we have obtained all the necessary elements, we can proceed with the construction of the variable that we intend to add to the Paths Enum.
To achieve this, we define an initializer declaration with a single item: a switch expression. This switch expression incorporates a case for each element within the enum and returns the corresponding required string. In order to accomplish this, we need to create syntax nodes for each case.
Here's a breakdown of the code:
And finally, we can return the variable.
Let's run the tests to see if we are indeed generating the correct variable.
Now that we have verified the functionality of our macro, we can begin integrating it into our application.
To add our macro package to our Xcode project seamlessly, we can follow these steps:
With the package and dependency set up correctly, we can now import the APIEndpoints module into our code. This allows us to access the @APIEndpoints() macro and utilize it conveniently within our project.
To import the module and apply the macro to the Paths enum, we simply:
So if we want to see what the macro actually generated, we can right-click on @APIEndpoints() and click Expand Macro.
I have made some enhancements to the macro to ensure it can handle SnakeCasedOptions effectively.
Additionally, I have implemented error handling for scenarios where the macro is used on types other than an enum or if invalid snake case options are provided.
To delve deeper into the complete code, I encourage you to explore my GitHub repository. Feel free to examine the code and provide any feedback or suggestions you may have.
???????????? ???????????????? ???????????????? | ?????? | ???????? | ?????????? & ?????????????????? | ?????? | ??????
1 年Great explanation ??