quick_request — modern HTTP for Flutter/Dart

quick_request is a tiny yet powerful HTTP client for Flutter/Dart that It auto-selects the best transport for your platform:

  • Mobile/Desktopdart:io (HttpClient)
  • Webdart:html (HttpRequest)

It delivers a clean builder API, non-throwing interceptors, a single-retry JWT refresh pattern, optional in-memory caching, and pragmatic cancel/timeout handling — all with zero third‑party runtime dependencies.


Why quick_request?

  • Zero external deps – ship lean binaries, avoid version lock-in.
  • Non-throwing interceptors – keep side effects in interceptors, keep logic in the client.
  • Single, guaranteed retry on 401 – no infinite loops.
  • Predictable API – every call returns a ResponseModel<T>. You choose how to branch on success/failure.
  • Extensible – adapters are pluggable; mock easily in tests.
  • Portable – works on IO and Web, automatically.

If you like the ergonomics of popular HTTP libraries but want fewer moving parts, stricter control over side effects, and an opinionated JWT flow, quick_request is for you.


Installation

To add the quick_request package to your project, include the following line in your pubspec.yaml file: dependencies: quick_request: ^0.1.2


Quick Start

import 'package:quick_request/quick_request.dart';
import 'package:quick_request/interceptors.dart';

final api = QuickRequest(
  baseUrl: "https://httpbin.org",
  interceptors: [LoggingInterceptor()],
);

final res = await api
  .url("/get")
  .query({"page": 1, "pageSize": 20})
  .headers({"X-Trace": "abc-123"})
  .get<Map<String, dynamic>>(fromJson: (j) => j);

if (!res.error) {
  print("Data: ${res.data}");
} else {
  print("Error: ${res.message} (code: ${res.statusCode})");
}

Key idea: every request returns a ResponseModel<T>. Prefer if (!res.error) over try/catch in app code.


Core Concepts

1) Builder chain

QuickRequest()
  .url("/users/{id}")
  .pathParams({"id": 42})
  .query({"include": "posts,profile"})
  .headers({"X-App-Version": "1.0.0"})
  .body({"nickname": "zeno"})
  .timeout(const Duration(seconds: 10));

2) Response model

class ResponseModel<T> {
  final T? data;
  final bool error;
  final String? message;
  final int? statusCode;
}

3) Interceptors (never throw)

class LoggingInterceptor extends QuickInterceptor {
  @override
  Future<void> onRequest(RequestOptions o) async {
    // print("[REQ] ${o.method} ${o.uri}");
  }
}

4) Platform adapters

  • IO: HttpClient (default on mobile/desktop)
  • Web: XMLHttpRequest (CORS rules apply)
  • Custom: implement QuickHttpAdapter and pass QuickRequest(adapter: ...)

Making Requests (realistic examples)

Assume baseUrl: https://api.shoply.dev for a demo e‑commerce API.

GET: list products (with pagination & filters)

final api = QuickRequest(baseUrl: "https://api.shoply.dev");

final res = await api
  .url("/v1/products")
  .query({
    "q": "wireless",
    "page": 1,
    "pageSize": 20,
    "sort": "price:asc",
  })
  .get<List<Product>>(
    fromJson: (j) => (j as List).map((e) => Product.fromJson(e)).toList(),
    expectJsonArray: true,
  );

if (!res.error) {
  for (final p in res.data!) {
    print("${p.title} — ${p.price}");
  }
}

GET: path params

final product = await api
  .url("/v1/products/{id}")
  .pathParams({"id": "prd_8QHSK"})
  .get<Product>(fromJson: (j) => Product.fromJson(j));

if (!product.error) print(product.data!.title);

POST: create order

final order = await api
  .url("/v1/orders")
  .body({
    "items": [
      {"productId": "prd_8QHSK", "qty": 2},
      {"productId": "prd_91ASD", "qty": 1},
    ],
    "address": {"line1": "Main St 1", "city": "Istanbul", "zip": "34000"},
    "notes": "Leave at reception"
  })
  .post<Order>(fromJson: (j) => Order.fromJson(j));

if (!order.error) print("Order #${order.data!.id} created!");

PATCH: partial update

final updated = await api
  .url("/v1/orders/{id}")
  .pathParams({"id": "ord_1A2B3C"})
  .body({"notes": "Deliver after 6pm"})
  .patch<Order>(fromJson: (j) => Order.fromJson(j));

DELETE: cancel order

final cancel = await api
  .url("/v1/orders/{id}")
  .pathParams({"id": "ord_1A2B3C"})
  .delete<Map<String, dynamic>>(fromJson: (j) => j);

if (!cancel.error) print("Order cancelled");

Tip: If your API wraps responses ({ data, message }), adapt fromJson to pick nested fields.


Authentication & JWT Auto-Refresh

Attach access tokens via interceptor and refresh once on 401.

final authApi = QuickRequest(baseUrl: "https://api.shoply.dev");

