Annotate your Dart.
Ship the whole API.
Revali reads the annotations on your classes and generates the server, a type-safe Dart client, an OpenAPI document and a production Dockerfile. You write business logic —
revali dev writes the rest, and hot reloads it.
@Controller('users')
class UsersController {
const UsersController(this.users);
final UserService users;
@Get()
Future<List<User>> all() => users.all();
@Get(':id')
Future<User> byId(@Param() String id) {
return users.find(id);
}
@Post()
@StatusCode(201)
Future<User> create(@Body() NewUser body) {
return users.create(body);
}
}
.revali/server
.revali/revali_client
swagger.yaml
.revali/build
Write it once. Generate the rest.
Your controller is the only place an endpoint is described. Everything downstream is derived from it, so nothing can drift out of sync — not the client, not the docs, not the deploy.
prefix: /api (4 routes)
GET /users → UsersController.all
GET /users/:id → UsersController.byId
POST /users → UsersController.create
// .revali/revali_client — generated, then
// imported straight into your Flutter app.
import 'package:revali_client/client.dart';
final server = Server();
// The same names. The same types.
// No HTTP written by hand, on either side.
final users = await server.users.all();
final ada = await server.users.create(
NewUser(name: 'Ada', email: 'ada@revali.dev'),
);
openapi: 3.0.3
info:
title: API
version: 1.0.0
paths:
/users:
get:
operationId: users_all
tags:
- users
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
post:
operationId: users_create
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/NewUser'
# Stage 1: Build environment
FROM dart:stable AS build
WORKDIR /app
COPY . .
RUN dart pub get
# Build the server with Revali
RUN dart run revali build --release
# Compile to native executable
RUN dart compile exe .revali/server/server.dart \
-o /app/server
# Stage 2: Runtime environment
FROM alpine:latest
RUN apk add --no-cache libc6-compat ca-certificates
COPY --from=build /app/server /app/bin/server
CMD ["/app/bin/server"]
The return type is the hook.
No registration table, no ordering config, no base class per concern. Write a method on a LifecycleComponent, and what it returns decides where in the request it runs.
class Session implements LifecycleComponent {
const Session(this.tokens);
final TokenService tokens;
// A Guard: it can stop the request.
Future<GuardResult> authenticated(
@Header('authorization') String? auth,
) async {
if (await tokens.valid(auth)) {
return const GuardResult.pass();
}
return const GuardResult.block(statusCode: 401);
}
// An Interceptor: it runs after the handler.
InterceptorPostResult timing(Context context) {
context.response.headers.add('x-served-by', 'revali');
return const InterceptorPostResult.next();
}
}
Everything a real API needs, already wired up.
Binding, validation, dependency injection, realtime, CORS, exception handling. Not a routing library you assemble a framework around.
Typed request binding
Query, path, header, cookie and body params are parsed and typed before your method runs. A bad request never reaches it — it is a 400 with a real message.
@Get('search')
Future<Page<User>> search(
@Query() String q,
@Query('page') int page,
@Header('accept-language') String? locale,
@Cookie('session') String? session,
) async {
// Parsed, typed, and validated before you
// are called. A bad request never gets here
// — it is a 400 with a real message.
return users.search(q, page: page);
}
Dependency injection built in
Register singletons, lazy singletons and factories on your app config. Constructors are injected — including your lifecycle components.
@App()
final class MainApp extends AppConfig {
const MainApp() : super(port: 8080);
@override
Future<void> configureDependencies(DI di) async {
di.registerSingleton(await Database.connect());
di.registerLazySingleton<UserService>(UserServiceImpl.new);
di.registerFactory<Mailer>(SmtpMailer.new);
}
}
WebSockets
Annotate a method. Two-way, receive-only or send-only.
@Controller('chat')
class ChatController {
const ChatController();
@WebSocket('messages')
String handle(@Body() String message) {
return 'Echo: $message';
}
}
Server-sent events
Return a Stream and Revali handles the rest, cleanup included.
@Controller('events')
class EventController {
const EventController();
@SSE('live')
Stream<String> live(CleanUp cleanUp) async* {
cleanUp.add(feed.dispose);
yield* feed.stream;
}
}
Write your own construct
Everything above is a construct. Yours gets the same input.
class GraphQlConstruct implements BuildConstruct {
@override
Future<void> build(
RevaliContext context,
List<MetaRoute> routes,
) async {
// Every route, every annotation, every type
// — the same input the built-in server uses.
}
}
Access control
CORS origins, required and forbidden headers, and pre-flight handling, declared with @AllowOrigins and friends.
Pipes & transformation
Convert a raw String id into a real User before your handler sees it, in one annotation.
Scaffolding
revali create generates controllers, apps, pipes, observers and lifecycle components.
One command. Then just write code.
No build_runner in a second terminal, no codegen step to remember, no restart to sit through. Save the file; the routes are regenerated and the server hot reloads.
Hot reload
Changes in routes/ and lib/components/ reload the running server in place.
Real debugging
A Dart VM service is running, so attach your IDE and set breakpoints in generated code too.
See your routes
revali routes prints the route table straight from the generated manifest.
Diagnose it
revali doctor checks the kernel, constructs and generated output.
Running in under a minute.
dart pub add revali --dev
dart pub add revali_router
@Controller('hello')
class HelloController {
const HelloController();
@Get()
String world() => 'Hello, world!';
}
dart run revali dev
Stop writing the parts a compiler could write.
Revali is open source and MIT licensed. Your annotations are the spec — everything else is generated.