Skip to content

Commit 48aa04b

Browse files
authored
Merge pull request #714 from modelix/feature/delete-branches
feat(model-server): implement deleting branches via REST API
2 parents 854f72c + 0ea0ee5 commit 48aa04b

File tree

6 files changed

+220
-20
lines changed

6 files changed

+220
-20
lines changed

model-client/src/commonMain/kotlin/org/modelix/model/client2/IModelClientV2.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ interface IModelClientV2 {
4444
suspend fun deleteRepository(repository: RepositoryId): Boolean
4545
suspend fun listBranches(repository: RepositoryId): List<BranchReference>
4646

47+
/**
48+
* Deletes a branch from a repository if it exists.
49+
*
50+
* @param branch the branch to delete
51+
* @return true if the branch existed and could be deleted, else false.
52+
*/
53+
suspend fun deleteBranch(branch: BranchReference): Boolean
54+
4755
@Deprecated("repository ID is required for permission checks")
4856
@DeprecationInfo("3.7.0", "May be removed with the next major release. Also remove the endpoint from the model-server.")
4957
suspend fun loadVersion(versionHash: String, baseVersion: IVersion?): IVersion

model-client/src/commonMain/kotlin/org/modelix/model/client2/ModelClientV2.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import io.ktor.client.plugins.ResponseException
2222
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
2323
import io.ktor.client.plugins.expectSuccess
2424
import io.ktor.client.request.HttpRequestBuilder
25+
import io.ktor.client.request.delete
2526
import io.ktor.client.request.get
2627
import io.ktor.client.request.post
2728
import io.ktor.client.request.prepareGet
@@ -168,6 +169,25 @@ class ModelClientV2(
168169
}.bodyAsText().lines().filter { it.isNotEmpty() }.map { repository.getBranchReference(it) }
169170
}
170171

