rest_api_client 2.4.1 copy "rest_api_client: ^2.4.1" to clipboard
rest_api_client: ^2.4.1 copied to clipboard

Abstraction for communicating with REST API in flutter projects. Incorporates exception handling and jwt with refresh token authorization.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:rest_api_client/rest_api_client.dart';

/// Global API client instance
late RestApiClient apiClient;

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Initialize storage (required once per app lifetime)
  await RestApiClient.initFlutter();

  // Create and configure the API client
  apiClient = RestApiClientImpl(
    options: RestApiClientOptions(
      // Using JSONPlaceholder as a free test API
      baseUrl: 'https://jsonplaceholder.typicode.com',
      cacheEnabled: true,
    ),
    authOptions: AuthOptions(
      // Configure token refresh (example configuration)
      refreshTokenEndpoint: '/auth/refresh',
      resolveJwt: (response) => response.data['accessToken'],
      resolveRefreshToken: (response) => response.data['refreshToken'],
    ),
    loggingOptions: LoggingOptions(
      // Enable logging for debugging
      logNetworkTraffic: true,
    ),
    cacheOptions: CacheOptions(
      cacheLifetimeDuration: const Duration(minutes: 5),
      useAuthorization: false, // Public API, no auth needed
    ),
    retryOptions: RetryOptions(enabled: true, maxRetries: 3),
  );

  // Initialize the client
  await apiClient.init();

  // Listen to exceptions globally
  apiClient.exceptionHandler.exceptions.stream.listen((exception) {
    debugPrint('Global exception: $exception');
  });

  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Rest API Client Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const HomeScreen(),
    );
  }
}

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Rest API Client Examples'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: ListView(
        children: [
          _ExampleTile(
            title: 'GET Request',
            subtitle: 'Fetch a list of posts',
            onTap:
                () => Navigator.push(
                  context,
                  MaterialPageRoute(builder: (_) => const GetExampleScreen()),
                ),
          ),
          _ExampleTile(
            title: 'POST Request',
            subtitle: 'Create a new post',
            onTap:
                () => Navigator.push(
                  context,
                  MaterialPageRoute(builder: (_) => const PostExampleScreen()),
                ),
          ),
          _ExampleTile(
            title: 'Caching',
            subtitle: 'Cache-first and streamed requests',
            onTap:
                () => Navigator.push(
                  context,
                  MaterialPageRoute(builder: (_) => const CacheExampleScreen()),
                ),
          ),
          _ExampleTile(
            title: 'Error Handling',
            subtitle: 'Handle different error types',
            onTap:
                () => Navigator.push(
                  context,
                  MaterialPageRoute(builder: (_) => const ErrorExampleScreen()),
                ),
          ),
          _ExampleTile(
            title: 'Type Conversion',
            subtitle: 'Parse responses into typed objects',
            onTap:
                () => Navigator.push(
                  context,
                  MaterialPageRoute(builder: (_) => const TypedExampleScreen()),
                ),
          ),
        ],
      ),
    );
  }
}

class _ExampleTile extends StatelessWidget {
  final String title;
  final String subtitle;
  final VoidCallback onTap;

  const _ExampleTile({
    required this.title,
    required this.subtitle,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(title),
      subtitle: Text(subtitle),
      trailing: const Icon(Icons.chevron_right),
      onTap: onTap,
    );
  }
}

// =============================================================================
// GET Request Example
// =============================================================================

class GetExampleScreen extends StatefulWidget {
  const GetExampleScreen({super.key});

  @override
  State<GetExampleScreen> createState() => _GetExampleScreenState();
}

class _GetExampleScreenState extends State<GetExampleScreen> {
  List<dynamic> posts = [];
  bool isLoading = false;
  String? error;

  Future<void> fetchPosts() async {
    setState(() {
      isLoading = true;
      error = null;
    });

    // Simple GET request
    final result = await apiClient.get(
      '/posts',
      queryParameters: {'_limit': 10},
    );

    setState(() {
      isLoading = false;
      if (result.hasData) {
        posts = result.data;
      } else if (result.isError) {
        error = result.exception.toString();
      }
    });
  }

