Generate type-safe Flutter/Dart API clients from OpenAPI specs — with status-code-level response handling, Riverpod integration, and cursor-based pagination.
Inspired by orval for React. florval brings the same level of automation to Flutter: one command turns your OpenAPI spec into production-ready Dart code.
Your OpenAPI spec:
/tasks/{id}:
get:
operationId: getTask
parameters:
- name: id
in: path
required: true
schema: { type: string }
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/Task"
"401":
content:
application/json:
schema:
$ref: "#/components/schemas/UnauthorizedError"
"404":
content:
application/json:
schema:
$ref: "#/components/schemas/NotFoundError"florval generates a dio client and Riverpod provider — each status code is routed to a typed variant automatically:
// clients/tasks_api_client.dart
class TasksApiClient {
final Dio _dio;
TasksApiClient(this._dio);
Future<GetTaskResponse> getTask({required String id}) async {
try {
final response = await _dio.get('/tasks/$id');
return switch (response.statusCode) {
200 => GetTaskResponse.success(Task.fromJson(response.data)),
401 => GetTaskResponse.unauthorized(UnauthorizedError.fromJson(response.data)),
404 => GetTaskResponse.notFound(NotFoundError.fromJson(response.data)),
_ => GetTaskResponse.unknown(response.statusCode ?? 0, response.data),
};
} on DioException catch (e) { /* same routing for error responses */ }
}
}
// providers/tasks_providers.dart
@Riverpod(retry: retry)
class GetTask extends _$GetTask {
@override
FutureOr<GetTaskResponse> build({required String id}) async {
final client = ref.watch(tasksApiClientProvider);
return client.getTask(id: id);
}
}You write — pattern-match to get the freezed Task model directly:
final response = await client.getTask(id: taskId);
switch (response) {
case GetTaskResponseSuccess(:final data) => showTask(data), // data is Task
case GetTaskResponseNotFound(:final data) => showError(data.message),
case GetTaskResponseUnauthorized(:final data) => handleAuth(data),
case GetTaskResponseUnknown(:final statusCode) => showError('Error: $statusCode'),
}Your OpenAPI spec:
/tasks:
post:
operationId: createTask
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateTaskRequest"
responses:
"201":
content:
application/json:
schema:
$ref: "#/components/schemas/Task"
"401":
content:
application/json:
schema:
$ref: "#/components/schemas/UnauthorizedError"
"422":
content:
application/json:
schema:
$ref: "#/components/schemas/ValidationError"florval generates a client method and a Mutation helper that auto-invalidates related GET providers:
// clients/tasks_api_client.dart
Future<CreateTaskResponse> createTask({required CreateTaskRequest body}) async {
try {
final response = await _dio.post('/tasks', data: body.toJson());
return switch (response.statusCode) {
201 => CreateTaskResponse.created(Task.fromJson(response.data)),
401 => CreateTaskResponse.unauthorized(UnauthorizedError.fromJson(response.data)),
422 => CreateTaskResponse.unprocessableEntity(ValidationError.fromJson(response.data)),
_ => CreateTaskResponse.unknown(response.statusCode ?? 0, response.data),
};
} on DioException catch (e) { /* same routing for error responses */ }
}
// providers/tasks_providers.dart
final createTaskMutation = Mutation<CreateTaskResponse>();
Future<CreateTaskResponse> createTask(
MutationTarget ref, {
required CreateTaskRequest body,
}) async {
return createTaskMutation.run(ref, (tsx) async {
final client = tsx.get(tasksApiClientProvider);
final result = await client.createTask(body: body);
ref.container.invalidate(listTasksProvider); // auto-invalidate GET providers
ref.container.invalidate(getTaskProvider);
return result;
});
}You write:
final response = await createTask(ref, body: CreateTaskRequest(title: 'New task'));
switch (response) {
case CreateTaskResponseCreated(:final data) => showTask(data), // data is Task
case CreateTaskResponseUnprocessableEntity(:final data) => showErrors(data.errors),
case CreateTaskResponseUnauthorized(:final data) => handleAuth(data),
case CreateTaskResponseUnknown(:final statusCode) => showError('Error: $statusCode'),
}
// listTasks and getTask providers are automatically refreshed!Your OpenAPI spec:
Task:
type: object
required: [id, title, description, status, priority, assignee_id, tags, due_date, created_at, updated_at]
properties:
id: { type: string, format: uuid }
title: { type: string }
description: { type: string, nullable: true }
status: { type: string, enum: [todo, in_progress, done] }
priority: { type: string, enum: [low, medium, high, urgent] }
assignee_id: { type: string, nullable: true, format: uuid }
tags: { type: array, items: { type: string } }
due_date: { type: string, nullable: true, format: date-time }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }florval generates — inline enum properties become dedicated Dart enums automatically:
// models/task.dart
@freezed
abstract class Task with _$Task {
const factory Task({
required String id,
required String title,
required String? description,
required TaskStatus status,
required TaskPriority priority,
@JsonKey(name: 'assignee_id') required String? assigneeId,
required User? assignee,
required List<String> tags,
@JsonKey(name: 'due_date') required DateTime? dueDate,
@JsonKey(name: 'created_at') required DateTime createdAt,
@JsonKey(name: 'updated_at') required DateTime updatedAt,
}) = _Task;
factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);
}
// models/task_status.dart — generated from inline enum
enum TaskStatus {
@JsonValue('todo')
todo,
@JsonValue('in_progress')
inProgress,
@JsonValue('done')
done;
String get jsonValue => switch (this) {
TaskStatus.todo => 'todo',
TaskStatus.inProgress => 'in_progress',
TaskStatus.done => 'done',
};
static TaskStatus fromJsonValue(String value) =>
values.firstWhere((e) => e.jsonValue == value);
}Your OpenAPI spec:
/tasks/{id}:
put:
operationId: updateTask
# ...
UpdateTaskRequest:
type: object
required: [title, status, priority] # only 3 fields required
properties:
title: { type: string }
description: { type: string, nullable: true }
assignee_id: { type: string, nullable: true }
due_date: { type: string, nullable: true, format: date-time }
tags: { type: array, items: { type: string } }florval generates — optional fields wrapped in JsonOptional<T> to distinguish "not sent" from "null":
// models/update_task_request.dart
@Freezed(fromJson: false, toJson: false)
abstract class UpdateTaskRequest with _$UpdateTaskRequest {
const UpdateTaskRequest._();
const factory UpdateTaskRequest({
required String title,
@Default(JsonOptional<String>.absent()) JsonOptional<String> description,
required UpdateTaskRequestStatus status,
required UpdateTaskRequestPriority priority,
@JsonKey(name: 'assignee_id')
@Default(JsonOptional<String>.absent()) JsonOptional<String> assigneeId,
@JsonKey(name: 'due_date')
@Default(JsonOptional<DateTime>.absent()) JsonOptional<DateTime> dueDate,
@Default(JsonOptional<List<String>>.absent()) JsonOptional<List<String>> tags,
}) = _UpdateTaskRequest;
factory UpdateTaskRequest.fromJson(Map<String, dynamic> json) { /* ... */ }
Map<String, dynamic> toJson() { /* ... */ }
}You write:
// Only update title — optional fields stay untouched on the server
final body = UpdateTaskRequest(
title: 'New title',
status: UpdateTaskRequestStatus.done,
priority: UpdateTaskRequestPriority.high,
);
// → {"title": "New title", "status": "done", "priority": "high"}
// Explicitly clear the due date
final body = UpdateTaskRequest(
title: 'New title',
status: UpdateTaskRequestStatus.done,
priority: UpdateTaskRequestPriority.high,
dueDate: JsonOptional.value(null),
);
// → {"title": "New title", "status": "done", "priority": "high", "due_date": null}Your OpenAPI spec:
NotificationPayload:
oneOf:
- $ref: "#/components/schemas/TaskAssignedPayload"
- $ref: "#/components/schemas/CommentAddedPayload"
discriminator:
propertyName: type
mapping:
task_assigned: "#/components/schemas/TaskAssignedPayload"
comment_added: "#/components/schemas/CommentAddedPayload"florval generates — freezed sealed classes with unionKey and @FreezedUnionValue:
@Freezed(unionKey: 'type')
sealed class NotificationPayload with _$NotificationPayload {
@FreezedUnionValue('task_assigned')
const factory NotificationPayload.taskAssigned({
@JsonKey(name: 'task_id') required String taskId,
@JsonKey(name: 'task_title') required String taskTitle,
@JsonKey(name: 'assigned_by') required String assignedBy,
}) = NotificationPayloadTaskAssigned;
@FreezedUnionValue('comment_added')
const factory NotificationPayload.commentAdded({
@JsonKey(name: 'task_id') required String taskId,
@JsonKey(name: 'comment_text') required String commentText,
@JsonKey(name: 'commented_by') required String commentedBy,
}) = NotificationPayloadCommentAdded;
factory NotificationPayload.fromJson(Map<String, dynamic> json) =>
_$NotificationPayloadFromJson(json);
}You write:
final payload = NotificationPayload.fromJson(json);
switch (payload) {
case NotificationPayloadTaskAssigned(:final taskId, :final taskTitle):
showAssignment(taskId, taskTitle);
case NotificationPayloadCommentAdded(:final commentText):
showComment(commentText);
}Most Flutter OpenAPI generators treat every response as a single type:
// ❌ What other generators produce — you're on your own for error handling
try {
final user = await client.getUser(id: 42);
// What if the server returned 404? 422? 500?
// You don't know until it throws.
} on DioException catch (e) {
if (e.response?.statusCode == 404) { ... }
else if (e.response?.statusCode == 422) { ... }
// Manual, error-prone, no type safety
}// ✅ florval — every status code is a typed variant
final response = await client.getTask(id: taskId);
switch (response) {
case GetTaskResponseSuccess(:final data) => showTask(data),
case GetTaskResponseNotFound(:final data) => showError(data.message),
case GetTaskResponseUnauthorized(:final data) => handleAuth(data),
case GetTaskResponseUnknown(:final statusCode) => showError('Error: $statusCode'),
}No exceptions. No statusCode == 200 checks. Every response path is exhaustive and compiler-checked.
Core — what sets florval apart:
- Status-code Union types — plain Dart sealed classes for every endpoint response
- JsonOptional<T> for PATCH/PUT — distinguishes "don't send this key" from "send null"
- Riverpod 3.x integration — Notifiers for GET, Mutation API for POST/PUT/DELETE
- Auto-invalidation — mutations automatically refresh related GET providers
Generation:
- freezed 3.x models — immutable data classes with
copyWith, JSON serialization - Inline enum generation —
enumproperties in schemas become dedicated Dart enums with@JsonValue - Doc comments —
descriptionandexamplefrom OpenAPI specs become///doc comments @Deprecatedannotations — schema, property, operation, and parameter-leveldeprecatedflagsreadOnly/writeOnly— OpenAPI field flags propagated to the intermediate representation@Defaultvalues — OpenAPIdefaultvalues generate@Default(...)annotations- dio clients — clean HTTP clients, no Retrofit, full control over your Dio instance
- Cursor-based pagination —
fetchMore()with automatic data accumulation - Discriminator Union types —
@Freezed(unionKey: ...)with@FreezedUnionValue - multipart/form-data — file uploads with
MultipartFilesupport
DX:
- Watch mode — auto-regenerate on spec file changes
- OpenAPI 3.0 & 3.1 — v3.0 specs are normalized to v3.1 automatically
- Swagger 2.0 — partial support (auto-normalized to v3.1)
- Zero runtime dependency — generated code depends only on dio, freezed, and optionally Riverpod
dev_dependencies:
florval: ^0.2.0dart run florval initThis creates a florval.yaml config file. Edit schema_path to point to your OpenAPI spec.
dart run florval generatedart run build_runner build --delete-conflicting-outputsThis runs freezed, json_serializable, and riverpod_generator on the generated code.
Full florval.yaml reference:
florval:
schema_path: openapi.yaml # Required. Path to OpenAPI spec.
output_directory: lib/api/generated # Output directory.
client:
base_url_env: API_BASE_URL # Env var name for base URL.
timeout: 30000 # Request timeout (ms).
riverpod:
enabled: false # Generate Riverpod providers.
auto_invalidate: false # Invalidate GET providers after mutations.
retry: # Riverpod-level retry for GET providers.
max_attempts: 3
delay: 1000 # Initial delay (ms), linear backoff.
pagination: # Cursor-based pagination endpoints.
- operation_id: listItems
cursor_param: after
next_cursor_field: nextCursor
items_field: items| Feature | florval | swagger_parser | openapi_generator |
|---|---|---|---|
| Status-code Union types | ✅ | ❌ | ❌ |
| JsonOptional (undefined vs null) | ✅ | ❌ | ❌ |
| Riverpod integration | ✅ | ❌ | ❌ |
| Auto-invalidation after mutations | ✅ | ❌ | ❌ |
| Inline enum generation | ✅ | ✅ | ✅ |
| Doc comments from description/example | ✅ | ❌ | ✅ |
| @Deprecated from OpenAPI flags | ✅ | ❌ | ✅ |
| @Default from OpenAPI defaults | ✅ | ❌ | ❌ |
| Cursor-based pagination | ✅ | ❌ | ❌ |
| freezed 3.x | ✅ | ✅ | ❌ |
| No Retrofit dependency | ✅ | ❌ | N/A |
| OpenAPI 3.0 + 3.1 | ✅ | ✅ | ✅ |
| Swagger 2.0 | ✅ | ✅ | ✅ |
| multipart/form-data | ✅ | ✅ | ✅ |
lib/api/generated/
├── core/
│ └── json_optional.dart # Runtime type for PATCH/PUT
├── models/ # freezed data classes
├── responses/ # Status-code sealed classes
├── clients/ # dio API clients
├── providers/ # Riverpod Notifiers + Mutations
└── api.dart # Barrel file
dart run florval init # Create florval.yaml template
dart run florval init --config custom.yaml --force # Custom config path
dart run florval generate # Generate from florval.yaml
dart run florval generate --watch # Watch mode
dart run florval generate --schema api.yaml --output lib/api/
dart run florval generate --verbose # Debug outputdependencies:
dio: ^5.0.0
freezed_annotation: ^3.0.0
json_annotation: ^4.0.0
# Only if riverpod.enabled: true
riverpod: ^3.0.0
riverpod_annotation: ^3.0.0
dev_dependencies:
build_runner: ^2.4.0
freezed: ^3.0.0
json_serializable: ^6.0.0
# Only if riverpod.enabled: true
riverpod_generator: ^3.0.0
florval: ^0.2.0| Version | Support |
|---|---|
| OpenAPI 3.1 | Full |
| OpenAPI 3.0 | Full (auto-normalized to 3.1) |
| Swagger 2.0 | Partial (auto-normalized to 3.1) |
MIT
encer.co.jp is committed to shaping the future of Flutter.