Flint’s new Feature Constraints DSL

Uncategorized

Flint is our open source framework for tackling a lot of Apple platform boilerplate code. One of the many powerful things it gives you is support for Conditional Features in your apps. You define how your feature is toggled on or off, and your code is not allowed to even run the actions associated with the feature unless all those toggles are on. This post is about some great improvements to this mechanism and how we tackled them.

For those unfamiliar, a DSL is a Domain Specific Language, a sort of mini-programming language that you create inside your existing code to enable users (or programmers) to reason about a problem using terminology specific to a “domain” — a particular area of knowledge or responsibility. I experienced this in the past mostly in Grails and Groovy, but they are used in many other languages and frameworks such as Rails.

Until this week, Flint had support for Conditional Features that used simple property conventions to allow you to control whether or not a feature was available at runtime. It worked like this:

final class DocumentSharingFeature: ConditionalFeature {
    static var availability: FeatureAvailability = .runtimeEvaluated

    /// Change this to `false` to see Sharing be unavailable
    static var isAvailable: Bool? = true

    …    
}

It actually supported four different availability checks:

  • .runtimeEvaluated — the value of the feature’s isAvailable would not be cached and so you could change this at runtime in your code, by calling into whatever you liked to get the value
  • .purchase(requirement) — the feature requires one or more purchases in order to be enabled
  • .userToggled — the feature could be turned on or off manually at runtime, with the user’s preference stored

This was a good start as you can ultimately override isAvailable and do whatever you like if Flint doesn’t cater for it, but in discussions with other developers it became clear we can and should do a lot more out of the box. System permissions such as Photo library or Contacts access in particular were definitely an improvement to consider.

Having looked at all the things you might reasonably use to control feature availability, I split them into “ownership” groupings initially and decided we’re going to use the term “feature constraints” from now on:

  1. User constraints: this includes purchase requirements, user toggling (a boolean set as a user preference), custom authorisations such as whether they have set up a Twitter account or not, system permissions — such as Camera access and Location “When In Use” and more
  2. Developer constraints: boolean runtime checks toggled by code at runtime, A/B testing variants, custom permissions (e.g. LDAP integration, “magical” internal users)
  3. Environment constraints: limitations on supported platforms & OS versions, capabilities such was whether significant change location monitoring is supported on the device and app running mode i.e. background/foreground

Once you look at all this you see that there are some nuances that pop out that are not going to be handled well just with convention properties. To constrain features for the scenarios above you need some particular behaviours:

  • Platform compatibility and minimum version requirements are mutually exclusive. Only the current platform’s constraint should be enforced, and only one minimum version requirement is valid at a time.
  • User-based constraints including the granted system permissions are manifold, not mutually exclusive, and they are additive. All user constraints must be satisfied for a feature to be enabled
  • Developers will need to be able to add custom permissions, perhaps to integrate with another permissions system, LDAP services or “blessed” internal users.
  • Developers will need to be able to change the environment constraints on a per-platform basis. e.g. you shouldn’t require a system permission such as HealthKit data access on macOS or tvOS which don’t support this currently.
  • Developers will sometimes need to apply logic that controls what the conditions are on a feature, say for specific build flavours or backward compatibility with older app versions.

All of these led to us dropping the availability convention property for a new Feature Constraints DSL that could support these complex mixtures of requirements. We’ve worked through several iterations already and the initial version is landing on master imminently.

Here’s what the first draft looks like:

final class DocumentSharingFeature: ConditionalFeature {
    static func constraints(requirements: FeatureConstraintsBuilder) {
        requirements.iOS = 9
        requirements.macOS = "10.13"
        requirements.watchOS = .any 

        requirements.precondition(.runtimeEnabled)
        requirements.precondition(.userToggled(defaultValue: true))
        requirements.permissions(.camera, .photos, .location(usage: .whenInUse)
    }

    /// Change this to `false` to see Sharing be unavailable
    static var isEnabled: Bool? = true
}

The above translates to:

