openapi_flutter_gen
High-performance OpenAPI-to-Dart/Flutter code generator. Parse your spec once, get a complete, ready-to-use API client with zero
build_runner, zero code generation at build time.
Why this exists
Most Dart/Flutter OpenAPI generators require build_runner to run in YOUR project, every time the spec changes. They generate .g.dart files you must commit and maintain, slowing your builds and coupling your app to code-gen tooling.
openapi_flutter_gen runs once as a standalone CLI. It produces standalone .dart source files — immutable models, typed API services, sealed exhaustive responses, auth interceptors, pagination helpers. Commit them, import them, done.
Comparison with real OpenAPI generators on pub.dev
Features verified by cloning and analyzing each competitor's source code (June 2026).
| Feature | openapi_flutter_gen | swagger_dart_code_generator | swagger_parser | openapi_generator | space_gen | openapi_spec |
|---|---|---|---|---|---|---|
| Zero build_runner in consumer | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ |
| Sealed exhaustive responses | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
| Immutable models (const) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ |
| Typed auth from spec | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Multipart/FormData | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Pagination helpers | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
| Isolate JSON deserialization | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Swagger 2.0 support | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| oneOf / allOf / anyOf | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| copyWith / == / hashCode | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ |
| Standalone CLI (no host project) | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ |
Install
dart pub global activate openapi_flutter_gen
Or add to your project's dev_dependencies:
dev_dependencies:
openapi_flutter_gen: ^0.2.10
Run
openapi_flutter_gen --spec swagger.json --output ./lib/api --package-name my_api
Or from a URL:
openapi_flutter_gen --spec-url https://api.example.com/swagger/v1/swagger.json -o ./lib/api
All flags
-s, --spec Path to OpenAPI spec (JSON or YAML)
-u, --spec-url URL to OpenAPI spec
-o, --output Output directory (default: ./generated)
-p, --package-name Dart package name (default: api_client)
--use-compute Generate Isolate.run wrappers for JSON deserialization
--no-isolates Disable parallel generation
-h, --help Show usage
Generated client structure
my_api/
├── pubspec.yaml # Dio + collection dependencies
├── analysis_options.yaml # Lint rules
└── lib/
├── my_api.dart # Barrel export
└── src/
├── models/ # One file per schema
│ ├── pet.dart # Immutable class: fromJson, toJson, copyWith, ==, hashCode
│ ├── pet_status.dart # Enum with fromJson/toJson
│ └── ...
├── api/ # Services + result types
│ ├── api_client.dart # Root client with typed service getters
│ ├── pets_api.dart # PetsApi: one method per operation
│ ├── list_pets_result.dart # Sealed result for each operation
│ └── ...
└── core/ # Support files
├── auth.dart # Typed security (Bearer, ApiKey, OAuth2)
├── error_handler.dart # ApiErrorInterceptor (global + per-call)
├── interceptors.dart # Auth, Retry, Logging
└── pagination.dart # Offset + Cursor pagination
Generated code walkthrough
Models — immutable, const, full-featured
Each schema becomes a standalone class with everything you need:
class Pet {
const Pet({
required this.id,
required this.name,
this.tag,
this.status,
});
final int id;
final String name;
final String? tag;
final PetStatus? status;
// Deserialization: single hash lookup per field, null-safe
factory Pet.fromJson(Map<String, dynamic> json) {
return Pet(
id: (json['id'] as num).toInt(),
name: json['name'] as String,
tag: json['tag'] != null ? json['tag'] as String : null,
status: json['status'] != null ? PetStatus.fromJson(json['status'] as String) : null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
if (tag != null) 'tag': tag,
if (status != null) 'status': status.toJson(),
};
}
Pet copyWith({int? id, String? name, String? tag, PetStatus? status}) {
if (id == null && name == null && tag == null && status == null) return this;
return Pet(
id: id ?? this.id,
name: name ?? this.name,
tag: tag ?? this.tag,
status: status ?? this.status,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) || (other is Pet && id == other.id && name == other.name && tag == other.tag && status == other.status);
@override
int get hashCode => Object.hash(id, name, tag, status);
@override
String toString() => 'Pet(id=$id, name=$name, tag=$tag, status=$status)';
}
Enums — string or int-backed
enum PetStatus {
available('available'),
pending('pending'),
sold('sold');
const PetStatus(this.value);
final String value;
static PetStatus fromJson(String json) =>
PetStatus.values.firstWhere((e) => e.name == json || e.value == json,
orElse: () => throw ArgumentError('Unknown PetStatus: $json'));
String toJson() => value;
}
Sealed union types — oneOf / anyOf
oneOf schemas become sealed class hierarchies with exhaustive pattern matching:
sealed class PetType {
const PetType();
factory PetType.fromJson(Map<String, dynamic> json) {
if (json.containsKey('petType')) {
return switch (json['petType'] as String) {
'dog' => PetTypeDog.fromJson(json),
'cat' => PetTypeCat.fromJson(json),
_ => _PetTypeUnknown.fromJson(json),
};
}
try { return PetTypeDog.fromJson(json); } catch (_) {}
try { return PetTypeCat.fromJson(json); } catch (_) {}
throw FormatException('Cannot decode PetType', json);
}
}
class PetTypeDog extends PetType {
const PetTypeDog(this.value);
final Dog value;
factory PetTypeDog.fromJson(Map<String, dynamic> json) => PetTypeDog(Dog.fromJson(json));
Map<String, dynamic> toJson() => value.toJson();
}
class PetTypeCat extends PetType {
const PetTypeCat(this.value);
final Cat value;
factory PetTypeCat.fromJson(Map<String, dynamic> json) => PetTypeCat(Cat.fromJson(json));
Map<String, dynamic> toJson() => value.toJson();
}
API services — one method per operation, typed parameters
class PetsApi {
const PetsApi({required this.dio, this.baseUrl});
final Dio dio;
final String? baseUrl;
Future<ListPetsResult> listPets({
int? limit,
PetStatus? status,
CancelToken? cancelToken,
Map<String, dynamic>? extra,
Options? options,
}) async {
final reqQueryParams = <String, dynamic>{};
if (limit != null) reqQueryParams['limit'] = limit.toString();
if (status != null) reqQueryParams['status'] = status.toJson().toString();
final response = await dio.request(
'/pets',
queryParameters: reqQueryParams.isNotEmpty ? reqQueryParams : null,
options: options ?? Options(method: 'GET', extra: extra),
cancelToken: cancelToken,
);
return ListPetsResult.fromResponse(response);
}
Future<CreatePetResult> createPet({
required CreatePetRequest createPetRequest,
CancelToken? cancelToken,
Map<String, dynamic>? extra,
Options? options,
}) async {
final response = await dio.request(
'/pets',
data: createPetRequest.toJson(),
options: options ?? Options(method: 'POST', extra: extra),
cancelToken: cancelToken,
);
return CreatePetResult.fromResponse(response);
}
}
Path parameters are automatically interpolated:
Future<GetPetResult> getPet({
required int petId,
...
}) async {
final response = await dio.request('/pets/$petId', ...);
...
}
Sealed result types — every HTTP status code is a typed variant
sealed class ListPetsResult {
const ListPetsResult();
factory ListPetsResult.fromResponse(Response response) {
return switch (response.statusCode!) {
200 => ListPetsResultHttp200(
List<Pet>.generate(response.data.length, (i) => Pet.fromJson((response.data as List)[i]), growable: false),
),
400 => ListPetsResultHttp400(Error.fromJson(response.data as Map<String, dynamic>)),
_ => ListPetsResultError.fromResponse(response),
};
}
}
class ListPetsResultHttp200 extends ListPetsResult {
const ListPetsResultHttp200(this.data);
final List<Pet> data;
}
class ListPetsResultHttp400 extends ListPetsResult {
const ListPetsResultHttp400(this.data);
final Error data;
}
class ListPetsResultError extends ListPetsResult {
const ListPetsResultError(this.response);
final Response<dynamic> response;
factory ListPetsResultError.fromResponse(Response response) => ListPetsResultError(response);
}
Using the generated client
1. Import and initialize
import 'package:my_api/my_api.dart';
final client = ApiClient(
baseUrl: 'https://api.example.com',
bearerAuth: BearerAuthSecurity(token: jwt),
errorHandler: ApiErrorInterceptor(
onUnauthorized: (_) => logout(),
onServerError: (_) => showErrorToast(),
),
);
2. Make API calls with exhaustive switch
final result = await client.pets.listPets(limit: 20);
switch (result) {
case ListPetsResultHttp200(:final data):
print('Got ${data.length} pets: ${data.map((p) => p.name).join(", ")}');
case ListPetsResultHttp400(:final data):
print('Bad request: ${data.message}');
case ListPetsResultError(:final response):
print('HTTP ${response.statusCode}: unexpected error');
}
3. Create resources
final result = await client.pets.createPet(
createPetRequest: CreatePetRequest(
name: 'Rex',
status: PetStatus.available,
),
);
switch (result) {
case CreatePetResultHttp201(:final data):
print('Created pet ${data.id}: ${data.name}');
case CreatePetResultError(:final response):
print('Failed: ${response.statusCode}');
}
4. Upload files (multipart/form-data)
Multipart endpoints automatically generate FormData — no manual construction needed:
final result = await client.mediaManager.addOrUpdateMedia(
culture: 'it',
bodyMultipartFormData: AddOrUpdateMediaBodyMultipartFormData(
mediaFile: imageBytes, // Uint8List → MultipartFile.fromBytes automatically
contentId: '12345',
contentTypeId: 'image',
),
);
5. Download binary files
Binary responses use ResponseType.bytes automatically:
final result = await client.mediaManager.getMedia(mediaId: 'abc');
switch (result) {
case GetMediaResultHttp200(:final data):
// data is Uint8List
await File('downloaded.pdf').writeAsBytes(data);
}
6. Error handling — global + per-call
Global error handler catches all calls:
final client = ApiClient(
errorHandler: ApiErrorInterceptor(
onUnauthorized: (_) => redirectToLogin(),
onServerError: (_) => reportCrash(),
),
);
Per-call override with chain or skip:
// Chain: per-call runs first, then global
await client.pets.deletePet(
petId: 123,
extra: {
'perCallErrorHandler': ApiErrorInterceptor(
onNotFound: (_) => showToast('Already deleted'),
),
},
);
// Skip global — only this handler fires
await client.pets.deletePet(
petId: 123,
extra: {
'perCallErrorHandler': ApiErrorInterceptor(
skipGlobal: true,
onNotFound: (_) => showToast('Already deleted'),
),
},
);
7. Pagination — forEach / toList
Offset-based:
final result = await client.pets.listPets(limit: 50);
switch (result) {
case ListPetsResultHttp200(:final data):
await data.forEach((pet) async => await processPet(pet));
final all = await data.toList(); // collects all pages
}
8. Dio cache integration
Add dio_mcache for transparent caching:
import 'package:dio_mcache/dio_mcache.dart';
final client = ApiClient(
baseUrl: 'https://api.example.com',
bearerAuth: BearerAuthSecurity(token: jwt),
dio: Dio()..interceptors.add(DioCacheInterceptor(
options: DioCacheOptions(expiration: const Duration(minutes: 5)),
)),
);
// All API calls are now cached automatically
9. Auth — token provider for dynamic refresh
final client = ApiClient(
bearerAuth: BearerAuthSecurity(
tokenProvider: () async {
final stored = await secureStorage.read('jwt');
if (stored != null) return stored;
return await refreshToken();
},
),
);
10. Custom Dio instance
final client = ApiClient(
dio: Dio(BaseOptions(
baseUrl: 'https://api.example.com',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
))..interceptors.addAll([
DioCacheInterceptor(options: DioCacheOptions(expiration: const Duration(minutes: 5))),
LoggingInterceptor(),
]),
);
Compute mode — Isolate-based JSON deserialization
For large API responses, JSON deserialization can block the main isolate. Use --use-compute to generate Isolate.run wrappers:
openapi_flutter_gen --spec swagger.json --use-compute
The generated service methods wrap deserialization in an isolate:
Future<ListPetsResult> listPets({...}) async {
final response = await dio.request('/pets', ...);
// Deserialization runs in a separate isolate — main thread stays responsive
final statusCode = response.statusCode ?? 0;
final data = response.data;
return Isolate.run(() => deserializeListPets((statusCode: statusCode, data: data)));
}
Supported specifications
| Format | Version | Tested with |
|---|---|---|
| OpenAPI 3.x (JSON) | 3.0.x | petstore.json (14 schemas) |
| OpenAPI 3.x (YAML) | 3.1.x | train-travel OpenAPI (47 schemas) |
| Swagger 2.0 (JSON) | 2.0 | petstore.swagger.io (16 schemas) |
| Production API (JSON) | 3.0 | 927 schemas, 605 operations, 0 issues |
Features
- OAS 3.x + Swagger 2.0 — JSON + YAML, full
$refresolution, oneOf/anyOf/allOf, discriminators, inline schema extraction at any nesting depth - Sealed exhaustive responses — every HTTP status code maps to a typed variant; switch statements are checked for exhaustiveness at compile time
- Immutable models —
constconstructors,copyWithwith zero-allocation fast path, structural==/hashCode,toString - Typed auth — Bearer, ApiKey, OAuth2, OpenID Connect generated from spec's
securitySchemes; static token or dynamictokenProvider - Per-request auth override — every API method accepts
optionsandextraparameters, enabling per-call cache control, error handling, and more - Multipart/FormData — binary fields automatically use
MultipartFile.fromBytesintoFormData(); string/int fields map to form fields - Error handling — global
ApiErrorInterceptorwith callbacks per status code; per-call overrides with chain/skip semantics - Pagination — offset-based and cursor-based
PaginatedResponse<T>withforEachandtoListextensions - Dio-based — uses Dio for HTTP; supports custom
Dioinstances, interceptors, and base URL - Compute mode —
--use-computewraps heavy JSON deserialization inIsolate.run, keeping the main isolate responsive - Parallel generation — file writing distributed across isolates for large specs
- Zero build_runner — no code generation at build time; no
.g.dartfiles; just import and use
Architecture
Spec (JSON/YAML)
│
▼
Loader ──► SwaggerNormalizer (Swagger 2.0 → OAS 3.x)
│
▼
OpenApiSpecParser
├── $ref resolution
├── inline schema extraction (recursive, any depth)
└── IR construction (IrSchema, IrOperation, IrApiDocument)
│
▼
CodeGenerator (parallel via Isolate.spawn)
├── ModelGenerator (IrObjectSchema → class, IrEnumSchema → enum, IrUnionSchema → sealed class)
├── ApiGenerator (IrOperation → service method + sealed result type)
└── SupportGenerator (auth, error handler, interceptors, pagination, pubspec, barrel)
│
▼
.dart files → import and use
Contributing
dart test # 36 tests, 3 spec formats, compute + normal mode
License
Dual-licensed.
Open Source — GNU AGPL v3
You can use, modify, and distribute this software freely under the terms of the GNU Affero General Public License v3. This includes the network-use clause: if you modify openapi_flutter_gen and run it as part of a network service (SaaS), you must make your modifications available to users of that service.
Commercial License
If the AGPL does not fit your business model, a commercial license is available.
What you get:
- Full rights to use openapi_flutter_gen in proprietary, closed-source applications
- No obligation to disclose your source code or modifications
- No network-use copyleft restrictions
- Priority email support
- Indemnification
Contact us for pricing and terms.
Libraries
- openapi_flutter_gen
- A high-performance OpenAPI-to-Dart/Flutter code generator.
Produces immutable models, sealed exhaustive response types, typed auth
interceptors, pagination, and Isolate-based JSON deserialization — all
with zero
build_runnerdependency in generated code.