Nexa is a SwiftUI-inspired declarative networking library built on URLSession.
- Declarative request builders for
GET,POST,PUT,PATCH, andDELETE - Typed response decoding with Swift Concurrency
- Value-semantic request composition
- Query, header, timeout, body, and JSON encoding support
- Endpoint-based API with compile-time response typing
- Request-level and global interceptor chains
- Built-in authentication and token refresh flow through
NXAuthTokenProvider - Retry policies with fixed and exponential backoff
- Response validation and server error decoding
- Logger hooks and transport abstraction for testing
| Platform | Swift | Installation |
|---|---|---|
| iOS 15.0+ / macOS 12.0+ | Swift 6.1 | Swift Package Manager |
Add Nexa to your Package.swift:
dependencies: [
.package(url: "https://github.com/opficdev/Nexa.git", branch: "main")
]Then add Nexa to your target dependencies:
.target(
name: "AppModule",
dependencies: [
.product(name: "Nexa", package: "Nexa")
]
)Most code starts from NXAPIClient, then moves into either NXRequestBuilder or NXTypedRequestBuilder<Response>.
The rest of the public surface is made of extension points for auth, logging, testing, retry, validation, and custom error mapping.
| API | When to use it | Example |
|---|---|---|
NXAPIClient |
Main entry point for requests that share one baseURL and one configuration |
client.get("/users", as: User.self).send() |
NXRequestBuilder |
When you want to inspect the assembled URLRequest or receive NXRawResponse without decoding |
try await client.get("/users").raw() |
NXTypedRequestBuilder<Response> |
When the response should decode directly into a Decodable type |
try await client.get("/users/1", as: User.self).send() |
NXEndpoint |
When an endpoint definition should be reusable and carry its response type with it | try await client.send(UserEndpoint(identifier: 1)) |
NXClientConfiguration |
When shared headers, transport, logger, auth, encoder, decoder, or interceptors should be configured once | NXClientConfiguration(baseURL: url, authTokenProvider: yourAuthTokenProvider) |
NXRetryPolicy |
When a request should retry on retryable status codes or transport failures | .retry(.init(maxAttempts: 3)) |
NXValidationPolicy |
When the accepted status codes differ from the default 200..<300 |
.validate(.statusCodes([200, 201, 204])) |
NXHTTPTransport |
When you need stubs in tests or want to replace the transport implementation | NXClientConfiguration(baseURL: url, transport: yourStubTransport) |
NXHTTPInterceptor |
When you need cross-cutting request behavior such as tracing or header injection | .intercept(yourInterceptor) |
NXAuthTokenProvider |
When .authorized() requests need token lookup and refresh support |
authTokenProvider: yourAuthTokenProvider |
NXServerErrorDecoder |
When failed responses should decode into your own domain error | serverErrorDecoder: yourServerErrorDecoder |
NXLogger |
When you want structured request lifecycle logging | logger: yourLogger |
NXRawResponse |
When you need both Data and HTTPURLResponse directly |
let response = try await client.get("/users").raw() |
NXError |
When handling Nexa-specific failures in calling code | catch let error as NXError |
NXHTTPMethod |
When defining an NXEndpoint method |
var method: NXHTTPMethod { .post } |
Assume client below is an NXAPIClient that has already been configured.
Use NXAPIClient + NXTypedRequestBuilder<Response> for most application code:
import Foundation
import Nexa
struct User: Decodable {
let id: Int
let name: String
}
let user = try await client
.get("/users/42", as: User.self)
.send()Use NXRequestBuilder when you want to inspect the request or handle the raw response yourself:
import Foundation
import Nexa
let request = try await client
.post("/users")
.header("X-Trace-Id", UUID().uuidString)
.preparedURLRequest()Use NXEndpoint when the same endpoint shape is reused in several places:
import Foundation
import Nexa
struct User: Decodable {
let id: Int
let name: String
}
struct UserEndpoint: NXEndpoint {
let identifier: Int
var method: NXHTTPMethod { .get }
var path: String { "/users/\(identifier)" }
func configure(_ builder: NXTypedRequestBuilder<User>) -> NXTypedRequestBuilder<User> {
builder.query("include", "profile")
}
}Use the lower-level protocols only when the default behavior is not enough:
NXHTTPTransport: stubs, mocks, custom network backendsNXHTTPInterceptor: tracing, request mutation, custom flow controlNXAuthTokenProvider: bearer token injection and refreshNXServerErrorDecoder: server payload to domain error mappingNXLogger: request lifecycle logging and observability
sequenceDiagram
autonumber
participant App
participant Config as NXClientConfiguration
participant Client as NXAPIClient
participant Builder as Builder
participant Assembler as NXRequestAssembler
participant Chain as NXInterceptorChain
participant Transport as NXHTTPTransport
participant Pipeline as NXResponsePipeline
App->>Config: 공통 설정 구성
App->>Client: NXAPIClient(configuration)
alt method 기반 호출
App->>Client: get/post/put/patch/delete
Client->>Builder: NXRequestBuilder or NXTypedRequestBuilder
else endpoint 기반 호출
App->>Client: request(endpoint) / send(endpoint)
Client->>Builder: endpoint.configure(builder)
end
App->>Builder: query/header/authorized/retry/validate/intercept...
alt preparedURLRequest()
Builder->>Assembler: assemble()
Assembler-->>Builder: URLRequest
Builder-->>App: URLRequest
else raw()
Builder->>Assembler: assemble()
Assembler-->>Builder: URLRequest
Builder->>Chain: execute(context)
Chain->>Transport: send(request)
Transport-->>Chain: NXRawResponse
Chain-->>Builder: NXRawResponse
Builder->>Pipeline: validate()
Pipeline-->>Builder: validated
Builder-->>App: NXRawResponse
else send()
Builder->>Assembler: assemble()
Assembler-->>Builder: URLRequest
Builder->>Chain: execute(context)
Chain->>Transport: send(request)
Transport-->>Chain: NXRawResponse
Chain-->>Builder: NXRawResponse
Builder->>Pipeline: validate()
Pipeline-->>Builder: validated
Builder->>Pipeline: decode()
Pipeline-->>Builder: Response
Builder-->>App: Response
end
Nexa keeps request code compact while still exposing auth, retry, validation, and decoding in one flow.
import Foundation
import Nexa
struct User: Decodable {
let id: Int
let name: String
}
let client = NXAPIClient(
configuration: NXClientConfiguration(
baseURL: URL(string: "https://api.example.com")!
)
)
let user = try await client
.get("/users/me", as: User.self)
.query("include", "profile")
.accept("application/json")
.send()Add .authorized() only when the client has an authTokenProvider.
You can also build requests step by step:
import Foundation
import Nexa
struct CreateUserPayload: Encodable {
let name: String
}
struct User: Decodable {
let id: Int
let name: String
}
let createdUser = try await client
.post("/users", as: User.self)
.header("X-Trace-Id", UUID().uuidString)
.json(CreateUserPayload(name: "opfic"))
.send()If you prefer a Moya-style endpoint abstraction, define an NXEndpoint and let Nexa keep the response type attached to the endpoint itself.
import Foundation
import Nexa
struct User: Decodable {
let id: Int
let name: String
}
struct UserEndpoint: NXEndpoint {
let identifier: Int
var method: NXHTTPMethod { .get }
var path: String { "/users/\(identifier)" }
func configure(_ builder: NXTypedRequestBuilder<User>) -> NXTypedRequestBuilder<User> {
builder
.query("include", "profile")
.accept("application/json")
}
}
let user = try await client.send(UserEndpoint(identifier: 42))NXClientConfiguration centralizes the pieces that usually spread across a custom API layer.
import Foundation
import Nexa
let configuration = NXClientConfiguration(
baseURL: URL(string: "https://api.example.com")!,
headers: [
"Accept": "application/json"
],
transport: NXURLSessionTransport(),
logger: NXNoopLogger(),
interceptors: [],
serverErrorDecoder: NXDefaultServerErrorDecoder(),
authTokenProvider: nil
)
let client = NXAPIClient(configuration: configuration)Replace the defaults with your own conforming types only when you need custom behavior:
NXLoggerfor structured loggingNXHTTPInterceptorfor request tracing or mutationNXServerErrorDecoderfor mapping failed responses to domain errorsNXAuthTokenProviderfor bearer token injection and refresh
Nexa currently supports:
- Global headers and per-request headers
- Raw body and JSON body encoding
- Request-level validation policies
- Automatic auth header injection for
authorized()requests - Token refresh and retry handling
- Custom transports for stubbing and isolated tests
Nexa was designed to keep request execution testable. NXHTTPTransport lets you replace live networking with a custom transport and validate the outgoing request and decoded response.
import Foundation
import Nexa
struct User: Codable, Equatable {
let id: Int
let name: String
}
struct StubTransport: NXHTTPTransport {
func send(_ request: URLRequest) async throws -> NXRawResponse {
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!
return NXRawResponse(
data: Data(#"{"id":1,"name":"opfic"}"#.utf8),
response: response
)
}
}
let client = NXAPIClient(
configuration: NXClientConfiguration(
baseURL: URL(string: "https://example.com")!,
transport: StubTransport()
)
)
let user = try await client.get("/users/1", as: User.self).send()