op_rest_api_client

A lightweight, extensible Dart package for interacting with RESTful APIs.
Provides structured API requests, robust error handling, authentication support, and identity-based token management, while leveraging op_result for structured responses.


  • 🔐 Authentication support: Integrates with Identity for token-based authentication, including automatic token refresh.
  • 🚧 Automatic token refresh: Automatically retries requests with a refreshed token upon receiving a 401 Unauthorized.
  • Robust error handling: Utilizes OpResult<T> for structured success and failure responses.
  • 🎯 Type-safe API endpoints: Encourages defining API endpoints using enums or constants for better maintainability.
  • Custom response validation: Allows fine-grained validation via customValidator.

📦 Installation

Run the install command to always get the latest version:

dart pub add op_rest_api_client

Or add it manually to your pubspec.yaml:

dependencies:
  op_rest_api_client: ^<latest version>

🛠️ Usage

1️⃣ Define API Endpoints as an Enum

enum MyApiEndpoints {
  getUser,
  updateUser,
  deleteUser,
}

OpRestApiEndpoint getEndpoint(MyApiEndpoints endpoint) {
  switch (endpoint) {
    case MyApiEndpoints.getUser:
      return OpRestApiEndpoint('/user', OpRestApiClientMethods.get);
    case MyApiEndpoints.updateUser:
      return OpRestApiEndpoint('/user/update', OpRestApiClientMethods.put);
    case MyApiEndpoints.deleteUser:
      return OpRestApiEndpoint('/user/delete', OpRestApiClientMethods.delete);
  }
}

2️⃣ Initialize the API Client

import 'package:op_rest_api_client/op_rest_api_client.dart';
import 'package:http/http.dart' as http;

final apiClient = OpRestApiClient<MyApiEndpoints>(
  baseUrl: "https://api.example.com",
  client: http.Client(),
);

3️⃣ Send API Requests

🔹 GET Request

final result = await apiClient.send(
  endpoint: getEndpoint(MyApiEndpoints.getUser),
  identity: userIdentity, // Optional authentication
);

if (result.isSuccess) {
  print("User data: \${result.data.body}");
} else {  
  print("Error: \${result.error.getErrorMessage()}");
}

🔹 POST Request (With Body)

final newUser = {
  'name': 'John Doe',
  'email': 'john.doe@example.com',
};

final result = await apiClient.send(
  endpoint: getEndpoint(MyApiEndpoints.updateUser), 
  identity: userIdentity,
  body: newUser,
);

if (result.isSuccess) {
  print("User updated successfully!");
} else {
  print("Error: \${result.error.getErrorMessage()}");
}

🔹 DELETE Request

final result = await apiClient.send(
  endpoint: getEndpoint(MyApiEndpoints.deleteUser), 
  identity: userIdentity,
);

if (result.isSuccess) {
  print("User deleted!");
} else {
  print("Error: \${result.error.getErrorMessage()}");
}

📌 Authentication & Token Refresh

The client supports authentication via OpRestApiIdentity, handling token refresh automatically.

class MyApiIdentity extends OpRestApiIdentity<String> {
  final String accessToken;
  final String refreshToken;

  MyApiIdentity({
    required this.accessToken,
    required this.refreshToken,
  });

  @override
  String get identityData => accessToken;

  @override
  bool isValid() => accessToken.isNotEmpty;

  @override
  bool isExpired() {
    // Example logic for expiration check (adjust as needed)
    return false;
  }

  @override
  Map<String, String>? headers() => {"Authorization": "Bearer $accessToken"};  

  @override
  Future<OpRestApiIdentity<String>?> refresh() async {
    // Refresh token logic (example)
    return MyApiIdentity(
      accessToken: "newAccessToken",
      refreshToken: "newRefreshToken",
    );
  }
}

final userIdentity = MyApiIdentity(
  accessToken: "abc123",
  refreshToken: "refresh123",
);

If a request fails due to an expired token (401):

  • The client automatically attempts token refresh once.
  • If the refresh succeeds, the original request is retried automatically using the new token.
  • If the refresh fails, an unauthenticated error (OpError(AuthError.unauthenticated)) is returned.

❌ Error Handling

All API responses are wrapped in OpResult<http.Response>, providing a structured way to handle success and failure.

Handling API Responses

  • Success: Returns OpResult.success(response), containing the full http.Response.
  • Failure: Returns OpResult.failure(error), containing an OpError with details such as status code and error type.

A customValidator function can be provided to define additional validation logic before marking a response as success or failure.

Using customValidator for Response Validation:

OpResult<http.Response, MyCustomError>? myValidator(http.Response response) {
  if (response.statusCode == 200 || response.statusCode == 202) {
    return OpResult.success(response);
  }
  if (response.statusCode == 404) {
    return OpResult.failure(OpError(type: MyCustomError.notFound));
  }
  return null; // Fallback to default handling
}

final result = await apiClient.send(
  endpoint: getEndpoint(MyApiEndpoints.getUser),
  customValidator: myValidator,
);

🛠 Advanced Configuration

🌐 Custom Headers

await apiClient.send(
  endpoint: getEndPoint(MyApiEndpoints.getUser),
  headers: {
    "X-Custom-Header": "value",
  },
);

📜 Enforcing Response Type

If an API is expected to return a certain content type (e.g. JSON) but sometimes it doesn't, then the optional responseMapper parameter can be used to enforce that format:

final apiClient = OpRestApiClient<MyApiEndpoints>(
  baseUrl: "https://api.example.com",
  client: http.Client(),
  responseMapper: (response) {
    // Example: Ensure response is always JSON
    if (!response.headers['content-type']?.contains('application/json') ?? true) {
      throw FormatException('Invalid response format');
    }
    return response;
  },
);

** Handling true offline detection at app level **

By default, OpRestApiClient distinguishes between:

  • networkUnreachable: the device is connected but cannot reach the server (e.g., DNS failure, timeout, socket exception).
  • gatewayError: server responded with 502 or 504 (e.g., proxy or upstream error).

However, it does not determine whether the device is completely offline (i.e., no internet connection).

To provide a better user experience, you can probe for actual connectivity at the app level and override the error type accordingly:

if (error.type == OpRestApiErrorType.networkUnreachable) {
  final isOffline = await myConnectivityChecker.isOffline();
  final adjustedError = isOffline
      ? OpError(type: OpRestApiErrorType.networkOffline)
      : error;

  showErrorMessage(adjustedError.getErrorMessage());
}

🚀 Why Use op_rest_api_client?

Structured API calls → No need to manage HTTP manually.
Authentication & token refresh → Handles 401 errors automatically.
Strong typing with enums → Prevents invalid API endpoints.
Full OpResult support → Clear success/failure responses.
Custom error handling → Map HTTP codes to app-specific errors.


📌 Versioning & Changelog

For release history and breaking changes, check the CHANGELOG.


⚖ License

This project is licensed under the BSD 3-Clause License.

Libraries

op_rest_api_client