172+
override suspend fun deleteBranch(branch: BranchReference): Boolean {
173+
try {
174+
return httpClient.delete {
175+
url {
176+
takeFrom(baseUrl)
177+
appendPathSegmentsEncodingSlash(
178+
"repositories",
179+
branch.repositoryId.id,
180+
"branches",
181+
branch.branchName,
182+
)
183+
}
184+
}.status == HttpStatusCode.NoContent
185+
} catch (ex: Exception) {
186+
LOG.error(ex) { ex.message }
187+
return false
188+
}
189+
}
190+
171191
@Deprecated("repository ID is required for permission checks")
172192
@DeprecationInfo("3.7.0", "May be removed with the next major release. Also remove the endpoint from the model-server.")
173193
override suspend fun loadVersion(versionHash: String, baseVersion: IVersion?): IVersion {

model-server-openapi/specifications/model-server.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,24 @@ paths:
8585
"200":
8686
$ref: '#/components/responses/200'
8787
/v2/repositories/{repository}/branches/{branch}:
88+
delete:
89+
operationId: deleteRepositoryBranch
90+
parameters:
91+
- name: repository
92+
in: "path"
93+
required: true
94+
schema:
95+
type: string
96+
- name: branch
97+
in: "path"
98+
required: true
99+
schema:
100+
type: string
101+
responses:
102+
"404":
103+
$ref: '#/components/responses/404'
104+
"204":
105+
description: "Branch successfully deleted"
88106
get:
89107
operationId: getRepositoryBranch
90108
parameters:

model-server/src/main/kotlin/org/modelix/model/server/handlers/ModelReplicationServer.kt

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import io.ktor.server.plugins.origin
2323
import io.ktor.server.request.acceptItems
2424
import io.ktor.server.request.receive
2525
import io.ktor.server.request.receiveStream
26+
import io.ktor.server.resources.delete
2627
import io.ktor.server.resources.get
2728
import io.ktor.server.resources.post
2829
import io.ktor.server.resources.put
@@ -148,6 +149,37 @@ class ModelReplicationServer(
148149
call.respondDelta(versionHash, baseVersionHash)
149150
}
150151

152+
delete<Paths.deleteRepositoryBranch> {
153+
val repositoryName = call.parameters["repository"]
154+
if (repositoryName == null) {
155+
call.respondText("Request lacks repository name", status = HttpStatusCode.BadRequest)
156+
return@delete
157+
}
158+
val repositoryId = try {
159+
RepositoryId(repositoryName)
160+
} catch (e: IllegalArgumentException) {
161+
call.respondText("Invalid repository name '$repositoryName'", status = HttpStatusCode.BadRequest)
162+
return@delete
163+
}
164+
val branch = call.parameters["branch"]
165+
if (branch == null) {
166+
call.respondText("Request lacks branch name", status = HttpStatusCode.BadRequest)
167+
return@delete
168+
}
169+
170+
if (!repositoriesManager.getBranchNames(repositoryId).contains(branch)) {
171+
call.respondText(
172+
"Repository does not exist or branch '$branch' does not exist in repository '$repositoryId'",
173+
status = HttpStatusCode.NotFound,
174+
)
175+
return@delete
176+
}
177+
178+
repositoriesManager.removeBranches(repositoryId, setOf(branch))
179+
180+
call.respond(HttpStatusCode.NoContent)
181+
}
182+
151183
get<Paths.getRepositoryBranchHash> {
152184
fun ApplicationCall.repositoryId() = RepositoryId(parameters["repository"]!!)
153185
fun PipelineContext<Unit, ApplicationCall>.repositoryId() = call.repositoryId()

model-server/src/test/kotlin/org/modelix/model/server/ModelClientV2Test.kt

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import org.modelix.model.api.PBranch
2424
import org.modelix.model.client2.ModelClientV2
2525
import org.modelix.model.client2.runWrite
2626
import org.modelix.model.client2.runWriteOnBranch
27+
import org.modelix.model.lazy.BranchReference
2728
import org.modelix.model.lazy.CLTree
2829
import org.modelix.model.lazy.CLVersion
2930
import org.modelix.model.lazy.RepositoryId
@@ -165,6 +166,47 @@ class ModelClientV2Test {
165166
assertFalse(containsRepository)
166167
}
167168

169+
@Test
170+
fun `branches from non-existing repositories cannot be removed`() = runTest {
171+
val client = createModelClient()
172+
val repositoryId = RepositoryId(UUID.randomUUID().toString())
173+
174+
val success = client.deleteBranch(BranchReference(repositoryId, "doesntmatter"))
175+
176+
assertFalse(success)
177+
}
178+
179+
@Test
180+
fun `non-existing branches from existing repositories cannot be removed`() = runTest {
181+
val client = createModelClient()
182+
val repositoryId = RepositoryId(UUID.randomUUID().toString())
183+
client.initRepository(repositoryId)
184+
185+
val success = client.deleteBranch(BranchReference(repositoryId, "doesnotexist"))
186+
187+
assertFalse(success)
188+
}
189+
190+
@Test
191+
fun `existing branches from existing repositories can be removed`() = runTest {
192+
val client = createModelClient()
193+
val repositoryId = RepositoryId(UUID.randomUUID().toString())
194+
client.initRepository(repositoryId)
195+
val branchToDelete = BranchReference(repositoryId, "todelete")
196+
client.push(
197+
branchToDelete,
198+
requireNotNull(
199+
client.pullIfExists(BranchReference(repositoryId, "master")),
200+
) { "the master branch must always exist" },
201+
null,
202+
)
203+
204+
val success = client.deleteBranch(BranchReference(repositoryId, branchToDelete.branchName))
205+
206+
assertTrue(success)
207+
assertFalse(client.listBranches(repositoryId).contains(branchToDelete))
208+
}
209+
168210
@Test
169211
fun `pulling existing versions pulls all referenced objects`() = runTest {
170212
// Arrange

model-server/src/test/kotlin/org/modelix/model/server/handlers/ModelReplicationServerTest.kt

Lines changed: 100 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import io.ktor.client.HttpClient
1919
import io.ktor.client.engine.cio.CIO
2020
import io.ktor.client.plugins.HttpTimeout
2121
import io.ktor.client.plugins.defaultRequest
22+
import io.ktor.client.request.delete
2223
import io.ktor.client.request.get
24+
import io.ktor.client.statement.bodyAsText
2325
import io.ktor.http.HttpHeaders
2426
import io.ktor.http.HttpStatusCode
2527
import io.ktor.http.appendPathSegments
@@ -51,39 +53,52 @@ import org.modelix.model.server.store.InMemoryStoreClient
5153
import org.modelix.model.server.store.LocalModelClient
5254
import org.modelix.modelql.core.assertNotEmpty
5355
import kotlin.test.Test
56+
import kotlin.test.assertContains
5457
import kotlin.test.assertEquals
58+
import kotlin.test.assertFalse
5559
import kotlin.test.fail
5660
import kotlin.time.Duration.Companion.seconds
5761

5862
class ModelReplicationServerTest {
5963

60-
private fun getDefaultModelReplicationServer(): ModelReplicationServer {
64+
private data class Fixture(
65+
val storeClient: InMemoryStoreClient,
66+
val modelClient: LocalModelClient,
67+
val repositoriesManager: IRepositoriesManager,
68+
val modelReplicationServer: ModelReplicationServer,
69+
)
70+
71+
private fun getDefaultModelReplicationServerFixture(): Fixture {
6172
val storeClient = InMemoryStoreClient()
6273
val modelClient = LocalModelClient(storeClient)
6374
val repositoriesManager = RepositoriesManager(modelClient)
64-
return ModelReplicationServer(repositoriesManager, modelClient, InMemoryModels())
75+
return Fixture(
76+
storeClient,
77+
modelClient,
78+
repositoriesManager,
79+
ModelReplicationServer(repositoriesManager, modelClient, InMemoryModels()),
80+
)
6581
}
6682

6783
private fun runWithTestModelServer(
68-
modelReplicationServer: ModelReplicationServer = getDefaultModelReplicationServer(),
69-
block: suspend ApplicationTestBuilder.(scope: CoroutineScope) -> Unit,
84+
fixture: Fixture = getDefaultModelReplicationServerFixture(),
85+
block: suspend ApplicationTestBuilder.(scope: CoroutineScope, fixture: Fixture) -> Unit,
7086
) = testApplication {
7187
application {
7288
installAuthentication(unitTestMode = true)
7389
installDefaultServerPlugins()
74-
modelReplicationServer.init(this)
90+
fixture.modelReplicationServer.init(this)
7591
}
7692

7793
coroutineScope {
78-
block(this)
94+
block(this, fixture)
7995
}
8096
}
8197

8298
@Test
83-
fun `pulling delta does not return objects twice`() = runWithTestModelServer {
99+
fun `pulling delta does not return objects twice`() = runWithTestModelServer { _, _ ->
84100
// Arrange
85-
val url = "http://localhost/v2"
86-
val modelClient = ModelClientV2.builder().url(url).client(client).build().also { it.init() }
101+
val modelClient = ModelClientV2.builder().url("v2").client(client).build().also { it.init() }
87102
val repositoryId = RepositoryId("repo1")
88103
val branchId = repositoryId.getBranchReference("my-branch")
89104
// By calling modelClient.runWrite twice, we create to versions.
@@ -98,8 +113,7 @@ class ModelReplicationServerTest {
98113
// Act
99114
val response = client.get {
100115
url {
101-
takeFrom(url)
102-
appendPathSegments("repositories", repositoryId.id, "branches", branchId.branchName)
116+
appendPathSegments("v2", "repositories", repositoryId.id, "branches", branchId.branchName)
103117
}
104118
useVersionStreamFormat()
105119
}
@@ -117,6 +131,75 @@ class ModelReplicationServerTest {
117131
}
118132
}
119133

134+
@Test
135+
fun `responds with 404 when deleting a branch from a non-existent repository`() {
136+
runWithTestModelServer { _, _ ->
137+
val response = client.delete {
138+
url {
139+
appendPathSegments("v2", "repositories", "doesnotexist", "branches", "does not exist")
140+
}
141+
}
142+
143+
assertEquals(HttpStatusCode.NotFound, response.status)
144+
assertContains(response.bodyAsText(), "does not exist in repository")
145+
}
146+
}
147+
148+
@Test
149+
fun `responds with 404 when deleting a non-existent branch`() {
150+
val repositoryId = RepositoryId("repo1")
151+
152+
runWithTestModelServer { _, fixture ->
153+
fixture.repositoriesManager.createRepository(repositoryId, null)
154+
155+
val response = client.delete {
156+
url {
157+
appendPathSegments("v2", "repositories", repositoryId.id, "branches", "does not exist")
158+
}
159+
}
160+
161+
assertEquals(HttpStatusCode.NotFound, response.status)
162+
assertContains(response.bodyAsText(), "does not exist in repository")
163+
}
164+
}
165+
166+
@Test
167+
fun `responds with 400 when deleting from an invalid repository ID`() {
168+
runWithTestModelServer { _, fixture ->
169+
val response = client.delete {
170+
url {
171+
appendPathSegments("v2", "repositories", "invalid with spaces", "branches", "master")
172+
}
173+
}
174+
assertEquals(HttpStatusCode.BadRequest, response.status)
175+
assertContains(response.bodyAsText(), "Invalid repository name")
176+
}
177+
}
178+
179+
@Test
180+
fun `successfully deletes existing branches`() {
181+
val repositoryId = RepositoryId("repo1")
182+
val branch = "testbranch"
183+
val defaultBranchRef = repositoryId.getBranchReference("master")
184+
185+
runWithTestModelServer { _, fixture ->
186+
fixture.repositoriesManager.createRepository(repositoryId, null)
187+
fixture.repositoriesManager.mergeChanges(
188+
repositoryId.getBranchReference(branch),
189+
checkNotNull(fixture.repositoriesManager.getVersionHash(defaultBranchRef)) { "Default branch must exist" },
190+
)
191+
192+
val response = client.delete {
193+
url {
194+
appendPathSegments("v2", "repositories", repositoryId.id, "branches", branch)
195+
}
196+
}
197+
198+
assertEquals(HttpStatusCode.NoContent, response.status)
199+
assertFalse(fixture.repositoriesManager.getBranchNames(repositoryId).contains(branch))
200+
}
201+
}
202+
120203
@Test
121204
fun `server responds with error when failing to compute delta before starting to respond`() {
122205
// Arrange
@@ -130,18 +213,16 @@ class ModelReplicationServerTest {
130213
}
131214
}
132215
val modelReplicationServer = ModelReplicationServer(faultyRepositoriesManager, modelClient, InMemoryModels())
133-
val url = "http://localhost/v2"
134216
val repositoryId = RepositoryId("repo1")
135217
val branchRef = repositoryId.getBranchReference()
136218

137-
runWithTestModelServer(modelReplicationServer) {
219+
runWithTestModelServer(Fixture(storeClient, modelClient, faultyRepositoriesManager, modelReplicationServer)) { _, _ ->
138220
repositoriesManager.createRepository(repositoryId, null)
139221

140222
// Act
141223
val response = client.get {
142224
url {
143-
takeFrom(url)
144-
appendPathSegments("repositories", repositoryId.id, "branches", branchRef.branchName)
225+
appendPathSegments("v2", "repositories", repositoryId.id, "branches", branchRef.branchName)
145226
}
146227
useVersionStreamFormat()
147228
}
@@ -210,10 +291,10 @@ class ModelReplicationServerTest {
210291
runWithNettyServer(setupBlock, testBlock)
211292
}
212293

213-
fun `client can pull versions in legacy version delta format`() = runWithTestModelServer {
294+
@Test
295+
fun `client can pull versions in legacy version delta format`() = runWithTestModelServer { _, _ ->
214296
// Arrange
215-
val url = "http://localhost/v2"
216-
val modelClient = ModelClientV2.builder().url(url).client(client).build().also { it.init() }
297+
val modelClient = ModelClientV2.builder().url("v2").client(client).build().also { it.init() }
217298
val repositoryId = RepositoryId("repo1")
218299
val branchId = repositoryId.getBranchReference("my-branch")
219300
modelClient.runWrite(branchId) { root ->
@@ -224,8 +305,7 @@ class ModelReplicationServerTest {
224305
val response = client.get {
225306
headers[HttpHeaders.Accept] = VersionDeltaStream.CONTENT_TYPE.toString()
226307
url {
227-
takeFrom(url)
228-
appendPathSegments("repositories", repositoryId.id, "branches", branchId.branchName)
308+
appendPathSegments("v2", "repositories", repositoryId.id, "branches", branchId.branchName)
229309
}
230310
}
231311
val versionDelta = response.readVersionDelta()

0 commit comments

Comments
 (0)