Creating Cloud Functions in Dart
Photo by Pero Kalimero on Unsplash.
Cloud Functions are serverless functions that are executed each time we access them via an HTTP request. They can be used to react to specific triggers - such as additions in Firebase Storage that trigger a Push Notification, or directly called from our app to access a specific resource using a Service Account.
In this article, we’ll explore a specific usage of Cloud Functions - abstracting Secret API keys from the AccuWeather API and accessing and using it to query the API.
Creating our first Cloud Function
When building our first Dart Cloud Function, we have the bulk of the work is already done for us by the dartfn
package. This package will create:
- Sample Cloud Function code in
lib/functions.dart
- Local server in
bin/server.dart
so that we can test it locally - Tests for the cloud function
- Docker file to host the cloud function
- Makefile to build, run and test the cloud function
To use dartfn
, we must first activate it globally:
$ dart pub global activate dartfn
Next, we create a new directory for our cloud function:
$ mkdir example_function && cd example_function
And finally, we can use dartfn
to populate this folder with a template. Since we want to create a simple Cloud Function, we’ll use the helloworld
template:
$ dartfn generate helloworld
This results in the following files being created:
.
├── Dockerfile
├── Makefile
├── README.md
├── analysis_options.yaml
├── bin
│ └── server.dart
├── lib
│ └── functions.dart
├── pubspec.yaml
└── test
└── function_test.dart
What we want to be looking at is the lib/functions.dart
file, which is where our cloud functions will be:
import 'package:functions_framework/functions_framework.dart';
import 'package:shelf/shelf.dart';
@CloudFunction()
Response function(Request request) => Response.ok('Hello, World!');
Basically, our cloud function will accept any request and reply with a String message saying Hello World
.
To test it locally, we first need to run our server:
$ dart bin/server.dart
This server will be hosted at localhost:8080
, which means we will be able to access our cloud function by creating a HTTP POST
request via curl
:
$ curl -i localhost:8080
HTTP/1.1 200 OK
date: Sun, 14 Nov 2021 10:47:30 GMT
content-length: 13
x-frame-options: SAMEORIGIN
content-type: text/plain; charset=utf-8
x-xss-protection: 1; mode=block
x-content-type-options: nosniff
server: dart:io with Shelf
Hello, World!%
And, as expected, it replies back with “Hello, World!”.
Using a Cloud Function to hide Secret Keys
Let us imagine we are developing a weather application that needs to retrieve data from a private 3rd party service. Most 3rd party services will provide us with some sort of API key that will be linked to our billing account - the more calls we make to their services, the more we will have to pay at the end of the month.
If we use these keys directly in our Flutter applications, there’s always a chance that someone will try to either reverse engineer the mobile and desktop apps or see the main.dart.js
file on Flutter Web to look for these keys and use them for their purposes and needs.
A common way to go around this is to hide these secret keys behind a backend server - we expose an endpoint in our server that uses this API key, processes the data, and then gives it back to us. However, sometimes it is not feasible to create a full-blown backend server just for this. Instead, we can create a Cloud Function whose purpose is to access a specific API endpoint and return the response to the application.
As an example, we will want to use AccuWeather to get the daily forecast for a specific city.
Looking at the documentation, we will need to use two endpoints:
- City Search endpoints will get us a list of cities with their
keys
- We use the
keys
from the previous step and call the 1 Day of Daily Forecasts
Accepting body data from the request
As seen in the helloworld
example, the Cloud Function will accept a Request
object, which allows us to read the body
of the HTTP POST
request using the readAsString
:
@CloudFunction()
Future<Response> function(Request request) async {
final queryBody = await request.readAsString();
}
Since we want to query this cloud function with a city name, we can stipulate that we will accept a JSON
body with the following structure:
{
"query": "<city_name>"
}
As such, we can get now the query parameter from the request body:
@CloudFunction()
Future<Response> function(Request request) async {
final queryBody = await request.readAsString();
final query = jsonDecode(queryBody)['query'];
}
The query
variable is what we are going to use when we use the Cities Search AccuWeather endpoint.
Searching for Cities in AccuWeather
Now that we have our query from the request, we can use it to make a call to AccuWeather’s Cities API. For convenience, the Location
class was created to deserialize the response.
@CloudFunction()
Future<Response> function(Request request) async {
final queryBody = await request.readAsString();
final query = jsonDecode(queryBody)['query'];
final searchUri = Uri.parse(
"http://dataservice.accuweather.com/locations/v1/cities/search?apikey=$weatherApiKey&q=$query");
final cities = await http
.get(searchUri)
.then((response) =>
jsonDecode(response.body) as Iterable
)
.then((locations) => locations.map((value) => Location.fromMap(value)))
.then((list) => list.take(3));
}
We are using take(3)
to only get 3 results from our search. This means that we will only search for the daily forecast of the first 3 cities that matches our search criteria.
However, what happens if there are no cities available?
In that case, we should return a response from the cloud function with an empty JSON:
@CloudFunction()
Future<Response> function(Request request) async {
///...
if (cities.isEmpty) {
return Response.ok("{}");
}
}
Obtaining the Weather Forecast
We can to query the list of cities from the AccuWeather API and get the forecast for each:
@CloudFunction()
Future<Response> function(Request request) async {
// ...
for (var city in cities) {
final weatherSearch = Uri.parse(
'http://dataservice.accuweather.com//forecasts/v1/daily/1day/${city.key}?apikey=$weatherApiKey&metric=true');
final weather = await http
.get(weatherSearch)
.then((response) => jsonDecode(response.body))
.then((weather) => Weather.fromMap(weather));
}
}
Adding a Response to our Cloud Function request
The first thing we should determine is: what data does the application need to display?
For now, we can say that it needs:
- The city name
- The country name
- The maximum temperature
- The minimum temperature
- The date for the forecast
With this information, we can create a new class that encapsulates all this data, with a toJson
method that allows serializing the content.
import 'dart:convert';
class WeatherLocation {
final String location;
final String country;
final int epochTime;
final double maximum;
final double minimum;
WeatherLocation({
required this.location,
required this.country,
required this.maximum,
required this.minimum,
required this.epochTime,
});
Map<String, dynamic> toMap() {
return {
'location': location,
'country': country,
'epochTime': epochTime,
'maximum': maximum,
'minimum': minimum,
};
}
String toJson() => json.encode(toMap());
factory WeatherLocation.fromMap(Map<String, dynamic> map) {
return WeatherLocation(
location: map['location'],
country: map['country'],
epochTime: map['epochTime'],
maximum: map['maximum'],
minimum: map['minimum'],
);
}
factory WeatherLocation.fromJson(String source) => WeatherLocation.fromMap(json.decode(source));
}
Next, we can change our forecast query loop to create a new WeatherLocation
object each time we get a response from the server:
@CloudFunction()
Future<Response> function(Request request) async {
// ...
final List<WeatherLocation> result = <WeatherLocation>[];
for (var city in cities) {
final weatherSearch = Uri.parse(
'http://dataservice.accuweather.com//forecasts/v1/daily/1day/${city.key}?apikey=$weatherApiKey&metric=true');
final weather = await http
.get(weatherSearch)
.then((response) => jsonDecode(response.body))
.then((weather) => Weather.fromMap(weather));
result.add(
WeatherLocation(
epochTime: weather.dailyForecasts.first.epochDate,
location: city.localizedName,
country: city.region.englishName,
maximum: weather.dailyForecasts.first.temperature.maximum.value,
minimum: weather.dailyForecasts.first.temperature.minimum.value,
),
);
}
}
Finally, we create a Map
from the WeatherLocation
list, and serialize it to JSON using the jsonEncode
function so that it outputs a String
. This String
is going to be what we send as a response with the Response.ok
function:
@CloudFunction()
Future<Response> function(Request request) async {
//...
final resultMap = result.map((location) => location.toMap()).toList();
return Response.ok(jsonEncode(resultMap));
}
Now, if we run the server via dart bin/server.dart
we can use curl
to get test it:
$ curl -i -X POST -H "Content-Type: application/json" -d '{"query":"fenix"}' http://localhost:8080/
HTTP/1.1 200 OK
date: Sat, 13 Nov 2021 19:36:45 GMT
content-length: 194
x-frame-options: SAMEORIGIN
content-type: text/plain; charset=utf-8
x-xss-protection: 1; mode=block
x-content-type-options: nosniff
server: dart:io with Shelf
[{"location":"Fenix","maximum":21.3,"minimum":0.3,"country":"United States","epochTime":1636804800},{"location":"Fenix","maximum":32.3,"minimum":22.6,"country":"Ecuador","epochTime":1636804800}]%
Publishing the Cloud Function
Since our Cloud Function works locally, we can now deploy it using the following steps:
- Create a new Google Cloud project and retrieve its ID;
- Activate billing for the project;
- Install Google Cloud SDK;
- Use
$ gcloud auth login
to login to your Google account; - Set the specific project you want to deploy your cloud function with
$ gcloud config set project <PROJECT_ID>
with the ID from step 1.
Now we can manage our Google Cloud project via the command line, including deploying a new Cloud Function.
To publish the cloud function we will need to use:
$ gcloud beta run deploy <name> \
--source=. \
--region=<server> \
--platform managed \
--allow-unauthenticated # If we want everyone to access this endpoint
For our specific example it would be:
$ gcloud beta run deploy weather-cloud-function \
--source=. \
--region=us-central1 \
--platform managed \
--allow-unauthenticated
At the end of the process, you will receive URL for us to access it such as:
https://weather-cloud-function-io370i7pja-uc.a.run.app
As with the previous example, to test our cloud function, we can always use curl
.
Using Cloud Functions inside our Flutter apps
Using a Cloud Function is as simple as querying a REST API endpoint.
We going to use the http
package, with a specific query
parameter, and use a POST
method to the Cloud Function URL:
Future<List<WeatherLocation>> searchWeather(String input) {
return http
.post(
Uri.parse("https://weather-cloud-function-io370i7pja-uc.a.run.app"),
body: jsonEncode({'query': input}))
.then((response) => jsonDecode(response.body) as Iterable)
.then((json) =>
json.map((value) => WeatherLocation.fromMap(value)).toList());
}
With this, we can now create a simple Weather application:
Conclusion
Apart from serving as simple endpoints to protect API keys, we can use Cloud Functions to “react” to specific events:
- They can be called in response to an event, such as merging into the main branch of a GitHub repository, and react to it by performing actions - sending a slack message with a summary and calling our CD to start the deployment with the newly merged code;
- When a value is updated in Firebase Firestore, we can use a Cloud Function to dispatch a notification to certain users;
Moreover, with dartfn
we not only have a simple setup project but a Docker
file and a Makefile
which we can explore in future content. This makes it easier to create maintainable cloud functions that can be tested and integrated with a simple CI/CD environment.
Finally, the ability to use Dart as a language to develop the cloud functions and the ease of use of the gcloud
SDK, makes Cloud Functions a technology to consider when we want to have serverless execution of functions.
The code for this project is hosted in Github at: https://github.com/Vanethos/dart_weather_cloud_function
Want to get the latest articles and news? Subscribe to the newsletter here 👇
And for other articles, check the rest of the blog! Blog - Gonçalo Palma