Conduit

Conduit #

Conduit adalah framework web server Dart yang paling lengkap — menyediakan ORM terintegrasi, autentikasi OAuth2, migration database, dan code generation dalam satu paket. Conduit adalah penerus dari Aqueduct (framework populer yang sudah tidak aktif dikembangkan) dengan desain yang lebih modern dan maintenance yang aktif. Jika kamu membangun REST API yang butuh database, autentikasi, dan fitur enterprise tanpa ingin merakit semua komponen secara manual, Conduit adalah pilihan yang paling lengkap di ekosistem Dart server-side.

Conduit vs Shelf vs Aqueduct #

flowchart LR
    subgraph "Low-level"
        S["Shelf\n• Middleware composable\n• Tidak ada ORM\n• Fleksibel maksimal"]
    end
    subgraph "Full-featured"
        C["Conduit\n• ORM terintegrasi\n• OAuth2 bawaan\n• Migration database\n• Code generation"]
    end
    subgraph "Deprecated"
        A["Aqueduct\n• Pendahulu Conduit\n• Tidak aktif dikembangkan"]
    end
    A -->|fork & rewrite| C
Aspek Shelf Conduit
ORM ✗ (manual / package lain) ✓ bawaan
Auth ✗ (manual) ✓ OAuth2 bawaan
Migration DB ✓ bawaan
Code gen conduit CLI
Boilerplate Sedikit Lebih banyak
Fleksibilitas Sangat tinggi Terbatas pada pattern Conduit
Learning curve Rendah Sedang-tinggi
Cocok untuk API ringan, microservice API kompleks dengan DB dan auth

Instalasi #

# Install Conduit CLI secara global
dart pub global activate conduit

# Buat project baru
conduit create nama_project

# Atau dengan database PostgreSQL dari awal
conduit create --template db nama_project

# Jalankan server development
conduit serve

# Struktur project yang dibuat:
nama_project/
  ├── lib/
  │   ├── nama_project.dart        ← entry point
  │   ├── channel.dart             ← request pipeline
  │   └── controller/
  │       └── identitas_controller.dart
  ├── migrations/                  ← database migrations
  ├── test/
  ├── config.yaml                  ← konfigurasi
  ├── config.src.yaml              ← template konfigurasi
  └── pubspec.yaml

Channel — Titik Masuk Aplikasi #

ApplicationChannel adalah kelas utama yang mengonfigurasi seluruh pipeline request — routing, middleware, dan koneksi database:

// lib/channel.dart
import 'package:conduit_core/conduit_core.dart';
import 'controllers/produk_controller.dart';
import 'controllers/auth_controller.dart';

class TokoChannel extends ApplicationChannel {
  // Context database — diinisialisasi sekali, digunakan semua controller
  late ManagedContext context;
  late AuthServer authServer;

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

    // Baca konfigurasi dari config.yaml
    final config = TokoConfig(options!.configurationFilePath!);

    // Inisialisasi database
    final dataModel = ManagedDataModel.fromCurrentMirrorSystem();
    final psc = PostgreSQLPersistentStore(
      config.database.username,
      config.database.password,
      config.database.host,
      config.database.port,
      config.database.databaseName,
    );
    context = ManagedContext(dataModel, psc);

    // Setup Auth server untuk OAuth2
    final authStorage = ManagedAuthDelegate<Pengguna>(context);
    authServer = AuthServer(authStorage);
  }

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

    // Rute publik
    router
      .route('/produk/[:id]')
      .link(() => ProdukController(context));

    router
      .route('/auth/token')
      .link(() => AuthController(authServer));

    // Rute yang butuh autentikasi
    router
      .route('/profil')
      .link(() => Authorizer.bearer(authServer))
      ..link(() => ProfilController(context));

    router
      .route('/admin/[:path]')
      .link(() => Authorizer.bearer(authServer, scopes: ['admin']))
      ..link(() => AdminController(context));

    return router;
  }
}

