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.
ApplicationChanneladalah pusat konfigurasi — inisialisasi database, auth server, dan routing semuanya di sini, di methodprepare()danentryPoint.ResourceControllerdengan 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.ManagedObjectadalah fondasi ORM — definisikan model sebagai dua kelas:class Model extends ManagedObject<_Model>danclass _Modelberisi property dengan anotasi.- Migration otomatis via
conduit db generate— Conduit mendeteksi perubahan model dan menghasilkan migration SQL.conduit db upgrademengeksekusinya ke database.- OAuth2 bawaan —
AuthServer,AuthController, danAuthorizer.bearer()memberikan autentikasi OAuth2 dengan token endpoint, refresh token, dan scope tanpa konfigurasi tambahan.Agentuntuk 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.