Skip to content

Commit 79c9e5d

Browse files
committed
feat: oneshot idempotent post and enhanced delete
1 parent 93450d7 commit 79c9e5d

File tree

4 files changed

+237
-21
lines changed

4 files changed

+237
-21
lines changed

src/main/kotlin/io/sdkman/plugins/Routing.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,11 @@ fun Application.configureRouting(repo: VersionsRepository) {
7474
VersionValidator.validateVersion(version)
7575
.map { validVersion ->
7676
repo.create(validVersion)
77-
call.respond(HttpStatusCode.NoContent)
77+
.map { call.respond(HttpStatusCode.Created) }
78+
.getOrElse { error ->
79+
val errorResponse = ErrorResponse("Database error", error)
80+
call.respond(HttpStatusCode.InternalServerError, errorResponse)
81+
}
7882
}
7983
.getOrElse { error ->
8084
val errorResponse = ErrorResponse("Validation failed", error.message)
@@ -85,8 +89,10 @@ fun Application.configureRouting(repo: VersionsRepository) {
8589
val uniqueVersion = call.receive<UniqueVersion>()
8690
VersionValidator.validateUniqueVersion(uniqueVersion)
8791
.map { validUniqueVersion ->
88-
repo.delete(validUniqueVersion)
89-
call.respond(HttpStatusCode.NoContent)
92+
when (repo.delete(validUniqueVersion)) {
93+
1 -> call.respond(HttpStatusCode.NoContent)
94+
0 -> call.respond(HttpStatusCode.NotFound)
95+
}
9096
}
9197
.getOrElse { error ->
9298
val errorResponse = ErrorResponse("Validation failed", error.message)

src/main/kotlin/io/sdkman/repos/VersionsRepository.kt

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ import arrow.core.Option
44
import arrow.core.firstOrNone
55
import arrow.core.getOrElse
66
import arrow.core.toOption
7+
import arrow.core.Either
8+
import arrow.core.right
79
import io.sdkman.domain.Platform
810
import io.sdkman.domain.UniqueVersion
911
import io.sdkman.domain.Version
1012
import kotlinx.coroutines.Dispatchers
1113
import org.jetbrains.exposed.dao.id.IntIdTable
1214
import org.jetbrains.exposed.sql.*
1315
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
14-
import org.jetbrains.exposed.sql.statements.InsertStatement
1516
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
1617
import org.jetbrains.exposed.sql.transactions.transaction
1718

@@ -77,26 +78,47 @@ class VersionsRepository {
7778
.firstOrNone()
7879
}
7980

80-
fun create(cv: Version): InsertStatement<Number> = transaction {
81-
Versions.insert {
82-
it[candidate] = cv.candidate
83-
it[version] = cv.version
84-
it[vendor] = cv.vendor.getOrNull()
85-
it[platform] = cv.platform.name
86-
it[url] = cv.url
87-
it[visible] = cv.visible
88-
it[md5sum] = cv.md5sum.getOrNull()
89-
it[sha256sum] = cv.sha256sum.getOrNull()
90-
it[sha512sum] = cv.sha512sum.getOrNull()
91-
}
81+
fun create(cv: Version): Either<String, Unit> = transaction {
82+
Versions.select {
83+
(Versions.candidate eq cv.candidate) and
84+
(Versions.version eq cv.version) and
85+
(cv.vendor.fold({ Versions.vendor eq null }, { Versions.vendor eq it })) and
86+
(Versions.platform eq cv.platform.name)
87+
}.firstOrNone()
88+
.map {
89+
Versions.update({
90+
(Versions.candidate eq cv.candidate) and
91+
(Versions.version eq cv.version) and
92+
(cv.vendor.fold({ Versions.vendor eq null }, { Versions.vendor eq it })) and
93+
(Versions.platform eq cv.platform.name)
94+
}) {
95+
it[url] = cv.url
96+
it[visible] = cv.visible
97+
it[md5sum] = cv.md5sum.getOrNull()
98+
it[sha256sum] = cv.sha256sum.getOrNull()
99+
it[sha512sum] = cv.sha512sum.getOrNull()
100+
}.let { Unit.right() }
101+
}.getOrElse {
102+
Versions.insert {
103+
it[candidate] = cv.candidate
104+
it[version] = cv.version
105+
it[vendor] = cv.vendor.getOrNull()
106+
it[platform] = cv.platform.name
107+
it[url] = cv.url
108+
it[visible] = cv.visible
109+
it[md5sum] = cv.md5sum.getOrNull()
110+
it[sha256sum] = cv.sha256sum.getOrNull()
111+
it[sha512sum] = cv.sha512sum.getOrNull()
112+
}.let { Unit.right() }
113+
}
92114
}
93115

94116
fun delete(version: UniqueVersion): Int = transaction {
95117
Versions.deleteWhere {
96118
val baseCondition = (candidate eq version.candidate) and
97119
(this.version eq version.version) and
98120
(platform eq version.platform.name)
99-
121+
100122
version.vendor.fold(
101123
{ baseCondition and (vendor eq null) },
102124
{ vendorValue -> baseCondition and (vendor eq vendorValue) }

src/test/kotlin/io/sdkman/DeleteVersionApiSpec.kt

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ class DeleteVersionApiSpec : ShouldSpec({
182182
}
183183
}
184184

185-
should("return NO_CONTENT when attempting to delete non-existent version") {
185+
should("return NOT_FOUND when attempting to delete non-existent version") {
186186
val requestBody = UniqueVersion(
187187
candidate = "nonexistent",
188188
version = "1.0.0",
@@ -197,7 +197,7 @@ class DeleteVersionApiSpec : ShouldSpec({
197197
setBody(requestBody)
198198
header(HttpHeaders.Authorization, BasicAuthHeader)
199199
}
200-
response.status shouldBe HttpStatusCode.NoContent
200+
response.status shouldBe HttpStatusCode.NotFound
201201
}
202202
}
203203
}
@@ -216,4 +216,61 @@ class DeleteVersionApiSpec : ShouldSpec({
216216
}
217217
}
218218
}
219+
220+
should("return 204 NO_CONTENT for successful deletion of existing version") {
221+
val candidate = "kotlin"
222+
val version = "1.9.0"
223+
val vendor = "jetbrains"
224+
val platform = Platform.LINUX_X64
225+
226+
val requestBody = UniqueVersion(
227+
candidate = candidate,
228+
version = version,
229+
vendor = vendor.some(),
230+
platform = platform,
231+
).toJsonString()
232+
233+
withCleanDatabase {
234+
insertVersions(
235+
Version(
236+
candidate = candidate,
237+
version = version,
238+
platform = platform,
239+
url = "https://kotlin-1.9.0-linux",
240+
visible = true,
241+
vendor = vendor.some(),
242+
sha256sum = "kotlin-hash".some()
243+
)
244+
)
245+
withTestApplication {
246+
val response = client.delete("/versions") {
247+
contentType(ContentType.Application.Json)
248+
setBody(requestBody)
249+
header(HttpHeaders.Authorization, BasicAuthHeader)
250+
}
251+
response.status shouldBe HttpStatusCode.NoContent
252+
}
253+
selectVersion(candidate, version, vendor.some(), platform) shouldBe None
254+
}
255+
}
256+
257+
should("return 404 NOT_FOUND when attempting to delete non-existent version with vendor") {
258+
val requestBody = UniqueVersion(
259+
candidate = "gradle",
260+
version = "8.0.0",
261+
vendor = "gradle-inc".some(),
262+
platform = Platform.UNIVERSAL
263+
).toJsonString()
264+
265+
withCleanDatabase {
266+
withTestApplication {
267+
val response = client.delete("/versions") {
268+
contentType(ContentType.Application.Json)
269+
setBody(requestBody)
270+
header(HttpHeaders.Authorization, BasicAuthHeader)
271+
}
272+
response.status shouldBe HttpStatusCode.NotFound
273+
}
274+
}
275+
}
219276
})

src/test/kotlin/io/sdkman/PostVersionApiSpec.kt

Lines changed: 133 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class PostVersionApiSpec : ShouldSpec({
4040
setBody(requestBody)
4141
header(Authorization, BasicAuthHeader)
4242
}
43-
response.status shouldBe HttpStatusCode.NoContent
43+
response.status shouldBe HttpStatusCode.Created
4444
}
4545
selectVersion(
4646
candidate = version.candidate,
@@ -69,7 +69,7 @@ class PostVersionApiSpec : ShouldSpec({
6969
setBody(requestBody)
7070
header(Authorization, BasicAuthHeader)
7171
}
72-
response.status shouldBe HttpStatusCode.NoContent
72+
response.status shouldBe HttpStatusCode.Created
7373
}
7474
selectVersion(
7575
candidate = version.candidate,
@@ -104,5 +104,136 @@ class PostVersionApiSpec : ShouldSpec({
104104
}
105105
}
106106

107+
//TODO: Move this into a new IdempotentVersionPostApiSpec
108+
should("POST be idempotent - same version posted twice should succeed with 201") {
109+
val version = Version(
110+
candidate = "java",
111+
version = "17.0.2",
112+
platform = Platform.LINUX_X64,
113+
url = "https://java-17.0.2-original",
114+
visible = true,
115+
vendor = "temurin".some(),
116+
md5sum = "original-hash".some()
117+
)
118+
val requestBody = version.toJsonString()
119+
120+
withCleanDatabase {
121+
withTestApplication {
122+
// First POST
123+
val response1 = client.post("/versions") {
124+
contentType(ContentType.Application.Json)
125+
setBody(requestBody)
126+
header(Authorization, BasicAuthHeader)
127+
}
128+
response1.status shouldBe HttpStatusCode.Created
129+
130+
// Second POST (idempotent)
131+
val response2 = client.post("/versions") {
132+
contentType(ContentType.Application.Json)
133+
setBody(requestBody)
134+
header(Authorization, BasicAuthHeader)
135+
}
136+
response2.status shouldBe HttpStatusCode.Created
137+
}
138+
// Verify version exists in database
139+
selectVersion(
140+
candidate = version.candidate,
141+
version = version.version,
142+
vendor = version.vendor,
143+
platform = version.platform
144+
) shouldBe version.some()
145+
}
146+
}
147+
148+
//TODO: Move this into a new IdempotentVersionPostApiSpec
149+
should("POST overwrite existing version with different data") {
150+
val originalVersion = Version(
151+
candidate = "java",
152+
version = "17.0.3",
153+
platform = Platform.LINUX_X64,
154+
url = "https://java-17.0.3-original",
155+
visible = true,
156+
vendor = "temurin".some(),
157+
md5sum = "original-hash".some()
158+
)
159+
160+
val updatedVersion = Version(
161+
candidate = "java",
162+
version = "17.0.3",
163+
platform = Platform.LINUX_X64,
164+
url = "https://java-17.0.3-updated",
165+
visible = false,
166+
vendor = "temurin".some(),
167+
sha256sum = "updated-hash".some()
168+
)
169+
170+
withCleanDatabase {
171+
withTestApplication {
172+
// First POST
173+
val response1 = client.post("/versions") {
174+
contentType(ContentType.Application.Json)
175+
setBody(originalVersion.toJsonString())
176+
header(Authorization, BasicAuthHeader)
177+
}
178+
response1.status shouldBe HttpStatusCode.Created
179+
180+
// Second POST with different data (overwrite)
181+
val response2 = client.post("/versions") {
182+
contentType(ContentType.Application.Json)
183+
setBody(updatedVersion.toJsonString())
184+
header(Authorization, BasicAuthHeader)
185+
}
186+
response2.status shouldBe HttpStatusCode.Created
187+
}
188+
// Verify the updated version is stored
189+
selectVersion(
190+
candidate = updatedVersion.candidate,
191+
version = updatedVersion.version,
192+
vendor = updatedVersion.vendor,
193+
platform = updatedVersion.platform
194+
) shouldBe updatedVersion.some()
195+
}
196+
}
197+
198+
//TODO: Move this into a new IdempotentVersionPostApiSpec
199+
should("POST be idempotent for version without vendor") {
200+
val version = Version(
201+
candidate = "scala",
202+
version = "3.2.0",
203+
platform = Platform.UNIVERSAL,
204+
url = "https://scala-3.2.0",
205+
visible = true,
206+
vendor = None
207+
)
208+
val requestBody = version.toJsonString()
209+
210+
withCleanDatabase {
211+
withTestApplication {
212+
// First POST
213+
val response1 = client.post("/versions") {
214+
contentType(ContentType.Application.Json)
215+
setBody(requestBody)
216+
header(Authorization, BasicAuthHeader)
217+
}
218+
response1.status shouldBe HttpStatusCode.Created
219+
220+
// Second POST (idempotent)
221+
val response2 = client.post("/versions") {
222+
contentType(ContentType.Application.Json)
223+
setBody(requestBody)
224+
header(Authorization, BasicAuthHeader)
225+
}
226+
response2.status shouldBe HttpStatusCode.Created
227+
}
228+
// Verify version exists in database
229+
selectVersion(
230+
candidate = version.candidate,
231+
version = version.version,
232+
vendor = version.vendor,
233+
platform = version.platform
234+
) shouldBe version.some()
235+
}
236+
}
237+
107238
})
108239

0 commit comments

Comments
 (0)