Sieťovanie v Swift: Moderný REST API klient s URLSession a async/await

Naučte sa postaviť moderného REST API klienta v Swift s URLSession a async/await. Od základov cez autentifikáciu, middleware a stránkovanie až po SwiftUI integráciu a unit testy.

Sieťovanie v Swift prešlo za posledné roky naozaj dramatickou premenou. Ak si pamätáte časy NSURLConnection a komplikovaných delegátov, viete o čom hovorím. Potom prišiel URLSession s completion handlermi, čo bolo fajn, ale stále to znamenalo vnorené callbacky a pomerne neprehľadný kód. No a dnes? Dnes máme async/await a úprimne, je to ako deň a noc.

V ére Swift 6.x máme k dispozícii nástroje, o ktorých sa nám pred piatimi rokmi ani nesnívalo — natívna konkurencia, striktná kontrola dátových pretekov pri kompilácii a minimálny boilerplate.

V tomto sprievodcovi si krok za krokom postavíme moderného API klienta od základov. Prejdeme si HTTP metódy, ošetrovanie chýb, dekódovanie JSON, autentifikáciu, middleware, stránkovanie aj integráciu so SwiftUI. Všetky príklady sú pripravené na okamžité použitie vo vašom projekte. Tak poďme na to.

Základy URLSession s async/await

Jadrom sieťovej komunikácie v Swift je trieda URLSession. Apple ju predstavilo ešte v iOS 7, no s príchodom Swift 5.5 a natívneho async/await získala úplne novú dimenziu. Zabudnite na vnorené callbacky — moderný kód vyzerá takmer ako synchronný a je oveľa príjemnejšie ho čítať.

Prvý GET požiadavok

Najjednoduchší sieťový požiadavok s async/await vyzerá takto:

import Foundation

// Definícia dátového modelu
struct Pouzivatel: Codable, Sendable {
    let id: Int
    let meno: String
    let email: String
}

// Jednoduchý GET požiadavok
func nacitajPouzivatela(id: Int) async throws -> Pouzivatel {
    let url = URL(string: "https://api.example.com/users/\(id)")!
    let (data, odpoved) = try await URLSession.shared.data(from: url)

    guard let httpOdpoved = odpoved as? HTTPURLResponse,
          httpOdpoved.statusCode == 200 else {
        throw URLError(.badServerResponse)
    }

    return try JSONDecoder().decode(Pouzivatel.self, from: data)
}

Všimnite si niekoľko vecí. URLSession.shared je singleton zdieľaný celou aplikáciou — pre jednoduché požiadavky úplne postačuje. Kľúčové slovo await označuje bod pozastavenia, kde sa vykonávanie funkcie zastaví, kým nepríde odpoveď zo servera. Aplikácia pritom zostáva plne responzívna. Metóda data(from:) vracia dvojicu (Data, URLResponse), z ktorej extrahujeme dáta aj HTTP odpoveď.

Rozdiel oproti starému prístupu

Pre porovnanie — takto vyzeral rovnaký požiadavok s completion handlermi:

// Starý prístup s completion handlerom - nepoužívajte
func nacitajPouzivatela(id: Int, completion: @escaping (Result<Pouzivatel, Error>) -> Void) {
    let url = URL(string: "https://api.example.com/users/\(id)")!
    URLSession.shared.dataTask(with: url) { data, odpoved, chyba in
        if let chyba = chyba {
            completion(.failure(chyba))
            return
        }
        guard let data = data else {
            completion(.failure(URLError(.cannotDecodeContentData)))
            return
        }
        do {
            let pouzivatel = try JSONDecoder().decode(Pouzivatel.self, from: data)
            completion(.success(pouzivatel))
        } catch {
            completion(.failure(error))
        }
    }.resume()
}

Rozdiel je markantný. Async/await verzia je kratšia, čitateľnejšia a chyby sa propagujú automaticky cez throws bez nutnosti manuálneho volania completion(.failure(...)). Osobne som si na starý štýl zvykol, ale keď som prvýkrát prepísal väčší networking layer na async/await, nevedel som sa vrátiť.

Budovanie generického API klienta

Pre reálne aplikácie potrebujeme niečo robustnejšie než priame volanie URLSession.shared. Postavme si generický APIClient založený na protokoloch, ktorý bude znovupoužiteľný naprieč projektami.

Protokol pre API endpoint

import Foundation

// Protokol definujúci endpoint API
protocol APIEndpoint {
    associatedtype OdpovedTyp: Decodable

    var zakladnaURL: String { get }
    var cesta: String { get }
    var metoda: HTTPMetoda { get }
    var hlavicky: [String: String] { get }
    var parametre: [String: String]? { get }
    var telo: Encodable? { get }
}

// Predvolené hodnoty
extension APIEndpoint {
    var zakladnaURL: String { "https://api.example.com" }
    var hlavicky: [String: String] { ["Content-Type": "application/json"] }
    var parametre: [String: String]? { nil }
    var telo: Encodable? { nil }
}

Všimnite si, ako predvolené hodnoty v extension zjednodušujú definovanie konkrétnych endpointov. Väčšina z nich bude zdieľať rovnakú základnú URL aj hlavičky, takže ich nemusíte zakaždým opakovať.

Trieda APIClient

// Hlavná trieda API klienta
final class APIClient: Sendable {
    private let session: URLSession
    private let dekoder: JSONDecoder

    init(session: URLSession = .shared, dekoder: JSONDecoder = .init()) {
        self.session = session
        self.dekoder = dekoder
    }

    // Generická metóda na vykonanie požiadavku
    func vykonaj<E: APIEndpoint>(_ endpoint: E) async throws -> E.OdpovedTyp {
        let poziadavka = try vytvorPoziadavku(z: endpoint)
        let (data, odpoved) = try await session.data(for: poziadavka)

        try overOdpoved(odpoved)

        return try dekoder.decode(E.OdpovedTyp.self, from: data)
    }

    // Vytvorenie URLRequest z endpointu
    private func vytvorPoziadavku<E: APIEndpoint>(z endpoint: E) throws -> URLRequest {
        var komponenty = URLComponents(string: endpoint.zakladnaURL + endpoint.cesta)!

        if let parametre = endpoint.parametre {
            komponenty.queryItems = parametre.map { URLQueryItem(name: $0.key, value: $0.value) }
        }

        guard let url = komponenty.url else {
            throw APIChyba.nespravnaURL
        }

        var poziadavka = URLRequest(url: url)
        poziadavka.httpMethod = endpoint.metoda.rawValue
        poziadavka.allHTTPHeaderFields = endpoint.hlavicky

        if let telo = endpoint.telo {
            poziadavka.httpBody = try JSONEncoder().encode(telo)
        }

        return poziadavka
    }

    // Overenie HTTP odpovede
    private func overOdpoved(_ odpoved: URLResponse) throws {
        guard let httpOdpoved = odpoved as? HTTPURLResponse else {
            throw APIChyba.nespravnaOdpoved
        }

        switch httpOdpoved.statusCode {
        case 200...299:
            return // Úspech
        case 401:
            throw APIChyba.neautorizovany
        case 403:
            throw APIChyba.zakazanyPristup
        case 404:
            throw APIChyba.nenajdene
        case 500...599:
            throw APIChyba.chybaServera(kod: httpOdpoved.statusCode)
        default:
            throw APIChyba.neocakavanyStatusKod(httpOdpoved.statusCode)
        }
    }
}

Tento API klient je Sendable, čo znamená, že ho môžete bezpečne používať z viacerých vlákien — kľúčová požiadavka pre Swift 6.x. Vďaka generikám a associatedtype v protokole kompilátor automaticky vie, aký typ dát má dekódovať. Žiadne pretypovávanie, žiadne hádanie — to je proste krásna vec.

HTTP metódy: GET, POST, PUT, DELETE

REST API štandardne využíva štyri základné HTTP metódy (plus PATCH, ktorý sa hodí na čiastočné aktualizácie). Vytvorme si pre ne prehľadný enum a ukážme, ako definovať konkrétne endpointy.

Enum HTTP metód

// Enum pre HTTP metódy
enum HTTPMetoda: String, Sendable {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case delete = "DELETE"
    case patch = "PATCH"
}

Praktické endpointy

// Pomocný wrapper pre kódovanie tela požiadavku
struct AnyEncodable: Encodable {
    private let zakoduj: (Encoder) throws -> Void

    init<T: Encodable>(_ hodnota: T) {
        zakoduj = hodnota.encode
    }

    func encode(to encoder: Encoder) throws {
        try zakoduj(encoder)
    }
}

// GET - získanie zoznamu používateľov
struct ZiskajPouzivatelov: APIEndpoint {
    typealias OdpovedTyp = [Pouzivatel]

    var cesta: String { "/users" }
    var metoda: HTTPMetoda { .get }
    var parametre: [String: String]?

    init(stranka: Int = 1, limit: Int = 20) {
        parametre = ["page": "\(stranka)", "limit": "\(limit)"]
    }
}

// POST - vytvorenie nového používateľa
struct VytvorPouzivatela: APIEndpoint {
    typealias OdpovedTyp = Pouzivatel

    var cesta: String { "/users" }
    var metoda: HTTPMetoda { .post }
    var telo: Encodable?

    init(meno: String, email: String) {
        telo = AnyEncodable(["meno": meno, "email": email])
    }
}

// PUT - aktualizácia existujúceho používateľa
struct AktualizujPouzivatela: APIEndpoint {
    typealias OdpovedTyp = Pouzivatel

    let pouzivatelID: Int
    var cesta: String { "/users/\(pouzivatelID)" }
    var metoda: HTTPMetoda { .put }
    var telo: Encodable?

    init(id: Int, meno: String, email: String) {
        self.pouzivatelID = id
        telo = AnyEncodable(["meno": meno, "email": email])
    }
}

// DELETE - odstránenie používateľa
struct OdstranPouzivatela: APIEndpoint {
    typealias OdpovedTyp = PrazdnaOdpoved

    let pouzivatelID: Int
    var cesta: String { "/users/\(pouzivatelID)" }
    var metoda: HTTPMetoda { .delete }
}

// Prázdna odpoveď pre DELETE požiadavky
struct PrazdnaOdpoved: Decodable {}

Použitie je potom maximálne jednoduché:

let klient = APIClient()

// Získanie zoznamu
let pouzivatelia = try await klient.vykonaj(ZiskajPouzivatelov(stranka: 1))

// Vytvorenie nového
let novy = try await klient.vykonaj(VytvorPouzivatela(meno: "Jano", email: "[email protected]"))

// Aktualizácia
let aktualizovany = try await klient.vykonaj(AktualizujPouzivatela(id: 1, meno: "Janko", email: "[email protected]"))

// Odstránenie
_ = try await klient.vykonaj(OdstranPouzivatela(pouzivatelID: 1))

Čitateľné, stručné a typovo bezpečné. Presne takto by mal vyzerať moderný Swift kód.

Ošetrovanie chýb pri sieťovej komunikácii

Spoľahlivé ošetrovanie chýb je základný kameň každého produkčného API klienta. Sieťové požiadavky môžu zlyhať z naozaj mnohých dôvodov — výpadok pripojenia, chyba servera, neplatný token, neočakávaný formát odpovede... A verte mi, v produkcii sa stretne s každým z nich.

Vlastné typy chýb

// Komplexný enum chýb API klienta
enum APIChyba: LocalizedError, Sendable {
    case nespravnaURL
    case nespravnaOdpoved
    case neautorizovany
    case zakazanyPristup
    case nenajdene
    case chybaServera(kod: Int)
    case neocakavanyStatusKod(Int)
    case chybaDekodovania(Error)
    case sietovyProblem(Error)
    case prekrocenyCas
    case zrusenyPoziadavok

    var errorDescription: String? {
        switch self {
        case .nespravnaURL:
            return "Nesprávna URL adresa požiadavku."
        case .nespravnaOdpoved:
            return "Server vrátil neočakávanú odpoveď."
        case .neautorizovany:
            return "Prístup odmietnutý. Skontrolujte prihlasovacie údaje."
        case .zakazanyPristup:
            return "Na túto operáciu nemáte oprávnenie."
        case .nenajdene:
            return "Požadovaný zdroj nebol nájdený."
        case .chybaServera(let kod):
            return "Chyba servera (kód \(kod)). Skúste to prosím neskôr."
        case .neocakavanyStatusKod(let kod):
            return "Neočakávaný status kód: \(kod)."
        case .chybaDekodovania(let chyba):
            return "Chyba pri spracovaní dát: \(chyba.localizedDescription)"
        case .sietovyProblem(let chyba):
            return "Problém so sieťou: \(chyba.localizedDescription)"
        case .prekrocenyCas:
            return "Požiadavok trval príliš dlho. Skontrolujte pripojenie."
        case .zrusenyPoziadavok:
            return "Požiadavok bol zrušený."
        }
    }
}

Bezpečné vykonanie požiadavku s ošetrením chýb

// Rozšírenie APIClienta o bezpečné vykonanie
extension APIClient {
    func bezpecneVykonaj<E: APIEndpoint>(_ endpoint: E) async -> Result<E.OdpovedTyp, APIChyba> {
        do {
            let vysledok = try await vykonaj(endpoint)
            return .success(vysledok)
        } catch let chyba as APIChyba {
            return .failure(chyba)
        } catch let chyba as DecodingError {
            return .failure(.chybaDekodovania(chyba))
        } catch let chyba as URLError where chyba.code == .timedOut {
            return .failure(.prekrocenyCas)
        } catch let chyba as URLError where chyba.code == .cancelled {
            return .failure(.zrusenyPoziadavok)
        } catch {
            return .failure(.sietovyProblem(error))
        }
    }
}

Vďaka tomuto rozšíreniu môžete na volajúcej strane elegantne spracovať všetky možné chybové stavy pomocou switch nad Result typom. Každá chyba má lokalizovaný popis v slovenčine, čo sa hodí aj pre zobrazovanie hlášok priamo používateľovi.

Dekódovanie JSON s Codable

Protokol Codable je základ pre prácu s JSON v Swift. Lenže reálne API často vyžadujú špeciálnu konfiguráciu dekodéra — iný formát dátumov, konverziu medzi snake_case a camelCase, alebo vlastné dekódovacie stratégie. Pozrime sa, ako to riešiť elegantne.

Konfigurácia JSONDecoder

// Továreň pre konfiguráciu dekodéra
enum DekoderTovaren {
    // Štandardný dekodér pre väčšinu API
    static func standardny() -> JSONDecoder {
        let dekoder = JSONDecoder()
        dekoder.keyDecodingStrategy = .convertFromSnakeCase
        dekoder.dateDecodingStrategy = .iso8601
        return dekoder
    }

    // Dekodér s vlastným formátom dátumu
    static func sVlastnymDatumom(format: String) -> JSONDecoder {
        let dekoder = JSONDecoder()
        dekoder.keyDecodingStrategy = .convertFromSnakeCase

        let formatter = DateFormatter()
        formatter.dateFormat = format
        formatter.locale = Locale(identifier: "sk_SK")
        formatter.timeZone = TimeZone(identifier: "Europe/Bratislava")

        dekoder.dateDecodingStrategy = .formatted(formatter)
        return dekoder
    }

    // Dekodér s viacerými formátmi dátumu
    static func flexibilny() -> JSONDecoder {
        let dekoder = JSONDecoder()
        dekoder.keyDecodingStrategy = .convertFromSnakeCase
        dekoder.dateDecodingStrategy = .custom { dekoder in
            let kontajner = try dekoder.singleValueContainer()
            let retazec = try kontajner.decode(String.self)

            // Skúsime ISO 8601
            let iso8601Formatter = ISO8601DateFormatter()
            iso8601Formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
            if let datum = iso8601Formatter.date(from: retazec) {
                return datum
            }

            // Skúsime klasický formát
            let datumFormatter = DateFormatter()
            datumFormatter.dateFormat = "yyyy-MM-dd"
            if let datum = datumFormatter.date(from: retazec) {
                return datum
            }

            throw DecodingError.dataCorruptedError(
                in: kontajner,
                debugDescription: "Nepodarilo sa dekódovať dátum: \(retazec)"
            )
        }
        return dekoder
    }
}

Tá flexibilná verzia dekodéra sa vám hodí vtedy, keď API nie je úplne konzistentné v tom, ako posiela dátumy (a to sa stáva častejšie, než by ste čakali).

Zložitejšie modely s vlastným dekódovaním

// Model s vlastnou dekódovacou logikou
struct Clanok: Codable, Sendable {
    let id: Int
    let nazov: String
    let obsah: String
    let autor: Autor
    let datumVytvorenia: Date
    let tagy: [String]
    let pocetZobrazeni: Int

    struct Autor: Codable, Sendable {
        let id: Int
        let menoAPriezvisko: String
        let avatar: URL?
    }
}

// Model API odpovede s metadátami
struct APIOdpoved<T: Decodable>: Decodable {
    let data: T
    let metadata: Metadata

    struct Metadata: Decodable {
        let celkovyPocet: Int
        let aktualnaStranka: Int
        let pocetStranok: Int
        let masiDalsiu: Bool
    }
}

Konverzia snake_case na camelCase cez .convertFromSnakeCase automaticky premapuje JSON kľúče ako datum_vytvorenia na Swift property datumVytvorenia. Pre zložitejšie prípady si môžete definovať vlastný CodingKeys enum.

Autentifikácia a zabezpečenie

Väčšina moderných API vyžaduje autentifikáciu. Najčastejšie sú to Bearer tokeny (JWT) alebo API kľúče. Implementujme si vzor interceptora, ktorý automaticky pridá autentifikačné hlavičky ku každému požiadavku — takže sa o to nemusíte starať na každom jednom mieste volania.

Protokol autentifikátora

// Protokol pre rôzne typy autentifikácie
protocol Autentifikator: Sendable {
    func autentifikuj(_ poziadavka: inout URLRequest) async throws
}

// Bearer token autentifikácia
final class BearerAutentifikator: Autentifikator {
    private let ziskajToken: @Sendable () async throws -> String

    init(ziskajToken: @escaping @Sendable () async throws -> String) {
        self.ziskajToken = ziskajToken
    }

    func autentifikuj(_ poziadavka: inout URLRequest) async throws {
        let token = try await ziskajToken()
        poziadavka.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
    }
}

// API kľúč autentifikácia
final class APIKlucAutentifikator: Autentifikator {
    private let kluc: String
    private let nazovHlavicky: String

    init(kluc: String, nazovHlavicky: String = "X-API-Key") {
        self.kluc = kluc
        self.nazovHlavicky = nazovHlavicky
    }

    func autentifikuj(_ poziadavka: inout URLRequest) async throws {
        poziadavka.setValue(kluc, forHTTPHeaderField: nazovHlavicky)
    }
}

Rozšírenie API klienta o autentifikáciu

// API klient s podporou autentifikácie
final class AutentifikovanyAPIClient: Sendable {
    private let klient: APIClient
    private let autentifikator: Autentifikator

    init(klient: APIClient = APIClient(), autentifikator: Autentifikator) {
        self.klient = klient
        self.autentifikator = autentifikator
    }

    func vykonaj<E: APIEndpoint>(_ endpoint: E) async throws -> E.OdpovedTyp {
        var poziadavka = try vytvorZakladnuPoziadavku(z: endpoint)
        try await autentifikator.autentifikuj(&poziadavka)

        let (data, odpoved) = try await klient.session.data(for: poziadavka)
        try klient.overOdpoved(odpoved)

        return try klient.dekoder.decode(E.OdpovedTyp.self, from: data)
    }
}

// Použitie s Bearer tokenom
let autentKlient = AutentifikovanyAPIClient(
    autentifikator: BearerAutentifikator {
        // Tu získame token z Keychain alebo z pamäte
        return TokenManager.shared.aktualnyToken
    }
)

let profil = try await autentKlient.vykonaj(ZiskajProfil())

Vzor interceptora oddeľuje logiku autentifikácie od samotného API klienta. Vďaka protokolu Autentifikator jednoducho vymeníte typ autentifikácie bez zmeny zvyšku kódu. A keď príde požiadavka na OAuth2? Stačí pridať novú implementáciu protokolu.

Middleware: logovanie a opakovanie požiadavkov

Middleware vzor umožňuje pridávať funkcionalitu do reťazca spracovávania požiadavkov bez toho, aby ste museli meniť jadro API klienta. Dva najčastejšie prípady použitia sú logovanie (neoceniteľné pri debugovaní) a automatické opakovanie pri zlyhaniach.

Protokol middleware

// Protokol pre middleware
protocol APIMiddleware: Sendable {
    func spracujPoziadavku(_ poziadavka: URLRequest) async throws -> URLRequest
    func spracujOdpoved(_ data: Data, _ odpoved: URLResponse) async throws -> (Data, URLResponse)
}

// Logovací middleware
struct LogovaciMiddleware: APIMiddleware {
    func spracujPoziadavku(_ poziadavka: URLRequest) async throws -> URLRequest {
        print("➡️ \(poziadavka.httpMethod ?? "?") \(poziadavka.url?.absoluteString ?? "")")
        if let telo = poziadavka.httpBody,
           let text = String(data: telo, encoding: .utf8) {
            print("   Telo: \(text)")
        }
        return poziadavka
    }

    func spracujOdpoved(_ data: Data, _ odpoved: URLResponse) async throws -> (Data, URLResponse) {
        if let http = odpoved as? HTTPURLResponse {
            print("⬅️ Status: \(http.statusCode) | Veľkosť: \(data.count) bajtov")
        }
        return (data, odpoved)
    }
}

// Middleware pre automatické opakovanie požiadavkov
struct OpakovacMiddleware: APIMiddleware {
    let maxPocetPokusov: Int
    let zakladneCakanie: TimeInterval

    init(maxPocetPokusov: Int = 3, zakladneCakanie: TimeInterval = 1.0) {
        self.maxPocetPokusov = maxPocetPokusov
        self.zakladneCakanie = zakladneCakanie
    }

    func spracujPoziadavku(_ poziadavka: URLRequest) async throws -> URLRequest {
        return poziadavka // Bez zmeny
    }

    func spracujOdpoved(_ data: Data, _ odpoved: URLResponse) async throws -> (Data, URLResponse) {
        return (data, odpoved) // Logika opakovania je v klientovi
    }
}

// API klient s podporou middleware a opakovania
final class MiddlewareAPIClient: Sendable {
    private let session: URLSession
    private let dekoder: JSONDecoder
    private let middleware: [APIMiddleware]
    private let maxPocetPokusov: Int

    init(
        session: URLSession = .shared,
        dekoder: JSONDecoder = DekoderTovaren.standardny(),
        middleware: [APIMiddleware] = [],
        maxPocetPokusov: Int = 1
    ) {
        self.session = session
        self.dekoder = dekoder
        self.middleware = middleware
        self.maxPocetPokusov = maxPocetPokusov
    }

    func vykonaj<E: APIEndpoint>(_ endpoint: E) async throws -> E.OdpovedTyp {
        var poziadavka = try vytvorPoziadavku(z: endpoint)

        // Aplikovanie middleware na požiadavku
        for mw in middleware {
            poziadavka = try await mw.spracujPoziadavku(poziadavka)
        }

        // Opakovanie s exponenciálnym čakaním
        var poslednaChyba: Error?
        for pokus in 0..<maxPocetPokusov {
            do {
                var (data, odpoved) = try await session.data(for: poziadavka)

                // Aplikovanie middleware na odpoveď
                for mw in middleware {
                    (data, odpoved) = try await mw.spracujOdpoved(data, odpoved)
                }

                try overOdpoved(odpoved)
                return try dekoder.decode(E.OdpovedTyp.self, from: data)
            } catch {
                poslednaChyba = error
                if pokus < maxPocetPokusov - 1 {
                    let cakanie = pow(2.0, Double(pokus)) * 1.0
                    try await Task.sleep(for: .seconds(cakanie))
                }
            }
        }

        throw poslednaChyba ?? APIChyba.nespravnaOdpoved
    }
}

Exponenciálne čakanie (exponential backoff) postupne predlžuje intervaly medzi pokusmi — 1 sekunda, 2 sekundy, 4 sekundy. Tým sa vyhnete tomu, že zahltíte server pri dočasných výpadkoch. Je to jednoduchý trik, ale v praxi funguje skvele.

Stránkovanie výsledkov

Väčšina REST API vracia výsledky po stránkach. Existujú dva hlavné prístupy: offset-based (klasické stránkovanie s číslom stránky) a cursor-based (kurzorové stránkovanie). Implementujme si oba, pretože oba majú svoje miesto.

Offset-based stránkovanie

// Odpoveď s informáciami o stránkovaní
struct StrankovanaOdpoved<T: Decodable>: Decodable {
    let polozky: [T]
    let celkovyPocet: Int
    let stranka: Int
    let pocetNaStranku: Int

    var masiDalsiuStranku: Bool {
        stranka * pocetNaStranku < celkovyPocet
    }
}

// Správca stránkovania
actor SpravcoStrankovania<T: Decodable & Sendable> {
    private var aktualnaStranka = 1
    private var nacitavaSa = false
    private var vsetkyNacitane = false
    private(set) var polozky: [T] = []

    private let nacitajStranku: @Sendable (Int) async throws -> StrankovanaOdpoved<T>

    init(nacitajStranku: @escaping @Sendable (Int) async throws -> StrankovanaOdpoved<T>) {
        self.nacitajStranku = nacitajStranku
    }

    func nacitajDalsiu() async throws {
        guard !nacitavaSa, !vsetkyNacitane else { return }

        nacitavaSa = true
        defer { nacitavaSa = false }

        let odpoved = try await nacitajStranku(aktualnaStranka)
        polozky.append(contentsOf: odpoved.polozky)
        vsetkyNacitane = !odpoved.masiDalsiuStranku
        aktualnaStranka += 1
    }

    func resetuj() {
        aktualnaStranka = 1
        nacitavaSa = false
        vsetkyNacitane = false
        polozky = []
    }
}

Použitie actor tu nie je náhodné — zabezpečuje, že stav stránkovania je vždy konzistentný, aj keď by sa náhodou zavolalo načítanie z viacerých miest súčasne.

Cursor-based stránkovanie

// Kurzorové stránkovanie - ideálne pre nekonečné posúvanie
struct KurzorovaOdpoved<T: Decodable>: Decodable {
    let polozky: [T]
    let dalsiKurzor: String?

    var masiDalsie: Bool { dalsiKurzor != nil }
}

// AsyncSequence pre kurzorové stránkovanie
struct StrankovanaSekvencia<T: Decodable & Sendable>: AsyncSequence {
    typealias Element = [T]

    let nacitajStranku: @Sendable (String?) async throws -> KurzorovaOdpoved<T>

    func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator(nacitajStranku: nacitajStranku)
    }

    struct AsyncIterator: AsyncIteratorProtocol {
        let nacitajStranku: @Sendable (String?) async throws -> KurzorovaOdpoved<T>
        var aktualnyKurzor: String? = nil
        var skoncil = false

        mutating func next() async throws -> [T]? {
            guard !skoncil else { return nil }

            let odpoved = try await nacitajStranku(aktualnyKurzor)
            aktualnyKurzor = odpoved.dalsiKurzor
            skoncil = !odpoved.masiDalsie

            return odpoved.polozky.isEmpty ? nil : odpoved.polozky
        }
    }
}

// Použitie s for-await-in
func nacitajVsetkyPrispevky() async throws -> [Prispevok] {
    var vsetky: [Prispevok] = []

    let sekvencia = StrankovanaSekvencia<Prispevok> { kurzor in
        // Zavolanie API s kurzorom
        try await apiKlient.nacitajPrispevky(kurzor: kurzor)
    }

    for try await stranka in sekvencia {
        vsetky.append(contentsOf: stranka)
    }

    return vsetky
}

Použitie AsyncSequence pre stránkovanie je podľa mňa jedno z najelegantnejších riešení, aké Swift ponúka. Jednoduchý for try await cyklus automaticky načítava ďalšie stránky, kým sú k dispozícii. Žiadny manuálny tracking stavu, žiadne komplikované callbacky.

Integrácia so SwiftUI pomocou @Observable

V modernom SwiftUI (iOS 17+) používame makro @Observable namiesto staršieho ObservableObject. Vytvorme si kompletný ViewModel pre správu sieťových požiadavkov s načítavacím stavom a ošetrením chýb.

import SwiftUI

// Enum pre stav načítavania
enum StavNacitavania<T: Sendable>: Sendable {
    case necinna
    case nacitavanie
    case uspech(T)
    case chyba(String)
}

// ViewModel pre zoznam používateľov
@Observable
@MainActor
final class PouzivateliaViewModel {
    var stav: StavNacitavania<[Pouzivatel]> = .necinna
    var vybranyPouzivatel: Pouzivatel?
    var zobrazChybu = false
    var textChyby = ""

    private let klient: APIClient

    init(klient: APIClient = APIClient()) {
        self.klient = klient
    }

    func nacitajPouzivatelov() async {
        stav = .nacitavanie

        let vysledok = await klient.bezpecneVykonaj(ZiskajPouzivatelov())

        switch vysledok {
        case .success(let pouzivatelia):
            stav = .uspech(pouzivatelia)
        case .failure(let chyba):
            textChyby = chyba.localizedDescription
            zobrazChybu = true
            stav = .chyba(chyba.localizedDescription)
        }
    }

    func obnovData() async {
        await nacitajPouzivatelov()
    }
}

// SwiftUI View
struct PouzivateliaView: View {
    @State private var viewModel = PouzivateliaViewModel()

    var body: some View {
        NavigationStack {
            Group {
                switch viewModel.stav {
                case .necinna:
                    ContentUnavailableView(
                        "Žiadne dáta",
                        systemImage: "person.3",
                        description: Text("Potiahnite nadol pre načítanie")
                    )

                case .nacitavanie:
                    ProgressView("Načítavam používateľov...")

                case .uspech(let pouzivatelia):
                    List(pouzivatelia, id: \.id) { pouzivatel in
                        VStack(alignment: .leading) {
                            Text(pouzivatel.meno)
                                .font(.headline)
                            Text(pouzivatel.email)
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                        }
                    }
                    .refreshable {
                        await viewModel.obnovData()
                    }

                case .chyba(let sprava):
                    ContentUnavailableView(
                        "Nastala chyba",
                        systemImage: "exclamationmark.triangle",
                        description: Text(sprava)
                    )
                }
            }
            .navigationTitle("Používatelia")
            .task {
                await viewModel.nacitajPouzivatelov()
            }
            .alert("Chyba", isPresented: $viewModel.zobrazChybu) {
                Button("OK") {}
                Button("Skúsiť znova") {
                    Task { await viewModel.nacitajPouzivatelov() }
                }
            } message: {
                Text(viewModel.textChyby)
            }
        }
    }
}

Modifikátor .task automaticky spustí asynchrónnu úlohu pri zobrazení view a zruší ju pri zmiznutí — o lifecycle sa nemusíte starať. .refreshable pridáva natívnu funkciu potiahnutia nadol. A vďaka @Observable sa view automaticky prekreslí pri každej zmene stavu. Jednoduché a funkčné.

Unit testovanie API klienta

Testovanie sieťového kódu je kriticky dôležité. Nechceme ale, aby testy záviseli od reálneho servera (to by bola nočná mora na CI). Využijeme URLProtocol na mockovanie sieťových odpovedí a dependency injection na vkladanie testovacích závislostí.

Mockovanie pomocou URLProtocol

import Foundation

// Mockovací URL protokol pre testy
final class MockURLProtocol: URLProtocol, @unchecked Sendable {
    // Handler, ktorý definuje odpoveď pre každý požiadavok
    nonisolated(unsafe) static var obsluznyHandler:
        ((URLRequest) throws -> (Data, HTTPURLResponse))?

    override class func canInit(with request: URLRequest) -> Bool {
        return true // Zachytíme všetky požiadavky
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }

    override func startLoading() {
        guard let handler = MockURLProtocol.obsluznyHandler else {
            client?.urlProtocolDidFinishLoading(self)
            return
        }

        do {
            let (data, odpoved) = try handler(request)
            client?.urlProtocol(self, didReceive: odpoved, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: data)
            client?.urlProtocolDidFinishLoading(self)
        } catch {
            client?.urlProtocol(self, didFailWithError: error)
        }
    }

    override func stopLoading() {
        // Prázdna implementácia - požadovaná podtriedou
    }
}

Testovacie prípady

import Testing
@testable import MojaAplikacia

@Suite("Testy API klienta")
struct APIClientTesty {
    let klient: APIClient

    init() {
        // Konfigurácia s mock protokolom
        let konfiguracia = URLSessionConfiguration.ephemeral
        konfiguracia.protocolClasses = [MockURLProtocol.self]
        let session = URLSession(configuration: konfiguracia)
        klient = APIClient(session: session)
    }

    @Test("Úspešné načítanie zoznamu používateľov")
    func nacitaniePouzivatelov() async throws {
        // Príprava mock dát
        let mockPouzivatelia = [
            Pouzivatel(id: 1, meno: "Jano", email: "[email protected]"),
            Pouzivatel(id: 2, meno: "Mária", email: "[email protected]")
        ]
        let mockData = try JSONEncoder().encode(mockPouzivatelia)

        MockURLProtocol.obsluznyHandler = { poziadavka in
            let odpoved = HTTPURLResponse(
                url: poziadavka.url!,
                statusCode: 200,
                httpVersion: nil,
                headerFields: nil
            )!
            return (mockData, odpoved)
        }

        // Vykonanie
        let vysledok = try await klient.vykonaj(ZiskajPouzivatelov())

        // Overenie
        #expect(vysledok.count == 2)
        #expect(vysledok[0].meno == "Jano")
        #expect(vysledok[1].email == "[email protected]")
    }

    @Test("Správne ošetrenie 404 chyby")
    func osetrenie404() async {
        MockURLProtocol.obsluznyHandler = { poziadavka in
            let odpoved = HTTPURLResponse(
                url: poziadavka.url!,
                statusCode: 404,
                httpVersion: nil,
                headerFields: nil
            )!
            return (Data(), odpoved)
        }

        do {
            _ = try await klient.vykonaj(ZiskajPouzivatelov())
            Issue.record("Očakávali sme chybu, ale požiadavok prešiel")
        } catch let chyba as APIChyba {
            #expect(chyba == .nenajdene)
        } catch {
            Issue.record("Neočakávaný typ chyby: \(error)")
        }
    }

    @Test("POST požiadavok obsahuje správne telo")
    func postPoziadavka() async throws {
        MockURLProtocol.obsluznyHandler = { poziadavka in
            // Overíme, že požiadavka obsahuje správne telo
            #expect(poziadavka.httpMethod == "POST")
            #expect(poziadavka.value(forHTTPHeaderField: "Content-Type") == "application/json")

            let mockOdpoved = Pouzivatel(id: 3, meno: "Peter", email: "[email protected]")
            let data = try JSONEncoder().encode(mockOdpoved)
            let odpoved = HTTPURLResponse(
                url: poziadavka.url!,
                statusCode: 201,
                httpVersion: nil,
                headerFields: nil
            )!
            return (data, odpoved)
        }

        let novy = try await klient.vykonaj(
            VytvorPouzivatela(meno: "Peter", email: "[email protected]")
        )
        #expect(novy.meno == "Peter")
    }
}

Používame nový framework Swift Testing s makrami @Test a #expect namiesto staršieho XCTest. Je to výrazne čitateľnejšie a každý test beží izolovane s vlastnou konfiguráciou, čo zabraňuje vzájomným interakciám.

Osvedčené postupy a optimalizácia výkonu

Na záver si prejdime súbor praktických odporúčaní, ktoré z vášho API klienta urobia produkčne pripravený komponent. Niektoré z nich sa môžu zdať samozrejmé, ale z vlastnej skúsenosti viem, že práve na tieto detaily sa v praxi často zabúda.

Konfigurácia URLSession

// Optimalizovaná konfigurácia pre produkčné prostredie
func vytvorProdukciuSession() -> URLSession {
    let konfiguracia = URLSessionConfiguration.default

    // Časové limity
    konfiguracia.timeoutIntervalForRequest = 30   // 30 sekúnd na požiadavok
    konfiguracia.timeoutIntervalForResource = 300  // 5 minút na celkové stiahnutie

    // Limity pripojení
    konfiguracia.httpMaximumConnectionsPerHost = 6

    // Cachovanie
    let cache = URLCache(
        memoryCapacity: 50 * 1024 * 1024,  // 50 MB v pamäti
        diskCapacity: 200 * 1024 * 1024     // 200 MB na disku
    )
    konfiguracia.urlCache = cache
    konfiguracia.requestCachePolicy = .returnCacheDataElseLoad

    // Sieťové nastavenia
    konfiguracia.waitsForConnectivity = true
    konfiguracia.multipathServiceType = .handover

    return URLSession(configuration: konfiguracia)
}

// Konfigurácia pre sťahovanie na pozadí
func vytvorPozadioveSession(identifikator: String) -> URLSession {
    let konfiguracia = URLSessionConfiguration.background(
        withIdentifier: identifikator
    )
    konfiguracia.isDiscretionary = true
    konfiguracia.sessionSendsLaunchEvents = true

    return URLSession(configuration: konfiguracia)
}

Zrušenie požiadavkov

// Správne rušenie požiadavkov s Task
@Observable
@MainActor
final class VyhladavanieViewModel {
    var vysledky: [Vysledok] = []
    var hladanyText = "" {
        didSet { vyhladajSoCakanim() }
    }

    private var aktivnaUloha: Task<Void, Never>?
    private let klient = APIClient()

    private func vyhladajSoCakanim() {
        // Zrušenie predchádzajúceho požiadavku
        aktivnaUloha?.cancel()

        aktivnaUloha = Task {
            // Debounce - počkáme 300ms pred odoslaním
            try? await Task.sleep(for: .milliseconds(300))

            // Kontrola, či úloha nebola zrušená
            guard !Task.isCancelled else { return }

            do {
                let vysledky = try await klient.vyhladaj(text: hladanyText)
                if !Task.isCancelled {
                    self.vysledky = vysledky
                }
            } catch {
                // Pri zrušení ignorujeme chybu
                if !Task.isCancelled {
                    print("Chyba vyhľadávania: \(error)")
                }
            }
        }
    }
}

Debounce s 300ms oneskorením je tu zásadný — bez neho by ste pri rýchlom písaní posielali požiadavok na každý stlačený kláves, čo by zbytočne zaťažilo server aj sieť.

Prehľad osvedčených postupov

  • Používajte jednu inštanciu URLSession — nevytvárajte novú session pre každý požiadavok. URLSession efektívne spravuje pool pripojení a ich znovupoužitie.
  • Označujte typy ako Sendable — Swift 6 vyžaduje explicitnú bezpečnosť pri konkurencii. Všetky dátové modely a API klient by mali byť Sendable.
  • Vždy rušte nepotrebné požiadavky — pri odchode z obrazovky alebo pri novom vyhľadávaní zrušte predchádzajúce požiadavky cez Task.cancel().
  • Implementujte cachovanie — správne nastavený URLCache dramaticky zlepšuje odozvu aj offline skúsenosť. Rešpektujte HTTP hlavičky Cache-Control a ETag.
  • Používajte ephemeral sessions v testochURLSessionConfiguration.ephemeral neukladá dáta na disk, čo zabraňuje interferencii medzi testami.
  • Centralizujte konfiguráciu — základná URL, hlavičky a timeout by mali byť na jednom mieste, nie roztrúsené po celom projekte.
  • Logovanie vypnite v produkcii — podrobné logovanie sieťových požiadavkov je užitočné pri vývoji, ale v produkčnej verzii môže spomaliť aplikáciu a prezradiť citlivé údaje.
  • Využívajte waitsForConnectivity — nastavenie waitsForConnectivity = true spôsobí, že URLSession automaticky počká na obnovenie pripojenia namiesto okamžitého zlyhania.

Často kladené otázky o sieťovaní v Swift

Aký je rozdiel medzi URLSession.shared a vlastnou konfiguráciou URLSession?

URLSession.shared je singleton s predvolenou konfiguráciou — vhodný pre jednoduché požiadavky, ale neumožňuje prispôsobiť cachovanie, timeouty, certifikátovú validáciu ani sieťové politiky. Pre produkčné aplikácie odporúčame vytvoriť vlastnú inštanciu URLSession s URLSessionConfiguration.default, kde si nastavíte presné časové limity, veľkosť cache a maximálny počet súbežných pripojení. Vlastná konfigurácia tiež umožňuje použiť ephemeral session pre citlivé dáta alebo background session pre sťahovanie veľkých súborov.

Ako správne ošetriť výpadok internetového pripojenia v Swift aplikácii?

Najrobustnejší prístup kombinuje viacero techník. Na úrovni URLSession nastavte waitsForConnectivity = true, aby sa požiadavky automaticky odložili namiesto okamžitého zlyhania. Pre aktívne monitorovanie stavu siete použite NWPathMonitor z Network frameworku, ktorý vás upozorní na zmeny pripojenia v reálnom čase. Na úrovni UI implementujte automatické opakovanie s exponenciálnym čakaním a zobrazujte používateľovi jasné informácie o stave. Zvážte aj offline mód cez lokálne cachovanie dát pomocou URLCache alebo perzistentnú databázu ako SwiftData.

Je bezpečné volať URLSession z hlavného vlákna?

Áno, volanie async metód URLSession z hlavného vlákna je úplne bezpečné. Na rozdiel od synchronného sieťovania, kde by sa hlavné vlákno zablokovalo, mechanizmus async/await zabezpečí, že vlákno sa uvoľní počas čakania na odpoveď servera. UI zostáva plne responzívne. Dôležité je však spracovávať výsledok na hlavnom vlákne (pomocou @MainActor), ak aktualizujete UI prvky. Modifier .task vo SwiftUI toto automaticky zabezpečuje a navyše ruší úlohu pri zmiznutí view.

Ako implementovať obnovu JWT tokenu bez opakovania požiadavku manuálne?

Najelegantnejšie riešenie je implementácia interceptora v API klientovi. Keď server vráti status 401, interceptor automaticky zavolá endpoint na obnovenie tokenu s refresh tokenom, uloží nový access token a zopakuje pôvodný požiadavok — to všetko transparentne pre volajúci kód. Dôležité je synchronizovať obnovenie tokenu pomocou actora, aby sa pri viacerých súbežných 401 odpovediach token obnovoval iba raz. Ostatné požiadavky čakajú na dokončenie obnovy a potom sa automaticky zopakujú s novým tokenom.

Kedy použiť Alamofire alebo inú knižnicu tretej strany namiesto čistého URLSession?

S príchodom async/await a moderných API v Swift 6.x sa potreba knižníc tretích strán výrazne znížila. Čistý URLSession dnes pokrýva väčšinu prípadov použitia vrátane JSON kódovania, upload/download s progresom, certifikátového pinningu aj pozadového sťahovania. Alamofire stále dáva zmysel v komplexných projektoch, kde využijete pokročilé funkcie ako automatická serializácia parametrov, validačné reťazce či multipart upload. Pre väčšinu stredne veľkých aplikácií je však vlastný API klient postavený na URLSession dostatočne výkonný, ľahší a bez externých závislostí.

O Autorovi Editorial Team

Our team of expert writers and editors.