package mx.trackermap.TrackerMap.client.infrastructure import com.russhwolf.settings.Settings import com.russhwolf.settings.string import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.* import io.ktor.client.features.json.* import io.ktor.client.features.json.serializer.KotlinxSerializer import io.ktor.client.features.logging.DEFAULT import io.ktor.client.features.logging.LogLevel import io.ktor.client.features.logging.Logger import io.ktor.client.features.logging.Logging import io.ktor.client.request.* import io.ktor.client.request.forms.FormDataContent import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.util.* import mx.trackermap.TrackerMap.client.apis.ACCESS_TOKEN_KEY import mx.trackermap.TrackerMap.client.apis.SERVER_URL_KEY import kotlinx.serialization.json.Json as KotlinJson open class ApiClient( defaultBaseUrl: String = "", ) { companion object { protected const val ApiContentType = "Content-Type" protected const val ApiAccept = "Accept" protected const val ApiJsonMediaType = "application/json" protected const val ApiFormDataMediaType = "multipart/form-data" protected const val ApiFormURLType = "application/x-www-form-urlencoded" protected const val ApiXmlMediaType = "application/xml" val client: HttpClient = HttpClient(CIO) { install(JsonFeature) { serializer = KotlinxSerializer( KotlinJson { ignoreUnknownKeys = true } ) } install(Logging) { logger = Logger.DEFAULT level = LogLevel.ALL } engine { requestTimeout = 20_000 } } val defaultHeaders: Map = mapOf( ApiContentType to ApiJsonMediaType, ApiAccept to ApiJsonMediaType ) val jsonHeaders: Map = mapOf( ApiContentType to ApiJsonMediaType, ApiAccept to ApiJsonMediaType ) } var baseUrl: String = "" var token: String = "" init { val settings = Settings() baseUrl = settings.getString(SERVER_URL_KEY, defaultBaseUrl) token = settings.getString(ACCESS_TOKEN_KEY, "") } protected inline fun fillRequest( requestBuilder: HttpRequestBuilder, content: T, mediaType: String = ApiJsonMediaType ) { when { mediaType == ApiFormDataMediaType && content is Map<*, *> -> { val parametersBuilder = ParametersBuilder() content.forEach { map -> if (map.key is String && map.value is String) { parametersBuilder[map.key as String] = map.value as String } } parametersBuilder.build() requestBuilder.contentType(ContentType.MultiPart.FormData) requestBuilder.body = parametersBuilder } mediaType == ApiJsonMediaType -> { requestBuilder.contentType(ContentType.Application.Json) if (content != null) { requestBuilder.body = content } } mediaType == ApiFormURLType && content is Map<*, *> -> { val parametersBuilder = ParametersBuilder() content.forEach { item -> parametersBuilder[item.key as String] = item.value as String } requestBuilder.body = FormDataContent(parametersBuilder.build()) } mediaType == ApiXmlMediaType -> TODO("xml not currently supported.") // TODO: this should be extended with other serializers else -> TODO("requestBody currently only supports JSON body and File body.") } } protected suspend inline fun request( requestConfig: RequestConfig, body: Any? = null ): ApiInfrastructureResponse { val httpUrl: Url try { httpUrl = Url(baseUrl) } catch (e: URLDecodeException) { throw IllegalStateException("baseUrl is invalid.") } val urlBuilder = URLBuilder(httpUrl) .path("${httpUrl.encodedPath.trimStart('/')}${requestConfig.path}") requestConfig.query.forEach { query -> query.value.forEach { queryValue -> urlBuilder.parameters.append(query.key, queryValue) } } val url = urlBuilder.build() val headers = defaultHeaders + requestConfig.headers if (headers[ApiContentType] ?: "" == "") { throw IllegalStateException("Missing Content-Type header. This is required.") } if (headers[ApiAccept] ?: "" == "") { throw IllegalStateException("Missing Accept header. This is required.") } // TODO: support multiple contentType,accept options here. val contentType = (headers[ApiContentType] as String).substringBefore(";").lowercase() val accept = (headers[ApiAccept] as String).substringBefore(";").lowercase() val request = HttpRequestBuilder() request.url(url) request.accept(ContentType.parse(accept)) when (requestConfig.method) { RequestMethod.DELETE -> { request.method = HttpMethod.Delete } RequestMethod.GET -> { request.method = HttpMethod.Get } RequestMethod.HEAD -> { request.method = HttpMethod.Head } RequestMethod.PATCH -> { request.method = HttpMethod.Patch fillRequest(request, body, contentType) } RequestMethod.PUT -> { request.method = HttpMethod.Put fillRequest(request, body, contentType) } RequestMethod.POST -> { request.method = HttpMethod.Post fillRequest(request, body, contentType) } RequestMethod.OPTIONS -> { request.method = HttpMethod.Options } } if (token.isNotEmpty()) { request.headers["Cookie"] = token } val response: HttpResponse = client.request(request) // TODO: handle specific mapping types. e.g. Map> when (response.status.value) { in 300..399 -> return Redirection( response.status.value, response.headers.toMap() ) in 100..199 -> return Informational( response.status.description, response.status.value, response.headers.toMap() ) in 200..299 -> return Success( response.receive(), response.status.value, response.headers.toMap() ) in 400..499 -> return ClientError( response.receive(), response.status.value, response.headers.toMap() ) else -> return ServerError( null, response.receive(), response.status.value, response.headers.toMap() ) } } }