Building Dart APIs with Google Cloud Run

Learn how to build backend applications using Dart and Google Cloud Run. By Alhassan Kamil.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 3 of 5 of this article. Click here to view the first page.

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:

  1. Reading the request body into req as a string.
  2. Returning a 403 Forbidden response if the request is empty, has no body or either email or password are missing from the request body.
  3. If the request passes validation, it generates an apiKey and id, then uses it with a hash of the user’s password to create a user.
  4. 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 a 200 OK response if everything goes well.
  5. 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:

  1. Fetches all users from Cloud Firestore using Helper.getDocs(). This utility function takes a FirestoreApi instance and the collection ID to fetch from, users in the case above.
  2. Finds a user with the email-password combination, returning a 200 OK response if they exists or 403 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

Signup response

Then log the user in:

curl -X POST -d '{"email": "newuser@example.com", "password": "pass1234"}' http://localhost:8080/v1/users/login

Login response

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:

  1. Checking the request and returning a 403 Forbidden response if it’s empty.
  2. Decoding the request and returning another 403 Forbidden response if either title or description is empty.
  3. Creating a new note, saving it into the notes collection and returning 200 OK success response containing the note.
  4. 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:

Saved note response

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.