All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.ecwid.apiclient.v3.ApiClientHelper.kt Maven / Gradle / Ivy

There is a newer version: 0.347.0
Show newest version
package com.ecwid.apiclient.v3

import com.ecwid.apiclient.v3.config.*
import com.ecwid.apiclient.v3.dto.ApiRequest
import com.ecwid.apiclient.v3.dto.common.EcwidApiError
import com.ecwid.apiclient.v3.dto.common.PartialResult
import com.ecwid.apiclient.v3.dto.product.result.FetchedProduct.ProductOption
import com.ecwid.apiclient.v3.dto.report.result.FetchedReportResponse
import com.ecwid.apiclient.v3.dto.report.result.FetchedReportResponse.FetchedAdditionalData.*
import com.ecwid.apiclient.v3.dto.report.result.FetchedReportResponse.FetchedAdditionalData.AdditionalDataType.*
import com.ecwid.apiclient.v3.exception.EcwidApiException
import com.ecwid.apiclient.v3.exception.JsonDeserializationException
import com.ecwid.apiclient.v3.httptransport.*
import com.ecwid.apiclient.v3.impl.*
import com.ecwid.apiclient.v3.jsontransformer.JsonTransformer
import com.ecwid.apiclient.v3.jsontransformer.JsonTransformerProvider
import com.ecwid.apiclient.v3.jsontransformer.PolymorphicType
import com.ecwid.apiclient.v3.metric.RequestSizeMetric
import com.ecwid.apiclient.v3.metric.RequestTimeMetric
import com.ecwid.apiclient.v3.metric.ResponseSizeMetric
import com.ecwid.apiclient.v3.responsefields.ResponseFields
import com.ecwid.apiclient.v3.responsefields.responseFieldsOf
import com.ecwid.apiclient.v3.util.buildEndpointPath
import com.ecwid.apiclient.v3.util.createSecurePatterns
import com.ecwid.apiclient.v3.util.maskLogString
import java.net.URI
import java.util.*
import java.util.logging.Level
import java.util.logging.Logger
import kotlin.random.Random
import kotlin.reflect.KClass

private const val APP_CLIENT_ID_HEADER_NAME = "X-Ecwid-App-Client-Id"
private const val APP_CLIENT_SECRET_HEADER_NAME = "X-Ecwid-App-Secret-Key"
private const val RESPONSE_FIELDS_PARAM_NAME = "responseFields"
private const val REQUEST_ID_HEADER_NAME = "X-Ecwid-Api-Request-Id"

private const val REQUEST_ID_LENGTH = 8
private val REQUEST_ID_CHARACTERS = ('a'..'z') + ('A'..'Z') + ('0'..'9')

