Created
May 22, 2024 18:03
-
-
Save younata/e6fd80cede42fa34d65e09cdfb3e69a1 to your computer and use it in GitHub Desktop.
Example Client-Server implementation, using a theoretical variant of the subsonic API that actually returns JSON
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Observation | |
enum State<T> { | |
case loading | |
case loaded(T) | |
case failed(String) | |
} | |
@MainActor | |
final class ArtistsController { | |
let subsonicService: SubsonicService | |
var state: State<[Artist]> = .loading | |
init(subsonicService: SubsonicService) { self.subsonicService = subsonicService } | |
func fetch() async { | |
state = .loading | |
do { | |
state = .loaded(try await subsonicService.artists()) | |
} catch { | |
state = .failed("Error loading data, sorry!") | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
struct ArtistsList { | |
let controller: ArtistsController | |
var body: some View { | |
ZStack { | |
switch controller.state { | |
case .loading: | |
ProgressView() | |
case .failed(let reason): | |
VStack { | |
Text(verbatim: reason) | |
Button("Retry?") { | |
Task { | |
await controller.fetch() | |
} | |
} | |
} | |
.padding() | |
case .loaded(let artists): | |
List(artists) { artist in | |
Text(verbatim: artist.name) | |
} | |
} | |
} | |
.task { | |
await controller.fetch() | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public protocol AuthService: Actor { | |
func authenticatedRequest(to path: String, arguments: [URLQueryItem]) throws -> URLRequest | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Fakes // github.com/quick/swift-fakes | |
// Fakes provides the Spy and Pendable classes. | |
final class FakeHTTPClient: HTTPClient { | |
let dataSpy = Spy<URLRequest, Pendable<Result<(Data, URLResponse), Error>>>(pendingFailure: TestError.uhOh) | |
func data(for request: URLRequest) async throws -> (Data, URLResponse) { | |
try await dataSpy(request) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Creating a network client | |
public protocol HTTPClient: Sendable { | |
func data(for request: URLRequest) async throws -> (Data, URLResponse) | |
} | |
extension URLSession: HTTPClient {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Another fake, this time without actually using swift fakes. | |
import Nimble | |
actor LoggedInAuthService: AuthService { | |
func authenticatedRequest(to path: String, arguments: [URLQueryItem]) throws -> URLRequest { | |
var components = try unwrap(URLComponents(string: "https://example.com")) | |
// unwrap comes from Nimble, it throws & registers a test failure if the (auto)closure passed in to it returns nil. | |
// you could also use XCTUnwrap from XCTest which has very similar behavior, but as the author of Nimble, I'm very obviously biased. | |
components.path = path | |
components.queryItems = arguments | |
return URLRequest(url: try unwrap(components.url)) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Creating a service that uses the network client | |
public protocol SubsonicService: Sendable { | |
func artists() async throws -> [Artist] | |
// (the actual one has more, this is a demonstration) | |
} | |
// Creating an implementation of SubsonicService | |
public struct JSONSubsonicService: SubsonicService { | |
let client: HTTPClient | |
let authService: AuthService | |
func artists() async throws -> [Artist] { | |
let request = try await authService.authenticatedRequest(to: "/rest/getArtists", arguments: []) | |
let (data, response) = try await client.data(for: request) | |
return try JSONDecoder().decode([Artist.self], from: data) | |
} | |
} | |
struct Artist: Identifiable, Equatable { | |
let id: String | |
let name: String | |
// in actuality there's more, but for the sake of example. | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Fakes // github.com/quick/swift-fakes | |
import Nimble // github.com/quick/nimble | |
import XCTest | |
@testable import WhateverThisSampleAppIs | |
final class SubsonicServiceTests: XCTestCase { | |
func testArtistsWhenLoggedInMakesRequestToArtist() { | |
// arrange | |
let client = FakeHTTPClient() | |
let authService = LoggedInAuthService() | |
let subject = SubsonicService(client: client, authService: authService) | |
// act | |
async let _ = subject.artists() | |
// assert | |
await expect(httpClient.dataSpy).toEventually(beCalled( | |
URLRequest(url: URL(string: "https://example.com/rest/getArtists")!) | |
)) | |
} | |
func testArtistsWhenLoggedInAndRequestSucceedsReturnsArtists() throws { | |
// arrange | |
let client = FakeHTTPClient() | |
let authService = LoggedInAuthService() | |
let subject = SubsonicService(client: client, authService: authService) | |
client.dataSpy.resolve(success: Data(""" | |
[{"id": "1", "name": "artist 1"}, {"id": "2", "name": "artist 2"}] | |
""".utf8)) // in actuality, you might want these to be stored on disk as responses. | |
// act | |
async let value = subject.artists() | |
// assert | |
let artists = try await value | |
expect(artists).to(equal([ | |
Artist(id: "1", name: "artist 1"), | |
Artist(id: "2", name: "artist 2") | |
])) | |
} | |
func testArtistsWhenLoggedInAndRequestFailsThrowsError() { | |
let client = FakeHTTPClient() | |
let authService = LoggedInAuthService() | |
let subject = SubsonicService(client: client, authService: authService) | |
client.dataSpy.resolve(failure: TestError.uhOh) | |
let task = Task { try await subject.artists() } | |
// using a task here to allow us to pass the result of this in to nimble's expect | |
await expect { try await artists.value }.to(throwError(TestError.uhOh)) | |
} | |
// And then we would also add a test that it throws when incorrect json is sent. | |
// As well as for invalid http responses (e.g. what if we get a 401? That would be handled as invalid json). | |
// For completeness, we should also test that it correctly handles credentials not being stored in AuthService. | |
} | |
enum TestError: Error { | |
case uhOh | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment