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.
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.
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.
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.
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.
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 theSERIAL
type). TheNOT NULL
constraint ensures that this column cannot contain aNULL
value. -
title
: a string column with a maximum length of 255 characters. TheNOT NULL
constraint ensures that this column cannot contain aNULL
value. -
description
: a text column. TheNOT NULL
constraint ensures that this column cannot contain aNULL
value. -
completed
: a boolean column with a default value ofFALSE
. -
created_at
: atimestamp
column with a default value of the current timestamp. TheNOT NULL
constraint ensures that this column cannot contain aNULL
value. -
updated_at
: atimestamp
column that can contain aNULL
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.
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
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! 😄
Top comments (0)