Dart Web Routing
Building APIs with Shelf and Dart Frog
Explanation
Server-Side Dart
Dart isn't just for Flutter. With frameworks like Shelf and Dart Frog, you can build fast, type-safe backend APIs that share code with your Flutter apps.
Key Concepts
- Shelf: Minimal web server middleware
- Dart Frog: Full-featured API framework
- Middleware: Request/response pipeline
- Handlers: Process incoming requests
Framework Comparison
| Feature | Shelf | Dart Frog | |---------|-------|-----------| | Type | Middleware | Full framework | | Routing | Manual | File-based | | CLI | None | Built-in | | Learning | Easy | Easy |
Demonstration
Example 1: Shelf Basics
import 'dart:convert';
import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
import 'package:shelf_router/shelf_router.dart';
// Models
class User {
final int id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'email': email,
};
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'],
name: json['name'],
email: json['email'],
);
}
// In-memory store
final users = <int, User>{};
var nextId = 1;
// Handlers
Response listUsers(Request request) {
final userList = users.values.map((u) => u.toJson()).toList();
return Response.ok(
jsonEncode({'data': userList}),
headers: {'content-type': 'application/json'},
);
}
Response getUser(Request request, String id) {
final userId = int.tryParse(id);
if (userId == null || !users.containsKey(userId)) {
return Response.notFound(
jsonEncode({'error': 'User not found'}),
headers: {'content-type': 'application/json'},
);
}
return Response.ok(
jsonEncode({'data': users[userId]!.toJson()}),
headers: {'content-type': 'application/json'},
);
}
Future<Response> createUser(Request request) async {
try {
final body = await request.readAsString();
final data = jsonDecode(body) as Map<String, dynamic>;
if (data['name'] == null || data['email'] == null) {
return Response(
400,
body: jsonEncode({'error': 'Name and email required'}),
headers: {'content-type': 'application/json'},
);
}
final user = User(
id: nextId++,
name: data['name'],
email: data['email'],
);
users[user.id] = user;
return Response(
201,
body: jsonEncode({'data': user.toJson()}),
headers: {'content-type': 'application/json'},
);
} catch (e) {
return Response(
400,
body: jsonEncode({'error': 'Invalid JSON'}),
headers: {'content-type': 'application/json'},
);
}
}
Future<Response> updateUser(Request request, String id) async {
final userId = int.tryParse(id);
if (userId == null || !users.containsKey(userId)) {
return Response.notFound(
jsonEncode({'error': 'User not found'}),
headers: {'content-type': 'application/json'},
);
}
final body = await request.readAsString();
final data = jsonDecode(body) as Map<String, dynamic>;
final existing = users[userId]!;
final updated = User(
id: userId,
name: data['name'] ?? existing.name,
email: data['email'] ?? existing.email,
);
users[userId] = updated;
return Response.ok(
jsonEncode({'data': updated.toJson()}),
headers: {'content-type': 'application/json'},
);
}
Response deleteUser(Request request, String id) {
final userId = int.tryParse(id);
if (userId == null || !users.containsKey(userId)) {
return Response.notFound(
jsonEncode({'error': 'User not found'}),
headers: {'content-type': 'application/json'},
);
}
users.remove(userId);
return Response(204);
}
void main() async {
final router = Router()
..get('/users', listUsers)
..get('/users/<id>', getUser)
..post('/users', createUser)
..put('/users/<id>', updateUser)
..delete('/users/<id>', deleteUser);
final handler = const Pipeline()
.addMiddleware(logRequests())
.addMiddleware(corsMiddleware())
.addHandler(router);
final server = await io.serve(handler, 'localhost', 8080);
print('Server running on http://${server.address.host}:${server.port}');
}
// CORS Middleware
Middleware corsMiddleware() {
return (Handler innerHandler) {
return (Request request) async {
if (request.method == 'OPTIONS') {
return Response.ok('', headers: _corsHeaders);
}
final response = await innerHandler(request);
return response.change(headers: _corsHeaders);
};
};
}
const _corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
Example 2: Dart Frog
// routes/index.dart
import 'package:dart_frog/dart_frog.dart';
Response onRequest(RequestContext context) {
return Response.json({'message': 'Welcome to the API'});
}
// routes/users/index.dart
import 'package:dart_frog/dart_frog.dart';
Future<Response> onRequest(RequestContext context) async {
final userRepository = context.read<UserRepository>();
return switch (context.request.method) {
HttpMethod.get => _listUsers(context, userRepository),
HttpMethod.post => _createUser(context, userRepository),
_ => Response(statusCode: 405),
};
}
Response _listUsers(RequestContext context, UserRepository repo) {
final users = repo.findAll();
return Response.json({'data': users.map((u) => u.toJson()).toList()});
}
Future<Response> _createUser(
RequestContext context,
UserRepository repo,
) async {
final body = await context.request.json() as Map<String, dynamic>;
if (body['name'] == null || body['email'] == null) {
return Response.json(
{'error': 'Name and email required'},
statusCode: 400,
);
}
final user = repo.create(
name: body['name'] as String,
email: body['email'] as String,
);
return Response.json({'data': user.toJson()}, statusCode: 201);
}
// routes/users/[id].dart
import 'package:dart_frog/dart_frog.dart';
Future<Response> onRequest(RequestContext context, String id) async {
final userRepository = context.read<UserRepository>();
final userId = int.tryParse(id);
if (userId == null) {
return Response.json({'error': 'Invalid ID'}, statusCode: 400);
}
return switch (context.request.method) {
HttpMethod.get => _getUser(userRepository, userId),
HttpMethod.put => _updateUser(context, userRepository, userId),
HttpMethod.delete => _deleteUser(userRepository, userId),
_ => Response(statusCode: 405),
};
}
Response _getUser(UserRepository repo, int id) {
final user = repo.findById(id);
if (user == null) {
return Response.json({'error': 'User not found'}, statusCode: 404);
}
return Response.json({'data': user.toJson()});
}
Future<Response> _updateUser(
RequestContext context,
UserRepository repo,
int id,
) async {
final body = await context.request.json() as Map<String, dynamic>;
final user = repo.update(id, body);
if (user == null) {
return Response.json({'error': 'User not found'}, statusCode: 404);
}
return Response.json({'data': user.toJson()});
}
Response _deleteUser(UserRepository repo, int id) {
final deleted = repo.delete(id);
if (!deleted) {
return Response.json({'error': 'User not found'}, statusCode: 404);
}
return Response(statusCode: 204);
}
// main.dart
import 'package:dart_frog/dart_frog.dart';
Handler middleware(Handler handler) {
return handler
.use(requestLogger())
.use(provider<UserRepository>((_) => UserRepository()));
}
Example 3: Middleware and Dependencies
import 'package:shelf/shelf.dart';
// Logging middleware
Middleware logRequests() {
return (Handler innerHandler) {
return (Request request) async {
final stopwatch = Stopwatch()..start();
final response = await innerHandler(request);
stopwatch.stop();
print('[${response.statusCode}] ${request.method} ${request.url} - ${stopwatch.elapsedMilliseconds}ms');
return response;
};
};
}
// Auth middleware
Middleware authMiddleware() {
return (Handler innerHandler) {
return (Request request) async {
// Skip auth for public routes
if (request.url.path.startsWith('public/')) {
return innerHandler(request);
}
final authHeader = request.headers['authorization'];
if (authHeader == null || !authHeader.startsWith('Bearer ')) {
return Response(
401,
body: jsonEncode({'error': 'Unauthorized'}),
headers: {'content-type': 'application/json'},
);
}
final token = authHeader.substring(7);
try {
final userId = validateToken(token);
// Add user to request context
final updatedRequest = request.change(
context: {...request.context, 'userId': userId},
);
return innerHandler(updatedRequest);
} catch (e) {
return Response(
401,
body: jsonEncode({'error': 'Invalid token'}),
headers: {'content-type': 'application/json'},
);
}
};
};
}
int validateToken(String token) {
// Validate JWT and return user ID
return 1;
}
// Rate limiting middleware
Middleware rateLimiter({int maxRequests = 100, Duration window = const Duration(minutes: 1)}) {
final requests = <String, List<DateTime>>{};
return (Handler innerHandler) {
return (Request request) async {
final ip = request.headers['x-forwarded-for'] ?? 'unknown';
final now = DateTime.now();
final windowStart = now.subtract(window);
// Clean old requests
requests[ip] = (requests[ip] ?? [])
.where((time) => time.isAfter(windowStart))
.toList();
if (requests[ip]!.length >= maxRequests) {
return Response(
429,
body: jsonEncode({'error': 'Rate limit exceeded'}),
headers: {'content-type': 'application/json'},
);
}
requests[ip]!.add(now);
return innerHandler(request);
};
};
}
// Compose middleware
void main() async {
final handler = const Pipeline()
.addMiddleware(logRequests())
.addMiddleware(rateLimiter())
.addMiddleware(corsMiddleware())
.addMiddleware(authMiddleware())
.addHandler(router);
await io.serve(handler, 'localhost', 8080);
}
Key Takeaways:
- Shelf provides flexible middleware composition
- Dart Frog offers file-based routing
- Both are type-safe and fast
- Share code between frontend and backend
- Middleware handles cross-cutting concerns
Imitation
Challenge 1: Add Validation
Task: Create a validation middleware for request bodies.
Solution
typedef Validator = String? Function(dynamic value);
class ValidationSchema {
final Map<String, List<Validator>> rules;
ValidationSchema(this.rules);
Map<String, List<String>>? validate(Map<String, dynamic> data) {
final errors = <String, List<String>>{};
for (final field in rules.keys) {
final value = data[field];
final fieldErrors = <String>[];
for (final validator in rules[field]!) {
final error = validator(value);
if (error != null) fieldErrors.add(error);
}
if (fieldErrors.isNotEmpty) {
errors[field] = fieldErrors;
}
}
return errors.isEmpty ? null : errors;
}
}
// Validators
Validator required() => (value) =>
value == null || (value is String && value.isEmpty)
? 'This field is required'
: null;
Validator minLength(int min) => (value) =>
value is String && value.length < min
? 'Must be at least $min characters'
: null;
Validator email() => (value) =>
value is String && !RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)
? 'Invalid email address'
: null;
// Usage
final userSchema = ValidationSchema({
'name': [required(), minLength(2)],
'email': [required(), email()],
});
Future<Response> createUser(Request request) async {
final body = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
final errors = userSchema.validate(body);
if (errors != null) {
return Response(
400,
body: jsonEncode({'error': 'Validation failed', 'details': errors}),
headers: {'content-type': 'application/json'},
);
}
// Create user...
}
Practice
Exercise 1: Build a Todo API
Difficulty: Intermediate
Create a complete Todo API with Dart Frog:
- CRUD operations
- User authentication
- Todo ownership
Exercise 2: WebSocket Chat
Difficulty: Advanced
Implement real-time chat:
- WebSocket connections
- Room-based messaging
- User presence
Summary
What you learned:
- Shelf for minimal APIs
- Dart Frog for full-featured APIs
- Middleware composition
- Request validation
- Authentication patterns
Next Steps:
- Read: Dart OOP
- Practice: Build a REST API
- Explore: Share code with Flutter
