Skip to content

Commit

Permalink
Add ability to read resources as byte arrays
Browse files Browse the repository at this point in the history
Closes #39
  • Loading branch information
goncalossilva committed Mar 23, 2023
1 parent 8ddb572 commit 1fdf37e
Show file tree
Hide file tree
Showing 11 changed files with 130 additions and 12 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Notable changes are documented in this file, whose format follows [Keep a Change

## [Unreleased]

### Added

- Add `Resource.readBytes()` for reading resources as byte arrays.

### Changed

- Throw `FileReadException` when failing to read resources, instead of `RuntimeException`.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Once that's done, a `Resource` class becomes available in all test sources, with
class Resource(path: String) {
fun exists(): Boolean
fun readText(): String
fun readBytes(): ByteArray
}
```

Expand Down
7 changes: 7 additions & 0 deletions resources-library/src/commonMain/kotlin/Resource.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,11 @@ public expect class Resource(path: String) {
* @throws FileReadException when the resource doesn't exist or can't be read.
*/
public fun readText(): String

/**
* Returns the resource's content as a byte array.
*
* @throws FileReadException when the resource doesn't exist or can't be read.
*/
public fun readBytes(): ByteArray
}
16 changes: 16 additions & 0 deletions resources-library/src/darwinMain/kotlin/Resource.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
package com.goncalossilva.resources

import kotlinx.cinterop.ObjCObjectVar
import kotlinx.cinterop.UnsafeNumber
import kotlinx.cinterop.alloc
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.cinterop.readBytes
import kotlinx.cinterop.value
import platform.Foundation.NSBundle
import platform.Foundation.NSData
import platform.Foundation.NSDataReadingUncached
import platform.Foundation.NSError
import platform.Foundation.NSString
import platform.Foundation.NSUTF8StringEncoding
import platform.Foundation.dataWithContentsOfFile
import platform.Foundation.stringWithContentsOfFile

@OptIn(UnsafeNumber::class)
public actual class Resource actual constructor(private val path: String) {
private val absolutePath = NSBundle.mainBundle.pathForResource(
path.substringBeforeLast("."),
Expand All @@ -27,4 +33,14 @@ public actual class Resource actual constructor(private val path: String) {
NSString.stringWithContentsOfFile(absolutePath, NSUTF8StringEncoding, error.ptr)
?: throw FileReadException("$path: Read failed: ${error.value}")
}

public actual fun readBytes(): ByteArray = memScoped {
if (absolutePath == null) {
throw FileReadException("$path: No such file or directory")
}
val error = alloc<ObjCObjectVar<NSError?>>()
val data = NSData.dataWithContentsOfFile(absolutePath, NSDataReadingUncached, error.ptr)
val bytes = data?.bytes ?: throw FileReadException("$path: Read failed: ${error.value}")
bytes.readBytes(data.length.toInt())
}
}
48 changes: 39 additions & 9 deletions resources-library/src/jsMain/kotlin/Resource.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.goncalossilva.resources

import org.khronos.webgl.Int8Array
import org.khronos.webgl.Uint8Array
import org.w3c.xhr.XMLHttpRequest

private external fun require(name: String): dynamic
Expand All @@ -26,6 +28,12 @@ public actual class Resource actual constructor(path: String) {
else -> throw UnsupportedOperationException("Unsupported JS runtime")
}

public actual fun readBytes(): ByteArray = when {
IS_BROWSER -> resourceBrowser.readBytes()
IS_NODE -> resourceNode.readBytes()
else -> throw UnsupportedOperationException("Unsupported JS runtime")
}

private companion object {
@Suppress("MaxLineLength")
private val IS_BROWSER: Boolean = js(
Expand All @@ -40,18 +48,33 @@ public actual class Resource actual constructor(path: String) {
* Browser-based resource implementation.
*/
private class ResourceBrowser(private val path: String) {
private val request = XMLHttpRequest().apply {
private fun request(config: (XMLHttpRequest.() -> Unit)? = null) = XMLHttpRequest().apply {
open("GET", path, false)
config?.invoke(this)
send()
}

@Suppress("MagicNumber")
fun exists(): Boolean = request.status in 200..299
fun exists(): Boolean = request().status in 200..299

fun readText(): String = request().let { request ->
if (exists()) {
request.responseText
} else {
throw FileReadException("$path: Read failed: ${request.statusText}")
}
}

fun readText(): String = if (exists()) {
request.responseText
} else {
throw FileReadException("$path: No such file or directory")
fun readBytes(): ByteArray = request {
// https://web.archive.org/web/20071103070418/http://mgran.blogspot.com/2006/08/downloading-binary-streams-with.html
overrideMimeType("text/plain; charset=x-user-defined")
}.let { request ->
if (exists()) {
val response = request.responseText
ByteArray(response.length) { response[it].code.toUByte().toByte() }
} else {
throw FileReadException("$path: Read failed: ${request.statusText}")
}
}
}

Expand All @@ -64,9 +87,16 @@ public actual class Resource actual constructor(path: String) {
fun exists(): Boolean = fs.existsSync(path) as Boolean

fun readText(): String = runCatching {
fs.readFileSync(path, "utf8")
fs.readFileSync(path, "utf8") as String
}.getOrElse { cause ->
throw FileReadException("$path: No such file or directory", cause)
} as String
throw FileReadException("$path: Read failed", cause)
}

fun readBytes(): ByteArray = runCatching {
val buffer = fs.readFileSync(path).unsafeCast<Uint8Array>()
Int8Array(buffer.buffer, buffer.byteOffset, buffer.length).unsafeCast<ByteArray>()
}.getOrElse { cause ->
throw FileReadException("$path: Read failed", cause)
}
}
}
6 changes: 6 additions & 0 deletions resources-library/src/jvmMain/kotlin/Resource.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,10 @@ public actual class Resource actual constructor(private val path: String) {
}.getOrElse { cause ->
throw FileReadException("$path: No such file or directory", cause)
}

public actual fun readBytes(): ByteArray = runCatching {
file.readBytes()
}.getOrElse { cause ->
throw FileReadException("$path: No such file or directory", cause)
}
}
18 changes: 18 additions & 0 deletions resources-library/src/posixMain/kotlin/Resource.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package com.goncalossilva.resources
import kotlinx.cinterop.ByteVar
import kotlinx.cinterop.allocArray
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.readBytes
import kotlinx.cinterop.toKString
import platform.posix.F_OK
import platform.posix.access
import platform.posix.fclose
import platform.posix.fgets
import platform.posix.fopen
import platform.posix.fread
import platform.posix.posix_errno
import platform.posix.strerror

Expand All @@ -30,6 +32,22 @@ public actual class Resource actual constructor(private val path: String) {
}
}

public actual fun readBytes(): ByteArray = mutableListOf<Byte>().apply {
val file = fopen(path, "r")
?: throw FileReadException("$path: Open failed: ${strerror(posix_errno())}")
try {
memScoped {
val buffer = allocArray<ByteVar>(BUFFER_SIZE)
do {
val size = fread(buffer, 1, BUFFER_SIZE.toULong(), file)
addAll(buffer.readBytes(size.toInt()).asIterable())
} while (size > 0u)
}
} finally {
fclose(file)
}
}.toByteArray()

private companion object {
private const val BUFFER_SIZE = 8 * 1024
}
Expand Down
42 changes: 39 additions & 3 deletions resources-test/src/commonTest/kotlin/ResourceTest.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.goncalossilva.resources

import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
Expand Down Expand Up @@ -31,13 +32,13 @@ class ResourceTest {

@Test
fun readTextRoot() {
assertEquals("{}\n", Resource("src/commonTest/resources/302.json").readText())
assertEquals(JSON, Resource("src/commonTest/resources/302.json").readText())
}

@Test
fun readTextNested() {
assertEquals("{}\n", Resource("src/commonTest/resources/a/302.json").readText())
assertEquals("{}\n", Resource("src/commonTest/resources/a/folder/302.json").readText())
assertEquals(JSON, Resource("src/commonTest/resources/a/302.json").readText())
assertEquals(JSON, Resource("src/commonTest/resources/a/folder/302.json").readText())
}

@Test
Expand All @@ -56,4 +57,39 @@ class ResourceTest {
Resource("src/commonTest/resources/a/folder/404.json").readText()
}
}

@Test
fun readBytesRoot() {
assertContentEquals(GZIP, Resource("src/commonTest/resources/302.gz").readBytes())
}

@Test
fun readBytesNested() {
assertContentEquals(GZIP, Resource("src/commonTest/resources/a/302.gz").readBytes())
assertContentEquals(GZIP, Resource("src/commonTest/resources/a/folder/302.gz").readBytes())
}

@Test
fun readBytesRootThrowsWhenNotFound() {
assertFailsWith(FileReadException::class) {
Resource("src/commonTest/resources/404.gz").readBytes()
}
}

@Test
fun readBytesNestedThrowsWhenNotFound() {
assertFailsWith(FileReadException::class) {
Resource("src/commonTest/resources/a/404.gz").readBytes()
}
assertFailsWith(FileReadException::class) {
Resource("src/commonTest/resources/a/folder/404.gz").readBytes()
}
}

companion object {
const val JSON: String = "{}\n"
val GZIP: ByteArray = byteArrayOf(
31, -117, 8, 0, -82, -122, -31, 91, 2, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0
)
}
}
Binary file added resources-test/src/commonTest/resources/302.gz
Binary file not shown.
Binary file added resources-test/src/commonTest/resources/a/302.gz
Binary file not shown.
Binary file not shown.

0 comments on commit 1fdf37e

Please sign in to comment.