  @override
  void initState() {
    super.initState();
    fetchPosts();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('GET Request Example')),
      body: _buildBody(),
      floatingActionButton: FloatingActionButton(
        onPressed: fetchPosts,
        child: const Icon(Icons.refresh),
      ),
    );
  }

  Widget _buildBody() {
    if (isLoading) {
      return const Center(child: CircularProgressIndicator());
    }

    if (error != null) {
      return Center(child: Text('Error: $error'));
    }

    return ListView.builder(
      itemCount: posts.length,
      itemBuilder: (context, index) {
        final post = posts[index];
        return ListTile(
          leading: CircleAvatar(child: Text('${post['id']}')),
          title: Text(
            post['title'],
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
          ),
          subtitle: Text(
            post['body'],
            maxLines: 2,
            overflow: TextOverflow.ellipsis,
          ),
        );
      },
    );
  }
}

// =============================================================================
// POST Request Example
// =============================================================================

class PostExampleScreen extends StatefulWidget {
  const PostExampleScreen({super.key});

  @override
  State<PostExampleScreen> createState() => _PostExampleScreenState();
}

class _PostExampleScreenState extends State<PostExampleScreen> {
  final titleController = TextEditingController(text: 'My New Post');
  final bodyController = TextEditingController(
    text: 'This is the post content.',
  );
  bool isLoading = false;
  Map<String, dynamic>? createdPost;

  Future<void> createPost() async {
    setState(() {
      isLoading = true;
      createdPost = null;
    });

    // POST request with data
    final result = await apiClient.post(
      '/posts',
      data: {
        'title': titleController.text,
        'body': bodyController.text,
        'userId': 1,
      },
    );

    setState(() {
      isLoading = false;
      if (result.hasData) {
        createdPost = result.data;
      }
    });

    if (mounted && result.hasData) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Post created with ID: ${result.data['id']}')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('POST Request Example')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            TextField(
              controller: titleController,
              decoration: const InputDecoration(
                labelText: 'Title',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 16),
            TextField(
              controller: bodyController,
              decoration: const InputDecoration(
                labelText: 'Body',
                border: OutlineInputBorder(),
              ),
              maxLines: 3,
            ),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: isLoading ? null : createPost,
              child:
                  isLoading
                      ? const SizedBox(
                        height: 20,
                        width: 20,
                        child: CircularProgressIndicator(strokeWidth: 2),
                      )
                      : const Text('Create Post'),
            ),
            if (createdPost != null) ...[
              const SizedBox(height: 24),
              const Text(
                'Created Post:',
                style: TextStyle(fontWeight: FontWeight.bold),
              ),
              const SizedBox(height: 8),
              Card(
                child: Padding(
                  padding: const EdgeInsets.all(16),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text('ID: ${createdPost!['id']}'),
                      Text('Title: ${createdPost!['title']}'),
                      Text('Body: ${createdPost!['body']}'),
                    ],
                  ),
                ),
              ),
            ],
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    titleController.dispose();
    bodyController.dispose();
    super.dispose();
  }
}

// =============================================================================
// Caching Example
// =============================================================================

class CacheExampleScreen extends StatefulWidget {
  const CacheExampleScreen({super.key});

  @override
  State<CacheExampleScreen> createState() => _CacheExampleScreenState();
}

class _CacheExampleScreenState extends State<CacheExampleScreen> {
  List<String> logs = [];
  bool isLoading = false;

  void addLog(String message) {
    setState(() {
      logs.add('[${DateTime.now().toString().substring(11, 19)}] $message');
    });
  }

  Future<void> fetchWithCache() async {
    setState(() {
      logs.clear();
      isLoading = true;
    });

    addLog('Starting getCachedOrNetwork request...');

    // Cache-first strategy: returns cached data if available, otherwise fetches from network
    final result = await apiClient.getCachedOrNetwork('/posts/1');

    if (result.hasData) {
      final source = result is CacheResult ? 'CACHE' : 'NETWORK';
      addLog('Got data from $source: ${result.data['title']}');
    }

    setState(() => isLoading = false);
  }

