๐Ÿš€ Building a Fullstack App with dart_frog and Flutter in a Monorepo - Part 4

๐Ÿš€ Building a Fullstack App with dart_frog and Flutter in a Monorepo - Part 4

Connect to a Postgres DB, complete routes, HttpController, implement CRUD operations

ยท

21 min read

In the previous part, we set up models, data sources, repositories, exceptions and failures for the full-stack to-do application. We also made some changes to our packages. In this part, we will:

  • Connect to a Postgres database

  • Complete the backend routes

  • Add a new controller to handle HTTP requests

  • Add necessary failures and exceptions

  • Fully implement CRUD operations for the to-do application

๐Ÿš€ Implementing the Backend

It's time to tackle the backend of our to-do app! ๐Ÿ’ช Let's get coding! ๐Ÿ’ป

Importing necessary dependencies ๐Ÿ“ฆ

Time to bring in the big guns! ๐Ÿ’ช Let's import those dependencies

We will import all the necessary dependencies in the pubspec.yaml file.

dependencies:
  dart_frog: ^0.3.0
  data_source:
    path: ../data_source
  dotenv: ^4.0.1
  either_dart: ^0.3.0
  exceptions:
    path: ../exceptions
  failures:
    path: ../failures
  http: ^0.13.5
  models:
    path: ../models
  postgres: ^2.5.2
  repository:
    path: ../repository
  typedefs:
    path: ../typedefs

๐Ÿ› ๏ธ Setting up the Postgres database

๐Ÿ’พ Let's create a database

We will be using the PostgreSQL database for this tutorial. To use the PostgreSQL database for this tutorial, we can set up a test database on elephantsql.com. Simply sign up on the website and click on the option to create a new instance. You should see something similar to this.

Elephant SQL New DB

Once you add a name, you will be prompted to choose a name for your instance and a region that is nearest to you. I have selected the AP-East-1 region, but you can choose any region that you prefer.

Elephant SQL Select Region

Once you have chosen a name and selected a region, click the Create Instance button. This will redirect you to a dashboard where you can click on the instance name to access the credentials for the database you have just created. The credentials should look similar to this.

Elephant SQL Creds

Connecting to the Database ๐Ÿ”Œ

๐Ÿ’ป Setting up our database connection like a boss

Setting up environment ๐ŸŒฟ

Now that we have created a database, we can connect to it from our application. To do this, we will create a new file .env at the root of the backend directory. This file will contain the credentials for the database that we have just created. The .env file should look similar to this.

Once this is done, we will use the dotenv package to load the environment variables from the .env file. We will also use the postgres package to connect to the database. You can run the following command in the backend directory to add the necessary dependencies.

dart pub add dotenv postgres

This is how .env file should look. Make sure to use your database credentials

DB_HOST=tiny.db.elephantsql.com
DB_PORT=5432
DB_DATABASE=asztgqfq
DB_USERNAME=asztgqfq
DB_PASSWORD=PcIXbvXQLwpEON61GVPzqs0zHyzHyHZc

Creating database connection ๐Ÿ”—

Now, create a backend/lib/db/database_connection.dart file and add the following code.

import 'dart:developer';

import 'package:dotenv/dotenv.dart';
import 'package:postgres/postgres.dart';

class DatabaseConnection {
  DatabaseConnection(this._dotEnv) {
    _host = _dotEnv['DB_HOST'] ?? 'localhost';
    _port = int.tryParse(_dotEnv['DB_PORT'] ?? '') ?? 5432;
    _database = _dotEnv['DB_DATABASE'] ?? 'test';
    _username = _dotEnv['DB_USERNAME'] ?? 'test';
    _password = _dotEnv['DB_PASSWORD'] ?? 'test';
  }

  final DotEnv _dotEnv;
  late final String _host;
  late final int _port;
  late final String _database;
  late final String _username;
  late final String _password;
  PostgreSQLConnection? _connection;

  PostgreSQLConnection get db =>
      _connection ??= throw Exception('Database connection not initialized');

  Future<void> connect() async {
    try {
      _connection = PostgreSQLConnection(
        _host,
        _port,
        _database,
        username: _username,
        password: _password,
      );
      log('Database connection successful');
      return _connection!.open();
    } catch (e) {
      log('Database connection failed: $e');
    }
  }

  Future<void> close() => _connection!.close();
}

Whenever we want to query, we will open a connection to the database and close it once we are done. This will ensure that we are not keeping the connection open for too long.

๐Ÿ’‰Injecting DatabaseConnection through provider

Create a new file routes/_middleware.dart and add the following code.

import 'package:backend/db/database_connection.dart';
import 'package:dart_frog/dart_frog.dart';
import 'package:dotenv/dotenv.dart';

final env = DotEnv()..load();
final _db = DatabaseConnection(env);

Handler middleware(Handler handler) {
  return handler.use(provider<DatabaseConnection>((_) => _db));
}

This middleware is used to provide the DatabaseConnection instance to other parts of the application through a provider.

Fetching DatabaseConnection from provider ๐Ÿ”

Now in routes/index.dart you can get this DatabaseConnection from RequestContext and use it to query the database.

import 'package:backend/db/database_connection.dart';
import 'package:dart_frog/dart_frog.dart';

Future<Response> onRequest(RequestContext context) async {
  final connection = context.read<DatabaseConnection>();
  await connection.connect();
  final response =
      await connection.db.query('select * from information_schema.tables');
  await connection.close();
  return Response.json(body: response.map((e) => e.toColumnMap()).toList());
}

If you run dart_frog dev, then you should be able to open http://localhost:8080 and see the following output.

Database Connection Successful

We have successfully connected to the database.

Create Database Table ๐Ÿ“

๐Ÿ—ƒ๏ธ Let's build our database table

Before implementing the TodoDataSource, we will need to create the table in the database. To do this, open the elephantsql.com dashboard and click on the BROWSER tab.

Elephant SQL Query Execution

Then, execute the following query:

CREATE TABLE todos(
    id SERIAL PRIMARY KEY NOT NULL,
    title VARCHAR(255) NOT NULL,
    description TEXT NOT NULL,
    completed BOOL DEFAULT FALSE,
    created_at timestamp default current_timestamp NOT NULL,
    updated_at timestamp null
);

This PostgreSQL query creates a new table called todos with the following columns:

  • id: an integer column that is the table's primary key and is generated automatically by the database (using the SERIAL type). The NOT NULL constraint ensures that this column cannot contain a NULL value.

  • title: a string column with a maximum length of 255 characters. The NOT NULL constraint ensures that this column cannot contain a NULL value.

  • description: a text column. The NOT NULL constraint ensures that this column cannot contain a NULL value.

  • completed: a boolean column with a default value of FALSE.

  • created_at: a timestamp column with a default value of the current timestamp. The NOT NULL constraint ensures that this column cannot contain a NULL value.

  • updated_at: a timestamp column that can contain a NULL value.

The todos table will be used to store the to-do items in our application. Each row in the table represents a single to-do item, and the table's columns store the data for that to-do item.

Once you run the query, you should see a toast message at the top.

Elephant SQL Query Success

Implementing TodoDataSource ๐Ÿ’ช

Cooking up some TodoDataSource magic ๐Ÿ”ฎ

We will implement the todo data source in backend/lib/todo/data_source/todo_data_source_impl.dart. This file will contain the implementation of the TodoDataSource interface. We will pass a DatabaseConnection as a dependency to this class.

Create TodoDataSourceImpl and implement TodoDataSource interface, and override necessary methods. The empty implementation should look like this.

Empty TodoDataSource implementation

import 'package:backend/db/database_connection.dart';
import 'package:data_source/data_source.dart';
import 'package:models/models.dart';
import 'package:typedefs/typedefs.dart';

class TodoDataSourceImpl implements TodoDataSource {
  const TodoDataSourceImpl(this._databaseConnection);
  final DatabaseConnection _databaseConnection;

  @override
  Future<Todo> createTodo(CreateTodoDto todo) {
    throw UnimplementedError();
  }

  @override
  Future<void> deleteTodoById(TodoId id) {
    throw UnimplementedError();
  }

  @override
  Future<List<Todo>> getAllTodo() {
    throw UnimplementedError();
  }

  @override
  Future<Todo> getTodoById(TodoId id) {
    throw UnimplementedError();
  }

  @override
  Future<Todo> updateTodo({required TodoId id, required UpdateTodoDto todo}) {
    throw UnimplementedError();
  }
}

๐Ÿ’‰Injecting TodoDataSource through provider

Let's add this to our global middleware in routes/_middleware.dart file

import 'package:backend/db/database_connection.dart';
import 'package:backend/todo/data_source/todo_data_source_impl.dart';
import 'package:dart_frog/dart_frog.dart';
import 'package:dotenv/dotenv.dart';
final env = DotEnv()..load();
final _db = DatabaseConnection(env);
final _ds = TodoDataSourceImpl(_db);

Handler middleware(Handler handler) {
  return handler
      .use(requestLogger())
      .use(provider<DatabaseConnection>((_) => _db))
      .use(provider<TodoDataSource>((_) => _ds));
}

createTodo implementation

Now we will implement the createTodo method.

  @override
  Future<Todo> createTodo(CreateTodoDto todo) async {
    try {
      await _databaseConnection.connect();
      final result = await _databaseConnection.db.query(
        '''
        INSERT INTO todos (title, description, completed, created_at)
        VALUES (@title, @description, @completed, @created_at) RETURNING *
        ''',
        substitutionValues: {
          'title': todo.title,
          'description': todo.description,
          'completed': false,
          'created_at': DateTime.now(),
        },
      );
      if (result.affectedRowCount == 0) {
        throw const ServerException('Failed to create todo');
      }
      final todoMap = result.first.toColumnMap();
      return Todo(
        id: todoMap['id'] as int,
        title: todoMap['title'] as String,
        description: todoMap['description'] as String,
        createdAt: todoMap['created_at'] as DateTime,
      );
    } on PostgreSQLException catch (e) {
      throw ServerException(e.message ?? 'Unexpected error');
    } finally {
      await _databaseConnection.close();
    }
  }

First, the method establishes a connection to the database using the _databaseConnection object. Then, it uses the query method on the db object to execute an INSERT statement The substitutionValues parameter is used to bind the values from the CreateTodoDto .

If the INSERT statement is successful, the method retrieves the inserted row from the database using the RETURNING * clause and converts it to a map using the toColumnMap method. The method then uses this map to create and return a new Todo object.

getAllTodo implementation

Now we will implement the getAllTodo method.

  @override
  Future<List<Todo>> getAllTodo() async {
    try {
      await _databaseConnection.connect();
      final result = await _databaseConnection.db.query(
        'SELECT * FROM todos',
      );
      final data =
          result.map((e) => e.toColumnMap()).map(Todo.fromJson).toList();
      return data;
    } on PostgreSQLException catch (e) {
      throw ServerException(e.message ?? 'Unexpected error');
    } finally {
      await _databaseConnection.close();
    }
  }

This getAllTodo method is used to retrieve a list of all the to-do items stored in the database. This executes a SELECT query to retrieve all rows from the todos table, maps each row to a Todo object using the Todo.fromJson function, and returns the list of Todo objects.

getTodoById implementation ๐Ÿ”

Now we will implement the getTodoById method.

@override
Future<Todo> getTodoById(TodoId id) async {
  try {
    await _databaseConnection.connect();
    final result = await _databaseConnection.db.query(
      'SELECT * FROM todos WHERE id = @id',
      substitutionValues: {'id': id},
    );
    if (result.isEmpty) {
      throw const NotFoundException('Todo not found');
    }
    return Todo.fromJson(result.first.toColumnMap());
  } on PostgreSQLException catch (e) {
    throw ServerException(e.message ?? 'Unexpected error');
  } finally {
    await _databaseConnection.close();
  }
}

We execute a SELECT query that selects from todos table where the id equals the provided id. If the query returns an empty result set, we throw a NotFoundException, indicating that the requested to-do item could not be found, else it returns the mapped todo object.

updateTodo implementation ๐Ÿ”ง

Here is the implementation of the updateTodo method.

  @override
  Future<Todo> updateTodo({
    required TodoId id,
    required UpdateTodoDto todo,
  }) async {
    try {
      await _databaseConnection.connect();
      final result = await _databaseConnection.db.query(
        '''
        UPDATE todos
        SET title = COALESCE(@new_title, title),
            description = COALESCE(@new_description, description),
            completed = COALESCE(@new_completed, completed),
            updated_at = current_timestamp
        WHERE id = @id
        RETURNING *
        ''',
        substitutionValues: {
          'id': id,
          'new_title': todo.title,
          'new_description': todo.description,
          'new_completed': todo.completed,
        },
      );
      if (result.isEmpty) {
        throw const NotFoundException('Todo not found');
      }
      return Todo.fromJson(result.first.toColumnMap());
    } on PostgreSQLException catch (e) {
      throw ServerException(e.message ?? 'Unexpected error');
    } finally {
      await _databaseConnection.close();
    }
  }

It executes an UPDATE query on the todos table. If no value is provided for a column, then the COALESCE function is used to keep the existing value in the database unchanged. The updated_at column is set to the current_timestamp. If the result set is empty, it means that no row with the given id was found, so a NotFoundException is thrown.

deleteTodoById implementation ๐Ÿ—‘๏ธ

Now, we will implement the deleteTodoById method.

  @override
  Future<void> deleteTodoById(TodoId id) async {
    try {
      await _databaseConnection.connect();
      await _databaseConnection.db.query(
        '''
        DELETE FROM todos
        WHERE id = @id
        ''',
        substitutionValues: {'id': id},
      );
    } on PostgreSQLException catch (e) {
      throw ServerException(e.message ?? 'Unexpected error');
    } finally {
      await _databaseConnection.close();
    }
  }

If the delete statement is successful, the method does not return anything.

PostgresSQLException handling ๐Ÿšจ

In all of the methods above, if there is an exception while querying, like PostgresSQLException, it is caught and a ServerException is thrown with a more general error message. Finally, the database connection is closed before the method finishes executing.

Implementing TodoRepository ๐Ÿ’ช

๐Ÿ’ช Time to make that TodoRepository do some work!

We will implement the todo repository in backend/lib/todo/repositories/todo_repository_impl.dart. This file will contain the implementation of the TodoRepository interface. We will pass a TodoDataSource as a dependency to this class.

Empty TodoRepository implementation

Create TodoRepositoryImpl and implement TodoRepository interface, and override necessary methods. The empty implementation should look like this.

import 'package:data_source/data_source.dart';
import 'package:either_dart/either.dart';
import 'package:failures/failures.dart';
import 'package:models/models.dart';
import 'package:repository/repository.dart';
import 'package:typedefs/src/typedefs.dart';

class TodoRepositoryImpl implements TodoRepository {
  TodoRepositoryImpl(this.dataSource);

  final TodoDataSource dataSource;

  @override
  Future<Either<Failure, Todo>> createTodo(CreateTodoDto createTodoDto) {
    throw UnimplementedError();
  }

  @override
  Future<Either<Failure, void>> deleteTodo(TodoId id) {
    throw UnimplementedError();
  }

  @override
  Future<Either<Failure, Todo>> getTodoById(TodoId id) {
    throw UnimplementedError();
  }

  @override
  Future<Either<Failure, List<Todo>>> getTodos() {
    throw UnimplementedError();
  }

  @override
  Future<Either<Failure, Todo>> updateTodo({
    required TodoId id,
    required UpdateTodoDto updateTodoDto,
  }) {
    throw UnimplementedError();
  }
}

๐Ÿ’‰Injecting TodoRepository through provider

Before implementing the methods of TodoRepository, we will add it to our global middleware so that we can access it from our routes.

import 'package:backend/db/database_connection.dart';
import 'package:backend/todo/data_source/todo_data_source_impl.dart';
import 'package:backend/todo/repositories/todo_repository_impl.dart';
import 'package:dart_frog/dart_frog.dart';
import 'package:data_source/data_source.dart';
import 'package:dotenv/dotenv.dart';
import 'package:repository/repository.dart';
final env = DotEnv()..load();
final _db = DatabaseConnection(env);
final _ds = TodoDataSourceImpl(_db);
final _repo = TodoRepositoryImpl(_ds);

Handler middleware(Handler handler) {
  return handler
      .use(requestLogger())
      .use(provider<DatabaseConnection>((_) => _db))
      .use(provider<TodoDataSource>((_) => _ds))
      .use(provider<TodoRepository>((_) => _repo));
}

createTodo implementation

Now we will implement the createTodo method.

  @override
  Future<Either<Failure, Todo>> createTodo(CreateTodoDto createTodoDto) async {
    try {
      final todo = await dataSource.createTodo(createTodoDto);
      return Right(todo);
    } on ServerException catch (e) {
      log(e.message);
      return Left(
        ServerFailure(message: e.message),
      );
    }
  }

In this code, we are implementing the createTodo method which is part of the TodoRepository interface using dataSource.createTodo method. The dataSource.createTodo method is responsible for inserting the todo into the database. If the insertion is successful, it returns the todo object.

getTodoById implementation

Now we will implement the getTodoById method.

  @override
  Future<Either<Failure, Todo>> getTodoById(TodoId id) async {
    try {
      final res = await dataSource.getTodoById(id);
      return Right(res);
    } on NotFoundException catch (e) {
      log(e.message);
      return Left(
        ServerFailure(
          message: e.message,
          statusCode: e.statusCode,
        ),
      );
    } on ServerException catch (e) {
      log(e.message);
      return Left(
        ServerFailure(message: e.message),
      );
    }
  }

The method calls the dataSource.getTodoById method, which is responsible for querying the database and returning the todo object. If the todo is found, it returns the value. A NotFoundException is thrown when the todo with the given id is not found in the database.

getTodos implementation

Here, we will implement the getTodos method.

  @override
  Future<Either<Failure, List<Todo>>> getTodos() async {
    try {
      return Right(await dataSource.getAllTodo());
    } on ServerException catch (e) {
      log(e.message);
      return Left(
        ServerFailure(message: e.message),
      );
    }
  }

We call the dataSource.getAllTodo method. If the method execution is successful, we return the list of todo items.

updateTodo implementation ๐Ÿ”ง

Now we will implement the updateTodo method.

  @override
  Future<Either<Failure, Todo>> updateTodo({
    required TodoId id,
    required UpdateTodoDto updateTodoDto,
  }) async {
    try {
      return Right(
        await dataSource.updateTodo(
          id: id,
          todo: updateTodoDto,
        ),
      );
    } on NotFoundException catch (e) {
      log(e.message);
      return Left(
        ServerFailure(
          message: e.message,
          statusCode: e.statusCode,
        ),
      );
    } on ServerException catch (e) {
      log(e.message);
      return Left(
        ServerFailure(message: e.message),
      );
    }
  }

The method updates the todo using the dataSource.updateTodo method. If the update is successful, it returns the updated todo. If the update fails and a NotFoundException is thrown, it logs the error message and returns a ServerFailure object with the appropriate status code.

deleteTodo implementation

Finally, we will implement the deleteTodo method.

  @override
  Future<Either<Failure, void>> deleteTodo(TodoId id) async {
    try {
      final exists = await getTodoById(id);
      if (exists.isLeft) return exists;
      final todo = await dataSource.deleteTodoById(id);
      return Right(todo);
    } on ServerException catch (e) {
      log(e.message);
      return Left(
        ServerFailure(message: e.message),
      );
    }
  }

We check if a todo exists by calling the this.getTodoById method. If it does not exist, we return a Failure. If the todo does exist, we delete it by calling the dataSource.deleteTodoById.

Handling Exception ๐Ÿ›‘

The dataSource methods throw exceptions like ServerException and NotFoundException. We catch these exceptions and log the error message. We then return a Left object containing a ServerFailure object. The ServerFailure object is a custom failure type that we can use to indicate that a server error occurred.

Building the TodoController ๐Ÿ”จ

Bringing it all together with our fancy new controller ๐ŸŽ‰

The controller will be responsible for handling HTTP requests and sending back the appropriate response. We will implement five methods in the controller:

  • index GET /resource ๐Ÿ”

  • show GET /resource/{id} ๐Ÿ“–

  • store POST /resource ๐Ÿ“ค

  • update PUT/PATCH /resource/{id} ๐Ÿ”—

  • delete DELETE /resource/{id} ๐Ÿ—‘๏ธ

These methods will correspond to the standard HTTP methods for retrieving, creating, updating, and deleting data. By using these methods, we can keep our code clean and organized. This approach is inspired by the Laravel framework's API controller methods.

Abstract HttpController ๐Ÿ”ฎ

We will create a new abstract class in the backend/lib/controller/http_controller.dart called HttpController and which will have five methods.

import 'dart:async';

import 'package:dart_frog/dart_frog.dart';

abstract class HttpController {
  FutureOr<Response> index(Request request);

  FutureOr<Response> store(Request request);

  FutureOr<Response> show(Request request, String id);

  FutureOr<Response> update(Request request, String id);

  FutureOr<Response> destroy(Request request, String id);
}

Empty TodoController implementation

Now for the implementation of this contract, we will create a new class TodoController in the backend/lib/controller/todo_controller.dart file. We will implement each method in the TodoController class.

import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:backend/controller/http_controller.dart';
import 'package:dart_frog/dart_frog.dart';
import 'package:either_dart/either.dart';
import 'package:exceptions/exceptions.dart';
import 'package:failures/failures.dart';
import 'package:models/models.dart';
import 'package:repository/repository.dart';
import 'package:typedefs/typedefs.dart';

class TodoController extends HttpController {
  TodoController(this._repo);

  final TodoRepository _repo;
  @override
  FutureOr<Response> index(Request request) async {
    throw UnimplementedError();
  }

  @override
  FutureOr<Response> show(Request request, String id) async {
    throw UnimplementedError();
  }

  @override
  FutureOr<Response> destroy(Request request, String id) async {
    throw UnimplementedError();
  }

  @override
  FutureOr<Response> store(Request request) async {
    throw UnimplementedError();
  }

  @override
  FutureOr<Response> update(Request request, String id) async {
    throw UnimplementedError();
  }

  Future<Either<Failure, Map<String, dynamic>>> parseJson(
    Request request,
  ) async {
    throw UnimplementedError();
  }
}

Parse request body ๐Ÿ”ฌ

Before we implement the methods, we will create a new helper method in HttpController which will be responsible to parse the request body.

If the request body is not a valid JSON, it will return a Left object containing a BadRequestFailure object. If the request body is a valid JSON, it will return a Right object containing the parsed JSON.

Add the following method to the HttpController class.

  Future<Either<Failure, Map<String, dynamic>>> parseJson(
    Request request,
  ) async {
    try {
      final body = await request.body();
      if (body.isEmpty) {
        throw const BadRequestException(message: 'Invalid body');
      }
      late final Map<String, dynamic> json;
      try {
        json = jsonDecode(body) as Map<String, dynamic>;
        return Right(json);
      } catch (e) {
        throw const BadRequestException(message: 'Invalid body');
      }
    } on BadRequestException catch (e) {
      return Left(
        ValidationFailure(
          message: e.message,
          errors: {},
        ),
      );
    }
  }

๐Ÿ’‰Injecting TodoController through provider

Let's add this to our global middleware routes/_middleware.dart file.

import 'package:backend/db/database_connection.dart';
import 'package:backend/todo/controller/todo_controller.dart';
import 'package:backend/todo/data_source/todo_data_source_impl.dart';
import 'package:backend/todo/repositories/todo_repository_impl.dart';
import 'package:dart_frog/dart_frog.dart';
import 'package:data_source/data_source.dart';
import 'package:dotenv/dotenv.dart';
import 'package:repository/repository.dart';

final env = DotEnv()..load();
final _db = DatabaseConnection(env);
final _ds = TodoDataSourceImpl(_db);
final _repo = TodoRepositoryImpl(_ds);
final _todoController = TodoController(_repo);

Handler middleware(Handler handler) {
  return handler
      .use(requestLogger())
      .use(provider<DatabaseConnection>((_) => _db))
      .use(provider<TodoDataSource>((_) => _ds))
      .use(provider<TodoRepository>((_) => _repo))
      .use(provider<TodoController>((_) => _todoController));
}

Note: We are using requestLogger middleware to log the request and response.

Implementing TodoController ๐Ÿš€

We will implement the TodoController methods as follows:

index implementation

The index method will be responsible for retrieving all todo items from the database. We will implement it as follows:

  @override
  FutureOr<Response> index(Request request) async {
    final res = await _repo.getTodos();
    return res.fold(
      (left) => Response.json(
        body: {'message': left.message},
        statusCode: left.statusCode,
      ),
      (right) => Response.json(
        body: right.map((e) => e.toJson()).toList(),
      ),
    );
  }

We will call the getTodos method and map the response. If the failure case is returned, we will return a response with an error status code and the error message. Else, we will return a 200 status code and the list of todos.

show implementation

show method will be responsible for retrieving a single to-do item from the database. We will implement it as follows:

  @override
  FutureOr<Response> show(Request request, String id) async {
    final todoId = mapTodoId(id);
    if (todoId.isLeft) {
      return Response.json(
        body: {'message': todoId.left.message},
        statusCode: todoId.left.statusCode,
      );
    }
    final res = await _repo.getTodoById(todoId.right);
    return res.fold(
      (left) => Response.json(
        body: {'message': left.message},
        statusCode: left.statusCode,
      ),
      (right) => Response.json(
        body: right.toJson(),
      ),
    );
  }

We will first call mapTodoId method to validate the id parameter. If it returns a failure, we will return a failure response with the status code. Then we will get the todo item from the repository and return the todo item if it is found. Else, we will return a failure response with the status code.

store implementation

store method is as follows

  @override
  FutureOr<Response> store(Request request) async {
    final parsedBody = await parseJson(request);
    if (parsedBody.isLeft) {
      return Response.json(
        body: {'message': parsedBody.left.message},
        statusCode: parsedBody.left.statusCode,
      );
    }
    final json = parsedBody.right;
    final createTodoDto = CreateTodoDto.validated(json);
    if (createTodoDto.isLeft) {
      return Response.json(
        body: {
          'message': createTodoDto.left.message,
          'errors': createTodoDto.left.errors,
        },
        statusCode: createTodoDto.left.statusCode,
      );
    }
    final res = await _repo.createTodo(createTodoDto.right);
    return res.fold(
      (left) => Response.json(
        body: {'message': left.message},
        statusCode: left.statusCode,
      ),
      (right) => Response.json(
        body: right.toJson(),
        statusCode: HttpStatus.created,
      ),
    );
  }

If parseJson resolves to a failure, we will return a response with an error status code and message.

We will pass the JSON object to the CreateTodoDto.validated method. If this returns a failure, we will return a response with an error status code and message, here it will be ValidationFailure.

We will pass the DTO to createTodo method of the TodoRepository. If creating fails we will return a response with an error status code and message.

If everything goes right, we will return a response with a 201 status code and the to-do item.

destroy implementation

destroy method is as follows

  @override
  FutureOr<Response> destroy(Request request, String id) async {
    final todoId = mapTodoId(id);
    if (todoId.isLeft) {
      return Response.json(
        body: {'message': todoId.left.message},
        statusCode: todoId.left.statusCode,
      );
    }
    final res = await _repo.deleteTodo(todoId.right);
    return res.fold(
      (left) => Response.json(
        body: {'message': left.message},
        statusCode: left.statusCode,
      ),
      (right) => Response.json(body: {'message': 'OK'}),
    );
  }

We will first call mapTodoId method to validate the id parameter. If it returns a failure, we will return a failure response with the status code. Then we will get the delete todo item from the repository and return OK with 200 status code if. If there is a failure, we will return a failure response with the status code.

update implementation

update method will be responsible for updating a single to-do item from the database. We will implement it as follows:

  @override
  FutureOr<Response> update(Request request, String id) async {
    final parsedBody = await parseJson(request);
    final todoId = mapTodoId(id);
    if (todoId.isLeft) {
      return Response.json(
        body: {'message': todoId.left.message},
        statusCode: todoId.left.statusCode,
      );
    }
    if (parsedBody.isLeft) {
      return Response.json(
        body: {'message': parsedBody.left.message},
        statusCode: parsedBody.left.statusCode,
      );
    }

    final json = parsedBody.right;
    final updateTodoDto = UpdateTodoDto.validated(json);
    if (updateTodoDto.isLeft) {
      return Response.json(
        body: {
          'message': updateTodoDto.left.message,
          'errors': updateTodoDto.left.errors,
        },
        statusCode: updateTodoDto.left.statusCode,
      );
    }
    final res = await _repo.updateTodo(
      id: todoId.right,
      updateTodoDto: updateTodoDto.right,
    );
    return res.fold(
      (left) => Response.json(
        body: {'message': left.message},
        statusCode: left.statusCode,
      ),
      (right) => Response.json(
        body: right.toJson(),
      ),
    );
  }

This method is similar to the store method. We will first validate the id parameter and then the JSON body. If both are valid, we will call the updateTodo method of the TodoRepository and then resolve the failure or the updated value.

Implementing Routes ๐Ÿ›ฃ๏ธ

We will now implement the routes. These are the routes that we will have

  • GET /todos - Get all todos

  • GET /todos/:id - Get a single todo

  • POST /todos - Create a todo

  • PUT /todos/:id - Update a todo

  • PATCH /todos/:id - Update a todo

  • DELETE /todos/:id - Delete a todo

dart_frog has a file system routing. For example, let's say we need to create todos/1 route. We will create a file routes/todos/[id].dart and it will be mapped to todos/1 route.

Implementing todos/ route

To implement all the HTTP methods related to todos/ route, we will create a new file routes/todos/index.dart.

We will create a file routes/todos/index.dart and implement the routes as follows:

import 'dart:io';

import 'package:backend/todo/controller/todo_controller.dart';
import 'package:dart_frog/dart_frog.dart';

Future<Response> onRequest(RequestContext context) async {
  final controller = context.read<TodoController>();
  switch (context.request.method) {
    case HttpMethod.get:
      return controller.index(context.request);
    case HttpMethod.post:
      return controller.store(context.request);
    case HttpMethod.put:
    case HttpMethod.patch:
    case HttpMethod.delete:
    case HttpMethod.head:
    case HttpMethod.options:
      return Response.json(
        body: {'error': '๐Ÿ‘€ Looks like you are lost ๐Ÿ”ฆ'},
        statusCode: HttpStatus.methodNotAllowed,
      );
  }
}

Here, we are getting the TodoController from the context and mapping the respective HTTP method to the controller method. We are also handling cases when the HTTP method is not allowed.

Implementing todos/:id route

Similarly, we will create a file routes/todos/[id].dart and implement the routes as follows:

import 'dart:io';

import 'package:backend/todo/controller/todo_controller.dart';
import 'package:dart_frog/dart_frog.dart';

Future<Response> onRequest(RequestContext context, String id) async {
  final todoController = context.read<TodoController>();
  switch (context.request.method) {
    case HttpMethod.get:
      return todoController.show(context.request, id);
    case HttpMethod.put:
    case HttpMethod.patch:
      return todoController.update(context.request, id);
    case HttpMethod.delete:
      return todoController.destroy(context.request, id);
    case HttpMethod.head:
    case HttpMethod.options:
    case HttpMethod.post:
      return Response.json(
        body: {'error': '๐Ÿ‘€ Looks like you are lost ๐Ÿ”ฆ'},
        statusCode: HttpStatus.methodNotAllowed,
      );
  }
}

Here, we will get the id parameter from the route as a second parameter in onRequest method as a string. This can be anything. This explains the use of mapTodoId function in typedefs package.

We will pass the id parameter to the controller methods. We will also handle the case when the HTTP method is not allowed.

Implementing / route

Finally, we will update routes/index.dart file to return methodNotAllowed response. This is our / route, which will be handled as follows:

import 'dart:io';

import 'package:dart_frog/dart_frog.dart';

Future<Response> onRequest(RequestContext context) async {
  return Response.json(
    body: {'error': '๐Ÿ‘€ Looks like you are lost ๐Ÿ”ฆ'},
    statusCode: HttpStatus.methodNotAllowed,
  );
}

๐Ÿงช Testing backend

Time to put our backend to the test! ๐Ÿ”

We will run some e2e tests, to verify the backend works fine. create a file backend/e2e/routes_test.dart and implement the tests as follows:

import 'dart:convert';
import 'dart:io';

import 'package:http/http.dart' as http;
import 'package:models/models.dart';
import 'package:test/test.dart';

void main() {
  late Todo createdTodo;
  tearDownAll(() async {
    final response = await http.get(Uri.parse('http://localhost:8080/todos'));
    final todos = (jsonDecode(response.body) as List)
        .map((e) => Todo.fromJson(e as Map<String, dynamic>))
        .toList();
    for (final todo in todos) {
      await http.delete(Uri.parse('http://localhost:8080/todos/${todo.id}'));
    }
  });
  group('E2E -', () {
    test('GET /todos returns empty list of todos', () async {
      final response = await http.get(Uri.parse('http://localhost:8080/todos'));
      expect(response.statusCode, HttpStatus.ok);
      expect(response.body, equals('[]'));
    });

    test('POST /todos to create a new todo', () async {
      final response = await http.post(
        Uri.parse('http://localhost:8080/todos'),
        headers: {
          'Content-Type': 'application/json',
        },
        body: jsonEncode(_createTodoDto.toJson()),
      );
      expect(response.statusCode, HttpStatus.created);
      createdTodo =
          Todo.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
      expect(createdTodo.title, equals(_createTodoDto.title));
      expect(createdTodo.description, equals(_createTodoDto.description));
    });

    test('GET /todos returns list of todos with one todo', () async {
      final response = await http.get(Uri.parse('http://localhost:8080/todos'));
      expect(response.statusCode, HttpStatus.ok);
      final todos = (jsonDecode(response.body) as List)
          .map((e) => Todo.fromJson(e as Map<String, dynamic>))
          .toList();
      expect(todos.length, equals(1));
      expect(todos.first, equals(createdTodo));
    });

    test('GET /todos/:id returns the created todo', () async {
      final response = await http.get(
        Uri.parse('http://localhost:8080/todos/${createdTodo.id}'),
      );
      expect(response.statusCode, HttpStatus.ok);
      final todo =
          Todo.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
      expect(todo, equals(createdTodo));
    });

    test('PUT /todos/:id to update the created todo', () async {
      final updateTodoDto = UpdateTodoDto(
        title: 'updated title',
        description: 'updated description',
      );
      final response = await http.put(
        Uri.parse('http://localhost:8080/todos/${createdTodo.id}'),
        headers: {
          'Content-Type': 'application/json',
        },
        body: jsonEncode(updateTodoDto.toJson()),
      );
      expect(response.statusCode, HttpStatus.ok);
      final todo =
          Todo.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
      expect(todo.title, equals(updateTodoDto.title));
      expect(todo.description, equals(updateTodoDto.description));
    });

    test('PATCH /todos/:id to update the created todo', () async {
      final updateTodoDto = UpdateTodoDto(
        title: 'UPDATED TITLE',
        description: 'UPDATED DESCRIPTION',
      );
      final response = await http.patch(
        Uri.parse('http://localhost:8080/todos/${createdTodo.id}'),
        headers: {
          'Content-Type': 'application/json',
        },
        body: jsonEncode(updateTodoDto.toJson()),
      );
      expect(response.statusCode, HttpStatus.ok);
      final todo =
          Todo.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
      expect(todo.title, equals(updateTodoDto.title));
      expect(todo.description, equals(updateTodoDto.description));
    });

    test('DELETE /todos/:id to delete the created todo', () async {
      final response = await http.delete(
        Uri.parse('http://localhost:8080/todos/${createdTodo.id}'),
      );
      expect(response.statusCode, HttpStatus.ok);
      expect(response.body, jsonEncode({'message': 'OK'}));
    });
    test('GET /todos returns empty list of todos', () async {
      final response = await http.get(Uri.parse('http://localhost:8080/todos'));
      expect(response.statusCode, HttpStatus.ok);
      expect(response.body, equals('[]'));
    });
  });
}

final _createTodoDto = CreateTodoDto(
  title: 'title',
  description: 'description',
);

To run the tests, first, start the backend server by running the following command:

dart_frog dev

And then on a new terminal run the tests:

dart test e2e/routes_test.dart

Tests Passing fine


Wow, we've made it to the end of part 4! ๐ŸŽ‰ It's been a wild ride, but we've finally completed the backend of our full-stack to-do application. We connected to a Postgres database, completed all our backend routes, and fully implemented CRUD operations. We even tested our backend to make sure everything is running smoothly.

But we're not done yet! In the final part of this tutorial, we'll be building the front end of our to-do app using Flutter. It's going to be a blast! ๐Ÿ’ป

Don't forget, you can always refer back to the GitHub repo for this tutorial at https://github.com/saileshbro/full_stack_todo_dart if you need a little help along the way.

Until next time, happy coding! ๐Ÿ˜„

ย