ApiService.swift 21 KB

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