Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Examples/NexaClient/NexaClient.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.0;
};
name = Debug;
};
Expand Down Expand Up @@ -347,7 +347,7 @@
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.0;
};
name = Release;
};
Expand Down
4 changes: 0 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
// swift-tools-version: 6.1
// The swift-tools-version declares the minimum version of Swift required to build this package.

import Foundation
import PackageDescription
Expand All @@ -11,16 +10,13 @@ let package = Package(
.macOS(.v12),
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "Nexa",
targets: ["Nexa"]
),
],
dependencies: [],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(name: "Nexa"),
.testTarget(
name: "NexaTests",
Expand Down
5 changes: 5 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Nexa는 `URLSession` 기반의 SwiftUI 스타일 선언형 네트워킹 라이
- [x] `NXAuthTokenProvider`를 통한 인증 및 토큰 갱신 흐름 내장
- [x] Fixed backoff 및 exponential backoff 기반 재시도 정책
- [x] 응답 유효성 검사 및 서버 에러 디코딩
- [x] 성공한 `GET` 응답과 진행 중인 동일 요청을 위한 memory response cache
- [x] 로거 훅 및 테스트를 위한 transport 추상화

## 요구 사항
Expand Down Expand Up @@ -74,6 +75,7 @@ dependencies: [
| `NXTypedRequestBuilder<Response>` | 응답을 `Decodable` 타입으로 바로 디코딩할 때 | `try await client.get("/users/1", as: User.self).send()` |
| `NXEndpoint` | 엔드포인트 정의를 재사용하고 응답 타입을 함께 관리할 때 | `try await client.send(UserEndpoint(identifier: 1))` |
| `NXClientConfiguration` | 공통 헤더, transport, 로거, 인증, 인코더, 디코더, 인터셉터를 한 번에 설정할 때 | `NXClientConfiguration(baseURL: url, authTokenProvider: yourAuthTokenProvider)` |
| `NXCache` | 인증이 필요 없는 성공한 `GET` 응답을 짧은 TTL 동안 재사용할 때 | `NXClientConfiguration(baseURL: url, cache: .memory(ttl: 0.3))` |
| `NXRetryPolicy` | 재시도 가능한 상태 코드나 전송 오류 시 재시도할 때 | `.retry(.init(maxAttempts: 3))` |
| `NXValidationPolicy` | 허용할 상태 코드가 기본값(`200..<300`)과 다를 때 | `.validate(.statusCodes([200, 201, 204]))` |
| `NXHTTPTransport` | 테스트용 스텁이 필요하거나 transport 구현을 교체할 때 | `NXClientConfiguration(baseURL: url, transport: yourStubTransport)` |
Expand Down Expand Up @@ -299,6 +301,7 @@ let configuration = NXClientConfiguration(
transport: NXURLSessionTransport(),
logger: NXNoopLogger(),
interceptors: [],
cache: .memory(ttl: 0.3),
serverErrorDecoder: NXDefaultServerErrorDecoder(),
authTokenProvider: nil
)
Expand All @@ -318,6 +321,8 @@ let client = NXAPIClient(configuration: configuration)
- 전역 헤더 및 요청별 헤더
- 원시 바디 및 JSON 바디 인코딩
- 요청 단위 유효성 검사 정책
- 성공한 비인증 `GET` 응답의 TTL 내 memory 재사용
- 첫 요청이 실행 중일 때 진행 중인 동일 `GET` 요청 재사용
- `.authorized()` 요청에 대한 자동 인증 헤더 주입
- 토큰 갱신 및 재시도 처리
- 스터빙 및 격리 테스트를 위한 커스텀 transport
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Nexa is a SwiftUI-inspired declarative networking library built on `URLSession`.
- [x] Built-in authentication and token refresh flow through `NXAuthTokenProvider`
- [x] Retry policies with fixed and exponential backoff
- [x] Response validation and server error decoding
- [x] Memory response cache for successful `GET` responses and in-flight identical requests
- [x] Logger hooks and transport abstraction for testing

## Requirements
Expand Down Expand Up @@ -74,6 +75,7 @@ The rest of the public surface is made of extension points for auth, logging, te
| `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)` |
| `NXCache` | When successful unauthenticated `GET` responses should be reused for a short TTL | `NXClientConfiguration(baseURL: url, cache: .memory(ttl: 0.3))` |
| `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)` |
Expand Down Expand Up @@ -299,6 +301,7 @@ let configuration = NXClientConfiguration(
transport: NXURLSessionTransport(),
logger: NXNoopLogger(),
interceptors: [],
cache: .memory(ttl: 0.3),
serverErrorDecoder: NXDefaultServerErrorDecoder(),
authTokenProvider: nil
)
Expand All @@ -318,6 +321,8 @@ Nexa currently supports:
- Global headers and per-request headers
- Raw body and JSON body encoding
- Request-level validation policies
- In-memory reuse of successful unauthenticated `GET` responses within a TTL
- In-flight identical `GET` request reuse while the first request is still running
- Automatic auth header injection for `authorized()` requests
- Token refresh and retry handling
- Custom transports for stubbing and isolated tests
Expand Down
16 changes: 16 additions & 0 deletions Sources/Nexa/Core/NXCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// NXCache.swift
// Nexa
//
// Created by opfic on 6/19/26.
//

import Foundation

/// Response cache behavior for successful GET responses and in-flight identical requests.
public enum NXCache: Sendable, Equatable {
/// Does not cache responses and executes identical requests separately.
case disabled
/// Stores successful GET responses in memory for the specified TTL and reuses in-flight identical GET request results.
case memory(ttl: TimeInterval)
}
5 changes: 5 additions & 0 deletions Sources/Nexa/Core/NXClientConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ public struct NXClientConfiguration: Sendable {
public let logger: any NXLogger
/// Interceptors applied to every request.
public let interceptors: [any NXHTTPInterceptor]
/// Response cache behavior for successful GET responses and in-flight identical requests.
public let cache: NXCache
/// Decoder used by typed requests to decode the response body.
public let decoder: JSONDecoder
/// Encoder used for JSON request bodies when no encoder is passed to `json(_:encoder:)`.
Expand All @@ -57,6 +59,7 @@ public struct NXClientConfiguration: Sendable {
/// - transport: Transport used to execute requests.
/// - logger: Logger that receives request lifecycle events.
/// - interceptors: Interceptors applied to every request.
/// - cache: Response cache behavior for successful GET responses and in-flight identical requests.
/// - decoder: Decoder used for typed responses.
/// - encoder: Encoder used for JSON request bodies.
/// - serverErrorDecoder: Decoder used to map failed responses to custom errors.
Expand All @@ -67,6 +70,7 @@ public struct NXClientConfiguration: Sendable {
transport: any NXHTTPTransport = NXURLSessionTransport(),
logger: any NXLogger = NXNoopLogger(),
interceptors: [any NXHTTPInterceptor] = [],
cache: NXCache = .disabled,
decoder: JSONDecoder = JSONDecoder(),
encoder: JSONEncoder = JSONEncoder(),
serverErrorDecoder: any NXServerErrorDecoder = NXDefaultServerErrorDecoder(),
Expand All @@ -77,6 +81,7 @@ public struct NXClientConfiguration: Sendable {
self.transport = transport
self.logger = logger
self.interceptors = interceptors
self.cache = cache
self.decoder = decoder
self.encoder = encoder
self.serverErrorDecoder = serverErrorDecoder
Expand Down
17 changes: 14 additions & 3 deletions Sources/Nexa/Public/NXAPIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,20 @@ import Foundation
///
/// Use the untyped overloads when you need a prepared `URLRequest` or `NXRawResponse`.
public struct NXAPIClient: Sendable {
let clientConfiguration: NXClientConfiguration
private let configuration: NXClientConfiguration
private let responseCacheStore: NXResponseCacheStore?

/// Creates a client that uses the provided configuration for all requests.
///
/// - Parameter configuration: Shared settings such as the base URL, transport, logger, and auth provider.
public init(configuration: NXClientConfiguration) {
clientConfiguration = configuration
self.configuration = configuration
responseCacheStore = switch configuration.cache {
case .disabled:
nil
case .memory:
NXResponseCacheStore()
}
}

/// Creates an untyped `GET` request builder for the given path.
Expand Down Expand Up @@ -151,7 +158,11 @@ public struct NXAPIClient: Sendable {
}

func request(method: NXHTTPMethod, path: String) -> NXRequestBuilder {
NXRequestBuilder(clientConfiguration: clientConfiguration, requestSpec: RequestSpec(method: method, path: path))
NXRequestBuilder(
clientConfiguration: configuration,
responseCacheStore: responseCacheStore,
requestSpec: RequestSpec(method: method, path: path)
)
}

func typedRequest<Response: Decodable>(method: NXHTTPMethod, path: String) -> NXTypedRequestBuilder<Response> {
Expand Down
36 changes: 32 additions & 4 deletions Sources/Nexa/Public/NXRequestBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,19 @@ import Foundation
/// .raw()
/// ```
public struct NXRequestBuilder: Sendable {
let clientConfiguration: NXClientConfiguration
let requestSpec: RequestSpec
private let clientConfiguration: NXClientConfiguration
private let responseCacheStore: NXResponseCacheStore?
private let requestSpec: RequestSpec

init(
clientConfiguration: NXClientConfiguration,
responseCacheStore: NXResponseCacheStore?,
requestSpec: RequestSpec
) {
self.clientConfiguration = clientConfiguration
self.responseCacheStore = responseCacheStore
self.requestSpec = requestSpec
}

/// Appends a query item to the request URL.
///
Expand Down Expand Up @@ -174,7 +185,11 @@ public struct NXRequestBuilder: Sendable {
///
/// - Returns: Raw response data and HTTP metadata.
public func raw() async throws -> NXRawResponse {
try await NXRequestExecutor.executeRaw(clientConfiguration: clientConfiguration, requestSpec: requestSpec)
try await NXRequestExecutor.executeRaw(
clientConfiguration: clientConfiguration,
responseCacheStore: responseCacheStore,
requestSpec: requestSpec
)
}

/// Converts the builder into a typed builder that decodes the response.
Expand All @@ -185,9 +200,22 @@ public struct NXRequestBuilder: Sendable {
NXTypedRequestBuilder(requestBuilder: self)
}

func decoded<Response: Decodable>(_ type: Response.Type) async throws -> Response {
try await NXRequestExecutor.executeDecode(
clientConfiguration: clientConfiguration,
responseCacheStore: responseCacheStore,
requestSpec: requestSpec,
responseType: Response.self
)
}

func modifying(_ update: (inout RequestSpec) throws -> Void) rethrows -> Self {
var copiedRequestSpec = requestSpec
try update(&copiedRequestSpec)
return Self(clientConfiguration: clientConfiguration, requestSpec: copiedRequestSpec)
return Self(
clientConfiguration: clientConfiguration,
responseCacheStore: responseCacheStore,
requestSpec: copiedRequestSpec
)
}
}
14 changes: 1 addition & 13 deletions Sources/Nexa/Public/NXTypedRequestBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,6 @@ public struct NXTypedRequestBuilder<Response>: Sendable where Response: Decodabl
self.requestBuilder = requestBuilder
}

var clientConfiguration: NXClientConfiguration {
requestBuilder.clientConfiguration
}

var requestSpec: RequestSpec {
requestBuilder.requestSpec
}

/// Appends a query item to the request URL.
///
/// - Parameters:
Expand Down Expand Up @@ -163,10 +155,6 @@ public struct NXTypedRequestBuilder<Response>: Sendable where Response: Decodabl
/// - Returns: Decoded response value.
/// - Throws: `NXError` if the request fails or decoding fails.
public func send() async throws -> Response {
try await NXRequestExecutor.executeDecode(
clientConfiguration: clientConfiguration,
requestSpec: requestSpec,
responseType: Response.self
)
try await requestBuilder.decoded(Response.self)
}
}
33 changes: 33 additions & 0 deletions Sources/Nexa/Runtime/NXRequestCacheKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// NXRequestCacheKey.swift
// Nexa
//
// Created by opfic on 6/19/26.
//

import Foundation

struct NXRequestCacheKey: Hashable, Sendable {
let method: String
let url: String
let headers: Set<Header>

init?(request: URLRequest) {
guard let method = request.httpMethod,
let url = request.url?.absoluteString else {
return nil
}

self.method = method
self.url = url
headers = Set(
(request.allHTTPHeaderFields ?? [:])
.map { Header(name: $0.key.lowercased(), value: $0.value) }
)
}

struct Header: Hashable, Sendable {
let name: String
let value: String
}
}
15 changes: 15 additions & 0 deletions Sources/Nexa/Runtime/NXRequestExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Foundation
enum NXRequestExecutor {
static func executeRaw(
clientConfiguration: NXClientConfiguration,
responseCacheStore: NXResponseCacheStore?,
requestSpec: RequestSpec
) async throws -> NXRawResponse {
do {
Expand All @@ -29,6 +30,7 @@ enum NXRequestExecutor {
context: context,
interceptors: runtimeInterceptors(
clientConfiguration: clientConfiguration,
responseCacheStore: responseCacheStore,
requestSpec: requestSpec
),
transport: clientConfiguration.transport
Expand All @@ -48,11 +50,13 @@ enum NXRequestExecutor {

static func executeDecode<T: Decodable>(
clientConfiguration: NXClientConfiguration,
responseCacheStore: NXResponseCacheStore?,
requestSpec: RequestSpec,
responseType: T.Type
) async throws -> T {
let rawResponse = try await executeRaw(
clientConfiguration: clientConfiguration,
responseCacheStore: responseCacheStore,
requestSpec: requestSpec
)

Expand All @@ -69,6 +73,7 @@ enum NXRequestExecutor {

private static func runtimeInterceptors(
clientConfiguration: NXClientConfiguration,
responseCacheStore: NXResponseCacheStore?,
requestSpec: RequestSpec
) -> [any NXHTTPInterceptor] {
var interceptors: [any NXHTTPInterceptor] = [
Expand All @@ -78,6 +83,16 @@ enum NXRequestExecutor {
interceptors.append(contentsOf: clientConfiguration.interceptors)
interceptors.append(contentsOf: requestSpec.requestInterceptors)
interceptors.append(NXLoggerInterceptor())

if let responseCacheStore {
interceptors.append(
NXResponseCacheInterceptor(
cache: clientConfiguration.cache,
store: responseCacheStore
)
)
}

return interceptors
}
}
Loading