// Konfigurasi dari YAML
class TokoConfig extends Configuration {
  late DatabaseConfiguration database;
  TokoConfig(String path) : super.fromFile(File(path));
}

Controller — Handler Request #

ResourceController menyediakan cara deklaratif untuk mendefinisikan handler per HTTP method menggunakan anotasi:

// lib/controllers/produk_controller.dart
import 'package:conduit_core/conduit_core.dart';
import '../model/produk.dart';

class ProdukController extends ResourceController {
  ProdukController(this.context);
  final ManagedContext context;

  // GET /produk — ambil semua dengan pagination
  @Operation.get()
  Future<Response> ambilSemua({
    @Bind.query('halaman') int halaman = 1,
    @Bind.query('perHalaman') int perHalaman = 20,
    @Bind.query('kategori') String? kategori,
  }) async {
    final query = Query<Produk>(context)
      ..where((p) => p.aktif).equalTo(true)
      ..fetchLimit = perHalaman
      ..fetchOffset = (halaman - 1) * perHalaman
      ..sortBy((p) => p.dibuatPada, QuerySortOrder.descending);

    if (kategori != null) {
      query.where((p) => p.kategori).equalTo(kategori);
    }

    final produkList = await query.fetch();
    final total = await query.reduce.count();

    return Response.ok({
      'data': produkList.map((p) => p.asMap()).toList(),
      'total': total,
      'halaman': halaman,
      'perHalaman': perHalaman,
    });
  }

  // GET /produk/:id — ambil satu
  @Operation.get('id')
  Future<Response> ambilSatu(@Bind.path('id') int id) async {
    final query = Query<Produk>(context)
      ..where((p) => p.id).equalTo(id)
      ..where((p) => p.aktif).equalTo(true);

    final produk = await query.fetchOne();
    if (produk == null) {
      return Response.notFound(body: {'error': 'Produk tidak ditemukan'});
    }

    return Response.ok(produk.asMap());
  }

  // POST /produk — buat produk baru
  @Operation.post()
  Future<Response> buat(@Bind.body() Produk produkBaru) async {
    produkBaru
      ..aktif = true
      ..dibuatPada = DateTime.now().toUtc();

    final query = Query<Produk>(context)..values = produkBaru;
    final hasil = await query.insert();

    return Response.ok(hasil.asMap())..statusCode = 201;
  }

  // PUT /produk/:id — update produk
  @Operation.put('id')
  Future<Response> perbarui(
    @Bind.path('id') int id,
    @Bind.body() Produk produkUpdate,
  ) async {
    final query = Query<Produk>(context)
      ..where((p) => p.id).equalTo(id)
      ..values = (produkUpdate..diubahPada = DateTime.now().toUtc());

    final hasil = await query.updateOne();
    if (hasil == null) {
      return Response.notFound(body: {'error': 'Produk tidak ditemukan'});
    }

    return Response.ok(hasil.asMap());
  }

  // DELETE /produk/:id — soft delete
  @Operation.delete('id')
  Future<Response> hapus(@Bind.path('id') int id) async {
    final query = Query<Produk>(context)
      ..where((p) => p.id).equalTo(id)
      ..values.aktif = false;

    final hasil = await query.updateOne();
    if (hasil == null) {
      return Response.notFound(body: {'error': 'Produk tidak ditemukan'});
    }

    return Response.ok({'pesan': 'Produk berhasil dihapus'});
  }
}

ORM — ManagedObject #

Conduit memiliki ORM terintegrasi berbasis ManagedObject. Setiap model dipetakan ke tabel database:

// lib/model/produk.dart
import 'package:conduit_core/conduit_core.dart';

class Produk extends ManagedObject<_Produk> implements _Produk {}

class _Produk {
  @primaryKey
  int? id;

  @Column(indexed: true)
  String? nama;

  @Column()
  String? deskripsi;

