From 00d6e5b33d046096fb4ebef0bd8f30d989910493 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 13 Apr 2026 12:58:54 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EC=9D=91=EB=8B=B5=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Nexa/Runtime/NXResponsePipeline.swift | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 Nexa/Sources/Nexa/Runtime/NXResponsePipeline.swift diff --git a/Nexa/Sources/Nexa/Runtime/NXResponsePipeline.swift b/Nexa/Sources/Nexa/Runtime/NXResponsePipeline.swift new file mode 100644 index 0000000..3aea056 --- /dev/null +++ b/Nexa/Sources/Nexa/Runtime/NXResponsePipeline.swift @@ -0,0 +1,30 @@ +// +// NXResponsePipeline.swift +// Nexa +// +// Created by 최윤진 on 4/13/26. +// + +import Foundation + +enum NXResponsePipeline { + static func validate( + clientConfiguration: NXClientConfiguration, + requestSpec: RequestSpec, + rawResponse: NXRawResponse + ) throws { + let statusCode = rawResponse.response.statusCode + + guard requestSpec.validationPolicy.allows(statusCode: statusCode) else { + if let serverError = clientConfiguration.serverErrorDecoder.decodeServerError( + data: rawResponse.data, + response: rawResponse.response, + decoder: clientConfiguration.decoder + ) { + throw NXError.server(statusCode: statusCode, data: rawResponse.data, underlying: serverError) + } + + throw NXError.invalidStatus(statusCode: statusCode, data: rawResponse.data) + } + } +} From 6c7310d74037feec406220bb0941257c20a7eed4 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 13 Apr 2026 13:01:21 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EC=9D=91=EB=8B=B5=20=EB=94=94?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Nexa/Sources/Nexa/Runtime/NXResponsePipeline.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Nexa/Sources/Nexa/Runtime/NXResponsePipeline.swift b/Nexa/Sources/Nexa/Runtime/NXResponsePipeline.swift index 3aea056..c061aa5 100644 --- a/Nexa/Sources/Nexa/Runtime/NXResponsePipeline.swift +++ b/Nexa/Sources/Nexa/Runtime/NXResponsePipeline.swift @@ -27,4 +27,16 @@ enum NXResponsePipeline { throw NXError.invalidStatus(statusCode: statusCode, data: rawResponse.data) } } + + static func decode( + clientConfiguration: NXClientConfiguration, + rawResponse: NXRawResponse, + responseType: T.Type + ) throws -> T { + do { + return try clientConfiguration.decoder.decode(responseType, from: rawResponse.data) + } catch { + throw NXError.decoding(error, data: rawResponse.data) + } + } } From 81d3d9b331ff323e98c1b59bafd77980f865497b Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 13 Apr 2026 13:02:13 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EC=9D=91=EB=8B=B5=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=A7=A4=ED=95=91=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Nexa/Runtime/NXResponsePipeline.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Nexa/Sources/Nexa/Runtime/NXResponsePipeline.swift b/Nexa/Sources/Nexa/Runtime/NXResponsePipeline.swift index c061aa5..6392773 100644 --- a/Nexa/Sources/Nexa/Runtime/NXResponsePipeline.swift +++ b/Nexa/Sources/Nexa/Runtime/NXResponsePipeline.swift @@ -39,4 +39,27 @@ enum NXResponsePipeline { throw NXError.decoding(error, data: rawResponse.data) } } + + static func map(error: any Error) -> NXError { + if let nxError = error as? NXError { + return nxError + } + + if let urlError = error as? URLError { + switch urlError.code { + case .timedOut: + return .timeout + case .cancelled: + return .cancelled + default: + return .transport(urlError) + } + } + + if error is CancellationError { + return .cancelled + } + + return .unknown(error) + } } From c556ee512d97210de43fa6634a7faa141e11b27a Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 13 Apr 2026 13:03:20 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EC=9D=91=EB=8B=B5=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EA=B3=BC=20=EB=94=94=EC=BD=94=EB=94=A9=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NexaTests/NXResponsePipelineTests.swift | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 Nexa/Tests/NexaTests/NXResponsePipelineTests.swift diff --git a/Nexa/Tests/NexaTests/NXResponsePipelineTests.swift b/Nexa/Tests/NexaTests/NXResponsePipelineTests.swift new file mode 100644 index 0000000..dff18a7 --- /dev/null +++ b/Nexa/Tests/NexaTests/NXResponsePipelineTests.swift @@ -0,0 +1,145 @@ +// +// NXResponsePipelineTests.swift +// Nexa +// +// Created by 최윤진 on 4/13/26. +// + +import Foundation +import Testing +@testable import Nexa + +@Suite("응답 검증과 디코딩 파이프라인 테스트") +struct NXResponsePipelineTests { + @Test("응답 검증은 허용된 상태코드를 통과시킨다") + func validateAllowsExpectedStatusCode() throws { + let configuration = makeConfiguration() + let requestSpec = RequestSpec(method: .get, path: "/users") + let rawResponse = makeRawResponse(statusCode: 200, body: "{}") + + try NXResponsePipeline.validate( + clientConfiguration: configuration, + requestSpec: requestSpec, + rawResponse: rawResponse + ) + } + + @Test("응답 검증은 서버 에러 디코더를 우선 사용한다") + func validateUsesServerErrorDecoderFirst() { + let configuration = makeConfiguration(serverErrorDecoder: MockServerErrorDecoder()) + let requestSpec = RequestSpec(method: .get, path: "/users") + let rawResponse = makeRawResponse(statusCode: 400, body: "{\"message\":\"bad request\"}") + + do { + try NXResponsePipeline.validate( + clientConfiguration: configuration, + requestSpec: requestSpec, + rawResponse: rawResponse + ) + Issue.record("Expected server error") + } catch let error as NXError { + guard case let .server(statusCode, _, underlying) = error else { + Issue.record("Unexpected NXError: \(error)") + return + } + #expect(statusCode == 400) + #expect((underlying as? MockAPIError)?.message == "bad request") + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test("응답 디코딩은 디코더를 사용해 모델을 반환한다") + func decodeReturnsDecodedModel() throws { + let configuration = makeConfiguration() + let rawResponse = makeRawResponse(statusCode: 200, body: "{\"id\":1,\"name\":\"opfic\"}") + + let user = try NXResponsePipeline.decode( + clientConfiguration: configuration, + rawResponse: rawResponse, + responseType: PipelineUser.self + ) + + #expect(user.id == 1) + #expect(user.name == "opfic") + } + + @Test("응답 디코딩 실패는 NXError.decoding으로 매핑한다") + func decodeMapsDecodingFailure() { + let configuration = makeConfiguration() + let rawResponse = makeRawResponse(statusCode: 200, body: "{\"unexpected\":true}") + + do { + _ = try NXResponsePipeline.decode( + clientConfiguration: configuration, + rawResponse: rawResponse, + responseType: PipelineUser.self + ) + Issue.record("Expected decoding error") + } catch let error as NXError { + guard case .decoding = error else { + Issue.record("Unexpected NXError: \(error)") + return + } + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test("에러 매핑은 URLError와 NXError를 적절한 케이스로 변환한다") + func mapConvertsKnownErrors() { + let timeoutError = NXResponsePipeline.map(error: URLError(.timedOut)) + guard case .timeout = timeoutError else { + Issue.record("Expected timeout mapping") + return + } + + let nxError = NXResponsePipeline.map(error: NXError.authenticationRequired) + guard case .authenticationRequired = nxError else { + Issue.record("Expected passthrough NXError") + return + } + } + + private func makeConfiguration( + serverErrorDecoder: any NXServerErrorDecoder = NXDefaultServerErrorDecoder() + ) -> NXClientConfiguration { + NXClientConfiguration( + baseURL: URL(string: "https://example.com")!, + serverErrorDecoder: serverErrorDecoder + ) + } +} + +private struct PipelineUser: Decodable { + let id: Int + let name: String +} + +private struct MockAPIError: Error { + let message: String +} + +private struct MockServerErrorDecoder: NXServerErrorDecoder { + func decodeServerError(data: Data, response: HTTPURLResponse, decoder: JSONDecoder) -> (any Error)? { + let object = try? decoder.decode(ServerErrorEnvelope.self, from: data) + guard let message = object?.message else { + return nil + } + return MockAPIError(message: message) + } +} + +private struct ServerErrorEnvelope: Decodable { + let message: String +} + +private func makeRawResponse(statusCode: Int, body: String) -> NXRawResponse { + let response = HTTPURLResponse( + url: URL(string: "https://example.com")!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )! + return NXRawResponse(data: Data(body.utf8), response: response) +} From 1c2df14198120a739afd1a8bba8b2dda5943fb62 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 13 Apr 2026 14:21:07 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20'#expect(throws:=20)'=20?= =?UTF-8?q?=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NexaTests/NXResponsePipelineTests.swift | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/Nexa/Tests/NexaTests/NXResponsePipelineTests.swift b/Nexa/Tests/NexaTests/NXResponsePipelineTests.swift index dff18a7..a7bb8ae 100644 --- a/Nexa/Tests/NexaTests/NXResponsePipelineTests.swift +++ b/Nexa/Tests/NexaTests/NXResponsePipelineTests.swift @@ -30,22 +30,20 @@ struct NXResponsePipelineTests { let requestSpec = RequestSpec(method: .get, path: "/users") let rawResponse = makeRawResponse(statusCode: 400, body: "{\"message\":\"bad request\"}") - do { + #expect { try NXResponsePipeline.validate( clientConfiguration: configuration, requestSpec: requestSpec, rawResponse: rawResponse ) - Issue.record("Expected server error") - } catch let error as NXError { - guard case let .server(statusCode, _, underlying) = error else { - Issue.record("Unexpected NXError: \(error)") - return + } throws: { error in + guard let nxError = error as? NXError, + case let .server(statusCode, _, underlying) = nxError else { + return false } - #expect(statusCode == 400) - #expect((underlying as? MockAPIError)?.message == "bad request") - } catch { - Issue.record("Unexpected error: \(error)") + + return statusCode == 400 && + (underlying as? MockAPIError)?.message == "bad request" } } @@ -69,20 +67,17 @@ struct NXResponsePipelineTests { let configuration = makeConfiguration() let rawResponse = makeRawResponse(statusCode: 200, body: "{\"unexpected\":true}") - do { + #expect { _ = try NXResponsePipeline.decode( clientConfiguration: configuration, rawResponse: rawResponse, responseType: PipelineUser.self ) - Issue.record("Expected decoding error") - } catch let error as NXError { - guard case .decoding = error else { - Issue.record("Unexpected NXError: \(error)") - return + } throws: { error in + guard let nxError = error as? NXError, case .decoding = nxError else { + return false } - } catch { - Issue.record("Unexpected error: \(error)") + return true } }