📦 SwiftyLaunch Modules
⚙️ BackendKit
Using BackendKit

Using BackendKit

BackendKit (SwiftyLaunch Module) - Using BackendKit

In this section we will cover how to edit your backend endpoints (cloud functions) how to deploy your changes and how to call them from your app.

What are Backend Endpoints?

In this tutorial we use words "endpoints" and "cloud functions" interchangeably, so don't be confused by that.

Cloud functions are simply functions that you can call from your front-end app. That's it. You don't have to spin up your own server, or worry about running costs, as BackendKit is built on top of Firebase Cloud Functions, which allows you to deploy serverless applications.

What are serverless applications / serverless computing?

These terms are used when you don't have a server running 24/7 waiting for your request. Instead, the server is spun up on-demand, when one of its endpoints is called. This is a great way to save money, as you only pay for the actual time your server is running.

This also means that the first time your endpoint is called, the server might take longer to respond (up to even 10 seconds), as it has to spin up. This is called a cold start. After the first call, the server will stay up for a while, so the next calls will be much faster. So if you get a lot of traffic, you get lightning fast results, while saving money when you don't.

💡

Real world example of this would be wanting to watch a YouTube Video. If your computer is off, you will have to first turn it on, open your browser, type in the URL, and wait for the video to load. (= Server is cold)

But if your computer is already turned on and your browser is already open, it would take mere seconds to open a video and start watching it. (= Server is warm)

The provider (Firebase in our case) also takes care of all the scaling, and maintenance, so you can focus on developing your app instead of having to act the role of Gilfoyle (opens in a new tab).

Your costs are literally $0 when you don't have any traffic (the case for 99% of all indie devs sadly). Terrific!

Creating and calling a new Endpoint

Basic Example

Creating a new endpoint is literally just about creating a special function and returning a value from it.

Don't believe me? Here's a simple example:

import { onCall } from "firebase-functions/v2/https";
 
export const helloWorldFunction = onCall(async (request) => {
  return "Hello World!";
});

Thats it! You just created a new endpoint called helloWorldFunction that returns "Hello World!" when called.

Now how does one call it from the app? (First of it, you obviously have to deploy it)

import FirebaseFunctions
 
struct ContentView: View {
    var body: some View {
        Button("Call Hello World") {
            Task {
                let data = try await Functions.functions().httpsCallable("helloWorldFunction").call()
                print(data.data)
            }
        }
    }
}

This is a simple SwiftUI view that has a button that will call the helloWorldFunction when pressed. The data variable will contain the response from the server, in this case "Hello World!".

Note that the cloud function name (in this case helloWorldFunction) is passed as a string to the httpsCallable function.

It returns a HTTPSCallableResult object, which contains the response from the server. To access the directly returned data, we use the data property of the HTTPSCallableResult object. This data is of type Any, which is fine for printing out the response, but you should cast it to the correct type if you want to use it in your app.

Example of casting the data to an object:

In the cloud function:

import { onCall } from "firebase-functions/v2/https";
 
type HelloWorldResponse = {
  message: string;
  count: number;
  someBooleanValue: boolean;
};
 
export const helloWorldFunction = onCall(async (request) => {
  const response: HelloWorldResponse = {
    message: "Hey There"!,
    count: 3,
    someBooleanValue: true,
  };
  return response;
});

This is how we cast it properly in the app after requesting it. We first define the expected type of the data, and mark it as Codable.

We additionally create a way to initialize it from a dictionary of type [String: Any], which can be interpreted as a JSON object. It will either initialize the object or throw an error if it fails.

struct HelloWorldReceivedDataType: Codable {
	let message: String
	let count: Int
	let someBooleanValue: Bool
 
    init(from rawData: [String: Any]) throws {
		guard JSONSerialization.isValidJSONObject(rawData),
			let data = try? JSONSerialization.data(withJSONObject: rawData),
			let decodedResults = try? JSONDecoder().decode(Self.self, from: data)
		else {
			throw NSError(domain: "Invalid Init Object", code: 0, userInfo: nil)
		}
		self = decodedResults
	}
}
💡

It is always a good idea to type out the returing function in the cloud function and not just returning an implicitly typed object. This way you can be sure that the data you are receiving is the data you are expecting and you can easily compare the send types from your backend with the expected received types in your frontend.

Then, during the request, we first try to cast the received data to a dictionary of type [String: Any]. If it succeeds, we try to then convert the dictionary to our ReceivedDataType object.

import FirebaseFunctions
 
struct HelloWorldReceivedDataType: Codable { /* see above */ }
 
struct ContentView: View {
    var body: some View {
        Button("Call Hello World") {
            Task {
                do {
                    let receivedDataRaw = try await Functions.functions().httpsCallable("helloWorldFunction").call()
 
                    guard let receivedDataDict = receivedDataRaw.data as? [String: Any] else {
                        // ... error handling
                        return
                    }
 
                    let receivedDataConverted = try HelloWorldReceivedDataType(from: receivedDataDict)
                    print(receivedDataConverted)
                } catch {
                    // ... error handling
                    return
                }
            }
        }
    }
}

Passing data to a Cloud Function

You can also pass data to a cloud function. On the front-end just pass your data as a dictionary of type [String: Any] to the call function.

// ...
let contentDict: [String: Any] = ["name": "John"]
let data = try await Functions.functions().httpsCallable("functionWithParams").call(contentDict)
print(data.data)
// ...

On the backend, you can access the passed data in the request object. Get their value using optional chaining (opens in a new tab), and throw an error if the value is not present.

export const functionWithParams = onCall(async (request) => {
  const name = request.data?.name as string | null;
  if (!name) {
    throw new Error("No Name Provided");
  }
  return name;
});

Error Handling

You can always just throw a generic error in the cloud function, but this way, you won't be able to access error codes or messages in the app. Instead, we recommend to use HttpsError

export const functionWithParams = onCall(async (request) => {
  const name = request.data?.name as string | null;
  if (!name) {
    throw new HttpsError("invalid-argument", "No Name Provided");
  }
  return name;
});

This way, you can access the error code and message in the app.

do {
    let contentDict: [String: Any] = ["lastname": "Doe"]
    let data = try await Functions.functions().httpsCallable("functionWithParams").call(contentDict)
    print(data.data)
} catch {
    if let error = error as NSError? {
        if error.domain == FunctionsErrorDomain {
            let code = FunctionsErrorCode(rawValue: error.code)!
            switch code {
                case .invalidArgument: print("Invalid argument")
                default: print("Server error")
            }
            let message = error.localizedDescription
            print(message)
        }
        // ...
    }
    // ...
}

In this example, calling the functionWithParams function with a dictionary that doesn't contain the key name will result in an error with the code invalid-argument and the message No Name Provided.

Console Error Example

SwiftyLaunch of the box definitions

SwiftyLaunch, by default, creates all of the endpoints in the index.ts file, but splits up the module functions, (such as functions related to InAppPurchaseKit or AIKit) in their dedicated files.

Example of an AIKit endpoint and AIKit functions (from the AIKit ChatBot Example App):

index.ts
import * as AI from "./AIKit/AI";
 
// Send a new chat message to AI
// Will return the provided text message + the AI's response
export const sendANewAIChatMessageForCurrentUser = onCall(async (request) => {
  // ... fetch previous chat history
 
  const response = await AI.accessGPTChat({
    text,
    previousChatMessages: chatHistoryInGPTFormat,
  });
 
  // ... append the response to the chat history
 
  // return the new chat history
  return resultingChatHistoryAdditions;
});
AI.ts
export type GPTChatMessage = {};
 
export async function accessGPTChat({
  text,
  previousChatMessages = [],
}: {
  text: string;
  previousChatMessages?: GPTChatMessage[];
}): Promise<string | null> {
  const response = await openai.chat.completions.create({
    // ...
  });
  return response.choices[0].message.content;
}

To call these functions from the client, we have a dedicated BackendFunctions.swift file included in FirebaseKit, which extends the DB class, which is responsible for handling Database-related and Authentication-related operations.

BackendFunctions.swift
extension DB {
    	/// Will return new chat messages to append to current chat history
	public func sendNewAIChatMessage(text: String) async -> [AIChatMessage] {
		// ...
		do {
			let contentDict: [String: Any] = ["text": text]
			let data = try await functions.httpsCallable("sendANewAIChatMessageForCurrentUser").call(contentDict)
 
            // we return an array of objects, so cast it to an array of dictionaries
			guard let messagesRaw = data.data as? [[String: Any]] else {
                // ... error handling
			}
 
            // map the dictionaries to AIChatMessage objects
			return try messagesRaw.map({ try AIChatMessage(from: $0) })
		} catch {
			// ... error handling
		}
	}
}

To call it, we include the DB environment object in our view, and call the function from there.

⚠️

Note that the sendANewAIChatMessageForCurrentUser endpoint expects you to be logged in on the front end, as it fetches the chat history of the currently logged in user from the database. If you are not logged in, you will get an error.

SomeView.swift
import FirebaseKit
import SwiftUI
 
struct YourView: View {
    @EnvironmentObject var db: DB
 
    var body: some View {
        Button("Send Message") {
            Task {
                let messages = await db.sendNewAIChatMessage(text: "Hello")
                print(messages)
            }
        }
        // .requireLogin is an AuthKit modifier that will show a login screen if the user is not logged in
        .requireLogin(db: db, onCancel: {})
    }
}

Running BackendKit locally

Refer to Running BackendKit locally to learn how to run your cloud functions locally.

Deploying Endpoints

To deploy an endpoint, you have to run the following command in the terminal (from the /funtions directory):

firebase deploy --only functions

This will deploy your functions to the Firebase Cloud Functions, after which you can call them from your app. It will detect what functions were actually changed and only deploy those, so you don't have to worry about unnecessary deployments.

In the previous sections, we have created two functions: helloWorldFunction and functionWithParams. If you run the deploy command, both of these functions along with any other functions you have created will be deployed to the cloud and can be seen in the Firebase Console.

Deployed Functions in Console