Skip to content

Commit cc2704e

Browse files
committed
feat: implement sdkman cli download
1 parent 24f428d commit cc2704e

8 files changed

Lines changed: 442 additions & 10 deletions

File tree

src/main/kotlin/io/sdkman/broker/App.kt

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import io.sdkman.broker.application.service.HealthService
2020
import io.sdkman.broker.application.service.HealthServiceImpl
2121
import io.sdkman.broker.application.service.ReleaseService
2222
import io.sdkman.broker.application.service.ReleaseServiceImpl
23+
import io.sdkman.broker.application.service.SdkmanCliDownloadService
24+
import io.sdkman.broker.application.service.SdkmanCliDownloadServiceImpl
2325
import io.sdkman.broker.config.DefaultAppConfig
2426
import kotlinx.serialization.ExperimentalSerializationApi
2527
import kotlinx.serialization.json.Json
@@ -47,10 +49,11 @@ object App {
4749
val healthService = HealthServiceImpl(applicationRepository, postgresHealthRepository)
4850
val releaseService = ReleaseServiceImpl()
4951
val versionService = CandidateDownloadServiceImpl(versionRepository, auditRepository)
52+
val sdkmanCliDownloadService = SdkmanCliDownloadServiceImpl()
5053

5154
// Start Ktor server
5255
embeddedServer(Netty, port = config.serverPort, host = config.serverHost) {
53-
configureApp(healthService, releaseService, versionService)
56+
configureApp(healthService, releaseService, versionService, sdkmanCliDownloadService)
5457
}.start(wait = true)
5558
}
5659
}
@@ -59,7 +62,8 @@ object App {
5962
fun Application.configureApp(
6063
healthService: HealthService,
6164
releaseService: ReleaseService,
62-
candidateDownloadService: CandidateDownloadService
65+
candidateDownloadService: CandidateDownloadService,
66+
sdkmanCliDownloadService: SdkmanCliDownloadService
6367
) {
6468
// Install plugins
6569
install(ContentNegotiation) {
@@ -76,5 +80,5 @@ fun Application.configureApp(
7680

7781
// Configure routes
7882
metaRoutes(healthService, releaseService)
79-
downloadRoutes(candidateDownloadService)
83+
downloadRoutes(candidateDownloadService, sdkmanCliDownloadService)
8084
}

src/main/kotlin/io/sdkman/broker/adapter/primary/rest/DownloadRoutes.kt

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@ import io.ktor.server.response.respondRedirect
1111
import io.ktor.server.routing.get
1212
import io.ktor.server.routing.routing
1313
import io.sdkman.broker.application.service.CandidateDownloadService
14+
import io.sdkman.broker.application.service.SdkmanCliDownloadService
1415
import io.sdkman.broker.domain.model.VersionError
1516

16-
fun Application.downloadRoutes(candidateDownloadService: CandidateDownloadService) {
17+
fun Application.downloadRoutes(
18+
candidateDownloadService: CandidateDownloadService,
19+
sdkmanCliDownloadService: SdkmanCliDownloadService
20+
) {
1721
routing {
1822
get("/download/{candidate}/{version}/{platform}") {
1923
val candidate = call.parameters["candidate"] ?: return@get call.respondBadRequest()
@@ -39,12 +43,29 @@ fun Application.downloadRoutes(candidateDownloadService: CandidateDownloadServic
3943
}
4044
)
4145
}
46+
47+
get("/download/sdkman/{command}/{version}/{platform}") {
48+
val command = call.parameters["command"] ?: return@get call.respondBadRequest()
49+
val version = call.parameters["version"] ?: return@get call.respondBadRequest()
50+
val platform = call.parameters["platform"] ?: return@get call.respondBadRequest()
51+
52+
sdkmanCliDownloadService.downloadSdkmanCli(command, version, platform)
53+
.fold(
54+
{ error -> call.handleVersionError(error) },
55+
{ downloadInfo ->
56+
call.response.header("X-Sdkman-ArchiveType", downloadInfo.archiveType)
57+
call.respondRedirect(downloadInfo.downloadUrl, permanent = false)
58+
}
59+
)
60+
}
4261
}
4362
}
4463

4564
private fun ApplicationCall.handleVersionError(error: VersionError) =
4665
when (error) {
66+
is VersionError.InvalidCommand -> respondWithStatus(HttpStatusCode.BadRequest)
4767
is VersionError.InvalidPlatform -> respondWithStatus(HttpStatusCode.BadRequest)
68+
is VersionError.InvalidVersion -> respondWithStatus(HttpStatusCode.BadRequest)
4869
is VersionError.VersionNotFound -> respondWithStatus(HttpStatusCode.NotFound)
4970
is VersionError.DatabaseError -> respondWithStatus(HttpStatusCode.InternalServerError)
5071
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package io.sdkman.broker.application.service
2+
3+
import arrow.core.Either
4+
import arrow.core.flatMap
5+
import arrow.core.left
6+
import arrow.core.right
7+
import io.sdkman.broker.domain.model.Platform
8+
import io.sdkman.broker.domain.model.VersionError
9+
10+
// TODO: Move the service interface to the domain package as described in hexagonal architecture
11+
interface SdkmanCliDownloadService {
12+
fun downloadSdkmanCli(
13+
command: String,
14+
version: String,
15+
platformCode: String
16+
): Either<VersionError, SdkmanCliDownloadInfo>
17+
}
18+
19+
class SdkmanCliDownloadServiceImpl : SdkmanCliDownloadService {
20+
private val validCommands = setOf("install", "selfupdate")
21+
private val githubReleasesUrl = "https://github.com/sdkman/sdkman-cli/releases/download"
22+
23+
override fun downloadSdkmanCli(
24+
command: String,
25+
version: String,
26+
platformCode: String
27+
): Either<VersionError, SdkmanCliDownloadInfo> =
28+
validateCommand(command)
29+
.flatMap { validateVersion(version) }
30+
.flatMap { validatePlatform(platformCode) }
31+
.map { constructDownloadInfo(version) }
32+
33+
private fun validateCommand(command: String): Either<VersionError, String> =
34+
if (command in validCommands) {
35+
command.right()
36+
} else {
37+
VersionError.InvalidCommand(command).left()
38+
}
39+
40+
private fun validateVersion(version: String): Either<VersionError, String> =
41+
if (version.isNotBlank()) {
42+
version.right()
43+
} else {
44+
VersionError.InvalidVersion(version).left()
45+
}
46+
47+
private fun validatePlatform(platformCode: String): Either<VersionError, Platform> =
48+
Platform.fromCode(platformCode)
49+
.toEither { VersionError.InvalidPlatform(platformCode) }
50+
51+
private fun constructDownloadInfo(version: String): SdkmanCliDownloadInfo {
52+
val tag = if (version.startsWith("latest+")) "latest" else version
53+
val filename = "sdkman-cli-$version.zip"
54+
return SdkmanCliDownloadInfo("$githubReleasesUrl/$tag/$filename")
55+
}
56+
}
57+
58+
data class SdkmanCliDownloadInfo(
59+
val downloadUrl: String,
60+
val archiveType: String = "zip"
61+
)
Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package io.sdkman.broker.domain.model
22

33
sealed class VersionError {
4-
data class VersionNotFound(val candidate: String, val version: String, val platform: String) : VersionError()
4+
data class DatabaseError(val cause: Throwable) : VersionError()
5+
6+
data class InvalidCommand(val command: String) : VersionError()
57

68
data class InvalidPlatform(val platform: String) : VersionError()
79

8-
data class DatabaseError(val cause: Throwable) : VersionError()
10+
data class InvalidVersion(val version: String) : VersionError()
11+
12+
data class VersionNotFound(val candidate: String, val version: String, val platform: String) : VersionError()
913
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
package io.sdkman.broker.acceptance
2+
3+
import io.kotest.core.spec.style.ShouldSpec
4+
import io.kotest.matchers.shouldBe
5+
import io.ktor.client.request.get
6+
import io.ktor.http.HttpHeaders
7+
import io.ktor.http.HttpStatusCode
8+
import io.ktor.server.testing.testApplication
9+
import io.sdkman.broker.support.configureAppForTesting
10+
import org.junit.jupiter.api.Tag
11+
12+
@Tag("acceptance")
13+
class SdkmanCliDownloadAcceptanceSpec : ShouldSpec({
14+
15+
context("SDKMAN CLI download endpoint") {
16+
should("redirect to GitHub release for stable version with install command") {
17+
testApplication {
18+
application {
19+
configureAppForTesting()
20+
}
21+
22+
val client =
23+
createClient {
24+
followRedirects = false
25+
}
26+
27+
// when: client requests SDKMAN CLI stable version
28+
val response = client.get("/download/sdkman/install/5.19.0/linuxx64")
29+
30+
// then: should redirect to GitHub release
31+
response.status shouldBe HttpStatusCode.Found
32+
response.headers[HttpHeaders.Location] shouldBe
33+
"https://github.com/sdkman/sdkman-cli/releases/download/5.19.0/sdkman-cli-5.19.0.zip"
34+
response.headers["X-Sdkman-ArchiveType"] shouldBe "zip"
35+
}
36+
}
37+
38+
should("redirect to GitHub release for stable version with selfupdate command") {
39+
testApplication {
40+
application {
41+
configureAppForTesting()
42+
}
43+
44+
val client =
45+
createClient {
46+
followRedirects = false
47+
}
48+
49+
// when: client requests SDKMAN CLI stable version with selfupdate
50+
val response = client.get("/download/sdkman/selfupdate/5.19.0/darwinarm64")
51+
52+
// then: should redirect to GitHub release
53+
response.status shouldBe HttpStatusCode.Found
54+
response.headers[HttpHeaders.Location] shouldBe
55+
"https://github.com/sdkman/sdkman-cli/releases/download/5.19.0/sdkman-cli-5.19.0.zip"
56+
response.headers["X-Sdkman-ArchiveType"] shouldBe "zip"
57+
}
58+
}
59+
60+
should("redirect to GitHub release for beta version with latest+ prefix") {
61+
testApplication {
62+
application {
63+
configureAppForTesting()
64+
}
65+
66+
val client =
67+
createClient {
68+
followRedirects = false
69+
}
70+
71+
// when: client requests SDKMAN CLI beta version
72+
val response = client.get("/download/sdkman/install/latest+b8d230b/linuxx64")
73+
74+
// then: should redirect to GitHub release with latest tag
75+
response.status shouldBe HttpStatusCode.Found
76+
response.headers[HttpHeaders.Location] shouldBe
77+
"https://github.com/sdkman/sdkman-cli/releases/download/latest/sdkman-cli-latest+b8d230b.zip"
78+
response.headers["X-Sdkman-ArchiveType"] shouldBe "zip"
79+
}
80+
}
81+
82+
should("return 400 Bad Request for invalid command") {
83+
testApplication {
84+
application {
85+
configureAppForTesting()
86+
}
87+
88+
val client =
89+
createClient {
90+
followRedirects = false
91+
}
92+
93+
// when: client requests with invalid command
94+
val response = client.get("/download/sdkman/invalid/5.19.0/linuxx64")
95+
96+
// then: should return bad request
97+
response.status shouldBe HttpStatusCode.BadRequest
98+
}
99+
}
100+
101+
should("return 400 Bad Request for empty version") {
102+
testApplication {
103+
application {
104+
configureAppForTesting()
105+
}
106+
107+
val client =
108+
createClient {
109+
followRedirects = false
110+
}
111+
112+
// when: client requests with blank version (space)
113+
val response = client.get("/download/sdkman/install/%20/linuxx64")
114+
115+
// then: should return bad request
116+
response.status shouldBe HttpStatusCode.BadRequest
117+
}
118+
}
119+
120+
should("return 400 Bad Request for invalid platform") {
121+
testApplication {
122+
application {
123+
configureAppForTesting()
124+
}
125+
126+
val client =
127+
createClient {
128+
followRedirects = false
129+
}
130+
131+
// when: client requests with invalid platform
132+
val response = client.get("/download/sdkman/install/5.19.0/invalidplatform")
133+
134+
// then: should return bad request
135+
response.status shouldBe HttpStatusCode.BadRequest
136+
}
137+
}
138+
139+
should("work with different valid platforms (platform validation only)") {
140+
testApplication {
141+
application {
142+
configureAppForTesting()
143+
}
144+
145+
val client =
146+
createClient {
147+
followRedirects = false
148+
}
149+
150+
// when: client requests with different valid platforms
151+
val platforms = listOf("linuxx64", "linuxarm64", "darwinx64", "darwinarm64", "windowsx64")
152+
153+
platforms.forEach { platform ->
154+
val response = client.get("/download/sdkman/install/5.19.0/$platform")
155+
156+
// then: should redirect successfully for all valid platforms
157+
response.status shouldBe HttpStatusCode.Found
158+
response.headers[HttpHeaders.Location] shouldBe
159+
"https://github.com/sdkman/sdkman-cli/releases/download/5.19.0/sdkman-cli-5.19.0.zip"
160+
response.headers["X-Sdkman-ArchiveType"] shouldBe "zip"
161+
}
162+
}
163+
}
164+
165+
should("handle case sensitivity correctly for commands") {
166+
testApplication {
167+
application {
168+
configureAppForTesting()
169+
}
170+
171+
val client =
172+
createClient {
173+
followRedirects = false
174+
}
175+
176+
// when: client requests with uppercase command
177+
val response = client.get("/download/sdkman/INSTALL/5.19.0/linuxx64")
178+
179+
// then: should return bad request (case sensitive)
180+
response.status shouldBe HttpStatusCode.BadRequest
181+
}
182+
}
183+
184+
should("verify that platform parameter is validated but not used in URL construction") {
185+
testApplication {
186+
application {
187+
configureAppForTesting()
188+
}
189+
190+
val client =
191+
createClient {
192+
followRedirects = false
193+
}
194+
195+
// when: client requests with different valid platforms
196+
val response1 = client.get("/download/sdkman/install/5.19.0/linuxx64")
197+
val response2 = client.get("/download/sdkman/install/5.19.0/windowsx64")
198+
199+
// then: both should redirect to same URL (platform-agnostic)
200+
response1.status shouldBe HttpStatusCode.Found
201+
response2.status shouldBe HttpStatusCode.Found
202+
response1.headers[HttpHeaders.Location] shouldBe response2.headers[HttpHeaders.Location]
203+
}
204+
}
205+
}
206+
})

0 commit comments

Comments
 (0)