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.

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
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