DEV Community

KhoPhi
KhoPhi

Posted on

Getting Started with the Aqueduct Framework

You ain't got time. I ain't got time neither. Let's get over with this pretty quick. Here are some of the facets of the Aqueduct framework you stand to understand in this guide.

  • Starting an Aqueduct project
  • Handling Migrations and providing seed data or fixtures for migrations
  • Routing i.e handling params and query params
  • Making CRUD requests, handled by a controller
  • Using PostgreSQL as backend
  • Basic pagination in Aqueduct
  • Writing basic tests.

The reason behind the article (as well as many of my others) is the frustration I went through getting the most basic things done, and the unnecessary lengthy process other tutorials/articles go through, winding long and time wasting. For the full rant, see at the end.

TL;DR

Without much ado, let's rock and roll

Setting Up Project

I'm not gonna pamper you. Here's how to setup an Aqueduct project. You don't have time, so just straight to the meat

Setting up Database

Create a postgreSQL database, call it anything you want.

  $ sudo -u postgres psql
  > CREATE USER projectuser WITH PASSWORD 'password';
  > CREATE DATABASE mydatabase;
  > GRANT ALL PRIVILEGES ON DATABASE mydatabase TO projectuser;
  > \q

Then create a database.yaml file in the root project (same location as the pubspec.yaml), and put in these:

username: 'projectuser'
password: 'password'
host: 'localhost'
port: 5432
databaseName: 'mydatabase'

The above database.yaml is for the aqueduct dbcommands to make connections to the DB. However, for our application to speak to the database, we need update the config.yaml with our database info.

database:
  host: 'localhost'
  port: 5432
  username: 'projectuser'
  password: 'password'
  databaseName: 'mydatabase'

Migration

If you're coming from somewhere like Django or Laravel, migration is likely a piece of cake for you.

Otherwise, think of migrations as a way to communicate your schema changes to the database, for real. If you didn't get that, please google for more details on migrations

TL;DR

Last but not least, run the migration, to convey what database scheme we have and want to the database for real. You can check the migrations/00000001_initial.migration.dart file to see what is gonna happen.

Also, seed/fixture data is added for free. Duh!

Run aqueduct db upgrade to commit the migration to database.

Run Application

In the project root folder, run aqueduct serve

Since this project is API endpoints only, you'll have to consume using a REST Client, such as Postman.

If using Postman, check out this API request collection to help you get started with consuming your endpoints. You might wanna read on how to use the collection in Postman.

We've spent 2 seconds already. That's too much time. Let's actually get into the real deals

Application Channel, Entry Point & Routing

In the lib/channel.dart is where we find, more or less, the bootstrapping of our application, and the few get-outta-ways we'll want done, such as, keeping in touch with the database by loading the config file, and also create one or two routes.

Nothing extraordinary happening. Just the same principles you know from your Ruby, DJango or Laravel. Heck, even Angular!

Update your lib/channel.dart file with these contents

import 'controller/icd_controller.dart';
import 'dart:io';
import 'mpharma.dart';

class MpharmaChannel extends ApplicationChannel {
  ManagedContext context;

  @override
  Future prepare() async {
    logger.onRecord.listen(
        (rec) => print("$rec ${rec.error ?? ""} ${rec.stackTrace ?? ""}"));

                   // this Class is at the bottom
    final config = DatabaseConfig(options.configurationFilePath);

    final dataModel = ManagedDataModel.fromCurrentMirrorSystem();

    final persistentStore = PostgreSQLPersistentStore.fromConnectionInfo(
      config.database.username,
      config.database.password,
      config.database.host,
      config.database.port,
      config.database.databaseName,
    );

    context = ManagedContext(dataModel, persistentStore);
  }

  @override
  Controller get entryPoint {
    final router = Router();

    router.route("/example").linkFunction((request) async {
      return Response.ok({"key": "value"});
    });

    router.route('/icd/[:id]').link(() => ICDController(context));

    return router;
  }
}

class DatabaseConfig extends Configuration {
  DatabaseConfig(String path) : super.fromFile(File(path));

  DatabaseConfiguration database;

  @optionalConfiguration
  int identifier;
}

In English:

  • We're importing a few. dart:io allows us to read files (we're reading the config.yaml file. Think of it as magic!
  • We also load a controller file. If coming from Angular, think of it as the Component.ts file
  • Then we create some routes. This part, lemme explain a bit

router.route('/icd/[:id]').link(() => ICDController(context));

A lot is packed into the above single line of code. So much, I can write a book on this line alone. But well...

The above route matches anything like this

example.com/icd GET
example.com/icd POST
example.com/icd/1 GET
example.com/icd/1 PUT
example.com/icd/1 DELETE

example.com/icd?limit=x&offset=x

The [:id] is the params. The [ ] around it makes that param optional. Thus, without an ID param, it'll do just fine. So if example.com/icd comes in, the application will do just fine.

You'll see how each of these HTTP verbs are handled in the controller when we switch over to it next. However, know that we're using the HTTP verbs, as many as possible, at least to distinguish the requests

The ICDController() was imported from the controller, which handles the routes coming through this endpoint.

To pass query params, just pass it in the URL, like it's done in other frameworks. You only deal with the query params, if any, when in the controller.

So you see, just a single line, all the punch it throws? Excited yet? If not, the Controller might whet you up!

Mr. Controller