  @Column()
  double? harga;

  @Column(defaultValue: '0')
  int? stok;

  @Column(indexed: true)
  String? kategori;

  @Column(defaultValue: 'true')
  bool? aktif;

  @Column()
  DateTime? dibuatPada;

  @Column(nullable: true)
  DateTime? diubahPada;

  // Relasi — satu produk dimiliki satu kategori
  @Relate(#produk, isRequired: true, onDelete: DeleteRule.cascade)
  KategoriProduk? kategoriObj;
}

// Model relasi
class KategoriProduk extends ManagedObject<_KategoriProduk>
    implements _KategoriProduk {}

class _KategoriProduk {
  @primaryKey
  int? id;

  @Column(unique: true)
  String? nama;

  // Relasi balik — satu kategori punya banyak produk
  ManagedSet<Produk>? produk;
}

Query DSL #

Conduit menyediakan query builder yang type-safe:

// Ambil dengan filter
final mahal = await Query<Produk>(context)
  ..where((p) => p.harga).greaterThan(5000000)
  ..where((p) => p.aktif).equalTo(true)
  ..sortBy((p) => p.harga, QuerySortOrder.ascending)
  ..fetchLimit = 10
  .fetch();

// JOIN — sertakan relasi
final denganKategori = await Query<Produk>(context)
  ..join(object: (p) => p.kategoriObj)
  ..where((p) => p.aktif).equalTo(true)
  .fetch();

// Agregasi
final totalHarga = await Query<Produk>(context)
  .reduce
  .sum((p) => p.harga!);

final jumlah = await Query<Produk>(context)
  ..where((p) => p.aktif).equalTo(true)
  .reduce
  .count();

// Insert
final baru = await (Query<Produk>(context)
  ..values.nama = 'Laptop'
  ..values.harga = 15000000
  ..values.aktif = true
  ..values.dibuatPada = DateTime.now().toUtc()
).insert();

// Update
await (Query<Produk>(context)
  ..where((p) => p.id).equalTo(42)
  ..values.harga = 14000000
  ..values.diubahPada = DateTime.now().toUtc()
).updateOne();

// Delete
await (Query<Produk>(context)
  ..where((p) => p.aktif).equalTo(false)
).delete();

Migration Database #

Conduit mengelola skema database melalui file migration yang di-generate otomatis:

# Generate migration dari perubahan model
conduit db generate

# Ini menghasilkan file di migrations/:
# migrations/00000001_Initial.migration.dart

# Preview SQL yang akan dieksekusi
conduit db upgrade --dry-run

# Jalankan migration ke database
conduit db upgrade

# Rollback ke versi tertentu
conduit db upgrade --target 1

# Lihat status migration
conduit db list-versions
// migrations/00000001_Initial.migration.dart — dibuat otomatis oleh Conduit
import 'package:conduit_core/conduit_core.dart';

class Initial extends Migration {
  @override
  Future upgrade() async {
    // Buat tabel
    database.createTable(SchemaTable('_kategori_produk', [
      SchemaColumn('id', ManagedPropertyType.bigInteger,
          isPrimaryKey: true, autoincrement: true),
      SchemaColumn('nama', ManagedPropertyType.string,
          isUnique: true, isIndexed: true),
    ]));

    database.createTable(SchemaTable('_produk', [
      SchemaColumn('id', ManagedPropertyType.bigInteger,
          isPrimaryKey: true, autoincrement: true),
      SchemaColumn('nama', ManagedPropertyType.string, isIndexed: true),
      SchemaColumn('harga', ManagedPropertyType.doublePrecision),
      SchemaColumn('stok', ManagedPropertyType.integer, defaultValue: '0'),
      SchemaColumn('aktif', ManagedPropertyType.boolean, defaultValue: 'true'),
      SchemaColumn('dibuat_pada', ManagedPropertyType.datetime),
      SchemaColumn.relationship('kategori_obj_id', ManagedPropertyType.bigInteger,
          relatedTableName: '_kategori_produk', rule: DeleteRule.cascade),
    ]));
  }

