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: true to enableRateLimit so clients are identified by the first X-Forwarded-For entry 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

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 counter
  • http_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'},
);


License

BSD 3-Clause License © 2025 Akash G Krishnan

Libraries

dartapi_core