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
65 changes: 65 additions & 0 deletions Nexa/Sources/Nexa/Runtime/NXResponsePipeline.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//
// 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)
}
}

static func decode<T: Decodable>(
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)
}
}

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)
}
}
140 changes: 140 additions & 0 deletions Nexa/Tests/NexaTests/NXResponsePipelineTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//
// 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\"}")

#expect {
try NXResponsePipeline.validate(
clientConfiguration: configuration,
requestSpec: requestSpec,
rawResponse: rawResponse
)
} throws: { error in
guard let nxError = error as? NXError,
case let .server(statusCode, _, underlying) = nxError else {
return false
}

return statusCode == 400 &&
(underlying as? MockAPIError)?.message == "bad request"
}
}

@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}")

#expect {
_ = try NXResponsePipeline.decode(
clientConfiguration: configuration,
rawResponse: rawResponse,
responseType: PipelineUser.self
)
} throws: { error in
guard let nxError = error as? NXError, case .decoding = nxError else {
return false
}
return true
}
}

@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)
}
Loading