HTTP FileStorage

I have attachemnt in my data model, so I use FileRef but recently I had to allow to have the link in the database but keep the file in an external tools (sharepoint)

So I created new FileStorage HTTP and HTTPS to have keep the url.

import io.jmix.core.FileRef
import io.jmix.core.FileStorage
import org.springframework.core.io.ResourceLoader
import org.springframework.stereotype.Component
import java.io.IOException
import java.io.InputStream
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration

@Component("com_ExternalHttpFileStorage")
class ExternalHttpFileStorage (private val resourceLoader: ResourceLoader) : FileStorage {

    companion object {
        const val NAME = "http"
    }

    private val httpClient: HttpClient = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(5))
        .followRedirects(HttpClient.Redirect.NORMAL)
        .build()

    override fun getStorageName() = NAME

    override fun openStream(reference: FileRef): InputStream {
        try {
            val request = HttpRequest.newBuilder()
                .uri(URI.create(storageName + "://" + reference.path))
                .GET()
                .timeout(Duration.ofSeconds(30))
                .build()

            val response = httpClient.send(
                request,
                HttpResponse.BodyHandlers.ofInputStream()
            )

            if (response.statusCode() in 200..299) {
                return response.body()
            }

            throw IOException("Failed to open remote file. HTTP status: ${response.statusCode()}")

        } catch (ex: Exception) {
            throw RuntimeException("Error opening HTTP resource", ex)
        }
    }

    override fun fileExists(reference: FileRef): Boolean {
        return try {
            val request = HttpRequest.newBuilder()
                .uri(URI.create(reference.path))
                .method("HEAD", HttpRequest.BodyPublishers.noBody())
                .timeout(Duration.ofSeconds(5))
                .build()

            val response = httpClient.send(
                request,
                HttpResponse.BodyHandlers.discarding()
            )

            response.statusCode() in 200..399

        } catch (ex: Exception) {
            false
        }
    }

    override fun removeFile(reference: FileRef) {
        // Nothing to delete (remote resource not managed)
    }

    fun saveStream(uri: URI): FileRef {
        return FileRef (storageName, URI(
            storageName,
            uri.authority,
            uri.path,
            uri.query,
            uri.fragment
        ).toURL().toString().replaceFirst("$storageName://", ""),
            (uri.path + (uri.fragment?.let { "#$it" } ?: "")).substringAfterLast("/"))
    }

    fun saveStream(fileName: String, parameters: MutableMap<String, Any> = mutableMapOf()): FileRef
        = FileRef(
        storageName,
        fileName,
        fileName.substringAfterLast("/")
    )

    override fun saveStream(fileName: String, inputStream: InputStream, parameters: MutableMap<String, Any>): FileRef
        = saveStream(fileName, inputStream, parameters)
}

@Component("com_ExternalHttpsFileStorage")
class ExternalHttpsFileStorage (resourceLoader: ResourceLoader) : ExternalHttpFileStorage(resourceLoader) {
    companion object {
        const val NAME = "https"
    }
    override fun getStorageName() = NAME
}

But I have a problem if the link contains a blank, that I replace by %20 because FileRefConverter use static FileRef.fromString() method and this method decode url then check blank.

import io.jmix.core.FileRef
import io.jmix.core.common.util.URLEncodeUtils
import jakarta.persistence.AttributeConverter
import jakarta.persistence.Converter
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.context.annotation.Primary
import org.springframework.stereotype.Component
import java.net.URI
import java.net.URISyntaxException

@Primary
@Component
@Converter(autoApply = true)
class ComFileRefConverter : AttributeConverter<FileRef, String> {

   val log: Logger = LoggerFactory.getLogger(ComFileRefConverter::class.java)

    override fun convertToDatabaseColumn(attribute: FileRef?): String? {
        return attribute?.toString()?.replace("%20", "")?.replace("#", "%23")
    }

    override fun convertToEntityAttribute(dbData: String?): FileRef? {
        return try {
            when {
                dbData == null -> null
                dbData.startsWith(ExternalHttpFileStorage.NAME+"://") -> fromString(dbData)
                dbData.startsWith(ExternalHttpsFileStorage.NAME+"://") -> fromString(dbData)
                else -> FileRef.fromString(dbData)
            }
        } catch (e: RuntimeException) {
            log.warn("Cannot convert $dbData to FileRef", e)
            null
        }
    }

    fun fromString(fileRefString: String): FileRef {
        val fileRefUri: URI?
        try {
            fileRefUri = URI(fileRefString)
        } catch (e: URISyntaxException) {
            throw IllegalArgumentException("Cannot convert " + fileRefString + " to FileRef", e)
        }
        val storageName = fileRefUri.getScheme()
        val path = if (fileRefUri.getAuthority() == null)
            fileRefUri.getPath()
        else
            fileRefUri.getAuthority() + fileRefUri.getPath()
        val query = fileRefUri.getRawQuery()
        // FIX authorize blank in HTTP
        //require(!StringUtils.isAnyBlank(storageName, path, query)) { "Cannot convert " + fileRefString + " to FileRef" }
        val params: Array<String?> = query.split("&".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
        val nameParamPair: Array<String?> = params[0]!!.split("=".toRegex()).toTypedArray()
        require(nameParamPair.size == 2) { "Cannot convert " + fileRefString + " to FileRef" }
        val fileName = URLEncodeUtils.decodeUtf8(nameParamPair[1])
        if (params.size > 1) {
            val paramsMap: MutableMap<String?, String?> = LinkedHashMap<String?, String?>()
            for (i in 1..<params.size) {
                val paramPair: Array<String?> = params[i]!!.split("=".toRegex()).toTypedArray()
                require(paramPair.size == 2) { "Cannot convert " + fileRefString + " to FileRef" }
                paramsMap.put(paramPair[0], URLEncodeUtils.decodeUtf8(paramPair[1]))
            }
            return FileRef(storageName, path, fileName, paramsMap)
        }
        return FileRef(storageName, path, fileName)
    }
}

And I need to apply the converter on each field

    @Convert(disableConversion = true, converter = ComFileRefConverter::class)
    @Column(name = "FILE_", length = 1024, nullable = false)
    @NotNull
    var file: FileRef? = null

It’s ok in the build & run, but Studio don’t like this converter it in the liquibase step,

image

Hi

Why do you use disableConversion = true parameter for your attributes?
It seems that this parameter disables conversion for attribute and Studio can’t find proper SQL type for FileRef.
Without parameter Liquibase generation works fine. Do you have any runtime issues in runtime if it is not specified?

Also I see autoApply = true on your custom converter, but there is already defined auto apply converter for FileRef in Jmix. That is why you need to specify @Converter for each attribute. So autoApply = true on your custom converter is actually not correct.

Thanks,

I removed disableConversion = true and it’s fixed.
My first try was the autoApply, but it’s useless in this case, I removed it.