falkon 0.1.0 copy "falkon: ^0.1.0" to clipboard
falkon: ^0.1.0 copied to clipboard

A lean, interceptor-driven HTTP client for Dart — built on dart:io with zero third-party dependencies.

Falkon #

Falkon

pub version pub points dart sdk license: MIT

A lean, interceptor-driven HTTP client for Dart — built on dart:io with zero third-party dependencies.

Falkon gives you typed results, a clean interceptor chain, multipart uploads with progress tracking, and a sealed Result<T> that keeps exceptions out of your business logic.


Features #

  • Sealed Result<T>Success and Failure are pattern-matchable; errors never escape as uncaught exceptions
  • Interceptor chain — request, response, and error hooks; short-circuit or recover at any stage
  • Bundled interceptorsBearerTokenInterceptor, LoggingInterceptor, RetryInterceptor ready to use
  • Multipart uploads — stream files from disk or bytes with real-time progress callbacks
  • File downloads — pipe directly to disk; no full-body buffering
  • Per-request overrides — base URL, headers, timeout, and interceptor bypass via RequestOptions
  • Fluent config builder — readable, immutable FalkonClientConfig
  • Zero dependenciesdart:io + dart:convert only

Installation #

dependencies:
  falkon: ^0.1.0
dart pub get

Quick start #

import 'package:falkon/falkon.dart';

final client = FalkonClient(
  FalkonClientConfig.builder('https://api.example.com')
    .timeout(const Duration(seconds: 15))
    .addInterceptor(LoggingInterceptor())
    .addInterceptor(BearerTokenInterceptor(() => authService.token))
    .maxRetries(2)
    .build(),
);

// GET
final result = await client.get('/posts/1', parser: Post.fromJson);

result.fold(
  onSuccess: (post) => print(post.title),
  onFailure: (err)  => print('Error: $err'),
);

Usage #

Defining a model #

Any class with a fromJson factory works:

class Post {
  final int    id;
  final String title;
  final String body;

  const Post({required this.id, required this.title, required this.body});

  factory Post.fromJson(Map<String, dynamic> json) => Post(
    id:    json['id']    as int,
    title: json['title'] as String,
    body:  json['body']  as String,
  );
}

All HTTP verbs #

final post   = await client.get   ('/posts/1',           parser: Post.fromJson);
final created = await client.post  ('/posts', body: data, parser: Post.fromJson);
final updated = await client.put   ('/posts/1', body: data, parser: Post.fromJson);
final patched = await client.patch ('/posts/1', body: data, parser: Post.fromJson);
final deleted = await client.delete('/posts/1');

Query parameters #

final result = await client.get(
  '/search',
  query: {'q': 'flutter', 'page': '2'},
  parser: SearchResult.fromJson,
);

Working with Result<T> #

// Pattern match (Dart 3+)
switch (result) {
  case Success(:final data, :final statusCode):
    print('$statusCode → ${data.title}');
  case Failure(:final error):
    print('Failed: $error');
}

// Functional helpers
final title = result
  .map((post) => post.title.toUpperCase())
  .fold(onSuccess: (t) => t, onFailure: (_) => 'unknown');

// Side-effects
result
  .onSuccess((post) => cache.store(post))
  .onFailure((err)  => logger.error(err));

Error types #

Type When
HttpException Non-2xx response; carries statusCode and responseBody
ParseException JSON decode or fromJson threw
ConnectionException DNS failure, socket error, no network
TimeoutException Request exceeded its deadline
UnknownNetworkException Everything else
if (result.isFailure) {
  switch (result.error) {
    case HttpException(:final statusCode) when statusCode == 401:
      await authService.refresh();
    case TimeoutException():
      showRetryDialog();
    default:
      logError(result.error);
  }
}

Interceptors #

Built-in interceptors #

// Inject a bearer token on every request
BearerTokenInterceptor(() => storage.read('access_token'))

// Pretty-print requests and responses
LoggingInterceptor()

// Retry on connection/timeout failures with exponential back-off
RetryInterceptor(maxAttempts: 3)

Writing a custom interceptor #

class AuthRefreshInterceptor extends Interceptor {
  final AuthService _auth;
  AuthRefreshInterceptor(this._auth);

  @override
  void onRequest(RequestContext ctx, RequestHandler handler) {
    handler.next(
      ctx.copyWith(headers: {
        ...ctx.headers,
        'Authorization': 'Bearer ${_auth.accessToken}',
      }),
    );
  }

  @override
  void onError(NetworkException error, ErrorHandler handler) async {
    if (error is HttpException && error.statusCode == 401) {
      await _auth.refresh();
      // Signal caller to retry — or resolve with a cached response
    }
    handler.next(error);
  }
}

Registering interceptors #

