rest_client_builder 1.2.2 copy "rest_client_builder: ^1.2.2" to clipboard
rest_client_builder: ^1.2.2 copied to clipboard

A Clean Architecture code-generation framework for building typed REST API clients in Dart and Flutter using annotations and build_runner.

RestApiBuilder #

A Clean Architecture code-generation framework for typed REST API clients in Dart and Flutter — powered by annotations and build_runner.

Declare endpoints once. Generate type-safe clients that return RestResult<T>, execute on Dio, and upload with RestPart (no dart:io File).

Status: Production-ready. Includes Dio runtime, compile-time validation, multipart, interceptors, cancel/progress, @RestModel / @RestApi codegen, and a full example app. Not included: response cache, auth-token refresh.


Vision #

RestApiBuilder makes REST integration a declarative, compile-time concern. Developers describe resources with annotations; the framework generates clients that fit Clean Architecture — domain code stays free of transport details, while HTTP remains explicit, testable, and replaceable.

One cohesive Dart package: a small public API, standard build_runner tooling, and an internal design that can grow without leaking generator internals to apps.


Features #

  • Typed @RestApi clients → Future<RestResult<T>>
  • @RestModel JSON codegen (fromJson / toJson) with JsonKey
  • Dio-backed runtime (DioRestClient, retries, timeouts, logging)
  • Multipart via RestPart.fromBytes / RestPart.fromBase64 (no File)
  • CancelToken, upload & download progress callbacks
  • @UseInterceptor / @ExcludeInterceptor per class or method
  • Compile-time validation (duplicate routes, GET+Body, missing Path, multipart)
  • Generated endpoint docs (ApiDocs.endpoints + dartdoc tables)
  • Clean Architecture layout in a single package
  • Works in Flutter apps and pure Dart services

Why RestApiBuilder? #

RestApiBuilder
Architecture Annotations / core / runtime / generator clearly separated
Results RestResult + RestError instead of unchecked throws
Uploads Memory-safe RestPart — web and Flutter friendly
Safety Invalid APIs fail at build_runner, not in production
Toolchain Standard build_runner + source_gen — no custom CLI
Surface One import: package:rest_client_builder/rest_client_builder.dart

Compared with hand-written Dio clients: less boilerplate, consistent mapping, and shared validation rules across the team.


Installation #

# pubspec.yaml
dependencies:
  rest_client_builder: ^0.1.0

dev_dependencies:
  build_runner: ^2.4.15

Path install (local development):

dependencies:
  rest_client_builder:
    path: ../rest_client_builder
dart pub get
dart run build_runner build --delete-conflicting-outputs
# or:
dart run build_runner watch --delete-conflicting-outputs
import 'package:rest_client_builder/rest_client_builder.dart';

Builders ship with the package — no consumer build.yaml required. Generated files always land under rest_client_builder/generated/ next to the source (source files must live in a subdirectory of lib/):

Builder Output
rest_model part 'rest_client_builder/generated/file.g.dart'
rest_api part 'rest_client_builder/generated/file.rest.g.dart'
rest_configuration part 'rest_client_builder/generated/file.rest.config.g.dart'

Project Structure #

rest_client_builder/
├── lib/
│   ├── rest_client_builder.dart      # Public barrel
│   └── src/
│       ├── annotations/           # @RestApi, @RestModel, HTTP, params, …
│       ├── core/                  # RestResult, RestError, logger, utils
│       ├── runtime/               # Dio client, multipart, cancel, interceptors
│       ├── generator/             # Visitors, validators, writers (private)
│       └── internal/              # Private helpers
├── example/                       # Full consumer app
├── test/                          # Unit + generator + runtime tests
└── build.yaml                     # Builder registration

Quick Start (Step-by-step) #

Integrating rest_api_builder is designed to be incredibly fast with zero boilerplate.

Step 1: Define your Model #

Just write a standard Dart class and annotate it with @RestModel(). The builder automatically handles all fromJson and toJson serialization.

import 'package:rest_client_builder/rest_client_builder.dart';
export 'user.g.dart'; // Export the generated file

@RestModel()
class User {
  const User({required this.id, required this.name});

  final String id;

  @JsonKey(name: 'user_name') // Supports json_annotation tags
  final String name;
}

Step 2: Define your API #

Write an abstract class annotated with @RestApi(). Define your endpoints using @GET, @POST, @PUT, etc.

import 'package:rest_client_builder/rest_client_builder.dart';

import 'user.dart'; // Import your models
import 'user_service.rest.g.dart'; // Import the generated file
export 'user_service.rest.g.dart'; // Export the generated file

@RestApi(baseUrl: 'https://api.example.com')
abstract class UserService {
  @GET('/users/{id}')
  Future<RestResult<User>> getUser(@Path('id') String id);

  @POST('/users')
  Future<RestResult<User>> createUser(@Body() User user);
}

Step 3: Run the Code Generator #

Run the standard Dart build runner to generate the networking code:

dart run build_runner build --delete-conflicting-outputs

Step 4: Initialize and Use #

Use RestClientBuilder to configure your global networking settings (timeouts, retries, headers, etc.). Then, use the generated client.userService extension to call your API!

// 1. Initialize the shared client configuration
final RestClient client = RestClientBuilder()
  .baseUrl('https://api.example.com')
  .defaultHeaders({'platform': 'flutter'})
  .logging(enable: true)
  .build();

// 2. Access your API securely via the generated extension
UserService apiService = client.userService;

// 3. Make requests and get strongly-typed RestResult objects
final user = (await apiService.getUser('1')).getOrThrow();

Detailed Guides #

Advanced Configuration (Class-based) #

For very large apps, you can define settings in a class as plain fields and let the builder generate the client factory using RestApiGlobalConfiguration:

class AppRestConfiguration implements RestApiGlobalConfiguration {
  final String baseUrl = Environment.api;
  final Map<String, String> headers = {'platform': 'flutter'};
  final int? retryMaxAttempts = 3;
  final bool? enableLog = true;
  final List<Interceptor> interceptors = [];
  // (timeouts and other fields can be added here)
}

// Automatically creates and caches a shared client:
final RestClient client = AppRestConfiguration().createRestClient();

createRestClient() returns a shared client per configuration type (cached by RestApiClientRegistry), so every API reuses one Dio connection pool. Call createFreshRestClient() only when you need an isolated instance (e.g. tests). Reset the cache with RestApiClientRegistry.reset().

API and Endpoint Overrides #

The same policies can be narrowed for one API or one endpoint. Method values override API values, which override the shared client configuration:

@RestApi(path: '/payments')
@Retry(2, 200, [502, 503])
@ReceiveTimeout(15000)
abstract class PaymentApi {
  @GET('/history')
  @Retry(4, 500, [429, 503])
  @EnableLog(true)
  Future<RestResult<List<Payment>>> history();
}

@Headers, @UseInterceptor, and @ExcludeInterceptor can also be placed on an API class or endpoint. The generated request uses the resolved timeout, retry policy, logging flag, headers, and interceptors.

Async Token (Interceptors) #

Do not pass a token into your base configuration: it becomes stale after login, logout, or refresh. Load it asynchronously in a global interceptor immediately before every request instead:

class AuthInterceptor implements RestInterceptor {
  @override
  Future<RestRequest> onRequest(RestRequest request) async {
    final token = await secureStorage.read(key: 'access_token');
    if (token == null || token.isEmpty || request is! BasicRestRequest) {
      return request;
    }
    return request.copyWith(headers: {
      ...request.headers,
      'Authorization': 'Bearer $token',
    });
  }

  @override
  Future<RestResponse> onResponse(RestResponse response) async => response;

  @override
  Future<RestResult<RestResponse>> onError(RestError error) async =>
      Failure(error);
}

Add AuthInterceptor() to your RestClientBuilder().interceptors([...]).

More Examples #

@RestApi(baseUrl: 'https://api.example.com') @Headers({'Accept': 'application/json'}) @UseInterceptor([AuthInterceptor]) @Tag('users') abstract class UserService { @GET('/users/{id}') Future<RestResult


**Rules:** keep the class abstract; use the generated `createXxxService()`
function to get an instance; add the `import`/`export` for the
generated file. Methods use `@GET` / `@POST` / `@PUT` / `@PATCH` / `@DELETE` /
`@HEAD` / `@OPTIONS`.

### RestModel

Just annotate your class — **no `fromJson`, no `toJson`, no `part` file needed**.
The builder generates everything:

```dart
import 'package:rest_client_builder/rest_client_builder.dart';
export 'user.g.dart'; // makes generated helpers available

@RestModel()
class User {
  const User({required this.id, required this.name});

  final String id;

  @JsonKey(name: 'user_name')
  final String name;
}

The builder auto-generates restUserFromJson(json), restUserToJson(instance), and a .toJson() extension method. Your API clients use these automatically.

Supports primitives, enum, DateTime, nested @RestModels, List, Map, and JsonKey. Field mapping uses JsonKey from json_annotation (re-exported) — no custom field annotation.

CRUD Example #

@RestApi(baseUrl: 'https://api.example.com')
abstract class UserService {
  @GET('/users/{id}')
  Future<RestResult<User>> getUser(@Path('id') String id);

  @GET('/users')
  Future<RestResult<List<User>>> listUsers(
    @QueryMap() Map<String, Object?> filters,
  );

  @POST('/users')
  Future<RestResult<User>> createUser(@Body() User user);

  @PUT('/users/{id}')
  Future<RestResult<User>> replaceUser(
    @Path('id') String id,
    @Body() User user,
  );

  @PATCH('/users/{id}')
  Future<RestResult<User>> patchUser(
    @Path('id') String id,
    @Body() Map<String, Object?> patch,
  );

  @DELETE('/users/{id}')
  Future<RestResult<void>> deleteUser(@Path('id') String id);
}
UserService apiService = client.userService;
final user = (await apiService.getUser('1')).getOrThrow();
await apiService.createUser(user);
await apiService.replaceUser('1', user);
await apiService.patchUser('1', {'user_name': 'Ada'});
await apiService.deleteUser('1');

Request Models #

Use @RestModel classes (or maps) as @Body payloads — zero boilerplate:

@RestModel()
class CreateUserRequest {
  const CreateUserRequest({required this.name, required this.email});

  final String name;
  final String email;
}

@POST('/users')
Future<RestResult<User>> create(@Body() CreateUserRequest body);

The builder generates toJson() automatically — you never write it.

Form and multipart requests use @Field / @Part instead of @Body.

Response Models #

API methods return Future<RestResult<T>> where T is typically a @RestModel:

@GET('/users/{id}')
Future<RestResult<User>> getUser(@Path('id') String id);

@GET('/users')
Future<RestResult<List<User>>> listUsers();

@DELETE('/users/{id}')
Future<RestResult<void>> deleteUser(@Path('id') String id);

Generated code maps JSON → model via RestResponseMapper and the auto-generated restTypeFromJson functions, then wraps the value in Success or Failure.

JsonKey Examples #

@RestModel()
class User {
  const User({
    required this.id,
    required this.name,
    this.nickname = 'guest',
    this.localOnly,
  });

  final String id;

  /// JSON key rename
  @JsonKey(name: 'user_name')
  final String name;

  /// Default when key is missing / null
  @JsonKey(defaultValue: 'guest')
  final String nickname;

  /// Omit from JSON
  @JsonKey(ignore: true)
  final String? localOnly;
}

No fromJson or toJson needed — the builder handles all serialization.

Multipart #

Mark the method @Multipart and bind parts with @Part / @PartMap. Prefer RestPart for binary data.

@POST('/avatar')
@Multipart()
Future<RestResult<User>> uploadAvatar(
  @Part(name: 'file') RestPart file,
  @Part(name: 'label') String label, {
  CancelToken? cancelToken,
  RestProgressCallback? onSendProgress,
  RestProgressCallback? onReceiveProgress,
});

Upload Example #

// From memory (image picker, buffer, …) — no dart:io File
final fromBytes = RestPart.fromBytes(
  name: 'file',
  bytes: imageBytes,
  fileName: 'avatar.png',
  contentType: 'image/png',
);

// From Base64 (standard or URL-safe; whitespace ignored)
final fromBase64 = RestPart.fromBase64(
  name: 'file',
  base64: base64Payload,
  fileName: 'avatar.png',
  contentType: 'image/png',
);

final token = BasicCancelToken();
final result = await api.uploadAvatar(
  fromBytes,
  'profile',
  cancelToken: token,
  onSendProgress: (count, total) {
    // upload progress
  },
);

token.cancel('user aborted'); // optional

List<int> + @Part(fileName:, contentType:) is also supported.

Download Example #

Use progress on the receive side; absolute URLs via @Url when needed:

@GET('/files/{id}')
Future<RestResult<List<int>>> downloadFile(
  @Path('id') String id, {
  RestProgressCallback? onReceiveProgress,
  CancelToken? cancelToken,
});

@GET()
Future<RestResult<String>> downloadAbsolute(
  @Url() String absoluteUrl, {
  RestProgressCallback? onReceiveProgress,
});
final bytes = await api.downloadFile(
  '42',
  onReceiveProgress: (count, total) {
    // download progress (total may be -1 if unknown)
  },
  cancelToken: token,
);

Wire response typing to match your API (bytes, model, or string). Progress is forwarded to Dio onReceiveProgress.

Headers #

@Headers({'Accept': 'application/json', 'X-Client': 'app'})
@RestApi()
abstract class UserApi {
  @GET('/me')
  @Headers({'X-Debug': '1'})
  Future<RestResult<User>> me(
    @Header('Authorization') String auth,
    @HeaderMap() Map<String, String> extra,
  );
}

Merge order: config defaults → class @Headers → method @Headers@Header / @HeaderMap → interceptor changes.

Query #

@GET('/users')
Future<RestResult<List<User>>> search(
  @Query('q') String query,
  @Query('page') int page, {
  @Query('expand') String? expand,
  @QueryMap() Map<String, Object?> filters,
});

Nullable @Query values are omitted when null.

Path #

@GET('/users/{id}/posts/{postId}')
Future<RestResult<Post>> getPost(
  @Path('id') String id,
  @Path('postId') String postId,
);

Every {placeholder} needs a matching @Path; every @Path must appear in the template (enforced at compile time).

Body #

@POST('/users')
Future<RestResult<User>> create(@Body() User user);

@PATCH('/users/{id}')
Future<RestResult<User>> patch(
  @Path('id') String id,
  @Body() Map<String, Object?> patch,
);

@Body models call .toJson() when available. GET/HEAD cannot use @Body.

FormUrlEncoded #

@POST('/login')
@FormUrlEncoded()
@ExcludeInterceptor([AuthInterceptor])
Future<RestResult<User>> login(
  @Field('email') String email,
  @Field('password') String password,
);

@POST('/login')
@FormUrlEncoded()
Future<RestResult<User>> loginMap(
  @FieldMap() Map<String, String> fields,
);

Do not mix @FormUrlEncoded with @Body or @Multipart.

Interceptors #

Register instances on DioRestClient. Filter per call with annotations (matched by runtime type name):

@RestApi()
@UseInterceptor([AuthInterceptor])
abstract class UserApi {
  @POST('/login')
  @FormUrlEncoded()
  @ExcludeInterceptor([AuthInterceptor])
  Future<RestResult<User>> login(@FieldMap() Map<String, String> fields);

  @POST('/avatar')
  @Multipart()
  @UseInterceptor([UploadInterceptor]) // class ∪ method → Auth + Upload
  Future<RestResult<User>> uploadAvatar(@Part(name: 'file') RestPart file);
}
Mechanism Behavior
Client interceptors + enableLog Registered pool
@UseInterceptor (merged) Whitelist when non-empty
@ExcludeInterceptor (merged) Remove matching types

Implement RestInterceptor (onRequest / onResponse / onError).

Retry #

@Retry(3, 400, [502, 503])
@RestApi()
abstract class UserApi {}
BasicRestClientConfig(
  retryMaxAttempts: 3,
  retryDelayMs: 400,
  retryStatusCodes: const [502, 503],
);

Retries on configured status codes and on timeout / connection errors. Cancelled requests are never retried.

Timeout #

@ConnectTimeout(10000)
@ReceiveTimeout(30000)
@SendTimeout(15000)
@RestApi()
abstract class UserApi {}
BasicRestClientConfig(
  connectTimeoutMs: 10000,
  receiveTimeoutMs: 30000,
  sendTimeoutMs: 15000,
);

Logging #

@EnableLog()
@RestApi()
abstract class UserApi {}
BasicRestClientConfig(
  enableLog: true,
  logger: const ConsoleRestLogger(), // or NoOpRestLogger / custom
);

When enableLog is true, DioRestClient attaches LoggingRestInterceptor.

CancelToken #

Mark the parameter with @Cancel() (named that way so it does not clash with the CancelToken runtime type):

@POST('/avatar')
@Multipart()
Future<RestResult<User>> uploadAvatar(
  @Part(name: 'file') RestPart file, {
  @Cancel() CancelToken? cancelToken,
});
final token = BasicCancelToken();

final future = api.uploadAvatar(
  part,
  cancelToken: token,
);

token.cancel('user aborted');

final result = await future;
// Failure → RestErrorCodes.cancelled

BasicCancelToken wraps Dio’s cancel token. Use isCancelled / whenCancelled for cooperative UI. Bare CancelToken parameters without @Cancel() are still accepted for backward compatibility.

Upload Progress #

@POST('/avatar')
@Multipart()
Future<RestResult<User>> uploadAvatar(
  @Part(name: 'file') RestPart file, {
  RestProgressCallback? onSendProgress,
});
await api.uploadAvatar(
  part,
  onSendProgress: (count, total) {
    final pct = total > 0 ? count / total : null;
  },
);

Any RestProgressCallback parameter that is not named for download is treated as upload progress. Forwarded to Dio onSendProgress.

Download Progress #

@GET('/files/{id}')
Future<RestResult<List<int>>> download(
  @Path('id') String id, {
  RestProgressCallback? onReceiveProgress, // or onDownloadProgress / downloadProgress
});
await api.download(
  '1',
  onReceiveProgress: (count, total) {
    // total may be -1 when unknown
  },
);

Forwarded to Dio onReceiveProgress.

RestResult #

Sealed success/failure at architecture boundaries:

final result = await api.getUser('1');

result.when(
  success: (user) => print(user.name),
  failure: (error) => print(error.message),
);

final user = result.getOrThrow();
final maybe = result.dataOrNull;
API Meaning
isSuccess / isFailure Discriminators
dataOrNull / errorOrNull Nullable accessors
when / fold Exhaustive handling
map / flatMap Transform success
getOrThrow Unwrap or throw RestError

RestError #

Transport-agnostic structured error:

if (result case Failure(:final error)) {
  print(error.message);
  print(error.code);        // e.g. timeout, cancelled, http, connection
  print(error.statusCode);  // when known
  print(error.details);
}

Common factories / codes: unknown, validation, timeout, cancelled, connection, http, serialization, plus RestError.fromException.

Validation Rules #

Enforced by DefaultRestApiValidator during build_runner (errors fail the build):

Rule Error when
Duplicate route Same METHOD + path on two methods
GET/HEAD + Body @GET or @HEAD declares @Body
Missing Path {id} without @Path('id'), or @Path not in template
Invalid Multipart @Multipart without parts; @Part without @Multipart; multipart + @Body / @Field; RestPart without @Part
Invalid Form @FormUrlEncoded without fields, or + @Body
Return type Not Future<RestResult<T>>
Multiple @Body More than one body parameter

Generated Files #

Source Generated Contents
@RestModel class *.g.dart restTypeFromJson / restTypeToJson + .toJson() extension
@RestApi class *.rest.g.dart createXxxApi(), _Api impl, ApiDocs, dartdoc
@RestConfiguration class *.rest.config.g.dart restClientConfig + createRestClient() extension
// Models — just export:
export 'user.g.dart';

// APIs — import and export:
import 'user_api.rest.g.dart';
export 'user_api.rest.g.dart';
print(UserApiDocs.endpoints);
// ['GET /users/{id} => getUser', ...]

Do not edit generated files by hand.

Folder Architecture #

┌─────────────────────────────────────────────────────────┐
│  Consumer (Flutter / Dart app)                          │
│  import 'package:rest_client_builder/rest_client_builder.dart' │
└───────────────────────────┬─────────────────────────────┘
                            │ public API only
┌───────────────────────────▼─────────────────────────────┐
│  annotations  │  core (RestResult)  │  runtime (Dio)    │
└───────────────────────────┬─────────────────────────────┘
                            │ used by
┌───────────────────────────▼─────────────────────────────┐
│  generator (build_runner / source_gen) — not exported   │
└───────────────────────────┬─────────────────────────────┘
                            │
┌───────────────────────────▼─────────────────────────────┐
│  internal (private helpers)                             │
└─────────────────────────────────────────────────────────┘
Path Responsibility
lib/rest_client_builder.dart Public barrel
lib/src/annotations/ Declarative annotations
lib/src/core/ Results, errors, logger, utils (no HTTP)
lib/src/runtime/ Dio client, request/response, multipart, cancel, progress, interceptors
lib/src/generator/ Visitors, validators, writers, builders
example/ Consumer application
test/ Package tests

Generator Flow #

build_runner
    │
    ▼
analyzer  (parse + resolve → Element model)
    │
    ▼
source_gen GeneratorForAnnotation
    │
    ├─ RestModelGenerator
    │     Visitor → Model → Validator → Writer → *.g.dart
    │
    └─ RestApiGenerator
          Visitor → Model → Validator → Writer → *.rest.g.dart
                              │
                              ├─ duplicate routes
                              ├─ GET + Body
                              ├─ missing Path
                              └─ invalid Multipart

Runtime Flow #

Generated Api.method(...)
        │
        ▼
BasicRestRequest (+ cancel / progress / extras)
        │
        ▼
DioRestClient.execute
        │
        ├─ merge headers / timeouts / base URL
        ├─ resolve interceptors (use / exclude)
        ├─ DefaultInterceptorPipeline
        │       onRequest → …
        │       DioRestHttpEngine.send  (Dio)
        │       … ← onResponse
        ├─ retry loop (status / timeout / connection)
        │
        ▼
RestResult<RestResponse>
        │
        ▼
RestResponseMapper (JSON → RestModel)
        │
        ▼
RestResult<T>  =  Success(T) | Failure(RestError)

Best Practices #

  1. Keep @RestApi classes abstract — annotations only.
  2. Always return RestResult<T>; handle failures at the use-case boundary.
  3. Prefer RestPart for uploads (bytes/Base64); avoid dart:io in shared code.
  4. Treat configuration annotations as docs; wire BasicRestClientConfig explicitly.
  5. Register interceptor instances; use annotations only to filter by type name.
  6. Cancel long uploads with BasicCancelToken.
  7. Unit-test with CallbackRestClient or a fake Dio adapter.
  8. Fix validation errors at codegen time — do not disable the builder.
  9. Commit generated parts if your CI needs offline builds; otherwise generate in CI.
  10. One API class per resource area; share @RestModels across clients.

FAQ #

Why RestResult instead of throwing?
Expected failures are data. Use when / fold / getOrThrow without try/catch noise.

Does multipart need dart:io?
No — RestPart.fromBytes / RestPart.fromBase64.

Can GET send a body?
No. The validator rejects @GET + @Body.

How do duplicate routes fail?
Same HTTP verb + path on two methods → InvalidGenerationSourceError at build time.

Where do @UseInterceptor types come from?
Register them on DioRestClient. Annotations whitelist/exclude by runtimeType name.

Is caching / token refresh included?
No. Implement as custom RestInterceptors if needed.

Flutter and pure Dart?
Both. No Flutter SDK dependency.

How do I run the example?

cd example
dart pub get
dart run build_runner build --delete-conflicting-outputs
dart run lib/main.dart

Roadmap #

Status Item
Done Annotations, core, Dio runtime
Done @RestModel / @RestApi codegen
Done Multipart RestPart, cancel, progress
Done Interceptor use/exclude
Done Compile-time validation + generated docs
Next @RestConfiguration → config factory codegen
Next OpenAPI import / export
Next Auth-refresh interceptor helpers
Next Optional response cache interceptor
Next custom_lint rules mirroring validators

License #

See the repository license file.

2
likes
0
points
634
downloads

Publisher

unverified uploader

Weekly Downloads

A Clean Architecture code-generation framework for building typed REST API clients in Dart and Flutter using annotations and build_runner.

Repository (GitHub)
View/report issues

Topics

#rest #api #codegen #build-runner #clean-architecture

License

unknown (license)

Dependencies

analyzer, build, code_builder, dart_style, dio, json_annotation, meta, source_gen

More

Packages that depend on rest_client_builder