Skip to main content
Glama
ApiClient.kt32.2 kB
package maestro.cli.api import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.github.michaelbull.result.Err import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result import maestro.cli.CliError import maestro.cli.analytics.Analytics import maestro.cli.analytics.TrialStartedEvent import maestro.cli.analytics.TrialStartFailedEvent import maestro.cli.analytics.TrialStartPromptedEvent import maestro.cli.insights.AnalysisDebugFiles import maestro.cli.model.FlowStatus import maestro.cli.runner.resultview.AnsiResultView import maestro.cli.util.CiUtils import maestro.cli.util.EnvUtils import maestro.cli.util.PrintUtils import maestro.cli.view.brightRed import maestro.cli.view.cyan import maestro.cli.view.green import maestro.utils.HttpClient import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.Protocol import okhttp3.Request import okhttp3.RequestBody import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okio.Buffer import okio.BufferedSink import okio.ForwardingSink import okio.IOException import okio.buffer import java.io.File import java.nio.file.Path import java.util.Scanner import kotlin.io.path.absolutePathString import kotlin.io.path.exists import kotlin.time.Duration.Companion.minutes class ApiClient( private val baseUrl: String, ) { private val client = HttpClient.build( name = "ApiClient", readTimeout = 5.minutes, writeTimeout = 5.minutes, protocols = listOf(Protocol.HTTP_1_1), interceptors = listOf(SystemInformationInterceptor()), ) val domain: String get() { val regex = "https?://[^.]+.([a-zA-Z0-9.-]*).*".toRegex() val matchResult = regex.matchEntire(baseUrl) val domain = if (!matchResult?.groups?.get(1)?.value.isNullOrEmpty()) { matchResult?.groups?.get(1)?.value } else { matchResult?.groups?.get(0)?.value } return domain ?: "mobile.dev" } fun sendErrorReport(exception: Exception, commandLine: String) { post<Unit>( path = "/maestro/error", body = mapOf( "exception" to exception, "commandLine" to commandLine ) ) } fun sendScreenReport(maxDepth: Int) { post<Unit>( path = "/maestro/screen", body = mapOf( "maxDepth" to maxDepth ) ) } fun getLatestCliVersion(): CliVersion { val request = Request.Builder() .header("X-FRESH-INSTALL", if (!Analytics.hasRunBefore) "true" else "false") .url("$baseUrl/v2/maestro/version") .get() .build() val response = try { client.newCall(request).execute() } catch (e: IOException) { throw ApiException(statusCode = null) } response.use { if (!response.isSuccessful) { throw ApiException( statusCode = response.code ) } return JSON.readValue(response.body?.bytes(), CliVersion::class.java) } } fun getAuthUrl(port: String): String { return "$baseUrl/v2/maestroLogin/authUrl?port=$port" } fun exchangeToken(code: String): String { val requestBody = code.toRequestBody("text/plain".toMediaType()) val request = Request.Builder() .url("$baseUrl/v2/maestroLogin/exchange") .post(requestBody) .build() try { client.newCall(request).execute().use { response -> val responseBody = response.body?.string() println(responseBody ?: "No response body received") if (!response.isSuccessful) { throw IOException("HTTP ${response.code}: ${response.message}\nBody: $responseBody") } return responseBody ?: throw IOException("Empty response body") } } catch (e: Exception) { throw IOException("${e.message}", e) } } fun isAuthTokenValid(authToken: String): Boolean { val request = Request.Builder() .url("$baseUrl/v2/maestroLogin/valid") .header("Authorization", "Bearer $authToken") .get() .build() client.newCall(request).execute().use { response -> return !(!response.isSuccessful && (response.code == 401 || response.code == 403)) } } private fun getAgent(): String { return CiUtils.getCiProvider() ?: "cli" } fun uploadStatus( authToken: String, uploadId: String, projectId: String?, ): UploadStatus { val baseUrl = "$baseUrl/v2/project/$projectId/upload/$uploadId" val request = Request.Builder() .header("Authorization", "Bearer $authToken") .url(baseUrl) .get() .build() val response = try { client.newCall(request).execute() } catch (e: IOException) { throw ApiException(statusCode = null) } response.use { if (!response.isSuccessful) { throw ApiException( statusCode = response.code ) } return JSON.readValue(response.body?.bytes(), UploadStatus::class.java) } } fun render( screenRecording: File, frames: List<AnsiResultView.Frame>, progressListener: (totalBytes: Long, bytesWritten: Long) -> Unit = { _, _ -> }, ): String { val baseUrl = "https://maestro-record.ngrok.io" val body = MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart( "screenRecording", screenRecording.name, screenRecording.asRequestBody("application/mp4".toMediaType()).observable(progressListener) ) .addFormDataPart("frames", JSON.writeValueAsString(frames)) .build() val request = Request.Builder() .url("$baseUrl/render") .post(body) .build() val response = client.newCall(request).execute().use { response -> if (!response.isSuccessful) { throw CliError("Render request failed (${response.code}): ${response.body?.string()}") } JSON.readValue(response.body?.bytes(), RenderResponse::class.java) } return response.id } fun getRenderState(id: String): RenderState { val baseUrl = "https://maestro-record.ngrok.io" val request = Request.Builder() .url("$baseUrl/v2/render/$id") .get() .build() val response = client.newCall(request).execute().use { response -> if (!response.isSuccessful) { throw CliError("Get render state request failed (${response.code}): ${response.body?.string()}") } JSON.readValue(response.body?.bytes(), RenderState::class.java) } val downloadUrl = if (response.downloadUrl == null) null else "$baseUrl${response.downloadUrl}" return response.copy(downloadUrl = downloadUrl) } fun upload( authToken: String, appFile: Path?, workspaceZip: Path, uploadName: String?, mappingFile: Path?, repoOwner: String?, repoName: String?, branch: String?, commitSha: String?, pullRequestId: String?, env: Map<String, String>? = null, androidApiLevel: Int?, iOSVersion: String? = null, appBinaryId: String? = null, includeTags: List<String> = emptyList(), excludeTags: List<String> = emptyList(), maxRetryCount: Int = 3, completedRetries: Int = 0, disableNotifications: Boolean, deviceLocale: String? = null, progressListener: (totalBytes: Long, bytesWritten: Long) -> Unit = { _, _ -> }, projectId: String, deviceModel: String? = null, deviceOs: String? = null, ): UploadResponse { if (appBinaryId == null && appFile == null) throw CliError("Missing required parameter for option '--app-file' or '--app-binary-id'") if (appFile != null && !appFile.exists()) throw CliError("App file does not exist: ${appFile.absolutePathString()}") if (!workspaceZip.exists()) throw CliError("Workspace zip does not exist: ${workspaceZip.absolutePathString()}") val requestPart = mutableMapOf<String, Any>() if (uploadName != null) { requestPart["benchmarkName"] = uploadName } repoOwner?.let { requestPart["repoOwner"] = it } repoName?.let { requestPart["repoName"] = it } branch?.let { requestPart["branch"] = it } commitSha?.let { requestPart["commitSha"] = it } pullRequestId?.let { requestPart["pullRequestId"] = it } env?.let { requestPart["env"] = it } requestPart["agent"] = getAgent() androidApiLevel?.let { requestPart["androidApiLevel"] = it } iOSVersion?.let { requestPart["iOSVersion"] = it } appBinaryId?.let { requestPart["appBinaryId"] = it } deviceLocale?.let { requestPart["deviceLocale"] = it } requestPart["projectId"] = projectId deviceModel?.let { requestPart["deviceModel"] = it } deviceOs?.let { requestPart["deviceOs"] = it } if (includeTags.isNotEmpty()) requestPart["includeTags"] = includeTags if (excludeTags.isNotEmpty()) requestPart["excludeTags"] = excludeTags if (disableNotifications) requestPart["disableNotifications"] = true val bodyBuilder = MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart( "workspace", "workspace.zip", workspaceZip.toFile().asRequestBody("application/zip".toMediaType()) ) .addFormDataPart("request", JSON.writeValueAsString(requestPart)) if (appFile != null) { bodyBuilder.addFormDataPart( "app_binary", "app.zip", appFile.toFile().asRequestBody("application/zip".toMediaType()).observable(progressListener) ) } if (mappingFile != null) { bodyBuilder.addFormDataPart( "mapping", "mapping.txt", mappingFile.toFile().asRequestBody("text/plain".toMediaType()) ) } val body = bodyBuilder.build() fun retry(message: String, e: Throwable? = null): UploadResponse { if (completedRetries >= maxRetryCount) { e?.printStackTrace() throw CliError(message) } PrintUtils.message("$message, retrying (${completedRetries + 1}/$maxRetryCount)...") Thread.sleep(BASE_RETRY_DELAY_MS + (2000 * completedRetries)) return upload( authToken = authToken, appFile = appFile, workspaceZip = workspaceZip, uploadName = uploadName, mappingFile = mappingFile, repoOwner = repoOwner, repoName = repoName, branch = branch, commitSha = commitSha, pullRequestId = pullRequestId, env = env, androidApiLevel = androidApiLevel, iOSVersion = iOSVersion, includeTags = includeTags, excludeTags = excludeTags, maxRetryCount = maxRetryCount, completedRetries = completedRetries + 1, progressListener = progressListener, appBinaryId = appBinaryId, disableNotifications = disableNotifications, deviceLocale = deviceLocale, projectId = projectId, ) } val url = "$baseUrl/v2/project/$projectId/runMaestroTest" val response = try { val request = Request.Builder() .header("Authorization", "Bearer $authToken") .url(url) .post(body) .build() client.newCall(request).execute() } catch (e: IOException) { return retry("Upload failed due to socket exception", e) } response.use { if (!response.isSuccessful) { val errorMessage = response.body?.string().takeIf { it?.isNotEmpty() == true } ?: "Unknown" if (response.code == 403 && errorMessage.contains( "Your trial has not started yet", ignoreCase = true ) ) { Analytics.trackEvent(TrialStartPromptedEvent()) PrintUtils.info("\n[ERROR] Your trial has not started yet".brightRed()) PrintUtils.info("[INFO] Start your 7-day free trial with no credit card required!".green()) PrintUtils.info("${"[INPUT]".cyan()} Please enter your company name to start the free trial: ") val scanner = Scanner(System.`in`) val companyName = scanner.nextLine().trim() if (companyName.isNotEmpty()) { println("\u001B[33;1m[INFO]\u001B[0m Starting your trial for company: \u001B[36;1m$companyName\u001B[0m...") val isTrialStarted = startTrial(authToken, companyName); if (isTrialStarted) { println("\u001B[32;1m[SUCCESS]\u001B[0m Free trial successfully started! Enjoy your 7-day free trial!\n") return upload( authToken = authToken, appFile = appFile, workspaceZip = workspaceZip, uploadName = uploadName, mappingFile = mappingFile, repoOwner = repoOwner, repoName = repoName, branch = branch, commitSha = commitSha, pullRequestId = pullRequestId, env = env, androidApiLevel = androidApiLevel, iOSVersion = iOSVersion, includeTags = includeTags, excludeTags = excludeTags, maxRetryCount = maxRetryCount, completedRetries = completedRetries + 1, progressListener = progressListener, appBinaryId = appBinaryId, disableNotifications = disableNotifications, deviceLocale = deviceLocale, projectId = projectId ) } else { println("\u001B[31;1m[ERROR]\u001B[0m Failed to start trial. Please check your details and try again.") } } else { println("\u001B[31;1m[ERROR]\u001B[0m Company name is required to start your free trial.") // Track trial start failed event for empty company name Analytics.trackEvent(TrialStartFailedEvent( companyName = "", failureReason = "EMPTY_COMPANY_NAME" )) } } if (response.code >= 500) { return retry("Upload failed with status code ${response.code}: $errorMessage") } else { throw CliError("Upload request failed (${response.code}): $errorMessage") } } val responseBody = JSON.readValue(response.body?.bytes(), Map::class.java) return parseUploadResponse(responseBody) } } private fun startTrial(authToken: String, companyName: String): Boolean { println("Starting your trial...") val url = "$baseUrl/v2/start-trial" val request = StartTrialRequest(companyName, referralSource = "cli") val jsonBody = JSON.writeValueAsString(request).toRequestBody("application/json".toMediaType()) val trialRequest = Request.Builder() .header("Authorization", "Bearer $authToken") .url(url) .post(jsonBody) .build() try { val response = client.newCall(trialRequest).execute() if (response.isSuccessful) { Analytics.trackEvent(TrialStartedEvent(companyName = companyName)) return true } val errorMessage = response.body?.string() ?: "Unknown error" println("\u001B[31m$errorMessage\u001B[0m"); // Track trial start failed event Analytics.trackEvent(TrialStartFailedEvent( companyName = companyName, failureReason = "API_ERROR: $errorMessage" )) return false } catch (e: IOException) { println("\u001B[31;1m[ERROR]\u001B[0m We're experiencing connectivity issues, please try again in sometime, reach out to the slack channel in case if this doesn't work.") // Track trial start failed event Analytics.trackEvent(TrialStartFailedEvent( companyName = companyName, failureReason = "CONNECTIVITY_ERROR: ${e.message}" )) return false } } private fun parseUploadResponse(responseBody: Map<*, *>): UploadResponse { @Suppress("UNCHECKED_CAST") val orgId = responseBody["orgId"] as String val uploadId = responseBody["uploadId"] as String val appId = responseBody["appId"] as String val appBinaryId = responseBody["appBinaryId"] as String val deviceConfigMap = responseBody["deviceConfiguration"] as Map<String, Any> val platform = deviceConfigMap["platform"].toString().uppercase() val deviceConfiguration = DeviceConfiguration( platform = platform, deviceName = deviceConfigMap["deviceName"] as String, orientation = deviceConfigMap["orientation"] as String, osVersion = deviceConfigMap["osVersion"] as String, displayInfo = deviceConfigMap["displayInfo"] as String, deviceLocale = deviceConfigMap["deviceLocale"] as? String ) return UploadResponse( orgId = orgId, uploadId = uploadId, deviceConfiguration = deviceConfiguration, appId = appId, appBinaryId = appBinaryId ) } private inline fun <reified T> post(path: String, body: Any): Result<T, Response> { val bodyBytes = JSON.writeValueAsBytes(body) val request = Request.Builder() .post(bodyBytes.toRequestBody("application/json".toMediaType())) .url("$baseUrl$path") .build() val response = client.newCall(request).execute() if (!response.isSuccessful) return Err(response) if (Unit is T) return Ok(Unit) val parsed = JSON.readValue(response.body?.bytes(), T::class.java) return Ok(parsed) } private fun RequestBody.observable( progressListener: (totalBytes: Long, bytesWritten: Long) -> Unit, ) = object : RequestBody() { override fun contentLength() = this@observable.contentLength() override fun contentType() = this@observable.contentType() override fun writeTo(sink: BufferedSink) { val forwardingSink = object : ForwardingSink(sink) { private var bytesWritten = 0L override fun write(source: Buffer, byteCount: Long) { super.write(source, byteCount) bytesWritten += byteCount progressListener(contentLength(), bytesWritten) } }.buffer() progressListener(contentLength(), 0) this@observable.writeTo(forwardingSink) forwardingSink.flush() } } fun analyze( authToken: String, debugFiles: AnalysisDebugFiles, ): AnalyzeResponse { val mediaType = "application/json; charset=utf-8".toMediaType() val body = JSON.writeValueAsString(debugFiles).toRequestBody(mediaType) val url = "$baseUrl/v2/analyze" val request = Request.Builder() .header("Authorization", "Bearer $authToken") .url(url) .post(body) .build() val response = client.newCall(request).execute() response.use { if (!response.isSuccessful) { val errorMessage = response.body?.string().takeIf { it?.isNotEmpty() == true } ?: "Unknown" throw CliError("Analyze request failed (${response.code}): $errorMessage") } val parsed = JSON.readValue(response.body?.bytes(), AnalyzeResponse::class.java) return parsed; } } fun botMessage(question: String, sessionId: String, authToken: String): List<MessageContent> { val body = JSON.writeValueAsString( MessageRequest( sessionId = sessionId, context = emptyList(), messages = listOf( ContentDetail( type = "text", text = question ) ) ) ) val url = "$baseUrl/v2/bot/message" val request = Request.Builder() .url(url) .header("Authorization", "Bearer $authToken") .post(body.toRequestBody("application/json".toMediaType())) .build() val response = client.newCall(request).execute() response.use { if (!response.isSuccessful) { val errorMessage = response.body?.string().takeIf { it?.isNotEmpty() == true } ?: "Unknown" throw CliError("bot message request failed (${response.code}): $errorMessage") } val data = response.body?.bytes() val parsed = JSON.readValue(data, object : TypeReference<List<MessageContent>>() {}) return parsed; } } fun getUser(authToken: String): UserResponse { val baseUrl = "$baseUrl/v2/maestro-studio/user" val request = Request.Builder() .header("Authorization", "Bearer $authToken") .url(baseUrl) .get() .build() val response = try { client.newCall(request).execute() } catch (e: IOException) { throw ApiException(statusCode = null) } response.use { if (!response.isSuccessful) { throw ApiException( statusCode = response.code ) } val responseBody = response.body?.string() try { val user = JSON.readValue(responseBody, UserResponse::class.java) return user } catch (e: Exception) { throw e } } } fun getOrg(authToken: String): OrgResponse { val baseUrl = "$baseUrl/v2/maestro-studio/org" val request = Request.Builder() .header("Authorization", "Bearer $authToken") .url(baseUrl) .get() .build() val response = try { client.newCall(request).execute() } catch (e: IOException) { throw ApiException(statusCode = null) } response.use { if (!response.isSuccessful) { throw ApiException( statusCode = response.code ) } val responseBody = response.body?.string() try { val user = JSON.readValue(responseBody, OrgResponse::class.java) return user } catch (e: Exception) { throw e } } } fun getOrgs(authToken: String): List<OrgResponse> { val url = "$baseUrl/v2/maestro-studio/orgs" val request = Request.Builder() .header("Authorization", "Bearer $authToken") .url(url) .get() .build() val response = try { client.newCall(request).execute() } catch (e: IOException) { throw ApiException(statusCode = null) } response.use { if (!response.isSuccessful) { throw ApiException( statusCode = response.code ) } val responseBody = response.body?.string() try { val orgs = JSON.readValue(responseBody, object : TypeReference<List<OrgResponse>>() {}) return orgs } catch (e: Exception) { throw e } } } fun switchOrg(authToken: String, orgId: String): String { val url = "$baseUrl/v2/maestro-studio/org/switch" val request = Request.Builder() .header("Authorization", "Bearer $authToken") .url(url) .post(orgId.toRequestBody("text/plain".toMediaType())) .build() val response = try { client.newCall(request).execute() } catch (e: IOException) { throw ApiException(statusCode = null) } response.use { if (!response.isSuccessful) { throw ApiException( statusCode = response.code ) } val responseBody = response.body?.string() try { // The endpoint returns the API key directly as plain text return responseBody ?: throw Exception("No API key in switch org response") } catch (e: Exception) { throw e } } } fun getProjects(authToken: String): List<ProjectResponse> { val url = "$baseUrl/v2/maestro-studio/projects" val request = Request.Builder() .header("Authorization", "Bearer $authToken") .url(url) .get() .build() val response = try { client.newCall(request).execute() } catch (e: IOException) { throw ApiException(statusCode = null) } response.use { if (!response.isSuccessful) { throw ApiException( statusCode = response.code ) } val responseBody = response.body?.string() try { val projects = JSON.readValue(responseBody, object : TypeReference<List<ProjectResponse>>() {}) return projects } catch (e: Exception) { throw e } } } data class ApiException( val statusCode: Int?, ) : Exception("Request failed. Status code: $statusCode") companion object { private const val BASE_RETRY_DELAY_MS = 3000L private val JSON = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) } } data class UploadResponse( val orgId: String, val uploadId: String, val appId: String, val deviceConfiguration: DeviceConfiguration?, val appBinaryId: String?, ) data class DeviceConfiguration( val platform: String, val deviceName: String, val orientation: String, val osVersion: String, val displayInfo: String, val deviceLocale: String? ) @JsonIgnoreProperties(ignoreUnknown = true) data class DeviceInfo( val platform: String, val displayInfo: String, val isDefaultOsVersion: Boolean, val deviceLocale: String, ) @JsonIgnoreProperties(ignoreUnknown = true) data class UploadStatus( val uploadId: String, val status: Status, val completed: Boolean, val totalTime: Long?, val startTime: Long?, val flows: List<FlowResult>, val appPackageId: String?, val wasAppLaunched: Boolean ) { data class FlowResult( val name: String, val status: FlowStatus, val errors: List<String>, val startTime: Long, val totalTime: Long? = null, val cancellationReason: CancellationReason? = null ) enum class Status { PENDING, PREPARING, INSTALLING, RUNNING, SUCCESS, ERROR, CANCELED, WARNING, STOPPED } // These values must match backend monorepo models // in package models.benchmark.BenchmarkCancellationReason enum class CancellationReason { BENCHMARK_DEPENDENCY_FAILED, INFRA_ERROR, OVERLAPPING_BENCHMARK, TIMEOUT, CANCELED_BY_USER, RUN_EXPIRED, } } data class RenderResponse( val id: String, ) data class RenderState( val status: String, val positionInQueue: Int?, val currentTaskProgress: Float?, val error: String?, val downloadUrl: String?, ) data class UserResponse( val id: String, val email: String, val firstName: String?, val lastName: String?, val status: String, val role: String, val workOSOrgId: String, ) { val name: String get() = when { !firstName.isNullOrBlank() && !lastName.isNullOrBlank() -> "$firstName $lastName" !firstName.isNullOrBlank() -> firstName!! !lastName.isNullOrBlank() -> lastName!! else -> email } } data class OrgResponse( val id: String, val name: String, val quota: Map<String, Map<String, Number>>?, val metadata: Map<String, String>?, val workOSOrgId: String?, ) data class ProjectResponse( val id: String, val name: String, ) data class CliVersion( val major: Int, val minor: Int, val patch: Int, ) : Comparable<CliVersion> { override fun compareTo(other: CliVersion): Int { return COMPARATOR.compare(this, other) } override fun toString(): String { return "$major.$minor.$patch" } companion object { private val COMPARATOR = compareBy<CliVersion>({ it.major }, { it.minor }, { it.patch }) fun parse(versionString: String): CliVersion? { val parts = versionString.split('.') if (parts.size != 3) return null val major = parts[0].toIntOrNull() ?: return null val minor = parts[1].toIntOrNull() ?: return null val patch = parts[2].toIntOrNull() ?: return null return CliVersion(major, minor, patch) } } } class SystemInformationInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val newRequest = chain.request().newBuilder() .header("X-UUID", Analytics.uuid) .header("X-VERSION", EnvUtils.getVersion().toString()) .header("X-OS", EnvUtils.OS_NAME) .header("X-OSARCH", EnvUtils.OS_ARCH) .build() return chain.proceed(newRequest) } } data class Insight( val category: String, val reasoning: String, ) data class StartTrialRequest( val companyName: String, val referralSource: String, ) class AnalyzeResponse( val htmlReport: String?, val output: String, val insights: List<Insight> )

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/mobile-dev-inc/Maestro'

If you have feedback or need assistance with the MCP directory API, please join our Discord server