Современная типобезопасная HTTP библиотека для Swift
Легковесная, типобезопасная библиотека для работы с HTTP в Swift приложениях
EKNetwork — это современная библиотека для сетевых запросов, которая сочетает простоту использования с мощными возможностями. Она создана для разработчиков, которые ценят типобезопасность, чистый код и современные практики Swift.
- 🚀 Type-Safe API — Полная типобезопасность на уровне компиляции, никаких runtime ошибок
- ⚡ Async/Await — Нативная поддержка современного Swift concurrency без callback hell
- 🔄 Автоматический Retry — Гибкая политика повторных попыток для каждого запроса
- 🔐 Token Refresh — Автоматическое обновление токенов при 401 ошибках
- 📊 Progress Tracking — Отслеживание прогресса загрузки и выгрузки с поддержкой SwiftUI
- 🌊 Streaming-ответы — Полноценная поддержка NDJSON / SSE / chunked transfer через тот же пайплайн
NetworkRequest(начиная с 1.6.0). См. API_RU.md. - 🎨 Гибкая конфигурация — Настройка JSON кодирования/декодирования для каждого запроса
- 🧪 Тестируемость — Протоколы для легкого мокирования и тестирования
- 📦 Zero Dependencies — Никаких внешних зависимостей, только стандартная библиотека Swift
- 🛡️ Production Ready — Протестировано, оптимизировано и готово к использованию
Описывайте запросы как типы Swift — компилятор сам проверит правильность вашего кода:
struct SignInRequest: NetworkRequest {
struct Response: Decodable {
let token: String
let user: User
}
// ...
}Легко комбинируйте различные типы запросов, создавайте базовые классы для общих паттернов:
protocol AuthenticatedRequest: NetworkRequest {
// Общая логика для авторизованных запросов
}Четкая иерархия ошибок с возможностью кастомной обработки:
do {
let response = try await manager.send(request)
} catch let error as HTTPError {
// Обработка HTTP ошибок
} catch NetworkError.unauthorized {
// Обработка авторизации
}Пишите меньше кода, делайте больше. Один запрос = одна структура:
struct GetUserRequest: NetworkRequest {
typealias Response = User
var path: String { "/users/\(id)" }
var method: HTTPMethod { .get }
let id: Int
}21 тест покрывает все основные сценарии использования, включая edge cases.
Добавьте EKNetwork в зависимости вашего проекта в Package.swift:
dependencies: [
.package(url: "https://github.com/emvakar/EKNetwork.git", from: "1.4.2")
]Или через Xcode:
- File → Add Packages...
- Введите URL репозитория:
https://github.com/emvakar/EKNetwork.git - Выберите версию
Затем добавьте продукт в ваш target:
.target(
name: "YourTarget",
dependencies: [
.product(name: "EKNetwork", package: "EKNetwork")
]
)- Swift: 6.0+
- iOS: 18.0+
- macOS: 15.0+
import EKNetwork
struct SignInRequest: NetworkRequest {
struct Response: Decodable {
let token: String
let user: User
}
struct User: Decodable {
let id: Int
let email: String
let name: String
}
let email: String
let password: String
var path: String { "/api/v1/auth/sign-in" }
var method: HTTPMethod { .post }
var body: RequestBody? {
RequestBody(encodable: [
"email": email,
"password": password
])
}
}let manager = NetworkManager(
baseURL: { URL(string: "https://api.example.com")! }
)
let response = try await manager.send(
SignInRequest(
email: "user@example.com",
password: "securepassword"
),
accessToken: { TokenStore.shared.accessToken }
)
print("Token: \(response.token)")
print("User: \(response.user.name)")Вот и всё! Всего несколько строк кода для полноценного сетевого запроса с типобезопасностью и обработкой ошибок.
Для полной документации API см. API_RU.md. Справочник включает:
- Полную документацию методов и свойств
- Описание параметров
- Примеры использования
- Детали обработки ошибок
- Соответствие протоколам
struct SearchRequest: NetworkRequest {
struct Response: Decodable {
let results: [SearchResult]
let total: Int
}
let query: String
let page: Int
var path: String { "/api/search" }
var method: HTTPMethod { .get }
var queryParameters: [String: String]? {
["q": query, "page": "\(page)", "limit": "20"]
}
}
let response = try await manager.send(
SearchRequest(query: "Swift", page: 1),
accessToken: nil
)struct UploadAvatarRequest: NetworkRequest {
typealias Response = StatusCodeResponse
let imageData: Data
var path: String { "/api/user/avatar" }
var method: HTTPMethod { .post }
var multipartData: MultipartFormData? {
var data = MultipartFormData()
data.addPart(
name: "avatar",
data: imageData,
mimeType: "image/jpeg",
filename: "avatar.jpg"
)
return data
}
}
let response = try await manager.send(
UploadAvatarRequest(imageData: imageData),
accessToken: tokenProvider
)@MainActor
class UploadViewModel: ObservableObject {
@Published var uploadProgress: Double = 0.0
func uploadFile(_ data: Data) async throws {
let progress = NetworkProgress()
// Связываем прогресс с UI
progress.$fractionCompleted
.assign(to: &$uploadProgress)
struct UploadRequest: NetworkRequest {
typealias Response = StatusCodeResponse
var path: String { "/api/upload" }
var method: HTTPMethod { .post }
var progress: NetworkProgress? { progress }
var multipartData: MultipartFormData? {
var data = MultipartFormData()
data.addPart(name: "file", data: fileData, mimeType: "application/octet-stream")
return data
}
}
let manager = NetworkManager(baseURL: { baseURL })
_ = try await manager.send(UploadRequest(), accessToken: nil)
}
}struct CriticalRequest: NetworkRequest {
typealias Response = CriticalData
var path: String { "/api/critical" }
var method: HTTPMethod { .get }
var retryPolicy: RetryPolicy {
RetryPolicy(
maxRetryCount: 3,
delay: 2.0
) { error in
// Повторяем только при сетевых ошибках
if let urlError = error as? URLError {
return urlError.code == .timedOut ||
urlError.code == .networkConnectionLost
}
return false
}
}
}class TokenManager: TokenRefreshProvider {
func refreshTokenIfNeeded() async throws {
// Ваша логика обновления токена
let refreshRequest = RefreshTokenRequest(
refreshToken: TokenStore.shared.refreshToken
)
let response = try await networkManager.send(refreshRequest, accessToken: nil)
TokenStore.shared.accessToken = response.accessToken
}
}
let manager = NetworkManager(baseURL: { baseURL })
manager.tokenRefresher = TokenManager()
// При получении 401 токен автоматически обновится и запрос повторится
let response = try await manager.send(
ProtectedRequest(),
accessToken: { TokenStore.shared.accessToken }
)struct APIRequest: NetworkRequest {
typealias Response = APIResponse
var path: String { "/api/data" }
var method: HTTPMethod { .get }
var errorDecoder: ((Data) -> Error?)? {
{ data in
// Декодируем кастомную ошибку от сервера
if let apiError = try? JSONDecoder().decode(APIError.self, from: data) {
return apiError
}
return nil
}
}
}
struct APIError: Decodable, Error {
let code: String
let message: String
}struct DateRequest: NetworkRequest {
struct Body: Encodable {
let timestamp: Date
let event: String
}
struct Response: Decodable {
let id: String
let createdAt: Date
}
var path: String { "/api/events" }
var method: HTTPMethod { .post }
var body: RequestBody? {
RequestBody(encodable: Body(timestamp: Date(), event: "test"))
}
var jsonEncoder: JSONEncoder {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.keyEncodingStrategy = .convertToSnakeCase
return encoder
}
var jsonDecoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}
}Если нужна единая стратегия декодирования для всех запросов (например, гибкие даты), можно передать глобальный декодер в NetworkManager.
let network = NetworkManager(
baseURL: URL(string: "https://api.example.com")!,
responseDecoderProvider: {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}
)По умолчанию глобальный декодер применяется только если запрос это разрешает.
Чтобы отключить для конкретного запроса (если используется кастомный decodeResponse), задайте:
var allowsResponseDecoderOverride: Bool { false }Пример: гибкое декодирование даты (строка или unix seconds):
let network = NetworkManager(
baseURL: URL(string: "https://api.example.com")!,
responseDecoderProvider: {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
if let seconds = try? container.decode(Double.self) {
return Date(timeIntervalSince1970: seconds)
}
if let string = try? container.decode(String.self) {
if let date = ISO8601DateFormatter().date(from: string) {
return date
}
let df = DateFormatter()
df.locale = Locale(identifier: "en_US_POSIX")
df.timeZone = TimeZone(secondsFromGMT: 0)
df.dateFormat = "yyyy-MM-dd"
if let date = df.date(from: string) { return date }
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date")
}
return decoder
}
)struct LoginRequest: NetworkRequest {
struct Response: Decodable {
let token: String
}
let username: String
let password: String
var path: String { "/login" }
var method: HTTPMethod { .post }
var body: RequestBody? {
RequestBody(formURLEncoded: [
"username": username,
"password": password
])
}
}struct BinaryUploadRequest: NetworkRequest {
typealias Response = UploadResponse
let binaryData: Data
var path: String { "/api/upload/binary" }
var method: HTTPMethod { .post }
var body: RequestBody? {
RequestBody(data: binaryData, contentType: "application/octet-stream")
}
}// Базовый URL вычисляется при каждом запросе через замыкание — без гонок
var currentBase = URL(string: "https://api.staging.example.com")!
let manager = NetworkManager(baseURL: { currentBase })
// Переключение на production: измените значение, захваченное замыканием
currentBase = URL(string: "https://api.example.com")!
// Все последующие запросы будут использовать новый URL
// Или читайте из конфига/окружения
let manager = NetworkManager(baseURL: { AppConfig.shared.apiBaseURL })let userAgentConfig = UserAgentConfiguration(
appName: "MyApp",
appVersion: "2.0.0",
bundleIdentifier: "com.example.myapp",
buildNumber: "123",
osVersion: UIDevice.current.systemVersion
)
let manager = NetworkManager(
baseURL: { baseURL },
userAgentConfiguration: userAgentConfig
)
// User-Agent будет автоматически добавлен ко всем запросамГруппируйте запросы по функциональности для лучшей организации кода:
enum AuthRequests {
struct SignIn: NetworkRequest {
struct Response: Decodable { let token: String }
let email: String
let password: String
var path: String { "/auth/sign-in" }
var method: HTTPMethod { .post }
// ...
}
struct SignOut: NetworkRequest {
typealias Response = EmptyResponse
var path: String { "/auth/sign-out" }
var method: HTTPMethod { .post }
}
struct RefreshToken: NetworkRequest {
struct Response: Decodable { let accessToken: String }
let refreshToken: String
var path: String { "/auth/refresh" }
var method: HTTPMethod { .post }
// ...
}
}
enum UserRequests {
struct GetProfile: NetworkRequest {
typealias Response = UserProfile
var path: String { "/user/profile" }
var method: HTTPMethod { .get }
}
struct UpdateProfile: NetworkRequest {
typealias Response = UserProfile
let name: String
var path: String { "/user/profile" }
var method: HTTPMethod { .put }
// ...
}
}Создайте единую точку доступа к API:
class APIClient {
static let shared = APIClient()
private let manager: NetworkManager
private init() {
let baseURL = URL(string: "https://api.example.com")!
manager = NetworkManager(
baseURL: { baseURL },
userAgentConfiguration: UserAgentConfiguration(
appName: Bundle.main.appName,
appVersion: Bundle.main.appVersion,
bundleIdentifier: Bundle.main.bundleIdentifier ?? "",
buildNumber: Bundle.main.buildNumber,
osVersion: UIDevice.current.systemVersion
)
)
manager.tokenRefresher = TokenManager()
}
func send<T: NetworkRequest>(_ request: T) async throws -> T.Response {
try await manager.send(request, accessToken: {
TokenStore.shared.accessToken
})
}
}
// Использование
let profile = try await APIClient.shared.send(UserRequests.GetProfile())Используйте иерархию ошибок для правильной обработки:
func handleRequest<T: NetworkRequest>(_ request: T) async {
do {
let response = try await manager.send(request, accessToken: tokenProvider)
// Обработка успешного ответа
await handleSuccess(response)
} catch let error as HTTPError {
switch error.statusCode {
case 400:
await handleBadRequest(error)
case 401:
await handleUnauthorized()
case 404:
await handleNotFound()
case 500...599:
await handleServerError(error)
default:
await handleUnknownError(error)
}
} catch NetworkError.unauthorized {
await handleUnauthorized()
} catch NetworkError.invalidURL {
await handleInvalidURL()
} catch {
await handleUnknownError(error)
}
}Используйте протоколы для мокирования:
// Мок URLSession
class MockURLSession: URLSessionProtocol {
var responseData: Data?
var response: URLResponse?
var error: Error?
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
if let error = error {
throw error
}
return (
responseData ?? Data(),
response ?? HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!
)
}
}
// В тестах
func testSignIn() async throws {
let mockSession = MockURLSession()
mockSession.responseData = try JSONEncoder().encode(
SignInRequest.Response(token: "test-token", user: testUser)
)
let manager = NetworkManager(
baseURL: { URL(string: "https://test.com")! },
session: mockSession
)
let response = try await manager.send(
SignInRequest(email: "test@test.com", password: "password"),
accessToken: nil
)
XCTAssertEqual(response.token, "test-token")
}EKNetwork имеет полное тестовое покрытие (21 тест) и предоставляет протоколы для легкого тестирования:
- ✅ Все HTTP методы (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE, CONNECT)
- ✅ Query параметры
- ✅ Различные типы body (JSON, Form URL Encoded, Multipart, Raw Data)
- ✅ Retry policy
- ✅ Token refresh
- ✅ Error handling
- ✅ Progress tracking
- ✅ User-Agent configuration
- ✅ Content-Length headers
Запустите тесты:
swift testМы приветствуем вклад в проект! Пожалуйста, ознакомьтесь с CONTRIBUTING.md для получения подробной информации.
- ⭐ Поставьте звезду на GitHub — это помогает проекту быть более заметным
- 🐛 Сообщайте об ошибках — создавайте issues с подробным описанием проблемы
- 💡 Предлагайте новые функции — делитесь идеями по улучшению библиотеки
- 📝 Улучшайте документацию — помогайте делать документацию лучше
- 🔧 Отправляйте Pull Requests — исправления и новые функции всегда приветствуются
- 💬 Расскажите о проекте — поделитесь с друзьями и коллегами
- 🐦 Следите за обновлениями — watch репозиторий, чтобы быть в курсе
- Fork репозитория
- Создайте ветку для ваших изменений (
git checkout -b feature/amazing-feature) - Внесите изменения и добавьте тесты
- Убедитесь, что все тесты проходят (
swift test) - Создайте Pull Request с подробным описанием изменений
Подробнее в CONTRIBUTING.md.
EKNetwork — это open source проект, созданный с любовью для Swift сообщества. Если проект полезен для вас, рассмотрите возможность поддержки:
- ⭐ Поставьте звезду на GitHub — это бесплатно и помогает проекту
- 🐛 Сообщайте об ошибках — помогайте улучшать качество
- 💡 Предлагайте идеи — делитесь своими мыслями о развитии
- 🔧 Вносите код — Pull Requests всегда приветствуются
- 📢 Расскажите о проекте — поделитесь в социальных сетях, блогах, на конференциях
- 💰 Финансовая поддержка — если хотите поддержать разработку финансово, свяжитесь с автором
- 🚀 Помогает проекту развиваться быстрее
- 🐛 Улучшает качество и стабильность
- 📚 Расширяет документацию и примеры
- 🌟 Делает проект более заметным в сообществе
- 💡 Вдохновляет на новые функции и улучшения
Спасибо всем, кто поддерживает проект! 🙏
EKNetwork доступен под лицензией MIT. См. LICENSE для получения дополнительной информации.
Спасибо всем контрибьюторам, которые помогают улучшать EKNetwork!
Особую благодарность:
- Swift сообществу за вдохновение и feedback
- Всем, кто тестирует библиотеку и сообщает об ошибках
- Контрибьюторам, которые улучшают код и документацию
- 💬 Issues: GitHub Issues
- 📖 API Reference: API_RU.md - Полная документация API
- 📚 Документация: Full Documentation
- 🔒 Безопасность: SECURITY.md для сообщений о уязвимостях
- ✅ Stable: Готов к использованию в production
- ✅ Tested: 21 тест покрывает основные сценарии
- ✅ Documented: Полная документация с примерами
- ✅ Maintained: Активная поддержка и развитие
Для разработчиков, желающих внести вклад в проект, см. PROJECT_STRUCTURE.md для понимания структуры проекта.