Day 28: API Client with Dio

Call the Pexels API with Dio: BaseOptions, timeouts and the Authorization header, safe logging, turning DioException into readable errors, the Result pattern from Day 9, and a repository that keeps networking out of your UI.

မနေ့က Day 27 မှာ Pexels API key ကို .env ထဲ ထည့်ပြီး JSON ကို ပြောင်းပေးမယ့် model တွေ ရေးခဲ့ပြီးပါပြီ။ ဒီနေ့မှာတော့ Pexels API ကို တကယ် ခေါ်မယ့် network layer ကို တည်ဆောက်ကြပါမယ်။ ဒီအတွက် Flutter community မှာ အသုံးအများဆုံး HTTP client တွေထဲက တခုဖြစ်တဲ့ Dio ကို သုံးသွားမှာ ဖြစ်ပါတယ်။

ဒီနေ့ ပြီးသွားရင် project ထဲမှာ ဒီလို layer ၃ ခု ရှိလာပါမယ်။

UI (screens, widgets)
   │  Day 29 မှာ ချိတ်ပါမယ်
   ▼
PhotoRepository   ← "curated photos ပေးပါ" လို့ပဲ ပြောရုံ
   │
   ▼
ApiClient (Dio)   ← URL, header, timeout, error handling
   │
   ▼
Pexels API

UI ကနေ Dio ကို တိုက်ရိုက် မခေါ်ဘဲ layer ခွဲထားရတဲ့ အကြောင်းရင်းကတော့ screen တွေက "ပုံ ၂၀ ပေးပါ" လို့ပဲ သိဖို့ လိုပြီး URL ဘယ်လိုဆောက်လဲ၊ header ဘာထည့်လဲ၊ error ကို ဘယ်လို ကိုင်တွယ်လဲဆိုတာ မသိဖို့ လိုလို့ပါ။ ဒါမှ API ပြောင်းရင်ဖြစ်ဖြစ်၊ test လုပ်ချင်ရင်ဖြစ်ဖြစ် တနေရာတည်းမှာ ပြင်ရုံပါပဲ။

ဘာလို့ Dio လဲ?

Dart team က ထုတ်တဲ့ http package နဲ့လဲ API ခေါ်လို့ ရပါတယ်။ Dio ကိုတော့ အောက်က feature တွေ built-in ပါလို့ ရွေးတာ ဖြစ်ပါတယ်။

  • BaseOptions - base URL, header, timeout တွေကို တခါတည်း သတ်မှတ်ထားလို့ရ
  • Interceptors - request/response တိုင်းကို ကြားထဲက ဝင်ကြည့်၊ ပြင်လို့ရ (logging, token ထည့်တာ, retry)
  • JSON decode - response ကို JSON အဖြစ် အလိုအလျောက် ပြောင်းပေး
  • DioException - error အမျိုးအစား (timeout, no internet, 4xx/5xx) ကို type နဲ့ ခွဲသိနိုင်

Interceptor တွေ, retry တွေ အပါအဝင် Dio ရဲ့ advanced အပိုင်းတွေကိုတော့ Day 44 မှာ ထပ်ပြီး အသေးစိတ် လေ့လာမှာ ဖြစ်ပါတယ်။ ဒီနေ့တော့ Pixly အတွက် လိုတာလောက်ကိုပဲ ကြည့်ပါမယ်။

fvm flutter pub add dio

ApiClient

// lib/core/api/api_client.dart
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:pixly/config/env.dart';
import 'package:pixly/core/api/api_exception.dart';

/// Thin wrapper around Dio configured for the Pexels API.
class ApiClient {
  ApiClient({Dio? dio, String? apiKey})
    : _dio = dio ?? Dio(_options(apiKey ?? Env.pexelsApiKey)) {
    if (dio == null && kDebugMode) {
      _dio.interceptors.add(
        LogInterceptor(
          requestHeader: false, // never print the API key
          responseHeader: false,
          responseBody: false,
        ),
      );
    }
  }

  static const String baseUrl = 'https://api.pexels.com/v1';

  final Dio _dio;

