Todos 🗒
Difficulty: 🟠 Intermediate
Length: 30 minutes
Before getting started, read the prerequisites to make sure your development environment is ready.
Overview
In this tutorial, we're going to build an app that exposes two endpoints which allow us to perform CRUD
operations on a list of todos.
CRUD
stands for create
, read
, update
, and delete
.
When we're done, we should have an app that supports the following requests:
# Create a new todo
curl --request POST \
--url http://localhost:8080/todos \
--header 'Content-Type: application/json' \
--data '{
"title": "Take out trash"
}'
# Read all todos
curl --request GET \
--url http://localhost:8080/todos
# Read a specific todo by id
curl --request GET \
--url http://localhost:8080/todos/<id>
# Update a specific todo by id
curl --request PUT \
--url http://localhost:8080/todos/<id> \
--header 'Content-Type: application/json' \
--data '{
"title": "Take out trash!",
"isCompleted": true
}'
# Delete a specific todo by id
curl --request DELETE \
--url http://localhost:8080/todos/<id>
Creating a new app
To create a new Dart Frog app, open your terminal, cd
into the directory where you'd like to create the app, and run the following command:
dart_frog create todos
You should see an output similar to:
✓ Creating todos (0.1s)
✓ Installing dependencies (1.7s)
Created todos at ./todos.
Get started by typing:
cd ./todos
dart_frog dev
Install and use the Dart Frog VS Code extension to easily create Dart Frog apps within your IDE.
Running the development server
You should now have a directory called todos
-- cd
into it:
cd todos
Then, run the following command:
dart_frog dev
This will start the development server on port 8080
:
✓ Running on http://localhost:8080 (1.3s)
The Dart VM service is listening on http://127.0.0.1:8181/YKEF_nbwOpM=/
The Dart DevTools debugger and profiler is available at: http://127.0.0.1:8181/YKEF_nbwOpM=/devtools/#/?uri=ws%3A%2F%2F127.0.0.1%3A8181%2FYKEF_nbwOpM%3D%2Fws
[hotreload] Hot reload is enabled.
Make sure it's working by opening http://localhost:8080 in your browser or via cURL
:
curl --request GET \
--url http://localhost:8080
If everything succeeded, you should see Welcome to Dart Frog!
.
Todos Data Source
Creating package:todos_data_source
Now that we have a running application, we need to define an abstraction for a todos data source which will be responsible for exposing APIs to perform C.R.U.D operations on a list of todos.
Since the todos data source is not tightly coupled to our Dart Frog application, we can create it as a package.
Decomposing a project into one or more packages is a form of modularization which can help with maintainability and reusability.
In this tutorial, we're going to use package:mason_cli to help us create new packages quickly.
If you don't have package:mason_cli
installed, follow the installation directions before proceeding.
Install the latest version of the Very Good Dart Package by running:
mason add -g very_good_dart_package
Then we can create the todos_data_source
via:
mason make very_good_dart_package --project_name "todos_data_source" --description "A generic interface for managing todos." -o packages
Alternatively you can run mason make very_good_dart_package
and fill out the interactive prompts.
Now we should have the scaffolding for the todos_data_source
package under packages/todos_data_source
:
├── packages
│ └── todos_data_source
│ ├── README.md
│ ├── analysis_options.yaml
│ ├── coverage_badge.svg
│ ├── lib
│ ├── pubspec.lock
│ ├── pubspec.yaml
│ └── test
Updating the pubspec.yaml
Next, let's update the pubspec.yaml
in the todos_data_source
to include the relevant dependencies:
name: todos_data_source
description: A generic interface for managing todos.
version: 0.1.0+1
publish_to: none
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
equatable: ^2.0.3
json_annotation: ^4.6.0
meta: ^1.7.0
dev_dependencies:
build_runner: ^2.2.0
json_serializable: ^6.3.1
mocktail: ^1.0.0
test: ^1.19.2
very_good_analysis: ^5.0.0
Install the newly added dependencies via:
dart pub get
Make sure to run the above command from within the packages/todos_data_source
directory.
Creating the Todo
model
Next, let's define our todo model which will be a plain Dart class which represents a single todo item.
Create lib/src/models/todo.dart
with the following contents:
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:meta/meta.dart';
part 'todo.g.dart';
/// {@template todo}
/// A single todo item.
///
/// Contains a [title], [description] and [id], in addition to a [isCompleted]
/// flag.
///
/// If an [id] is provided, it cannot be empty. If no [id] is provided, one
/// will be generated.
///
/// [Todo]s are immutable and can be copied using [copyWith], in addition to
/// being serialized and deserialized using [toJson] and [fromJson]
/// respectively.
/// {@endtemplate}
()
class Todo extends Equatable {
/// {@macro todo}
Todo({
this.id,
required this.title,
this.description = '',
this.isCompleted = false,
}) : assert(id == null || id.isNotEmpty, 'id cannot be empty');
/// The unique identifier of the todo.
///
/// Cannot be empty.
final String? id;
/// The title of the todo.
///
/// Note that the title may be empty.
final String title;
/// The description of the todo.
///
/// Defaults to an empty string.
final String description;
/// Whether the todo is completed.
///
/// Defaults to `false`.
final bool isCompleted;
/// Returns a copy of this todo with the given values updated.
///
/// {@macro todo}
Todo copyWith({
String? id,
String? title,
String? description,
bool? isCompleted,
}) {
return Todo(
id: id ?? this.id,
title: title ?? this.title,
description: description ?? this.description,
isCompleted: isCompleted ?? this.isCompleted,
);
}
/// Deserializes the given `Map<String, dynamic>` into a [Todo].
static Todo fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
/// Converts this [Todo] into a `Map<String, dynamic>`.
Map<String, dynamic> toJson() => _$TodoToJson(this);
List<Object?> get props => [id, title, description, isCompleted];
}
The Todo
class uses package:json_serializable to handle generating the code to (de)serialize to and from JSON.
The Todo
class extends uses package:equatable to override ==
and hashCode
so that we can compare Todo
instances by value.
Next, we need to use package:build_runner to generate the relevant code for json_serializable
:
dart run build_runner build --delete-conflicting-outputs
We should see that the todos.g.dart
file was generated and our code should not have any errors or warnings at this point.
Let's add a barrel file to export our models
by creating lib/src/models/models.dart
and exporting todos.dart
:
export 'todo.dart';
Also, let's update the library exports to include the models
in lib/todos_data_source.dart
:
/// A generic interface for managing todos.
library todos_data_source;
export 'src/models/models.dart';
export 'src/todos_data_source.dart';
That's it for the models
. Next, we'll define the TodosDataSource
class.
Creating the TodosDataSource
The last thing we need to do in the todos_data_source
package is define the TodosDataSource
class. It's going to be an abstract
class because it will serve as an interface which can have multiple concrete implementations.
Create lib/src/todos_data_source.dart
with the following contents:
import 'package:todos_data_source/todos_data_source.dart';
/// An interface for a todos data source.
/// A todos data source supports basic C.R.U.D operations.
/// * C - Create
/// * R - Read
/// * U - Update
/// * D - Delete
abstract class TodosDataSource {
/// Create and return the newly created todo.
Future<Todo> create(Todo todo);
/// Return all todos.
Future<List<Todo>> readAll();
/// Return a todo with the provided [id] if one exists.
Future<Todo?> read(String id);
/// Update the todo with the provided [id] to match [todo] and
/// return the updated todo.
Future<Todo> update(String id, Todo todo);
/// Delete the todo with the provided [id] if one exists.
Future<void> delete(String id);
}
We're done with the todos_data_source
! Next, we'll create a concrete implementation of the TodosDataSource
interface which is backed by an in-memory cache.
In-Memory Todos Data Source
Just like with the todos_data_source
, we'll create a new package called in_memory_todos_data_source
to contain the concrete implementation.
Creating package:in_memory_todos_data_source
From the root of the project we can use mason make
to generate a new Dart package again:
mason make very_good_dart_package --project_name "in_memory_todos_data_source" --description "An in-memory implementation of the TodosDataSource interface." -o packages
Updating the pubspec.yaml
Next, let's update the pubspec.yaml
in the in_memory_todos_data_source
to include the relevant dependencies:
name: in_memory_todos_data_source
description: An in-memory implementation of the TodosDataSource interface.
version: 0.1.0+1
publish_to: none
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
todos_data_source:
path: ../todos_data_source
uuid: ^3.0.6
dev_dependencies:
mocktail: ^1.0.0
test: ^1.19.2
very_good_analysis: ^5.0.0
The in_memory_todos_data_source
depends on the todos_data_source
via path
.
Install the newly added dependencies via:
dart pub get
Creating the InMemoryTodosDataSource
Next, let's update lib/src/in_memory_todos_data_source.dart
to implement the TodosDataSource
interface:
import 'package:todos_data_source/todos_data_source.dart';
import 'package:uuid/uuid.dart';
/// An in-memory implementation of the [TodosDataSource] interface.
class InMemoryTodosDataSource implements TodosDataSource {
/// Map of ID -> Todo
final _cache = <String, Todo>{};
Future<Todo> create(Todo todo) async {
final id = const Uuid().v4();
final createdTodo = todo.copyWith(id: id);
_cache[id] = createdTodo;
return createdTodo;
}
Future<List<Todo>> readAll() async => _cache.values.toList();
Future<Todo?> read(String id) async => _cache[id];
Future<Todo> update(String id, Todo todo) async {
return _cache.update(id, (value) => todo);
}
Future<void> delete(String id) async => _cache.remove(id);
}
That's it! We're done making the data sources for our Dart Frog application and we're ready to start working on the Dart Frog app itself!
Updating the pubspec.yaml
The first thing we need to do is update the root pubspec.yaml
to contain the todos_data_source
and in_memory_todos_data_source
dependencies:
name: todos
description: An example todos app built with Dart Frog.
version: 1.0.0+1
publish_to: none
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
dart_frog: ^1.0.0
in_memory_todos_data_source:
path: packages/in_memory_todos_data_source
todos_data_source:
path: packages/todos_data_source
dev_dependencies:
http: ^1.0.0
mocktail: ^1.0.0
test: ^1.19.2
very_good_analysis: ^5.0.0
Install the newly added dependencies via:
dart pub get
Creating middleware
Next, let's create a top-level piece of middleware
to provide the TodosDataSource
to all routes. Create routes/_middleware.dart
with the following contents:
import 'package:dart_frog/dart_frog.dart';
import 'package:in_memory_todos_data_source/in_memory_todos_data_source.dart';
final _dataSource = InMemoryTodosDataSource();
Handler middleware(Handler handler) {
return handler
.use(requestLogger())
.use(provider<TodosDataSource>((_) => _dataSource));
}
We're providing a single instance of the TodosDataSource
so we have a single source of data for the lifetime of the application.
In addition, we're using the requestLogger
middleware from package:dart_frog
to log all requests for debugging.
Install and use the Dart Frog VS Code extension to easily create new middleware within your IDE.
Creating the /todos
route
Next, delete the root route handler at routes/index.dart
and create a route handler for the /todos
endpoint by creating routes/todos/index.dart
:
import 'dart:async';
import 'dart:io';
import 'package:dart_frog/dart_frog.dart';
import 'package:todos_data_source/todos_data_source.dart';
FutureOr<Response> onRequest(RequestContext context) async {
switch (context.request.method) {
case HttpMethod.get:
return _get(context);
case HttpMethod.post:
return _post(context);
case HttpMethod.delete:
case HttpMethod.head:
case HttpMethod.options:
case HttpMethod.patch:
case HttpMethod.put:
return Response(statusCode: HttpStatus.methodNotAllowed);
}
}
Future<Response> _get(RequestContext context) async {
final dataSource = context.read<TodosDataSource>();
final todos = await dataSource.readAll();
return Response.json(body: todos);
}
Future<Response> _post(RequestContext context) async {
final dataSource = context.read<TodosDataSource>();
final todo = Todo.fromJson(
await context.request.json() as Map<String, dynamic>,
);
return Response.json(
statusCode: HttpStatus.created,
body: await dataSource.create(todo),
);
}
Install and use the Dart Frog VS Code extension to easily create new routes within your IDE.
We're using context.read<TodosDataSource>
to access the provided instance of the TodosDataSource
.
In this route handler, we only want to handle GET
and POST
requests so we're using a switch
statement on context.request.method
. If the HttpMethod
is not GET
or POST
, our route handler responds with a 405
status code (method not allowed).
In addition, we're using the Response.json
constructor to respond with Content-Type: application/json
.
Next, we'll create a route handler for the /todos/<id>
endpoint so that we can handle operations for a specific todo.
Creating the /todos/<id>
route
We can create a dynamic route to handle matching and id
by creating a file called: routes/todos/[id].dart
.
Dynamic routes allow you to have one or more dynamic path segments in your route. Learn more about dynamic routes.
import 'dart:async';
import 'dart:io';
import 'package:dart_frog/dart_frog.dart';
import 'package:todos_data_source/todos_data_source.dart';
FutureOr<Response> onRequest(RequestContext context, String id) async {
final dataSource = context.read<TodosDataSource>();
final todo = await dataSource.read(id);
if (todo == null) {
return Response(statusCode: HttpStatus.notFound, body: 'Not found');
}
switch (context.request.method) {
case HttpMethod.get:
return _get(context, todo);
case HttpMethod.put:
return _put(context, id, todo);
case HttpMethod.delete:
return _delete(context, id);
case HttpMethod.head:
case HttpMethod.options:
case HttpMethod.patch:
case HttpMethod.post:
return Response(statusCode: HttpStatus.methodNotAllowed);
}
}
Future<Response> _get(RequestContext context, Todo todo) async {
return Response.json(body: todo);
}
Future<Response> _put(RequestContext context, String id, Todo todo) async {
final dataSource = context.read<TodosDataSource>();
final updatedTodo = Todo.fromJson(
await context.request.json() as Map<String, dynamic>,
);
final newTodo = await dataSource.update(
id,
todo.copyWith(
title: updatedTodo.title,
description: updatedTodo.description,
isCompleted: updatedTodo.isCompleted,
),
);
return Response.json(body: newTodo);
}
Future<Response> _delete(RequestContext context, String id) async {
final dataSource = context.read<TodosDataSource>();
await dataSource.delete(id);
return Response(statusCode: HttpStatus.noContent);
}
onRequest
now has two parameters: RequestContext
, and id
. The id
path segment is forwarded to the onRequest
method call.
Just like in the /todos
route handler, we are switching on the context.request.method
and selectively handling GET
, PUT
, and DELETE
requests.
Summary
Be sure to save all the changes and hot reload should kick in ⚡️
[hotreload] - Application reloaded.
You should now be able to make requests to create, read, update, and delete todos:
# Create a new todo
curl --request POST \
--url http://localhost:8080/todos \
--header 'Content-Type: application/json' \
--data '{
"title": "Take out trash"
}'
# Read all todos
curl --request GET \
--url http://localhost:8080/todos
# Read a specific todo by id
curl --request GET \
--url http://localhost:8080/todos/<id>
# Update a specific todo by id
curl --request PUT \
--url http://localhost:8080/todos/<id> \
--header 'Content-Type: application/json' \
--data '{
"title": "Take out trash!",
"isCompleted": true
}'
# Delete a specific todo by id
curl --request DELETE \
--url http://localhost:8080/todos/<id>
You should see detailed request logs in the console due to the requestLogger
middleware that look similar to:
2022-08-09T17:43:35.816387 0:00:00.016484 GET [200] /todos
2022-08-09T17:44:05.561021 0:00:00.022465 POST [201] /todos
🎉 Congrats, you've created a todos
application using Dart Frog. View the full source code.