  @override
  Future downgrade() async {
    database.deleteTable('_produk');
    database.deleteTable('_kategori_produk');
  }

  @override
  Future seed() async {
    // Isi data awal
    await database.store.execute(
      "INSERT INTO _kategori_produk (nama) VALUES ('elektronik'), ('fashion')",
    );
  }
}

Autentikasi OAuth2 #

Conduit memiliki server OAuth2 bawaan — lengkap dengan token endpoint, refresh token, dan scope:

// lib/model/pengguna.dart
import 'package:conduit_core/conduit_core.dart';

// ManagedAuthResourceOwner menjadikan model ini resource owner OAuth2
class Pengguna extends ManagedObject<_Pengguna>
    implements _Pengguna, ManagedAuthResourceOwner<_Pengguna> {}

class _Pengguna extends ResourceOwnerTableDefinition {
  @Column(unique: true, indexed: true)
  String? email;

  String? nama;

  // hashedPassword dan salt diwarisi dari ResourceOwnerTableDefinition
}

// lib/controllers/register_controller.dart
class RegisterController extends ResourceController {
  RegisterController(this.context, this.authServer);
  final ManagedContext context;
  final AuthServer authServer;

  @Operation.post()
  Future<Response> daftar(@Bind.body() Pengguna penggunaBaru) async {
    // Hash password sebelum simpan
    final salt = generateRandomSalt();
    final hashedPassword = hashPassword(penggunaBaru.password!, salt);

    final query = Query<Pengguna>(context)
      ..values.email = penggunaBaru.email
      ..values.nama = penggunaBaru.nama
      ..values.hashedPassword = hashedPassword
      ..values.salt = salt;

    try {
      final pengguna = await query.insert();
      return Response.ok(pengguna.asMap()..remove('hashedPassword')..remove('salt'));
    } on QueryException catch (e) {
      if (e.event == QueryExceptionEvent.conflict) {
        return Response.conflict(body: {'error': 'Email sudah terdaftar'});
      }
      rethrow;
    }
  }
}

// Konfigurasi OAuth2 di channel.dart
// POST /auth/token dengan body: grant_type=password&username=&password=
// Respons: { access_token, token_type, expires_in, refresh_token }
router
  .route('/auth/token')
  .link(() => AuthController(authServer));

// Endpoint yang butuh auth
router
  .route('/api/[:path]')
  .link(() => Authorizer.bearer(authServer))  // verifikasi Bearer token
  ..link(() => ApiController(context));

Middleware — Filter #

Conduit menggunakan konsep Controller yang bisa di-chain sebagai middleware:

// Middleware logging
class LoggingMiddleware extends Controller {
  @override
  FutureOr<RequestOrResponse> handle(Request request) async {
    final mulai = DateTime.now();
    print('[${mulai.toIso8601String()}] ${request.raw.method} ${request.raw.uri.path}');

    // Teruskan ke controller berikutnya
    final response = await super.handle(request);

    final durasi = DateTime.now().difference(mulai);
    print('→ ${(response as Response).statusCode} (${durasi.inMilliseconds}ms)');

    return response;
  }
}

// CORS middleware
class CORSController extends Controller {
  @override
  FutureOr<RequestOrResponse> handle(Request request) async {
    if (request.raw.method == 'OPTIONS') {
      return Response.ok(null)
        ..contentType = null
        ..headers['Access-Control-Allow-Origin'] = '*'
        ..headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
        ..headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization';
    }

    final response = await super.handle(request) as Response;
    return response
      ..headers['Access-Control-Allow-Origin'] = '*';
  }
}

// Penggunaan di channel.dart
@override
Controller get entryPoint {
  final router = Router();

  // Tambahkan middleware CORS dan logging ke semua route
  router
    .route('/api/[:path]')
    .link(() => CORSController())
    ..link(() => LoggingMiddleware())
    ..link(() => ApiController(context));

  return router;
}

Testing di Conduit #

Conduit menyediakan Agent untuk integration testing tanpa perlu server eksternal:

// test/produk_test.dart
import 'package:conduit_test/conduit_test.dart';
import 'package:test/test.dart';
import 'package:nama_project/channel.dart';

void main() {
  final app = Application<TokoChannel>();
  late Agent agent;

  setUpAll(() async {
    // Jalankan aplikasi di mode test
    await app.startOnCurrentIsolate();
    agent = Agent(app);
  });

  tearDownAll(() async {
    await app.stop();
  });

  group('GET /produk', () {
    test('mengembalikan list produk', () async {
      final response = await agent.get('/produk');
      expect(response, hasStatus(200));
      expect(response, hasBody(partial({
        'data': isList,
        'total': isInteger,
      })));
    });

    test('filter berdasarkan kategori', () async {
      final response = await agent.get('/produk?kategori=elektronik');
      expect(response, hasStatus(200));
    });
  });

  group('POST /produk', () {
    test('buat produk baru berhasil', () async {
      final response = await agent.post('/produk', body: {
        'nama': 'Test Produk',
        'harga': 100000,
        'stok': 10,
      });
      expect(response, hasStatus(201));
      expect(response, hasBody(partial({'id': isInteger})));
    });

    test('gagal tanpa autentikasi pada endpoint protected', () async {
      final response = await agent.get('/admin/produk');
      expect(response, hasStatus(401));
    });
  });
}

Konfigurasi #

# config.yaml
database:
  host: localhost
  port: 5432
  databaseName: toko_db
  username: postgres
  password: password

# config.production.yaml — override untuk produksi
database:
  host: prod-db.example.com
  databaseName: toko_production
  username: app_user
  password: ${DB_PASSWORD}  # baca dari environment variable
# Jalankan dengan config yang berbeda
conduit serve --config-path config.production.yaml

# Atau via environment variable
DB_PASSWORD=secret conduit serve

Ringkasan #

