Quick Start
Build a small multi-tenant catalog API. By the end you will have:
- A typed
Productentity with generated CRUD routes - JWT auth on
/products - Per-tenant row scoping
?query=support for filtering, search, sorting, and paging- Console telemetry
/docsand/openapi.json
1. Define an Entity
dart
// lib/catalog/product.dart
import 'package:json_annotation/json_annotation.dart';
part 'product.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class Product {
Product({
required this.id,
required this.tenantId,
required this.name,
required this.priceCents,
this.description,
this.createdAt,
this.updatedAt,
});
final String id;
final String tenantId;
final String name;
final int priceCents;
final String? description;
final DateTime? createdAt;
final DateTime? updatedAt;
factory Product.fromJson(Map<String, dynamic> json) =>
_$ProductFromJson(json);
Map<String, dynamic> toJson() => _$ProductToJson(this);
}Run dart run build_runner build to emit product.g.dart.
2. Declare a Feature
dart
// lib/catalog/catalog_feature.dart
import 'package:cdx_query/cdx_query.dart';
import 'package:vyuh_server/vyuh_server.dart';
import 'package:vyuh_server_plugin_entity_crud/vyuh_server_plugin_entity_crud.dart';
import 'product.dart';
final catalogFeature = FeatureDescriptor(
name: 'catalog',
description: 'Product catalog CRUD',
descriptors: [
EntityCrudDescriptor<Product>(
name: 'Products',
basePath: '/products',
config: EntityCrudConfig<Product>(
schema: 'public',
table: 'products',
fromJson: Product.fromJson,
toJson: (p) => p.toJson(),
searchableFields: const ['name', 'description'],
sortableFields: const ['name', 'price_cents', 'created_at'],
defaultSortField: 'name',
scope: (req, _) {
final tenantId = req.actor.tenantId;
if (tenantId == null) {
throw EntityValidationException(
field: 'tenant_id',
reason: 'Authenticated actor has no tenant id',
);
}
return FilterCondition(
field: 'tenant_id',
operator: FilterOps.equals,
value: FilterValue.single(tenantId),
);
},
),
),
],
);The descriptor is data. EntityCrudPlugin claims it at boot and turns it into routes during runtime.start().
3. Wire the Server
dart
// bin/server.dart
import 'package:vyuh_server/vyuh_server.dart';
import 'package:vyuh_server_plugin_postgres/vyuh_server_plugin_postgres.dart';
import 'package:vyuh_server_plugin_auth_jwt/vyuh_server_plugin_auth_jwt.dart';
import 'package:vyuh_server_plugin_telemetry_console/vyuh_server_plugin_telemetry_console.dart';
import 'package:cdx_query_server_postgres/cdx_query_server_postgres.dart';
import 'package:vyuh_server_plugin_entity_crud/vyuh_server_plugin_entity_crud.dart';
import 'package:vyuh_server_plugin_openapi/vyuh_server_plugin_openapi.dart';
import 'package:my_app/auth/verify_token.dart';
import 'package:my_app/catalog/catalog_feature.dart';
Future<void> main() async {
final runtime = await VyuhServer.bootstrap(
name: 'catalog-api',
plugins: [
EnvPlugin(
configs: [TypedEnvConfig.factory(PostgresConnectionConfig.new)],
),
ConsoleTelemetryPlugin(),
PostgresDbPlugin(),
JwtAuthPlugin(verifier: verifyTokenWithJwks),
PostgresQueryPlugin(),
EntityCrudPlugin(),
OpenApiPlugin(
title: 'Catalog API',
version: '1.0.0',
description: 'Multi-tenant product catalog',
),
],
features: [catalogFeature],
);
await runtime.start(
port: 8080,
middleware: [
GlobalMiddleware.cors(),
GlobalMiddleware.errorHandler(),
GlobalMiddleware.requestLogger(),
GlobalMiddleware.auth(),
],
);
}Set POSTGRES_URL for production. Without it, the Postgres plugin uses the local development default.
4. Run It
bash
dart run bin/server.dartOpen http://localhost:8080/docs for the generated API explorer.
5. What Was Mounted
For basePath: '/products', the current standard route surface is:
| Method | Path | Purpose |
|---|---|---|
GET | /products/ | List with canonical ?query=<cdx_query.Query JSON> |
POST | /products/ | Create |
GET | /products/count | Count matching rows |
GET | /products/grouped | Grouped list by ?query= with group_by cdx.query.group.filter_bucket |
GET | /products/:id/position | Position in the current list window |
GET | /products/:id | Get one |
PUT | /products/:id | Update |
DELETE | /products/:id | Delete |
GET | /products/_/schema | Entity metadata for clients |
GET/POST/PUT/DELETE | /products/saved-views... | Per-user saved views |
All /products routes are protected by default because EntityCrudDescriptor.protectedPaths is omitted. The scope filter is ANDed into list, get, update, delete, count, grouped, and position queries.
Where to Go Next
- Architecture — bootstrap and serve phases
- Plugins & Features — contribution slots
- Entity CRUD — capabilities, hooks, and relationships
- SaaS API Example — a fuller production shape