diff --git a/Application/DevLogApp/Sources/Resource/Info.plist b/Application/DevLogApp/Sources/Resource/Info.plist
index 252abcda..cfc4fe6a 100644
--- a/Application/DevLogApp/Sources/Resource/Info.plist
+++ b/Application/DevLogApp/Sources/Resource/Info.plist
@@ -42,6 +42,8 @@
FIRESTORE_DATABASE_ID
$(FIRESTORE_DATABASE_ID)
+ FUNCTION_API_BASE_URL
+ $(FUNCTION_API_BASE_URL)
GIDClientID
$(CLIENT_ID)
GITHUB_CLIENT_ID
diff --git a/Application/DevLogData/Sources/Mapper/WebPageMapping.swift b/Application/DevLogData/Sources/Mapper/WebPageMapping.swift
index 668c3d09..0fd4f972 100644
--- a/Application/DevLogData/Sources/Mapper/WebPageMapping.swift
+++ b/Application/DevLogData/Sources/Mapper/WebPageMapping.swift
@@ -23,6 +23,7 @@ public extension WebPageResponse {
imageURL = nil
}
return WebPage(
+ id: id,
title: title,
url: url,
displayURL: displayURL,
diff --git a/Application/DevLogData/Sources/Protocol/WebPageService.swift b/Application/DevLogData/Sources/Protocol/WebPageService.swift
index 17cdab35..27141350 100644
--- a/Application/DevLogData/Sources/Protocol/WebPageService.swift
+++ b/Application/DevLogData/Sources/Protocol/WebPageService.swift
@@ -10,6 +10,6 @@ import Foundation
public protocol WebPageService {
func fetchWebPages(_ query: String) async throws -> [WebPageResponse]
func upsertWebPage(_ request: WebPageRequest) async throws
- func deleteWebPage(_ urlString: String) async throws
- func undoDeleteWebPage(_ urlString: String) async throws
+ func deleteWebPage(_ id: String) async throws
+ func undoDeleteWebPage(_ id: String) async throws
}
diff --git a/Application/DevLogData/Sources/Repository/WebPageRepositoryImpl.swift b/Application/DevLogData/Sources/Repository/WebPageRepositoryImpl.swift
index f15fc3f4..dd166f1b 100644
--- a/Application/DevLogData/Sources/Repository/WebPageRepositoryImpl.swift
+++ b/Application/DevLogData/Sources/Repository/WebPageRepositoryImpl.swift
@@ -63,18 +63,18 @@ final class WebPageRepositoryImpl: WebPageRepository {
}
}
- func delete(_ urlString: String) async throws {
+ func delete(id: String, urlString: String) async throws {
do {
- try await webPageService.deleteWebPage(urlString)
+ try await webPageService.deleteWebPage(id)
await metadataService.removeCachedImage(for: urlString)
} catch {
throw error.toDomain()
}
}
- func undoDelete(_ urlString: String) async throws {
+ func undoDelete(_ id: String) async throws {
do {
- try await webPageService.undoDeleteWebPage(urlString)
+ try await webPageService.undoDeleteWebPage(id)
} catch {
throw error.toDomain()
}
diff --git a/Application/DevLogDomain/Sources/Entity/WebPage.swift b/Application/DevLogDomain/Sources/Entity/WebPage.swift
index 1c6993d5..8cc720c8 100644
--- a/Application/DevLogDomain/Sources/Entity/WebPage.swift
+++ b/Application/DevLogDomain/Sources/Entity/WebPage.swift
@@ -8,17 +8,20 @@
import Foundation
public struct WebPage: Hashable {
+ public let id: String
public let title: String?
public let url: URL
public let displayURL: URL
public let imageURL: URL?
public init(
+ id: String,
title: String?,
url: URL,
displayURL: URL,
imageURL: URL?
) {
+ self.id = id
self.title = title
self.url = url
self.displayURL = displayURL
diff --git a/Application/DevLogDomain/Sources/Protocol/WebPageRepository.swift b/Application/DevLogDomain/Sources/Protocol/WebPageRepository.swift
index e27300f1..e3126dbb 100644
--- a/Application/DevLogDomain/Sources/Protocol/WebPageRepository.swift
+++ b/Application/DevLogDomain/Sources/Protocol/WebPageRepository.swift
@@ -8,6 +8,6 @@
public protocol WebPageRepository {
func fetch(_ query: String) async throws -> [WebPage]
func upsert(_ urlString: String) async throws
- func delete(_ urlString: String) async throws
- func undoDelete(_ urlString: String) async throws
+ func delete(id: String, urlString: String) async throws
+ func undoDelete(_ id: String) async throws
}
diff --git a/Application/DevLogDomain/Sources/UseCase/WebPage/Upsert/DeleteWebPageUseCase.swift b/Application/DevLogDomain/Sources/UseCase/WebPage/Upsert/DeleteWebPageUseCase.swift
index ba05568a..af714538 100644
--- a/Application/DevLogDomain/Sources/UseCase/WebPage/Upsert/DeleteWebPageUseCase.swift
+++ b/Application/DevLogDomain/Sources/UseCase/WebPage/Upsert/DeleteWebPageUseCase.swift
@@ -6,5 +6,5 @@
//
public protocol DeleteWebPageUseCase {
- func execute(_ urlString: String) async throws
+ func execute(id: String, urlString: String) async throws
}
diff --git a/Application/DevLogDomain/Sources/UseCase/WebPage/Upsert/DeleteWebPageUseCaseImpl.swift b/Application/DevLogDomain/Sources/UseCase/WebPage/Upsert/DeleteWebPageUseCaseImpl.swift
index baa112fc..1adc3e1c 100644
--- a/Application/DevLogDomain/Sources/UseCase/WebPage/Upsert/DeleteWebPageUseCaseImpl.swift
+++ b/Application/DevLogDomain/Sources/UseCase/WebPage/Upsert/DeleteWebPageUseCaseImpl.swift
@@ -12,7 +12,7 @@ public final class DeleteWebPageUseCaseImpl: DeleteWebPageUseCase {
self.repository = repository
}
- public func execute(_ urlString: String) async throws {
- try await repository.delete(urlString)
+ public func execute(id: String, urlString: String) async throws {
+ try await repository.delete(id: id, urlString: urlString)
}
}
diff --git a/Application/DevLogDomain/Sources/UseCase/WebPage/Upsert/UndoDeleteWebPageUseCase.swift b/Application/DevLogDomain/Sources/UseCase/WebPage/Upsert/UndoDeleteWebPageUseCase.swift
index 85d60ea1..1464d2b5 100644
--- a/Application/DevLogDomain/Sources/UseCase/WebPage/Upsert/UndoDeleteWebPageUseCase.swift
+++ b/Application/DevLogDomain/Sources/UseCase/WebPage/Upsert/UndoDeleteWebPageUseCase.swift
@@ -6,5 +6,5 @@
//
public protocol UndoDeleteWebPageUseCase {
- func execute(_ urlString: String) async throws
+ func execute(_ id: String) async throws
}
diff --git a/Application/DevLogDomain/Sources/UseCase/WebPage/Upsert/UndoDeleteWebPageUseCaseImpl.swift b/Application/DevLogDomain/Sources/UseCase/WebPage/Upsert/UndoDeleteWebPageUseCaseImpl.swift
index 7ffe1a40..508f47b1 100644
--- a/Application/DevLogDomain/Sources/UseCase/WebPage/Upsert/UndoDeleteWebPageUseCaseImpl.swift
+++ b/Application/DevLogDomain/Sources/UseCase/WebPage/Upsert/UndoDeleteWebPageUseCaseImpl.swift
@@ -12,7 +12,7 @@ public final class UndoDeleteWebPageUseCaseImpl: UndoDeleteWebPageUseCase {
self.repository = repository
}
- public func execute(_ urlString: String) async throws {
- try await repository.undoDelete(urlString)
+ public func execute(_ id: String) async throws {
+ try await repository.undoDelete(id)
}
}
diff --git a/Application/DevLogInfra/Sources/Common/FirebaseConfiguration.swift b/Application/DevLogInfra/Sources/Common/FirebaseConfiguration.swift
index 72345e7c..a4d476ca 100644
--- a/Application/DevLogInfra/Sources/Common/FirebaseConfiguration.swift
+++ b/Application/DevLogInfra/Sources/Common/FirebaseConfiguration.swift
@@ -6,12 +6,12 @@
//
import FirebaseFirestore
-import FirebaseFunctions
import Foundation
enum FirebaseConfiguration {
private enum InfoKey {
static let databaseID = "FIRESTORE_DATABASE_ID"
+ static let functionAPIBaseURL = "FUNCTION_API_BASE_URL"
}
static let defaultDatabaseID = "staging"
@@ -39,7 +39,31 @@ enum FirebaseConfiguration {
Firestore.firestore(database: databaseID)
}
- static var functions: Functions {
- Functions.functions(region: "asia-northeast3")
+ static func functionAPIBaseURL() throws -> URL {
+ if let value = resolvedValue(for: InfoKey.functionAPIBaseURL),
+ let url = URL(string: value) {
+ return url
+ }
+
+ throw URLError(.badURL)
+ }
+
+ private static func resolvedValue(for key: String) -> String? {
+ let environmentValue = ProcessInfo.processInfo.environment[key]?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if let environmentValue, !environmentValue.isEmpty {
+ return environmentValue
+ }
+
+ guard let rawValue = Bundle.main.object(forInfoDictionaryKey: key) as? String else {
+ return nil
+ }
+
+ let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !value.isEmpty, !value.hasPrefix("$(") else {
+ return nil
+ }
+
+ return value
}
}
diff --git a/Application/DevLogInfra/Sources/Extension/FirebaseFunctions+.swift b/Application/DevLogInfra/Sources/Extension/FirebaseFunctions+.swift
deleted file mode 100644
index d9ce43bc..00000000
--- a/Application/DevLogInfra/Sources/Extension/FirebaseFunctions+.swift
+++ /dev/null
@@ -1,14 +0,0 @@
-//
-// FirebaseFunctions+.swift
-// DevLogInfra
-//
-// Created by opfic on 3/16/26.
-//
-
-import FirebaseFunctions
-
-extension Functions {
- func httpsCallable(_ name: some RawRepresentable) -> HTTPSCallable {
- httpsCallable(name.rawValue)
- }
-}
diff --git a/Application/DevLogInfra/Sources/Service/FunctionAPIClient.swift b/Application/DevLogInfra/Sources/Service/FunctionAPIClient.swift
new file mode 100644
index 00000000..7cc7edc3
--- /dev/null
+++ b/Application/DevLogInfra/Sources/Service/FunctionAPIClient.swift
@@ -0,0 +1,158 @@
+//
+// FunctionAPIClient.swift
+// DevLogInfra
+//
+// Created by opfic on 6/26/26.
+//
+
+import FirebaseAuth
+import Foundation
+import DevLogData
+import Nexa
+
+final class FunctionAPIClient {
+ static let shared = FunctionAPIClient()
+
+ private let apiClient: Result
+
+ private init() {
+ let authTokenProvider = FirebaseAuthTokenProvider()
+ apiClient = Result {
+ try NXAPIClient(
+ configuration: NXClientConfiguration(
+ baseURL: FirebaseConfiguration.functionAPIBaseURL(),
+ headers: ["Accept": "application/json"],
+ serverErrorDecoder: FunctionAPIServerErrorDecoder(),
+ authTokenProvider: authTokenProvider
+ )
+ )
+ }
+ }
+
+ func send(
+ _ endpoint: FunctionAPIEndpoint,
+ payload: some Encodable,
+ requiresAuthentication: Bool = true
+ ) async throws {
+ var request = try client()
+ .request(endpoint)
+ .json(payload)
+
+ if requiresAuthentication {
+ request = request.authorized()
+ }
+
+ _ = try await request.raw()
+ }
+
+ func send(
+ _ endpoint: FunctionAPIEndpoint,
+ requiresAuthentication: Bool = true
+ ) async throws {
+ var request = try client()
+ .request(endpoint)
+
+ if requiresAuthentication {
+ request = request.authorized()
+ }
+
+ _ = try await request.raw()
+ }
+
+ func send(
+ _ endpoint: FunctionAPIEndpoint,
+ payload: some Encodable,
+ requiresAuthentication: Bool = true
+ ) async throws -> Response {
+ var request = try client()
+ .request(endpoint)
+ .json(payload)
+
+ if requiresAuthentication {
+ request = request.authorized()
+ }
+
+ return try await request.send()
+ }
+
+ func send(
+ _ endpoint: FunctionAPIEndpoint,
+ requiresAuthentication: Bool = true
+ ) async throws -> Response {
+ try await send(
+ endpoint,
+ payload: EmptyPayload(),
+ requiresAuthentication: requiresAuthentication
+ )
+ }
+
+ private func client() throws -> NXAPIClient {
+ try apiClient.get()
+ }
+}
+
+struct FunctionAPIEndpoint: NXEndpoint {
+ let method: NXHTTPMethod
+ let path: String
+}
+
+struct FunctionAPIResponse: Decodable {
+ let accessToken: String?
+ let customToken: String?
+ let refreshToken: String?
+ let token: String?
+}
+
+struct EmptyAPIResponse: Decodable {}
+
+private struct EmptyPayload: Encodable {}
+
+private struct FunctionAPIErrorBody: Decodable {
+ let code: String
+ let message: String?
+}
+
+private struct FunctionAPIServerErrorDecoder: NXServerErrorDecoder {
+ func decodeServerError(
+ data: Data,
+ response: HTTPURLResponse,
+ decoder: JSONDecoder
+ ) -> (any Error)? {
+ guard let body = try? decoder.decode(
+ FunctionAPIErrorBody.self,
+ from: data
+ ) else { return nil }
+
+ switch body.code {
+ case EmailFetchError.emailNotFound.code:
+ return EmailFetchError.emailNotFound
+ case EmailFetchError.emailMismatch.code:
+ return EmailFetchError.emailMismatch
+ default:
+ return nil
+ }
+ }
+}
+
+private actor FirebaseAuthTokenProvider: NXAuthTokenProvider {
+ func currentAccessToken() async throws -> String? {
+ try await Auth.auth().currentUser?.getIDToken()
+ }
+
+ func refreshAccessToken() async throws -> String? {
+ try await Auth.auth().currentUser?.getIDToken(forcingRefresh: true)
+ }
+}
+
+extension Error {
+ var apiEmailFetchError: EmailFetchError? {
+ guard let error = self as? NXError,
+ case let .server(
+ statusCode: _,
+ data: _,
+ underlying: underlying
+ ) = error else { return nil }
+
+ return underlying as? EmailFetchError
+ }
+}
diff --git a/Application/DevLogInfra/Sources/Service/FunctionAPIEndpoint.swift b/Application/DevLogInfra/Sources/Service/FunctionAPIEndpoint.swift
new file mode 100644
index 00000000..37a82dbb
--- /dev/null
+++ b/Application/DevLogInfra/Sources/Service/FunctionAPIEndpoint.swift
@@ -0,0 +1,50 @@
+//
+// FunctionAPIEndpoint.swift
+// DevLogInfra
+//
+// Created by opfic on 6/26/26.
+//
+
+import Foundation
+
+extension FunctionAPIEndpoint where Response == EmptyAPIResponse {
+ static func requestTodoDeletion(_ id: String) -> Self {
+ Self(method: .post, path: "/todos/\(functionAPIPathSegment(id))/deletion-request")
+ }
+
+ static func undoTodoDeletion(_ id: String) -> Self {
+ Self(method: .delete, path: "/todos/\(functionAPIPathSegment(id))/deletion-request")
+ }
+
+ static func requestWebPageDeletion(_ id: String) -> Self {
+ Self(method: .post, path: "/web-pages/\(functionAPIPathSegment(id))/deletion-request")
+ }
+
+ static func undoWebPageDeletion(_ id: String) -> Self {
+ Self(method: .delete, path: "/web-pages/\(functionAPIPathSegment(id))/deletion-request")
+ }
+
+ static func requestPushNotificationDeletion(_ id: String) -> Self {
+ Self(method: .post, path: "/push-notifications/\(functionAPIPathSegment(id))/deletion-request")
+ }
+
+ static func undoPushNotificationDeletion(_ id: String) -> Self {
+ Self(method: .delete, path: "/push-notifications/\(functionAPIPathSegment(id))/deletion-request")
+ }
+
+ static let revokeAppleAccessToken = Self(method: .delete, path: "/auth/apple/access-token")
+ static let revokeGithubAccessToken = Self(method: .delete, path: "/auth/github/access-token")
+}
+
+extension FunctionAPIEndpoint where Response == FunctionAPIResponse {
+ static let requestAppleCustomToken = Self(method: .post, path: "/auth/apple/custom-token")
+ static let refreshAppleAccessToken = Self(method: .post, path: "/auth/apple/access-token")
+ static let requestAppleRefreshToken = Self(method: .post, path: "/auth/apple/refresh-token")
+ static let requestGithubTokens = Self(method: .post, path: "/auth/github/tokens")
+}
+
+private func functionAPIPathSegment(_ value: String) -> String {
+ var allowed = CharacterSet.alphanumerics
+ allowed.insert(charactersIn: "-._~")
+ return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
+}
diff --git a/Application/DevLogInfra/Sources/Service/PushNotificationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/PushNotificationServiceImpl.swift
index c856c688..cd9029af 100644
--- a/Application/DevLogInfra/Sources/Service/PushNotificationServiceImpl.swift
+++ b/Application/DevLogInfra/Sources/Service/PushNotificationServiceImpl.swift
@@ -8,7 +8,6 @@
import FirebaseAuth
import Combine
import FirebaseFirestore
-import FirebaseFunctions
import DevLogCore
import DevLogData
@@ -29,13 +28,7 @@ final class PushNotificationServiceImpl: PushNotificationService {
}
}
- private enum FunctionName: String {
- case requestPushNotificationDeletion
- case undoPushNotificationDeletion
- }
-
private let store = FirebaseConfiguration.firestore
- private let functions = FirebaseConfiguration.functions
private let logger = Logger(category: "PushNotificationServiceImpl")
/// 푸시 알림 On/Off 설정
@@ -231,8 +224,9 @@ final class PushNotificationServiceImpl: PushNotificationService {
do {
guard Auth.auth().currentUser?.uid != nil else { throw DataLayerError.notAuthenticated }
- let function = functions.httpsCallable(FunctionName.requestPushNotificationDeletion)
- _ = try await function.call(["notificationId": notificationID])
+ try await FunctionAPIClient.shared.send(
+ .requestPushNotificationDeletion(notificationID)
+ )
} catch {
logger.error("Failed to request notification deletion", error: error)
record(error, code: .deleteNotification)
@@ -244,8 +238,9 @@ final class PushNotificationServiceImpl: PushNotificationService {
do {
guard Auth.auth().currentUser?.uid != nil else { throw DataLayerError.notAuthenticated }
- let function = functions.httpsCallable(FunctionName.undoPushNotificationDeletion)
- _ = try await function.call(["notificationId": notificationID])
+ try await FunctionAPIClient.shared.send(
+ .undoPushNotificationDeletion(notificationID)
+ )
} catch {
logger.error("Failed to undo notification deletion", error: error)
record(error, code: .undoDeleteNotification)
diff --git a/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift
index 3722707e..99eb9222 100644
--- a/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift
+++ b/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift
@@ -9,7 +9,6 @@ import AuthenticationServices
import CryptoKit
import FirebaseAuth
import FirebaseFirestore
-import FirebaseFunctions
import FirebaseMessaging
import Foundation
import DevLogCore
@@ -28,17 +27,9 @@ final class AppleAuthenticationServiceImpl: AuthenticationService {
}
}
- private enum FunctionName: String {
- case requestAppleCustomToken
- case refreshAppleAccessToken
- case requestAppleRefreshToken
- case revokeAppleAccessToken
- }
-
private var appleSignInDelegate: AppleSignInDelegate?
private var appleSignInContinuation: CheckedContinuation?
private let store = FirebaseConfiguration.firestore
- private let functions = FirebaseConfiguration.functions
private let messaging = Messaging.messaging()
private var user: User? { Auth.auth().currentUser }
private let providerID = AuthProviderID.apple
@@ -155,7 +146,7 @@ final class AppleAuthenticationServiceImpl: AuthenticationService {
let authorizationCode = response.authorizationCode
let idTokenString = response.idTokenString
- let refreshToken = try await requestAppleRefreshToken(uid: uid, authorizationCode: authorizationCode)
+ let refreshToken = try await requestAppleRefreshToken(authorizationCode: authorizationCode)
guard let appleEmail = credential.email else {
try await revokeAppleAccessToken(token: refreshToken)
@@ -275,13 +266,16 @@ final class AppleAuthenticationServiceImpl: AuthenticationService {
throw URLError(.badServerResponse)
}
- let requestTokenFunction = functions.httpsCallable(FunctionName.requestAppleCustomToken)
- let result = try await requestTokenFunction.call([
- "idToken": idToken,
- "authorizationCode": authorizationCode
- ])
+ let response = try await FunctionAPIClient.shared.send(
+ .requestAppleCustomToken,
+ payload: [
+ "idToken": idToken,
+ "authorizationCode": authorizationCode
+ ],
+ requiresAuthentication: false
+ )
- if let data = result.data as? [String: Any], let customToken = data["customToken"] as? String {
+ if let customToken = response.customToken {
return customToken
}
throw URLError(.badServerResponse)
@@ -289,11 +283,9 @@ final class AppleAuthenticationServiceImpl: AuthenticationService {
// Apple AceessToken 재발급 메서드
private func refreshAppleAccessToken() async throws -> String {
- let refreshFunction = functions.httpsCallable(FunctionName.refreshAppleAccessToken)
- let result = try await refreshFunction.call()
+ let response = try await FunctionAPIClient.shared.send(.refreshAppleAccessToken)
- guard let data = result.data as? [String: Any],
- let accessToken = data["token"] as? String else {
+ guard let accessToken = response.token else {
throw URLError(.cannotParseResponse)
}
@@ -301,31 +293,28 @@ final class AppleAuthenticationServiceImpl: AuthenticationService {
}
// Apple RefreshToken 발급 메서드
- func requestAppleRefreshToken(uid: String, authorizationCode: Data) async throws -> String {
+ func requestAppleRefreshToken(authorizationCode: Data) async throws -> String {
guard let authorizationCode = String(data: authorizationCode, encoding: .utf8) else {
throw URLError(.userAuthenticationRequired)
}
- let requestFuction = functions.httpsCallable(FunctionName.requestAppleRefreshToken)
-
- let params: [String: Any] = [
- "authorizationCode": authorizationCode,
- "uid": uid
- ]
-
- let result = try await requestFuction.call(params)
+ let response = try await FunctionAPIClient.shared.send(
+ .requestAppleRefreshToken,
+ payload: ["authorizationCode": authorizationCode]
+ )
- if let data = result.data as? [String: Any], let accessToken = data["refreshToken"] as? String {
- return accessToken
+ if let refreshToken = response.refreshToken {
+ return refreshToken
}
throw URLError(.badServerResponse)
}
// Apple AccessToken 취소 메서드
func revokeAppleAccessToken(token: String) async throws {
- let revokeFunction = functions.httpsCallable(FunctionName.revokeAppleAccessToken)
-
- _ = try await revokeFunction.call(["token": token])
+ try await FunctionAPIClient.shared.send(
+ .revokeAppleAccessToken,
+ payload: ["token": token]
+ )
}
}
diff --git a/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift
index f02da95e..8a2aedfe 100644
--- a/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift
+++ b/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift
@@ -9,7 +9,6 @@ import AuthenticationServices
import Foundation
import FirebaseAuth
import FirebaseFirestore
-import FirebaseFunctions
import FirebaseMessaging
import Nexa
import DevLogCore
@@ -28,18 +27,12 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService {
}
}
- private enum FunctionName: String {
- case requestGithubTokens
- case revokeGithubAccessToken
- }
-
private enum GitHubAPI {
static let baseURL = URL(string: "https://api.github.com")!
static let acceptHeader = "application/vnd.github.v3+json"
}
private let store = FirebaseConfiguration.firestore
- private let functions = FirebaseConfiguration.functions
private let messaging = Messaging.messaging()
private var user: User? { Auth.auth().currentUser }
private let providerID = AuthProviderID.gitHub
@@ -251,14 +244,15 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService {
// Firebase Function 호출: Custom Token 발급
private func requestTokens(authorizationCode: String) async throws -> (String, String) {
- let requestTokenFunction = functions.httpsCallable(FunctionName.requestGithubTokens)
-
do {
- let result = try await requestTokenFunction.call(["code": authorizationCode])
+ let response = try await FunctionAPIClient.shared.send(
+ .requestGithubTokens,
+ payload: ["code": authorizationCode],
+ requiresAuthentication: false
+ )
- if let data = result.data as? [String: Any],
- let accessToken = data["accessToken"] as? String,
- let customToken = data["customToken"] as? String {
+ if let accessToken = response.accessToken,
+ let customToken = response.customToken {
return (accessToken, customToken)
}
throw TokenError.invalidResponse
@@ -268,15 +262,16 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService {
}
private func revokeAccessToken(accessToken: String? = nil) async throws {
- var param: [String: Any] = [:]
+ var param: [String: String] = [:]
if let accessToken = accessToken {
param["accessToken"] = accessToken
}
- let revokeFunction = functions.httpsCallable(FunctionName.revokeGithubAccessToken)
-
- _ = try await revokeFunction.call(param)
+ try await FunctionAPIClient.shared.send(
+ .revokeGithubAccessToken,
+ payload: param
+ )
}
// GitHub API로 사용자 프로필 정보 가져오기
@@ -315,15 +310,11 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService {
}
private func mapRequestTokensError(_ error: Error) -> Error {
- let nsError = error as NSError
- guard nsError.domain == FunctionsErrorDomain,
- let details = nsError.userInfo[FunctionsErrorDetailsKey] as? [String: Any],
- let reason = details["reason"] as? String,
- reason == EmailFetchError.emailNotFound.code else {
- return error
+ if let emailFetchError = error.apiEmailFetchError {
+ return emailFetchError
}
- return EmailFetchError.emailNotFound
+ return error
}
}
diff --git a/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift b/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift
index fd84aa95..931b4769 100644
--- a/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift
+++ b/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift
@@ -7,7 +7,6 @@
import FirebaseAuth
import FirebaseFirestore
-import FirebaseFunctions
import DevLogCore
import DevLogData
@@ -25,13 +24,7 @@ final class TodoServiceImpl: TodoService {
}
}
- private enum FunctionName: String {
- case requestTodoDeletion
- case undoTodoDeletion
- }
-
private let store = FirebaseConfiguration.firestore
- private let functions = FirebaseConfiguration.functions
private let encoder = Firestore.Encoder()
private let logger = Logger(category: "TodoServiceImpl")
@@ -222,8 +215,9 @@ final class TodoServiceImpl: TodoService {
logger.info("Requesting todo deletion")
do {
- let function = functions.httpsCallable(FunctionName.requestTodoDeletion)
- _ = try await function.call(["todoId": todoId])
+ try await FunctionAPIClient.shared.send(
+ .requestTodoDeletion(todoId)
+ )
logger.info("Successfully requested todo deletion")
} catch {
@@ -239,8 +233,9 @@ final class TodoServiceImpl: TodoService {
logger.info("Undoing todo deletion")
do {
- let function = functions.httpsCallable(FunctionName.undoTodoDeletion)
- _ = try await function.call(["todoId": todoId])
+ try await FunctionAPIClient.shared.send(
+ .undoTodoDeletion(todoId)
+ )
logger.info("Successfully undone todo deletion")
} catch {
diff --git a/Application/DevLogInfra/Sources/Service/WebPageServiceImpl.swift b/Application/DevLogInfra/Sources/Service/WebPageServiceImpl.swift
index 6053cbf9..f432aa8f 100644
--- a/Application/DevLogInfra/Sources/Service/WebPageServiceImpl.swift
+++ b/Application/DevLogInfra/Sources/Service/WebPageServiceImpl.swift
@@ -7,7 +7,6 @@
import FirebaseAuth
import FirebaseFirestore
-import FirebaseFunctions
import DevLogCore
import DevLogData
@@ -23,13 +22,7 @@ final class WebPageServiceImpl: WebPageService {
}
}
- private enum FunctionName: String {
- case requestWebPageDeletion
- case undoWebPageDeletion
- }
-
private let store = FirebaseConfiguration.firestore
- private let functions = FirebaseConfiguration.functions
private let encoder = Firestore.Encoder()
private let logger = Logger(category: "WebPageServiceImpl")
@@ -77,8 +70,8 @@ final class WebPageServiceImpl: WebPageService {
}
do {
- let documentID = documentID(for: request.url)
- let docRef = store.document(FirestorePath.webPage(uid, documentId: documentID))
+ let docID = documentID(for: request.url)
+ let docRef = store.document(FirestorePath.webPage(uid, documentId: docID))
let data = try encoder.encode(request)
try await docRef.setData(data, merge: true)
logger.info("Successfully upserted web page")
@@ -89,8 +82,8 @@ final class WebPageServiceImpl: WebPageService {
}
}
- func deleteWebPage(_ urlString: String) async throws {
- logger.info("Requesting web page deletion: \(urlString)")
+ func deleteWebPage(_ id: String) async throws {
+ logger.info("Requesting web page deletion: \(id)")
guard Auth.auth().currentUser?.uid != nil else {
logger.error("User not authenticated")
@@ -98,8 +91,9 @@ final class WebPageServiceImpl: WebPageService {
}
do {
- let function = functions.httpsCallable(FunctionName.requestWebPageDeletion)
- _ = try await function.call(["urlString": urlString])
+ try await FunctionAPIClient.shared.send(
+ .requestWebPageDeletion(id)
+ )
logger.info("Successfully requested web page deletion")
} catch {
logger.error("Failed to request web page deletion", error: error)
@@ -108,8 +102,8 @@ final class WebPageServiceImpl: WebPageService {
}
}
- func undoDeleteWebPage(_ urlString: String) async throws {
- logger.info("Undoing web page deletion: \(urlString)")
+ func undoDeleteWebPage(_ id: String) async throws {
+ logger.info("Undoing web page deletion: \(id)")
guard Auth.auth().currentUser?.uid != nil else {
logger.error("User not authenticated")
@@ -117,8 +111,9 @@ final class WebPageServiceImpl: WebPageService {
}
do {
- let function = functions.httpsCallable(FunctionName.undoWebPageDeletion)
- _ = try await function.call(["urlString": urlString])
+ try await FunctionAPIClient.shared.send(
+ .undoWebPageDeletion(id)
+ )
logger.info("Successfully undone web page deletion")
} catch {
logger.error("Failed to undo web page deletion", error: error)
@@ -126,17 +121,6 @@ final class WebPageServiceImpl: WebPageService {
throw error
}
}
-
- private func documentID(for url: String) -> String {
- if let encoded = url.addingPercentEncoding(withAllowedCharacters: .alphanumerics) {
- return encoded
- }
- let base64 = Data(url.utf8).base64EncodedString()
- return base64
- .replacingOccurrences(of: "/", with: "_")
- .replacingOccurrences(of: "+", with: "-")
- .replacingOccurrences(of: "=", with: "")
- }
}
private extension WebPageServiceImpl {
@@ -152,6 +136,17 @@ private extension WebPageServiceImpl {
Self.record(error, code: code)
}
+ func documentID(for url: String) -> String {
+ if let encoded = url.addingPercentEncoding(withAllowedCharacters: .alphanumerics) {
+ return encoded
+ }
+ let base64 = Data(url.utf8).base64EncodedString()
+ return base64
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "=", with: "")
+ }
+
func makeResponse(from snapshot: QueryDocumentSnapshot) -> WebPageResponse? {
let data = snapshot.data()
guard
diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift
index 32ffbbd8..dd8e6984 100644
--- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift
+++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift
@@ -87,7 +87,10 @@ extension HomeFeature {
func deleteWebPageEffect(_ page: WebPageItem) -> Effect {
.run { [deleteWebPageUseCase] send in
do {
- try await deleteWebPageUseCase.execute(page.url.absoluteString)
+ try await deleteWebPageUseCase.execute(
+ id: page.id,
+ urlString: page.url.absoluteString
+ )
} catch {
await send(.store(.handleWebPageDeleteFailure(page.id)))
await send(.store(.setAlert(isPresented: true, type: .error)))
@@ -95,15 +98,13 @@ extension HomeFeature {
}
}
- func undoDeleteWebPageEffect(_ urlString: String) -> Effect {
+ func undoDeleteWebPageEffect(_ webPage: DeletedWebPage) -> Effect {
.run { [undoDeleteWebPageUseCase, addWebPageUseCase] send in
do {
- try await undoDeleteWebPageUseCase.execute(urlString)
- try await addWebPageUseCase.execute(urlString)
+ try await undoDeleteWebPageUseCase.execute(webPage.id)
+ try await addWebPageUseCase.execute(webPage.urlString)
} catch {
- if let webPageURL = URL(string: urlString) {
- await send(.store(.setWebPageHidden(webPageURL, true)))
- }
+ await send(.store(.setWebPageHidden(webPage.id, true)))
await send(.store(.setAlert(isPresented: true, type: .error)))
}
}
diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift
index 2edde907..2844fa3a 100644
--- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift
+++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift
@@ -23,7 +23,7 @@ struct HomeFeature {
var isNetworkConnected = true
var webPageURLInput = "https://"
var selectedTodoCategory: TodoCategory?
- var deletedWebPageURLString: String?
+ var deletedWebPage: DeletedWebPage?
var loading = LoadingFeature.State()
var showContentPicker: Bool { sheet?.contentPickerState != nil }
@@ -46,6 +46,11 @@ struct HomeFeature {
}
}
+ struct DeletedWebPage: Equatable {
+ let id: String
+ let urlString: String
+ }
+
enum Action: Equatable {
case alert(PresentationAction)
case sheet(PresentationAction)
@@ -73,8 +78,8 @@ struct HomeFeature {
case setSheet(SheetState?)
case setPresentation(Presentation, Bool)
case setAlert(isPresented: Bool, type: AlertType? = nil)
- case setWebPageHidden(URL, Bool)
- case handleWebPageDeleteFailure(URL)
+ case setWebPageHidden(String, Bool)
+ case handleWebPageDeleteFailure(String)
case setTodoCategory([TodoCategoryItem])
case updateRecentTodos([RecentTodoItem])
case updateWebPages([WebPageItem])
@@ -234,8 +239,8 @@ private extension HomeFeature {
return fetchWebPagesEffect()
case .finishDeleteWebPageToast(let urlString):
state.webPages.removeAll { $0.url.absoluteString == urlString && $0.isHidden }
- if state.deletedWebPageURLString == urlString {
- state.deletedWebPageURLString = nil
+ if state.deletedWebPage?.urlString == urlString {
+ state.deletedWebPage = nil
}
case .tapTodoCategory(let category):
state.selectedTodoCategory = category
@@ -259,16 +264,19 @@ private extension HomeFeature {
guard let index = state.webPages.firstIndex(where: { $0.id == page.id }) else {
return .none
}
- state.deletedWebPageURLString = page.url.absoluteString
+ state.deletedWebPage = DeletedWebPage(
+ id: page.id,
+ urlString: page.url.absoluteString
+ )
state.webPages[index].isHidden = true
return deleteWebPageEffect(page)
case .undoDeleteWebPage:
- guard let urlString = state.deletedWebPageURLString else { return .none }
- if let index = state.webPages.firstIndex(where: { $0.url.absoluteString == urlString }) {
+ guard let webPage = state.deletedWebPage else { return .none }
+ if let index = state.webPages.firstIndex(where: { $0.id == webPage.id }) {
state.webPages[index].isHidden = false
}
- state.deletedWebPageURLString = nil
- return undoDeleteWebPageEffect(urlString)
+ state.deletedWebPage = nil
+ return undoDeleteWebPageEffect(webPage)
}
return .none
@@ -287,12 +295,12 @@ private extension HomeFeature {
Self.setPresentation(&state, presentation: presentation, isPresented: isPresented)
case .setAlert(let isPresented, let type):
Self.setAlert(&state, isPresented: isPresented, type: type)
- case .setWebPageHidden(let webPageURL, let isHidden):
- if let index = state.webPages.firstIndex(where: { $0.id == webPageURL }) {
+ case .setWebPageHidden(let id, let isHidden):
+ if let index = state.webPages.firstIndex(where: { $0.id == id }) {
state.webPages[index].isHidden = isHidden
}
- case .handleWebPageDeleteFailure(let webPageURL):
- if let index = state.webPages.firstIndex(where: { $0.id == webPageURL }) {
+ case .handleWebPageDeleteFailure(let id):
+ if let index = state.webPages.firstIndex(where: { $0.id == id }) {
state.webPages[index].isHidden = false
} else {
state.needsWebPageRefresh = true
diff --git a/Application/DevLogPresentation/Sources/Structure/WebPageItem.swift b/Application/DevLogPresentation/Sources/Structure/WebPageItem.swift
index 666d1aaf..510e28a1 100644
--- a/Application/DevLogPresentation/Sources/Structure/WebPageItem.swift
+++ b/Application/DevLogPresentation/Sources/Structure/WebPageItem.swift
@@ -16,7 +16,7 @@ public struct WebPageItem: Identifiable, Hashable {
self.metadata = metadata
}
- public var id: URL { metadata.url }
+ public var id: String { metadata.id }
public var title: String { metadata.title ?? String(localized: "web_page_missing_title") }
public var url: URL { metadata.url }
public var displayURL: String { metadata.displayURL.absoluteString }
diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift
index 80e84efa..7f8c2386 100644
--- a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift
+++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift
@@ -185,11 +185,13 @@ func makeHomeTodo(
}
func makeHomeWebPage(
+ id: String = "web-page-id",
title: String = "OpenAI",
urlString: String = "https://openai.com"
) -> WebPage {
let url = URL(string: urlString)!
return WebPage(
+ id: id,
title: title,
url: url,
displayURL: url,
diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift
index a7da6f02..11ca6379 100644
--- a/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift
+++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift
@@ -109,10 +109,11 @@ struct HomeFeatureTests {
#expect(adapter.webPages.filter { !$0.isHidden }.isEmpty)
await waitUntil {
- context.deleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"]
+ context.deleteWebPageUseCaseSpy.calls.count == 1
}
- #expect(context.deleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"])
+ #expect(context.deleteWebPageUseCaseSpy.calls.first?.id == "web-page-id")
+ #expect(context.deleteWebPageUseCaseSpy.calls.first?.urlString == "https://openai.com")
}
@Test("웹페이지 삭제를 되돌리면 되돌리기 유스케이스가 호출되고 숨김 상태가 해제된다")
@@ -133,14 +134,14 @@ struct HomeFeatureTests {
await adapter.undoDeleteWebPage()
await waitUntil {
- context.undoDeleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"]
+ context.undoDeleteWebPageUseCaseSpy.calledIDs == ["web-page-id"]
}
let restoredWebPageItem = try #require(adapter.webPages.first {
$0.url.absoluteString == "https://openai.com"
})
- #expect(context.undoDeleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"])
+ #expect(context.undoDeleteWebPageUseCaseSpy.calledIDs == ["web-page-id"])
#expect(!restoredWebPageItem.isHidden)
}
diff --git a/Application/DevLogPresentation/Tests/Search/SearchFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Search/SearchFeatureTestDoubles.swift
index 6853a8c8..cc058ed8 100644
--- a/Application/DevLogPresentation/Tests/Search/SearchFeatureTestDoubles.swift
+++ b/Application/DevLogPresentation/Tests/Search/SearchFeatureTestDoubles.swift
@@ -269,11 +269,13 @@ func makeSearchTodo(
}
func makeSearchWebPage(
+ id: String = "web-page-id",
title: String? = "Web",
urlString: String = "https://example.com"
) -> WebPage {
let url = URL(string: urlString)!
return WebPage(
+ id: id,
title: title,
url: url,
displayURL: url,
diff --git a/Application/DevLogPresentation/Tests/Support/TestSupport.swift b/Application/DevLogPresentation/Tests/Support/TestSupport.swift
index 42b23d07..1ebf5afb 100644
--- a/Application/DevLogPresentation/Tests/Support/TestSupport.swift
+++ b/Application/DevLogPresentation/Tests/Support/TestSupport.swift
@@ -167,18 +167,18 @@ final class AddWebPageUseCaseSpy: AddWebPageUseCase {
}
final class DeleteWebPageUseCaseSpy: DeleteWebPageUseCase {
- private(set) var calledUrlStrings: [String] = []
+ private(set) var calls: [(id: String, urlString: String)] = []
- func execute(_ urlString: String) async throws {
- calledUrlStrings.append(urlString)
+ func execute(id: String, urlString: String) async throws {
+ calls.append((id, urlString))
}
}
final class UndoDeleteWebPageUseCaseSpy: UndoDeleteWebPageUseCase {
- private(set) var calledUrlStrings: [String] = []
+ private(set) var calledIDs: [String] = []
- func execute(_ urlString: String) async throws {
- calledUrlStrings.append(urlString)
+ func execute(_ id: String) async throws {
+ calledIDs.append(id)
}
}