Give your simulator superpowers

RocketSim: An Essential Developer Tool
as recommended by Apple

Win a iOSKonf '24 conference ticket with thisfree giveaway

Share Swift Code between Swift On Server Vapor and Client App

Sharing Swift code between a backend and client app is one of the benefits you’ll get when working with Swift on a Server. You can create dedicated Swift Packages containing sharable logic for both client and backend.

While many developers mention sharing Swift code as one of the benefits of Swift on the server, it’s only sometimes immediately clear how to best do this. Sharing models is pretty straightforward, but how about sharing endpoints? While I started writing a backend for RocketSim in Swift, I decided to dive into a possible generic solution that allows you to reuse both models and endpoints with Vapor.

The importance of sharing code

If you can share code between your client and the backend, you’ll be able to set up constraints that ensure both sides work correctly together. Once you change a model structure in your backend, your client will automatically have to adapt to the new changes. You might not notice and run into unexpected decoding errors without reusing the same model layer.

Defining the project structure for sharing Swift code

Before diving into code examples, it’s essential to look at the package structure for sharing Swift code. We will be using Swift Package Manager to define our packages. If you’re new to Swift packages, read my article Swift Package Manager framework creation in Xcode.

When defining your package structure, you want to keep the number of unused dependencies as low as possible. For example, the package used by both client and backend should not fetch all Vapor-related dependencies since you won’t use them in your client app. In my case, I decided to go for the following structure:

  • Client App
  • Models & Endpoint definitions Package
  • Backend Package

In other words, both the client and backend depend on a shared package containing all models and endpoint definitions.

Sharing code for models

It’s useful for you to share models like request responses to set up contracts for expected results between the client and the backend. Since we’re working with Swift packages, we have to take care of exposing essential parts using the public keyword. Similar to fileprivate and private, the public keyword is part of the access control modifiers in Swift.

As an example, you might have a backend to return articles. The article struct could look as follows:

struct Article: Codable {
    let author: String
    let title: String
}

While this code works fine within the package, it won’t be usable for anyone depending on your shared layer. Therefore, we need to expose the type, parameters, and initializer:

public struct Article: Codable {
    public let author: String
    public let title: String

    public init(author: String, title: String) {
        self.author = author
        self.title = title
    }
}

We can now create new Article instances from within the backend package to return as a request-response, but we can also use the Article for decoding response data in our client layer.

Sharing Endpoint definitions

Sharing models Swift code is relatively easy compared to sharing endpoint definitions. The latter includes more factors like the HTTP method, path definition, request parameters, and response model type. All these have to be defined in a central place so that:

  • The client can use the endpoint definition to perform requests with the backend
  • The backend can use the endpoint definition to correctly configure endpoints

Like our models, we ensure client and backend expect similar endpoint behavior. If the backend changes anything in one of the endpoints, the client will have to adapt automatically.

We start by defining an APIEndpoint protocol:

public enum HTTPMethod: String {
    case GET, POST
}

public protocol APIEndpoint {
    associatedtype BodyParameters: Codable
    associatedtype Response: Codable

    static var path: String { get }
    static var httpMethod: HTTPMethod { get }
    static var response: Response.Type { get }

    var parameters: BodyParameters { get }
}

For now, it’s only supporting GET and POST, but you can easily add more cases if needed. The protocol defines two associated types:

  • BodyParameters: a codable instance that you can use for request definition and response decoding
  • Response: a codable instance to use on the backend for returning the correct response body, as well as to decoding response data inside the client

By using this protocol we can define an endpoint to get the article for a specific identifier:

public struct ArticleEndpoint: APIEndpoint {
    public static let path: String = "remove"
    public static let httpMethod: HTTPMethod = .POST
    public static let response: Article.Type = Article.self
    public let parameters: ArticleBody
}

public struct ArticleBody: Codable {
    public let id: String

    public init(id: String) {
        self.id = id
    }
}

Configuring endpoints inside your backend package

We now have to ensure our backend correctly configures all shared endpoints. To simplify this process, we will use a generic method as an extension of Vapor’s RoutesBuilder:

extension SharedArticlesLogicPackage.HTTPMethod {
    var nioHTTPMethod: NIOHTTP1.HTTPMethod {
        switch self {
        case .GET: return .GET
        case .POST: return .POST
        }
    }
}

extension RoutesBuilder {
    @discardableResult
    func endpoint<T: APIEndpoint>(
        _ endpoint: T.Type,
        use closure: @escaping (Request, T.BodyParameters) async throws -> T.Response
    ) -> Route where T.Response: AsyncResponseEncodable
    {
        return self.on(endpoint.httpMethod.nioHTTPMethod, PathComponent(stringLiteral: endpoint.path)) { request in
            let content = try request.content.decode(endpoint.BodyParameters)
            return try await closure(request, content)
        }
    }
}

The code might look intimidating initially, but it’s building upon the same structure as Vapor’s endpoint definition methods. We have to extend our shared-package-defined HTTPMethod to allow converting to a NIOHTTP1.HTTPMethod instance. You can replace SharedArticlesLogicPackage with the name of your shared package.

Now that we have this generic logic in place, we can start defining our API endpoint:

extension Article: Content { }
extension ArticleBody: Content { }

extension ArticleEndpoint {
    static func register(with routes: RoutesBuilder) {
        routes.endpoint(self) { request, requestBody in
            let articleIdentifier = requestBody.id
            /// Fetch the article for the given ID...

            return Article(
                author: "Antoine van der Lee",
                title: "Share Swift Code between Swift On Server Vapor and Client App"
            )
        }
    }
}

First, we must ensure Article and ArticleBody conform to Vapor’s Content protocol. Secondly, we define a new static register method containing all logic required to make the endpoint work. Finally, we can register the endpoint with our routes builder:

func routes(_ app: Application) throws {
    ArticleEndpoint.register(with: app)
}

Requesting endpoints inside your client

Our final step is to ensure we can call any defined endpoint from within our client. In this case, I’m sharing generic logic to perform POST requests:

@discardableResult
private func request<T: APIEndpoint>(_ endpoint: T) async throws -> T.Response {
    let url = apiHost.baseURL
        .appendingPathComponent(T.path)
    var urlRequest = URLRequest(url: url)
    urlRequest.httpMethod = "POST"
    urlRequest.httpBody = try encoder.encode(endpoint.parameters)

    var headers = urlRequest.allHTTPHeaderFields ?? [:]
    headers["Content-Type"] = "application/json"
    urlRequest.allHTTPHeaderFields = headers

    let (data, _) = try await URLSession.shared.data(for: urlRequest)
    return try decoder.decode(T.response, from: data)
}

You’d like to optimize this code for all supported HTTP methods, but I’ll leave that up as an exercise for the reader. After defining the generic request method, we can start using it for our article endpoint:

func fetchArticle(identifier: String) async throws -> Article {
    let requestBody = ArticleBody(id: identifier)
    let requestEndpoint = ArticleEndpoint(parameters: requestBody)
    let article = try await request(requestEndpoint)
    return article
}

We’ve ensured the backend and client use the same endpoint and model definitions. Due to this contract, our client code will notify us of any changes inside the endpoint definition. For example, if we decide to change the id parameter to identifier:

Sharing Swift Code between your client and backend results in compile-time contracts.
Sharing Swift Code between your client and backend results in compile-time contracts.

Conclusion

Sharing Swift code between your client and backend allows you to ensure both sides are working with the same layer. You prevent yourself from running into unexpected request failures due to a mismatch in an endpoint or model definition. Using a few generic Vapor extensions, we can simplify endpoint definitions.

If you like to improve your Swift knowledge, even more, check out the Swift category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.

Thanks!

 

Stay updated with the latest in Swift

The 2nd largest newsletter in the Apple development community with 18,327 developers.


Featured SwiftLee Jobs

Find your next Swift career step at world-class companies with impressive apps by joining the SwiftLee Talent Collective. I'll match engineers in my collective with exciting app development companies. SwiftLee Jobs