Building Dart APIs with Google Cloud Run
Learn how to build backend applications using Dart and Google Cloud Run. By Alhassan Kamil.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Building Dart APIs with Google Cloud Run
30 mins
- Getting Started
- Running Your First Dart API Server
- Setting up a Google Cloud Project
- Creating the Project
- Creating a Service Account
- Setting up Billing
- An Overview of REST APIs
- Developing the API Endpoints
- Designing Endpoints
- Implementing the UserRoutes Endpoints
- Adding the NoteRoutes Endpoints
- Creating the AppRoutes Endpoints
- Testing the Routes
- Writing Controller Logic
- UserController
- NoteController
- Adding Middleware
- Response Headers Middleware
- Authentication Middleware
- Deploying Your Dart API on Google Cloud Run
- Installing Google Cloud CLI
- Configuring the Dockerfile
- Deploying to Cloud Run
- Accessing Your Dart REST API
- Where to Go From Here
Writing Controller Logic
A controller, borrowed from the MVC pattern, is a class that sits between the client and the resource server (the database, in this case) and controls what data the client can access and what format it’s in for the client to understand.
You’ll implement two controllers: UserController
and NoteController
. UserController
holds authentication logic while NoteController
contains the logic for managing notes.
UserController
You’ll use register()
and login()
in lib/controllers/user_controller.dart to authenticate users. These have been defined, so you just need to modify them going forward.
Import these in lib/controllers/user_controller.dart:
import 'dart:convert';
import 'package:collection/collection.dart';
import '../helpers/helper.dart';
import '../models/user.dart';
Next, change register()
so it contains the following:
// 1
final req = await request.readAsString();
// 2
if (request.isEmpty || !validate(req)) {
return Response.forbidden(jsonEncode({'message': 'Bad request'}));
}
// 3
final mJson = jsonDecode(req) as Map<String, dynamic>;
final apiKey = Helper.randomChars(40);
final id = Helper.randomChars(15);
final user = User(
id: id,
email: (mJson['email'] ?? '') as String,
password: Helper.hash(mJson['password'] as String),
apiKey: apiKey);
try {
// 4
Helper.push(firestoreApi,
path: 'users/$id',
fields: user
.toMap()
.map((key, value) => MapEntry(key, Value(stringValue: value))));
return Response.ok(user.toJson());
} on Exception {
// 5
return Helper.error();
}
This is what your code is doing:
- Reading the request body into
req
as a string. - Returning a
403 Forbidden
response if the request is empty, has no body or eitheremail
orpassword
are missing from the request body. - If the request passes validation, it generates an
apiKey
andid
, then uses it with a hash of the user’s password to create a user. - Saving the user against their ID to the users collection on Cloud Firestore, wrapping this code in a
try
block to capture any exceptions thrown, and returning a200 OK
response if everything goes well. - Returning a
503 Internal Server Error
response if an exception was thrown, signalling an unsuccessful resource creation.
Notice that you called validate()
in register()
, which hasn’t been implemented yet. Modify it’s stub to contain the code below:
final json = jsonDecode(req) as Map;
return req.trim().isNotEmpty &&
json['email'] != null &&
(json['email'] as String).trim().isNotEmpty &&
json['password'] != null &&
(json['password'] as String).trim().isNotEmpty;
All validate()
does is to validate that req
contains both email
and password
.
Finish up UserController
by modifying login()
to have the following code:
final req = await request.readAsString();
if (request.isEmpty || !validate(req)) {
return Response.forbidden(jsonEncode({'message': 'Bad request'}));
}
final mJson = jsonDecode(req) as Map<String, dynamic>;
// 1
final docs = await Helper.getDocs(firestoreApi, 'users');
if ((docs.documents ?? []).isEmpty) {
return Response.notFound(jsonEncode({'message': 'User not found'}));
}
// 2
final user = docs.documents!.firstWhereOrNull((e) =>
e.fields?['email']?.stringValue == mJson['email'] &&
e.fields?['password']?.stringValue ==
Helper.hash(mJson['password'] as String));
if (user == null) {
return Response.forbidden(
jsonEncode({'message': 'Invalid email and/or password'}));
}
return Response.ok(jsonEncode(
{'apiKey': docs.documents!.first.fields?['apiKey']?.stringValue}));
The code above:
- Fetches all users from Cloud Firestore using
Helper.getDocs()
. This utility function takes aFirestoreApi
instance and the collection ID to fetch from, users in the case above. - Finds a user with the email-password combination, returning a
200 OK
response if they exists or403 Forbidden
if they don’t.
Restart the server and see that authentication works.
Register a user:
curl -X POST -d '{"email": "newuser@example.com", "password": "pass1234"}' http://localhost:8080/v1/users/register
Then log the user in:
curl -X POST -d '{"email": "newuser@example.com", "password": "pass1234"}' http://localhost:8080/v1/users/login
NoteController
The NoteController
manages notes. For now, it contains stubs for store()
, index()
, show()
and destroy()
, which you’ll change in this section.
Firstly, you need to import the following:
import 'dart:convert';
import '../helpers/helper.dart';
import '../models/note.dart';
Then, modify store()
to contain the following code:
// 1
final req = await request.readAsString();
final id = Helper.randomChars(15);
final isEmpty = request.isEmpty || req.trim().isEmpty;
if (isEmpty) {
return Response.forbidden(jsonEncode({'message': 'Bad request'}));
}
// 2
final json = jsonDecode(req) as Map<String, dynamic>;
final title = (json['title'] ?? '') as String;
final description = (json['description'] ?? '') as String;
if (title.isEmpty || description.isEmpty) {
return Response.forbidden(
jsonEncode({'message': 'All fields are required'}));
}
// 3
final note = Note(title: title, description: description, id: id);
try {
await Helper.push(firestoreApi,
path: 'notes/$id',
fields: note
.toMap()
.map((key, value) => MapEntry(key, Value(stringValue: value))));
return Response.ok(note.toJson());
} on Exception {
// 4
return Helper.error();
}
This is what your code is doing:
- Checking the request and returning a
403 Forbidden
response if it’s empty. - Decoding the request and returning another
403 Forbidden
response if eithertitle
ordescription
is empty. - Creating a new note, saving it into the notes collection and returning
200 OK
success response containing the note. - Returning a
503 Internal Server Error
if the note was unable to save.
The next task is to retrieve all notes in index()
. So alter it’s body with the following code:
try {
final docList = await Helper.getDocs(firestoreApi, 'notes');
final notes = docList.documents
?.map((e) =>
e.fields?.map((key, value) => MapEntry(key, value.stringValue)))
.toList();
return Response.ok(jsonEncode(notes ?? <String>[]));
} on Exception {
return Helper.error();
}
The code above retrieves all notes from the database and returns them as a response. If there was an exception, an error response is, instead, returned.
Moving forward, change the body of show()
to also match the code below:
try {
final doc = await firestoreApi.projects.databases.documents
.get('${Helper.doc}/notes/$id');
final notes =
doc.fields?.map((key, value) => MapEntry(key, value.stringValue));
return Response.ok(jsonEncode(notes));
} on Exception {
return Helper.error();
}
This retrieves the note by it’s ID and returns a 200 OK
response containing the note or a 503 Internal Server Error
response if there’s an error.
Lastly, you’d want to delete notes. You can do that in destroy()
like this:
try {
await firestoreApi.projects.databases.documents
.delete('${Helper.doc}/notes/$id');
return Response.ok(jsonEncode({'message': 'Delete successful'}));
} on Exception {
return Helper.error();
}
The code is similar to that of show()
, except you used delete()
instead of get()
and returned a message instead of a note.
This completes the note controller logic, so you can now manage notes. All API endpoints should now also work.
Restart the server and try to save a note:
curl -X POST -d '{"title": "Monday Journeys","description": "Accra to Tamale"}' http://localhost:8080/v1/notes/
You should get a response like below:
Explore the other endpoints to see the responses:
- Get all notes
curl http://localhost:8080/v1/notes
. - Get the note with ID WVVehUGGr56RkXy:
curl http://localhost:8080/v1/notes/WVVehUGGr56RkXy
. - Delete note with ID WVVehUGGr56RkXy:
curl -X DELETE http://localhost:8080/v1/notes/WVVehUGGr56RkXy
.
As of now, anyone can use the API to access notes. This isn’t a good security practice. So you need to make the notes accessible to only authenticated users. You’ll use middleware to do that.