ResourceModel.kt

/**
 * This file is part of the pl.wrzasq.cform.
 *
 * @license http://mit-license.org/ The MIT license
 * @copyright 2022 © by Rafał Wrzeszcz - Wrzasq.pl.
 */

package pl.wrzasq.cform.resource.aws.dynamodbitem.model

import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonProperty
import org.json.JSONObject
import software.amazon.awssdk.core.BytesWrapper
import software.amazon.awssdk.core.SdkBytes
import software.amazon.awssdk.services.dynamodb.model.AttributeValue
import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest
import software.amazon.awssdk.services.dynamodb.model.GetItemResponse
import software.amazon.awssdk.services.dynamodb.model.PutItemRequest

/**
 * AWS account resource description.
 */
class ResourceModel {
    /**
     * Target table name.
     */
    @JsonProperty("TableName")
    var tableName: String? = null

    /**
     * Record identifier.
     */
    @JsonProperty("Id")
    var id: String? = null

    /**
     * Object key structure.
     */
    @JsonProperty("Key")
    var key: Map<String, Map<String, Any>>? = null

    /**
     * Additional data fields.
     */
    @JsonProperty("Data")
    var data: Map<String, Map<String, Any>> = emptyMap()

    /**
     * Persistem item properties.
     */
    @JsonProperty("Item")
    var item: Map<String, Map<String, Any>>? = null

    /**
     * Primary identifier of the resource.
     */
    @get:JsonIgnore
    val primaryIdentifier: JSONObject?
        get() {
            val identifier = JSONObject()

            if (tableName != null) {
                identifier.put(IDENTIFIER_KEY_TABLE_NAME, tableName)
            }
            if (id != null) {
                identifier.put(IDENTIFIER_KEY_ID, id)
            }

            // only return the identifier if it can be used, i.e. if all components are present
            return if (identifier.isEmpty) null else identifier
        }

    /**
     * All of the unique identifiers.
     */
    @get:JsonIgnore
    val additionalIdentifiers: List<Any>? = null

    companion object {
        /**
         * CloudFormation resource type.
         */
        @JsonIgnore
        val TYPE_NAME = "WrzasqPl::AWS::DynamoDbItem"

        /**
         * Property path to table name.
         */
        @JsonIgnore
        val IDENTIFIER_KEY_TABLE_NAME = "/properties/TableName"

        /**
         * Property path to record ID.
         */
        @JsonIgnore
        val IDENTIFIER_KEY_ID = "/properties/Id"
    }
}

/**
 * Request to read a resource.
 *
 * @return AWS service request to read resource tgs.
 */
fun ResourceModel.toReadRequest(): GetItemRequest = GetItemRequest.builder()
    .tableName(tableName)
    .key(key?.mapValues { convertToAttribute(it.value) })
    .build()

/**
 * Request to create a resource.
 *
 * @return AWS service request to create a resource.
 */
fun ResourceModel.toCreateRequest(): PutItemRequest = PutItemRequest.builder()
    .tableName(tableName)
    .item(((key ?: emptyMap()) + data).mapValues { convertToAttribute(it.value) })
    .build()

/**
 * Request to delete a resource.
 *
 * @return AWS service request to delete a resource.
 */
fun ResourceModel.toDeleteRequest(): DeleteItemRequest = DeleteItemRequest.builder()
    .tableName(tableName)
    .key(key?.mapValues { convertToAttribute(it.value) })
    .build()

/**
 * Translates resource object from SDK into a resource model.
 *
 * @param describeResponse AWS service describe resource response.
 * @param model Input model for identity properties.
 * @return Resource model.
 */
fun fromReadResponse(
    describeResponse: GetItemResponse,
    model: ResourceModel
) = ResourceModel().apply {
    tableName = model.tableName
    id = model.id
    key = model.key
    data = model.data
    item = describeResponse.item().mapValues { convertToMap(it.value) }
}

private fun convertToCollection(value: Any?) = if (value is Collection<*>) {
    value.map(Any?::toString)
} else {
    listOf(value.toString())
}

private fun convertToAttribute(value: Map<*, Any?>): AttributeValue = value
    .mapKeys { it.key.toString() }
    .entries
    .fold(AttributeValue.builder()) { accumulator, entry ->
        when (entry.key) {
            "S" -> accumulator.s(entry.value.toString())
            "N" -> accumulator.n(entry.value.toString())
            "B" -> accumulator.b(SdkBytes.fromUtf8String(entry.value.toString()))
            "SS" -> accumulator.ss(convertToCollection(entry.value))
            "NS" -> accumulator.ns(convertToCollection(entry.value))
            "BS" -> accumulator.bs(convertToCollection(entry.value).map(SdkBytes::fromUtf8String))
            "M" -> {
                val target = mutableMapOf<String, AttributeValue>()
                val nested = entry.value // used for smart-case

                if (nested is Map<*, *>) {
                    nested.forEach { (key, single) ->
                        if (single is Map<*, *>) {
                            target[key.toString()] = convertToAttribute(single)
                        }
                    }
                }

                accumulator.m(target)
            }
            "L" -> {
                val nested = entry.value // used for smart-case

                if (nested is Collection<*>) {
                    accumulator.l(
                        nested
                            .filterIsInstance<Map<*, *>>()
                            .map(::convertToAttribute)
                    )
                }

                accumulator
            }
            "BOOL" -> accumulator.bool(entry.value.toString().lowercase() != "false")
            "NUL" -> accumulator.nul(true)
            else -> accumulator
        }
    }
        .build()

private fun convertToMap(value: AttributeValue): Map<String, Any> {
    val target = mutableMapOf<String, Any>()

    value.s()?.let { target["S"] = it }
    value.n()?.let { target["N"] = it }
    value.b()?.let { target["B"] = it.asUtf8String() }
    if (value.hasSs()) target["SS"] = value.ss()
    if (value.hasNs()) target["NS"] = value.ns()
    if (value.hasBs()) target["BS"] = value.bs().map(BytesWrapper::asUtf8String)
    if (value.hasM()) target["M"] = value.m().mapValues { convertToMap(it.value) }
    if (value.hasL()) target["L"] = value.l().map(::convertToMap)
    value.bool()?.let { target["BOOL"] = it }
    value.nul()?.let { target["NUL"] = it }

    return target
}