rest_client_builder 1.2.0 copy "rest_client_builder: ^1.2.0" to clipboard
rest_client_builder: ^1.2.0 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

Getting Started #

Minimal loop:

  1. Annotate models with @RestModel and APIs with @RestApi.
  2. Add part directives and a factory … = _Api; constructor.
  3. Run build_runner.
  4. Inject DioRestClient (or CallbackRestClient in tests).

See example/ for a complete CRUD + multipart demo.

RestConfiguration #

Put app-wide settings in one class as plain fields. The builder generates createRestClient() so application code never manually translates configuration into RestGlobalConfig.

class AppRestConfiguration implements RestApiGlobalConfiguration {
  final String baseUrl = Environment.api;
  final Map<String, String> headers = {'platform': 'flutter'};
  final int? retryMaxAttempts = 3;
  final int? retryDelayMs = 400;
  final List<int>? retryStatusCodes = [503];
  final int? connectTimeoutMs = 10000;
  final int? receiveTimeoutMs = 30000;
  final int? sendTimeoutMs = 15000;
  final bool? enableLog = true;
  final List<Interceptor> interceptors = [
        LoggingInterceptor(),
        MetricsInterceptor(),
      ];
}

final RestClient client = AppRestConfiguration().createRestClient();
final userApi = UserApi(client);

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().

@Retry, @ConnectTimeout, @ReceiveTimeout, and @SendTimeout use milliseconds. All generated APIs receive the same client; a microservice can still override its base URL when constructing a specific API.

The generated configuration is a RestGlobalConfig. Its nullable constructor overrides resolve to these defaults: retryMaxAttempts: 0, connect timeout 500ms, receive/send timeout 1000ms, and logging enabled.

For a small app or a test, you can create the same global configuration without annotations:

final client = DioRestClient(
  config: RestGlobalConfig(
    baseUrl: Environment.api,
    defaultHeaders: {'platform': 'flutter'},
    // All other values are optional:
    // retryMaxAttempts defaults to 0
    // connectTimeoutMs defaults to 500
    // receiveTimeoutMs / sendTimeoutMs default to 1000
    // enableLog defaults to true
  ),
);

Global settings are plain nullable fields. Leave a field null to use the standard RestGlobalConfig default.

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 {
  factory PaymentApi(RestClient client) = _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 from local storage #

Do not pass a token into AppRestConfiguration: 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 the interceptors field. The interceptor pipeline already awaits onRequest, so no token is needed in the configuration class.

Annotation Role
@RestConfiguration Enables global-config code generation
RestApiGlobalConfiguration fields Base URL, headers, policies, interceptors
RestGlobalConfig Runtime defaults and resolved settings
API / endpoint annotations Per-API or per-request overrides

RestApi #

part 'user_api.rest.g.dart';

@RestApi(baseUrl: 'https://api.example.com')
@Headers({'Accept': 'application/json'})
@UseInterceptor([AuthInterceptor])
@Tag('users')
abstract class UserApi {
  factory UserApi(
    RestClient client, {
    String? baseUrl,
    Map<String, String>? headers,
  }) = _UserApi;

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

Rules: return Future<RestResult<T>>; provide a redirecting factory; add the part file. Methods use @GET / @POST / @PUT / @PATCH / @DELETE / @HEAD / @OPTIONS.

RestModel #

part 'user.g.dart';

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

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);

  final String id;

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

  Map<String, dynamic> toJson() => _$UserToJson(this);
}

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 UserApi {
  factory UserApi(RestClient client, {String? baseUrl}) = _UserApi;

  @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);
}
final api = UserApi(client);
final user = (await api.getUser('1')).getOrThrow();
await api.createUser(user);
await api.replaceUser('1', user);
await api.patchUser('1', {'user_name': 'Ada'});
await api.deleteUser('1');

Request Models #

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

@RestModel()
class CreateUserRequest {
  const CreateUserRequest({required this.name, required this.email});
  factory CreateUserRequest.fromJson(Map<String, dynamic> json) =>
      _$CreateUserRequestFromJson(json);

  final String name;
  final String email;

  Map<String, dynamic> toJson() => _$CreateUserRequestToJson(this);
}

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

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 → T.fromJson via RestResponseMapper, 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,
  });

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);

  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;

  Map<String, dynamic> toJson() => _$UserToJson(this);
}

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 _$TypeFromJson / _$TypeToJson
@RestApi class *.rest.g.dart _Api impl, ApiDocs, dartdoc
part 'user.g.dart';
part '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 + factory 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