  static BaseOptions _options(String apiKey) {
    return BaseOptions(
      baseUrl: baseUrl,
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 10),
      headers: {'Authorization': apiKey},
    );
  }

  /// GET [path] and return the decoded JSON object.
  /// Throws [ApiException] when the request fails.
  Future<Map<String, dynamic>> get(
    String path, {
    Map<String, dynamic>? queryParameters,
  }) async {
    try {
      final response = await _dio.get<Map<String, dynamic>>(
        path,
        queryParameters: queryParameters,
      );
      return response.data ?? {};
    } on DioException catch (e) {
      throw ApiException.fromDio(e);
    }
  }
}

တပိုင်းချင်း ကြည့်ရအောင်ပါ။

BaseOptions

  • baseUrl - request တိုင်းရဲ့ ရှေ့က URL ပါ။ ဒါကြောင့် နောက်ပိုင်း '/curated' လို့ပဲ ရေးရင် https://api.pexels.com/v1/curated ကို ခေါ်သွားမှာပါ။
  • connectTimeout - server နဲ့ ချိတ်ဆက်တာ ၁၀ စက္ကန့်ထက် ကြာရင် error အဖြစ် ရပ်လိုက်ပါတယ်။
  • receiveTimeout - response data ကို စောင့်တဲ့အခါ (data အပိုင်း တခုနဲ့ တခုကြား အပါအဝင်) ၁၀ စက္ကန့်ထက် ဘာမှ မရောက်လာရင် error အဖြစ် ရပ်လိုက်ပါတယ်။ Request တခုလုံး ၁၀ စက္ကန့်အတွင်း ပြီးရမယ်လို့ ဆိုလိုတာ မဟုတ်ပါဘူး။ Data တွေ ဆက်တိုက် ဝင်နေသရွေ့ download က ၁၀ စက္ကန့်ထက် ကြာနိုင်ပါတယ်။

Timeout မထားရင် internet မကောင်းတဲ့အခါ user က loading ကို အကြာကြီး စောင့်နေရနိုင်ပါတယ်။ - headers: {'Authorization': apiKey} - Day 27 မှာ ပြောခဲ့သလို Pexels က key ကို Authorization header ထဲမှာ တိုက်ရိုက် ထည့်ခိုင်းပါတယ်။ Bearer မခံရပါဘူး။ BaseOptions မှာ ထည့်ထားတဲ့အတွက် request တိုင်းမှာ အလိုအလျောက် ပါသွားမှာပါ။

Constructor မှာ Dio ကို လက်ခံထားတာ

ApiClient({Dio? dio, String? apiKey}) ဆိုပြီး Dio ကို optional parameter အနေနဲ့ လက်ခံထားပါတယ်။ ပုံမှန်သုံးတဲ့အခါ ဘာမှ မပေးရင် အပေါ်က option တွေနဲ့ Dio အသစ် ဆောက်ပေးပါတယ်။ Test လုပ်တဲ့အခါကျတော့ internet မလိုတဲ့ fake Dio ကို ထည့်ပေးလို့ ရသွားပါတယ်။ ဒီလို dependency ကို အပြင်ကနေ ထည့်ပေးတဲ့ ပုံစံကို dependency injection လို့ ခေါ်ပါတယ်။

LogInterceptor - သတိထားစရာ

Development လုပ်တဲ့အခါ request တွေ ဘယ်လို သွားလဲ console မှာ မြင်ရရင် debug လုပ်ရ လွယ်ပါတယ်။ Dio မှာ ပါပြီးသား LogInterceptor ကို kDebugMode (debug build မှာပဲ true ဖြစ်တဲ့ constant) မှာပဲ ထည့်ထားပါတယ်။

သတိထားရမှာက LogInterceptor က default အနေနဲ့ request header တွေကိုပါ print ထုတ်ပါတယ်။ ဒါဆို Authorization header ထဲက API key ပါ console log ထဲ ရောက်သွားမှာပါ။ ဒါကြောင့် requestHeader: false လို့ ပိတ်ထားတာ ဖြစ်ပါတယ်။ Log တွေကို screenshot ရိုက်ပြီး group တွေမှာ မေးခွန်းမေးတဲ့အခါ key ပါ မပါသွားအောင် ဒီလို အသေးစိတ်လေးတွေက အရေးကြီးပါတယ်။

get() method

  • _dio.get<Map<String, dynamic>> - response ကို JSON object (Map) အဖြစ် ရမယ်လို့ Dio ကို ပြောထားတာပါ။
  • queryParameters - URL ရဲ့ ?page=2&per_page=20 အပိုင်းကို Map အနေနဲ့ ပေးလို့ ရပါတယ်။ Dio က URL encoding ကိုပါ လုပ်ပေးတဲ့အတွက် coffee & cake လို space တွေ, & တွေ ပါတဲ့ search keyword ကိုလဲ မှန်မှန်ကန်ကန် ပို့ပေးပါတယ်။ URL string ကို '/search?query=$query' လို ကိုယ်တိုင် ဆက်ရေးရင်တော့ & ပါတဲ့ keyword မှာ URL ပျက်သွားမှာ ဖြစ်ပါတယ်။
  • on DioException catch (e) - Dio က error တက်ရင် DioException throw လုပ်ပါတယ်။ ဒါကို UI က နားလည်လွယ်တဲ့ ApiException အဖြစ် ပြောင်းပြီး ပြန် throw လုပ်ပါတယ်။

ApiException - Error တွေကို ဘာသာပြန်ခြင်း

DioException ထဲမှာ developer တွေအတွက် အသေးစိတ် message တွေ ပါပေမယ့် user ကို တိုက်ရိုက် ပြဖို့ မသင့်ပါဘူး။ Day 14 မှာ လေ့လာခဲ့တဲ့ custom exception ကို သုံးပြီး user ကို ပြလို့ရတဲ့ message အဖြစ် ပြောင်းပါမယ်။

// lib/core/api/api_exception.dart
import 'package:dio/dio.dart';

/// A readable error the UI can show, created from a [DioException].
class ApiException implements Exception {
  const ApiException(this.message, {this.statusCode});

  final String message;
  final int? statusCode;

  factory ApiException.fromDio(DioException e) {
    final status = e.response?.statusCode;
    return switch (e.type) {
      .connectionTimeout || .sendTimeout || .receiveTimeout =>
        const ApiException('The connection timed out. Please try again.'),
      .connectionError => const ApiException(
        'Unable to connect. Check your network and try again.',
      ),
      .cancel => const ApiException('The request was cancelled.'),
      .badResponse => switch (status) {
        401 => ApiException(
          'Invalid API key. Check PEXELS_API_KEY in your .env file.',
          statusCode: status,
        ),
        429 => ApiException(
          'Too many requests. Please wait a moment and try again.',
          statusCode: status,
        ),
        _ => ApiException('Server error ($status).', statusCode: status),
      },
      _ => const ApiException('Something went wrong. Please try again.'),
    };
  }

  @override
  String toString() => 'ApiException($statusCode): $message';
}

ဒီမှာ Day 4 က switch expression နဲ့ Dart 3.10 dot shorthand ကို ပေါင်းသုံးထားပါတယ်။ e.type က DioExceptionType enum ဖြစ်တဲ့အတွက် DioExceptionType.connectionTimeout လို့ အရှည်ကြီး ရေးစရာ မလိုဘဲ .connectionTimeout လို့ပဲ ရေးလို့ ရပါတယ်။ || နဲ့ pattern တွေကို ပေါင်းပြီး timeout အမျိုးအစား ၃ ခုလုံးကို message တခုတည်းနဲ့ ကိုင်တွယ်ထားပါတယ်။

DioExceptionType ဘာဖြစ်တာလဲ
connectionTimeout, sendTimeout, receiveTimeout Timeout ထက် ကြာသွား
connectionError Server ကို ချိတ်လို့ မရ (internet မရှိတာ၊ domain ရှာမတွေ့တာ၊ server က connection ငြင်းတာ စတာတွေ)
badResponse Server က ပြန်ဖြေပေမယ့် status code က error (4xx, 5xx)
cancel Request ကို ကိုယ်တိုင် cancel လုပ်လိုက်
badCertificate, unknown SSL ပြဿနာ နဲ့ အခြား error တွေ (_ နဲ့ ဖမ်းထား)

