๐ Building a Fullstack App with dart_frog and Flutter in a Monorepo - Part 3
Enhancing the backend with failure classes, exceptions, and data validation.
In the previous part, we set up models, data sources, repositories, and failures for the full-stack to-do application. In this part, we will:
Make changes to the failure classes
Create new failures
Create new Exceptions
Add validation to DTOs
Creating and updating packages ๐ฆ
In this section, we will create new packages using very_good_cli and update existing ones to efficiently manage the packages in our full-stack to-do application.
Working with Failure classes
It's time to shake things up in the failure department! ๐ฅ Let's get to work on updating our failure classes.
Create build.yaml
We will now create a new file called build.yaml
in the failures
directory and add the following code. This will change the behaviour of the json_serializable
so that it generates JSON keys in snake_case
.
targets:
$default:
builders:
json_serializable:
options:
any_map: false
checked: false
create_factory: true
disallow_unrecognized_keys: false
explicit_to_json: true
field_rename: snake
generic_argument_factories: false
ignore_unannotated: false
include_if_null: true
Update Failure
Let's add a new getter to the Failure
abstract class to get the status code of the failure. This will allow us to map the failure to the appropriate HTTP status code in the controller.
abstract class Failure {
String get message;
+ int get statusCode;
}
Update NetworkFailure
We will update our failure package and add more failures to it. To do this, we will begin by renaming the code
field in our NetworkFailure
class to statusCode
. This will make the field more meaningful and easier for our readers to understand.
class NetworkFailure extends Failure with _$NetworkFailure {
/// {@macro network_failure}
const factory NetworkFailure({
required String message,
- required int code,
+ required int statusCode,
@Default([]) List<String> errors,
}) = _NetworkFailure;
Create new failures
Time to add some more failure friends to our app! ๐
We'll be creating RequestFailure
, ServerFailure
, and ValidationFailure
to help us map out any potential errors that may occur.
RequestFailure
To create a RequestFailure
class, we will create a new file in the src
directory called request_failure/request_failure.dart
.
import 'dart:io';
import 'package:failures/failures.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'request_failure.freezed.dart';
@freezed
class RequestFailure extends Failure with _$RequestFailure {
const factory RequestFailure({
required String message,
@Default(HttpStatus.badRequest) int statusCode,
}) = _RequestFailure;
}
We will create two new classes in the src
directory, ServerFailure
and ValidationFailure
. The ServerFailure
class will be used to represent errors that occur on the server side of our application, and the ValidationFailure
class will be used to represent validation errors in our application.
ServerFailure
import 'dart:io';
import 'package:failures/failures.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'server_failure.freezed.dart';
@freezed
class ServerFailure extends Failure with _$ServerFailure {
const factory ServerFailure({
required String message,
@Default(HttpStatus.internalServerError) int statusCode,
}) = _ServerFailure;
}
ValidationFailure
import 'dart:io';
import 'package:failures/failures.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'validation_failure.freezed.dart';
@freezed
class ValidationFailure extends Failure with _$ValidationFailure {
const factory ValidationFailure({
required String message,
@Default(HttpStatus.badRequest) int statusCode,
@Default({}) Map<String, List<String>> errors,
}) = _ValidationFailure;
}
Don't forget to export and run
build_runner
๐
library failures;
export 'src/failure.dart';
export 'src/network_failure/network_failure.dart';
export 'src/request_failure/request_failure.dart';
export 'src/server_failure/server_failure.dart';
export 'src/validation_failure/validation_failure.dart';
๐ก Note: You can run the
build_runner
command by runningflutter pub run build_runner build
in the terminal.
Working with packages
Get ready for some package updating fun! ๐ฆ
Update typedefs
package
We will be creating a function called mapTodoId
in the typedefs
package. This function will be responsible for converting a string id to a TodoId
object. This is necessary because we need a way to convert user input into a format our application can understand and use. If the id is not valid, the mapTodoId
function will return a RequestFailure
object. This will allow us to handle any errors or invalid input gracefully, ensuring that our application is robust and can handle any potential issues
Either<Failure, TodoId> mapTodoId(String id) {
try {
final todoId = int.tryParse(id);
if (todoId == null) throw const BadRequestException(message: 'Invalid id');
return Right(todoId);
} on BadRequestException catch (e) {
return Left(
RequestFailure(
message: e.message,
statusCode: e.statusCode,
),
);
}
}
The mapTodoId
method will return either a TodoId
object or a RequestFailure
object. Make sure to add the dependencies either_dart
and failures
to the pubspec.yaml
file of the typedefs
package.
dependencies:
either_dart: ^0.3.0
exceptions:
path: ../exceptions
failures:
path: ../failures
Create a new exceptions
package
To handle internal exceptions and map them to the appropriate failures, we will create a new package called exceptions
and throw our custom exceptions. For example, if we encounter a PostgreSQLException
while inserting a new to-do, we will throw a ServerException
and map it to the ServerFailure
class. To create the exceptions
package, run the following command in the terminal:
very_good create -t dart_pkg exceptions
This package will include different types of exceptions such as ServerException
, HttpException
, and NotFoundException
. The ServerException
will be thrown in the case of an internal server error, while the HttpException
is an abstract class that will be extended by other exceptions like NotFoundException
and BadRequestException
. We can use these custom exceptions to handle internal errors and map them to the appropriate failure classes, such as ServerFailure
or RequestFailure
. We will start by creating a new file inside src/server_exception/server_exception.dart
and add the following code:
ServerException
To create ServerException
we will create a new file src/server_exception/server_exception.dart
and add the following code:
class ServerException implements Exception {
const ServerException(this.message);
final String message;
@override
String toString() => 'ServerException: $message';
}
HttpExpection
To create HttpException
we will create a new file src/http_exception/http_exception.dart
and add the following code:
abstract class HttpException implements Exception {
const HttpException(this.message, this.statusCode);
final String message;
final int statusCode;
@override
String toString() => '$runtimeType: $message';
}
NotFoundException
Next, we will create NotFoundException
, which will be thrown when a resource is not found. To do this, create a new file called src/http_exception/not_found_exception.dart
and add the following code:
class NotFoundException extends HttpException {
const NotFoundException(String message) : super(message, HttpStatus.notFound);
}
BadRequestException
Similarly, we will create a new file inside src/http_exception/bad_request_exception.dart
and add the following code:
class BadRequestException extends HttpException {
const BadRequestException({
required String message,
this.errors = const {},
}) : super(message, HttpStatus.badRequest);
final Map<String, List<String>> errors;
}
Make sure to import the HttpException
. Once you are done with HttpException
, add an export statement in http_exception.dart
file.
export './bad_request_exception.dart';
export './not_found_exception.dart';
And finally, export the HttpException
from exceptions/lib/exceptions.dart
file.
library exceptions;
export 'src/http_exception/http_exception.dart';
export 'src/server_exception/server_exception.dart';
Update models
package
Let's handle the validation
Ready to add some sass to your data validation? Let's get those DTOs in shape! ๐ช
To add validation to our CreateTodoDto
class, we will create a new static method called validated in models/lib/src/create_todo_dto/create_todo_dto.dart
. This method will return either a ValidationFailure
object or a CreateTodoDto
object. We will use this method to ensure that our to-do creation requests contain all necessary information before they are processed. The validation rules are:
the
title
should not be emptythe
description
should not be empty
Before we can add the validated method to the CreateTodoDto
class, we need to make sure that the necessary packages are added to the pubspec.yaml
file.
dependencies:
either_dart: ^0.3.0
exceptions:
path: ../exceptions
failures:
path: ../failures
freezed_annotation: ^2.2.0
json_annotation: ^4.7.0
typedefs:
path: ../typedefs
Now we will create a validated
method inside CreateTodoDto
static Either<ValidationFailure, CreateTodoDto> validated(
Map<String, dynamic> json,
) {
try {
final errors = <String, List<String>>{};
if (json['title'] == null) {
errors['title'] = ['Title is required'];
}
if (json['description'] == null) {
errors['description'] = ['Description is required'];
}
if (errors.isEmpty) return Right(CreateTodoDto.fromJson(json));
throw BadRequestException(
message: 'Validation failed',
errors: errors,
);
} on BadRequestException catch (e) {
return Left(
ValidationFailure(
message: e.message,
errors: e.errors,
statusCode: e.statusCode,
),
);
}
}
Similarly, we will create a new static method called validated
to validate the UpdateTodoDto
. We will ensure that at least one field is present.
static Either<ValidationFailure, UpdateTodoDto> validated(
Map<String, dynamic> json,
) {
try {
final errors = <String, List<String>>{};
if (json['title'] == null || json['title'] == '') {
errors['title'] = ['At least one field must be provided'];
}
if (json['description'] == null || json['description'] == '') {
errors['description'] = ['At least one field must be provided'];
}
if (json['completed'] == null) {
errors['completed'] = ['At least one field must be provided'];
}
if (errors.length < 3) return Right(UpdateTodoDto.fromJson(json));
throw BadRequestException(
message: 'Validation failed',
errors: errors,
);
} on BadRequestException catch (e) {
return Left(
ValidationFailure(
message: e.message,
statusCode: e.statusCode,
errors: e.errors,
),
);
}
}
Custom JSON converters
To serialize and deserialize our DateTime
objects, we will create a new file called models/lib/src/serializers/date_time_serializer.dart
. In this file, we will add the necessary code to handle the serialization and deserialization of DateTime
objects.
These classes will implement the JsonConverter
interface and provide the necessary logic to convert DateTime
objects to and from JSON. The DateTimeConverterNullable
class will handle cases where the DateTime
object may be null
, while the DateTimeConverter
class will handle non-null DateTime
objects. With these classes in place, we will be able to correctly handle the formatting of DateTime
objects when retrieving data from the database.
class DateTimeConverterNullable extends JsonConverter<DateTime?, dynamic> {
const DateTimeConverterNullable();
@override
DateTime? fromJson(dynamic json) {
if (json == null) return null;
return const DateTimeConverter().fromJson(json);
}
@override
String? toJson(DateTime? object) {
if (object == null) return null;
return const DateTimeConverter().toJson(object);
}
}
class DateTimeConverter extends JsonConverter<DateTime, dynamic> {
const DateTimeConverter();
@override
DateTime fromJson(dynamic json) {
if (json is DateTime) return json;
return DateTime.parse(json as String);
}
@override
String toJson(DateTime object) {
return object.toIso8601String();
}
}
Now we can use this converter in our Todo
model.
factory Todo({
required TodoId id,
required String title,
@Default('') String description,
@Default(false) bool? completed,
- required DateTime createdAt,
+ @DateTimeConverter() required DateTime createdAt,
- DateTime? updatedAt,
+ @DateTimeConverterNullable() DateTime? updatedAt,
}) = _Todo;
Once you have finished updating the Todo
model to use the converters.
Don't forget to run
build_runner
๐
Woo hoo! We made it to the end of part 3 ๐
In this part, we gave our failure classes a little update and created some new ones. We also added some shiny new exceptions and made sure our DTOs were properly validated. We're almost there, friends! Just a few more steps until we can fully implement the CRUD operations for our awesome to-do app.
In the final part, we'll finally be able to connect to a Postgres database and complete all our backend routes. It's going to be a coding party ๐ Are you ready to join the fun? I know I am! ๐คฉ
And if you ever need a reference, just head on over to the GitHub repo for this tutorial at https://github.com/saileshbro/full_stack_todo_dart.
Let's get ready to code some magic in part 4! ๐