class ApiClientHelper private constructor(
	private val apiServerDomain: ApiServerDomain,
	private val credentials: ApiCredentials,
	private val loggingSettings: LoggingSettings,
	val httpTransport: HttpTransport,
	val jsonTransformer: JsonTransformer
) {

	private val log = Logger.getLogger(this::class.qualifiedName)

	constructor(
		apiServerDomain: ApiServerDomain,
		storeCredentials: ApiStoreCredentials,
		loggingSettings: LoggingSettings,
		httpTransport: HttpTransport,
		jsonTransformerProvider: JsonTransformerProvider
	) : this(
		apiServerDomain = apiServerDomain,
		credentials = storeCredentials,
		loggingSettings = loggingSettings,
		httpTransport = httpTransport,
		jsonTransformer = jsonTransformerProvider.build(createPolymorphicTypeList())
	)

	constructor(
		apiServerDomain: ApiServerDomain,
		credentials: ApiCredentials,
		loggingSettings: LoggingSettings,
		httpTransport: HttpTransport,
		jsonTransformerProvider: JsonTransformerProvider
	) : this(
		apiServerDomain = apiServerDomain,
		credentials = credentials,
		loggingSettings = loggingSettings,
		httpTransport = httpTransport,
		jsonTransformer = jsonTransformerProvider.build(createPolymorphicTypeList())
	)

	@PublishedApi
	internal fun  makeRequestInt(request: ApiRequest, responseParser: ResponseParser, responseFieldsOverride: ResponseFields? = null): V {
		val requestId = generateRequestId()

		val requestInfo = request.toRequestInfo()
		val originalHttpRequest = requestInfo.toHttpRequest(requestId, responseFieldsOverride)
		val (httpRequest, requestSizeCounter) = RequestSizeMetric.makeHttpRequestCountable(originalHttpRequest)
		logRequestIfNeeded(requestId, httpRequest, requestInfo.httpBody)

		val startTime = Date().time
		val httpResponse = httpTransport.makeHttpRequest(httpRequest)
		val requestTimeMs = Date().time - startTime

		RequestTimeMetric.observeRequest(
			apiRequest = request,
			requestInfo = requestInfo,
			requestTimeMs = requestTimeMs,
			httpResponse = httpResponse,
		)
		RequestSizeMetric.observeRequest(
			apiRequest = request,
			requestInfo = requestInfo,
			size = requestSizeCounter,
			httpResponse = httpResponse,
		)
		ResponseSizeMetric.observeResponse(
			apiRequest = request,
			requestInfo = requestInfo,
			httpResponse = httpResponse,
		)

		return processHttpResponse(
			httpResponse = httpResponse,
			requestId = requestId,
			requestTime = requestTimeMs,
			responseParser = responseParser
		)
	}

	inline fun  makeObjectResultRequest(request: ApiRequest): V {
		return makeRequestInt(request, ObjectResponseParser(jsonTransformer, V::class.java))
	}

	fun > makeObjectPartialResultRequest(request: ApiRequest, resultClass: KClass): V {
		return makeRequestInt(
			request = request,
			responseParser = ObjectResponseParser(jsonTransformer, resultClass.java),
			responseFieldsOverride = responseFieldsOf(resultClass)
		)
	}

	@Suppress("UNCHECKED_CAST")
	fun > makeObjectPartialResultRequestList(request: ApiRequest, resultClass: KClass): List {
		return makeRequestInt(
			request = request,
			responseParser = ObjectResponseParser(jsonTransformer, resultClass.java.arrayType() as Class>),
			responseFieldsOverride = responseFieldsOf(resultClass)
		).toList()
	}

	@Suppress("unused")
	fun , VExt : PartialResult<*>> makeObjectPartialResultWithExtRequest(
		request: ApiRequest,
		baseResultClass: KClass,
		extResultClass: KClass,
	): ParsedResponseWithExt {
		return makeRequestInt(
			request = request,
			responseParser = ObjectWithExtResponseParser(jsonTransformer, baseResultClass.java, extResultClass.java),
			responseFieldsOverride = responseFieldsOf(baseResultClass) + responseFieldsOf(extResultClass),
		)
	}

	@Suppress("unused")
	inline fun  makeObjectWithExtResultRequest(request: ApiRequest): ParsedResponseWithExt {
		return makeRequestInt(
			request,
			ObjectWithExtResponseParser(jsonTransformer, VBase::class.java, VExt::class.java)
		)
	}

	@Suppress("NOTHING_TO_INLINE")
	inline fun makeByteArrayResultRequest(request: ApiRequest): ByteArray {
		return makeRequestInt(request, ByteArrayResponseParser())
	}

	@Suppress("NOTHING_TO_INLINE")
	inline fun makeStringResultRequest(request: ApiRequest): String {
		return makeRequestInt(request, StringResponseParser())
	}

	@PublishedApi
	internal fun  processHttpResponse(
		httpResponse: HttpResponse,
		requestId: String,
		requestTime: Long,
		responseParser: ResponseParser
	): V {
		val responseBytes = httpResponse.responseBytes
		return when (httpResponse) {
			is HttpResponse.Success -> {
				try {
					val parsedResponse = responseParser.parse(responseBytes)
					val getResponseBodyForLog = { responseParser.getLogString(responseBytes) }
					logSuccessfulResponseIfNeeded(requestId, requestTime, getResponseBodyForLog)
					parsedResponse
				} catch (e: JsonDeserializationException) {
					logCannotParseResponseError(requestId, requestTime, responseBytes.asString(), e)
					throw EcwidApiException(
						message = e.message,
						cause = e
					)
				}
			}

			is HttpResponse.Error -> {
				try {
					val responseBody = responseBytes.asString()
					logErrorResponseIfNeeded(requestId, requestTime, httpResponse.statusCode, responseBody)
					val ecwidError = if (responseBody.isNotBlank()) {
						jsonTransformer.deserializeOrNull(responseBody, EcwidApiError::class.java)
					} else {
						null
					}
					throw EcwidApiException(
						statusCode = httpResponse.statusCode,
						reasonPhrase = httpResponse.reasonPhrase,
						code = ecwidError?.errorCode,
						message = ecwidError?.errorMessage
					)
				} catch (e: JsonDeserializationException) {
					throw EcwidApiException(
						statusCode = httpResponse.statusCode,
						reasonPhrase = httpResponse.reasonPhrase,
						message = e.message,
						cause = e
					)
				}
			}

			is HttpResponse.TransportError -> {
				logTransportErrorResponseIfNeeded(
					requestId,
					requestTime,
					httpResponse.exception.message,
					httpResponse.exception
				)
				throw EcwidApiException(
					message = httpResponse.exception.message,
					cause = httpResponse.exception
				)
			}
		}
	}

	@PublishedApi
	internal fun logRequestIfNeeded(requestId: String, httpRequest: HttpRequest, httpBody: HttpBody) {
		if (!loggingSettings.logRequest) {
			return
		}

		val securePatterns = createSecurePatterns()
		logEntry(
			prefix = "Request",
			logLevel = Level.INFO,
			requestId = requestId,
			maxSectionLength = loggingSettings.maxLogSectionLength,
			sections = mutableListOf().apply {
				add("${httpRequest.method} ${httpRequest.uri}")
				if (loggingSettings.logRequestParams) {
					add(httpRequest.params.dumpToString().maskLogString(securePatterns))
				}
				if (loggingSettings.logRequestBody) {
					httpBody.asString()?.maskLogString(securePatterns)?.let(::add)
				}
			},
		)
	}

	@PublishedApi
	internal fun RequestInfo.toHttpRequest(requestId: String, responseFieldsOverride: ResponseFields?): HttpRequest {
		val uri = createApiEndpointUri(pathSegments)
		val params = if (responseFieldsOverride != null) params.withResponseFieldsParam(responseFieldsOverride) else params
		val headers = headers.withRequestId(requestId).withCredentials(credentials)

		return when (method) {
			HttpMethod.GET -> HttpRequest.HttpGetRequest(
				uri = uri,
				params = params,
				headers = headers,
			)

			HttpMethod.POST -> HttpRequest.HttpPostRequest(
				uri = uri,
				params = params,
				transportHttpBody = httpBody.prepare(jsonTransformer),
				headers = headers,
			)

			HttpMethod.PUT -> HttpRequest.HttpPutRequest(
				uri = uri,
				params = params,
				transportHttpBody = httpBody.prepare(jsonTransformer),
				headers = headers,
			)

			HttpMethod.DELETE -> HttpRequest.HttpDeleteRequest(
				uri = uri,
				params = params,
				headers = headers,
			)
		}
	}

	@PublishedApi
	internal fun createApiEndpointUri(pathSegments: List): String {
		val uri = URI(
			"https",
			null,
			apiServerDomain.host,
			if (apiServerDomain.securePort == DEFAULT_HTTPS_PORT) -1 else apiServerDomain.securePort,
			null,
			null,
			null
		)
		val encodedPath = buildBaseEndpointPath(credentials) + "/" + buildEndpointPath(pathSegments)
		return uri.toString() + encodedPath
	}

	private fun logSuccessfulResponseIfNeeded(requestId: String, requestTime: Long, getResponseBody: () -> String) {
		if (!loggingSettings.logResponse) {
			return
		}

		val securePatterns = createSecurePatterns()
		logEntry(
			prefix = "Response",
			logLevel = Level.INFO,
			requestId = requestId,
			maxSectionLength = loggingSettings.maxLogSectionLength,
			sections = mutableListOf().apply {
				add("OK")
				add("$requestTime ms")
				if (loggingSettings.logSuccessfulResponseBody) {
					add(getResponseBody.invoke().maskLogString(securePatterns))
				}
			}
		)
	}

	private fun logErrorResponseIfNeeded(
		requestId: String,
		requestTime: Long,
		httpStatusCode: Int,
		responseBody: String
	) {
		if (!loggingSettings.logResponse) {
			return
		}

		logEntry(
			prefix = "Response",
			logLevel = Level.INFO,
			requestId = requestId,
			maxSectionLength = loggingSettings.maxLogSectionLength,
			sections = mutableListOf().apply {
				add("ERR $httpStatusCode")
				add("$requestTime ms")
				if (loggingSettings.logFailedResponseBody) {
					add(responseBody)
				}
			}
		)
	}

	private fun logTransportErrorResponseIfNeeded(
		requestId: String,
		requestTime: Long,
		errorMessage: String?,
		exception: Exception
	) {
		if (!loggingSettings.logResponse) {
			return
		}

		logEntry(
			prefix = "Response",
			logLevel = Level.WARNING,
			requestId = requestId,
			maxSectionLength = loggingSettings.maxLogSectionLength,
			sections = mutableListOf().apply {
				add("ERR")
				add("$requestTime ms")
				if (errorMessage != null) {
					add(errorMessage)
				}
			},
			exception = exception
		)
	}

	private fun logCannotParseResponseError(
		requestId: String,
		requestTime: Long,
		responseBody: String,
		exception: Exception
	) {
		if (!loggingSettings.logResponse) {
			return
		}

		logEntry(
			prefix = "Response",
			logLevel = Level.WARNING,
			requestId = requestId,
			maxSectionLength = loggingSettings.maxLogSectionLength,
			sections = mutableListOf().apply {
				add("ERR")
				add("$requestTime ms")
				if (loggingSettings.logFailedResponseBody) {
					add(responseBody)
				}
			},
			exception = exception
		)
	}

	private fun logEntry(
		prefix: String,
		logLevel: Level?,
		requestId: String,
		maxSectionLength: Int,
		sections: List,
		exception: Exception? = null,
	) {
		val sectionsString = sections.joinToString(separator = "; ") {
			if (it.length > maxSectionLength) it.take(maxSectionLength) + "…" else it
		}
		val logMessage = "$prefix [$requestId]: $sectionsString"
		if (exception != null) {
			log.log(logLevel, logMessage, exception)
		} else {
			log.log(logLevel, logMessage)
		}
	}

	private fun buildBaseEndpointPath(credentials: ApiCredentials) = when (credentials) {
		is ApiStoreCredentials -> "/api/v3/${credentials.storeId}"
		is ApiAppCredentials -> "/api/v3"
	}
}

