diff --git a/Examples/NexaClient/NexaClient.xcodeproj/project.pbxproj b/Examples/NexaClient/NexaClient.xcodeproj/project.pbxproj index 4524580..141128b 100644 --- a/Examples/NexaClient/NexaClient.xcodeproj/project.pbxproj +++ b/Examples/NexaClient/NexaClient.xcodeproj/project.pbxproj @@ -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; }; @@ -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; }; diff --git a/Package.swift b/Package.swift index 65d94ee..11ff48a 100644 --- a/Package.swift +++ b/Package.swift @@ -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 @@ -11,7 +10,6 @@ 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"] @@ -19,8 +17,6 @@ let package = Package( ], 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", diff --git a/README.ko.md b/README.ko.md index 7d9f01d..3aa1d65 100644 --- a/README.ko.md +++ b/README.ko.md @@ -30,6 +30,7 @@ Nexa는 `URLSession` 기반의 SwiftUI 스타일 선언형 네트워킹 라이 - [x] `NXAuthTokenProvider`를 통한 인증 및 토큰 갱신 흐름 내장 - [x] Fixed backoff 및 exponential backoff 기반 재시도 정책 - [x] 응답 유효성 검사 및 서버 에러 디코딩 +- [x] 성공한 `GET` 응답과 진행 중인 동일 요청을 위한 memory response cache - [x] 로거 훅 및 테스트를 위한 transport 추상화 ## 요구 사항 @@ -74,6 +75,7 @@ dependencies: [ | `NXTypedRequestBuilder` | 응답을 `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)` | @@ -299,6 +301,7 @@ let configuration = NXClientConfiguration( transport: NXURLSessionTransport(), logger: NXNoopLogger(), interceptors: [], + cache: .memory(ttl: 0.3), serverErrorDecoder: NXDefaultServerErrorDecoder(), authTokenProvider: nil ) @@ -318,6 +321,8 @@ let client = NXAPIClient(configuration: configuration) - 전역 헤더 및 요청별 헤더 - 원시 바디 및 JSON 바디 인코딩 - 요청 단위 유효성 검사 정책 +- 성공한 비인증 `GET` 응답의 TTL 내 memory 재사용 +- 첫 요청이 실행 중일 때 진행 중인 동일 `GET` 요청 재사용 - `.authorized()` 요청에 대한 자동 인증 헤더 주입 - 토큰 갱신 및 재시도 처리 - 스터빙 및 격리 테스트를 위한 커스텀 transport diff --git a/README.md b/README.md index 8002c4a..95c6134 100644 --- a/README.md +++ b/README.md @@ -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 @@ -74,6 +75,7 @@ The rest of the public surface is made of extension points for auth, logging, te | `NXTypedRequestBuilder` | 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)` | @@ -299,6 +301,7 @@ let configuration = NXClientConfiguration( transport: NXURLSessionTransport(), logger: NXNoopLogger(), interceptors: [], + cache: .memory(ttl: 0.3), serverErrorDecoder: NXDefaultServerErrorDecoder(), authTokenProvider: nil ) @@ -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 diff --git a/Sources/Nexa/Core/NXCache.swift b/Sources/Nexa/Core/NXCache.swift new file mode 100644 index 0000000..eaf795b --- /dev/null +++ b/Sources/Nexa/Core/NXCache.swift @@ -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) +} diff --git a/Sources/Nexa/Core/NXClientConfiguration.swift b/Sources/Nexa/Core/NXClientConfiguration.swift index 93eb3d8..6379770 100644 --- a/Sources/Nexa/Core/NXClientConfiguration.swift +++ b/Sources/Nexa/Core/NXClientConfiguration.swift @@ -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:)`. @@ -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. @@ -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(), @@ -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 diff --git a/Sources/Nexa/Public/NXAPIClient.swift b/Sources/Nexa/Public/NXAPIClient.swift index c688281..736b97b 100644 --- a/Sources/Nexa/Public/NXAPIClient.swift +++ b/Sources/Nexa/Public/NXAPIClient.swift @@ -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. @@ -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(method: NXHTTPMethod, path: String) -> NXTypedRequestBuilder { diff --git a/Sources/Nexa/Public/NXRequestBuilder.swift b/Sources/Nexa/Public/NXRequestBuilder.swift index 50a7184..37d4836 100644 --- a/Sources/Nexa/Public/NXRequestBuilder.swift +++ b/Sources/Nexa/Public/NXRequestBuilder.swift @@ -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. /// @@ -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. @@ -185,9 +200,22 @@ public struct NXRequestBuilder: Sendable { NXTypedRequestBuilder(requestBuilder: self) } + func decoded(_ 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 + ) } } diff --git a/Sources/Nexa/Public/NXTypedRequestBuilder.swift b/Sources/Nexa/Public/NXTypedRequestBuilder.swift index bd25cfb..9c51ad0 100644 --- a/Sources/Nexa/Public/NXTypedRequestBuilder.swift +++ b/Sources/Nexa/Public/NXTypedRequestBuilder.swift @@ -35,14 +35,6 @@ public struct NXTypedRequestBuilder: 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: @@ -163,10 +155,6 @@ public struct NXTypedRequestBuilder: 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) } } diff --git a/Sources/Nexa/Runtime/NXRequestCacheKey.swift b/Sources/Nexa/Runtime/NXRequestCacheKey.swift new file mode 100644 index 0000000..f68aff4 --- /dev/null +++ b/Sources/Nexa/Runtime/NXRequestCacheKey.swift @@ -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
+ + 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 + } +} diff --git a/Sources/Nexa/Runtime/NXRequestExecutor.swift b/Sources/Nexa/Runtime/NXRequestExecutor.swift index 98175a3..dc892cd 100644 --- a/Sources/Nexa/Runtime/NXRequestExecutor.swift +++ b/Sources/Nexa/Runtime/NXRequestExecutor.swift @@ -10,6 +10,7 @@ import Foundation enum NXRequestExecutor { static func executeRaw( clientConfiguration: NXClientConfiguration, + responseCacheStore: NXResponseCacheStore?, requestSpec: RequestSpec ) async throws -> NXRawResponse { do { @@ -29,6 +30,7 @@ enum NXRequestExecutor { context: context, interceptors: runtimeInterceptors( clientConfiguration: clientConfiguration, + responseCacheStore: responseCacheStore, requestSpec: requestSpec ), transport: clientConfiguration.transport @@ -48,11 +50,13 @@ enum NXRequestExecutor { static func executeDecode( clientConfiguration: NXClientConfiguration, + responseCacheStore: NXResponseCacheStore?, requestSpec: RequestSpec, responseType: T.Type ) async throws -> T { let rawResponse = try await executeRaw( clientConfiguration: clientConfiguration, + responseCacheStore: responseCacheStore, requestSpec: requestSpec ) @@ -69,6 +73,7 @@ enum NXRequestExecutor { private static func runtimeInterceptors( clientConfiguration: NXClientConfiguration, + responseCacheStore: NXResponseCacheStore?, requestSpec: RequestSpec ) -> [any NXHTTPInterceptor] { var interceptors: [any NXHTTPInterceptor] = [ @@ -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 } } diff --git a/Sources/Nexa/Runtime/NXResponseCacheInterceptor.swift b/Sources/Nexa/Runtime/NXResponseCacheInterceptor.swift new file mode 100644 index 0000000..eb7d674 --- /dev/null +++ b/Sources/Nexa/Runtime/NXResponseCacheInterceptor.swift @@ -0,0 +1,41 @@ +// +// NXResponseCacheInterceptor.swift +// Nexa +// +// Created by opfic on 6/19/26. +// + +import Foundation + +struct NXResponseCacheInterceptor: NXHTTPInterceptor { + let cache: NXCache + let store: NXResponseCacheStore + + func intercept( + context: NXRequestExecutionContext, + next: @escaping @Sendable (NXRequestExecutionContext) async throws -> NXRawResponse + ) async throws -> NXRawResponse { + guard case let .memory(ttl) = cache, + 0 < ttl, + context.specification.method == .get, + context.specification.authRequirement == .none, + context.specification.body == nil else { + return try await next(context) + } + + guard let key = NXRequestCacheKey(request: context.request) else { + return try await next(context) + } + + return try await store.response( + for: key, + ttl: ttl, + shouldCache: { rawResponse in + 200 <= rawResponse.response.statusCode && rawResponse.response.statusCode < 300 + }, + load: { + try await next(context) + } + ) + } +} diff --git a/Sources/Nexa/Runtime/NXResponseCacheStore.swift b/Sources/Nexa/Runtime/NXResponseCacheStore.swift new file mode 100644 index 0000000..ac8b87f --- /dev/null +++ b/Sources/Nexa/Runtime/NXResponseCacheStore.swift @@ -0,0 +1,61 @@ +// +// NXResponseCacheStore.swift +// Nexa +// +// Created by opfic on 6/19/26. +// + +import Foundation + +actor NXResponseCacheStore { + private var responses: [NXRequestCacheKey: CachedResponse] = [:] + private var inFlightTasks: [NXRequestCacheKey: Task] = [:] + + func response( + for key: NXRequestCacheKey, + ttl: TimeInterval, + shouldCache: @escaping @Sendable (NXRawResponse) -> Bool, + load: @escaping @Sendable () async throws -> NXRawResponse + ) async throws -> NXRawResponse { + let now = Date() + + if let cachedResponse = responses[key] { + if now < cachedResponse.expirationDate { + return cachedResponse.rawResponse + } + + responses.removeValue(forKey: key) + } + + if let task = inFlightTasks[key] { + return try await task.value + } + + let task = Task { + try await load() + } + inFlightTasks[key] = task + + do { + let rawResponse = try await task.value + inFlightTasks[key] = nil + + if shouldCache(rawResponse) { + responses[key] = CachedResponse( + rawResponse: rawResponse, + expirationDate: Date().addingTimeInterval(ttl) + ) + } + + return rawResponse + } catch { + inFlightTasks[key] = nil + throw error + } + } + + private struct CachedResponse: Sendable { + let rawResponse: NXRawResponse + let expirationDate: Date + } +} diff --git a/Tests/NexaTests/NXClientConfigurationProtocolTests.swift b/Tests/NexaTests/NXClientConfigurationProtocolTests.swift index 3bbdb89..b9e41d5 100644 --- a/Tests/NexaTests/NXClientConfigurationProtocolTests.swift +++ b/Tests/NexaTests/NXClientConfigurationProtocolTests.swift @@ -36,6 +36,7 @@ struct NXClientConfigurationProtocolTests { baseURL: URL(string: "https://api.example.com")!, headers: ["X-App": "Nexa"], transport: transport, + cache: .memory(ttl: 5), decoder: JSONDecoder(), encoder: JSONEncoder(), serverErrorDecoder: serverErrorDecoder, @@ -45,6 +46,7 @@ struct NXClientConfigurationProtocolTests { #expect(configuration.baseURL.absoluteString == "https://api.example.com") #expect(configuration.headers["X-App"] == "Nexa") #expect(configuration.transport is MockTransport) + #expect(configuration.cache == .memory(ttl: 5)) #expect(configuration.serverErrorDecoder is MockServerErrorDecoder) #expect(configuration.authTokenProvider is MockAuthTokenProvider) } @@ -54,6 +56,7 @@ struct NXClientConfigurationProtocolTests { let configuration = NXClientConfiguration(baseURL: URL(string: "https://api.example.com")!) #expect(configuration.transport is NXURLSessionTransport) + #expect(configuration.cache == .disabled) #expect(configuration.serverErrorDecoder is NXDefaultServerErrorDecoder) #expect(configuration.authTokenProvider == nil) } diff --git a/Tests/NexaTests/NXDeclarativeRequestDSLTests.swift b/Tests/NexaTests/NXDeclarativeRequestDSLTests.swift index bbca1dd..7b8da24 100644 --- a/Tests/NexaTests/NXDeclarativeRequestDSLTests.swift +++ b/Tests/NexaTests/NXDeclarativeRequestDSLTests.swift @@ -12,36 +12,34 @@ import Testing @Suite("선언형 요청 DSL 기본 구조 테스트") struct NXDeclarativeRequestDSLTests { @Test("HTTP 메서드 진입점이 요청 스펙의 메서드와 경로를 설정한다") - func apiClientVerbEntryPoints() { + func apiClientVerbEntryPoints() async throws { let client = makeClient() - let getBuilder = client.get("/users") - #expect(getBuilder.requestSpec.method == .get) - #expect(getBuilder.requestSpec.path == "/users") + let getRequest = try await client.get("/users").preparedURLRequest() + #expect(getRequest.httpMethod == "GET") + #expect(getRequest.url?.path == "/users") - let postBuilder = client.post("/users") - #expect(postBuilder.requestSpec.method == .post) - #expect(postBuilder.requestSpec.path == "/users") + let postRequest = try await client.post("/users").preparedURLRequest() + #expect(postRequest.httpMethod == "POST") + #expect(postRequest.url?.path == "/users") - let deleteBuilder = client.delete("/users/1") - #expect(deleteBuilder.requestSpec.method == .delete) - #expect(deleteBuilder.requestSpec.path == "/users/1") + let deleteRequest = try await client.delete("/users/1").preparedURLRequest() + #expect(deleteRequest.httpMethod == "DELETE") + #expect(deleteRequest.url?.path == "/users/1") } @Test("Endpoint 요청이 configure를 통해 빌더를 구성한다") - func endpointConfiguration() { + func endpointConfiguration() async throws { let client = makeClient() - let builder = client.request(UsersEndpoint()) + let request = try await client.request(UsersEndpoint()).preparedURLRequest() - #expect(builder.requestSpec.method == .get) - #expect(builder.requestSpec.path == "/users") - #expect(builder.requestSpec.queryItems.count == 1) - #expect(builder.requestSpec.queryItems.first?.name == "page") - #expect(builder.requestSpec.queryItems.first?.value == "1") + #expect(request.httpMethod == "GET") + #expect(request.url?.path == "/users") + #expect(request.url?.query == "page=1") } @Test("RequestBuilder modifier가 원본을 변경하지 않고 새 값을 생성한다") - func requestBuilderValueSemantics() { + func requestBuilderValueSemantics() async throws { let client = makeClient() let originalBuilder = client.get("/users") @@ -51,28 +49,28 @@ struct NXDeclarativeRequestDSLTests { .authorized() .timeout(-3) - #expect(originalBuilder.requestSpec.queryItems.isEmpty) - #expect(originalBuilder.requestSpec.headers.isEmpty) - #expect(originalBuilder.requestSpec.authRequirement == .none) - #expect(originalBuilder.requestSpec.timeout == nil) + let originalRequest = try await originalBuilder.preparedURLRequest() + #expect(originalRequest.url?.query == nil) + #expect(originalRequest.value(forHTTPHeaderField: "X-Trace") == nil) - #expect(modifiedBuilder.requestSpec.queryItems.count == 1) - #expect(modifiedBuilder.requestSpec.headers["X-Trace"] == "abc") - #expect(modifiedBuilder.requestSpec.authRequirement == .required) - #expect(modifiedBuilder.requestSpec.timeout == 0) + let modifiedRequest = try await modifiedBuilder.preparedURLRequest() + #expect(modifiedRequest.url?.query == "page=1") + #expect(modifiedRequest.value(forHTTPHeaderField: "X-Trace") == "abc") + #expect(modifiedRequest.timeoutInterval == 0) } @Test("headers와 accept modifier가 헤더를 누적한다") - func headerModifiers() { + func headerModifiers() async throws { let client = makeClient() - let builder = client.get("/users") + let request = try await client.get("/users") .headers(["A": "1", "B": "2"]) .accept("application/json") + .preparedURLRequest() - #expect(builder.requestSpec.headers["A"] == "1") - #expect(builder.requestSpec.headers["B"] == "2") - #expect(builder.requestSpec.headers["Accept"] == "application/json") + #expect(request.value(forHTTPHeaderField: "A") == "1") + #expect(request.value(forHTTPHeaderField: "B") == "2") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") } private func makeClient() -> NXAPIClient { diff --git a/Tests/NexaTests/NXRequestBodyExecutionAPITests.swift b/Tests/NexaTests/NXRequestBodyExecutionAPITests.swift index 75d0c50..da87f91 100644 --- a/Tests/NexaTests/NXRequestBodyExecutionAPITests.swift +++ b/Tests/NexaTests/NXRequestBodyExecutionAPITests.swift @@ -12,14 +12,15 @@ import Testing @Suite("요청 본문 처리와 실행 API 테스트") struct NXRequestBodyExecutionAPITests { @Test("json 본문은 설정 인코더를 사용해 인코딩한다") - func jsonBodyUsesConfigurationEncoder() throws { + func jsonBodyUsesConfigurationEncoder() async throws { let encoder = JSONEncoder() encoder.keyEncodingStrategy = .convertToSnakeCase let client = makeClient(encoder: encoder) - let builder = try client.post("/users").json(UserPayload(userName: "opfic")) - - guard case let .data(data) = builder.requestSpec.body else { + let request = try await client.post("/users") + .json(UserPayload(userName: "opfic")) + .preparedURLRequest() + guard let data = request.httpBody else { Issue.record("body data not found") return } @@ -29,16 +30,17 @@ struct NXRequestBodyExecutionAPITests { } @Test("json 본문은 전달된 인코더를 우선 사용한다") - func jsonBodyUsesOverrideEncoder() throws { + func jsonBodyUsesOverrideEncoder() async throws { let defaultEncoder = JSONEncoder() defaultEncoder.keyEncodingStrategy = .convertToSnakeCase let overrideEncoder = JSONEncoder() let client = makeClient(encoder: defaultEncoder) - let builder = try client.post("/users").json(UserPayload(userName: "opfic"), encoder: overrideEncoder) - - guard case let .data(data) = builder.requestSpec.body else { + let request = try await client.post("/users") + .json(UserPayload(userName: "opfic"), encoder: overrideEncoder) + .preparedURLRequest() + guard let data = request.httpBody else { Issue.record("body data not found") return } @@ -48,22 +50,23 @@ struct NXRequestBodyExecutionAPITests { } @Test("body와 contentType modifier는 데이터를 body와 헤더에 각각 저장한다") - func rawBodyStoresDataAndHeader() { + func rawBodyStoresDataAndHeader() async throws { let client = makeClient() let payload = Data("hello".utf8) - let builder = client + let request = try await client .post("/users") .body(payload) .contentType("text/plain") + .preparedURLRequest() - guard case let .data(data) = builder.requestSpec.body else { + guard let data = request.httpBody else { Issue.record("body data not found") return } #expect(data == payload) - #expect(builder.requestSpec.headers["Content-Type"] == "text/plain") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "text/plain") } @Test("실행 API는 transport 응답을 사용한다") diff --git a/Tests/NexaTests/NXResponseCacheInterceptorTests.swift b/Tests/NexaTests/NXResponseCacheInterceptorTests.swift new file mode 100644 index 0000000..8ccca2b --- /dev/null +++ b/Tests/NexaTests/NXResponseCacheInterceptorTests.swift @@ -0,0 +1,212 @@ +// +// NXResponseCacheInterceptorTests.swift +// Nexa +// +// Created by opfic on 6/19/26. +// + +import Foundation +import Testing +@testable import Nexa + +@Suite("응답 캐시와 중복 요청 방지 테스트") +struct NXResponseCacheInterceptorTests { + @Test("동일한 GET 요청이 동시에 실행되면 transport 호출을 하나로 합친다") + func identicalConcurrentGetRequestsShareInFlightResponse() async throws { + let attemptCounter = AttemptCounter() + let client = makeClient( + transport: ClosureTransport { _ in + _ = await attemptCounter.increment() + try await Task.sleep(nanoseconds: 50_000_000) + return makeRawResponse(statusCode: 200, body: #"{"id":1,"name":"cached"}"#, path: "/users") + }, + cache: .memory(ttl: 10) + ) + + let users = try await withThrowingTaskGroup(of: UserDTO.self) { group in + for _ in 0..<5 { + group.addTask { + try await client.get("/users", as: UserDTO.self).send() + } + } + + var values: [UserDTO] = [] + for try await value in group { + values.append(value) + } + return values + } + + #expect(users == Array(repeating: UserDTO(id: 1, name: "cached"), count: 5)) + #expect(await attemptCounter.value() == 1) + } + + @Test("TTL 안의 동일한 GET 요청은 캐시 응답을 반환한다") + func identicalGetRequestWithinTTLUsesCachedResponse() async throws { + let attemptCounter = AttemptCounter() + let client = makeClient( + transport: ClosureTransport { _ in + let attemptNumber = await attemptCounter.increment() + return makeRawResponse( + statusCode: 200, + body: #"{"id":\#(attemptNumber),"name":"cached"}"#, + path: "/users" + ) + }, + cache: .memory(ttl: 10) + ) + + let firstUser = try await client.get("/users", as: UserDTO.self).send() + let secondUser = try await client.get("/users", as: UserDTO.self).send() + + #expect(firstUser == UserDTO(id: 1, name: "cached")) + #expect(secondUser == firstUser) + #expect(await attemptCounter.value() == 1) + } + + @Test("호출자 Task 취소는 진행 중인 동일 GET 요청을 제거하지 않는다") + func cancellingCallerTaskDoesNotRemoveInFlightResponse() async throws { + let attemptCounter = AttemptCounter() + let requestStartProbe = RequestStartProbe() + let client = makeClient( + transport: ClosureTransport { _ in + _ = await attemptCounter.increment() + await requestStartProbe.recordStart() + try await Task.sleep(nanoseconds: 50_000_000) + return makeRawResponse(statusCode: 200, body: #"{"id":1,"name":"cached"}"#, path: "/users") + }, + cache: .memory(ttl: 10) + ) + + let firstTask = Task { + try await client.get("/users", as: UserDTO.self).send() + } + await requestStartProbe.waitForStart() + firstTask.cancel() + + let secondUser = try await client.get("/users", as: UserDTO.self).send() + _ = try? await firstTask.value + + #expect(secondUser == UserDTO(id: 1, name: "cached")) + #expect(await attemptCounter.value() == 1) + } + + @Test("TTL이 지난 GET 요청은 새로 실행한다") + func getRequestAfterTTLExecutesAgain() async throws { + let attemptCounter = AttemptCounter() + let client = makeClient( + transport: ClosureTransport { _ in + let attemptNumber = await attemptCounter.increment() + return makeRawResponse( + statusCode: 200, + body: #"{"id":\#(attemptNumber),"name":"cached"}"#, + path: "/users" + ) + }, + cache: .memory(ttl: 0.01) + ) + + let firstUser = try await client.get("/users", as: UserDTO.self).send() + try await Task.sleep(nanoseconds: 20_000_000) + let secondUser = try await client.get("/users", as: UserDTO.self).send() + + #expect(firstUser == UserDTO(id: 1, name: "cached")) + #expect(secondUser == UserDTO(id: 2, name: "cached")) + #expect(await attemptCounter.value() == 2) + } + + @Test("TTL이 지난 캐시 응답은 실패한 재요청에 재사용하지 않는다") + func expiredCacheResponseIsNotReusedAfterReloadFailure() async throws { + let attemptCounter = AttemptCounter() + let client = makeClient( + transport: ClosureTransport { _ in + let attemptNumber = await attemptCounter.increment() + + if attemptNumber == 1 { + return makeRawResponse( + statusCode: 200, + body: #"{"id":1,"name":"cached"}"#, + path: "/users" + ) + } + + throw URLError(.timedOut) + }, + cache: .memory(ttl: 0.01) + ) + + let firstUser = try await client.get("/users", as: UserDTO.self).send() + try await Task.sleep(nanoseconds: 20_000_000) + + await #expect { + let _: UserDTO = try await client.get("/users", as: UserDTO.self).send() + } throws: { error in + guard case NXError.timeout = error else { + return false + } + return true + } + + #expect(firstUser == UserDTO(id: 1, name: "cached")) + #expect(await attemptCounter.value() == 2) + } + + @Test("POST 요청은 캐시하지 않는다") + func postRequestDoesNotUseCache() async throws { + let attemptCounter = AttemptCounter() + let client = makeClient( + transport: ClosureTransport { _ in + let attemptNumber = await attemptCounter.increment() + return makeRawResponse( + statusCode: 200, + body: #"{"id":\#(attemptNumber),"name":"posted"}"#, + path: "/users" + ) + }, + cache: .memory(ttl: 10) + ) + + let firstUser = try await client.post("/users", as: UserDTO.self).send() + let secondUser = try await client.post("/users", as: UserDTO.self).send() + + #expect(firstUser == UserDTO(id: 1, name: "posted")) + #expect(secondUser == UserDTO(id: 2, name: "posted")) + #expect(await attemptCounter.value() == 2) + } + + private func makeClient( + transport: any NXHTTPTransport, + cache: NXCache + ) -> NXAPIClient { + NXAPIClient( + configuration: NXClientConfiguration( + baseURL: URL(string: "https://example.com")!, + transport: transport, + cache: cache + ) + ) + } +} + +private actor RequestStartProbe { + private var didStart = false + private var continuations: [CheckedContinuation] = [] + + func recordStart() { + didStart = true + continuations.forEach { continuation in + continuation.resume() + } + continuations.removeAll() + } + + func waitForStart() async { + if didStart { + return + } + + await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } +}