Skip to content

Conduit: A Tour

The tour demonstrates many of Conduit's features.

Command-Line Interface (CLI)

The conduit command line tool creates, runs and documents Conduit applications; manages database migrations; and manages OAuth client identifiers. Install by running pub global activate conduit on a machine with Dart installed.

Create and run an application:

conduit create my_app
cd my_app/
conduit serve

Initialization

A Conduit application starts at an ApplicationChannel. You subclass it once per application to handle initialization tasks like setting up routes and database connections. An example application looks like this:

import 'package:conduit_core/conduit_core.dart';

class TodoApp extends ApplicationChannel {
  ManagedContext context;

  @override
  Future prepare() async {
    context = ManagedContext(...);
  }

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

    router
      .route("/projects/[:id]")
      .link(() => ProjectController(context));

    return router;
  }
}

Routing

A router determines which controller object should handle a request. The route specification syntax is a concise syntax to construct routes with variables and optional segments in a single statement.

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

  // Handles /users, /users/1, /users/2, etc.
  router
    .route("/projects/[:id]")
    .link(() => ProjectController());

  // Handles any route that starts with /file/
  router
    .route("/file/*")
    .link(() => FileController());

  // Handles the specific route /health
  router
    .route("/health")
    .linkFunction((req) async => Response.ok(null));

  return router;
}

Controllers

Controllers handle requests. A controller handles a request by overriding its handle method. This method either returns a response or a request. If a response is returned, that response is sent to the client. If the request is returned, the linked controller handles the request.

class SecretKeyAuthorizer extends Controller {
  @override
  Future<RequestOrResponse> handle(Request request) async {
    if (request.raw.headers.value("x-secret-key") == "secret!") {
      return request;
    }

    return Response.badRequest();
  }
}

This behavior allows for middleware controllers to be linked together, such that a request goes through a number of steps before it is finally handled.

All controllers execute their code in an exception handler. If an exception is thrown in your controller code, a response with an appropriate error code is returned. You subclass HandlerException to provide error response customization for application-specific exceptions.

ResourceControllers

ResourceControllers are the most often used controller. Each operation - e.g. POST /projects, GET /projects and GET /projects/1 - is mapped to methods in a subclass. Parameters of those methods are annotated to bind the values of the request when the method is invoked.

import 'package:conduit_core/conduit_core.dart'

class ProjectController extends ResourceController {    
  @Operation.get('id')
  Future<Response> getProjectById(@Bind.path("id") int id) async {
    // GET /projects/:id
    return Response.ok(...);
  }

  @Operation.post()
  Future<Response> createProject(@Bind.body() Project project) async {
    // POST /project
    final inserted = await insertProject(project);
    return Response.ok(inserted);
  }

  @Operation.get()
  Future<Response> getAllProjects(
    @Bind.header("x-client-id") String clientId,
    {@Bind.query("limit") int limit: 10}) async {
    // GET /projects
    return Response.ok(...);
  }
}

ManagedObjectControllers

ManagedObjectController<T>s are ResourceControllers that automatically map a REST interface to database queries; e.g. POST inserts a row, GET gets all row of a type. They do not need to be subclassed, but can be to provide customization.

router
  .route("/users/[:id]")
  .link(() => ManagedObjectController<Project>(context));

Configuration

An application's configuration is written in a YAML file. Each environment your application runs in (e.g., locally, under test, production, development) has different values for things like the port to listen on and database connection credentials. The format of a configuration file is defined by your application. An example looks like:

// config.yaml
database:
  host: api.projects.com
  port: 5432
  databaseName: project
port: 8000

Subclass Configuration and declare a property for each key in your configuration file:

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

  DatabaseConfiguration database;
  int port;
}

The default name of your configuration file is config.yaml, but can be changed at the command-line. You create an instance of your configuration from the configuration file path from your application options:

import 'package:conduit_core/conduit_core.dart';

class TodoApp extends ApplicationChannel {
  @override
  Future prepare() async {
    var options = TodoConfig(options.configurationFilePath);
    ...
  }
}

Running and Concurrency

Conduit applications are run with the conduit serve command line tool. You can attach debugging and instrumentation tools and specify how many threads the application should run on:

conduit serve --observe --isolates 5 --port 8888

Conduit applications are multi-isolate (multi-threaded). Each isolate runs a replica of the same web server with its own set of services like database connections. This makes behavior like database connection pooling implicit.

PostgreSQL ORM

The Query<T> class configures and executes database queries. Its type argument determines what table is to be queried and the type of object you will work with in your code.

import 'package:conduit_core/conduit_core.dart'

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

  final ManagedContext context;

  @Operation.get()
  Future<Response> getAllProjects() async {
    final query = Query<Project>(context);

    final results = await query.fetch();

    return Response.ok(results);
  }
}

Configuration of the query - like its WHERE clause - are configured through a fluent, type-safe syntax. A property selector identifies which column of the table to apply an expression to. The following query fetches all project's due in the next week and includes their tasks by joining the related table.

final nextWeek = DateTime.now().add(Duration(days: 7));
final query = Query<Project>(context)
  ..where((project) => project.dueDate).isLessThan(nextWeek)
  ..join(set: (project) => project.tasks);
final projects = await query.fetch();

Rows are inserted or updated by setting the statically-typed values of a query.

final insertQuery = Query<Project>(context)
  ..values.name = "Build an conduit"
  ..values.dueDate = DateTime(year, month);
var newProject = await insertQuery.insert();  

final updateQuery = Query<Project>(context)
  ..where((project) => project.id).equalTo(newProject.id)
  ..values.name = "Build a miniature conduit";
newProject = await updateQuery.updateOne();

Query<T>s can perform sorting, joining and paging queries.

final overdueQuery = Query<Project>(context)
  ..where((project) => project.dueDate).lessThan(DateTime().now())
  ..sortBy((project) => project.dueDate, QuerySortOrder.ascending)
  ..join(object: (project) => project.owner);

final overdueProjectsAndTheirOwners = await query.fetch();

Controllers will interpret exceptions thrown by queries to return an appropriate error response to the client. For example, unique constraint conflicts return 409, missing required properties return 400 and database connection failure returns 503.

Defining a Data Model

To use the ORM, you declare your tables as Dart types and create a subclass of ManagedObject<T>. A subclass maps to a table in the database, each instance maps to a row, and each property is a column. The following declaration will map to a table named _project with columns id, name and dueDate.

class Project extends ManagedObject<_Project> implements _Project {
  bool get isPastDue => dueDate.difference(DateTime.now()).inSeconds < 0;
}

class _Project  {
  @primaryKey
  int id;

  @Column(indexed: true)
  String name;

  DateTime dueDate;
}

Managed objects have relationships to other managed objects. Relationships can be has-one, has-many and many-to-many. A relationship is always two-sided - the related types must declare a property that references each other.

class Project extends ManagedObject<_Project> implements _Project {}
class _Project {
  ...

  // Project has-many Tasks
  ManagedSet<Task> tasks;
}

class Task extends ManagedObject<_Task> implements _Task {}
class _Task {
  ...

  // Task belongs to a project, maps to 'project_id' foreign key column
  @Relate(#tasks)
  Project project;
}

ManagedObject<T>s are serializable and can be directly read from a request body, or encoded as a response body.

class ProjectController extends ResourceController {
  @Operation.put('id')
  Future<Response> updateProject(@Bind.path('id') int projectId, @Bind.body() Project project) async {
    final query = Query<Project>(context)
      ..where((project) => project.id).equalTo(projectId)
      ..values = project;

    return Response.ok(await query.updateOne());
  }
}

Database Migrations

The CLI will automatically generate database migration scripts by detecting changes to your managed objects. The following, when ran in a project directory, will generate and execute a database migration.

conduit db generate
conduit db upgrade --connect postgres://user:password@host:5432/database

You can edit migration files by hand to alter any assumptions or enter required values, and run conduit db validate to ensure the changes still yield the same schema. Be sure to keep generated files in version control.

OAuth 2.0

An OAuth 2.0 server implementation handles authentication and authorization for Conduit applications. You create an AuthServer and its delegate as services in your application. The delegate is configurable and manages how tokens are generated and stored. By default, access tokens are a random 32-byte string and client identifiers, tokens and access codes are stored in your database using the ORM.

import 'package:conduit_core/conduit_core.dart';
import 'package:conduit_core/managed_auth.dart';

class AppApplicationChannel extends ApplicationChannel {
  AuthServer authServer;
  ManagedContext context;

  @override
  Future prepare() async {
    context = ManagedContext(...);

    final delegate = ManagedAuthDelegate<User>(context);
    authServer = AuthServer(delegate);
  }  
}

Built-in authentication controllers for exchanging user credentials for access tokens are named AuthController and AuthCodeController. Authorizers are middleware that require a valid access token to access their linked controller.

Controller get entryPoint {
  final router = Router();

  // POST /auth/token with username and password (or access code) to get access token
  router
    .route("/auth/token")
    .link(() => AuthController(authServer));

  // GET /auth/code returns login form, POST /auth/code grants access code
  router
    .route("/auth/code")
    .link(() => AuthCodeController(authServer));

  // ProjectController requires request to include access token
  router
    .route("/projects/[:id]")
    .link(() => Authorizer.bearer(authServer))
    .link(() => ProjectController(context));

  return router;
}

The CLI has tools to manage OAuth 2.0 client identifiers and access scopes.

conduit auth add-client \
  --id com.app.mobile \
  --secret foobar \
  --redirect-uri https://somewhereoutthere.com \
  --allowed-scopes "users projects admin.readonly"

Logging

All requests are logged to an application-wide logger. Set up a listener for the logger in ApplicationChannel to write log messages to the console or another medium.

class WildfireChannel extends ApplicationChannel {
  @override
  Future prepare() async {
    logger.onRecord.listen((record) {
      print("$record");
    });
  }
}

Testing

Conduit tests start a local version of your application and execute requests. You write expectations on the responses. A TestHarness manages the starting and stopping of an application, and exposes a default Agent for executing requests. An Agent can be configured to have default headers, and multiple agents can be used within the same test.

import 'harness/app.dart';

void main() {
  final harness = TestHarness<TodoApp>()..install();

  test("GET /projects returns all projects" , () async {
    var response = await harness.agent.get("/projects");
    expectResponse(response, 200, body: every(partial({
      "id": greaterThan(0),
      "name": isNotNull,
      "dueDate": isNotNull
    })));
  });
}

Testing with a Database

Conduit's ORM uses PostgreSQL as its database. Before your tests run, Conduit will create your application's database tables in a local PostgreSQL database. After the tests complete, it will delete those tables. This allows you to start with an empty database for each test suite as well as control exactly which records are in your database while testing, but without having to manage database schemas or use an mock implementation (e.g., SQLite).

This behavior, and behavior for managing applications with an OAuth 2.0 provider, are available as harness mixins.

Documentation

OpenAPI documents describe your application's interface. These documents can be used to generate documentation and client code. A document can be generated by reflecting on your application's codebase, just run the conduit document command.

The conduit document client command creates a web page that can be used to configure issue requests specific to your application.