  • Requires iOS 9 or higher, if running on iOS
  • Requires macOS 10.13 or higher if running on macOS
  • Is available on any version of tvOS (implied by no setting)
  • Is available on any version of watchOS
  • Requires the isEnabled property to be true at runtime, and this can change at runtime if needed
  • Requires authorisation for Camera access, Photo library access and Location (when in use)

Some of that syntax might look pretty strange, as we do some DSL tricks utilising properties and function overloads as well as Swift ExpressibleBy conformance.

We pass a builder object to your Feature’s constraints() function that provides the DSL you can use to build your feature requirements. The DSL implementation builds up the internal data structures required for the constraints.

Using platform requirements

The platform requirements are implemented as property setters on the builder itself, of enum type PlatformVersionConstraint.

static func constraints(requirements: FeatureConstraintsBuilder) {
    requirements.iOS = 9
    requirements.macOS = "10.13"
    requirements.watchOS = .any 
}

These special properties can be assigned UInt literals, String literals and the enum itself which has .any and .atLeast(version:) and .unsupported cases.

The UInt and String support is a very convenient way to specify an “at least” version without the full syntax of the enum, and reads nicely. This is achieved by making the enum conform to ExpressibleByIntLiteral and ExpressibleByStringLiteral. The string support is needed for major.minor and major.minor.patch requirements.

Using preconditions

We have support for one or more preconditions using overloaded functions precondition(condition) and variadic preconditions(…) where the condition is a value of the FeaturePrecondition type:

static func constraints(requirements: FeatureConstraintsBuilder) {
    requirements.precondition(.runtimeEnabled)
    requirements.precondition(.userToggled(defaultValue: true))

    requirements.preconditions(
        .purchase(requirement: ProductRequirement(premiumProduct)),
        .purchase(requirement: ProductRequirement(basicProduct))
    )
}

Here it uses the runtime isEnabled check and the User Toggling requirements, using separate function calls. The in-app purchase requirements are added with the variadic plural form of the preconditions function. In future we may add more syntactic sugar for purchases() so you don’t have to call the precondition function.

This function-based approach allows you to add some logic evaluated at compile time or app startup that can change the preconditions for a given build or user. You might place an #if around some of it for example:

static func constraints(requirements: FeatureConstraintsBuilder) {
    requirements.precondition(.runtimeEnabled)
    requirements.precondition(.userToggled(defaultValue: true))

// Don't require purchases for dev builds, it's a pain!
#if !DEBUG
    requirements.preconditions(
        .purchase(requirement: ProductRequirement(premiumProduct)),
        .purchase(requirement: ProductRequirement(basicProduct))
    )
#endif
}

This would not have been feasible without a lot of repetition using property conventions like availability as you cannot add logic in the middle of array literals, so the function-based additive nature of this works well.

Using system permissions

Declaring the permissions required for a feature is just like using precondition and the same mechanism exists to support multiple permissions in one call to permissions using variadic function arguments.

static func constraints(requirements: FeatureConstraintsBuilder) {
    requirements.permission(.camera)
    requirements.permissions(.photos, .location(usage: .whenInUse))
}

The same rationale applies here – offering both these functions allows you to apply extra build or run-time logic to selectively require certain permissions.

Note that having both of these function overloads is not strictly necessary as the variadic plural form can take just a single argument, but it reads better like this and the extra overload requires no real effort.

Building the DSL in Swift

You might wonder what it means to make a DSL in Swift. There’s nothing particularly hard about it — you are just defining properties and functions on some kind of “builder” type that constructs the internal data structures you need to evaluate the “rules” that the programmer writes using the DSL.

Some DSLs can benefit from internal “node” types that are returned and used to call further functions and assign property values, and our DSL will need that in future when we add support for preconditions and permissions required only for specific platform & OS versions. All in good time!

The challenge is honing the concepts you are trying to expose and how they should manifest. What mental model is the user of the DSL going to need? What are the interactions between the different kinds of things you can do with the DSL?

In our case, the FeatureConstraintsBuilder protocol has only three simple requirements:

public protocol FeatureConstraintsBuilder: AnyObject {
    func platform(_ requirement: PlatformConstraint)

    func precondition(_ requirement: FeaturePrecondition)

    func permission(_ permission: SystemPermission)
}

The default implementation of this builder only supplies those, and you can use those in your DSL code for the long-form approach. That’s already cool, meets all the functional needs were have, and allows our implementations to be very simple as there are very few things to implement. You can ship that, but the usage would look like this:

static func constraints(requirements: FeatureConstraintsBuilder) {
    requirements.platform(PlatformConstraint(platform: .iOS, version: 9)
    requirements.platform(PlatformConstraint(platform: .macOS, version: "10.13")
    requirements.platform(PlatformConstraint(platform: .watchOS, version: .any)

    requirements.precondition(.runtimeEnabled)
    requirements.precondition(.userToggled(defaultValue: true))

    requirements.permission(.camera)
    requirements.permission(.photos)
    requirements.permission(.location(usage: .whenInUse))
}

That’s fine compared to implementing all this logic yourself but it is pretty verbose and we can definitely do better.

Through the magic that is Swift Protocol Extensions we can add more syntactic sugar that simplifies the platform() calls and the multiple precondition and permission calls, while not putting these on the actual implementation of the builder. This means alternative implementations such as a test mock builder implementation gets the benefit of these syntactic enhancements without having to implement them!

The syntactic sugar provided in the protocol extension is entirely implemented by calling the members of the base protocol itself:

/// Syntactic sugar
public extension FeatureConstraintsBuilder {
    public func preconditions(_ requirements: FeaturePrecondition...) {
        for requirement in requirements {
            self.precondition(requirement)
        }
    }

    public func permissions(_ requirements: SystemPermission...) {
        for requirement in requirements {
            self.permission(requirement)
        }
    }

    public var iOS: PlatformVersionConstraint {
        get {
            fatalError("Not supported, you can only assign in this DSL")
        }
        set {
            self.platform(.init(platform: .iOS, version: newValue))
        }
    }

    public var watchOS: PlatformVersionConstraint {
        get {
            fatalError("Not supported, you can only assign in this DSL")
        }
        set {
            self.platform(.init(platform: .watchOS, version: newValue))
        }
    }

    …
}

This is really neat, and means we end up with the DSL code from above looking like this:

static func constraints(requirements: FeatureConstraintsBuilder) {
    requirements.iOS = 9
    requirements.macOS = "10.13"
    requirements.watchOS = .any 

    requirements.preconditions(.runtimeEnabled, .userToggled(defaultValue: true))
    requirements.permissions(.camera, .photos, .location(usage: .whenInUse))
}

I know which I prefer to read and write. Note that through the use of Swift’s strong typing we get autocompletion on all of these which means you don’t have to remember all the possible preconditions or permissions.

We have a lot more we will add to future revisions of the Feature Constraints DSL. We’ll be adding per-platform version specific preconditions and permissions soon, and support for custom permissions and app execution states in future, probably post the 1.0 final release because if you really need those there are already other ways to achieve them.

The beauty of these conventions in DSLs is that once the infrastructure is there, it is usually easy to add new convenient functions. While writing and testing this I noticed that creating features that only work on iOS, it is tedious and error-prone to set the other platforms to unsupported. The intention is also lost somewhat:

static func constraints(requirements: FeatureConstraintsBuilder) {
    requirements.iOS = 9
    requirements.tvOS = .unsupported
    requirements.macOS = .unsupported
    requirements.watchOS = .unsupported
}

So I quickly added a new convenience property for each platform that automatically sets all the others to .unsupported. The above is now reduced to:

static func constraints(requirements: FeatureConstraintsBuilder) {
    requirements.iOSOnly = 9
}

Please take a look at Flint on Github and try out some of the ideas. We think you’ll be surprised at how much you can get done with so little code and with a cleaner code base.

Soon we’ll have a nice simple API for requesting authorization for any permissions that your feature requires but have not yet been granted. The goal is to take a lot of the pain away from all those prompts we need to show users before they authorise, and without spamming them for all the permissions at startup. In fact, with Flint you will only need to prompt for permissions when the users actually try to use the features.

We’d love to hear your thoughts, questions and contributions on the FlintCore Slack too!

The Author

Marc Palmer (Twitter, Mastodon) is a consultant and software engineer specialising in Apple platforms. He currently works on the iOS team of Concepts sketching app, as well as his own apps like video subtitle app Captionista. He created the Flint open source framework. He can also do a pretty good job of designing app products. Don't ask him to draw anything, because that's really embarrassing. You can find out more here.