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

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

Enhancing the backend with failure classes, exceptions, and data validation.

ยท

9 min read

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:

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 running flutter 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 empty

  • the 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! ๐Ÿ˜„

ย