17 Jan 20208 min read

Making your life easier with Stencil

Recently I have been working on the project which had to fetch data from quite a big service. There would be nothing wrong with that if only I was provided a proper SDK or at least a data model used by the API. Unfortunately for me, it is a really old service and it uses XML as its data format. They are working on the RESTful API using JSON but I just could not wait an unknown amount of time for that. I had to go with the SOAP and all I got was a 20 MB zip file that contained WSDL and XSD files.

The time it would take to write models in Swift by hand was just unreasonable since I needed not just pure models but also a way to convert them both ways - from Swift model to XML and parse XML to Swift model.

The decision was easy as I couldn't afford to spend a few weeks creating hundreds of models by hand and on top of it... by analyzing the XSD files since the documentation was poor. I had to generate all the models and extensions for parsing.

As a tool for this job I picked a Stencil


Stencil is a simple and powerful template language for Swift. It provides a syntax similar to Django and Mustache. If you're familiar with these, you will feel right at home with Stencil.


You can find Stencil on GitHub and it supports all three most commonly used package managers - Swift Package Manager, CocoaPods and Carthage.

It's a really powerful tool that you can use in various ways. Just take a look at one of the most popular projects that use Stencil:

Sourcery - a tool to auto-generate Swift code for resources of your projects, to make them type-safe to use.

SwiftGen - a code generator for Swift language, built on top of Apple's own SourceKit. It extends the language abstractions to allow you to generate boilerplate code automatically.

and there are even more of them.

How to get started?

First of all, we need to create a template. It can be a file with any extension you like if you want you could use .swift as well although it makes things a little bit more complicated so better try to avoid using swift file as a template. There is barely any advantages of doing that anyway since it won't be a fully valid swift file.

It's possible to include your templates in the bundle or can just load them straight from the disk. To add them to the bundle we simply have to add those files to the list of files in the "Copy Bundle Resources" script which you can find under the Build Phases tab in the project's settings.

So first, we need to create an environment object:

// loading from bundle
Environment(loader: FileSystemLoader(bundle: [Bundle.main]))

// loading from disk
Environment(loader: FileSystemLoader(paths: [...]))

If we choose to go with the first option - the Bundle - we have to copy our template files to the actual Bundle. To do that, simply go to the project settings and Build Phases tab. Now, add new "Copy files" script, set the Destination to Products Directory and add your template files to the list. Now you will be able to access those files in the runtime from the Bundle.

let myTemplate = environment.loadTemplate(name: "<TEMPLATE_FILE>") // 1

If you did copy your template files to the Bundle and have specified a certain Subpath - do not forget to include the subpath to the template's name above.

INDIE DEV
MusicHarbor
MusicHarbor

MusicHarbor is an App Store featured app that helps you to track new music releases, music videos, concert dates and news from your favorite artists. Think of it as your personal, chronological feed of every single new release from the artists you follow. It has support for the latest iOS features, and is highly customizable, so you can configure it to work the way you want!

Rendering

Now it's time to render the actual code. To do that we will need an [String: Any] dictionary. Let's say we want to generate models for our project and those models have been parsed from XML files to those 2 sample structs:

struct MyStruct {
    let name: String
    let properties: [MyProperty]

    var dictionary: [String: Any] {
        return [
            "name": name,
            "properties": properties.map { $0.dictionary }
        ]
    }
}

struct MyProperty {
    let name: String
    let type: String

    var dictionary: [String: Any] {
        return [
            "name": name,
            "type": type
        ]
    }
}

Those dictionary variables are just needed to pass to the Stencil template. We could use different (more efficient) ways to create those dictionaries but I did need to do some manipulations while creating dictionaries in my real project so just giving it as an example.

The models will be presented as structs, so we need a Stencil template for that (more about templates you can find right here):

import Foundation

internal struct {{ name }} {
{% for property in properties %}    let {{ property.name }}: {{ property.type }}
{% endfor %}}

And now, just pass the MyStruct item to the template we created earlier:

let item = MyStruct(name: "OrdinaryCoding", properties: [
    MyProperty(name: "title", type: "String"),
    MyProperty(name: "description", type: "String"),
    MyProperty(name: "address", type: "URL")
])