@PublishedApi
internal fun generateRequestId(): String {
	return (0 until REQUEST_ID_LENGTH)
		.map { Random.nextInt(0, REQUEST_ID_CHARACTERS.size) }
		.map(REQUEST_ID_CHARACTERS::get)
		.joinToString("")
}

@PublishedApi
internal fun Map.withCredentials(credentials: ApiCredentials) = when (credentials) {
	is ApiStoreCredentials -> this.withApiTokenHeader(credentials.apiToken)
	is ApiAppCredentials -> withAppCredentialsHeaders(credentials)
}

internal fun Map.withResponseFieldsParam(responseFields: ResponseFields): Map {
	return if (responseFields.isAll()) {
		this - RESPONSE_FIELDS_PARAM_NAME
	} else {
		this + (RESPONSE_FIELDS_PARAM_NAME to responseFields.toHttpParameter())
	}
}

@PublishedApi
internal fun Map.withApiTokenHeader(apiToken: String): Map {
	return toMutableMap()
		.apply {
			put("Authorization", "Bearer $apiToken")
		}
		.toMap()
}

@PublishedApi
internal fun Map.withAppCredentialsHeaders(appCredentials: ApiAppCredentials): Map {
	return toMutableMap()
		.apply {
			put(APP_CLIENT_ID_HEADER_NAME, appCredentials.clientId)
			put(APP_CLIENT_SECRET_HEADER_NAME, appCredentials.clientSecret)
		}
		.toMap()
}

