Macro Feature Flag System
Posted Wednesday, November 15, 2023.
Introduction
We want to create a feature flag system that can be updated (likely from an API) and overriden locally for development, but also with minimal code needed for maintenance. Lets say you have some UI setup for handling overriding flags. We would like to create a system where we add the flag in one place an for free we get:
- An up to date list of flags (if you use an enum with
CaseIterable
you can get this for free, but only with an enum)
- UI for overriding the flag
- Publishers generated to subscribe to changes in the flag (from locally overriding or from the API updating)
To do these things, we can create a small library with a macro and a property wrapper to help us. Consuming this library will look like this:
import Features
@FeatureSet
enum Features {
@Feature("FlagOne") static var flagOne
@Feature("FlagTwo") static var flagTwo
}
And thats it! Behind the scenes you get all of this for free:
import Features
import SwiftUI
@FeatureSet
enum Features {
@Feature("FlagOne") static var flagOne
@Feature("FlagTwo") static var flagTwo
// Generated by @FeatureSet macro
static func update(with payload: [String: Bool]) {
flagOne.update(value: payload["FlagOne"])
flagTwo.update(value: payload["FlagTwo"])
}
}
// Generated by @FeatureSet macro
extension Features: CaseIterable {
static let allCases: [Flag] = [
flagOne,
flagTwo
]
}
// Built in from @Feature property wrapper
let enabled = Features.flagOne.enabled
let cancellable = Features.$flagOne.sink { _ in }
let view = FeaturesView(flags: Features.allCases)
Package Structure
Create a new swift package (swift package init --type library
). The finished file structure of this package will look like:
.
├── Sources
│ ├── Features
│ │ ├── Feature.swift
│ │ ├── FeaturesView.swift
│ │ ├── Flag.swift
│ │ └── Macros.swift
│ └── FeaturesMacro
│ ├── FeatureSetMacro.swift
│ └── Macros.swift
├── Package.swift
└── Package.resolved
Package.swift
In our Package.swift
we need to import CompilerPluginSupport
to be able to use .macro
targets. We also need to rely on the SwiftSyntax
dependency to write our macro.
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.
import CompilerPluginSupport
import PackageDescription
let package = Package(
name: "features",
platforms: [
.iOS(.v15),
.macOS(.v12)
],
products: [
.library(name: "Features", targets: ["Features"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0"),
],
targets: [
.macro(
name: "FeaturesMacros",
dependencies: [
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
]
),
.target(
name: "Features",
dependencies: [
.target(name: "FeaturesMacros")
]
)
]
)
FeaturesMacros/Macros.swift
We are going to write and expose only 1 macro called FeatureSet
. In order to expose our macro to the Features
target, we need to expose it using @main
and conforming our type to CompilerPlugin
.
import SwiftCompilerPlugin
import SwiftSyntaxMacros
@main
struct FeaturesPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
FeatureSetMacro.self
]
}
FeaturesMacros/FeatureSetMacro.swift
Now we can start writing our FeatureSet
macro. We will start with the type and a couple helpers.
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
public enum FeatureSetMacro {}
extension DeclModifierSyntax {
var isPublic: Bool {
switch name.tokenKind {
case .keyword(.public): true
default: false
}
}
}
extension DeclModifierSyntax {
var isStatic: Bool {
switch name.tokenKind {
case .keyword(.static): true
default: false
}
}
}
extension SyntaxStringInterpolation {
// It would be nice for SwiftSyntaxBuilder to provide this out-of-the-box.
mutating func appendInterpolation<Node: SyntaxProtocol>(_ node: Node?) {
if let node {
appendInterpolation(node)
}
}
}
Our macro is going to be both a member and extension type macro.
Because we want to generate a member (update(with:)
) with this macro, we need to conform to MemberMacro
. We look through all the members of the declaration we are attached to, and look for static members that are annotated @Feature(...)
. We need to get both the variable name and string value of the feature.
extension FeatureSetMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
let access = declaration.modifiers.first(where: \.isPublic)
let members = declaration
.memberBlock
.members
.compactMap { member -> (String, String)? in
guard
let variable = member.decl.as(VariableDeclSyntax.self),
let attribute = variable.attributes.first?.as(AttributeSyntax.self),
let attributeName = attribute.attributeName.as(IdentifierTypeSyntax.self),
attributeName.name.text == "Feature",
variable.modifiers.contains(where: \.isStatic),
let arguments = attribute.arguments?.as(LabeledExprListSyntax.self),
let argument = arguments.first?.as(LabeledExprSyntax.self),
let expression = argument.expression.as(StringLiteralExprSyntax.self),
let flagString = expression.segments.first?.as(StringSegmentSyntax.self)?.content.text,
let binding = variable.bindings.first?.as(PatternBindingSyntax.self),
let flagName = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text
else { return nil }
return (flagString, flagName)
}
.map { string, name in
#" \#(name).update(value: payload["\#(string)"])"#
}
.joined(separator: "\n")
return [
"""
\(access)static func update(with payload: [String: Bool]) {
\(raw: members)
}
"""
]
}
}
This will generate our update method and the output will look something like this:
static func update(with payload: [String: Bool]) {
flagOne.update(value: payload["FlagOne"])
flagTwo.update(value: payload["FlagTwo"])
...
}
It is handy because as we add/remove flags, we dont have to keep this update(with:)
method up to date.
We also want to conform to CaseIterable
and provide the implementation for allCases
. To do this we need to conform to ExtensionMacro
. In here we only need to get the variable names of static members that are marked @Feature(...)
.
extension FeatureSetMacro: 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] {
let access = declaration.modifiers.first(where: \.isPublic)
let members = declaration
.memberBlock
.members
.compactMap { member -> String? in
guard
let variable = member.decl.as(VariableDeclSyntax.self),
let attribute = variable.attributes.first?.as(AttributeSyntax.self),
let attributeName = attribute.attributeName.as(IdentifierTypeSyntax.self),
attributeName.name.text == "Feature",
variable.modifiers.contains(where: \.isStatic),
let binding = variable.bindings.first?.as(PatternBindingSyntax.self),
let flagName = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text
else { return nil }
return flagName
}
.map { name in
#" \#(name)"#
}
.joined(separator: ",\n")
let extensionDecl = ExtensionDeclSyntax(
extendedType: type,
inheritanceClause: .init(inheritedTypes: .init(arrayLiteral: .init(type: IdentifierTypeSyntax(name: "CaseIterable"))))
) {
"""
\(access)static let allCases: [Flag] = [
\(raw: members)
]
"""
}
return [extensionDecl]
}
}
This will generate the extension to our type and will look something like this:
extension MyTypeWithFeatureSetMacro: CaseIterable {
static let allCases: [Flag] = [
flagOne,
flagTwo,
...
]
}
Now we can move into our Features
target and expose the macro from our library.
Features/Macros.swift
We conformed our macro type to both MemberMacro
and ExtensionMacro
so now we need to wire it up. We add @attached(member, names: named(update))
.
@attached(member, names: named(update)) // 1
@attached(extension, conformances: CaseIterable, names: named(allCases)) // 2
public macro FeatureSet() = #externalMacro(module: "FeaturesMacros", type: "FeatureSetMacro") // 3
Explanation:
- Needed for member macro and notes that we will be creating a member named
update
(the static function to update the flags)
- Needed for extension macro and notes that we will be conforming to the protocol
CaseIterable
and creating a member named allCases
(static requirement for CaseIterable
protocol)
- Publicly expose the macro so it can be used with
@FeatureSet
and links it to our FeaturesMacro
target.
Features/Flag.swift
The type of each feature flag will be a Flag
. Because features can only be one type it is nice that we can use the propery wrapper to hold the type and dont have to declare it for each flag:
@FeatureSet
enum Features {
@Feature("MyFlag") static var myFlag
// vs
@Feature("MyFlag") static var myFlag: Flag // compiles but is redundant
}
This is the underlying type that holds the state for each flag and persists overriding to UserDefaults. Our member macro created a function update
that in turn calls the update
method on each flag that is in the feature set.
import Combine
import Foundation
public class Flag: ObservableObject, Identifiable {
let name: String
let key: String
var storage = false
@Published var overriden: Bool
@Published public private(set) var isEnabled: Bool
init(name: String) {
let key = "_override-\(name)"
let override = UserDefaults.standard.object(forKey: key) as? Bool
self.key = key
self.overriden = override != nil
self.isEnabled = override ?? false
self.name = name
}
func override(value: Bool) {
if value == storage {
overriden = false
UserDefaults.standard.removeObject(forKey: key)
} else {
overriden = true
UserDefaults.standard.setValue(value, forKey: key)
}
isEnabled = value
}
public func update(value: Bool?) {
guard let value else { return }
storage = value
reset()
}
func reset() {
isEnabled = storage
overriden = false
UserDefaults.standard.removeObject(forKey: key)
}
public var id: String { name }
}
We conform to Identifiable
so we can use the array in a List
or ForEach
in SwiftUI (though this is not required, we could also do ForEach(flags, id: \.name)
).
Features/Feature.swift
The property wrapper for Feature
is quite simple. It passes the name string along to its wrappedValue
of Flag
but then also forwards the publisher out as its projected value. We marked the isEnabled
property as public private(set)
because we don't want it to be easy to update the value of the feature flag. It should only be done externally from the API or from the local overrides (we wouldn't want someone to accidentally be able to do Features.flagOne.isEnabled = false
and turn it off in code)
import Combine
@propertyWrapper
public struct Feature {
public init(_ name: String) {
self.wrappedValue = Flag(name: name)
}
public var wrappedValue: Flag
public var projectedValue: AnyPublisher<Bool, Never> {
wrappedValue.$isEnabled.eraseToAnyPublisher()
}
}
Features/FeaturesView.swift
This is a pretty quick and dirty implementation of UI for allowing overriding flags. It takes in an array of flags (which we get for free from our @FeatureSet
macro) and displays them in a list which is searchable by name. From here you can toggle flags on and off and see which ones are overriden.
import SwiftUI
@MainActor
final class FeaturesViewModel: ObservableObject {
let flags: [Flag]
@Published var search = ""
@Published var filtered: [Flag]
init(flags: [Flag]) {
self.flags = flags
self.filtered = flags
$search
.removeDuplicates()
.delay(for: 0.1, scheduler: RunLoop.main)
.map { query in
if query.isEmpty {
return flags
} else {
let search = query.lowercased()
return flags.filter { $0.name.lowercased().contains(search) }
}
}
.assign(to: &$filtered)
}
}
public struct FeaturesView: View {
@StateObject var viewModel: FeaturesViewModel
public init(flags: [Flag]) {
self._viewModel = .init(wrappedValue: .init(flags: flags))
}
public var body: some View {
List(viewModel.filtered) { flag in
FlagView(flag: flag)
}
.searchable(text: $viewModel.search)
.navigationTitle("Search Features")
}
}
struct FlagView: View {
@ObservedObject var flag: Flag
init(flag: Flag) {
self.flag = flag
}
var binding: Binding<Bool> {
.init {
flag.isEnabled
} set: { newValue in
flag.override(value: newValue)
}
}
var body: some View {
HStack {
Text(flag.name)
if flag.overriden {
Text("Overridden")
.font(.caption2)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background {
Capsule()
.fill(.red)
}
}
Spacer()
if flag.overriden {
Button {
flag.reset()
} label: {
Image(systemName: "gobackward")
}
}
Toggle("Enabled", isOn: binding.animation())
.labelsHidden()
}
}
}
iOS App Usage
Now in our iOS app we can use this library with the macro and property wrapper we created earlier. Start by importing the Features
library and adding it as a dependency to your target.
Features.swift
Here we create our feature set with some custom flags.
import Features
@FeatureSet
enum Features {
@Feature("BlueText") static var blueText
@Feature("RedBackground") static var redBackground
}
ContentView.swift
Updating our ContentView
we can showcase how our new feature flag system works. I will explain a couple of things below:
import Combine
import Features
import SwiftUI
extension Color {
static let systemBackground = Color(uiColor: .systemBackground)
}
@MainActor final class VM: ObservableObject {
@Published var color: Color = .systemBackground
init() {
// 1
Features
.$redBackground
.receive(on: RunLoop.main)
.map { $0 ? Color.red : .systemBackground }
.assign(to: &$color)
}
}
struct ContentView: View {
@State private var show = false
@State private var count = 0
@StateObject private var vm = VM()
var body: some View {
NavigationStack {
ZStack {
vm.color.ignoresSafeArea()
VStack {
// 2
Text("Hello, world! \(count)")
.background(Features.blueText.isEnabled ? Color.blue : Color.clear)
Button("Refresh") { count += 1 }
// 3
Button("Simulate FF Sync") {
Features.update(with: [
"BlueText": Bool.random(),
"RedBackground": Bool.random()
])
}
}
}
.navigationTitle("Feature Test")
.toolbar {
Button("Features") {
show = true
}
}
}
.sheet(isPresented: $show) {
FeaturesSheet()
}
}
}
struct FeaturesSheet: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
FeaturesView(flags: Features.allCases)
.navigationTitle("Features")
.toolbar {
Button("Close", action: dismiss.callAsFunction)
}
}
}
}
-
Here is an example of how we can subscribe to changes. You can see when the feature sheet is presented, changing the background toggle will update the main background color automatically
-
Here we are using an imperitive check that is not subscribed to changes, so it won't re-evaluate unless its view does. This is why there is a refresh button that changes the count, which in turn invalides the Text
and causes its .background(...)
to re-execute and again evaluate the enabled status of the flag.
-
This randomly sets the flags, "simulating" an update that an API would probably do:
let payload: [String: Bool] = try await featureService.fetch()
Features.update(with: payload) // any subscribers to any of the flags will be updated!
Conclusion
This was a pretty throw together example, but I think it does a good job of showcasing a simple macro and propertywrapper and how they can work together to reduce a lot of the code you write and repetition.
As always, if there is anything glaringly wrong or a better way to do something, let me know!
Tagged With: