İçeriğe geç / Skip to content / Zum Inhalt
Ahmet Balaman LogoAhmet Balaman

Flutter: HTTP İstekleri ve REST API Kullanımı

Ahmet Balaman

7 dk okuma

FlutterHTTPREST APIJSONAsyncFutureBuilder
Flutter: HTTP İstekleri ve REST API Kullanımı

Gerçek bir uygulamanın verisi neredeyse her zaman bir sunucudan gelir: ürün listesi, kullanıcı profili, mesajlar. Flutter'da bu işin en sade aracı, Dart ekibinin yayımladığı http paketi. Bu yazıda bir REST API'ye GET, POST, PUT ve DELETE isteği atmayı, gelen JSON'u model sınıfına çevirmeyi, durum kodlarını ve zaman aşımını doğru ele almayı, sonra da hepsini "yükleniyor, hata, veri" durumlarını gösteren bir ekrana bağlamayı adım adım kuruyoruz. Örneklerde, gerçek bir sunucu kurmadan deneme yapabilmen için JSONPlaceholder adlı sahte API'yi kullanıyorum.

Başlamadan önce async, await ve Future kavramlarının oturmuş olması işini çok kolaylaştırır; emin değilsen önce Dart'ta asenkron işlemler ve hata yönetimi yazısına göz at.

Kurulum ve İzinler

Paketi projene ekle:

flutter pub add http

Kodda paketi bir önekle içe aktarmak yaygın bir alışkanlıktır; böylece get, post gibi kısa isimler kendi fonksiyonlarınla çakışmaz:

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

Android'de uygulamanın internete çıkabilmesi için android/app/src/main/AndroidManifest.xml dosyasında INTERNET izni olmalı. Flutter'ın ağ dokümanı bu satırı açıkça ekletir:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />
    <application ...>

macOS hedefliyorsan DebugProfile.entitlements ve Release.entitlements dosyalarına com.apple.security.network.client anahtarını da eklemen gerekir. iOS için ekstra izin yok, ama adresin https olması gerekir (aşağıdaki hatalar bölümünde değiniyorum).

İlk GET İsteği

En basit hâliyle bir istek şöyle görünür:

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

Future<void> fetchFirstPost() async {
  final uri = Uri.https('jsonplaceholder.typicode.com', '/posts/1');
  final response = await http.get(uri);

  if (response.statusCode == 200) {
    final json = jsonDecode(response.body) as Map<String, dynamic>;
    print(json['title']);
  } else {
    print('İstek başarısız: ${response.statusCode}');
  }
}

Üç parça var. Uri.https adresi güvenli biçimde kurar; sorgu parametrelerini üçüncü argüman olarak verirsen ({'userId': '1'}) özel karakterleri senin yerine kodlar. http.get bir Future<Response> döndürür; response.statusCode sunucunun cevap kodunu, response.body ise gövdeyi metin olarak taşır. jsonDecode da bu metni Dart nesnelerine çevirir: JSON nesnesi Map<String, dynamic>, JSON dizisi List<dynamic> olur.

JSON'dan Model Sınıfına: fromJson ve toJson

json['title'] gibi string anahtarlarla her yerde çalışmak, bir yazım hatasının derleme zamanında değil çalışırken patlaması demektir. Bunun yerine veriyi bir kez model sınıfına çevirip uygulamanın geri kalanında tipli nesnelerle çalışırız:

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

  final int id;
  final int userId;
  final String title;
  final String body;

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

  Map<String, dynamic> toJson() => {
        'id': id,
        'userId': userId,
        'title': title,
        'body': body,
      };

  Post copyWith({String? title, String? body}) => Post(
        id: id,
        userId: userId,
        title: title ?? this.title,
        body: body ?? this.body,
      );
}

fromJson sunucudan gelen veriyi nesneye, toJson ise nesneyi sunucuya gidecek yapıya çevirir. copyWith, alanları final olan bir nesnenin değiştirilmiş kopyasını üretir; güncelleme isteğinde işimize yarayacak.

Bir ayrıntıya dikkat: json['id'] as int satırı, sunucu id'yi beklenmedik biçimde metin olarak gönderirse bir TypeError fırlatır. Bu bir Exception değil, Error'dır; yani on Exception catch bloğu onu yakalamaz. Dart 3'ün desen eşleme (pattern matching) özelliğiyle yapıyı tek seferde kontrol edip anlamlı bir hata üretebilirsin. Flutter dokümanındaki örnekler de bu yolu kullanıyor:

