SwiftyBridgesVapor

0.2.0

Simple server APIs in Swift
SwiftyBridges/SwiftyBridgesVapor

What's New

0.2.0

2021-09-24T19:47:26Z

Allow running server under Swift 5.4

SwiftyBridges

  • Are you or your team working on a server and client in Swift?
  • Are you tired of worrying about HTTP and generating requests and responses?
  • Do you want to skip cobbling together an API client?

SwiftyBridges is here to help! 😎

What is SwiftyBridges?

SwiftyBridges lets you write the server logic in a simple way and then automatically generates an API client plus all communication code for both server and client.

Server code:

import SwiftyBridges
import Vapor

struct HelloAPI: APIDefinition {
    var request: Request
    
    public func hello(firstName: String, lastName: String) -> String {
        "Hello, \(firstName) \(lastName)!"
    }
}

Client code:

import SwiftyBridgesClient

let api = HelloAPI(url: serverURL)

let greeting = try await api.hello(firstName: "Swifty", lastName: "Bridges")
print(greeting)

Requirements

Server: Vapor >= 4.0

Code generation: Xcode 13.0

Client: Swift >= 5.5

Usage

Server

Create an API definition:

import SwiftyBridges

struct IceCreamAPI: APIDefinition {
    var request: Request
    
    public func getAllFlavors() -> [IceCreamFlavor] {
        [
            IceCreamFlavor(name "Chocolate"),
            IceCreamFlavor(name "Vanilla"),
        ]
    }
}

Conform the API definition struct to APIDefinition and make methods that shall be available to the client public.

All parameter and return types of public must conform to Codable (or be futures of Codable types):

struct IceCreamFlavor: Codable {
    var name
}

Create an instance of APIRouter:

import SwiftyBridges

let apiRouter = APIRouter()

Register all API definitions:

apiRouter.register(IceCreamAPI.self)

Set up a POST route for the API router:

app.post("api") { req -> EventLoopFuture<Response> in
    apiRouter.handle(req)
}

Optional Features

API methods may return futures of Codable values:

public func getAllFlavors() -> EventLoopFuture<[IceCreamFlavor]> {
    ...
}

API methods may throw:

public func getAllFlavors() throws -> EventLoopFuture<[IceCreamFlavor]> {
    ...
}

API definitions may use middlewares:

struct IceCreamAPI: APIDefinition {
    static let middlewares: [Middleware] = [
        UserToken.authenticator(),
        User.guardMiddleware(), // <- Optional
    ]
    
    var request: Request
    var user: User
    
    init(request: Request) throws {
        self.request = request
        self.user = try request.auth.require(User.self)
    }
    
    ...
}

Code generation

⚠️ Code generation currently needs the command line tools of Xcode 13.0

Using Mint

Ensure Mint is installed:

$ brew install mint

Then run:

$ mint run SwiftyBridges/SwiftyBridgesVapor@0.1.1 [path to server package]/Sources/App

(The first time you run this, this may take several minutes.)

This will generate the files ServerGenerated.swift and ClientGenerated.swift. Make sure that these files are in the right directories (ServerGenerated.swift in the server project and ClientGenerated.swift in the client project) and are compiled.

Alternatively, you can add --server-output [path to server package]/Sources/App/Generated.swift --client-output [path to client code]/Generated.swift to the command to directly generate the swift files in the correct places.

Manually

To generate the communication code for both server and client, run the following commands in terminal:

$ git clone https://github.com/SwiftyBridges/SwiftyBridgesVapor.git
$ cd SwiftyBridgesVapor
$ swift run BridgeBuilder [path to server package]/Sources/App

This will generate the files ServerGenerated.swift and ClientGenerated.swift. Make sure that these files are in the right directories (ServerGenerated.swift in the server project and ClientGenerated.swift in the client project) and are compiled.

Alternatively, you can add --server-output [path to server package]/Sources/App/Generated.swift --client-output [path to client code]/Generated.swift to the command to directly generate the swift files in the correct places.

Client

Make sure all Codable types that are used by the API methods are available to the generated code.

Then use the API:

import SwiftyBridgesClient

let api = IceCreamAPI(url: serverURL)

let flavors: [IceCreamFlavor] = try await api.getAllFlavors()

That's it!

Examples

You can find a sample server and client implementation in the Examples repository.

Installation

Server

Add SwiftyBridgesVapor to your Package.swift:

// swift-tools-version:5.5
import PackageDescription

let package = Package(
    name: "MyServer",
    dependencies: [
        .package(url: "https://github.com/SwiftyBridges/SwiftyBridgesVapor.git", .upToNextMinor(from: "0.1.2")),
    ],
    targets: [
        .target(
            name: "App",
            dependencies: [
                .product(name: "SwiftyBridges", package: "SwiftyBridgesVapor"),
            ]
        ),
    ]
)

Client

Add SwiftyBridgesClient with a version matching the version of SwiftyBridgesVapor used by the server in Xcode or to your Package.swift:

// swift-tools-version:5.2
import PackageDescription

let package = Package(
    name: "MyApp",
    dependencies: [
        .package(url: "https://github.com/SwiftyBridges/SwiftyBridgesClient.git", .upToNextMinor(from: "0.1.1")),
    ],
    targets: [
        .target(
            name: "MyApp",
            dependencies: [
                .product(name: "SwiftyBridgesClient", package: "SwiftyBridgesClient"),
            ]
        ),
    ]
)

Authentication

A simple way to implement authentication is via bearer tokens:

On the server, use BearerAuthenticator or ModelTokenAuthenticatable as described in the Vapor documentation.

For example, if you are using Fluent, conform your token model to ModelTokenAuthenticatable:

extension UserToken: ModelTokenAuthenticatable {
    static let valueKey = \UserToken.$value
    static let userKey = \UserToken.$user

    var isValid: Bool {
        Date() < expirationDate // <- If tokens do not expire, simply return true
    }
}

Then you can restrict one of your API definitions to logged in users:

struct IceCreamAPI: APIDefinition {
    static let middlewares: [Middleware] = [
        UserToken.authenticator(),
        User.guardMiddleware(), // <- Only needed if you don't use the `init()` below.
    ]
    
    var request: Request
    var user: User
    
    init(request: Request) throws {
        self.request = request
        self.user = try request.auth.require(User.self)
    }
    
    ...
}

On the client, you can pass the user token as the bearer token:

let api = IceCreamAPI(url: serverURL, bearerToken: userToken)

Authentication may also be done by:

  • Explicitly passing the user token:
    public func getAllFlavors(userToken: String) -> [IceCreamFlavor]
  • Passing authentication information in the URL query:
    let api = IceCreamAPI(url: serverURLWithConfiguredQuery)
  • Passing authentication information in HTTP headers:
    let api = IceCreamAPI(baseRequest: requestWithPresetHTTPHeaders)

Login

Login may for example be implemented using an unauthenticated API definition like so:

import Fluent
import SwiftyBridges
import Vapor

/// Allows the user to log in and to register an account
struct LoginAPI: APIDefinition {
    var request: Request
    
    /// Allows the user to log in
    /// - Parameters:
    ///   - username: The username of the user
    ///   - password: The password of the user
    /// - Returns: A user token needed to perform subsequent API calls for this user
    public func logIn(username: String, password: String) throws -> EventLoopFuture<String> {
        User.query(on: request.db)
            .filter(\.$name == username)
            .first()
            .flatMapThrowing { foundUser -> UserToken in
                guard
                    let user = foundUser,
                    try user.verify(password: password)
                else {
                    throw Abort(.unauthorized)
                }
                return try user.generateToken()
            }.flatMap { token in
                token.save(on: request.db)
                    .map { token.value }
            }
    }
}

The client can then use the returned user token as the bearer token as explained above.

Login Expiration

If the login has expired, the server can throw an Abort(.unauthorized) or just use a middleware like UserToken.authenticator() in combination with User.guardMiddleware().

On the client-side, this can be handled like this:

let iceCreamAPI = IceCreamAPI(url: serverURL, bearerToken: userToken)

let httpErrors = iceCreamAPI.errors
    .compactMap { $0 as? HTTPError }

Task {
    if await httpErrors.first(where: { $0.isUnauthorizedError }) != nil {
        handleExpiredLogin()
    }
}

Current Limitations

  • SwiftyBridges currently only supports Vapor on the server-side
  • Server-side API methods do not currently support the following features:
    • Default parameter values
    • Variadic parameters
    • async
  • All errors thrown by API methods are currently converted to HTTPError on the client
  • Running code generation as part of the server code compilation is currently not supported. This will hopefully change when Package Plugins land in Swift 5.6.

If any of these limitations is bothering you, please get in touch.

Description

  • Swift Tools 5.4.0
View More Packages from this Author

Dependencies

Last updated: Fri Jan 12 2024 23:35:10 GMT-1000 (Hawaii-Aleutian Standard Time)