Consumo de APIs no iOS com SwiftUI: URLSession, async/await e decodificação JSON

Capítulo 9

Tempo estimado de leitura: 8 minutos

+ Exercício

O que significa “consumir uma API” no iOS

Consumir uma API é fazer requisições HTTP (como GET e POST) para um servidor, receber uma resposta (geralmente JSON) e transformar esses dados em modelos Swift para exibir na interface. No iOS moderno, o caminho mais direto é usar URLSession com async/await, decodificar com Codable e tratar estados como carregando, sucesso e erro.

Arquitetura simples e boas práticas (View vs Serviço)

Para manter o código organizado, separe responsabilidades:

  • Serviço de rede: monta requisições, chama URLSession, valida resposta e decodifica JSON.
  • Camada de API: define endpoints e métodos (ex.: fetchPosts(), createPost(...)).
  • ViewModel: chama a API, controla estados (loading, erro, dados) e expõe propriedades para a View.
  • View: apenas renderiza estados e dispara ações (ex.: “recarregar”).

Passo a passo: criando um módulo de rede com URLSession + async/await

1) Defina erros de rede

Erros claros facilitam mensagens para o usuário e debug. Crie um enum com casos comuns.

import Foundation

enum NetworkError: Error, LocalizedError {
    case invalidURL
    case invalidResponse
    case httpStatus(Int)
    case emptyData
    case decoding(Error)
    case transport(Error)

    var errorDescription: String? {
        switch self {
        case .invalidURL:
            return "URL inválida."
        case .invalidResponse:
            return "Resposta inválida do servidor."
        case .httpStatus(let code):
            return "Falha na requisição (HTTP \(code))."
        case .emptyData:
            return "Resposta vazia."
        case .decoding:
            return "Não foi possível interpretar os dados recebidos."
        case .transport:
            return "Erro de conexão. Verifique sua internet."
        }
    }
}

2) Crie um cliente HTTP reutilizável

Esse cliente centraliza: execução da requisição, validação do status code, decodificação JSON e configuração de headers.

import Foundation

final class HTTPClient {
    private let session: URLSession
    private let decoder: JSONDecoder

    init(session: URLSession = .shared) {
        self.session = session
        self.decoder = JSONDecoder()
        // Ajuste conforme sua API (snake_case, datas etc.)
        self.decoder.keyDecodingStrategy = .useDefaultKeys
    }

    func get<T: Decodable>(
        url: URL,
        headers: [String: String] = [:]
    ) async throws -> T {
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }

        return try await send(request)
    }

    func post<T: Decodable, Body: Encodable>(
        url: URL,
        body: Body,
        headers: [String: String] = [:]
    ) async throws -> T {
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }

        do {
            request.httpBody = try JSONEncoder().encode(body)
        } catch {
            throw NetworkError.transport(error)
        }

        return try await send(request)
    }

    private func send<T: Decodable>(_ request: URLRequest) async throws -> T {
        do {
            let (data, response) = try await session.data(for: request)

            guard let http = response as? HTTPURLResponse else {
                throw NetworkError.invalidResponse
            }

            guard (200...299).contains(http.statusCode) else {
                throw NetworkError.httpStatus(http.statusCode)
            }

            guard !data.isEmpty else {
                throw NetworkError.emptyData
            }

            do {
                return try decoder.decode(T.self, from: data)
            } catch {
                throw NetworkError.decoding(error)
            }
        } catch {
            throw NetworkError.transport(error)
        }
    }
}

Modelos Codable: criando e decodificando JSON

Vamos consumir uma API pública bem conhecida para testes: https://jsonplaceholder.typicode.com. Ela retorna posts e comentários em JSON.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

Modelo para GET (Post)

import Foundation

struct Post: Identifiable, Codable {
    let userId: Int
    let id: Int
    let title: String
    let body: String
}

Como as chaves do JSON são iguais às propriedades (userId, id, title, body), não precisamos de CodingKeys. Se sua API usar snake_case (ex.: user_id), você pode configurar decoder.keyDecodingStrategy = .convertFromSnakeCase.

Modelo para POST (criar Post)

Em um POST, normalmente você envia um corpo com os campos necessários. Aqui vamos enviar title, body e userId.

import Foundation

struct CreatePostBody: Encodable {
    let title: String
    let body: String
    let userId: Int
}

Camada de API: endpoints e métodos

Agora crie uma camada específica para a API, usando o HTTPClient. Assim, a ViewModel não precisa saber montar URLs ou requests.

import Foundation

final class PostsAPI {
    private let client: HTTPClient
    private let baseURL = URL(string: "https://jsonplaceholder.typicode.com")!

    init(client: HTTPClient = HTTPClient()) {
        self.client = client
    }

    func fetchPosts() async throws -> [Post] {
        let url = baseURL.appending(path: "posts")
        return try await client.get(url: url)
    }

    func createPost(title: String, body: String, userId: Int) async throws -> Post {
        let url = baseURL.appending(path: "posts")
        let payload = CreatePostBody(title: title, body: body, userId: userId)
        // JSONPlaceholder aceita e retorna um objeto (simulado)
        return try await client.post(url: url, body: payload)
    }
}

Se você precisar de headers (por exemplo, Authorization), passe no parâmetro headers do cliente:

let headers = ["Authorization": "Bearer SEU_TOKEN"]
let posts: [Post] = try await client.get(url: url, headers: headers)

Cache básico em memória (sem persistência)

Um cache simples evita refazer a mesma requisição ao voltar para a tela. Aqui vamos guardar a lista em memória e definir uma “validade” curta.

import Foundation

actor PostsCache {
    private var cachedPosts: [Post]?
    private var timestamp: Date?
    private let ttl: TimeInterval

    init(ttl: TimeInterval = 60) {
        self.ttl = ttl
    }

    func get() -> [Post]? {
        guard let cachedPosts, let timestamp else { return nil }
        if Date().timeIntervalSince(timestamp) <= ttl {
            return cachedPosts
        }
        return nil
    }

    func set(_ posts: [Post]) {
        self.cachedPosts = posts
        self.timestamp = Date()
    }

    func clear() {
        self.cachedPosts = nil
        self.timestamp = nil
    }
}

Usamos actor para garantir segurança de concorrência ao acessar o cache.

ViewModel: estados de carregamento, erro e dados

Uma forma prática de modelar estado é com um enum. Isso evita múltiplas flags soltas.

import Foundation

@MainActor
final class PostsViewModel: ObservableObject {
    enum State {
        case idle
        case loading
        case loaded([Post])
        case failed(String)
    }

    @Published private(set) var state: State = .idle

    private let api: PostsAPI
    private let cache: PostsCache

    init(api: PostsAPI = PostsAPI(), cache: PostsCache = PostsCache()) {
        self.api = api
        self.cache = cache
    }

    func loadPosts(forceRefresh: Bool = false) async {
        if !forceRefresh, let cached = await cache.get() {
            state = .loaded(cached)
            return
        }

        state = .loading
        do {
            let posts = try await api.fetchPosts()
            await cache.set(posts)
            state = .loaded(posts)
        } catch {
            let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
            state = .failed(message)
        }
    }

    func createSamplePost() async {
        state = .loading
        do {
            _ = try await api.createPost(title: "Novo post", body: "Conteúdo", userId: 1)
            // Após criar, recarregue (ou insira localmente na lista)
            await loadPosts(forceRefresh: true)
        } catch {
            let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
            state = .failed(message)
        }
    }
}

Por que @MainActor? Porque atualizamos propriedades observadas pela UI. Assim garantimos que alterações em @Published ocorram na thread principal.

Tela: consumindo API pública e exibindo em lista

A View vai reagir ao estado: mostra progresso, lista ou erro com botão de tentar novamente.

import SwiftUI

struct PostsListView: View {
    @StateObject private var vm = PostsViewModel()

    var body: some View {
        Group {
            switch vm.state {
            case .idle, .loading:
                VStack(spacing: 12) {
                    ProgressView()
                    Text("Carregando...")
                        .foregroundStyle(.secondary)
                }

            case .failed(let message):
                VStack(spacing: 12) {
                    Text("Não foi possível carregar")
                        .font(.headline)
                    Text(message)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                        .multilineTextAlignment(.center)
                    Button("Tentar novamente") {
                        Task { await vm.loadPosts(forceRefresh: true) }
                    }
                }
                .padding()

            case .loaded(let posts):
                List(posts) { post in
                    NavigationLink(value: post) {
                        VStack(alignment: .leading, spacing: 6) {
                            Text(post.title)
                                .font(.headline)
                                .lineLimit(2)
                            Text(post.body)
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                                .lineLimit(2)
                        }
                    }
                }
                .refreshable {
                    await vm.loadPosts(forceRefresh: true)
                }
            }
        }
        .navigationDestination(for: Post.self) { post in
            PostDetailView(post: post)
        }
        .navigationTitle("Posts")
        .toolbar {
            ToolbarItem(placement: .topBarTrailing) {
                Button("POST") {
                    Task { await vm.createSamplePost() }
                }
            }
        }
        .task {
            await vm.loadPosts()
        }
    }
}

Tela de detalhes

import SwiftUI

struct PostDetailView: View {
    let post: Post

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 12) {
                Text(post.title)
                    .font(.title2)
                    .bold()
                Text(post.body)
                    .font(.body)
                Divider()
                Text("User ID: \(post.userId)")
                    .font(.footnote)
                    .foregroundStyle(.secondary)
            }
            .padding()
        }
        .navigationTitle("Detalhes")
        .navigationBarTitleDisplayMode(.inline)
    }
}

Tratamento de erros: o que checar em cada etapa

EtapaProblema comumComo tratamos
Montar URLString inválidaNetworkError.invalidURL (quando aplicável)
ConexãoSem internet, timeoutNetworkError.transport
Resposta HTTPStatus 401/404/500NetworkError.httpStatus(code)
DadosBody vazioNetworkError.emptyData
DecodificaçãoModelo não bate com JSONNetworkError.decoding(error)

Introdução prática a POST: headers e envio de JSON

Para POST, os pontos essenciais são:

  • Definir httpMethod = "POST"
  • Adicionar header Content-Type: application/json
  • Codificar o corpo com JSONEncoder e atribuir em httpBody
  • Decodificar a resposta (muitas APIs retornam o recurso criado)

No exemplo, o botão “POST” chama createSamplePost(). Em uma API real, você normalmente coletaria os campos em uma tela de formulário e enviaria o payload.

Dicas rápidas para evitar bugs comuns

  • Modelos e JSON não batem: verifique nomes de chaves, tipos (String vs Int) e campos opcionais. Se um campo pode vir ausente, declare como opcional (String?).
  • Datas: se a API retorna datas em string, configure decoder.dateDecodingStrategy (ex.: ISO8601) e use Date no modelo.
  • Atualização de UI fora da main thread: mantenha a ViewModel em @MainActor.
  • Requisições repetidas: use cache em memória (como mostrado) e .refreshable para atualização manual.
  • Testabilidade: injete dependências (HTTPClient, PostsAPI) via init para facilitar mocks em testes.

Agora responda o exercício sobre o conteúdo:

Em uma arquitetura simples para consumo de API no SwiftUI, qual divisão de responsabilidades mantém o código mais organizado?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

A separação correta isola responsabilidades: rede (requisição/validação/decodificação), API (endpoints), ViewModel (estado e coordenação) e View (renderização e ações). Isso reduz acoplamento e facilita manutenção e testes.

Próximo capitúlo

Arquitetura inicial para apps iOS com SwiftUI: organização de pastas e camadas

Arrow Right Icon
Capa do Ebook gratuito iOS para Iniciantes com SwiftUI: do zero ao primeiro app na App Store
64%

iOS para Iniciantes com SwiftUI: do zero ao primeiro app na App Store

Novo curso

14 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.