Skip to content

Commit 89305f8

Browse files
committed
feat: support media endpoints
1 parent 836f372 commit 89305f8

File tree

9 files changed

+534
-0
lines changed

9 files changed

+534
-0
lines changed
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package com.ctrlhub.core.media
2+
3+
import com.ctrlhub.core.api.ApiClientException
4+
import com.ctrlhub.core.api.ApiException
5+
import com.ctrlhub.core.api.UnauthorizedException
6+
import com.ctrlhub.core.api.response.PaginatedList
7+
import com.ctrlhub.core.media.request.CreateImagePayload
8+
import com.ctrlhub.core.media.request.CreateImagePayloadAttributes
9+
import com.ctrlhub.core.media.response.Image
10+
import com.ctrlhub.core.router.Router
11+
import io.ktor.client.HttpClient
12+
import io.ktor.client.call.body
13+
import io.ktor.client.plugins.ClientRequestException
14+
import io.ktor.http.HttpStatusCode
15+
import java.io.File
16+
import java.nio.file.Files
17+
import kotlin.io.encoding.Base64
18+
import kotlin.io.encoding.ExperimentalEncodingApi
19+
import kotlin.io.path.createTempFile
20+
21+
/**
22+
* An images router that deals with the images area of the Ctrl Hub API
23+
*/
24+
class ImagesRouter(httpClient: HttpClient): Router(httpClient) {
25+
26+
/**
27+
* Get all image records for a given organisation
28+
*
29+
* @param organisationId String The organisation ID to retrieve all image records for
30+
*
31+
* @return Paginated response of image records
32+
*/
33+
suspend fun all(organisationId: String): PaginatedList<Image> {
34+
return fetchPaginatedJsonApiResources("/v3/orgs/$organisationId/media/images")
35+
}
36+
37+
/**
38+
* Get an image record
39+
*
40+
* @param organisationId String The associated organisation ID to retrieve an image record for
41+
* @param imageId String The image ID to retrieve the record for
42+
*
43+
* @return Matching image record
44+
*/
45+
suspend fun one(organisationId: String, imageId: String): Image {
46+
return fetchJsonApiResource("/v3/orgs/$organisationId/media/images/$imageId")
47+
}
48+
49+
/**
50+
* Get the actual image data
51+
*
52+
* @param organisationId String The associated organisation ID to retrieve an image record for
53+
* @param imageId String The image ID to retrieve the record for
54+
* @param size String The size of the image to retrieve, plus the extension. Format of this is either "original.{extension}" or "{width}.{height}.{extension}"
55+
*
56+
* @return File instance containing the image data
57+
*/
58+
suspend fun proxy(organisationId: String, imageId: String, size: String = "original.jpg"): File {
59+
val endpoint = "/v3/orgs/$organisationId/media/images/$imageId/$size"
60+
61+
return try {
62+
val response = performGet(endpoint)
63+
val bytes = response.body<ByteArray>()
64+
65+
val tempFile = createTempFile(suffix = ".jpg").toFile()
66+
tempFile.writeBytes(bytes)
67+
68+
tempFile
69+
} catch (e: ClientRequestException) {
70+
if (e.response.status == HttpStatusCode.Unauthorized) {
71+
throw UnauthorizedException("Unauthorized action: $endpoint", e.response, e)
72+
}
73+
throw ApiClientException("Request failed: $endpoint", e.response, e)
74+
} catch (e: Exception) {
75+
throw ApiException("Request failed: $endpoint", e)
76+
}
77+
}
78+
79+
/**
80+
* Creates a new image
81+
*
82+
* @param organisationId String The organisation ID to associate this image with
83+
* @param image File The image file to upload
84+
*
85+
* @return Image Image data on successful response
86+
*/
87+
@OptIn(ExperimentalEncodingApi::class)
88+
suspend fun create(organisationId: String, image: File): Image {
89+
val endpoint = "/v3/orgs/$organisationId/media/images"
90+
91+
return try {
92+
val bytes = image.readBytes()
93+
val mimeType = Files.probeContentType(image.toPath())
94+
val base64Data = Base64.encode(bytes)
95+
val dataUri = "data:$mimeType;base64,$base64Data"
96+
97+
val response = performPost(endpoint, body = CreateImagePayload(
98+
attributes = CreateImagePayloadAttributes(
99+
content = dataUri
100+
)
101+
))
102+
103+
fetchJsonApiResource(response)
104+
} catch (e: ClientRequestException) {
105+
if (e.response.status == HttpStatusCode.Unauthorized) {
106+
throw UnauthorizedException("Unauthorized action: $endpoint", e.response, e)
107+
}
108+
throw ApiClientException("Request failed: $endpoint", e.response, e)
109+
} catch (e: Exception) {
110+
throw ApiException("Request failed: $endpoint", e)
111+
}
112+
}
113+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.ctrlhub.core.media.request
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
data class CreateImagePayload(
7+
val type: String = "images",
8+
val attributes: CreateImagePayloadAttributes
9+
)
10+
11+
@Serializable
12+
data class CreateImagePayloadAttributes(
13+
val content: String
14+
)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.ctrlhub.core.media.response
2+
3+
import com.fasterxml.jackson.annotation.JsonCreator
4+
import com.fasterxml.jackson.annotation.JsonProperty
5+
import com.github.jasminb.jsonapi.StringIdHandler
6+
import com.github.jasminb.jsonapi.annotations.Id
7+
import com.github.jasminb.jsonapi.annotations.Type
8+
9+
@Type("images")
10+
data class Image @JsonCreator constructor(
11+
@Id(StringIdHandler::class) val id: String = "",
12+
@JsonProperty("mime_type") val mimeType: String,
13+
@JsonProperty("extension") val extension: String,
14+
@JsonProperty("width") val width: Int,
15+
@JsonProperty("height") val height: Int,
16+
@JsonProperty("bytes") val bytes: Long,
17+
@JsonProperty("dimensions") val dimensions: List<ImageDimensions> = emptyList()
18+
)
19+
20+
data class ImageDimensions(
21+
val width: Int,
22+
val height: Int,
23+
)

src/main/kotlin/com/ctrlhub/core/serializer/LocalDateSerializer.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import kotlinx.serialization.encoding.Encoder
88
import java.time.LocalDate
99
import java.time.format.DateTimeFormatter
1010

11+
@OptIn(ExperimentalSerializationApi::class)
1112
@Serializer(forClass = LocalDate::class)
1213
class LocalDateSerializer : KSerializer<LocalDate?> {
1314
private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package com.ctrlhub.core.media
2+
3+
import com.ctrlhub.core.configureForTest
4+
import com.ctrlhub.core.media.response.Image
5+
import io.ktor.client.HttpClient
6+
import io.ktor.client.engine.mock.MockEngine
7+
import io.ktor.client.engine.mock.respond
8+
import io.ktor.http.HttpHeaders
9+
import io.ktor.http.HttpStatusCode
10+
import io.ktor.http.headersOf
11+
import io.ktor.utils.io.ByteReadChannel
12+
import kotlinx.coroutines.runBlocking
13+
import org.junit.jupiter.api.Assertions.assertNotNull
14+
import java.io.File
15+
import java.nio.file.Files
16+
import java.nio.file.Paths
17+
import kotlin.io.path.Path
18+
import kotlin.io.path.readText
19+
import kotlin.test.Test
20+
import kotlin.test.assertEquals
21+
import kotlin.test.assertIs
22+
import kotlin.test.assertTrue
23+
24+
class ImagesRouterTest {
25+
26+
@Test
27+
fun `test can get all images successfully`() {
28+
val jsonFilePath = Paths.get("src/test/resources/media/all-images-response.json")
29+
val jsonContent = Files.readString(jsonFilePath)
30+
31+
val mockEngine = MockEngine { request ->
32+
respond(
33+
content = ByteReadChannel(jsonContent),
34+
status = HttpStatusCode.OK,
35+
headers = headersOf(HttpHeaders.ContentType, "application/json")
36+
)
37+
}
38+
39+
val imagesRouter = ImagesRouter(httpClient = HttpClient(mockEngine).configureForTest())
40+
41+
runBlocking {
42+
val organisationId = "test-org-id"
43+
val response = imagesRouter.all(organisationId)
44+
45+
assertEquals(3, response.data.size)
46+
47+
val firstImage = response.data.first()
48+
assertNotNull(firstImage.id)
49+
assertEquals(".png", firstImage.extension)
50+
assertEquals(100, firstImage.width)
51+
assertEquals(56, firstImage.height)
52+
}
53+
}
54+
55+
@Test
56+
fun `can retrieve one image`() {
57+
val jsonFilePath = Path("src/test/resources/media/one-image-response.json")
58+
val jsonContent = jsonFilePath.readText()
59+
60+
val mockEngine = MockEngine { request ->
61+
respond(
62+
content = ByteReadChannel(jsonContent),
63+
status = HttpStatusCode.OK,
64+
headers = headersOf(HttpHeaders.ContentType, "application/json")
65+
)
66+
}
67+
68+
val imagesRouter = ImagesRouter(httpClient = HttpClient(mockEngine).configureForTest())
69+
70+
runBlocking {
71+
val organisationId = "123"
72+
val imageId = "456"
73+
val response = imagesRouter.one(organisationId = organisationId, imageId = imageId)
74+
75+
assertIs<Image>(response)
76+
assertNotNull(response.id)
77+
assertNotNull(response.mimeType)
78+
}
79+
}
80+
81+
@Test
82+
fun `can create an image`() {
83+
val jsonFilePath = Path("src/test/resources/media/create-image-response.json")
84+
val jsonContent = jsonFilePath.readText()
85+
86+
val mockEngine = MockEngine { request ->
87+
respond(
88+
content = ByteReadChannel(jsonContent),
89+
status = HttpStatusCode.Created,
90+
headers = headersOf(HttpHeaders.ContentType, "application/json")
91+
)
92+
}
93+
94+
val imagesRouter = ImagesRouter(httpClient = HttpClient(mockEngine).configureForTest())
95+
96+
runBlocking {
97+
val organisationId = "123"
98+
val testImageFile = File("src/test/resources/media/sample-image.png")
99+
val response = imagesRouter.create(organisationId = organisationId, image = testImageFile)
100+
101+
assertIs<Image>(response)
102+
assertNotNull(response.id)
103+
assertNotNull(response.mimeType)
104+
}
105+
}
106+
107+
@Test
108+
fun `can retrieve image file via proxy`() {
109+
// Load a small dummy image file as bytes for response simulation
110+
val imageFilePath = Paths.get("src/test/resources/media/sample-image.png")
111+
val imageBytes = Files.readAllBytes(imageFilePath)
112+
113+
val mockEngine = MockEngine { request ->
114+
respond(
115+
content = ByteReadChannel(imageBytes),
116+
status = HttpStatusCode.OK,
117+
headers = headersOf(HttpHeaders.ContentType, "image/png")
118+
)
119+
}
120+
121+
val imagesRouter = ImagesRouter(httpClient = HttpClient(mockEngine).configureForTest())
122+
123+
runBlocking {
124+
val organisationId = "123"
125+
val imageId = "abc123"
126+
val size = "original.png"
127+
128+
val resultFile = imagesRouter.proxy(organisationId, imageId, size)
129+
130+
// Check the file is not empty and has expected size
131+
assertTrue(resultFile.exists())
132+
assertEquals(imageBytes.size, resultFile.length().toInt())
133+
134+
// Optionally verify the file extension matches requested size (".png" here)
135+
assertTrue(resultFile.name.endsWith(".jpg") || resultFile.name.endsWith(".png")) // adjust if needed
136+
}
137+
}
138+
}

0 commit comments

Comments
 (0)