  Future<void> fetchStreamed() async {
    setState(() {
      logs.clear();
      isLoading = true;
    });

    addLog('Starting getStreamed request...');

    // Stale-while-revalidate: emits cached data first, then fresh data
    await for (final result in apiClient.getStreamed('/posts/2')) {
      if (result.hasData) {
        final source = result is CacheResult ? 'CACHE' : 'NETWORK';
        addLog('Got data from $source: ${result.data['title']}');
      }
    }

    setState(() => isLoading = false);
  }

  Future<void> fetchCacheOnly() async {
    setState(() {
      logs.clear();
    });

    addLog('Fetching from cache only...');

    // Cache-only: returns only cached data, no network request
    final result = await apiClient.getCached('/posts/1');

    if (result.hasData) {
      addLog('Found in cache: ${result.data['title']}');
    } else {
      addLog('Not found in cache');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Caching Example')),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                ElevatedButton(
                  onPressed: isLoading ? null : fetchWithCache,
                  child: const Text('Cache or Network'),
                ),
                ElevatedButton(
                  onPressed: isLoading ? null : fetchStreamed,
                  child: const Text('Streamed'),
                ),
                ElevatedButton(
                  onPressed: isLoading ? null : fetchCacheOnly,
                  child: const Text('Cache Only'),
                ),
              ],
            ),
          ),
          const Divider(),
          Expanded(
            child: ListView.builder(
              padding: const EdgeInsets.all(16),
              itemCount: logs.length,
              itemBuilder: (context, index) {
                return Text(
                  logs[index],
                  style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

// =============================================================================
// Error Handling Example
// =============================================================================

class ErrorExampleScreen extends StatefulWidget {
  const ErrorExampleScreen({super.key});

  @override
  State<ErrorExampleScreen> createState() => _ErrorExampleScreenState();
}

class _ErrorExampleScreenState extends State<ErrorExampleScreen> {
  String? resultText;
  bool isLoading = false;

  Future<void> triggerNotFound() async {
    setState(() {
      isLoading = true;
      resultText = null;
    });

    // Request to a non-existent endpoint
    final result = await apiClient.get('/posts/99999999');

    setState(() {
      isLoading = false;
      if (result.isError) {
        if (result.exception is ValidationException) {
          resultText = 'ValidationException: Resource not found (404)';
        } else {
          resultText = 'Error: ${result.exception}';
        }
        resultText = '$resultText\nStatus: ${result.statusCode}';
      } else {
        resultText = 'Unexpected success: ${result.data}';
      }
    });
  }

  Future<void> handleResultSafely() async {
    setState(() {
      isLoading = true;
      resultText = null;
    });

    final result = await apiClient.get('/posts/1');

    setState(() {
      isLoading = false;

      final buffer = StringBuffer();

      // Check various result properties
      buffer.writeln('hasData: ${result.hasData}');
      buffer.writeln('isSuccess: ${result.isSuccess}');
      buffer.writeln('isError: ${result.isError}');
      buffer.writeln('isConnectionError: ${result.isConnectionError}');
      buffer.writeln('statusCode: ${result.statusCode}');

      if (result.hasData) {
        buffer.writeln('\nData: ${result.data['title']}');
      }

      resultText = buffer.toString();
    });
  }

  Future<void> silentRequest() async {
    setState(() {
      isLoading = true;
      resultText = null;
    });

    // Silent exception: won't broadcast to global exception stream
    final result = await apiClient.get(
      '/invalid-endpoint',
      options: RestApiClientRequestOptions(silentException: true),
    );

    setState(() {
      isLoading = false;
      resultText =
          result.isError
              ? 'Silent error (not broadcast globally): ${result.exception}'
              : 'Success: ${result.data}';
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Error Handling Example')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                ElevatedButton(
                  onPressed: isLoading ? null : triggerNotFound,
                  child: const Text('Trigger 404'),
                ),
                ElevatedButton(
                  onPressed: isLoading ? null : handleResultSafely,
                  child: const Text('Check Result'),
                ),
                ElevatedButton(
                  onPressed: isLoading ? null : silentRequest,
                  child: const Text('Silent Error'),
                ),
              ],
            ),
            const SizedBox(height: 24),
            if (isLoading)
              const Center(child: CircularProgressIndicator())
            else if (resultText != null)
              Card(
                child: Padding(
                  padding: const EdgeInsets.all(16),
                  child: Text(
                    resultText!,
                    style: const TextStyle(fontFamily: 'monospace'),
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }
}

// =============================================================================
// Type Conversion Example
// =============================================================================

/// Example model class
class Post {
  final int id;
  final int userId;
  final String title;
  final String body;

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

  factory Post.fromJson(Map<String, dynamic> json) {
    return Post(
      id: json['id'],
      userId: json['userId'],
      title: json['title'],
      body: json['body'],
    );
  }
}

class TypedExampleScreen extends StatefulWidget {
  const TypedExampleScreen({super.key});

  @override
  State<TypedExampleScreen> createState() => _TypedExampleScreenState();
}

class _TypedExampleScreenState extends State<TypedExampleScreen> {
  Post? post;
  List<Post>? posts;
  bool isLoading = false;

  Future<void> fetchSinglePost() async {
    setState(() {
      isLoading = true;
      post = null;
      posts = null;
    });

    // Use onSuccess to parse response into typed object
    final result = await apiClient.get<Post>(
      '/posts/1',
      onSuccess: (data) => Post.fromJson(data),
    );

    setState(() {
      isLoading = false;
      if (result.hasData) {
        post = result.data;
      }
    });
  }

  Future<void> fetchPostList() async {
    setState(() {
      isLoading = true;
      post = null;
      posts = null;
    });

    // Parse list response
    final result = await apiClient.get<List<Post>>(
      '/posts',
      queryParameters: {'_limit': 5},
      onSuccess: (data) => (data as List).map((e) => Post.fromJson(e)).toList(),
    );

    setState(() {
      isLoading = false;
      if (result.hasData) {
        posts = result.data;
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Type Conversion Example')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Row(
              children: [
                Expanded(
                  child: ElevatedButton(
                    onPressed: isLoading ? null : fetchSinglePost,
                    child: const Text('Fetch Single'),
                  ),
                ),
                const SizedBox(width: 8),
                Expanded(
                  child: ElevatedButton(
                    onPressed: isLoading ? null : fetchPostList,
                    child: const Text('Fetch List'),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 24),
            if (isLoading)
              const Center(child: CircularProgressIndicator())
            else if (post != null)
              Card(
                child: Padding(
                  padding: const EdgeInsets.all(16),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        'Post (typed)',
                        style: Theme.of(context).textTheme.titleMedium,
                      ),
                      const SizedBox(height: 8),
                      Text('ID: ${post!.id}'),
                      Text('User ID: ${post!.userId}'),
                      Text('Title: ${post!.title}'),
                      Text(
                        'Body: ${post!.body}',
                        maxLines: 3,
                        overflow: TextOverflow.ellipsis,
                      ),
                    ],
                  ),
                ),
              )
            else if (posts != null)
              Expanded(
                child: ListView.builder(
                  itemCount: posts!.length,
                  itemBuilder: (context, index) {
                    final p = posts![index];
                    return Card(
                      child: ListTile(
                        leading: CircleAvatar(child: Text('${p.id}')),
                        title: Text(
                          p.title,
                          maxLines: 1,
                          overflow: TextOverflow.ellipsis,
                        ),
                        subtitle: Text('User: ${p.userId}'),
                      ),
                    );
                  },
                ),
              ),
          ],
        ),
      ),
    );
  }
}
33
likes
150
points
706
downloads

Publisher

unverified uploader

Weekly Downloads

Abstraction for communicating with REST API in flutter projects. Incorporates exception handling and jwt with refresh token authorization.

Repository (GitHub)
View/report issues

Documentation

API reference

License

MIT (license)

Dependencies

crypto, dio, flutter, jwt_decoder, pretty_dio_logger, storage_repository

More

Packages that depend on rest_api_client