Flutter: Requisições HTTP

Carlos Costa • 13/11/2024 Nesse tutorial vamos entender como fazer requisições HTTP no Flutter, veremos todas as etapas necessárias para consumir dados de uma API e exibir na nossa UI

Requisitando os dados de uma API


Para começar, faremos uma requisição básica usando o pacote http, usaremos a API do JSONPlaceholder para consumir alguns dados fictícios.

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

sealed class Service {

  // client HTTP
  HttpClient client = HttpClient();

  static Future<http.Response> fetchUsers() async {

    // URL da API
    var url = Uri.parse("https://jsonplaceholder.typicode.com/users");

    var response = await http.get(url);

    return response;
  }
}

Aqui temos um método que faz uma requisição para a rota /users.

Quando printamos o body e o tipo da resposta, temos o seguinte resultado:

FilledButton(
  onPressed: () async {
    var response = await Service.fetchUsers();

    debugPrint(response.body.runtimeType.toString());
    debugPrint(response.body);
  },
  child: const Text('Request'),
)
👇
String
[
  {
    "id": 1,
    "name": "Leanne Graham",
    "username": "Bret",
    "email": "Sincere@april.biz",
    "address": {
      "street": "Kulas Light",
      "suite": "Apt. 556",
      "city": "Gwenborough",
      "zipcode": "92998-3874",
      "geo": {
        "lat": "-37.3159",
        "lng": "81.1496"
      }
    }
    "company": {
      "name": "Romaguera-Crona",
      "catchPhrase": "Multi-layered client-server neural-net",
      "bs": "harness real-time e-markets"
    }
  },
],

Recebemos uma resposta do tipo String, então não vamos conseguir manipular esses dados diretamente como desejamos, nossa resposta vai precisar passar por mais algumas etapas até ser consumida pela nossa UI.

Serialização e Deserialização


De forma resumida:

Serialização: é o processo de transformar um objeto em uma string

Deserialização: é o processo de transformar uma string em um objeto

Agora faremos mais algumas mudanças no nosso método getUsers().

sealed class Service {
  HttpClient client = HttpClient();

  static Future<(http.Response, dynamic)> fetchUsers() async {
    var url = Uri.parse("https://jsonplaceholder.typicode.com/users");

    var response = await http.get(url);

    //deserializing reponse body
    var users = jsonDecode(response.body);

    return (response, users);
  }
}

Aqui, além de retornar a resposta, também retornamos o body da resposta deserializado. Para deserializar o body utilizamos a função jsonDecode() que transforma a String em um objeto to tipo _Map<String, dynamic>.

O método jsonDecode() é uma função do pacote dart:convert que transforma a String em um map to tipo _Map<String, dynamic>. É necessário que a string esteja em formato JSON ou receberemos uma exceção.

Agora vamos imprimir o runtimeType do body deserializado.

FilledButton(
  onPressed: () async {
    var (response, users) = await Service.fetchUsers();

    debugPrint('Response type: ${response.runtimeType}');
    debugPrint('Users type: ${users.runtimeType}');

    users.forEach((user) {
      debugPrint('User type: ${user.runtimeType}');
    });
  },
  child: const Text('Request'),
)
👇
Response type: Response
Users type: List<dynamic>
User type: _Map<String, dynamic>

Tivermos um avanço com a nossa requisição, mas ainda não é o suficiente, precisamos transformar esses dados em objetos do tipo User.

Adaptação dos dados


Para transformar os objetos do tipo _Map<String, dynamic> em objetos do tipo User, vamos precisar fazer o mapeamento das classes referentes aos dados que recebemos na requisição.

Observando o resultado da requisição:

{
  "id": 1,
  "name": "Leanne Graham",
  "username": "Bret",
  "email": "Sincere@april.biz",
  "address": {
    "street": "Kulas Light",
    "suite": "Apt. 556",
    "city": "Gwenborough",
    "zipcode": "92998-3874",
    "geo": {
      "lat": "-37.3159",
      "lng": "81.1496"
    }
  }
  "company": {
    "name": "Romaguera-Crona",
    "catchPhrase": "Multi-layered client-server neural-net",
    "bs": "harness real-time e-markets"
  }
}

Podemos extrair as seguintes classes: User, Company, Address e Geo.

Criando a classe User:

import 'package:json_annotation/json_annotation.dart';
import '/modules/http_request/models/address/address.dart';
import '/modules/http_request/models/company/company.dart';

part 'user.g.dart';

@JsonSerializable(explicitToJson: true)
class User {
  final String name;
  final String username;
  final String email;
  final String phone;
  final String website;
  final Address address;
  final Company company;

  User({
    required this.name,
    required this.username,
    required this.email,
    required this.phone,
    required this.website,
    required this.address,
    required this.company,
  });

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

No exemplo anterior utilizamos a biblioteca json_serializable para gerar o código necessário para serializar e deserializar a classe.

Para mais detalhes veja: json_serializable e json_annotation.

Na classe User temos a seguinte situação:

  • • A classe User é anotada com @JsonSerializable(explicitToJson: true). Essa anotação é necessária para que o build runner possa gerar o código necessário para serializar e deserializar a classe.
  • • o método fromJson() é responsável por criar uma instância da classe a partir de um Map com os atributos necessários.
  • • o método toJson() é responsável por transformar uma instância da classe em um Map com os atributos do objeto.
  • user.g.dart é o arquivo gerado pelo build runner. Esse arquivo contém o código necessário para serializar e deserializar a classe.

As outras classes serão criadas seguindo o mesmo padrão.

//address.dart

import 'package:json_annotation/json_annotation.dart';
import '/modules/http_request/models/geo/geo.dart';

part 'address.g.dart';

@JsonSerializable(explicitToJson: true)
class Address {
  final String street;
  final String suite;
  final String city;
  final String zipcode;
  final Geo geo;

  Address({
    required this.street,
    required this.suite,
    required this.city,
    required this.zipcode,
    required this.geo,
  });

  factory Address.fromJson(Map<String, dynamic> json) =>
      _$AddressFromJson(json);

  Map<String, dynamic> toJson() => _$AddressToJson(this);
}

//company.dart

import 'package:json_annotation/json_annotation.dart';

part 'company.g.dart';

@JsonSerializable(explicitToJson: true)
class Company {
  final String name;
  final String catchPhrase;
  final String bs;

  Company({
    required this.name,
    required this.catchPhrase,
    required this.bs,
  });

  factory Company.fromJson(Map<String, dynamic> json) =>
      _$CompanyFromJson(json);

  Map<String, dynamic> toJson() => _$CompanyToJson(this);
}
//geo.dart

import 'package:json_annotation/json_annotation.dart';

part 'geo.g.dart';

@JsonSerializable(explicitToJson: true)
class Geo {
  final String lat;
  final String lng;

  Geo({
    required this.lat,
    required this.lng,
  });

  factory Geo.fromJson(Map<String, dynamic> json) => _$GeoFromJson(json);

  Map<String, dynamic> toJson() => _$GeoToJson(this);
}

Fazendo mais uma mudança no método fetchUsers():

sealed class Service {
  HttpClient client = HttpClient();

  static Future<(http.Response, List<User>)> fetchUsers() async {
    var url = Uri.parse("https://jsonplaceholder.typicode.com/users");

    var response = await http.get(url);

    //check if the response is not 200
    if (response.statusCode != 200) {
      throw Exception("Error while fetching data");
    }

    //deserializes the response body
    var body = jsonDecode(response.body);

    var users = <User>[];

    for (var user in body) {
      //add the user to the list
      users.add(User.fromJson(user));
    }

    return (response, users);
  }
}

Adicionamos uma verificação para saber se a requisição foi bem sucedida, se algum error acontecer, lançamos uma exceção do tipo Exception com uma mensagem de erro. Também usamos o método fromJson() para transformar cada objeto do tipo _Map<String, dynamic> em um objeto do tipo User.

Finalmente temos o necessário para consumir os dados da requisição na nossa UI.

Consumindo os dados da requisição


Para consumir esses dados vamos criar um mixin que vai ser responsável por gerenciar o estado da nossa UI.

import 'package:flutter/material.dart';
import './service.dart';
import './models/user/user.dart';

mixin UserQuery<T extends StatefulWidget> on State<T> {
  bool isLoading = true;
  bool isSucess = false;
  bool isError = false;
  List<User> users = [];
  String? errorMessage;

  Future<void> getUsers() async {
    try {
      var (_, data) = await Service.fetchUsers();

      setState(() {
        isLoading = false;
        isSucess = true;
        users = data;
      });
    } catch (e) {
      setState(() {
        isLoading = false;
        isError = true;
        errorMessage = e.toString();
      });
    }
  }
}
  • isLoading: indica se a requisição está em andamento.
  • isSucess: indica se a requisição foi bem sucedida.
  • isError: indica se occorrou algum erro.
  • users: resultado da requisição.
  • errorMessage: detalhes do erro

Também temos uma chamada ao método fetchUsers() que é responsável por fazer a requisição.

Para finalizar, vamos fazer uma tela que exibe os dados da requisição.

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

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

  @override
  State<HttpRequestPage> createState() => _HttpRequestPageState();
}

class _HttpRequestPageState extends State<HttpRequestPage> with UserQuery {
  @override
  void initState() {
    super.initState();

    getUsers();
  }

  @override
  Widget build(BuildContext context) {
    // Se isLoading for true, exibe um loading
    if (isLoading) {
      return const Scaffold(
        body: Center(child: CircularProgressIndicator()),
      );
    }

    // Se isError for true, exibe um Text com a mensagem de erro
    if (isError) {
      return Scaffold(
        body: Center(child: Text(errorMessage!)),
      );
    }

    // Se isSucess for true, exibe a lista de usuários
    return Scaffold(
      appBar: AppBar(
        title: const Text('HTTP Request'),
      ),
      body: ListView.builder(
        itemCount: users.length,
        itemBuilder: (context, index) {
          var user = users[index];

          return ListTile(
            title: Text(user.name),
            subtitle: Text(user.email),
          );
        },
      ),
    );
  }
}

Quando o widget(HttpRequestPage) é iniciado, o método initState() é chamado e faz a requisição da nossa lista de users. Enquanto o processo não é finalizado, é exibido um CircularProgressIndicator, se a chamada for bem sucedida, a lista de usuários é exibida, se retornar um erro, é exibido um Text com a mensagem de erro.

Referências


O código completo desse está disponível no GitHub.