private fun Map.withRequestId(requestId: String): Map {
	return toMutableMap()
		.apply {
			put(REQUEST_ID_HEADER_NAME, requestId)
		}
		.toMap()
}

private fun Map.dumpToString(): String {
	return this
		.map { (key, value) -> "$key=$value" }
		.joinToString(separator = ", ")
}

fun ByteArray.asString() = String(this, Charsets.UTF_8)

@PublishedApi
internal fun HttpBody.prepare(jsonTransformer: JsonTransformer): TransportHttpBody {
	return when (this) {
		HttpBody.EmptyBody -> TransportHttpBody.EmptyBody
		is HttpBody.JsonBody -> {
			val bodyAsBytes = jsonTransformer.serialize(obj, objExt).toByteArray()
			TransportHttpBody.ByteArrayBody(bodyAsBytes, MIME_TYPE_APPLICATION_JSON)
		}

		is HttpBody.ByteArrayBody -> {
			TransportHttpBody.ByteArrayBody(bytes, mimeType)
		}

		is HttpBody.InputStreamBody -> {
			TransportHttpBody.InputStreamBody(stream, mimeType)
		}

		is HttpBody.LocalFileBody -> {
			TransportHttpBody.LocalFileBody(file, mimeType)
		}
	}
}

fun createPolymorphicTypeList(): List> {
	return listOf(
		createProductOptionsPolymorphicType(),
		createAdditionalDataPolymorphicType()
	)
}

