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

io.kjson.resource.ResourceLoader.kt Maven / Gradle / Ivy

The newest version!
/*
 * @(#) ResourceLoader.kt
 *
 * resource-loader  Resource loading mechanism
 * Copyright (c) 2023, 2024 Peter Wall
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

package io.kjson.resource

import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLConnection
import java.nio.file.FileSystem
import java.nio.file.FileSystems
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.time.Instant

import io.kjson.util.HTTPHeader
import io.kjson.util.Cache
import net.pwall.text.Wildcard

/**
 * The base `ResourceLoader` class.
 *
 * @author  Peter Wall
 */
abstract class ResourceLoader(
    val baseURL: URL = defaultBaseURL(),
) {

    private val connectionFilters = mutableListOf<(URLConnection) -> URLConnection?>()

    open val defaultExtension: String? = null

    open val defaultMIMEType: String? = null

    /**
     * Load the resource, that is, read the external representation of the resource from the `InputStream` in the
     * [ResourceDescriptor] and return the internal form.
     */
    abstract fun load(rd: ResourceDescriptor): T

    /**
     * Get a [Resource], specifying a [File].
     */
    fun resource(resourceFile: File): Resource = Resource(resourceFile.toPath(), resourceFile.toURI().toURL(), this)

    /**
     * Get a [Resource], specifying a [Path].
     */
    fun resource(resourcePath: Path): Resource = Resource(resourcePath, resourcePath.toUri().toURL(), this)

    /**
     * Get a [Resource], specifying a [URL].
     */
    fun resource(resourceURL: URL): Resource = Resource(derivePath(resourceURL), resourceURL, this)

    /**
     * Load the resource identified by the specified [Resource].  This function is open for extension to allow, for
     * example, caching implementations to provide a returned resource bypassing the regular mechanism.
     */
    open fun load(resource: Resource): T = load(openResource(resource))

    /**
     * Load the resource identified by the specified [URL].
     */
    fun load(resourceURL: URL): T = load(resource(resourceURL))

    /**
     * Load the resource identified by an identifier string, which is resolved against the base URL.
     */
    fun load(resourceId: String): T = load(baseURL.resolve(resourceId))

    /**
     * Open a [Resource] for reading.  This function is open for extension to allow non-standard URLs to be mapped to
     * actual resources.  The result of this function is a [ResourceDescriptor], which contains an open `InputStream`
     * and all the metadata known about the resource.
     */
    open fun openResource(resource: Resource): ResourceDescriptor {
        try {
            resource.resourcePath?.let { path ->
                if (!Files.exists(path) || Files.isDirectory(path))
                    throw ResourceNotFoundException(resource.resourceURL)
                return ResourceDescriptor(
                    inputStream = Files.newInputStream(path),
                    url = resource.resourceURL,
                    size = Files.size(path),
                    time = Files.getLastModifiedTime(path).toInstant(),
                )
            }
            var conn: URLConnection = resource.resourceURL.openConnection()
            for (filter in connectionFilters)
                conn = filter(conn) ?: throw ResourceLoaderException("Connection vetoed - ${resource.resourceURL}")
            return if (conn is HttpURLConnection) {
                // TODO think about adding support for ifModifiedSince / ETag
                if (conn.responseCode == HttpURLConnection.HTTP_NOT_FOUND)
                    throw ResourceNotFoundException(resource.resourceURL)
                if (conn.responseCode != HttpURLConnection.HTTP_OK)
                    throw IOException("Error status - ${conn.responseCode} - ${resource.resourceURL}")
                val contentLength = conn.contentLengthLong.takeIf { it >= 0 }
                val lastModified = conn.lastModified.takeIf { it != 0L }?.let { Instant.ofEpochMilli(it) }
                val contentTypeHeader = conn.contentType?.let { HTTPHeader.parse(it) }
                val charsetName: String? = contentTypeHeader?.element()?.parameter("charset")
                val mimeType: String? = contentTypeHeader?.firstElementText()
                ResourceDescriptor(
                    inputStream = conn.inputStream,
                    url = resource.resourceURL,
                    charsetName = charsetName,
                    size = contentLength,
                    time = lastModified,
                    mimeType = mimeType,
                    eTag = conn.getHeaderField("etag"),
                )
            }
            else {
                ResourceDescriptor(
                    inputStream = conn.inputStream,
                    url = resource.resourceURL,
                )
            }
        }
        catch (rle: ResourceLoaderException) {
            throw rle
        }
        catch (e: Exception) {
            throw ResourceLoaderException("Error opening resource ${resource.resourceURL}", e)
        }
    }

    /**
     * Add the default extension to a file name or URL string.
     */
    fun addExtension(s: String): String = when {
        defaultExtension != null && s.indexOf('.', s.lastIndexOf(File.separatorChar) + 1) < 0 -> "$s.$defaultExtension"
        else -> s
    }

    /**
     * Add a connection filter for HTTP connections.
     */
    fun addConnectionFilter(filter: (URLConnection) -> URLConnection?) {
        connectionFilters.add(filter)
    }

    /**
     * Add an authorization filter for HTTP connections.
     */
    fun addAuthorizationFilter(host: String, headerName: String, headerValue: String?) {
        addConnectionFilter(AuthorizationFilter(Wildcard(host), headerName, headerValue))
    }

    /**
     * Add an authorization filter for HTTP connections (specifying a wildcarded hostname).
     */
    fun addAuthorizationFilter(hostWildcard: Wildcard, headerName: String, headerValue: String?) {
        addConnectionFilter(AuthorizationFilter(hostWildcard, headerName, headerValue))
    }

    /**
     * Add a redirection filter for HTTP connections.
     */
    fun addRedirectionFilter(fromHost: String, fromPort: Int = -1, toHost: String, toPort: Int = -1) {
        addConnectionFilter(RedirectionFilter(fromHost, fromPort, toHost, toPort))
    }

    /**
     * Add a redirection filter for prefix-based redirections.
     */
    fun addRedirectionFilter(fromPrefix: String, toPrefix: String) {
        addConnectionFilter(PrefixRedirectionFilter(fromPrefix, toPrefix))
    }

    class AuthorizationFilter(
        private val hostWildcard: Wildcard,
        private val headerName: String,
        private val headerValue: String?,
    ) : (URLConnection) -> URLConnection? {

        override fun invoke(connection: URLConnection): URLConnection {
            if (connection is HttpURLConnection && hostWildcard matches connection.url.host)
                connection.addRequestProperty(headerName, headerValue)
            return connection
        }

    }

    class RedirectionFilter(
        private val fromHost: String,
        private val fromPort: Int = -1,
        private val toHost: String,
        private val toPort: Int = -1,
    ) : (URLConnection) -> URLConnection? {

        override fun invoke(connection: URLConnection): URLConnection {
            val url = connection.url
            return if (connection !is HttpURLConnection || !url.matchesHost(fromHost) || url.port != fromPort)
                connection
            else
                URL(url.protocol, toHost, toPort, url.file).openConnection() as HttpURLConnection
        }

    }

    class PrefixRedirectionFilter(
        private val fromPrefix: String,
        private val toPrefix: String,
    ) : (URLConnection) -> URLConnection? {

        override fun invoke(connection: URLConnection): URLConnection = connection.url.toString().let {
            if (it.startsWith(fromPrefix))
                URL(toPrefix + it.substring(fromPrefix.length)).openConnection()
            else
                connection
        }

    }

    companion object {

        private val defaultFileSystem = FileSystems.getDefault()
        private val fileSystemCache = Cache {
            FileSystems.newFileSystem(Paths.get(adjustWindowsPath(it)), null as ClassLoader?)
        }

        fun derivePath(url: URL): Path? {
            val uri = url.toURI()
            return when (uri.scheme) {
                "jar" -> {
                    val schemeSpecific = uri.schemeSpecificPart
                    var start = schemeSpecific.indexOf(':') // probably stepped past "file:"
                    val bang = schemeSpecific.lastIndexOf('!')
                    if (start !in 0 until bang)
                        return null
                    start++
                    while (start + 2 < bang && schemeSpecific[start] == '/' && schemeSpecific[start + 1] == '/')
                        start++ // implementations vary in their use of multiple slash characters
                    val fs = fileSystemCache[schemeSpecific.substring(start, bang)]
                    fs.getPath(schemeSpecific.substring(bang + 1))
                }
                "file" -> defaultFileSystem.getPath(adjustWindowsPath(uri.path))
                else -> null
            }
        }

        private fun adjustWindowsPath(path: String): String =
                if (File.separatorChar == '\\' && path[0] == '/' && path[2] == ':') path.substring(1) else path

        fun URL.matchesHost(target: String): Boolean = if (target.startsWith("*."))
            host.endsWith(target.substring(1)) || host == target.substring(2)
        else
            host == target

        fun URL.resolve(relativeURL: String) = URL(this, relativeURL)

        fun defaultBaseURL(): URL = File(".").canonicalFile.toURI().toURL()

    }

}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy