dartapi_core 0.4.0
dartapi_core: ^0.4.0 copied to clipboard
Core utilities for building typed, structured REST APIs in Dart, including routing, validation, and middleware support.
dartapi_core #
A framework for building typed, structured REST APIs in Dart — routing, validation, middleware, dependency injection, JWT auth, OpenAPI documentation, and full server lifecycle. Use it directly or via the dartapi CLI.
Getting Started in 5 Minutes (No CLI) #
dependencies:
dartapi_core: ^0.3.0
import 'package:dartapi_core/dartapi_core.dart';
void main() async {
final app = DartAPI(appName: 'my_api');
app.addControllers([
InlineController([
ApiRoute(
method: ApiMethod.get,
path: '/hello',
summary: 'Say hello',
typedHandler: (req, _) async => {'message': 'Hello, World!'},
),
]),
]);
app.enableDocs(title: 'My API', version: '1.0.0');
app.enableHealthCheck();
await app.start(port: 8080);
}
Run with dart run bin/main.dart — open http://localhost:8080/docs for Swagger UI.
Examples #
Three runnable examples live in example/:
| Example | Description |
|---|---|
example/minimal/ |
One file, one route — the smallest possible server |
example/rest_api/ |
Full CRUD with JWT auth, FieldSet DTOs, ServiceRegistry, tests |
example/standalone_no_cli/ |
Annotated starter project (equivalent to dartapi create --minimal) |
Each example has its own pubspec.yaml and README.md with copy-paste run instructions.
Installation #
dependencies:
dartapi_core: ^0.3.0
Routing #
Define endpoints with ApiRoute<Input, Output>. The framework handles request parsing, response serialization, and error mapping automatically.
class UserController extends BaseController {
@override
List<ApiRoute> get routes => [
ApiRoute<void, List<String>>(
method: ApiMethod.get,
path: '/users',
typedHandler: getAllUsers,
summary: 'Get all users',
),
ApiRoute<UserDTO, UserDTO>(
method: ApiMethod.post,
path: '/users',
statusCode: 201,
typedHandler: createUser,
dtoParser: UserDTO.fromJson,
),
];
}
For one-off routes without a dedicated controller class, use InlineController:
app.addControllers([
InlineController([
ApiRoute<void, Map<String, String>>(
method: ApiMethod.get,
path: '/ping',
typedHandler: (req, _) async => {'status': 'ok'},
),
]),
]);
Path Parameters #
Use request.pathParam<T>(name) to extract typed path parameters. Shelf Router populates these from route patterns like /users/<id>.
ApiRoute<void, User>(
method: ApiMethod.get,
path: '/users/<id>',
typedHandler: (request, _) async {
final id = request.pathParam<int>('id');
return userService.findById(id);
},
)
Supported types: String, int, double, bool. Throws ApiException(400) if the param is missing or cannot be converted.
Query Parameters #
Use request.queryParam<T>(name, defaultValue: ...) to extract typed query parameters.
ApiRoute<void, List<Product>>(
method: ApiMethod.get,
path: '/products',
typedHandler: (request, _) async {
final page = request.queryParam<int>('page', defaultValue: 1);
final limit = request.queryParam<int>('limit', defaultValue: 20);
final search = request.queryParam<String>('q');
return productService.list(page: page!, limit: limit!, search: search);
},
)
Returns null (or defaultValue) when the parameter is absent.
Custom Response Status Codes #
Set statusCode on any route to override the default 200 OK:
ApiRoute(method: ApiMethod.post, path: '/users', statusCode: 201, ...)
ApiRoute(method: ApiMethod.delete, path: '/users/<id>', statusCode: 204, ...)
Request Validation #
Single-field validation #
Use verifyKey<T>() on request body maps to extract fields with type checking and optional validators:
factory UserDTO.fromJson(Map<String, dynamic> json) {
return UserDTO(
name: json.verifyKey<String>('name', validators: [
MinLengthValidator(2), MaxLengthValidator(50),
]),
age: json.verifyKey<int>('age'),
email: json.verifyKey<String>('email', validators: [EmailValidator()]),
);
}
Throws ApiException(422) on the first failing field.
Multi-field validation (validateAll) #
Use validateAll to collect errors from every field before throwing — the client sees all problems at once instead of fixing them one at a time:
factory BookDTO.fromJson(Map<String, dynamic> json) {
json.validateAll({
'title': () => json.verifyKey<String>('title', validators: [NotEmptyValidator(), MaxLengthValidator(200)]),
'author': () => json.verifyKey<String>('author', validators: [NotEmptyValidator()]),
'year': () => json.verifyKey<int>('year'),
});
return BookDTO(
title: json['title'] as String,
author: json['author'] as String,
year: json['year'] as int,
);
}
Throws a single ApiException(422) listing every invalid field.
FieldSet — declare fields once, get validation + schema #
FieldSet is the recommended way to define DTOs. Declare fields once and get runtime validation and an OpenAPI JSON Schema from the same source — no drift between rules and docs.
class CreateUserDTO {
static final fields = FieldSet({
'name': Field<String>(validators: [NotEmptyValidator(), MaxLengthValidator(100)], example: 'Alice'),
'email': Field<String>(validators: [EmailValidator()]),
'age': Field<int>(required: false, validators: [RangeValidator(min: 0, max: 150)]),
'role': Field<String>(validators: [EnumValidator(['user', 'admin'])]),
'tags': Field<List<String>>(), // emits {type: array, items: {type: string}}
});
static Map<String, dynamic> get schema => fields.toJsonSchema();
factory CreateUserDTO.fromJson(Map<String, dynamic> json) {
fields.validate(json); // collects ALL field errors, throws ValidationException
return CreateUserDTO(...);
}
}
Use the schema in OpenAPI:
app.enableDocs(
title: 'My API',
schemas: {'CreateUserDTO': CreateUserDTO.schema},
);
// then on a route:
ApiRoute(requestSchema: {r'$ref': '#/components/schemas/CreateUserDTO'}, ...)
Built-in validators #
Each validator also implements toSchemaProperties() so its constraints appear in the generated OpenAPI spec automatically.
| Validator | Type | Schema output |
|---|---|---|
EmailValidator([msg]) |
String |
{format: email} |
MinLengthValidator(n) |
String |
{minLength: n} |
MaxLengthValidator(n) |
String |
{maxLength: n} |
NotEmptyValidator() |
String |
{minLength: 1} |
RangeValidator<T>(min:, max:) |
num |
{minimum, maximum} |
PatternValidator(regex, msg) |
String |
{pattern: regex.pattern} |
UrlValidator() |
String |
{format: uri} |
EnumValidator<T>(values, [msg]) |
any | {enum: [...]} |
Custom validators #
class MinLengthValidator extends Validators<String> {
final int min;
MinLengthValidator(this.min) : super('Must be at least $min characters');
@override
bool validate(dynamic value) => (value as String).length >= min;
}
Error Handling #
Throw ApiException(statusCode, message) from any handler or validator to return a specific HTTP error:
throw ApiException(404, 'User not found');
throw ApiException(422, 'Invalid input');
throw ApiException(401, 'Unauthorized');
The framework catches these automatically and returns a JSON response with the correct status code.
Dependency Injection #
ServiceRegistry is built into DartAPI. Registrations are lazy singletons — the factory runs on first get<T>(), is cached, and dependencies are resolved automatically.
final app = DartAPI();
app.register<UserRepository>((_) => InMemoryUserRepository());
app.register<JwtService>(
(r) => JwtService(
accessTokenSecret: 'secret',
refreshTokenSecret: 'refresh-secret',
issuer: 'my-app',
audience: 'api-users',
tokenStore: r.get<InMemoryTokenStore>(),
),
);
app.register<UserService>((r) => UserService(r.get<UserRepository>()));
// Resolve when wiring controllers
app.addControllers([
UserController(service: app.get<UserService>()),
]);
Use registerSingleton<T>(instance) to register an already-constructed instance:
app.registerSingleton<AppConfig>(AppConfig(environment: env));
Circular dependencies are detected at resolution time with a full chain in the error message (e.g. A → B → A).
JWT Authentication #
JwtService, authMiddleware, InMemoryTokenStore, and apiKeyMiddleware are all included in dartapi_core — no separate auth package needed.
Setup #
final jwt = JwtService(
accessTokenSecret: 'my-access-secret',
refreshTokenSecret: 'my-refresh-secret',
issuer: 'my-app',
audience: 'api-clients',
tokenStore: InMemoryTokenStore(),
);
RS256 (asymmetric):
final jwt = JwtService.rs256(
privateKeyPem: File('private.pem').readAsStringSync(),
publicKeyPem: File('public.pem').readAsStringSync(),
issuer: 'my-app',
audience: 'api-clients',
);
Generating tokens #
final pair = jwt.generateTokenPair(claims: {
'sub': 'user-123',
'email': 'alice@example.com',
});
// pair.accessToken, pair.refreshToken
Refresh tokens are single-use when a tokenStore is configured: verifyRefreshToken
consumes the presented token, so a refresh endpoint must return a full new pair:
final payload = await jwt.verifyRefreshToken(oldRefreshToken);
if (payload == null) throw ApiException(401, 'Invalid refresh token');
final pair = jwt.generateTokenPair(claims: {'sub': payload['sub']});
Reuse of an already-rotated refresh token is the classic token-theft signal. Hook
onRefreshTokenReuse to respond (e.g. force re-login):
JwtService(
...,
tokenStore: store,
onRefreshTokenReuse: (payload) async {
await sessions.terminateAllForUser(payload['sub'] as String);
},
);
Protecting routes #
ApiRoute<void, UserProfile>(
method: ApiMethod.get,
path: '/me',
middlewares: [authMiddleware(jwt)],
security: [SecurityScheme.bearer], // shows lock icon in Swagger UI
typedHandler: (req, _) async {
final user = req.context['user'] as Map<String, dynamic>;
return getProfile(user['sub'] as String);
},
)
Token revocation #
// Logout handler — revoke both tokens:
final revoked = await jwt.revokeToken(accessToken); // true when verified + revoked
await jwt.revokeToken(refreshToken);
final payload = await jwt.verifyAccessToken(accessToken); // null
revokeToken verifies the token's signature before revoking, so forged tokens
cannot revoke other users' sessions. Revocation entries carry a TTL matching the
token's remaining lifetime — InMemoryTokenStore prunes them automatically, and
custom TokenStore backends receive the TTL (e.g. for Redis SET ... EX).
Distributed backends should also override TokenStore.revokeIfActive with an
atomic operation (Redis SET NX EX) so refresh rotation stays single-use across
instances.
API key middleware #
ApiRoute(
method: ApiMethod.post,
path: '/webhooks/stripe',
middlewares: [apiKeyMiddleware(validKeys: {'whsec_abc123'})],
typedHandler: handleStripeWebhook,
)
Middleware #
Opt-in pipeline helpers (via DartAPI) #
app.enableCompression(); // gzip responses
app.enableBackgroundTasks(); // req.backgroundTasks
app.enableTimeout(const Duration(seconds: 30)); // 408 on timeout
app.enableRateLimit(maxRequests: 100, window: Duration(minutes: 1));
app.enableBodySizeLimit(maxBytes: 1024 * 1024); // 413 above 1 MB
app.enableSecurityHeaders(); // X-Frame-Options etc.
app.enableMetrics(); // GET /metrics
app.enableHealthCheck(); // GET /health
app.enableDocs(title: 'My API', version: '1.0.0'); // GET /docs
app.configureLogging( // built-in logging
format: LogFormat.json,
excludePaths: ['/health', '/metrics'],
);
await app.start(port: 8080); // also: address:, shared:, securityContext:
// ...
await app.stop(); // graceful shutdown (drains, then runs onShutdown hooks)
Behind a proxy or load balancer? Rate limiting keys by the real socket IP by default (spoof-proof). Add
trustProxy: truetoenableRateLimitso clients are identified by the firstX-Forwarded-Forentry instead of the proxy's IP. Never set it on a directly exposed server.
Per-route middleware #
ApiRoute(
middlewares: [authMiddleware(jwtService)],
...
)
Middleware reference #
| Middleware | Description |
|---|---|
loggingMiddleware() |
Logs method, URI, status |
globalExceptionMiddleware(onError:) |
Catch-all exception handler |
rateLimitMiddleware(maxRequests:, window:) |
Token-bucket rate limiter; returns 429 |
requestIdMiddleware() |
Attaches X-Request-Id; stores in context['requestId'] |
compressionMiddleware(threshold:) |
Gzip responses above threshold |
backgroundTaskMiddleware() |
Enables request.backgroundTasks |
cacheMiddleware(ttl:, keyExtractor:) |
In-memory GET cache; adds X-Cache: HIT/MISS |
authMiddleware(jwtService) |
JWT Bearer token validation; returns 401 with WWW-Authenticate |
apiKeyMiddleware(validKeys:, header:) |
Static API key validation |
securityHeadersMiddleware(...) |
X-Frame-Options, nosniff, CSP, HSTS, etc. |
bodySizeLimitMiddleware(maxBytes:) |
Rejects oversized payloads with 413 |
timeoutMiddleware(duration) |
Returns 408 when a handler exceeds the timeout |
metricsMiddleware() |
Records Prometheus counters and latency histograms |
Production Deployment #
Graceful shutdown #
SIGINT/SIGTERM (and await app.stop()) stop accepting new connections,
wait until every in-flight response is fully written, then force-close
stragglers. onShutdown hooks run after the drain, so closing your
database in a hook is safe. Tune the drain window with:
final app = DartAPI(shutdownGracePeriod: Duration(seconds: 15)); // default 30s
This is exactly what a Kubernetes rolling deploy expects — no dropped
requests on SIGTERM.
API versioning #
app.addControllers([UserController(), OrderController()], prefix: '/api/v1');
// GET /users → GET /api/v1/users — OpenAPI spec shows the full path too
Static files #
app.serveStatic('/public', 'web'); // GET /public/logo.png
app.serveStatic('/', 'web', defaultDocument: 'index.html'); // SPA hosting
Native TLS #
Most deployments terminate TLS at a proxy — but serving HTTPS directly is one parameter:
final context = SecurityContext()
..useCertificateChain('cert.pem')
..usePrivateKey('key.pem');
await app.start(port: 443, securityContext: context);
Real client IPs #
clientIp(request) returns the connection's socket IP — spoof-proof.
Behind a trusted proxy, clientIp(request, trustProxy: true) reads
X-Forwarded-For / X-Real-IP. Use it for audit logs and custom
rate-limit keys.
Path Parameters #
Use request.pathParam<T>(name) for typed path parameters, request.queryParam<T>(name) for query params, request.header<T>(name) for headers.
Pagination #
Pagination.fromRequest() reads ?page and ?limit, clamps them, and computes the SQL offset:
final p = Pagination.fromRequest(request, defaultLimit: 20, maxLimit: 100);
// p.page, p.limit, p.offset
return PaginatedResponse(data: rows, pagination: p, total: totalCount);
Serializes to:
{
"data": [...],
"meta": { "page": 2, "limit": 20, "total": 150, "totalPages": 8, "hasNext": true, "hasPrev": true }
}
Response Caching #
Per-route (recommended) #
ApiRoute(
method: ApiMethod.get,
path: '/products',
cacheTtl: Duration(minutes: 10),
typedHandler: (req, _) async => fetchProducts(),
)
Global #
Pipeline()
.addMiddleware(cacheMiddleware(ttl: Duration(minutes: 10)))
.addHandler(router.handler)
Cached responses include X-Cache: HIT; misses include X-Cache: MISS. Only 200 GET responses are cached.
Background Tasks #
Schedule async work to run after the response has been sent (similar to FastAPI's BackgroundTasks):
// Enable once:
app.enableBackgroundTasks();
// In a handler:
typedHandler: (req, dto) async {
final user = await createUser(dto!);
req.backgroundTasks.add(() => emailService.sendWelcome(user.email));
return user; // response sent immediately; email sends after
}
Tasks run sequentially after the response resolves. Errors in tasks are swallowed.
OpenAPI / Swagger Docs #
app.addControllers([userController, productController]);
app.enableDocs(
title: 'My App',
version: '1.0.0',
schemas: {'CreateUserDTO': CreateUserDTO.schema}, // optional shared schemas
);
await app.start();
| Endpoint | Description |
|---|---|
GET /openapi.json |
OpenAPI 3.0 spec |
GET /docs |
Swagger UI (with persistent Bearer token support) |
GET /redoc |
ReDoc UI |
Documenting query parameters — use QueryParamSpec so params appear in Swagger UI:
ApiRoute(
method: ApiMethod.get,
path: '/users',
queryParams: [
QueryParamSpec('page', type: 'integer', defaultValue: 1),
QueryParamSpec('limit', type: 'integer', defaultValue: 20),
QueryParamSpec('search', description: 'Filter by name'),
],
typedHandler: ...,
)
Shared schemas with $ref — register named schemas and reference them:
app.enableDocs(schemas: {'CreateUserDTO': CreateUserDTO.schema});
// on a route:
ApiRoute(requestSchema: {r'$ref': '#/components/schemas/CreateUserDTO'}, ...)
Environment Config #
final env = mergeEnv([
loadEnvFile('env/.env'),
loadEnvFile('env/.env.dev'),
]);
final config = AppConfig(environment: env);
// config.port, config.jwtAccessSecret, config.corsOrigin, config.dbName, etc.
loadEnvFile is gracefully ignored when the file doesn't exist.
WebSocket Support #
class ChatController extends BaseController {
@override
List<ApiRoute> get routes => [];
@override
List<WebSocketRoute> get webSocketRoutes => [
WebSocketRoute(
path: '/ws/chat',
handler: (channel, _) async {
await for (final message in channel.stream) {
channel.sink.add('Echo: $message');
}
},
),
];
}
Server-Sent Events #
ApiRoute<void, void>(
method: ApiMethod.get,
path: '/events',
typedHandler: (req, _) async {
final stream = Stream.periodic(Duration(seconds: 1), (i) =>
SseEvent(data: 'tick $i', event: 'tick', id: '$i'));
return sseResponse(stream.take(10));
},
)
File Uploads #
Future<String> uploadAvatar(Request request, void _) async {
if (!request.isMultipart) throw ApiException(400, 'Expected multipart/form-data');
final avatar = await request.file('avatar');
if (avatar == null) throw ApiException(400, 'Missing file field "avatar"');
await saveFile(avatar.filename!, avatar.bytes);
return 'Uploaded ${avatar.filename}';
}
Prometheus Metrics #
app.enableMetrics(); // registers GET /metrics
Exposes:
http_requests_total{method, path, status}— request counterhttp_request_duration_seconds{method, path}— latency histogram
HTTP Test Client #
import 'package:dartapi_core/dartapi_core.dart';
import 'package:test/test.dart';
void main() {
late DartApiTestClient client;
setUp(() {
final router = RouterManager();
router.registerController(UserController(...));
client = DartApiTestClient(router.handler.call);
});
test('GET /users returns 200', () async {
final res = await client.get('/users');
expect(res.statusCode, 200);
expect(res.json<List>(), isNotEmpty);
});
}
Pass defaultHeaders once to authenticate the whole suite:
client = DartApiTestClient(
router.handler.call,
defaultHeaders: {'authorization': 'Bearer $adminToken'},
);
Links #
License #
BSD 3-Clause License © 2025 Akash G Krishnan