Future<String?> readAccessToken() async => await tokenStore.read("access");
Future<String?> refreshAccessToken() async {
  final refreshToken = await tokenStore.read("refresh");

  final res = await authApi
      .url("/v1/auth/refresh")
      .body({"refreshToken": refreshToken})
      .post<Map<String, dynamic>>(fromJson: (j) => j);

  if (!res.error) {
    final newToken = res.data!["accessToken"] as String;
    await tokenStore.write("access", newToken);
    return newToken;
  }
  return null;
}

final api = QuickRequest(
  baseUrl: "https://api.shoply.dev",
  interceptors: [
    JwtRefreshInterceptor(
      getAccessToken: readAccessToken,
      refreshToken: refreshAccessToken,
    ),
  ],
);

// secure call (401 will trigger a single refresh + retry)
final me = await api.url("/v1/users/me").get<User>(fromJson: (j) => User.fromJson(j));
if (me.error) {
  // if still failing after refresh, handle logout etc.
}

Flow

  1. Request goes out with Authorization: Bearer <access>.
  2. If backend returns 401, the interceptor uses refreshToken() to fetch a new access token.
  3. Interceptor sets shouldRetry=true and passes the new token.
  4. Client retries exactly once with updated Authorization.
  5. If it fails again, the response is returned as an error (you decide what to do).

The interceptor never throws; it only signals. The client owns retry semantics.


Error Handling Patterns

Since every call returns ResponseModel<T>, treat errors centrally in your feature code:

Future<void> loadProfile() async {
  final res = await api.url("/v1/users/me").get<User>(fromJson: (j) => User.fromJson(j));
  if (res.error) {
    // show message from API if provided
    showToast(res.message ?? "Something went wrong");
    if (res.statusCode == 401) navigateToLogin();
    return;
  }
  state = res.data;
}

When to throw?
Inside your app, almost never. Reserve exceptions for truly exceptional control flow. The library itself uses exceptions internally but wraps outcomes into ResponseModel before returning.


Cancellation & Timeout

final token = CancelToken();

final future = api
  .url("/v1/reports/slow")
  .timeout(const Duration(seconds: 10))
  .cancelToken(token)
  .get<Map<String, dynamic>>(fromJson: (j) => j);

// somewhere else (e.g., screen disposed)
token.cancel("User navigated away");

final res = await future;
if (res.error) print(res.message); // "Request cancelled" or "Request timed out"
  • Web: true cancel via XMLHttpRequest.abort()
  • IO: request is aborted/connection closed; you get a “cancelled” error wrapper.

Caching

Enable fast reads and offline-friendly UX with the simple in-memory cache.

final api = QuickRequest(
  baseUrl: "https://api.shoply.dev",
  cacheManager: CacheManager(),
);

// First call hits the network and caches raw JSON
final r1 = await api.url("/v1/categories").get<List<Category>>(
  fromJson: (j) => (j as List).map((e) => Category.fromJson(e)).toList(),
  useCache: true,
);

// Later calls with the same (method|uri|headers|body) key are served from cache
final r2 = await api.url("/v1/categories").get<List<Category>>(
  fromJson: (j) => (j as List).map((e) => Category.fromJson(e)).toList(),
  useCache: true,
);

Notes

  • Key = METHOD|URI|headers|body
  • Default TTL = 5 minutes (override with cacheTtl on the request)
  • Store is in-memory; clear with cacheManager.clear() or invalidate per key if needed.

File Upload / Binary Bodies (pragmatic)

There’s no dedicated multipart builder. You have 2 simple options:

1) Raw bytes + content-type

final bytes = await File(path).readAsBytes();
final res = await api
  .url("/v1/files")
  .headers({"Content-Type": "application/octet-stream"})
  .body(bytes) // List<int>
  .post<Map<String, dynamic>>(fromJson: (j) => j);

2) JSON + base64 (backend decodes)

final res = await api
  .url("/v1/files/base64")
  .body({"filename": "avatar.png", "content": base64String})
  .post<Map<String, dynamic>>(fromJson: (j) => j);

Need multipart with boundaries? Implement a tiny custom adapter or pre-build bytes yourself and set a matching Content-Type: multipart/form-data; boundary=....


Models (example)

class Product {
  final String id;
  final String title;
  final num price;

  Product({required this.id, required this.title, required this.price});

  factory Product.fromJson(Map<String, dynamic> j) => Product(
    id: j["id"] as String,
    title: j["title"] as String,
    price: j["price"] as num,
  );
}

class Order {
  final String id;
  final num total;
  Order({required this.id, required this.total});
  factory Order.fromJson(Map<String, dynamic> j) => Order(
    id: j["id"] as String,
    total: j["total"] as num,
  );
}

class User {
  final String id;
  final String email;
  User({required this.id, required this.email});
  factory User.fromJson(Map<String, dynamic> j) => User(
    id: j["id"] as String,
    email: j["email"] as String,
  );
}

class Category {
  final String id;
  final String name;
  Category({required this.id, required this.name});
  factory Category.fromJson(Map<String, dynamic> j) => Category(
    id: j["id"] as String,
    name: j["name"] as String,
  );
}

