From f0c6ea71a4fcc4786454eb3f0308091514b7c2d5 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Wed, 21 Oct 2026 16:11:13 +0800 Subject: [PATCH] Add sequential fake GET responses --- CHANGELOG.md | 5 + README.md | 14 +++ Sources/Networking/FakeRequest.swift | 112 ++++++++++++++---- .../Networking/Networking+HTTPRequests.swift | 22 ++++ Sources/Networking/Networking+New.swift | 6 +- Sources/Networking/Networking+Private.swift | 16 ++- Tests/NetworkingTests/FakeRequestTests.swift | 18 +++ 7 files changed, 166 insertions(+), 27 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..19fb195 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## Unreleased + +- Add ordered response sequences to `fakeGET`; later calls repeat the final response. diff --git a/README.md b/README.md index dbb8cf2..e5df954 100755 --- a/README.md +++ b/README.md @@ -452,6 +452,20 @@ let result: Result<[Story], NetworkingError> = await networking.get("/stories") // .success carrying the stories ``` +Register a response sequence when the same path returns different pages. Calls use each response in order, then repeat the last response: + +```swift +struct Story: Codable { let id: Int; let title: String } + +let networking = Networking(baseURL: "https://example.com") +await networking.fakeGET("/stories", responses: [ + [Story(id: 1, title: "First page")], + [Story(id: 2, title: "Second page")] +]) +let first: Result<[Story], NetworkingError> = await networking.get("/stories") +let second: Result<[Story], NetworkingError> = await networking.get("/stories") +``` + **Faking with contents of a file**: A file outside the main bundle takes the bundle parameter; everything else uses `Bundle.main`. diff --git a/Sources/Networking/FakeRequest.swift b/Sources/Networking/FakeRequest.swift index 0879166..f071e68 100644 --- a/Sources/Networking/FakeRequest.swift +++ b/Sources/Networking/FakeRequest.swift @@ -1,6 +1,6 @@ import Foundation -struct FakeRequest { +final class FakeRequest { // `.data` holds already-serialized JSON (or raw file bytes), served back as-is; `.image` is served // straight back by the image-download path. enum Payload { @@ -9,19 +9,87 @@ struct FakeRequest { case image(Image) } - let payload: Payload - let responseType: Networking.ResponseType - let headerFields: [String: String]? - let statusCode: Int - let delay: Double + struct Response { + let payload: Payload + let responseType: Networking.ResponseType + let headerFields: [String: String]? + let statusCode: Int + let delay: Double + + func replacing(_ values: [String: String]) -> Response? { + guard case .data(let data) = payload, + let responseString = String(data: data, encoding: .utf8), + let stringData = responseString.replacing(values).data(using: .utf8) + else { return nil } + + return Response( + payload: .data(stringData), + responseType: responseType, + headerFields: headerFields, + statusCode: statusCode, + delay: delay + ) + } + } + + private let responses: [Response] + private var nextResponseIndex = 0 + + private var firstResponse: Response { responses[0] } + var payload: Payload { firstResponse.payload } + var responseType: Networking.ResponseType { firstResponse.responseType } + var headerFields: [String: String]? { firstResponse.headerFields } + var statusCode: Int { firstResponse.statusCode } + var delay: Double { firstResponse.delay } + + init( + payload: Payload, + responseType: Networking.ResponseType, + headerFields: [String: String]?, + statusCode: Int, + delay: Double + ) { + self.init(responses: [Response( + payload: payload, + responseType: responseType, + headerFields: headerFields, + statusCode: statusCode, + delay: delay + )]) + } + + init(responses: [Response]) { + precondition(!responses.isEmpty, "Networking: a fake response sequence must not be empty") + self.responses = responses + } + + func nextResponse() -> Response { + let response = responses[nextResponseIndex] + nextResponseIndex = min(nextResponseIndex + 1, responses.count - 1) + return response + } + + static func takeNext( + ofType type: Networking.RequestType, + forPath path: String, + in collection: [Networking.RequestType: [String: FakeRequest]] + ) throws -> Response? { + guard let requests = collection[type], path.count > 0 else { return nil } + + if let request = requests[path] { + return request.nextResponse() + } + + guard let match = templatedMatch(forPath: path, in: requests) else { return nil } + return match.request.nextResponse().replacing(match.values) + } static func find( ofType type: Networking.RequestType, forPath path: String, in collection: [Networking.RequestType: [String: FakeRequest]] ) throws -> FakeRequest? { - guard let requests = collection[type] else { return nil } - guard path.count > 0 else { return nil } + guard let requests = collection[type], path.count > 0 else { return nil } if let result = requests[path] { return result @@ -33,30 +101,32 @@ struct FakeRequest { // missed exactly walks the templated ones, and the values it captured are substituted into the body // as well as the path. private static func templated(forPath path: String, in requests: [String: FakeRequest]) -> FakeRequest? { + guard let match = templatedMatch(forPath: path, in: requests), + let response = match.request.firstResponse.replacing(match.values) + else { return nil } + return FakeRequest(responses: [response]) + } + + private static func templatedMatch( + forPath path: String, + in requests: [String: FakeRequest] + ) -> (request: FakeRequest, values: [String: String])? { var evaluatedPath = path evaluatedPath.removeFirstLetterIfDash() evaluatedPath.removeLastLetterIfDash() let lookupPathParts = evaluatedPath.components(separatedBy: "/") for (originalFakedPath, fakeRequest) in requests { - guard let replacedValues = captures(from: originalFakedPath, matching: lookupPathParts), originalFakedPath.replacing(replacedValues) == path else { continue } - - guard case .data(let data) = fakeRequest.payload, let responseString = String(data: data, encoding: .utf8) + guard let replacedValues = captures(from: originalFakedPath, matching: lookupPathParts), + originalFakedPath.replacing(replacedValues) == path, + fakeRequest.firstResponse.replacing(replacedValues) != nil else { continue } - - let substituted = responseString.replacing(replacedValues) - guard let stringData = substituted.data(using: .utf8) else { continue } - return FakeRequest( - payload: .data(stringData), - responseType: fakeRequest.responseType, - headerFields: fakeRequest.headerFields, - statusCode: fakeRequest.statusCode, - delay: fakeRequest.delay - ) + return (fakeRequest, replacedValues) } return nil } + // The values a templated path captures from a lookup — "/users/{userID}" against "/users/10" gives // ["{userID}": "10"] — or nil where the two do not describe the same path at all. private static func captures(from fakedPath: String, matching lookupPathParts: [String]) -> [String: String]? { diff --git a/Sources/Networking/Networking+HTTPRequests.swift b/Sources/Networking/Networking+HTTPRequests.swift index 00ff89d..b35ec94 100644 --- a/Sources/Networking/Networking+HTTPRequests.swift +++ b/Sources/Networking/Networking+HTTPRequests.swift @@ -462,6 +462,28 @@ extension Networking { ) } + public func fakeGET( + _ path: String, + responses: [Response], + headerFields: [String: String]? = nil, + statusCode: Int = 200, + delay: Double = 0 + ) { + registerFake( + requestType: .get, + path: path, + responses: responses.map { response in + FakeRequest.Response( + payload: fakePayload(response), + responseType: .json, + headerFields: headerFields, + statusCode: statusCode, + delay: delay + ) + } + ) + } + public func fakeGET( _ path: String, headerFields: [String: String]? = nil, diff --git a/Sources/Networking/Networking+New.swift b/Sources/Networking/Networking+New.swift index d1c9304..ce46bde 100644 --- a/Sources/Networking/Networking+New.swift +++ b/Sources/Networking/Networking+New.swift @@ -40,7 +40,7 @@ extension Networking { var responseMetadata: ResponseMetadata? do { - if let fakeRequest = try FakeRequest.find( + if let fakeResponse = try FakeRequest.takeNext( ofType: requestType, forPath: path, in: fakeRequests @@ -55,7 +55,7 @@ extension Networking { emit(.started(fakeContext)) let (fakeResult, fakeStatus, fakeBytes): (Result, Int, Int) = try await handleFakeRequest( - fakeRequest, + fakeResponse, path: path, requestType: requestType ) @@ -358,7 +358,7 @@ extension Networking { } private func handleFakeRequest( - _ fakeRequest: FakeRequest, + _ fakeRequest: FakeRequest.Response, path: String, requestType: RequestType ) async throws -> (Result, statusCode: Int, byteCount: Int) { diff --git a/Sources/Networking/Networking+Private.swift b/Sources/Networking/Networking+Private.swift index d16a34c..6ee7fa9 100644 --- a/Sources/Networking/Networking+Private.swift +++ b/Sources/Networking/Networking+Private.swift @@ -60,8 +60,18 @@ extension Networking { fakeRequests[requestType] = requests } + func registerFake( + requestType: RequestType, + path: String, + responses: [FakeRequest.Response] + ) { + var requests = fakeRequests[requestType] ?? [String: FakeRequest]() + requests[path] = FakeRequest(responses: responses) + fakeRequests[requestType] = requests + } + func handleFakeRequest( - _ fakeRequest: FakeRequest, + _ fakeRequest: FakeRequest.Response, path: String, cacheName: String?, cachingLevel: CachingLevel @@ -121,7 +131,7 @@ extension Networking { var statusCode: Int? var byteCount = 0 do { - if let fakeRequests = fakeRequests[requestType], let fakeRequest = fakeRequests[path] { + if let fakeRequest = try FakeRequest.takeNext(ofType: requestType, forPath: path, in: fakeRequests) { let (fakeResponse, _) = try handleFakeRequest( fakeRequest, path: path, @@ -219,7 +229,7 @@ extension Networking { var statusCode: Int? var byteCount = 0 do { - if let fakeRequests = fakeRequests[requestType], let fakeRequest = fakeRequests[path] { + if let fakeRequest = try FakeRequest.takeNext(ofType: requestType, forPath: path, in: fakeRequests) { let (fakeResponse, _) = try handleFakeRequest( fakeRequest, path: path, diff --git a/Tests/NetworkingTests/FakeRequestTests.swift b/Tests/NetworkingTests/FakeRequestTests.swift index 227d0e7..6c2a382 100644 --- a/Tests/NetworkingTests/FakeRequestTests.swift +++ b/Tests/NetworkingTests/FakeRequestTests.swift @@ -255,6 +255,24 @@ extension FakeRequestTests { } } + func testFakeGETWithResponseSequenceRepeatsLastResponse() async throws { + let networking = Networking(baseURL: baseURL) + await networking.fakeGET("/stories", responses: [["page": "first"], ["page": "second"]]) + + let first: Result = await networking.get("/stories") + let second: Result = await networking.get("/stories") + let third: Result = await networking.get("/stories") + + for (result, expectedPage) in [(first, "first"), (second, "second"), (third, "second")] { + switch result { + case .success(let response): + XCTAssertEqual(response.body.string(for: "page"), expectedPage) + case .failure(let error): + XCTFail(error.localizedDescription) + } + } + } + func testGETWithDelay() async throws { let networking = Networking(baseURL: baseURL) let delay: Double = 2.0