Lightweight Swift networking utilities: a small HTTP client, request builders, encoders/decoders and helpers used across iOS/macOS projects.
- Network Utilities: Various URLRequest helpers, and builder.
- Network Client: HTTP client supporting Interceptor chaining, reqeuest/response logging, with default encoding & decoding.
- Swift 5.10
- iOS 13+ / macOS 10.15+
Add the package entry to your Package.swift or use Xcode's SPM UI:
.package(url: "https://github.com/indice-co/Indice.Swift.Networking", .upToNextMajor(from: "1.5.1"))- Build a URLRequest with the typed builder
import NetworkUtilities
let request = URLRequest.build()
.get(url: URL(string: "https://api.example.com/items")!)
.add(query: "page", value: "1")
.set(header: .accept(type: .json))
.build()- Send requests with NetworkClient
import NetworkClient
let client = NetworkClient()
struct Item: Decodable {
let id: Int
let name: String
}
Task {
do {
let response: NetworkClient.Response<[Item]>
= try await client.fetch(request: request)
let items = response.item
print("Got items: \(items.count)")
} catch {
print("Request failed: \(error)")
}
}- JSON body:
bodyJson(of:)uses aJSONDataEncoder(default:DefaultJsonEncoder). - Form body:
bodyForm(params:)andbodyFormUtf8(params:)useDefaultFormEncoder. - Multipart:
bodyMultipartaccepts a builder to add string fields, data or file parts.
Example (JSON POST):
struct Payload: Encodable { let name: String }
let request = try URLRequest.build()
.post(url: URL(string: "https://api.example.com/create")!)
.bodyJson(of: Payload(name: "Alice"))
.set(header: .content(type: .json))
.build()Example (multipart):
let request = try URLRequest.build()
.post(url: URL(string: "https://api.example.com/upload")!)
.bodyMultipart { builder in
builder.add(
key: "description",
value: "My file")
try builder.add(
key: "file",
file: .init(
file: fileUrl,
filename: "photo.jpg",
mimeType: .auto()))
}
.build()The URLRequest.Builder (via URLRequest.builder(with: options)), guides the request creation.
For example a GET request, doesn't use a body
let request = try URLRequest.builder()
.get(url: URL(string: "https://api.example.com/upload")!)
.bodyJson(of: Payload(name: "Alice")) // ❌ compiler error.
.build()The URLRequest.Builder will go through the following stages
- VERB
- BODY (when applicable)
- QUERY Params
- HEADERS
let requestGET = try URLRequest.builder()
.get(url: URL(string: "https://api.example.com/upload")!)
.add(query: "param1", value: "value1")
.add(query: "param2", value: "value2")
.add(header: .authorisation(auth: authToken))
.build()
let requestPOST = try URLRequest.builder()
.post(url: URL(string: "https://api.example.com/upload")!)
.bodyJson(of: Payload(name: "Alice"))
.add(query: "param", value: "value")
.add(header: .authorisation(auth: authToken))
.build()The VERBs that support a request body (PUT, POST, PATCH), require a body step on their build chain.
To build one without a body, use the .noBody() option.
let requestPOST = try URLRequest.builder()
.post(url: URL(string: "https://api.example.com/upload")!)
.noBody()
.add(query: "param", value: "value")
.build()- The default decoder is
DefaultDecoderwhich uses JSON decoding and also handles plainStringandBoolresponses. - Use
NullHandlingDecoder(viadecoder.handlingOptionalResponses) if the endpoint may return empty bodies (e.g. 204) for optional types.
Example:
These scenarios will fail throwing a NetworkClient.Error.decodingError error.
let client = NetworkClient(decoder: .default)
// Throws because the expected response is a not null model
// an empty body, Data(), will no t be decoded to SomeModel
let response: NetworkClient.Response<SomeModel>
try await client.fetch(request: request)
// Throws because while the model is nullable,
// the default decoder will still try to decode an empty response body.
let response: NetworkClient.Response<SomeModel?>
try await client.fetch(request: request)
let clientWithNullDecoder = NetworkClient(decoder: .default.handlingOptionalResponses)
// Throws, while using the `NullHandlingDecoder`, the response is not a nullable model,
// so the `Decode` will still try to decode an empty response body.
let response: NetworkClient.Response<SomeModel>
try await clientWithNullDecoder.fetch(request: request)In order to use the NullHandlingDecoder, the response type MUST be an Optional type.
Only then will the decoder check the response body length.
let clientWithNullDecoder = NetworkClient(
decoder: .default.handlingOptionalResponses)
// This will succeed with a null `reponse.item`
let response: NetworkClient.Response<SomeModel?>
try await clientWithNullDecoder.fetch(request: request)NetworkClientaccepts an array ofInterceptorinstances. Interceptors can modify and react to requests and responses. Interceptors are called in the order that are provided when building theNetworkClient.
Example — adding an auth header
// Simple interceptor that adds an Authorization header
struct AuthInterceptor: InterceptorProtocol {
let tokenStorage: TokenStorage
func process(
_ request: URLRequest,
next: @Sendable (URLRequest) async throws -> NetworkClient.ChainResult
) async throws -> NetworkClient.ChainResult {
let accessToken = try tokenStorage.requireAuthorization
let authorizedRequest = request
.setting(header: .authorisation(auth: accessToken))
return try await next(authorizedRequest)
}
}
let client = NetworkClient(interceptors: [AuthInterceptor(tokenStorage: someStorage)])- The client can deduplicate identical requests when desired. Call
withInstanceCaching()on aURLRequestto enable instance caching.
Example — deduplicating two concurrent fetches for the same request:
import NetworkClient
import NetworkUtilities
struct Item: Decodable { let id: Int; let name: String }
let client = NetworkClient()
let request = URLRequest.build()
.get(url: URL(string: "https://api.example.com/items")!)
.set(header: .accept(type: .json))
.build()
.withInstanceCaching()
Task {
async let first: NetworkClient.Response<[Item]> = try client.fetch(request: request)
async let second: NetworkClient.Response<[Item]> = try client.fetch(request: request)
do {
let (r1, r2) = try await (first, second)
print("Both responses received; items: \(r1.item.count), \(r2.item.count)")
} catch {
print("Request failed: \(error)")
}
}When withInstanceCaching() is used the client will perform a single network call for identical requests and return the same response to all awaiting callers.
Note: the instance cache only holds the in-flight task for the original request — the cache entry is removed when that request completes (either success or failure). A subsequent identical request made after completion will start a new HTTP call.
Use a custom URLSession, logger, interceptors, decoder and an error mapper to adapt the client to more advanced app needs. Example:
import NetworkClient
import NetworkUtilities
// Custom error for illustration
enum AuthError: Error { case unauthorized }
// Custom URLSession
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.waitsForConnectivity = true
let session = URLSession(configuration: configuration)
// Logger with Authorization header masked
let logger = DefaultLogger.default(logLevel: .full, headerMasks: [.authorization])
// Simple auth interceptor that adds an Authorization header
struct SimpleAuthInterceptor: InterceptorProtocol {
let tokenProvider: () async throws -> String
func process(
_ request: URLRequest,
next: @Sendable (URLRequest) async throws -> NetworkClient.ChainResult
) async throws -> NetworkClient.ChainResult {
let token = try await tokenProvider()
let authorized = request.setting(header: .authorisation(auth: token))
return try await next(authorized)
}
}
// Error mapper: convert 401 -> AuthError
let errorMapper = ResponseErrorMapper { info in
if info.response.statusCode == 401 {
return AuthError.unauthorized
}
return info.error
}
let client = NetworkClient(
interceptors: [
SimpleAuthInterceptor { "Bearer my_access_token" }
],
decoder: .default.handlingOptionalResponses,
logging: logger,
session: session,
apiErrorMapper: errorMapper
)
// Use the client as usual
let request = URLRequest.build()
.get(url: URL(string: "https://api.example.com/profile")!)
.set(header: .accept(type: .json))
.build()
Task {
do {
let response: NetworkClient.Response<Profile> = try await client.fetch(request: request)
print("Got profile: \(response.item)")
} catch {
print("Request failed: \(error)")
}
}This setup demonstrates injecting a custom URLSession (timeouts, connectivity), a DefaultLogger with header masking, an auth interceptor, a NullHandlingDecoder wrapper (handlingOptionalResponses) and a ResponseErrorMapper to translate HTTP errors into domain errors.
- By default the client throws
NetworkClient.Error.apiError(response:data:)for non-2xx responses. - Customize mapping by providing a
ResponseErrorMapperto theNetworkClientinitializer to convert server errors into domain-specific errors.
- Client implementation:
Sources/NetworkClient/Client/NetworkClient.swift - Request builder:
Sources/NetworkUtilities/URLRequestBuilder.swift - Encoders:
Sources/NetworkUtilities/Encoding.swift - Decoders:
Sources/NetworkClient/Protocols/Decoding(includingDefaultDecoderandNullHandlingDecoder) - Interceptors & logging:
Sources/NetworkClient/HelpersandSources/NetworkClient/Protocols/Logging