  • Conduit adalah framework Dart server-side paling lengkap — ORM, OAuth2, migration database, dan code generation dalam satu paket. Gunakan jika membangun API yang kompleks dengan database.
  • ApplicationChannel adalah pusat konfigurasi — inisialisasi database, auth server, dan routing semuanya di sini, di method prepare() dan entryPoint.
  • ResourceController dengan anotasi @Operation.get(), @Operation.post(), @Operation.put(), @Operation.delete() — routing deklaratif yang otomatis memetakan HTTP method ke handler.
  • @Bind.path(), @Bind.query(), @Bind.body() — parameter binding yang type-safe dari URL path, query string, dan request body.
  • ManagedObject adalah fondasi ORM — definisikan model sebagai dua kelas: class Model extends ManagedObject<_Model> dan class _Model berisi property dengan anotasi.
  • Migration otomatis via conduit db generate — Conduit mendeteksi perubahan model dan menghasilkan migration SQL. conduit db upgrade mengeksekusinya ke database.
  • OAuth2 bawaanAuthServer, AuthController, dan Authorizer.bearer() memberikan autentikasi OAuth2 dengan token endpoint, refresh token, dan scope tanpa konfigurasi tambahan.
  • Agent untuk testing — integration test tanpa server eksternal, langsung memanggil handler dan memverifikasi response dengan matcher khusus Conduit.
  • Pilih Conduit jika: butuh ORM, auth, dan migration terintegrasi. Pilih Shelf jika: butuh fleksibilitas maksimal dengan overhead minimal.
  • Conduit adalah penerus aktif Aqueduct — jika menemukan kode atau tutorial Aqueduct, migrasi ke Conduit relatif mudah karena API-nya sangat mirip.

← Sebelumnya: Flutter   Berikutnya: Aqueduct →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact