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
.
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):
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;
});
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.
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.
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.