private fun createProductOptionsPolymorphicType(): PolymorphicType {
	return PolymorphicType(
		rootClass = ProductOption::class.java,
		jsonFieldName = "type",
		childClasses = mapOf(
			"select" to ProductOption.SelectOption::class.java,
			"size" to ProductOption.SizeOption::class.java,
			"radio" to ProductOption.RadioOption::class.java,
			"checkbox" to ProductOption.CheckboxOption::class.java,
			"textfield" to ProductOption.TextFieldOption::class.java,
			"textarea" to ProductOption.TextAreaOption::class.java,
			"date" to ProductOption.DateOption::class.java,
			"files" to ProductOption.FilesOption::class.java
		)
	)
}

private fun createAdditionalDataPolymorphicType(): PolymorphicType {
	return PolymorphicType(
		rootClass = FetchedReportResponse.FetchedAdditionalData::class.java,
		jsonFieldName = "type",
		childClasses = mapOf(
			UTM.name.lowercase() to AdditionalUtmData::class.java,
			ORDERS.name.lowercase() to AdditionalOrdersData::class.java,
			CUSTOMERS.name.lowercase() to AdditionalCustomerData::class.java,
			INVENTORY_PRODUCT.name.lowercase() to AdditionalInventoryData::class.java,
			PRODUCT.name.lowercase() to AdditionalProductData::class.java,
			COUPONS.name.lowercase() to AdditionalCouponData::class.java,
			ABANDONED_CARTS.name.lowercase() to AdditionalAbandonedCartData::class.java,
			SHIPPING.name.lowercase() to AdditionalShippingData::class.java,
			LANDING.name.lowercase() to AdditionalLandingData::class.java,
			CATEGORY.name.lowercase() to AdditionalCategoryData::class.java,
		)
	)
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy