dartapi_core 0.4.0 copy "dartapi_core: ^0.4.0" to clipboard
dartapi_core: ^0.4.0 copied to clipboard

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

example/dartapi_core_example.dart

/// A self-contained Books API showcasing the core features of dartapi_core:
///
///   ● [DartAPI] server with middleware pipeline
///   ● [ServiceRegistry] for dependency injection
///   ● JWT auth ([JwtService], [authMiddleware], [InMemoryTokenStore])
///   ● [BaseController] with typed [ApiRoute]s, DTO validation, and caching
///   ● [Pagination] and [PaginatedResponse]
///   ● Background tasks (post-response async work)
///   ● [InlineController] for one-off routes
///   ● [AppConfig] / [loadEnvFile] for environment config
///   ● [ApiException] for typed HTTP errors
///   ● Health check, Prometheus metrics, OpenAPI docs
///
/// Run:
///   dart example/dartapi_core_example.dart
///
/// Open:
///   http://localhost:8080/docs     — Swagger UI
///   http://localhost:8080/health   — health check
///   http://localhost:8080/books    — public book list
///
/// Login (returns JWT):
///   POST /auth/login  {"email":"demo@example.com","password":"demo1234"}
// ignore_for_file: avoid_print
library;

import 'dart:io';
import 'package:dartapi_core/dartapi_core.dart';

// ── Domain model ──────────────────────────────────────────────────────────────

class Book implements Serializable {
  final int id;
  final String title;
  final String author;
  final int year;

  const Book({
    required this.id,
    required this.title,
    required this.author,
    required this.year,
  });

  @override
  Map<String, dynamic> toJson() => {
    'id': id,
    'title': title,
    'author': author,
    'year': year,
  };

  static const schema = {
    'type': 'object',
    'properties': {
      'id': {'type': 'integer'},
      'title': {'type': 'string'},
      'author': {'type': 'string'},
      'year': {'type': 'integer'},
    },
  };
}

// ── DTO with validation ───────────────────────────────────────────────────────

class BookDTO {
  final String title;
  final String author;
  final int year;

  const BookDTO({
    required this.title,
    required this.author,
    required this.year,
  });

  /// Parses and validates the request body.
  ///
  /// Throws [ApiException(422)] with all field errors collected before throwing
  /// (no short-circuit — the caller sees every invalid field at once).
  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,
    );
  }

  static const schema = {
    'type': 'object',
    'required': ['title', 'author', 'year'],
    'properties': {
      'title': {'type': 'string', 'maxLength': 200, 'example': 'Clean Code'},
      'author': {'type': 'string', 'example': 'Robert C. Martin'},
      'year': {'type': 'integer', 'example': 2008},
    },
  };
}

// ── Repository (in-memory) ────────────────────────────────────────────────────

class BookRepository {
  final List<Book> _books = [
    const Book(
      id: 1,
      title: 'Clean Code',
      author: 'Robert C. Martin',
      year: 2008,
    ),
    const Book(
      id: 2,
      title: 'The Pragmatic Programmer',
      author: 'David Thomas',
      year: 1999,
    ),
    const Book(id: 3, title: 'Design Patterns', author: 'GoF', year: 1994),
    const Book(
      id: 4,
      title: 'Refactoring',
      author: 'Martin Fowler',
      year: 1999,
    ),
    const Book(
      id: 5,
      title: 'Domain-Driven Design',
      author: 'Eric Evans',
      year: 2003,
    ),
  ];
  int _nextId = 6;

  List<Book> getAll({required int page, required int limit}) {
    final offset = (page - 1) * limit;
    return _books.skip(offset).take(limit).toList();
  }

  int get total => _books.length;

  Book? getById(int id) => _books.where((b) => b.id == id).firstOrNull;

  Book create(BookDTO dto) {
    final book = Book(
      id: _nextId++,
      title: dto.title,
      author: dto.author,
      year: dto.year,
    );
    _books.add(book);
    return book;
  }

  Book? update(int id, BookDTO dto) {
    final idx = _books.indexWhere((b) => b.id == id);
    if (idx == -1) return null;
    final updated = Book(
      id: id,
      title: dto.title,
      author: dto.author,
      year: dto.year,
    );
    _books[idx] = updated;
    return updated;
  }

  bool delete(int id) {
    final idx = _books.indexWhere((b) => b.id == id);
    if (idx == -1) return false;
    _books.removeAt(idx);
    return true;
  }
}

// ── Service ───────────────────────────────────────────────────────────────────

class BookService {
  final BookRepository _repo;
  BookService(this._repo);

  ({List<Book> books, int total}) list({
    required int page,
    required int limit,
  }) => (books: _repo.getAll(page: page, limit: limit), total: _repo.total);

  Book get(int id) =>
      _repo.getById(id) ?? (throw const ApiException(404, 'Book not found'));

  Book create(BookDTO dto) => _repo.create(dto);

  Book update(int id, BookDTO dto) =>
      _repo.update(id, dto) ??
      (throw const ApiException(404, 'Book not found'));

  void delete(int id) {
    if (!_repo.delete(id)) throw const ApiException(404, 'Book not found');
  }
}

// ── Controller ────────────────────────────────────────────────────────────────

class BookController extends BaseController {
  final BookService _service;
  final JwtService _jwt;

  BookController({required BookService service, required JwtService jwt})
    : _service = service,
      _jwt = jwt;

  @override
  List<ApiRoute> get routes => [
    // ── GET /books — public, paginated, cached ───────────────────────────
    ApiRoute<void, PaginatedResponse>(
      method: ApiMethod.get,
      path: '/books',
      summary: 'List books',
      description:
          'Public paginated list. Supports `?page` and `?limit` (max 50). '
          'Response cached for 30 seconds.',
      cacheTtl: const Duration(seconds: 30),
      responseSchema: {
        'type': 'object',
        'properties': {
          'data': {'type': 'array', 'items': Book.schema},
          'meta': {
            'type': 'object',
            'properties': {
              'page': {'type': 'integer'},
              'limit': {'type': 'integer'},
              'total': {'type': 'integer'},
              'totalPages': {'type': 'integer'},
              'hasNext': {'type': 'boolean'},
              'hasPrev': {'type': 'boolean'},
            },
          },
        },
      },
      typedHandler: (req, _) async {
        final p = Pagination.fromRequest(req, defaultLimit: 3, maxLimit: 50);
        final (:books, :total) = _service.list(page: p.page, limit: p.limit);
        return PaginatedResponse(data: books, pagination: p, total: total);
      },
    ),

    // ── GET /books/:id — requires auth ───────────────────────────────────
    ApiRoute<void, Book>(
      method: ApiMethod.get,
      path: '/books/<id>',
      summary: 'Get book',
      description:
          'Returns a single book by ID. Requires a valid Bearer token.',
      middlewares: [authMiddleware(_jwt)],
      security: [SecurityScheme.bearer],
      responseSchema: Book.schema,
      typedHandler: (req, _) async => _service.get(req.pathParam<int>('id')),
    ),

    // ── POST /books — requires auth, fires background task ───────────────
    ApiRoute<BookDTO, Book>(
      method: ApiMethod.post,
      path: '/books',
      statusCode: 201,
      summary: 'Create book',
      description:
          'Creates a new book. Requires auth. '
          'A background task fires after the response is sent '
          '(simulates an audit log entry).',
      dtoParser: BookDTO.fromJson,
      requestSchema: BookDTO.schema,
      responseSchema: Book.schema,
      middlewares: [authMiddleware(_jwt)],
      security: [SecurityScheme.bearer],
      typedHandler: (req, dto) async {
        final book = _service.create(dto!);
        req.backgroundTasks.add(() async {
          // Runs after the 201 response is delivered to the client.
          print('[audit] Book created: id=${book.id} "${book.title}"');
        });
        return book;
      },
    ),

    // ── PUT /books/:id — requires auth ───────────────────────────────────
    ApiRoute<BookDTO, Book>(
      method: ApiMethod.put,
      path: '/books/<id>',
      summary: 'Update book',
      description: 'Replaces all fields of an existing book.',
      dtoParser: BookDTO.fromJson,
      requestSchema: BookDTO.schema,
      responseSchema: Book.schema,
      middlewares: [authMiddleware(_jwt)],
      security: [SecurityScheme.bearer],
      typedHandler:
          (req, dto) async => _service.update(req.pathParam<int>('id'), dto!),
    ),

    // ── DELETE /books/:id — requires auth ────────────────────────────────
    ApiRoute<void, void>(
      method: ApiMethod.delete,
      path: '/books/<id>',
      statusCode: 204,
      summary: 'Delete book',
      description: 'Permanently removes a book. Returns 204 on success.',
      middlewares: [authMiddleware(_jwt)],
      security: [SecurityScheme.bearer],
      typedHandler: (req, _) async => _service.delete(req.pathParam<int>('id')),
    ),
  ];
}

// ── Minimal hardcoded user store (replace with a real DB in production) ───────

const _demoCredentials = {'email': 'demo@example.com', 'password': 'demo1234'};

Map<String, dynamic>? _authenticate(String email, String password) {
  if (email == _demoCredentials['email'] &&
      password == _demoCredentials['password']) {
    return {'sub': '1', 'email': email, 'role': 'user'};
  }
  return null;
}

// ── Entry point ───────────────────────────────────────────────────────────────

Future<void> main() async {
  // Load .env files — gracefully ignored when files don't exist.
  final env = mergeEnv([loadEnvFile('env/.env'), loadEnvFile('env/.env.dev')]);
  final config = AppConfig(environment: env);

  final app = DartAPI(appName: 'books-api', corsOrigin: config.corsOrigin);

  // ── Dependency injection via ServiceRegistry ──────────────────────────────
  //
  // Registrations are lazy singletons — constructed on the first get<T>() call.
  // The factory receives the registry so it can resolve sub-dependencies.

  app.register<BookRepository>((_) => BookRepository());

  app.register<BookService>((r) => BookService(r.get<BookRepository>()));

  app.register<InMemoryTokenStore>((_) => InMemoryTokenStore());

  app.register<JwtService>(
    (r) => JwtService(
      accessTokenSecret: config.jwtAccessSecret,
      refreshTokenSecret: config.jwtRefreshSecret,
      issuer: 'books-api',
      audience: 'books-api-users',
      tokenStore: r.get<InMemoryTokenStore>(),
    ),
  );

  // ── Middleware pipeline ───────────────────────────────────────────────────
  app.enableCompression();
  app.enableBackgroundTasks();
  app.enableTimeout(const Duration(seconds: 30));
  app.enableRateLimit(maxRequests: 100, window: const Duration(minutes: 1));

  // ── Auth routes (inline — no dedicated controller needed) ─────────────────
  final jwt = app.get<JwtService>();

  app.addControllers([
    InlineController([
      // POST /auth/login — returns access + refresh tokens
      ApiRoute<Map<String, dynamic>, Map<String, String>>(
        method: ApiMethod.post,
        path: '/auth/login',
        summary: 'Login',
        description:
            'Returns an access + refresh token pair.\n\n'
            'Demo credentials: `demo@example.com` / `demo1234`',
        dtoParser: (json) => json,
        requestSchema: {
          'type': 'object',
          'required': ['email', 'password'],
          'properties': {
            'email': {
              'type': 'string',
              'format': 'email',
              'example': 'demo@example.com',
            },
            'password': {'type': 'string', 'example': 'demo1234'},
          },
        },
        typedHandler: (req, body) async {
          final email = body?['email']?.toString() ?? '';
          final password = body?['password']?.toString() ?? '';
          final claims = _authenticate(email, password);
          if (claims == null) {
            throw const ApiException(401, 'Invalid credentials');
          }
          final pair = jwt.generateTokenPair(claims: claims);
          return {
            'accessToken': pair.accessToken,
            'refreshToken': pair.refreshToken,
          };
        },
      ),

      // POST /auth/refresh — rotate the refresh token for a new token pair.
      // verifyRefreshToken consumes the presented token (single use), so the
      // response must include a fresh refresh token as well.
      ApiRoute<Map<String, dynamic>, Map<String, String>>(
        method: ApiMethod.post,
        path: '/auth/refresh',
        summary: 'Refresh token',
        description:
            'Exchange a valid refresh token for a new access/refresh pair. '
            'Refresh tokens are single-use.',
        dtoParser: (json) => json,
        requestSchema: {
          'type': 'object',
          'required': ['refreshToken'],
          'properties': {
            'refreshToken': {'type': 'string'},
          },
        },
        typedHandler: (req, body) async {
          final token = body?['refreshToken']?.toString();
          if (token == null) {
            throw const ApiException(400, 'refreshToken is required');
          }
          final claims = await jwt.verifyRefreshToken(token);
          if (claims == null) {
            throw const ApiException(401, 'Invalid or expired refresh token');
          }
          final pair = jwt.generateTokenPair(
            claims: {'sub': claims['sub'], 'email': claims['email'] ?? ''},
          );
          return {
            'accessToken': pair.accessToken,
            'refreshToken': pair.refreshToken,
          };
        },
      ),

      // POST /auth/logout — revokes the current access token
      ApiRoute<void, Map<String, String>>(
        method: ApiMethod.post,
        path: '/auth/logout',
        summary: 'Logout',
        description: 'Revokes the Bearer token from the Authorization header.',
        middlewares: [authMiddleware(jwt)],
        security: [SecurityScheme.bearer],
        typedHandler: (req, _) async {
          final token = req.headers.getToken();
          if (token != null) await jwt.revokeToken(token);
          return {'message': 'Logged out'};
        },
      ),
    ]),

    // ── Book CRUD ─────────────────────────────────────────────────────────
    BookController(service: app.get<BookService>(), jwt: jwt),
  ]);

  // ── Built-in endpoints ────────────────────────────────────────────────────
  app.enableHealthCheck(); // GET /health
  app.enableMetrics(); // GET /metrics (Prometheus)
  app.enableDocs(
    title: 'Books API',
    version: '1.0.0',
  ); // GET /docs (Swagger UI)

  final port = int.tryParse(Platform.environment['PORT'] ?? '') ?? 8080;

  print('');
  print('Books API  →  http://localhost:$port');
  print('─' * 44);
  print('  Swagger UI  http://localhost:$port/docs');
  print('  Health      http://localhost:$port/health');
  print('  Metrics     http://localhost:$port/metrics');
  print('─' * 44);
  print('  Public:  GET  /books');
  print('  Auth:    POST /auth/login');
  print('           POST /auth/refresh');
  print('           POST /auth/logout  (Bearer)');
  print('  Books:   GET  /books/:id    (Bearer)');
  print('           POST /books        (Bearer)');
  print('           PUT  /books/:id    (Bearer)');
  print('           DELETE /books/:id  (Bearer)');
  print('─' * 44);
  print('  Demo login: demo@example.com / demo1234');
  print('');

  await app.start(port: port);
}
1
likes
160
points
114
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

BSD-3-Clause (license)

Dependencies

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

More

Packages that depend on dartapi_core