rest_client_builder 1.2.0
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/@RestApicodegen, 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
@RestApiclients →Future<RestResult<T>> @RestModelJSON codegen (fromJson/toJson) withJsonKey- Dio-backed runtime (
DioRestClient, retries, timeouts, logging) - Multipart via
RestPart.fromBytes/RestPart.fromBase64(noFile) CancelToken, upload & download progress callbacks@UseInterceptor/@ExcludeInterceptorper 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:
- Annotate models with
@RestModeland APIs with@RestApi. - Add
partdirectives and afactory … = _Api;constructor. - Run
build_runner. - Inject
DioRestClient(orCallbackRestClientin 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 #
- Keep
@RestApiclasses abstract — annotations + factory only. - Always return
RestResult<T>; handle failures at the use-case boundary. - Prefer
RestPartfor uploads (bytes/Base64); avoiddart:ioin shared code. - Treat configuration annotations as docs; wire
BasicRestClientConfigexplicitly. - Register interceptor instances; use annotations only to filter by type name.
- Cancel long uploads with
BasicCancelToken. - Unit-test with
CallbackRestClientor a fake Dio adapter. - Fix validation errors at codegen time — do not disable the builder.
- Commit generated parts if your CI needs offline builds; otherwise generate in CI.
- 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.