factory Post.fromJson(Map<String, dynamic> json) {
  return switch (json) {
    {
      'id': int id,
      'userId': int userId,
      'title': String title,
      'body': String body,
    } =>
      Post(id: id, userId: userId, title: title, body: body),
    _ => throw const FormatException('Post JSON beklenen yapıda değil'),
  };
}

Model sayısı ve alan sayısı arttıkça bu kodu elle yazmak yorucu hâle gelir. O noktada json_serializable gibi kod üreten paketlere geçmek mantıklı; mantık aynı, sadece fromJson ve toJson senin yerine üretilir.

POST, PUT ve DELETE

REST'te işlemler HTTP metoduyla ifade edilir: GET okur, POST yeni kayıt oluşturur, PUT mevcut kaydı bütünüyle günceller, DELETE siler. Gövde gönderen isteklerde iki şeyi unutmamak gerekir: veriyi jsonEncode ile metne çevirmek ve Content-Type başlığıyla sunucuya bunun JSON olduğunu söylemek.

const headers = {'Content-Type': 'application/json; charset=UTF-8'};

// POST: yeni kayıt
final created = await http.post(
  Uri.https('jsonplaceholder.typicode.com', '/posts'),
  headers: headers,
  body: jsonEncode({'userId': 1, 'title': 'Merhaba', 'body': 'İlk yazım'}),
);
print(created.statusCode); // 201 Created

// PUT: kaydı güncelle
final updated = await http.put(
  Uri.https('jsonplaceholder.typicode.com', '/posts/${post.id}'),
  headers: headers,
  body: jsonEncode(post.copyWith(title: 'Yeni başlık').toJson()),
);
print(updated.statusCode); // 200 OK

// DELETE: kaydı sil
final deleted = await http.delete(
  Uri.https('jsonplaceholder.typicode.com', '/posts/${post.id}'),
);
print(deleted.statusCode); // 200 OK

JSONPlaceholder bu istekleri gerçekten kaydetmez, sadece kaydetmiş gibi cevap verir: POST sonrası sana id: 101 olan bir nesne döner ama listeyi tekrar çektiğinde onu göremezsin. Deneme için ideal, yalnızca bunu hata sanma. Kaydın yalnızca bazı alanlarını değiştiren http.patch de aynı şekilde kullanılır; hangisinin desteklendiğini API'nin dokümanı söyler.

Durum Kodları ve Hata Yönetimi

Bir HTTP isteği iki farklı şekilde başarısız olabilir ve ikisini ayırt etmek önemlidir:

  1. İstek sunucuya hiç ulaşamaz. İnternet yoktur, alan adı çözülemez, bağlantı kopar. Bu durumda http.get bir istisna fırlatır; http paketi bunu http.ClientException olarak verir.
  2. Sunucu cevap verir ama cevap "hata"dır. 404, 401, 500 gibi kodlar istisna fırlatmaz. response normal biçimde gelir, sadece statusCode 2xx aralığında değildir. Kontrolü senin yapman gerekir.

Yeni başlayanların en sık yaptığı hata ikinci durumu atlamaktır: statusCode'a bakmadan jsonDecode(response.body) yazmak. 404 sayfası çoğu zaman HTML döner ve jsonDecode bir FormatException fırlatır; ya da sunucunun hata JSON'u modele çevrilmeye çalışılır ve alakasız bir tip hatası alırsın. Sık göreceğin kodların kabaca anlamı şöyle:

Kod Anlamı Uygulamada ne yaparsın?
200, 201, 204 Başarılı (204'te gövde boştur) Veriyi işle
400 İstek hatalı Gönderdiğin veriyi kontrol et
401, 403 Kimlik doğrulanamadı / yetki yok Kullanıcıyı girişe yönlendir
404 Kaynak yok "Bulunamadı" göster
500, 502, 503 Sunucu tarafında sorun "Daha sonra tekrar dene" göster

Zaman Aşımı

http.get ve diğer fonksiyonların bir zaman aşımı parametresi yok; sunucu yavaşsa ya da bağlantı asılı kalırsa kullanıcı uzun süre spinner'a bakıp durur. Dart'ın her Future'da bulunan timeout metodu burada işe yarar:

final response = await http
    .get(uri)
    .timeout(const Duration(seconds: 10));

Süre dolarsa dart:async içindeki TimeoutException fırlar. Bunu da yakalayıp kullanıcıya anlaşılır bir mesaj göstermek gerekir; bir sonraki bölümde hepsini tek yerde topluyoruz.

İstekleri Bir Servis Sınıfında Toplamak

Her ekranın kendi http.get çağrısını yazması, hata ve zaman aşımı kontrolünün her yerde tekrar etmesi demektir. Bunun yerine istekleri tek bir sınıfta toplarız. Aynı sunucuya birden fazla istek atacaksan paketin dokümanının önerdiği gibi bir http.Client nesnesi tutup işin bitince close() ile kapatmak da bağlantının yeniden kullanılmasını sağlar:

import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;

class ApiException implements Exception {
  const ApiException(this.message, {this.statusCode});

  final String message;
  final int? statusCode;

  @override
  String toString() => message;
}

class PostApi {
  PostApi({http.Client? client}) : _client = client ?? http.Client();

  static const _host = 'jsonplaceholder.typicode.com';
  static const _timeout = Duration(seconds: 10);
  static const _headers = {
    'Content-Type': 'application/json; charset=UTF-8',
    'Accept': 'application/json',
  };

  final http.Client _client;

  Future<List<Post>> fetchPosts({int? userId}) async {
    final uri = Uri.https(
      _host,
      '/posts',
      userId == null ? null : {'userId': '$userId'},
    );
    final data = await _send(() => _client.get(uri, headers: _headers));
    return (data as List<dynamic>)
        .map((e) => Post.fromJson(e as Map<String, dynamic>))
        .toList();
  }

  Future<Post> createPost({
    required int userId,
    required String title,
    required String body,
  }) async {
    final data = await _send(
      () => _client.post(
        Uri.https(_host, '/posts'),
        headers: _headers,
        body: jsonEncode({'userId': userId, 'title': title, 'body': body}),
      ),
    );
    return Post.fromJson(data as Map<String, dynamic>);
  }

  Future<Post> updatePost(Post post) async {
    final data = await _send(
      () => _client.put(
        Uri.https(_host, '/posts/${post.id}'),
        headers: _headers,
        body: jsonEncode(post.toJson()),
      ),
    );
    return Post.fromJson(data as Map<String, dynamic>);
  }

  Future<void> deletePost(int id) async {
    await _send(() => _client.delete(Uri.https(_host, '/posts/$id')));
  }

  /// Ortak akış: zaman aşımı, bağlantı hatası, durum kodu ve JSON çözme.
  Future<Object?> _send(Future<http.Response> Function() request) async {
    final http.Response response;
    try {
      response = await request().timeout(_timeout);
    } on TimeoutException {
      throw const ApiException('Sunucu zamanında yanıt vermedi.');
    } on http.ClientException {
      throw const ApiException('Bağlantı kurulamadı, internetini kontrol et.');
    }

    final code = response.statusCode;
    if (code == 401) {
      throw const ApiException('Oturumun sona ermiş.', statusCode: 401);
    }
    if (code == 404) {
      throw const ApiException('Kayıt bulunamadı.', statusCode: 404);
    }
    if (code < 200 || code >= 300) {
      throw ApiException('Sunucu hatası ($code).', statusCode: code);
    }

    if (response.bodyBytes.isEmpty) return null; // Örneğin 204 No Content
    try {
      return jsonDecode(utf8.decode(response.bodyBytes));
    } on FormatException {
      throw const ApiException('Sunucudan beklenmeyen bir yanıt geldi.');
    }
  }

  void close() => _client.close();
}

Bu sınıfın getirdiği üç kazanç var. Ekranlar artık yalnızca ApiException ile ilgileniyor; zaman aşımı mı, bağlantı mı, 404 mü, ayrımını tek yer yapıyor. http.Client'ı constructor'dan alabildiği için testte gerçek sunucu yerine package:http/testing.dart içindeki MockClient verilebiliyor. Gövdeyi utf8.decode(response.bodyBytes) ile çözmek de Türkçe karakterlerin, sunucu karakter setini belirtmese bile doğru görünmesini garantiliyor.

Mini Senaryo: Yazılar Ekranı

Şimdi servisi bir ekrana bağlayalım. Açılışta kullanıcının yazıları gelir, yüklenirken spinner döner, hata olursa mesaj ve "Tekrar dene" butonu çıkar, sağ alttaki butonla yeni yazı gönderilir:

import 'package:flutter/material.dart';

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

  @override
  State<PostsPage> createState() => _PostsPageState();
}

class _PostsPageState extends State<PostsPage> {
  final _api = PostApi();
  late Future<List<Post>> _postsFuture;

  @override
  void initState() {
    super.initState();
    _postsFuture = _api.fetchPosts(userId: 1);
  }

  @override
  void dispose() {
    _api.close();
    super.dispose();
  }

  void _reload() {
    setState(() => _postsFuture = _api.fetchPosts(userId: 1));
  }

  Future<void> _addPost() async {
    final messenger = ScaffoldMessenger.of(context);
    try {
      final post = await _api.createPost(
        userId: 1,
        title: 'Yeni yazı',
        body: 'Flutter ile gönderildi',
      );
      messenger.showSnackBar(
        SnackBar(content: Text('Oluşturuldu, id: ${post.id}')),
      );
    } on ApiException catch (e) {
      messenger.showSnackBar(SnackBar(content: Text(e.message)));
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Yazılar'),
        actions: [
          IconButton(onPressed: _reload, icon: const Icon(Icons.refresh)),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _addPost,
        child: const Icon(Icons.add),
      ),
      body: FutureBuilder<List<Post>>(
        future: _postsFuture,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }
          if (snapshot.hasError) {
            return Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text('${snapshot.error}'),
                  const SizedBox(height: 12),
                  FilledButton(
                    onPressed: _reload,
                    child: const Text('Tekrar dene'),
                  ),
                ],
              ),
            );
          }
          final posts = snapshot.data ?? const <Post>[];
          if (posts.isEmpty) {
            return const Center(child: Text('Henüz yazı yok'));
          }
          return ListView.separated(
            itemCount: posts.length,
            separatorBuilder: (context, index) => const Divider(height: 1),
            itemBuilder: (context, index) {
              final post = posts[index];
              return ListTile(
                title: Text(post.title),
                subtitle: Text(
                  post.body,
                  maxLines: 2,
                  overflow: TextOverflow.ellipsis,
                ),
              );
            },
          );
        },
      ),
    );
  }
}

Dikkat edilecek kararlar şunlar. Future initState içinde bir kez oluşturuluyor; build içinde _api.fetchPosts() çağırsaydık her yeniden çizimde yeni bir istek giderdi. Bu hatanın ayrıntısını FutureBuilder yazısında anlattım. Hata mesajı doğrudan ApiException'ın toString çıktısından geliyor, bu yüzden kullanıcı teknik bir yığın izi değil okunabilir bir cümle görüyor. _addPost içinde ScaffoldMessenger await'ten önce alınıyor; böylece istek sürerken kullanıcı sayfadan çıksa bile artık geçersiz bir context kullanılmıyor. Liste kısmı için ListView yazısına, mesaj için SnackBar yazısına bakabilirsin.

Aynı veriyi birden fazla ekranda paylaşacaksan ya da ekleme ve silme sonrası listeyi güncel tutmak istiyorsan bu FutureBuilder yapısı yetmez; servisi Provider ile bir ChangeNotifier'a ya da Riverpod ile bir provider'a taşımak daha doğru olur. PostApi sınıfı iki durumda da olduğu gibi kullanılır.

dio Ne Zaman Mantıklı?

http paketi bilerek küçük tutulmuş bir pakettir. Daha kalabalık projelerde sık tercih edilen dio ise bazı işleri hazır getirir: BaseOptions ile ortak adres ve bağlantı zaman aşımı, her isteğe otomatik token ekleyen interceptor'lar, istek iptali için CancelToken, dosya yükleme ve indirmede ilerleme bilgisi. JSON yanıtını da response.data içinde çözülmüş olarak verir ve 2xx dışındaki kodlarda varsayılan olarak DioException fırlatır.

Tek bir API'ye birkaç istek atan bir uygulama için http artı yukarıdaki gibi küçük bir servis sınıfı yeterlidir ve neler olduğunu açıkça görürsün. Token yenileme, merkezi loglama ya da büyük dosya aktarımı gibi ihtiyaçlar başladığında dio'ya geçmek, aynı şeyleri elle yazmaktan daha az iş çıkarır. Kavramlar (metot, başlık, gövde, durum kodu) iki pakette de aynıdır.

Sık Yapılan Hatalar

1. Map'i jsonEncode etmeden göndermek

body: {'title': 'Merhaba'} yazarsan http bunu JSON değil, form verisi (application/x-www-form-urlencoded) olarak gönderir. Sunucu 400 ya da 415 döndürür veya alanları boş görür. JSON bekleyen bir API'ye her zaman body: jsonEncode(...) ve 'Content-Type': 'application/json' başlığıyla gönder.

2. Durum kodunu kontrol etmeden jsonDecode çağırmak

Belirti: FormatException: Unexpected character (at character 1) <!DOCTYPE html>. Sunucu hata verip HTML sayfası döndürmüş, sen de onu JSON sanmışsın. Önce statusCode'a bak, sonra çöz.

3. Debug'da çalışan uygulamanın release'de internete çıkamaması

Belirti: Geliştirme sırasında her şey yolunda, mağazaya yüklenen sürümde istekler hiç çalışmıyor. flutter create ile gelen şablonda INTERNET izni yalnızca android/app/src/debug/ ve profile/ altındaki manifest dosyalarında bulunur; hot reload ve hata ayıklayıcının çalışması için oradadır. Release derlemesi main klasöründeki manifest'i kullanır. İzni oraya eklemeyi unutma.

4. Emülatörden localhost'a istek atmak ve düz http kullanmak

Kendi bilgisayarında bir API çalıştırıyorsan (örneğin ASP.NET Core Minimal API ile yazdığın bir servis), Android emülatörü içinde localhost emülatörün kendisini gösterir. Bilgisayarına 10.0.2.2 adresiyle ulaşırsın. Ayrıca Android 9 ve sonrası ile iOS, varsayılan olarak şifresiz http:// bağlantılara izin vermez. Geliştirme sırasında bu kısıtı gevşetebilirsin, ama yayındaki uygulama her zaman https kullanmalı.

5. Future'ı build içinde oluşturmak

FutureBuilder(future: api.fetchPosts(), ...) yazmak, klavye her açıldığında ya da setState her çağrıldığında aynı isteği yeniden atar. FutureinitState içinde bir kez oluştur ve bir alanda sakla.

Sık Sorulan Sorular

İsteklere token nasıl eklenir, token nerede saklanmalı?

Token'ı her istekte headers içinde 'Authorization': 'Bearer $token' biçiminde gönderirsin; servis sınıfı kullanıyorsan bunu ortak başlıklara eklemek yeterli. Token'ı saklamak için SharedPreferences uygun değildir, çünkü veri şifrelenmez. Neyin nerede saklanacağını SharedPreferences ile yerel veri saklama yazısında ayrıntılı anlattım; kısa cevap: flutter_secure_storage.

Flutter web'de aynı istek neden CORS hatası veriyor?

Flutter web, isteği tarayıcının içinden atar ve tarayıcı, başka bir alan adına yapılan isteklerde sunucunun CORS başlıklarıyla izin vermesini bekler. Mobilde bu kontrol yoktur, bu yüzden aynı kod telefonda çalışıp web'de takılabilir. Çözüm Flutter tarafında değil, sunucu tarafındadır: API'nin uygulamanın çalıştığı adrese izin veren CORS ayarı yapılması gerekir.

Büyük JSON'ları çözerken arayüz takılırsa ne yapmalıyım?

jsonDecode ve model dönüşümü ana izolatta çalışır. Birkaç yüz kayıtta fark edilmez, ama çok büyük yanıtlarda animasyonlar kısa süre donabilir. Flutter dokümanı bu durumda ayrıştırmayı compute fonksiyonuyla (ya da Isolate.run ile) ayrı bir izolata taşımayı önerir; fonksiyon üst seviye ya da statik olmalı ve yalnızca gövde metnini alıp model listesini döndürmelidir.

Yorumlar