| name | flutter-openapi-client |
| description | Use this skill whenever a Flutter app needs to call a backend API — including initial API client setup, adding new endpoints, handling auth tokens, error mapping, retries, request/response logging, multipart uploads, or regenerating the client from an updated OpenAPI spec. Triggers include "add an endpoint", "call the backend", "Dio interceptor", "OpenAPI", "swagger", "regenerate the API client", "auth token", "401 refresh", or any HTTP work in a Flutter project. Apply even when the user doesn't mention OpenAPI by name — generated clients from the backend spec are the default pattern here. |
Flutter API Client (OpenAPI-Generated)
Encodes the API client pattern for Flutter apps talking to a backend that publishes an OpenAPI spec (typical of Go/Gin services). The backend spec is the contract; the Dart client is generated from it. Hand-written HTTP code is the exception, not the rule.
Generator choice
Use openapi_generator (the Dart pub package wrapping openapi-generator-cli) with the dart-dio-next generator. It produces:
- A typed API class per OpenAPI tag (
AuthApi, BookingsApi, etc.)
- Freezed-style request/response models with
fromJson/toJson
- Built on top of Dio, so interceptors compose naturally
Alternative considered and rejected by default: chopper (annotation-based, but requires hand-writing every endpoint — defeats the point of having a spec).
Project layout
lib/core/api/
├── api_client.dart # Dio instance + interceptor wiring
├── interceptors/
│ ├── auth_interceptor.dart # Attach bearer token, handle 401 refresh
│ ├── logging_interceptor.dart # Dev-only request/response logging
│ └── error_interceptor.dart # Map DioException → app Failure type
├── failures.dart # Sealed Failure hierarchy
└── generated/ # openapi_generator output — DO NOT EDIT
└── ...
Generation workflow
- Backend publishes
openapi.yaml at a known URL or commits it to a shared location.
- Frontend
pubspec.yaml references it via openapi_generator_annotations.
- Annotate a config class:
@Openapi(
additionalProperties: DioProperties(pubName: 'app_api', pubAuthor: 'team'),
inputSpec: RemoteSpec(path: 'https://api.example.com/openapi.yaml'),
generatorName: Generator.dioNext,
outputDirectory: 'lib/core/api/generated',
)
class OpenapiConfig {}
- Run
dart run build_runner build --delete-conflicting-outputs to regenerate.
- Commit the generated code. CI does not regenerate — that path leads to broken builds when the backend is down.
- When the backend updates the spec, the frontend bumps the commit pin and regenerates as an explicit step in a PR. Treat spec changes like breaking API changes — they are.
Dio configuration
Single Dio instance per app, provided via Riverpod:
@Riverpod(keepAlive: true)
Dio dio(DioRef ref) {
final dio = Dio(BaseOptions(
baseUrl: ref.watch(envProvider).apiBaseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {'Accept': 'application/json'},
));
dio.interceptors.addAll([
AuthInterceptor(ref),
if (kDebugMode) LoggingInterceptor(),
ErrorInterceptor(),
]);
return dio;
}
Order matters: auth first (so retries after refresh get a fresh token), logging next, error last (so it sees the final exception after retries).
Auth interceptor — token refresh
Concurrent 401s must not trigger N parallel refresh calls. Use a single in-flight refresh future:
class AuthInterceptor extends Interceptor {
Future<String>? _refreshing;
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode != 401 || _isRefreshRequest(err.requestOptions)) {
return handler.next(err);
}
final newToken = await (_refreshing ??= _refreshToken());
_refreshing = null;
if (newToken == null) return handler.next(err);
err.requestOptions.headers['Authorization'] = 'Bearer $newToken';
final retry = await dio.fetch(err.requestOptions);
handler.resolve(retry);
}
}
If refresh fails, clear the session and surface the original 401 — let the auth state provider react to the cleared session and navigate to login.
Error mapping — sealed Failure type
The UI should never see DioException. Map it once, in the error interceptor:
sealed class Failure {
const Failure(this.message);
final String message;
}
class NetworkFailure extends Failure { ... }
class UnauthorizedFailure extends Failure { ... }
class ValidationFailure extends Failure {
final Map<String, List<String>> fieldErrors;
...
}
class ServerFailure extends Failure { ... }
class UnknownFailure extends Failure { ... }
Repositories return Future<T> and throw Failure. Notifiers wrap with AsyncValue.guard. UI pattern-matches on the Failure subtype.
Repository pattern
The generated API class is consumed only inside repositories:
class BookingRepositoryImpl implements BookingRepository {
BookingRepositoryImpl(this._api);
final BookingsApi _api;
@override
Future<List<Booking>> list({required String userId}) async {
final res = await _api.listBookings(userId: userId);
return res.data!.map(_toBooking).toList();
}
Booking _toBooking(BookingDto dto) => Booking(
id: dto.id,
startsAt: dto.startsAt,
// ... explicit mapping, never `fromJson` on domain entities
);
}
Why explicit mappers: generated DTOs are coupled to the spec version. Mapping at the boundary keeps domain entities stable across spec changes.
Multipart uploads
Use Dio's FormData.fromMap with MultipartFile.fromBytes (web-safe) rather than MultipartFile.fromPath when the app needs to run on web. Stream progress via Dio's onSendProgress callback into a Notifier.
Cancellation
Long-running calls (search, large lists) accept a CancelToken. Cancel on widget dispose or when the user types a new query — debounce + cancel is far better than just debounce.
Anti-patterns to reject
- Hand-writing endpoint methods when an OpenAPI spec exists — regenerate instead.
- Multiple Dio instances scattered across the app.
- Catching
DioException in widgets or notifiers — must be mapped to Failure at the interceptor.
- Storing tokens in
SharedPreferences — use flutter_secure_storage (Keychain/Keystore).
- Logging request bodies in release builds (PII leak).
- Putting
await dio.get(...) directly in a widget or a Notifier — must go through a repository.
- Refresh-token logic spread across multiple interceptors.