rest_api_client 3.0.0 copy "rest_api_client: ^3.0.0" to clipboard
rest_api_client: ^3.0.0 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();

  // 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}'),
                      ),
                    );
                  },
                ),
              ),
          ],
        ),
      ),
    );
  }
}
34
likes
0
points
505
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

License

unknown (license)

Dependencies

crypto, dio, flutter, jwt_decoder, pretty_dio_logger, storage_repository

More

Packages that depend on rest_api_client