Skip to content

Commit c369443

Browse files
committed
Add Modern Clean Architecture
1 parent a35591a commit c369443

File tree

204 files changed

+14875
-0
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

204 files changed

+14875
-0
lines changed

.DS_Store

-6 KB
Binary file not shown.

.gitignore

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
### macOS ###
2+
# General
3+
.DS_Store
4+
.AppleDouble
5+
.LSOverride
6+
7+
# Icon must end with two
8+
Icon
9+
10+
# Thumbnails
11+
._*
12+
13+
# Files that might appear in the root of a volume
14+
.DocumentRevisions-V100
15+
.fseventsd
16+
.Spotlight-V100
17+
.TemporaryItems
18+
.Trashes
19+
.VolumeIcon.icns
20+
.com.apple.timemachine.donotpresent
21+
22+
# Directories potentially created on remote AFP share
23+
.AppleDB
24+
.AppleDesktop
25+
Network Trash Folder
26+
Temporary Items
27+
.apdisk
28+
29+
### Xcode ###
30+
# Xcode
31+
#
32+
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
33+
34+
## User settings
35+
xcuserdata/
36+
37+
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
38+
*.xcscmblueprint
39+
*.xccheckout
40+
41+
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
42+
build/
43+
DerivedData/
44+
*.moved-aside
45+
*.pbxuser
46+
!default.pbxuser
47+
*.mode1v3
48+
!default.mode1v3
49+
*.mode2v3
50+
!default.mode2v3
51+
*.perspectivev3
52+
!default.perspectivev3
53+
54+
### Xcode Patch ###
55+
*.xcodeproj/*
56+
!*.xcodeproj/project.pbxproj
57+
!*.xcodeproj/xcshareddata/
58+
!*.xcworkspace/contents.xcworkspacedata
59+
/*.gcno
60+
61+
### Projects ###
62+
*.xcodeproj
63+
*.xcworkspace
64+
65+
### Tuist derived files ###
66+
graph.dot
67+
Derived/
68+
69+
### Tuist managed dependencies ###
70+
Tuist/.build

.mise.toml

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[tools]
2+
tuist = "4.16.1"

.package.resolved

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"originHash" : "cb9339b11ed9d7d2ecad83323078648becec11b51488c8702396ac0d9ffb1283",
3+
"pins" : [
4+
{
5+
"identity" : "swift-syntax",
6+
"kind" : "remoteSourceControl",
7+
"location" : "https://github.com/apple/swift-syntax.git",
8+
"state" : {
9+
"revision" : "0687f71944021d616d34d922343dcef086855920",
10+
"version" : "600.0.1"
11+
}
12+
}
13+
],
14+
"version" : 3
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import Apollo
2+
import ApolloAPI
3+
import Foundation
4+
import Combine
5+
6+
public extension ApolloClient {
7+
func fetch<Query: GraphQLQuery>(query: Query) async throws -> Query.Data {
8+
let holder = CancellableHolder()
9+
return try await withTaskCancellationHandler {
10+
try await withCheckedThrowingContinuation { continuation in
11+
holder.value = self.fetch(query: query) { result in
12+
switch result {
13+
case .success(let gqlResutl):
14+
if let data = gqlResutl.data {
15+
continuation.resume(returning: data)
16+
} else if let error = gqlResutl.errors?.first {
17+
continuation.resume(throwing: error)
18+
} else {
19+
continuation.resume(throwing: NoDataError())
20+
}
21+
case .failure(let error):
22+
continuation.resume(throwing: error)
23+
}
24+
}
25+
}
26+
} onCancel: {
27+
holder.cancel()
28+
}
29+
}
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import Foundation
2+
import Apollo
3+
4+
final class CancellableHolder: @unchecked Sendable {
5+
private var lock = NSRecursiveLock()
6+
private var innerCancellable: Cancellable?
7+
8+
private func synced<Result>(_ action: () throws -> Result) rethrows -> Result {
9+
lock.lock()
10+
defer { lock.unlock() }
11+
return try action()
12+
}
13+
14+
var value: Cancellable? {
15+
get { synced { innerCancellable } }
16+
set { synced { innerCancellable = newValue } }
17+
}
18+
19+
func cancel() {
20+
synced { innerCancellable?.cancel() }
21+
}
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
2+
public struct NoDataError: Error {
3+
public init() {}
4+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import SwiftCompilerPlugin
2+
import SwiftSyntaxMacros
3+
4+
@main
5+
struct Plugin: CompilerPlugin {
6+
let providingMacros: [Macro.Type] = [
7+
InvertedDependency.self
8+
]
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import SwiftSyntax
2+
import SwiftSyntaxMacros
3+
import SwiftDiagnostics
4+
5+
public struct InvertedDependency: Macro {
6+
public func expand(
7+
declaration: DeclSyntax,
8+
context: MacroExpansionContext
9+
) throws -> DeclSyntax {
10+
guard let protocolDecl = declaration.as(ProtocolDeclSyntax.self) else {
11+
throw DependencyKeyMacroErrors.shouldBeAttachedToAProtocol
12+
}
13+
14+
// Retrieve the protocol name
15+
let protocolName = protocolDecl.name.text
16+
17+
// Generate the dependency key enum and unimplemented struct
18+
let enumCode = """
19+
enum \(protocolName)DependencyKey: TestDependencyKey {
20+
struct Unimplemented: \(protocolName) {
21+
\(protocolDecl.memberBlock.members.compactMap { member -> String? in
22+
guard let funcDecl = member.decl.as(FunctionDeclSyntax.self) else { return nil }
23+
let funcName = funcDecl.name.text
24+
let returnType = funcDecl.signature.returnClause?.description ?? ""
25+
let isThrowing = funcDecl.signature.effectSpecifiers?.throwsClause != nil
26+
let throwsAttribute = isThrowing ? "throws" : ""
27+
return "func \(funcName)\(funcDecl.signature.parameterClause) \(throwsAttribute)\(returnType) { unimplemented(#function) }"
28+
}
29+
.joined(separator: "\n"))
30+
}
31+
32+
static var testValue: \(protocolName) {
33+
Unimplemented()
34+
}
35+
}
36+
"""
37+
38+
// Generate the DependencyValues extension
39+
let extensionCode = """
40+
public extension DependencyValues {
41+
var \(protocolName.firstLowercased()): \(protocolName) {
42+
get { self[\(protocolName)DependencyKey.self] }
43+
set { self[\(protocolName)DependencyKey.self] = newValue }
44+
}
45+
}
46+
"""
47+
48+
return DeclSyntax(stringLiteral: "\(enumCode)\n\n\(extensionCode)")
49+
}
50+
}
51+
52+
fileprivate extension String {
53+
/// Returns the string with the first character lowercased, for naming conventions.
54+
func firstLowercased() -> String {
55+
return prefix(1).lowercased() + dropFirst()
56+
}
57+
}
58+
59+
struct TextMessage: DiagnosticMessage {
60+
var message: String
61+
62+
var diagnosticID: SwiftDiagnostics.MessageID
63+
64+
var severity: SwiftDiagnostics.DiagnosticSeverity
65+
}
66+
67+
enum DependencyKeyMacroErrors: Error {
68+
case shouldBeAttachedToAProtocol
69+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import SwiftCompilerPlugin
2+
import SwiftSyntaxMacros
3+
4+
@main
5+
struct Plugin: CompilerPlugin {
6+
let providingMacros: [Macro.Type] = [
7+
InvertedDependency.self
8+
]
9+
}
+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import Foundation
2+
3+
public final class FileCache {
4+
private let fileManager = FileManager.default
5+
private let directory: String
6+
7+
public init(name: String) {
8+
self.directory = "\(Bundle.main.bundleIdentifier ?? "")/" + (name.hasPrefix("/") ? String(name.dropFirst()) : name)
9+
}
10+
11+
public func loadFile(path: String) throws -> Data {
12+
let fileURL = directoryURL.appendingPathComponent(path)
13+
return try Data(contentsOf: fileURL)
14+
}
15+
16+
public func persist(data: Data, path: String) throws {
17+
let path = path.hasPrefix("/") ? String(path.dropFirst()) : path
18+
try createDirectoryIfNeeded()
19+
let fileURL = directoryURL.appendingPathComponent(path)
20+
let fileDirectoryURL = fileURL.deletingLastPathComponent()
21+
try createDirectoryIfNeeded(for: fileDirectoryURL)
22+
23+
if fileManager.fileExists(atPath: fileURL.path) {
24+
try fileManager.removeItem(at: fileURL)
25+
}
26+
27+
try data.write(to: fileURL, options: .atomic)
28+
}
29+
30+
public func exists(atPath path: String) -> Bool {
31+
let fileURL = directoryURL.appendingPathComponent(path)
32+
return fileManager.fileExists(atPath: fileURL.path)
33+
}
34+
35+
public func persist<T: Encodable>(item: T, encoder: JSONEncoder, path: String) throws {
36+
let data = try encoder.encode(item)
37+
try persist(data: data, path: path)
38+
}
39+
40+
private func createDirectoryIfNeeded() throws {
41+
if fileManager.fileExists(atPath: directoryURL.path) == false {
42+
try fileManager.createDirectory(
43+
at: directoryURL,
44+
withIntermediateDirectories: true,
45+
attributes: nil
46+
)
47+
}
48+
}
49+
50+
private func createDirectoryIfNeeded(for url: URL) throws {
51+
if fileManager.fileExists(atPath: url.path) == false {
52+
try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
53+
}
54+
}
55+
56+
private var directoryURL: URL {
57+
cacheDirectory().appendingPathComponent(directory)
58+
}
59+
60+
private func cacheDirectory() -> URL {
61+
return fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0]
62+
}
63+
}
+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import Foundation
2+
3+
public protocol DataFetching {
4+
func fetch(resource: Resource) async throws -> Data
5+
}
6+
7+
public final class HTTPClient: DataFetching {
8+
private let session: URLSessionProtocol
9+
private let environment: Environment
10+
private let urlComponentsInterceptor: URLComponentsInterceptor
11+
12+
public init(
13+
session: URLSessionProtocol = URLSession.shared,
14+
environment: Environment,
15+
urlComponentsInterceptor: URLComponentsInterceptor
16+
) {
17+
self.session = session
18+
self.environment = environment
19+
self.urlComponentsInterceptor = urlComponentsInterceptor
20+
}
21+
22+
public func fetch(resource: Resource) async throws -> Data {
23+
let request = request(for: resource)
24+
25+
do {
26+
let (data, response) = try await session.data(for: request)
27+
28+
guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else {
29+
throw NetworkError.invalidResponse
30+
}
31+
32+
return data
33+
} catch let error as URLError where error.code == .notConnectedToInternet {
34+
throw NetworkError.notConnectedToInternet
35+
} catch let error as URLError where error.code == .cancelled {
36+
throw NetworkError.cancelled
37+
} catch let error as NetworkError {
38+
throw error
39+
} catch {
40+
throw NetworkError.networkError(error)
41+
}
42+
}
43+
44+
private func request(for resource: Resource) -> URLRequest {
45+
var components = URLComponents()
46+
47+
components.scheme = environment.schema
48+
components.host = environment.host
49+
components.path = "/" + environment.version + resource.path
50+
components.queryItems = resource.query.map { key, value in URLQueryItem(name: key, value: value) }
51+
52+
urlComponentsInterceptor.modify(components: &components)
53+
54+
var request = URLRequest(url: components.url!)
55+
request.httpMethod = resource.method.rawValue
56+
57+
return request
58+
}
59+
}
60+
61+
public enum NetworkError: Error {
62+
case networkError(Error)
63+
case invalidResponse
64+
case cancelled
65+
case notConnectedToInternet
66+
}
67+
68+
public extension HTTPClient {
69+
struct Environment {
70+
let schema: String
71+
let host: String
72+
let version: String
73+
74+
public init(schema: String, host: String, version: String) {
75+
self.schema = schema
76+
self.host = host
77+
self.version = version
78+
}
79+
}
80+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
public enum HTTPMethod: String {
2+
case GET
3+
case POST
4+
case DELETE
5+
}

0 commit comments

Comments
 (0)