LogoRevali

Generated Code

The structure of the generated client code

The Revali Client generates well-structured, idiomatic Dart code designed to interact with your Revali server. All generated code adheres to SOLID principles, promoting separation of concerns, testability, and long-term maintainability.

Overview#

At the heart of the client lies the Server class โ€” the primary entry point for accessing backend functionality. For each server-side controller, a corresponding data source is generated on the client side. This includes:

  • An interface that defines the available methods and types
  • An implementation that handles request construction, network calls, and response parsing

This architecture ensures your client code is type-safe, testable, and follows clean architecture patterns.


Project Structure#

Generated files are stored under the .revali/revali_client/ directory and follow a clean modular structure:

.revali/
โ””โ”€โ”€ revali_client/
    โ”œโ”€โ”€ lib/
    โ”‚   โ”œโ”€โ”€ client.dart           # All generated implementations
    โ”‚   โ”œโ”€โ”€ interfaces.dart       # All generated interfaces
    โ”‚   โ””โ”€โ”€ src/
    โ”‚       โ”œโ”€โ”€ server.dart       # The main Server class
    โ”‚       โ”œโ”€โ”€ impls/
    โ”‚       โ”‚   โ”œโ”€โ”€ user_data_source_impl.dart
    โ”‚       โ”‚   โ”œโ”€โ”€ post_data_source_impl.dart
    โ”‚       โ”‚   โ””โ”€โ”€ auth_data_source_impl.dart
    โ”‚       โ””โ”€โ”€ interfaces/
    โ”‚           โ”œโ”€โ”€ user_data_source.dart
    โ”‚           โ”œโ”€โ”€ post_data_source.dart
    โ”‚           โ””โ”€โ”€ auth_data_source.dart
    โ””โ”€โ”€ pubspec.yaml

Import Structure#

You only need to import two files to use the generated code:

import 'package:revali_client/client.dart';      // For implementations
import 'package:revali_client/interfaces.dart';  // For type definitions

The client.dart file exports:

  • The Server class
  • All data source implementations
  • The underlying HTTP client

The interfaces.dart file exports:

  • All data source interface definitions
  • Shared models and types

The Server Class#

The Server class is the main entry point for your client. It exposes a field for each data source, one per controller. Each field:

  • Is typed using the interface (e.g., UserDataSource)
  • Returns the implementation (e.g., UserDataSourceImpl)

This design inverts the dependency flow and enables client code to depend on abstractions โ€” not implementations.

Basic Example#

import 'package:revali_client/client.dart';
import 'package:revali_client/interfaces.dart';

void main() async {
  // Create the server instance
  final server = Server();

  // Access data sources through interfaces
  final UserDataSource users = server.user;
  final PostDataSource posts = server.post;

  // Make type-safe API calls
  final allUsers = await users.getAll();
  final user = await users.getById(id: '123');
}

Server Class Initialization#

The Server class can be customized during initialization:

final server = Server(
  // Provide custom storage for cookies and session data
  storage: MyCustomStorage(),

  // Point at a different host at runtime (e.g. a LAN IP for testing from a
  // physical device, or an environment-specific API URL)
  baseUrl: Uri.parse('https://api.example.com'),

  // Provide a custom HTTP client -- interceptors are configured here, not
  // on Server directly (see HTTP Interceptors below)
  client: HttpPackageClient(
    interceptors: [
      LoggingInterceptor(),
      AuthInterceptor(),
    ],
  ),
);

Data Sources#

For each controller in your server, Revali Client generates a corresponding data source consisting of an interface and implementation.

Naming Convention#

Data sources follow a predictable naming pattern based on the controller name:

Controller NameInterface NameImplementation NameServer Property
UserController UserDataSource UserDataSourceImpl server.user
PostController PostDataSource PostDataSourceImpl server.post
AuthController AuthDataSource AuthDataSourceImpl server.auth

The server property name is the camelCase version of the controller name without the "Controller" suffix.

Generated Interface#

Given this server controller:

routes/user_controller.dart
@Controller('users')
class UserController {
  @Get()
  Future<List<User>> getAll() async {
    return await userService.getAllUsers();
  }

  @Get(':id')
  Future<User> getById(@Param('id') String id) async {
    return await userService.getUserById(id);
  }

  @Post()
  Future<User> create(@Body() User user) async {
    return await userService.createUser(user);
  }

  @Delete(':id')
  Future<void> delete(@Param('id') String id) async {
    await userService.deleteUser(id);
  }
}

Revali Client generates this interface:

.revali/revali_client/lib/src/interfaces/user_data_source.dart
abstract class UserDataSource {
  /// GET /api/users
  Future<List<User>> getAll();

  /// GET /api/users/:id
  Future<User> getById({required String id});

  /// POST /api/users
  Future<User> create(User user);

  /// DELETE /api/users/:id
  Future<void> delete({required String id});
}

Generated Implementation#

The corresponding implementation handles all the HTTP details:

.revali/revali_client/lib/src/impls/user_data_source_impl.dart
class UserDataSourceImpl implements UserDataSource {
  const UserDataSourceImpl(this._client);

  final RevaliClient _client;

  @override
  Future<List<User>> getAll() async {
    final request = HttpRequest(
      method: 'GET',
      path: '/api/users',
    );

    final response = await _client.send(request);

    if (response.statusCode != 200) {
      throw ServerException.fromResponse(response);
    }

    return (response.body as List)
        .map((e) => User.fromJson(e as Map<String, dynamic>))
        .toList();
  }

  @override
  Future<User> getById({required String id}) async {
    final request = HttpRequest(
      method: 'GET',
      path: '/api/users/$id',
    );

    final response = await _client.send(request);

    if (response.statusCode != 200) {
      throw ServerException.fromResponse(response);
    }

    return User.fromJson(response.body as Map<String, dynamic>);
  }

  @override
  Future<User> create(User user) async {
    final request = HttpRequest(
      method: 'POST',
      path: '/api/users',
      body: user.toJson(),
    );

    final response = await _client.send(request);

    if (response.statusCode != 200) {
      throw ServerException.fromResponse(response);
    }

    return User.fromJson(response.body as Map<String, dynamic>);
  }

  @override
  Future<void> delete({required String id}) async {
    final request = HttpRequest(
      method: 'DELETE',
      path: '/api/users/$id',
    );

    final response = await _client.send(request);

    if (response.statusCode != 204) {
      throw ServerException.fromResponse(response);
    }
  }
}

Method Parameters#

Revali Client correctly maps all parameter types from your server endpoints:

Path Parameters#

Path parameters are converted to named parameters:

// Server
@Get(':id')
Future<User> getUser(@Param('id') String id) async { ... }

// Generated Client
Future<User> getUser({required String id});

Query Parameters#

Query parameters are also mapped to named parameters:

// Server
@Get()
Future<List<User>> search(@Query('name') String? name) async { ... }

// Generated Client
Future<List<User>> search({String? name});

Request Body#

Body parameters are passed as positional parameters:

// Server
@Post()
Future<User> create(@Body() User user) async { ... }

// Generated Client
Future<User> create(User user);

Headers#

Headers are mapped to named parameters:

// Server
@Get()
Future<User> getCurrent(@Header('Authorization') String token) async { ... }

// Generated Client
Future<User> getCurrent({required String authorization});

Error Handling#

All generated methods automatically handle HTTP errors by throwing a ServerException:

try {
  final user = await server.user.getById(id: '123');
} on ServerException catch (e) {
  print('Error ${e.statusCode}: ${e.message}');
  print('Body: ${e.body}');
}

The ServerException class provides:

  • statusCode: The HTTP status code (e.g., 404, 500)
  • message: A human-readable error message
  • body: The raw response body
  • headers: Response headers

Custom Error Handling#

You can use HTTP interceptors to customize error handling globally:

class ErrorInterceptor implements HttpInterceptor {
  @override
  FutureOr<void> onResponse(HttpResponse response) async {
    if (response.statusCode >= 400) {
      // Log error, show notification, etc.
      print('API Error: ${response.statusCode}');
    }
  }
}

final server = Server(
  client: HttpPackageClient(interceptors: [ErrorInterceptor()]),
);

Testing with Generated Code#

The interface-based design makes testing straightforward. You can easily create mock implementations:

Creating a Mock#

class MockUserDataSource implements UserDataSource {
  @override
  Future<List<User>> getAll() async {
    return [
      User(id: '1', name: 'Test User 1'),
      User(id: '2', name: 'Test User 2'),
    ];
  }

  @override
  Future<User> getById({required String id}) async {
    return User(id: id, name: 'Test User');
  }

  @override
  Future<User> create(User user) async {
    return user.copyWith(id: 'generated-id');
  }

  @override
  Future<void> delete({required String id}) async {
    // Mock implementation
  }
}

With Dependency Injection#

class UserRepository {
  UserRepository(this.dataSource);

  final UserDataSource dataSource;

  Future<List<User>> getAllUsers() => dataSource.getAll();
}

void main() {
  test('repository uses data source', () async {
    final repository = UserRepository(MockUserDataSource());
    final users = await repository.getAllUsers();

    expect(users, isNotEmpty);
  });
}

Type Safety and Serialization#

Automatic Serialization#

The generated code automatically handles JSON serialization and deserialization:

// Request body serialization
final user = User(name: 'Alice', email: 'alice@example.com');
await server.user.create(user); // Automatically calls user.toJson()

// Response deserialization
final users = await server.user.getAll(); // Automatically parses JSON to List<User>

Requirements for Custom Types#

For custom types to work with the generated client, they must:

  1. Have a fromJson factory constructor:

    factory User.fromJson(Map<String, dynamic> json) => User(...);
    
  2. Have a toJson method:

    Map<String, dynamic> toJson() => {...};
    

Benefits of This Architecture#

๐Ÿงช Testability#

The interface-implementation split makes it trivial to:

  • Create mock implementations for testing
  • Swap implementations without changing client code
  • Test business logic in isolation

๐Ÿ”’ Type Safety#

Every API call is:

  • Fully type-checked at compile time
  • Protected against typos and parameter errors
  • Backed by your server's actual implementation

๐Ÿงน Clean Code#

The generated code:

  • Follows SOLID principles
  • Maintains clear separation of concerns
  • Enables dependency injection
  • Promotes clean architecture patterns

๐Ÿ”„ Automatic Synchronization#

When you change your server:

  • Client code automatically regenerates
  • Type errors appear at compile time
  • No manual updates needed

๐Ÿ“ฆ Zero Boilerplate#

You never write:

  • HTTP request construction
  • URL path building
  • JSON serialization/deserialization
  • Error parsing

Next Steps#