badResponse ထဲမှာ Pexels အတွက် အရေးကြီးတဲ့ status code ၂ ခုကို သီးသန့် ကိုင်တွယ်ထားပါတယ်။

  • 401 - API key မှားနေတာ ဒါမှမဟုတ် မပါတာ
  • 429 - Day 25 မှာ ပြောခဲ့တဲ့ rate limit (default အနေနဲ့ တနာရီ request 200 နဲ့ တလ request 20,000) တခုခု ကျော်သွားတာ

Result Pattern

Repository က error တက်ရင် exception throw လုပ်မယ့်အစား Day 9 မှာ လေ့လာခဲ့တဲ့ Result pattern နဲ့ ပြန်ပေးပါမယ်။

// lib/core/result.dart
/// Either a value or an error message - the Result pattern from Day 9.
sealed class Result<T> {
  const Result();
}

class Success<T> extends Result<T> {
  const Success(this.data);
  final T data;
}

class Failure<T> extends Result<T> {
  const Failure(this.message);
  final String message;
}

sealed class ဖြစ်တဲ့အတွက် switch နဲ့ Success case တခုတည်းကိုပဲ ရေးပြီး Failure case ကို မေ့ကျန်ခဲ့ရင် compiler က "switch က exhaustive မဖြစ်ဘူး" ဆိုပြီး error ပြပေးပါတယ်။ (_ wildcard နဲ့ ဖမ်းထားရင်တော့ compiler က မသိနိုင်ပါဘူး။ ဒါကြောင့် case တခုချင်းစီကို သီးသန့် ရေးတာ ပိုကောင်းပါတယ်။) Day 29 မှာ Provider ကနေ ဒီ Result ကို ဘယ်လို သုံးလဲ မြင်ရမှာပါ။

PhotoRepository

// lib/repositories/photo_repository.dart
import 'package:pixly/core/api/api_client.dart';
import 'package:pixly/core/api/api_exception.dart';
import 'package:pixly/core/result.dart';
import 'package:pixly/models/photos_page.dart';

/// Everything Pixly needs from Pexels, returned as [Result]s.
class PhotoRepository {
  PhotoRepository(this._client);

  final ApiClient _client;

  static const int perPage = 20;

  Future<Result<PhotosPage>> curated({int page = 1}) {
    return _fetch('/curated', {'page': page, 'per_page': perPage});
  }

  Future<Result<PhotosPage>> search(String query, {int page = 1}) {
    return _fetch('/search', {
      'query': query,
      'page': page,
      'per_page': perPage,
    });
  }

  Future<Result<PhotosPage>> _fetch(
    String path,
    Map<String, dynamic> query,
  ) async {
    try {
      final json = await _client.get(path, queryParameters: query);
      return Success(PhotosPage.fromJson(json));
    } on ApiException catch (e) {
      return Failure(e.message);
    } on TypeError {
      // The JSON didn't have the shape our models expect.
      return const Failure('Unexpected response from the server.');
    }
  }
}

Repository က Pixly ရဲ့ data လိုအပ်ချက်တွေကို နာမည်ကောင်းကောင်းနဲ့ method တွေအဖြစ် ထုတ်ပေးထားတာ ဖြစ်ပါတယ်။

  • curated(page: 2) - Pexels ရဲ့ curated photos page 2
  • search('cat', page: 1) - "cat" နဲ့ ရှာထားတဲ့ ရလဒ်

perPage ကို 20 ထားပါတယ်။ Pexels ရဲ့ default က page တခုမှာ ၁၅ ပုံ ဖြစ်ပြီး အများဆုံး ၈၀ ထိ တောင်းလို့ ရပါတယ်။ Phone screen တခုစာ grid ကို ပြည့်ပြီး scroll ဆွဲစရာ ကျန်အောင် ၂၀ ယူထားတာ ဖြစ်ပါတယ်။ (Request တခါမှာ ပုံ အများကြီး တောင်းလိုက်ရင် rate limit ကိုလဲ သက်သာစေပါတယ်။)

_fetch ထဲမှာတော့ ApiClient က ပြန်ပေးတဲ့ JSON ကို Day 27 က PhotosPage.fromJson နဲ့ model ပြောင်းပြီး Success ထဲ ထည့်ပေးပါတယ်။ Error ၂ မျိုးကို Failure အဖြစ် ပြောင်းပေးထားပါတယ်။

  • ApiException - timeout, connection error, 401, 429 စတဲ့ network error တွေ
  • TypeError - server က ပြန်ပေးတဲ့ JSON ပုံစံက ကိုယ်မျှော်လင့်ထားတာနဲ့ မတူတဲ့အခါ (ဥပမာ id နေရာမှာ number အစား string ပါလာတာ) Day 27 ရဲ့ as int လို cast တွေက TypeError throw လုပ်ပါတယ်။ ဒါကို မဖမ်းထားရင် app ရဲ့ UI အထိ error ပေါက်သွားမှာ ဖြစ်ပါတယ်။

ဒါကြောင့် Repository ကို သုံးမယ့်သူ (Day 29 က Provider) က ဒီ error တွေအတွက် try/catch ရေးစရာ မလိုတော့ဘဲ Result ကို စစ်ရုံပါပဲ။

Network မလိုဘဲ စမ်းကြည့်ခြင်း

Dio မှာ request တွေကို တကယ် ပို့ပေးတဲ့ အပိုင်းကို adapter လို့ ခေါ်ပါတယ်။ Test တွေမှာ adapter ကို internet မသုံးဘဲ Day 27 က sample JSON ကို ပြန်ပေးတဲ့ fake adapter နဲ့ အစားထိုးပြီး ApiClient နဲ့ Repository ကို စမ်းထားပါတယ်။

// test/api_test.dart (excerpt)
test('search() URL-encodes the query', () async {
  final (repo, adapter) = build(
    (_) => FakeAdapter.json(FakeAdapter.fixture('search_nature')),
  );

  await repo.search('coffee & cake');

  final uri = adapter.requests.single.uri;
  expect(uri.path, '/v1/search');
  expect(uri.queryParameters['query'], 'coffee & cake');
  expect(uri.query, contains('query=coffee+%26+cake'));
});

ဒီ test တွေက

  • curated() က /v1/curated ကို page, per_page နဲ့ ခေါ်လား၊ Authorization header မှာ key ကို Bearer မပါဘဲ ပို့လား
  • search keyword ကို URL encode လုပ်လား
  • 401, 429, 500 status တွေ, timeout, connection error တွေကို message မှန်မှန်ကန်ကန် ပြောင်းပေးလား
  • JSON ပုံစံ မှားနေရင် crash မဖြစ်ဘဲ Failure ပြန်ပေးလား

ဆိုတာတွေကို စစ်ပေးပါတယ်။ FakeAdapter နဲ့ build() helper ကတော့ project ရဲ့ test/helpers/fake_adapter.dart နဲ့ test/api_test.dart ထဲမှာ ရှိပါတယ်။ Test ရေးနည်း အသေးစိတ်ကိုတော့ Day 86 မှာ ပြောပါမယ်။

ဒီနေ့ ပြီးသွားတဲ့အခါ project ထဲမှာ

lib/
├── core/
│   ├── api/
│   │   ├── api_client.dart
│   │   └── api_exception.dart
│   └── result.dart
├── repositories/
│   └── photo_repository.dart
└── ...

ဆိုပြီး network layer တခုလုံး အသင့်ဖြစ်နေပါပြီ။ UI မှာ ဓာတ်ပုံတွေ ပေါ်လာဖို့ကတော့ မနက်ဖြန် Day 29 မှာ Provider နဲ့ ချိတ်ဆက်ပါမယ်။

Source code: ဒီနေ့ရဲ့ code အပြည့်အစုံကို GitHub မှာ run ကြည့်လို့ ရပါတယ်။


ဒီနေ့ Day 28 အတွက်ကတော့ ဒီလောက်ပဲ ဖြစ်ပါတယ်။ အဆုံးထိ ဖတ်ပေးတဲ့အတွက် အများကြီး ကျေးဇူးတင်ပါတယ်။ နားမလည်တာတွေ အဆင်မပြေတာတွေ ရှိခဲ့ရင်လဲ အောက်မှာပေးထားတဲ့ discord server ထဲမှာ လာရောက်ဆွေးနွေးနိုင်ပါတယ်။ နောက်နေ့တွေမှာလဲ ဆက်လက်ပြီး sharing လုပ်သွားပေးသွားမှာ ဖြစ်တဲ့အတွက် subscribe လုပ်ထားဖို့ ဖိတ်ခေါ်ပါတယ်။