PLEASE NOTE: As of Dart 2 the API for Aqueduct has changed, leading to breaking changes. This article was based on Aqueduct 2.5.0 for Dart v1.
I have updated this as a new video series: http://bit.ly/aqueduct-tutorial
In Part 1 we had a brief overview of Aqueduct, its features and learnt how to set up the example project using its CLI tool.
This article is part of a series, covering these topics:
- Part 1: Setting up and running the example
- Part 2: Implementing routing with CRUD operations (we’re here)
- Part 3: Connecting Web APIs to PostgreSQL database
- Part 4: Writing automated tests
- *Bonus content* DB Migration and Model Relationships 😄
In this part, we’ll be implementing a custom route with CRUD(create, read, update, delete) capabilities.
Before jumping into this, we need to understand the concept of Routers and HTTPControllers. This will inform the way we proceed. At the end of this part, we’ll have our endpoint in place, with the ability to manipulate our data source through the CRUD actions we define.
What is a Router?
Routers are responsible for capturing the request path and determining the logic that runs based on it. The request path is defined by registering a route when calling the route
method on a Router
object that is supplied to us. The registration occurs when the setupRouter
method in our FaveReadsSink subclass is called:
// lib/fave_reads_sink.dart
@override
void setupRouter(Router router) {...}
The setupRouter
method provides a Router object as a parameter, which we then use to define each of our routes:
@override
void setupRouter(Router router) {
router.route('/router-1').listen(...);
router.route('/router-2').listen(...);
router.route('/router-3').listen(...); // and so on
}
Calling the route method accepts a string containing the path name, followed by a listen
method that then allows us to define the logic to run when a request is made to this path. The route can contain path variables, which are placeholder tokens that represent whatever value is in that segment of the path:
router.route('/items/:itemID');
The example above declares a path variable itemID
, which matches “/items/0”, “/items/1”, “/items/foo” and so on. The value of itemID
will be “0”, “1” and “foo” respectively.
Path variables can also be optional, so that allows us to set them like so:
router.route('/items/[:itemID]');
This means that route-matching will also include “/items” as a path name.
The documentation on Routers is quite comprehensive and I’d recommend checking that out.
So, how do I apply this?
With this knowledge let’s open up lib/fave_reads_sink.dart
from the project and amend setupRouter
implementation as follows:
@override
void setupRouter((Router router) async {
router.route('/books[/:index]').listen((Request incomingRequest) async {
return new Response.ok('Showing all books.');
});
router.route('/').listen((Request incomingRequest) async {
return new Response.ok('<h1>Welcome to FaveReads</h1>')
..contentType = ContentType.HTML;
});
});
The root path(/) now returns HTML content. We also have a “/books” route defined that accepts an optional path variable named index
. This will be the endpoint for our CRUD operations.
Invoking the route method returns a RouteController
which exposes the listen
method for us to define our logic to run in. There are two other methods we can use, namely pipe
and generate
. The latter allows us to create a new HTTPController object that gives better handling of our request (more on that later).
The listen
method accepts a closure containing a Request
object that represents an incoming request. We can then pull the information we need from that, perform transformations, and return a response.
The current logic for “/books” return the same response regardless of the request action. Let’s modify this to return a different response for each of our actions:
router.route('/books[/:index]').listen((Request incomingRequest) async {
String reqMethod = incomingRequest.innerRequest.method;
String index = incomingRequest.path.variables["index"];
if (reqMethod == 'GET') {
if(index != null) {
return new Response.ok('Showing book by index: $index');
}
return new Response.ok('Showing all books.');
} else if (reqMethod == 'POST') {
return new Response.ok('Added a book.');
} else if (reqMethod == 'PUT') {
return new Response.ok('Added a book.');
} else if (reqMethod == 'DELETE') {
return new Response.ok('Added a book.');
}
// If all else fails
return new Response(405, null, 'Not sure what you\'re asking here');
});
This works as expected by the code quality gets pretty ugly quickly. It’s repetitive and makes an easy mess of things:
This can be cleaned up using an HTTPController
!
What is an HTTPController?
HTTPControllers respond to HTTP requests by mapping them to a particular ‘handler method’ to generate a response. A request is sent to an HTTPController by a Router as long as its path is matched.
To create one we will create a BooksController
that extends HTTPController
in order to add the behaviour we want. Let’s do this now. Create controller/books_controller.dart
in the lib
directory with the below content:
import '../fave_reads.dart';
class BooksController extends HTTPController {
// invoked for GET /books
@httpGet // HTTPMethod meta data
Future<Response> getAllBooks() async => new Response.ok('Showing all books');
// invoked for GET /books/:index
@httpGet // HTTPMethod meta data
Future<Response> getBook(@HTTPPath("index") int idx) async => new Response.ok('Showing single book');
// invoked for POST /books
@httpPost // HTTPMethod meta data
Future<Response> addBook() async => new Response.ok('Added a book');
// invoked for PUT /books
@httpPut // HTTPMethod meta data
Future<Response> updateBook() async => new Response.ok('Updated a book');
// invoked for DELETE /books
@httpDelete // HTTPMethod meta data
Future<Response> deleteBook() async => new Response.ok('Deleted a book');
}
Here’s a summation of what’s happening:
- The BooksController subclass consists of 5 handler methods, known as responder methods.
- Each responder method is annotated with a constant reflecting the appropriate request method:
@httpGet
,@httpPost
,@httpPut
,@httpDelete
. Other methods will useHTTPMethod
, like@HTTPMethod('PATCH')
. - Each responder method returns a Future of type
Response
. Futures are to Dart what Promises are to JavaScript. - A responder method can bind values from the request to its arguments. We see this with the
getBook()
responder method argument:@HTTPPath("index") int idx
. It's path variableindex
is cast to an integer and assigned to a variable namedidx
.
If none of the responder methods match the request method(e.g. PATCH), a 405 Method Not Allowed
response is returned.
Let’s go to lib/fave_reads_sink.dart
and use this controller:
import 'fave_reads.dart';
import 'controller/books_controller.dart'; // don't forget to import!
// ...
// ...
@override
void setupRouter((Router router) async {
router
.route('/books/[:index]')
.generate(() => new BooksController()); // replaces `listen` method
// ...
and run our project by doing aqueduct serve
or dart bin/main.dart
in the terminal.
We can test our responses by using Postman.
Mocking our datasource
For our datasource, let’s create an array inside controller/books_controller.dart
:
import '../fave_reads.dart';
List books = [
{
'title': 'Head First Design Patterns',
'author': 'Eric Freeman',
'year': 2004
},
{
'title': 'Clean Code: A handbook of Agile Software Craftsmanship',
'author': 'Robert C. Martin',
'year': 2008
},
{
'title': 'Code Complete: A Practical Handbook of Software Construction',
'author': 'Steve McConnell',
'year': 2004
},
];
class BooksController extends HTTPController {...}
We will then update our responder methods inside BooksController
to manipulate this dataset:
class BooksController extends HTTPController {
@httpGet
Future<Response> getAll() async => new Response.ok(books);
@httpGet
Future<Response> getSingle(@HTTPPath("index") int idx) async {
if (idx < 0 || idx > books.length - 1) { // index out of range
return new Response.notFound(body: 'Book does not exist');
}
return new Response.ok(books[idx]);
}
@httpPost
Future<Response> addSingle() async {
var book = request.body.asMap(); // `request` represents the current request. This is a property inside HTTPController base class
books.add(book);
return new Response.ok(book);
}
@httpPut
Future<Response> replaceSingle(@HTTPPath("index") int idx) async {
if (idx < 0 || idx > books.length - 1) { // index out of range
return new Response.notFound(body: 'Book does not exist');
}
var body = request.body.asMap();
for (var i = 0; i < books.length; i++) {
if (i == idx) {
books[i]["title"] = body["title"];
books[i]["author"] = body["author"];
books[i]["year"] = body["year"];
}
}
return new Response.ok(body);
}
@httpDelete
Future<Response> delete(@HTTPPath("index") int idx) async {
if (idx < 0 || idx > books.length - 1) { // index out of range
return new Response.notFound(body: 'Book does not exist');
}
books.removeAt(idx);
return new Response.ok('Book successfully deleted.');
}
}
Learn more about Dart's array/list methods
Restart the server again and test this out with Postman.
Please note: This is running in an isolate, which means that any side-effects can only be seen in Postman’s(or whatever tool) session. Opening a separate session(like the browser) will not show these changes. This is because isolates by design do not share state. Not to worry though–this will be resolved when we implement the real database.
Refactoring the solution
I should have finished already, but that will create more work for us in Part 3. I don’t want that to happen, therefore please bear with me on this final stretch 😊
So remember when I said you could bind values from the request to the arguments of a responder method? Well, we can refactor our POST operation to convert its payload to a map through the @HTTPBody()
metadata:
@httpPost
Future<Response> addSingle(@HTTPBody() Map book) async {
books.add(book);
return new Response.ok('Added new book.');
}
Here, an attempt is made to parse the request payload as a Map
type. We could also specify a custom type, not just using the inbuilt ones, as long as the custom type extends an HTTPSerializable
type. Let’s do this by introducing a Book model inside lib/model/book.dart
:
import '../fave_reads.dart';
class Book extends HTTPSerializable {
String title;
String author;
int year;
Book({this.title, this.author, this.year});
@override
Map<String, dynamic> asMap() => {
"title": title,
"author": author,
"year": year,
};
@override
void readFromMap(Map requestBody) {
title = requestBody["title"];
author = requestBody["author"];
year = requestBody["year"];
}
}
Here’s what is happening in summary:
- Our Book model implements
HTTPSerializable
, which is a utility used to parse information from an HTTP request - Defining
asMap
andreadFromMap(Map requestBody)
methods. The first will be used when a JSON response is being sent back to the client, while the latter retrieves the request body and extracts the data for populating our model’s properties.
Now we just need to use this model:
// lib/controller/book_controller.dart
import '../fave_reads.dart';
import 'model/book.dart';
List books = [
new Book(
title: 'Head First Design Patterns',
author: 'Eric Freeman',
year: 2004
),
new Book(
title: 'Clean Code: A handbook of Agile Software Craftsmanship',
author: 'Robert C. Martin',
year: 2008
),
new Book(
title: 'Code Complete: A Practical Handbook of Software Construction',
author: 'Steve McConnell',
year: 2004
),
];
class BooksController extends HTTPController {
// ...
// ...
Future<Response> addSingle(@HTTPbody() Book book) async { // note the `Book` type being used
books.add(book);
return new Response.ok(book);
}
//...
//...
}
Restart the server and test the results with Postman.
Conclusion
We’ve made some significant progress by fleshing out the skeleton of our web APIs. I hope that this journey has been a fun challenge so far. I’d encourage you to go through the further reading materials below to grasp the concepts we’ve covered.
As always I’m open to receiving feedback. Let me know what you liked about this tutorial, what you disliked and what you would like to see in future. I’d really be grateful for that.
And this concludes Part 2 of the series. The source code is available on github and will be updated as we go through the series. Stay tuned for more.
Further reading
Article No Longer Available
Originally posted on Medium
Top comments (1)
Part 3 is now available here: dev.to/graphicbeacon/building-rest...