Dart API Development

Building APIs with Dart and Shelf

2026-02-01

Explanation

Dart for Backend Development

Dart can power server-side applications with frameworks like Shelf, Angel, and Aqueduct. Its async/await model makes it excellent for handling concurrent requests.

Framework Comparison

| Framework | Type | Best For | |-----------|------|----------| | Shelf | Minimal | Microservices | | Dart Frog | Modern | APIs | | Angel3 | Full-featured | Large apps | | Serverpod | Full-stack | Flutter apps |


Demonstration

Example 1: Shelf Basics

import 'dart:convert';
import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_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'] as int,
    name: json['name'] as String,
    email: json['email'] as String,
  );
}

// In-memory storage
final users = <int, User>{};
var nextId = 1;

// Handlers
Response _jsonResponse(Object data, {int statusCode = 200}) {
  return Response(
    statusCode,
    body: jsonEncode(data),
    headers: {'content-type': 'application/json'},
  );
}

Future<Response> _listUsers(Request request) async {
  final userList = users.values.map((u) => u.toJson()).toList();
  return _jsonResponse({'data': userList});
}

Future<Response> _getUser(Request request, String id) async {
  final userId = int.tryParse(id);
  if (userId == null) {
    return _jsonResponse({'error': 'Invalid ID'}, statusCode: 400);
  }

  final user = users[userId];
  if (user == null) {
    return _jsonResponse({'error': 'User not found'}, statusCode: 404);
  }

  return _jsonResponse({'data': user.toJson()});
}

Future<Response> _createUser(Request request) async {
  final body = await request.readAsString();
  final json = jsonDecode(body) as Map<String, dynamic>;

  final user = User(
    id: nextId++,
    name: json['name'] as String,
    email: json['email'] as String,
  );

  users[user.id] = user;

  return _jsonResponse({'data': user.toJson()}, statusCode: 201);
}

Future<Response> _updateUser(Request request, String id) async {
  final userId = int.tryParse(id);
  if (userId == null || !users.containsKey(userId)) {
    return _jsonResponse({'error': 'User not found'}, statusCode: 404);
  }

  final body = await request.readAsString();
  final json = jsonDecode(body) as Map<String, dynamic>;

  final user = User(
    id: userId,
    name: json['name'] as String,
    email: json['email'] as String,
  );

  users[userId] = user;

  return _jsonResponse({'data': user.toJson()});
}

Future<Response> _deleteUser(Request request, String id) async {
  final userId = int.tryParse(id);
  if (userId == null || !users.containsKey(userId)) {
    return _jsonResponse({'error': 'User not found'}, statusCode: 404);
  }

  users.remove(userId);

  return Response(204);
}

// Router setup
Router createRouter() {
  final router = Router();

  router.get('/api/users', _listUsers);
  router.get('/api/users/<id>', _getUser);
  router.post('/api/users', _createUser);
  router.put('/api/users/<id>', _updateUser);
  router.delete('/api/users/<id>', _deleteUser);

  return router;
}

void main() async {
  final router = createRouter();
  final handler = Pipeline()
      .addMiddleware(logRequests())
      .addHandler(router);

  final server = await shelf_io.serve(handler, 'localhost', 8080);
  print('Server running on http://${server.address.host}:${server.port}');
}

Example 2: Middleware and Error Handling

import 'dart:convert';
import 'package:shelf/shelf.dart';

// Custom middleware
Middleware corsMiddleware() {
  return (Handler handler) {
    return (Request request) async {
      if (request.method == 'OPTIONS') {
        return Response.ok('', headers: _corsHeaders);
      }

      final response = await handler(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': 'Origin, Content-Type, Authorization',
};

// Authentication middleware
Middleware authMiddleware() {
  return (Handler handler) {
    return (Request request) async {
      // Skip auth for public routes
      if (request.url.path.startsWith('api/public/')) {
        return handler(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);
      final user = await validateToken(token);

      if (user == null) {
        return Response(401,
          body: jsonEncode({'error': 'Invalid token'}),
          headers: {'content-type': 'application/json'},
        );
      }

      // Add user to request context
      final updatedRequest = request.change(context: {
        'user': user,
        ...request.context,
      });

      return handler(updatedRequest);
    };
  };
}

// Error handling middleware
Middleware errorHandler() {
  return (Handler handler) {
    return (Request request) async {
      try {
        return await handler(request);
      } on FormatException catch (e) {
        return Response(400,
          body: jsonEncode({'error': 'Invalid request format: ${e.message}'}),
          headers: {'content-type': 'application/json'},
        );
      } on NotFoundException catch (e) {
        return Response(404,
          body: jsonEncode({'error': e.message}),
          headers: {'content-type': 'application/json'},
        );
      } catch (e, stackTrace) {
        print('Error: $e\n$stackTrace');
        return Response(500,
          body: jsonEncode({'error': 'Internal server error'}),
          headers: {'content-type': 'application/json'},
        );
      }
    };
  };
}

// Custom exceptions
class NotFoundException implements Exception {
  final String message;
  NotFoundException(this.message);
}

class ValidationException implements Exception {
  final Map<String, String> errors;
  ValidationException(this.errors);
}

// Logging middleware
Middleware requestLogger() {
  return (Handler handler) {
    return (Request request) async {
      final stopwatch = Stopwatch()..start();

      final response = await handler(request);

      stopwatch.stop();
      print('${request.method} ${request.url} - '
            '${response.statusCode} - ${stopwatch.elapsedMilliseconds}ms');

      return response;
    };
  };
}

// Pipeline setup
Handler createHandler(Router router) {
  return Pipeline()
      .addMiddleware(requestLogger())
      .addMiddleware(corsMiddleware())
      .addMiddleware(errorHandler())
      .addMiddleware(authMiddleware())
      .addHandler(router);
}

Example 3: Service Layer Pattern

// Repository interface
abstract class Repository<T, ID> {
  Future<T?> findById(ID id);
  Future<List<T>> findAll();
  Future<T> save(T entity);
  Future<void> delete(ID id);
}

// User repository
class UserRepository implements Repository<User, int> {
  final Map<int, User> _storage = {};
  int _nextId = 1;

  @override
  Future<User?> findById(int id) async => _storage[id];

  @override
  Future<List<User>> findAll() async => _storage.values.toList();

  @override
  Future<User> save(User user) async {
    final id = user.id == 0 ? _nextId++ : user.id;
    final savedUser = User(id: id, name: user.name, email: user.email);
    _storage[id] = savedUser;
    return savedUser;
  }

  @override
  Future<void> delete(int id) async => _storage.remove(id);

  Future<User?> findByEmail(String email) async {
    return _storage.values.cast<User?>().firstWhere(
      (u) => u?.email == email,
      orElse: () => null,
    );
  }
}

// Service layer
class UserService {
  final UserRepository _repository;

  UserService(this._repository);

  Future<List<User>> getAllUsers() => _repository.findAll();

  Future<User> getUserById(int id) async {
    final user = await _repository.findById(id);
    if (user == null) {
      throw NotFoundException('User not found');
    }
    return user;
  }

  Future<User> createUser(CreateUserDto dto) async {
    // Validation
    final errors = <String, String>{};
    if (dto.name.isEmpty) errors['name'] = 'Name is required';
    if (!dto.email.contains('@')) errors['email'] = 'Invalid email';

    if (errors.isNotEmpty) {
      throw ValidationException(errors);
    }

    // Check for duplicate email
    final existing = await _repository.findByEmail(dto.email);
    if (existing != null) {
      throw ValidationException({'email': 'Email already in use'});
    }

    final user = User(id: 0, name: dto.name, email: dto.email);
    return _repository.save(user);
  }

  Future<User> updateUser(int id, UpdateUserDto dto) async {
    final existing = await _repository.findById(id);
    if (existing == null) {
      throw NotFoundException('User not found');
    }

    final user = User(
      id: id,
      name: dto.name ?? existing.name,
      email: dto.email ?? existing.email,
    );

    return _repository.save(user);
  }

  Future<void> deleteUser(int id) async {
    final existing = await _repository.findById(id);
    if (existing == null) {
      throw NotFoundException('User not found');
    }
    await _repository.delete(id);
  }
}

// DTOs
class CreateUserDto {
  final String name;
  final String email;

  CreateUserDto({required this.name, required this.email});

  factory CreateUserDto.fromJson(Map<String, dynamic> json) => CreateUserDto(
    name: json['name'] as String? ?? '',
    email: json['email'] as String? ?? '',
  );
}

class UpdateUserDto {
  final String? name;
  final String? email;

  UpdateUserDto({this.name, this.email});

  factory UpdateUserDto.fromJson(Map<String, dynamic> json) => UpdateUserDto(
    name: json['name'] as String?,
    email: json['email'] as String?,
  );
}

Example 4: Dart Frog Framework

// 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 'dart:io';
import 'package:dart_frog/dart_frog.dart';

Future<Response> onRequest(RequestContext context) async {
  final userService = context.read<UserService>();

  switch (context.request.method) {
    case HttpMethod.get:
      final users = await userService.getAllUsers();
      return Response.json({'data': users.map((u) => u.toJson()).toList()});

    case HttpMethod.post:
      final body = await context.request.json() as Map<String, dynamic>;
      final dto = CreateUserDto.fromJson(body);
      final user = await userService.createUser(dto);
      return Response.json(
        {'data': user.toJson()},
        statusCode: HttpStatus.created,
      );

    default:
      return Response(statusCode: HttpStatus.methodNotAllowed);
  }
}

// routes/users/[id].dart
import 'dart:io';
import 'package:dart_frog/dart_frog.dart';

Future<Response> onRequest(RequestContext context, String id) async {
  final userService = context.read<UserService>();
  final userId = int.tryParse(id);

  if (userId == null) {
    return Response.json(
      {'error': 'Invalid ID'},
      statusCode: HttpStatus.badRequest,
    );
  }

  switch (context.request.method) {
    case HttpMethod.get:
      try {
        final user = await userService.getUserById(userId);
        return Response.json({'data': user.toJson()});
      } on NotFoundException {
        return Response.json(
          {'error': 'User not found'},
          statusCode: HttpStatus.notFound,
        );
      }

    case HttpMethod.put:
      final body = await context.request.json() as Map<String, dynamic>;
      final dto = UpdateUserDto.fromJson(body);
      final user = await userService.updateUser(userId, dto);
      return Response.json({'data': user.toJson()});

    case HttpMethod.delete:
      await userService.deleteUser(userId);
      return Response(statusCode: HttpStatus.noContent);

    default:
      return Response(statusCode: HttpStatus.methodNotAllowed);
  }
}

// main.dart (middleware setup)
import 'package:dart_frog/dart_frog.dart';

Handler middleware(Handler handler) {
  return handler
      .use(requestLogger())
      .use(provider<UserService>((context) => UserService(UserRepository())));
}

Example 5: Database Integration

import 'package:postgres/postgres.dart';

// Database connection
class Database {
  late PostgreSQLConnection _connection;

  Future<void> connect() async {
    _connection = PostgreSQLConnection(
      'localhost',
      5432,
      'mydb',
      username: 'user',
      password: 'password',
    );
    await _connection.open();
  }

  PostgreSQLConnection get connection => _connection;

  Future<void> close() => _connection.close();
}

// User repository with PostgreSQL
class PostgresUserRepository implements Repository<User, int> {
  final Database _db;

  PostgresUserRepository(this._db);

  @override
  Future<User?> findById(int id) async {
    final results = await _db.connection.query(
      'SELECT id, name, email, created_at FROM users WHERE id = @id',
      substitutionValues: {'id': id},
    );

    if (results.isEmpty) return null;

    return _mapRow(results.first);
  }

  @override
  Future<List<User>> findAll() async {
    final results = await _db.connection.query(
      'SELECT id, name, email, created_at FROM users ORDER BY created_at DESC',
    );

    return results.map(_mapRow).toList();
  }

  @override
  Future<User> save(User user) async {
    if (user.id == 0) {
      // Insert
      final results = await _db.connection.query(
        '''
        INSERT INTO users (name, email)
        VALUES (@name, @email)
        RETURNING id, name, email, created_at
        ''',
        substitutionValues: {
          'name': user.name,
          'email': user.email,
        },
      );
      return _mapRow(results.first);
    } else {
      // Update
      final results = await _db.connection.query(
        '''
        UPDATE users
        SET name = @name, email = @email
        WHERE id = @id
        RETURNING id, name, email, created_at
        ''',
        substitutionValues: {
          'id': user.id,
          'name': user.name,
          'email': user.email,
        },
      );
      return _mapRow(results.first);
    }
  }

  @override
  Future<void> delete(int id) async {
    await _db.connection.query(
      'DELETE FROM users WHERE id = @id',
      substitutionValues: {'id': id},
    );
  }

  User _mapRow(PostgreSQLResultRow row) {
    return User(
      id: row[0] as int,
      name: row[1] as String,
      email: row[2] as String,
    );
  }
}

Example 6: Testing

import 'package:test/test.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';

void main() {
  late UserRepository repository;
  late UserService service;
  late Router router;

  setUp(() {
    repository = UserRepository();
    service = UserService(repository);
    router = createRouter(service);
  });

  group('User API', () {
    test('GET /api/users returns empty list initially', () async {
      final request = Request('GET', Uri.parse('http://localhost/api/users'));
      final response = await router(request);

      expect(response.statusCode, equals(200));

      final body = jsonDecode(await response.readAsString());
      expect(body['data'], isEmpty);
    });

    test('POST /api/users creates a user', () async {
      final request = Request(
        'POST',
        Uri.parse('http://localhost/api/users'),
        body: jsonEncode({'name': 'Arthur', 'email': 'art@bpc.com'}),
        headers: {'content-type': 'application/json'},
      );
      final response = await router(request);

      expect(response.statusCode, equals(201));

      final body = jsonDecode(await response.readAsString());
      expect(body['data']['name'], equals('Arthur'));
      expect(body['data']['id'], isNotNull);
    });

    test('GET /api/users/:id returns user', () async {
      // Create user first
      await service.createUser(CreateUserDto(name: 'Test', email: 'test@example.com'));

      final request = Request('GET', Uri.parse('http://localhost/api/users/1'));
      final response = await router(request);

      expect(response.statusCode, equals(200));
    });

    test('GET /api/users/:id returns 404 for missing user', () async {
      final request = Request('GET', Uri.parse('http://localhost/api/users/999'));
      final response = await router(request);

      expect(response.statusCode, equals(404));
    });

    test('DELETE /api/users/:id removes user', () async {
      await service.createUser(CreateUserDto(name: 'Test', email: 'test@example.com'));

      final request = Request('DELETE', Uri.parse('http://localhost/api/users/1'));
      final response = await router(request);

      expect(response.statusCode, equals(204));

      // Verify deletion
      final users = await service.getAllUsers();
      expect(users, isEmpty);
    });
  });

  group('UserService', () {
    test('createUser validates email', () async {
      expect(
        () => service.createUser(CreateUserDto(name: 'Test', email: 'invalid')),
        throwsA(isA<ValidationException>()),
      );
    });

    test('createUser prevents duplicate emails', () async {
      await service.createUser(CreateUserDto(name: 'First', email: 'test@example.com'));

      expect(
        () => service.createUser(CreateUserDto(name: 'Second', email: 'test@example.com')),
        throwsA(isA<ValidationException>()),
      );
    });
  });
}

Key Takeaways:

  • Shelf provides minimal, flexible API building
  • Middleware pattern for cross-cutting concerns
  • Service layer for business logic
  • Dart Frog offers file-based routing
  • Testing is straightforward with Dart

Imitation

Challenge 1: Build a Todo API

Task: Create a complete CRUD API for todos with filtering.

Solution

import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';

class Todo {
  final int id;
  String title;
  bool completed;

  Todo({required this.id, required this.title, this.completed = false});

  Map<String, dynamic> toJson() => {
    'id': id,
    'title': title,
    'completed': completed,
  };
}

class TodoService {
  final _todos = <int, Todo>{};
  int _nextId = 1;

  List<Todo> getAll({bool? completed}) {
    var todos = _todos.values.toList();
    if (completed != null) {
      todos = todos.where((t) => t.completed == completed).toList();
    }
    return todos;
  }

  Todo? getById(int id) => _todos[id];

  Todo create(String title) {
    final todo = Todo(id: _nextId++, title: title);
    _todos[todo.id] = todo;
    return todo;
  }

  Todo? update(int id, {String? title, bool? completed}) {
    final todo = _todos[id];
    if (todo == null) return null;

    if (title != null) todo.title = title;
    if (completed != null) todo.completed = completed;

    return todo;
  }

  bool delete(int id) => _todos.remove(id) != null;
}

Router createTodoRouter(TodoService service) {
  final router = Router();

  router.get('/api/todos', (Request request) {
    final completedParam = request.url.queryParameters['completed'];
    final completed = completedParam == null
        ? null
        : completedParam == 'true';

    final todos = service.getAll(completed: completed);
    return Response.ok(
      jsonEncode({'data': todos.map((t) => t.toJson()).toList()}),
      headers: {'content-type': 'application/json'},
    );
  });

  router.post('/api/todos', (Request request) async {
    final body = jsonDecode(await request.readAsString());
    final todo = service.create(body['title']);
    return Response(201,
      body: jsonEncode({'data': todo.toJson()}),
      headers: {'content-type': 'application/json'},
    );
  });

  router.put('/api/todos/<id>', (Request request, String id) async {
    final todoId = int.tryParse(id);
    if (todoId == null) {
      return Response(400, body: jsonEncode({'error': 'Invalid ID'}));
    }

    final body = jsonDecode(await request.readAsString());
    final todo = service.update(
      todoId,
      title: body['title'],
      completed: body['completed'],
    );

    if (todo == null) {
      return Response(404, body: jsonEncode({'error': 'Not found'}));
    }

    return Response.ok(
      jsonEncode({'data': todo.toJson()}),
      headers: {'content-type': 'application/json'},
    );
  });

  router.delete('/api/todos/<id>', (Request request, String id) {
    final todoId = int.tryParse(id);
    if (todoId == null || !service.delete(todoId)) {
      return Response(404);
    }
    return Response(204);
  });

  return router;
}


Practice

Exercise 1: Add Authentication

Difficulty: Intermediate

Add JWT authentication:

  • Login endpoint
  • Auth middleware
  • Protected routes

Exercise 2: Add WebSocket Support

Difficulty: Advanced

Implement real-time updates:

  • WebSocket connections
  • Broadcast changes
  • Connection management

Summary

What you learned:

  • Shelf for API development
  • Middleware patterns
  • Service layer architecture
  • Database integration
  • Testing APIs

Next Steps:

  • Read: Dart OOP
  • Practice: Build a Flutter app with this API
  • Explore: Serverpod

Resources