dartapi_core 0.1.8 copy "dartapi_core: ^0.1.8" to clipboard
dartapi_core: ^0.1.8 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 accessToken = jwt.generateAccessToken(claims: claims);
          return {
            'accessToken': accessToken,
            'refreshToken': jwt.generateRefreshToken(accessToken: accessToken),
          };
        },
      ),

      // POST /auth/refresh — exchange refresh token for new access token
      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 token.',
        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 newAccess = jwt.generateAccessToken(claims: {
            'sub': claims['sub'],
            'email': claims['email'] ?? '',
          });
          return {'accessToken': newAccess};
        },
      ),

      // 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
1.2k
downloads

Documentation

API reference

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_web_socket, web_socket_channel

More

Packages that depend on dartapi_core