Realistic End-to-End Recipe: Login → Refresh → Fetch

// 1) Login (obtain access & refresh)
final login = await QuickRequest(baseUrl: "https://api.shoply.dev")
  .url("/v1/auth/login")
  .body({"email": email, "password": password})
  .post<Map<String, dynamic>>(fromJson: (j) => j);

if (login.error) throw Exception(login.message);

await tokenStore.write("access",  login.data!["accessToken"]);
await tokenStore.write("refresh", login.data!["refreshToken"]);

// 2) Configure API client with JWT interceptor
final api = QuickRequest(
  baseUrl: "https://api.shoply.dev",
  interceptors: [
    JwtRefreshInterceptor(
      getAccessToken: () async => tokenStore.read("access"),
      refreshToken: () async {
        final refresh = await tokenStore.read("refresh");
        final r = await QuickRequest(baseUrl: "https://api.shoply.dev")
          .url("/v1/auth/refresh")
          .body({"refreshToken": refresh})
          .post<Map<String, dynamic>>(fromJson: (j) => j);
        if (!r.error) {
          final newAccess = r.data!["accessToken"] as String;
          await tokenStore.write("access", newAccess);
          return newAccess;
        }
        return null;
      },
    ),
  ],
);

// 3) Use anywhere in the app
final me = await api.url("/v1/users/me").get<User>(fromJson: (j) => User.fromJson(j));
if (!me.error) print("Hello ${me.data!.email}");

Testing (no network)

Mock the adapter to simulate 401 → refresh → 200, timeouts, or custom payloads.

class MockAdapter implements QuickHttpAdapter {
  int calls = 0;
  @override
  Future<ResponseOptions> send(RequestOptions o) async {
    calls++;
    if (calls == 1) {
      return ResponseOptions(
        statusCode: 401,
        data: {"message": "expired"},
        requestOptions: o,
      );
    }
    return ResponseOptions(
      statusCode: 200,
      data: {"id": "usr_1", "email": "a@b.co"},
      requestOptions: o,
    );
  }
}

void main() async {
  final api = QuickRequest(
    baseUrl: "https://any",
    adapter: MockAdapter(),
    interceptors: [
      JwtRefreshInterceptor(
        getAccessToken: () async => "old",
        refreshToken: () async => "new", // trigger single retry
      ),
    ],
  );

  final res = await api.url("/me").get<Map<String, dynamic>>(fromJson: (j) => j);
  assert(!res.error);
}

Advanced

Global headers

QuickRequest global({required String baseUrl, required String token}) => QuickRequest(
  baseUrl: baseUrl,
  interceptors: [
    // Small interceptor that injects a static header
    _StaticHeaderInterceptor({"Authorization": "Bearer $token"}),
  ],
);

class _StaticHeaderInterceptor extends QuickInterceptor {
  final Map<String, String> headers;
  _StaticHeaderInterceptor(this.headers);
  @override
  Future<void> onRequest(RequestOptions o) async {
    headers.forEach((k, v) => o.headers.putIfAbsent(k, () => v));
  }
}

Multiple services

final catalogApi = QuickRequest(baseUrl: "https://api.shoply.dev");
final billingApi = QuickRequest(baseUrl: "https://billing.shoply.dev");

Custom adapter (e.g., proxy/TLS pinning/offline queue)

class MyAdapter implements QuickHttpAdapter {
  @override
  Future<ResponseOptions> send(RequestOptions o) async {
    // ...custom transport...
    return ResponseOptions(statusCode: 200, data: {"ok": true}, requestOptions: o);
  }
}

final api = QuickRequest(baseUrl: "https://any", adapter: MyAdapter());

FAQ

Q: Why not throw exceptions to signal request errors?
A: Because networking errors are common flow, not exceptional flow. Returning ResponseModel<T> keeps UI logic simple and predictable.

Q: Can I intercept and block a request?
A: Interceptors are designed to be non-blocking/non-throwing. You can signal a retry or modify headers/body, but avoid control flow exceptions. If you must block, return an error model early from your feature code instead of throwing in interceptors.

Q: Does cancel truly abort on all platforms?
A: Web → yes (abort()). IO → best effort (HttpClient.abort/close); in practice it behaves as expected for most APIs.

Q: Multipart forms?
A: Not provided out of the box to keep the core small. Use raw bytes or a custom adapter; or prebuild multipart bytes yourself.


At a glance (comparison mindset)

Capability quick_request Typical HTTP clients
External deps 0 1–2+
IO/Web support Yes (auto) Often yes
Interceptors Non-throwing, signal-based Varies (may throw)
JWT refresh Single retry built-in pattern Varies
Return type ResponseModel Varies (Response/throws)
Mockability Adapter interface Varies
Cache In-memory (optional) Varies
Cancel/Timeout Yes Yes

The goal isn’t to replace every feature elsewhere, but to hit the 90% most practical scenarios with clarity and stability.


License

MIT

Contributing

PRs and feature requests are welcome. For major changes, open an issue first so we can discuss the design.


Enjoy building with quick_request!