let rendered = try? myTemplate.render(item.dictionary)

and we will have beautifully rendered struct:

import Foundation

internal struct OrdinaryCoding {
    let title: String
    let description: String
    let address: URL
}

There is more!

We can also create our own filters and rules as well as use nested templates.

To use nested templates simply include the other one in your template:

{% include "templates/xsd-type-properties.stencil" %}

If you plan to generate Swift code and the source of your data might not be the highest quality you might ie. like me, want to generate a filter that will make sure the name of your properties is Swift-compatible. To do that we have to create a Filter and register it in our Environment.

First of all, let's create a filter:

extension Extension {
    struct Filters {
        enum Error: Swift.Error {
            case invalidInputType
            case invalidOption(option: String)
        }

        static func swiftProperty(_ value: Any?) throws -> Any? {
            let string = try Filters.parseString(from: value)
            return string.swiftProperty
        }

        static func parseString(from value: Any?) throws -> String {
            if let losslessString = value as? LosslessStringConvertible {
                return String(describing: losslessString)
            }
            if let string = value as? String {
                return string
            }

            throw Error.invalidInputType
        }
    }
}

it uses the String extension, that adjusts the property's name to make sure it's Swift-compatible:

extension String {
    static func indent(level: Int) -> String {
        guard level > 0 else {
            return ""
        }

        let prefix = String(repeating: "    ", count: level)

        return prefix
    }

    var isNumber: Bool {
        return !isEmpty && rangeOfCharacter(from: CharacterSet.decimalDigits.inverted) == nil
    }

    var removedWhitespaces: String {
        let comps = components(separatedBy: CharacterSet.whitespacesAndNewlines)
        return comps.joined(separator: "")
    }

    var lowerFirstLetter: String {
        let first = String(prefix(1)).lowercased()
        let other = String(dropFirst(1))
        return first + other
    }

    var swiftProperty: String {
        var first = String(prefix(1)).replacingOccurrences(of: "-", with: "negative")

        if first.isNumber {
            first = "_\(first)"
        }

        let other = String(dropFirst(1))
        let property = (first + other)
            .removedWhitespaces
            .lowerFirstLetter
            .replacingOccurrences(of: "-", with: "")
            .replacingOccurrences(of: "/", with: "")
            .replacingOccurrences(of: ")", with: "")

        switch property {
        case "class",
             "extension",
             "default",
             "return",
             "private",
             "operator":
            return "_\(property)"
        default: return property
        }
    }
}

Now, we need to register our new filter. We can do that by creating a Stencil's Extension object and register it there, and then pass this object to our Environment.

let ext = Extension()
ext.registerFilter("swiftProperty", filter: Extension.Filters.swiftProperty)

Environment(loader: FileSystemLoader(bundle: [Bundle.main]), extensions: [
    `extension`
])

and now, we will be able to use that filter in our template to make sure the property's name is correct.

{{ property.name|swiftProperty }}

INDIE DEV
MusicHarbor
MusicHarbor

MusicHarbor is an App Store featured app that helps you to track new music releases, music videos, concert dates and news from your favorite artists. Think of it as your personal, chronological feed of every single new release from the artists you follow. It has support for the latest iOS features, and is highly customizable, so you can configure it to work the way you want!

Conclusion

In my article, I showed you the most basic usage of Stencil for code generation. But even such a simple thing like generating structs for our project can be automated... or even more, it should be automated especially if your project is not just an app in Swift but also the other developer writes it ie. in Kotlin for Android. You can share all the models' structure and be sure that no one is missing anything in case anything changes over time.

Once you start adding more advanced templates, like for example, it might be an extension to your model for parsing it to XML and backward which I needed in my project or even some other logic dedicated to your models.

We always should make sure we avoid as many bugs as possible, and code generation wherever possible helps us with that. It might look like a small issue if you only have a few models in your project but in my case, I had over 1000 structs and enumerations. I cannot imagine writing it by hand, together with an XML parsing extension.

Thank you for reading and have a good day!