DEV Community

Jermaine
Jermaine

Posted on • Edited on

Building RESTful Web APIs with Dart, Aqueduct, and PostgreSQL — Part 2: Routing

Featured image for Building RESTful Web APIs with Dart, Aqueduct and PostgreSQL


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:

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) {...}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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]');
Enter fullscreen mode Exit fullscreen mode

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;
  });
});
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

This works as expected by the code quality gets pretty ugly quickly. It’s repetitive and makes an easy mess of things:

Image of spaghetti meatballs via Giphy

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');
}
Enter fullscreen mode Exit fullscreen mode

Here’s a summation of what’s happening:

  1. The BooksController subclass consists of 5 handler methods, known as responder methods.
  2. Each responder method is annotated with a constant reflecting the appropriate request method: @httpGet, @httpPost, @httpPut, @httpDelete. Other methods will use HTTPMethod, like @HTTPMethod('PATCH').
  3. Each responder method returns a Future of type Response. Futures are to Dart what Promises are to JavaScript.
  4. 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 variable index is cast to an integer and assigned to a variable named idx.

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
// ...
Enter fullscreen mode Exit fullscreen mode

and run our project by doing aqueduct serve or dart bin/main.dart in the terminal.

We can test our responses by using Postman.

Image of Postman app

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 {...}
Enter fullscreen mode Exit fullscreen mode

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.');
  }
}
Enter fullscreen mode Exit fullscreen mode

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.');
}
Enter fullscreen mode Exit fullscreen mode

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"];
  }
}
Enter fullscreen mode Exit fullscreen mode

Here’s what is happening in summary:

  1. Our Book model implements HTTPSerializable, which is a utility used to parse information from an HTTP request
  2. Defining asMap and readFromMap(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);
  }
  //...
  //...
}
Enter fullscreen mode Exit fullscreen mode

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



Originally posted on Medium

Top comments (1)

Collapse
 
creativ_bracket profile image
Jermaine

Part 3 is now available here: dev.to/graphicbeacon/building-rest...