Vyuh CDX
Configuration

Entity API

Comprehensive guide to the entity API architecture, implementation patterns, and data operations

This guide covers the entity API architecture in depth, including base classes, HTTP implementations, versioning, caching, and advanced patterns for data operations.

Architecture Overview

The entity API system follows a layered architecture with clear separation of concerns:

EntityApi<T>
├── HttpEntityApi<T>
│   └── + CachedMixin<T>

VersionedEntityApi<T>
├── HttpVersionedEntityApi<T>
│   └── + CachedVersioningMixin<T>
  • EntityApi<T> - Abstract base contract for all entity operations
  • HttpEntityApi<T> - HTTP/REST implementation
  • CachedMixin<T> - Optional caching layer
  • VersionedEntityApi<T> - Abstract versioning contract
  • HttpVersionedEntityApi<T> - HTTP versioning implementation with audit trails
  • CachedVersioningMixin<T> - Optional caching for versioning operations

Key Design Principles

  1. Composition over Inheritance - Features added via mixins, not deep hierarchies
  2. Type Safety - Strong generic typing throughout the system
  3. Immutable Configuration - API instances configured at construction time
  4. Consistent Error Handling - Standardized error responses across all operations

Base API Classes

EntityApi<T>

The abstract base class that defines the contract for all entity operations:

@immutable
abstract class EntityApi<T extends EntityBase> {
  final String entityType;
  final String schemaType;
  final String? defaultSortField;

  const EntityApi({
    required this.entityType,
    required this.schemaType,
    this.defaultSortField,
  });

  // Abstract CRUD operations
  Future<T?> get(String id);
  Future<List<T>> getMany({
    int page = 0,
    int pageSize = 20,
    String? query,
    String? sortBy,
    String? sortOrder,
    Map<String, dynamic>? filters,
  });
  Future<T> create(T entity);
  Future<T> update(String id, T entity, {String? remarks});
  Future<bool> delete(String id);
  Future<int?> findPosition(String entityId);
  Future<int> count({
    String? query,
    Map<String, dynamic>? filters,
  });

  // Must be implemented by subclasses
  T fromJson(Map<String, dynamic> json);
}

Key Features:

  • Generic type parameter ensures type safety
  • Consistent parameter naming across all operations
  • Pagination support via page and pageSize
  • Flexible filtering and sorting capabilities
  • Optional remarks for audit trails

VersionedEntityApi<T>

Extends EntityApi to add versioning and audit capabilities:

@immutable
abstract class VersionedEntityApi<T extends VersionedEntity>
    extends EntityApi<T> {

  const VersionedEntityApi({
    required super.entityType,
    required super.schemaType,
    super.defaultSortField,
  });

  // Version history
  Future<List<EntityVersion>> versions(
    String entityId, {
    int page = 0,
    int pageSize = 20,
  });

  // Audit trail
  Future<List<EntityAudit>> audits(
    String entityId, {
    int page = 0,
    int pageSize = 20,
  });

  // Version retrieval
  Future<T?> version(String entityId, int versionNumber);

  // Version restoration
  Future<T> restore(
    String entityId,
    int versionNumber, {
    String? reason,
  });

  // Version comparison
  Future<List<FieldChangeRecord>> compare(
    String entityId,
    int fromVersion,
    int toVersion,
  );

  // Archive operations
  Future<T> archive(String entityId, {String? reason});
  Future<T> unarchive(String entityId, {String? reason});

  // JSON deserialization helpers
  EntityVersion versionFromJson(Map<String, dynamic> json);
  EntityAudit auditFromJson(Map<String, dynamic> json);
  FieldChangeRecord fieldChangeFromJson(Map<String, dynamic> json);
}

Versioning Models:

@JsonSerializable()
class EntityVersion {
  final String id;
  final String entityType;
  final String entityId;
  final int versionNumber;
  final Map<String, dynamic> entityData;
  final DateTime createdAt;
  final UserDetails? createdBy;
  final bool isActive;
  final String? changeSummary;
}

@JsonSerializable()
class EntityAudit {
  final String id;
  final String entityType;
  final String entityId;
  final String action;           // 'CREATE', 'UPDATE', 'DELETE', etc.
  final UserDetails? performedBy;
  final DateTime performedAt;
  final Map<String, dynamic>? beforeState;
  final Map<String, dynamic>? afterState;
  final List<FieldChangeRecord>? fieldChanges;
  final String? ipAddress;
  final String? userAgent;
  final String? reason;
}

@JsonSerializable()
class FieldChangeRecord {
  final String field;
  final dynamic oldValue;
  final dynamic newValue;
  final String changeType;      // 'added', 'modified', 'removed'
}

HTTP Implementations

HttpEntityApi<T>

Provides REST-based implementation of EntityApi:

class HttpEntityApi<T extends EntityBase> extends EntityApi<T> {
  final EndpointBuilder pathBuilder;
  final T Function(Map<String, dynamic>) _fromJson;

  HttpEntityApi({
    required super.entityType,
    required super.schemaType,
    required this.pathBuilder,
    required T Function(Map<String, dynamic>) fromJson,
    super.defaultSortField,
  }) : _fromJson = fromJson;

  // Default headers with authentication
  Map<String, String> get defaultHeaders => {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer ${vyuh.auth.token}',
  };

  @override
  T fromJson(Map<String, dynamic> json) => _fromJson(json);
}

Creating an HTTP API Instance

final userApi = HttpEntityApi<User>(
  entityType: 'user',
  schemaType: 'sso.users',
  pathBuilder: EndpointBuilder(prefix: '/api/users'),
  fromJson: User.fromJson,
  defaultSortField: 'name',
);

HTTP Request/Response Flow

List Operation:

final users = await userApi.getMany(
  page: 0,
  pageSize: 20,
  query: 'john',
  sortBy: 'name',
  sortOrder: 'asc',
  filters: {'role': 'admin'},
);

// HTTP Request:
// GET /api/users?page=0&limit=20&search=john&sortBy=name&sortOrder=asc&role=admin
// Authorization: Bearer <token>

// Response Format:
{
  "success": true,
  "data": [
    {"id": "1", "name": "John Doe", ...},
    {"id": "2", "name": "John Smith", ...}
  ],
  "metadata": {
    "total": 2,
    "page": 0,
    "pageSize": 20
  }
}

Create Operation:

final newUser = await userApi.create(user);

// HTTP Request:
// POST /api/users
// Body: {
//   "name": "Jane Doe",
//   "email": "jane@example.com",
//   "created_at": "2025-10-26T...",
//   "updated_at": "2025-10-26T...",
//   "created_by": "current-user-id",
//   "updated_by": "current-user-id"
// }

// Note: id field is automatically removed
//       timestamps and user attribution added automatically

Update Operation:

final updated = await userApi.update(
  userId,
  user,
  remarks: 'Updated email address',
);

// HTTP Request:
// PUT /api/users/{userId}
// Body: {
//   "name": "Jane Doe",
//   "email": "jane.doe@example.com",
//   "updated_at": "2025-10-26T...",
//   "updated_by": "current-user-id",
//   "_audit_remarks": "Updated email address"
// }

// Note: created_at and created_by are removed
//       _audit_remarks used for audit trail

HttpVersionedEntityApi<T>

Extends HttpEntityApi with versioning capabilities:

@immutable
class HttpVersionedEntityApi<T extends VersionedEntity>
    extends HttpEntityApi<T>
    with HttpVersioningMixin<T> {
  final VersionedEndpointBuilder _versionedPathBuilder;

  HttpVersionedEntityApi({
    required super.entityType,
    required super.schemaType,
    required VersionedEndpointBuilder pathBuilder,
    required super.fromJson,
    super.defaultSortField,
  })  : _versionedPathBuilder = pathBuilder,
        super(pathBuilder: pathBuilder);

  @override
  VersionedEndpointBuilder get versionedPathBuilder => _versionedPathBuilder;
}

Versioning Operations

Version History:

final versions = await api.versions(entityId, page: 0, pageSize: 10);

// GET /api/entities/{id}/versions?page=0&limit=10
// Returns: List<EntityVersion>

Audit Trail:

final audits = await api.audits(entityId, page: 0, pageSize: 10);

// GET /api/entities/{id}/audit?page=0&limit=10
// Returns: List<EntityAudit>

Restore Version:

final restored = await api.restore(
  entityId,
  versionNumber: 5,
  reason: 'Rollback erroneous changes',
);

// POST /api/entities/{id}/restore/5
// Body: {
//   "version_number": 5,
//   "reason": "Rollback erroneous changes"
// }

Compare Versions:

final changes = await api.compare(entityId, fromVersion: 3, toVersion: 5);

// GET /api/entities/{id}/compare/3/5
// Returns: List<FieldChangeRecord>

Archive/Unarchive:

final archived = await api.archive(
  entityId,
  reason: 'No longer in use',
);

// POST /api/entities/{id}/archive
// Body: {"reason": "No longer in use"}

final unarchived = await api.unarchive(entityId);

// POST /api/entities/{id}/unarchive

Endpoint Builders

EndpointBuilder

Handles route construction for standard CRUD operations:

class EndpointBuilder {
  final String _prefix;
  final String? _baseUrl;

  const EndpointBuilder({
    required String prefix,
    String? baseUrl,
  })  : _prefix = prefix,
        _baseUrl = baseUrl;

  Uri getMany({Map<String, dynamic>? queryParameters});
  Uri count({Map<String, dynamic>? queryParameters});
  Uri get(String id);
  Uri create();
  Uri update(String id);
  Uri delete(String id);
  Uri buildUri(String path, {Map<String, dynamic>? queryParameters});
}

Route Pattern:

GET    /{prefix}                  # List
GET    /{prefix}/count            # Count
GET    /{prefix}/{id}             # Get by ID
POST   /{prefix}                  # Create
PUT    /{prefix}/{id}             # Update
DELETE /{prefix}/{id}             # Delete

VersionedEndpointBuilder

Extends EndpointBuilder with versioning routes:

class VersionedEndpointBuilder extends EndpointBuilder {
  const VersionedEndpointBuilder({
    required super.prefix,
    super.baseUrl,
  });

  Uri versions(String entityId, {Map<String, dynamic>? queryParameters});
  Uri audit(String entityId, {Map<String, dynamic>? queryParameters});
  Uri byVersion(String entityId, int versionNumber);
  Uri restoreVersion(String entityId, int versionNumber);
  Uri compareVersions(String entityId, int fromVersion, int toVersion);
  Uri archive(String entityId);
  Uri unarchive(String entityId);
}

Versioning Routes:

GET    /{prefix}/{id}/versions          # Version history
GET    /{prefix}/{id}/audit             # Audit trail
GET    /{prefix}/{id}/versions/{num}    # Specific version
POST   /{prefix}/{id}/restore/{num}     # Restore version
GET    /{prefix}/{id}/compare/{f}/{t}   # Compare versions
POST   /{prefix}/{id}/archive           # Archive entity
POST   /{prefix}/{id}/unarchive         # Unarchive entity

SingletonVersionedEndpointBuilder

For entities with only one instance (like Settings):

class SingletonVersionedEndpointBuilder extends VersionedEndpointBuilder {
  const SingletonVersionedEndpointBuilder({
    required super.prefix,
    super.baseUrl,
  });

  // Overrides to remove {id} from paths
  @override
  Uri get(String id) => buildUri('');

  @override
  Uri update(String id) => buildUri('');

  @override
  Uri delete(String id) => buildUri('');
}

Singleton Routes:

GET    /{prefix}                   # Get (no ID needed)
PUT    /{prefix}                   # Update (no ID needed)
GET    /{prefix}/versions          # Version history
POST   /{prefix}/restore/{num}     # Restore version

Caching System

CachedMixin

Adds caching to any EntityApi implementation:

mixin CachedMixin<T extends EntityBase> on EntityApi<T> {
  CacheKeyBuilder get keyBuilder;
  Duration get staleTime;

  // Cache invalidation
  void invalidateAll();
  void invalidateById(String id);
  void invalidateList();
  void invalidateOperation(CacheOperation operation);
}

Cache Key Pattern:

{entityType}::{operation}::{paramsHash}

Examples:
- user::list::abc123          # List with specific filters
- user::byId::user-123         # Specific user
- user::count::def456          # Count with filters

Using Caching

class CachedUserApi extends HttpEntityApi<User> with CachedMixin<User> {
  CachedUserApi({
    required super.entityType,
    required super.schemaType,
    required super.pathBuilder,
    required super.fromJson,
  });

  @override
  CacheKeyBuilder get keyBuilder => CacheKeyBuilder(entityType: entityType);

  @override
  Duration get staleTime => const Duration(minutes: 5);
}

Automatic Cache Management:

  • getMany() - Caches list results
  • get() - Caches individual entities
  • create() - Invalidates list cache
  • update() - Invalidates list and byId caches
  • delete() - Invalidates list and byId caches

CachedVersioningMixin

Adds caching for versioning operations:

mixin CachedVersioningMixin<T extends VersionedEntity>
    on VersionedEntityApi<T>, CachedMixin<T> {

  // Cached operations
  Future<List<EntityVersion>> versions(String entityId, {...});
  Future<List<EntityAudit>> audits(String entityId, {...});
  Future<T?> version(String entityId, int versionNumber);
  Future<List<FieldChangeRecord>> compare(...);

  // Mutating operations invalidate caches
  Future<T> restore(String entityId, int versionNumber, {...});
  Future<T> archive(String entityId, {...});
  Future<T> unarchive(String entityId, {...});
}

Cache Keys for Versioning:

user::versions::user-123::0::20      # Version history
user::audits::user-123::0::20        # Audit trail
user::version::user-123::5           # Specific version
user::compare::user-123::3::5        # Version comparison

Error Handling

Error Response Format

All API errors follow a consistent format:

class ApiErrorResponse {
  final bool success;
  final ApiError? error;
  final String? message;

  String get errorMessage {
    if (error?.message != null) return error!.message;
    if (message != null) return message!;
    return 'An unknown error occurred';
  }
}

class ApiError {
  final String? code;
  final String message;
  final Map<String, dynamic>? details;

  String get formattedMessage {
    if (code != null && code!.isNotEmpty) {
      return '[$code] $message';
    }
    return message;
  }
}

Error Response Examples:

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid email format",
    "details": {
      "field": "email",
      "value": "invalid-email"
    }
  }
}

{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "Entity not found",
    "details": {
      "entityType": "user",
      "entityId": "unknown-id"
    }
  }
}

{
  "success": false,
  "error": {
    "code": "PERMISSION_DENIED",
    "message": "Insufficient permissions to perform this action"
  }
}

Custom Error Handling

class CustomApi extends HttpEntityApi<CustomEntity> {
  // ... constructor

  @override
  Future<CustomEntity> create(CustomEntity entity) async {
    try {
      return await super.create(entity);
    } on DioException catch (e) {
      if (e.response?.statusCode == 409) {
        throw ConflictException(
          'Entity with this identifier already exists',
        );
      } else if (e.response?.statusCode == 422) {
        final errors = e.response?.data['error']?['details'];
        throw ValidationException(
          'Validation failed',
          fieldErrors: errors,
        );
      }
      rethrow;
    }
  }
}

For managing relationships between entities:

class StandardRelatedApi {
  final String domain;              // e.g., 'elog'
  final String parentIdentifier;    // e.g., 'user_groups'
  final String relationSegment;     // e.g., 'users'
  final String requestBodyKey;      // e.g., 'user_ids'

  // List related entities
  Future<List<Map<String, dynamic>>> list(String parentId);

  // Link entities
  Future<void> link(String parentId, List<String> childIds);

  // Unlink entities
  Future<void> unlink(String parentId, List<String> childIds);
}

Usage Example:

final userGroupUsersApi = StandardRelatedApi(
  domain: 'elog',
  parentIdentifier: 'user_groups',
  relationSegment: 'users',
  requestBodyKey: 'user_ids',
);

// List users in a group
final users = await userGroupUsersApi.list(groupId);
// GET /elog/user_groups/{groupId}/users

// Add users to group
await userGroupUsersApi.link(groupId, [userId1, userId2]);
// POST /elog/user_groups/{groupId}/users
// Body: {"user_ids": ["userId1", "userId2"]}

// Remove users from group
await userGroupUsersApi.unlink(groupId, [userId1]);
// DELETE /elog/user_groups/{groupId}/users
// Body: {"user_ids": ["userId1"]}

Advanced Patterns

Custom JSON Transformation

Override serialization for create/update operations:

class EquipmentApi extends HttpEntityApi<Equipment> {
  // ... constructor

  @override
  Future<Map<String, dynamic>> toCreateJson(Equipment entity) async {
    final json = entity.toJson();

    // Add custom fields
    json['installation_date'] = DateTime.now().toIso8601String();
    json['initial_status'] = 'pending_verification';

    // Transform nested objects
    if (entity.location != null) {
      json['location_id'] = entity.location!.id;
      json.remove('location');
    }

    return json;
  }

  @override
  Future<Map<String, dynamic>> toUpdateJson(Equipment entity) async {
    final json = await toCreateJson(entity);

    // Add update-specific fields
    json['last_modified_by'] = await getCurrentUserId();

    return json;
  }
}

Batch Operations

class BatchCapableApi<T extends EntityBase> extends HttpEntityApi<T> {
  // ... constructor

  Future<List<T>> batchCreate(List<T> entities) async {
    final uri = pathBuilder.buildUri('/batch');
    final response = await dio.post(
      uri.toString(),
      data: {
        'entities': await Future.wait(
          entities.map((e) => toCreateJson(e)),
        ),
      },
      options: Options(headers: defaultHeaders),
    );

    final data = response.data['data'] as List;
    return data.map((json) => fromJson(json)).toList();
  }

  Future<void> batchUpdate(Map<String, T> updates) async {
    final uri = pathBuilder.buildUri('/batch');
    final updateData = <String, dynamic>{};

    for (final entry in updates.entries) {
      updateData[entry.key] = await toUpdateJson(entry.value);
    }

    await dio.put(
      uri.toString(),
      data: {'updates': updateData},
      options: Options(headers: defaultHeaders),
    );

    // Invalidate caches
    invalidateList();
    for (final id in updates.keys) {
      invalidateById(id);
    }
  }

  Future<void> batchDelete(List<String> ids) async {
    final uri = pathBuilder.buildUri('/batch');
    await dio.delete(
      uri.toString(),
      data: {'ids': ids},
      options: Options(headers: defaultHeaders),
    );

    // Invalidate caches
    invalidateList();
    for (final id in ids) {
      invalidateById(id);
    }
  }
}

Optimistic Updates

class OptimisticApi<T extends EntityBase> extends HttpEntityApi<T> {
  final StreamController<T> _entityStreamController = StreamController.broadcast();

  Stream<T> get entityStream => _entityStreamController.stream;

  Future<T> optimisticUpdate(
    String id,
    T entity,
    T Function(T) optimisticTransform,
  ) async {
    // Apply optimistic update
    final optimistic = optimisticTransform(entity);
    _entityStreamController.add(optimistic);

    try {
      // Perform actual update
      final result = await update(id, entity);
      _entityStreamController.add(result);
      return result;
    } catch (e) {
      // Revert on failure
      _entityStreamController.add(entity);
      rethrow;
    }
  }
}

Field-Level Updates

class PartialUpdateApi<T extends EntityBase> extends HttpEntityApi<T> {
  Future<T> updateFields(
    String id,
    Map<String, dynamic> fields, {
    String? remarks,
  }) async {
    final uri = pathBuilder.update(id);
    final data = {
      ...fields,
      'updated_at': DateTime.now().toIso8601String(),
      'updated_by': vyuh.auth.currentUser?.id,
      if (remarks != null) '_audit_remarks': remarks,
    };

    final response = await dio.patch(
      uri.toString(),
      data: data,
      options: Options(headers: defaultHeaders),
    );

    invalidateById(id);
    invalidateList();

    return fromJson(response.data['data']);
  }
}

// Usage
await api.updateFields(
  userId,
  {'email': 'newemail@example.com', 'phone': '+1234567890'},
  remarks: 'Updated contact information',
);

Configuration Examples

Basic API Registration

final equipmentConfig = EntityConfiguration<Equipment>(
  metadata: EntityMetadata(
    identifier: 'equipment',
    name: 'Equipment',
    pluralName: 'Equipment',
    // ... other metadata
  ),
  api: (client) => HttpEntityApi<Equipment>(
    entityType: 'equipment',
    schemaType: 'public.equipment',
    pathBuilder: EndpointBuilder(prefix: '/api/equipment'),
    fromJson: Equipment.fromJson,
  ),
  // ... other configuration
);

Versioned Entity with Caching

class CachedEquipmentApi extends HttpVersionedEntityApi<Equipment>
    with CachedMixin<Equipment>, CachedVersioningMixin<Equipment> {

  CachedEquipmentApi()
      : super(
          entityType: 'equipment',
          schemaType: 'public.equipment',
          pathBuilder: VersionedEndpointBuilder(prefix: '/api/equipment'),
          fromJson: Equipment.fromJson,
          defaultSortField: 'name',
        );

  @override
  CacheKeyBuilder get keyBuilder => CacheKeyBuilder(entityType: entityType);

  @override
  Duration get staleTime => const Duration(minutes: 10);
}

// Register
final config = EntityConfiguration<Equipment>(
  metadata: equipmentMetadata,
  api: (_) => CachedEquipmentApi(),
  // ... other configuration
);

Singleton Entity

class SettingsApi extends HttpVersionedEntityApi<Settings> {
  SettingsApi()
      : super(
          entityType: 'settings',
          schemaType: 'sso.settings',
          pathBuilder: SingletonVersionedEndpointBuilder(
            prefix: '/api/settings',
          ),
          fromJson: Settings.fromJson,
        );

  // Override to handle singleton semantics
  @override
  Future<Settings?> get(String id) async {
    // Ignore id parameter for singleton
    final uri = versionedPathBuilder.get('');
    final response = await dio.get(
      uri.toString(),
      options: Options(headers: defaultHeaders),
    );
    return fromJson(response.data['data']);
  }
}

Best Practices

API Design

  1. Use Appropriate Base Class

    • EntityApi for simple entities without versioning
    • VersionedEntityApi for entities requiring audit trails
  2. Leverage Mixins

    • Add caching with CachedMixin for frequently accessed data
    • Combine mixins for full feature set
  3. Type Safety

    • Always specify generic types: HttpEntityApi<User>
    • Use fromJson factory methods consistently
  4. Error Handling

    • Handle specific HTTP status codes appropriately
    • Provide meaningful error messages
    • Use remarks parameter for audit context
  5. Performance

    • Enable caching for read-heavy entities
    • Use batch operations when processing multiple entities
    • Configure appropriate staleTime for caches

Testing

// Mock API for testing
class MockUserApi extends EntityApi<User> {
  final List<User> _users = [];

  @override
  Future<List<User>> getMany({...}) async {
    return _users;
  }

  @override
  Future<User> create(User entity) async {
    _users.add(entity);
    return entity;
  }

  @override
  User fromJson(Map<String, dynamic> json) => User.fromJson(json);
}

// Test usage
test('user api creates user', () async {
  final api = MockUserApi();
  final user = User(id: '1', name: 'Test User', ...);

  final created = await api.create(user);
  expect(created.name, 'Test User');

  final users = await api.getMany();
  expect(users.length, 1);
});

Next: Entity Metadata - Deep dive into entity metadata configuration