// At build time (preferred)
FalkonClientConfig.builder(baseUrl)
  .addInterceptor(LoggingInterceptor())
  .addInterceptor(AuthRefreshInterceptor(authService))
  .build();

// At runtime
client.addInterceptor(AnalyticsInterceptor());

File transfers #

Multipart upload #

final result = await client.upload<UploadResponse>(
  '/media/upload',
  files: [
    UploadFile.fromFile(
      fieldName:   'avatar',
      file:        File('/path/to/photo.jpg'),
      contentType: 'image/jpeg',
    ),
    UploadFile.fromBytes(
      fieldName:   'thumbnail',
      bytes:       thumbnailBytes,
      filename:    'thumb.png',
      contentType: 'image/png',
    ),
  ],
  fields: {'userId': '42', 'album': 'profile'},
  parser: UploadResponse.fromJson,
  onProgress: (sent, total) {
    print('${(sent / total * 100).toStringAsFixed(1)}%');
  },
);

File download #

final result = await client.download(
  'https://files.example.com/report.pdf',
  savePath: '/storage/emulated/0/Download/report.pdf',
  onProgress: (received, total) => updateProgressBar(received / total),
);

result.fold(
  onSuccess: (file) => openFile(file.path),
  onFailure: (err)  => showError(err.message),
);

Per-request options #

final result = await client.get(
  '/internal/status',
  options: RequestOptions(
    baseUrl:          'https://internal.example.com',
    headers:          {'X-Service-Key': secrets.serviceKey},
    timeout:          const Duration(seconds: 5),
    skipInterceptors: true,
  ),
);

Architecture patterns #

Repository #

class PostRepository extends NetworkRepository {
  final NetworkClient _client;
  const PostRepository(this._client);

  Future<Result<Post>>       getPost(int id)    => _client.get('/posts/$id', parser: Post.fromJson);
  Future<Result<Post>>       createPost(Post p) => _client.post('/posts', body: p.toJson(), parser: Post.fromJson);
  Future<Result<List<Post>>> listPosts()        => _client.get('/posts', parser: (json) => /* ... */);
}

Domain service #

class PostService extends NetworkService {
  PostService(super.client);

  Future<Result<Post>> getValidated(int id) async {
    return client
      .get('/posts/$id', parser: Post.fromJson)
      .then((r) => r.map(_validate));
  }

  Post _validate(Post post) {
    if (post.title.isEmpty) throw const FormatException('Empty title');
    return post;
  }
}

Mocking in tests #

Because everything is coded to the NetworkClient interface, swapping in a fake is trivial:

class MockNetworkClient implements NetworkClient {
  final Map<String, dynamic> _responses = {};

  void stub(String path, Map<String, dynamic> json) =>
      _responses[path] = json;

  @override
  Future<Result<T>> get<T>(String path, {
    Map<String, dynamic>? query,
    JsonParser<T>? parser,
    RequestOptions? options,
  }) async {
    final data = _responses[path];
    if (data == null) return Failure(const HttpException(statusCode: 404, message: 'Not stubbed'));
    return Success(parser != null ? parser(data) : data as T);
  }

  // ... other methods
}

Configuration reference #

FalkonClientConfig.builder('https://api.example.com')
  .headers({'Content-Type': 'application/json', 'Accept': 'application/json'})
  .timeout(const Duration(seconds: 30))
  .maxRetries(3)
  .addInterceptor(LoggingInterceptor())
  .addInterceptor(BearerTokenInterceptor(() => token))
  .parseErrorBodies()   // forward non-2xx JSON bodies to your parser
  .build();
Option Default Description
baseUrl required Scheme + host prepended to every relative path
headers {'Content-Type': 'application/json; charset=utf-8'} Default headers merged with every request
timeout 30s Combined connection + response deadline
maxRetries 0 Retry attempts for ConnectionException / TimeoutException
parseErrorBodies false Pass non-2xx responses through the JSON parser

Requirements #

SDK Version
Dart >= 3.0.0
Flutter >= 3.10.0 (if used in a Flutter project)

No third-party dependencies. Uses only dart:io and dart:convert.


Contributing #

Contributions are welcome. Please open an issue before submitting a pull request for significant changes.

  1. Fork the repository
  2. Create a feature branch: git checkout -b feat/my-feature
  3. Commit with conventional commits: feat:, fix:, docs:
  4. Open a pull request against main

Run the self-contained test suite:

dart test.dart

License #

MIT — see LICENSE for details.

1
likes
160
points
86
downloads
screenshot

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

A lean, interceptor-driven HTTP client for Dart — built on dart:io with zero third-party dependencies.

Repository (GitHub)
View/report issues

Topics

#restapi #http #dio

License

MIT (license)

Dependencies

http, http_parser

More

Packages that depend on falkon