Our Component Controller is where we handle the logic and communicates through the ORM that comes with Aqueduct to the Database. Above, we setup to use PostgreSQL, which depending on who you ask, is the best Relational Database platform in the world.

'Abeg', let's end the DB wars here!

Like other platforms, having a model for our database means we can more or less regulate what data structures flows through our application, and into the database.

In Aqueduct, we do so, by creating a model/icd_model.dart

import 'package:mpharma/mpharma.dart';

class ICD extends ManagedObject<_ICD> implements _ICD {

  @override
  void willInsert() {
    createdAt = DateTime.now().toUtc();
  }
}

class _ICD {
  @primaryKey
  int id;

  @Column()
  String categoryCode;

  @Column()
  String diagnosisCode;

  @Column()
  String fullCode;

  @Column()
  String abbrDesc;

  @Column()
  String fullDesc;

  @Column()
  String categoryTitle;

  @Column()
  DateTime createdAt;

}

The only part I'll comment on is

  @override
  void willInsert() {
    createdAt = DateTime.now().toUtc();
  }

In Mongoose (ExpressJS, NodeJS, MongoDB), there's the { timestamp: true } option, which when specified, any data passing through the schema into the database, gets a createdAt and updatedAt timestamps. It happens for free, seamlessly

In Aqueduct, a similar result can be achieved via the model signals. In the case above, whenever there's an insert about to happen - willInsert(), cook up a createdAt field, slap in there the current Datetime in UTC format, then add as a field to whatever data structure is about to be saved.

The rest of the code is, well, code!

I keep dangling the controller in your face, but not showing you the code? Here we go

import 'package:aqueduct/aqueduct.dart';
import 'package:mpharma/mpharma.dart';
import 'package:mpharma/model/icd_model.dart';

class ICDController extends ResourceController {
  ICDController(this.context);

  final ManagedContext context;

  @Operation.get()
  Future<Response> getAll({@Bind.query('limit') int limit, @Bind.query('offset') int pageby}) async {

    limit ??= 100;
    pageby ??= 0;

    if (pageby != null) { pageby = pageby * 10; }
    
    final icdQuery = Query<ICD>(context);

    icdQuery
      ..pageBy((p) => p.createdAt, QuerySortOrder.descending)
      ..fetchLimit = limit
      ..offset = pageby;
      
    final icds = await icdQuery.fetch();

    return Response.ok(icds);
  }

  @Operation.get('id')
  Future<Response> getById(@Bind.path('id') int id) async {
    final query = Query<ICD>(context)..where((icd) => icd.id).equalTo(id);

    final icd = await query.fetchOne();

    if (icd == null) {
      return Response.notFound(body: 'Not found');
    }

    return Response.ok(icd);
  }

  @Operation.post()
  Future<Response> createICD(@Bind.body() ICD body) async {
    final query = Query<ICD>(context)..values = body;
    final insertICD = await query.insert();

    return Response.ok(insertICD);
  }

  @Operation.put('id')
  Future<Response> updateById(
      @Bind.path('id') int id, @Bind.body() ICD body) async {
    final query = Query<ICD>(context)
      ..where((icd) => icd.id).equalTo(id)
      ..values = body;

    final icd = await query.updateOne();

    if (icd == null) {
      return Response.notFound(body: 'Not found');
    }

    return Response.ok(icd);
  }

  @Operation.delete('id')
  Future<Response> deleteById(@Bind.path('id') int id) async {
    final query = Query<ICD>(context)..where((icd) => icd.id).equalTo(id);

    final icd = await query.delete();
    return Response.ok({'state': true, 'msg': 'Delete successfull'});
  }
}

A lot to unpack here? Nope. Just a few

  @Operation.get()
  Future<Response> getAll({@Bind.query('limit') int limit, @Bind.query('offset') int pageby}) async {
    print(limit);
    limit ??= 100;
    pageby ??= 0;

@Operation.get(), @Operation.post() etc, is more like the router.get('/url', req, res) in Express

Future<Response> getAll({@Bind.query('limit') int limit, @Bind.query('offset') int pageby}) async { .. }

Take note of this part first,
getAll({@Bind.query('limit') int limit, @Bind.query('offset') int pageby})

When you do getAll(@Bind.query...) it means totally different from getAll({@Bind.query ...})

Do you notice the difference? The second one is in curly braces. The first is not.

Within curly braces mean it's optional parameters, thus, in the above within the braces, if the URL doesn't supply any query params, we'll do just fine.

    limit ??= 100;
    pageby ??= 0;

    // should this next line even be there? I wrote it
    // Can't tell for sure if it should be there. ¯\_(ツ)_/¯
    if (pageby != null) { pageby = pageby * 10; }
    
    final icdQuery = Query<ICD>(context);

    icdQuery
      ..pageBy((p) => p.createdAt, QuerySortOrder.descending)
      ..fetchLimit = limit
      ..offset = pageby;

limit ??= 100; is basically saying if the limit query param is empty, just assign 100 to that var. Same for the pageby

The rest, is just straight code written in English. If any part of the controller code isn't clear, kindly leave a comment. Otherwise I take it as y'all readers (more like my classroom students) said "Yes sir, we understand" to me.

Conclusion

And so we're done. Source code to above project here:

Aqueduct CRUD API

Leave any comments you might have in the comment section below.

If you're into tests, check the sample Test I wrote for this project.

Top comments (0)