ApiService.swift 21 KB

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