dartapi_core 0.1.7 copy "dartapi_core: ^0.1.7" to clipboard
dartapi_core: ^0.1.7 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.1.6
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.1.6

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 accessToken = jwt.generateAccessToken(claims: {
  'sub': 'user-123',
  'email': 'alice@example.com',
});

final refreshToken = jwt.generateRefreshToken(accessToken: accessToken);

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 #

await jwt.revokeToken(accessToken);
final payload = await jwt.verifyAccessToken(accessToken); // null

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));                 // 503 on timeout
app.enableRateLimit(maxRequests: 100, window: Duration(minutes: 1));
app.enableMetrics();                                             // GET /metrics
app.enableHealthCheck();                                         // GET /health
app.enableDocs(title: 'My API', version: '1.0.0');             // GET /docs

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
apiKeyMiddleware(validKeys:, header:) Static API key validation

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

1
likes
0
points
1.2k
downloads

Publisher

verified publisherakashgk.com

Weekly Downloads

Core utilities for building typed, structured REST APIs in Dart, including routing, validation, and middleware support.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

dart_jsonwebtoken, mime, shelf, shelf_cors_headers, shelf_router, shelf_web_socket, web_socket_channel

More

Packages that depend on dartapi_core