ApiService.swift 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573
  1. // Copyright 2023 Google LLC
  2. //
  3. // Licensed under the Apache License, Version 2.0 (the "License");
  4. // you may not use this file except in compliance with the License.
  5. // You may obtain a copy of the License at
  6. //
  7. // http://www.apache.org/licenses/LICENSE-2.0
  8. //
  9. // Unless required by applicable law or agreed to in writing, software
  10. // distributed under the License is distributed on an "AS IS" BASIS,
  11. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. // See the License for the specific language governing permissions and
  13. // limitations under the License.
  14. import FirebaseCore
  15. import FirebaseInstallations
  16. import Foundation
  17. import UIKit
  18. // Avoids exposing internal APIs to Swift users
  19. @_implementationOnly import FirebaseCoreInternal
  20. enum Strings {
  21. static let errorDomain = "com.firebase.appdistribution.api"
  22. static let errorDetailsKey = "details"
  23. static let httpGet = "GET"
  24. static let httpPost = "POST"
  25. static let releaseEndpointUrlTemplate =
  26. "https://firebaseapptesters.googleapis.com/v1alpha/devices/-/testerApps/%@/installations/%@/releases"
  27. static let findReleaseEndpointUrlTemplate =
  28. "https://firebaseapptesters.googleapis.com/v1alpha/projects/%@/installations/%@/releases:find?compositeBinaryId.displayVersion=%@&compositeBinaryId.buildVersion=%@&compositeBinaryId.codeHash=%@"
  29. static let createFeedbackEndpointUrlTemplate =
  30. "https://firebaseapptesters.googleapis.com/v1alpha/%@/feedbackReports"
  31. static let uploadImageEndpointUrlTemplate =
  32. "https://firebaseapptesters.googleapis.com/upload/v1alpha/%@:uploadArtifact"
  33. static let commitFeedbackEndpointUrlTemplate =
  34. "https://firebaseapptesters.googleapis.com/v1alpha/%@:commit"
  35. static let installationsAuthHeader = "X-Goog-Firebase-Installations-Auth"
  36. static let apiHeaderKey = "X-Goog-Api-Key"
  37. static let apiBundleKey = "X-Ios-Bundle-Identifier"
  38. static let responseReleaseKey = "releases"
  39. static let compositeBinaryIdQueryParamName = "compositeBinaryId"
  40. static let uploadArtifactTypeQueryParamName = "type"
  41. static let uploadArtifactScreenshotType = "SCREENSHOT"
  42. static let GoogleUploadProtocolHeader = "X-Goog-Upload-Protocol"
  43. static let GoogleUploadProtocolRaw = "raw"
  44. static let GoogleUploadFileNameHeader = "X-Goog-Upload-File-Name"
  45. static let GoogleUploadFileName = "screenshot.png"
  46. static let contentTypeHeader = "Content-Type"
  47. static let jsonContentType = "application/json"
  48. }
  49. enum AppDistributionApiError: NSInteger {
  50. case ApiErrorTimeout = 0
  51. case ApiTokenGenerationFailure = 1
  52. case ApiInstallationIdentifierError = 2
  53. case ApiErrorUnauthenticated = 3
  54. case ApiErrorUnauthorized = 4
  55. case ApiErrorNotFound = 5
  56. case ApiErrorUnknownFailure = 6
  57. case ApiErrorParseFailure = 7
  58. }
  59. struct CompositeBinaryId: Codable {
  60. var displayVersion: String
  61. var buildVersion: String
  62. var codeHash: String
  63. }
  64. struct FindReleaseResponse: Codable {
  65. var release: String
  66. }
  67. struct FeedbackReport: Codable {
  68. var name: String?
  69. var text: String?
  70. }
  71. @objc(FIRFADApiServiceSwift) open class ApiService: NSObject {
  72. @objc(generateAuthTokenWithCompletion:) public static func generateAuthToken(completion: @escaping (_ identifier: String?,
  73. _ authTokenResult: InstallationsAuthTokenResult?,
  74. _ error: Error?)
  75. -> Void) {
  76. generateAuthToken(installations: Installations.installations(), completion: completion)
  77. }
  78. static func generateAuthToken(installations: InstallationsProtocol,
  79. completion: @escaping (_ identifier: String?,
  80. _ authTokenResult: InstallationsAuthTokenResult?,
  81. _ error: Error?)
  82. -> Void) {
  83. installations.authToken(completion: { authTokenResult, error in
  84. var fadError: Error? = error
  85. if self.handleError(
  86. error: &fadError,
  87. description: "Failed to generate Firebase installation auth token",
  88. code: .ApiTokenGenerationFailure
  89. ) {
  90. Logger
  91. .logError(String(format: "Error getting auth token. Error: %@",
  92. error?.localizedDescription ?? ""))
  93. completion(nil, nil, fadError)
  94. return
  95. }
  96. installations.installationID(completion: { identifier, error in
  97. var fadError: Error? = error
  98. if self.handleError(
  99. error: &fadError,
  100. description: "Failed to generate Firebase installation id",
  101. code: .ApiInstallationIdentifierError
  102. ) {
  103. Logger
  104. .logError(String(format: "Error getting installation ID. Error: %@",
  105. error?.localizedDescription ?? ""))
  106. completion(nil, nil, fadError)
  107. return
  108. }
  109. completion(identifier, authTokenResult, nil)
  110. })
  111. })
  112. }
  113. @objc(fetchReleasesWithCompletion:) public static func fetchReleases(completion: @escaping (_ releases: [
  114. Any,
  115. ]?,
  116. _ error: Error?)
  117. -> Void) {
  118. guard let app = FirebaseApp.app() else {
  119. return
  120. }
  121. fetchReleases(
  122. app: app,
  123. installations: Installations.installations(),
  124. urlSession: URLSession.shared,
  125. completion: completion
  126. )
  127. }
  128. static func fetchReleases(app: FirebaseApp, installations: InstallationsProtocol,
  129. urlSession: URLSession, completion: @escaping (_ releases: [Any]?,
  130. _ error: Error?)
  131. -> Void) {
  132. Logger.logInfo(String(
  133. format: "Requesting release for app id - %@",
  134. app.options.googleAppID
  135. ))
  136. generateAuthToken(installations: installations) { identifier, authTokenResult, error in
  137. let urlString = String(
  138. format: Strings.releaseEndpointUrlTemplate,
  139. app.options.googleAppID,
  140. identifier!
  141. )
  142. let request = self.createHttpRequest(
  143. app: app,
  144. method: Strings.httpGet,
  145. url: urlString,
  146. authTokenResult: authTokenResult!
  147. )
  148. Logger.logInfo(String(
  149. format: "Url: %@ Auth token: %@ Api Key: %@",
  150. urlString,
  151. authTokenResult?.authToken ?? "",
  152. app.options.apiKey ?? ""
  153. ))
  154. let listReleaseDataTask = urlSession
  155. .dataTask(with: request as URLRequest) { data, response, error in
  156. var fadError = error
  157. let releasesResponse = self.handleResponse(data: data,
  158. response: response,
  159. error: &fadError,
  160. returnType: [String: [Any]].self)
  161. DispatchQueue.main.async {
  162. completion(releasesResponse?[Strings.responseReleaseKey], fadError)
  163. }
  164. }
  165. listReleaseDataTask.resume()
  166. }
  167. }
  168. @objc(findReleaseWithDisplayVersion:buildVersion:codeHash:completion:)
  169. public static func findRelease(displayVersion: String, buildVersion: String, codeHash: String,
  170. completion: @escaping (_ releaseName: String?, _ error: Error?)
  171. -> Void) {
  172. findRelease(
  173. app: FirebaseApp.app()!,
  174. installations: Installations.installations(),
  175. urlSession: URLSession.shared,
  176. displayVersion: displayVersion,
  177. buildVersion: buildVersion,
  178. codeHash: codeHash,
  179. completion: completion
  180. )
  181. }
  182. private static func buildFindReleaseUrl(projectNumber: String, identifier: String,
  183. displayVersion: String, buildVersion: String,
  184. codeHash: String) -> String {
  185. return String(
  186. format: Strings.findReleaseEndpointUrlTemplate,
  187. projectNumber,
  188. identifier,
  189. displayVersion,
  190. buildVersion,
  191. codeHash
  192. )
  193. }
  194. static func findRelease(app: FirebaseApp, installations: InstallationsProtocol,
  195. urlSession: URLSession, displayVersion: String,
  196. buildVersion: String, codeHash: String,
  197. completion: @escaping (_ releaseName: String?, _ error: Error?)
  198. -> Void) {
  199. generateAuthToken(installations: installations) { identifier, authTokenResult, error in
  200. // TODO(tundeagboola) The backend may not accept project ID here in which case
  201. // we'll have to figure out a way to get the project number
  202. let urlString = buildFindReleaseUrl(
  203. projectNumber: app.options.gcmSenderID,
  204. identifier: identifier!,
  205. displayVersion: displayVersion,
  206. buildVersion: buildVersion,
  207. codeHash: codeHash
  208. )
  209. let request = self.createHttpRequest(
  210. app: app,
  211. method: Strings.httpGet,
  212. url: URL(string: urlString),
  213. authTokenResult: authTokenResult!
  214. )
  215. let findReleaseDataTask = urlSession
  216. .dataTask(with: request as URLRequest) { data, response, error in
  217. var fadError = error
  218. let findReleaseResponse = self.handleCodableResponse(
  219. data: data,
  220. response: response,
  221. error: &fadError,
  222. returnType: FindReleaseResponse.self
  223. )
  224. DispatchQueue.main.async {
  225. completion(findReleaseResponse?.release, fadError)
  226. }
  227. }
  228. findReleaseDataTask.resume()
  229. }
  230. }
  231. @objc(createFeedbackWithReleaseName:feedbackText:completion:)
  232. public static func createFeedback(releaseName: String,
  233. feedbackText: String,
  234. completion: @escaping (_ feedbackName: String?, _ error: Error?)
  235. -> Void) {
  236. createFeedback(
  237. app: FirebaseApp.app()!,
  238. installations: Installations.installations(),
  239. urlSession: URLSession.shared,
  240. releaseName: releaseName,
  241. feedbackText: feedbackText,
  242. completion: completion
  243. )
  244. }
  245. static func createFeedback(app: FirebaseApp, installations: InstallationsProtocol,
  246. urlSession: URLSession, releaseName: String,
  247. feedbackText: String,
  248. completion: @escaping (_ feedbackName: String?, _ error: Error?)
  249. -> Void) {
  250. generateAuthToken(installations: installations) { identifier, authTokenResult, error in
  251. guard let authTokenResult = authTokenResult else {
  252. completion(nil, error)
  253. return
  254. }
  255. let urlString = String(
  256. format: Strings.createFeedbackEndpointUrlTemplate,
  257. releaseName
  258. )
  259. let request = createHttpRequest(
  260. app: app,
  261. method: Strings.httpPost,
  262. url: urlString,
  263. authTokenResult: authTokenResult
  264. )
  265. request.setValue(Strings.jsonContentType, forHTTPHeaderField: Strings.contentTypeHeader)
  266. let feedbackReport = FeedbackReport(text: feedbackText)
  267. request.httpBody = try? JSONEncoder().encode(feedbackReport)
  268. let createFeedbackTask = urlSession
  269. .dataTask(with: request as URLRequest) { data, response, error in
  270. var fadError = error
  271. let feedback = self.handleCodableResponse(
  272. data: data,
  273. response: response,
  274. error: &fadError,
  275. returnType: FeedbackReport.self
  276. )
  277. DispatchQueue.main.async {
  278. completion(feedback?.name, fadError)
  279. }
  280. }
  281. createFeedbackTask.resume()
  282. }
  283. }
  284. @objc(uploadImageWithFeedbackName:image:completion:)
  285. public static func uploadImage(feedbackName: String,
  286. image: UIImage,
  287. completion: @escaping (_ error: Error?)
  288. -> Void) {
  289. uploadImage(
  290. app: FirebaseApp.app()!,
  291. installations: Installations.installations(),
  292. urlSession: URLSession.shared,
  293. feedbackName: feedbackName,
  294. image: image,
  295. completion: completion
  296. )
  297. }
  298. static func uploadImage(app: FirebaseApp, installations: InstallationsProtocol,
  299. urlSession: URLSession, feedbackName: String,
  300. image: UIImage,
  301. completion: @escaping (_ error: Error?)
  302. -> Void) {
  303. generateAuthToken(installations: installations) { identifier, authTokenResult, error in
  304. guard let authTokenResult = authTokenResult else {
  305. completion(error)
  306. return
  307. }
  308. let urlString = String(
  309. format: Strings.uploadImageEndpointUrlTemplate,
  310. feedbackName
  311. )
  312. guard var urlComponents = URLComponents(string: urlString) else {
  313. // TODO(tundeagboola) We should throw exceptions here insead of piping errors
  314. Logger.logError("Unable to build URL for uploadArtifact request")
  315. return
  316. }
  317. urlComponents.queryItems = [URLQueryItem(
  318. name: Strings.uploadArtifactTypeQueryParamName,
  319. value: Strings.uploadArtifactScreenshotType
  320. )]
  321. guard let url = urlComponents.url else {
  322. // TODO(tundeagboola) We should throw exceptions here insead of piping errors
  323. Logger.logError("Unable to build URL for uploadArtifact request")
  324. return
  325. }
  326. let request = createHttpRequest(
  327. app: app,
  328. method: Strings.httpPost,
  329. url: url,
  330. authTokenResult: authTokenResult
  331. )
  332. request.setValue(
  333. Strings.GoogleUploadProtocolHeader,
  334. forHTTPHeaderField: Strings.GoogleUploadProtocolRaw
  335. )
  336. request.setValue(
  337. Strings.GoogleUploadFileNameHeader,
  338. forHTTPHeaderField: Strings.GoogleUploadFileName
  339. )
  340. // TODO(tundeagboola) Add support for jpegs
  341. request.httpBody = image.pngData()
  342. let uploadImageTask = urlSession
  343. .dataTask(with: request as URLRequest) { data, response, error in
  344. var fadError = error
  345. self.handleError(httpResponse: response as? HTTPURLResponse, error: &fadError)
  346. DispatchQueue.main.async {
  347. completion(fadError)
  348. }
  349. }
  350. uploadImageTask.resume()
  351. }
  352. }
  353. @objc(commitFeedbackWithFeedbackName:completion:)
  354. public static func commitFeedback(feedbackName: String,
  355. completion: @escaping (_ error: Error?)
  356. -> Void) {
  357. commitFeedback(
  358. app: FirebaseApp.app()!,
  359. installations: Installations.installations(),
  360. urlSession: URLSession.shared,
  361. feedbackName: feedbackName,
  362. completion: completion
  363. )
  364. }
  365. static func commitFeedback(app: FirebaseApp, installations: InstallationsProtocol,
  366. urlSession: URLSession,
  367. feedbackName: String,
  368. completion: @escaping (_ error: Error?)
  369. -> Void) {
  370. generateAuthToken(installations: installations) { identifier, authTokenResult, error in
  371. guard let authTokenResult = authTokenResult else {
  372. completion(error)
  373. return
  374. }
  375. let urlString = String(
  376. format: Strings.commitFeedbackEndpointUrlTemplate,
  377. feedbackName
  378. )
  379. let request = createHttpRequest(
  380. app: app,
  381. method: Strings.httpPost,
  382. url: urlString,
  383. authTokenResult: authTokenResult
  384. )
  385. let commitFeedbackTask = urlSession
  386. .dataTask(with: request as URLRequest) { data, response, error in
  387. var fadError = error
  388. self.handleError(httpResponse: response as? HTTPURLResponse, error: &fadError)
  389. DispatchQueue.main.async {
  390. completion(fadError)
  391. }
  392. }
  393. commitFeedbackTask.resume()
  394. }
  395. }
  396. @discardableResult
  397. static func handleError(httpResponse: HTTPURLResponse?, error: inout Error?) -> Bool {
  398. // TODO(tundeagboola) We should be throwing errors instead of piping the error object through
  399. if error != nil || httpResponse == nil {
  400. return handleError(
  401. error: &error,
  402. description: "Unknown http error occurred",
  403. code: .ApiErrorUnknownFailure
  404. )
  405. } else if httpResponse?.statusCode != 200 {
  406. error = createError(statusCode: httpResponse!.statusCode)
  407. return true
  408. }
  409. return false
  410. }
  411. @discardableResult
  412. static func handleError(error: inout Error?, description: String?,
  413. code: AppDistributionApiError) -> Bool {
  414. // TODO(tundeagboola) We should be throwing errors instead of piping the error object through
  415. if error != nil {
  416. error = createError(description: description!, code: code)
  417. return true
  418. }
  419. return false
  420. }
  421. static func createError(description: String, code: AppDistributionApiError) -> Error {
  422. let userInfo = [NSLocalizedDescriptionKey: description]
  423. return NSError(domain: Strings.errorDomain, code: code.rawValue, userInfo: userInfo)
  424. }
  425. static func createError(statusCode: NSInteger) -> Error {
  426. switch statusCode {
  427. case 401:
  428. return createError(description: "Tester not authenticated", code: .ApiErrorUnauthenticated)
  429. case 403, 400:
  430. return createError(description: "Tester not authorized", code: .ApiErrorUnauthorized)
  431. case 404:
  432. return createError(description: "Tester or releases not found", code: .ApiErrorUnauthorized)
  433. case 408, 504:
  434. return createError(description: "Request timeout", code: .ApiErrorTimeout)
  435. default:
  436. print("Unknown status code")
  437. }
  438. let description = String(format: "Unknown status code: %ld", statusCode)
  439. return createError(description: description, code: .ApiErrorUnknownFailure)
  440. }
  441. static func createHttpRequest(app: FirebaseApp, method: String, url: String,
  442. authTokenResult: InstallationsAuthTokenResult)
  443. -> NSMutableURLRequest {
  444. return createHttpRequest(
  445. app: app,
  446. method: method,
  447. url: URL(string: url),
  448. authTokenResult: authTokenResult
  449. )
  450. }
  451. static func createHttpRequest(app: FirebaseApp, method: String, url: URL?,
  452. authTokenResult: InstallationsAuthTokenResult)
  453. -> NSMutableURLRequest {
  454. let request = NSMutableURLRequest()
  455. request.url = url
  456. request.httpMethod = method
  457. request.setValue(authTokenResult.authToken, forHTTPHeaderField: Strings.installationsAuthHeader)
  458. request.setValue(
  459. app.options.apiKey,
  460. forHTTPHeaderField: Strings.apiHeaderKey
  461. )
  462. request.setValue(Bundle.main.bundleIdentifier, forHTTPHeaderField: Strings.apiBundleKey)
  463. return request
  464. }
  465. static func validateResponse(response: URLResponse?, error: inout Error?) -> Bool {
  466. guard let response = response else {
  467. handleError(
  468. error: &error,
  469. description: "URLResponse is nil",
  470. code: .ApiErrorUnknownFailure
  471. )
  472. return false
  473. }
  474. let httpResponse = response as! HTTPURLResponse
  475. Logger
  476. .logInfo(String(format: "HTTPResponse status code %ld, response %@", httpResponse.statusCode,
  477. httpResponse))
  478. if handleError(httpResponse: httpResponse, error: &error) {
  479. Logger
  480. .logError(String(format: "App tester API service error: %@",
  481. error?.localizedDescription ?? ""))
  482. return false
  483. }
  484. return true
  485. }
  486. static func handleCodableResponse<T: Codable>(data: Data?, response: URLResponse?,
  487. error: inout Error?,
  488. returnType: T.Type) -> T? {
  489. if !validateResponse(response: response, error: &error) {
  490. return nil
  491. }
  492. guard let data = data else {
  493. return nil
  494. }
  495. do {
  496. return try JSONDecoder().decode(T.self, from: data)
  497. } catch let thrownError {
  498. handleApiParserErorr(thrownError, &error)
  499. return nil
  500. }
  501. }
  502. static func handleResponse<T>(data: Data?, response: URLResponse?,
  503. error: inout Error?, returnType: T.Type) -> T? {
  504. if !validateResponse(response: response, error: &error) {
  505. return nil
  506. }
  507. guard let data = data else {
  508. return nil
  509. }
  510. do {
  511. return try JSONSerialization.jsonObject(
  512. with: data,
  513. options: JSONSerialization.ReadingOptions(rawValue: 0)
  514. ) as? T
  515. } catch let thrownError {
  516. handleApiParserErorr(thrownError, &error)
  517. return nil
  518. }
  519. }
  520. static func handleApiParserErorr(_ thrownError: Error, _ error: inout Error?) {
  521. let description: String = (thrownError as NSError)
  522. .userInfo[NSLocalizedDescriptionKey] as? String ?? "Failed to parse response"
  523. error = thrownError
  524. handleError(error: &error, description: description, code: .ApiErrorParseFailure)
  525. Logger.logError("Tester API - Error deserializing json response")
  526. }
  527. }