Skip to content

Quick Start

Build a small multi-tenant catalog API. By the end you will have:

  • A typed Product entity with generated CRUD routes
  • JWT auth on /products
  • Per-tenant row scoping
  • ?query= support for filtering, search, sorting, and paging
  • Console telemetry
  • /docs and /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.dart

Open http://localhost:8080/docs for the generated API explorer.

5. What Was Mounted

For basePath: '/products', the current standard route surface is:

MethodPathPurpose
GET/products/List with canonical ?query=<cdx_query.Query JSON>
POST/products/Create
GET/products/countCount matching rows
GET/products/groupedGrouped list by ?query= with group_by cdx.query.group.filter_bucket
GET/products/:id/positionPosition in the current list window
GET/products/:idGet one
PUT/products/:idUpdate
DELETE/products/:idDelete
GET/products/_/schemaEntity 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