Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changelog

## Unreleased

- Add ordered response sequences to `fakeGET`; later calls repeat the final response.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
112 changes: 91 additions & 21 deletions Sources/Networking/FakeRequest.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand All @@ -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]? {
Expand Down
22 changes: 22 additions & 0 deletions Sources/Networking/Networking+HTTPRequests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,28 @@ extension Networking {
)
}

public func fakeGET<Response: Encodable>(
_ 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,
Expand Down
6 changes: 3 additions & 3 deletions Sources/Networking/Networking+New.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -55,7 +55,7 @@ extension Networking {
emit(.started(fakeContext))
let (fakeResult, fakeStatus, fakeBytes): (Result<T, NetworkingError>, Int, Int) =
try await handleFakeRequest(
fakeRequest,
fakeResponse,
path: path,
requestType: requestType
)
Expand Down Expand Up @@ -358,7 +358,7 @@ extension Networking {
}

private func handleFakeRequest<T: Decodable>(
_ fakeRequest: FakeRequest,
_ fakeRequest: FakeRequest.Response,
path: String,
requestType: RequestType
) async throws -> (Result<T, NetworkingError>, statusCode: Int, byteCount: Int) {
Expand Down
16 changes: 13 additions & 3 deletions Sources/Networking/Networking+Private.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions Tests/NetworkingTests/FakeRequestTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<JSONResponse, NetworkingError> = await networking.get("/stories")
let second: Result<JSONResponse, NetworkingError> = await networking.get("/stories")
let third: Result<JSONResponse, NetworkingError> = 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
Expand Down